Skip to content

Commit 43c6c26

Browse files
authored
fix(doctor): detect Codex bwrap namespace denials
Fixes #83018.
1 parent 4a360ac commit 43c6c26

5 files changed

Lines changed: 210 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ Docs: https://docs.openclaw.ai
201201
- ClawHub: preserve configured base URL path prefixes when building API request URLs, so self-hosted ClawHub instances mounted under a subpath keep routing correctly. (#83982) Thanks @ThiagoCAltoe.
202202
- Slack: persist delivered inbound message IDs and fail closed when same-channel thread replies lose their thread context, preventing delayed duplicate replies and accidental channel-root posts. Fixes #83521. Thanks @shannon0430.
203203
- Codex app-server: complete OpenClaw dynamic tool diagnostics at the request boundary so successful, failed, timed out, aborted, and blocked tool calls do not leave active tool state behind. Fixes #83474. Thanks @rozmiarD.
204+
- Doctor/Codex: warn when Linux host policy blocks the Codex bwrap user or network namespace path used by sandboxed app-server turns, with Ubuntu/AppArmor repair guidance. Refs #83018.
204205
- Gateway/config: keep config writes from failing on unrelated unresolved auth-profile SecretRefs while preserving live auth-profile runtime snapshots.
205206
- Gateway/sessions: clear stored CLI provider resume bindings on non-subagent `/reset` so the next turn starts a fresh provider-side CLI conversation instead of resuming old context. (#83448) Thanks @jasonyliu.
206207
- Doctor: preserve legacy whole-agent Claude CLI intent by moving matching Anthropic model selections to model-scoped runtime policy before removing stale runtime pins. Fixes #83491. Thanks @danielcrick.

docs/gateway/sandboxing.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,18 @@ If you deploy the OpenClaw Gateway itself as a Docker container, it orchestrates
100100
- **FS bridge parity (identical volume map)**: The OpenClaw Gateway native process also writes heartbeat and bridge files to the `workspace` directory. Because the Gateway evaluates the exact same string (the host path) from within its own containerized environment, the Gateway deployment MUST include an identical volume map linking the host namespace natively (`-v /home/user/.openclaw:/home/user/.openclaw`).
101101
- **Codex code mode**: When an OpenClaw sandbox is active, OpenClaw constrains Codex app-server turns to Codex `workspace-write` sandboxing even if the Codex plugin default is `danger-full-access`. The Codex turn network flag follows the OpenClaw sandbox egress setting, so Docker `network: "none"` stays offline and `network: "bridge"` or a custom Docker network allows outbound access. Do not mount the host Docker socket into agent sandbox containers or custom Codex sandboxes.
102102

103+
On Ubuntu/AppArmor hosts, Codex `workspace-write` can fail before shell startup
104+
when the service user is not allowed to create unprivileged user namespaces.
105+
When Docker sandbox egress is disabled (`network: "none"`, the default),
106+
Codex also needs an unprivileged network namespace. Common symptoms are
107+
`bwrap: setting up uid map: Permission denied` and
108+
`bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`. Run
109+
`openclaw doctor`; if it reports a Codex bwrap namespace probe failure, prefer
110+
an AppArmor profile that grants the required namespaces to the OpenClaw service
111+
process. `kernel.apparmor_restrict_unprivileged_userns=0` is a host-wide
112+
fallback with security tradeoffs; use it only when that host posture is
113+
acceptable.
114+
103115
If you map paths internally without absolute host parity, OpenClaw natively throws an `EACCES` permission error attempting to write its heartbeat inside the container environment because the fully qualified path string doesn't exist natively.
104116
</Warning>
105117

docs/plugins/codex-harness-reference.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,16 @@ from the OpenClaw sandbox egress setting: Docker `network: "none"` stays
154154
offline, while `network: "bridge"` or a custom Docker network permits outbound
155155
access.
156156

157+
On Ubuntu/AppArmor hosts, Codex bwrap can fail under `workspace-write` before
158+
the shell command starts. If you see
159+
`bwrap: setting up uid map: Permission denied` or
160+
`bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`, run
161+
`openclaw doctor` and fix the reported host namespace policy for the OpenClaw
162+
service user rather than granting broader Docker container privileges. Prefer
163+
a scoped AppArmor profile for the service process; the
164+
`kernel.apparmor_restrict_unprivileged_userns=0` fallback is host-wide and has
165+
security tradeoffs.
166+
157167
## Auth and environment isolation
158168

159169
Auth is selected in this order:

src/commands/doctor-sandbox.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,85 @@ async function isDockerAvailable(): Promise<boolean> {
8282
}
8383
}
8484

85+
type CodexBwrapNamespaceProbe =
86+
| { ok: true }
87+
| { ok: false; kind: "user" | "network"; command: string; reason: string };
88+
89+
function formatNamespaceProbeCommand(args: string[]): string {
90+
return ["unshare", ...args].join(" ");
91+
}
92+
93+
async function runCodexBwrapNamespaceProbe(
94+
kind: "user" | "network",
95+
args: string[],
96+
): Promise<CodexBwrapNamespaceProbe> {
97+
try {
98+
await runExec("unshare", args, {
99+
timeoutMs: 5_000,
100+
});
101+
return { ok: true };
102+
} catch (error) {
103+
const reason =
104+
(error as { stderr?: string } | undefined)?.stderr?.trim() ||
105+
(error as { stdout?: string } | undefined)?.stdout?.trim() ||
106+
(error instanceof Error ? error.message : String(error));
107+
return { ok: false, kind, command: formatNamespaceProbeCommand(args), reason };
108+
}
109+
}
110+
111+
function codexBwrapNeedsNetworkNamespaceProbe(cfg: OpenClawConfig): boolean {
112+
const network = cfg.agents?.defaults?.sandbox?.docker?.network?.trim().toLowerCase();
113+
return network === undefined || network === "" || network === "none";
114+
}
115+
116+
async function probeCodexBwrapNamespaces(cfg: OpenClawConfig): Promise<CodexBwrapNamespaceProbe> {
117+
if (process.platform !== "linux") {
118+
return { ok: true };
119+
}
120+
const userProbe = await runCodexBwrapNamespaceProbe("user", [
121+
"--user",
122+
"--map-root-user",
123+
"true",
124+
]);
125+
if (!userProbe.ok || !codexBwrapNeedsNetworkNamespaceProbe(cfg)) {
126+
return userProbe;
127+
}
128+
return await runCodexBwrapNamespaceProbe("network", [
129+
"--user",
130+
"--map-root-user",
131+
"--net",
132+
"true",
133+
]);
134+
}
135+
136+
async function noteCodexBwrapNamespaceWarning(cfg: OpenClawConfig): Promise<void> {
137+
const probe = await probeCodexBwrapNamespaces(cfg);
138+
if (probe.ok) {
139+
return;
140+
}
141+
const symptom =
142+
probe.kind === "user"
143+
? " bwrap: setting up uid map: Permission denied"
144+
: " bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted";
145+
const networkSentence = codexBwrapNeedsNetworkNamespaceProbe(cfg)
146+
? "With Docker sandbox network egress disabled, it also needs an unprivileged network namespace."
147+
: "Docker sandbox network egress is enabled, so doctor only checked the user namespace.";
148+
const lines = [
149+
`Codex bwrap ${probe.kind} namespace probe failed while Docker sandbox mode is enabled.`,
150+
`Codex app-server \`workspace-write\` shell execution needs unprivileged user namespaces. ${networkSentence}`,
151+
"On Ubuntu/AppArmor hosts this usually appears as:",
152+
symptom,
153+
`Probe command: ${probe.command}`,
154+
`Probe result: ${probe.reason}`,
155+
"",
156+
"Fix the host namespace policy for the OpenClaw service user, then restart the gateway.",
157+
"Prefer an AppArmor profile that grants the required namespaces to the OpenClaw service process.",
158+
"`kernel.apparmor_restrict_unprivileged_userns=0` is a host-wide fallback with security tradeoffs; use it only when that host posture is acceptable.",
159+
"Do not add broad Docker container privileges just to satisfy nested bwrap; that weakens the outer sandbox.",
160+
];
161+
note(lines.join("\n"), "Sandbox");
162+
}
163+
85164
async function dockerImageExists(image: string): Promise<boolean> {
86165
try {
87166
await runExec("docker", ["image", "inspect", image], { timeoutMs: 5_000 });
@@ -227,6 +306,7 @@ export async function maybeRepairSandboxImages(
227306
note(lines.join("\n"), "Sandbox");
228307
return cfg;
229308
}
309+
await noteCodexBwrapNamespaceWarning(cfg);
230310

231311
let next = cfg;
232312
const changes: string[] = [];

src/commands/doctor-sandbox.warns-sandbox-enabled-without-docker.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,21 @@ describe("maybeRepairSandboxImages", () => {
6969
};
7070
}
7171

72+
function createSandboxConfigWithDockerNetwork(network: string): OpenClawConfig {
73+
return {
74+
agents: {
75+
defaults: {
76+
sandbox: {
77+
mode: "all",
78+
docker: {
79+
network,
80+
},
81+
},
82+
},
83+
},
84+
};
85+
}
86+
7287
async function runSandboxRepair(params: {
7388
mode: "off" | "all" | "non-main";
7489
dockerAvailable: boolean;
@@ -131,6 +146,98 @@ describe("maybeRepairSandboxImages", () => {
131146
);
132147
expect(dockerUnavailableWarning).toBeUndefined();
133148
});
149+
150+
it("warns when Codex bwrap namespaces are blocked on a sandboxed Linux host", async () => {
151+
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("linux");
152+
runExec.mockImplementation(async (command: string, args: string[]) => {
153+
if (command === "docker" && args[0] === "version") {
154+
return { stdout: "24.0.0", stderr: "" };
155+
}
156+
if (command === "unshare") {
157+
throw Object.assign(new Error("unshare failed"), {
158+
stderr: "unshare: write failed /proc/self/uid_map: Operation not permitted",
159+
});
160+
}
161+
return { stdout: "", stderr: "" };
162+
});
163+
164+
try {
165+
await maybeRepairSandboxImages(createSandboxConfig("all"), mockRuntime, mockPrompter);
166+
} finally {
167+
platformSpy.mockRestore();
168+
}
169+
170+
expect(note).toHaveBeenCalledWith(
171+
expect.stringContaining("Codex bwrap user namespace probe failed"),
172+
"Sandbox",
173+
);
174+
expect(note).toHaveBeenCalledWith(
175+
expect.stringContaining("kernel.apparmor_restrict_unprivileged_userns=0"),
176+
"Sandbox",
177+
);
178+
});
179+
180+
it("checks Codex bwrap network namespaces only when Docker sandbox egress is offline", async () => {
181+
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("linux");
182+
runExec.mockImplementation(async (command: string, args: string[]) => {
183+
if (command === "docker" && args[0] === "version") {
184+
return { stdout: "24.0.0", stderr: "" };
185+
}
186+
if (command === "unshare") {
187+
if (args.includes("--net")) {
188+
throw Object.assign(new Error("unshare failed"), {
189+
stderr: "unshare: unshare failed: Operation not permitted",
190+
});
191+
}
192+
return { stdout: "", stderr: "" };
193+
}
194+
return { stdout: "", stderr: "" };
195+
});
196+
197+
try {
198+
await maybeRepairSandboxImages(createSandboxConfig("all"), mockRuntime, mockPrompter);
199+
} finally {
200+
platformSpy.mockRestore();
201+
}
202+
203+
expect(note).toHaveBeenCalledWith(
204+
expect.stringContaining("Codex bwrap network namespace probe failed"),
205+
"Sandbox",
206+
);
207+
expect(note).toHaveBeenCalledWith(
208+
expect.stringContaining("bwrap: loopback: Failed RTM_NEWADDR"),
209+
"Sandbox",
210+
);
211+
});
212+
213+
it("skips the Codex bwrap network namespace probe when Docker sandbox egress is enabled", async () => {
214+
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("linux");
215+
runExec.mockImplementation(async (command: string, args: string[]) => {
216+
if (command === "docker" && args[0] === "version") {
217+
return { stdout: "24.0.0", stderr: "" };
218+
}
219+
if (command === "unshare") {
220+
return { stdout: "", stderr: "" };
221+
}
222+
return { stdout: "", stderr: "" };
223+
});
224+
225+
try {
226+
await maybeRepairSandboxImages(
227+
createSandboxConfigWithDockerNetwork("bridge"),
228+
mockRuntime,
229+
mockPrompter,
230+
);
231+
} finally {
232+
platformSpy.mockRestore();
233+
}
234+
235+
expect(
236+
runExec.mock.calls.some(
237+
([command, args]) => command === "unshare" && Array.isArray(args) && args.includes("--net"),
238+
),
239+
).toBe(false);
240+
});
134241
});
135242

136243
describe("maybeRepairSandboxRegistryFiles", () => {

0 commit comments

Comments
 (0)