diff --git a/.github/pr-screenshots/issue-319/implementation.png b/.github/pr-screenshots/issue-319/implementation.png new file mode 100644 index 00000000..e8817a1f Binary files /dev/null and b/.github/pr-screenshots/issue-319/implementation.png differ diff --git a/.github/pr-screenshots/issue-319/verification.png b/.github/pr-screenshots/issue-319/verification.png new file mode 100644 index 00000000..963c8176 Binary files /dev/null and b/.github/pr-screenshots/issue-319/verification.png differ diff --git a/packages/app/src/docker-git/api-client-auth.ts b/packages/app/src/docker-git/api-client-auth.ts index 7bf40c36..b402be08 100644 --- a/packages/app/src/docker-git/api-client-auth.ts +++ b/packages/app/src/docker-git/api-client-auth.ts @@ -25,11 +25,11 @@ import type { AuthGithubLoginCommand, AuthGithubLogoutCommand, AuthGithubStatusCommand, - AuthGrokLogoutCommand, - AuthGrokStatusCommand, AuthGitlabLoginCommand, AuthGitlabLogoutCommand, - AuthGitlabStatusCommand + AuthGitlabStatusCommand, + AuthGrokLogoutCommand, + AuthGrokStatusCommand } from "./frontend-lib/core/domain.js" import { resolvePathFromCwd } from "./frontend-lib/usecases/path-helpers.js" import type { ApiAuthRequiredError, ApiRequestError } from "./host-errors.js" diff --git a/packages/app/src/docker-git/api-client.ts b/packages/app/src/docker-git/api-client.ts index 573f9ef9..1ec2b796 100644 --- a/packages/app/src/docker-git/api-client.ts +++ b/packages/app/src/docker-git/api-client.ts @@ -31,11 +31,11 @@ export { githubLogin, githubLogout, githubStatus, - grokLogout, - grokStatus, gitlabLogin, gitlabLogout, - gitlabStatus + gitlabStatus, + grokLogout, + grokStatus } from "./api-client-auth.js" export { type ApiContainerTask, diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index 2bde89d7..ffa83275 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -102,9 +102,12 @@ Container runtime env (set via .orch/env/project.env): MCP_PLAYWRIGHT_ISOLATED=1|0 Isolated browser contexts; default 0 shares the VNC session MCP_PLAYWRIGHT_CDP_GUARD=1|0 Guard CDP so MCP cannot close/crash shared Chromium (default: 1) MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE=1|0 Block destructive Browser.close/crash CDP methods (default: 1) - MCP_PLAYWRIGHT_CDP_ENDPOINT=http://... Override CDP endpoint (default: http://dg--browser:9223) - MCP_PLAYWRIGHT_RETRY_ATTEMPTS= Retry attempts for nested browser startup wait (default: 10) - MCP_PLAYWRIGHT_RETRY_DELAY= Delay between retry attempts (default: 2) + MCP_PLAYWRIGHT_CDP_ENDPOINT=http://... Override CDP endpoint (default: http://127.0.0.1:9223) + MCP_PLAYWRIGHT_CDP_TIMEOUT= CDP connect timeout passed to Playwright MCP (default: 60000) + MCP_PLAYWRIGHT_READY_ATTEMPTS= Startup readiness attempts before disabling broken MCP (default: 60) + MCP_PLAYWRIGHT_READY_DELAY= Delay between startup readiness attempts (default: 1) + MCP_PLAYWRIGHT_RETRY_ATTEMPTS= Legacy CDP preflight attempts when CDP guard is disabled (default: 10) + MCP_PLAYWRIGHT_RETRY_DELAY= Delay between legacy preflight attempts (default: 2) Auth providers: github, gh GitHub CLI auth (tokens saved to env file) diff --git a/packages/app/src/docker-git/program-auth.ts b/packages/app/src/docker-git/program-auth.ts index 77d7adde..df31dc1f 100644 --- a/packages/app/src/docker-git/program-auth.ts +++ b/packages/app/src/docker-git/program-auth.ts @@ -9,11 +9,11 @@ import { githubLogin, githubLogout, githubStatus, - grokLogout, - grokStatus, gitlabLogin, gitlabLogout, gitlabStatus, + grokLogout, + grokStatus, type JsonValue, renderJsonPayload } from "./api-client.js" @@ -21,7 +21,7 @@ import { type ControllerRuntime, ensureControllerReady } from "./controller.js" import type { Command } from "./frontend-lib/core/domain.js" import type { ApiRequestError, CliError } from "./host-errors.js" import { terminalAuthTitle } from "./menu-auth-shared.js" -import { attachTerminalSession } from "./terminal-session-client.js" +import { attachTerminalSession, type TerminalSessionClientError } from "./terminal-session-client.js" type OperationalCommand = Exclude @@ -111,7 +111,7 @@ const handleGrokLoginCommand = ( ) => withControllerReady( createAuthTerminalSession("GrokOauth", command.label).pipe( - Effect.flatMap((session) => + Effect.flatMap((session): Effect.Effect => session === null ? Effect.fail(missingAuthTerminalSessionError("GrokOauth")) : attachTerminalSession({ diff --git a/packages/app/src/lib/core/templates-entrypoint.ts b/packages/app/src/lib/core/templates-entrypoint.ts index 43b16baa..02641d16 100644 --- a/packages/app/src/lib/core/templates-entrypoint.ts +++ b/packages/app/src/lib/core/templates-entrypoint.ts @@ -27,6 +27,7 @@ import { renderEntrypointGitConfig, renderEntrypointGitHooks } from "./templates import { renderEntrypointGrokConfig } from "./templates-entrypoint/grok.js" import { renderEntrypointDockerGitBootstrap } from "./templates-entrypoint/nested-docker-git.js" import { renderEntrypointOpenCodeConfig } from "./templates-entrypoint/opencode.js" +import { renderEntrypointPlaywrightBrowserRuntime } from "./templates-entrypoint/playwright-browser.js" import { renderEntrypointProjectAgentRules } from "./templates-entrypoint/project-rules.js" import { renderEntrypointRtkConfig } from "./templates-entrypoint/rtk.js" import { renderEntrypointBackgroundTasks } from "./templates-entrypoint/tasks.js" @@ -47,7 +48,6 @@ export const renderEntrypoint = (config: TemplateConfig): string => renderEntrypointCodexHome(config), renderEntrypointCodexSharedAuth(config), renderEntrypointOpenCodeConfig(config), - renderEntrypointMcpPlaywright(config), renderEntrypointZshShell(config), renderEntrypointZshUserRc(config), renderEntrypointPrompt(), @@ -60,6 +60,8 @@ export const renderEntrypoint = (config: TemplateConfig): string => renderEntrypointProjectAgentRules(), renderEntrypointAgentsNotice(config), renderEntrypointDockerSocket(config), + renderEntrypointPlaywrightBrowserRuntime(config), + renderEntrypointMcpPlaywright(config), renderEntrypointGitConfig(config), renderEntrypointClaudeConfig(config), renderEntrypointGeminiConfig(config), diff --git a/packages/app/src/lib/core/templates-entrypoint/claude.ts b/packages/app/src/lib/core/templates-entrypoint/claude.ts index 209ae0fe..fb43458b 100644 --- a/packages/app/src/lib/core/templates-entrypoint/claude.ts +++ b/packages/app/src/lib/core/templates-entrypoint/claude.ts @@ -193,7 +193,7 @@ const renderClaudeMcpPlaywrightConfig = (): string => String.raw`# Claude Code: keep Playwright MCP config in sync with container settings CLAUDE_SETTINGS_FILE="${"$"}{CLAUDE_HOME_JSON:-$CLAUDE_CONFIG_DIR/.claude.json}" docker_git_sync_claude_playwright_mcp() { - CLAUDE_SETTINGS_FILE="$CLAUDE_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="$MCP_PLAYWRIGHT_ENABLE" node - <<'NODE' + CLAUDE_SETTINGS_FILE="$CLAUDE_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" node - <<'NODE' const fs = require("node:fs") const path = require("node:path") diff --git a/packages/app/src/lib/core/templates-entrypoint/codex.ts b/packages/app/src/lib/core/templates-entrypoint/codex.ts index 718c5800..b520bd63 100644 --- a/packages/app/src/lib/core/templates-entrypoint/codex.ts +++ b/packages/app/src/lib/core/templates-entrypoint/codex.ts @@ -99,7 +99,7 @@ EOF fi if [[ -z "$MCP_PLAYWRIGHT_CDP_ENDPOINT" ]]; then - MCP_PLAYWRIGHT_CDP_ENDPOINT="http://__SERVICE_NAME__-browser:9223" + MCP_PLAYWRIGHT_CDP_ENDPOINT="http://127.0.0.1:9223" fi # Replace the docker-git Playwright block to allow upgrades via --force without manual edits. diff --git a/packages/app/src/lib/core/templates-entrypoint/gemini.ts b/packages/app/src/lib/core/templates-entrypoint/gemini.ts index 99c753ba..7d1572b0 100644 --- a/packages/app/src/lib/core/templates-entrypoint/gemini.ts +++ b/packages/app/src/lib/core/templates-entrypoint/gemini.ts @@ -160,13 +160,6 @@ const geminiSettingsJsonTemplate = `{ "selectedType": "oauth-personal" }, "disableYoloMode": false - }, - "mcpServers": { - "playwright": { - "command": "docker-git-playwright-mcp", - "args": [], - "trust": true - } } }` @@ -204,10 +197,40 @@ if [[ -d /etc/sudoers.d ]]; then chmod 0440 /etc/sudoers.d/gemini-agent fi` -const renderGeminiMcpPlaywrightConfig = (_config: TemplateConfig): string => - String.raw`# Gemini CLI: keep Playwright MCP config in sync (TODO: Gemini CLI MCP integration format) -# For now, Gemini CLI uses MCP via ~/.gemini/settings.json or command line. -# We'll ensure it has the same Playwright capability as Claude/Codex once format is confirmed.` +const renderGeminiMcpPlaywrightConfig = (): string => + String.raw`# Gemini CLI: keep Playwright MCP config in sync with container settings +docker_git_sync_gemini_playwright_mcp() { + GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") +const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + if (isRecord(parsed)) settings = parsed +} catch {} + +const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } +if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") { + nextServers.playwright = { command: "docker-git-playwright-mcp", args: [], trust: true } +} else { + delete nextServers.playwright +} + +const nextSettings = { ...settings } +Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_gemini_playwright_mcp` const renderGeminiProfileSetup = (config: TemplateConfig): string => String.raw`GEMINI_PROFILE="/etc/profile.d/gemini-config.sh" @@ -311,7 +334,7 @@ export const renderEntrypointGeminiConfig = (config: TemplateConfig): string => [ renderGeminiAuthConfig(config), renderGeminiPermissionSettingsConfig(config), - renderGeminiMcpPlaywrightConfig(config), + renderGeminiMcpPlaywrightConfig(), renderGeminiSudoConfig(config), renderGeminiProfileSetup(config), renderEntrypointGeminiNotice(config) diff --git a/packages/app/src/lib/core/templates-entrypoint/grok.ts b/packages/app/src/lib/core/templates-entrypoint/grok.ts index 269df685..30998746 100644 --- a/packages/app/src/lib/core/templates-entrypoint/grok.ts +++ b/packages/app/src/lib/core/templates-entrypoint/grok.ts @@ -151,14 +151,7 @@ const renderGrokAuthConfig = (config: TemplateConfig): string => const grokSettingsJsonTemplate = `{ "sandboxMode": "off", - "confirmBeforeToolUse": false, - "mcpServers": { - "playwright": { - "command": "docker-git-playwright-mcp", - "args": [], - "trust": true - } - } + "confirmBeforeToolUse": false }` const grokUserSettingsJsonTemplate = `{ @@ -189,6 +182,41 @@ GROK_SETTINGS_OWNER_GID="$(id -g "${config.sshUser}" 2>/dev/null || id -g)" chown -R "$GROK_SETTINGS_OWNER_UID:$GROK_SETTINGS_OWNER_GID" "$GROK_SETTINGS_DIR" || true chmod 0600 "$GROK_CONFIG_SETTINGS_FILE" "$GROK_USER_SETTINGS_FILE" 2>/dev/null || true` +const renderGrokMcpPlaywrightConfig = (): string => + String.raw`# Grok CLI: keep Playwright MCP config in sync with container settings +docker_git_sync_grok_playwright_mcp() { + GROK_CONFIG_SETTINGS_FILE="$GROK_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") +const settingsPath = process.env.GROK_CONFIG_SETTINGS_FILE +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + if (isRecord(parsed)) settings = parsed +} catch {} + +const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } +if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") { + nextServers.playwright = { command: "docker-git-playwright-mcp", args: [], trust: true } +} else { + delete nextServers.playwright +} + +const nextSettings = { ...settings } +Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_grok_playwright_mcp` + const renderGrokSudoConfig = (config: TemplateConfig): string => String.raw`# Grok CLI: allow passwordless sudo for agent tasks # Risk rationale: Grok runs inside an isolated per-project container. The sshUser @@ -308,6 +336,7 @@ export const renderEntrypointGrokConfig = (config: TemplateConfig): string => [ renderGrokAuthConfig(config), renderGrokPermissionSettingsConfig(config), + renderGrokMcpPlaywrightConfig(), renderGrokSudoConfig(config), renderGrokProfileSetup(config), renderEntrypointGrokNotice(config) diff --git a/packages/app/src/lib/core/templates-entrypoint/playwright-browser.ts b/packages/app/src/lib/core/templates-entrypoint/playwright-browser.ts new file mode 100644 index 00000000..53be6516 --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/playwright-browser.ts @@ -0,0 +1,28 @@ +import type { TemplateConfig } from "../domain.js" + +// CHANGE: source and start the nested browser runtime from the main project entrypoint. +// WHY: issue #306 follow-up requires dg-*-browser to be owned by dg-* lifecycle, not a host-compose sibling. +// QUOTE(ТЗ): "раз это браузер контейнер от нашего контейнера то хотелось бы что бы он внутри нашего контейрнера и поднимался бы" +// REF: issue-306-browser-nested-runtime +// SOURCE: n/a +// FORMAT THEOREM: enable_mcp_playwright(project) -> entrypoint(project) attempts nested_browser_start(project) +// PURITY: SHELL +// EFFECT: sourced shell functions may call Docker when enabled +// INVARIANT: stop function is always defined before sshd lifecycle traps are installed +// COMPLEXITY: O(1) +export const renderEntrypointPlaywrightBrowserRuntime = (_config: TemplateConfig): string => + String.raw`# Nested Playwright browser runtime. Defaults are no-ops so sshd cleanup can call them unconditionally. +docker_git_start_playwright_browser() { return 0; } +docker_git_stop_playwright_browser() { return 0; } + +DOCKER_GIT_BROWSER_RUNTIME="/opt/docker-git/browser/docker-git-browser-runtime.sh" +if [[ -f "$DOCKER_GIT_BROWSER_RUNTIME" ]]; then + # shellcheck disable=SC1090 + source "$DOCKER_GIT_BROWSER_RUNTIME" +fi + +if [[ "$MCP_PLAYWRIGHT_ENABLE" == "1" ]]; then + docker_git_start_playwright_browser || true +else + docker_git_stop_playwright_browser || true +fi` diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index 7af83e43..1b0123c6 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -153,14 +153,15 @@ RUN set -eu; \ const dockerGitSessionSyncPackage = "@prover-coder-ai/docker-git-session-sync@latest" -const dockerfilePlaywrightMcpBlock = String.raw`RUN npm install -g @playwright/mcp@latest +const dockerfilePlaywrightMcpBlock = String.raw`ARG PLAYWRIGHT_MCP_VERSION=0.0.75 +RUN npm install -g "@playwright/mcp@${"$"}{PLAYWRIGHT_MCP_VERSION}" -# docker-git: wrapper that waits for the guarded CDP endpoint before launching Playwright MCP. +# docker-git: wrapper that launches the MCP stdio server without blocking initialize on CDP readiness. RUN cat <<'EOF' > /usr/local/bin/docker-git-playwright-mcp #!/usr/bin/env bash set -euo pipefail -# Fast-path for help/version (avoid waiting for the browser sidecar). +# Fast-path for help/version (avoid waiting for nested browser startup). for arg in "$@"; do case "$arg" in -h|--help|-V|--version) @@ -171,22 +172,36 @@ done CDP_ENDPOINT="\${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}" if [[ -z "$CDP_ENDPOINT" ]]; then - CDP_ENDPOINT="http://__SERVICE_NAME__-browser:9223" + CDP_ENDPOINT="http://127.0.0.1:9223" fi -# CHANGE: add retry logic for browser sidecar startup wait -# WHY: the browser container may take time to initialize, causing MCP server to fail on first attempt -# QUOTE(issue-123): "Почему MCP сервер лежит с ошибкой?" -# REF: issue-123 -# SOURCE: n/a -# FORMAT THEOREM: forall t in [1..max_attempts]: retry(t) -> eventually(cdp_ready) OR timeout_error +# CHANGE: keep MCP initialize independent from nested browser readiness +# WHY: Codex starts MCP servers during boot; blocking here closes stdio before initialize when CDP is slow. +# QUOTE(issue-319): "handshaking with MCP server failed: connection closed: initialize response" +# REF: issue-319 +# SOURCE: https://playwright.dev/mcp/configuration/options +# FORMAT THEOREM: guarded_cdp(endpoint) -> mcp_stdio_ready_before_browser_connection # PURITY: SHELL -# INVARIANT: script exits only after cdp_ready OR all retries exhausted -# COMPLEXITY: O(max_attempts * timeout_per_attempt) +# INVARIANT: guarded mode never exits before handing stdio to playwright-mcp +# COMPLEXITY: O(1) MCP_PLAYWRIGHT_RETRY_ATTEMPTS="\${MCP_PLAYWRIGHT_RETRY_ATTEMPTS:-10}" MCP_PLAYWRIGHT_RETRY_DELAY="\${MCP_PLAYWRIGHT_RETRY_DELAY:-2}" MCP_PLAYWRIGHT_CDP_GUARD="\${MCP_PLAYWRIGHT_CDP_GUARD:-1}" +MCP_PLAYWRIGHT_CDP_TIMEOUT="\${MCP_PLAYWRIGHT_CDP_TIMEOUT:-60000}" +EXTRA_ARGS=() +if [[ "\${MCP_PLAYWRIGHT_ISOLATED:-0}" == "1" ]]; then + EXTRA_ARGS+=(--isolated) +fi + +# Guarded endpoints are stable HTTP CDP endpoints. Passing the HTTP URL lets Playwright MCP +# re-resolve /json/version instead of pinning itself to one stale /devtools/browser/. +if [[ "$MCP_PLAYWRIGHT_CDP_GUARD" == "1" ]]; then + exec playwright-mcp --cdp-endpoint "$CDP_ENDPOINT" --cdp-timeout "$MCP_PLAYWRIGHT_CDP_TIMEOUT" "\${EXTRA_ARGS[@]}" "$@" +fi + +# kechangdev/browser-vnc binds Chromium CDP on 127.0.0.1:9222; it also host-checks HTTP requests. +# When the guard is disabled, preserve the old behavior by converting the HTTP endpoint to WS. fetch_cdp_version() { curl -sSf --connect-timeout 3 --max-time 10 -H 'Host: 127.0.0.1:9222' "\${CDP_ENDPOINT%/}/json/version" 2>/dev/null } @@ -197,7 +212,7 @@ for attempt in $(seq 1 "$MCP_PLAYWRIGHT_RETRY_ATTEMPTS"); do break fi if [[ "$attempt" -lt "$MCP_PLAYWRIGHT_RETRY_ATTEMPTS" ]]; then - echo "docker-git-playwright-mcp: waiting for browser sidecar (attempt $attempt/$MCP_PLAYWRIGHT_RETRY_ATTEMPTS)..." >&2 + echo "docker-git-playwright-mcp: waiting for nested browser runtime (attempt $attempt/$MCP_PLAYWRIGHT_RETRY_ATTEMPTS)..." >&2 sleep "$MCP_PLAYWRIGHT_RETRY_DELAY" fi done @@ -207,19 +222,6 @@ if [[ -z "$JSON" ]]; then exit 1 fi -EXTRA_ARGS=() -if [[ "\${MCP_PLAYWRIGHT_ISOLATED:-0}" == "1" ]]; then - EXTRA_ARGS+=(--isolated) -fi - -# Guarded endpoints are stable HTTP CDP endpoints. Passing the HTTP URL lets Playwright MCP -# re-resolve /json/version instead of pinning itself to one stale /devtools/browser/. -if [[ "$MCP_PLAYWRIGHT_CDP_GUARD" == "1" ]]; then - exec playwright-mcp --cdp-endpoint "$CDP_ENDPOINT" "\${EXTRA_ARGS[@]}" "$@" -fi - -# kechangdev/browser-vnc binds Chromium CDP on 127.0.0.1:9222; it also host-checks HTTP requests. -# When the guard is disabled, preserve the old behavior by converting the HTTP endpoint to WS. WS_URL="$(printf "%s" "$JSON" | node -e 'const fs=require("fs"); const j=JSON.parse(fs.readFileSync(0,"utf8")); process.stdout.write(j.webSocketDebuggerUrl || "")')" if [[ -z "$WS_URL" ]]; then echo "docker-git-playwright-mcp: webSocketDebuggerUrl missing" >&2 @@ -230,10 +232,17 @@ fi 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)')" 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())')" -exec playwright-mcp --cdp-endpoint "$WS_REWRITTEN" "\${EXTRA_ARGS[@]}" "$@" +exec playwright-mcp --cdp-endpoint "$WS_REWRITTEN" --cdp-timeout "$MCP_PLAYWRIGHT_CDP_TIMEOUT" "\${EXTRA_ARGS[@]}" "$@" EOF RUN chmod +x /usr/local/bin/docker-git-playwright-mcp` +const renderDockerfilePlaywrightRuntime = (config: TemplateConfig): string => + config.enableMcpPlaywright + ? `# docker-git nested Playwright browser runtime context +COPY Dockerfile.browser mcp-playwright-start-extra.sh docker-git-browser-runtime.sh /opt/docker-git/browser/ +RUN chmod +x /opt/docker-git/browser/mcp-playwright-start-extra.sh /opt/docker-git/browser/docker-git-browser-runtime.sh` + : "" + /** * Renders /etc/profile.d/bun.sh with a runtime-relative PATH extension. * @@ -391,6 +400,7 @@ export const renderDockerfile = (config: TemplateConfig): string => renderDockerfilePrompt(), renderDockerfileNode(), renderDockerfileBun(config), + renderDockerfilePlaywrightRuntime(config), renderDockerfileRtk(), renderDockerfileOpenCode(), renderDockerfileGitleaks(), diff --git a/packages/app/src/lib/core/templates/playwright-browser-runtime.ts b/packages/app/src/lib/core/templates/playwright-browser-runtime.ts index d3edc4dc..00137f59 100644 --- a/packages/app/src/lib/core/templates/playwright-browser-runtime.ts +++ b/packages/app/src/lib/core/templates/playwright-browser-runtime.ts @@ -27,6 +27,53 @@ docker_git_browser_context_dir() { printf '%s\\n' "\${DOCKER_GIT_BROWSER_CONTEXT_DIR:-/opt/docker-git/browser}" } +docker_git_disable_playwright_mcp() { + docker_git_browser_log "$1; disabling Playwright MCP for this container start" + MCP_PLAYWRIGHT_ENABLE=0 + export MCP_PLAYWRIGHT_ENABLE +} + +docker_git_playwright_cdp_endpoint() { + printf '%s\\n' "\${MCP_PLAYWRIGHT_CDP_ENDPOINT:-http://127.0.0.1:9223}" +} + +docker_git_fetch_playwright_cdp_version() { + local endpoint + endpoint="$(docker_git_playwright_cdp_endpoint)" + curl -sSf --connect-timeout 3 --max-time 10 -H 'Host: 127.0.0.1:9222' "\${endpoint%/}/json/version" >/dev/null 2>&1 +} + +docker_git_wait_for_playwright_cdp() { + local attempts="\${MCP_PLAYWRIGHT_READY_ATTEMPTS:-60}" + local delay="\${MCP_PLAYWRIGHT_READY_DELAY:-1}" + local endpoint + endpoint="$(docker_git_playwright_cdp_endpoint)" + if [[ ! "$attempts" =~ ^[0-9]+$ ]] || (( attempts < 1 )); then + docker_git_browser_log "invalid MCP_PLAYWRIGHT_READY_ATTEMPTS=$attempts; using 60" + attempts=60 + fi + if [[ ! "$delay" =~ ^[0-9]+$ ]]; then + docker_git_browser_log "invalid MCP_PLAYWRIGHT_READY_DELAY=$delay; using 1" + delay=1 + fi + + local attempt=1 + while (( attempt <= attempts )); do + if docker_git_fetch_playwright_cdp_version; then + docker_git_browser_log "CDP endpoint is ready: $endpoint" + return 0 + fi + if (( attempt < attempts )); then + docker_git_browser_log "waiting for CDP endpoint $endpoint (attempt $attempt/$attempts)" + sleep "$delay" + fi + attempt=$((attempt + 1)) + done + + docker_git_browser_log "CDP endpoint did not become ready: $endpoint" + return 1 +} + docker_git_stop_playwright_browser() { local container_name="\${DOCKER_GIT_BROWSER_CONTAINER_NAME:-}" if [[ -z "$container_name" ]]; then @@ -83,15 +130,15 @@ docker_git_start_playwright_browser() { context_dir="$(docker_git_browser_context_dir)" if [[ -z "$container_name" || -z "$image_name" || -z "$volume_name" || -z "$main_container" ]]; then - docker_git_browser_log "missing browser runtime configuration; skipping nested browser start" + docker_git_disable_playwright_mcp "missing browser runtime configuration" return 0 fi if ! docker_git_browser_has_docker; then - docker_git_browser_log "Docker API is unavailable; skipping nested browser start" + docker_git_disable_playwright_mcp "Docker API is unavailable" return 0 fi if [[ ! -f "$context_dir/Dockerfile.browser" ]]; then - docker_git_browser_log "browser Dockerfile is missing at $context_dir/Dockerfile.browser" + docker_git_disable_playwright_mcp "browser Dockerfile is missing at $context_dir/Dockerfile.browser" return 0 fi @@ -100,7 +147,7 @@ docker_git_start_playwright_browser() { local build_log if ! build_log="$(mktemp "\${TMPDIR:-/tmp}/docker-git-browser-build.XXXXXX.log" 2>/dev/null)"; then - docker_git_browser_log "failed to create browser build log; skipping nested browser start" + docker_git_disable_playwright_mcp "failed to create browser build log" return 0 fi docker_git_browser_register_temp_file "$build_log" @@ -113,6 +160,7 @@ docker_git_start_playwright_browser() { docker_git_browser_log "browser image build failed or timed out after \${build_timeout}s; output follows" cat "$build_log" >&2 || true docker_git_browser_log "browser image build log path before cleanup: $build_log" + docker_git_disable_playwright_mcp "browser image build failed" return 0 } rm -f -- "$build_log" @@ -144,7 +192,12 @@ docker_git_start_playwright_browser() { docker_git_browser_log "starting $container_name inside $main_container network namespace" docker "\${args[@]}" "$image_name" >/dev/null || { - docker_git_browser_log "failed to start $container_name" + docker_git_disable_playwright_mcp "failed to start $container_name" + return 0 + } + + docker_git_wait_for_playwright_cdp || { + docker_git_disable_playwright_mcp "nested browser started but CDP is unavailable" return 0 } } diff --git a/packages/app/src/lib/usecases/mcp-playwright.ts b/packages/app/src/lib/usecases/mcp-playwright.ts index 49e0c696..cd3bff36 100644 --- a/packages/app/src/lib/usecases/mcp-playwright.ts +++ b/packages/app/src/lib/usecases/mcp-playwright.ts @@ -29,7 +29,7 @@ const enableInTemplate = (template: TemplateConfig): TemplateConfig => ({ }) // CHANGE: enable Playwright MCP in an existing docker-git project directory (files only) -// WHY: allow adding the browser sidecar + MCP server config without wiping env or volumes +// WHY: allow adding the nested browser runtime + MCP server config without wiping env or volumes // QUOTE(ТЗ): "Добавить возможность поднимать MCP Playrgiht в контейнере который уже создан" // REF: issue-29 // SOURCE: n/a diff --git a/packages/app/src/web/app-ready-terminal-screen.tsx b/packages/app/src/web/app-ready-terminal-screen.tsx index 225ed982..98fae077 100644 --- a/packages/app/src/web/app-ready-terminal-screen.tsx +++ b/packages/app/src/web/app-ready-terminal-screen.tsx @@ -7,9 +7,9 @@ import type { ReadyLayoutProps } from "./app-ready-layout.js" import { Box, Text } from "./elements.js" import { TaskPanel } from "./panel-tasks.js" import { TerminalPanel } from "./panel-terminal.js" -import type { TerminalExitInfo } from "./terminal-panel-runtime-types.js" import { type BrowserScreen, projectPickerScreen } from "./screen.js" import { shouldShowTerminalTabs } from "./terminal-mobile-layout.js" +import type { TerminalExitInfo } from "./terminal-panel-runtime-types.js" import { terminalSessionId } from "./terminal-state.js" import { type ActiveTerminalSession, isPendingActiveTerminalSession, terminalTitleById } from "./terminal.js" @@ -521,6 +521,7 @@ export const TerminalScreen = (props: TerminalScreenProps): JSX.Element | null = { setTerminalView("terminal") }} diff --git a/packages/app/src/web/panel-terminal.tsx b/packages/app/src/web/panel-terminal.tsx index d3d584f9..8bbbb0c2 100644 --- a/packages/app/src/web/panel-terminal.tsx +++ b/packages/app/src/web/panel-terminal.tsx @@ -3,7 +3,6 @@ import "xterm/css/xterm.css" import { type CSSProperties, type JSX, useCallback, useEffect, useRef, useState } from "react" import { - type TerminalExitInfo, isModifierOnlyTerminalKey, type MobileTerminalKey, mobileTerminalKeyInput, @@ -12,6 +11,7 @@ import { import { resolveTerminalCompactHeaderMode, resolveTerminalTypingMode } from "./terminal-mobile-layout.js" import { type TerminalConnectionState, + type TerminalExitInfo, type TerminalInputController, type TerminalStatus, useTerminalSessionLifecycle diff --git a/packages/app/src/web/terminal-panel-runtime.ts b/packages/app/src/web/terminal-panel-runtime.ts index 365e989d..00397c87 100644 --- a/packages/app/src/web/terminal-panel-runtime.ts +++ b/packages/app/src/web/terminal-panel-runtime.ts @@ -240,8 +240,8 @@ export const useTerminalSessionLifecycle = ( } export { - type TerminalExitInfo, type TerminalConnectionState, + type TerminalExitInfo, type TerminalInputController, type TerminalStatus } from "./terminal-panel-runtime-types.js" diff --git a/packages/app/test-adapters/core-templates.ts b/packages/app/test-adapters/core-templates.ts new file mode 100644 index 00000000..68ba3db3 --- /dev/null +++ b/packages/app/test-adapters/core-templates.ts @@ -0,0 +1,2 @@ +export { defaultTemplateConfig, type TemplateConfig } from "../src/lib/core/domain.js" +export { planFiles } from "../src/lib/core/templates.js" diff --git a/packages/app/tests/docker-git/core-templates.test.ts b/packages/app/tests/docker-git/core-templates.test.ts index d5d31e7b..88d02262 100644 --- a/packages/app/tests/docker-git/core-templates.test.ts +++ b/packages/app/tests/docker-git/core-templates.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "@effect/vitest" -import { defaultTemplateConfig, type TemplateConfig } from "../../src/lib/core/domain.js" -import { planFiles } from "../../src/lib/core/templates.js" +import { defaultTemplateConfig, planFiles, type TemplateConfig } from "../../test-adapters/core-templates.js" const makeTemplateConfig = (overrides: Partial = {}): TemplateConfig => ({ ...defaultTemplateConfig, @@ -26,21 +25,45 @@ const makeTemplateConfig = (overrides: Partial = {}): TemplateCo ...overrides }) +type PlannedFile = ReturnType[number] +type GeneratedFile = Extract + +const getGeneratedFile = (files: ReadonlyArray, relativePath: string): GeneratedFile => { + const file = files.find( + (candidate): candidate is GeneratedFile => candidate._tag === "File" && candidate.relativePath === relativePath + ) + if (file === undefined) { + throw new Error(`Missing generated file: ${relativePath}`) + } + return file +} + +const getGeneratedFilePaths = (files: ReadonlyArray): ReadonlyArray => + files.flatMap((file) => file._tag === "File" ? [file.relativePath] : []) + describe("app planFiles", () => { it("includes nested browser runtime artifacts when Playwright is enabled", () => { const files = planFiles(makeTemplateConfig({ enableMcpPlaywright: true })) - const filePaths = files.flatMap((file) => file._tag === "File" ? [file.relativePath] : []) - const runtime = files.find( - (file): file is Extract<(typeof files)[number], { readonly _tag: "File" }> => - file._tag === "File" && file.relativePath === "docker-git-browser-runtime.sh" - ) + const filePaths = getGeneratedFilePaths(files) + const runtime = getGeneratedFile(files, "docker-git-browser-runtime.sh") + const dockerfile = getGeneratedFile(files, "Dockerfile") expect(filePaths).toContain("Dockerfile.browser") expect(filePaths).toContain("mcp-playwright-start-extra.sh") expect(filePaths).toContain("docker-git-browser-runtime.sh") - expect(runtime).toBeDefined() - expect(runtime?.mode).toBe(0o755) - expect(runtime?.contents).toContain('if [[ "${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then') - expect(runtime?.contents).not.toContain('\\${MCP_PLAYWRIGHT_ENABLE:-0}') + expect(runtime.mode).toBe(0o755) + expect(runtime.contents).toContain("if [[ \"${MCP_PLAYWRIGHT_ENABLE:-0}\" != \"1\" ]]; then") + expect(runtime.contents).toContain("docker_git_wait_for_playwright_cdp()") + expect(runtime.contents).toContain("MCP_PLAYWRIGHT_ENABLE=0") + expect(runtime.contents).not.toContain("\\${MCP_PLAYWRIGHT_ENABLE:-0}") + expect(dockerfile.contents).toContain( + "COPY Dockerfile.browser mcp-playwright-start-extra.sh docker-git-browser-runtime.sh /opt/docker-git/browser/" + ) + expect(dockerfile.contents).toContain("ARG PLAYWRIGHT_MCP_VERSION=0.0.75") + expect(dockerfile.contents).toContain("RUN npm install -g \"@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}\"") + expect(dockerfile.contents).toContain("MCP_PLAYWRIGHT_CDP_TIMEOUT=\"${MCP_PLAYWRIGHT_CDP_TIMEOUT:-60000}\"") + expect(runtime.contents).toContain("invalid MCP_PLAYWRIGHT_READY_ATTEMPTS") + expect(runtime.contents).toContain("while (( attempt <= attempts )); do") + expect(runtime.contents).not.toContain("for attempt in $(seq 1 \"$attempts\")") }) }) diff --git a/packages/lib/src/core/templates-entrypoint.ts b/packages/lib/src/core/templates-entrypoint.ts index b0fa1afb..e3dcbab4 100644 --- a/packages/lib/src/core/templates-entrypoint.ts +++ b/packages/lib/src/core/templates-entrypoint.ts @@ -47,7 +47,6 @@ export const renderEntrypoint = (config: TemplateConfig): string => renderEntrypointCodexHome(config), renderEntrypointCodexSharedAuth(config), renderEntrypointOpenCodeConfig(config), - renderEntrypointMcpPlaywright(config), renderEntrypointZshShell(config), renderEntrypointZshUserRc(config), renderEntrypointPrompt(), @@ -61,6 +60,7 @@ export const renderEntrypoint = (config: TemplateConfig): string => renderEntrypointAgentsNotice(config), renderEntrypointDockerSocket(config), renderEntrypointPlaywrightBrowserRuntime(config), + renderEntrypointMcpPlaywright(config), renderEntrypointGitConfig(config), renderEntrypointClaudeConfig(config), renderEntrypointGeminiConfig(config), diff --git a/packages/lib/src/core/templates-entrypoint/claude.ts b/packages/lib/src/core/templates-entrypoint/claude.ts index 88be1107..25b7f84d 100644 --- a/packages/lib/src/core/templates-entrypoint/claude.ts +++ b/packages/lib/src/core/templates-entrypoint/claude.ts @@ -192,7 +192,7 @@ const renderClaudeMcpPlaywrightConfig = (): string => String.raw`# Claude Code: keep Playwright MCP config in sync with container settings CLAUDE_SETTINGS_FILE="${"$"}{CLAUDE_HOME_JSON:-$CLAUDE_CONFIG_DIR/.claude.json}" docker_git_sync_claude_playwright_mcp() { - CLAUDE_SETTINGS_FILE="$CLAUDE_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="$MCP_PLAYWRIGHT_ENABLE" node - <<'NODE' + CLAUDE_SETTINGS_FILE="$CLAUDE_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" node - <<'NODE' const fs = require("node:fs") const path = require("node:path") diff --git a/packages/lib/src/core/templates-entrypoint/codex.ts b/packages/lib/src/core/templates-entrypoint/codex.ts index 479c8a5d..71c73ce3 100644 --- a/packages/lib/src/core/templates-entrypoint/codex.ts +++ b/packages/lib/src/core/templates-entrypoint/codex.ts @@ -98,7 +98,7 @@ EOF fi if [[ -z "$MCP_PLAYWRIGHT_CDP_ENDPOINT" ]]; then - MCP_PLAYWRIGHT_CDP_ENDPOINT="http://__SERVICE_NAME__-browser:9223" + MCP_PLAYWRIGHT_CDP_ENDPOINT="http://127.0.0.1:9223" fi # Replace the docker-git Playwright block to allow upgrades via --force without manual edits. diff --git a/packages/lib/src/core/templates-entrypoint/gemini.ts b/packages/lib/src/core/templates-entrypoint/gemini.ts index f6da35cd..f41fba74 100644 --- a/packages/lib/src/core/templates-entrypoint/gemini.ts +++ b/packages/lib/src/core/templates-entrypoint/gemini.ts @@ -159,13 +159,6 @@ const geminiSettingsJsonTemplate = `{ "selectedType": "oauth-personal" }, "disableYoloMode": false - }, - "mcpServers": { - "playwright": { - "command": "docker-git-playwright-mcp", - "args": [], - "trust": true - } } }` @@ -203,10 +196,40 @@ if [[ -d /etc/sudoers.d ]]; then chmod 0440 /etc/sudoers.d/gemini-agent fi` -const renderGeminiMcpPlaywrightConfig = (_config: TemplateConfig): string => - String.raw`# Gemini CLI: keep Playwright MCP config in sync (TODO: Gemini CLI MCP integration format) -# For now, Gemini CLI uses MCP via ~/.gemini/settings.json or command line. -# We'll ensure it has the same Playwright capability as Claude/Codex once format is confirmed.` +const renderGeminiMcpPlaywrightConfig = (): string => + String.raw`# Gemini CLI: keep Playwright MCP config in sync with container settings +docker_git_sync_gemini_playwright_mcp() { + GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") +const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + if (isRecord(parsed)) settings = parsed +} catch {} + +const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } +if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") { + nextServers.playwright = { command: "docker-git-playwright-mcp", args: [], trust: true } +} else { + delete nextServers.playwright +} + +const nextSettings = { ...settings } +Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_gemini_playwright_mcp` const renderGeminiProfileSetup = (config: TemplateConfig): string => String.raw`GEMINI_PROFILE="/etc/profile.d/gemini-config.sh" @@ -310,7 +333,7 @@ export const renderEntrypointGeminiConfig = (config: TemplateConfig): string => [ renderGeminiAuthConfig(config), renderGeminiPermissionSettingsConfig(config), - renderGeminiMcpPlaywrightConfig(config), + renderGeminiMcpPlaywrightConfig(), renderGeminiSudoConfig(config), renderGeminiProfileSetup(config), renderEntrypointGeminiNotice(config) diff --git a/packages/lib/src/core/templates-entrypoint/grok.ts b/packages/lib/src/core/templates-entrypoint/grok.ts index e75543d4..d297b5af 100644 --- a/packages/lib/src/core/templates-entrypoint/grok.ts +++ b/packages/lib/src/core/templates-entrypoint/grok.ts @@ -150,14 +150,7 @@ const renderGrokAuthConfig = (config: TemplateConfig): string => const grokSettingsJsonTemplate = `{ "sandboxMode": "off", - "confirmBeforeToolUse": false, - "mcpServers": { - "playwright": { - "command": "docker-git-playwright-mcp", - "args": [], - "trust": true - } - } + "confirmBeforeToolUse": false }` const grokUserSettingsJsonTemplate = `{ @@ -188,6 +181,41 @@ GROK_SETTINGS_OWNER_GID="$(id -g "${config.sshUser}" 2>/dev/null || id -g)" chown -R "$GROK_SETTINGS_OWNER_UID:$GROK_SETTINGS_OWNER_GID" "$GROK_SETTINGS_DIR" || true chmod 0600 "$GROK_CONFIG_SETTINGS_FILE" "$GROK_USER_SETTINGS_FILE" 2>/dev/null || true` +const renderGrokMcpPlaywrightConfig = (): string => + String.raw`# Grok CLI: keep Playwright MCP config in sync with container settings +docker_git_sync_grok_playwright_mcp() { + GROK_CONFIG_SETTINGS_FILE="$GROK_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") +const settingsPath = process.env.GROK_CONFIG_SETTINGS_FILE +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + if (isRecord(parsed)) settings = parsed +} catch {} + +const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } +if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") { + nextServers.playwright = { command: "docker-git-playwright-mcp", args: [], trust: true } +} else { + delete nextServers.playwright +} + +const nextSettings = { ...settings } +Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_grok_playwright_mcp` + const renderGrokSudoConfig = (config: TemplateConfig): string => String.raw`# Grok CLI: allow passwordless sudo for agent tasks # Risk rationale: Grok runs inside an isolated per-project container. The sshUser @@ -307,6 +335,7 @@ export const renderEntrypointGrokConfig = (config: TemplateConfig): string => [ renderGrokAuthConfig(config), renderGrokPermissionSettingsConfig(config), + renderGrokMcpPlaywrightConfig(), renderGrokSudoConfig(config), renderGrokProfileSetup(config), renderEntrypointGrokNotice(config) diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 3079edbe..929b8665 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -153,9 +153,10 @@ RUN set -eu; \ const dockerGitSessionSyncPackage = "@prover-coder-ai/docker-git-session-sync@latest" -const dockerfilePlaywrightMcpBlock = String.raw`RUN npm install -g @playwright/mcp@latest +const dockerfilePlaywrightMcpBlock = String.raw`ARG PLAYWRIGHT_MCP_VERSION=0.0.75 +RUN npm install -g "@playwright/mcp@${"$"}{PLAYWRIGHT_MCP_VERSION}" -# docker-git: wrapper that waits for the guarded CDP endpoint before launching Playwright MCP. +# docker-git: wrapper that launches the MCP stdio server without blocking initialize on CDP readiness. RUN cat <<'EOF' > /usr/local/bin/docker-git-playwright-mcp #!/usr/bin/env bash set -euo pipefail @@ -174,19 +175,33 @@ if [[ -z "$CDP_ENDPOINT" ]]; then CDP_ENDPOINT="http://127.0.0.1:9223" fi -# CHANGE: add retry logic for nested browser runtime startup wait -# WHY: the browser container may take time to initialize, causing MCP server to fail on first attempt -# QUOTE(issue-123): "Почему MCP сервер лежит с ошибкой?" -# REF: issue-123 -# SOURCE: n/a -# FORMAT THEOREM: forall t in [1..max_attempts]: retry(t) -> eventually(cdp_ready) OR timeout_error +# CHANGE: keep MCP initialize independent from nested browser readiness +# WHY: Codex starts MCP servers during boot; blocking here closes stdio before initialize when CDP is slow. +# QUOTE(issue-319): "handshaking with MCP server failed: connection closed: initialize response" +# REF: issue-319 +# SOURCE: https://playwright.dev/mcp/configuration/options +# FORMAT THEOREM: guarded_cdp(endpoint) -> mcp_stdio_ready_before_browser_connection # PURITY: SHELL -# INVARIANT: script exits only after cdp_ready OR all retries exhausted -# COMPLEXITY: O(max_attempts * timeout_per_attempt) +# INVARIANT: guarded mode never exits before handing stdio to playwright-mcp +# COMPLEXITY: O(1) MCP_PLAYWRIGHT_RETRY_ATTEMPTS="\${MCP_PLAYWRIGHT_RETRY_ATTEMPTS:-10}" MCP_PLAYWRIGHT_RETRY_DELAY="\${MCP_PLAYWRIGHT_RETRY_DELAY:-2}" MCP_PLAYWRIGHT_CDP_GUARD="\${MCP_PLAYWRIGHT_CDP_GUARD:-1}" +MCP_PLAYWRIGHT_CDP_TIMEOUT="\${MCP_PLAYWRIGHT_CDP_TIMEOUT:-60000}" +EXTRA_ARGS=() +if [[ "\${MCP_PLAYWRIGHT_ISOLATED:-0}" == "1" ]]; then + EXTRA_ARGS+=(--isolated) +fi + +# Guarded endpoints are stable HTTP CDP endpoints. Passing the HTTP URL lets Playwright MCP +# re-resolve /json/version instead of pinning itself to one stale /devtools/browser/. +if [[ "$MCP_PLAYWRIGHT_CDP_GUARD" == "1" ]]; then + exec playwright-mcp --cdp-endpoint "$CDP_ENDPOINT" --cdp-timeout "$MCP_PLAYWRIGHT_CDP_TIMEOUT" "\${EXTRA_ARGS[@]}" "$@" +fi + +# kechangdev/browser-vnc binds Chromium CDP on 127.0.0.1:9222; it also host-checks HTTP requests. +# When the guard is disabled, preserve the old behavior by converting the HTTP endpoint to WS. fetch_cdp_version() { curl -sSf --connect-timeout 3 --max-time 10 -H 'Host: 127.0.0.1:9222' "\${CDP_ENDPOINT%/}/json/version" 2>/dev/null } @@ -207,19 +222,6 @@ if [[ -z "$JSON" ]]; then exit 1 fi -EXTRA_ARGS=() -if [[ "\${MCP_PLAYWRIGHT_ISOLATED:-0}" == "1" ]]; then - EXTRA_ARGS+=(--isolated) -fi - -# Guarded endpoints are stable HTTP CDP endpoints. Passing the HTTP URL lets Playwright MCP -# re-resolve /json/version instead of pinning itself to one stale /devtools/browser/. -if [[ "$MCP_PLAYWRIGHT_CDP_GUARD" == "1" ]]; then - exec playwright-mcp --cdp-endpoint "$CDP_ENDPOINT" "\${EXTRA_ARGS[@]}" "$@" -fi - -# kechangdev/browser-vnc binds Chromium CDP on 127.0.0.1:9222; it also host-checks HTTP requests. -# When the guard is disabled, preserve the old behavior by converting the HTTP endpoint to WS. WS_URL="$(printf "%s" "$JSON" | node -e 'const fs=require("fs"); const j=JSON.parse(fs.readFileSync(0,"utf8")); process.stdout.write(j.webSocketDebuggerUrl || "")')" if [[ -z "$WS_URL" ]]; then echo "docker-git-playwright-mcp: webSocketDebuggerUrl missing" >&2 @@ -230,7 +232,7 @@ fi 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)')" 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())')" -exec playwright-mcp --cdp-endpoint "$WS_REWRITTEN" "\${EXTRA_ARGS[@]}" "$@" +exec playwright-mcp --cdp-endpoint "$WS_REWRITTEN" --cdp-timeout "$MCP_PLAYWRIGHT_CDP_TIMEOUT" "\${EXTRA_ARGS[@]}" "$@" EOF RUN chmod +x /usr/local/bin/docker-git-playwright-mcp` diff --git a/packages/lib/src/core/templates/playwright-browser-runtime.ts b/packages/lib/src/core/templates/playwright-browser-runtime.ts index 6f63af46..6ebca0e9 100644 --- a/packages/lib/src/core/templates/playwright-browser-runtime.ts +++ b/packages/lib/src/core/templates/playwright-browser-runtime.ts @@ -26,6 +26,53 @@ docker_git_browser_context_dir() { printf '%s\\n' "\${DOCKER_GIT_BROWSER_CONTEXT_DIR:-/opt/docker-git/browser}" } +docker_git_disable_playwright_mcp() { + docker_git_browser_log "$1; disabling Playwright MCP for this container start" + MCP_PLAYWRIGHT_ENABLE=0 + export MCP_PLAYWRIGHT_ENABLE +} + +docker_git_playwright_cdp_endpoint() { + printf '%s\\n' "\${MCP_PLAYWRIGHT_CDP_ENDPOINT:-http://127.0.0.1:9223}" +} + +docker_git_fetch_playwright_cdp_version() { + local endpoint + endpoint="$(docker_git_playwright_cdp_endpoint)" + curl -sSf --connect-timeout 3 --max-time 10 -H 'Host: 127.0.0.1:9222' "\${endpoint%/}/json/version" >/dev/null 2>&1 +} + +docker_git_wait_for_playwright_cdp() { + local attempts="\${MCP_PLAYWRIGHT_READY_ATTEMPTS:-60}" + local delay="\${MCP_PLAYWRIGHT_READY_DELAY:-1}" + local endpoint + endpoint="$(docker_git_playwright_cdp_endpoint)" + if [[ ! "$attempts" =~ ^[0-9]+$ ]] || (( attempts < 1 )); then + docker_git_browser_log "invalid MCP_PLAYWRIGHT_READY_ATTEMPTS=$attempts; using 60" + attempts=60 + fi + if [[ ! "$delay" =~ ^[0-9]+$ ]]; then + docker_git_browser_log "invalid MCP_PLAYWRIGHT_READY_DELAY=$delay; using 1" + delay=1 + fi + + local attempt=1 + while (( attempt <= attempts )); do + if docker_git_fetch_playwright_cdp_version; then + docker_git_browser_log "CDP endpoint is ready: $endpoint" + return 0 + fi + if (( attempt < attempts )); then + docker_git_browser_log "waiting for CDP endpoint $endpoint (attempt $attempt/$attempts)" + sleep "$delay" + fi + attempt=$((attempt + 1)) + done + + docker_git_browser_log "CDP endpoint did not become ready: $endpoint" + return 1 +} + docker_git_stop_playwright_browser() { local container_name="\${DOCKER_GIT_BROWSER_CONTAINER_NAME:-}" if [[ -z "$container_name" ]]; then @@ -82,15 +129,15 @@ docker_git_start_playwright_browser() { context_dir="$(docker_git_browser_context_dir)" if [[ -z "$container_name" || -z "$image_name" || -z "$volume_name" || -z "$main_container" ]]; then - docker_git_browser_log "missing browser runtime configuration; skipping nested browser start" + docker_git_disable_playwright_mcp "missing browser runtime configuration" return 0 fi if ! docker_git_browser_has_docker; then - docker_git_browser_log "Docker API is unavailable; skipping nested browser start" + docker_git_disable_playwright_mcp "Docker API is unavailable" return 0 fi if [[ ! -f "$context_dir/Dockerfile.browser" ]]; then - docker_git_browser_log "browser Dockerfile is missing at $context_dir/Dockerfile.browser" + docker_git_disable_playwright_mcp "browser Dockerfile is missing at $context_dir/Dockerfile.browser" return 0 fi @@ -99,7 +146,7 @@ docker_git_start_playwright_browser() { local build_log if ! build_log="$(mktemp "\${TMPDIR:-/tmp}/docker-git-browser-build.XXXXXX.log" 2>/dev/null)"; then - docker_git_browser_log "failed to create browser build log; skipping nested browser start" + docker_git_disable_playwright_mcp "failed to create browser build log" return 0 fi docker_git_browser_register_temp_file "$build_log" @@ -112,6 +159,7 @@ docker_git_start_playwright_browser() { docker_git_browser_log "browser image build failed or timed out after \${build_timeout}s; output follows" cat "$build_log" >&2 || true docker_git_browser_log "browser image build log path before cleanup: $build_log" + docker_git_disable_playwright_mcp "browser image build failed" return 0 } rm -f -- "$build_log" @@ -143,7 +191,12 @@ docker_git_start_playwright_browser() { docker_git_browser_log "starting $container_name inside $main_container network namespace" docker "\${args[@]}" "$image_name" >/dev/null || { - docker_git_browser_log "failed to start $container_name" + docker_git_disable_playwright_mcp "failed to start $container_name" + return 0 + } + + docker_git_wait_for_playwright_cdp || { + docker_git_disable_playwright_mcp "nested browser started but CDP is unavailable" return 0 } } diff --git a/packages/lib/src/usecases/auth-grok-oauth.ts b/packages/lib/src/usecases/auth-grok-oauth.ts index 16d9db55..34d62984 100644 --- a/packages/lib/src/usecases/auth-grok-oauth.ts +++ b/packages/lib/src/usecases/auth-grok-oauth.ts @@ -6,8 +6,9 @@ import { runCommandWithExitCodes } from "../shell/command-runner.js" import { resolveDockerVolumeHostPath } from "../shell/docker-auth.js" import { AuthError, CommandFailedError } from "../shell/errors.js" -// CHANGE: run the standard Grok CLI browser login flow inside the auth container -// WHY: `docker-git auth grok login` should behave like other interactive CLI logins +// CHANGE: add Grok CLI OAuth/browser authentication flow +// WHY: issue #304 expects `grok login` style URL handoff and callback paste support +// QUOTE(ТЗ): "Paste the URL here if it doesn't connect" // REF: issue-304 // SOURCE: https://x.ai/news/grok-build-cli // FORMAT THEOREM: forall cmd: runGrokOauthLogin(cmd) -> grok_credentials_stored | error @@ -41,15 +42,15 @@ const buildDockerGrokAuthSpec = ( }) /** - * Builds the Docker CLI argument vector for the standard interactive Grok login flow. + * Builds the Docker CLI argument vector for the official Grok device-code login flow. * * @param spec Docker auth container paths, image, working directory, and environment bindings. - * @returns Immutable Docker argument vector ending with `grok login`. + * @returns Immutable Docker argument vector ending with `grok login --device-auth`. * @pure true * @effect none; CORE argument builder only transforms immutable input data. * @invariant every non-empty environment binding is emitted as an adjacent `-e` argument pair. * @precondition spec.hostPath and spec.containerPath identify the selected Grok auth account directory. - * @postcondition returned args execute the standard Grok CLI browser login flow. + * @postcondition returned args execute the official headless Grok login mode documented by xAI. * @complexity O(n) time / O(n) space, where n is spec.env.length. * @throws Never - invalid process execution is represented by callers through typed Effect errors. */ @@ -73,9 +74,19 @@ export const buildDockerGrokAuthArgs = (spec: DockerGrokAuthSpec): ReadonlyArray } base.push("-e", trimmed) } - return [...base, spec.image, "grok", "login"] + return [...base, spec.image, "grok", "login", "--device-auth"] } +const printOauthInstructions = (): Effect.Effect => + Effect.sync(() => { + process.stderr.write("\n") + process.stderr.write("Grok CLI OAuth Authentication\n") + process.stderr.write("1. Open the Grok sign-in URL printed by the CLI.\n") + process.stderr.write("2. Complete browser authentication.\n") + process.stderr.write("3. If the callback cannot connect, paste the returned URL into the prompt.\n") + process.stderr.write("\n") + }) + const grokAuthPermissionScript = [ "target_uid=\"${CHOWN_UID:-$(stat -c %u \"$1\" 2>/dev/null || id -u)}\"", "target_gid=\"${CHOWN_GID:-$(stat -c %g \"$1\" 2>/dev/null || id -g)}\"", @@ -109,7 +120,7 @@ const fixGrokAuthPermissions = (cwd: string, hostPath: string, containerPath: st ) /** - * Runs the standard interactive Grok login inside the docker-git auth container. + * Runs the Grok OAuth device login inside the docker-git auth container. * * @param cwd Working directory used for Docker command execution. * @param accountPath Selected docker-git Grok account directory. @@ -132,6 +143,7 @@ export const runGrokOauthLoginWithPrompt = ( } ): Effect.Effect => Effect.gen(function*(_) { + yield* _(printOauthInstructions()) const hostPath = yield* _(resolveDockerVolumeHostPath(cwd, accountPath)) const spec = buildDockerGrokAuthSpec(cwd, hostPath, options.image, options.containerPath) yield* _( diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 6c34a241..c8da9828 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -229,6 +229,20 @@ describe("renderDockerfile", () => { expect(dockerfile).not.toContain("npm install -g grok-dev") expect(dockerfile).not.toContain("grok --version >/dev/null || true") }) + + it("renders Playwright MCP without blocking stdio initialization on CDP readiness", () => { + const dockerfile = renderDockerfile(makeTemplateConfig({ enableMcpPlaywright: true })) + const guardedExecIndex = dockerfile.indexOf('if [[ "$MCP_PLAYWRIGHT_CDP_GUARD" == "1" ]]; then') + const fetchIndex = dockerfile.indexOf("fetch_cdp_version()") + + expectContainsAll(dockerfile, [ + 'MCP_PLAYWRIGHT_CDP_TIMEOUT="${MCP_PLAYWRIGHT_CDP_TIMEOUT:-60000}"', + 'exec playwright-mcp --cdp-endpoint "$CDP_ENDPOINT" --cdp-timeout "$MCP_PLAYWRIGHT_CDP_TIMEOUT"', + 'exec playwright-mcp --cdp-endpoint "$WS_REWRITTEN" --cdp-timeout "$MCP_PLAYWRIGHT_CDP_TIMEOUT"' + ]) + expect(guardedExecIndex).toBeGreaterThanOrEqual(0) + expect(fetchIndex).toBeGreaterThan(guardedExecIndex) + }) }) describe("renderPromptScript", () => { @@ -435,6 +449,9 @@ describe("renderEntrypoint auth bridge", () => { "docker_git_detect_claude_project_rules()", "docker_git_detect_gemini_project_rules()", "docker_git_detect_grok_project_rules()", + "docker_git_sync_gemini_playwright_mcp()", + "docker_git_sync_grok_playwright_mcp()", + 'MCP_PLAYWRIGHT_ENABLE="${MCP_PLAYWRIGHT_ENABLE:-0}" node', "DOCKER_GIT_RTK_ENABLE=\"${DOCKER_GIT_RTK_ENABLE:-1}\"", "DOCKER_GIT_RTK_ENABLE=1", "docker_git_rtk_init_as_user()", @@ -721,6 +738,15 @@ describe("renderDockerCompose", () => { expect(runtime).not.toContain('mktemp "\\${TMPDIR:-/tmp}/docker-git-browser-build.XXXXXX.log"') expect(runtime).toContain('docker "${args[@]}" "$image_name" >/dev/null || {') expect(runtime).not.toContain('docker "\\${args[@]}" "$image_name" >/dev/null || {') + expect(runtime).toContain("docker_git_wait_for_playwright_cdp()") + expect(runtime).toContain('local attempts="${MCP_PLAYWRIGHT_READY_ATTEMPTS:-60}"') + expect(runtime).toContain('local delay="${MCP_PLAYWRIGHT_READY_DELAY:-1}"') + expect(runtime).toContain("invalid MCP_PLAYWRIGHT_READY_ATTEMPTS") + expect(runtime).toContain("invalid MCP_PLAYWRIGHT_READY_DELAY") + expect(runtime).toContain("while (( attempt <= attempts )); do") + expect(runtime).not.toContain('for attempt in $(seq 1 "$attempts")') + expect(runtime).toContain("MCP_PLAYWRIGHT_ENABLE=0") + expect(runtime).toContain('docker_git_disable_playwright_mcp "nested browser started but CDP is unavailable"') }) it("plans nested browser runtime artifacts when Playwright is enabled", () => { @@ -737,6 +763,17 @@ describe("renderDockerCompose", () => { expect(runtime).toBeDefined() expect(runtime?.mode).toBe(0o755) expect(runtime?.contents).toContain('if [[ "${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then') + expect(runtime?.contents).toContain("docker_git_wait_for_playwright_cdp()") + expect(runtime?.contents).toContain("MCP_PLAYWRIGHT_ENABLE=0") + }) + + it("renders Playwright browser startup before MCP client config", () => { + const entrypoint = renderEntrypoint(makeTemplateConfig({ enableMcpPlaywright: true })) + const browserRuntimeIndex = entrypoint.indexOf("docker_git_start_playwright_browser") + const mcpConfigIndex = entrypoint.indexOf("[mcp_servers.playwright]") + + expect(browserRuntimeIndex).toBeGreaterThanOrEqual(0) + expect(mcpConfigIndex).toBeGreaterThan(browserRuntimeIndex) }) it("renders local Docker socket mount only when explicitly enabled", () => { diff --git a/packages/lib/tests/usecases/mcp-playwright.test.ts b/packages/lib/tests/usecases/mcp-playwright.test.ts index 2ad3d40a..fd803c45 100644 --- a/packages/lib/tests/usecases/mcp-playwright.test.ts +++ b/packages/lib/tests/usecases/mcp-playwright.test.ts @@ -125,19 +125,23 @@ describe("enableMcpPlaywrightProjectFiles", () => { expect(composeAfter).toContain(" - /var/run/docker.sock:/var/run/docker.sock") const dockerfileAfter = yield* _(fs.readFileString(path.join(outDir, "Dockerfile"))) - expect(dockerfileAfter).toContain("@playwright/mcp") + expect(dockerfileAfter).toContain("ARG PLAYWRIGHT_MCP_VERSION=0.0.75") + expect(dockerfileAfter).toContain('RUN npm install -g "@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}"') - // CHANGE: verify retry logic is included in docker-git-playwright-mcp wrapper - // WHY: issue-123 requires retry mechanism to handle nested browser startup delays - // QUOTE(issue-123): "Почему MCP сервер лежит с ошибкой?" - // REF: issue-123 + // CHANGE: verify lazy Playwright MCP startup and legacy guarded fallback wiring + // WHY: issue-319 requires MCP stdio initialize to answer even when CDP is still starting + // QUOTE(issue-319): "MCP startup failed: handshaking with MCP server failed" + // REF: issue-319 expect(dockerfileAfter).toContain("MCP_PLAYWRIGHT_RETRY_ATTEMPTS") expect(dockerfileAfter).toContain("MCP_PLAYWRIGHT_RETRY_DELAY") expect(dockerfileAfter).toContain("MCP_PLAYWRIGHT_CDP_GUARD") + expect(dockerfileAfter).toContain("MCP_PLAYWRIGHT_CDP_TIMEOUT") expect(dockerfileAfter).toContain('if [[ "${MCP_PLAYWRIGHT_ISOLATED:-0}" == "1" ]]; then') expect(dockerfileAfter).toContain("fetch_cdp_version()") expect(dockerfileAfter).toContain("waiting for nested browser runtime") - expect(dockerfileAfter).toContain('exec playwright-mcp --cdp-endpoint "$CDP_ENDPOINT"') + expect(dockerfileAfter).toContain( + 'exec playwright-mcp --cdp-endpoint "$CDP_ENDPOINT" --cdp-timeout "$MCP_PLAYWRIGHT_CDP_TIMEOUT"' + ) expect(dockerfileAfter).toContain( "COPY Dockerfile.browser mcp-playwright-start-extra.sh docker-git-browser-runtime.sh /opt/docker-git/browser/" ) @@ -168,6 +172,13 @@ describe("enableMcpPlaywrightProjectFiles", () => { expect(browserRuntime).toContain('DOCKER_GIT_BROWSER_BUILD_TIMEOUT_SECONDS:-600') expect(browserRuntime).toContain('timeout "$build_timeout" docker build') expect(browserRuntime).toContain('cat "$build_log" >&2 || true') + expect(browserRuntime).toContain("docker_git_wait_for_playwright_cdp()") + expect(browserRuntime).toContain('local attempts="${MCP_PLAYWRIGHT_READY_ATTEMPTS:-60}"') + expect(browserRuntime).toContain("invalid MCP_PLAYWRIGHT_READY_ATTEMPTS") + expect(browserRuntime).toContain("while (( attempt <= attempts )); do") + expect(browserRuntime).not.toContain('for attempt in $(seq 1 "$attempts")') + expect(browserRuntime).toContain("MCP_PLAYWRIGHT_ENABLE=0") + expect(browserRuntime).toContain('docker_git_disable_playwright_mcp "nested browser started but CDP is unavailable"') expect(browserRuntime).toContain('--filter "label=docker-git.browser=1" --filter "label=docker-git.project-container"') expect(browserRuntime).toContain('docker inspect --format \'{{ .State.Running }}\' "$project_container"') expect(browserRuntime).toContain('if ! docker volume create "$volume_name" >/dev/null 2>&1; then')