Skip to content

Commit c61be87

Browse files
Syyseanobviyus
andauthored
fix: prevent sandbox browser CDP startup hangs (openclaw#62873) (thanks @Syysean)
* refactor(sandbox): remove socat proxy and fix chromium keyring deadlock * fix(sandbox): address review feedback by reinstating cdp isolation and stability flags * fix(sandbox): increase entrypoint cdp timeout to 20s to honor autoStartTimeoutMs * fix(sandbox): align implementation with PR description (keyring bypass, fail-fast, watchdog) * fix * fix(sandbox): remove bash CDP watchdog to eliminate dual-timeout race * fix(sandbox): apply final fail-fast and lifecycle bindings * fix(sandbox): restore noVNC and CDP port offset * fix(sandbox): add max-time to curl to prevent HTTP hang * fix(sandbox): align timeout with host and restore env flags * fix(sandbox): pass auto-start timeout to container and restore wait -n * fix(sandbox): update hash input type to include autoStartTimeoutMs * fix(sandbox): implement production-grade lifecycle and timeout management - Add strict integer validation for port and timeout environment variables - Implement robust two-stage trap cleanup (SIGTERM with SIGKILL fallback) to prevent zombie processes - Refactor CDP readiness probe to use absolute millisecond-precision deadlines - Add early fail-fast detection if Chromium crashes during the startup phase - Track all daemon PIDs explicitly for reliable teardown via wait -n * fix(sandbox): allow renderer process limit to be 0 for chromium default * fix(sandbox): add autoStartTimeoutMs to SandboxBrowserHashInput type * test(sandbox): cover browser timeout cleanup --------- Co-authored-by: Ayaan Zaidi <hi@obviy.us>
1 parent 4ad4ee1 commit c61be87

5 files changed

Lines changed: 180 additions & 54 deletions

File tree

Lines changed: 126 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,7 @@
11
#!/usr/bin/env bash
2-
set -euo pipefail
2+
set -Eeuo pipefail
33

4-
dedupe_chrome_args() {
5-
local -A seen_args=()
6-
local -a unique_args=()
7-
8-
for arg in "${CHROME_ARGS[@]}"; do
9-
if [[ -n "${seen_args["$arg"]:+x}" ]]; then
10-
continue
11-
fi
12-
seen_args["$arg"]=1
13-
unique_args+=("$arg")
14-
done
15-
16-
CHROME_ARGS=("${unique_args[@]}")
17-
}
4+
export DBUS_SESSION_BUS_ADDRESS=/dev/null
185

196
export DISPLAY=:1
207
export HOME=/tmp/openclaw-home
@@ -29,45 +16,115 @@ ENABLE_NOVNC="${OPENCLAW_BROWSER_ENABLE_NOVNC:-1}"
2916
HEADLESS="${OPENCLAW_BROWSER_HEADLESS:-0}"
3017
ALLOW_NO_SANDBOX="${OPENCLAW_BROWSER_NO_SANDBOX:-0}"
3118
NOVNC_PASSWORD="${OPENCLAW_BROWSER_NOVNC_PASSWORD:-}"
19+
3220
DISABLE_GRAPHICS_FLAGS="${OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS:-1}"
3321
DISABLE_EXTENSIONS="${OPENCLAW_BROWSER_DISABLE_EXTENSIONS:-1}"
3422
RENDERER_PROCESS_LIMIT="${OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT:-2}"
23+
AUTO_START_TIMEOUT_MS="${OPENCLAW_BROWSER_AUTO_START_TIMEOUT_MS:-12000}"
3524

36-
mkdir -p "${HOME}" "${HOME}/.chrome" "${XDG_CONFIG_HOME}" "${XDG_CACHE_HOME}"
25+
validate_uint() {
26+
local name="$1"
27+
local value="$2"
28+
local min="${3:-0}"
29+
local max="${4:-4294967295}"
3730

38-
Xvfb :1 -screen 0 1280x800x24 -ac -nolisten tcp &
31+
if ! [[ "$value" =~ ^[0-9]+$ ]]; then
32+
echo "[sandbox] ERROR: $name must be an integer, got: ${value}" >&2
33+
exit 1
34+
fi
35+
if (( value < min || value > max )); then
36+
echo "[sandbox] ERROR: $name out of range (${min}..${max}), got: ${value}" >&2
37+
exit 1
38+
fi
39+
}
3940

40-
if [[ "${HEADLESS}" == "1" ]]; then
41-
CHROME_ARGS=(
42-
"--headless=new"
43-
)
44-
else
45-
CHROME_ARGS=()
41+
validate_uint "CDP_PORT" "$CDP_PORT" 1 65535
42+
validate_uint "VNC_PORT" "$VNC_PORT" 1 65535
43+
validate_uint "NOVNC_PORT" "$NOVNC_PORT" 1 65535
44+
validate_uint "AUTO_START_TIMEOUT_MS" "$AUTO_START_TIMEOUT_MS" 1 2147483647
45+
if [[ -n "$RENDERER_PROCESS_LIMIT" ]]; then
46+
validate_uint "RENDERER_PROCESS_LIMIT" "$RENDERER_PROCESS_LIMIT" 0 2147483647
4647
fi
4748

49+
cleanup() {
50+
local code="${1:-1}"
51+
trap - EXIT INT TERM
52+
53+
local pids=()
54+
local pid
55+
56+
for pid in "${WEBSOCKIFY_PID:-}" "${X11VNC_PID:-}" "${SOCAT_PID:-}" "${CHROME_PID:-}" "${XVFB_PID:-}"; do
57+
if [[ -n "${pid:-}" ]]; then
58+
pids+=("$pid")
59+
fi
60+
done
61+
62+
if ((${#pids[@]} > 0)); then
63+
kill -TERM "${pids[@]}" 2>/dev/null || true
64+
65+
for _ in {1..10}; do
66+
local alive=0
67+
for pid in "${pids[@]}"; do
68+
if kill -0 "$pid" 2>/dev/null; then
69+
alive=1
70+
break
71+
fi
72+
done
73+
if [[ "$alive" == "0" ]]; then
74+
break
75+
fi
76+
sleep 0.2
77+
done
78+
79+
kill -KILL "${pids[@]}" 2>/dev/null || true
80+
wait 2>/dev/null || true
81+
fi
82+
83+
exit "$code"
84+
}
85+
86+
trap 'cleanup "$?"' EXIT
87+
trap 'cleanup 130' INT
88+
trap 'cleanup 143' TERM
89+
90+
mkdir -p "${HOME}" "${HOME}/.chrome" "${XDG_CONFIG_HOME}" "${XDG_CACHE_HOME}"
91+
92+
Xvfb :1 -screen 0 1280x800x24 -ac -nolisten tcp &
93+
XVFB_PID=$!
94+
echo "[sandbox] Xvfb started (PID: ${XVFB_PID})"
95+
4896
if [[ "${CDP_PORT}" -ge 65535 ]]; then
4997
CHROME_CDP_PORT="$((CDP_PORT - 1))"
5098
else
5199
CHROME_CDP_PORT="$((CDP_PORT + 1))"
52100
fi
53101

54-
CHROME_ARGS+=(
102+
CHROME_ARGS=(
55103
"--remote-debugging-address=127.0.0.1"
56104
"--remote-debugging-port=${CHROME_CDP_PORT}"
57105
"--user-data-dir=${HOME}/.chrome"
58106
"--no-first-run"
59107
"--no-default-browser-check"
60108
"--disable-dev-shm-usage"
61109
"--disable-background-networking"
62-
"--disable-features=TranslateUI"
63110
"--disable-breakpad"
64111
"--disable-crash-reporter"
65112
"--no-zygote"
66113
"--metrics-recording-only"
114+
"--password-store=basic"
115+
"--use-mock-keychain"
67116
)
68117

118+
if [[ "${HEADLESS}" == "1" ]]; then
119+
CHROME_ARGS+=("--headless=new")
120+
fi
121+
122+
if [[ "${ALLOW_NO_SANDBOX}" == "1" ]]; then
123+
CHROME_ARGS+=("--no-sandbox" "--disable-setuid-sandbox")
124+
fi
125+
69126
DISABLE_GRAPHICS_FLAGS_LOWER="${DISABLE_GRAPHICS_FLAGS,,}"
70-
if [[ "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "1" || "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "true" || "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "yes" || "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "on" ]]; then
127+
if [[ "${DISABLE_GRAPHICS_FLAGS_LOWER}" =~ ^(1|true|yes|on)$ ]]; then
71128
CHROME_ARGS+=(
72129
"--disable-3d-apis"
73130
"--disable-gpu"
@@ -76,52 +133,75 @@ if [[ "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "1" || "${DISABLE_GRAPHICS_FLAGS_LOWE
76133
fi
77134

78135
DISABLE_EXTENSIONS_LOWER="${DISABLE_EXTENSIONS,,}"
79-
if [[ "${DISABLE_EXTENSIONS_LOWER}" == "1" || "${DISABLE_EXTENSIONS_LOWER}" == "true" || "${DISABLE_EXTENSIONS_LOWER}" == "yes" || "${DISABLE_EXTENSIONS_LOWER}" == "on" ]]; then
80-
CHROME_ARGS+=(
81-
"--disable-extensions"
82-
)
136+
if [[ "${DISABLE_EXTENSIONS_LOWER}" =~ ^(1|true|yes|on)$ ]]; then
137+
CHROME_ARGS+=("--disable-extensions")
83138
fi
84139

85140
if [[ "${RENDERER_PROCESS_LIMIT}" =~ ^[0-9]+$ && "${RENDERER_PROCESS_LIMIT}" -gt 0 ]]; then
86141
CHROME_ARGS+=("--renderer-process-limit=${RENDERER_PROCESS_LIMIT}")
87142
fi
88143

89-
if [[ "${ALLOW_NO_SANDBOX}" == "1" ]]; then
90-
CHROME_ARGS+=(
91-
"--no-sandbox"
92-
"--disable-setuid-sandbox"
93-
)
94-
fi
95-
96-
dedupe_chrome_args
144+
echo "[sandbox] Starting Chromium..."
97145
chromium "${CHROME_ARGS[@]}" about:blank &
146+
CHROME_PID=$!
147+
echo "[sandbox] Chromium started (PID: ${CHROME_PID})"
148+
149+
start_ms=$(date +%s%3N)
150+
deadline_ms=$(( start_ms + AUTO_START_TIMEOUT_MS ))
151+
CDP_READY=0
152+
probe_url="http://127.0.0.1:${CHROME_CDP_PORT}/json/version"
98153

99-
for _ in $(seq 1 50); do
100-
if curl -sS --max-time 1 "http://127.0.0.1:${CHROME_CDP_PORT}/json/version" >/dev/null; then
154+
echo "[sandbox] Waiting up to ${AUTO_START_TIMEOUT_MS}ms for CDP on port ${CHROME_CDP_PORT}..."
155+
156+
while (( $(date +%s%3N) < deadline_ms )); do
157+
if ! kill -0 "${CHROME_PID}" 2>/dev/null; then
158+
echo "[sandbox] ERROR: Chromium exited before CDP became ready."
159+
exit 1
160+
fi
161+
162+
if curl -fsS --max-time 0.5 "${probe_url}" >/dev/null; then
163+
CDP_READY=1
101164
break
102165
fi
103-
sleep 0.1
166+
167+
sleep 0.2
104168
done
105169

170+
if [[ "${CDP_READY}" == "0" ]]; then
171+
echo "[sandbox] ERROR: CDP failed to start within ${AUTO_START_TIMEOUT_MS}ms."
172+
exit 1
173+
fi
174+
175+
echo "[sandbox] CDP ready. Starting socat..."
176+
106177
SOCAT_LISTEN_ADDR="TCP-LISTEN:${CDP_PORT},fork,reuseaddr,bind=0.0.0.0"
107178
if [[ -n "${CDP_SOURCE_RANGE}" ]]; then
108179
SOCAT_LISTEN_ADDR="${SOCAT_LISTEN_ADDR},range=${CDP_SOURCE_RANGE}"
109180
fi
181+
110182
socat "${SOCAT_LISTEN_ADDR}" "TCP:127.0.0.1:${CHROME_CDP_PORT}" &
183+
SOCAT_PID=$!
184+
echo "[sandbox] socat started (PID: ${SOCAT_PID})"
111185

112186
if [[ "${ENABLE_NOVNC}" == "1" && "${HEADLESS}" != "1" ]]; then
113-
# VNC auth passwords are max 8 chars; use a random default when not provided.
114187
if [[ -z "${NOVNC_PASSWORD}" ]]; then
115188
NOVNC_PASSWORD="$(< /proc/sys/kernel/random/uuid)"
116189
NOVNC_PASSWORD="${NOVNC_PASSWORD//-/}"
117190
NOVNC_PASSWORD="${NOVNC_PASSWORD:0:8}"
118191
fi
119-
NOVNC_PASSWD_FILE="${HOME}/.vnc/passwd"
192+
120193
mkdir -p "${HOME}/.vnc"
121-
x11vnc -storepasswd "${NOVNC_PASSWORD}" "${NOVNC_PASSWD_FILE}" >/dev/null
122-
chmod 600 "${NOVNC_PASSWD_FILE}"
123-
x11vnc -display :1 -rfbport "${VNC_PORT}" -shared -forever -rfbauth "${NOVNC_PASSWD_FILE}" -localhost &
194+
x11vnc -storepasswd "${NOVNC_PASSWORD}" "${HOME}/.vnc/passwd" >/dev/null
195+
chmod 600 "${HOME}/.vnc/passwd"
196+
197+
x11vnc -display :1 -rfbport "${VNC_PORT}" -shared -forever -rfbauth "${HOME}/.vnc/passwd" -localhost &
198+
X11VNC_PID=$!
199+
echo "[sandbox] x11vnc started (PID: ${X11VNC_PID})"
200+
124201
websockify --web /usr/share/novnc/ "${NOVNC_PORT}" "localhost:${VNC_PORT}" &
202+
WEBSOCKIFY_PID=$!
203+
echo "[sandbox] websockify started (PID: ${WEBSOCKIFY_PID})"
125204
fi
126205

206+
echo "[sandbox] Container running. Monitoring all sub-processes..."
127207
wait -n

src/agents/sandbox/browser.create.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ describe("ensureSandboxBrowser create args", () => {
108108
});
109109

110110
beforeEach(() => {
111+
vi.restoreAllMocks();
111112
BROWSER_BRIDGES.clear();
112113
resetNoVncObserverTokensForTests();
113114
dockerMocks.dockerContainerState.mockClear();
@@ -239,4 +240,39 @@ describe("ensureSandboxBrowser create args", () => {
239240
const labels = collectDockerFlagValues(createArgs ?? [], "--label");
240241
expect(labels).toContain(`openclaw.mountFormatVersion=${SANDBOX_MOUNT_FORMAT_VERSION}`);
241242
});
243+
244+
it("force-removes the browser container when CDP never becomes reachable", async () => {
245+
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
246+
bridgeMocks.startBrowserBridgeServer.mockImplementationOnce(async (params) => {
247+
await params.onEnsureAttachTarget?.({});
248+
return {
249+
server: {} as never,
250+
port: 19000,
251+
baseUrl: "http://127.0.0.1:19000",
252+
state: {
253+
server: null,
254+
port: 19000,
255+
resolved: { profiles: {} },
256+
profiles: new Map(),
257+
},
258+
};
259+
});
260+
261+
const cfg = buildConfig(false);
262+
cfg.browser.autoStartTimeoutMs = 1;
263+
264+
await expect(
265+
ensureSandboxBrowser({
266+
scopeKey: "session:test",
267+
workspaceDir: "/tmp/workspace",
268+
agentWorkspaceDir: "/tmp/workspace",
269+
cfg,
270+
}),
271+
).rejects.toThrow("hung container has been forcefully removed");
272+
273+
expect(dockerMocks.execDocker).toHaveBeenCalledWith(
274+
["rm", "-f", expect.stringMatching(/^openclaw-sbx-browser-session-test-/)],
275+
{ allowFailure: true },
276+
);
277+
});
242278
});

src/agents/sandbox/browser.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ export async function ensureSandboxBrowser(params: {
167167
noVncPort: params.cfg.browser.noVncPort,
168168
headless: params.cfg.browser.headless,
169169
enableNoVnc: params.cfg.browser.enableNoVnc,
170+
autoStartTimeoutMs: params.cfg.browser.autoStartTimeoutMs,
170171
cdpSourceRange,
171172
},
172173
securityEpoch: SANDBOX_BROWSER_SECURITY_HASH_EPOCH,
@@ -262,14 +263,15 @@ export async function ensureSandboxBrowser(params: {
262263
args.push("-e", `OPENCLAW_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`);
263264
args.push("-e", `OPENCLAW_BROWSER_ENABLE_NOVNC=${params.cfg.browser.enableNoVnc ? "1" : "0"}`);
264265
args.push("-e", `OPENCLAW_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`);
266+
args.push(
267+
"-e",
268+
`OPENCLAW_BROWSER_AUTO_START_TIMEOUT_MS=${params.cfg.browser.autoStartTimeoutMs}`,
269+
);
265270
if (cdpSourceRange) {
266271
args.push("-e", `${CDP_SOURCE_RANGE_ENV_KEY}=${cdpSourceRange}`);
267272
}
268273
args.push("-e", `OPENCLAW_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`);
269274
args.push("-e", `OPENCLAW_BROWSER_NOVNC_PORT=${params.cfg.browser.noVncPort}`);
270-
// Chromium's setuid/namespace sandbox cannot work inside Docker containers
271-
// (PID namespace creation requires privileges Docker does not grant by default).
272-
// The container itself provides isolation, so --no-sandbox is safe here.
273275
args.push("-e", "OPENCLAW_BROWSER_NO_SANDBOX=1");
274276
if (noVncEnabled && noVncPassword) {
275277
args.push("-e", `${NOVNC_PASSWORD_ENV_KEY}=${noVncPassword}`);
@@ -302,9 +304,6 @@ export async function ensureSandboxBrowser(params: {
302304
let desiredAuthToken = normalizeOptionalString(params.bridgeAuth?.token);
303305
let desiredAuthPassword = normalizeOptionalString(params.bridgeAuth?.password);
304306
if (!desiredAuthToken && !desiredAuthPassword) {
305-
// Always require auth for the sandbox bridge server, even if gateway auth
306-
// mode doesn't produce a shared secret (e.g. trusted-proxy).
307-
// Keep it stable across calls by reusing the existing bridge auth.
308307
desiredAuthToken = existing?.authToken;
309308
desiredAuthPassword = existing?.authPassword;
310309
if (!desiredAuthToken && !desiredAuthPassword) {
@@ -349,8 +348,9 @@ export async function ensureSandboxBrowser(params: {
349348
timeoutMs: params.cfg.browser.autoStartTimeoutMs,
350349
});
351350
if (!ok) {
351+
await execDocker(["rm", "-f", containerName], { allowFailure: true });
352352
throw new Error(
353-
`Sandbox browser CDP did not become reachable on 127.0.0.1:${mappedCdp} within ${params.cfg.browser.autoStartTimeoutMs}ms.`,
353+
`Sandbox browser CDP did not become reachable on 127.0.0.1:${mappedCdp} within ${params.cfg.browser.autoStartTimeoutMs}ms. The hung container has been forcefully removed.`,
354354
);
355355
}
356356
}

src/agents/sandbox/config-hash.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ describe("computeSandboxBrowserConfigHash", () => {
118118
noVncPort: 6080,
119119
headless: false,
120120
enableNoVnc: true,
121+
autoStartTimeoutMs: 12000,
121122
},
122123
securityEpoch: "epoch-v1",
123124
workspaceAccess: "rw" as const,
@@ -150,6 +151,7 @@ describe("computeSandboxBrowserConfigHash", () => {
150151
noVncPort: 6080,
151152
headless: false,
152153
enableNoVnc: true,
154+
autoStartTimeoutMs: 12000,
153155
},
154156
workspaceAccess: "rw" as const,
155157
workspaceDir: "/tmp/workspace",
@@ -176,6 +178,7 @@ describe("computeSandboxBrowserConfigHash", () => {
176178
noVncPort: 6080,
177179
headless: false,
178180
enableNoVnc: true,
181+
autoStartTimeoutMs: 12000,
179182
},
180183
securityEpoch: "epoch-v1",
181184
workspaceAccess: "rw" as const,
@@ -204,6 +207,7 @@ describe("computeSandboxBrowserConfigHash", () => {
204207
noVncPort: 6080,
205208
headless: false,
206209
enableNoVnc: true,
210+
autoStartTimeoutMs: 12000,
207211
},
208212
securityEpoch: "epoch-v1",
209213
workspaceAccess: "rw" as const,

src/agents/sandbox/config-hash.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ type SandboxBrowserHashInput = {
1313
docker: SandboxDockerConfig;
1414
browser: Pick<
1515
SandboxBrowserConfig,
16-
"cdpPort" | "cdpSourceRange" | "vncPort" | "noVncPort" | "headless" | "enableNoVnc"
16+
| "cdpPort"
17+
| "cdpSourceRange"
18+
| "vncPort"
19+
| "noVncPort"
20+
| "headless"
21+
| "enableNoVnc"
22+
| "autoStartTimeoutMs"
1723
>;
1824
securityEpoch: string;
1925
workspaceAccess: SandboxWorkspaceAccess;

0 commit comments

Comments
 (0)