From 903276c798100256adbaef9edf03be270a7d6307 Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Mon, 6 Jul 2026 21:23:46 -0700 Subject: [PATCH 01/17] Add Podman sandbox backend Closes #101348 Adds a bundled podman sandbox backend, SDK helper exports, policy/Codex integration, generated plugin docs, and focused tests. --- .github/labeler.yml | 6 + docs/cli/policy.md | 23 +- docs/cli/sandbox.md | 6 +- docs/gateway/config-agents.md | 4 +- docs/gateway/sandboxing.md | 70 +++- docs/install/docker.md | 2 +- docs/install/podman.md | 9 + docs/plugins/reference/podman.md | 19 + docs/plugins/sdk-subpaths.md | 2 +- docs/tools/multi-agent-sandbox-tools.md | 4 +- .../src/app-server/dynamic-tool-build.test.ts | 19 + .../src/app-server/dynamic-tool-build.ts | 8 +- extensions/podman/index.ts | 25 ++ extensions/podman/openclaw.plugin.json | 42 +++ extensions/podman/package.json | 24 ++ extensions/podman/src/backend.test.ts | 304 +++++++++++++++ extensions/podman/src/backend.ts | 350 ++++++++++++++++++ extensions/podman/src/config.test.ts | 29 ++ extensions/podman/src/config.ts | 44 +++ extensions/podman/src/podman.test.ts | 25 ++ extensions/podman/src/podman.ts | 195 ++++++++++ extensions/podman/tsconfig.json | 16 + .../register.sandbox-and-tools.test-utils.ts | 65 ++++ .../policy/src/doctor/sandbox-findings.ts | 12 +- extensions/policy/src/policy-state-sandbox.ts | 6 +- pnpm-lock.yaml | 9 + src/agents/sandbox.ts | 18 +- src/agents/sandbox/context.ts | 1 + .../sandbox/context.user-fallback.test.ts | 23 ++ src/agents/sandbox/docker-user.ts | 4 + src/plugin-sdk/sandbox.ts | 12 + src/plugins/bundled-plugin-metadata.test.ts | 2 + 32 files changed, 1344 insertions(+), 34 deletions(-) create mode 100644 docs/plugins/reference/podman.md create mode 100644 extensions/podman/index.ts create mode 100644 extensions/podman/openclaw.plugin.json create mode 100644 extensions/podman/package.json create mode 100644 extensions/podman/src/backend.test.ts create mode 100644 extensions/podman/src/backend.ts create mode 100644 extensions/podman/src/config.test.ts create mode 100644 extensions/podman/src/config.ts create mode 100644 extensions/podman/src/podman.test.ts create mode 100644 extensions/podman/src/podman.ts create mode 100644 extensions/podman/tsconfig.json diff --git a/.github/labeler.yml b/.github/labeler.yml index 1a245d4c8219..2e054a1d48f7 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -316,6 +316,7 @@ - "scripts/**/*docker*" - "scripts/**/Dockerfile*" - "scripts/sandbox-*.sh" + - "extensions/podman/**" - "src/agents/sandbox*.ts" - "src/commands/sandbox*.ts" - "src/cli/sandbox-cli.ts" @@ -324,6 +325,7 @@ - "docs/cli/sandbox.md" - "docs/gateway/sandbox*.md" - "docs/install/docker.md" + - "docs/install/podman.md" - "docs/tools/multi-agent-sandbox-tools.md" "agents": @@ -697,6 +699,10 @@ - changed-files: - any-glob-to-any-file: - "extensions/openshell/**" +"extensions: podman": + - changed-files: + - any-glob-to-any-file: + - "extensions/podman/**" "extensions: parallel": - changed-files: - any-glob-to-any-file: diff --git a/docs/cli/policy.md b/docs/cli/policy.md index 20774579258d..bfca16497c81 100644 --- a/docs/cli/policy.md +++ b/docs/cli/policy.md @@ -300,7 +300,8 @@ 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 +evidence the matched agent's sandbox backend can expose. Docker and Podman +container sandboxes expose the shared container posture settings. If a backend cannot observe a rule you enabled for it, policy reports `policy/sandbox-container-posture-unobservable` instead of passing; scope container rules to the agent groups that use a backend which can expose them. @@ -396,16 +397,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..42636d381a75 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` | @@ -102,7 +102,7 @@ Sandbox settings live in `~/.openclaw/openclaw.json` under `agents.defaults.sand "defaults": { "sandbox": { "mode": "all", // off, non-main, all - "backend": "docker", // docker, ssh, openshell (plugin-provided) + "backend": "docker", // docker, podman, ssh, openshell (plugin-provided) "scope": "agent", // session, agent, shared "docker": { "image": "openclaw-sandbox:bookworm-slim", diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 34a465b16d6e..e9cd491be699 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) | openshell | podman | 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 is Docker-only. `sandbox.docker.binds` applies to Docker and Podman container sandboxes. Build images (from a source checkout): diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 7fc25c81bab5..e26d96264f6b 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -50,15 +50,15 @@ The first use after upgrading from an older release creates non-shared runtimes **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`. -| | 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 | Podman | SSH | OpenShell | +| ------------------- | -------------------------------- | -------------------------------- | ------------------------------ | --------------------------------------------------- | +| **Where it runs** | Local container | Local Podman container | Any SSH-accessible host | OpenShell managed sandbox | +| **Setup** | `scripts/sandbox-setup.sh` | Podman plugin + sandbox image | SSH key + target host | OpenShell plugin enabled | +| **Workspace model** | Bind-mount or copy | Bind-mount or copy | Remote-canonical (seed once) | `mirror` or `remote` | +| **Network control** | `docker.network` (default: none) | `docker.network` (default: none) | Depends on remote host | Depends on OpenShell | +| **Browser sandbox** | Supported | Not supported yet | Not supported | Not supported yet | +| **Bind mounts** | `docker.binds` | `docker.binds` | N/A | N/A | +| **Best for** | Local dev, full isolation | Rootless/local Podman installs | Offloading to a remote machine | Managed remote sandboxes with optional two-way sync | ## Supported capability matrix @@ -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 `backend: "podman"` when you want local container sandboxing through Podman instead of Docker. The Podman sandbox backend is implemented as a bundled plugin and reuses the same `sandbox.docker.*` container settings as the Docker backend: image, workdir, network, read-only root, tmpfs, capabilities, environment, resource limits, custom binds, and setup command. + +For rootless Podman, the backend creates default-user containers with Podman's `keep-id` user namespace and the current uid/gid so writable workspace bind mounts remain writable from inside the sandbox. If you set `sandbox.docker.user`, OpenClaw leaves that user mapping to your Podman/image configuration. + +```json5 +{ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "podman", + scope: "session", + workspaceAccess: "rw", + docker: { + image: "openclaw-sandbox:bookworm-slim", + network: "none", + readOnlyRoot: true, + capDrop: ["ALL"], + }, + }, + }, + }, + plugins: { + entries: { + podman: { + enabled: true, + config: { + command: "podman", + // Optional: use one Podman remote connection style. + // connection: "dev", + // url: "unix:///run/user/1000/podman/podman.sock", + }, + }, + }, + }, +} +``` + +Build or pull the sandbox image into the Podman store selected by the plugin config before enabling the backend. From a source checkout, the same Dockerfile can be built with Podman: + +```bash +podman build -t openclaw-sandbox:bookworm-slim -f scripts/docker/sandbox/Dockerfile . +``` + +Podman backend limits: + +- Browser sandboxing is not supported yet; keep `sandbox.browser.enabled` off or use the Docker backend. +- `sandbox.docker.gpus` is Docker-only; Podman GPU/device setup is host-specific and should be handled with a custom image/runtime outside this backend for now. +- `connection` and `url` are mutually exclusive plugin config options because Podman accepts only one remote target selection for a command invocation. + ## SSH backend Use `backend: "ssh"` to sandbox `exec`, file tools, and media reads on an arbitrary SSH-accessible machine. diff --git a/docs/install/docker.md b/docs/install/docker.md index a36092510f4e..3b03fb45f0fa 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 sandbox backend uses Docker when `agents.defaults.sandbox` is enabled, but sandboxing is off by default and does not require the gateway itself to run in Docker. Podman, SSH, and OpenShell sandbox backends are also available; see [Sandboxing](/gateway/sandboxing). 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..20633cde2ee2 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"` when you want OpenClaw to run sandboxed agent tools in local Podman containers. + +The Podman sandbox backend ships as a bundled plugin. It reuses the same `agents.defaults.sandbox.docker.*` container settings as the Docker backend, but executes them through the `podman` CLI and optional Podman remote `connection` or `url` plugin config. Browser sandboxes and `sandbox.docker.gpus` remain Docker-only for now. + +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/plugins/reference/podman.md b/docs/plugins/reference/podman.md new file mode 100644 index 000000000000..09839d34e404 --- /dev/null +++ b/docs/plugins/reference/podman.md @@ -0,0 +1,19 @@ +--- +summary: "OpenClaw sandbox backend for rootless Podman containers." +read_when: + - You are installing, configuring, or auditing the podman plugin +title: "Podman plugin" +--- + +# Podman plugin + +OpenClaw sandbox backend for rootless Podman containers. + +## Distribution + +- Package: `@openclaw/podman-sandbox` +- Install route: included in OpenClaw + +## Surface + +plugin diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index ced2954ae0f1..e293104e3189 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -265,7 +265,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It | `plugin-sdk/tool-payload` | Private-local after July 2026; Extract normalized payloads from tool result objects | | `plugin-sdk/tool-results` | Typed text and JSON agent tool result builders | | `plugin-sdk/tool-send` | Extract canonical send target fields from tool args | - | `plugin-sdk/sandbox` | Private-local after July 2026; Sandbox backend types and SSH/OpenShell command helpers, including fail-fast exec command preflight | + | `plugin-sdk/sandbox` | Private-local after July 2026; Sandbox backend types, container backend helper builders, and SSH/OpenShell command helpers, including fail-fast exec command preflight | | `plugin-sdk/temp-path` | Shared temp-download path helpers and private secure temp workspaces | | `plugin-sdk/logging-core` | Subsystem logger and redaction helpers | | `plugin-sdk/markdown-table-runtime` | Private-local after July 2026; Markdown table mode and conversion helpers | diff --git a/docs/tools/multi-agent-sandbox-tools.md b/docs/tools/multi-agent-sandbox-tools.md index 39ca3d85f052..cdeb4aad67ed 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 Docker-compatible container settings used by Docker and Podman 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/podman/index.ts b/extensions/podman/index.ts new file mode 100644 index 000000000000..ea812c41b3a5 --- /dev/null +++ b/extensions/podman/index.ts @@ -0,0 +1,25 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox"; +import { + createPodmanSandboxBackendFactory, + createPodmanSandboxBackendManager, +} from "./src/backend.js"; +import { createPodmanPluginConfigSchema, resolvePodmanPluginConfig } from "./src/config.js"; + +export default definePluginEntry({ + id: "podman", + name: "Podman Sandbox", + description: "OpenClaw sandbox backend for rootless Podman containers.", + configSchema: createPodmanPluginConfigSchema(), + register(api) { + if (api.registrationMode !== "full") { + return; + } + const pluginConfig = resolvePodmanPluginConfig(api.pluginConfig); + registerSandboxBackend("podman", { + factory: createPodmanSandboxBackendFactory({ pluginConfig }), + manager: createPodmanSandboxBackendManager({ pluginConfig }), + resolveWorkdir: ({ cfg }) => cfg.docker.workdir, + }); + }, +}); diff --git a/extensions/podman/openclaw.plugin.json b/extensions/podman/openclaw.plugin.json new file mode 100644 index 000000000000..fd15622149b7 --- /dev/null +++ b/extensions/podman/openclaw.plugin.json @@ -0,0 +1,42 @@ +{ + "id": "podman", + "activation": { + "onStartup": true + }, + "name": "Podman Sandbox", + "description": "OpenClaw sandbox backend for rootless Podman containers.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "command": { + "type": "string", + "minLength": 1 + }, + "connection": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string", + "minLength": 1 + } + } + }, + "uiHints": { + "command": { + "label": "Podman Command", + "help": "Path or command name for the Podman CLI." + }, + "connection": { + "label": "Podman Connection", + "help": "Optional Podman connection name passed with --connection.", + "advanced": true + }, + "url": { + "label": "Podman URL", + "help": "Optional Podman service URL passed with --url.", + "advanced": true + } + } +} diff --git a/extensions/podman/package.json b/extensions/podman/package.json new file mode 100644 index 000000000000..97a597851a28 --- /dev/null +++ b/extensions/podman/package.json @@ -0,0 +1,24 @@ +{ + "name": "@openclaw/podman-sandbox", + "version": "2026.6.11", + "private": true, + "description": "OpenClaw sandbox backend for rootless Podman containers.", + "type": "module", + "devDependencies": { + "@openclaw/plugin-sdk": "workspace:*", + "openclaw": "workspace:*" + }, + "peerDependencies": { + "openclaw": ">=2026.6.11" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/podman/src/backend.test.ts b/extensions/podman/src/backend.test.ts new file mode 100644 index 000000000000..9e0da3b1682a --- /dev/null +++ b/extensions/podman/src/backend.test.ts @@ -0,0 +1,304 @@ +import type { CreateSandboxBackendParams, SandboxConfig } from "openclaw/plugin-sdk/sandbox"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const podmanMocks = vi.hoisted(() => ({ + execPodman: vi.fn(), + execPodmanRaw: vi.fn(), + podmanContainerState: vi.fn(), + readPodmanContainerLabel: vi.fn(), +})); +const sdkMocks = vi.hoisted(() => ({ + readSandboxRegistryEntry: vi.fn(), +})); + +vi.mock("./podman.js", async () => { + const actual = await vi.importActual("./podman.js"); + return { + ...actual, + execPodman: podmanMocks.execPodman, + execPodmanRaw: podmanMocks.execPodmanRaw, + podmanContainerState: podmanMocks.podmanContainerState, + readPodmanContainerLabel: podmanMocks.readPodmanContainerLabel, + }; +}); + +vi.mock("openclaw/plugin-sdk/sandbox", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readSandboxRegistryEntry: sdkMocks.readSandboxRegistryEntry, + }; +}); + +const { createPodmanSandboxBackendFactory } = await import("./backend.js"); + +function createSandboxConfig(overrides: Partial = {}): SandboxConfig { + return { + mode: "all", + backend: "podman", + scope: "session", + workspaceAccess: "rw", + workspaceRoot: "/tmp/openclaw-sandboxes", + docker: { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp"], + network: "none", + capDrop: ["ALL"], + env: { LANG: "C.UTF-8" }, + }, + ssh: { + command: "ssh", + workspaceRoot: "/tmp/openclaw-sandboxes", + strictHostKeyChecking: true, + updateHostKeys: true, + }, + browser: { + enabled: false, + image: "openclaw-sandbox-browser:bookworm-slim", + containerPrefix: "openclaw-sbx-browser-", + network: "openclaw-sandbox-browser", + cdpPort: 9222, + vncPort: 5900, + noVncPort: 6080, + headless: true, + enableNoVnc: true, + allowHostControl: false, + autoStart: true, + autoStartTimeoutMs: 12_000, + }, + tools: { + allow: [], + deny: [], + }, + prune: { + idleHours: 24, + maxAgeDays: 7, + }, + ...overrides, + }; +} + +function createParams( + overrides: Partial = {}, +): CreateSandboxBackendParams { + const cfg = overrides.cfg ?? createSandboxConfig(); + return { + sessionKey: "agent:main:podman-test", + scopeKey: "agent:main:podman-test", + workspaceDir: "/tmp/openclaw-sandbox-workspace", + agentWorkspaceDir: "/tmp/openclaw-agent-workspace", + cfg, + ...overrides, + }; +} + +function commandCalls() { + return podmanMocks.execPodman.mock.calls.map((call) => call[1] as string[]); +} + +function expectedKeepIdUserArgs() { + return typeof process.getuid === "function" && typeof process.getgid === "function" + ? ["--userns", "keep-id", "--user", `${process.getuid()}:${process.getgid()}`] + : []; +} + +describe("Podman sandbox backend", () => { + beforeEach(() => { + podmanMocks.execPodman.mockReset(); + podmanMocks.execPodmanRaw.mockReset(); + podmanMocks.podmanContainerState.mockReset(); + podmanMocks.readPodmanContainerLabel.mockReset(); + sdkMocks.readSandboxRegistryEntry.mockReset(); + podmanMocks.execPodman.mockResolvedValue({ stdout: "", stderr: "", code: 0 }); + podmanMocks.execPodmanRaw.mockResolvedValue({ + stdout: Buffer.from(""), + stderr: Buffer.from(""), + code: 0, + }); + podmanMocks.podmanContainerState.mockResolvedValue({ exists: false, running: false }); + podmanMocks.readPodmanContainerLabel.mockResolvedValue(null); + sdkMocks.readSandboxRegistryEntry.mockResolvedValue(null); + }); + + it("creates and starts a missing Podman sandbox container", async () => { + const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } }); + const backend = await factory(createParams()); + const calls = commandCalls(); + const createArgs = calls.find((args) => args[0] === "create"); + + expect(backend.id).toBe("podman"); + expect(backend.workdir).toBe("/workspace"); + expect(createArgs).toEqual( + expect.arrayContaining([ + "create", + "--name", + expect.stringMatching(/^openclaw-sbx-agent-main-podman-test-/), + "--read-only", + "--network", + "none", + "--cap-drop", + "ALL", + ...expectedKeepIdUserArgs(), + "-v", + "/tmp/openclaw-sandbox-workspace:/workspace:z", + "-v", + "/tmp/openclaw-agent-workspace:/agent:z", + "openclaw-sandbox:bookworm-slim", + "sleep", + "infinity", + ]), + ); + expect(calls.some((args) => args[0] === "start")).toBe(true); + }); + + it("does not override an explicitly configured sandbox user", async () => { + const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } }); + + await factory( + createParams({ + cfg: createSandboxConfig({ + docker: { ...createSandboxConfig().docker, user: "1001:1001" }, + }), + }), + ); + + const createArgs = commandCalls().find((args) => args[0] === "create"); + expect(createArgs).toEqual(expect.arrayContaining(["--user", "1001:1001"])); + expect(createArgs).not.toContain("keep-id"); + }); + + it("recreates a stopped existing container when the config hash label does not match", async () => { + podmanMocks.podmanContainerState.mockResolvedValue({ exists: true, running: false }); + podmanMocks.readPodmanContainerLabel.mockResolvedValue("old-hash"); + const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } }); + + await factory(createParams()); + + const calls = commandCalls(); + expect(calls).toContainEqual([ + "rm", + "-f", + expect.stringMatching(/^openclaw-sbx-agent-main-podman-test-/), + ]); + expect(calls.some((args) => args[0] === "create")).toBe(true); + }); + + it("preserves a running existing container when the config hash label does not match", async () => { + podmanMocks.podmanContainerState.mockResolvedValue({ exists: true, running: true }); + podmanMocks.readPodmanContainerLabel.mockResolvedValue("old-hash"); + const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } }); + + await factory(createParams()); + + const calls = commandCalls(); + expect(calls.some((args) => args[0] === "rm")).toBe(false); + expect(calls.some((args) => args[0] === "create")).toBe(false); + }); + + it("recreates an old running container when the config hash label does not match", async () => { + podmanMocks.podmanContainerState.mockResolvedValue({ exists: true, running: true }); + podmanMocks.readPodmanContainerLabel.mockResolvedValue("old-hash"); + sdkMocks.readSandboxRegistryEntry.mockResolvedValue({ lastUsedAtMs: 1 }); + const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } }); + + await factory(createParams()); + + const calls = commandCalls(); + expect(calls).toContainEqual([ + "rm", + "-f", + expect.stringMatching(/^openclaw-sbx-agent-main-podman-test-/), + ]); + expect(calls.some((args) => args[0] === "create")).toBe(true); + }); + + it("reuses a matching running container", async () => { + let capturedHash = ""; + podmanMocks.execPodman.mockImplementation(async (_config, args: string[]) => { + if (args[0] === "create") { + capturedHash = + args + .find((arg) => arg.startsWith("openclaw.configHash=")) + ?.slice("openclaw.configHash=".length) ?? ""; + } + return { stdout: "", stderr: "", code: 0 }; + }); + const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } }); + await factory(createParams()); + + podmanMocks.execPodman.mockClear(); + podmanMocks.podmanContainerState.mockResolvedValue({ exists: true, running: true }); + podmanMocks.readPodmanContainerLabel.mockResolvedValue(capturedHash); + await factory(createParams()); + + const calls = commandCalls(); + expect(calls.some((args) => args[0] === "create")).toBe(false); + expect(calls.some((args) => args[0] === "rm")).toBe(false); + }); + + it("builds exec specs with Podman remote options", async () => { + const factory = createPodmanSandboxBackendFactory({ + pluginConfig: { command: "podman", connection: "dev" }, + }); + const backend = await factory(createParams()); + + const spec = await backend.buildExecSpec({ + command: "pwd", + env: { PATH: "/usr/local/bin" }, + usePty: true, + }); + + expect(spec.argv.slice(0, 4)).toEqual(["podman", "--connection", "dev", "exec"]); + expect(spec.argv).toContain("-t"); + expect(spec.argv).toContain("OPENCLAW_PREPEND_PATH=/usr/local/bin"); + }); + + it("routes filesystem bridge shell commands through podman exec", async () => { + const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } }); + const backend = await factory(createParams()); + + await backend.runShellCommand({ script: 'stat -c %s "$1"', args: ["/workspace/file.txt"] }); + + expect(podmanMocks.execPodmanRaw).toHaveBeenCalledWith( + { command: "podman" }, + [ + "exec", + "-i", + expect.stringMatching(/^openclaw-sbx-agent-main-podman-test-/), + "sh", + "-c", + 'stat -c %s "$1"', + "openclaw-sandbox-fs", + "/workspace/file.txt", + ], + expect.objectContaining({ allowFailure: undefined }), + ); + }); + + it("fails closed for unsupported browser and GPU options", async () => { + const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } }); + + await expect( + factory( + createParams({ + cfg: createSandboxConfig({ + browser: { ...createSandboxConfig().browser, enabled: true }, + }), + }), + ), + ).rejects.toThrow(/browser sandboxes/i); + + await expect( + factory( + createParams({ + cfg: createSandboxConfig({ + docker: { ...createSandboxConfig().docker, gpus: "all" }, + }), + }), + ), + ).rejects.toThrow(/does not support sandbox\.docker\.gpus/i); + }); +}); diff --git a/extensions/podman/src/backend.ts b/extensions/podman/src/backend.ts new file mode 100644 index 000000000000..9feb5d4a4dc8 --- /dev/null +++ b/extensions/podman/src/backend.ts @@ -0,0 +1,350 @@ +import { + appendReadOnlyWorkspaceSkillMountArgs, + appendWorkspaceMountArgs, + buildDockerExecArgs, + buildSandboxCreateArgs, + computeSandboxConfigHash, + formatReadOnlyWorkspaceSkillMountHashState, + readSandboxRegistryEntry, + resolveDockerEnvPolicyEpoch, + resolveReadOnlyWorkspaceSkillMounts, + resolveSandboxConfigForAgent, + SANDBOX_MOUNT_FORMAT_VERSION, + slugifySessionKey, + type CreateSandboxBackendParams, + type SandboxBackendCommandParams, + type SandboxBackendFactory, + type SandboxBackendHandle, + type SandboxBackendManager, +} from "openclaw/plugin-sdk/sandbox"; +import type { PodmanPluginConfig } from "./config.js"; +import { + execPodman, + execPodmanRaw, + podmanContainerState, + readPodmanContainerLabel, +} from "./podman.js"; + +type PodmanSandboxBackendParams = { + pluginConfig: PodmanPluginConfig; +}; + +const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000; +const PODMAN_USERNS_CONFIG_EPOCH = "podman-keep-id-v1"; + +function buildContainerName(params: CreateSandboxBackendParams): string { + const scopeKey = + params.cfg.scope === "shared" + ? "shared" + : params.cfg.scope === "session" + ? params.sessionKey + : params.scopeKey; + const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey); + return `${params.cfg.docker.containerPrefix}${slug}`.slice(0, 63); +} + +function assertSupportedPodmanConfig(params: CreateSandboxBackendParams): void { + if (params.cfg.docker.gpus) { + throw new Error( + 'Podman sandbox backend does not support sandbox.docker.gpus. Use backend "docker" for Docker GPU runtime support, or configure GPU devices through a custom Podman image/runtime outside this backend.', + ); + } + if (params.cfg.browser.enabled) { + throw new Error("Podman sandbox backend does not support browser sandboxes yet."); + } +} + +async function ensurePodmanImage(config: PodmanPluginConfig, image: string): Promise { + const result = await execPodman(config, ["image", "inspect", image], { allowFailure: true }); + if (result.code === 0) { + return; + } + const detail = result.stderr.trim() || result.stdout.trim(); + throw new Error( + `Podman sandbox image not found: ${image}. Build or pull it into the selected Podman store first.${detail ? ` Podman said: ${detail}` : ""}`, + ); +} + +function appendCustomBinds(args: string[], binds: readonly string[] | undefined): void { + for (const bind of binds ?? []) { + args.push("-v", bind); + } +} + +function currentUserForKeepId(): string | null { + const getuid = process.getuid; + const getgid = process.getgid; + if (typeof getuid !== "function" || typeof getgid !== "function") { + return null; + } + return `${getuid()}:${getgid()}`; +} + +function appendPodmanUserNamespaceArgs(args: string[], cfg: CreateSandboxBackendParams["cfg"]) { + if (cfg.docker.user) { + return; + } + const user = currentUserForKeepId(); + if (!user) { + return; + } + // Rootless Podman needs keep-id so the sandbox can write workspace bind mounts. + // When callers set docker.user explicitly, that ownership contract is theirs. + args.push("--userns", "keep-id", "--user", user); +} + +function computePodmanConfigHash(params: CreateSandboxBackendParams): string { + const readOnlyWorkspaceSkillMounts = resolveReadOnlyWorkspaceSkillMounts({ + workspaceDir: params.workspaceDir, + agentWorkspaceDir: params.agentWorkspaceDir, + skillsWorkspaceDir: params.skillsWorkspaceDir, + workdir: params.cfg.docker.workdir, + workspaceAccess: params.cfg.workspaceAccess, + }); + const genericHash = computeSandboxConfigHash({ + docker: params.cfg.docker, + dockerEnvPolicyEpoch: resolveDockerEnvPolicyEpoch(params.cfg.docker.env), + workspaceAccess: params.cfg.workspaceAccess, + workspaceDir: params.workspaceDir, + agentWorkspaceDir: params.agentWorkspaceDir, + mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION, + readOnlyWorkspaceSkillMounts: formatReadOnlyWorkspaceSkillMountHashState( + readOnlyWorkspaceSkillMounts, + ), + }); + return `${genericHash}:${PODMAN_USERNS_CONFIG_EPOCH}`; +} + +async function createPodmanContainer(params: { + pluginConfig: PodmanPluginConfig; + createParams: CreateSandboxBackendParams; + containerName: string; + configHash: string; +}) { + const { createParams } = params; + await ensurePodmanImage(params.pluginConfig, createParams.cfg.docker.image); + const readOnlyWorkspaceSkillMounts = resolveReadOnlyWorkspaceSkillMounts({ + workspaceDir: createParams.workspaceDir, + agentWorkspaceDir: createParams.agentWorkspaceDir, + skillsWorkspaceDir: createParams.skillsWorkspaceDir, + workdir: createParams.cfg.docker.workdir, + workspaceAccess: createParams.cfg.workspaceAccess, + }); + const args = buildSandboxCreateArgs({ + name: params.containerName, + cfg: createParams.cfg.docker, + scopeKey: createParams.scopeKey, + configHash: params.configHash, + includeBinds: false, + bindSourceRoots: [createParams.workspaceDir, createParams.agentWorkspaceDir], + }); + appendPodmanUserNamespaceArgs(args, createParams.cfg); + args.push("--workdir", createParams.cfg.docker.workdir); + appendWorkspaceMountArgs({ + args, + workspaceDir: createParams.workspaceDir, + agentWorkspaceDir: createParams.agentWorkspaceDir, + skillsWorkspaceDir: createParams.skillsWorkspaceDir, + workdir: createParams.cfg.docker.workdir, + workspaceAccess: createParams.cfg.workspaceAccess, + readOnlyWorkspaceSkillMounts, + includeReadOnlyWorkspaceSkillMounts: false, + }); + appendCustomBinds(args, createParams.cfg.docker.binds); + appendReadOnlyWorkspaceSkillMountArgs({ + args, + readOnlyWorkspaceSkillMounts, + }); + args.push(createParams.cfg.docker.image, "sleep", "infinity"); + await execPodman(params.pluginConfig, args); + await execPodman(params.pluginConfig, ["start", params.containerName]); + const setupCommand = createParams.cfg.docker.setupCommand?.trim(); + if (setupCommand) { + await execPodman(params.pluginConfig, [ + "exec", + "-i", + params.containerName, + "/bin/sh", + "-lc", + setupCommand, + ]); + } +} + +async function ensurePodmanContainer(params: { + pluginConfig: PodmanPluginConfig; + createParams: CreateSandboxBackendParams; +}): Promise { + assertSupportedPodmanConfig(params.createParams); + const containerName = buildContainerName(params.createParams); + const expectedHash = computePodmanConfigHash(params.createParams); + const state = await podmanContainerState(params.pluginConfig, containerName); + let hasContainer = state.exists; + let running = state.running; + if (hasContainer) { + const currentHash = await readPodmanContainerLabel( + params.pluginConfig, + containerName, + "openclaw.configHash", + ); + if (currentHash !== expectedHash) { + const registryEntry = (await readSandboxRegistryEntry(containerName)) ?? undefined; + const lastUsedAtMs = registryEntry?.lastUsedAtMs; + const isHot = + running && + (typeof lastUsedAtMs !== "number" || Date.now() - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS); + if (isHot) { + // Match Docker's hot-container contract: keep recently used runtimes + // alive so config drift does not kill active agent work mid-turn. + return containerName; + } + await execPodman(params.pluginConfig, ["rm", "-f", containerName], { + allowFailure: true, + }); + hasContainer = false; + } + } + if (!hasContainer) { + await createPodmanContainer({ + pluginConfig: params.pluginConfig, + createParams: params.createParams, + containerName, + configHash: expectedHash, + }); + } else if (!running) { + await execPodman(params.pluginConfig, ["start", containerName]); + } + return containerName; +} + +function createPodmanSandboxBackendHandle(params: { + pluginConfig: PodmanPluginConfig; + containerName: string; + workdir: string; + env?: Record; + image: string; +}): SandboxBackendHandle { + return { + id: "podman", + runtimeId: params.containerName, + runtimeLabel: params.containerName, + workdir: params.workdir, + env: params.env, + configLabel: params.image, + configLabelKind: "Image", + async buildExecSpec({ command, workdir, env, usePty }) { + const invocation = { + command: params.pluginConfig.command, + args: buildDockerExecArgs({ + containerName: params.containerName, + command, + workdir: workdir ?? params.workdir, + env, + tty: usePty, + }), + }; + if (params.pluginConfig.connection) { + invocation.args.unshift("--connection", params.pluginConfig.connection); + } + if (params.pluginConfig.url) { + invocation.args.unshift("--url", params.pluginConfig.url); + } + return { + argv: [invocation.command, ...invocation.args], + env: process.env, + stdinMode: usePty ? "pipe-open" : "pipe-closed", + }; + }, + runShellCommand(command) { + return runPodmanSandboxShellCommand({ + pluginConfig: params.pluginConfig, + containerName: params.containerName, + ...command, + }); + }, + }; +} + +export function runPodmanSandboxShellCommand( + params: { + pluginConfig: PodmanPluginConfig; + containerName: string; + } & SandboxBackendCommandParams, +) { + const podmanArgs = [ + "exec", + "-i", + params.containerName, + "sh", + "-c", + params.script, + "openclaw-sandbox-fs", + ]; + if (params.args?.length) { + podmanArgs.push(...params.args); + } + return execPodmanRaw(params.pluginConfig, podmanArgs, { + input: params.stdin, + allowFailure: params.allowFailure, + signal: params.signal, + }); +} + +export function createPodmanSandboxBackendFactory( + params: PodmanSandboxBackendParams, +): SandboxBackendFactory { + return async (createParams) => { + const containerName = await ensurePodmanContainer({ + pluginConfig: params.pluginConfig, + createParams, + }); + return createPodmanSandboxBackendHandle({ + pluginConfig: params.pluginConfig, + containerName, + workdir: createParams.cfg.docker.workdir, + env: createParams.cfg.docker.env, + image: createParams.cfg.docker.image, + }); + }; +} + +export function createPodmanSandboxBackendManager(params: { + pluginConfig: PodmanPluginConfig; +}): SandboxBackendManager { + return { + async describeRuntime({ entry, config, agentId }) { + const state = await podmanContainerState(params.pluginConfig, entry.containerName); + let actualConfigLabel = entry.image; + if (state.exists) { + const result = await execPodman( + params.pluginConfig, + ["inspect", "-f", "{{.Config.Image}}", entry.containerName], + { allowFailure: true }, + ); + if (result.code === 0) { + actualConfigLabel = result.stdout.trim() || actualConfigLabel; + } + } + const configuredImage = resolveSandboxConfigForAgent(config, agentId).docker.image; + return { + running: state.running, + actualConfigLabel, + configLabelMatch: actualConfigLabel === configuredImage, + }; + }, + async removeRuntime({ entry }) { + const result = await execPodman(params.pluginConfig, ["rm", "-f", entry.containerName], { + allowFailure: true, + }); + if (result.code !== 0) { + const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`; + if (/no such (container|object)|does not exist/iu.test(detail)) { + return; + } + throw new Error( + `Failed to remove Podman sandbox runtime ${entry.containerName}: ${detail}`, + ); + } + }, + }; +} diff --git a/extensions/podman/src/config.test.ts b/extensions/podman/src/config.test.ts new file mode 100644 index 000000000000..c1e150d3d20a --- /dev/null +++ b/extensions/podman/src/config.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { resolvePodmanPluginConfig } from "./config.js"; + +describe("resolvePodmanPluginConfig", () => { + it("defaults to the podman command", () => { + expect(resolvePodmanPluginConfig(undefined)).toEqual({ command: "podman" }); + }); + + it("trims command and optional remote selectors", () => { + expect( + resolvePodmanPluginConfig({ + command: " /usr/bin/podman ", + connection: " dev ", + }), + ).toEqual({ + command: "/usr/bin/podman", + connection: "dev", + }); + }); + + it("rejects connection and url together", () => { + expect(() => + resolvePodmanPluginConfig({ + connection: "dev", + url: "unix:///run/user/1000/podman/podman.sock", + }), + ).toThrow(/cannot set both connection and url/i); + }); +}); diff --git a/extensions/podman/src/config.ts b/extensions/podman/src/config.ts new file mode 100644 index 000000000000..cca2b21bbb6e --- /dev/null +++ b/extensions/podman/src/config.ts @@ -0,0 +1,44 @@ +import { buildJsonPluginConfigSchema } from "openclaw/plugin-sdk/plugin-entry"; + +export type PodmanPluginConfig = { + command: string; + connection?: string; + url?: string; +}; + +export type PodmanPluginConfigInput = { + command?: unknown; + connection?: unknown; + url?: unknown; +}; + +export function createPodmanPluginConfigSchema() { + return buildJsonPluginConfigSchema({ + type: "object", + additionalProperties: false, + properties: { + command: { type: "string", minLength: 1 }, + connection: { type: "string", minLength: 1 }, + url: { type: "string", minLength: 1 }, + }, + }); +} + +function readOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +export function resolvePodmanPluginConfig(input: unknown): PodmanPluginConfig { + const config = (input && typeof input === "object" ? input : {}) as PodmanPluginConfigInput; + const command = readOptionalString(config.command) ?? "podman"; + const connection = readOptionalString(config.connection); + const url = readOptionalString(config.url); + if (connection && url) { + throw new Error("Podman sandbox config cannot set both connection and url."); + } + return { + command, + ...(connection ? { connection } : {}), + ...(url ? { url } : {}), + }; +} diff --git a/extensions/podman/src/podman.test.ts b/extensions/podman/src/podman.test.ts new file mode 100644 index 000000000000..84f3e957ce57 --- /dev/null +++ b/extensions/podman/src/podman.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { buildPodmanInvocation } from "./podman.js"; + +describe("buildPodmanInvocation", () => { + it("uses the configured command and appends args", () => { + expect( + buildPodmanInvocation({ + config: { command: "/opt/bin/podman" }, + args: ["inspect", "openclaw-sbx-main"], + }), + ).toEqual({ + command: "/opt/bin/podman", + args: ["inspect", "openclaw-sbx-main"], + }); + }); + + it("places remote options before the subcommand", () => { + expect( + buildPodmanInvocation({ + config: { command: "podman", url: "unix:///run/user/1000/podman/podman.sock" }, + args: ["exec", "sandbox", "true"], + }).args, + ).toEqual(["--url", "unix:///run/user/1000/podman/podman.sock", "exec", "sandbox", "true"]); + }); +}); diff --git a/extensions/podman/src/podman.ts b/extensions/podman/src/podman.ts new file mode 100644 index 000000000000..d48fd6d0a334 --- /dev/null +++ b/extensions/podman/src/podman.ts @@ -0,0 +1,195 @@ +import { spawn } from "node:child_process"; +import type { PodmanPluginConfig } from "./config.js"; + +export type ExecPodmanRawOptions = { + allowFailure?: boolean; + input?: Buffer | string; + signal?: AbortSignal; +}; + +export type ExecPodmanRawResult = { + stdout: Buffer; + stderr: Buffer; + code: number; +}; + +type ExecPodmanRawError = Error & { + code: number; + stdout: Buffer; + stderr: Buffer; +}; + +function createAbortError(message: string): Error { + const error = new Error(message); + error.name = "AbortError"; + return error; +} + +export function buildPodmanInvocation(params: { + config: PodmanPluginConfig; + args: readonly string[]; +}): { command: string; args: string[] } { + const args: string[] = []; + if (params.config.connection) { + args.push("--connection", params.config.connection); + } + if (params.config.url) { + args.push("--url", params.config.url); + } + args.push(...params.args); + return { command: params.config.command, args }; +} + +export function execPodmanRaw( + config: PodmanPluginConfig, + args: string[], + opts?: ExecPodmanRawOptions, +): Promise { + return new Promise((resolve, reject) => { + const invocation = buildPodmanInvocation({ config, args }); + const child = spawn(invocation.command, invocation.args, { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let aborted = false; + let outputStreamError: Error | undefined; + + const signal = opts?.signal; + const handleAbort = () => { + if (aborted) { + return; + } + aborted = true; + child.kill("SIGTERM"); + }; + if (signal) { + if (signal.aborted) { + handleAbort(); + } else { + signal.addEventListener("abort", handleAbort, { once: true }); + } + } + + const handleStreamError = (error: Error) => { + if (outputStreamError) { + return; + } + outputStreamError = error; + child.kill("SIGTERM"); + }; + child.stdout?.on("error", handleStreamError); + child.stdout?.on("data", (chunk) => { + stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.stderr?.on("error", handleStreamError); + child.stderr?.on("data", (chunk) => { + stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + + child.on("error", (error) => { + if (signal) { + signal.removeEventListener("abort", handleAbort); + } + if ( + error && + typeof error === "object" && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ) { + reject( + Object.assign( + new Error( + `Sandbox backend "podman" requires Podman, but the "${config.command}" command was not found in PATH. Install Podman or set agents.defaults.sandbox.backend to another backend.`, + ), + { code: "INVALID_CONFIG", cause: error }, + ), + ); + return; + } + reject(error); + }); + + child.on("close", (code) => { + if (signal) { + signal.removeEventListener("abort", handleAbort); + } + const stdout = Buffer.concat(stdoutChunks); + const stderr = Buffer.concat(stderrChunks); + if (aborted || signal?.aborted) { + reject(createAbortError("Aborted")); + return; + } + if (outputStreamError) { + reject(outputStreamError); + return; + } + const exitCode = code ?? 0; + if (exitCode !== 0 && !opts?.allowFailure) { + reject( + Object.assign( + new Error(stderr.toString("utf8").trim() || `podman ${args.join(" ")} failed`), + { + code: exitCode, + stdout, + stderr, + }, + ) satisfies ExecPodmanRawError, + ); + return; + } + resolve({ stdout, stderr, code: exitCode }); + }); + + const stdin = child.stdin; + if (stdin) { + stdin.on("error", handleStreamError); + if (opts?.input !== undefined) { + stdin.end(opts.input); + } else { + stdin.end(); + } + } + }); +} + +export async function execPodman( + config: PodmanPluginConfig, + args: string[], + opts?: ExecPodmanRawOptions, +) { + const result = await execPodmanRaw(config, args, opts); + return { + stdout: result.stdout.toString("utf8"), + stderr: result.stderr.toString("utf8"), + code: result.code, + }; +} + +export async function podmanContainerState(config: PodmanPluginConfig, name: string) { + const result = await execPodman(config, ["inspect", "-f", "{{.State.Running}}", name], { + allowFailure: true, + }); + if (result.code !== 0) { + return { exists: false, running: false }; + } + return { exists: true, running: result.stdout.trim() === "true" }; +} + +export async function readPodmanContainerLabel( + config: PodmanPluginConfig, + containerName: string, + label: string, +): Promise { + const result = await execPodman( + config, + ["inspect", "-f", `{{ index .Config.Labels "${label}" }}`, containerName], + { allowFailure: true }, + ); + if (result.code !== 0) { + return null; + } + const raw = result.stdout.trim(); + return raw && raw !== "" ? raw : null; +} diff --git a/extensions/podman/tsconfig.json b/extensions/podman/tsconfig.json new file mode 100644 index 000000000000..b8a85a99ac3d --- /dev/null +++ b/extensions/podman/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.package-boundary.base.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["./*.ts", "./src/**/*.ts"], + "exclude": [ + "./**/*.test.ts", + "./dist/**", + "./node_modules/**", + "./src/test-support/**", + "./src/**/*test-helpers.ts", + "./src/**/*test-harness.ts", + "./src/**/*test-support.ts" + ] +} 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..bc4c346d9f3d 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 @@ -222,6 +222,71 @@ describe("registerPolicyDoctorChecks", () => { ); }); + it("collects container posture for the Podman sandbox backend", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "Podman", + docker: { + network: "host", + binds: ["/var/run/podman/podman.sock:/podman.sock:rw"], + seccompProfile: "unconfined", + }, + }, + }, + }, + } as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + sandbox: { + allowBackends: ["podman"], + containers: { + denyHostNetwork: true, + denyContainerRuntimeSocketMounts: true, + denyUnconfinedProfiles: true, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfg)); + const evidence = collectPolicyEvidence(cfg as unknown as Record); + + expect(evidence.sandboxPosture).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "backend", value: "podman" }), + expect.objectContaining({ kind: "containerNetwork", value: "host" }), + expect.objectContaining({ kind: "containerMount" }), + expect.objectContaining({ + kind: "containerSecurityProfile", + profile: "seccomp", + value: "unconfined", + }), + ]), + ); + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ checkId: "policy/sandbox-container-host-network-denied" }), + expect.objectContaining({ checkId: "policy/sandbox-container-runtime-socket-mount" }), + expect.objectContaining({ checkId: "policy/sandbox-container-unconfined-profile" }), + ]), + ); + expect(result.findings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/sandbox-container-posture-unobservable", + }), + ]), + ); + }); + it("evaluates inherited container mounts for browser containers on non-Docker backends", async () => { const configPath = join(workspaceDir, "openclaw.jsonc"); const cfg = { diff --git a/extensions/policy/src/doctor/sandbox-findings.ts b/extensions/policy/src/doctor/sandbox-findings.ts index 37db804f563d..c23654326967 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; } } @@ -277,7 +277,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, { @@ -291,6 +294,11 @@ function sandboxContainerPostureUnobservableFindings( ); } +function isObservableContainerSandboxBackend(value: string): boolean { + const backend = value.toLowerCase(); + return backend === "docker" || backend === "podman"; +} + function sandboxContainerHostNetworkFindings( sandboxPolicy: Record, policyDocName: string, diff --git a/extensions/policy/src/policy-state-sandbox.ts b/extensions/policy/src/policy-state-sandbox.ts index 66e86fa3b8ad..bdef66954e6c 100644 --- a/extensions/policy/src/policy-state-sandbox.ts +++ b/extensions/policy/src/policy-state-sandbox.ts @@ -87,12 +87,16 @@ function pushSandboxPostureEvidence( inherited: localBackend === undefined && inheritedBackend !== undefined, }); - if (effectiveBackend === "docker") { + if (isObservableContainerSandboxBackend(effectiveBackend)) { pushSandboxDockerPosture(entries, effectiveParams); } pushSandboxBrowserPosture(entries, effectiveParams); } +function isObservableContainerSandboxBackend(backend: string): boolean { + return backend === "docker" || backend === "podman"; +} + function pushSandboxDockerPosture( entries: PolicySandboxPostureEvidence[], params: SandboxPostureParams, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61be913121cc..911989426f0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1555,6 +1555,15 @@ importers: specifier: workspace:* version: link:../.. + extensions/podman: + devDependencies: + '@openclaw/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + openclaw: + specifier: workspace:* + version: link:../.. + extensions/policy: dependencies: json5: diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 9e50e89f0181..a9ff8b20a073 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -19,7 +19,21 @@ export { requireSandboxBackendFactory, } from "./sandbox/backend.js"; -export { isDockerDaemonUnavailable } from "./sandbox/docker.js"; +export { + buildSandboxCreateArgs, + isDockerDaemonUnavailable, + resolveDockerEnvPolicyEpoch, +} from "./sandbox/docker.js"; +export { computeSandboxConfigHash } from "./sandbox/config-hash.js"; +export { + appendReadOnlyWorkspaceSkillMountArgs, + appendWorkspaceMountArgs, + formatReadOnlyWorkspaceSkillMountHashState, + resolveReadOnlyWorkspaceSkillMounts, + SANDBOX_MOUNT_FORMAT_VERSION, + type ReadOnlyWorkspaceSkillMount, +} from "./sandbox/workspace-mounts.js"; +export { resolveSandboxScopeKey, slugifySessionKey } from "./sandbox/shared.js"; export { listSandboxBrowsers, listSandboxContainers, @@ -46,6 +60,8 @@ export { uploadDirectoryToSshTarget, } from "./sandbox/ssh.js"; export { sanitizeEnvVars } from "./sandbox/sanitize-env-vars.js"; +export { buildDockerExecArgs } from "./bash-tools.shared.js"; +export { readRegistryEntry as readSandboxRegistryEntry } from "./sandbox/registry.js"; export { createRemoteShellSandboxFsBridge } from "./sandbox/remote-fs-bridge.js"; export { createWritableRenameTargetResolver } from "./sandbox/fs-bridge-rename-targets.js"; export { resolveWritableRenameTargets } from "./sandbox/fs-bridge-rename-targets.js"; diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 9c87be08f7df..d43c793f60f8 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, }); diff --git a/src/agents/sandbox/context.user-fallback.test.ts b/src/agents/sandbox/context.user-fallback.test.ts index 324d3266ade4..03d294637c1c 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,29 @@ 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("does not apply Docker workspace ownership fallback for Podman", async () => { + const resolved = await resolveSandboxDockerUser({ + backend: "podman", + docker: baseDocker, + workspaceDir: "/tmp/workspace", + stat: async () => ({ uid: 1001, gid: 1002 }), + }); + expect(resolved.user).toBeUndefined(); + }); + 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-user.ts b/src/agents/sandbox/docker-user.ts index 62250e036b8d..3ec2bf20cb6e 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,9 @@ export async function resolveSandboxDockerUser(params: { if (configuredUser) { return params.docker; } + if (params.backend.trim().toLowerCase() !== "docker") { + return params.docker; + } const stat = params.stat ?? ((workspaceDir: string) => fs.stat(workspaceDir)); try { const workspaceStat = await stat(params.workspaceDir); diff --git a/src/plugin-sdk/sandbox.ts b/src/plugin-sdk/sandbox.ts index 1118ff8f970a..1f38da1b22d5 100644 --- a/src/plugin-sdk/sandbox.ts +++ b/src/plugin-sdk/sandbox.ts @@ -30,10 +30,13 @@ export type { OpenClawConfig } from "../config/config.js"; export { buildExecRemoteCommand, + buildDockerExecArgs, buildRemoteWorkdirValidationCommand, buildRemoteCommand, buildSshSandboxArgv, buildValidatedExecRemoteCommand, + buildSandboxCreateArgs, + computeSandboxConfigHash, createRemoteShellSandboxFsBridge, createWritableRenameTargetResolver, createSshSandboxSessionFromConfigText, @@ -45,12 +48,21 @@ export { isToolAllowed, registerSandboxBackend, requireSandboxBackendFactory, + resolveDockerEnvPolicyEpoch, + resolveReadOnlyWorkspaceSkillMounts, + readSandboxRegistryEntry, + resolveSandboxConfigForAgent, resolveSandboxRuntimeStatus, + appendReadOnlyWorkspaceSkillMountArgs, + appendWorkspaceMountArgs, + formatReadOnlyWorkspaceSkillMountHashState, resolveWritableRenameTargets, resolveWritableRenameTargetsForBridge, runSshSandboxCommand, + SANDBOX_MOUNT_FORMAT_VERSION, sanitizeEnvVars, shellEscape, + slugifySessionKey, uploadDirectoryToSshTarget, } from "../agents/sandbox.js"; diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 6fc1fdd58f2b..acaa04450e57 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -55,6 +55,8 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [ "ollama", "opencode", "openshell", + "phone-control", + "podman", "policy", "reef", "talk-voice", From 99a87cd822ec68940da4df01a02cb1ee97361a62 Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Tue, 7 Jul 2026 15:44:07 -0700 Subject: [PATCH 02/17] Fix Podman sandbox CI checks --- docs/.i18n/glossary.zh-CN.json | 4 ++++ docs/docs_map.md | 10 ++++++++++ extensions/podman/src/backend.test.ts | 4 +++- extensions/podman/src/backend.ts | 2 +- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index 4bc968088388..e62dfa54c16b 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -99,6 +99,10 @@ "source": "OpenAI provider", "target": "OpenAI provider" }, + { + "source": "Podman plugin", + "target": "Podman 插件" + }, { "source": "Mantis", "target": "Mantis" diff --git a/docs/docs_map.md b/docs/docs_map.md index c6ed073662a9..3e8e0b8b0586 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -4041,6 +4041,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 @@ -4856,6 +4857,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 @@ -7221,6 +7223,14 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Surface - H2: Related docs +## plugins/reference/podman.md + +- Route: /plugins/reference/podman +- Headings: + - H1: Podman plugin + - H2: Distribution + - H2: Surface + ## plugins/reference/policy.md - Route: /plugins/reference/policy diff --git a/extensions/podman/src/backend.test.ts b/extensions/podman/src/backend.test.ts index 9e0da3b1682a..b6aa4c110429 100644 --- a/extensions/podman/src/backend.test.ts +++ b/extensions/podman/src/backend.test.ts @@ -1,6 +1,8 @@ -import type { CreateSandboxBackendParams, SandboxConfig } from "openclaw/plugin-sdk/sandbox"; +import type { CreateSandboxBackendParams } from "openclaw/plugin-sdk/sandbox"; import { beforeEach, describe, expect, it, vi } from "vitest"; +type SandboxConfig = CreateSandboxBackendParams["cfg"]; + const podmanMocks = vi.hoisted(() => ({ execPodman: vi.fn(), execPodmanRaw: vi.fn(), diff --git a/extensions/podman/src/backend.ts b/extensions/podman/src/backend.ts index 9feb5d4a4dc8..5583400f0305 100644 --- a/extensions/podman/src/backend.ts +++ b/extensions/podman/src/backend.ts @@ -180,7 +180,7 @@ async function ensurePodmanContainer(params: { const expectedHash = computePodmanConfigHash(params.createParams); const state = await podmanContainerState(params.pluginConfig, containerName); let hasContainer = state.exists; - let running = state.running; + const running = state.running; if (hasContainer) { const currentHash = await readPodmanContainerLabel( params.pluginConfig, From 4ba02b83e81a44116d8724936641d7c5975e5c02 Mon Sep 17 00:00:00 2001 From: sallyom Date: Wed, 15 Jul 2026 19:57:53 -0400 Subject: [PATCH 03/17] refactor(sandbox): share container backend with Podman Signed-off-by: sallyom --- .github/labeler.yml | 6 - docs/.i18n/glossary.zh-CN.json | 4 - docs/cli/policy.md | 10 +- docs/cli/sandbox.md | 2 +- docs/docs_map.md | 8 - docs/gateway/config-agents.md | 4 +- docs/gateway/sandboxing.md | 82 ++-- docs/gateway/security/index.md | 2 +- docs/help/faq.md | 2 +- docs/install/docker.md | 2 +- docs/install/podman.md | 4 +- docs/plugins/reference/podman.md | 19 - docs/plugins/sdk-subpaths.md | 2 +- docs/tools/multi-agent-sandbox-tools.md | 2 +- extensions/mxc/test/mxc-backend.test.ts | 1 + extensions/openshell/src/backend.e2e.test.ts | 1 + .../src/backend.exec-workdir.test.ts | 1 + .../openshell/src/openshell-core.test.ts | 1 + extensions/podman/index.ts | 25 -- extensions/podman/openclaw.plugin.json | 42 --- extensions/podman/package.json | 24 -- extensions/podman/src/backend.test.ts | 306 --------------- extensions/podman/src/backend.ts | 350 ------------------ extensions/podman/src/config.test.ts | 29 -- extensions/podman/src/config.ts | 44 --- extensions/podman/src/podman.test.ts | 25 -- extensions/podman/src/podman.ts | 195 ---------- extensions/podman/tsconfig.json | 16 - .../register.sandbox-and-tools.test-utils.ts | 65 ---- .../policy/src/doctor/sandbox-findings.ts | 12 +- extensions/policy/src/policy-state-sandbox.ts | 8 +- extensions/policy/src/policy-state.test.ts | 42 +++ pnpm-lock.yaml | 9 - .../sandbox.resolveSandboxContext.test.ts | 119 ++++++ src/agents/sandbox.ts | 18 +- src/agents/sandbox/backend.test.ts | 6 + src/agents/sandbox/backend.ts | 17 +- src/agents/sandbox/browser.create.test.ts | 1 + src/agents/sandbox/config.test.ts | 15 + src/agents/sandbox/config.ts | 7 +- src/agents/sandbox/container-engine.ts | 122 ++++++ src/agents/sandbox/context.ts | 19 +- .../sandbox/context.user-fallback.test.ts | 11 + src/agents/sandbox/docker-backend.test.ts | 179 ++++++++- src/agents/sandbox/docker-backend.ts | 212 ++++++++--- src/agents/sandbox/docker-user.ts | 4 +- .../docker.config-hash-recreate.test.ts | 305 ++++++++++++++- src/agents/sandbox/docker.test.ts | 247 +++++++++++- src/agents/sandbox/docker.ts | 311 +++++++++------- src/agents/sandbox/fs-bridge.test-helpers.ts | 16 + src/agents/sandbox/manage.test.ts | 5 + src/agents/sandbox/podman-runtime.ts | 280 ++++++++++++++ src/agents/sandbox/prune.test.ts | 1 + src/agents/sandbox/registry.test.ts | 27 ++ src/agents/sandbox/registry.ts | 3 + src/agents/sandbox/sanitize-env-vars.ts | 28 ++ src/agents/sandbox/ssh-backend.test.ts | 2 + src/agents/sandbox/types.ts | 2 + src/agents/sandbox/workspace-authority.ts | 2 +- src/commands/doctor-sandbox.ts | 107 ++++-- ...rns-sandbox-enabled-without-docker.test.ts | 58 ++- src/plugin-sdk/sandbox.ts | 12 - src/plugins/bundled-plugin-metadata.test.ts | 2 - 63 files changed, 1952 insertions(+), 1531 deletions(-) delete mode 100644 docs/plugins/reference/podman.md delete mode 100644 extensions/podman/index.ts delete mode 100644 extensions/podman/openclaw.plugin.json delete mode 100644 extensions/podman/package.json delete mode 100644 extensions/podman/src/backend.test.ts delete mode 100644 extensions/podman/src/backend.ts delete mode 100644 extensions/podman/src/config.test.ts delete mode 100644 extensions/podman/src/config.ts delete mode 100644 extensions/podman/src/podman.test.ts delete mode 100644 extensions/podman/src/podman.ts delete mode 100644 extensions/podman/tsconfig.json create mode 100644 src/agents/sandbox/container-engine.ts create mode 100644 src/agents/sandbox/podman-runtime.ts diff --git a/.github/labeler.yml b/.github/labeler.yml index 2e054a1d48f7..1a245d4c8219 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -316,7 +316,6 @@ - "scripts/**/*docker*" - "scripts/**/Dockerfile*" - "scripts/sandbox-*.sh" - - "extensions/podman/**" - "src/agents/sandbox*.ts" - "src/commands/sandbox*.ts" - "src/cli/sandbox-cli.ts" @@ -325,7 +324,6 @@ - "docs/cli/sandbox.md" - "docs/gateway/sandbox*.md" - "docs/install/docker.md" - - "docs/install/podman.md" - "docs/tools/multi-agent-sandbox-tools.md" "agents": @@ -699,10 +697,6 @@ - changed-files: - any-glob-to-any-file: - "extensions/openshell/**" -"extensions: podman": - - changed-files: - - any-glob-to-any-file: - - "extensions/podman/**" "extensions: parallel": - changed-files: - any-glob-to-any-file: diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index e62dfa54c16b..4bc968088388 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -99,10 +99,6 @@ "source": "OpenAI provider", "target": "OpenAI provider" }, - { - "source": "Podman plugin", - "target": "Podman 插件" - }, { "source": "Mantis", "target": "Mantis" diff --git a/docs/cli/policy.md b/docs/cli/policy.md index bfca16497c81..a02d6290ff02 100644 --- a/docs/cli/policy.md +++ b/docs/cli/policy.md @@ -300,12 +300,16 @@ more restrictive; a weaker duplicate claim is rejected (allow-lists are subsets, deny-lists are supersets, required booleans are fixed). Container posture rules (`sandbox.containers.*`) are checked only against -evidence the matched agent's sandbox backend can expose. Docker and Podman -container sandboxes expose the shared container posture settings. If a backend cannot -observe a rule you enabled for it, policy reports +evidence the matched agent's sandbox backend can expose. The Docker and Podman +backends expose the same `sandbox.docker.*` container posture settings. If a +backend cannot observe a rule you enabled for it, policy reports `policy/sandbox-container-posture-unobservable` instead of passing; scope container rules to the agent groups that use a backend which can expose them. +Backend authorization uses the configured identity. `backend: "docker"` +requires `allowBackends: ["docker"]`, while `backend: "podman"` requires +`allowBackends: ["podman"]`. + Top-level `ingress.session.requireDmScope` stays global; `session.dmScope` is not channel-attributable evidence, so it cannot be scoped by `channelIds`. diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 42636d381a75..551db6599e41 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -102,7 +102,7 @@ Sandbox settings live in `~/.openclaw/openclaw.json` under `agents.defaults.sand "defaults": { "sandbox": { "mode": "all", // off, non-main, all - "backend": "docker", // docker, podman, ssh, openshell (plugin-provided) + "backend": "docker", // docker, ssh, openshell (plugin-provided) "scope": "agent", // session, agent, shared "docker": { "image": "openclaw-sandbox:bookworm-slim", diff --git a/docs/docs_map.md b/docs/docs_map.md index 3e8e0b8b0586..ec4e5c81fb5e 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -7223,14 +7223,6 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Surface - H2: Related docs -## plugins/reference/podman.md - -- Route: /plugins/reference/podman -- Headings: - - H1: Podman plugin - - H2: Distribution - - H2: Surface - ## plugins/reference/policy.md - Route: /plugins/reference/policy diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index e9cd491be699..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) | openshell | podman | ssh + backend: "docker", // docker (default) | podman | openshell | ssh scope: "agent", // session | agent (default) | shared workspaceAccess: "none", // none (default) | ro | rw workspaceRoot: "~/.openclaw/sandboxes", @@ -946,7 +946,7 @@ noVNC observer access is password-protected and brokered through a one-time, aut -Browser sandboxing is Docker-only. `sandbox.docker.binds` applies to Docker and Podman container sandboxes. +Browser sandboxing requires the Docker engine. `sandbox.docker.binds` applies to both the Docker and Podman backends. Build images (from a source checkout): diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index e26d96264f6b..f1d81e4aa309 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 | Podman | SSH | OpenShell | -| ------------------- | -------------------------------- | -------------------------------- | ------------------------------ | --------------------------------------------------- | -| **Where it runs** | Local container | Local Podman container | Any SSH-accessible host | OpenShell managed sandbox | -| **Setup** | `scripts/sandbox-setup.sh` | Podman plugin + sandbox image | SSH key + target host | OpenShell plugin enabled | -| **Workspace model** | Bind-mount or copy | Bind-mount or copy | Remote-canonical (seed once) | `mirror` or `remote` | -| **Network control** | `docker.network` (default: none) | `docker.network` (default: none) | Depends on remote host | Depends on OpenShell | -| **Browser sandbox** | Supported | Not supported yet | Not supported | Not supported yet | -| **Bind mounts** | `docker.binds` | `docker.binds` | N/A | N/A | -| **Best for** | Local dev, full isolation | Rootless/local Podman installs | Offloading to a remote machine | Managed remote sandboxes with optional two-way sync | +| | Docker or Podman backend | SSH | OpenShell | +| ------------------- | ----------------------------------------- | ------------------------------ | --------------------------------------------------- | +| **Where it runs** | Local Docker or Podman container | Any SSH-accessible host | OpenShell managed sandbox | +| **Setup** | Docker and/or Podman | SSH key + target host | OpenShell plugin enabled | +| **Workspace model** | Bind-mount or copy | Remote-canonical (seed once) | `mirror` or `remote` | +| **Network control** | `docker.network` (default: none) | Depends on remote host | Depends on OpenShell | +| **Browser sandbox** | Docker engine only | Not supported | Not supported yet | +| **Bind mounts** | `docker.binds` | N/A | N/A | +| **Best for** | Local development and container isolation | Offloading to a remote machine | Managed remote sandboxes with optional two-way sync | ## Supported capability matrix @@ -85,7 +85,7 @@ and [Plugin execution model](/plugins/architecture#execution-model). ## Docker backend -Docker is the default backend once sandboxing is enabled. It runs tools and sandbox browsers locally through the Docker daemon socket (`/var/run/docker.sock`); isolation comes from Docker namespaces. +The Docker backend runs tools locally through the `docker` CLI. Its selection and error behavior are unchanged; it does not probe or fall back to Podman. Defaults: `network: "none"` (no egress), `readOnlyRoot: true`, `capDrop: ["ALL"]`, image `openclaw-sandbox:bookworm-slim`. @@ -119,7 +119,7 @@ OpenClaw also creates Docker sandbox containers with an init process and mounted read-only at `/agent`; write operations to the agent workspace are rejected, while the configured tmpfs paths remain writable. -To expose host GPUs, set `agents.defaults.sandbox.docker.gpus` (or the per-agent override) to a value like `"all"` or `"device=GPU-uuid"`. This is passed to Docker's `--gpus` flag and requires a compatible host runtime such as NVIDIA Container Toolkit. +To expose host GPUs, set `agents.defaults.sandbox.docker.gpus` (or the per-agent override) to a value like `"all"` or `"device=GPU-uuid"`. This is passed to the selected container engine's Docker-compatible `--gpus` flag and requires compatible host GPU setup. **Docker-out-of-Docker (DooD) constraints** @@ -145,9 +145,11 @@ On Ubuntu/AppArmor hosts with Docker sandbox mode enabled, Codex app-server `wor ## Podman backend -Use `backend: "podman"` when you want local container sandboxing through Podman instead of Docker. The Podman sandbox backend is implemented as a bundled plugin and reuses the same `sandbox.docker.*` container settings as the Docker backend: image, workdir, network, read-only root, tmpfs, capabilities, environment, resource limits, custom binds, and setup command. +Use `sandbox.backend: "podman"` to select the native `podman` CLI directly. This is a built-in backend, not a plugin. It does not probe or select Docker, even when the `docker` executable is installed. -For rootless Podman, the backend creates default-user containers with Podman's `keep-id` user namespace and the current uid/gid so writable workspace bind mounts remain writable from inside the sandbox. If you set `sandbox.docker.user`, OpenClaw leaves that user mapping to your Podman/image configuration. +Podman reuses the existing `sandbox.docker.*` settings and the active native `podman` CLI context; it adds no separate connection configuration surface. + +Rootless Podman defaults to `--userns=keep-id` for writable workspace mounts. A long-lived sandbox can reserve subordinate IDs and block unrelated `--userns=auto` workloads; remove it before starting those workloads. Set `sandbox.docker.user` to control the container user; rootful Podman otherwise uses the workspace owner when available. ```json5 { @@ -167,33 +169,31 @@ For rootless Podman, the backend creates default-user containers with Podman's ` }, }, }, - plugins: { - entries: { - podman: { - enabled: true, - config: { - command: "podman", - // Optional: use one Podman remote connection style. - // connection: "dev", - // url: "unix:///run/user/1000/podman/podman.sock", - }, - }, - }, - }, } ``` -Build or pull the sandbox image into the Podman store selected by the plugin config before enabling the backend. From a source checkout, the same Dockerfile can be built with Podman: +Build or pull the sandbox image into the selected Podman store before enabling the backend. From a source checkout, build the same sandbox Dockerfile with Podman: ```bash podman build -t openclaw-sandbox:bookworm-slim -f scripts/docker/sandbox/Dockerfile . ``` -Podman backend limits: +Podman notes: -- Browser sandboxing is not supported yet; keep `sandbox.browser.enabled` off or use the Docker backend. -- `sandbox.docker.gpus` is Docker-only; Podman GPU/device setup is host-specific and should be handled with a custom image/runtime outside this backend for now. -- `connection` and `url` are mutually exclusive plugin config options because Podman accepts only one remote target selection for a command invocation. +- Browser sandboxing is not supported by Podman; keep `sandbox.browser.enabled` off, or install Docker and select `backend: "docker"`. +- Local Podman engines and Podman Machine are supported. Podman Machine bind sources must be under the host home directory, which is its default shared volume. Arbitrary remote Podman connections are rejected; use the SSH backend for remote execution. +- Custom `tmpfs` or bind mounts must not cover `/run/podman-init`; OpenClaw rejects them so sandbox cleanup continues to work. + + +**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 @@ -459,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 @@ -523,9 +523,11 @@ 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"`; rootless Podman must set `user: "0:0"` because omission + defaults to the invoking user through `--userns=keep-id`. - Sandbox exec does **not** inherit host `process.env`. Use `agents.defaults.sandbox.docker.env` (or a custom image) for skill API keys. - - Values in `agents.defaults.sandbox.docker.env` are passed as explicit Docker container environment variables. Anyone with Docker daemon access can inspect them with Docker metadata commands such as `docker inspect`. Use a custom image, mounted secret file, or another secret delivery path if that metadata exposure is not acceptable. + - Values in `agents.defaults.sandbox.docker.env` are passed as explicit container environment variables. Anyone with access to the selected container engine can inspect them with metadata commands such as `docker inspect` or `podman inspect`. Use a custom image, mounted secret file, or another secret delivery path if that metadata exposure is not acceptable. 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 3b03fb45f0fa..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. Podman, SSH, and OpenShell sandbox backends are also available; see [Sandboxing](/gateway/sandboxing). +The default Docker sandbox backend uses only the `docker` CLI. Set the backend to `"podman"` to select native Podman directly. Sandboxing is off by default and does not require the gateway itself to run in a container. SSH and OpenShell sandbox backends are also available; see [Sandboxing](/gateway/sandboxing). Hosting multiple users? See [Multi-tenant hosting](/gateway/multi-tenant-hosting) for the one-cell-per-tenant model. diff --git a/docs/install/podman.md b/docs/install/podman.md index 20633cde2ee2..387f43842d99 100644 --- a/docs/install/podman.md +++ b/docs/install/podman.md @@ -92,9 +92,9 @@ The manual launcher reads only a small allowlist of Podman-related keys from `~/ ## Agent sandbox backend -This page covers running the Gateway itself in a Podman container. Agent sandboxing is separate: set `agents.defaults.sandbox.backend: "podman"` when you want OpenClaw to run sandboxed agent tools in local Podman containers. +This page covers running the Gateway itself in a Podman container. Agent sandboxing is separate. Set `agents.defaults.sandbox.backend: "podman"` to select the native Podman CLI directly. The default `"docker"` backend remains Docker-only. -The Podman sandbox backend ships as a bundled plugin. It reuses the same `agents.defaults.sandbox.docker.*` container settings as the Docker backend, but executes them through the `podman` CLI and optional Podman remote `connection` or `url` plugin config. Browser sandboxes and `sandbox.docker.gpus` remain Docker-only for now. +Podman reuses the same `agents.defaults.sandbox.docker.*` container settings as Docker but executes them through the native `podman` CLI. Browser sandboxes remain Docker-only for now. See [Sandboxing](/gateway/sandboxing#podman-backend) for the config example and image-build command. diff --git a/docs/plugins/reference/podman.md b/docs/plugins/reference/podman.md deleted file mode 100644 index 09839d34e404..000000000000 --- a/docs/plugins/reference/podman.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -summary: "OpenClaw sandbox backend for rootless Podman containers." -read_when: - - You are installing, configuring, or auditing the podman plugin -title: "Podman plugin" ---- - -# Podman plugin - -OpenClaw sandbox backend for rootless Podman containers. - -## Distribution - -- Package: `@openclaw/podman-sandbox` -- Install route: included in OpenClaw - -## Surface - -plugin diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index e293104e3189..ced2954ae0f1 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -265,7 +265,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It | `plugin-sdk/tool-payload` | Private-local after July 2026; Extract normalized payloads from tool result objects | | `plugin-sdk/tool-results` | Typed text and JSON agent tool result builders | | `plugin-sdk/tool-send` | Extract canonical send target fields from tool args | - | `plugin-sdk/sandbox` | Private-local after July 2026; Sandbox backend types, container backend helper builders, and SSH/OpenShell command helpers, including fail-fast exec command preflight | + | `plugin-sdk/sandbox` | Private-local after July 2026; Sandbox backend types and SSH/OpenShell command helpers, including fail-fast exec command preflight | | `plugin-sdk/temp-path` | Shared temp-download path helpers and private secure temp workspaces | | `plugin-sdk/logging-core` | Subsystem logger and redaction helpers | | `plugin-sdk/markdown-table-runtime` | Private-local after July 2026; Markdown table mode and conversion helpers | diff --git a/docs/tools/multi-agent-sandbox-tools.md b/docs/tools/multi-agent-sandbox-tools.md index cdeb4aad67ed..3eff2c9a1a29 100644 --- a/docs/tools/multi-agent-sandbox-tools.md +++ b/docs/tools/multi-agent-sandbox-tools.md @@ -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"`). The `docker` block configures Docker-compatible container settings used by Docker and Podman backends. +`agents.entries.*.sandbox.{docker,browser,prune}.*` overrides `agents.defaults.sandbox.{docker,browser,prune}.*` for that agent (ignored when sandbox scope resolves to `"shared"`). The `docker` block configures both built-in container backends. ### Tool restrictions 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/podman/index.ts b/extensions/podman/index.ts deleted file mode 100644 index ea812c41b3a5..000000000000 --- a/extensions/podman/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox"; -import { - createPodmanSandboxBackendFactory, - createPodmanSandboxBackendManager, -} from "./src/backend.js"; -import { createPodmanPluginConfigSchema, resolvePodmanPluginConfig } from "./src/config.js"; - -export default definePluginEntry({ - id: "podman", - name: "Podman Sandbox", - description: "OpenClaw sandbox backend for rootless Podman containers.", - configSchema: createPodmanPluginConfigSchema(), - register(api) { - if (api.registrationMode !== "full") { - return; - } - const pluginConfig = resolvePodmanPluginConfig(api.pluginConfig); - registerSandboxBackend("podman", { - factory: createPodmanSandboxBackendFactory({ pluginConfig }), - manager: createPodmanSandboxBackendManager({ pluginConfig }), - resolveWorkdir: ({ cfg }) => cfg.docker.workdir, - }); - }, -}); diff --git a/extensions/podman/openclaw.plugin.json b/extensions/podman/openclaw.plugin.json deleted file mode 100644 index fd15622149b7..000000000000 --- a/extensions/podman/openclaw.plugin.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "id": "podman", - "activation": { - "onStartup": true - }, - "name": "Podman Sandbox", - "description": "OpenClaw sandbox backend for rootless Podman containers.", - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "command": { - "type": "string", - "minLength": 1 - }, - "connection": { - "type": "string", - "minLength": 1 - }, - "url": { - "type": "string", - "minLength": 1 - } - } - }, - "uiHints": { - "command": { - "label": "Podman Command", - "help": "Path or command name for the Podman CLI." - }, - "connection": { - "label": "Podman Connection", - "help": "Optional Podman connection name passed with --connection.", - "advanced": true - }, - "url": { - "label": "Podman URL", - "help": "Optional Podman service URL passed with --url.", - "advanced": true - } - } -} diff --git a/extensions/podman/package.json b/extensions/podman/package.json deleted file mode 100644 index 97a597851a28..000000000000 --- a/extensions/podman/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@openclaw/podman-sandbox", - "version": "2026.6.11", - "private": true, - "description": "OpenClaw sandbox backend for rootless Podman containers.", - "type": "module", - "devDependencies": { - "@openclaw/plugin-sdk": "workspace:*", - "openclaw": "workspace:*" - }, - "peerDependencies": { - "openclaw": ">=2026.6.11" - }, - "peerDependenciesMeta": { - "openclaw": { - "optional": true - } - }, - "openclaw": { - "extensions": [ - "./index.ts" - ] - } -} diff --git a/extensions/podman/src/backend.test.ts b/extensions/podman/src/backend.test.ts deleted file mode 100644 index b6aa4c110429..000000000000 --- a/extensions/podman/src/backend.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -import type { CreateSandboxBackendParams } from "openclaw/plugin-sdk/sandbox"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -type SandboxConfig = CreateSandboxBackendParams["cfg"]; - -const podmanMocks = vi.hoisted(() => ({ - execPodman: vi.fn(), - execPodmanRaw: vi.fn(), - podmanContainerState: vi.fn(), - readPodmanContainerLabel: vi.fn(), -})); -const sdkMocks = vi.hoisted(() => ({ - readSandboxRegistryEntry: vi.fn(), -})); - -vi.mock("./podman.js", async () => { - const actual = await vi.importActual("./podman.js"); - return { - ...actual, - execPodman: podmanMocks.execPodman, - execPodmanRaw: podmanMocks.execPodmanRaw, - podmanContainerState: podmanMocks.podmanContainerState, - readPodmanContainerLabel: podmanMocks.readPodmanContainerLabel, - }; -}); - -vi.mock("openclaw/plugin-sdk/sandbox", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - readSandboxRegistryEntry: sdkMocks.readSandboxRegistryEntry, - }; -}); - -const { createPodmanSandboxBackendFactory } = await import("./backend.js"); - -function createSandboxConfig(overrides: Partial = {}): SandboxConfig { - return { - mode: "all", - backend: "podman", - scope: "session", - workspaceAccess: "rw", - workspaceRoot: "/tmp/openclaw-sandboxes", - docker: { - image: "openclaw-sandbox:bookworm-slim", - containerPrefix: "openclaw-sbx-", - workdir: "/workspace", - readOnlyRoot: true, - tmpfs: ["/tmp"], - network: "none", - capDrop: ["ALL"], - env: { LANG: "C.UTF-8" }, - }, - ssh: { - command: "ssh", - workspaceRoot: "/tmp/openclaw-sandboxes", - strictHostKeyChecking: true, - updateHostKeys: true, - }, - browser: { - enabled: false, - image: "openclaw-sandbox-browser:bookworm-slim", - containerPrefix: "openclaw-sbx-browser-", - network: "openclaw-sandbox-browser", - cdpPort: 9222, - vncPort: 5900, - noVncPort: 6080, - headless: true, - enableNoVnc: true, - allowHostControl: false, - autoStart: true, - autoStartTimeoutMs: 12_000, - }, - tools: { - allow: [], - deny: [], - }, - prune: { - idleHours: 24, - maxAgeDays: 7, - }, - ...overrides, - }; -} - -function createParams( - overrides: Partial = {}, -): CreateSandboxBackendParams { - const cfg = overrides.cfg ?? createSandboxConfig(); - return { - sessionKey: "agent:main:podman-test", - scopeKey: "agent:main:podman-test", - workspaceDir: "/tmp/openclaw-sandbox-workspace", - agentWorkspaceDir: "/tmp/openclaw-agent-workspace", - cfg, - ...overrides, - }; -} - -function commandCalls() { - return podmanMocks.execPodman.mock.calls.map((call) => call[1] as string[]); -} - -function expectedKeepIdUserArgs() { - return typeof process.getuid === "function" && typeof process.getgid === "function" - ? ["--userns", "keep-id", "--user", `${process.getuid()}:${process.getgid()}`] - : []; -} - -describe("Podman sandbox backend", () => { - beforeEach(() => { - podmanMocks.execPodman.mockReset(); - podmanMocks.execPodmanRaw.mockReset(); - podmanMocks.podmanContainerState.mockReset(); - podmanMocks.readPodmanContainerLabel.mockReset(); - sdkMocks.readSandboxRegistryEntry.mockReset(); - podmanMocks.execPodman.mockResolvedValue({ stdout: "", stderr: "", code: 0 }); - podmanMocks.execPodmanRaw.mockResolvedValue({ - stdout: Buffer.from(""), - stderr: Buffer.from(""), - code: 0, - }); - podmanMocks.podmanContainerState.mockResolvedValue({ exists: false, running: false }); - podmanMocks.readPodmanContainerLabel.mockResolvedValue(null); - sdkMocks.readSandboxRegistryEntry.mockResolvedValue(null); - }); - - it("creates and starts a missing Podman sandbox container", async () => { - const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } }); - const backend = await factory(createParams()); - const calls = commandCalls(); - const createArgs = calls.find((args) => args[0] === "create"); - - expect(backend.id).toBe("podman"); - expect(backend.workdir).toBe("/workspace"); - expect(createArgs).toEqual( - expect.arrayContaining([ - "create", - "--name", - expect.stringMatching(/^openclaw-sbx-agent-main-podman-test-/), - "--read-only", - "--network", - "none", - "--cap-drop", - "ALL", - ...expectedKeepIdUserArgs(), - "-v", - "/tmp/openclaw-sandbox-workspace:/workspace:z", - "-v", - "/tmp/openclaw-agent-workspace:/agent:z", - "openclaw-sandbox:bookworm-slim", - "sleep", - "infinity", - ]), - ); - expect(calls.some((args) => args[0] === "start")).toBe(true); - }); - - it("does not override an explicitly configured sandbox user", async () => { - const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } }); - - await factory( - createParams({ - cfg: createSandboxConfig({ - docker: { ...createSandboxConfig().docker, user: "1001:1001" }, - }), - }), - ); - - const createArgs = commandCalls().find((args) => args[0] === "create"); - expect(createArgs).toEqual(expect.arrayContaining(["--user", "1001:1001"])); - expect(createArgs).not.toContain("keep-id"); - }); - - it("recreates a stopped existing container when the config hash label does not match", async () => { - podmanMocks.podmanContainerState.mockResolvedValue({ exists: true, running: false }); - podmanMocks.readPodmanContainerLabel.mockResolvedValue("old-hash"); - const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } }); - - await factory(createParams()); - - const calls = commandCalls(); - expect(calls).toContainEqual([ - "rm", - "-f", - expect.stringMatching(/^openclaw-sbx-agent-main-podman-test-/), - ]); - expect(calls.some((args) => args[0] === "create")).toBe(true); - }); - - it("preserves a running existing container when the config hash label does not match", async () => { - podmanMocks.podmanContainerState.mockResolvedValue({ exists: true, running: true }); - podmanMocks.readPodmanContainerLabel.mockResolvedValue("old-hash"); - const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } }); - - await factory(createParams()); - - const calls = commandCalls(); - expect(calls.some((args) => args[0] === "rm")).toBe(false); - expect(calls.some((args) => args[0] === "create")).toBe(false); - }); - - it("recreates an old running container when the config hash label does not match", async () => { - podmanMocks.podmanContainerState.mockResolvedValue({ exists: true, running: true }); - podmanMocks.readPodmanContainerLabel.mockResolvedValue("old-hash"); - sdkMocks.readSandboxRegistryEntry.mockResolvedValue({ lastUsedAtMs: 1 }); - const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } }); - - await factory(createParams()); - - const calls = commandCalls(); - expect(calls).toContainEqual([ - "rm", - "-f", - expect.stringMatching(/^openclaw-sbx-agent-main-podman-test-/), - ]); - expect(calls.some((args) => args[0] === "create")).toBe(true); - }); - - it("reuses a matching running container", async () => { - let capturedHash = ""; - podmanMocks.execPodman.mockImplementation(async (_config, args: string[]) => { - if (args[0] === "create") { - capturedHash = - args - .find((arg) => arg.startsWith("openclaw.configHash=")) - ?.slice("openclaw.configHash=".length) ?? ""; - } - return { stdout: "", stderr: "", code: 0 }; - }); - const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } }); - await factory(createParams()); - - podmanMocks.execPodman.mockClear(); - podmanMocks.podmanContainerState.mockResolvedValue({ exists: true, running: true }); - podmanMocks.readPodmanContainerLabel.mockResolvedValue(capturedHash); - await factory(createParams()); - - const calls = commandCalls(); - expect(calls.some((args) => args[0] === "create")).toBe(false); - expect(calls.some((args) => args[0] === "rm")).toBe(false); - }); - - it("builds exec specs with Podman remote options", async () => { - const factory = createPodmanSandboxBackendFactory({ - pluginConfig: { command: "podman", connection: "dev" }, - }); - const backend = await factory(createParams()); - - const spec = await backend.buildExecSpec({ - command: "pwd", - env: { PATH: "/usr/local/bin" }, - usePty: true, - }); - - expect(spec.argv.slice(0, 4)).toEqual(["podman", "--connection", "dev", "exec"]); - expect(spec.argv).toContain("-t"); - expect(spec.argv).toContain("OPENCLAW_PREPEND_PATH=/usr/local/bin"); - }); - - it("routes filesystem bridge shell commands through podman exec", async () => { - const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } }); - const backend = await factory(createParams()); - - await backend.runShellCommand({ script: 'stat -c %s "$1"', args: ["/workspace/file.txt"] }); - - expect(podmanMocks.execPodmanRaw).toHaveBeenCalledWith( - { command: "podman" }, - [ - "exec", - "-i", - expect.stringMatching(/^openclaw-sbx-agent-main-podman-test-/), - "sh", - "-c", - 'stat -c %s "$1"', - "openclaw-sandbox-fs", - "/workspace/file.txt", - ], - expect.objectContaining({ allowFailure: undefined }), - ); - }); - - it("fails closed for unsupported browser and GPU options", async () => { - const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } }); - - await expect( - factory( - createParams({ - cfg: createSandboxConfig({ - browser: { ...createSandboxConfig().browser, enabled: true }, - }), - }), - ), - ).rejects.toThrow(/browser sandboxes/i); - - await expect( - factory( - createParams({ - cfg: createSandboxConfig({ - docker: { ...createSandboxConfig().docker, gpus: "all" }, - }), - }), - ), - ).rejects.toThrow(/does not support sandbox\.docker\.gpus/i); - }); -}); diff --git a/extensions/podman/src/backend.ts b/extensions/podman/src/backend.ts deleted file mode 100644 index 5583400f0305..000000000000 --- a/extensions/podman/src/backend.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { - appendReadOnlyWorkspaceSkillMountArgs, - appendWorkspaceMountArgs, - buildDockerExecArgs, - buildSandboxCreateArgs, - computeSandboxConfigHash, - formatReadOnlyWorkspaceSkillMountHashState, - readSandboxRegistryEntry, - resolveDockerEnvPolicyEpoch, - resolveReadOnlyWorkspaceSkillMounts, - resolveSandboxConfigForAgent, - SANDBOX_MOUNT_FORMAT_VERSION, - slugifySessionKey, - type CreateSandboxBackendParams, - type SandboxBackendCommandParams, - type SandboxBackendFactory, - type SandboxBackendHandle, - type SandboxBackendManager, -} from "openclaw/plugin-sdk/sandbox"; -import type { PodmanPluginConfig } from "./config.js"; -import { - execPodman, - execPodmanRaw, - podmanContainerState, - readPodmanContainerLabel, -} from "./podman.js"; - -type PodmanSandboxBackendParams = { - pluginConfig: PodmanPluginConfig; -}; - -const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000; -const PODMAN_USERNS_CONFIG_EPOCH = "podman-keep-id-v1"; - -function buildContainerName(params: CreateSandboxBackendParams): string { - const scopeKey = - params.cfg.scope === "shared" - ? "shared" - : params.cfg.scope === "session" - ? params.sessionKey - : params.scopeKey; - const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey); - return `${params.cfg.docker.containerPrefix}${slug}`.slice(0, 63); -} - -function assertSupportedPodmanConfig(params: CreateSandboxBackendParams): void { - if (params.cfg.docker.gpus) { - throw new Error( - 'Podman sandbox backend does not support sandbox.docker.gpus. Use backend "docker" for Docker GPU runtime support, or configure GPU devices through a custom Podman image/runtime outside this backend.', - ); - } - if (params.cfg.browser.enabled) { - throw new Error("Podman sandbox backend does not support browser sandboxes yet."); - } -} - -async function ensurePodmanImage(config: PodmanPluginConfig, image: string): Promise { - const result = await execPodman(config, ["image", "inspect", image], { allowFailure: true }); - if (result.code === 0) { - return; - } - const detail = result.stderr.trim() || result.stdout.trim(); - throw new Error( - `Podman sandbox image not found: ${image}. Build or pull it into the selected Podman store first.${detail ? ` Podman said: ${detail}` : ""}`, - ); -} - -function appendCustomBinds(args: string[], binds: readonly string[] | undefined): void { - for (const bind of binds ?? []) { - args.push("-v", bind); - } -} - -function currentUserForKeepId(): string | null { - const getuid = process.getuid; - const getgid = process.getgid; - if (typeof getuid !== "function" || typeof getgid !== "function") { - return null; - } - return `${getuid()}:${getgid()}`; -} - -function appendPodmanUserNamespaceArgs(args: string[], cfg: CreateSandboxBackendParams["cfg"]) { - if (cfg.docker.user) { - return; - } - const user = currentUserForKeepId(); - if (!user) { - return; - } - // Rootless Podman needs keep-id so the sandbox can write workspace bind mounts. - // When callers set docker.user explicitly, that ownership contract is theirs. - args.push("--userns", "keep-id", "--user", user); -} - -function computePodmanConfigHash(params: CreateSandboxBackendParams): string { - const readOnlyWorkspaceSkillMounts = resolveReadOnlyWorkspaceSkillMounts({ - workspaceDir: params.workspaceDir, - agentWorkspaceDir: params.agentWorkspaceDir, - skillsWorkspaceDir: params.skillsWorkspaceDir, - workdir: params.cfg.docker.workdir, - workspaceAccess: params.cfg.workspaceAccess, - }); - const genericHash = computeSandboxConfigHash({ - docker: params.cfg.docker, - dockerEnvPolicyEpoch: resolveDockerEnvPolicyEpoch(params.cfg.docker.env), - workspaceAccess: params.cfg.workspaceAccess, - workspaceDir: params.workspaceDir, - agentWorkspaceDir: params.agentWorkspaceDir, - mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION, - readOnlyWorkspaceSkillMounts: formatReadOnlyWorkspaceSkillMountHashState( - readOnlyWorkspaceSkillMounts, - ), - }); - return `${genericHash}:${PODMAN_USERNS_CONFIG_EPOCH}`; -} - -async function createPodmanContainer(params: { - pluginConfig: PodmanPluginConfig; - createParams: CreateSandboxBackendParams; - containerName: string; - configHash: string; -}) { - const { createParams } = params; - await ensurePodmanImage(params.pluginConfig, createParams.cfg.docker.image); - const readOnlyWorkspaceSkillMounts = resolveReadOnlyWorkspaceSkillMounts({ - workspaceDir: createParams.workspaceDir, - agentWorkspaceDir: createParams.agentWorkspaceDir, - skillsWorkspaceDir: createParams.skillsWorkspaceDir, - workdir: createParams.cfg.docker.workdir, - workspaceAccess: createParams.cfg.workspaceAccess, - }); - const args = buildSandboxCreateArgs({ - name: params.containerName, - cfg: createParams.cfg.docker, - scopeKey: createParams.scopeKey, - configHash: params.configHash, - includeBinds: false, - bindSourceRoots: [createParams.workspaceDir, createParams.agentWorkspaceDir], - }); - appendPodmanUserNamespaceArgs(args, createParams.cfg); - args.push("--workdir", createParams.cfg.docker.workdir); - appendWorkspaceMountArgs({ - args, - workspaceDir: createParams.workspaceDir, - agentWorkspaceDir: createParams.agentWorkspaceDir, - skillsWorkspaceDir: createParams.skillsWorkspaceDir, - workdir: createParams.cfg.docker.workdir, - workspaceAccess: createParams.cfg.workspaceAccess, - readOnlyWorkspaceSkillMounts, - includeReadOnlyWorkspaceSkillMounts: false, - }); - appendCustomBinds(args, createParams.cfg.docker.binds); - appendReadOnlyWorkspaceSkillMountArgs({ - args, - readOnlyWorkspaceSkillMounts, - }); - args.push(createParams.cfg.docker.image, "sleep", "infinity"); - await execPodman(params.pluginConfig, args); - await execPodman(params.pluginConfig, ["start", params.containerName]); - const setupCommand = createParams.cfg.docker.setupCommand?.trim(); - if (setupCommand) { - await execPodman(params.pluginConfig, [ - "exec", - "-i", - params.containerName, - "/bin/sh", - "-lc", - setupCommand, - ]); - } -} - -async function ensurePodmanContainer(params: { - pluginConfig: PodmanPluginConfig; - createParams: CreateSandboxBackendParams; -}): Promise { - assertSupportedPodmanConfig(params.createParams); - const containerName = buildContainerName(params.createParams); - const expectedHash = computePodmanConfigHash(params.createParams); - const state = await podmanContainerState(params.pluginConfig, containerName); - let hasContainer = state.exists; - const running = state.running; - if (hasContainer) { - const currentHash = await readPodmanContainerLabel( - params.pluginConfig, - containerName, - "openclaw.configHash", - ); - if (currentHash !== expectedHash) { - const registryEntry = (await readSandboxRegistryEntry(containerName)) ?? undefined; - const lastUsedAtMs = registryEntry?.lastUsedAtMs; - const isHot = - running && - (typeof lastUsedAtMs !== "number" || Date.now() - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS); - if (isHot) { - // Match Docker's hot-container contract: keep recently used runtimes - // alive so config drift does not kill active agent work mid-turn. - return containerName; - } - await execPodman(params.pluginConfig, ["rm", "-f", containerName], { - allowFailure: true, - }); - hasContainer = false; - } - } - if (!hasContainer) { - await createPodmanContainer({ - pluginConfig: params.pluginConfig, - createParams: params.createParams, - containerName, - configHash: expectedHash, - }); - } else if (!running) { - await execPodman(params.pluginConfig, ["start", containerName]); - } - return containerName; -} - -function createPodmanSandboxBackendHandle(params: { - pluginConfig: PodmanPluginConfig; - containerName: string; - workdir: string; - env?: Record; - image: string; -}): SandboxBackendHandle { - return { - id: "podman", - runtimeId: params.containerName, - runtimeLabel: params.containerName, - workdir: params.workdir, - env: params.env, - configLabel: params.image, - configLabelKind: "Image", - async buildExecSpec({ command, workdir, env, usePty }) { - const invocation = { - command: params.pluginConfig.command, - args: buildDockerExecArgs({ - containerName: params.containerName, - command, - workdir: workdir ?? params.workdir, - env, - tty: usePty, - }), - }; - if (params.pluginConfig.connection) { - invocation.args.unshift("--connection", params.pluginConfig.connection); - } - if (params.pluginConfig.url) { - invocation.args.unshift("--url", params.pluginConfig.url); - } - return { - argv: [invocation.command, ...invocation.args], - env: process.env, - stdinMode: usePty ? "pipe-open" : "pipe-closed", - }; - }, - runShellCommand(command) { - return runPodmanSandboxShellCommand({ - pluginConfig: params.pluginConfig, - containerName: params.containerName, - ...command, - }); - }, - }; -} - -export function runPodmanSandboxShellCommand( - params: { - pluginConfig: PodmanPluginConfig; - containerName: string; - } & SandboxBackendCommandParams, -) { - const podmanArgs = [ - "exec", - "-i", - params.containerName, - "sh", - "-c", - params.script, - "openclaw-sandbox-fs", - ]; - if (params.args?.length) { - podmanArgs.push(...params.args); - } - return execPodmanRaw(params.pluginConfig, podmanArgs, { - input: params.stdin, - allowFailure: params.allowFailure, - signal: params.signal, - }); -} - -export function createPodmanSandboxBackendFactory( - params: PodmanSandboxBackendParams, -): SandboxBackendFactory { - return async (createParams) => { - const containerName = await ensurePodmanContainer({ - pluginConfig: params.pluginConfig, - createParams, - }); - return createPodmanSandboxBackendHandle({ - pluginConfig: params.pluginConfig, - containerName, - workdir: createParams.cfg.docker.workdir, - env: createParams.cfg.docker.env, - image: createParams.cfg.docker.image, - }); - }; -} - -export function createPodmanSandboxBackendManager(params: { - pluginConfig: PodmanPluginConfig; -}): SandboxBackendManager { - return { - async describeRuntime({ entry, config, agentId }) { - const state = await podmanContainerState(params.pluginConfig, entry.containerName); - let actualConfigLabel = entry.image; - if (state.exists) { - const result = await execPodman( - params.pluginConfig, - ["inspect", "-f", "{{.Config.Image}}", entry.containerName], - { allowFailure: true }, - ); - if (result.code === 0) { - actualConfigLabel = result.stdout.trim() || actualConfigLabel; - } - } - const configuredImage = resolveSandboxConfigForAgent(config, agentId).docker.image; - return { - running: state.running, - actualConfigLabel, - configLabelMatch: actualConfigLabel === configuredImage, - }; - }, - async removeRuntime({ entry }) { - const result = await execPodman(params.pluginConfig, ["rm", "-f", entry.containerName], { - allowFailure: true, - }); - if (result.code !== 0) { - const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`; - if (/no such (container|object)|does not exist/iu.test(detail)) { - return; - } - throw new Error( - `Failed to remove Podman sandbox runtime ${entry.containerName}: ${detail}`, - ); - } - }, - }; -} diff --git a/extensions/podman/src/config.test.ts b/extensions/podman/src/config.test.ts deleted file mode 100644 index c1e150d3d20a..000000000000 --- a/extensions/podman/src/config.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolvePodmanPluginConfig } from "./config.js"; - -describe("resolvePodmanPluginConfig", () => { - it("defaults to the podman command", () => { - expect(resolvePodmanPluginConfig(undefined)).toEqual({ command: "podman" }); - }); - - it("trims command and optional remote selectors", () => { - expect( - resolvePodmanPluginConfig({ - command: " /usr/bin/podman ", - connection: " dev ", - }), - ).toEqual({ - command: "/usr/bin/podman", - connection: "dev", - }); - }); - - it("rejects connection and url together", () => { - expect(() => - resolvePodmanPluginConfig({ - connection: "dev", - url: "unix:///run/user/1000/podman/podman.sock", - }), - ).toThrow(/cannot set both connection and url/i); - }); -}); diff --git a/extensions/podman/src/config.ts b/extensions/podman/src/config.ts deleted file mode 100644 index cca2b21bbb6e..000000000000 --- a/extensions/podman/src/config.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { buildJsonPluginConfigSchema } from "openclaw/plugin-sdk/plugin-entry"; - -export type PodmanPluginConfig = { - command: string; - connection?: string; - url?: string; -}; - -export type PodmanPluginConfigInput = { - command?: unknown; - connection?: unknown; - url?: unknown; -}; - -export function createPodmanPluginConfigSchema() { - return buildJsonPluginConfigSchema({ - type: "object", - additionalProperties: false, - properties: { - command: { type: "string", minLength: 1 }, - connection: { type: "string", minLength: 1 }, - url: { type: "string", minLength: 1 }, - }, - }); -} - -function readOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -export function resolvePodmanPluginConfig(input: unknown): PodmanPluginConfig { - const config = (input && typeof input === "object" ? input : {}) as PodmanPluginConfigInput; - const command = readOptionalString(config.command) ?? "podman"; - const connection = readOptionalString(config.connection); - const url = readOptionalString(config.url); - if (connection && url) { - throw new Error("Podman sandbox config cannot set both connection and url."); - } - return { - command, - ...(connection ? { connection } : {}), - ...(url ? { url } : {}), - }; -} diff --git a/extensions/podman/src/podman.test.ts b/extensions/podman/src/podman.test.ts deleted file mode 100644 index 84f3e957ce57..000000000000 --- a/extensions/podman/src/podman.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildPodmanInvocation } from "./podman.js"; - -describe("buildPodmanInvocation", () => { - it("uses the configured command and appends args", () => { - expect( - buildPodmanInvocation({ - config: { command: "/opt/bin/podman" }, - args: ["inspect", "openclaw-sbx-main"], - }), - ).toEqual({ - command: "/opt/bin/podman", - args: ["inspect", "openclaw-sbx-main"], - }); - }); - - it("places remote options before the subcommand", () => { - expect( - buildPodmanInvocation({ - config: { command: "podman", url: "unix:///run/user/1000/podman/podman.sock" }, - args: ["exec", "sandbox", "true"], - }).args, - ).toEqual(["--url", "unix:///run/user/1000/podman/podman.sock", "exec", "sandbox", "true"]); - }); -}); diff --git a/extensions/podman/src/podman.ts b/extensions/podman/src/podman.ts deleted file mode 100644 index d48fd6d0a334..000000000000 --- a/extensions/podman/src/podman.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { spawn } from "node:child_process"; -import type { PodmanPluginConfig } from "./config.js"; - -export type ExecPodmanRawOptions = { - allowFailure?: boolean; - input?: Buffer | string; - signal?: AbortSignal; -}; - -export type ExecPodmanRawResult = { - stdout: Buffer; - stderr: Buffer; - code: number; -}; - -type ExecPodmanRawError = Error & { - code: number; - stdout: Buffer; - stderr: Buffer; -}; - -function createAbortError(message: string): Error { - const error = new Error(message); - error.name = "AbortError"; - return error; -} - -export function buildPodmanInvocation(params: { - config: PodmanPluginConfig; - args: readonly string[]; -}): { command: string; args: string[] } { - const args: string[] = []; - if (params.config.connection) { - args.push("--connection", params.config.connection); - } - if (params.config.url) { - args.push("--url", params.config.url); - } - args.push(...params.args); - return { command: params.config.command, args }; -} - -export function execPodmanRaw( - config: PodmanPluginConfig, - args: string[], - opts?: ExecPodmanRawOptions, -): Promise { - return new Promise((resolve, reject) => { - const invocation = buildPodmanInvocation({ config, args }); - const child = spawn(invocation.command, invocation.args, { - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - }); - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - let aborted = false; - let outputStreamError: Error | undefined; - - const signal = opts?.signal; - const handleAbort = () => { - if (aborted) { - return; - } - aborted = true; - child.kill("SIGTERM"); - }; - if (signal) { - if (signal.aborted) { - handleAbort(); - } else { - signal.addEventListener("abort", handleAbort, { once: true }); - } - } - - const handleStreamError = (error: Error) => { - if (outputStreamError) { - return; - } - outputStreamError = error; - child.kill("SIGTERM"); - }; - child.stdout?.on("error", handleStreamError); - child.stdout?.on("data", (chunk) => { - stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - child.stderr?.on("error", handleStreamError); - child.stderr?.on("data", (chunk) => { - stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - - child.on("error", (error) => { - if (signal) { - signal.removeEventListener("abort", handleAbort); - } - if ( - error && - typeof error === "object" && - "code" in error && - (error as NodeJS.ErrnoException).code === "ENOENT" - ) { - reject( - Object.assign( - new Error( - `Sandbox backend "podman" requires Podman, but the "${config.command}" command was not found in PATH. Install Podman or set agents.defaults.sandbox.backend to another backend.`, - ), - { code: "INVALID_CONFIG", cause: error }, - ), - ); - return; - } - reject(error); - }); - - child.on("close", (code) => { - if (signal) { - signal.removeEventListener("abort", handleAbort); - } - const stdout = Buffer.concat(stdoutChunks); - const stderr = Buffer.concat(stderrChunks); - if (aborted || signal?.aborted) { - reject(createAbortError("Aborted")); - return; - } - if (outputStreamError) { - reject(outputStreamError); - return; - } - const exitCode = code ?? 0; - if (exitCode !== 0 && !opts?.allowFailure) { - reject( - Object.assign( - new Error(stderr.toString("utf8").trim() || `podman ${args.join(" ")} failed`), - { - code: exitCode, - stdout, - stderr, - }, - ) satisfies ExecPodmanRawError, - ); - return; - } - resolve({ stdout, stderr, code: exitCode }); - }); - - const stdin = child.stdin; - if (stdin) { - stdin.on("error", handleStreamError); - if (opts?.input !== undefined) { - stdin.end(opts.input); - } else { - stdin.end(); - } - } - }); -} - -export async function execPodman( - config: PodmanPluginConfig, - args: string[], - opts?: ExecPodmanRawOptions, -) { - const result = await execPodmanRaw(config, args, opts); - return { - stdout: result.stdout.toString("utf8"), - stderr: result.stderr.toString("utf8"), - code: result.code, - }; -} - -export async function podmanContainerState(config: PodmanPluginConfig, name: string) { - const result = await execPodman(config, ["inspect", "-f", "{{.State.Running}}", name], { - allowFailure: true, - }); - if (result.code !== 0) { - return { exists: false, running: false }; - } - return { exists: true, running: result.stdout.trim() === "true" }; -} - -export async function readPodmanContainerLabel( - config: PodmanPluginConfig, - containerName: string, - label: string, -): Promise { - const result = await execPodman( - config, - ["inspect", "-f", `{{ index .Config.Labels "${label}" }}`, containerName], - { allowFailure: true }, - ); - if (result.code !== 0) { - return null; - } - const raw = result.stdout.trim(); - return raw && raw !== "" ? raw : null; -} diff --git a/extensions/podman/tsconfig.json b/extensions/podman/tsconfig.json deleted file mode 100644 index b8a85a99ac3d..000000000000 --- a/extensions/podman/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../tsconfig.package-boundary.base.json", - "compilerOptions": { - "rootDir": "." - }, - "include": ["./*.ts", "./src/**/*.ts"], - "exclude": [ - "./**/*.test.ts", - "./dist/**", - "./node_modules/**", - "./src/test-support/**", - "./src/**/*test-helpers.ts", - "./src/**/*test-harness.ts", - "./src/**/*test-support.ts" - ] -} 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 bc4c346d9f3d..f346ddd430c5 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 @@ -222,71 +222,6 @@ describe("registerPolicyDoctorChecks", () => { ); }); - it("collects container posture for the Podman sandbox backend", async () => { - const configPath = join(workspaceDir, "openclaw.jsonc"); - const cfg = { - ...cfgWithPolicy(), - agents: { - defaults: { - sandbox: { - mode: "all", - backend: "Podman", - docker: { - network: "host", - binds: ["/var/run/podman/podman.sock:/podman.sock:rw"], - seccompProfile: "unconfined", - }, - }, - }, - }, - } as OpenClawConfig; - await fs.writeFile(configPath, "{}", "utf-8"); - await fs.writeFile( - join(workspaceDir, "policy.jsonc"), - JSON.stringify({ - sandbox: { - allowBackends: ["podman"], - containers: { - denyHostNetwork: true, - denyContainerRuntimeSocketMounts: true, - denyUnconfinedProfiles: true, - }, - }, - }), - "utf-8", - ); - - const result = await runPolicyChecks(ctx(configPath, cfg)); - const evidence = collectPolicyEvidence(cfg as unknown as Record); - - expect(evidence.sandboxPosture).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: "backend", value: "podman" }), - expect.objectContaining({ kind: "containerNetwork", value: "host" }), - expect.objectContaining({ kind: "containerMount" }), - expect.objectContaining({ - kind: "containerSecurityProfile", - profile: "seccomp", - value: "unconfined", - }), - ]), - ); - expect(result.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ checkId: "policy/sandbox-container-host-network-denied" }), - expect.objectContaining({ checkId: "policy/sandbox-container-runtime-socket-mount" }), - expect.objectContaining({ checkId: "policy/sandbox-container-unconfined-profile" }), - ]), - ); - expect(result.findings).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "policy/sandbox-container-posture-unobservable", - }), - ]), - ); - }); - it("evaluates inherited container mounts for browser containers on non-Docker backends", async () => { const configPath = join(workspaceDir, "openclaw.jsonc"); const cfg = { diff --git a/extensions/policy/src/doctor/sandbox-findings.ts b/extensions/policy/src/doctor/sandbox-findings.ts index c23654326967..37db804f563d 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" && !isObservableContainerSandboxBackend(backend.value)) { + if (typeof backend?.value === "string" && backend.value.toLowerCase() !== "docker") { return true; } } @@ -277,10 +277,7 @@ function sandboxContainerPostureUnobservableFindings( } return sandboxPostureEntries(evidence, "backend") .filter(evidenceFilter) - .filter( - (entry) => - typeof entry.value === "string" && !isObservableContainerSandboxBackend(entry.value), - ) + .filter((entry) => typeof entry.value === "string" && entry.value.toLowerCase() !== "docker") .flatMap((entry) => enabledRules.map((rule) => sandboxPostureFinding(entry, { @@ -294,11 +291,6 @@ function sandboxContainerPostureUnobservableFindings( ); } -function isObservableContainerSandboxBackend(value: string): boolean { - const backend = value.toLowerCase(); - return backend === "docker" || backend === "podman"; -} - function sandboxContainerHostNetworkFindings( sandboxPolicy: Record, policyDocName: string, diff --git a/extensions/policy/src/policy-state-sandbox.ts b/extensions/policy/src/policy-state-sandbox.ts index bdef66954e6c..7717d8734784 100644 --- a/extensions/policy/src/policy-state-sandbox.ts +++ b/extensions/policy/src/policy-state-sandbox.ts @@ -87,16 +87,12 @@ function pushSandboxPostureEvidence( inherited: localBackend === undefined && inheritedBackend !== undefined, }); - if (isObservableContainerSandboxBackend(effectiveBackend)) { + if (effectiveBackend === "docker" || effectiveBackend === "podman") { pushSandboxDockerPosture(entries, effectiveParams); } pushSandboxBrowserPosture(entries, effectiveParams); } -function isObservableContainerSandboxBackend(backend: string): boolean { - return backend === "docker" || backend === "podman"; -} - function pushSandboxDockerPosture( entries: PolicySandboxPostureEvidence[], params: SandboxPostureParams, @@ -245,7 +241,7 @@ function pushSandboxBrowserPosture( sourceSuffix: "browser/binds", surface: "browser", }); - } else if (params.effectiveBackend !== "docker") { + } else if (params.effectiveBackend !== "docker" && params.effectiveBackend !== "podman") { const localDocker = !params.sharedSandboxScope && isRecord(params.sandbox.docker) ? params.sandbox.docker : {}; const inheritedDocker = isRecord(params.inheritedSandbox.docker) diff --git a/extensions/policy/src/policy-state.test.ts b/extensions/policy/src/policy-state.test.ts index 2f157c0f5028..3f3c805a5ce6 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/pnpm-lock.yaml b/pnpm-lock.yaml index 911989426f0f..61be913121cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1555,15 +1555,6 @@ importers: specifier: workspace:* version: link:../.. - extensions/podman: - devDependencies: - '@openclaw/plugin-sdk': - specifier: workspace:* - version: link:../../packages/plugin-sdk - openclaw: - specifier: workspace:* - version: link:../.. - extensions/policy: dependencies: json5: diff --git a/src/agents/sandbox.resolveSandboxContext.test.ts b/src/agents/sandbox.resolveSandboxContext.test.ts index d2fa3c98a539..51b1bc412e4a 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,114 @@ describe("resolveSandboxContext", () => { } }, 15_000); + it("keeps Docker isolated from Podman when the Docker backend is configured", async () => { + containerEngineMocks.resolvePodmanSandboxRuntimeInfo.mockClear(); + const backendFactory = vi.fn(async () => ({ + id: "docker", + runtimeId: "docker-runtime", + runtimeLabel: "Docker Runtime", + workdir: "/workspace", + buildExecSpec: async () => ({ + argv: ["docker", "exec"], + env: process.env, + stdinMode: "pipe-closed" as const, + }), + runShellCommand: async () => ({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }), + })); + const restore = registerSandboxBackend("docker", backendFactory); + try { + const cfg: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "docker", + scope: "session", + workspaceAccess: "rw", + prune: { idleHours: 0, maxAgeDays: 0 }, + }, + }, + }, + }; + + const result = await resolveSandboxContext({ + config: cfg, + sessionKey: "agent:worker:docker", + workspaceDir: "/tmp/openclaw-test", + }); + + expect(result?.backendId).toBe("docker"); + expect(containerEngineMocks.resolvePodmanSandboxRuntimeInfo).not.toHaveBeenCalled(); + expect(backendFactory).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: expect.objectContaining({ backend: "docker" }), + }), + ); + } finally { + restore(); + } + }, 15_000); + + it("uses Podman directly when the Podman backend is configured", async () => { + containerEngineMocks.resolvePodmanSandboxRuntimeInfo.mockResolvedValueOnce({ + rootless: true, + remote: false, + machine: false, + }); + const backendFactory = vi.fn(async () => ({ + id: "podman", + runtimeId: "podman-runtime", + runtimeLabel: "Podman Runtime", + workdir: "/workspace", + buildExecSpec: async () => ({ + argv: ["podman", "exec"], + env: process.env, + stdinMode: "pipe-closed" as const, + }), + runShellCommand: async () => ({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }), + })); + const restore = registerSandboxBackend("podman", backendFactory); + try { + const cfg: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "podman", + scope: "session", + workspaceAccess: "rw", + prune: { idleHours: 0, maxAgeDays: 0 }, + }, + }, + }, + }; + + const result = await resolveSandboxContext({ + config: cfg, + sessionKey: "agent:worker:podman", + workspaceDir: "/tmp/openclaw-test", + }); + + expect(result?.backendId).toBe("podman"); + expect(containerEngineMocks.resolvePodmanSandboxRuntimeInfo).toHaveBeenCalledOnce(); + expect(backendFactory).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: expect.objectContaining({ backend: "podman" }), + }), + ); + } finally { + restore(); + } + }, 15_000); + it("passes the resolved browser SSRF policy to sandbox browser setup", async () => { ensureSandboxBrowserMock.mockClear(); const restore = registerSandboxBackend("test-browser-backend", async () => ({ diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index a9ff8b20a073..9e50e89f0181 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -19,21 +19,7 @@ export { requireSandboxBackendFactory, } from "./sandbox/backend.js"; -export { - buildSandboxCreateArgs, - isDockerDaemonUnavailable, - resolveDockerEnvPolicyEpoch, -} from "./sandbox/docker.js"; -export { computeSandboxConfigHash } from "./sandbox/config-hash.js"; -export { - appendReadOnlyWorkspaceSkillMountArgs, - appendWorkspaceMountArgs, - formatReadOnlyWorkspaceSkillMountHashState, - resolveReadOnlyWorkspaceSkillMounts, - SANDBOX_MOUNT_FORMAT_VERSION, - type ReadOnlyWorkspaceSkillMount, -} from "./sandbox/workspace-mounts.js"; -export { resolveSandboxScopeKey, slugifySessionKey } from "./sandbox/shared.js"; +export { isDockerDaemonUnavailable } from "./sandbox/docker.js"; export { listSandboxBrowsers, listSandboxContainers, @@ -60,8 +46,6 @@ export { uploadDirectoryToSshTarget, } from "./sandbox/ssh.js"; export { sanitizeEnvVars } from "./sandbox/sanitize-env-vars.js"; -export { buildDockerExecArgs } from "./bash-tools.shared.js"; -export { readRegistryEntry as readSandboxRegistryEntry } from "./sandbox/registry.js"; export { createRemoteShellSandboxFsBridge } from "./sandbox/remote-fs-bridge.js"; export { createWritableRenameTargetResolver } from "./sandbox/fs-bridge-rename-targets.js"; export { resolveWritableRenameTargets } from "./sandbox/fs-bridge-rename-targets.js"; 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..d70bc877d552 --- /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.command} ${args.join(" ")} failed`), + { code: exitCode, stdout, stderr }, + ); + throw error; + } + return { stdout, stderr, code: exitCode }; +} + +export async function execContainer( + engine: SandboxContainerEngine, + args: string[], + opts?: ExecContainerRawOptions, +) { + const result = await execContainerRaw(engine, args, opts); + return { + stdout: result.stdout.toString("utf8"), + stderr: result.stderr.toString("utf8"), + code: result.code, + }; +} diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index d43c793f60f8..525a52092cf5 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -20,6 +20,11 @@ import { getSandboxBackendWorkdirResolver, requireSandboxBackendFactory } from " import { ensureSandboxBrowser } from "./browser.js"; import { resolveSandboxConfigForAgent } from "./config.js"; import { resolveSandboxDockerUser } from "./docker-user.js"; +import { + DOCKER_SANDBOX_ENGINE, + PODMAN_SANDBOX_ENGINE, + resolvePodmanSandboxRuntimeInfo, +} from "./docker.js"; import { createSandboxFsBridge } from "./fs-bridge.js"; import { toSandboxProvisioningError } from "./provisioning-error.js"; import { readRegisteredSandboxRuntimeIds, updateRegistry } from "./registry.js"; @@ -224,8 +229,18 @@ async function resolveProvisionedSandboxContext( workspaceDir: params.workspaceDir, }); + const configuredBackend = cfg.backend.trim().toLowerCase(); + const containerEngine = + configuredBackend === "docker" + ? DOCKER_SANDBOX_ENGINE + : configuredBackend === "podman" + ? PODMAN_SANDBOX_ENGINE + : null; + const podmanRuntimeInfo = + containerEngine?.id === "podman" ? await resolvePodmanSandboxRuntimeInfo() : undefined; const docker = await resolveSandboxDockerUser({ backend: cfg.backend, + ...(podmanRuntimeInfo ? { podmanRootless: podmanRuntimeInfo.rootless } : {}), docker: cfg.docker, workspaceDir, }); @@ -283,9 +298,7 @@ async function resolveProvisionedSandboxContext( })() : undefined; if (resolvedCfg.browser.enabled && backend.capabilities?.browser !== true) { - throw new Error( - `Sandbox backend "${resolvedCfg.backend}" does not support browser sandboxes yet.`, - ); + throw new Error(`Sandbox backend "${backend.id}" does not support browser sandboxes yet.`); } const browser = resolvedCfg.browser.enabled && backend.capabilities?.browser === true diff --git a/src/agents/sandbox/context.user-fallback.test.ts b/src/agents/sandbox/context.user-fallback.test.ts index 03d294637c1c..43a218985f71 100644 --- a/src/agents/sandbox/context.user-fallback.test.ts +++ b/src/agents/sandbox/context.user-fallback.test.ts @@ -55,6 +55,17 @@ describe("resolveSandboxDockerUser", () => { expect(resolved.user).toBeUndefined(); }); + it("applies workspace ownership fallback for rootful Podman", async () => { + const resolved = await resolveSandboxDockerUser({ + backend: "podman", + podmanRootless: false, + docker: baseDocker, + workspaceDir: "/tmp/workspace", + stat: async () => ({ uid: 1001, gid: 1002 }), + }); + expect(resolved.user).toBe("1001:1002"); + }); + it("leaves docker.user unset when workspace stat fails", async () => { const resolved = await resolveSandboxDockerUser({ backend: "docker", diff --git a/src/agents/sandbox/docker-backend.test.ts b/src/agents/sandbox/docker-backend.test.ts index 35f7e3c067f0..73d68afc8f45 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,97 @@ describe("docker sandbox backend manager", () => { }), ).resolves.toBeUndefined(); }); + + it("uses Podman for Podman registry entries", async () => { + dockerMocks.execContainer.mockResolvedValueOnce({ + code: 0, + stdout: "", + stderr: "", + }); + + await podmanSandboxBackendManager.removeRuntime({ + entry: { + containerName: "sandbox-podman", + backendId: "podman", + backendTarget: { key: "local", globalArgs: [] }, + runtimeLabel: "sandbox-podman", + sessionKey: "agent:coder:main", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "openclaw-sandbox:bookworm-slim", + }, + config: createConfig(), + }); + + expect(dockerMocks.execContainer).toHaveBeenCalledWith( + expect.objectContaining({ id: "podman", command: "podman" }), + ["rm", "-f", "sandbox-podman"], + { allowFailure: true }, + ); + expect(dockerMocks.validateSandboxContainerEngineTarget).toHaveBeenCalledWith( + expect.objectContaining({ id: "podman", command: "podman" }), + { key: "local", globalArgs: [] }, + ); + }); + + it("rejects browser sandboxing on the explicit Podman backend", async () => { + const config = createConfig(); + config.agents!.defaults!.sandbox!.backend = "podman"; + await expect( + createPodmanSandboxBackend({ + sessionKey: "agent:coder:main", + scopeKey: "agent:coder:main", + workspaceDir: "/workspace", + agentWorkspaceDir: "/workspace", + skillsWorkspaceDir: "/workspace/.openclaw/sandbox-skills", + cfg: resolveSandboxConfigForAgent(config), + }), + ).rejects.toThrow( + "Podman sandboxing does not support browser sandboxes. Install Docker and select the docker backend, or disable sandbox.browser.enabled.", + ); + + expect(dockerMocks.ensureSandboxContainer).not.toHaveBeenCalled(); + }); + + it("matches canonical Podman image identity when Podman expands a short name", async () => { + dockerMocks.execContainer + .mockResolvedValueOnce({ + code: 0, + stdout: "localhost/openclaw-sandbox:bookworm-slim\tsha256:abc123\n", + stderr: "", + }) + .mockResolvedValueOnce({ + code: 0, + stdout: "abc123\n", + stderr: "", + }); + + const result = await podmanSandboxBackendManager.describeRuntime({ + entry: { + containerName: "sandbox-podman", + backendId: "podman", + backendTarget: { key: "local", globalArgs: [] }, + runtimeLabel: "sandbox-podman", + sessionKey: "agent:coder:main", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "openclaw-sandbox:bookworm-slim", + configLabelKind: "Image", + }, + config: createConfig(), + agentId: "coder", + }); + + expect(result).toEqual({ + running: true, + actualConfigLabel: "localhost/openclaw-sandbox:bookworm-slim", + configLabelMatch: true, + }); + expect(dockerMocks.execContainer).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ id: "podman", command: "podman" }), + ["image", "inspect", "-f", "{{.Id}}", "openclaw-sandbox:bookworm-slim"], + { allowFailure: true }, + ); + }); }); 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 3ec2bf20cb6e..bc988546ed1b 100644 --- a/src/agents/sandbox/docker-user.ts +++ b/src/agents/sandbox/docker-user.ts @@ -3,6 +3,7 @@ import type { SandboxDockerConfig } from "./types.js"; export async function resolveSandboxDockerUser(params: { backend: string; + podmanRootless?: boolean; docker: SandboxDockerConfig; workspaceDir: string; stat?: (workspaceDir: string) => Promise<{ uid: number; gid: number }>; @@ -11,7 +12,8 @@ export async function resolveSandboxDockerUser(params: { if (configuredUser) { return params.docker; } - if (params.backend.trim().toLowerCase() !== "docker") { + const backend = params.backend.trim().toLowerCase(); + if (backend !== "docker" && !(backend === "podman" && params.podmanRootless === false)) { return params.docker; } const stat = params.stat ?? ((workspaceDir: string) => fs.stat(workspaceDir)); diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index b68836b79118..a44e152172ee 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -16,6 +16,7 @@ import { SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js"; type SpawnCall = { command: string; args: string[]; + globalArgs: string[]; }; const spawnState = vi.hoisted(() => ({ @@ -23,6 +24,8 @@ const spawnState = vi.hoisted(() => ({ containerExists: true, inspectRunning: true, labelHash: "", + podmanInfo: "true\tfalse\n", + podmanConnections: "[]\n", })); const registryMocks = vi.hoisted(() => ({ @@ -52,15 +55,23 @@ vi.mock("../../runtime.js", () => ({ })); async function spawnDockerProcess(commandAndArgs: string[]) { - const [command = "", ...args] = commandAndArgs; + const [command = "", ...rawArgs] = commandAndArgs; + const globalArgs: string[] = []; + let args = rawArgs; + if (command === "podman") { + while (args[0] === "--url" || args[0] === "--identity") { + globalArgs.push(...args.slice(0, 2)); + args = args.slice(2); + } + } // The tests assert docker CLI arguments without requiring Docker; this mock // implements only the inspect/create/start/rm calls used by ensureSandboxContainer. - spawnState.calls.push({ command, args }); + spawnState.calls.push({ command, args, globalArgs }); let code = 0; let stdout = ""; let stderr = ""; - if (command !== "docker") { + if (command !== "docker" && command !== "podman") { code = 1; stderr = `unexpected command: ${command}`; } else if (args[0] === "inspect" && args[1] === "-f" && args[2] === "{{.State.Running}}") { @@ -81,6 +92,10 @@ async function spawnDockerProcess(commandAndArgs: string[]) { } else { stdout = `${spawnState.labelHash}\n`; } + } else if (command === "podman" && args[0] === "info") { + stdout = spawnState.podmanInfo; + } else if (command === "podman" && args[0] === "system") { + stdout = spawnState.podmanConnections; } else if (args[0] === "rm" && args[1] === "-f") { spawnState.containerExists = false; spawnState.inspectRunning = false; @@ -122,6 +137,7 @@ vi.mock("../../process/exec.js", async (importOriginal) => ({ let ensureSandboxContainer: typeof import("./docker.js").ensureSandboxContainer; let resolveDockerEnvPolicyEpoch: typeof import("./docker.js").resolveDockerEnvPolicyEpoch; +let PODMAN_SANDBOX_ENGINE: typeof import("./docker.js").PODMAN_SANDBOX_ENGINE; async function loadFreshDockerModuleForTest() { vi.resetModules(); @@ -133,7 +149,8 @@ async function loadFreshDockerModuleForTest() { ...(await importOriginal()), spawnCommand: spawnDockerProcess, })); - ({ ensureSandboxContainer, resolveDockerEnvPolicyEpoch } = await import("./docker.js")); + ({ ensureSandboxContainer, resolveDockerEnvPolicyEpoch, PODMAN_SANDBOX_ENGINE } = + await import("./docker.js")); } function createSandboxConfig( @@ -148,6 +165,7 @@ function createSandboxConfig( scope: "shared", workspaceAccess, workspaceRoot: "~/.openclaw/sandboxes", + dockerTmpfsSource: "default", docker: { image: "openclaw-sandbox:test", containerPrefix: "oc-test-", @@ -191,6 +209,7 @@ async function ensureSandboxCreateCallForTest(params: { cfg: SandboxConfig; workspaceDir?: string; scopeKey?: string; + engine?: import("./docker.js").SandboxContainerEngine; }): Promise { const workspaceDir = params.workspaceDir ?? "/tmp/workspace"; await ensureSandboxContainer({ @@ -198,13 +217,14 @@ async function ensureSandboxCreateCallForTest(params: { workspaceDir, agentWorkspaceDir: workspaceDir, cfg: params.cfg, + ...(params.engine ? { engine: params.engine } : {}), }); const createCall = spawnState.calls.find( - (call) => call.command === "docker" && call.args[0] === "create", + (call) => call.command === (params.engine?.command ?? "docker") && call.args[0] === "create", ); if (!createCall) { - throw new Error("expected docker create call"); + throw new Error(`expected ${params.engine?.command ?? "docker"} create call`); } return createCall; } @@ -221,6 +241,8 @@ describe("ensureSandboxContainer config-hash recreation", () => { spawnState.containerExists = true; spawnState.inspectRunning = true; spawnState.labelHash = ""; + spawnState.podmanInfo = "true\tfalse\n"; + spawnState.podmanConnections = "[]\n"; registryMocks.readRegistryEntry.mockClear(); registryMocks.updateRegistry.mockClear(); registryMocks.updateRegistry.mockResolvedValue(undefined); @@ -600,4 +622,275 @@ describe("ensureSandboxContainer config-hash recreation", () => { `openclaw.mountFormatVersion=${SANDBOX_MOUNT_FORMAT_VERSION}`, ); }); + + it("uses the shared lifecycle with Podman keep-id ownership", async () => { + const workspaceDir = "/tmp/workspace"; + const cfg = createSandboxConfig([]); + cfg.docker.user = undefined; + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); + + const createCall = await ensureSandboxCreateCallForTest({ + cfg, + workspaceDir, + engine: PODMAN_SANDBOX_ENGINE, + }); + + expect(createCall.command).toBe("podman"); + expect(collectDockerFlagValues(createCall.args, "--userns")).toEqual(["keep-id"]); + expect(collectDockerFlagValues(createCall.args, "--user")).toEqual([]); + expect(createCall.args).toContain("--http-proxy=false"); + expect(createCall.args).toContain("--init"); + expect(createCall.args).toContain("--read-only-tmpfs=true"); + expect(collectDockerFlagValues(createCall.args, "--tmpfs")).toEqual(["/tmp", "/var/tmp"]); + expect(collectDockerFlagValues(createCall.args, "-v")).toContain( + `${workspaceDir}:/workspace:z`, + ); + expect(registryMocks.updateRegistry.mock.calls.at(-1)?.[0]?.backendId).toBe("podman"); + expect(registryMocks.updateRegistry.mock.calls.at(-1)?.[0]?.backendTarget).toEqual({ + key: "local", + globalArgs: [], + }); + }); + + it("rejects a Podman runtime recorded for a different engine target", async () => { + const cfg = createSandboxConfig([]); + registryMocks.readRegistryEntry.mockResolvedValue({ + containerName: "oc-test-podman-shared", + backendId: "podman", + backendTarget: { + key: `machine:${"a".repeat(32)}`, + globalArgs: ["--url", "ssh://core@127.0.0.1:60001/run/user/501/podman/podman.sock"], + }, + sessionKey: "shared", + createdAtMs: 1, + lastUsedAtMs: 1, + image: cfg.docker.image, + }); + + await expect( + ensureSandboxContainer({ + engine: PODMAN_SANDBOX_ENGINE, + scopeKey: "shared", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg, + }), + ).rejects.toThrow(/active Podman connection changed/u); + expect(spawnState.calls.some((call) => call.args[0] === "inspect")).toBe(false); + }); + + it("uses collision-safe Docker name truncation for a long container prefix", async () => { + const cfg = createSandboxConfig([]); + cfg.scope = "session"; + cfg.docker.containerPrefix = "x".repeat(56); + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); + + const createCall = await ensureSandboxCreateCallForTest({ + cfg, + scopeKey: "agent:first:session", + }); + const containerName = collectDockerFlagValues(createCall.args, "--name")[0]; + + expect(containerName).toHaveLength(63); + expect(containerName).toMatch(/^x{50}-[a-f0-9]{12}$/); + }); + + it("preserves distinct session suffixes with a long Podman container prefix", async () => { + const cfg = createSandboxConfig([]); + cfg.scope = "session"; + cfg.docker.containerPrefix = "x".repeat(56); + cfg.docker.user = undefined; + spawnState.containerExists = false; + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); + + const firstCreate = await ensureSandboxCreateCallForTest({ + cfg, + scopeKey: "agent:first:session", + engine: PODMAN_SANDBOX_ENGINE, + }); + const firstName = collectDockerFlagValues(firstCreate.args, "--name")[0]; + + spawnState.calls.length = 0; + spawnState.containerExists = false; + const secondCreate = await ensureSandboxCreateCallForTest({ + cfg, + scopeKey: "agent:second:session", + engine: PODMAN_SANDBOX_ENGINE, + }); + const secondName = collectDockerFlagValues(secondCreate.args, "--name")[0]; + + expect(firstName).not.toBe(secondName); + expect(firstName?.length).toBeLessThanOrEqual(63); + expect(secondName?.length).toBeLessThanOrEqual(63); + }); + + it("uses Podman init when mounts leave podman-init visible", async () => { + const cfg = createSandboxConfig([]); + cfg.docker.tmpfs = ["/tmp", "/var/tmp"]; + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); + + const createCall = await ensureSandboxCreateCallForTest({ + cfg, + engine: PODMAN_SANDBOX_ENGINE, + }); + + expect(createCall.args).toContain("--init"); + }); + + it("rejects a workdir whose managed workspace bind would cover Podman init", async () => { + const cfg = createSandboxConfig([]); + cfg.docker.workdir = "/run"; + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); + + await expect( + ensureSandboxCreateCallForTest({ cfg, engine: PODMAN_SANDBOX_ENGINE }), + ).rejects.toThrow("would cover Podman's init path"); + }); + + it("omits the default /run tmpfs for writable-root Podman sandboxes", async () => { + const cfg = createSandboxConfig([]); + cfg.docker.readOnlyRoot = false; + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); + + const createCall = await ensureSandboxCreateCallForTest({ + cfg, + engine: PODMAN_SANDBOX_ENGINE, + }); + + expect(createCall.args).toContain("--init"); + expect(createCall.args).not.toContain("--read-only-tmpfs=true"); + expect(collectDockerFlagValues(createCall.args, "--tmpfs")).toEqual(["/tmp", "/var/tmp"]); + }); + + it("rejects an explicitly configured bare /run tmpfs", async () => { + const cfg = createSandboxConfig([]); + cfg.dockerTmpfsSource = "configured"; + cfg.docker.readOnlyRoot = false; + cfg.docker.tmpfs = ["/run"]; + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); + + await expect( + ensureSandboxCreateCallForTest({ cfg, engine: PODMAN_SANDBOX_ENGINE }), + ).rejects.toThrow("would cover Podman's init path"); + }); + + it("invalidates a Podman container when the same tmpfs list becomes explicit", async () => { + const workspaceDir = makeTempDir(); + const cfg = createSandboxConfig([], [`${workspaceDir}:/workspace:rw`]); + const genericHash = computeSandboxConfigHash({ + docker: cfg.docker, + dockerEnvPolicyEpoch: resolveDockerEnvPolicyEpoch(cfg.docker.env), + workspaceAccess: cfg.workspaceAccess, + workspaceDir, + agentWorkspaceDir: workspaceDir, + mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION, + createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH, + readOnlyWorkspaceSkillMounts: [], + }); + const oldHash = `${genericHash}:podman-runtime-v5:keep-id:default`; + cfg.dockerTmpfsSource = "configured"; + spawnState.inspectRunning = false; + spawnState.labelHash = oldHash; + registryMocks.readRegistryEntry.mockResolvedValue({ + containerName: "oc-test-podman-shared", + backendId: "podman", + backendTarget: { key: "local", globalArgs: [] }, + sessionKey: "shared", + createdAtMs: 1, + lastUsedAtMs: 0, + image: cfg.docker.image, + configHash: oldHash, + }); + + await expect( + ensureSandboxContainer({ + engine: PODMAN_SANDBOX_ENGINE, + scopeKey: "agent:main:session-1", + workspaceDir, + agentWorkspaceDir: workspaceDir, + cfg, + }), + ).rejects.toThrow("would cover Podman's init path"); + + expect( + spawnState.calls.some( + (call) => call.command === "podman" && call.args[0] === "rm" && call.args[1] === "-f", + ), + ).toBe(true); + }); + + it("rejects customized /run tmpfs options instead of discarding them", async () => { + const cfg = createSandboxConfig([]); + cfg.dockerTmpfsSource = "configured"; + cfg.docker.tmpfs = ["/run:size=64m,mode=0700"]; + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); + + await expect( + ensureSandboxCreateCallForTest({ cfg, engine: PODMAN_SANDBOX_ENGINE }), + ).rejects.toThrow("would cover Podman's init path"); + }); + + it("allows Podman Machine workspaces under the default home share", async () => { + const cfg = createSandboxConfig([]); + const workspaceDir = path.join(os.homedir(), "openclaw-podman-workspace"); + cfg.docker.binds = [`${workspaceDir}:/workspace:rw`]; + spawnState.podmanInfo = "true\ttrue\n"; + spawnState.podmanConnections = JSON.stringify([ + { + Name: "podman-machine-default", + URI: "ssh://core@127.0.0.1/run/user/501/podman/podman.sock", + IsMachine: true, + Default: true, + }, + ]); + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); + + const createCall = await ensureSandboxCreateCallForTest({ + cfg, + workspaceDir, + engine: PODMAN_SANDBOX_ENGINE, + }); + + expect(createCall.command).toBe("podman"); + expect(createCall.globalArgs).toEqual([ + "--url", + "ssh://core@127.0.0.1/run/user/501/podman/podman.sock", + ]); + }); + + it("rejects Podman Machine bind sources outside the default home share", async () => { + const cfg = createSandboxConfig([]); + spawnState.podmanInfo = "true\ttrue\n"; + spawnState.podmanConnections = JSON.stringify([ + { + Name: "podman-machine-default", + URI: "ssh://core@127.0.0.1/run/user/501/podman/podman.sock", + IsMachine: true, + Default: true, + }, + ]); + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); + + await expect( + ensureSandboxContainer({ + engine: PODMAN_SANDBOX_ENGINE, + scopeKey: "agent:test:session", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg, + }), + ).rejects.toThrow(/outside the default host home share/u); + + expect(spawnState.calls.some((call) => call.args[0] === "create")).toBe(false); + }); }); diff --git a/src/agents/sandbox/docker.test.ts b/src/agents/sandbox/docker.test.ts index a2f7c2801e12..6565960ff5ec 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,6 +17,9 @@ const spawnState = vi.hoisted(() => ({ calls: [] as SpawnCall[], imageExists: true, inspectError: "", + infoAvailable: { docker: false, podman: false }, + podmanConnections: "[]\n", + podmanInfo: "true\tfalse\n", lastOptions: undefined as SpawnCallOptions | undefined, executionError: undefined as Error | undefined, transportFailure: false, @@ -41,10 +45,19 @@ async function spawnDockerProcess(commandAndArgs: string[], options?: SpawnCallO } let code = 0; + let stdout = ""; let stderr = ""; - if (command !== "docker") { + if (command !== "docker" && command !== "podman") { code = 1; stderr = `unexpected command: ${command}`; + } else if (command === "podman" && args[0] === "system") { + stdout = spawnState.podmanConnections; + } else if (args[0] === "info") { + code = spawnState.infoAvailable[command as "docker" | "podman"] ? 0 : 1; + if (code === 0 && command === "podman" && args.includes("--format")) { + stdout = spawnState.podmanInfo; + } + stderr = code === 0 ? "" : `${command} unavailable`; } else if (args[0] === "image" && args[1] === "inspect") { code = spawnState.imageExists ? 0 : 1; stderr = spawnState.imageExists @@ -58,7 +71,7 @@ async function spawnDockerProcess(commandAndArgs: string[], options?: SpawnCallO failed: code !== 0, isCanceled: false, exitCode: code, - stdout: Buffer.alloc(0), + stdout: Buffer.from(stdout), stderr: Buffer.from(stderr), }; } @@ -69,7 +82,11 @@ vi.mock("../../process/exec.js", async (importOriginal) => ({ })); let ensureDockerImage: typeof import("./docker.js").ensureDockerImage; +let ensureContainerImage: typeof import("./docker.js").ensureContainerImage; let execDockerRaw: typeof import("./docker.js").execDockerRaw; +let podmanSandboxEngine: typeof import("./docker.js").PODMAN_SANDBOX_ENGINE; +let resolvePodmanSandboxRuntimeInfo: typeof import("./docker.js").resolvePodmanSandboxRuntimeInfo; +let validateSandboxContainerEngineTarget: typeof import("./docker.js").validateSandboxContainerEngineTarget; async function loadFreshDockerModuleForTest() { vi.resetModules(); @@ -77,9 +94,196 @@ async function loadFreshDockerModuleForTest() { ...(await importOriginal()), spawnCommand: spawnDockerProcess, })); - ({ ensureDockerImage, execDockerRaw } = await import("./docker.js")); + const dockerModule = await import("./docker.js"); + ({ ensureContainerImage, ensureDockerImage, execDockerRaw } = dockerModule); + resolvePodmanSandboxRuntimeInfo = dockerModule.resolvePodmanSandboxRuntimeInfo; + validateSandboxContainerEngineTarget = dockerModule.validateSandboxContainerEngineTarget; + podmanSandboxEngine = dockerModule.PODMAN_SANDBOX_ENGINE; } +describe("resolvePodmanSandboxRuntimeInfo", () => { + beforeEach(async () => { + spawnState.calls.length = 0; + spawnState.infoAvailable.podman = true; + spawnState.podmanConnections = "[]\n"; + spawnState.podmanInfo = "true\tfalse\n"; + await loadFreshDockerModuleForTest(); + }); + + it("rejects an arbitrary remote Podman connection", async () => { + spawnState.podmanInfo = "true\ttrue\n"; + spawnState.podmanConnections = JSON.stringify([ + { + Name: "remote", + URI: "ssh://example.test/run/user/1000/podman/podman.sock", + Default: true, + }, + ]); + + await expect(resolvePodmanSandboxRuntimeInfo()).rejects.toThrow( + /active Podman connection is remote/u, + ); + }); + + it("allows Podman Machine connections", async () => { + spawnState.podmanInfo = "true\ttrue\n"; + spawnState.podmanConnections = JSON.stringify([ + { + Name: "podman-machine-default", + URI: "ssh://core@127.0.0.1/run/user/501/podman/podman.sock", + IsMachine: true, + Default: true, + }, + ]); + + await expect(resolvePodmanSandboxRuntimeInfo()).resolves.toEqual({ + machine: true, + rootless: true, + target: { + key: expect.stringMatching(/^machine:[a-f0-9]{32}$/u), + globalArgs: ["--url", "ssh://core@127.0.0.1/run/user/501/podman/podman.sock"], + }, + }); + }); + + it("rejects an unknown configured remote connection", async () => { + spawnState.podmanInfo = "true\ttrue\n"; + spawnState.podmanConnections = JSON.stringify([ + { + Name: "podman-machine-default", + URI: "ssh://core@127.0.0.1/run/user/501/podman/podman.sock", + IsMachine: true, + Default: true, + }, + ]); + + await withEnvAsync({ CONTAINER_CONNECTION: "missing", CONTAINER_HOST: undefined }, async () => { + await expect(resolvePodmanSandboxRuntimeInfo()).rejects.toThrow(/could not be identified/u); + }); + }); + + it("prefers a configured host URI over a configured connection name", async () => { + spawnState.podmanInfo = "true\ttrue\n"; + spawnState.podmanConnections = JSON.stringify([ + { + Name: "podman-machine-default", + URI: "ssh://core@127.0.0.1/run/user/501/podman/podman.sock", + IsMachine: true, + }, + ]); + + await withEnvAsync( + { + CONTAINER_CONNECTION: "podman-machine-default", + CONTAINER_HOST: "ssh://example.test/run/user/1000/podman/podman.sock", + }, + async () => { + await expect(resolvePodmanSandboxRuntimeInfo()).rejects.toThrow( + /active Podman connection is remote/u, + ); + }, + ); + }); + + it("validates a named remote connection when the configured host URI is empty", async () => { + spawnState.podmanInfo = "true\ttrue\n"; + spawnState.podmanConnections = JSON.stringify([ + { + Name: "remote", + URI: "ssh://example.test/run/user/1000/podman/podman.sock", + }, + ]); + + await withEnvAsync({ CONTAINER_CONNECTION: "remote", CONTAINER_HOST: " " }, async () => { + await expect(resolvePodmanSandboxRuntimeInfo()).rejects.toThrow( + /active Podman connection is remote/u, + ); + }); + }); + + it("uses Podman's local Unix fallback when no connection is configured", async () => { + spawnState.podmanInfo = "true\ttrue\t/run/user/1000/podman/podman.sock\n"; + + await withEnvAsync({ CONTAINER_CONNECTION: undefined, CONTAINER_HOST: undefined }, async () => { + await expect(resolvePodmanSandboxRuntimeInfo()).resolves.toEqual({ + machine: false, + rootless: true, + target: { + key: expect.stringMatching(/^socket:[a-f0-9]{32}$/u), + globalArgs: ["--url", "unix:///run/user/1000/podman/podman.sock"], + }, + }); + }); + }); + + it("revalidates the active Podman connection on every resolution", async () => { + spawnState.podmanInfo = "true\tfalse\n"; + await expect(resolvePodmanSandboxRuntimeInfo()).resolves.toEqual({ + machine: false, + rootless: true, + target: { key: "local", globalArgs: [] }, + }); + + spawnState.podmanInfo = "true\ttrue\n"; + spawnState.podmanConnections = JSON.stringify([ + { + Name: "remote", + URI: "ssh://example.test/run/user/1000/podman/podman.sock", + Default: true, + }, + ]); + + await expect(resolvePodmanSandboxRuntimeInfo()).rejects.toThrow( + /active Podman connection is remote/u, + ); + }); + + it("ignores a saved remote default while the CLI uses its local engine", async () => { + spawnState.podmanConnections = JSON.stringify([ + { + Name: "saved-remote", + URI: "ssh://example.test/run/user/1000/podman/podman.sock", + Default: true, + }, + ]); + + await expect(resolvePodmanSandboxRuntimeInfo()).resolves.toEqual({ + machine: false, + rootless: true, + target: { key: "local", globalArgs: [] }, + }); + expect(spawnState.calls.some((call) => call.args[0] === "system")).toBe(false); + }); + + it("rejects a different allowed Podman Machine after a runtime target is recorded", async () => { + spawnState.podmanInfo = "true\ttrue\n"; + spawnState.podmanConnections = JSON.stringify([ + { + Name: "podman-machine-first", + URI: "ssh://core@127.0.0.1:60001/run/user/501/podman/podman.sock", + Identity: "/tmp/first-machine-key", + IsMachine: true, + Default: true, + }, + ]); + const first = await resolvePodmanSandboxRuntimeInfo(); + + spawnState.podmanConnections = JSON.stringify([ + { + Name: "podman-machine-second", + URI: "ssh://core@127.0.0.1:60002/run/user/501/podman/podman.sock", + Identity: "/tmp/second-machine-key", + IsMachine: true, + Default: true, + }, + ]); + + await expect( + validateSandboxContainerEngineTarget(podmanSandboxEngine, first.target), + ).rejects.toThrow(/active Podman connection changed/u); + }); +}); + describe("ensureDockerImage", () => { beforeEach(async () => { spawnState.calls.length = 0; @@ -116,8 +320,9 @@ describe("ensureDockerImage", () => { } expect(err).toBeInstanceOf(Error); - expect((err as Error).message).toContain("scripts/sandbox-setup.sh"); - expect((err as Error).message).toContain("python3"); + expect((err as Error).message).toBe( + `Sandbox image not found: ${DEFAULT_SANDBOX_IMAGE}. Build it with scripts/sandbox-setup.sh before enabling Docker sandboxing. The default image includes python3 for sandbox write/edit helpers; OpenClaw will not substitute plain debian:bookworm-slim.`, + ); expect(spawnState.calls).toEqual([ { command: "docker", @@ -126,6 +331,21 @@ describe("ensureDockerImage", () => { ]); }); + it("gives Podman users a Podman build command for the missing default image", async () => { + spawnState.imageExists = false; + + await expect(ensureContainerImage(podmanSandboxEngine, DEFAULT_SANDBOX_IMAGE)).rejects.toThrow( + `podman build -t ${DEFAULT_SANDBOX_IMAGE} -f scripts/docker/sandbox/Dockerfile .`, + ); + + expect(spawnState.calls).toEqual([ + { + command: "podman", + args: ["image", "inspect", DEFAULT_SANDBOX_IMAGE], + }, + ]); + }); + it("throws when the Docker daemon is unavailable during image inspection", async () => { spawnState.imageExists = false; spawnState.inspectError = @@ -142,6 +362,23 @@ describe("ensureDockerImage", () => { }, ]); }); + + it("preserves the Docker error for other image inspection failures", async () => { + spawnState.imageExists = false; + spawnState.inspectError = "permission denied"; + + await expect(ensureDockerImage(DEFAULT_SANDBOX_IMAGE)).rejects.toThrow( + "Failed to inspect sandbox image: permission denied", + ); + }); + + it("preserves the Docker error for a missing custom image", async () => { + spawnState.imageExists = false; + + await expect(ensureDockerImage("example/custom:latest")).rejects.toThrow( + "Sandbox image not found: example/custom:latest. Build or pull it first.", + ); + }); }); describe("execDockerRaw", () => { diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index aae4556eb56e..4df971fc84b3 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -3,97 +3,62 @@ * * Wraps Docker spawn, environment sanitization, container inspection, creation, and exec behavior. */ -import { createAbortError } from "../../infra/abort-signal.js"; -import { toErrorObject } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { isPlainCommandExitFailure, spawnCommand } from "../../process/exec.js"; -import { sanitizeEnvVars, sanitizeExplicitSandboxEnvVars } from "./sanitize-env-vars.js"; +import { + DOCKER_SANDBOX_ENGINE, + execContainer, + execContainerRaw, + type ExecContainerRawOptions, + type ExecDockerRawResult, + type SandboxContainerEngine, + type SandboxContainerEngineTarget, +} from "./container-engine.js"; +import { + assertPodmanSandboxTarget, + bindPodmanSandboxEngine, + resolvePodmanSandboxConfigHash, + resolvePodmanSandboxContainerPrefix, + resolvePodmanSandboxCreatePolicy, + resolvePodmanSandboxRuntimeInfo, + type PodmanSandboxRuntimeInfo, +} from "./podman-runtime.js"; +import { + resolveDockerEnvPolicyEpoch, + sanitizeExplicitSandboxEnvVars, +} from "./sanitize-env-vars.js"; -type ExecDockerRawOptions = { - allowFailure?: boolean; - input?: Buffer | string; - signal?: AbortSignal; -}; +export { + DOCKER_SANDBOX_ENGINE, + execContainer, + execContainerRaw, + PODMAN_SANDBOX_ENGINE, +} from "./container-engine.js"; +export type { + ExecDockerRawResult, + SandboxContainerEngine, + SandboxContainerEngineTarget, +} from "./container-engine.js"; +export { + bindPodmanSandboxEngine, + resolvePodmanSandboxRuntimeInfo, + validateSandboxContainerEngineTarget, +} from "./podman-runtime.js"; +export type { PodmanSandboxRuntimeInfo } from "./podman-runtime.js"; +export { resolveDockerEnvPolicyEpoch } from "./sanitize-env-vars.js"; -export type ExecDockerRawResult = { - stdout: Buffer; - stderr: Buffer; - code: number; -}; - -type ExecDockerRawError = Error & { - code: number; - stdout: Buffer; - stderr: Buffer; -}; +type ExecDockerRawOptions = ExecContainerRawOptions; export async function execDockerRaw( args: string[], opts?: ExecDockerRawOptions, ): Promise { - let result; - try { - result = await spawnCommand(["docker", ...args], { - cancelSignal: opts?.signal, - encoding: "buffer", - input: opts?.input ?? Buffer.alloc(0), - maxBuffer: SANDBOX_COMMAND_MAX_BUFFER_BYTES, - reject: false, - stripFinalNewline: false, - }); - } catch (error) { - if (opts?.signal?.aborted) { - throw createAbortError("Aborted"); - } - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - throw Object.assign( - new Error( - 'Sandbox mode requires Docker, but the "docker" command was not found in PATH. Install Docker (and ensure "docker" is available), or set `agents.defaults.sandbox.mode=off` to disable sandboxing.', - ), - { code: "INVALID_CONFIG", cause: error }, - ); - } - throw error; - } - if (opts?.signal?.aborted || result.isCanceled) { - throw createAbortError("Aborted"); - } - if (result.failed && !isPlainCommandExitFailure(result)) { - if (result.code === "ENOENT") { - throw Object.assign( - new Error( - 'Sandbox mode requires Docker, but the "docker" command was not found in PATH. Install Docker (and ensure "docker" is available), or set `agents.defaults.sandbox.mode=off` to disable sandboxing.', - ), - { code: "INVALID_CONFIG", cause: result }, - ); - } - throw toErrorObject(result, "Docker command execution failed"); - } - const stdout = Buffer.from(result.stdout); - const stderr = Buffer.from(result.stderr); - const exitCode = result.exitCode ?? (result.failed ? 1 : 0); - if (exitCode !== 0 && !opts?.allowFailure) { - const message = stderr.length > 0 ? stderr.toString("utf8").trim() : ""; - const error: ExecDockerRawError = Object.assign( - new Error(message || `docker ${args.join(" ")} failed`), - { code: exitCode, stdout, stderr }, - ); - throw error; - } - return { stdout, stderr, code: exitCode }; + return await execContainerRaw(DOCKER_SANDBOX_ENGINE, args, opts); } import { markOpenClawExecEnv } from "../../infra/openclaw-exec-env.js"; import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; -import { - computeSandboxConfigHash, - SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH, -} from "./config-hash.js"; -import { - DEFAULT_SANDBOX_IMAGE, - SANDBOX_COMMAND_MAX_BUFFER_BYTES, - SANDBOX_DOCKER_CREATE_ARGS_EPOCH, -} from "./constants.js"; +import { computeSandboxConfigHash } from "./config-hash.js"; +import { DEFAULT_SANDBOX_IMAGE, SANDBOX_DOCKER_CREATE_ARGS_EPOCH } from "./constants.js"; import { handleHotSandboxConfigMismatch } from "./current-config.js"; import { readRegistryEntry, updateRegistry } from "./registry.js"; import { buildSandboxContainerName, slugifySessionKey } from "./shared.js"; @@ -115,31 +80,6 @@ const sandboxContainerLifecycleQueue = new KeyedAsyncQueue(); type ExecDockerOptions = ExecDockerRawOptions; -function envRecordsEqual(left: Record, 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) { @@ -338,8 +315,8 @@ export function buildSandboxCreateArgs(params: { const createdAtMs = params.createdAtMs ?? Date.now(); const args = ["create", "--name", params.name]; - // Docker's init owns PID 1 so orphaned children from long-running tool and - // browser workloads are reaped instead of accumulating against pidsLimit. + // The container engine's init owns PID 1 so orphaned children from long-running + // tool and browser workloads are reaped instead of accumulating against pidsLimit. args.push("--init"); args.push("--label", "openclaw.sandbox=1"); args.push("--label", `openclaw.sessionKey=${params.scopeKey}`); @@ -444,8 +421,10 @@ function appendCustomBinds(args: string[], cfg: SandboxDockerConfig): void { } async function createSandboxContainer(params: { + engine: SandboxContainerEngine; name: string; cfg: SandboxDockerConfig; + dockerTmpfsSource: SandboxConfig["dockerTmpfsSource"]; workspaceDir: string; workspaceAccess: SandboxWorkspaceAccess; agentWorkspaceDir: string; @@ -453,18 +432,35 @@ async function createSandboxContainer(params: { scopeKey: string; configHash?: string; readOnlyWorkspaceSkillMounts: readonly ReadOnlyWorkspaceSkillMount[]; + podmanRuntimeInfo?: PodmanSandboxRuntimeInfo; }) { - const { name, cfg, workspaceDir, scopeKey } = params; - await ensureDockerImage(cfg.image); + const { engine, name, cfg, workspaceDir, scopeKey } = params; + const podmanPolicy = + engine.id === "podman" && params.podmanRuntimeInfo + ? resolvePodmanSandboxCreatePolicy({ + cfg, + dockerTmpfsSource: params.dockerTmpfsSource, + workspaceDir, + workspaceAccess: params.workspaceAccess, + agentWorkspaceDir: params.agentWorkspaceDir, + readOnlyWorkspaceSkillMounts: params.readOnlyWorkspaceSkillMounts, + runtimeInfo: params.podmanRuntimeInfo, + }) + : undefined; + const createCfg = podmanPolicy?.cfg ?? cfg; + await ensureContainerImage(engine, cfg.image); const args = buildSandboxCreateArgs({ name, - cfg, + cfg: createCfg, scopeKey, configHash: params.configHash, includeBinds: false, bindSourceRoots: [workspaceDir, params.agentWorkspaceDir], }); + if (podmanPolicy) { + args.push(...podmanPolicy.extraCreateArgs); + } args.push("--workdir", cfg.workdir); appendWorkspaceMountArgs({ args, @@ -483,19 +479,24 @@ async function createSandboxContainer(params: { }); args.push(cfg.image, "sleep", "infinity"); - await execDocker(args); - await execDocker(["start", name]); + await execContainer(engine, args); + await execContainer(engine, ["start", name]); if (cfg.setupCommand?.trim()) { - await execDocker(["exec", "-i", name, "/bin/sh", "-lc", cfg.setupCommand]); + await execContainer(engine, ["exec", "-i", name, "/bin/sh", "-lc", cfg.setupCommand]); } } -async function readContainerConfigHash(containerName: string): Promise { - 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 +506,15 @@ type EnsureSandboxContainerParams = { }; export async function ensureSandboxContainer(params: EnsureSandboxContainerParams) { + const engine = params.engine ?? DOCKER_SANDBOX_ENGINE; const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(params.scopeKey); - const containerName = buildSandboxContainerName(params.cfg.docker.containerPrefix, slug); + const prefix = + engine.id === "podman" + ? resolvePodmanSandboxContainerPrefix(params.cfg.docker.containerPrefix) + : params.cfg.docker.containerPrefix; + const containerName = buildSandboxContainerName(prefix, slug); - // Independent agent runs can converge on one Docker resource. Serialize the + // Independent agent runs can converge on one container resource. Serialize the // full lifecycle so followers re-read state after create, start, or replace. return await sandboxContainerLifecycleQueue.enqueue(containerName, async () => { return await ensureSandboxContainerLifecycle(params, containerName); @@ -519,6 +525,27 @@ async function ensureSandboxContainerLifecycle( params: EnsureSandboxContainerParams, containerName: string, ) { + const configuredEngine = params.engine ?? DOCKER_SANDBOX_ENGINE; + const podmanRuntimeInfo = + configuredEngine.id === "podman" ? await resolvePodmanSandboxRuntimeInfo() : undefined; + if (podmanRuntimeInfo) { + assertPodmanSandboxTarget(params.podmanTarget, podmanRuntimeInfo.target); + } + const engine = podmanRuntimeInfo + ? bindPodmanSandboxEngine(podmanRuntimeInfo.target) + : configuredEngine; + const existingRegistryEntry = await readRegistryEntry(containerName); + if (engine.id === "podman" && existingRegistryEntry) { + if (!existingRegistryEntry.backendTarget) { + throw Object.assign( + new Error( + `Podman sandbox runtime ${containerName} has no recorded engine target. Remove that unshipped runtime manually before recreating it.`, + ), + { code: "INVALID_CONFIG" }, + ); + } + assertPodmanSandboxTarget(existingRegistryEntry.backendTarget, podmanRuntimeInfo!.target); + } const readOnlyWorkspaceSkillMounts = resolveReadOnlyWorkspaceSkillMounts({ workspaceDir: params.workspaceDir, agentWorkspaceDir: params.agentWorkspaceDir, @@ -526,7 +553,7 @@ async function ensureSandboxContainerLifecycle( workdir: params.cfg.docker.workdir, workspaceAccess: params.cfg.workspaceAccess, }); - const expectedHash = computeSandboxConfigHash({ + const genericConfigHash = computeSandboxConfigHash({ docker: params.cfg.docker, dockerEnvPolicyEpoch: resolveDockerEnvPolicyEpoch(params.cfg.docker.env), workspaceAccess: params.cfg.workspaceAccess, @@ -538,21 +565,23 @@ async function ensureSandboxContainerLifecycle( readOnlyWorkspaceSkillMounts, ), }); + const expectedHash = + engine.id === "podman" + ? resolvePodmanSandboxConfigHash({ + genericConfigHash, + configuredUser: Boolean(params.cfg.docker.user), + dockerTmpfsSource: params.cfg.dockerTmpfsSource, + }) + : genericConfigHash; const now = Date.now(); - const state = await dockerContainerState(containerName); + const state = await containerState(engine, containerName); let hasContainer = state.exists; let running = state.running; let currentHash: string | null = null; let hashMismatch = false; - let registryEntry: - | { - lastUsedAtMs: number; - configHash?: string; - } - | undefined; + const registryEntry = existingRegistryEntry ?? undefined; if (hasContainer) { - registryEntry = (await readRegistryEntry(containerName)) ?? undefined; - currentHash = await readContainerConfigHash(containerName); + currentHash = await readContainerConfigHash(engine, containerName); if (!currentHash) { currentHash = registryEntry?.configHash ?? null; } @@ -572,7 +601,7 @@ async function ensureSandboxContainerLifecycle( : {}), }); } else { - await execDocker(["rm", "-f", containerName], { allowFailure: true }); + await execContainer(engine, ["rm", "-f", containerName], { allowFailure: true }); hasContainer = false; running = false; } @@ -580,8 +609,10 @@ async function ensureSandboxContainerLifecycle( } if (!hasContainer) { await createSandboxContainer({ + engine, name: containerName, cfg: params.cfg.docker, + dockerTmpfsSource: params.cfg.dockerTmpfsSource, workspaceDir: params.workspaceDir, workspaceAccess: params.cfg.workspaceAccess, agentWorkspaceDir: params.agentWorkspaceDir, @@ -589,13 +620,15 @@ async function ensureSandboxContainerLifecycle( scopeKey: params.scopeKey, configHash: expectedHash, readOnlyWorkspaceSkillMounts, + podmanRuntimeInfo, }); } else if (!running) { - await execDocker(["start", containerName]); + await execContainer(engine, ["start", containerName]); } await updateRegistry({ containerName, - backendId: "docker", + backendId: engine.id, + ...(podmanRuntimeInfo ? { backendTarget: podmanRuntimeInfo.target } : {}), runtimeLabel: containerName, sessionKey: params.scopeKey, createdAtMs: now, 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..a3b06d5872e9 --- /dev/null +++ b/src/agents/sandbox/podman-runtime.ts @@ -0,0 +1,280 @@ +import os from "node:os"; +import path from "node:path"; +import { isPathInside } from "../../infra/path-guards.js"; +import { splitSandboxBindSpec } from "./bind-spec.js"; +import { + execContainer, + PODMAN_SANDBOX_ENGINE, + type SandboxContainerEngine, + type SandboxContainerEngineTarget, +} from "./container-engine.js"; +import { hashTextSha256 } from "./hash.js"; +import { resolveSandboxHostPathViaExistingAncestor } from "./host-paths.js"; +import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js"; +import type { ReadOnlyWorkspaceSkillMount } from "./workspace-mounts.js"; + +const SANDBOX_ENGINE_PROBE_TIMEOUT_MS = 5_000; +const PODMAN_INIT_PATH = "/run/podman-init"; + +export type PodmanSandboxRuntimeInfo = { + machine: boolean; + rootless: boolean; + target: SandboxContainerEngineTarget; +}; + +function hashPodmanTarget(kind: "machine" | "socket", ...parts: string[]): string { + return `${kind}:${hashTextSha256(parts.join("\0")).slice(0, 32)}`; +} + +function invalidPodmanConfig(message: string): Error { + return Object.assign(new Error(message), { code: "INVALID_CONFIG" }); +} + +async function assertSupportedPodmanConnection(remoteSocketPath: string): Promise<{ + machine: boolean; + target: SandboxContainerEngineTarget; +}> { + const result = await execContainer( + PODMAN_SANDBOX_ENGINE, + ["system", "connection", "list", "--format", "json"], + { + allowFailure: true, + signal: AbortSignal.timeout(SANDBOX_ENGINE_PROBE_TIMEOUT_MS), + }, + ); + if (result.code !== 0) { + const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`; + throw new Error(`Failed to inspect the active Podman connection: ${detail}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(result.stdout); + } catch (error) { + throw new Error("Podman returned invalid connection metadata", { cause: error }); + } + const connections = Array.isArray(parsed) + ? parsed.filter( + (entry): entry is Record => 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://")) { + if (selected?.IsMachine === true) { + const identity = + process.env.CONTAINER_SSHKEY?.trim() || + (typeof selected.Identity === "string" ? selected.Identity : ""); + return { + machine: true, + target: { + key: hashPodmanTarget("machine", selectedUri, identity), + globalArgs: ["--url", selectedUri, ...(identity ? ["--identity", identity] : [])], + }, + }; + } + throw unsupportedRemoteError(); + } + return { + machine: false, + target: { + key: hashPodmanTarget("socket", selectedUri), + globalArgs: ["--url", selectedUri], + }, + }; +} + +export async function resolvePodmanSandboxRuntimeInfo(): Promise { + const result = await execContainer( + PODMAN_SANDBOX_ENGINE, + [ + "info", + "--format", + "{{.Host.Security.Rootless}}\t{{.Host.ServiceIsRemote}}\t{{.Host.RemoteSocket.Path}}", + ], + { + allowFailure: true, + signal: AbortSignal.timeout(SANDBOX_ENGINE_PROBE_TIMEOUT_MS), + }, + ); + if (result.code !== 0) { + const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`; + throw new Error(`Failed to inspect Podman user namespace mode: ${detail}`); + } + const [rootless = "", serviceIsRemote = "", remoteSocketPath = ""] = result.stdout + .trim() + .split("\t", 3); + let machine = false; + let target: SandboxContainerEngineTarget = { key: "local", globalArgs: [] }; + if (serviceIsRemote === "true") { + ({ machine, target } = await assertSupportedPodmanConnection(remoteSocketPath)); + } + return { machine, rootless: rootless === "true", target }; +} + +export async function validateSandboxContainerEngineTarget( + engine: SandboxContainerEngine, + expectedTarget?: SandboxContainerEngineTarget, +): Promise { + 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); + } + + const extraCreateArgs = ["--http-proxy=false"]; + if (params.cfg.readOnlyRoot) { + extraCreateArgs.push("--read-only-tmpfs=true"); + } + if (!params.cfg.user) { + // Resolve against the engine host so native remote contexts and Podman machines use + // their own identity without reserving every subordinate ID. + extraCreateArgs.push("--userns", "keep-id"); + } + return { cfg, extraCreateArgs }; +} + +export function resolvePodmanSandboxConfigHash(params: { + genericConfigHash: string; + configuredUser: boolean; + dockerTmpfsSource: SandboxConfig["dockerTmpfsSource"]; +}): string { + const userMode = params.configuredUser ? "configured-user" : "keep-id"; + return `${params.genericConfigHash}:podman-runtime-v5:${userMode}:${params.dockerTmpfsSource}`; +} + +export function resolvePodmanSandboxContainerPrefix(containerPrefix: string): string { + return `${containerPrefix}podman-`; +} 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.ts b/src/agents/sandbox/workspace-authority.ts index 1fc785244220..66e75956d7e2 100644 --- a/src/agents/sandbox/workspace-authority.ts +++ b/src/agents/sandbox/workspace-authority.ts @@ -158,7 +158,7 @@ export function resolveSandboxWorkspaceAuthority(params: { return { sandboxed: false, workspaceAccess: sandbox.workspaceAccess }; } let confinementError: string | undefined; - if (sandbox.backend !== "docker") { + if (sandbox.backend !== "docker" && sandbox.backend !== "podman") { confinementError = "target sandbox backend does not provide local workspace confinement."; } else if (sandbox.scope !== "session") { confinementError = "target sandbox is not exclusive to this worker session."; 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/plugin-sdk/sandbox.ts b/src/plugin-sdk/sandbox.ts index 1f38da1b22d5..1118ff8f970a 100644 --- a/src/plugin-sdk/sandbox.ts +++ b/src/plugin-sdk/sandbox.ts @@ -30,13 +30,10 @@ export type { OpenClawConfig } from "../config/config.js"; export { buildExecRemoteCommand, - buildDockerExecArgs, buildRemoteWorkdirValidationCommand, buildRemoteCommand, buildSshSandboxArgv, buildValidatedExecRemoteCommand, - buildSandboxCreateArgs, - computeSandboxConfigHash, createRemoteShellSandboxFsBridge, createWritableRenameTargetResolver, createSshSandboxSessionFromConfigText, @@ -48,21 +45,12 @@ export { isToolAllowed, registerSandboxBackend, requireSandboxBackendFactory, - resolveDockerEnvPolicyEpoch, - resolveReadOnlyWorkspaceSkillMounts, - readSandboxRegistryEntry, - resolveSandboxConfigForAgent, resolveSandboxRuntimeStatus, - appendReadOnlyWorkspaceSkillMountArgs, - appendWorkspaceMountArgs, - formatReadOnlyWorkspaceSkillMountHashState, resolveWritableRenameTargets, resolveWritableRenameTargetsForBridge, runSshSandboxCommand, - SANDBOX_MOUNT_FORMAT_VERSION, sanitizeEnvVars, shellEscape, - slugifySessionKey, uploadDirectoryToSshTarget, } from "../agents/sandbox.js"; diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index acaa04450e57..6fc1fdd58f2b 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -55,8 +55,6 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [ "ollama", "opencode", "openshell", - "phone-control", - "podman", "policy", "reef", "talk-voice", From 038142d9705814fa8ad9c78880d8c41bc1ac1d68 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 15:56:45 +0800 Subject: [PATCH 04/17] fix(sandbox): expose Podman posture and mounts --- .../register.sandbox-and-tools.test-utils.ts | 52 +++++++++++++ .../policy/src/doctor/sandbox-findings.ts | 11 ++- src/commands/sandbox-explain.test.ts | 73 ++++++++++--------- src/commands/sandbox-explain.ts | 4 +- 4 files changed, 103 insertions(+), 37 deletions(-) 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/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, From 1828c862622f62709a57e98403d60512757a3128 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:16:45 +0800 Subject: [PATCH 05/17] fix(sandbox): preserve Podman workspace ownership --- .../sandbox.resolveSandboxContext.test.ts | 9 ++++++-- src/agents/sandbox/context.ts | 15 ------------- .../sandbox/context.user-fallback.test.ts | 5 ++--- src/agents/sandbox/docker-user.ts | 3 +-- .../docker.config-hash-recreate.test.ts | 22 ++++++++++++++++--- src/agents/sandbox/podman-runtime.ts | 6 ++--- 6 files changed, 32 insertions(+), 28 deletions(-) diff --git a/src/agents/sandbox.resolveSandboxContext.test.ts b/src/agents/sandbox.resolveSandboxContext.test.ts index 51b1bc412e4a..65ef90a34305 100644 --- a/src/agents/sandbox.resolveSandboxContext.test.ts +++ b/src/agents/sandbox.resolveSandboxContext.test.ts @@ -648,10 +648,15 @@ describe("resolveSandboxContext", () => { }); expect(result?.backendId).toBe("podman"); - expect(containerEngineMocks.resolvePodmanSandboxRuntimeInfo).toHaveBeenCalledOnce(); + const workspaceStat = await fs.stat("/tmp/openclaw-test"); expect(backendFactory).toHaveBeenCalledWith( expect.objectContaining({ - cfg: expect.objectContaining({ backend: "podman" }), + cfg: expect.objectContaining({ + backend: "podman", + docker: expect.objectContaining({ + user: `${workspaceStat.uid}:${workspaceStat.gid}`, + }), + }), }), ); } finally { diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 525a52092cf5..d772cec33aad 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -20,11 +20,6 @@ import { getSandboxBackendWorkdirResolver, requireSandboxBackendFactory } from " import { ensureSandboxBrowser } from "./browser.js"; import { resolveSandboxConfigForAgent } from "./config.js"; import { resolveSandboxDockerUser } from "./docker-user.js"; -import { - DOCKER_SANDBOX_ENGINE, - PODMAN_SANDBOX_ENGINE, - resolvePodmanSandboxRuntimeInfo, -} from "./docker.js"; import { createSandboxFsBridge } from "./fs-bridge.js"; import { toSandboxProvisioningError } from "./provisioning-error.js"; import { readRegisteredSandboxRuntimeIds, updateRegistry } from "./registry.js"; @@ -229,18 +224,8 @@ async function resolveProvisionedSandboxContext( workspaceDir: params.workspaceDir, }); - const configuredBackend = cfg.backend.trim().toLowerCase(); - const containerEngine = - configuredBackend === "docker" - ? DOCKER_SANDBOX_ENGINE - : configuredBackend === "podman" - ? PODMAN_SANDBOX_ENGINE - : null; - const podmanRuntimeInfo = - containerEngine?.id === "podman" ? await resolvePodmanSandboxRuntimeInfo() : undefined; const docker = await resolveSandboxDockerUser({ backend: cfg.backend, - ...(podmanRuntimeInfo ? { podmanRootless: podmanRuntimeInfo.rootless } : {}), docker: cfg.docker, workspaceDir, }); diff --git a/src/agents/sandbox/context.user-fallback.test.ts b/src/agents/sandbox/context.user-fallback.test.ts index 43a218985f71..41d97851ce3e 100644 --- a/src/agents/sandbox/context.user-fallback.test.ts +++ b/src/agents/sandbox/context.user-fallback.test.ts @@ -45,20 +45,19 @@ describe("resolveSandboxDockerUser", () => { expect(resolved.user).toBe("1001:1002"); }); - it("does not apply Docker workspace ownership fallback for Podman", async () => { + 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).toBeUndefined(); + expect(resolved.user).toBe("1001:1002"); }); it("applies workspace ownership fallback for rootful Podman", async () => { const resolved = await resolveSandboxDockerUser({ backend: "podman", - podmanRootless: false, docker: baseDocker, workspaceDir: "/tmp/workspace", stat: async () => ({ uid: 1001, gid: 1002 }), diff --git a/src/agents/sandbox/docker-user.ts b/src/agents/sandbox/docker-user.ts index bc988546ed1b..66bd5e7de09c 100644 --- a/src/agents/sandbox/docker-user.ts +++ b/src/agents/sandbox/docker-user.ts @@ -3,7 +3,6 @@ import type { SandboxDockerConfig } from "./types.js"; export async function resolveSandboxDockerUser(params: { backend: string; - podmanRootless?: boolean; docker: SandboxDockerConfig; workspaceDir: string; stat?: (workspaceDir: string) => Promise<{ uid: number; gid: number }>; @@ -13,7 +12,7 @@ export async function resolveSandboxDockerUser(params: { return params.docker; } const backend = params.backend.trim().toLowerCase(); - if (backend !== "docker" && !(backend === "podman" && params.podmanRootless === false)) { + if (backend !== "docker" && backend !== "podman") { return params.docker; } const stat = params.stat ?? ((workspaceDir: string) => fs.stat(workspaceDir)); diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index a44e152172ee..551ce06de8cc 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -623,10 +623,10 @@ describe("ensureSandboxContainer config-hash recreation", () => { ); }); - it("uses the shared lifecycle with Podman keep-id ownership", async () => { + it("uses the shared lifecycle with rootless Podman workspace ownership", async () => { const workspaceDir = "/tmp/workspace"; const cfg = createSandboxConfig([]); - cfg.docker.user = undefined; + cfg.docker.user = "1001:1002"; spawnState.inspectRunning = false; registryMocks.readRegistryEntry.mockResolvedValue(null); @@ -638,7 +638,7 @@ describe("ensureSandboxContainer config-hash recreation", () => { expect(createCall.command).toBe("podman"); expect(collectDockerFlagValues(createCall.args, "--userns")).toEqual(["keep-id"]); - expect(collectDockerFlagValues(createCall.args, "--user")).toEqual([]); + 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"); @@ -653,6 +653,22 @@ describe("ensureSandboxContainer config-hash recreation", () => { }); }); + it("uses the workspace owner without keep-id for rootful Podman", async () => { + const cfg = createSandboxConfig([]); + cfg.docker.user = "1001:1002"; + spawnState.podmanInfo = "false\tfalse\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("rejects a Podman runtime recorded for a different engine target", async () => { const cfg = createSandboxConfig([]); registryMocks.readRegistryEntry.mockResolvedValue({ diff --git a/src/agents/sandbox/podman-runtime.ts b/src/agents/sandbox/podman-runtime.ts index a3b06d5872e9..31df4af1127b 100644 --- a/src/agents/sandbox/podman-runtime.ts +++ b/src/agents/sandbox/podman-runtime.ts @@ -258,9 +258,9 @@ export function resolvePodmanSandboxCreatePolicy(params: { if (params.cfg.readOnlyRoot) { extraCreateArgs.push("--read-only-tmpfs=true"); } - if (!params.cfg.user) { - // Resolve against the engine host so native remote contexts and Podman machines use - // their own identity without reserving every subordinate ID. + if (params.runtimeInfo.rootless) { + // keep-id maps the effective workspace-owner user back to the invoking + // rootless engine user. Older Podman releases do not override image USER. extraCreateArgs.push("--userns", "keep-id"); } return { cfg, extraCreateArgs }; From 74b4f850a45676421281ee9498fe1c215709b9d8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:26:32 +0800 Subject: [PATCH 06/17] fix(sandbox): harden Podman lifecycle failures --- src/agents/sandbox/container-engine.ts | 2 +- .../docker.config-hash-recreate.test.ts | 51 ++++++++++++++++++- src/agents/sandbox/docker.test.ts | 24 +++++++++ src/agents/sandbox/docker.ts | 18 +++++-- 4 files changed, 90 insertions(+), 5 deletions(-) diff --git a/src/agents/sandbox/container-engine.ts b/src/agents/sandbox/container-engine.ts index d70bc877d552..6e7f4cc9d0e3 100644 --- a/src/agents/sandbox/container-engine.ts +++ b/src/agents/sandbox/container-engine.ts @@ -100,7 +100,7 @@ export async function execContainerRaw( if (exitCode !== 0 && !opts?.allowFailure) { const message = stderr.length > 0 ? stderr.toString("utf8").trim() : ""; const error: ExecDockerRawError = Object.assign( - new Error(message || `${engine.command} ${args.join(" ")} failed`), + new Error(message || `${engine.displayName} command failed (exit ${exitCode})`), { code: exitCode, stdout, stderr }, ); throw error; diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index 551ce06de8cc..8c8ad2e6e474 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -30,6 +30,7 @@ const spawnState = vi.hoisted(() => ({ const registryMocks = vi.hoisted(() => ({ readRegistryEntry: vi.fn(), + removeRegistryEntry: vi.fn(), updateRegistry: vi.fn(), })); @@ -47,6 +48,7 @@ function makeTempDir(): string { vi.mock("./registry.js", () => ({ readRegistryEntry: registryMocks.readRegistryEntry, + removeRegistryEntry: registryMocks.removeRegistryEntry, updateRegistry: registryMocks.updateRegistry, })); @@ -143,6 +145,7 @@ 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) => ({ @@ -244,6 +247,8 @@ describe("ensureSandboxContainer config-hash recreation", () => { spawnState.podmanInfo = "true\tfalse\n"; spawnState.podmanConnections = "[]\n"; registryMocks.readRegistryEntry.mockClear(); + registryMocks.removeRegistryEntry.mockClear(); + registryMocks.removeRegistryEntry.mockResolvedValue(undefined); registryMocks.updateRegistry.mockClear(); registryMocks.updateRegistry.mockResolvedValue(undefined); runtimeMocks.log.mockClear(); @@ -693,7 +698,51 @@ describe("ensureSandboxContainer config-hash recreation", () => { cfg, }), ).rejects.toThrow(/active Podman connection changed/u); - expect(spawnState.calls.some((call) => call.args[0] === "inspect")).toBe(false); + expect(registryMocks.removeRegistryEntry).not.toHaveBeenCalled(); + expect(spawnState.calls).toContainEqual( + expect.objectContaining({ + command: "podman", + globalArgs: ["--url", "ssh://core@127.0.0.1:60001/run/user/501/podman/podman.sock"], + args: ["inspect", "-f", "{{.State.Running}}", "oc-test-podman-shared"], + }), + ); + }); + + it("recovers when a Podman target changed after the recorded runtime disappeared", async () => { + const cfg = createSandboxConfig([]); + spawnState.containerExists = false; + spawnState.inspectRunning = false; + 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("uses collision-safe Docker name truncation for a long container prefix", async () => { diff --git a/src/agents/sandbox/docker.test.ts b/src/agents/sandbox/docker.test.ts index 6565960ff5ec..2ca88bee485b 100644 --- a/src/agents/sandbox/docker.test.ts +++ b/src/agents/sandbox/docker.test.ts @@ -24,6 +24,7 @@ const spawnState = vi.hoisted(() => ({ executionError: undefined as Error | undefined, transportFailure: false, transportExitCode: 0, + plainExitWithoutStderr: false, })); async function spawnDockerProcess(commandAndArgs: string[], options?: SpawnCallOptions) { @@ -43,6 +44,15 @@ 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 = ""; @@ -293,6 +303,7 @@ describe("ensureDockerImage", () => { spawnState.executionError = undefined; spawnState.transportFailure = false; spawnState.transportExitCode = 0; + spawnState.plainExitWithoutStderr = false; await loadFreshDockerModuleForTest(); }); @@ -423,4 +434,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) => 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 4df971fc84b3..3140830c8e93 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -60,7 +60,7 @@ import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.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"; @@ -534,7 +534,7 @@ async function ensureSandboxContainerLifecycle( const engine = podmanRuntimeInfo ? bindPodmanSandboxEngine(podmanRuntimeInfo.target) : configuredEngine; - const existingRegistryEntry = await readRegistryEntry(containerName); + let existingRegistryEntry = await readRegistryEntry(containerName); if (engine.id === "podman" && existingRegistryEntry) { if (!existingRegistryEntry.backendTarget) { throw Object.assign( @@ -544,7 +544,19 @@ async function ensureSandboxContainerLifecycle( { code: "INVALID_CONFIG" }, ); } - assertPodmanSandboxTarget(existingRegistryEntry.backendTarget, podmanRuntimeInfo!.target); + try { + assertPodmanSandboxTarget(existingRegistryEntry.backendTarget, podmanRuntimeInfo!.target); + } catch (error) { + const recordedEngine = bindPodmanSandboxEngine(existingRegistryEntry.backendTarget); + const recordedState = await containerState(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, From e0e09b478358ca6534d9caa3c0c0e00e5b433551 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:33:21 +0800 Subject: [PATCH 07/17] fix(sandbox): preserve Podman target failures --- .../docker.config-hash-recreate.test.ts | 45 ++++++++++++++++++- src/agents/sandbox/docker.ts | 33 +++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index 8c8ad2e6e474..8804e4670e34 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -23,6 +23,7 @@ const spawnState = vi.hoisted(() => ({ calls: [] as SpawnCall[], containerExists: true, inspectRunning: true, + inspectError: "", labelHash: "", podmanInfo: "true\tfalse\n", podmanConnections: "[]\n", @@ -77,7 +78,10 @@ async function spawnDockerProcess(commandAndArgs: string[]) { 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 { @@ -243,6 +247,7 @@ describe("ensureSandboxContainer config-hash recreation", () => { spawnState.calls.length = 0; spawnState.containerExists = true; spawnState.inspectRunning = true; + spawnState.inspectError = ""; spawnState.labelHash = ""; spawnState.podmanInfo = "true\tfalse\n"; spawnState.podmanConnections = "[]\n"; @@ -712,6 +717,8 @@ describe("ensureSandboxContainer config-hash recreation", () => { 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", @@ -745,6 +752,42 @@ describe("ensureSandboxContainer config-hash recreation", () => { ); }); + 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"; diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 3140830c8e93..d3c8aebb5dcb 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -241,6 +241,37 @@ export async function containerState(engine: SandboxContainerEngine, name: strin 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; @@ -548,7 +579,7 @@ async function ensureSandboxContainerLifecycle( assertPodmanSandboxTarget(existingRegistryEntry.backendTarget, podmanRuntimeInfo!.target); } catch (error) { const recordedEngine = bindPodmanSandboxEngine(existingRegistryEntry.backendTarget); - const recordedState = await containerState(recordedEngine, containerName); + const recordedState = await recordedPodmanContainerState(recordedEngine, containerName); if (recordedState.exists) { throw error; } From 9d80906505c9adeba8ddb687c35bae2958572a21 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:40:16 +0800 Subject: [PATCH 08/17] fix(sandbox): align Podman keep-id user mapping --- .../docker.config-hash-recreate.test.ts | 45 ++++++++++++++++++- src/agents/sandbox/podman-runtime.ts | 23 ++++++++-- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index 8804e4670e34..4c1433ae05f2 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -647,7 +647,9 @@ describe("ensureSandboxContainer config-hash recreation", () => { }); expect(createCall.command).toBe("podman"); - expect(collectDockerFlagValues(createCall.args, "--userns")).toEqual(["keep-id"]); + 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"); @@ -679,6 +681,45 @@ describe("ensureSandboxContainer config-hash recreation", () => { expect(collectDockerFlagValues(createCall.args, "--userns")).toEqual([]); }); + it("maps an explicit root user through rootless Podman keep-id", async () => { + const cfg = createSandboxConfig([]); + cfg.docker.user = "0:0"; + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); + + const createCall = await ensureSandboxCreateCallForTest({ + cfg, + engine: PODMAN_SANDBOX_ENGINE, + }); + + expect(collectDockerFlagValues(createCall.args, "--user")).toEqual(["0:0"]); + expect(collectDockerFlagValues(createCall.args, "--userns")).toEqual(["keep-id:uid=0,gid=0"]); + }); + + 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([]); registryMocks.readRegistryEntry.mockResolvedValue({ @@ -902,7 +943,7 @@ describe("ensureSandboxContainer config-hash recreation", () => { createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH, readOnlyWorkspaceSkillMounts: [], }); - const oldHash = `${genericHash}:podman-runtime-v5:keep-id:default`; + const oldHash = `${genericHash}:podman-runtime-v6:keep-id:default`; cfg.dockerTmpfsSource = "configured"; spawnState.inspectRunning = false; spawnState.labelHash = oldHash; diff --git a/src/agents/sandbox/podman-runtime.ts b/src/agents/sandbox/podman-runtime.ts index 31df4af1127b..2f563297f93e 100644 --- a/src/agents/sandbox/podman-runtime.ts +++ b/src/agents/sandbox/podman-runtime.ts @@ -30,6 +30,21 @@ 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, gid] = match; + return gid ? `keep-id:uid=${uid},gid=${gid}` : `keep-id:uid=${uid}`; +} + async function assertSupportedPodmanConnection(remoteSocketPath: string): Promise<{ machine: boolean; target: SandboxContainerEngineTarget; @@ -259,9 +274,9 @@ export function resolvePodmanSandboxCreatePolicy(params: { extraCreateArgs.push("--read-only-tmpfs=true"); } if (params.runtimeInfo.rootless) { - // keep-id maps the effective workspace-owner user back to the invoking - // rootless engine user. Older Podman releases do not override image USER. - extraCreateArgs.push("--userns", "keep-id"); + // 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 }; } @@ -272,7 +287,7 @@ export function resolvePodmanSandboxConfigHash(params: { dockerTmpfsSource: SandboxConfig["dockerTmpfsSource"]; }): string { const userMode = params.configuredUser ? "configured-user" : "keep-id"; - return `${params.genericConfigHash}:podman-runtime-v5:${userMode}:${params.dockerTmpfsSource}`; + return `${params.genericConfigHash}:podman-runtime-v6:${userMode}:${params.dockerTmpfsSource}`; } export function resolvePodmanSandboxContainerPrefix(containerPrefix: string): string { From a53caebb6f07bacdbe0674f9d7cf2f8e4b5e028b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 17:44:22 +0800 Subject: [PATCH 09/17] fix(sandbox): reject unsafe rootless Podman users --- docs/gateway/sandboxing.md | 8 ++-- .../docker.config-hash-recreate.test.ts | 38 ++++++++++++------- src/agents/sandbox/podman-runtime.ts | 7 +++- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index f1d81e4aa309..021bff86ba1d 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -149,7 +149,7 @@ Use `sandbox.backend: "podman"` to select the native `podman` CLI directly. This Podman reuses the existing `sandbox.docker.*` settings and the active native `podman` CLI context; it adds no separate connection configuration surface. -Rootless Podman defaults to `--userns=keep-id` for writable workspace mounts. A long-lived sandbox can reserve subordinate IDs and block unrelated `--userns=auto` workloads; remove it before starting those workloads. Set `sandbox.docker.user` to control the container user; rootful Podman otherwise uses the workspace owner when available. +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 { @@ -523,9 +523,9 @@ 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. Docker can omit `user` or set - `user: "0:0"`; rootless Podman must set `user: "0:0"` because omission - defaults to the invoking user through `--userns=keep-id`. + - `user` must be root for package installs. Docker or rootful Podman can omit + `user` or set `user: "0:0"`. 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 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/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index 4c1433ae05f2..b49cf5fba1aa 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -681,20 +681,32 @@ describe("ensureSandboxContainer config-hash recreation", () => { expect(collectDockerFlagValues(createCall.args, "--userns")).toEqual([]); }); - it("maps an explicit root user through rootless Podman keep-id", async () => { - const cfg = createSandboxConfig([]); - cfg.docker.user = "0:0"; - spawnState.inspectRunning = false; - registryMocks.readRegistryEntry.mockResolvedValue(null); + it.each([{ user: "0" }, { user: "0:0" }, { user: "1001:0" }, { user: "0:1002" }])( + "rejects zero-valued rootless Podman user $user", + async ({ user }) => { + const cfg = createSandboxConfig([]); + cfg.docker.user = user; + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); - const createCall = await ensureSandboxCreateCallForTest({ - cfg, - engine: PODMAN_SANDBOX_ENGINE, - }); + 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(collectDockerFlagValues(createCall.args, "--user")).toEqual(["0:0"]); - expect(collectDockerFlagValues(createCall.args, "--userns")).toEqual(["keep-id:uid=0,gid=0"]); - }); + expect(spawnState.calls).not.toContainEqual( + expect.objectContaining({ + command: "podman", + args: expect.arrayContaining(["create"]), + }), + ); + }, + ); it("rejects nonnumeric users for rootless Podman keep-id", async () => { const cfg = createSandboxConfig([]); @@ -943,7 +955,7 @@ describe("ensureSandboxContainer config-hash recreation", () => { createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH, readOnlyWorkspaceSkillMounts: [], }); - const oldHash = `${genericHash}:podman-runtime-v6:keep-id:default`; + const oldHash = `${genericHash}:podman-runtime-v8:keep-id:default`; cfg.dockerTmpfsSource = "configured"; spawnState.inspectRunning = false; spawnState.labelHash = oldHash; diff --git a/src/agents/sandbox/podman-runtime.ts b/src/agents/sandbox/podman-runtime.ts index 2f563297f93e..ac88915f66ef 100644 --- a/src/agents/sandbox/podman-runtime.ts +++ b/src/agents/sandbox/podman-runtime.ts @@ -42,6 +42,11 @@ function resolvePodmanKeepIdMode(user: string | undefined): string { ); } const [, uid, gid] = match; + if (uid === "0" || gid === "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 gid ? `keep-id:uid=${uid},gid=${gid}` : `keep-id:uid=${uid}`; } @@ -287,7 +292,7 @@ export function resolvePodmanSandboxConfigHash(params: { dockerTmpfsSource: SandboxConfig["dockerTmpfsSource"]; }): string { const userMode = params.configuredUser ? "configured-user" : "keep-id"; - return `${params.genericConfigHash}:podman-runtime-v6:${userMode}:${params.dockerTmpfsSource}`; + return `${params.genericConfigHash}:podman-runtime-v8:${userMode}:${params.dockerTmpfsSource}`; } export function resolvePodmanSandboxContainerPrefix(containerPrefix: string): string { From d194f4b7128d25e74a77f22c732a3e139f0e33bc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 18:29:53 +0800 Subject: [PATCH 10/17] fix(sandbox): gate Podman versioned features --- docs/gateway/sandboxing.md | 7 +- extensions/policy/src/policy-state.test.ts | 2 +- .../docker.config-hash-recreate.test.ts | 98 +++++++++++++------ src/agents/sandbox/docker.test.ts | 26 ++--- src/agents/sandbox/podman-runtime.ts | 50 ++++++++-- 5 files changed, 132 insertions(+), 51 deletions(-) diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 021bff86ba1d..9ed9fa133f68 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -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 the selected container engine's Docker-compatible `--gpus` flag and requires compatible host GPU setup. +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** @@ -523,8 +523,9 @@ 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. Docker or rootful Podman can omit - `user` or set `user: "0:0"`. Rootless Podman rejects zero-valued users; + - `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 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/extensions/policy/src/policy-state.test.ts b/extensions/policy/src/policy-state.test.ts index 3f3c805a5ce6..80556aaf09d4 100644 --- a/extensions/policy/src/policy-state.test.ts +++ b/extensions/policy/src/policy-state.test.ts @@ -20,7 +20,7 @@ describe("scanPolicySandboxPosture", () => { defaults: { sandbox: { mode: "all", - backend: "podman", + backend: "Podman", docker: { network: "bridge", seccompProfile: "custom-seccomp.json", diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index b49cf5fba1aa..87e5a927bf84 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -25,7 +25,7 @@ const spawnState = vi.hoisted(() => ({ inspectRunning: true, inspectError: "", labelHash: "", - podmanInfo: "true\tfalse\n", + podmanInfo: "true\tfalse\t\t5.0.0\n", podmanConnections: "[]\n", })); @@ -249,7 +249,7 @@ describe("ensureSandboxContainer config-hash recreation", () => { spawnState.inspectRunning = true; spawnState.inspectError = ""; spawnState.labelHash = ""; - spawnState.podmanInfo = "true\tfalse\n"; + spawnState.podmanInfo = "true\tfalse\t\t5.0.0\n"; spawnState.podmanConnections = "[]\n"; registryMocks.readRegistryEntry.mockClear(); registryMocks.removeRegistryEntry.mockClear(); @@ -668,7 +668,7 @@ describe("ensureSandboxContainer config-hash recreation", () => { it("uses the workspace owner without keep-id for rootful Podman", async () => { const cfg = createSandboxConfig([]); cfg.docker.user = "1001:1002"; - spawnState.podmanInfo = "false\tfalse\n"; + spawnState.podmanInfo = "false\tfalse\t\t5.0.0\n"; spawnState.inspectRunning = false; registryMocks.readRegistryEntry.mockResolvedValue(null); @@ -681,32 +681,72 @@ describe("ensureSandboxContainer config-hash recreation", () => { expect(collectDockerFlagValues(createCall.args, "--userns")).toEqual([]); }); - it.each([{ user: "0" }, { user: "0:0" }, { user: "1001:0" }, { user: "0:1002" }])( - "rejects zero-valued rootless Podman user $user", - async ({ user }) => { - const cfg = createSandboxConfig([]); - cfg.docker.user = user; - spawnState.inspectRunning = false; - registryMocks.readRegistryEntry.mockResolvedValue(null); + 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); + 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"]), - }), - ); - }, - ); + 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([]); @@ -1003,7 +1043,7 @@ describe("ensureSandboxContainer config-hash recreation", () => { const cfg = createSandboxConfig([]); const workspaceDir = path.join(os.homedir(), "openclaw-podman-workspace"); cfg.docker.binds = [`${workspaceDir}:/workspace:rw`]; - spawnState.podmanInfo = "true\ttrue\n"; + spawnState.podmanInfo = "true\ttrue\t\t5.0.0\n"; spawnState.podmanConnections = JSON.stringify([ { Name: "podman-machine-default", @@ -1030,7 +1070,7 @@ describe("ensureSandboxContainer config-hash recreation", () => { it("rejects Podman Machine bind sources outside the default home share", async () => { const cfg = createSandboxConfig([]); - spawnState.podmanInfo = "true\ttrue\n"; + spawnState.podmanInfo = "true\ttrue\t\t5.0.0\n"; spawnState.podmanConnections = JSON.stringify([ { Name: "podman-machine-default", diff --git a/src/agents/sandbox/docker.test.ts b/src/agents/sandbox/docker.test.ts index 2ca88bee485b..48f9cf28e315 100644 --- a/src/agents/sandbox/docker.test.ts +++ b/src/agents/sandbox/docker.test.ts @@ -19,7 +19,7 @@ const spawnState = vi.hoisted(() => ({ inspectError: "", infoAvailable: { docker: false, podman: false }, podmanConnections: "[]\n", - podmanInfo: "true\tfalse\n", + podmanInfo: "true\tfalse\t\t5.0.0\n", lastOptions: undefined as SpawnCallOptions | undefined, executionError: undefined as Error | undefined, transportFailure: false, @@ -116,12 +116,12 @@ describe("resolvePodmanSandboxRuntimeInfo", () => { spawnState.calls.length = 0; spawnState.infoAvailable.podman = true; spawnState.podmanConnections = "[]\n"; - spawnState.podmanInfo = "true\tfalse\n"; + spawnState.podmanInfo = "true\tfalse\t\t5.0.0\n"; await loadFreshDockerModuleForTest(); }); it("rejects an arbitrary remote Podman connection", async () => { - spawnState.podmanInfo = "true\ttrue\n"; + spawnState.podmanInfo = "true\ttrue\t\t5.0.0\n"; spawnState.podmanConnections = JSON.stringify([ { Name: "remote", @@ -136,7 +136,7 @@ describe("resolvePodmanSandboxRuntimeInfo", () => { }); it("allows Podman Machine connections", async () => { - spawnState.podmanInfo = "true\ttrue\n"; + spawnState.podmanInfo = "true\ttrue\t\t5.0.0\n"; spawnState.podmanConnections = JSON.stringify([ { Name: "podman-machine-default", @@ -149,6 +149,7 @@ describe("resolvePodmanSandboxRuntimeInfo", () => { 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/run/user/501/podman/podman.sock"], @@ -157,7 +158,7 @@ describe("resolvePodmanSandboxRuntimeInfo", () => { }); it("rejects an unknown configured remote connection", async () => { - spawnState.podmanInfo = "true\ttrue\n"; + spawnState.podmanInfo = "true\ttrue\t\t5.0.0\n"; spawnState.podmanConnections = JSON.stringify([ { Name: "podman-machine-default", @@ -173,7 +174,7 @@ describe("resolvePodmanSandboxRuntimeInfo", () => { }); it("prefers a configured host URI over a configured connection name", async () => { - spawnState.podmanInfo = "true\ttrue\n"; + spawnState.podmanInfo = "true\ttrue\t\t5.0.0\n"; spawnState.podmanConnections = JSON.stringify([ { Name: "podman-machine-default", @@ -196,7 +197,7 @@ describe("resolvePodmanSandboxRuntimeInfo", () => { }); it("validates a named remote connection when the configured host URI is empty", async () => { - spawnState.podmanInfo = "true\ttrue\n"; + spawnState.podmanInfo = "true\ttrue\t\t5.0.0\n"; spawnState.podmanConnections = JSON.stringify([ { Name: "remote", @@ -212,12 +213,13 @@ describe("resolvePodmanSandboxRuntimeInfo", () => { }); it("uses Podman's local Unix fallback when no connection is configured", async () => { - spawnState.podmanInfo = "true\ttrue\t/run/user/1000/podman/podman.sock\n"; + 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"], @@ -227,14 +229,15 @@ describe("resolvePodmanSandboxRuntimeInfo", () => { }); it("revalidates the active Podman connection on every resolution", async () => { - spawnState.podmanInfo = "true\tfalse\n"; + 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\n"; + spawnState.podmanInfo = "true\ttrue\t\t5.0.0\n"; spawnState.podmanConnections = JSON.stringify([ { Name: "remote", @@ -260,13 +263,14 @@ describe("resolvePodmanSandboxRuntimeInfo", () => { 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\n"; + spawnState.podmanInfo = "true\ttrue\t\t5.0.0\n"; spawnState.podmanConnections = JSON.stringify([ { Name: "podman-machine-first", diff --git a/src/agents/sandbox/podman-runtime.ts b/src/agents/sandbox/podman-runtime.ts index ac88915f66ef..265fc624f5d1 100644 --- a/src/agents/sandbox/podman-runtime.ts +++ b/src/agents/sandbox/podman-runtime.ts @@ -15,11 +15,14 @@ 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 { @@ -42,12 +45,31 @@ function resolvePodmanKeepIdMode(user: string | undefined): string { ); } const [, uid, gid] = match; - if (uid === "0" || gid === "0") { + 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 gid ? `keep-id:uid=${uid},gid=${gid}` : `keep-id:uid=${uid}`; + 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 actual = match ? [Number(match[1]), Number(match[2])] : null; + if (actual && (actual[0] > minimum[0] || (actual[0] === minimum[0] && actual[1] >= 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 assertSupportedPodmanConnection(remoteSocketPath: string): Promise<{ @@ -134,7 +156,7 @@ export async function resolvePodmanSandboxRuntimeInfo(): Promise Date: Fri, 31 Jul 2026 18:35:25 +0800 Subject: [PATCH 11/17] fix(sandbox): normalize workspace backend ids --- src/agents/sandbox/workspace-authority.test.ts | 4 ++-- src/agents/sandbox/workspace-authority.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) 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 66e75956d7e2..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" && sandbox.backend !== "podman") { + 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."; From 9db4e38422269f1838d2784d047c235574b1f2d7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 18:40:39 +0800 Subject: [PATCH 12/17] fix(sandbox): identify Podman Machine targets --- .../docker.config-hash-recreate.test.ts | 61 +++++++++------- src/agents/sandbox/docker.test.ts | 44 +++++++++-- src/agents/sandbox/docker.ts | 3 + src/agents/sandbox/podman-runtime.ts | 73 ++++++++++++++++++- 4 files changed, 146 insertions(+), 35 deletions(-) diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index 87e5a927bf84..592d964345bf 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -27,6 +27,7 @@ const spawnState = vi.hoisted(() => ({ labelHash: "", podmanInfo: "true\tfalse\t\t5.0.0\n", podmanConnections: "[]\n", + podmanMachines: "[]\n", })); const registryMocks = vi.hoisted(() => ({ @@ -47,6 +48,27 @@ 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, @@ -102,6 +124,8 @@ async function spawnDockerProcess(commandAndArgs: string[]) { 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; @@ -251,6 +275,7 @@ describe("ensureSandboxContainer config-hash recreation", () => { 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); @@ -774,13 +799,11 @@ describe("ensureSandboxContainer config-hash recreation", () => { 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: `machine:${"a".repeat(32)}`, - globalArgs: ["--url", "ssh://core@127.0.0.1:60001/run/user/501/podman/podman.sock"], - }, + backendTarget: { key: "local", globalArgs: [] }, sessionKey: "shared", createdAtMs: 1, lastUsedAtMs: 1, @@ -797,11 +820,11 @@ describe("ensureSandboxContainer config-hash recreation", () => { }), ).rejects.toThrow(/active Podman connection changed/u); expect(registryMocks.removeRegistryEntry).not.toHaveBeenCalled(); - expect(spawnState.calls).toContainEqual( + expect(spawnState.calls).not.toContainEqual( expect.objectContaining({ command: "podman", - globalArgs: ["--url", "ssh://core@127.0.0.1:60001/run/user/501/podman/podman.sock"], - args: ["inspect", "-f", "{{.State.Running}}", "oc-test-podman-shared"], + globalArgs: [], + args: expect.arrayContaining(["inspect"]), }), ); }); @@ -1043,15 +1066,7 @@ describe("ensureSandboxContainer config-hash recreation", () => { const cfg = createSandboxConfig([]); const workspaceDir = path.join(os.homedir(), "openclaw-podman-workspace"); cfg.docker.binds = [`${workspaceDir}:/workspace:rw`]; - 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, - }, - ]); + usePodmanMachine(); spawnState.inspectRunning = false; registryMocks.readRegistryEntry.mockResolvedValue(null); @@ -1064,21 +1079,15 @@ describe("ensureSandboxContainer config-hash recreation", () => { expect(createCall.command).toBe("podman"); expect(createCall.globalArgs).toEqual([ "--url", - "ssh://core@127.0.0.1/run/user/501/podman/podman.sock", + "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([]); - 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, - }, - ]); + usePodmanMachine(); spawnState.inspectRunning = false; registryMocks.readRegistryEntry.mockResolvedValue(null); diff --git a/src/agents/sandbox/docker.test.ts b/src/agents/sandbox/docker.test.ts index 48f9cf28e315..ef076082388f 100644 --- a/src/agents/sandbox/docker.test.ts +++ b/src/agents/sandbox/docker.test.ts @@ -20,6 +20,7 @@ const spawnState = vi.hoisted(() => ({ 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, @@ -62,6 +63,8 @@ async function spawnDockerProcess(commandAndArgs: string[], options?: SpawnCallO 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")) { @@ -117,6 +120,7 @@ describe("resolvePodmanSandboxRuntimeInfo", () => { spawnState.infoAvailable.podman = true; spawnState.podmanConnections = "[]\n"; spawnState.podmanInfo = "true\tfalse\t\t5.0.0\n"; + spawnState.podmanMachines = "[]\n"; await loadFreshDockerModuleForTest(); }); @@ -140,11 +144,20 @@ describe("resolvePodmanSandboxRuntimeInfo", () => { spawnState.podmanConnections = JSON.stringify([ { Name: "podman-machine-default", - URI: "ssh://core@127.0.0.1/run/user/501/podman/podman.sock", - IsMachine: true, + 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, @@ -152,7 +165,12 @@ describe("resolvePodmanSandboxRuntimeInfo", () => { version: "5.0.0", target: { key: expect.stringMatching(/^machine:[a-f0-9]{32}$/u), - globalArgs: ["--url", "ssh://core@127.0.0.1/run/user/501/podman/podman.sock"], + globalArgs: [ + "--url", + "ssh://core@127.0.0.1:60000/run/user/501/podman/podman.sock", + "--identity", + "/tmp/podman-machine-default", + ], }, }); }); @@ -276,10 +294,18 @@ describe("resolvePodmanSandboxRuntimeInfo", () => { Name: "podman-machine-first", URI: "ssh://core@127.0.0.1:60001/run/user/501/podman/podman.sock", Identity: "/tmp/first-machine-key", - IsMachine: true, Default: true, }, ]); + 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([ @@ -287,10 +313,18 @@ describe("resolvePodmanSandboxRuntimeInfo", () => { Name: "podman-machine-second", URI: "ssh://core@127.0.0.1:60002/run/user/501/podman/podman.sock", Identity: "/tmp/second-machine-key", - IsMachine: true, Default: true, }, ]); + 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), diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index d3c8aebb5dcb..68ecc94d54c5 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -578,6 +578,9 @@ async function ensureSandboxContainerLifecycle( 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) { diff --git a/src/agents/sandbox/podman-runtime.ts b/src/agents/sandbox/podman-runtime.ts index 265fc624f5d1..40c1be7accc7 100644 --- a/src/agents/sandbox/podman-runtime.ts +++ b/src/agents/sandbox/podman-runtime.ts @@ -72,6 +72,65 @@ function assertPodmanVersionAtLeast( ); } +async function isPodmanMachineConnection(params: { + selectedName: string; + uri: string; + identity: string; +}): Promise { + let uri: URL; + try { + uri = new URL(params.uri); + } catch { + return false; + } + if (uri.protocol !== "ssh:" || !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 portMatches = String(machine.Port ?? "") === uri.port; + const userMatches = + typeof machine.RemoteUsername === "string" && + machine.RemoteUsername === decodeURIComponent(uri.username); + 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; @@ -127,10 +186,16 @@ async function assertSupportedPodmanConnection(remoteSocketPath: string): Promis throw unsupportedRemoteError(); } if (selectedUri && !selectedUri.startsWith("unix://")) { - if (selected?.IsMachine === true) { - const identity = - process.env.CONTAINER_SSHKEY?.trim() || - (typeof selected.Identity === "string" ? selected.Identity : ""); + 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: { From f626348e2c6f9ef6f7c54f8591a17417c7e1282c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 18:44:43 +0800 Subject: [PATCH 13/17] fix(sandbox): constrain Podman Machine endpoints --- src/agents/sandbox/docker.test.ts | 45 +++++++++++++++++++++++++++- src/agents/sandbox/podman-runtime.ts | 11 +++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/agents/sandbox/docker.test.ts b/src/agents/sandbox/docker.test.ts index ef076082388f..10290686385a 100644 --- a/src/agents/sandbox/docker.test.ts +++ b/src/agents/sandbox/docker.test.ts @@ -175,6 +175,40 @@ describe("resolvePodmanSandboxRuntimeInfo", () => { }); }); + 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([ @@ -204,9 +238,18 @@ describe("resolvePodmanSandboxRuntimeInfo", () => { await withEnvAsync( { CONTAINER_CONNECTION: "podman-machine-default", - CONTAINER_HOST: "ssh://example.test/run/user/1000/podman/podman.sock", + 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, ); diff --git a/src/agents/sandbox/podman-runtime.ts b/src/agents/sandbox/podman-runtime.ts index 40c1be7accc7..868edaa0342c 100644 --- a/src/agents/sandbox/podman-runtime.ts +++ b/src/agents/sandbox/podman-runtime.ts @@ -1,3 +1,4 @@ +import { isIP } from "node:net"; import os from "node:os"; import path from "node:path"; import { isPathInside } from "../../infra/path-guards.js"; @@ -83,7 +84,11 @@ async function isPodmanMachineConnection(params: { } catch { return false; } - if (uri.protocol !== "ssh:" || !uri.port || !uri.username) { + 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( @@ -118,9 +123,11 @@ async function isPodmanMachineConnection(params: { params.selectedName === machineName || params.selectedName === `${machineName}-root`; const portMatches = String(machine.Port ?? "") === uri.port; + const connectionUser = decodeURIComponent(uri.username); + const rootConnection = params.selectedName === `${machineName}-root`; const userMatches = typeof machine.RemoteUsername === "string" && - machine.RemoteUsername === decodeURIComponent(uri.username); + (connectionUser === machine.RemoteUsername || (rootConnection && connectionUser === "root")); const machineIdentity = typeof machine.IdentityPath === "string" && machine.IdentityPath ? path.resolve(machine.IdentityPath) From 6cb75d9c5d76b056bff4bea51978000953fb49cb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 18:49:22 +0800 Subject: [PATCH 14/17] fix(sandbox): handle zero Podman host ids --- src/agents/sandbox/context.user-fallback.test.ts | 12 ++++++++++++ src/agents/sandbox/docker-user.ts | 3 +++ 2 files changed, 15 insertions(+) diff --git a/src/agents/sandbox/context.user-fallback.test.ts b/src/agents/sandbox/context.user-fallback.test.ts index 41d97851ce3e..95b6138722a5 100644 --- a/src/agents/sandbox/context.user-fallback.test.ts +++ b/src/agents/sandbox/context.user-fallback.test.ts @@ -65,6 +65,18 @@ describe("resolveSandboxDockerUser", () => { expect(resolved.user).toBe("1001:1002"); }); + it("leaves Podman user unset when host ownership IDs are zero", async () => { + const docker = {}; + 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", diff --git a/src/agents/sandbox/docker-user.ts b/src/agents/sandbox/docker-user.ts index 66bd5e7de09c..63611d29fa2a 100644 --- a/src/agents/sandbox/docker-user.ts +++ b/src/agents/sandbox/docker-user.ts @@ -23,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; From 798e81496afbb94501daa21644eb55df5da378f1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 18:59:00 +0800 Subject: [PATCH 15/17] test(sandbox): cover zero Podman host ids --- src/agents/sandbox.resolveSandboxContext.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/agents/sandbox.resolveSandboxContext.test.ts b/src/agents/sandbox.resolveSandboxContext.test.ts index 65ef90a34305..1a595f770617 100644 --- a/src/agents/sandbox.resolveSandboxContext.test.ts +++ b/src/agents/sandbox.resolveSandboxContext.test.ts @@ -649,12 +649,16 @@ describe("resolveSandboxContext", () => { 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: `${workspaceStat.uid}:${workspaceStat.gid}`, + user: expectedUser, }), }), }), From fdb1ba733bee5c4eb0730817c84c78894789feff Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 19:34:06 +0800 Subject: [PATCH 16/17] fix(sandbox): harden Podman target validation proof --- .../sandbox/context.user-fallback.test.ts | 2 +- src/agents/sandbox/docker-backend.test.ts | 53 +++++++++++++++++++ src/agents/sandbox/podman-runtime.ts | 14 +++-- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/agents/sandbox/context.user-fallback.test.ts b/src/agents/sandbox/context.user-fallback.test.ts index 95b6138722a5..4fc2566506f0 100644 --- a/src/agents/sandbox/context.user-fallback.test.ts +++ b/src/agents/sandbox/context.user-fallback.test.ts @@ -66,7 +66,7 @@ describe("resolveSandboxDockerUser", () => { }); it("leaves Podman user unset when host ownership IDs are zero", async () => { - const docker = {}; + const docker = { ...baseDocker }; const resolved = await resolveSandboxDockerUser({ backend: "podman", docker, diff --git a/src/agents/sandbox/docker-backend.test.ts b/src/agents/sandbox/docker-backend.test.ts index 73d68afc8f45..481fc8cb0ef8 100644 --- a/src/agents/sandbox/docker-backend.test.ts +++ b/src/agents/sandbox/docker-backend.test.ts @@ -302,6 +302,59 @@ describe("docker sandbox backend manager", () => { ); }); + 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"; diff --git a/src/agents/sandbox/podman-runtime.ts b/src/agents/sandbox/podman-runtime.ts index 868edaa0342c..ca96a1e88896 100644 --- a/src/agents/sandbox/podman-runtime.ts +++ b/src/agents/sandbox/podman-runtime.ts @@ -45,7 +45,8 @@ function resolvePodmanKeepIdMode(user: string | undefined): string { `Rootless Podman sandbox user "${normalized}" must be a numeric UID or UID:GID so keep-id can preserve bind-mount ownership.`, ); } - const [, uid, gid] = match; + 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") { @@ -64,8 +65,9 @@ function assertPodmanVersionAtLeast( feature: string, ): void { const match = /^(\d+)\.(\d+)/u.exec(version.trim()); - const actual = match ? [Number(match[1]), Number(match[2])] : null; - if (actual && (actual[0] > minimum[0] || (actual[0] === minimum[0] && actual[1] >= minimum[1]))) { + 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( @@ -122,7 +124,11 @@ async function isPodmanMachineConnection(params: { !params.selectedName || params.selectedName === machineName || params.selectedName === `${machineName}-root`; - const portMatches = String(machine.Port ?? "") === uri.port; + 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 = From 50993de44e19ac751e8ce1b3268d3c959d4f8743 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 20:00:39 +0800 Subject: [PATCH 17/17] test(sandbox): satisfy strict catch typing --- src/agents/sandbox/docker.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/sandbox/docker.test.ts b/src/agents/sandbox/docker.test.ts index 10290686385a..e846845cfe7b 100644 --- a/src/agents/sandbox/docker.test.ts +++ b/src/agents/sandbox/docker.test.ts @@ -521,7 +521,7 @@ describe("execDockerRaw", () => { const secret = "sandbox-secret-value"; const error = await execDockerRaw(["create", "--env", `TOKEN=${secret}`]).catch( - (caught) => caught, + (caught: unknown) => caught, ); expect(error).toBeInstanceOf(Error);