From 903276c798100256adbaef9edf03be270a7d6307 Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Mon, 6 Jul 2026 21:23:46 -0700 Subject: [PATCH 001/347] 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 002/347] 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 003/347] 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 004/347] 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 58d888d573c453f0480e3d6e3a74a7ebf70af837 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 09:54:58 +0800 Subject: [PATCH 005/347] fix(ui): restore inherited settings defaults --- ...nfig-form-scalar-integrity.browser.test.ts | 164 ++++++++++++++++- .../config-form-structured-draft.ts | 1 + .../components/config-form.node.collection.ts | 8 +- ui/src/components/config-form.node.scalar.ts | 110 +++++++---- ui/src/components/config-form.node.shared.ts | 55 +++++- ui/src/components/config-form.node.ts | 54 ++++-- ui/src/components/config-form.render.ts | 2 + ui/src/components/settings-ui.ts | 2 + ui/src/e2e/config-form-defaults.e2e.test.ts | 174 ++++++++++++++++++ ui/src/i18n/locales/en.ts | 1 + ui/src/lib/config/index.test.ts | 46 +++++ ui/src/pages/config/config-page.ts | 1 + ui/src/pages/config/view-types.ts | 1 + ui/src/pages/config/view.browser.test.ts | 54 ++++++ ui/src/pages/config/view.ts | 1 + 15 files changed, 616 insertions(+), 58 deletions(-) create mode 100644 ui/src/e2e/config-form-defaults.e2e.test.ts diff --git a/ui/src/components/config-form-scalar-integrity.browser.test.ts b/ui/src/components/config-form-scalar-integrity.browser.test.ts index d6d99df9941e..ed9b25d70d14 100644 --- a/ui/src/components/config-form-scalar-integrity.browser.test.ts +++ b/ui/src/components/config-form-scalar-integrity.browser.test.ts @@ -1,7 +1,7 @@ // Control UI tests cover scalar identity and nullable enum behavior. import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; -import { renderNumberInput, renderSelect } from "./config-form.node.scalar.ts"; +import { renderNumberInput, renderSelect, renderTextInput } from "./config-form.node.scalar.ts"; function expectElement(element: T | null | undefined, label: string): T { expect(element instanceof Element, label).toBe(true); @@ -145,4 +145,166 @@ describe("config form scalar integrity", () => { select.dispatchEvent(new Event("change", { bubbles: true })); expect(onPatch).toHaveBeenLastCalledWith(["mode"], null); }); + + it("shows inherited defaults without turning them into stored overrides", () => { + const container = document.createElement("div"); + const onPatch = vi.fn(); + const onRemove = vi.fn(); + + render( + renderTextInput({ + schema: { type: "string", default: "balanced" }, + value: undefined, + path: ["mode"], + hints: {}, + unsupported: new Set(), + disabled: false, + inputType: "text", + onPatch, + onRemove, + }), + container, + ); + + const textInput = expectElement( + container.querySelector("input[type='text']"), + "defaulted text input", + ); + expect(textInput.value).toBe(""); + expect(textInput.placeholder).toBe("Default: balanced"); + expect(container.textContent).toContain("Using default: balanced"); + expect(container.querySelector("button[aria-label='Reset to default']")).toBeNull(); + expect(onPatch).not.toHaveBeenCalled(); + expect(onRemove).not.toHaveBeenCalled(); + + render( + renderNumberInput({ + schema: { type: "integer", default: 3 }, + value: undefined, + path: ["retries"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + onRemove, + }), + container, + ); + const numberInput = expectElement( + container.querySelector("input[type='number']"), + "defaulted number input", + ); + expect(numberInput.value).toBe(""); + expect(numberInput.placeholder).toBe("Default: 3"); + expect(container.textContent).toContain("Using default: 3"); + + const arrowUp = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "ArrowUp", + }); + numberInput.dispatchEvent(arrowUp); + expect(arrowUp.defaultPrevented).toBe(true); + expect(onPatch).toHaveBeenLastCalledWith(["retries"], 4); + }); + + it("restores scalar and select defaults by removing optional overrides", () => { + const container = document.createElement("div"); + const onPatch = vi.fn(); + const onRemove = vi.fn(); + + render( + renderNumberInput({ + schema: { type: "integer", default: 3 }, + value: 9, + path: ["retries"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + onRemove, + }), + container, + ); + expect(container.textContent).toContain("Default: 3"); + expectElement( + container.querySelector("button[aria-label='Reset to default']"), + "number reset", + ).click(); + expect(onPatch).toHaveBeenCalledWith(["retries"], undefined); + expect(onRemove).not.toHaveBeenCalled(); + + render( + renderSelect({ + schema: { type: "string", default: "balanced" }, + value: "fast", + path: ["mode"], + hints: {}, + unsupported: new Set(), + disabled: false, + options: ["balanced", "fast", "careful", "safe", "strict", "custom"], + onPatch, + onRemove, + }), + container, + ); + const select = expectElement( + container.querySelector("select"), + "default-aware select", + ); + expect(container.textContent).toContain("Default: balanced"); + expect(select.options[0]?.textContent?.trim()).toBe("Default: balanced"); + expect(select.selectedOptions[0]?.textContent?.trim()).toBe("fast"); + select.value = "__unset__"; + select.dispatchEvent(new Event("change", { bubbles: true })); + expect(onPatch).toHaveBeenCalledWith(["mode"], undefined); + + render( + renderSelect({ + schema: { type: "string", default: "balanced" }, + value: undefined, + path: ["mode"], + hints: {}, + unsupported: new Set(), + disabled: false, + options: ["balanced", "fast", "careful", "safe", "strict", "custom"], + onPatch, + onRemove, + }), + container, + ); + expect( + expectElement( + container.querySelector("select"), + "inherited select", + ).selectedOptions[0]?.textContent?.trim(), + ).toBe("Default: balanced"); + }); + + it("keeps restore disabled while a sensitive value is concealed", () => { + const container = document.createElement("div"); + + render( + renderTextInput({ + schema: { type: "string", default: "inherited" }, + value: "stored-secret", + path: ["secret"], + hints: { secret: { sensitive: true } }, + unsupported: new Set(), + disabled: false, + inputType: "text", + revealSensitive: false, + onPatch: vi.fn(), + onRemove: vi.fn(), + }), + container, + ); + + const reset = expectElement( + container.querySelector("button[aria-label='Reset to default']"), + "concealed sensitive reset", + ); + expect(reset.disabled).toBe(true); + expect(container.textContent).not.toContain("inherited"); + }); }); diff --git a/ui/src/components/config-form-structured-draft.ts b/ui/src/components/config-form-structured-draft.ts index 65e8afef9fdb..4812bf6b3542 100644 --- a/ui/src/components/config-form-structured-draft.ts +++ b/ui/src/components/config-form-structured-draft.ts @@ -133,6 +133,7 @@ class ConfigFormStructuredDraft extends OpenClawLightDomElement { controlIdentity: draftValue, structuredDraftOwner: true, onPatch: (path, value) => this.patchDraft(path, value), + onRemove: (path) => this.patchDraft(path, undefined), })} ${this.error ? html` diff --git a/ui/src/components/config-form.node.collection.ts b/ui/src/components/config-form.node.collection.ts index b37f4b14ce18..619ed4b87140 100644 --- a/ui/src/components/config-form.node.collection.ts +++ b/ui/src/components/config-form.node.collection.ts @@ -117,8 +117,8 @@ export function renderObject( revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, + onRemove, } = params; - const showLabel = params.showLabel ?? true; const { label, help, tags } = resolveFieldMeta(path, schema, hints); const selfMatched = searchCriteria && hasSearchCriteria(searchCriteria) @@ -181,7 +181,9 @@ export function renderObject( if (!canApplyObjectCandidate(schema, objectValue, candidate)) { return false; } - return onPatch(childPath, childValue) !== false; + const accepted = + childValue === undefined && onRemove ? onRemove(childPath) : onPatch(childPath, childValue); + return accepted !== false; }; const fields = html` @@ -223,7 +225,7 @@ export function renderObject( // Top-level objects and label-less contexts emit rows directly into the // surrounding settings-group so row dividers stay sibling-driven. - if (path.length === 1 || !showLabel) { + if (path.length === 1 || params.showLabel === false) { return html`${fields}`; } diff --git a/ui/src/components/config-form.node.scalar.ts b/ui/src/components/config-form.node.scalar.ts index ea9e271e5cd9..ffd1f77f3516 100644 --- a/ui/src/components/config-form.node.scalar.ts +++ b/ui/src/components/config-form.node.scalar.ts @@ -14,6 +14,8 @@ import { isSecretRefObject, jsonValue, renderFieldRow, + renderRestoreDefaultButton, + renderSchemaDefaultDescription, renderSensitiveToggleButton, wrapSensitiveControl, type ConfigNodeRenderParams, @@ -282,25 +284,21 @@ export function renderTextInput( : wrappedInput; const control = html` ${presentedInput} - ${schema.default !== undefined - ? html` - - - - ` - : nothing} + ${renderRestoreDefaultButton({ + ...params, + disabled: disabled || effectiveRedacted, + })} `; - return renderFieldRow({ label, help, helpId, tags, showLabel, control }); + return renderFieldRow({ + label, + help, + helpId, + defaultDescription: effectiveRedacted ? nothing : renderSchemaDefaultDescription(schema, value), + tags, + showLabel, + control, + }); } export function renderNumberInput(params: ConfigNodeRenderParams): TemplateResult { @@ -308,7 +306,8 @@ export function renderNumberInput(params: ConfigNodeRenderParams): TemplateResul const showLabel = params.showLabel ?? true; const { label, help, tags } = resolveFieldMeta(path, schema, hints); const helpId = showLabel && help ? configFieldId(path, "description") : undefined; - const displayValue = value ?? schema.default ?? ""; + const displayValue = value ?? ""; + const effectiveValue = value !== undefined ? value : schema.default; const constraints = numericInputConstraints(schema); const numericStep = typeof constraints.step === "number" ? constraints.step : 1; const controlIdentity = params.controlIdentity ?? params.sourceIdentity ?? value; @@ -341,7 +340,7 @@ export function renderNumberInput(params: ConfigNodeRenderParams): TemplateResul if (disabled) { return; } - const current = Number(displayValue); + const current = Number(effectiveValue); const base = Number.isFinite(current) ? current : normalizeNumericValue(0, schema); const candidate = normalizeNumericValue(base + direction * numericStep, schema); if (isSupportedConfigValueValid(schema, candidate)) { @@ -376,11 +375,24 @@ export function renderNumberInput(params: ConfigNodeRenderParams): TemplateResul aria-label=${label} aria-describedby=${helpId ?? nothing} aria-invalid="false" + placeholder=${schema.default !== undefined + ? t("configForm.defaultValue", { value: formatUnknownText(schema.default) }) + : nothing} min=${constraints.min ?? nothing} max=${constraints.max ?? nothing} step=${constraints.step} .value=${renderedValue} ?disabled=${disabled} + @keydown=${(event: KeyboardEvent) => { + if ( + value === undefined && + effectiveValue !== undefined && + (event.key === "ArrowUp" || event.key === "ArrowDown") + ) { + event.preventDefault(); + step(event.key === "ArrowUp" ? 1 : -1); + } + }} @input=${(event: Event) => { const target = event.target as HTMLInputElement; const raw = target.value; @@ -427,9 +439,18 @@ export function renderNumberInput(params: ConfigNodeRenderParams): TemplateResul > + + ${renderRestoreDefaultButton(params)} `; - return renderFieldRow({ label, help, helpId, tags, showLabel, control }); + return renderFieldRow({ + label, + help, + helpId, + defaultDescription: renderSchemaDefaultDescription(schema, value), + tags, + showLabel, + control, + }); } export function renderSelect( @@ -439,15 +460,17 @@ export function renderSelect( const showLabel = params.showLabel ?? true; const { label, help, tags } = resolveFieldMeta(path, schema, hints); const helpId = showLabel && help ? configFieldId(path, "description") : undefined; - const resolvedValue = value !== undefined ? value : schema.default; + const usingDefault = value === undefined && schema.default !== undefined; + const resolvedValue = usingDefault ? schema.default : value; const currentIndex = options.findIndex( (option) => option === resolvedValue || String(option) === String(resolvedValue), ); const unset = "__unset__"; const nullValue = "__null__"; const canSelectNull = schema.nullable && schema.enumIncludesNull; - const selectedValue = - resolvedValue === null && canSelectNull + const selectedValue = usingDefault + ? unset + : resolvedValue === null && canSelectNull ? nullValue : currentIndex >= 0 ? String(currentIndex) @@ -463,23 +486,34 @@ export function renderSelect( @change=${(event: Event) => { const target = event.target as HTMLSelectElement; const nextSelection = target.value; - if (nextSelection === unset && params.isRequired) { + if (nextSelection === unset && params.isRequired && schema.default === undefined) { target.value = selectedValue; return; } - const candidate = - nextSelection === unset - ? undefined - : nextSelection === nullValue - ? null - : options[Number(nextSelection)]; + if (nextSelection === unset) { + const accepted = + params.isRequired && schema.default !== undefined + ? onPatch(path, structuredClone(schema.default)) + : onPatch(path, undefined); + if (accepted === false) { + target.value = selectedValue; + } + return; + } + const candidate = nextSelection === nullValue ? null : options[Number(nextSelection)]; if (onPatch(path, candidate) === false) { target.value = selectedValue; } }} > - ${canSelectNull ? html` @@ -490,7 +524,7 @@ export function renderSelect( : nothing} ${options.map( (option, index) => html` - `, @@ -498,5 +532,13 @@ export function renderSelect( `; - return renderFieldRow({ label, help, helpId, tags, showLabel, control }); + return renderFieldRow({ + label, + help, + helpId, + defaultDescription: renderSchemaDefaultDescription(schema, value), + tags, + showLabel, + control, + }); } diff --git a/ui/src/components/config-form.node.shared.ts b/ui/src/components/config-form.node.shared.ts index 2b44e12f3e23..2f90c5666f51 100644 --- a/ui/src/components/config-form.node.shared.ts +++ b/ui/src/components/config-form.node.shared.ts @@ -49,6 +49,7 @@ export type ConfigNodeRenderParams = { isSensitivePathRevealed?: (path: Array) => boolean; onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => boolean | void; + onRemove?: (path: Array) => boolean | void; }; export type ConfigNodeRenderer = ( @@ -192,6 +193,7 @@ export function renderFieldRow(params: { label: unknown; help?: unknown; helpId?: string; + defaultDescription?: unknown; tags: string[]; showLabel: boolean; control: TemplateResult | typeof nothing; @@ -202,8 +204,13 @@ export function renderFieldRow(params: { // wildcard segments collapse), so their help is the parent's. Showing it again // per item is noise; a row with no label of its own gets no help of its own. const help = params.showLabel ? params.help : undefined; + const defaultDescription = params.showLabel ? params.defaultDescription : undefined; const hasText = - params.showLabel || Boolean(help) || params.tags.length > 0 || Boolean(params.error); + params.showLabel || + Boolean(help) || + Boolean(defaultDescription) || + params.tags.length > 0 || + Boolean(params.error); // Control-only rows (array/map item values) stack so the control gets full width. const stacked = params.stacked || !hasText; const className = stacked ? "settings-row settings-row--stacked" : "settings-row"; @@ -220,6 +227,9 @@ export function renderFieldRow(params: { >${help}` : nothing} + ${defaultDescription + ? html`${defaultDescription}` + : nothing} ${renderTags(params.tags)} ${params.error ? html`${params.error}` @@ -234,6 +244,49 @@ export function renderFieldRow(params: { `; } +export function renderSchemaDefaultDescription( + schema: JsonSchema, + value: unknown, +): TemplateResult | typeof nothing { + if (schema.default === undefined) { + return nothing; + } + return html`${t(value === undefined ? "configForm.usingDefault" : "configForm.defaultValue", { + value: formatUnknownText(schema.default), + })}`; +} + +export function renderRestoreDefaultButton( + params: Pick< + ConfigNodeRenderParams, + "schema" | "value" | "path" | "disabled" | "isRequired" | "onPatch" + >, +): TemplateResult | typeof nothing { + if (params.schema.default === undefined || params.value === undefined) { + return nothing; + } + return html` + + + + `; +} + export function renderSegmentedControl(params: { options: unknown[]; resolvedValue: unknown; diff --git a/ui/src/components/config-form.node.ts b/ui/src/components/config-form.node.ts index 83711d04fa4a..42c97d0856af 100644 --- a/ui/src/components/config-form.node.ts +++ b/ui/src/components/config-form.node.ts @@ -11,6 +11,8 @@ import { renderNumberInput, renderSelect, renderTextInput } from "./config-form. import { renderFieldRow, isAnySchema, + renderRestoreDefaultButton, + renderSchemaDefaultDescription, renderSegmentedControl, renderTags, type ConfigNodeRenderParams, @@ -95,25 +97,29 @@ export function renderNode(params: ConfigNodeRenderParams): TemplateResult | typ if (allLiterals && literals.length > 0 && literals.length <= 5) { // Use segmented control for small sets - const resolvedValue = value ?? schema.default; + const resolvedValue = value !== undefined ? value : schema.default; return renderFieldRow({ label, help, + defaultDescription: renderSchemaDefaultDescription(schema, value), tags, showLabel, - control: renderSegmentedControl({ - options: literals, - resolvedValue, - disabled, - ariaLabel: label, - onSelect: (literal) => onPatch(path, literal), - }), + control: html` + ${renderSegmentedControl({ + options: literals, + resolvedValue, + disabled, + ariaLabel: label, + onSelect: (literal) => onPatch(path, literal), + })} + ${renderRestoreDefaultButton(params)} + `, }); } if (allLiterals && literals.length > 5) { // Use dropdown for larger sets - return renderSelect({ ...params, options: literals, value: value ?? schema.default }); + return renderSelect({ ...params, options: literals }); } // Handle mixed primitive types @@ -156,22 +162,26 @@ export function renderNode(params: ConfigNodeRenderParams): TemplateResult | typ if (schema.enum) { const options = schema.enum; if (options.length <= 5) { - const resolvedValue = value ?? schema.default; + const resolvedValue = value !== undefined ? value : schema.default; return renderFieldRow({ label, help, + defaultDescription: renderSchemaDefaultDescription(schema, value), tags, showLabel, - control: renderSegmentedControl({ - options, - resolvedValue, - disabled, - ariaLabel: label, - onSelect: (option) => onPatch(path, option), - }), + control: html` + ${renderSegmentedControl({ + options, + resolvedValue, + disabled, + ariaLabel: label, + onSelect: (option) => onPatch(path, option), + })} + ${renderRestoreDefaultButton(params)} + `, }); } - return renderSelect({ ...params, options, value: value ?? schema.default }); + return renderSelect({ ...params, options }); } // Object type - collapsible section @@ -210,13 +220,19 @@ export function renderNode(params: ConfigNodeRenderParams): TemplateResult | typ }); } const description = - help || tags.length > 0 ? html`${help ?? nothing}${renderTags(tags)}` : undefined; + help || tags.length > 0 || schema.default !== undefined + ? html` + ${help ?? nothing} ${help && schema.default !== undefined ? html`
` : nothing} + ${renderSchemaDefaultDescription(schema, value)}${renderTags(tags)} + ` + : undefined; return renderSettingsToggleRow({ title: label, description, checked: displayValue, disabled, onChange, + actions: renderRestoreDefaultButton(params), }); } diff --git a/ui/src/components/config-form.render.ts b/ui/src/components/config-form.render.ts index faa8852769e6..158a2e7cd096 100644 --- a/ui/src/components/config-form.render.ts +++ b/ui/src/components/config-form.render.ts @@ -37,6 +37,7 @@ type ConfigFormProps = { isSensitivePathRevealed?: (path: Array) => boolean; onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; + onRemove?: (path: Array) => void; }; function renderAdvancedDivider(onHideAdvanced: (() => void) | undefined) { @@ -236,6 +237,7 @@ export function renderConfigForm(props: ConfigFormProps) { isSensitivePathRevealed: props.isSensitivePathRevealed, onToggleSensitivePath: props.onToggleSensitivePath, onPatch: props.onPatch, + onRemove: props.onRemove, }); return html`
diff --git a/ui/src/components/settings-ui.ts b/ui/src/components/settings-ui.ts index 49299799fd78..ec74f0f26fbd 100644 --- a/ui/src/components/settings-ui.ts +++ b/ui/src/components/settings-ui.ts @@ -166,6 +166,7 @@ export function renderSettingsToggleRow(props: { /** Runs synchronously during direct activation for effects gated on user activation. */ onAct?: (checked: boolean) => void; disabled?: boolean; + actions?: TemplateResult | typeof nothing; }): TemplateResult { const notifySwitchActivation = (event: MouseEvent | KeyboardEvent) => { const fromInput = event.composedPath().some((node) => node instanceof HTMLInputElement); @@ -200,6 +201,7 @@ export function renderSettingsToggleRow(props: { : nothing}
+ ${props.actions ?? nothing} { + const params = request.params; + if (!params || typeof params !== "object" || Array.isArray(params)) { + throw new Error("Expected config.set params"); + } + return JSON.parse(String((params as Record).raw)) as Record; +} + +describeControlUiE2e("Control UI config form defaults mocked Gateway E2E", () => { + beforeAll(async () => { + if (!chromiumAvailable) { + throw new Error( + `Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, + ); + } + server = await startControlUiE2eServer(); + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + }); + + afterAll(async () => { + await browser?.close(); + await server?.close(); + }); + + it("shows defaults and removes restored optional overrides from config.set", async () => { + const context = await browser.newContext({ + colorScheme: "dark", + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 1000, width: 1440 }, + }); + const page = await context.newPage(); + const config = { + runtime: { + enabled: false, + keep: "preserved", + mode: "custom", + retries: 9, + }, + }; + const gateway = await installMockGateway(page, { + methodResponses: { + "config.get": { + config, + hash: "config-form-defaults-e2e", + issues: [], + raw: JSON.stringify(config), + valid: true, + }, + "config.schema": { + generatedAt: "2026-07-31T00:00:00.000Z", + schema: { + type: "object", + properties: { + runtime: { + type: "object", + title: "Runtime defaults", + properties: { + enabled: { + type: "boolean", + title: "Enabled", + description: "Controls runtime processing.", + default: true, + }, + keep: { type: "string", title: "Keep" }, + mode: { + type: "string", + title: "Mode", + default: "balanced", + enum: ["balanced", "fast", "careful", "safe", "strict", "custom"], + }, + retries: { type: "integer", title: "Retries", default: 3 }, + }, + }, + }, + }, + uiHints: { + "runtime.enabled": { advanced: false }, + "runtime.keep": { advanced: false }, + "runtime.mode": { advanced: false }, + "runtime.retries": { advanced: false }, + }, + version: "e2e", + }, + }, + }); + + try { + const response = await page.goto(`${server.baseUrl}settings/advanced?section=runtime`); + expect(response?.status()).toBe(200); + + const panel = page.locator("#config-section-panel"); + const enabledRow = panel.locator(".settings-row").filter({ hasText: "Enabled" }); + const modeRow = panel.locator(".settings-row").filter({ hasText: "Mode" }); + const retriesRow = panel.locator(".settings-row").filter({ hasText: "Retries" }); + + await expect.poll(() => enabledRow.textContent()).toContain("Default: true"); + await expect + .poll(() => enabledRow.textContent().then((text) => text?.replace(/\s+/gu, " ").trim())) + .toContain("Controls runtime processing. Default: true"); + await expect.poll(() => modeRow.textContent()).toContain("Default: balanced"); + await expect.poll(() => modeRow.locator("select").inputValue()).not.toBe("__unset__"); + await expect.poll(() => retriesRow.getByRole("spinbutton").inputValue()).toBe("9"); + await expect + .poll(() => panel.getByRole("button", { name: "Reset to default" }).count()) + .toBe(2); + + if (captureUiProofEnabled) { + await mkdir(uiProofArtifactDir, { recursive: true }); + await panel.screenshot({ + animations: "disabled", + path: path.join(uiProofArtifactDir, "01-explicit-overrides.png"), + }); + } + + await enabledRow.getByRole("button", { name: "Reset to default" }).click(); + await modeRow.locator("select").selectOption("__unset__"); + await retriesRow.getByRole("button", { name: "Reset to default" }).click(); + + // Form mutations schedule config.set automatically; form mode has no manual Save control. + const saved = requestRaw(await gateway.waitForRequest("config.set")); + expect(saved).toEqual({ runtime: { keep: "preserved" } }); + + await expect.poll(() => enabledRow.textContent()).toContain("Using default: true"); + await expect.poll(() => modeRow.locator("select").inputValue()).toBe("__unset__"); + await expect.poll(() => retriesRow.getByRole("spinbutton").inputValue()).toBe(""); + await expect + .poll(() => retriesRow.getByRole("spinbutton").getAttribute("placeholder")) + .toBe("Default: 3"); + await expect + .poll(() => panel.getByRole("button", { name: "Reset to default" }).count()) + .toBe(0); + + if (captureUiProofEnabled) { + await panel.screenshot({ + animations: "disabled", + path: path.join(uiProofArtifactDir, "02-inherited-defaults.png"), + }); + } + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index c3711e43ea81..24fe0e023488 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1084,6 +1084,7 @@ export const en: TranslationMap = { structuredSecretRaw: "Structured value (SecretRef) - use Raw mode to edit", structuredSecretFile: "Structured value (SecretRef) - edit the config file directly", defaultValue: "Default: {value}", + usingDefault: "Using default: {value}", resetToDefault: "Reset to default", select: "Select...", nullValue: "null", diff --git a/ui/src/lib/config/index.test.ts b/ui/src/lib/config/index.test.ts index 7cff561bd552..967a616a766f 100644 --- a/ui/src/lib/config/index.test.ts +++ b/ui/src/lib/config/index.test.ts @@ -218,6 +218,52 @@ describe("createRuntimeConfigCapability", () => { runtimeConfig.dispose(); }); + it("removes a restored optional override from config.set while preserving siblings", async () => { + const submitted: Array<{ method: string; params: unknown }> = []; + const request = vi.fn(async (method: string, params?: unknown) => { + if (method === "config.get") { + return { + config: { runtime: { keep: true, mode: "custom" } }, + hash: "hash-1", + valid: true, + issues: [], + }; + } + if (method === "config.schema") { + return { + schema: { + type: "object", + properties: { + runtime: { + type: "object", + properties: { + keep: { type: "boolean" }, + mode: { type: "string", default: "balanced" }, + }, + }, + }, + }, + uiHints: {}, + }; + } + submitted.push({ method, params }); + return { hash: "hash-2" }; + }); + const client = { request } as unknown as GatewayBrowserClient; + const { gateway } = createGatewayHarness(client); + const runtimeConfig = createRuntimeConfigCapability(gateway); + + await Promise.all([runtimeConfig.ensureLoaded(), runtimeConfig.ensureSchemaLoaded()]); + runtimeConfig.removeFormValue(["runtime", "mode"]); + + await expect(runtimeConfig.save()).resolves.toBe(true); + const submission = submitted.find((entry) => entry.method === "config.set"); + const raw = (submission?.params as { raw?: unknown } | undefined)?.raw; + expect(typeof raw).toBe("string"); + expect(JSON.parse(raw as string)).toEqual({ runtime: { keep: true } }); + runtimeConfig.dispose(); + }); + it("submits only decimal numeric spellings as numbers", async () => { const submitted: Array<{ method: string; params: unknown }> = []; const request = vi.fn(async (method: string, params?: unknown) => { diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index 7e26832c69fd..66ec9c600256 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -866,6 +866,7 @@ export class ConfigPage extends OpenClawLightDomElement { onFormModeChange: (mode) => this.setFormMode(mode), onViewStateChange: () => this.requestUpdate(), onFormPatch: (path, value) => runtimeConfig.patchForm(path, value), + onFormRemove: (path) => runtimeConfig.removeFormValue(path), onSectionChange: (section) => this.setActiveSection(section), onSubsectionChange: (section) => this.setActiveSubsection(section), onSave: () => void runtimeConfig.save(), diff --git a/ui/src/pages/config/view-types.ts b/ui/src/pages/config/view-types.ts index feb9f9331b05..eba82d242f37 100644 --- a/ui/src/pages/config/view-types.ts +++ b/ui/src/pages/config/view-types.ts @@ -80,6 +80,7 @@ export type ConfigProps = { onFormModeChange: (mode: ConfigFormMode) => void; onViewStateChange: () => void; onFormPatch: (path: Array, value: unknown) => void; + onFormRemove: (path: Array) => void; onSectionChange: (section: string | null) => void; onSubsectionChange: (section: string | null) => void; onSave: () => void; diff --git a/ui/src/pages/config/view.browser.test.ts b/ui/src/pages/config/view.browser.test.ts index 43710dee9fab..f071f12d09a2 100644 --- a/ui/src/pages/config/view.browser.test.ts +++ b/ui/src/pages/config/view.browser.test.ts @@ -41,6 +41,7 @@ describe("config view", () => { onFormModeChange: vi.fn(), onViewStateChange: vi.fn(), onFormPatch: vi.fn(), + onFormRemove: vi.fn(), onSectionChange: vi.fn(), onSave: vi.fn(), onRawDiscard: vi.fn(), @@ -162,6 +163,59 @@ describe("config view", () => { return container.textContent?.replace(/\s+/g, " ").trim() ?? ""; } + it("routes restore-default actions through config removal", () => { + const onFormPatch = vi.fn(); + const onFormRemove = vi.fn(); + const { container } = renderConfigView({ + schema: { + type: "object", + properties: { + gateway: { + type: "object", + title: "Gateway", + properties: { + retries: { type: "integer", title: "Retries", default: 3 }, + mode: { + type: "string", + title: "Mode", + default: "balanced", + enum: ["balanced", "fast", "careful", "safe", "strict", "custom"], + }, + }, + }, + }, + }, + uiHints: { + "gateway.retries": { advanced: false }, + "gateway.mode": { advanced: false }, + }, + formValue: { gateway: { retries: 9, mode: "custom" } }, + activeSection: "gateway", + onFormPatch, + onFormRemove, + }); + + const retriesRow = Array.from(container.querySelectorAll(".settings-row")).find( + (row) => row.textContent?.includes("Retries"), + ); + queryRequired( + retriesRow ?? container, + "button[aria-label='Reset to default']", + HTMLButtonElement, + ).click(); + expect(onFormRemove).toHaveBeenCalledWith(["gateway", "retries"]); + expect(onFormPatch).not.toHaveBeenCalled(); + + const modeRow = Array.from(container.querySelectorAll(".settings-row")).find( + (row) => row.textContent?.includes("Mode"), + ); + const select = queryRequired(modeRow ?? container, "select", HTMLSelectElement); + expect(select.selectedOptions[0]?.textContent?.trim()).toBe("custom"); + select.value = "__unset__"; + select.dispatchEvent(new Event("change", { bubbles: true })); + expect(onFormRemove).toHaveBeenCalledWith(["gateway", "mode"]); + }); + it("uses one inline advanced disclosure without mutating config fields", () => { const schema = { type: "object", diff --git a/ui/src/pages/config/view.ts b/ui/src/pages/config/view.ts index fe388c658c2a..8de494525fca 100644 --- a/ui/src/pages/config/view.ts +++ b/ui/src/pages/config/view.ts @@ -486,6 +486,7 @@ export function renderConfig(props: ConfigProps) { disabled: configBusy || !props.formValue, unsupportedPaths: analysis.unsupportedPaths, onPatch: props.onFormPatch, + onRemove: props.onFormRemove, activeSection: props.activeSection, activeSubsection: effectiveSubsection, showAdvanced: effectiveShowAdvanced, From b1c565c35bf85ec6704f39d186d01c5165e2c111 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 11:14:53 +0800 Subject: [PATCH 006/347] fix(ui): restore curated settings defaults --- ui/src/components/settings-ui.ts | 35 +++++ ui/src/i18n/locales/en.ts | 2 + ui/src/pages/config/config-page.test.ts | 31 +++- ui/src/pages/config/config-page.ts | 25 +++- ui/src/pages/config/security.test.ts | 69 +++++++++ ui/src/pages/config/security.ts | 45 +++++- ui/src/pages/labs/labs-page.test.ts | 139 +++++++++++++++++- ui/src/pages/labs/labs-page.ts | 57 +++++-- ui/src/pages/labs/labs-registry.ts | 106 +++++++++++-- .../model-providers-page.test.ts | 27 ++++ .../model-providers/model-providers-page.ts | 10 +- ui/src/pages/model-providers/view.test.ts | 60 ++++++++ ui/src/pages/model-providers/view.ts | 91 ++++++++---- 13 files changed, 627 insertions(+), 70 deletions(-) diff --git a/ui/src/components/settings-ui.ts b/ui/src/components/settings-ui.ts index ec74f0f26fbd..26c408499473 100644 --- a/ui/src/components/settings-ui.ts +++ b/ui/src/components/settings-ui.ts @@ -7,6 +7,7 @@ import "@awesome.me/webawesome/dist/components/radio-group/radio-group.js"; import "@awesome.me/webawesome/dist/components/switch/switch.js"; import { html, nothing, type TemplateResult } from "lit"; import { live } from "lit/directives/live.js"; +import { t } from "../i18n/index.ts"; import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../lib/external-link.ts"; import { icons } from "./icons.ts"; import "./tooltip.ts"; @@ -223,6 +224,40 @@ export function renderSettingsToggleRow(props: { `; } +export function renderSettingsDefaultState(props: { + value: string; + overridden: boolean; + disabled?: boolean; + onReset: () => void; +}): { + description: TemplateResult; + action: TemplateResult | typeof nothing; +} { + return { + description: html`${t( + props.overridden ? "configForm.defaultValue" : "configForm.usingDefault", + { value: props.value }, + )}`, + action: props.overridden + ? html` + + ` + : nothing, + }; +} + export function renderSettingsSegmented(props: { value: T; options: ReadonlyArray<{ value: T; label: unknown; title?: string; testId?: string }>; diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 24fe0e023488..87078023d168 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1242,6 +1242,8 @@ export const en: TranslationMap = { model: "Model", thinking: "Thinking", fastMode: "Fast mode", + default: "Default", + modelPolicy: "Model policy", thinkingLevels: { off: "Off", low: "Low", diff --git a/ui/src/pages/config/config-page.test.ts b/ui/src/pages/config/config-page.test.ts index 890e679882ad..f0b555c3e650 100644 --- a/ui/src/pages/config/config-page.test.ts +++ b/ui/src/pages/config/config-page.test.ts @@ -11,7 +11,11 @@ import type { import { createStorageMock } from "../../test-helpers/storage.ts"; import * as chatModels from "../chat/models.ts"; import * as realtimeTalk from "../chat/realtime-talk.ts"; -import { ConfigPage, configSelectionFromSearch } from "./config-page.ts"; +import { + ConfigPage, + configSelectionFromSearch, + extractQuickSettingsSecurity, +} from "./config-page.ts"; import { configSectionKeysForPage } from "./config-sections.ts"; import type { ConfigViewState } from "./view.ts"; @@ -92,6 +96,31 @@ describe("configSelectionFromSearch", () => { }); }); +describe("extractQuickSettingsSecurity", () => { + it("preserves provenance for inherited security defaults", () => { + expect(extractQuickSettingsSecurity({})).toMatchObject({ + browserEnabled: true, + browserEnabledOverridden: false, + toolProfile: "full", + toolProfileOverridden: false, + }); + }); + + it("distinguishes explicit values that equal the defaults", () => { + expect( + extractQuickSettingsSecurity({ + browser: { enabled: true }, + tools: { profile: "full" }, + }), + ).toMatchObject({ + browserEnabled: true, + browserEnabledOverridden: true, + toolProfile: "full", + toolProfileOverridden: true, + }); + }); +}); + describe("ConfigPage moved section routes", () => { it.each([ ["channels", "channels", ""], diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index 66ec9c600256..a10b7570eacb 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -152,7 +152,7 @@ function configPageTitle(pageId: ConfigPageId): string { return titleForRoute(pageId); } -function extractQuickSettingsSecurity(config: unknown): SecurityOverview { +export function extractQuickSettingsSecurity(config: unknown): SecurityOverview { const root = asConfigRecord((config as { configForm?: unknown } | null)?.configForm) ?? asConfigRecord(config); @@ -162,7 +162,9 @@ function extractQuickSettingsSecurity(config: unknown): SecurityOverview { execPolicy: "unknown", deviceAuth: false, browserEnabled: true, + browserEnabledOverridden: false, toolProfile: "full", + toolProfileOverridden: false, }; } const gateway = asConfigRecord(root.gateway); @@ -191,7 +193,9 @@ function extractQuickSettingsSecurity(config: unknown): SecurityOverview { execPolicy: typeof security === "string" && security.trim() ? security.trim() : "allowlist", deviceAuth: controlUi?.dangerouslyDisableDeviceAuth !== true, browserEnabled: browser?.enabled !== false, + browserEnabledOverridden: browser !== null && Object.hasOwn(browser, "enabled"), toolProfile: typeof profile === "string" && profile.trim() ? profile.trim() : "full", + toolProfileOverridden: Object.hasOwn(tools, "profile"), }; } @@ -1049,9 +1053,22 @@ export class ConfigPage extends OpenClawLightDomElement { runtimeState.connected && hasOperatorAdminAccess(this.context.gateway.snapshot.hello?.auth ?? null), onPairMobile: () => void this.context.overlays.openDevicePairSetup(), - onBrowserEnabledToggle: (enabled) => - runtimeConfig.patchForm(["browser", "enabled"], enabled), - onToolProfileChange: (profile) => runtimeConfig.patchForm(["tools", "profile"], profile), + onBrowserEnabledToggle: (enabled) => { + if (enabled) { + runtimeConfig.removeFormValue(["browser", "enabled"]); + return; + } + runtimeConfig.patchForm(["browser", "enabled"], false); + }, + onBrowserEnabledReset: () => runtimeConfig.removeFormValue(["browser", "enabled"]), + onToolProfileChange: (profile) => { + if (profile === "full") { + runtimeConfig.removeFormValue(["tools", "profile"]); + return; + } + runtimeConfig.patchForm(["tools", "profile"], profile); + }, + onToolProfileReset: () => runtimeConfig.removeFormValue(["tools", "profile"]), editor: renderConfig({ ...props, embeddedEditor: true }), }); } diff --git a/ui/src/pages/config/security.test.ts b/ui/src/pages/config/security.test.ts index 0efa4373f265..d85a6ffdc816 100644 --- a/ui/src/pages/config/security.test.ts +++ b/ui/src/pages/config/security.test.ts @@ -48,7 +48,9 @@ function createProps(overrides: Partial = {}): SecurityViewPr execPolicy: "allowlist", deviceAuth: true, browserEnabled: true, + browserEnabledOverridden: true, toolProfile: "coding", + toolProfileOverridden: true, }, configBusy: false, canPairDevice: true, @@ -74,7 +76,9 @@ describe("renderSecurity", () => { execPolicy: "allowlist", deviceAuth: true, browserEnabled: false, + browserEnabledOverridden: true, toolProfile: "messaging", + toolProfileOverridden: true, }, onBrowserEnabledToggle, onToolProfileChange, @@ -127,7 +131,9 @@ describe("renderSecurity", () => { execPolicy: "allowlist", deviceAuth: true, browserEnabled: true, + browserEnabledOverridden: false, toolProfile: "full", + toolProfileOverridden: false, }, }), ), @@ -167,4 +173,67 @@ describe("renderSecurity", () => { expect(page).not.toBeNull(); expect(page?.querySelector("[data-testid='security-editor']")).not.toBeNull(); }); + + it("shows inherited defaults without reset actions", () => { + const container = document.createElement("div"); + + render( + renderSecurity( + createProps({ + security: { + gatewayAuth: "token", + execPolicy: "allowlist", + deviceAuth: true, + browserEnabled: true, + browserEnabledOverridden: false, + toolProfile: "full", + toolProfileOverridden: false, + }, + }), + ), + container, + ); + + expect(expectRowByTitle(container, "Browser enabled").textContent).toContain( + "Using default: Enabled", + ); + expect(expectRowByTitle(container, "Tool profile").textContent).toContain( + "Using default: Full", + ); + expect(container.querySelectorAll("button[aria-label='Reset to default']")).toHaveLength(0); + }); + + it("resets explicit browser and tool-profile overrides", () => { + const onBrowserEnabledReset = vi.fn(); + const onToolProfileReset = vi.fn(); + const container = document.createElement("div"); + + render( + renderSecurity( + createProps({ + security: { + gatewayAuth: "token", + execPolicy: "allowlist", + deviceAuth: true, + browserEnabled: true, + browserEnabledOverridden: true, + toolProfile: "full", + toolProfileOverridden: true, + }, + onBrowserEnabledReset, + onToolProfileReset, + }), + ), + container, + ); + + const browserRow = expectRowByTitle(container, "Browser enabled"); + const profileRow = expectRowByTitle(container, "Tool profile"); + expect(browserRow.textContent).toContain("Default: Enabled"); + expect(profileRow.textContent).toContain("Default: Full"); + browserRow.querySelector("button[aria-label='Reset to default']")?.click(); + profileRow.querySelector("button[aria-label='Reset to default']")?.click(); + expect(onBrowserEnabledReset).toHaveBeenCalledOnce(); + expect(onToolProfileReset).toHaveBeenCalledOnce(); + }); }); diff --git a/ui/src/pages/config/security.ts b/ui/src/pages/config/security.ts index fbf25833eb9e..8aaecb493b3e 100644 --- a/ui/src/pages/config/security.ts +++ b/ui/src/pages/config/security.ts @@ -5,6 +5,7 @@ import { html, type TemplateResult } from "lit"; import { icons } from "../../components/icons.ts"; import { renderDocsLink, + renderSettingsDefaultState, renderSettingsRow, renderSettingsSection, renderSettingsSegmented, @@ -22,7 +23,9 @@ export type SecurityOverview = { execPolicy: string; deviceAuth: boolean; browserEnabled: boolean; + browserEnabledOverridden: boolean; toolProfile: string; + toolProfileOverridden: boolean; }; type SecurityViewProps = { @@ -31,14 +34,36 @@ type SecurityViewProps = { canPairDevice: boolean; onPairMobile?: () => void; onBrowserEnabledToggle?: (enabled: boolean) => void; + onBrowserEnabledReset?: () => void; onToolProfileChange?: (profile: string) => void; + onToolProfileReset?: () => void; /** Embedded schema editor; it owns autosave status and the restart banner. */ editor: TemplateResult; }; function renderSecurityOverview(props: SecurityViewProps) { - const { gatewayAuth, execPolicy, deviceAuth, browserEnabled, toolProfile } = props.security; + const { + gatewayAuth, + execPolicy, + deviceAuth, + browserEnabled, + browserEnabledOverridden, + toolProfile, + toolProfileOverridden, + } = props.security; const normalizedToolProfile = toolProfile.trim() || "full"; + const browserDefaultState = renderSettingsDefaultState({ + value: t("common.enabled"), + overridden: browserEnabledOverridden, + disabled: props.configBusy, + onReset: () => props.onBrowserEnabledReset?.(), + }); + const toolProfileDefaultState = renderSettingsDefaultState({ + value: t("agents.toolCatalog.profiles.full"), + overridden: toolProfileOverridden, + disabled: props.configBusy, + onReset: () => props.onToolProfileReset?.(), + }); const profileOptions = PROFILE_OPTIONS.map((profile) => ({ value: profile.id as string, label: t(profile.labelKey), @@ -61,19 +86,25 @@ function renderSecurityOverview(props: SecurityViewProps) { }), renderSettingsToggleRow({ title: t("quickSettings.security.browserEnabled"), + description: browserDefaultState.description, checked: browserEnabled, disabled: props.configBusy, + actions: browserDefaultState.action, onChange: (enabled) => props.onBrowserEnabledToggle?.(enabled), }), renderSettingsRow({ title: t("quickSettings.security.toolProfile"), + description: toolProfileDefaultState.description, stacked: true, - control: renderSettingsSegmented({ - value: normalizedToolProfile, - options: profileOptions, - disabled: props.configBusy, - onChange: (profile) => props.onToolProfileChange?.(profile), - }), + control: html` + ${toolProfileDefaultState.action} + ${renderSettingsSegmented({ + value: normalizedToolProfile, + options: profileOptions, + disabled: props.configBusy, + onChange: (profile) => props.onToolProfileChange?.(profile), + })} + `, }), renderSettingsRow({ title: t("quickSettings.security.deviceAuth"), diff --git a/ui/src/pages/labs/labs-page.test.ts b/ui/src/pages/labs/labs-page.test.ts index a4c60196f73a..f21c8211e059 100644 --- a/ui/src/pages/labs/labs-page.test.ts +++ b/ui/src/pages/labs/labs-page.test.ts @@ -72,6 +72,16 @@ function codeModeToggle(page: LabsPageElement) { return labToggle(page, 0, "Code Mode"); } +function labRow(page: LabsPageElement, title: string) { + const row = [...page.querySelectorAll(".settings-row")].find( + (candidate) => candidate.querySelector(".settings-row__title")?.textContent?.trim() === title, + ); + if (!row) { + throw new Error(`${title} row not rendered`); + } + return row; +} + describe("LabsPage", () => { beforeEach(async () => { await i18n.setLocale("en"); @@ -138,7 +148,7 @@ describe("LabsPage", () => { label: "Code Mode", index: 0, sourceConfig: { tools: { codeMode: { enabled: false } } }, - expectedPatch: { tools: { codeMode: { enabled: "auto" } } }, + expectedPatch: { tools: { codeMode: { enabled: null } } }, note: "labs: update codeMode", }, { @@ -213,7 +223,7 @@ describe("LabsPage", () => { expect(labToggle(all.page, auditIndex, "audit").checked).toBe(true); }); - it("turns a broader audit mode off rather than narrowing it", async () => { + it("restores the default off mode from a broader audit mode", async () => { const auditIndex = LAB_FEATURES.findIndex((feature) => feature.id === "auditMessages"); const { page, runtimeConfig } = await mountPage({ logging: { audit: { messages: "all" } }, @@ -225,7 +235,7 @@ describe("LabsPage", () => { await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); expect(runtimeConfig.patch).toHaveBeenCalledWith({ - raw: { logging: { audit: { messages: "off" } } }, + raw: { logging: { audit: { messages: null } } }, note: "labs: update auditMessages", }); }); @@ -238,6 +248,58 @@ describe("LabsPage", () => { expect(restartRows).toHaveLength(1); expect(restartRows[0]?.textContent).toContain("Message audit metadata"); }); + + it("shows default provenance and reset actions only for overrides", async () => { + const inherited = await mountPage({}); + expect(labRow(inherited.page, "Code Mode").textContent).toContain("Using default: Enabled"); + expect(labRow(inherited.page, "Swarm").textContent).toContain("Using default: Disabled"); + expect(inherited.page.querySelectorAll("button[aria-label='Reset to default']")).toHaveLength( + 0, + ); + inherited.provider.remove(); + + const overridden = await mountPage({ + tools: { + codeMode: { enabled: "auto" }, + swarm: { enabled: false }, + }, + }); + expect(labRow(overridden.page, "Code Mode").textContent).toContain("Default: Enabled"); + expect(labRow(overridden.page, "Swarm").textContent).toContain("Default: Disabled"); + expect(overridden.page.querySelectorAll("button[aria-label='Reset to default']")).toHaveLength( + 2, + ); + }); + + it("restores an object gate without deleting sibling settings", async () => { + const { page, runtimeConfig } = await mountPage({ + tools: { loopDetection: { enabled: true, warningThreshold: 12 } }, + }); + + labRow(page, "Tool-loop detection") + .querySelector("button[aria-label='Reset to default']") + ?.click(); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { tools: { loopDetection: { enabled: null } } }, + note: "labs: update loopDetection", + }); + }); + + it("restores a shorthand gate at its owning parent path", async () => { + const { page, runtimeConfig } = await mountPage({ tools: { codeMode: "auto" } }); + + labRow(page, "Code Mode") + .querySelector("button[aria-label='Reset to default']") + ?.click(); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { tools: { codeMode: null } }, + note: "labs: update codeMode", + }); + }); }); describe("LabsPage code mode enablement", () => { @@ -279,6 +341,22 @@ describe("LabsPage code mode enablement", () => { note: "labs: update codeMode", }); }); + + it("restores the inherited auto tier instead of pinning it when re-enabled", async () => { + const { page, runtimeConfig } = await mountPage({ + tools: { codeMode: { enabled: false, timeoutMs: 5000 } }, + }); + const toggle = codeModeToggle(page); + + toggle.checked = true; + toggle.dispatchEvent(new Event("change", { bubbles: true, composed: true })); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { tools: { codeMode: { enabled: null } } }, + note: "labs: update codeMode", + }); + }); }); describe("LabsPage tool search enablement", () => { @@ -312,21 +390,52 @@ describe("LabsPage tool search enablement", () => { provider.remove(); }); - it("does not replace an operator's existing mode when already on", async () => { + it("restores a mode-only override at the Tool Search owner boundary", async () => { const { page, runtimeConfig } = await mountPage({ tools: { toolSearch: { mode: "tools" } }, }); const toggle = labToggle(page, toolSearchIndex, "Tool Search"); - // The row reads as on, so the only move available is turning it off — it - // cannot be clicked into overwriting `tools` with `directory`. expect(toggle.checked).toBe(true); + expect(labRow(page, "Tool Search").textContent).toContain("Default: Disabled"); toggle.checked = false; toggle.dispatchEvent(new Event("change", { bubbles: true, composed: true })); await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); expect(runtimeConfig.patch).toHaveBeenCalledWith({ - raw: { tools: { toolSearch: { enabled: false } } }, + raw: { tools: { toolSearch: null } }, + note: "labs: update toolSearch", + }); + }); + + it("enables an explicit-disabled override with the recommended mode", async () => { + const { page, runtimeConfig } = await mountPage({ + tools: { toolSearch: { enabled: false, mode: "tools" } }, + }); + const toggle = labToggle(page, toolSearchIndex, "Tool Search"); + + toggle.checked = true; + toggle.dispatchEvent(new Event("change", { bubbles: true, composed: true })); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { tools: { toolSearch: { enabled: true, mode: "directory" } } }, + note: "labs: update toolSearch", + }); + }); + + it("resets an explicit enabled override as a Tool Search unit", async () => { + const { page, runtimeConfig } = await mountPage({ + tools: { toolSearch: { enabled: true } }, + }); + + labRow(page, "Tool Search") + .querySelector("button[aria-label='Reset to default']") + ?.click(); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { tools: { toolSearch: null } }, note: "labs: update toolSearch", }); }); @@ -371,4 +480,20 @@ describe("LabsPage tool loop detection enablement", () => { note: "labs: update loopDetection", }); }); + + it("restores the disabled default instead of pinning false", async () => { + const { page, runtimeConfig } = await mountPage({ + tools: { loopDetection: { enabled: true, warningThreshold: 12 } }, + }); + const toggle = labToggle(page, loopDetectionIndex, "Tool-loop detection"); + + toggle.checked = false; + toggle.dispatchEvent(new Event("change", { bubbles: true, composed: true })); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { tools: { loopDetection: { enabled: null } } }, + note: "labs: update loopDetection", + }); + }); }); diff --git a/ui/src/pages/labs/labs-page.ts b/ui/src/pages/labs/labs-page.ts index e92cab811e67..69ffd8894b95 100644 --- a/ui/src/pages/labs/labs-page.ts +++ b/ui/src/pages/labs/labs-page.ts @@ -5,10 +5,11 @@ import { titleForRoute } from "../../app-navigation.ts"; import { applicationContext, type ApplicationContext } from "../../app/context.ts"; import { renderDocsLink, + renderSettingsDefaultState, renderSettingsPage, renderSettingsRow, renderSettingsSection, - renderSettingsToggle, + renderSettingsToggleRow, } from "../../components/settings-ui.ts"; import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; import { t } from "../../i18n/index.ts"; @@ -17,9 +18,10 @@ import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../../lib/external-l import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; import { - isLabFeatureEnabled, labFeatureMergePatch, + labFeatureResetPatch, LAB_FEATURES, + resolveLabFeatureState, type LabFeature, } from "./labs-registry.ts"; @@ -44,13 +46,17 @@ class LabsPage extends OpenClawLightDomElement { super.disconnectedCallback(); } + private editableConfig(): Record | null { + const snapshot = this.context?.runtimeConfig.state.configSnapshot; + return resolveEditableSnapshotConfig(snapshot); + } + private featureEnabled(feature: LabFeature): boolean { const pending = this.pendingValues[feature.id]; if (pending !== undefined) { return pending; } - const snapshot = this.context?.runtimeConfig.state.configSnapshot; - return isLabFeatureEnabled(resolveEditableSnapshotConfig(snapshot), feature); + return resolveLabFeatureState(this.editableConfig(), feature).enabled; } private canToggle(): boolean { @@ -69,7 +75,7 @@ class LabsPage extends OpenClawLightDomElement { this.pendingValues = next; } - private async setFeatureEnabled(feature: LabFeature, enabled: boolean) { + private async updateFeature(feature: LabFeature, enabled: boolean, raw: Record) { if (!this.canToggle()) { return; } @@ -79,7 +85,7 @@ class LabsPage extends OpenClawLightDomElement { this.saveError = null; try { const patched = await runtimeConfig.patch({ - raw: labFeatureMergePatch(feature, enabled), + raw, note: `labs: update ${feature.id}`, }); if (!patched) { @@ -99,23 +105,48 @@ class LabsPage extends OpenClawLightDomElement { } } + private setFeatureEnabled(feature: LabFeature, enabled: boolean) { + const config = this.editableConfig(); + const state = resolveLabFeatureState(config, feature); + const resetPatch = + enabled === state.defaultEnabled ? labFeatureResetPatch(config, feature) : null; + void this.updateFeature(feature, enabled, resetPatch ?? labFeatureMergePatch(feature, enabled)); + } + + private resetFeature(feature: LabFeature) { + const config = this.editableConfig(); + const state = resolveLabFeatureState(config, feature); + const resetPatch = labFeatureResetPatch(config, feature); + if (!resetPatch) { + return; + } + void this.updateFeature(feature, state.defaultEnabled, resetPatch); + } + private renderFeature(feature: LabFeature) { const title = feature.title(); + const state = resolveLabFeatureState(this.editableConfig(), feature); + const canToggle = this.canToggle(); + const defaultState = renderSettingsDefaultState({ + value: state.defaultEnabled ? t("common.enabled") : t("common.disabled"), + overridden: state.overridden, + disabled: !canToggle, + onReset: () => this.resetFeature(feature), + }); const description = html` ${feature.description()} ${t("labsPage.documentation")}${feature.restartHint ? html` ${feature.restartHint()}` : nothing} + ${defaultState.description} `; - return renderSettingsRow({ + return renderSettingsToggleRow({ title, description, - control: renderSettingsToggle({ - checked: this.featureEnabled(feature), - disabled: !this.canToggle(), - ariaLabel: title, - onChange: (enabled) => void this.setFeatureEnabled(feature, enabled), - }), + checked: this.featureEnabled(feature), + disabled: !canToggle, + actions: defaultState.action, + onChange: (enabled) => this.setFeatureEnabled(feature, enabled), }); } diff --git a/ui/src/pages/labs/labs-registry.ts b/ui/src/pages/labs/labs-registry.ts index cd3acccb0e30..0e9558e9b550 100644 --- a/ui/src/pages/labs/labs-registry.ts +++ b/ui/src/pages/labs/labs-registry.ts @@ -2,6 +2,7 @@ import { t } from "../../i18n/index.ts"; /** What a lab row writes at its gate. Most gates are booleans; some are modes. */ type LabFeatureValue = boolean | string; +type LabFeatureResetScope = "gate" | "parent"; export type LabFeature = { id: string; @@ -37,9 +38,21 @@ export type LabFeature = { * whatever a bare enable defaults to. */ enableAlso: Readonly> | null; + /** + * Ownership boundary for default provenance and reset. Most rows own only + * their gate; features whose runtime default depends on any parent config + * own and reset that parent as a unit. + */ + resetScope: LabFeatureResetScope; restartHint: (() => string) | null; }; +type LabFeatureState = { + enabled: boolean; + defaultEnabled: boolean; + overridden: boolean; +}; + export const LAB_FEATURES = [ { id: "codeMode", @@ -65,6 +78,7 @@ export const LAB_FEATURES = [ return true; }, enableAlso: null, + resetScope: "gate", restartHint: null, }, { @@ -78,6 +92,7 @@ export const LAB_FEATURES = [ activeValues: [true], readEnabled: null, enableAlso: null, + resetScope: "gate", restartHint: null, }, { @@ -109,6 +124,7 @@ export const LAB_FEATURES = [ // form, which is the surface with the weakest recall. Pin the bounded // directory instead, so enabling from Labs is the variant we recommend. enableAlso: { mode: "directory" }, + resetScope: "parent", restartHint: null, }, { @@ -124,6 +140,7 @@ export const LAB_FEATURES = [ // resolveToolLoopDetectionConfig reads this enabled leaf directly. readEnabled: null, enableAlso: null, + resetScope: "gate", restartHint: null, }, { @@ -137,6 +154,7 @@ export const LAB_FEATURES = [ activeValues: [true], readEnabled: null, enableAlso: null, + resetScope: "gate", restartHint: null, }, { @@ -153,6 +171,7 @@ export const LAB_FEATURES = [ activeValues: ["direct", "all"], readEnabled: null, enableAlso: null, + resetScope: "gate", // startGatewayEventSubscriptions resolves the mode once and bakes it into // the recorder, so this outlives the reload plan's `logging: none` rule. restartHint: () => t("labsPage.restartRequired"), @@ -170,16 +189,8 @@ function recordAtPath(config: Record, path: readonly string[]): return current; } -export function isLabFeatureEnabled( - config: Record | null, - feature: LabFeature, -): boolean { - if (!config) { - return false; - } - const parentPath = feature.configPath.slice(0, -1); +function readEnabledFromParent(feature: LabFeature, parent: unknown): boolean { const key = feature.configPath.at(-1); - const parent = recordAtPath(config, parentPath); if (feature.readEnabled) { return feature.readEnabled(parent); } @@ -194,6 +205,68 @@ export function isLabFeatureEnabled( return feature.activeValues.includes((parent as Record)[key] as LabFeatureValue); } +function labFeatureOverridePath( + config: Record, + feature: LabFeature, +): readonly string[] | null { + const parentPath = feature.configPath.slice(0, -1); + const key = feature.configPath.at(-1); + const parent = recordAtPath(config, parentPath); + if (!key) { + return null; + } + if (feature.resetScope === "parent") { + return parent === undefined ? null : parentPath; + } + // Boolean/string shorthands own the parent node rather than an `enabled` + // child. Resetting the child would replace the shorthand with an empty object. + if ( + key === "enabled" && + parent !== undefined && + (typeof parent !== "object" || parent === null) + ) { + return parentPath; + } + if ( + parent && + typeof parent === "object" && + !Array.isArray(parent) && + Object.hasOwn(parent, key) + ) { + return feature.configPath; + } + return null; +} + +export function resolveLabFeatureState( + config: Record | null, + feature: LabFeature, +): LabFeatureState { + const source = config ?? {}; + const parentPath = feature.configPath.slice(0, -1); + const key = feature.configPath.at(-1); + const parent = recordAtPath(source, parentPath); + const overridePath = labFeatureOverridePath(source, feature); + let defaultParent = parent; + if (overridePath?.length === parentPath.length) { + defaultParent = undefined; + } else if ( + overridePath && + key && + parent && + typeof parent === "object" && + !Array.isArray(parent) + ) { + defaultParent = { ...(parent as Record) }; + delete (defaultParent as Record)[key]; + } + return { + enabled: readEnabledFromParent(feature, parent), + defaultEnabled: readEnabledFromParent(feature, defaultParent), + overridden: overridePath !== null, + }; +} + export function labFeatureMergePatch( feature: LabFeature, enabled: boolean, @@ -210,3 +283,18 @@ export function labFeatureMergePatch( } return patch as Record; } + +export function labFeatureResetPatch( + config: Record | null, + feature: LabFeature, +): Record | null { + const path = labFeatureOverridePath(config ?? {}, feature); + if (!path?.length) { + return null; + } + let patch: unknown = null; + for (const segment of path.toReversed()) { + patch = { [segment]: patch }; + } + return patch as Record; +} diff --git a/ui/src/pages/model-providers/model-providers-page.test.ts b/ui/src/pages/model-providers/model-providers-page.test.ts index ee350cf6a555..be5da1bd7575 100644 --- a/ui/src/pages/model-providers/model-providers-page.test.ts +++ b/ui/src/pages/model-providers/model-providers-page.test.ts @@ -99,6 +99,7 @@ function createHarness(initialScopeId: string) { }, ensureLoaded: vi.fn(async () => undefined), patchForm: vi.fn(), + removeFormValue: vi.fn(), save: vi.fn(async () => true), apply: vi.fn(async () => true), discardDraft: vi.fn(async () => undefined), @@ -190,6 +191,32 @@ describe("ModelProvidersPage agent scope", () => { ); }); + it("removes thinking and fast overrides through the shared config draft", async () => { + const { context, runtimeConfig } = createHarness("main"); + const page = appendPage(context); + await vi.waitFor(() => expect(page.querySelector("#settings-model-behavior")).not.toBeNull()); + + const groups = page.querySelectorAll( + "#settings-model-behavior wa-radio-group", + ); + expect(groups).toHaveLength(2); + groups[0]!.value = ""; + groups[0]!.dispatchEvent(new Event("change", { bubbles: true })); + groups[1]!.value = ""; + groups[1]!.dispatchEvent(new Event("change", { bubbles: true })); + + expect(runtimeConfig.removeFormValue).toHaveBeenNthCalledWith(1, [ + "agents", + "defaults", + "thinkingDefault", + ]); + expect(runtimeConfig.removeFormValue).toHaveBeenNthCalledWith(2, [ + "agents", + "defaults", + "fastModeDefault", + ]); + }); + it("reloads credential status when the agent selector changes", async () => { const { agentSelection, context, notifySelection, request } = createHarness("main"); const page = appendPage(context); diff --git a/ui/src/pages/model-providers/model-providers-page.ts b/ui/src/pages/model-providers/model-providers-page.ts index 66c3e498e37c..2af2262065e8 100644 --- a/ui/src/pages/model-providers/model-providers-page.ts +++ b/ui/src/pages/model-providers/model-providers-page.ts @@ -586,9 +586,11 @@ export class ModelProvidersPage extends OpenClawLightDomElement { {}; const agentsDefaults = asConfigRecord(asConfigRecord(configObject.agents)?.defaults); const thinkingLevel = - typeof agentsDefaults?.thinkingDefault === "string" ? agentsDefaults.thinkingDefault : "off"; + typeof agentsDefaults?.thinkingDefault === "string" + ? agentsDefaults.thinkingDefault + : undefined; const fastValue = agentsDefaults?.fastModeDefault; - const fastMode = fastValue === "auto" || typeof fastValue === "boolean" ? fastValue : false; + const fastMode = fastValue === "auto" || typeof fastValue === "boolean" ? fastValue : undefined; const update = this.context.overlays.snapshot; // The overlay update states replace General's old configUpdating prop, // which config-page derived from this same snapshot (isUpdateBusy); the @@ -696,8 +698,12 @@ export class ModelProvidersPage extends OpenClawLightDomElement { }, onThinkingChange: (level) => runtimeConfig.patchForm(["agents", "defaults", "thinkingDefault"], level), + onThinkingReset: () => + runtimeConfig.removeFormValue(["agents", "defaults", "thinkingDefault"]), onFastModeChange: (mode: FastMode) => runtimeConfig.patchForm(["agents", "defaults", "fastModeDefault"], mode), + onFastModeReset: () => + runtimeConfig.removeFormValue(["agents", "defaults", "fastModeDefault"]), onOpenModelSetup: () => this.context.navigate("model-setup"), }); return html` diff --git a/ui/src/pages/model-providers/view.test.ts b/ui/src/pages/model-providers/view.test.ts index 02718444dccb..5589f1609627 100644 --- a/ui/src/pages/model-providers/view.test.ts +++ b/ui/src/pages/model-providers/view.test.ts @@ -74,7 +74,9 @@ function props(overrides: Partial = {}): ModelProviders onDefaultModelsSave: () => undefined, onDefaultModelsReset: () => undefined, onThinkingChange: () => undefined, + onThinkingReset: () => undefined, onFastModeChange: () => undefined, + onFastModeReset: () => undefined, onOpenModelSetup: () => undefined, ...overrides, }; @@ -147,6 +149,7 @@ describe("renderModelProviders", () => { expect(thinking?.value).toBe("low"); expect(fastMode?.value).toBe("auto"); expect([...fastMode!.querySelectorAll("wa-radio")].map((entry) => text(entry))).toEqual([ + "Default", "Auto", "Fast", "Standard", @@ -158,6 +161,63 @@ describe("renderModelProviders", () => { expect(onFastModeChange).toHaveBeenCalledWith(false); }); + it("shows inherited model policy, restores overrides, and preserves advanced thinking", () => { + const onThinkingReset = vi.fn(); + const onFastModeReset = vi.fn(); + const container = mount( + props({ + thinkingLevel: "adaptive", + fastMode: true, + onThinkingReset, + onFastModeReset, + }), + ); + const behavior = container.querySelector("#settings-model-behavior")!; + const thinkingRow = settingsRow(behavior, "Thinking"); + const fastRow = settingsRow(behavior, "Fast mode"); + + expect(thinkingRow.querySelector("wa-radio-group")?.value).toBe("adaptive"); + expect(text(thinkingRow)).toContain("Adaptive"); + expect(text(thinkingRow)).toContain("Default: Model policy"); + expect(text(fastRow)).toContain("Default: Model policy"); + + thinkingRow.querySelector('button[aria-label="Reset to default"]')?.click(); + fastRow.querySelector('button[aria-label="Reset to default"]')?.click(); + expect(onThinkingReset).toHaveBeenCalledOnce(); + expect(onFastModeReset).toHaveBeenCalledOnce(); + + selectSegment(thinkingRow.querySelector("wa-radio-group")!, ""); + selectSegment(fastRow.querySelector("wa-radio-group")!, ""); + expect(onThinkingReset).toHaveBeenCalledTimes(2); + expect(onFastModeReset).toHaveBeenCalledTimes(2); + + render( + renderModelProviders(props({ thinkingLevel: undefined, fastMode: undefined })), + container, + ); + const inheritedBehavior = container.querySelector("#settings-model-behavior")!; + const inheritedThinking = settingsRow(inheritedBehavior, "Thinking"); + const inheritedFast = settingsRow(inheritedBehavior, "Fast mode"); + expect(inheritedThinking.querySelector("wa-radio-group")?.value).toBe(""); + expect(inheritedFast.querySelector("wa-radio-group")?.value).toBe(""); + expect( + ( + inheritedThinking.querySelector('wa-radio[value=""]') as HTMLElement & { + checked: boolean; + } + ).checked, + ).toBe(true); + expect( + (inheritedFast.querySelector('wa-radio[value=""]') as HTMLElement & { checked: boolean }) + .checked, + ).toBe(true); + expect(text(inheritedThinking)).toContain("Using default: Model policy"); + expect(text(inheritedFast)).toContain("Using default: Model policy"); + expect( + inheritedBehavior.querySelectorAll('button[aria-label="Reset to default"]'), + ).toHaveLength(0); + }); + it("locks model behavior while shared config work is pending", () => { const container = mount(props({ configBusy: true })); const behavior = container.querySelector("#settings-model-behavior"); diff --git a/ui/src/pages/model-providers/view.ts b/ui/src/pages/model-providers/view.ts index 6141598a3ddf..7016a28221c0 100644 --- a/ui/src/pages/model-providers/view.ts +++ b/ui/src/pages/model-providers/view.ts @@ -6,6 +6,7 @@ import { renderProviderBrandIcon } from "../../components/provider-icon.ts"; import { renderProviderUsageDetails } from "../../components/provider-usage.ts"; import { renderSettingsEmpty, + renderSettingsDefaultState, renderSettingsGroup, renderSettingsPage, renderSettingsRow, @@ -15,7 +16,7 @@ import { renderSettingsValue, } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; -import { BASE_THINKING_LEVELS } from "../../lib/chat/thinking.ts"; +import { BASE_THINKING_LEVELS, formatThinkingOverrideLabel } from "../../lib/chat/thinking.ts"; import { formatCost, formatTimeMs, formatTokens } from "../../lib/format.ts"; import { MODEL_SETTINGS_TARGET_IDS } from "../config/settings-targets.ts"; import "../../styles/model-providers.css"; @@ -44,7 +45,7 @@ type ModelProvidersViewProps = { configuredModels: ModelPickerEntry[]; defaultModels: DefaultModelSelection; defaultModelsDirty: boolean; - thinkingLevel: string; + thinkingLevel: string | undefined; fastMode: FastMode | undefined; configBusy: boolean; unconfiguredProviders: ProviderOption[]; @@ -80,52 +81,88 @@ type ModelProvidersViewProps = { onUtilityChange: (model: string | null) => void; onDefaultModelsSave: () => void; onDefaultModelsReset: () => void; - onThinkingChange: (level: string) => void; + onThinkingChange: (level: string, element: HTMLElement) => void; + onThinkingReset: () => void; onFastModeChange: (mode: FastMode) => void; + onFastModeReset: () => void; onOpenModelSetup: () => void; }; // The global default intentionally omits "minimal"; the full list stays // available on session-level pickers. const THINKING_LEVELS = BASE_THINKING_LEVELS.filter((level) => level !== "minimal"); +const THINKING_LEVEL_SET = new Set(THINKING_LEVELS); function fastModeOptionValue(value: "auto" | "on" | "off"): FastMode { return value === "auto" ? "auto" : value === "on"; } function renderModelBehavior(props: ModelProvidersViewProps) { - const fastMode = formatFastModeValue(props.fastMode); + const thinkingLevels = + props.thinkingLevel && !THINKING_LEVEL_SET.has(props.thinkingLevel) + ? [...THINKING_LEVELS, props.thinkingLevel] + : THINKING_LEVELS; + const thinkingDefault = renderSettingsDefaultState({ + value: t("quickSettings.model.modelPolicy"), + overridden: props.thinkingLevel !== undefined, + disabled: props.configBusy, + onReset: props.onThinkingReset, + }); + const fastDefault = renderSettingsDefaultState({ + value: t("quickSettings.model.modelPolicy"), + overridden: props.fastMode !== undefined, + disabled: props.configBusy, + onReset: props.onFastModeReset, + }); + const fastMode = props.fastMode === undefined ? "" : formatFastModeValue(props.fastMode); return html`
${renderSettingsSection({ title: t("quickSettings.model.title") }, [ renderSettingsRow({ title: t("quickSettings.model.thinking"), - control: renderSettingsSegmented({ - value: props.thinkingLevel, - options: THINKING_LEVELS.map((level) => ({ - value: level, - label: t(`quickSettings.model.thinkingLevels.${level}`), - })), - disabled: props.configBusy, - onChange: props.onThinkingChange, - }), + description: thinkingDefault.description, + control: html` + ${renderSettingsSegmented({ + value: props.thinkingLevel ?? "", + options: [ + { value: "", label: t("quickSettings.model.default") }, + ...thinkingLevels.map((level) => ({ + value: level, + label: THINKING_LEVEL_SET.has(level) + ? t(`quickSettings.model.thinkingLevels.${level}`) + : formatThinkingOverrideLabel(level), + })), + ], + disabled: props.configBusy, + onChange: (value, element) => + value === "" ? props.onThinkingReset() : props.onThinkingChange(value, element), + })} + ${thinkingDefault.action} + `, }), renderSettingsRow({ title: t("quickSettings.model.fastMode"), - control: renderSettingsSegmented<"auto" | "on" | "off">({ - value: fastMode, - options: [ - { value: "auto", label: t("quickSettings.model.fastModes.auto") }, - { value: "on", label: t("quickSettings.model.fastModes.fast") }, - { value: "off", label: t("quickSettings.model.fastModes.standard") }, - ], - disabled: props.configBusy, - onChange: (value) => { - if (value !== fastMode) { - props.onFastModeChange(fastModeOptionValue(value)); - } - }, - }), + description: fastDefault.description, + control: html` + ${renderSettingsSegmented<"" | "auto" | "on" | "off">({ + value: fastMode, + options: [ + { value: "", label: t("quickSettings.model.default") }, + { value: "auto", label: t("quickSettings.model.fastModes.auto") }, + { value: "on", label: t("quickSettings.model.fastModes.fast") }, + { value: "off", label: t("quickSettings.model.fastModes.standard") }, + ], + disabled: props.configBusy, + onChange: (value) => { + if (value === "") { + props.onFastModeReset(); + } else if (value !== fastMode) { + props.onFastModeChange(fastModeOptionValue(value)); + } + }, + })} + ${fastDefault.action} + `, }), ])}
From 466c987264eb5bf17a23c7bfe9e43726b62a401e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 11:17:10 +0800 Subject: [PATCH 007/347] test(ui): prove curated settings defaults --- .../e2e/curated-settings-defaults.e2e.test.ts | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 ui/src/e2e/curated-settings-defaults.e2e.test.ts diff --git a/ui/src/e2e/curated-settings-defaults.e2e.test.ts b/ui/src/e2e/curated-settings-defaults.e2e.test.ts new file mode 100644 index 000000000000..a8d221bd19fe --- /dev/null +++ b/ui/src/e2e/curated-settings-defaults.e2e.test.ts @@ -0,0 +1,295 @@ +// Control UI tests cover inherited defaults across curated settings pages. +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { chromium, type Browser, type Locator, type Page } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + canRunPlaywrightChromium, + installMockGateway, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, + type ControlUiE2eServer, + type MockGatewayRequest, +} from "../test-helpers/control-ui-e2e.ts"; + +const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); +const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; +const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; + +const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; +const uiProofArtifactDir = path.join( + process.cwd(), + ".artifacts", + "control-ui-e2e", + "curated-settings-defaults", +); + +let browser: Browser; +let server: ControlUiE2eServer; + +function configResponse(config: Record, hash: string) { + return { + appliedConfigHash: hash, + config, + hash, + issues: [], + raw: JSON.stringify(config), + valid: true, + }; +} + +function requestRaw(request: MockGatewayRequest): Record { + const params = request.params; + if (!params || typeof params !== "object" || Array.isArray(params)) { + throw new Error("Expected config mutation params"); + } + return JSON.parse(String((params as Record).raw)) as Record; +} + +function hasOwnPath(value: Record, pathSegments: readonly string[]): boolean { + let current: unknown = value; + for (const [index, segment] of pathSegments.entries()) { + if (!current || typeof current !== "object" || Array.isArray(current)) { + return false; + } + if (!Object.hasOwn(current, segment)) { + return false; + } + current = (current as Record)[segment]; + if (index === pathSegments.length - 1) { + return true; + } + } + return false; +} + +function settingsRow(page: Page, title: string): Locator { + return page.locator(".settings-row").filter({ + has: page.locator(".settings-row__title", { hasText: title }), + }); +} + +async function expectInherited(row: Locator, value: string) { + await expect.poll(() => row.textContent()).toContain(`Using default: ${value}`); + await expect.poll(() => row.getByRole("button", { name: "Reset to default" }).count()).toBe(0); +} + +describeControlUiE2e("Control UI curated settings defaults mocked Gateway E2E", () => { + beforeAll(async () => { + if (!chromiumAvailable) { + throw new Error( + `Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, + ); + } + server = await startControlUiE2eServer(); + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + }); + + afterAll(async () => { + await browser?.close(); + await server?.close(); + }); + + it("restores Labs, Security, and Models overrides to inherited defaults across reloads", async () => { + const context = await browser.newContext({ + colorScheme: "dark", + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 1000, width: 1440 }, + }); + const page = await context.newPage(); + const initialConfig = { + agents: { + defaults: { + fastModeDefault: true, + model: "openai/gpt-5.5", + thinkingDefault: "high", + }, + }, + browser: { enabled: false }, + tools: { + codeMode: { enabled: false }, + profile: "minimal", + }, + }; + const afterLabsReset = { + agents: initialConfig.agents, + browser: initialConfig.browser, + tools: { profile: "minimal" }, + }; + const gateway = await installMockGateway(page, { + methodResponses: { + "config.get": configResponse(initialConfig, "curated-defaults-1"), + }, + }); + + try { + expect((await page.goto(`${server.baseUrl}settings/labs`))?.status()).toBe(200); + const codeModeRow = settingsRow(page, "Code Mode"); + const codeModeSwitch = codeModeRow.getByRole("switch", { name: "Code Mode", exact: true }); + await codeModeSwitch.waitFor(); + expect(await codeModeSwitch.getAttribute("aria-checked")).toBe("false"); + await expect.poll(() => codeModeRow.textContent()).toContain("Default: Enabled"); + + if (captureUiProofEnabled) { + await mkdir(uiProofArtifactDir, { recursive: true }); + await codeModeRow.screenshot({ + animations: "disabled", + path: path.join(uiProofArtifactDir, "01-labs-explicit-override.png"), + }); + } + + const configGetsBeforeLabsReset = (await gateway.getRequests("config.get")).length; + await gateway.deferNext("config.patch"); + await codeModeRow.getByRole("button", { name: "Reset to default" }).click(); + const labsPatch = requestRaw(await gateway.waitForRequest("config.patch")); + expect(labsPatch).toEqual({ tools: { codeMode: { enabled: null } } }); + + const afterLabsResponse = configResponse(afterLabsReset, "curated-defaults-2"); + await gateway.setMethodResponse("config.get", afterLabsResponse); + await gateway.resolveDeferred("config.patch", afterLabsResponse); + await expect + .poll(async () => (await gateway.getRequests("config.get")).length) + .toBe(configGetsBeforeLabsReset + 1); + await expectInherited(codeModeRow, "Enabled"); + expect(await codeModeSwitch.getAttribute("aria-checked")).toBe("true"); + + if (captureUiProofEnabled) { + await codeModeRow.screenshot({ + animations: "disabled", + path: path.join(uiProofArtifactDir, "02-labs-inherited-default.png"), + }); + } + + expect((await page.goto(`${server.baseUrl}settings/security`))?.status()).toBe(200); + const browserRow = settingsRow(page, "Browser enabled"); + const profileRow = settingsRow(page, "Tool profile"); + await browserRow.getByRole("switch", { name: "Browser enabled", exact: true }).waitFor(); + await expect.poll(() => browserRow.textContent()).toContain("Default: Enabled"); + await expect.poll(() => profileRow.textContent()).toContain("Default: Full"); + + if (captureUiProofEnabled) { + await page + .locator(".security-page .settings-section") + .first() + .screenshot({ + animations: "disabled", + path: path.join(uiProofArtifactDir, "03-security-explicit-overrides.png"), + }); + } + + const securitySavesBefore = (await gateway.getRequests("config.set")).length; + await browserRow.getByRole("button", { name: "Reset to default" }).click(); + await profileRow.getByRole("button", { name: "Reset to default" }).click(); + await expectInherited(browserRow, "Enabled"); + await expectInherited(profileRow, "Full"); + await expect + .poll(async () => { + const requests = await gateway.getRequests("config.set"); + const latest = requests.at(-1); + if (requests.length <= securitySavesBefore || !latest) { + return false; + } + const raw = requestRaw(latest); + return !hasOwnPath(raw, ["browser", "enabled"]) && !hasOwnPath(raw, ["tools", "profile"]); + }) + .toBe(true); + + if (captureUiProofEnabled) { + await page + .locator(".security-page .settings-section") + .first() + .screenshot({ + animations: "disabled", + path: path.join(uiProofArtifactDir, "04-security-inherited-defaults.png"), + }); + } + + expect((await page.reload())?.status()).toBe(200); + await expectInherited(settingsRow(page, "Browser enabled"), "Enabled"); + await expectInherited(settingsRow(page, "Tool profile"), "Full"); + + expect((await page.goto(`${server.baseUrl}settings/model-providers`))?.status()).toBe(200); + const thinkingRow = settingsRow(page, "Thinking"); + const fastModeRow = settingsRow(page, "Fast mode"); + await thinkingRow.getByRole("radio", { name: "High", exact: true }).waitFor(); + expect( + await thinkingRow + .getByRole("radio", { name: "High", exact: true }) + .getAttribute("aria-checked"), + ).toBe("true"); + await expect.poll(() => thinkingRow.textContent()).toContain("Default: Model policy"); + await expect.poll(() => fastModeRow.textContent()).toContain("Default: Model policy"); + + if (captureUiProofEnabled) { + await page.locator("#settings-model-behavior").screenshot({ + animations: "disabled", + path: path.join(uiProofArtifactDir, "05-models-explicit-overrides.png"), + }); + } + + const modelSavesBefore = (await gateway.getRequests("config.set")).length; + await thinkingRow.getByRole("button", { name: "Reset to default" }).click(); + await fastModeRow.getByRole("button", { name: "Reset to default" }).click(); + await expectInherited(thinkingRow, "Model policy"); + await expectInherited(fastModeRow, "Model policy"); + await expect + .poll(async () => { + const requests = await gateway.getRequests("config.set"); + const latest = requests.at(-1); + if (requests.length <= modelSavesBefore || !latest) { + return false; + } + const raw = requestRaw(latest); + return ( + !hasOwnPath(raw, ["agents", "defaults", "thinkingDefault"]) && + !hasOwnPath(raw, ["agents", "defaults", "fastModeDefault"]) + ); + }) + .toBe(true); + + if (captureUiProofEnabled) { + await page.locator("#settings-model-behavior").screenshot({ + animations: "disabled", + path: path.join(uiProofArtifactDir, "06-models-inherited-defaults.png"), + }); + } + + expect((await page.reload())?.status()).toBe(200); + const reloadedThinkingRow = settingsRow(page, "Thinking"); + const reloadedFastModeRow = settingsRow(page, "Fast mode"); + await expectInherited(reloadedThinkingRow, "Model policy"); + await expectInherited(reloadedFastModeRow, "Model policy"); + expect( + await reloadedThinkingRow + .getByRole("radio", { name: "Default", exact: true }) + .getAttribute("aria-checked"), + ).toBe("true"); + expect( + await reloadedFastModeRow + .getByRole("radio", { name: "Default", exact: true }) + .getAttribute("aria-checked"), + ).toBe("true"); + + expect((await page.goto(`${server.baseUrl}settings/labs`))?.status()).toBe(200); + const reloadedCodeModeRow = settingsRow(page, "Code Mode"); + await expectInherited(reloadedCodeModeRow, "Enabled"); + expect( + await reloadedCodeModeRow + .getByRole("switch", { name: "Code Mode", exact: true }) + .getAttribute("aria-checked"), + ).toBe("true"); + + if (captureUiProofEnabled) { + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(uiProofArtifactDir, "07-reload-inherited-defaults.png"), + }); + } + } finally { + await context.close(); + } + }); +}); From abea6345b51c404c0065853b517578f7470e504c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 11:27:12 +0800 Subject: [PATCH 008/347] fix(ui): restore memory settings defaults --- ui/src/i18n/locales/en.ts | 2 + ui/src/pages/agents/memory/dreaming.test.ts | 10 ++ ui/src/pages/agents/memory/dreaming.ts | 5 +- .../pages/agents/memory/memory-panel.test.ts | 74 ++++++++++++++- ui/src/pages/agents/memory/memory-panel.ts | 63 ++++++++++++- ui/src/pages/config/memory-defaults.test.ts | 49 ++++++++++ ui/src/pages/config/memory-defaults.ts | 33 +++++++ ui/src/pages/config/memory-dreaming.test.ts | 56 +++++++++++- ui/src/pages/config/memory-dreaming.ts | 91 ++++++++++++++++--- ui/src/pages/config/memory-page.test.ts | 2 +- ui/src/pages/config/memory-page.ts | 38 ++++---- ui/src/pages/config/memory-schema.ts | 16 +++- ui/src/pages/config/memory.test.ts | 64 ++++++++++++- ui/src/pages/config/memory.ts | 75 ++++++++++----- 14 files changed, 509 insertions(+), 69 deletions(-) create mode 100644 ui/src/pages/config/memory-defaults.test.ts create mode 100644 ui/src/pages/config/memory-defaults.ts diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 87078023d168..c807dd3d808a 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2490,11 +2490,13 @@ export const en: TranslationMap = { label: "Timezone", help: "IANA timezone used to interpret the cron cadence.", placeholder: "Europe/Vienna", + default: "Gateway local timezone", }, model: { label: "Dreaming model", help: "Provider/model override for dream diary narration. Requires subagent model overrides to be allowed.", placeholder: "anthropic/claude-sonnet-4-6", + default: "Agent model", }, verboseLogging: { label: "Verbose logging", diff --git a/ui/src/pages/agents/memory/dreaming.test.ts b/ui/src/pages/agents/memory/dreaming.test.ts index de25febdfdaa..b4f5a7f586ff 100644 --- a/ui/src/pages/agents/memory/dreaming.test.ts +++ b/ui/src/pages/agents/memory/dreaming.test.ts @@ -1018,6 +1018,7 @@ describe("dreaming controller", () => { ).toEqual({ pluginId: "memos-local-openclaw-plugin", enabled: true, + overridden: true, }); }); @@ -1042,6 +1043,15 @@ describe("dreaming controller", () => { ).toEqual({ pluginId: "memory-core", enabled: true, + overridden: true, + }); + }); + + it("uses the runtime enabled default when config omits the override", () => { + expect(resolveConfiguredDreaming(null)).toEqual({ + pluginId: "memory-core", + enabled: true, + overridden: false, }); }); diff --git a/ui/src/pages/agents/memory/dreaming.ts b/ui/src/pages/agents/memory/dreaming.ts index 6117ee1b06b1..239f4c2f98f8 100644 --- a/ui/src/pages/agents/memory/dreaming.ts +++ b/ui/src/pages/agents/memory/dreaming.ts @@ -467,6 +467,7 @@ function resolveDreamingPluginId(configValue: Record | null): s export function resolveConfiguredDreaming(configValue: Record | null): { pluginId: string; enabled: boolean; + overridden: boolean; } { const pluginId = resolveDreamingPluginId(configValue); const plugins = asRecord(configValue?.plugins); @@ -474,9 +475,11 @@ export function resolveConfiguredDreaming(configValue: Record | const pluginEntry = asRecord(entries?.[pluginId]); const config = asRecord(pluginEntry?.config); const dreaming = asRecord(config?.dreaming); + const overridden = typeof dreaming?.enabled === "boolean"; return { pluginId, - enabled: normalizeBoolean(dreaming?.enabled, false), + enabled: normalizeBoolean(dreaming?.enabled, true), + overridden, }; } diff --git a/ui/src/pages/agents/memory/memory-panel.test.ts b/ui/src/pages/agents/memory/memory-panel.test.ts index 66ffcb9d38b6..2dac03e136d4 100644 --- a/ui/src/pages/agents/memory/memory-panel.test.ts +++ b/ui/src/pages/agents/memory/memory-panel.test.ts @@ -23,6 +23,11 @@ type TestMemoryPanel = HTMLElement & { applyGatewaySnapshot: (snapshot: ApplicationGatewaySnapshot) => void; loadAll: () => Promise; openWikiPage: (lookup: string) => Promise; + resetEnabledOverride: (configured: { + pluginId: string; + enabled: boolean; + overridden: boolean; + }) => Promise; render: () => unknown; requestUpdate: () => void; readonly updateComplete: Promise; @@ -60,7 +65,11 @@ function deferred() { return { promise, resolve }; } -function contextWithGateway(client: GatewayBrowserClient, connected: boolean): ApplicationContext { +function contextWithGateway( + client: GatewayBrowserClient, + connected: boolean, + configForm: Record | null = null, +): ApplicationContext { const snapshot: ApplicationGatewaySnapshot = { client, phase: connected ? "connected" : "stopped", @@ -80,8 +89,11 @@ function contextWithGateway(client: GatewayBrowserClient, connected: boolean): A subscribe, }, runtimeConfig: { - state: { configSnapshot: null }, + state: { configForm, configSnapshot: null }, refresh: vi.fn(async () => undefined), + removeFormValue: vi.fn(), + waitForPendingWrites: vi.fn(async () => undefined), + save: vi.fn(async () => true), subscribe, }, } as unknown as ApplicationContext; @@ -233,6 +245,64 @@ describe("AgentMemoryPanel gateway lifecycle", () => { }); }); + it("resets the config-only dreaming override to the enabled runtime default", async () => { + const client = { + request: vi.fn(async () => ({ dreaming: { enabled: true } })), + } as unknown as GatewayBrowserClient; + const context = contextWithGateway(client, true, { + plugins: { + entries: { + "memory-core": { config: { dreaming: { enabled: false } } }, + }, + }, + }); + const page = createPage(context); + document.body.append(page); + await page.updateComplete; + + await page.resetEnabledOverride({ + pluginId: "memory-core", + enabled: false, + overridden: true, + }); + + expect(context.runtimeConfig.removeFormValue).toHaveBeenCalledWith([ + "plugins", + "entries", + "memory-core", + "config", + "dreaming", + "enabled", + ]); + expect(context.runtimeConfig.save).toHaveBeenCalledOnce(); + expect(context.runtimeConfig.refresh).toHaveBeenCalledOnce(); + }); + + it("does not refresh the stale override when saving a reset fails", async () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const context = contextWithGateway(client, true, { + plugins: { + entries: { + "memory-core": { config: { dreaming: { enabled: false } } }, + }, + }, + }); + vi.mocked(context.runtimeConfig.save).mockResolvedValue(false); + const page = createPage(context); + document.body.append(page); + await page.updateComplete; + + await page.resetEnabledOverride({ + pluginId: "memory-core", + enabled: false, + overridden: true, + }); + + expect(context.runtimeConfig.removeFormValue).toHaveBeenCalledOnce(); + expect(context.runtimeConfig.save).toHaveBeenCalledOnce(); + expect(context.runtimeConfig.refresh).not.toHaveBeenCalled(); + }); + it("uses localized empty content for wiki previews", async () => { const client = { request: vi.fn(async () => ({ title: "Empty", path: "empty.md", content: "" })), diff --git a/ui/src/pages/agents/memory/memory-panel.ts b/ui/src/pages/agents/memory/memory-panel.ts index 9bcdec7833c4..a117fdc88125 100644 --- a/ui/src/pages/agents/memory/memory-panel.ts +++ b/ui/src/pages/agents/memory/memory-panel.ts @@ -7,6 +7,7 @@ import { type ApplicationGateway, type ApplicationGatewaySnapshot, } from "../../../app/context.ts"; +import { renderSettingsDefaultState } from "../../../components/settings-ui.ts"; import { t } from "../../../i18n/index.ts"; import { currentConfigObject } from "../../../lib/config/index.ts"; import { formatTimeMs } from "../../../lib/format.ts"; @@ -368,6 +369,55 @@ class AgentMemoryPanel extends OpenClawLightDomElement { } } + private async removeEnabledOverride( + scope: DreamingTaskScope, + runtimeConfig: ApplicationContext["runtimeConfig"], + ): Promise { + const { pluginId } = resolveConfiguredDreaming(currentConfigObject(runtimeConfig.state)); + this.dreaming.dreamingModeSaving = true; + try { + runtimeConfig.removeFormValue([ + "plugins", + "entries", + pluginId, + "config", + "dreaming", + "enabled", + ]); + const saved = await runtimeConfig.save(); + return ( + saved && this.isTaskScopeCurrent(scope) && this.context.runtimeConfig === runtimeConfig + ); + } catch (error) { + this.dreaming.dreamingStatusError = + error instanceof Error ? error.message : t("dreaming.actions.updateFailed"); + return false; + } finally { + if (this.isTaskScopeCurrent(scope)) { + this.dreaming.dreamingModeSaving = false; + } + } + } + + private async resetEnabledOverride(configured: ReturnType) { + if (!configured.overridden || this.dreaming.dreamingModeSaving || this.toggleConfirmOpen) { + return; + } + this.dreaming.dreamingStatusError = null; + const scope = this.captureTaskScope(); + const runtimeConfig = this.context.runtimeConfig; + if (!scope || !(await this.removeEnabledOverride(scope, runtimeConfig))) { + this.dreaming.dreamingStatusError ??= t("dreaming.actions.updateFailed"); + return; + } + await runtimeConfig.refresh(); + if (!this.isTaskScopeCurrent(scope) || this.context.runtimeConfig !== runtimeConfig) { + return; + } + this.syncConfigSnapshot(); + await this.runDreamingTask(loadDreamingStatus, scope); + } + private async openWikiPage(lookup: string): Promise { const scope = this.captureTaskScope(); const client = scope?.state.client; @@ -407,10 +457,15 @@ class AgentMemoryPanel extends OpenClawLightDomElement { override render() { const dreaming = this.dreaming; const configState = this.context.runtimeConfig.state; - const dreamingOn = - dreaming.dreamingStatus?.enabled ?? - resolveConfiguredDreaming(currentConfigObject(configState)).enabled; + const configuredDreaming = resolveConfiguredDreaming(currentConfigObject(configState)); + const dreamingOn = dreaming.dreamingStatus?.enabled ?? configuredDreaming.enabled; const loading = dreaming.dreamingStatusLoading || dreaming.dreamingModeSaving; + const defaultState = renderSettingsDefaultState({ + value: t("common.enabled"), + overridden: configuredDreaming.overridden, + disabled: loading, + onReset: () => void this.resetEnabledOverride(configuredDreaming), + }); const refreshLoading = dreaming.dreamingStatusLoading || dreaming.dreamDiaryLoading; const selectedAgentId = dreaming.selectedAgentId ?? this.agentId; @@ -425,6 +480,8 @@ class AgentMemoryPanel extends OpenClawLightDomElement { > ${refreshLoading ? t("dreaming.header.refreshing") : t("dreaming.header.refresh")} + ${defaultState.description} + ${defaultState.action} - ${defaultState.description} + + ${configuredDreaming.engineOff + ? t("dreaming.header.engineOff") + : defaultState.description} + ${defaultState.action} ` - : nothing} - `, + description: html`${t("configView.chatPrefs.messageWidthHint")}
+ ${messageWidthDefaultState.description} ${t("quickSettings.personal.browserOnly")}`, + control: html` ${messageWidthDefaultState.action} ${messageWidthInput} `, })} ${renderSettingsSelectRow({ title: t("chat.sendShortcut"), value: props.chatSendShortcut, setting: "send-shortcut", + description: html`${sendShortcutDefaultState.description} ${t("configView.syncedHint")}`, + actions: sendShortcutDefaultState.action, options: [ { value: "enter", label: t("chat.sendShortcutEnter") }, { value: "modifier-enter", label: t("chat.sendShortcutModifierEnter") }, @@ -198,7 +224,7 @@ export function renderChatPreferencesSection( })} ${renderSettingsRow({ title: t("chat.followUpMode"), - description: followUpDescription, + description: html`${followUpDescription} ${t("configView.syncedHint")}`, control: html` - ${props.chatFollowUpMode + ${props.chatFollowUpModeOverridden ? html`` diff --git a/ui/src/pages/config/view-appearance.ts b/ui/src/pages/config/view-appearance.ts index 36060e05860e..593ec4851750 100644 --- a/ui/src/pages/config/view-appearance.ts +++ b/ui/src/pages/config/view-appearance.ts @@ -21,6 +21,7 @@ import { renderChatPreferencesSection, renderLanguageSection, renderLobsterPetSection, + serverUiPrefProvenanceHint, renderSidebarPreferencesSection, } from "./view-appearance-preferences.ts"; import type { ConfigProps } from "./view-types.ts"; @@ -150,14 +151,8 @@ export function renderAppearanceSection( overridden: props.themeModeOverridden, onReset: props.resetThemeMode, }); - const themeProvenance = - props.themeProvenance === "device-local" - ? t("quickSettings.personal.browserOnly") - : t("configView.syncedHint"); - const themeModeProvenance = - props.themeModeProvenance === "device-local" - ? t("quickSettings.personal.browserOnly") - : t("configView.syncedHint"); + const themeProvenance = serverUiPrefProvenanceHint(props.themeProvenance); + const themeModeProvenance = serverUiPrefProvenanceHint(props.themeModeProvenance); const textScaleDefaultState = renderSettingsDefaultState({ value: `${UI_APPEARANCE_DEFAULTS.textScale}%`, overridden: props.textScaleOverridden, diff --git a/ui/src/pages/config/view-types.ts b/ui/src/pages/config/view-types.ts index 57d2c8081aea..20ce5c92cf5f 100644 --- a/ui/src/pages/config/view-types.ts +++ b/ui/src/pages/config/view-types.ts @@ -152,8 +152,11 @@ export type ConfigProps = { setChatSendShortcut: (value: ChatSendShortcut) => void; resetChatSendShortcut: () => void; chatFollowUpMode: ChatFollowUpMode | undefined; + chatFollowUpModeOverridden: boolean; + chatFollowUpModeProvenance: ServerUiPrefProvenance; serverQueueMode: QueueMode | undefined; setChatFollowUpMode: (value: ChatFollowUpMode | undefined) => void; + resetChatFollowUpMode: () => void; catalogOpenTarget: CatalogOpenTarget; setCatalogOpenTarget: (value: CatalogOpenTarget) => void; microphone?: SettingsMediaDeviceState; diff --git a/ui/src/pages/config/view.browser.test.ts b/ui/src/pages/config/view.browser.test.ts index 05422553c891..edfdede195b4 100644 --- a/ui/src/pages/config/view.browser.test.ts +++ b/ui/src/pages/config/view.browser.test.ts @@ -95,8 +95,11 @@ describe("config view", () => { setChatSendShortcut: vi.fn(), resetChatSendShortcut: vi.fn(), chatFollowUpMode: undefined, + chatFollowUpModeOverridden: false, + chatFollowUpModeProvenance: "default" as const, serverQueueMode: "steer" as const, setChatFollowUpMode: vi.fn(), + resetChatFollowUpMode: vi.fn(), catalogOpenTarget: "viewer" as const, setCatalogOpenTarget: vi.fn(), gatewayUrl: "", @@ -1728,6 +1731,35 @@ describe("config view", () => { expect(resetTheme).toHaveBeenCalledOnce(); }); + it("shows pending synced preferences without claiming they already synced", () => { + const { container } = renderConfigView({ + activeSection: "__appearance__", + includeSections: ["__appearance__"], + theme: "claw", + themeOverridden: false, + themeProvenance: "pending", + chatFollowUpMode: "queue", + chatFollowUpModeOverridden: true, + chatFollowUpModeProvenance: "pending", + }); + const themeSection = queryRequired(container, "#settings-appearance-theme", HTMLElement); + const themeDescription = queryRequired( + themeSection, + ":scope > .settings-section__desc", + HTMLElement, + ); + const followUpRow = Array.from(container.querySelectorAll(".settings-row")).find( + (candidate) => + candidate.querySelector(".settings-row__title")?.textContent?.trim() === + "Follow-ups while the agent is working", + ); + + expect(themeDescription.textContent).toContain("Waiting to sync through the gateway"); + expect(themeDescription.textContent).not.toContain("Synced across your devices"); + expect(followUpRow?.textContent).toContain("Waiting to sync through the gateway"); + expect(followUpRow?.textContent).not.toContain("Synced across your devices"); + }); + it("labels synced and browser-only Chat preferences per row", () => { const { container } = renderConfigView({ activeSection: "__appearance__", @@ -2143,12 +2175,15 @@ describe("config view", () => { it("marks browser follow-up overrides and resets them to the server", () => { const setChatFollowUpMode = vi.fn(); + const resetChatFollowUpMode = vi.fn(); const { container } = renderConfigView({ activeSection: "__appearance__", includeSections: ["__appearance__"], chatFollowUpMode: "queue", + chatFollowUpModeOverridden: true, serverQueueMode: "steer", setChatFollowUpMode, + resetChatFollowUpMode, }); expect(container.textContent).toContain("Overriding server default (steer)"); @@ -2157,7 +2192,8 @@ describe("config view", () => { ); expect(reset).toBeDefined(); reset?.click(); - expect(setChatFollowUpMode).toHaveBeenCalledWith(undefined); + expect(resetChatFollowUpMode).toHaveBeenCalledOnce(); + expect(setChatFollowUpMode).not.toHaveBeenCalled(); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ From ef32ee2f9da081a1658a4e4bfa3424657149920c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 13:47:02 +0800 Subject: [PATCH 019/347] fix(ui): restore collection defaults --- ...g-form-collection-defaults.browser.test.ts | 277 ++++++++++++++++++ .../config-form-collection-draft.ts | 6 + .../components/config-form.node.collection.ts | 107 +++---- ui/src/components/config-form.node.json.ts | 62 ++++ ui/src/components/config-form.node.shared.ts | 4 + ui/src/components/config-form.node.ts | 3 +- ui/src/e2e/config-form-defaults.e2e.test.ts | 109 ++++++- .../e2e/curated-settings-defaults.e2e.test.ts | 6 + .../e2e/memory-settings-defaults.e2e.test.ts | 3 + 9 files changed, 503 insertions(+), 74 deletions(-) create mode 100644 ui/src/components/config-form-collection-defaults.browser.test.ts create mode 100644 ui/src/components/config-form.node.json.ts diff --git a/ui/src/components/config-form-collection-defaults.browser.test.ts b/ui/src/components/config-form-collection-defaults.browser.test.ts new file mode 100644 index 000000000000..73c2b97f853f --- /dev/null +++ b/ui/src/components/config-form-collection-defaults.browser.test.ts @@ -0,0 +1,277 @@ +// Control UI tests cover collection default provenance and restore behavior. +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { renderArray, renderObject } from "./config-form.node.collection.ts"; +import { renderJsonTextarea } from "./config-form.node.json.ts"; +import { renderNode } from "./config-form.ts"; + +function expectElement(element: T | null | undefined, label: string): T { + expect(element instanceof Element, label).toBe(true); + if (!(element instanceof Element)) { + throw new Error(`missing ${label}`); + } + return element; +} + +function resetButton(container: ParentNode): HTMLButtonElement { + return expectElement( + container.querySelector("button[aria-label='Reset to default']"), + "reset to default button", + ); +} + +describe("config form collection defaults", () => { + it("shows and restores optional JSON defaults without authoring the inherited value", () => { + const container = document.createElement("div"); + const onPatch = vi.fn(); + const schema = { + anyOf: [{ type: "object" as const }, { type: "array" as const }], + default: { mode: "balanced" }, + }; + + render( + renderJsonTextarea({ + schema, + value: { mode: "custom" }, + path: ["payload"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }), + container, + ); + + expect(container.textContent).toContain('Default: {"mode":"balanced"}'); + expectElement(container.querySelector("textarea"), "explicit JSON").value = + '{\n "mode": "custom"\n}'; + resetButton(container).click(); + expect(onPatch).toHaveBeenCalledWith(["payload"], undefined); + + onPatch.mockClear(); + render( + renderJsonTextarea({ + schema, + value: undefined, + path: ["payload"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }), + container, + ); + + expect(container.textContent).toContain('Using default: {"mode":"balanced"}'); + expectElement( + container.querySelector("textarea"), + "inherited JSON", + ).value = '{\n "mode": "balanced"\n}'; + expect(container.querySelector("button[aria-label='Reset to default']")).toBeNull(); + expect(onPatch).not.toHaveBeenCalled(); + }); + + it("shows optional array defaults and keeps rejected restores in the DOM", () => { + const container = document.createElement("div"); + const onPatch = vi.fn(() => false); + const schema = { + type: "array" as const, + items: { type: "string" as const }, + default: ["a", "b"], + }; + + render( + renderArray( + { + schema, + value: ["custom"], + path: ["values"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + + expect(container.textContent).toContain('Default: ["a","b"]'); + expect(container.textContent).toContain("1 item"); + expectElement(container.querySelector("input"), "explicit array item").value = + "custom"; + resetButton(container).click(); + expect(onPatch).toHaveBeenCalledWith(["values"], undefined); + expect(container.textContent).toContain("1 item"); + expectElement(container.querySelector("input"), "rejected array item").value = + "custom"; + + onPatch.mockClear(); + render( + renderArray( + { + schema, + value: undefined, + path: ["values"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + + expect(container.textContent).toContain('Using default: ["a","b"]'); + expect(container.textContent).toContain("2 items"); + const inheritedInputs = Array.from(container.querySelectorAll("input")); + expect(inheritedInputs.map((input) => input.value)).toEqual(["", ""]); + expect(inheritedInputs.map((input) => input.placeholder)).toEqual(["Default: a", "Default: b"]); + expect(container.querySelector("button[aria-label='Reset to default']")).toBeNull(); + expect(onPatch).not.toHaveBeenCalled(); + }); + + it("removes an optional object as one value and keeps inherited children inherited", () => { + const container = document.createElement("div"); + const onPatch = vi.fn(); + const onRemove = vi.fn(); + const schema = { + type: "object" as const, + properties: { + profile: { + type: "object" as const, + title: "Profile", + default: { enabled: true, mode: "balanced" }, + properties: { + enabled: { type: "boolean" as const, title: "Enabled" }, + mode: { type: "string" as const, title: "Mode" }, + }, + }, + sibling: { type: "string" as const, title: "Sibling" }, + }, + }; + + render( + renderObject( + { + schema, + value: { + profile: { enabled: false, mode: "custom" }, + sibling: "preserved", + }, + path: ["settings"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + onRemove, + }, + renderNode, + ), + container, + ); + + const profile = expectElement( + Array.from(container.querySelectorAll("details")).find((details) => + details.textContent?.includes("Profile"), + ), + "explicit profile object", + ); + expect(profile.textContent).toContain('Default: {"enabled":true,"mode":"balanced"}'); + resetButton(profile).click(); + expect(onRemove).toHaveBeenCalledWith(["settings", "profile"]); + expect(onPatch).not.toHaveBeenCalled(); + + onRemove.mockClear(); + render( + renderObject( + { + schema, + value: { sibling: "preserved" }, + path: ["settings"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + onRemove, + }, + renderNode, + ), + container, + ); + + const inheritedProfile = expectElement( + Array.from(container.querySelectorAll("details")).find((details) => + details.textContent?.includes("Profile"), + ), + "inherited profile object", + ); + expect(inheritedProfile.textContent).toContain( + 'Using default: {"enabled":true,"mode":"balanced"}', + ); + const enabledRow = expectElement( + Array.from(inheritedProfile.querySelectorAll(".settings-row")).find((row) => + row.textContent?.includes("Enabled"), + ), + "inherited enabled row", + ); + const modeRow = expectElement( + Array.from(inheritedProfile.querySelectorAll(".settings-row")).find((row) => + row.textContent?.includes("Mode"), + ), + "inherited mode row", + ); + expect(enabledRow.textContent).toContain("Using default: true"); + expect(modeRow.textContent).toContain("Using default: balanced"); + expect( + expectElement(modeRow.querySelector("input"), "inherited mode input") + .placeholder, + ).toBe("Default: balanced"); + expect(inheritedProfile.querySelector("button[aria-label='Reset to default']")).toBeNull(); + expect(onRemove).not.toHaveBeenCalled(); + }); + + it("deep-clones required collection defaults before restoring them", () => { + const container = document.createElement("div"); + const onPatch = vi.fn(); + const schemaDefault = { nested: { enabled: true } }; + + render( + renderObject( + { + schema: { + type: "object", + title: "Required object", + default: schemaDefault, + properties: { + nested: { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + }, + }, + }, + value: { nested: { enabled: false } }, + path: ["settings", "requiredObject"], + hints: {}, + unsupported: new Set(), + disabled: false, + isRequired: true, + onPatch, + }, + renderNode, + ), + container, + ); + + resetButton(container).click(); + expect(onPatch).toHaveBeenCalledWith(["settings", "requiredObject"], schemaDefault); + const restored = onPatch.mock.calls[0]?.[1] as typeof schemaDefault; + expect(restored).not.toBe(schemaDefault); + expect(restored.nested).not.toBe(schemaDefault.nested); + restored.nested.enabled = false; + expect(schemaDefault.nested.enabled).toBe(true); + }); +}); diff --git a/ui/src/components/config-form-collection-draft.ts b/ui/src/components/config-form-collection-draft.ts index 07b7797d60a3..7eff85760841 100644 --- a/ui/src/components/config-form-collection-draft.ts +++ b/ui/src/components/config-form-collection-draft.ts @@ -21,6 +21,12 @@ export type ConfigFormCollectionDraftCommit = { value: unknown; }; +export function openConfigFormCollectionDraft(event: Event, draftId: string): void { + const block = (event.currentTarget as HTMLElement).closest(".cfg-block"); + const draft = Array.from(block?.children ?? []).find((child) => child.id === draftId); + (draft as Partial | undefined)?.openDraft?.(); +} + export class ConfigFormCollectionDraft extends OpenClawLightDomElement { @property({ attribute: false }) props?: ConfigFormCollectionDraftProps; diff --git a/ui/src/components/config-form.node.collection.ts b/ui/src/components/config-form.node.collection.ts index 619ed4b87140..aae41ef5727f 100644 --- a/ui/src/components/config-form.node.collection.ts +++ b/ui/src/components/config-form.node.collection.ts @@ -11,7 +11,7 @@ import { rowIdentitiesForArray, } from "./config-form-array-identity.ts"; import { - ConfigFormCollectionDraft, + openConfigFormCollectionDraft, type ConfigFormCollectionDraftCommit, type ConfigFormCollectionDraftProps, } from "./config-form-collection-draft.ts"; @@ -36,7 +36,10 @@ import { jsonValue, renderFieldRow, renderJsonTextareaControl, + renderRestoreDefaultButton, + renderSchemaDefaultDescription, renderTags, + schemaWithDefault, type ConfigNodeRenderer, type ConfigNodeRenderParams, } from "./config-form.node.shared.ts"; @@ -52,54 +55,6 @@ import { renderSettingsEmpty } from "./settings-ui.ts"; const UNSET_ARRAY_SOURCE_IDENTITY = Symbol("unset-array-source"); const UNSET_MAP_SOURCE_IDENTITY = Symbol("unset-map-source"); -function openCollectionDraft(event: Event, draftId: string): void { - const block = (event.currentTarget as HTMLElement).closest(".cfg-block"); - const draft = Array.from(block?.children ?? []).find((child) => child.id === draftId); - const openDraft = (draft as Partial | undefined)?.openDraft; - if (typeof openDraft === "function") { - openDraft.call(draft); - } -} - -export function renderJsonTextarea(params: ConfigNodeRenderParams): TemplateResult { - const { schema, value, path, hints, disabled, onPatch } = params; - const showLabel = params.showLabel ?? true; - const { label, help, tags } = resolveFieldMeta(path, schema, hints); - const helpId = showLabel && help ? configFieldId(path, "description") : undefined; - const fallback = jsonValue(value); - const sensitiveState = getSensitiveRenderState({ - path, - value, - hints, - revealSensitive: params.revealSensitive ?? false, - isSensitivePathRevealed: params.isSensitivePathRevealed, - }); - - return renderFieldRow({ - label, - help, - helpId, - tags, - showLabel, - stacked: true, - control: renderJsonTextareaControl({ - schema, - path, - ariaLabel: label, - descriptionId: helpId, - sourceValue: params.sourceIdentity ?? value, - rowIdentity: params.rowIdentity, - fallback, - rows: 3, - sensitiveState, - disabled, - isRequired: params.isRequired, - onToggleSensitivePath: params.onToggleSensitivePath, - onPatch, - }), - }); -} - export function renderObject( params: ConfigNodeRenderParams, renderNode: ConfigNodeRenderer, @@ -126,7 +81,8 @@ export function renderObject( : false; const childSearchCriteria = selfMatched ? undefined : searchCriteria; - const fallback = value ?? schema.default; + const inherited = value === undefined && schema.default !== undefined; + const fallback = inherited ? schema.default : value; const objectSourceIdentity = fallback === undefined ? UNSET_MAP_SOURCE_IDENTITY : fallback; const objectValue = fallback && typeof fallback === "object" && !Array.isArray(fallback) @@ -188,16 +144,17 @@ export function renderObject( const fields = html` ${sorted.map(([propertyKey, node]) => { + const hasInheritedChild = inherited && Object.hasOwn(objectValue, propertyKey); return renderNode({ - schema: node, - value: objectValue[propertyKey], + schema: hasInheritedChild ? schemaWithDefault(node, objectValue[propertyKey]) : node, + value: inherited ? undefined : objectValue[propertyKey], path: [...path, propertyKey], hints, rawAvailable, unsupported, disabled, isRequired: requiredKeys.has(propertyKey), - sourceIdentity: objectValue[propertyKey], + sourceIdentity: inherited ? undefined : objectValue[propertyKey], controlIdentity: params.controlIdentity ?? objectValue, rowIdentity: params.rowIdentity, searchCriteria: childSearchCriteria, @@ -236,9 +193,17 @@ export function renderObject(
${label} ${help ? html`${help}` : nothing} + ${schema.default !== undefined + ? html`${renderSchemaDefaultDescription(schema, value)}` + : nothing} ${renderTags(tags)}
- ${icons.chevronDown} +
+ ${renderRestoreDefaultButton(params)} + ${icons.chevronDown} +
${fields}
@@ -283,6 +248,7 @@ export function renderArray( }); } + const inherited = value === undefined && Array.isArray(schema.default); const arrayValue = Array.isArray(value) ? value : Array.isArray(schema.default) @@ -372,7 +338,12 @@ export function renderArray(
${showLabel ? html`${label}` : nothing} - ${help ? html`${help}` : nothing} + ${showLabel && help ? html`${help}` : nothing} + ${showLabel && schema.default !== undefined + ? html`${renderSchemaDefaultDescription(schema, value)}` + : nothing} ${renderTags(tags)}
@@ -381,6 +352,7 @@ export function renderArray( count: String(arrayValue.length), })} + ${renderRestoreDefaultButton(params)}
@@ -575,7 +548,7 @@ function renderMapField( ?disabled=${disabled} @click=${(event: Event) => { if (entryDefault === NO_SAFE_DEFAULT) { - openCollectionDraft(event, draftId); + openConfigFormCollectionDraft(event, draftId); return; } const nextValue = { ...value }; @@ -587,7 +560,7 @@ function renderMapField( } nextValue[key] = entryDefault; if (onPatch(path, nextValue) === false) { - openCollectionDraft(event, draftId); + openConfigFormCollectionDraft(event, draftId); } }} > diff --git a/ui/src/components/config-form.node.json.ts b/ui/src/components/config-form.node.json.ts new file mode 100644 index 000000000000..9598be04121b --- /dev/null +++ b/ui/src/components/config-form.node.json.ts @@ -0,0 +1,62 @@ +// Control UI renderer for JSON-backed config form nodes. +import { html, nothing, type TemplateResult } from "lit"; +import { + getSensitiveRenderState, + jsonValue, + renderFieldRow, + renderJsonTextareaControl, + renderRestoreDefaultButton, + renderSchemaDefaultDescription, + type ConfigNodeRenderParams, +} from "./config-form.node.shared.ts"; +import { resolveConfigFieldMeta as resolveFieldMeta } from "./config-form.search.ts"; +import { configFieldId } from "./config-form.shared.ts"; + +export function renderJsonTextarea(params: ConfigNodeRenderParams): TemplateResult { + const { schema, value, path, hints, disabled, onPatch } = params; + const showLabel = params.showLabel ?? true; + const { label, help, tags } = resolveFieldMeta(path, schema, hints); + const helpId = showLabel && help ? configFieldId(path, "description") : undefined; + const fallback = jsonValue(value !== undefined ? value : schema.default); + const sensitiveState = getSensitiveRenderState({ + path, + value, + hints, + revealSensitive: params.revealSensitive ?? false, + isSensitivePathRevealed: params.isSensitivePathRevealed, + }); + const control = html` + ${renderJsonTextareaControl({ + schema, + path, + ariaLabel: label, + descriptionId: helpId, + sourceValue: params.sourceIdentity ?? value, + rowIdentity: params.rowIdentity, + fallback, + rows: 3, + sensitiveState, + disabled, + isRequired: params.isRequired, + onToggleSensitivePath: params.onToggleSensitivePath, + onPatch, + })} + ${renderRestoreDefaultButton({ + ...params, + disabled: disabled || sensitiveState.isRedacted, + })} + `; + + return renderFieldRow({ + label, + help, + helpId, + defaultDescription: sensitiveState.isRedacted + ? nothing + : renderSchemaDefaultDescription(schema, value), + tags, + showLabel, + stacked: true, + control, + }); +} diff --git a/ui/src/components/config-form.node.shared.ts b/ui/src/components/config-form.node.shared.ts index 2f90c5666f51..35415d4a7500 100644 --- a/ui/src/components/config-form.node.shared.ts +++ b/ui/src/components/config-form.node.shared.ts @@ -79,6 +79,10 @@ export function jsonValue(value: unknown): string { } } +export function schemaWithDefault(schema: JsonSchema, value: unknown): JsonSchema { + return { ...schema, default: value }; +} + function formatComparablePrimitive(value: unknown): string | null { if ( typeof value === "string" || diff --git a/ui/src/components/config-form.node.ts b/ui/src/components/config-form.node.ts index 42c97d0856af..c88ab6dfc280 100644 --- a/ui/src/components/config-form.node.ts +++ b/ui/src/components/config-form.node.ts @@ -6,7 +6,8 @@ import { structuredDraftInitialValue, type ConfigFormStructuredDraftProps, } from "./config-form-structured-draft.ts"; -import { renderArray, renderJsonTextarea, renderObject } from "./config-form.node.collection.ts"; +import { renderArray, renderObject } from "./config-form.node.collection.ts"; +import { renderJsonTextarea } from "./config-form.node.json.ts"; import { renderNumberInput, renderSelect, renderTextInput } from "./config-form.node.scalar.ts"; import { renderFieldRow, diff --git a/ui/src/e2e/config-form-defaults.e2e.test.ts b/ui/src/e2e/config-form-defaults.e2e.test.ts index 60d995307793..8fc9c1962e4d 100644 --- a/ui/src/e2e/config-form-defaults.e2e.test.ts +++ b/ui/src/e2e/config-form-defaults.e2e.test.ts @@ -1,7 +1,7 @@ // Control UI tests cover schema defaults and restoring inherited config values. import { mkdir } from "node:fs/promises"; import path from "node:path"; -import { chromium, type Browser } from "playwright"; +import { chromium, type Browser, type Locator, type Page } from "playwright"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { canRunPlaywrightChromium, @@ -36,6 +36,12 @@ function requestRaw(request: MockGatewayRequest): Record { return JSON.parse(String((params as Record).raw)) as Record; } +function settingsRow(page: Page, title: string): Locator { + return page.locator(".settings-row").filter({ + has: page.locator(".settings-row__title").getByText(title, { exact: true }), + }); +} + describeControlUiE2e("Control UI config form defaults mocked Gateway E2E", () => { beforeAll(async () => { if (!chromiumAvailable) { @@ -65,7 +71,10 @@ describeControlUiE2e("Control UI config form defaults mocked Gateway E2E", () => enabled: false, keep: "preserved", mode: "custom", + payload: { mode: "custom" }, + profile: { enabled: false, mode: "custom" }, retries: 9, + tags: ["custom"], }, }; const gateway = await installMockGateway(page, { @@ -99,7 +108,27 @@ describeControlUiE2e("Control UI config form defaults mocked Gateway E2E", () => default: "balanced", enum: ["balanced", "fast", "careful", "safe", "strict", "custom"], }, + payload: { + title: "Payload", + anyOf: [{ type: "object" }, { type: "array" }], + default: { mode: "balanced" }, + }, + profile: { + type: "object", + title: "Profile", + default: { enabled: true, mode: "balanced" }, + properties: { + enabled: { type: "boolean", title: "Profile enabled" }, + mode: { type: "string", title: "Profile mode" }, + }, + }, retries: { type: "integer", title: "Retries", default: 3 }, + tags: { + type: "array", + title: "Tags", + items: { type: "string" }, + default: ["stable", "default"], + }, }, }, }, @@ -108,7 +137,10 @@ describeControlUiE2e("Control UI config form defaults mocked Gateway E2E", () => "runtime.enabled": { advanced: false }, "runtime.keep": { advanced: false }, "runtime.mode": { advanced: false }, + "runtime.payload": { advanced: false }, + "runtime.profile": { advanced: false }, "runtime.retries": { advanced: false }, + "runtime.tags": { advanced: false }, }, version: "e2e", }, @@ -120,9 +152,16 @@ describeControlUiE2e("Control UI config form defaults mocked Gateway E2E", () => expect(response?.status()).toBe(200); const panel = page.locator("#config-section-panel"); - const enabledRow = panel.locator(".settings-row").filter({ hasText: "Enabled" }); - const modeRow = panel.locator(".settings-row").filter({ hasText: "Mode" }); - const retriesRow = panel.locator(".settings-row").filter({ hasText: "Retries" }); + const enabledRow = settingsRow(page, "Enabled"); + const modeRow = settingsRow(page, "Mode"); + const payloadRow = settingsRow(page, "Payload"); + const profileBlock = panel.locator("details").filter({ + has: page.locator(".cfg-object__summary .settings-row__title").getByText("Profile", { + exact: true, + }), + }); + const retriesRow = settingsRow(page, "Retries"); + const tagsBlock = panel.locator(".cfg-array").filter({ hasText: "Tags" }); await expect.poll(() => enabledRow.textContent()).toContain("Default: true"); await expect @@ -130,10 +169,15 @@ describeControlUiE2e("Control UI config form defaults mocked Gateway E2E", () => .toContain("Controls runtime processing. Default: true"); await expect.poll(() => modeRow.textContent()).toContain("Default: balanced"); await expect.poll(() => modeRow.locator("select").inputValue()).not.toBe("__unset__"); + await expect.poll(() => payloadRow.textContent()).toContain('Default: {"mode":"balanced"}'); + await expect + .poll(() => profileBlock.textContent()) + .toContain('Default: {"enabled":true,"mode":"balanced"}'); await expect.poll(() => retriesRow.getByRole("spinbutton").inputValue()).toBe("9"); + await expect.poll(() => tagsBlock.textContent()).toContain('Default: ["stable","default"]'); await expect .poll(() => panel.getByRole("button", { name: "Reset to default" }).count()) - .toBe(2); + .toBe(5); if (captureUiProofEnabled) { await mkdir(uiProofArtifactDir, { recursive: true }); @@ -143,26 +187,79 @@ describeControlUiE2e("Control UI config form defaults mocked Gateway E2E", () => }); } + await gateway.deferNext("config.set"); await enabledRow.getByRole("button", { name: "Reset to default" }).click(); await modeRow.locator("select").selectOption("__unset__"); + await payloadRow.getByRole("button", { name: "Reset to default" }).click(); + await profileBlock.getByRole("button", { name: "Reset to default" }).click(); await retriesRow.getByRole("button", { name: "Reset to default" }).click(); + await tagsBlock.getByRole("button", { name: "Reset to default" }).click(); // Form mutations schedule config.set automatically; form mode has no manual Save control. const saved = requestRaw(await gateway.waitForRequest("config.set")); expect(saved).toEqual({ runtime: { keep: "preserved" } }); + await expect + .poll(() => page.locator("openclaw-settings-save-indicator").textContent()) + .toContain("Saving"); await expect.poll(() => enabledRow.textContent()).toContain("Using default: true"); await expect.poll(() => modeRow.locator("select").inputValue()).toBe("__unset__"); + await expect + .poll(() => payloadRow.textContent()) + .toContain('Using default: {"mode":"balanced"}'); + await expect + .poll(() => profileBlock.textContent()) + .toContain('Using default: {"enabled":true,"mode":"balanced"}'); await expect.poll(() => retriesRow.getByRole("spinbutton").inputValue()).toBe(""); await expect .poll(() => retriesRow.getByRole("spinbutton").getAttribute("placeholder")) .toBe("Default: 3"); + await expect + .poll(() => tagsBlock.textContent()) + .toContain('Using default: ["stable","default"]'); await expect .poll(() => panel.getByRole("button", { name: "Reset to default" }).count()) .toBe(0); + const configGetsBeforeReload = (await gateway.getRequests("config.get")).length; + await gateway.resolveDeferred("config.set"); + await expect + .poll(() => page.locator("openclaw-settings-save-indicator").textContent()) + .toContain("Saved"); + expect((await page.reload())?.status()).toBe(200); + await expect + .poll(async () => (await gateway.getRequests("config.get")).length) + .toBe(configGetsBeforeReload + 1); + + const reloadedPanel = page.locator("#config-section-panel"); + const reloadedEnabledRow = settingsRow(page, "Enabled"); + const reloadedModeRow = settingsRow(page, "Mode"); + const reloadedPayloadRow = settingsRow(page, "Payload"); + const reloadedProfileBlock = reloadedPanel.locator("details").filter({ + has: page + .locator(".cfg-object__summary .settings-row__title") + .getByText("Profile", { exact: true }), + }); + const reloadedRetriesRow = settingsRow(page, "Retries"); + const reloadedTagsBlock = reloadedPanel.locator(".cfg-array").filter({ hasText: "Tags" }); + await expect.poll(() => reloadedEnabledRow.textContent()).toContain("Using default: true"); + await expect.poll(() => reloadedModeRow.locator("select").inputValue()).toBe("__unset__"); + await expect + .poll(() => reloadedPayloadRow.textContent()) + .toContain('Using default: {"mode":"balanced"}'); + await expect + .poll(() => reloadedProfileBlock.textContent()) + .toContain('Using default: {"enabled":true,"mode":"balanced"}'); + await expect.poll(() => reloadedRetriesRow.getByRole("spinbutton").inputValue()).toBe(""); + await expect + .poll(() => reloadedTagsBlock.textContent()) + .toContain('Using default: ["stable","default"]'); + await expect + .poll(() => reloadedPanel.getByRole("button", { name: "Reset to default" }).count()) + .toBe(0); + if (captureUiProofEnabled) { - await panel.screenshot({ + await reloadedPanel.screenshot({ animations: "disabled", path: path.join(uiProofArtifactDir, "02-inherited-defaults.png"), }); diff --git a/ui/src/e2e/curated-settings-defaults.e2e.test.ts b/ui/src/e2e/curated-settings-defaults.e2e.test.ts index a8d221bd19fe..8099ab7b4691 100644 --- a/ui/src/e2e/curated-settings-defaults.e2e.test.ts +++ b/ui/src/e2e/curated-settings-defaults.e2e.test.ts @@ -195,6 +195,9 @@ describeControlUiE2e("Control UI curated settings defaults mocked Gateway E2E", return !hasOwnPath(raw, ["browser", "enabled"]) && !hasOwnPath(raw, ["tools", "profile"]); }) .toBe(true); + await expect + .poll(() => page.locator("openclaw-settings-save-indicator").textContent()) + .toContain("Saved"); if (captureUiProofEnabled) { await page @@ -248,6 +251,9 @@ describeControlUiE2e("Control UI curated settings defaults mocked Gateway E2E", ); }) .toBe(true); + await expect + .poll(() => page.locator("openclaw-settings-save-indicator").textContent()) + .toContain("Saved"); if (captureUiProofEnabled) { await page.locator("#settings-model-behavior").screenshot({ diff --git a/ui/src/e2e/memory-settings-defaults.e2e.test.ts b/ui/src/e2e/memory-settings-defaults.e2e.test.ts index c39e2c0fe20d..ed244f8cdc28 100644 --- a/ui/src/e2e/memory-settings-defaults.e2e.test.ts +++ b/ui/src/e2e/memory-settings-defaults.e2e.test.ts @@ -164,6 +164,9 @@ describeControlUiE2e("Control UI Memory defaults mocked Gateway E2E", () => { "plugins.entries.memory-core.config.dreaming.verboseLogging", true, ); + await expect + .poll(() => page.locator("openclaw-settings-save-indicator").textContent()) + .toContain("Saved"); await page.reload(); const reloadedEngineRow = settingsRow(page, "Memory engine"); From d7b937c5ca8437c7623139209dc4ea8db4eb9c24 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 14:05:17 +0800 Subject: [PATCH 020/347] fix(ui): repair invalid memory settings state --- ui/src/i18n/locales/en.ts | 3 + .../pages/agents/memory/memory-panel.test.ts | 51 +++++++++ ui/src/pages/agents/memory/memory-panel.ts | 23 ++-- .../pages/config/memory-page.backend.test.ts | 104 ++++++++++++++++++ ui/src/pages/config/memory-schema.ts | 11 +- ui/src/pages/config/memory.test.ts | 29 +++++ ui/src/pages/config/memory.ts | 36 ++++-- 7 files changed, 233 insertions(+), 24 deletions(-) create mode 100644 ui/src/pages/config/memory-page.backend.test.ts diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 9a6e80c0f05e..760d5932c4ed 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2448,8 +2448,11 @@ export const en: TranslationMap = { rowTitle: "Retrieval backend", builtin: "Built-in", qmd: "QMD", + invalid: "Invalid configured value", builtinHint: "Memory files are indexed and searched by OpenClaw itself.", qmdHint: "Retrieval is delegated to QMD. Its settings appear below.", + invalidHint: + "The configured memory.backend value is invalid. Choose a backend or reset it to use the default.", }, addons: { title: "Add-ons", diff --git a/ui/src/pages/agents/memory/memory-panel.test.ts b/ui/src/pages/agents/memory/memory-panel.test.ts index 2babdea57276..3958ccb77020 100644 --- a/ui/src/pages/agents/memory/memory-panel.test.ts +++ b/ui/src/pages/agents/memory/memory-panel.test.ts @@ -366,6 +366,57 @@ describe("AgentMemoryPanel gateway lifecycle", () => { expect(container.querySelector('button[aria-label="Reset to default"]')).not.toBeNull(); }); + it("does not present cached runtime status after the memory engine switches Off", () => { + const context = contextWithGateway({} as GatewayBrowserClient, true, { + plugins: { slots: { memory: "none" } }, + }); + const page = document.createElement("openclaw-agent-memory-panel") as TestMemoryPanel; + page.context = context; + page.agentId = "main"; + page.dreaming.dreamingStatus = { + enabled: true, + promotedToday: 7, + timezone: "Mars/Base", + phases: { + light: { enabled: true, cron: "* * * * *", managedCronPresent: true }, + deep: { + enabled: true, + cron: "* * * * *", + managedCronPresent: true, + limit: 1, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + recencyHalfLifeDays: 1, + }, + rem: { + enabled: true, + cron: "* * * * *", + managedCronPresent: true, + lookbackDays: 1, + limit: 1, + minPatternStrength: 0, + }, + }, + } as NonNullable; + const container = document.createElement("div"); + + render(page.render(), container); + + const toggle = container.querySelector(".dreams__phase-toggle"); + expect(toggle?.textContent).toContain("Off"); + expect(toggle?.classList.contains("dreams__phase-toggle--on")).toBe(false); + expect(container.querySelector(".dreams__status-label")?.textContent).toContain("Idle"); + expect(container.textContent).toContain("0 promoted"); + expect(container.textContent).not.toContain("7 promoted"); + expect(container.textContent).not.toContain("Mars/Base"); + expect( + [...container.querySelectorAll(".dreams__phase-next")].every( + (phase) => phase.textContent?.trim() === "—", + ), + ).toBe(true); + }); + it("omits default provenance and reset when engine Off has no latent override", () => { const context = contextWithGateway({} as GatewayBrowserClient, true, { plugins: { slots: { memory: "none" } }, diff --git a/ui/src/pages/agents/memory/memory-panel.ts b/ui/src/pages/agents/memory/memory-panel.ts index e5aac401b37b..78f244f73095 100644 --- a/ui/src/pages/agents/memory/memory-panel.ts +++ b/ui/src/pages/agents/memory/memory-panel.ts @@ -466,7 +466,10 @@ class AgentMemoryPanel extends OpenClawLightDomElement { const dreaming = this.dreaming; const configState = this.context.runtimeConfig.state; const configuredDreaming = resolveConfiguredDreaming(currentConfigObject(configState)); - const dreamingOn = dreaming.dreamingStatus?.enabled ?? configuredDreaming.enabled; + // The status RPC can complete after config switches the engine Off. Keep the + // cached payload for a future refresh, but never present it as current runtime state. + const dreamingStatus = configuredDreaming.engineOff ? null : dreaming.dreamingStatus; + const dreamingOn = dreamingStatus?.enabled ?? configuredDreaming.enabled; const loading = dreaming.dreamingStatusLoading || dreaming.dreamingModeSaving; const defaultState = renderSettingsDefaultState({ value: t("common.enabled"), @@ -511,16 +514,16 @@ class AgentMemoryPanel extends OpenClawLightDomElement { viewState: this.viewState, active: dreamingOn, selectedAgentId, - shortTermCount: dreaming.dreamingStatus?.shortTermCount ?? 0, - groundedSignalCount: dreaming.dreamingStatus?.groundedSignalCount ?? 0, - totalSignalCount: dreaming.dreamingStatus?.totalSignalCount ?? 0, - promotedCount: dreaming.dreamingStatus?.promotedToday ?? 0, - phases: dreaming.dreamingStatus?.phases ?? undefined, - shortTermEntries: dreaming.dreamingStatus?.shortTermEntries ?? [], - promotedEntries: dreaming.dreamingStatus?.promotedEntries ?? [], + shortTermCount: dreamingStatus?.shortTermCount ?? 0, + groundedSignalCount: dreamingStatus?.groundedSignalCount ?? 0, + totalSignalCount: dreamingStatus?.totalSignalCount ?? 0, + promotedCount: dreamingStatus?.promotedToday ?? 0, + phases: dreamingStatus?.phases ?? undefined, + shortTermEntries: dreamingStatus?.shortTermEntries ?? [], + promotedEntries: dreamingStatus?.promotedEntries ?? [], dreamingOf: null, - nextCycle: resolveDreamingNextCycle(dreaming.dreamingStatus), - timezone: dreaming.dreamingStatus?.timezone ?? null, + nextCycle: resolveDreamingNextCycle(dreamingStatus), + timezone: dreamingStatus?.timezone ?? null, statusLoading: dreaming.dreamingStatusLoading, statusError: dreaming.dreamingStatusError, modeSaving: dreaming.dreamingModeSaving, diff --git a/ui/src/pages/config/memory-page.backend.test.ts b/ui/src/pages/config/memory-page.backend.test.ts new file mode 100644 index 000000000000..07d89f0db939 --- /dev/null +++ b/ui/src/pages/config/memory-page.backend.test.ts @@ -0,0 +1,104 @@ +/* @vitest-environment jsdom */ + +import { ContextProvider } from "@lit/context"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import { waitForFast } from "../../test-helpers/wait-for.ts"; +import { configRouteData } from "./route-data.ts"; +import "./memory-page.ts"; + +type MemoryPageElement = HTMLElement & { + configObject: Record; + routeData: ReturnType; +}; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("MemorySettingsPage backend repair", () => { + it("removes a malformed explicit backend through the curated reset action", async () => { + const removeFormValue = vi.fn(); + const subscribe = () => () => undefined; + const request = vi.fn((method: string) => { + if (method === "plugins.list") { + return Promise.resolve({ + plugins: [ + { + id: "memory-core", + name: "memory-core", + installed: true, + enabled: true, + state: "enabled", + kind: ["memory"], + }, + ], + diagnostics: [], + mutationAllowed: true, + }); + } + return Promise.resolve({}); + }); + const context = { + basePath: "", + gateway: { + snapshot: { + client: { request }, + phase: "connected", + hello: { auth: { role: "operator", scopes: ["operator.admin"] }, features: {} }, + }, + subscribe, + }, + runtimeConfig: { + state: { + client: {}, + connected: true, + configSaving: false, + configApplying: false, + configForm: { memory: { backend: "retired-backend" } }, + configSnapshot: null, + }, + subscribe, + lookupSchemaPath: vi.fn(async () => ({ type: "object" })), + patchForm: vi.fn(), + removeFormValue, + waitForPendingWrites: vi.fn(async () => undefined), + refresh: vi.fn(async () => undefined), + ensureLoaded: vi.fn(async () => undefined), + }, + agents: { + state: { + agentsList: { defaultId: "main", agents: [{ id: "main" }] }, + agentsLoading: false, + }, + subscribe, + ensureList: vi.fn(async () => undefined), + }, + navigate: vi.fn(), + replace: vi.fn(), + } as unknown as ApplicationContext; + const element = document.createElement("openclaw-memory-settings") as MemoryPageElement; + element.configObject = { memory: { backend: "retired-backend" } }; + element.routeData = configRouteData({ + pathname: "/settings/memory/settings", + search: "", + hash: "", + }); + (element as unknown as { context: ApplicationContext }).context = context; + const provider = new ContextProvider(element, { + context: applicationContext, + initialValue: context, + }); + provider.setValue(context); + document.body.append(element); + + await waitForFast(() => expect(element.textContent).toContain("Invalid configured value")); + const backendRow = [...element.querySelectorAll(".settings-row")].find((row) => + row.textContent?.includes("Retrieval backend"), + ); + + backendRow?.querySelector('button[aria-label="Reset to default"]')?.click(); + + expect(removeFormValue).toHaveBeenCalledWith(["memory", "backend"]); + }); +}); diff --git a/ui/src/pages/config/memory-schema.ts b/ui/src/pages/config/memory-schema.ts index 4f5bb812347e..531585f3c861 100644 --- a/ui/src/pages/config/memory-schema.ts +++ b/ui/src/pages/config/memory-schema.ts @@ -15,7 +15,8 @@ export type MemoryTab = MemoryRouteTab; export type MemoryBackend = "builtin" | "qmd"; export type MemoryBackendSelection = | { kind: "default"; backend: "builtin" } - | { kind: "pinned"; backend: MemoryBackend }; + | { kind: "pinned"; backend: MemoryBackend } + | { kind: "invalid"; backend: null; value: unknown }; /** * How `plugins.slots.memory` reads today, mirroring resolveSlotSelection in @@ -147,10 +148,14 @@ export function resolveMemoryBackendSelection( if (selectedEngineId(resolveMemoryEngineSelection(configObject)) !== MEMORY_CORE_PLUGIN_ID) { return null; } - const backend = asConfigRecord(configObject.memory)?.backend; + const memory = asConfigRecord(configObject.memory); + if (!memory || !Object.hasOwn(memory, "backend")) { + return { kind: "default", backend: "builtin" }; + } + const backend = memory.backend; return backend === "builtin" || backend === "qmd" ? { kind: "pinned", backend } - : { kind: "default", backend: "builtin" }; + : { kind: "invalid", backend: null, value: backend }; } export function resolveMemoryBackend(configObject: Record): MemoryBackend | null { diff --git a/ui/src/pages/config/memory.test.ts b/ui/src/pages/config/memory.test.ts index e2b1a3f94ddb..28a5f843c3fc 100644 --- a/ui/src/pages/config/memory.test.ts +++ b/ui/src/pages/config/memory.test.ts @@ -245,6 +245,27 @@ describe("renderMemory", () => { expect(backendRow?.querySelector('button[aria-label="Reset to default"]')).not.toBeNull(); }); + it("keeps a malformed explicit backend visible and repairable", () => { + const onBackendReset = vi.fn(); + const container = renderInto( + createProps({ + backendSelection: { kind: "invalid", backend: null, value: "retired-backend" }, + onBackendReset, + }), + ); + const backendRow = [...container.querySelectorAll(".settings-row")].find((row) => + row.textContent?.includes("Retrieval backend"), + ); + const active = backendRow?.querySelector("wa-radio.settings-segmented__btn--active"); + + expect(backendRow?.textContent).toContain("Invalid configured value"); + expect(backendRow?.textContent).toContain("Default: Built-in"); + expect(backendRow?.textContent).not.toContain("Using default: Built-in"); + expect(active?.getAttribute("value")).toBe("__invalid__"); + backendRow?.querySelector('button[aria-label="Reset to default"]')?.click(); + expect(onBackendReset).toHaveBeenCalledOnce(); + }); + it("routes engine and backend restore actions to their reset callbacks", () => { const onEngineReset = vi.fn(); const onBackendReset = vi.fn(); @@ -495,6 +516,14 @@ describe("resolveMemoryBackend", () => { kind: "pinned", backend: "qmd", }); + for (const value of ["retired-backend", null, { name: "qmd" }]) { + expect(resolveMemoryBackendSelection({ memory: { backend: value } })).toEqual({ + kind: "invalid", + backend: null, + value, + }); + } + expect(resolveMemoryBackend({ memory: { backend: "retired-backend" } })).toBeNull(); }); }); diff --git a/ui/src/pages/config/memory.ts b/ui/src/pages/config/memory.ts index df1bece10377..95e65c6f46d2 100644 --- a/ui/src/pages/config/memory.ts +++ b/ui/src/pages/config/memory.ts @@ -96,6 +96,7 @@ const MEMORY_PANEL_ID = "memory-settings-panel"; const MEMORY_DOCS_URL = "https://docs.openclaw.ai/concepts/memory"; const MEMORY_ENGINE_OFF = ""; +const MEMORY_BACKEND_INVALID = "__invalid__"; function engineHintKey(selection: MemoryEngineSelection): string { switch (selection.kind) { @@ -208,13 +209,23 @@ function renderBackendSection(props: MemoryViewProps) { if (props.backendSelection === null) { return nothing; } + const invalid = props.backendSelection.kind === "invalid"; const backend = props.backendSelection.backend; const defaultState = renderSettingsDefaultState({ value: t("memoryPage.backend.builtin"), - overridden: props.backendSelection.kind === "pinned", + overridden: props.backendSelection.kind !== "default", disabled: props.backendBusy, onReset: props.onBackendReset, }); + const controlValue = invalid ? MEMORY_BACKEND_INVALID : backend; + const options: Array<{ + value: MemoryBackend | typeof MEMORY_BACKEND_INVALID; + label: unknown; + }> = [ + ...(invalid ? [{ value: MEMORY_BACKEND_INVALID, label: t("memoryPage.backend.invalid") }] : []), + { value: "builtin", label: t("memoryPage.backend.builtin") }, + { value: "qmd", label: t("memoryPage.backend.qmd") }, + ]; // Anchor target for settings search: `backend` is curated out of the schema // editor, so it has no `#config-section-*` id of its own to scroll to. return html`
@@ -223,23 +234,26 @@ function renderBackendSection(props: MemoryViewProps) { renderSettingsRow({ title: t("memoryPage.backend.rowTitle"), description: html` - ${backend === "qmd" - ? t("memoryPage.backend.qmdHint") - : t("memoryPage.backend.builtinHint")} + ${invalid + ? t("memoryPage.backend.invalidHint") + : backend === "qmd" + ? t("memoryPage.backend.qmdHint") + : t("memoryPage.backend.builtinHint")} ${defaultState.description} `, stacked: true, control: html` ${defaultState.action} - ${renderSettingsSegmented({ - value: backend, - options: [ - { value: "builtin", label: t("memoryPage.backend.builtin") }, - { value: "qmd", label: t("memoryPage.backend.qmd") }, - ], + ${renderSettingsSegmented({ + value: controlValue, + options, disabled: props.backendBusy, ariaLabel: t("memoryPage.backend.rowTitle"), - onChange: (value) => props.onBackendChange(value), + onChange: (value) => { + if (value !== MEMORY_BACKEND_INVALID) { + props.onBackendChange(value); + } + }, })} `, }), From 9a7371a5b8067166f88f3abdb94f7032d3025b61 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 14:09:56 +0800 Subject: [PATCH 021/347] fix(ui): preserve settings default ownership --- ui/src/components/settings-ui.ts | 4 +- ui/src/pages/labs/labs-page.test.ts | 72 +++++++++++++++++++++++ ui/src/pages/labs/labs-page.ts | 6 +- ui/src/pages/labs/labs-registry.ts | 57 +++++++++++++++++- ui/src/pages/model-providers/view.test.ts | 30 ++++++++++ 5 files changed, 164 insertions(+), 5 deletions(-) diff --git a/ui/src/components/settings-ui.ts b/ui/src/components/settings-ui.ts index 26c408499473..e5bf1008150a 100644 --- a/ui/src/components/settings-ui.ts +++ b/ui/src/components/settings-ui.ts @@ -272,7 +272,7 @@ export function renderSettingsSegmented(props: { class="settings-segmented ${props.className ?? ""}" size="s" orientation="horizontal" - .value=${props.value} + .value=${live(props.value)} ?disabled=${props.disabled ?? false} @change=${(event: Event) => { const value = (event.currentTarget as HTMLElement & { value?: string }).value; @@ -296,7 +296,7 @@ export function renderSettingsSegmented(props: { : ""}" appearance="button" value=${option.value} - .checked=${option.value === props.value} + .checked=${live(option.value === props.value)} title=${option.title ?? nothing} data-test-id=${option.testId ?? nothing} > diff --git a/ui/src/pages/labs/labs-page.test.ts b/ui/src/pages/labs/labs-page.test.ts index f21c8211e059..5e6cfe4e0df2 100644 --- a/ui/src/pages/labs/labs-page.test.ts +++ b/ui/src/pages/labs/labs-page.test.ts @@ -271,6 +271,78 @@ describe("LabsPage", () => { ); }); + it.each([{ model: "ollama/qwen3:8b" }, { model: { primary: "ollama/qwen3:8b", fallbacks: [] } }])( + "treats onboarding-owned local model lean as inherited for $model", + async ({ model }) => { + const { page } = await mountPage({ + wizard: { localModelLeanAutoModel: "ollama/qwen3:8b" }, + agents: { + defaults: { + model, + experimental: { localModelLean: true }, + }, + }, + }); + const row = labRow(page, "Lean tools for local models"); + + expect(row.textContent).toContain("Using default: Enabled"); + expect(row.querySelector("button[aria-label='Reset to default']")).toBeNull(); + }, + ); + + it("releases onboarding ownership when local model lean is disabled", async () => { + const { page, runtimeConfig } = await mountPage({ + wizard: { localModelLeanAutoModel: "ollama/qwen3:8b" }, + agents: { + defaults: { + model: "ollama/qwen3:8b", + experimental: { localModelLean: true }, + }, + }, + }); + const localModelLeanIndex = LAB_FEATURES.findIndex( + (feature) => feature.id === "localModelLean", + ); + const toggle = labToggle(page, localModelLeanIndex, "local model lean"); + + toggle.checked = false; + toggle.dispatchEvent(new Event("change", { bubbles: true, composed: true })); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { + agents: { defaults: { experimental: { localModelLean: false } } }, + wizard: { localModelLeanAutoModel: null }, + }, + note: "labs: update localModelLean", + }); + }); + + it("clears a stale onboarding marker when restoring local model lean", async () => { + const { page, runtimeConfig } = await mountPage({ + wizard: { localModelLeanAutoModel: "ollama/old-model" }, + agents: { + defaults: { + model: "openai/gpt-5", + experimental: { localModelLean: true }, + }, + }, + }); + + labRow(page, "Lean tools for local models") + .querySelector("button[aria-label='Reset to default']") + ?.click(); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { + agents: { defaults: { experimental: { localModelLean: null } } }, + wizard: { localModelLeanAutoModel: null }, + }, + note: "labs: update localModelLean", + }); + }); + it("restores an object gate without deleting sibling settings", async () => { const { page, runtimeConfig } = await mountPage({ tools: { loopDetection: { enabled: true, warningThreshold: 12 } }, diff --git a/ui/src/pages/labs/labs-page.ts b/ui/src/pages/labs/labs-page.ts index 717fb9c20cd6..e3aa0f054852 100644 --- a/ui/src/pages/labs/labs-page.ts +++ b/ui/src/pages/labs/labs-page.ts @@ -110,7 +110,11 @@ class LabsPage extends OpenClawLightDomElement { const featureState = resolveLabFeatureState(config, feature); const resetPatch = enabled === featureState.defaultEnabled ? labFeatureResetPatch(config, feature) : null; - void this.updateFeature(feature, enabled, resetPatch ?? labFeatureMergePatch(feature, enabled)); + void this.updateFeature( + feature, + enabled, + resetPatch ?? labFeatureMergePatch(config, feature, enabled), + ); } private resetFeature(feature: LabFeature) { diff --git a/ui/src/pages/labs/labs-registry.ts b/ui/src/pages/labs/labs-registry.ts index 0e9558e9b550..c2f759defb0c 100644 --- a/ui/src/pages/labs/labs-registry.ts +++ b/ui/src/pages/labs/labs-registry.ts @@ -53,6 +53,9 @@ type LabFeatureState = { overridden: boolean; }; +const LOCAL_MODEL_LEAN_FEATURE_ID = "localModelLean"; +const LOCAL_MODEL_LEAN_AUTO_MODEL_PATH = ["wizard", "localModelLeanAutoModel"] as const; + export const LAB_FEATURES = [ { id: "codeMode", @@ -189,6 +192,33 @@ function recordAtPath(config: Record, path: readonly string[]): return current; } +function defaultModelRef(config: Record): string | undefined { + const model = recordAtPath(config, ["agents", "defaults", "model"]); + if (typeof model === "string") { + return model; + } + if (!model || typeof model !== "object" || Array.isArray(model)) { + return undefined; + } + const primary = (model as Record).primary; + return typeof primary === "string" ? primary : undefined; +} + +function onboardingOwnsLocalModelLean( + config: Record, + feature: LabFeature, +): boolean { + if (feature.id !== LOCAL_MODEL_LEAN_FEATURE_ID) { + return false; + } + const autoModel = recordAtPath(config, LOCAL_MODEL_LEAN_AUTO_MODEL_PATH); + return ( + typeof autoModel === "string" && + defaultModelRef(config) === autoModel && + recordAtPath(config, feature.configPath) === true + ); +} + function readEnabledFromParent(feature: LabFeature, parent: unknown): boolean { const key = feature.configPath.at(-1); if (feature.readEnabled) { @@ -243,6 +273,11 @@ export function resolveLabFeatureState( feature: LabFeature, ): LabFeatureState { const source = config ?? {}; + // Onboarding records this generated value beside the model it belongs to. + // Treat the pair as one inherited default so Labs never mislabels or detaches it. + if (onboardingOwnsLocalModelLean(source, feature)) { + return { enabled: true, defaultEnabled: true, overridden: false }; + } const parentPath = feature.configPath.slice(0, -1); const key = feature.configPath.at(-1); const parent = recordAtPath(source, parentPath); @@ -268,6 +303,7 @@ export function resolveLabFeatureState( } export function labFeatureMergePatch( + config: Record | null, feature: LabFeature, enabled: boolean, ): Record { @@ -281,7 +317,24 @@ export function labFeatureMergePatch( for (const segment of feature.configPath.slice(0, -1).toReversed()) { patch = { [segment]: patch }; } - return patch as Record; + return releaseLabFeatureOwnership(config, feature, patch as Record); +} + +function releaseLabFeatureOwnership( + config: Record | null, + feature: LabFeature, + patch: Record, +): Record { + if ( + feature.id !== LOCAL_MODEL_LEAN_FEATURE_ID || + recordAtPath(config ?? {}, LOCAL_MODEL_LEAN_AUTO_MODEL_PATH) === undefined + ) { + return patch; + } + return { + ...patch, + wizard: { localModelLeanAutoModel: null }, + }; } export function labFeatureResetPatch( @@ -296,5 +349,5 @@ export function labFeatureResetPatch( for (const segment of path.toReversed()) { patch = { [segment]: patch }; } - return patch as Record; + return releaseLabFeatureOwnership(config, feature, patch as Record); } diff --git a/ui/src/pages/model-providers/view.test.ts b/ui/src/pages/model-providers/view.test.ts index 5589f1609627..93187c098159 100644 --- a/ui/src/pages/model-providers/view.test.ts +++ b/ui/src/pages/model-providers/view.test.ts @@ -218,6 +218,36 @@ describe("renderModelProviders", () => { ).toHaveLength(0); }); + it("restores controlled model behavior when a reset is rejected", () => { + const viewProps = props({ + thinkingLevel: "high", + fastMode: true, + }); + const container = mount(viewProps); + const behavior = container.querySelector("#settings-model-behavior")!; + const thinking = settingsRow(behavior, "Thinking").querySelector( + "wa-radio-group", + )!; + const fastMode = settingsRow(behavior, "Fast mode").querySelector( + "wa-radio-group", + )!; + + selectSegment(thinking, ""); + selectSegment(fastMode, ""); + render(renderModelProviders(viewProps), container); + + expect(thinking.value).toBe("high"); + expect(fastMode.value).toBe("on"); + expect( + (thinking.querySelector('wa-radio[value="high"]') as HTMLElement & { checked: boolean }) + .checked, + ).toBe(true); + expect( + (fastMode.querySelector('wa-radio[value="on"]') as HTMLElement & { checked: boolean }) + .checked, + ).toBe(true); + }); + it("locks model behavior while shared config work is pending", () => { const container = mount(props({ configBusy: true })); const behavior = container.querySelector("#settings-model-behavior"); From 0352eb69fe1a8ec2186ba0a6be8a3fddd5118e74 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 14:15:22 +0800 Subject: [PATCH 022/347] fix(ui): type invalid memory backend options --- ui/src/pages/config/memory.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/ui/src/pages/config/memory.ts b/ui/src/pages/config/memory.ts index 95e65c6f46d2..7568de40f4fe 100644 --- a/ui/src/pages/config/memory.ts +++ b/ui/src/pages/config/memory.ts @@ -217,15 +217,21 @@ function renderBackendSection(props: MemoryViewProps) { disabled: props.backendBusy, onReset: props.onBackendReset, }); - const controlValue = invalid ? MEMORY_BACKEND_INVALID : backend; + const controlValue = + props.backendSelection.kind === "invalid" + ? MEMORY_BACKEND_INVALID + : props.backendSelection.backend; const options: Array<{ value: MemoryBackend | typeof MEMORY_BACKEND_INVALID; label: unknown; - }> = [ - ...(invalid ? [{ value: MEMORY_BACKEND_INVALID, label: t("memoryPage.backend.invalid") }] : []), + }> = []; + if (invalid) { + options.push({ value: MEMORY_BACKEND_INVALID, label: t("memoryPage.backend.invalid") }); + } + options.push( { value: "builtin", label: t("memoryPage.backend.builtin") }, { value: "qmd", label: t("memoryPage.backend.qmd") }, - ]; + ); // Anchor target for settings search: `backend` is curated out of the schema // editor, so it has no `#config-section-*` id of its own to scroll to. return html`
From c657ae45814d2cc544e7f43b92d9f0875235be88 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 14:19:06 +0800 Subject: [PATCH 023/347] fix(ui): preserve inherited collection defaults --- ...g-form-collection-defaults.browser.test.ts | 11 +++++++++ .../config-form-collection-draft.ts | 6 ----- .../components/config-form.node.collection.ts | 24 ++++++++++++++----- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/ui/src/components/config-form-collection-defaults.browser.test.ts b/ui/src/components/config-form-collection-defaults.browser.test.ts index 73c2b97f853f..ecf92a42eae5 100644 --- a/ui/src/components/config-form-collection-defaults.browser.test.ts +++ b/ui/src/components/config-form-collection-defaults.browser.test.ts @@ -230,6 +230,17 @@ describe("config form collection defaults", () => { ).toBe("Default: balanced"); expect(inheritedProfile.querySelector("button[aria-label='Reset to default']")).toBeNull(); expect(onRemove).not.toHaveBeenCalled(); + + const modeInput = expectElement( + modeRow.querySelector("input"), + "inherited mode input", + ); + modeInput.value = "custom"; + modeInput.dispatchEvent(new Event("input", { bubbles: true, composed: true })); + expect(onPatch).toHaveBeenCalledWith(["settings", "profile"], { + enabled: true, + mode: "custom", + }); }); it("deep-clones required collection defaults before restoring them", () => { diff --git a/ui/src/components/config-form-collection-draft.ts b/ui/src/components/config-form-collection-draft.ts index 7eff85760841..07b7797d60a3 100644 --- a/ui/src/components/config-form-collection-draft.ts +++ b/ui/src/components/config-form-collection-draft.ts @@ -21,12 +21,6 @@ export type ConfigFormCollectionDraftCommit = { value: unknown; }; -export function openConfigFormCollectionDraft(event: Event, draftId: string): void { - const block = (event.currentTarget as HTMLElement).closest(".cfg-block"); - const draft = Array.from(block?.children ?? []).find((child) => child.id === draftId); - (draft as Partial | undefined)?.openDraft?.(); -} - export class ConfigFormCollectionDraft extends OpenClawLightDomElement { @property({ attribute: false }) props?: ConfigFormCollectionDraftProps; diff --git a/ui/src/components/config-form.node.collection.ts b/ui/src/components/config-form.node.collection.ts index aae41ef5727f..3072c82b8c5a 100644 --- a/ui/src/components/config-form.node.collection.ts +++ b/ui/src/components/config-form.node.collection.ts @@ -11,7 +11,7 @@ import { rowIdentitiesForArray, } from "./config-form-array-identity.ts"; import { - openConfigFormCollectionDraft, + ConfigFormCollectionDraft, type ConfigFormCollectionDraftCommit, type ConfigFormCollectionDraftProps, } from "./config-form-collection-draft.ts"; @@ -53,6 +53,15 @@ import { configFieldId, hintForPath, type JsonSchema } from "./config-form.share import { renderSettingsEmpty } from "./settings-ui.ts"; const UNSET_ARRAY_SOURCE_IDENTITY = Symbol("unset-array-source"); + +function openCollectionDraft(event: Event, draftId: string): void { + const block = (event.currentTarget as HTMLElement).closest(".cfg-block"); + const draft = Array.from(block?.children ?? []).find((child) => child.id === draftId); + const openDraft = (draft as Partial | undefined)?.openDraft; + if (typeof openDraft === "function") { + openDraft.call(draft); + } +} const UNSET_MAP_SOURCE_IDENTITY = Symbol("unset-map-source"); export function renderObject( @@ -137,6 +146,9 @@ export function renderObject( if (!canApplyObjectCandidate(schema, objectValue, candidate)) { return false; } + if (inherited) { + return onPatch(path, candidate) !== false; + } const accepted = childValue === undefined && onRemove ? onRemove(childPath) : onPatch(childPath, childValue); return accepted !== false; @@ -361,10 +373,10 @@ export function renderArray( @click=${(event: Event) => { if (atomicCandidate) { if (onPatch(path, atomicCandidate) === false) { - openConfigFormCollectionDraft(event, draftId); + openCollectionDraft(event, draftId); } } else if (requiresDraft) { - openConfigFormCollectionDraft(event, draftId); + openCollectionDraft(event, draftId); } else if (autoCandidate) { appendArrayRowIdentities( autoCandidate, @@ -373,7 +385,7 @@ export function renderArray( ); if (onPatch(path, autoCandidate) === false) { discardArrayRowIdentities(autoCandidate); - openConfigFormCollectionDraft(event, draftId); + openCollectionDraft(event, draftId); } } }} @@ -548,7 +560,7 @@ function renderMapField( ?disabled=${disabled} @click=${(event: Event) => { if (entryDefault === NO_SAFE_DEFAULT) { - openConfigFormCollectionDraft(event, draftId); + openCollectionDraft(event, draftId); return; } const nextValue = { ...value }; @@ -560,7 +572,7 @@ function renderMapField( } nextValue[key] = entryDefault; if (onPatch(path, nextValue) === false) { - openConfigFormCollectionDraft(event, draftId); + openCollectionDraft(event, draftId); } }} > From a83a7905f72c739d10dd28e0adc52ea08ac6322c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 14:27:27 +0800 Subject: [PATCH 024/347] fix(ui): make security provenance null-safe --- ui/src/pages/config/config-page.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index e774285b6e64..c241b1976128 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -177,8 +177,8 @@ export function extractQuickSettingsSecurity(config: unknown): SecurityOverview } const gateway = asConfigRecord(root.gateway); const auth = asConfigRecord(gateway?.auth); - const tools = asConfigRecord(root.tools) ?? {}; - const exec = asConfigRecord(tools.exec) ?? {}; + const tools = asConfigRecord(root.tools); + const exec = asConfigRecord(tools?.exec) ?? {}; const browser = asConfigRecord(root.browser); const controlUi = asConfigRecord(gateway?.controlUi); let gatewayAuth = "unknown"; @@ -194,7 +194,7 @@ export function extractQuickSettingsSecurity(config: unknown): SecurityOverview ? "trusted-proxy" : "none"; } - const profile = tools.profile; + const profile = tools?.profile; const security = exec.security; return { gatewayAuth, @@ -203,7 +203,7 @@ export function extractQuickSettingsSecurity(config: unknown): SecurityOverview browserEnabled: browser?.enabled !== false, browserEnabledOverridden: browser !== null && Object.hasOwn(browser, "enabled"), toolProfile: typeof profile === "string" && profile.trim() ? profile.trim() : "full", - toolProfileOverridden: Object.hasOwn(tools, "profile"), + toolProfileOverridden: tools !== null && Object.hasOwn(tools, "profile"), }; } From 711ce34c533de0ea660eb0f7cd0b8aeaf08e6f52 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 14:35:20 +0800 Subject: [PATCH 025/347] fix(ui): conceal sensitive collection defaults --- ...g-form-collection-defaults.browser.test.ts | 59 +++++++++++++++++++ .../components/config-form.node.collection.ts | 38 ++++++++---- 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/ui/src/components/config-form-collection-defaults.browser.test.ts b/ui/src/components/config-form-collection-defaults.browser.test.ts index ecf92a42eae5..81568a94fabe 100644 --- a/ui/src/components/config-form-collection-defaults.browser.test.ts +++ b/ui/src/components/config-form-collection-defaults.browser.test.ts @@ -132,6 +132,65 @@ describe("config form collection defaults", () => { expect(onPatch).not.toHaveBeenCalled(); }); + it("conceals sensitive collection defaults and disables restore until revealed", () => { + const container = document.createElement("div"); + const onPatch = vi.fn(); + const hints = { + "settings.profile": { sensitive: true }, + "settings.tokens": { sensitive: true }, + }; + + render( + renderObject( + { + schema: { + type: "object", + title: "Profile", + default: { apiKey: "default-secret" }, + properties: { apiKey: { type: "string" } }, + }, + value: { apiKey: "authored-secret" }, + path: ["settings", "profile"], + hints, + unsupported: new Set(), + disabled: false, + revealSensitive: false, + onPatch, + }, + renderNode, + ), + container, + ); + + expect(container.textContent).not.toContain("default-secret"); + expect(resetButton(container).disabled).toBe(true); + + render( + renderArray( + { + schema: { + type: "array", + title: "Tokens", + items: { type: "string" }, + default: ["default-token"], + }, + value: ["authored-token"], + path: ["settings", "tokens"], + hints, + unsupported: new Set(), + disabled: false, + revealSensitive: false, + onPatch, + }, + renderNode, + ), + container, + ); + + expect(container.textContent).not.toContain("default-token"); + expect(resetButton(container).disabled).toBe(true); + }); + it("removes an optional object as one value and keeps inherited children inherited", () => { const container = document.createElement("div"); const onPatch = vi.fn(); diff --git a/ui/src/components/config-form.node.collection.ts b/ui/src/components/config-form.node.collection.ts index 3072c82b8c5a..0a216441bfec 100644 --- a/ui/src/components/config-form.node.collection.ts +++ b/ui/src/components/config-form.node.collection.ts @@ -53,16 +53,30 @@ import { configFieldId, hintForPath, type JsonSchema } from "./config-form.share import { renderSettingsEmpty } from "./settings-ui.ts"; const UNSET_ARRAY_SOURCE_IDENTITY = Symbol("unset-array-source"); +const UNSET_MAP_SOURCE_IDENTITY = Symbol("unset-map-source"); + +function collectionDefaultPresentation(params: ConfigNodeRenderParams, effectiveValue: unknown) { + const redacted = getSensitiveRenderState({ + path: params.path, + value: effectiveValue, + hints: params.hints, + revealSensitive: params.revealSensitive ?? false, + isSensitivePathRevealed: params.isSensitivePathRevealed, + }).isRedacted; + return { + description: redacted ? nothing : renderSchemaDefaultDescription(params.schema, params.value), + action: renderRestoreDefaultButton({ + ...params, + disabled: params.disabled || redacted, + }), + }; +} function openCollectionDraft(event: Event, draftId: string): void { const block = (event.currentTarget as HTMLElement).closest(".cfg-block"); const draft = Array.from(block?.children ?? []).find((child) => child.id === draftId); - const openDraft = (draft as Partial | undefined)?.openDraft; - if (typeof openDraft === "function") { - openDraft.call(draft); - } + (draft as Partial | undefined)?.openDraft?.call(draft); } -const UNSET_MAP_SOURCE_IDENTITY = Symbol("unset-map-source"); export function renderObject( params: ConfigNodeRenderParams, @@ -97,6 +111,7 @@ export function renderObject( fallback && typeof fallback === "object" && !Array.isArray(fallback) ? (fallback as Record) : {}; + const defaultPresentation = collectionDefaultPresentation(params, fallback); const entries = objectPropertyKeys(schema) .map((key) => [key, objectPropertySchema(schema, key)] as const) .filter((entry): entry is readonly [string, ConfigNodeRenderParams["schema"]] => @@ -206,14 +221,12 @@ export function renderObject( ${label} ${help ? html`${help}` : nothing} ${schema.default !== undefined - ? html`${renderSchemaDefaultDescription(schema, value)}` + ? html`${defaultPresentation.description}` : nothing} ${renderTags(tags)}
- ${renderRestoreDefaultButton(params)} + ${defaultPresentation.action} ${icons.chevronDown}
@@ -271,6 +284,7 @@ export function renderArray( : Array.isArray(schema.default) ? schema.default : UNSET_ARRAY_SOURCE_IDENTITY; + const defaultPresentation = collectionDefaultPresentation(params, arrayValue); const rowIdentities = rowIdentitiesForArray(arrayValue); const { minItems: minimumItems, @@ -352,9 +366,7 @@ export function renderArray( ${showLabel ? html`${label}` : nothing} ${showLabel && help ? html`${help}` : nothing} ${showLabel && schema.default !== undefined - ? html`${renderSchemaDefaultDescription(schema, value)}` + ? html`${defaultPresentation.description}` : nothing} ${renderTags(tags)}
@@ -364,7 +376,7 @@ export function renderArray( count: String(arrayValue.length), })} - ${renderRestoreDefaultButton(params)} + ${defaultPresentation.action}
${props.message - ? html`
${props.message.text}
` + ? html`
+ ${props.message.text} +
` + : nothing} + ${props.message?.warning + ? html`
${props.message.warning}
` : nothing}
`; diff --git a/ui/src/pages/model-providers/model-providers-page.test.ts b/ui/src/pages/model-providers/model-providers-page.test.ts index ee350cf6a555..67bea60ce2af 100644 --- a/ui/src/pages/model-providers/model-providers-page.test.ts +++ b/ui/src/pages/model-providers/model-providers-page.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { ModelsProbeResult } from "../../api/types.ts"; import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts"; +import type { DefaultModelSelection, ModelProviderLogoutTarget } from "./data.ts"; import { EMPTY_MODEL_PROVIDERS_DATA, type ModelProvidersData } from "./load.ts"; import type { ModelProvidersRouteData } from "./model-providers-page.ts"; import "./model-providers-page.ts"; @@ -13,9 +14,21 @@ type ModelProvidersPageTestElement = HTMLElement & { updateComplete: Promise; busy: Record; data: ModelProvidersData | null; + addProvider: () => Promise; + addProviderId: string; + addProviderKey: string; + addProviderOpen: boolean; + defaultsDraft: DefaultModelSelection | null; + keyDraft: string; + keyEditorProvider: string | null; + logout: (cardId: string, targets: ModelProviderLogoutTarget[]) => Promise; + messages: Record; + pendingLogoutProvider: string | null; probe: (cardId: string, providers: string[]) => Promise; probeResults: Record; routeData: ModelProvidersRouteData | undefined; + saveDefaultModels: () => Promise; + saveKey: (provider: string, configKey: string) => Promise; selectedAgentId: string; }; @@ -96,9 +109,12 @@ function createHarness(initialScopeId: string) { configFormMode: "form", configFormDirty: false, configAutoSaveStatus: "idle", + lastError: null as string | null, }, - ensureLoaded: vi.fn(async () => undefined), + ensureLoaded: vi.fn(async (): Promise => undefined), + patch: vi.fn(async () => true), patchForm: vi.fn(), + refresh: vi.fn(async () => undefined), save: vi.fn(async () => true), apply: vi.fn(async () => true), discardDraft: vi.fn(async () => undefined), @@ -190,6 +206,226 @@ describe("ModelProvidersPage agent scope", () => { ); }); + it("keeps a committed provider-key save successful when config refresh fails", async () => { + const { context, runtimeConfig } = createHarness("main"); + runtimeConfig.refresh.mockImplementationOnce(async () => { + runtimeConfig.state.lastError = "config.get failed after provider-key commit"; + }); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + page.keyEditorProvider = "openai"; + page.keyDraft = "replacement"; + + await page.saveKey("openai", "openai"); + + expect(runtimeConfig.patch).toHaveBeenCalledOnce(); + expect(page.keyEditorProvider).toBeNull(); + expect(page.messages.openai).toEqual({ + kind: "success", + text: "Secret saved.", + warning: "config.get failed after provider-key commit", + }); + }); + + it("keeps committed provider-add feedback visible when its refresh fails", async () => { + const { context, runtimeConfig } = createHarness("main"); + runtimeConfig.refresh.mockImplementationOnce(async () => { + runtimeConfig.state.lastError = "config.get failed after provider add"; + }); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + page.addProviderOpen = true; + page.addProviderId = "anthropic"; + page.addProviderKey = "new-provider-key"; + + await page.addProvider(); + await page.updateComplete; + + expect(runtimeConfig.patch).toHaveBeenCalledOnce(); + expect(page.addProviderOpen).toBe(true); + expect(page.addProviderKey).toBe(""); + const form = page.querySelector(".model-providers__add-form")?.parentElement; + expect( + [...form!.querySelectorAll('[role="status"]')].map((message) => message.textContent?.trim()), + ).toEqual(["Provider anthropic added.", "config.get failed after provider add"]); + }); + + it("keeps committed default models visible until their authoritative refresh succeeds", async () => { + const { context, runtimeConfig } = createHarness("main"); + runtimeConfig.refresh.mockImplementationOnce(async () => { + runtimeConfig.state.lastError = "config.get failed after saving default models"; + }); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + const selection: DefaultModelSelection = { + primary: "openai/gpt-5", + fallbacks: [], + utilityModel: null, + }; + page.defaultsDraft = selection; + + await page.saveDefaultModels(); + + expect(runtimeConfig.patch).toHaveBeenCalledOnce(); + expect(page.defaultsDraft).toBe(selection); + expect(page.messages.defaults).toEqual({ + kind: "success", + text: "Default models saved.", + warning: "config.get failed after saving default models", + }); + }); + + it("keeps a replacement agent's default-model draft after a global model write", async () => { + const { agentSelection, context, notifySelection, runtimeConfig } = createHarness("main"); + const gate = deferred(); + runtimeConfig.ensureLoaded.mockImplementationOnce(async () => gate.promise); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + const selection: DefaultModelSelection = { + primary: "openai/gpt-5", + fallbacks: [], + utilityModel: null, + }; + page.defaultsDraft = selection; + + const saving = page.saveDefaultModels(); + await vi.waitFor(() => expect(runtimeConfig.ensureLoaded).toHaveBeenCalledOnce()); + agentSelection.state.scopeId = "writer"; + notifySelection(); + await vi.waitFor(() => expect(page.selectedAgentId).toBe("writer")); + gate.resolve(); + await saving; + + expect(runtimeConfig.patch).toHaveBeenCalledOnce(); + expect(page.defaultsDraft).toBe(selection); + expect(page.messages.defaults).toBeUndefined(); + }); + + it("keeps global provider writes without clearing a replacement agent's credential draft", async () => { + const { agentSelection, context, notifySelection, runtimeConfig } = createHarness("main"); + const gate = deferred(); + runtimeConfig.ensureLoaded.mockImplementationOnce(async () => gate.promise); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + page.keyEditorProvider = "openai"; + page.keyDraft = "main-agent-key"; + + const saving = page.saveKey("openai", "openai"); + await vi.waitFor(() => expect(runtimeConfig.ensureLoaded).toHaveBeenCalledOnce()); + agentSelection.state.scopeId = "writer"; + notifySelection(); + await vi.waitFor(() => expect(page.selectedAgentId).toBe("writer")); + page.keyEditorProvider = "anthropic"; + page.keyDraft = "writer-agent-unsaved-key"; + gate.resolve(); + await saving; + + expect(runtimeConfig.patch).toHaveBeenCalledOnce(); + expect(runtimeConfig.patch).toHaveBeenCalledWith( + expect.objectContaining({ + raw: { models: { providers: { openai: { apiKey: "main-agent-key" } } } }, + }), + ); + expect(page.keyEditorProvider).toBe("anthropic"); + expect(page.keyDraft).toBe("writer-agent-unsaved-key"); + expect(page.messages.openai).toBeUndefined(); + }); + + it("keeps a replacement agent's matching add-provider draft after a global write", async () => { + const { agentSelection, context, notifySelection, runtimeConfig } = createHarness("main"); + const gate = deferred(); + runtimeConfig.ensureLoaded.mockImplementationOnce(async () => gate.promise); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + page.addProviderOpen = true; + page.addProviderId = "anthropic"; + page.addProviderKey = "shared-provider-key"; + + const adding = page.addProvider(); + await vi.waitFor(() => expect(runtimeConfig.ensureLoaded).toHaveBeenCalledOnce()); + agentSelection.state.scopeId = "writer"; + notifySelection(); + await vi.waitFor(() => expect(page.selectedAgentId).toBe("writer")); + page.addProviderOpen = true; + page.addProviderId = "anthropic"; + page.addProviderKey = "shared-provider-key"; + gate.resolve(); + await adding; + + expect(runtimeConfig.patch).toHaveBeenCalledOnce(); + expect(page.addProviderOpen).toBe(true); + expect(page.addProviderId).toBe("anthropic"); + expect(page.addProviderKey).toBe("shared-provider-key"); + expect(page.messages.add).toBeUndefined(); + }); + + it("stops queued agent-scoped logouts after the selected agent changes", async () => { + const { agentSelection, context, notifySelection, request } = createHarness("main"); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + request.mockClear(); + const firstLogout = deferred(); + request.mockImplementationOnce(async () => firstLogout.promise); + + const loggingOut = page.logout("openai", [ + { provider: "openai", profileIds: ["openai:first"] }, + { provider: "alias", profileIds: ["openai:second"] }, + ]); + await vi.waitFor(() => + expect(request).toHaveBeenCalledWith("models.authLogout", { + provider: "openai", + profileIds: ["openai:first"], + agentId: "main", + }), + ); + agentSelection.state.scopeId = "writer"; + notifySelection(); + await vi.waitFor(() => expect(page.selectedAgentId).toBe("writer")); + agentSelection.state.scopeId = "main"; + notifySelection(); + await vi.waitFor(() => expect(page.selectedAgentId).toBe("main")); + firstLogout.resolve({}); + await loggingOut; + + expect(request.mock.calls.filter(([method]) => method === "models.authLogout")).toHaveLength(1); + }); + + it("stops queued agent-scoped logouts when route data changes the selected agent", async () => { + const { agentSelection, context, request, snapshot } = createHarness("main"); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + request.mockClear(); + const firstLogout = deferred(); + request.mockImplementationOnce(async () => firstLogout.promise); + + const loggingOut = page.logout("openai", [ + { provider: "openai", profileIds: ["openai:first"] }, + { provider: "alias", profileIds: ["openai:second"] }, + ]); + await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); + page.pendingLogoutProvider = "openai"; + page.messages = { openai: { kind: "error", text: "Previous agent failure" } }; + page.probeResults = { + openai: { provider: "openai", status: "ok", results: [] }, + }; + agentSelection.state.scopeId = "writer"; + page.routeData = { + data: { ...EMPTY_MODEL_PROVIDERS_DATA, config: {}, updatedAt: 1 }, + client: snapshot.client, + agentId: "writer", + }; + await page.updateComplete; + expect(page.selectedAgentId).toBe("writer"); + expect(page.busy).toEqual({}); + expect(page.pendingLogoutProvider).toBeNull(); + expect(page.messages).toEqual({}); + expect(page.probeResults).toEqual({}); + firstLogout.resolve({}); + await loggingOut; + + expect(request.mock.calls.filter(([method]) => method === "models.authLogout")).toHaveLength(1); + }); + it("reloads credential status when the agent selector changes", async () => { const { agentSelection, context, notifySelection, request } = createHarness("main"); const page = appendPage(context); diff --git a/ui/src/pages/model-providers/model-providers-page.ts b/ui/src/pages/model-providers/model-providers-page.ts index 66c3e498e37c..3aaf40147757 100644 --- a/ui/src/pages/model-providers/model-providers-page.ts +++ b/ui/src/pages/model-providers/model-providers-page.ts @@ -17,6 +17,12 @@ import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; import { normalizeAgentId } from "../../lib/sessions/session-key.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; +import { + modelProviderErrorMessage, + runModelProviderConfigMutation, + type ModelProviderConfigMutation, + type ModelProviderConfigMutationResult, +} from "./config-mutation.ts"; import { buildModelProviderCards, buildSelectableDefaultModels, @@ -48,15 +54,10 @@ export type ModelProvidersRouteData = { agentId: string; }; -function errorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim()) { - return error.message; - } - return typeof error === "string" && error.trim() ? error : t("modelProviders.requestFailed"); -} - function isMissingMethodError(error: unknown): boolean { - return /method (?:not found|not supported)|unknown method/iu.test(errorMessage(error)); + return /method (?:not found|not supported)|unknown method/iu.test( + modelProviderErrorMessage(error), + ); } const PROBE_FAILURE_PRIORITY: readonly ModelsProbeResult["status"][] = [ @@ -116,6 +117,8 @@ export class ModelProvidersPage extends OpenClawLightDomElement { private dataClient: GatewayBrowserClient | null = null; private observedClient: GatewayBrowserClient | null = null; private clientEpoch = 0; + // Global config writes survive agent switches; their card state does not. + private agentEpoch = 0; private probeEpochs = new Map(); private readonly refreshTask = new Task(this, { autoRun: false, @@ -177,7 +180,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement { override willUpdate(changed: PropertyValues) { if (changed.has("routeData") && this.routeData) { const selectedAgentId = this.resolveSelectedAgentId(); - this.selectedAgentId = selectedAgentId; + this.setSelectedAgent(selectedAgentId); if (this.routeData.agentId === selectedAgentId) { this.data = this.routeData.data; this.dataClient = this.routeData.client; @@ -249,18 +252,26 @@ export class ModelProvidersPage extends OpenClawLightDomElement { return normalizeAgentId(agentsList?.defaultId ?? agentsList?.agents[0]?.id ?? "main"); } - private syncSelectedAgent() { - const agentId = this.resolveSelectedAgentId(); + private setSelectedAgent(agentId: string): boolean { if (agentId === this.selectedAgentId) { - return; + return false; } this.selectedAgentId = agentId; - void this.refreshTask.run([null, agentId, false]); - this.data = null; + this.agentEpoch += 1; this.busy = {}; this.pendingLogoutProvider = null; this.messages = {}; this.probeResults = {}; + return true; + } + + private syncSelectedAgent() { + const agentId = this.resolveSelectedAgentId(); + if (!this.setSelectedAgent(agentId)) { + return; + } + void this.refreshTask.run([null, agentId, false]); + this.data = null; // probeEpochs stays: per-card counters must remain monotonic across agent // switches, or an in-flight probe from the old agent can reuse an epoch // and clobber a newer probe's state (A->B->A ABA race). @@ -290,7 +301,19 @@ export class ModelProvidersPage extends OpenClawLightDomElement { } private canMutate(): boolean { - return this.mutationBlockedReason() === null; + return this.mutationBlockedReason() === null && !this.configBusy(); + } + + private configBusy(): boolean { + const runtimeState = this.context.runtimeConfig.state; + const update = this.context.overlays.snapshot; + return ( + runtimeState.configLoading || + runtimeState.configSaving || + runtimeState.configApplying || + update.updateRunning || + update.updateReconciliationPending + ); } private setBusy(key: string, value: boolean) { @@ -321,64 +344,30 @@ export class ModelProvidersPage extends OpenClawLightDomElement { this.probeResults = next; } - private async patchConfig(params: { - key: string; - raw: Record; - note: string; - success: string; - replacePaths?: string[]; - }): Promise { + private async patchConfig( + params: ModelProviderConfigMutation, + ): Promise { if (!this.canMutate() || this.busy[params.key]) { - return false; + return { ok: false }; } const client = this.context.gateway.snapshot.client; if (!client) { - return false; + return { ok: false }; } const clientEpoch = this.clientEpoch; - const runtimeConfig = this.context.runtimeConfig; - this.setBusy(params.key, true); - this.setMessage(params.key, null); - try { - await runtimeConfig.ensureLoaded(); - if (!this.isCurrentClient(client, clientEpoch)) { - return false; - } - const patched = await runtimeConfig.patch({ - raw: params.raw, - note: params.note, - ...(params.replacePaths ? { replacePaths: params.replacePaths } : {}), - }); - if (!this.isCurrentClient(client, clientEpoch)) { - return false; - } - if (!patched) { - this.setMessage(params.key, { - kind: "error", - text: runtimeConfig.state.lastError ?? t("modelProviders.configUnavailable"), - }); - return false; - } - await runtimeConfig.refresh(); - if (!this.isCurrentClient(client, clientEpoch)) { - return false; - } - await this.refresh({ force: true }); - if (!this.isCurrentClient(client, clientEpoch)) { - return false; - } - this.setMessage(params.key, { kind: "success", text: params.success }); - return true; - } catch (error) { - if (this.isCurrentClient(client, clientEpoch)) { - this.setMessage(params.key, { kind: "error", text: errorMessage(error) }); - } - return false; - } finally { - if (this.isCurrentClient(client, clientEpoch)) { - this.setBusy(params.key, false); - } - } + const agentEpoch = this.agentEpoch; + return runModelProviderConfigMutation( + { + runtimeConfig: this.context.runtimeConfig, + agentEpoch, + isCurrentClient: () => this.isCurrentClient(client, clientEpoch), + isCurrentAgent: () => this.agentEpoch === agentEpoch, + refreshProviders: () => this.refresh({ force: true }), + setBusy: (busy) => this.setBusy(params.key, busy), + setMessage: (message) => this.setMessage(params.key, message), + }, + params, + ); } private openKeyEditor(provider: string) { @@ -400,16 +389,22 @@ export class ModelProvidersPage extends OpenClawLightDomElement { this.clearProbe(provider); this.setMessage(provider, null); this.setMessage(`key:${provider}`, null); - const ok = await this.patchConfig({ + const result = await this.patchConfig({ key: `key:${provider}`, raw: buildProviderApiKeyPatch(configKey, apiKey), note: t("modelProviders.notes.saveKey", { provider }), success: t("modelProviders.apiKey.saved"), }); - if (ok) { + if (result.ok && this.agentEpoch === result.agentEpoch) { this.setMessage(`key:${provider}`, null); - this.closeKeyEditor(); - this.setMessage(provider, { kind: "success", text: t("modelProviders.apiKey.saved") }); + if (this.keyEditorProvider === provider && this.keyDraft.trim() === apiKey) { + this.closeKeyEditor(); + } + this.setMessage(provider, { + kind: "success", + text: t("modelProviders.apiKey.saved"), + ...(result.warning ? { warning: result.warning } : {}), + }); } } @@ -417,16 +412,22 @@ export class ModelProvidersPage extends OpenClawLightDomElement { this.clearProbe(provider); this.setMessage(provider, null); this.setMessage(`key:${provider}`, null); - const ok = await this.patchConfig({ + const result = await this.patchConfig({ key: `key:${provider}`, raw: buildProviderApiKeyPatch(configKey, null), note: t("modelProviders.notes.removeKey", { provider }), success: t("modelProviders.apiKey.removed"), }); - if (ok) { + if (result.ok && this.agentEpoch === result.agentEpoch) { this.setMessage(`key:${provider}`, null); - this.closeKeyEditor(); - this.setMessage(provider, { kind: "success", text: t("modelProviders.apiKey.removed") }); + if (this.keyEditorProvider === provider) { + this.closeKeyEditor(); + } + this.setMessage(provider, { + kind: "success", + text: t("modelProviders.apiKey.removed"), + ...(result.warning ? { warning: result.warning } : {}), + }); } } @@ -474,7 +475,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement { text: t("modelProviders.probe.unavailable"), }); } else { - this.setMessage(cardId, { kind: "error", text: errorMessage(error) }); + this.setMessage(cardId, { kind: "error", text: modelProviderErrorMessage(error) }); } } finally { if ( @@ -494,37 +495,43 @@ export class ModelProvidersPage extends OpenClawLightDomElement { } const clientEpoch = this.clientEpoch; const agentId = this.selectedAgentId; + const agentEpoch = this.agentEpoch; this.clearProbe(cardId); this.setBusy(key, true); this.setMessage(cardId, null); try { let firstError: unknown; for (const target of targets) { + // OAuth profiles are agent-owned; stop undispatched targets after any + // scope change, including a switch away from and back to this agent. + if (!this.isCurrentClient(client, clientEpoch) || this.agentEpoch !== agentEpoch) { + return; + } try { await client.request("models.authLogout", { ...target, agentId }); } catch (error) { firstError ??= error; } } - if (!this.isCurrentClient(client, clientEpoch) || this.selectedAgentId !== agentId) { + if (!this.isCurrentClient(client, clientEpoch) || this.agentEpoch !== agentEpoch) { return; } await this.refresh({ force: true }); - if (!this.isCurrentClient(client, clientEpoch) || this.selectedAgentId !== agentId) { + if (!this.isCurrentClient(client, clientEpoch) || this.agentEpoch !== agentEpoch) { return; } if (firstError) { - this.setMessage(cardId, { kind: "error", text: errorMessage(firstError) }); + this.setMessage(cardId, { kind: "error", text: modelProviderErrorMessage(firstError) }); return; } this.pendingLogoutProvider = null; this.setMessage(cardId, { kind: "success", text: t("modelProviders.logout.done") }); } catch (error) { - if (this.isCurrentClient(client, clientEpoch) && this.selectedAgentId === agentId) { - this.setMessage(cardId, { kind: "error", text: errorMessage(error) }); + if (this.isCurrentClient(client, clientEpoch) && this.agentEpoch === agentEpoch) { + this.setMessage(cardId, { kind: "error", text: modelProviderErrorMessage(error) }); } } finally { - if (this.isCurrentClient(client, clientEpoch) && this.selectedAgentId === agentId) { + if (this.isCurrentClient(client, clientEpoch) && this.agentEpoch === agentEpoch) { this.setBusy(key, false); } } @@ -536,19 +543,26 @@ export class ModelProvidersPage extends OpenClawLightDomElement { if (!provider || !apiKey) { return; } - const ok = await this.patchConfig({ + const result = await this.patchConfig({ key: "add", raw: buildProviderApiKeyPatch(provider, apiKey), note: t("modelProviders.notes.addProvider", { provider }), success: t("modelProviders.add.saved", { provider }), }); - if (ok) { - this.addProviderOpen = false; - this.addProviderId = ""; - this.addProviderKey = ""; + if (result.ok && this.agentEpoch === result.agentEpoch) { + if (this.addProviderId === provider && this.addProviderKey.trim() === apiKey) { + // A failed refresh leaves a new provider without a card. Keep its + // success + warning visible in the open form instead of losing both. + this.addProviderOpen = Boolean(result.warning); + if (!result.warning) { + this.addProviderId = ""; + } + this.addProviderKey = ""; + } this.setMessage(provider, { kind: "success", text: t("modelProviders.add.saved", { provider }), + ...(result.warning ? { warning: result.warning } : {}), }); } } @@ -558,14 +572,21 @@ export class ModelProvidersPage extends OpenClawLightDomElement { if (!selection?.primary) { return; } - const ok = await this.patchConfig({ + const result = await this.patchConfig({ key: "defaults", raw: buildDefaultModelsPatch(selection.primary, selection.fallbacks, selection.utilityModel), note: t("modelProviders.notes.defaultModel"), success: t("modelProviders.defaults.saved"), replacePaths: DEFAULT_MODELS_REPLACE_PATHS, }); - if (ok) { + // Without fresh provider data, clearing this committed draft would show + // the old default models beside a contradictory success message. + if ( + result.ok && + !result.warning && + this.agentEpoch === result.agentEpoch && + this.defaultsDraft === selection + ) { this.defaultsDraft = null; } } @@ -589,16 +610,10 @@ export class ModelProvidersPage extends OpenClawLightDomElement { typeof agentsDefaults?.thinkingDefault === "string" ? agentsDefaults.thinkingDefault : "off"; const fastValue = agentsDefaults?.fastModeDefault; const fastMode = fastValue === "auto" || typeof fastValue === "boolean" ? fastValue : false; - const update = this.context.overlays.snapshot; // The overlay update states replace General's old configUpdating prop, // which config-page derived from this same snapshot (isUpdateBusy); the // busy gate is behavior-identical to the pre-move General controls. - const configBusy = - runtimeState.configLoading || - runtimeState.configSaving || - runtimeState.configApplying || - update.updateRunning || - update.updateReconciliationPending; + const configBusy = this.configBusy(); const cards = buildModelProviderCards({ ...data, configProviderIds: config.providerIds, diff --git a/ui/src/pages/model-providers/view.test.ts b/ui/src/pages/model-providers/view.test.ts index f4aabefa6d04..705491c637f0 100644 --- a/ui/src/pages/model-providers/view.test.ts +++ b/ui/src/pages/model-providers/view.test.ts @@ -167,6 +167,181 @@ describe("renderModelProviders", () => { expect([...groups].every((group) => group.disabled)).toBe(true); }); + it("locks provider and default-model mutations while shared config work is pending", () => { + const container = mount( + props({ + configBusy: true, + defaultModelsDirty: true, + defaultModels: { + primary: "openai/gpt-5", + fallbacks: ["anthropic/claude"], + utilityModel: null, + }, + configuredModels: [ + { id: "openai/gpt-5", provider: "openai", name: "GPT-5", available: true }, + { id: "anthropic/claude", provider: "anthropic", name: "Claude", available: true }, + ], + cards: [ + card({ + hasConfigApiKey: true, + apiKey: { source: "config" }, + logoutTargets: [{ provider: "openai", profileIds: ["openai:oauth"] }], + }), + ], + keyEditorProvider: "openai", + keyDraft: "replacement", + addProviderOpen: true, + addProviderId: "anthropic", + addProviderKey: "new-provider-key", + }), + ); + + const defaults = container.querySelector(".model-providers__defaults"); + const defaultControls = [ + ...(defaults?.querySelectorAll( + "select, .model-providers__fallback-row button", + ) ?? []), + button(container, "Save"), + ]; + expect(defaultControls.map((control) => control?.disabled)).toEqual([ + true, + true, + true, + true, + true, + ]); + + const provider = container.querySelector('[data-provider-id="openai"]'); + expect( + provider?.querySelector(".model-providers__inline-form input")?.disabled, + ).toBe(true); + expect(button(provider!, "Replace key")?.disabled).toBe(true); + expect(button(provider!, "Remove key")?.disabled).toBe(true); + expect(button(provider!, "Log out")?.disabled).toBe(true); + + const addForm = container.querySelector(".model-providers__add-form"); + expect( + [ + ...(addForm?.querySelectorAll( + "select, input, button", + ) ?? []), + ].map((control) => control.disabled), + ).toEqual([true, true, true]); + }); + + it("locks an already-open provider form after mutation access is revoked", () => { + const onAddProvider = vi.fn(); + const onAddProviderToggle = vi.fn(); + const container = mount( + props({ + addProviderOpen: true, + addProviderId: "anthropic", + addProviderKey: "new-provider-key", + canMutate: false, + mutationBlockedReason: "Operator admin access required", + onAddProvider, + onAddProviderToggle, + }), + ); + const addForm = container.querySelector(".model-providers__add-form"); + const controls = [ + ...(addForm?.querySelectorAll( + "select, input, button", + ) ?? []), + ]; + + expect(controls.map((control) => control.disabled)).toEqual([true, true, true]); + addForm?.querySelector("button")?.click(); + expect(onAddProvider).not.toHaveBeenCalled(); + + const cancel = button(addForm!.closest(".settings-section")!, "Cancel"); + expect(cancel?.disabled).toBe(false); + cancel?.click(); + expect(onAddProviderToggle).toHaveBeenCalledOnce(); + }); + + it("freezes provider and credential fields while adding a provider", () => { + const container = mount( + props({ + addProviderOpen: true, + addProviderId: "anthropic", + addProviderKey: "new-provider-key", + busy: { add: true }, + }), + ); + const addForm = container.querySelector(".model-providers__add-form"); + + expect( + [ + ...(addForm?.querySelectorAll("select, input") ?? []), + ].map((control) => control.disabled), + ).toEqual([true, true]); + }); + + it("keeps committed credential success visible beside its refresh warning", () => { + const container = mount( + props({ + messages: { + openai: { + kind: "success", + text: "Secret saved.", + warning: "Config refresh failed after the secret was committed.", + }, + }, + }), + ); + const provider = container.querySelector('[data-provider-id="openai"]'); + const messages = [...(provider?.querySelectorAll('[role="status"]') ?? [])]; + + expect(messages.map((message) => text(message))).toEqual([ + "Secret saved.", + "Config refresh failed after the secret was committed.", + ]); + expect(messages[0]?.classList.contains("success")).toBe(true); + expect(messages[1]?.classList.contains("warning")).toBe(true); + }); + + it("keeps committed default-model success visible beside its refresh warning", () => { + const container = mount( + props({ + messages: { + defaults: { + kind: "success", + text: "Default models saved.", + warning: "Config refresh failed after the model defaults were committed.", + }, + }, + }), + ); + const defaults = container.querySelector(".model-providers__defaults"); + const messages = [...(defaults?.querySelectorAll('[role="status"]') ?? [])]; + + expect(messages.map((message) => text(message))).toEqual([ + "Default models saved.", + "Config refresh failed after the model defaults were committed.", + ]); + expect(messages[0]?.classList.contains("success")).toBe(true); + expect(messages[1]?.classList.contains("warning")).toBe(true); + }); + + it("announces provider and default-model mutation failures as accessible alerts", () => { + const container = mount( + props({ + messages: { + openai: { kind: "error", text: "Provider credential could not be saved." }, + defaults: { kind: "error", text: "Default models could not be saved." }, + }, + }), + ); + + expect(text(container.querySelector('[data-provider-id="openai"] [role="alert"]'))).toBe( + "Provider credential could not be saved.", + ); + expect(text(container.querySelector('.model-providers__defaults [role="alert"]'))).toBe( + "Default models could not be saved.", + ); + }); + it("keeps model behavior available while provider data loads", () => { const container = mount(props({ loading: true, thinkingLevel: "high", fastMode: true })); const behavior = container.querySelector("#settings-model-behavior"); diff --git a/ui/src/pages/model-providers/view.ts b/ui/src/pages/model-providers/view.ts index 6141598a3ddf..933ff600cdbb 100644 --- a/ui/src/pages/model-providers/view.ts +++ b/ui/src/pages/model-providers/view.ts @@ -30,7 +30,11 @@ import type { } from "./data.ts"; import { renderDefaultModels } from "./default-models-view.ts"; -export type ModelProviderRowMessage = { kind: "success" | "error"; text: string }; +export type ModelProviderRowMessage = { + kind: "success" | "error"; + text: string; + warning?: string; +}; type ModelProvidersViewProps = { connected: boolean; @@ -93,6 +97,24 @@ function fastModeOptionValue(value: "auto" | "on" | "off"): FastMode { return value === "auto" ? "auto" : value === "on"; } +function configMutationDisabled(props: ModelProvidersViewProps): boolean { + return !props.canMutate || props.configBusy; +} + +function renderMutationMessage(message: ModelProviderRowMessage | undefined) { + if (!message) { + return nothing; + } + return html` +
+ ${message.text} +
+ ${message.warning + ? html`
${message.warning}
` + : nothing} + `; +} + function renderModelBehavior(props: ModelProvidersViewProps) { const fastMode = formatFastModeValue(props.fastMode); return html` @@ -317,6 +339,7 @@ function renderKeyEditor(card: ModelProviderCard, props: ModelProvidersViewProps const authModeBlocked = card.apiKeySupported === false || Boolean(card.configAuthMode && card.configAuthMode !== "api-key"); + const mutationDisabled = configMutationDisabled(props); return html`
`; } function renderAddProvider(props: ModelProvidersViewProps) { + const busy = Boolean(props.busy.add); + const disabled = configMutationDisabled(props) || busy; const rows = html` ${props.unconfiguredProviders.length === 0 ? renderSettingsEmpty(t("modelProviders.add.none")) @@ -495,6 +518,7 @@ function renderAddProvider(props: ModelProvidersViewProps) {