diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 1e8a35eb8a25..c1952f4e710c 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -923,7 +923,7 @@ Codex app-server turns in an active OpenClaw sandbox use this same egress settin noVNC observer access is password-protected and brokered through a one-time, authenticated bootstrap URL. The observer URL is deliberately omitted from model-visible system prompt context. - `allowHostControl: false` (default) blocks sandboxed sessions from targeting the host browser. -- `network` defaults to `openclaw-sandbox-browser` (dedicated bridge network). Set to `bridge` only when you explicitly want global bridge connectivity. `"host"` is blocked here too. +- `network` defaults to `openclaw-sandbox-browser` (dedicated bridge network). Set to `bridge` only when you explicitly want global bridge connectivity. `"none"` is unsupported because CDP ports must be published to the host; `"host"` is blocked too. On upgrade, `openclaw doctor --fix` disables sidecars affected by a persisted `"none"` value and restores the dedicated network without silently enabling egress. - `cdpSourceRange` optionally restricts CDP ingress at the container edge to a CIDR range (for example `172.21.0.1/32`). - `sandbox.browser.binds` mounts additional host directories into the sandbox browser container only. When set (including `[]`), it replaces `docker.binds` for the browser container. - The sandbox browser container's Chromium always launches with `--no-sandbox --disable-setuid-sandbox` (containers do not have the kernel primitives Chrome's own sandbox needs); there is no config toggle for this. diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 233b2dfc603d..66b8d8ede1e4 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -133,6 +133,7 @@ On Ubuntu/AppArmor hosts with Docker sandbox mode enabled, Codex app-server `wor - The sandbox browser auto-starts (ensures CDP is reachable) when the browser tool needs it. Configure via `agents.defaults.sandbox.browser.autoStart` (default `true`) and `autoStartTimeoutMs` (default 12s). - Sandbox browser containers use a dedicated Docker network (`openclaw-sandbox-browser`) instead of the global `bridge` network. Configure with `agents.defaults.sandbox.browser.network`. +- Sandbox browser network mode `"none"` is unsupported because browser control requires host-published CDP ports. Use the dedicated default, `bridge`, or another custom bridge network. `openclaw doctor --fix` disables affected persisted sidecars and restores the dedicated network without silently enabling egress. - `agents.defaults.sandbox.browser.cdpSourceRange` restricts container-edge CDP ingress with a CIDR allowlist (for example `172.21.0.1/32`). - noVNC observer access is password-protected by default; OpenClaw emits a short-lived token URL that serves a local bootstrap page and opens noVNC with the password in the URL fragment (not query string or header logs). - `agents.defaults.sandbox.browser.allowHostControl` (default `false`) lets sandboxed sessions target the host browser explicitly. diff --git a/src/agents/sandbox/browser-network.ts b/src/agents/sandbox/browser-network.ts new file mode 100644 index 000000000000..8cf690350ae0 --- /dev/null +++ b/src/agents/sandbox/browser-network.ts @@ -0,0 +1 @@ +export const DEFAULT_SANDBOX_BROWSER_NETWORK = "openclaw-sandbox-browser"; diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 8a443e7ad3ed..865d5187381b 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -1004,22 +1004,23 @@ describe("ensureSandboxBrowser create args", () => { expect(BROWSER_BRIDGES.get("session:test")).not.toBe(cached); }); - it("does not inject a source range for network=none by default", async () => { + it("rejects network=none before Docker inspection or browser bridge startup", async () => { const cfg = buildConfig(false); cfg.browser.network = "none"; - const result = await ensureTestSandboxBrowser({ - scopeKey: "session:test", - workspaceDir: "/tmp/workspace", - agentWorkspaceDir: "/tmp/workspace", - cfg, - }); - - requireValue(result, "sandbox browser result"); - const createArgs = requireDockerCreateArgs(); - const envEntries = collectDockerFlagValues(createArgs, "-e"); - expect(envEntries.some((entry) => entry.startsWith("OPENCLAW_BROWSER_CDP_SOURCE_RANGE="))).toBe( - false, + await expect( + ensureTestSandboxBrowser({ + scopeKey: "session:test", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg, + }), + ).rejects.toThrow( + 'Sandbox browser network mode "none" is unsupported because browser control requires a host-reachable published CDP port.', ); + expect(dockerMocks.dockerContainerState).not.toHaveBeenCalled(); + expect(dockerMocks.execDocker).not.toHaveBeenCalled(); + expect(dockerMocks.readDockerPort).not.toHaveBeenCalled(); + expect(bridgeMocks.startBrowserBridgeServer).not.toHaveBeenCalled(); }); }); diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index bc7ca20f101c..f245bb546264 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -235,6 +235,11 @@ export async function ensureSandboxBrowser(params: { if (!isToolAllowed(params.cfg.tools, "browser")) { return null; } + if (normalizeOptionalLowercaseString(params.cfg.browser.network) === "none") { + throw new Error( + 'Sandbox browser network mode "none" is unsupported because browser control requires a host-reachable published CDP port. Use "bridge", a custom bridge network, or disable the sandbox browser.', + ); + } const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(params.scopeKey); const name = `${params.cfg.browser.containerPrefix}${slug}`; diff --git a/src/agents/sandbox/constants.ts b/src/agents/sandbox/constants.ts index 8eae18198f4b..27264d7a6762 100644 --- a/src/agents/sandbox/constants.ts +++ b/src/agents/sandbox/constants.ts @@ -7,6 +7,8 @@ import path from "node:path"; import { CHANNEL_IDS } from "../../channels/ids.js"; import { STATE_DIR } from "../../config/paths.js"; +export { DEFAULT_SANDBOX_BROWSER_NETWORK } from "./browser-network.js"; + export const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join(STATE_DIR, "sandboxes"); export const DEFAULT_SANDBOX_IMAGE = "openclaw-sandbox:bookworm-slim"; @@ -59,7 +61,6 @@ export const SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH = "2026-05-12-cdp-relay-auth"; export const SANDBOX_DOCKER_CREATE_ARGS_EPOCH = "2026-07-10-init"; export const DEFAULT_SANDBOX_BROWSER_PREFIX = "openclaw-sbx-browser-"; -export const DEFAULT_SANDBOX_BROWSER_NETWORK = "openclaw-sandbox-browser"; export const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222; export const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900; export const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080; diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 56e09cb9884d..6af0f2fc6616 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -2293,6 +2293,177 @@ describe("legacy migrate sandbox scope aliases", () => { expect(res.changes).toStrictEqual([]); expect(res.config).toBeNull(); }); + + it("disables the default sandbox browser network without granting inherited egress", () => { + const raw = { + agents: { + defaults: { + sandbox: { + browser: { + enabled: true, + network: " NONE ", + autoStart: false, + }, + }, + }, + entries: { + main: { default: true }, + inherited: { + sandbox: { + browser: { + enabled: true, + headless: true, + }, + }, + }, + isolated: { + sandbox: { + browser: { + network: "isolated-browser-net", + }, + }, + }, + blankEnabled: { + sandbox: { + browser: { + enabled: true, + network: " ", + }, + }, + }, + blankInherited: { + sandbox: { + browser: { + network: "", + }, + }, + }, + }, + }, + }; + + expect(findLegacyConfigIssues(raw).map((issue) => issue.path)).toEqual([ + "agents.defaults.sandbox.browser.network", + ]); + const res = migrateLegacyConfigForTest(raw); + + expect(res.changes).toStrictEqual([ + 'Disabled agents.entries.inherited.sandbox.browser because it inherited unsupported browser network "none".', + "Set agents.entries.isolated.sandbox.browser.enabled to true to preserve its explicit supported network while disabling the unsupported default browser network.", + "Set agents.entries.blankInherited.sandbox.browser.enabled to true to preserve its explicit supported network while disabling the unsupported default browser network.", + 'Disabled agents.defaults.sandbox.browser and moved its unsupported network "none" → "openclaw-sandbox-browser".', + ]); + expect(res.config?.agents?.defaults?.sandbox?.browser).toEqual({ + enabled: false, + network: "openclaw-sandbox-browser", + autoStart: false, + }); + expect(res.config?.agents?.entries?.inherited?.sandbox?.browser).toEqual({ + enabled: false, + headless: true, + }); + expect(res.config?.agents?.entries?.isolated?.sandbox?.browser).toEqual({ + enabled: true, + network: "isolated-browser-net", + }); + expect(res.config?.agents?.entries?.blankEnabled?.sandbox?.browser).toEqual({ + enabled: true, + network: " ", + }); + expect(res.config?.agents?.entries?.blankInherited?.sandbox?.browser).toEqual({ + enabled: true, + network: "", + }); + expect(migrateLegacyConfigForTest(res.config)).toEqual({ config: null, changes: [] }); + }); + + it("disables explicit per-agent network none in keyed rosters", () => { + const raw = { + agents: { + entries: { + main: { + default: true, + sandbox: { + browser: { + enabled: true, + network: "none", + headless: true, + }, + }, + }, + }, + }, + }; + + expect(findLegacyConfigIssues(raw).map((issue) => issue.path)).toEqual(["agents.entries"]); + const res = migrateLegacyConfigForTest(raw); + + expect(res.changes).toStrictEqual([ + 'Disabled agents.entries.main.sandbox.browser and moved its unsupported network "none" → "openclaw-sandbox-browser".', + ]); + expect(res.config?.agents?.entries?.main?.sandbox?.browser).toEqual({ + enabled: false, + network: "openclaw-sandbox-browser", + headless: true, + }); + expect(migrateLegacyConfigForTest(res.config)).toEqual({ config: null, changes: [] }); + }); + + it("disables explicit per-agent network none in legacy list rosters", () => { + const raw = { + agents: { + list: [ + { + id: "legacy", + default: true, + sandbox: { + browser: { + network: "NONE", + autoStart: false, + }, + }, + }, + ], + }, + }; + + expect(findLegacyConfigIssues(raw)).toContainEqual({ + path: "agents.list", + message: expect.stringContaining('sandbox.browser.network = "none"'), + }); + const res = migrateLegacyConfigForTest(raw); + + expect(res.changes).toStrictEqual([ + 'Disabled agents.list.0.sandbox.browser and moved its unsupported network "none" → "openclaw-sandbox-browser".', + ]); + expect(res.config?.agents?.entries?.legacy?.sandbox?.browser).toEqual({ + enabled: false, + network: "openclaw-sandbox-browser", + autoStart: false, + }); + expect(migrateLegacyConfigForTest(res.config)).toEqual({ config: null, changes: [] }); + }); + + it("leaves supported sandbox browser networks unchanged", () => { + const raw = { + agents: { + entries: { + main: { + default: true, + sandbox: { + browser: { + enabled: true, + network: "openclaw-sandbox-browser", + }, + }, + }, + }, + }, + }; + + expect(findLegacyConfigIssues(raw)).toEqual([]); + expect(migrateLegacyConfigForTest(raw)).toEqual({ config: null, changes: [] }); + }); }); describe("legacy migrate MCP server type aliases", () => { diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts index f68175ddbb0e..e5991b8e7a95 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts @@ -1,10 +1,12 @@ // Legacy runtime agent config migrations for memory, heartbeat, sandbox, and runtime policy keys. import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { isCanonicalToolProviderPolicyKey, normalizeToolProviderPolicyKey, } from "../../../agents/provider-tool-policy.js"; +import { DEFAULT_SANDBOX_BROWSER_NETWORK } from "../../../agents/sandbox/browser-network.js"; import { isKnownCoreToolId } from "../../../agents/tool-catalog.js"; import { isToolAllowedByPolicyName } from "../../../agents/tool-policy-match.js"; import { resolveToolProfilePolicy } from "../../../agents/tool-policy-shared.js"; @@ -168,6 +170,27 @@ const LEGACY_SANDBOX_SCOPE_RULES: LegacyConfigRule[] = [ }, ]; +const UNSUPPORTED_SANDBOX_BROWSER_NETWORK_RULES: LegacyConfigRule[] = [ + { + path: ["agents", "defaults", "sandbox", "browser", "network"], + message: + 'agents.defaults.sandbox.browser.network = "none" cannot expose the browser control port. Run "openclaw doctor --fix" to disable the sidecar and restore the dedicated browser network.', + match: isUnsupportedSandboxBrowserNetwork, + }, + { + path: ["agents", "entries"], + message: + 'agents.entries.*.sandbox.browser.network = "none" cannot expose the browser control port. Run "openclaw doctor --fix" to disable the affected sidecar and restore the dedicated browser network.', + match: hasAgentEntriesUnsupportedSandboxBrowserNetwork, + }, + { + path: ["agents", "list"], + message: + 'agents.list[].sandbox.browser.network = "none" cannot expose the browser control port. Run "openclaw doctor --fix" to disable the affected sidecar and restore the dedicated browser network.', + match: hasAgentListUnsupportedSandboxBrowserNetwork, + }, +]; + const LEGACY_AGENT_RUNTIME_POLICY_RULES: LegacyConfigRule[] = [ { path: ["agents", "defaults", "agentRuntime", "fallback"], @@ -562,6 +585,142 @@ function migrateLegacySandboxPerSession( delete sandbox.perSession; } +function getSandboxBrowserConfig(container: unknown): Record | null { + return getRecord(getRecord(getRecord(container)?.sandbox)?.browser); +} + +function isUnsupportedSandboxBrowserNetwork(value: unknown): boolean { + return normalizeOptionalLowercaseString(value) === "none"; +} + +function hasAgentEntriesUnsupportedSandboxBrowserNetwork(value: unknown): boolean { + const entries = getRecord(value); + return Boolean( + entries && + Object.entries(entries).some( + ([agentId, agent]) => + !isBlockedObjectKey(agentId) && + isUnsupportedSandboxBrowserNetwork(getSandboxBrowserConfig(agent)?.network), + ), + ); +} + +function hasAgentListUnsupportedSandboxBrowserNetwork(value: unknown): boolean { + return ( + Array.isArray(value) && + value.some((agent) => + isUnsupportedSandboxBrowserNetwork(getSandboxBrowserConfig(agent)?.network), + ) + ); +} + +function migrateExplicitUnsupportedSandboxBrowserNetwork( + browser: Record, + pathLabel: string, + changes: string[], +): void { + if (!isUnsupportedSandboxBrowserNetwork(browser.network)) { + return; + } + browser.enabled = false; + browser.network = DEFAULT_SANDBOX_BROWSER_NETWORK; + changes.push( + `Disabled ${pathLabel} and moved its unsupported network "none" → "${DEFAULT_SANDBOX_BROWSER_NETWORK}".`, + ); +} + +function migrateAgentBrowserInheritedFromUnsupportedDefault(params: { + agent: unknown; + pathLabel: string; + defaultBrowserEnabled: boolean; + changes: string[]; +}): void { + const browser = getSandboxBrowserConfig(params.agent); + if (!browser) { + return; + } + const hasExplicitNetwork = typeof browser.network === "string"; + const network = normalizeOptionalLowercaseString(browser.network); + if (network === "none") { + migrateExplicitUnsupportedSandboxBrowserNetwork(browser, params.pathLabel, params.changes); + return; + } + if (!hasExplicitNetwork && browser.enabled === true) { + browser.enabled = false; + params.changes.push( + `Disabled ${params.pathLabel} because it inherited unsupported browser network "none".`, + ); + return; + } + if (hasExplicitNetwork && browser.enabled === undefined && params.defaultBrowserEnabled) { + browser.enabled = true; + params.changes.push( + `Set ${params.pathLabel}.enabled to true to preserve its explicit supported network while disabling the unsupported default browser network.`, + ); + } +} + +function migrateUnsupportedSandboxBrowserNetworks( + raw: Record, + changes: string[], +): void { + const agents = getRecord(raw.agents); + const defaults = getRecord(agents?.defaults); + const defaultBrowser = getSandboxBrowserConfig(defaults); + const defaultNetworkUnsupported = isUnsupportedSandboxBrowserNetwork(defaultBrowser?.network); + const defaultBrowserEnabled = defaultBrowser?.enabled === true; + + const entries = getRecord(agents?.entries); + if (entries) { + for (const [agentId, agent] of Object.entries(entries)) { + if (isBlockedObjectKey(agentId)) { + continue; + } + const pathLabel = `agents.entries.${agentId}.sandbox.browser`; + if (defaultNetworkUnsupported) { + migrateAgentBrowserInheritedFromUnsupportedDefault({ + agent, + pathLabel, + defaultBrowserEnabled, + changes, + }); + } else { + const browser = getSandboxBrowserConfig(agent); + if (browser) { + migrateExplicitUnsupportedSandboxBrowserNetwork(browser, pathLabel, changes); + } + } + } + } + + if (Array.isArray(agents?.list)) { + for (const [index, agent] of agents.list.entries()) { + const pathLabel = `agents.list.${index}.sandbox.browser`; + if (defaultNetworkUnsupported) { + migrateAgentBrowserInheritedFromUnsupportedDefault({ + agent, + pathLabel, + defaultBrowserEnabled, + changes, + }); + } else { + const browser = getSandboxBrowserConfig(agent); + if (browser) { + migrateExplicitUnsupportedSandboxBrowserNetwork(browser, pathLabel, changes); + } + } + } + } + + if (defaultBrowser) { + migrateExplicitUnsupportedSandboxBrowserNetwork( + defaultBrowser, + "agents.defaults.sandbox.browser", + changes, + ); + } +} + function removeLegacyAgentRuntimePolicy( container: Record, pathLabel: string, @@ -1438,6 +1597,12 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS: LegacyConfigMigrationSpec[ } }, }), + defineLegacyConfigMigration({ + id: "agents.sandbox.browser.network-none", + describe: "Disable sandbox browser sidecars that use unsupported network mode none", + legacyRules: UNSUPPORTED_SANDBOX_BROWSER_NETWORK_RULES, + apply: migrateUnsupportedSandboxBrowserNetworks, + }), defineLegacyConfigMigration({ id: "memorySearch->memory.search", describe: "Move memory search config to its canonical memory owner", diff --git a/src/config/schema.help.runtime.ts b/src/config/schema.help.runtime.ts index bd1229f12051..d0534cc8bb35 100644 --- a/src/config/schema.help.runtime.ts +++ b/src/config/schema.help.runtime.ts @@ -159,9 +159,9 @@ export const RUNTIME_FIELD_HELP: Record = { "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.", "gateway.auth.password": "Required for Tailscale funnel.", "agents.defaults.sandbox.browser.network": - "Docker network for sandbox browser containers (default: openclaw-sandbox-browser). Avoid bridge if you need stricter isolation.", + 'Docker network for sandbox browser containers (default: openclaw-sandbox-browser). Use the dedicated default or a custom bridge network; "none" is unsupported because browser control requires published CDP ports.', "agents.entries.*.sandbox.browser.network": - "Per-agent override for sandbox browser Docker network.", + 'Per-agent override for the sandbox browser Docker network. Use a bridge network; "none" is unsupported because browser control requires published CDP ports.', "agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin": "DANGEROUS break-glass override that allows sandbox Docker network mode container:. This joins another container namespace and weakens sandbox isolation.", "agents.entries.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin":