diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cb88e16813..a1f200fe811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - Security/Voice Call: harden media stream WebSocket handling against pre-auth idle-connection DoS by adding strict pre-start timeouts, pending/per-IP connection limits, and total connection caps for streaming endpoints. This ships in the next npm release. Thanks @jiseoung for reporting. - Agents/Exec: honor explicit agent context when resolving `tools.exec` defaults for runs with opaque/non-agent session keys, so per-agent `host/security/ask` policies are applied consistently. (#11832) +- Sandbox/Docker: default sandbox container user to the workspace owner `uid:gid` when `agents.*.sandbox.docker.user` is unset, fixing non-root gateway file-tool permissions under capability-dropped containers. (#20979) - Telegram/Discord extensions: propagate trusted `mediaLocalRoots` through extension outbound `sendMedia` options so extension direct-send media paths honor agent-scoped local-media allowlists. (#20029, #21903, #23227) - Exec/Background: stop applying the default exec timeout to background sessions (`background: true` or explicit `yieldMs`) when no explicit timeout is set, so long-running background jobs are no longer terminated at the default timeout boundary. (#23303) - Plugins/Media sandbox: propagate trusted `mediaLocalRoots` through plugin action dispatch (including Discord/Telegram action adapters) so plugin send paths enforce the same agent-scoped local-media sandbox roots as core outbound sends. (#20258, #22718) diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 34bc45846b9..8468dd2c556 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -14,7 +14,7 @@ import { createSandboxFsBridge } from "./fs-bridge.js"; import { maybePruneSandboxes } from "./prune.js"; import { resolveSandboxRuntimeStatus } from "./runtime-status.js"; import { resolveSandboxScopeKey, resolveSandboxWorkspaceDir } from "./shared.js"; -import type { SandboxContext, SandboxWorkspaceInfo } from "./types.js"; +import type { SandboxContext, SandboxDockerConfig, SandboxWorkspaceInfo } from "./types.js"; import { ensureSandboxWorkspace } from "./workspace.js"; async function ensureSandboxWorkspaceLayout(params: { @@ -64,6 +64,29 @@ async function ensureSandboxWorkspaceLayout(params: { return { agentWorkspaceDir, scopeKey, sandboxWorkspaceDir, workspaceDir }; } +export async function resolveSandboxDockerUser(params: { + docker: SandboxDockerConfig; + workspaceDir: string; + stat?: (workspaceDir: string) => Promise<{ uid: number; gid: number }>; +}): Promise { + const configuredUser = params.docker.user?.trim(); + if (configuredUser) { + return params.docker; + } + const stat = params.stat ?? ((workspaceDir: string) => fs.stat(workspaceDir)); + try { + const workspaceStat = await stat(params.workspaceDir); + const uid = Number.isInteger(workspaceStat.uid) ? workspaceStat.uid : null; + const gid = Number.isInteger(workspaceStat.gid) ? workspaceStat.gid : null; + if (uid === null || gid === null || uid < 0 || gid < 0) { + return params.docker; + } + return { ...params.docker, user: `${uid}:${gid}` }; + } catch { + return params.docker; + } +} + function resolveSandboxSession(params: { config?: OpenClawConfig; sessionKey?: string }) { const rawSessionKey = params.sessionKey?.trim(); if (!rawSessionKey) { @@ -102,11 +125,17 @@ export async function resolveSandboxContext(params: { workspaceDir: params.workspaceDir, }); + const docker = await resolveSandboxDockerUser({ + docker: cfg.docker, + workspaceDir, + }); + const resolvedCfg = docker === cfg.docker ? cfg : { ...cfg, docker }; + const containerName = await ensureSandboxContainer({ sessionKey: rawSessionKey, workspaceDir, agentWorkspaceDir, - cfg, + cfg: resolvedCfg, }); const evaluateEnabled = @@ -132,7 +161,7 @@ export async function resolveSandboxContext(params: { scopeKey, workspaceDir, agentWorkspaceDir, - cfg, + cfg: resolvedCfg, evaluateEnabled, bridgeAuth, }); @@ -142,12 +171,12 @@ export async function resolveSandboxContext(params: { sessionKey: rawSessionKey, workspaceDir, agentWorkspaceDir, - workspaceAccess: cfg.workspaceAccess, + workspaceAccess: resolvedCfg.workspaceAccess, containerName, - containerWorkdir: cfg.docker.workdir, - docker: cfg.docker, - tools: cfg.tools, - browserAllowHostControl: cfg.browser.allowHostControl, + containerWorkdir: resolvedCfg.docker.workdir, + docker: resolvedCfg.docker, + tools: resolvedCfg.tools, + browserAllowHostControl: resolvedCfg.browser.allowHostControl, browser: browser ?? undefined, }; diff --git a/src/agents/sandbox/context.user-fallback.test.ts b/src/agents/sandbox/context.user-fallback.test.ts new file mode 100644 index 00000000000..11751918009 --- /dev/null +++ b/src/agents/sandbox/context.user-fallback.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { resolveSandboxDockerUser } from "./context.js"; +import type { SandboxDockerConfig } from "./types.js"; + +const baseDocker: SandboxDockerConfig = { + image: "ghcr.io/example/sandbox:latest", + containerPrefix: "openclaw-sandbox-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp"], + network: "none", + capDrop: ["ALL"], +}; + +describe("resolveSandboxDockerUser", () => { + it("keeps configured docker.user", async () => { + const resolved = await resolveSandboxDockerUser({ + docker: { ...baseDocker, user: "2000:2000" }, + workspaceDir: "/tmp/unused", + stat: async () => ({ uid: 1000, gid: 1000 }), + }); + expect(resolved.user).toBe("2000:2000"); + }); + + it("falls back to workspace ownership when docker.user is unset", async () => { + const resolved = await resolveSandboxDockerUser({ + 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({ + docker: baseDocker, + workspaceDir: "/tmp/workspace", + stat: async () => { + throw new Error("ENOENT"); + }, + }); + expect(resolved.user).toBeUndefined(); + }); +});