fix(sandbox): reject and migrate browser network none (#115250)

* fix(sandbox): reject browser network none

* test(ui): reset location after tools panel tests

* chore(sandbox): drop release-owned changelog entry

* fix(config): migrate unsupported sandbox browser network

* fix(sandbox): isolate browser network constant

* fix(config): preserve blank browser network overrides

* chore: leave release changelog untouched
This commit is contained in:
Vincent Koc
2026-07-29 06:22:33 +08:00
committed by GitHub
parent ee81498487
commit 4d439805aa
9 changed files with 362 additions and 17 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -0,0 +1 @@
export const DEFAULT_SANDBOX_BROWSER_NETWORK = "openclaw-sandbox-browser";

View File

@@ -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();
});
});

View File

@@ -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}`;

View File

@@ -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;

View File

@@ -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", () => {

View File

@@ -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<string, unknown> | 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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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",

View File

@@ -159,9 +159,9 @@ export const RUNTIME_FIELD_HELP: Record<string, string> = {
"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:<id>. This joins another container namespace and weakens sandbox isolation.",
"agents.entries.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin":