Skip to content

Commit 2e7776b

Browse files
committed
fix(mcp): guard shared playwright browser
1 parent b1ac5c2 commit 2e7776b

14 files changed

Lines changed: 540 additions & 24 deletions

File tree

packages/app/src/docker-git/cli/usage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ Container runtime env (set via .orch/env/project.env):
9494
DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=... zsh-autosuggestions highlight style (default: fg=8,italic)
9595
DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=... Suggestion sources (default: history completion)
9696
MCP_PLAYWRIGHT_ISOLATED=1|0 Isolated browser contexts (recommended for many Codex; default: 1)
97+
MCP_PLAYWRIGHT_CDP_GUARD=1|0 Guard CDP so MCP cannot close/crash shared Chromium (default: 1)
98+
MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE=1|0 Block destructive Browser.close/crash CDP methods (default: 1)
9799
MCP_PLAYWRIGHT_CDP_ENDPOINT=http://... Override CDP endpoint (default: http://dg-<repo>-browser:9223)
98100
MCP_PLAYWRIGHT_RETRY_ATTEMPTS=<n> Retry attempts for browser sidecar startup wait (default: 10)
99101
MCP_PLAYWRIGHT_RETRY_DELAY=<seconds> Delay between retry attempts (default: 2)

packages/app/src/lib/core/templates-entrypoint/base.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ AGENT_AUTO="\${AGENT_AUTO:-}"
3030
MCP_PLAYWRIGHT_ENABLE="\${MCP_PLAYWRIGHT_ENABLE:-${config.enableMcpPlaywright ? "1" : "0"}}"
3131
MCP_PLAYWRIGHT_CDP_ENDPOINT="\${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}"
3232
MCP_PLAYWRIGHT_ISOLATED="\${MCP_PLAYWRIGHT_ISOLATED:-1}"
33+
MCP_PLAYWRIGHT_CDP_GUARD="\${MCP_PLAYWRIGHT_CDP_GUARD:-1}"
34+
MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE="\${MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE:-1}"
3335
3436
SSH_ENV_PATH="/home/${config.sshUser}/.ssh/environment"
3537

packages/app/src/lib/core/templates/docker-compose.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ const buildPlaywrightFragments = (
102102
maybeBrowserService:
103103
`\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n restart: unless-stopped\n${
104104
renderResourceLimits(resourceLimits)
105+
}${
106+
renderEnvFiles(config)
105107
} environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n dns:\n - 8.8.8.8\n - 8.8.4.4\n - 1.1.1.1\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`,
106108
maybeBrowserVolume: ` ${browserVolumeName}:`
107109
}

packages/app/src/lib/core/templates/dockerfile.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,7 @@ RUN ARCH="$(uname -m)" \
119119

120120
const dockerfilePlaywrightMcpBlock = String.raw`RUN npm install -g @playwright/mcp@latest
121121
122-
# docker-git: wrapper that converts a CDP HTTP endpoint into a usable WS endpoint
123-
# Some Chromium images return webSocketDebuggerUrl pointing at 127.0.0.1 (container-local).
122+
# docker-git: wrapper that waits for the guarded CDP endpoint before launching Playwright MCP.
124123
RUN cat <<'EOF' > /usr/local/bin/docker-git-playwright-mcp
125124
#!/usr/bin/env bash
126125
set -euo pipefail
@@ -150,6 +149,7 @@ fi
150149
# COMPLEXITY: O(max_attempts * timeout_per_attempt)
151150
MCP_PLAYWRIGHT_RETRY_ATTEMPTS="\${MCP_PLAYWRIGHT_RETRY_ATTEMPTS:-10}"
152151
MCP_PLAYWRIGHT_RETRY_DELAY="\${MCP_PLAYWRIGHT_RETRY_DELAY:-2}"
152+
MCP_PLAYWRIGHT_CDP_GUARD="\${MCP_PLAYWRIGHT_CDP_GUARD:-1}"
153153
154154
fetch_cdp_version() {
155155
curl -sSf --connect-timeout 3 --max-time 10 -H 'Host: 127.0.0.1:9222' "\${CDP_ENDPOINT%/}/json/version" 2>/dev/null
@@ -171,7 +171,19 @@ if [[ -z "$JSON" ]]; then
171171
exit 1
172172
fi
173173
174+
EXTRA_ARGS=()
175+
if [[ "\${MCP_PLAYWRIGHT_ISOLATED:-1}" == "1" ]]; then
176+
EXTRA_ARGS+=(--isolated)
177+
fi
178+
179+
# Guarded endpoints are stable HTTP CDP endpoints. Passing the HTTP URL lets Playwright MCP
180+
# re-resolve /json/version instead of pinning itself to one stale /devtools/browser/<id>.
181+
if [[ "$MCP_PLAYWRIGHT_CDP_GUARD" == "1" ]]; then
182+
exec playwright-mcp --cdp-endpoint "$CDP_ENDPOINT" "\${EXTRA_ARGS[@]}" "$@"
183+
fi
184+
174185
# kechangdev/browser-vnc binds Chromium CDP on 127.0.0.1:9222; it also host-checks HTTP requests.
186+
# When the guard is disabled, preserve the old behavior by converting the HTTP endpoint to WS.
175187
WS_URL="$(printf "%s" "$JSON" | node -e 'const fs=require("fs"); const j=JSON.parse(fs.readFileSync(0,"utf8")); process.stdout.write(j.webSocketDebuggerUrl || "")')"
176188
if [[ -z "$WS_URL" ]]; then
177189
echo "docker-git-playwright-mcp: webSocketDebuggerUrl missing" >&2
@@ -182,11 +194,6 @@ fi
182194
BASE_WS="$(CDP_ENDPOINT="$CDP_ENDPOINT" node -e 'const { URL } = require("url"); const u=new URL(process.env.CDP_ENDPOINT); const proto=u.protocol==="https:"?"wss:":"ws:"; process.stdout.write(proto + "//" + u.host)')"
183195
WS_REWRITTEN="$(BASE_WS="$BASE_WS" WS_URL="$WS_URL" node -e 'const { URL } = require("url"); const base=new URL(process.env.BASE_WS); const ws=new URL(process.env.WS_URL); ws.protocol=base.protocol; ws.host=base.host; process.stdout.write(ws.toString())')"
184196
185-
EXTRA_ARGS=()
186-
if [[ "\${MCP_PLAYWRIGHT_ISOLATED:-1}" == "1" ]]; then
187-
EXTRA_ARGS+=(--isolated)
188-
fi
189-
190197
exec playwright-mcp --cdp-endpoint "$WS_REWRITTEN" "\${EXTRA_ARGS[@]}" "$@"
191198
EOF
192199
RUN chmod +x /usr/local/bin/docker-git-playwright-mcp`

packages/app/src/lib/core/templates/playwright.ts

Lines changed: 240 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22
export const renderPlaywrightBrowserDockerfile = (): string =>
33
`FROM kechangdev/browser-vnc:latest
44
5-
# bash for noVNC startup, procps for ps -p used by novnc_proxy, socat for CDP proxy
6-
# python3/net-tools for diagnostics
7-
RUN apk add --no-cache bash procps socat python3 net-tools
5+
# bash for noVNC startup, procps for ps -p used by novnc_proxy, socat for CDP fallback
6+
# nodejs/npm/ws for the CDP guard, python3/net-tools for diagnostics
7+
RUN apk add --no-cache bash procps socat nodejs npm python3 net-tools
8+
RUN npm install --omit=dev --prefix /opt/docker-git-cdp-guard ws@8.18.3
9+
10+
RUN cat <<'EOF' > /usr/local/bin/docker-git-cdp-guard
11+
${cdpGuardScript}
12+
EOF
13+
RUN chmod +x /usr/local/bin/docker-git-cdp-guard
814
915
COPY mcp-playwright-start-extra.sh /usr/local/bin/mcp-playwright-start-extra.sh
1016
RUN chmod +x /usr/local/bin/mcp-playwright-start-extra.sh
@@ -13,6 +19,231 @@ RUN chmod +x /usr/local/bin/mcp-playwright-start-extra.sh
1319
# Clear stale Chromium profile locks before boot
1420
ENTRYPOINT ["/bin/sh", "-lc", "rm -f /data/SingletonLock /data/SingletonCookie /data/SingletonSocket || true; /usr/local/bin/mcp-playwright-start-extra.sh & exec /start.sh"]`
1521

22+
const cdpGuardScript = String.raw`#!/usr/bin/env node
23+
"use strict";
24+
25+
const http = require("node:http");
26+
const { URL } = require("node:url");
27+
const { WebSocket, WebSocketServer } = require("/opt/docker-git-cdp-guard/node_modules/ws");
28+
29+
const upstreamHost = process.env.MCP_PLAYWRIGHT_UPSTREAM_CDP_HOST || "127.0.0.1";
30+
const upstreamPort = Number.parseInt(process.env.MCP_PLAYWRIGHT_UPSTREAM_CDP_PORT || "9222", 10);
31+
const listenHost = process.env.MCP_PLAYWRIGHT_CDP_GUARD_HOST || "0.0.0.0";
32+
const listenPort = Number.parseInt(process.env.MCP_PLAYWRIGHT_CDP_GUARD_PORT || "9223", 10);
33+
const blockedMethods = new Set(["Browser.close", "Browser.crash", "Browser.crashGpuProcess"]);
34+
35+
const log = (message) => process.stderr.write("[docker-git-cdp-guard] " + message + "\n");
36+
37+
const shouldBlockBrowserClose = () => process.env.MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE !== "0";
38+
39+
const requestHost = (request) => {
40+
const host = request.headers.host;
41+
return typeof host === "string" && host.length > 0 ? host : "127.0.0.1:" + listenPort;
42+
};
43+
44+
const rewriteWebSocketUrl = (value, host) => {
45+
try {
46+
const url = new URL(value);
47+
url.protocol = "ws:";
48+
url.host = host;
49+
return url.toString();
50+
} catch {
51+
return value;
52+
}
53+
};
54+
55+
const rewriteDebuggerUrls = (value, host) => {
56+
if (Array.isArray(value)) {
57+
return value.map((item) => rewriteDebuggerUrls(item, host));
58+
}
59+
if (value === null || typeof value !== "object") {
60+
return value;
61+
}
62+
return Object.fromEntries(
63+
Object.entries(value).map(([key, child]) => [
64+
key,
65+
key === "webSocketDebuggerUrl" && typeof child === "string"
66+
? rewriteWebSocketUrl(child, host)
67+
: rewriteDebuggerUrls(child, host)
68+
])
69+
);
70+
};
71+
72+
const rewriteJsonBody = (body, host) => {
73+
try {
74+
return Buffer.from(JSON.stringify(rewriteDebuggerUrls(JSON.parse(body.toString("utf8")), host)));
75+
} catch {
76+
return body;
77+
}
78+
};
79+
80+
const proxyHttp = (request, response) => {
81+
const chunks = [];
82+
request.on("data", (chunk) => chunks.push(chunk));
83+
request.on("end", () => {
84+
const headers = { ...request.headers, host: upstreamHost + ":" + upstreamPort };
85+
delete headers.connection;
86+
delete headers["content-length"];
87+
const upstream = http.request(
88+
{
89+
hostname: upstreamHost,
90+
port: upstreamPort,
91+
method: request.method,
92+
path: request.url || "/",
93+
headers
94+
},
95+
(upstreamResponse) => {
96+
const upstreamChunks = [];
97+
upstreamResponse.on("data", (chunk) => upstreamChunks.push(chunk));
98+
upstreamResponse.on("end", () => {
99+
const rawBody = Buffer.concat(upstreamChunks);
100+
const body = (request.url || "/").startsWith("/json")
101+
? rewriteJsonBody(rawBody, requestHost(request))
102+
: rawBody;
103+
const responseHeaders = { ...upstreamResponse.headers };
104+
delete responseHeaders["content-length"];
105+
delete responseHeaders["content-encoding"];
106+
response.writeHead(upstreamResponse.statusCode || 502, responseHeaders);
107+
response.end(body);
108+
});
109+
}
110+
);
111+
upstream.on("error", (error) => {
112+
response.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
113+
response.end("CDP upstream unavailable: " + error.message + "\n");
114+
});
115+
upstream.end(Buffer.concat(chunks));
116+
});
117+
};
118+
119+
const fetchCurrentBrowserPath = () =>
120+
new Promise((resolve, reject) => {
121+
const request = http.get(
122+
{
123+
hostname: upstreamHost,
124+
port: upstreamPort,
125+
path: "/json/version",
126+
headers: { host: upstreamHost + ":" + upstreamPort }
127+
},
128+
(response) => {
129+
const chunks = [];
130+
response.on("data", (chunk) => chunks.push(chunk));
131+
response.on("end", () => {
132+
try {
133+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
134+
const raw = typeof parsed.webSocketDebuggerUrl === "string" ? parsed.webSocketDebuggerUrl : "";
135+
const path = raw.length > 0 ? new URL(raw).pathname : "";
136+
path.length > 0 ? resolve(path) : reject(new Error("webSocketDebuggerUrl missing"));
137+
} catch (error) {
138+
reject(error);
139+
}
140+
});
141+
}
142+
);
143+
request.on("error", reject);
144+
});
145+
146+
const upstreamPathFor = async (rawPath) => {
147+
const path = rawPath || "/";
148+
return path.startsWith("/devtools/browser/") ? await fetchCurrentBrowserPath() : path;
149+
};
150+
151+
const parseMessage = (data) => JSON.parse(Buffer.isBuffer(data) ? data.toString("utf8") : String(data));
152+
153+
const isBlockedCdpMessage = (data) => {
154+
if (!shouldBlockBrowserClose()) {
155+
return false;
156+
}
157+
try {
158+
const message = parseMessage(data);
159+
return message !== null && typeof message === "object" && blockedMethods.has(message.method);
160+
} catch {
161+
return false;
162+
}
163+
};
164+
165+
const blockedCdpResponse = (data) => {
166+
try {
167+
const message = parseMessage(data);
168+
return Object.prototype.hasOwnProperty.call(message, "id")
169+
? JSON.stringify({ id: message.id, result: {} })
170+
: "";
171+
} catch {
172+
return "";
173+
}
174+
};
175+
176+
const handleWebSocket = async (client, request) => {
177+
const pending = [];
178+
let upstream = null;
179+
const forwardToUpstream = (data, isBinary) => {
180+
if (!upstream || upstream.readyState !== WebSocket.OPEN) {
181+
pending.push([data, isBinary]);
182+
return;
183+
}
184+
if (!isBinary && isBlockedCdpMessage(data)) {
185+
const response = blockedCdpResponse(data);
186+
if (response.length > 0 && client.readyState === WebSocket.OPEN) {
187+
client.send(response);
188+
}
189+
return;
190+
}
191+
upstream.send(data, { binary: isBinary });
192+
};
193+
194+
client.on("message", forwardToUpstream);
195+
196+
try {
197+
const upstreamPath = await upstreamPathFor(request.url || "/");
198+
upstream = new WebSocket("ws://" + upstreamHost + ":" + upstreamPort + upstreamPath, {
199+
headers: { host: upstreamHost + ":" + upstreamPort }
200+
});
201+
upstream.on("open", () => {
202+
for (const [data, isBinary] of pending.splice(0)) {
203+
forwardToUpstream(data, isBinary);
204+
}
205+
});
206+
upstream.on("message", (data, isBinary) => {
207+
if (client.readyState === WebSocket.OPEN) {
208+
client.send(data, { binary: isBinary });
209+
}
210+
});
211+
upstream.on("close", (code, reason) => {
212+
if (client.readyState === WebSocket.OPEN) {
213+
client.close(code, reason);
214+
}
215+
});
216+
upstream.on("error", (error) => {
217+
log("upstream websocket error: " + error.message);
218+
if (client.readyState === WebSocket.OPEN) {
219+
client.close(1011, "CDP upstream websocket error");
220+
}
221+
});
222+
client.on("close", () => {
223+
if (upstream && upstream.readyState === WebSocket.OPEN) {
224+
upstream.close();
225+
}
226+
});
227+
} catch (error) {
228+
log("websocket setup failed: " + error.message);
229+
client.close(1011, "CDP upstream unavailable");
230+
}
231+
};
232+
233+
const server = http.createServer(proxyHttp);
234+
const wss = new WebSocketServer({ noServer: true });
235+
236+
server.on("upgrade", (request, socket, head) => {
237+
wss.handleUpgrade(request, socket, head, (client) => {
238+
handleWebSocket(client, request);
239+
});
240+
});
241+
242+
server.listen(listenPort, listenHost, () => {
243+
log("listening on " + listenHost + ":" + listenPort + " -> " + upstreamHost + ":" + upstreamPort);
244+
});
245+
`
246+
16247
export const renderPlaywrightStartExtra = (): string =>
17248
`#!/bin/sh
18249
set -eu
@@ -23,8 +254,12 @@ rm -f /data/SingletonLock /data/SingletonCookie /data/SingletonSocket || true
23254
# Wait for chromium/x11vnc/noVNC to come up
24255
sleep 2
25256
26-
# CDP proxy: expose 9223 on the docker network, forward to 127.0.0.1:9222 inside the browser container
27-
socat TCP-LISTEN:9223,fork,reuseaddr TCP:127.0.0.1:9222 >/var/log/socat-9223.log 2>&1 &
257+
# CDP guard: expose 9223 on the docker network and block browser-level destructive CDP methods
258+
if [ "\${MCP_PLAYWRIGHT_CDP_GUARD:-1}" = "1" ]; then
259+
docker-git-cdp-guard >/var/log/docker-git-cdp-guard.log 2>&1 &
260+
else
261+
socat TCP-LISTEN:9223,fork,reuseaddr TCP:127.0.0.1:9222 >/var/log/socat-9223.log 2>&1 &
262+
fi
28263
29264
# Optional VNC password disabling (useful if you publish VNC/noVNC ports)
30265
if [ "\${VNC_NOPW:-1}" = "1" ]; then

packages/app/src/lib/usecases/actions/prepare-files.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,8 @@ const defaultProjectEnvContents = [
231231
"DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=fg=8,italic",
232232
"DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=history completion",
233233
"MCP_PLAYWRIGHT_ISOLATED=1",
234+
"MCP_PLAYWRIGHT_CDP_GUARD=1",
235+
"MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE=1",
234236
""
235237
].join("\n")
236238

packages/lib/src/core/templates-entrypoint/base.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ AGENT_AUTO="\${AGENT_AUTO:-}"
2929
MCP_PLAYWRIGHT_ENABLE="\${MCP_PLAYWRIGHT_ENABLE:-${config.enableMcpPlaywright ? "1" : "0"}}"
3030
MCP_PLAYWRIGHT_CDP_ENDPOINT="\${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}"
3131
MCP_PLAYWRIGHT_ISOLATED="\${MCP_PLAYWRIGHT_ISOLATED:-1}"
32+
MCP_PLAYWRIGHT_CDP_GUARD="\${MCP_PLAYWRIGHT_CDP_GUARD:-1}"
33+
MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE="\${MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE:-1}"
3234
3335
SSH_ENV_PATH="/home/${config.sshUser}/.ssh/environment"
3436

packages/lib/src/core/templates/docker-compose.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ const buildPlaywrightFragments = (
101101
maybeBrowserService:
102102
`\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n restart: unless-stopped\n${
103103
renderResourceLimits(resourceLimits)
104+
}${
105+
renderEnvFiles(config)
104106
} environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n dns:\n - 8.8.8.8\n - 8.8.4.4\n - 1.1.1.1\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`,
105107
maybeBrowserVolume: ` ${browserVolumeName}:`
106108
}

0 commit comments

Comments
 (0)