Skip to content

Commit b6a0fb7

Browse files
committed
fix(mcp): keep Playwright MCP handshake nonblocking
1 parent e7107a6 commit b6a0fb7

20 files changed

Lines changed: 393 additions & 112 deletions

File tree

299 KB
Loading
83.7 KB
Loading

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,12 @@ Container runtime env (set via .orch/env/project.env):
102102
MCP_PLAYWRIGHT_ISOLATED=1|0 Isolated browser contexts; default 0 shares the VNC session
103103
MCP_PLAYWRIGHT_CDP_GUARD=1|0 Guard CDP so MCP cannot close/crash shared Chromium (default: 1)
104104
MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE=1|0 Block destructive Browser.close/crash CDP methods (default: 1)
105-
MCP_PLAYWRIGHT_CDP_ENDPOINT=http://... Override CDP endpoint (default: http://dg-<repo>-browser:9223)
106-
MCP_PLAYWRIGHT_RETRY_ATTEMPTS=<n> Retry attempts for nested browser startup wait (default: 10)
107-
MCP_PLAYWRIGHT_RETRY_DELAY=<seconds> Delay between retry attempts (default: 2)
105+
MCP_PLAYWRIGHT_CDP_ENDPOINT=http://... Override CDP endpoint (default: http://127.0.0.1:9223)
106+
MCP_PLAYWRIGHT_CDP_TIMEOUT=<ms> CDP connect timeout passed to Playwright MCP (default: 60000)
107+
MCP_PLAYWRIGHT_READY_ATTEMPTS=<n> Startup readiness attempts before disabling broken MCP (default: 60)
108+
MCP_PLAYWRIGHT_READY_DELAY=<seconds> Delay between startup readiness attempts (default: 1)
109+
MCP_PLAYWRIGHT_RETRY_ATTEMPTS=<n> Legacy CDP preflight attempts when CDP guard is disabled (default: 10)
110+
MCP_PLAYWRIGHT_RETRY_DELAY=<seconds> Delay between legacy preflight attempts (default: 2)
108111
109112
Auth providers:
110113
github, gh GitHub CLI auth (tokens saved to env file)

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { renderEntrypointGitConfig, renderEntrypointGitHooks } from "./templates
2727
import { renderEntrypointGrokConfig } from "./templates-entrypoint/grok.js"
2828
import { renderEntrypointDockerGitBootstrap } from "./templates-entrypoint/nested-docker-git.js"
2929
import { renderEntrypointOpenCodeConfig } from "./templates-entrypoint/opencode.js"
30+
import { renderEntrypointPlaywrightBrowserRuntime } from "./templates-entrypoint/playwright-browser.js"
3031
import { renderEntrypointProjectAgentRules } from "./templates-entrypoint/project-rules.js"
3132
import { renderEntrypointRtkConfig } from "./templates-entrypoint/rtk.js"
3233
import { renderEntrypointBackgroundTasks } from "./templates-entrypoint/tasks.js"
@@ -47,7 +48,6 @@ export const renderEntrypoint = (config: TemplateConfig): string =>
4748
renderEntrypointCodexHome(config),
4849
renderEntrypointCodexSharedAuth(config),
4950
renderEntrypointOpenCodeConfig(config),
50-
renderEntrypointMcpPlaywright(config),
5151
renderEntrypointZshShell(config),
5252
renderEntrypointZshUserRc(config),
5353
renderEntrypointPrompt(),
@@ -60,6 +60,8 @@ export const renderEntrypoint = (config: TemplateConfig): string =>
6060
renderEntrypointProjectAgentRules(),
6161
renderEntrypointAgentsNotice(config),
6262
renderEntrypointDockerSocket(config),
63+
renderEntrypointPlaywrightBrowserRuntime(config),
64+
renderEntrypointMcpPlaywright(config),
6365
renderEntrypointGitConfig(config),
6466
renderEntrypointClaudeConfig(config),
6567
renderEntrypointGeminiConfig(config),

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ EOF
9999
fi
100100
101101
if [[ -z "$MCP_PLAYWRIGHT_CDP_ENDPOINT" ]]; then
102-
MCP_PLAYWRIGHT_CDP_ENDPOINT="http://__SERVICE_NAME__-browser:9223"
102+
MCP_PLAYWRIGHT_CDP_ENDPOINT="http://127.0.0.1:9223"
103103
fi
104104
105105
# Replace the docker-git Playwright block to allow upgrades via --force without manual edits.

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

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -160,13 +160,6 @@ const geminiSettingsJsonTemplate = `{
160160
"selectedType": "oauth-personal"
161161
},
162162
"disableYoloMode": false
163-
},
164-
"mcpServers": {
165-
"playwright": {
166-
"command": "docker-git-playwright-mcp",
167-
"args": [],
168-
"trust": true
169-
}
170163
}
171164
}`
172165

@@ -204,10 +197,40 @@ if [[ -d /etc/sudoers.d ]]; then
204197
chmod 0440 /etc/sudoers.d/gemini-agent
205198
fi`
206199

207-
const renderGeminiMcpPlaywrightConfig = (_config: TemplateConfig): string =>
208-
String.raw`# Gemini CLI: keep Playwright MCP config in sync (TODO: Gemini CLI MCP integration format)
209-
# For now, Gemini CLI uses MCP via ~/.gemini/settings.json or command line.
210-
# We'll ensure it has the same Playwright capability as Claude/Codex once format is confirmed.`
200+
const renderGeminiMcpPlaywrightConfig = (): string =>
201+
String.raw`# Gemini CLI: keep Playwright MCP config in sync with container settings
202+
docker_git_sync_gemini_playwright_mcp() {
203+
GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="$MCP_PLAYWRIGHT_ENABLE" node - <<'NODE'
204+
const fs = require("node:fs")
205+
const path = require("node:path")
206+
const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE
207+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
208+
if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0)
209+
210+
let settings = {}
211+
try {
212+
const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"))
213+
if (isRecord(parsed)) settings = parsed
214+
} catch {}
215+
216+
const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) }
217+
if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") {
218+
nextServers.playwright = { command: "docker-git-playwright-mcp", args: [], trust: true }
219+
} else {
220+
delete nextServers.playwright
221+
}
222+
223+
const nextSettings = { ...settings }
224+
Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers
225+
226+
if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0)
227+
228+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true })
229+
fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 })
230+
NODE
231+
}
232+
233+
docker_git_sync_gemini_playwright_mcp`
211234

212235
const renderGeminiProfileSetup = (config: TemplateConfig): string =>
213236
String.raw`GEMINI_PROFILE="/etc/profile.d/gemini-config.sh"
@@ -311,7 +334,7 @@ export const renderEntrypointGeminiConfig = (config: TemplateConfig): string =>
311334
[
312335
renderGeminiAuthConfig(config),
313336
renderGeminiPermissionSettingsConfig(config),
314-
renderGeminiMcpPlaywrightConfig(config),
337+
renderGeminiMcpPlaywrightConfig(),
315338
renderGeminiSudoConfig(config),
316339
renderGeminiProfileSetup(config),
317340
renderEntrypointGeminiNotice(config)

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

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -151,14 +151,7 @@ const renderGrokAuthConfig = (config: TemplateConfig): string =>
151151

152152
const grokSettingsJsonTemplate = `{
153153
"sandboxMode": "off",
154-
"confirmBeforeToolUse": false,
155-
"mcpServers": {
156-
"playwright": {
157-
"command": "docker-git-playwright-mcp",
158-
"args": [],
159-
"trust": true
160-
}
161-
}
154+
"confirmBeforeToolUse": false
162155
}`
163156

164157
const grokUserSettingsJsonTemplate = `{
@@ -189,6 +182,41 @@ GROK_SETTINGS_OWNER_GID="$(id -g "${config.sshUser}" 2>/dev/null || id -g)"
189182
chown -R "$GROK_SETTINGS_OWNER_UID:$GROK_SETTINGS_OWNER_GID" "$GROK_SETTINGS_DIR" || true
190183
chmod 0600 "$GROK_CONFIG_SETTINGS_FILE" "$GROK_USER_SETTINGS_FILE" 2>/dev/null || true`
191184

185+
const renderGrokMcpPlaywrightConfig = (): string =>
186+
String.raw`# Grok CLI: keep Playwright MCP config in sync with container settings
187+
docker_git_sync_grok_playwright_mcp() {
188+
GROK_CONFIG_SETTINGS_FILE="$GROK_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="$MCP_PLAYWRIGHT_ENABLE" node - <<'NODE'
189+
const fs = require("node:fs")
190+
const path = require("node:path")
191+
const settingsPath = process.env.GROK_CONFIG_SETTINGS_FILE
192+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
193+
if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0)
194+
195+
let settings = {}
196+
try {
197+
const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"))
198+
if (isRecord(parsed)) settings = parsed
199+
} catch {}
200+
201+
const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) }
202+
if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") {
203+
nextServers.playwright = { command: "docker-git-playwright-mcp", args: [], trust: true }
204+
} else {
205+
delete nextServers.playwright
206+
}
207+
208+
const nextSettings = { ...settings }
209+
Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers
210+
211+
if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0)
212+
213+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true })
214+
fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 })
215+
NODE
216+
}
217+
218+
docker_git_sync_grok_playwright_mcp`
219+
192220
const renderGrokSudoConfig = (config: TemplateConfig): string =>
193221
String.raw`# Grok CLI: allow passwordless sudo for agent tasks
194222
# Risk rationale: Grok runs inside an isolated per-project container. The sshUser
@@ -308,6 +336,7 @@ export const renderEntrypointGrokConfig = (config: TemplateConfig): string =>
308336
[
309337
renderGrokAuthConfig(config),
310338
renderGrokPermissionSettingsConfig(config),
339+
renderGrokMcpPlaywrightConfig(),
311340
renderGrokSudoConfig(config),
312341
renderGrokProfileSetup(config),
313342
renderEntrypointGrokNotice(config)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { TemplateConfig } from "../domain.js"
2+
3+
// CHANGE: source and start the nested browser runtime from the main project entrypoint.
4+
// WHY: issue #306 follow-up requires dg-*-browser to be owned by dg-* lifecycle, not a host-compose sibling.
5+
// QUOTE(ТЗ): "раз это браузер контейнер от нашего контейнера то хотелось бы что бы он внутри нашего контейрнера и поднимался бы"
6+
// REF: issue-306-browser-nested-runtime
7+
// SOURCE: n/a
8+
// FORMAT THEOREM: enable_mcp_playwright(project) -> entrypoint(project) attempts nested_browser_start(project)
9+
// PURITY: SHELL
10+
// EFFECT: sourced shell functions may call Docker when enabled
11+
// INVARIANT: stop function is always defined before sshd lifecycle traps are installed
12+
// COMPLEXITY: O(1)
13+
export const renderEntrypointPlaywrightBrowserRuntime = (_config: TemplateConfig): string =>
14+
String.raw`# Nested Playwright browser runtime. Defaults are no-ops so sshd cleanup can call them unconditionally.
15+
docker_git_start_playwright_browser() { return 0; }
16+
docker_git_stop_playwright_browser() { return 0; }
17+
18+
DOCKER_GIT_BROWSER_RUNTIME="/opt/docker-git/browser/docker-git-browser-runtime.sh"
19+
if [[ -f "$DOCKER_GIT_BROWSER_RUNTIME" ]]; then
20+
# shellcheck disable=SC1090
21+
source "$DOCKER_GIT_BROWSER_RUNTIME"
22+
fi
23+
24+
if [[ "$MCP_PLAYWRIGHT_ENABLE" == "1" ]]; then
25+
docker_git_start_playwright_browser || true
26+
else
27+
docker_git_stop_playwright_browser || true
28+
fi`

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

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,12 @@ const dockerGitSessionSyncPackage = "@prover-coder-ai/docker-git-session-sync@la
155155

156156
const dockerfilePlaywrightMcpBlock = String.raw`RUN npm install -g @playwright/mcp@latest
157157
158-
# docker-git: wrapper that waits for the guarded CDP endpoint before launching Playwright MCP.
158+
# docker-git: wrapper that launches the MCP stdio server without blocking initialize on CDP readiness.
159159
RUN cat <<'EOF' > /usr/local/bin/docker-git-playwright-mcp
160160
#!/usr/bin/env bash
161161
set -euo pipefail
162162
163-
# Fast-path for help/version (avoid waiting for the browser sidecar).
163+
# Fast-path for help/version (avoid waiting for nested browser startup).
164164
for arg in "$@"; do
165165
case "$arg" in
166166
-h|--help|-V|--version)
@@ -171,22 +171,36 @@ done
171171
172172
CDP_ENDPOINT="\${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}"
173173
if [[ -z "$CDP_ENDPOINT" ]]; then
174-
CDP_ENDPOINT="http://__SERVICE_NAME__-browser:9223"
174+
CDP_ENDPOINT="http://127.0.0.1:9223"
175175
fi
176176
177-
# CHANGE: add retry logic for browser sidecar startup wait
178-
# WHY: the browser container may take time to initialize, causing MCP server to fail on first attempt
179-
# QUOTE(issue-123): "Почему MCP сервер лежит с ошибкой?"
180-
# REF: issue-123
181-
# SOURCE: n/a
182-
# FORMAT THEOREM: forall t in [1..max_attempts]: retry(t) -> eventually(cdp_ready) OR timeout_error
177+
# CHANGE: keep MCP initialize independent from nested browser readiness
178+
# WHY: Codex starts MCP servers during boot; blocking here closes stdio before initialize when CDP is slow.
179+
# QUOTE(issue-319): "handshaking with MCP server failed: connection closed: initialize response"
180+
# REF: issue-319
181+
# SOURCE: https://playwright.dev/mcp/configuration/options
182+
# FORMAT THEOREM: guarded_cdp(endpoint) -> mcp_stdio_ready_before_browser_connection
183183
# PURITY: SHELL
184-
# INVARIANT: script exits only after cdp_ready OR all retries exhausted
185-
# COMPLEXITY: O(max_attempts * timeout_per_attempt)
184+
# INVARIANT: guarded mode never exits before handing stdio to playwright-mcp
185+
# COMPLEXITY: O(1)
186186
MCP_PLAYWRIGHT_RETRY_ATTEMPTS="\${MCP_PLAYWRIGHT_RETRY_ATTEMPTS:-10}"
187187
MCP_PLAYWRIGHT_RETRY_DELAY="\${MCP_PLAYWRIGHT_RETRY_DELAY:-2}"
188188
MCP_PLAYWRIGHT_CDP_GUARD="\${MCP_PLAYWRIGHT_CDP_GUARD:-1}"
189+
MCP_PLAYWRIGHT_CDP_TIMEOUT="\${MCP_PLAYWRIGHT_CDP_TIMEOUT:-60000}"
189190
191+
EXTRA_ARGS=()
192+
if [[ "\${MCP_PLAYWRIGHT_ISOLATED:-0}" == "1" ]]; then
193+
EXTRA_ARGS+=(--isolated)
194+
fi
195+
196+
# Guarded endpoints are stable HTTP CDP endpoints. Passing the HTTP URL lets Playwright MCP
197+
# re-resolve /json/version instead of pinning itself to one stale /devtools/browser/<id>.
198+
if [[ "$MCP_PLAYWRIGHT_CDP_GUARD" == "1" ]]; then
199+
exec playwright-mcp --cdp-endpoint "$CDP_ENDPOINT" --cdp-timeout "$MCP_PLAYWRIGHT_CDP_TIMEOUT" "\${EXTRA_ARGS[@]}" "$@"
200+
fi
201+
202+
# kechangdev/browser-vnc binds Chromium CDP on 127.0.0.1:9222; it also host-checks HTTP requests.
203+
# When the guard is disabled, preserve the old behavior by converting the HTTP endpoint to WS.
190204
fetch_cdp_version() {
191205
curl -sSf --connect-timeout 3 --max-time 10 -H 'Host: 127.0.0.1:9222' "\${CDP_ENDPOINT%/}/json/version" 2>/dev/null
192206
}
@@ -197,7 +211,7 @@ for attempt in $(seq 1 "$MCP_PLAYWRIGHT_RETRY_ATTEMPTS"); do
197211
break
198212
fi
199213
if [[ "$attempt" -lt "$MCP_PLAYWRIGHT_RETRY_ATTEMPTS" ]]; then
200-
echo "docker-git-playwright-mcp: waiting for browser sidecar (attempt $attempt/$MCP_PLAYWRIGHT_RETRY_ATTEMPTS)..." >&2
214+
echo "docker-git-playwright-mcp: waiting for nested browser runtime (attempt $attempt/$MCP_PLAYWRIGHT_RETRY_ATTEMPTS)..." >&2
201215
sleep "$MCP_PLAYWRIGHT_RETRY_DELAY"
202216
fi
203217
done
@@ -207,19 +221,6 @@ if [[ -z "$JSON" ]]; then
207221
exit 1
208222
fi
209223
210-
EXTRA_ARGS=()
211-
if [[ "\${MCP_PLAYWRIGHT_ISOLATED:-0}" == "1" ]]; then
212-
EXTRA_ARGS+=(--isolated)
213-
fi
214-
215-
# Guarded endpoints are stable HTTP CDP endpoints. Passing the HTTP URL lets Playwright MCP
216-
# re-resolve /json/version instead of pinning itself to one stale /devtools/browser/<id>.
217-
if [[ "$MCP_PLAYWRIGHT_CDP_GUARD" == "1" ]]; then
218-
exec playwright-mcp --cdp-endpoint "$CDP_ENDPOINT" "\${EXTRA_ARGS[@]}" "$@"
219-
fi
220-
221-
# kechangdev/browser-vnc binds Chromium CDP on 127.0.0.1:9222; it also host-checks HTTP requests.
222-
# When the guard is disabled, preserve the old behavior by converting the HTTP endpoint to WS.
223224
WS_URL="$(printf "%s" "$JSON" | node -e 'const fs=require("fs"); const j=JSON.parse(fs.readFileSync(0,"utf8")); process.stdout.write(j.webSocketDebuggerUrl || "")')"
224225
if [[ -z "$WS_URL" ]]; then
225226
echo "docker-git-playwright-mcp: webSocketDebuggerUrl missing" >&2
@@ -230,10 +231,17 @@ fi
230231
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)')"
231232
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())')"
232233
233-
exec playwright-mcp --cdp-endpoint "$WS_REWRITTEN" "\${EXTRA_ARGS[@]}" "$@"
234+
exec playwright-mcp --cdp-endpoint "$WS_REWRITTEN" --cdp-timeout "$MCP_PLAYWRIGHT_CDP_TIMEOUT" "\${EXTRA_ARGS[@]}" "$@"
234235
EOF
235236
RUN chmod +x /usr/local/bin/docker-git-playwright-mcp`
236237

238+
const renderDockerfilePlaywrightRuntime = (config: TemplateConfig): string =>
239+
config.enableMcpPlaywright
240+
? `# docker-git nested Playwright browser runtime context
241+
COPY Dockerfile.browser mcp-playwright-start-extra.sh docker-git-browser-runtime.sh /opt/docker-git/browser/
242+
RUN chmod +x /opt/docker-git/browser/mcp-playwright-start-extra.sh /opt/docker-git/browser/docker-git-browser-runtime.sh`
243+
: ""
244+
237245
/**
238246
* Renders /etc/profile.d/bun.sh with a runtime-relative PATH extension.
239247
*
@@ -391,6 +399,7 @@ export const renderDockerfile = (config: TemplateConfig): string =>
391399
renderDockerfilePrompt(),
392400
renderDockerfileNode(),
393401
renderDockerfileBun(config),
402+
renderDockerfilePlaywrightRuntime(config),
394403
renderDockerfileRtk(),
395404
renderDockerfileOpenCode(),
396405
renderDockerfileGitleaks(),

0 commit comments

Comments
 (0)