diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 1d270974d..6ba690335 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2181,7 +2181,8 @@ cross-session isolation. Use `scope: "session"` for per-session isolation. Legacy: `perSession` is still supported (`true` → `scope: "session"`, `false` → `scope: "shared"`). -`setupCommand` runs **once** after the container is created (inside the container via `sh -lc`). +`setupCommand` runs **once** after the container is created (inside the container via `sh -lc`, or +the command specified in `agents.defaults.sandbox.docker.shellCommand`). For package installs, ensure network egress, a writable root FS, and a root user. ```json5 @@ -2204,6 +2205,7 @@ For package installs, ensure network egress, a writable root FS, and a root user capDrop: ["ALL"], env: { LANG: "C.UTF-8" }, setupCommand: "apt-get update && apt-get install -y git curl jq", + shellCommand: ["bash", "-lc"], // Per-agent override (multi-agent): agents.list[].sandbox.docker.* pidsLimit: 256, memory: "1g", diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 5dc3a33dd..5acb37cb1 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -123,7 +123,8 @@ Docker installs and the containerized gateway live here: ## setupCommand (one-time container setup) `setupCommand` runs **once** after the sandbox container is created (not on every run). -It executes inside the container via `sh -lc`. +It executes inside the container via `sh -lc` unless `agents.defaults.sandbox.docker.shellCommand` +overrides it. Paths: - Global: `agents.defaults.sandbox.docker.setupCommand` diff --git a/docs/install/docker.md b/docs/install/docker.md index 8ca80e53b..95aec78c0 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -441,7 +441,8 @@ Example: - Container not running: it will auto-create per session on demand. - Permission errors in sandbox: set `docker.user` to a UID:GID that matches your mounted workspace ownership (or chown the workspace folder). -- Custom tools not found: Moltbot runs commands with `sh -lc` (login shell), which +- Custom tools not found: Moltbot runs commands with `sh -lc` (login shell) unless + `agents.defaults.sandbox.docker.shellCommand` overrides it. The login shell sources `/etc/profile` and may reset PATH. Set `docker.env.PATH` to prepend your custom tool paths (e.g., `/custom/bin:/usr/local/share/npm-global/bin`), or add a script under `/etc/profile.d/` in your Dockerfile. diff --git a/docs/tools/exec.md b/docs/tools/exec.md index ca50140a9..ec7451869 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -66,7 +66,8 @@ Example: already sets `env.PATH`). The daemon itself still runs with a minimal `PATH`: - macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin` - Linux: `/usr/local/bin`, `/usr/bin`, `/bin` -- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`. +- `host=sandbox`: runs `sh -lc` (login shell) inside the container unless + `agents.defaults.sandbox.docker.shellCommand` overrides it, so `/etc/profile` may reset `PATH`. Moltbot prepends `env.PATH` after profile sourcing via an internal env var (no shell interpolation); `tools.exec.pathPrepend` applies here too. - `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index b9de81872..5c5c586a9 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -372,6 +372,7 @@ async function runExecProcess(opts: { workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir, env: opts.env, tty: opts.usePty, + shellCommand: opts.sandbox.shellCommand, }), ], options: { diff --git a/src/agents/bash-tools.shared.ts b/src/agents/bash-tools.shared.ts index aa4e5d000..a75b462fa 100644 --- a/src/agents/bash-tools.shared.ts +++ b/src/agents/bash-tools.shared.ts @@ -15,8 +15,17 @@ export type BashSandboxConfig = { workspaceDir: string; containerWorkdir: string; env?: Record; + shellCommand?: string[]; }; +const DEFAULT_SANDBOX_SHELL_COMMAND = ["sh", "-lc"]; + +export function resolveSandboxShellCommand(shellCommand?: string[]) { + if (!shellCommand) return DEFAULT_SANDBOX_SHELL_COMMAND; + const trimmed = shellCommand.map((entry) => entry.trim()).filter(Boolean); + return trimmed.length > 0 ? trimmed : DEFAULT_SANDBOX_SHELL_COMMAND; +} + export function buildSandboxEnv(params: { defaultPath: string; paramsEnv?: Record; @@ -51,6 +60,7 @@ export function buildDockerExecArgs(params: { workdir?: string; env: Record; tty: boolean; + shellCommand?: string[]; }) { const args = ["exec", "-i"]; if (params.tty) args.push("-t"); @@ -72,7 +82,8 @@ export function buildDockerExecArgs(params: { const pathExport = hasCustomPath ? 'export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"; unset CLAWDBOT_PREPEND_PATH; ' : ""; - args.push(params.containerName, "sh", "-lc", `${pathExport}${params.command}`); + const [shell, ...shellArgs] = resolveSandboxShellCommand(params.shellCommand); + args.push(params.containerName, shell, ...shellArgs, `${pathExport}${params.command}`); return args; } diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index 6747aadc8..a1b42678d 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js"; import { getFinishedSession, resetProcessRegistryForTests } from "./bash-process-registry.js"; import { createExecTool, createProcessTool, execTool, processTool } from "./bash-tools.js"; -import { buildDockerExecArgs } from "./bash-tools.shared.js"; +import { buildDockerExecArgs, resolveSandboxShellCommand } from "./bash-tools.shared.js"; import { sanitizeBinaryOutput } from "./shell-utils.js"; const isWin = process.platform === "win32"; @@ -413,4 +413,22 @@ describe("buildDockerExecArgs", () => { expect(args).toContain("-t"); }); + + it("uses configured sandbox shell command when provided", () => { + const args = buildDockerExecArgs({ + containerName: "test-container", + command: "echo test", + env: { HOME: "/home/user" }, + tty: false, + shellCommand: ["bash", "-lc"], + }); + + expect(args).toContain("bash"); + expect(args).toContain("-lc"); + }); + + it("falls back to default shell command when override is empty", () => { + const resolved = resolveSandboxShellCommand(["", " "]); + expect(resolved).toEqual(["sh", "-lc"]); + }); }); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index d763393a4..c8a46f12c 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -275,6 +275,7 @@ export function createMoltbotCodingTools(options?: { workspaceDir: sandbox.workspaceDir, containerWorkdir: sandbox.containerWorkdir, env: sandbox.docker.env, + shellCommand: sandbox.docker.shellCommand, } : undefined, }); diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index aa848c54b..f63a8594b 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -66,6 +66,7 @@ export function resolveSandboxDockerConfig(params: { capDrop: agentDocker?.capDrop ?? globalDocker?.capDrop ?? ["ALL"], env, setupCommand: agentDocker?.setupCommand ?? globalDocker?.setupCommand, + shellCommand: agentDocker?.shellCommand ?? globalDocker?.shellCommand, pidsLimit: agentDocker?.pidsLimit ?? globalDocker?.pidsLimit, memory: agentDocker?.memory ?? globalDocker?.memory, memorySwap: agentDocker?.memorySwap ?? globalDocker?.memorySwap, diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 21e8c67b5..0e77014ca 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -3,6 +3,7 @@ import { spawn } from "node:child_process"; import { defaultRuntime } from "../../runtime.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; +import { resolveSandboxShellCommand } from "../bash-tools.shared.js"; import { readRegistry, updateRegistry } from "./registry.js"; import { computeSandboxConfigHash } from "./config-hash.js"; import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js"; @@ -203,7 +204,8 @@ async function createSandboxContainer(params: { await execDocker(["start", name]); if (cfg.setupCommand?.trim()) { - await execDocker(["exec", "-i", name, "sh", "-lc", cfg.setupCommand]); + const [shell, ...shellArgs] = resolveSandboxShellCommand(cfg.shellCommand); + await execDocker(["exec", "-i", name, shell, ...shellArgs, cfg.setupCommand]); } } diff --git a/src/agents/sandbox/types.docker.ts b/src/agents/sandbox/types.docker.ts index 51e1a6b8c..5d8d6580c 100644 --- a/src/agents/sandbox/types.docker.ts +++ b/src/agents/sandbox/types.docker.ts @@ -9,6 +9,7 @@ export type SandboxDockerConfig = { capDrop: string[]; env?: Record; setupCommand?: string; + shellCommand?: string[]; pidsLimit?: number; memory?: string | number; memorySwap?: string | number; diff --git a/src/config/schema.ts b/src/config/schema.ts index 28c994f3d..56bf203f3 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -421,6 +421,7 @@ const FIELD_HELP: Record = { "tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).", "tools.exec.safeBins": "Allow stdin-only safe binaries to run without explicit allowlist entries.", + "agents.defaults.sandbox.docker.shellCommand": "Sandbox shell command + args (default: sh -lc).", "tools.message.allowCrossContextSend": "Legacy override: allow cross-context sends across all providers.", "tools.message.crossContext.allowWithinProvider": diff --git a/src/config/types.sandbox.ts b/src/config/types.sandbox.ts index 4f5f83810..7a941aa7c 100644 --- a/src/config/types.sandbox.ts +++ b/src/config/types.sandbox.ts @@ -19,6 +19,8 @@ export type SandboxDockerSettings = { env?: Record; /** Optional setup command run once after container creation. */ setupCommand?: string; + /** Optional shell command + args for sandbox exec (default: ["sh", "-lc"]). */ + shellCommand?: string[]; /** Limit container PIDs (0 = Docker default). */ pidsLimit?: number; /** Limit container memory (e.g. 512m, 2g, or bytes as number). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 7e95c3538..7efcef20a 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -91,6 +91,7 @@ export const SandboxDockerSchema = z capDrop: z.array(z.string()).optional(), env: z.record(z.string(), z.string()).optional(), setupCommand: z.string().optional(), + shellCommand: z.array(z.string()).optional(), pidsLimit: z.number().int().positive().optional(), memory: z.union([z.string(), z.number()]).optional(), memorySwap: z.union([z.string(), z.number()]).optional(),