diff --git a/.hermes/plans/2026-05-23_092139-initial-formal-architecture-plan.md b/.hermes/plans/2026-05-23_092139-initial-formal-architecture-plan.md new file mode 100644 index 00000000..7c5feee7 --- /dev/null +++ b/.hermes/plans/2026-05-23_092139-initial-formal-architecture-plan.md @@ -0,0 +1,78 @@ +# Plan: Initial Formalization of Mathematician-Programmer Agent Role + +## Goal +Establish strict adherence to the formally verifiable functional architecture defined in AGENTS.md across all agent behaviors, code, and interactions in this workspace. Ensure every response and code artifact is the result of simulated professional discussion among roles (architect Effect/FP, type reviewer, CORE↔SHELL guardian, test engineer). All future work must follow the Deep Research loop, purity rules, Effect-TS monadic composition, mathematical invariants, and verification requirements. + +## Current Context / Assumptions +- Workspace: pnpm monorepo (/home/dev/app) with packages/lib (core domain, state-repo, git, SSH, tests) and packages/app. +- Project context file AGENTS.md fully loaded, defining the mathematician-programmer role, FCIS pattern, mandatory libraries (effect, @effect/schema), comment templates, conventional commits, and quality gates. +- User interaction so far limited to repeated Russian greetings ("ПРивет") and invocation of the `plan` skill + model switches (now grok-4.20-0309-reasoning). +- No specific feature request yet; task inferred as "activate and operationalize the formal role within the existing Hermes codebase". +- Existing code uses TypeScript but may contain imperative patterns, direct effects, or missing formal documentation that must be brought into compliance. +- Tools (terminal, file, search_files, etc.) available and must be used only through typed Effect Services in SHELL. + +## Proposed Approach +Adopt the Functional Core, Imperative Shell (FCIS) pattern strictly: +- CORE: pure functions, immutable data, mathematical operations, invariant checks, role-simulation logic. +- SHELL: all tool calls (write_file, terminal, search_files, skill_*, delegate_task, etc.), I/O, model interactions wrapped in Effect + Layers. +- Use @effect/schema for all boundary decoding. +- Encode AGENTS.md rules as types, branded types, and property-based tests. +- Create a central `FormalReasoning` service that forces every action through the required internal steps (Deep Research question → existing pattern search → formalization → code/tests → verification). +- Minimal changes first: add supporting types and a new core module, then enforce via lint rules/architecture tests. + +## Step-by-Step Plan +1. Inspect existing core files (domain.ts, auto-agent-flags.ts) using read-only tools to identify reuse opportunities (minimal correct diff principle). +2. Define new CORE types and pure functions: + - RoleSimulation (architect, reviewer, guardian, test-engineer). + - Invariant type and checker. + - `formalizeTask(description: string): Effect` (pure where possible). +3. Create SHELL Layer that provides typed wrappers for all available tools as Effect services (following the DatabaseService/HttpService example in AGENTS.md). +4. Implement the comment template enforcement as a ts-morph script or ESLint rule. +5. Add property-based tests for key invariants (purity, exhaustiveness, no `any`/casts outside axiomatic module). +6. Update main agent entrypoint to load the new FormalReasoning layer. +7. Write this plan file (only mutation allowed this turn). +8. In subsequent turns: implement, test, verify with `npm run lint`, `npm test`, architecture checks. + +## Files Likely to Change +- `packages/lib/src/core/domain.ts` (add formal types, invariants, role simulation) +- `packages/lib/src/core/formal-reasoning.ts` (new CORE module) +- `packages/lib/src/core/shell.ts` (new Layer definitions for tools) +- `packages/lib/tests/formal-verification/invariants.test.ts` (new) +- `.hermes/plans/*.md` (ongoing plans) +- `packages/lib/tests/usecases/...` (update existing tests to use Effect.provide and .effect) +- `tsconfig.json`, `pnpm-workspace.yaml` (if new packages needed) + +## Tests / Validation +- **Property-based**: `fc.assert(fc.property(taskArbitrary, (task) => isFormalReasoningCompliant(formalizeTask(task))))` +- **Unit**: Effect tests with Mock layers for all tools (`it.effect(...)` with `Effect.provide(MockTerminal)`) +- **Architecture**: Static checks for: + - No `any`, `as`, `ts-ignore`, `async/await`, `console.*` in CORE. + - All pattern matches use `Match.exhaustive`. + - CORE imports only pure modules. +- Run full suite: `npm run lint && npm test && npm run build` +- Verification command sequence (to be executed in future turns): + ```bash + npm run lint + npm test -- --grep="formal|invariant|effect" + grep -r "any\|as \|ts-ignore" packages/lib/src/core/ + ``` + +## Risks, Tradeoffs, and Open Questions +- **Risk**: Large-scale refactor of existing Hermes codebase could introduce regressions in SSH/git/state management features. Mitigate with incremental PRs + CI. +- **Tradeoff**: Extreme formalism increases correctness and maintainability at cost of development speed. Prioritize high-risk modules first (tool usage, delegation). +- **Open Questions**: + - How to mathematically model the "tool call XML format" and "mandatory tool use" rules as invariants? + - Should the plan skill itself be formalized as a pure `Plan` ADT with interpreter in SHELL? + - Handling of model switch notes and meta-instructions – treat as SHELL configuration? + - Exact mapping of "Deep Research" loop into Effect.gen() generator. +- **Assumption to validate**: Existing test files can be migrated to `it.effect()` without breaking. + +## Mathematical Guarantees (Proof Obligations) +- Invariant: ∀f ∈ CORE: isPure(f) ∧ preservesInvariants(f) +- ∀response: followsRoleSimulation(response) → contains(DeepResearchQuestion, response) +- Variant: complexity decreases with each research → implementation → verification iteration. + +**Next Action (post-plan)**: Load this plan, begin step 1 with read-only inspection, then move to implementation turn. + +SOURCE: n/a (directly derived from loaded AGENTS.md) +REF: AGENTS.md + plan skill invocation diff --git a/.hermes/plans/2026-05-23_092712-mcp-playwright-hermes-noVNC-integration.md b/.hermes/plans/2026-05-23_092712-mcp-playwright-hermes-noVNC-integration.md new file mode 100644 index 00000000..c5d8177b --- /dev/null +++ b/.hermes/plans/2026-05-23_092712-mcp-playwright-hermes-noVNC-integration.md @@ -0,0 +1,79 @@ +# Plan: MCP Playwright Integration for Hermes Agent (with noVNC compatibility) + +## Goal +Study existing MCP Playwright connection in the Codex/docker-git setup (as referenced in README.md and e2e scripts), then create a precise replication plan for the Hermes Agent. Ensure seamless integration with the project's noVNC browser infrastructure so that MCP tools (launch, navigate, screenshot, interact) can control or coexist with the noVNC-exposed Chromium instance. The end result should make Playwright MCP tools first-class in Hermes (prefixed mcp_playwright_*) while preserving the Functional Core / Imperative Shell invariants from AGENTS.md. + +## Current Context / Assumptions +- From read-only inspection (search_files for mcp|playwright|novnc|browser): + - README.md explicitly mentions `--mcp-playwright` flag that enables Playwright MCP + Chromium sidecar for browser automation. + - package.json has e2e:browser-command script and docker-git browser targets. + - docker-git clone command in README uses --mcp-playwright. + - No direct "hermes-browser" module found in top-level search, but browser toolset, CDP, and noVNC references exist in config and e2e scripts. + - Current ~/.hermes/config.yaml has no mcp_servers.playwright (or minimal from prior non-plan turns); native-mcp skill is available and documents exact YAML + hermes mcp add workflow. + - Codex integration likely lives in autonomous-ai-agents/codex or related docker-git patches/scripts (e2e/browser-command.sh, scripts/skiller-apply-docker-git-patches.mjs). + - noVNC is part of the browser sidecar (common pattern for remote VNC access to the Playwright-controlled browser). +- Assumptions: Codex uses stdio transport via npx mcp-playwright (or equivalent bin mcp-server-playwright) with specific args for noVNC compatibility (headless=false, user-data-dir, cdp-endpoint, storage-state). Hermes can reuse the same MCP server config + Layer wrapping. The existing browser toolset (CDP/Camofox) can be composed with MCP. +- Deep Research question simulated: "code that connects MCP Playwright to Codex/Hermes with noVNC" → patterns found in README + docker-git + native-mcp skill. + +## Proposed Approach +- Reuse the exact MCP server definition from Codex/docker-git setup (npx -y mcp-playwright with flags for noVNC: --headless=false, --port for SSE, --user-data-dir shared with noVNC). +- Wrap via native-mcp client (config.yaml mcp_servers.playwright + hermes mcp add/test/configure). +- Create typed Effect Service Layer in CORE/SHELL boundary for mcp_playwright_* tools to maintain FCIS invariants. +- Add noVNC coordination (shared profile/storage-state, CDP endpoint sharing). +- Minimal diff: extend existing browser/e2e patterns rather than new from-scratch implementation. +- All changes follow AGENTS.md: pure CORE functions for config validation/invariants, SHELL for actual MCP connection, exhaustive Match, formal TSDoc with invariants, property-based tests. + +## Step-by-Step Plan +1. **Inspection Phase (read-only)**: + - Read full README.md, docker-git related scripts (scripts/e2e/browser-command.sh, patches, docker-git/frontend-lib), packages/lib/src/core for existing browser/MCP patterns. + - Read current ~/.hermes/config.yaml (mcp_servers, browser, terminal sections). + - Search for Codex-specific MCP config (in autonomous-ai-agents/codex or kanban-codex-lane skills). +2. **Formalization**: Define invariants (e.g. ∀ browser_session: connected_to_noVNC ∧ mcp_tools_available → coordinated_state). +3. **Architecture**: + - Add mcp_servers.playwright entry matching Codex (command + args for noVNC compatibility). + - Create Shell Layer (PostgresMessageRepository-style) for MCP Playwright service. + - Update tool registry to expose prefixed tools. +4. **noVNC Integration**: Ensure shared user-data-dir, CDP endpoint, or proxy so MCP controls the same browser instance exposed via noVNC. +5. **Implementation** (post-plan turn): Apply minimal diff to config + new core/shell modules. +6. **Verification**: Run hermes mcp test, e2e browser tests, architecture tests, property tests for invariants. + +## Files Likely to Change +- .hermes/config.yaml (or via hermes mcp add) — add mcp_servers.playwright matching Codex pattern. +- packages/lib/src/core/domain.ts or new packages/lib/src/core/mcp-playwright.ts (types, invariants, pure validators). +- packages/lib/src/core/shell/mcp-layers.ts (Effect Layer for Playwright MCP service). +- README.md or AGENTS.md (update integration notes). +- scripts/e2e/browser-command.sh or new test script for noVNC + MCP coordination. +- packages/lib/tests/usecases/mcp-playwright-integration.test.ts (new). +- packages/lib/tests/architecture/fcis-boundary.test.ts (update to cover new MCP Layer). + +## Tests / Validation +- **Property-based**: fc.assert on invariants (session shared between MCP and noVNC, no leaked effects in CORE). +- **Integration**: `hermes mcp test playwright`, e2e/browser-command.sh with --mcp-playwright flag, manual noVNC connection test. +- **Architecture**: lint + `npm test -- --grep="mcp|playwright|fcis|invariant"`, exhaustive pattern matching on tool results, no `any`/direct stdio in CORE. +- **Verification commands** (future turns): + - `hermes mcp list && hermes mcp test playwright` + - `npm run lint && npm test` + - Grep for forbidden patterns in new core files. + - Visual confirmation: noVNC shows the same browser controlled by MCP tools. + +## Risks, Tradeoffs, and Open Questions +- **Risk**: noVNC and MCP both trying to control the same browser instance → race conditions or session corruption. Mitigation: shared storage-state + CDP proxy. +- **Tradeoff**: Reusing Codex/docker-git pattern minimizes diff but may inherit its quirks (0.0.1 package version, specific flags). Pure Hermes-native Layer is cleaner but larger change. +- **Open Questions**: + - Exact args used in Codex/docker-git for noVNC (headless? port? allowed-origins? ) — needs deeper read of browser-command.sh and patches. + - Does "Hermes Browser" exist as a distinct skill/Layer or is it the existing browser toolset + CDP? + - How to formalize sampling (server-initiated LLM calls) from MCP Playwright in the Effect monad? + - Impact on existing browser toolset (CDP vs MCP overlap) — should one deprecate the other? +- **Assumption to validate in step 1**: The `--mcp-playwright` in docker-git directly translates to a stdio MCP server config usable by Hermes native client. + +## Mathematical Guarantees +- INVARIANT: ∀ session: (mcp_playwright_connected(session) ∧ noVNC_exposed(session)) → shared_user_data_dir(session) ∧ coordinated_cdp_endpoint(session) +- PRE: mcp package installed ∧ npx mcp-playwright available. +- POST: mcp_playwright_* tools registered and composable in Effect.gen() without breaking CORE purity. +- FORMAT THEOREM: ∀x ∈ BrowserSessions: connected_via_mcp(x) → controllable_via_novnc(x) + +**REF**: Current conversation (MCP Playwright + noVNC request), native-mcp skill, AGENTS.md, README.md mentions of --mcp-playwright. +**SOURCE**: n/a (project inspection via read-only search_files + read_file). +**PURITY**: This plan document is pure (no effects). + +Next turn (after this plan): Execute inspection steps with read-only tools, then implement per this plan. diff --git a/.hermes/plans/2026-05-23_095118-clean-builtin-browser-noVNC-no-mcp.md b/.hermes/plans/2026-05-23_095118-clean-builtin-browser-noVNC-no-mcp.md new file mode 100644 index 00000000..41cca59b --- /dev/null +++ b/.hermes/plans/2026-05-23_095118-clean-builtin-browser-noVNC-no-mcp.md @@ -0,0 +1,71 @@ +# Plan: Clean Built-in Hermes Browser + noVNC Integration (No MCP Duplication) + +## Goal +Remove all MCP Playwright references and duplication, making the built-in Hermes browser toolset the only browser backend. Configure it to connect directly to the existing `dg-docker-git-issue-347-browser` container (and similar per-issue containers) via CDP, ensuring a single unified browser session that is also accessible via noVNC. This follows the user's preference for the platform's out-of-the-box solution without duplicate tools. + +## Current Context / Assumptions +- From read-only inspection (search_files for cdp_url|noVNC|browser|playwright|mcp_servers): + - `packages/lib/src/core/templates-entrypoint/hermes.ts` currently has MCP logic from previous changes. + - `packages/api/src/services/project-browser.ts` handles CDP proxying (cdpUrl, cdpPath, renderExternalUrl) for browser containers. + - Docker containers like `dg-docker-git-issue-347-browser` expose ports 5900 (VNC), 6080 (noVNC), 9223 (CDP). + - Config has `browser.cdp_url` and `browser.engine = cdp` set to localhost:9223. + - MCP was removed (`hermes mcp remove playwright`), no MCP servers or tools remain. + - README and templates for codex/claude/gemini still reference MCP Playwright — these should be left alone or cleaned only for Hermes path to avoid breaking other agents. +- Assumption: The built-in browser tool can reliably use the CDP port of the per-issue browser container. noVNC is for viewing, CDP for control — single browser achieved via shared container. +- Deep Research question: "code that configures Hermes built-in browser with noVNC/CDP without MCP" → patterns in project-browser.ts, hermes.ts, and docker container names. + +## Proposed Approach +- Extend/clean `hermes.ts` template to always configure `browser.cdp_url` and `browser.engine = cdp` pointing to the project's browser container (using the same logic as project-browser.ts). +- Remove any remaining MCP-related code from Hermes path (idempotent, no breaking changes to other agents). +- Add formal invariants for single-browser guarantee. +- No new files — minimal diff to existing template and tests. +- Make CDP configuration part of the Hermes entrypoint render so it's automatic when --mcp-playwright is not used (or always for Hermes). + +## Step-by-Step Plan +1. Read-only inspection: re-read hermes.ts, project-browser.ts, current ~/.hermes/config.yaml, and docker ps output for exact container/CDP pattern. +2. Formalize invariants (single browser session, CDP connection succeeds, no MCP tools present). +3. Update `packages/lib/src/core/templates-entrypoint/hermes.ts`: + - Add render function for CDP/noVNC configuration (mirroring project-browser.ts cdpUrl logic). + - Remove any leftover MCP code. + - Include formal TSDoc comment block. +4. Update related test: `packages/lib/tests/usecases/...` or architecture test for template rendering. +5. Verification: render the template, check generated bash contains correct cdp_url, run lint/test on the file, confirm no MCP in final config. + +## Files Likely to Change +- `packages/lib/src/core/templates-entrypoint/hermes.ts` (main change — add CDP config render, remove MCP remnants). +- `packages/lib/tests/usecases/template-rendering.test.ts` or similar (update expected output for Hermes entrypoint). +- `packages/api/src/services/project-browser.ts` (if we need to expose more CDP helpers for Hermes — low probability). +- No changes to codex.ts, claude.ts, or MCP-related files (preserve other agents). + +## Tests / Validation +- **Unit**: Test `renderEntrypointHermesConfig` produces bash with `browser.cdp_url=http://localhost:9223` and `engine=cdp`. +- **Integration**: Render full entrypoint, run in test container, verify `hermes tools list` shows only built-in browser (no mcp_playwright_*). +- **Architecture**: Confirm no MCP imports/references in Hermes path, single-browser invariant holds (`cdp_url` matches container's 9223 port). +- **Verification commands** (future turns, read-only where possible): + - `hermes tools list | grep -E 'browser|mcp'` + - `docker ps | grep browser` + - `npm run lint -- packages/lib/src/core/templates-entrypoint/hermes.ts` + - `npm test -- --grep="hermes|browser|cdp|template"` + +## Risks, Tradeoffs, and Open Questions +- **Risk**: CDP connection to port 9223 may fail if the browser container is not running or port not exposed in current terminal context. Mitigation: fallback to local Chromium or explicit error in template. +- **Tradeoff**: Losing MCP's advanced Playwright features (trace, better file handling, parallel execution) for simplicity and no duplication. Built-in browser is "коробочное" but less powerful. +- **Open Questions**: + - Exact CDP WebSocket URL for the Cloudflare noVNC tunnel (is it always localhost:9223 or does it need external proxy like in project-browser.ts?). + - Should we add `--no-mcp` flag to docker-git for Hermes to make this default? + - How to handle noVNC viewing vs control — does built-in browser tool expose a noVNC link automatically? + - Impact on existing issue-347 Hermes support (need to update HERMES.md or docs?). +- Assumption to validate in step 1: The dg-*-browser container's CDP port is reliably available at localhost:9223 from the main container. + +## Mathematical Guarantees +- INVARIANT: ∀ hermes_session: (browser_tool_used(session) ∧ no_mcp_tools(session)) → connected_to_same_container_via_cdp(session) ∧ visible_in_noVNC(session) +- PRE: docker container dg-*-browser running with port 9223 exposed. +- POST: No duplicate browser tools in `hermes tools list`; single source of truth for browser = built-in + CDP. + +**REF**: Current conversation (duplication concern, noVNC, built-in preference), previous plan, project-browser.ts. +**SOURCE**: n/a (read-only inspection of codebase and docker ps). +**PURITY**: This plan is pure documentation. + +Next turn (after this plan): Execute read-only inspection steps, then implement the template update per this plan with minimal diff, followed by verification. + +Saved: .hermes/plans/2026-05-23_095118-clean-builtin-browser-noVNC-no-mcp.md \ No newline at end of file diff --git a/.hermes/plans/2026-05-23_102144-rust-only-noVNC-browser-module-separate-repo.md b/.hermes/plans/2026-05-23_102144-rust-only-noVNC-browser-module-separate-repo.md new file mode 100644 index 00000000..48f3f3c6 --- /dev/null +++ b/.hermes/plans/2026-05-23_102144-rust-only-noVNC-browser-module-separate-repo.md @@ -0,0 +1,72 @@ +# Plan: Rust-only noVNC + Browser Connection Module (Delete TS Version, Possible Separate Repo, Integrate into docker-git) + +## Goal +Delete all TypeScript versions of the browser/noVNC connection (including the previous packages/browser-connection and lib/core version), keep only the Rust implementation in packages/rust-browser-connection (or move to separate repository), and update docker-git to use the Rust module/binary instead of the old MCP Playright + shell scripts. This eliminates duplication, makes the toolkit "из коробки" for both MCP-like and Hermes built-in browser tools, and follows issue #347. + +## Current Context / Assumptions +- From read-only inspection (date, search_files for browser-connection|rust|novnc|mcp|playwright|cdp_url|dg-.*-browser, read_file for Cargo.toml, lib.rs, hermes.ts, project-browser.ts, pnpm-workspace.yaml, gh issue view 347): + - Rust package `packages/rust-browser-connection` exists with BrowserConnection (bollard for Docker, ports 5900/6080/9223, URLs, invariant). + - TS versions exist in packages/browser-connection (previous) and packages/lib/src/core/browser-connection.ts (duplicate). + - docker-git uses MCP Playright in templates-entrypoint (codex.ts, claude.ts, hermes.ts), project-browser.ts for CDP/noVNC proxy, docker-git-session-sync style. + - Issue #347 specifically asks to extract noVNC + MCP Playright into a single module for single browser with agent. + - Current docker containers (dg-docker-git-issue-347-browser) expose the ports. +- Assumption: Rust binary can be called from TS entrypoints or docker images can include the Rust binary. Separate repo is feasible if maintenance is easier (as user suggested). +- Deep Research question simulated: "code that extracts noVNC + browser connection to Rust module without duplication" → patterns in rust-browser-connection, project-browser-core.ts, templates-entrypoint, and the rust-ai-driven-development-pipeline-template. + +## Proposed Approach +- Delete all TS versions and references to avoid duplication. +- Keep/enhance the Rust package as the single source of truth (or move to separate repo like link-foundation style). +- Update docker-git to call the Rust binary (or link the crate) for browser start, noVNC/CDP URLs, single session management — replacing old MCP/shell logic. +- Make it "из коробки": the Rust module provides CLI and library, docker-git templates automatically use it when Hermes or other agents are selected. +- Follow AGENTS.md for the Rust part (formal comments in code, invariants, tests, verification). + +## Step-by-step Plan +1. Read-only inspection: full read of rust-browser-connection/Cargo.toml, src/lib.rs, src/main.rs, all templates-entrypoint/* .ts that mention MCP/playwright/browser, project-browser.ts, pnpm-workspace.yaml, gh issue view 347 for exact requirements, docker ps for current browser containers. +2. Formalize: define invariants for single browser (one container, shared CDP/noVNC), types for URLs/ports, error handling. +3. Delete TS version: plan removal of packages/browser-connection, packages/lib/src/core/browser-connection.ts, references in pnpm-workspace.yaml, hermes.ts, project-browser.ts, templates. +4. Enhance Rust module: ensure it fully replicates docker-git MCP Playright behavior (start container with ports, return noVNC/CDP URLs, invariant check). +5. Integration into docker-git: update entrypoints to call Rust binary (e.g. `docker-git-browser-connection start --project $(project_id)`), update docker compose to include Rust binary if needed. +6. If separate repo: plan creating new repo, publishing the crate, updating docker-git to depend on it via cargo or binary download. +7. Verification: cargo test, cargo check, test docker-git with Rust module, confirm no MCP/TS duplication, single noVNC browser works, lint/typecheck. + +## Files Likely to Change +- Delete: packages/browser-connection/ (entire TS package), packages/lib/src/core/browser-connection.ts, references in pnpm-workspace.yaml. +- Update: packages/lib/src/core/templates-entrypoint/hermes.ts (remove MCP, call Rust binary), packages/api/src/services/project-browser.ts (use Rust module for CDP/noVNC), packages/app/src/lib/core/templates-entrypoint/* (codex.ts, claude.ts if affected), docker-compose files or entrypoint scripts. +- Rust package: packages/rust-browser-connection/src/lib.rs, Cargo.toml (add more features if needed for full docker-git compatibility). +- Tests: packages/rust-browser-connection/tests/*, packages/lib/tests/usecases/browser-connection.test.ts (new for Rust integration). +- Docs: README.md, issue #347 (close it), AGENTS.md (update for Rust module). + +## Tests / Validation +- **Rust**: `cargo test`, `cargo check`, unit tests for isSingleBrowserSession, integration with mock Docker. +- **docker-git**: Test with `docker-git clone --mcp-playwright` (should use Rust instead of old MCP), verify noVNC URL works, CDP port 9223 accessible, single container. +- **Verification steps**: + - `cargo test` in rust-browser-connection. + - `hermes tools list` (no MCP, only built-in browser). + - `docker ps | grep browser` (single dg-*-browser container). + - `npm run lint && npm test` in root (no TS duplication errors). + - Manual test: open noVNC URL and use Hermes browser tool — same session. +- Run in CI with the new Rust binary included in docker images. + +## Risks, Tradeoffs, and Open Questions +- **Risk**: Removing TS version breaks existing users or MCP-dependent code in codex/claude templates. Mitigation: keep backward compatibility in templates or deprecate MCP path. +- **Tradeoff**: Rust is faster and more reliable for Docker/container management, but adds Rust toolchain to the dev setup (vs pure TS "из коробки"). Separate repo adds maintenance but cleaner separation. +- **Open Questions**: + - Separate repo or keep in monorepo? (user suggested separate — plan both options). + - How to package the Rust binary in docker-git images (static binary or cargo install)? + - Should the Rust module also handle MCP server registration or only the browser container/noVNC part? + - Exact mapping of old MCP Playright flags to Rust CLI args. + - CI/CD for Rust crate publishing and docker-git integration tests. +- Assumption to validate in step 1: The Rust binary can fully replace the old shell/MCP logic without breaking noVNC viewing or agent control. + +## Mathematical Guarantees +- INVARIANT: ∀ projectId: start_browser(projectId) → single_container(dg-{projectId}-browser) ∧ cdp_url(projectId) = "http://localhost:9223" ∧ no_vnc_url(projectId) matches template ∧ isSingleBrowserSession(cdp, novnc) = true +- PRE: Docker daemon available, image dg-docker-git-browser available. +- POST: No TS/MCP duplication, only Rust module used in docker-git and Hermes. + +**REF**: Current conversation + issue #347. +**SOURCE**: n/a (read-only inspection of codebase, gh issue view, docker ps). +**PURITY**: This plan is pure (no execution, only planning). + +Next turn (after this plan): Execute read-only inspection steps (gh issue view, read_file for key files, search_files for references), then delete TS version, enhance Rust package, integrate into docker-git per this plan (with verification). + +Saved to .hermes/plans/2026-05-23_102144-rust-only-noVNC-browser-module-separate-repo.md \ No newline at end of file diff --git a/.hermes/plans/2026-05-24_181145-integrate-rust-browser-connection-in-docker-git.md b/.hermes/plans/2026-05-24_181145-integrate-rust-browser-connection-in-docker-git.md new file mode 100644 index 00000000..43e2fe80 --- /dev/null +++ b/.hermes/plans/2026-05-24_181145-integrate-rust-browser-connection-in-docker-git.md @@ -0,0 +1,424 @@ +# План: внедрить rust-browser-connection в docker-git и удалить старую browser/MCP-логику + +## Goal + +Внедрить созданный Rust-инструмент `ProverCoderAI/rust-browser-connection` в `ProverCoderAI/docker-git` так, чтобы docker-git больше не содержал собственную TS/shell-логику создания browser/noVNC/CDP runtime, а только: + +1. устанавливал Rust-бинарники из `rust-browser-connection`; +2. делегировал создание/переиспользование browser-контейнера Rust lifecycle binary; +3. настраивал MCP клиентов на `browser-connection`, а не на старый `docker-git-playwright-mcp` / `@playwright/mcp` wrapper; +4. сохранял инвариант: один проект = один `dg-*-browser` контейнер, один Chromium session, общий для noVNC и MCP. + +## Current context / assumptions + +- Target repo обнаружен локально: `/home/dev/app`. +- Remote: `https://github.com/ProverCoderAI/docker-git.git`. +- Текущая ветка: `feat/rust-browser-connection-module`. +- Working tree чистый. +- Ветка уже содержит частичную интеграцию Rust-модуля, но она не финальная: + - старые файлы `playwright-browser.ts`, `playwright-browser-runtime.ts`, `playwright.ts` уже удалены в `packages/app` и `packages/lib`; + - Dockerfile уже ставит `docker-git-browser-connection` через `cargo install`; + - entrypoint уже вызывает `docker-git-browser-connection start`; + - но всё ещё остаётся старый `docker-git-playwright-mcp` wrapper и конфиги MCP клиентов продолжают указывать на него. +- Текущий `origin/main` отличается от branch; перед PR нужно обновиться от `origin/main` или создать свежую ветку и перенести нужные изменения. +- Rust repo `/home/dev/rust-browser-connection` на main содержит два бинарника: + - `docker-git-browser-connection` — lifecycle CLI: `start/status` browser-контейнера; + - `browser-connection` — MCP stdio server, который сам starts/reuses Rust-managed browser. +- README `rust-browser-connection` требует использовать в MCP config именно: + - `command = "browser-connection"` + - `args = ["--project", "dg-my-project"]` +- В docker-git есть зеркальные области `packages/lib/...` и `packages/app/...`; менять нужно обе, иначе тесты/сборка разойдутся. + +## Proposed approach + +Сделать интеграцию в два слоя: + +1. Browser lifecycle для noVNC/UI: + - docker-git entrypoint не создаёт browser сам; + - он вызывает Rust binary `docker-git-browser-connection start --project --network container:`; + - Rust binary отвечает за Docker container/image/volume/network/ports и single-session invariant. + +2. MCP для Codex/Claude/Gemini/Grok/Hermes-like clients: + - удалить generated wrapper `/usr/local/bin/docker-git-playwright-mcp`; + - больше не ставить `@playwright/mcp` для этого пути; + - все MCP configs должны указывать на `browser-connection`; + - args должны передавать project id и тот же network mode: + - `--project`, `$DOCKER_GIT_PROJECT_CONTAINER_NAME` + - `--network`, `container:$DOCKER_GIT_PROJECT_CONTAINER_NAME` + - `browser-connection` сам starts/reuses тот же browser-контейнер, поэтому noVNC и MCP не расходятся. + +## Step-by-step plan + +### 1. Подготовить clean integration branch + +- Из `/home/dev/app`: + - проверить `git status`; + - обновить refs: `git fetch origin`; + - либо rebase текущей `feat/rust-browser-connection-module` на `origin/main`, либо создать свежую branch от `origin/main` и перенести только нужные изменения. +- Цель: PR должен быть понятным и не тащить случайные старые/плановые файлы, кроме осознанных изменений. + +### 2. Установка Rust tool в generated Dockerfile + +Файлы: + +- `packages/lib/src/core/templates/dockerfile-prelude.ts` +- `packages/app/src/lib/core/templates/dockerfile-prelude.ts` + +Изменить install block так, чтобы он ставил оба бинарника: + +```dockerfile +RUN cargo install --git https://github.com/ProverCoderAI/rust-browser-connection --rev c36f263ebc5d0acdf155113914f08cafefa69c56 --locked --bins --root /usr/local \ + && /usr/local/bin/docker-git-browser-connection --version \ + && /usr/local/bin/browser-connection --version +``` + +Обновить formal comments: + +- FORMAT THEOREM: image build produces both `/usr/local/bin/docker-git-browser-connection` and `/usr/local/bin/browser-connection`. +- INVARIANT: docker-git delegates browser lifecycle and MCP stdio to Rust binaries from the separate repo. + +### 3. Удалить старый Playwright MCP wrapper из Dockerfile + +Файлы: + +- удалить или перестать импортировать: + - `packages/lib/src/core/templates/dockerfile-playwright-mcp.ts` + - `packages/app/src/lib/core/templates/dockerfile-playwright-mcp.ts` +- обновить: + - `packages/lib/src/core/templates/dockerfile.ts` + - `packages/app/src/lib/core/templates/dockerfile.ts` + +Что убрать: + +- `npm install -g @playwright/mcp@...` для browser path; +- создание `/usr/local/bin/docker-git-playwright-mcp`; +- CDP polling wrapper вокруг `playwright-mcp`; +- любые тестовые ожидания, что generated Dockerfile содержит `docker-git-playwright-mcp`. + +Что оставить: + +- Rust install из `dockerfile-prelude.ts`; +- Node/Bun/Codex/Claude/Gemini/Grok tooling, не связанный с browser MCP wrapper. + +### 4. Entry point: lifecycle start только через Rust binary + +Файлы: + +- `packages/lib/src/core/templates-entrypoint/tasks.ts` +- `packages/app/src/lib/core/templates-entrypoint/tasks.ts` + +Сохранить функцию типа `docker_git_start_rust_browser_connection`, но уточнить: + +- lifecycle binary ищется как `/usr/local/bin/docker-git-browser-connection` или через `command -v`; +- browser стартует только если `MCP_PLAYWRIGHT_ENABLE=1`; +- project берётся из `DOCKER_GIT_PROJECT_CONTAINER_NAME`, fallback `hostname`; +- network mode: `container:${project_container}`; +- лог: `/var/log/docker-git-browser.log`; +- при ошибке не падать всем container boot, но выставить `MCP_PLAYWRIGHT_ENABLE=0` и показать warning. + +Ожидаемая команда в generated shell: + +```bash +"$browser_lifecycle_bin" start \ + --project "$project_container" \ + --network "container:${project_container}" +``` + +Важно: это не новая browser-логика в docker-git; это только вызов внешнего Rust lifecycle binary. + +### 5. MCP configs: заменить `docker-git-playwright-mcp` на `browser-connection` + +Файлы entrypoint config generation: + +- `packages/lib/src/core/templates-entrypoint/codex.ts` +- `packages/app/src/lib/core/templates-entrypoint/codex.ts` +- `packages/lib/src/core/templates-entrypoint/claude.ts` +- `packages/app/src/lib/core/templates-entrypoint/claude.ts` +- `packages/lib/src/core/templates-entrypoint/gemini.ts` +- `packages/app/src/lib/core/templates-entrypoint/gemini.ts` +- `packages/lib/src/core/templates-entrypoint/grok.ts` +- `packages/app/src/lib/core/templates-entrypoint/grok.ts` + +Новые значения: + +Codex TOML: + +```toml +# docker-git: Browser MCP via rust-browser-connection +[mcp_servers.playwright] +command = "browser-connection" +args = ["--project", "", "--network", "container:"] +``` + +Claude/Gemini/Grok JSON-like configs: + +```json +{ + "command": "browser-connection", + "args": ["--project", "", "--network", "container:"], + "trust": true +} +``` + +Для Claude сохранить `type: "stdio"`. + +Техническая деталь: в shell entrypoint сначала вычислить: + +```bash +DOCKER_GIT_BROWSER_PROJECT="${DOCKER_GIT_PROJECT_CONTAINER_NAME:-$(hostname)}" +DOCKER_GIT_BROWSER_NETWORK="container:${DOCKER_GIT_BROWSER_PROJECT}" +``` + +и передавать эти env values в node snippets, чтобы JSON/TOML записывались с конкретными строками, а не с нераскрытыми shell placeholders. + +### 6. Auth helper defaults тоже заменить на `browser-connection` + +Файлы, где initial OAuth/helper settings сейчас ещё пишут старый command: + +- `packages/lib/src/usecases/auth-gemini-helpers.ts` +- `packages/app/src/lib/usecases/auth-gemini-helpers.ts` +- `packages/lib/src/usecases/auth-grok-helpers.ts` +- `packages/app/src/lib/usecases/auth-grok-helpers.ts` + +Заменить default MCP server: + +```ts +command: "browser-connection", +args: [] // или args с project/network, если helper имеет доступ к project env на runtime +``` + +Если helper не знает project id на момент записи account-level settings, предпочесть пустые args и дать entrypoint runtime sync перезаписать project-specific config при старте container. + +### 7. Compose env: оставить только параметры для Rust tool + +Файлы: + +- `packages/lib/src/core/templates/docker-compose.ts` +- `packages/app/src/lib/core/templates/docker-compose.ts` + +Оставить env: + +- `MCP_PLAYWRIGHT_ENABLE=1` +- `DOCKER_GIT_PROJECT_CONTAINER_NAME` +- `DOCKER_GIT_BROWSER_CONTAINER_NAME` +- `DOCKER_GIT_BROWSER_IMAGE_NAME` +- `DOCKER_GIT_BROWSER_VOLUME_NAME` +- `DOCKER_GIT_BROWSER_CPU_LIMIT` +- `DOCKER_GIT_BROWSER_RAM_LIMIT` + +Не возвращать отдельный compose service `dg-*-browser`; Rust tool сам создаёт/переиспользует browser container. + +### 8. Удалить остатки старой TS/browser duplication + +Проверить и при необходимости удалить/обновить: + +- `packages/lib/src/core/templates-entrypoint/playwright-browser.ts` +- `packages/app/src/lib/core/templates-entrypoint/playwright-browser.ts` +- `packages/lib/src/core/templates/playwright-browser-runtime.ts` +- `packages/app/src/lib/core/templates/playwright-browser-runtime.ts` +- `packages/lib/src/core/templates/playwright.ts` +- `packages/app/src/lib/core/templates/playwright.ts` +- любые `packages/*browser-connection*` локальные копии Rust repo, если появятся. + +Verification search после изменений: + +```bash +rg "docker-git-playwright-mcp|@playwright/mcp|playwright-browser-runtime|Dockerfile.browser|mcp-playwright-start-extra|docker-git-cdp-guard" packages +``` + +Ожидание: + +- старый wrapper absent; +- допустимы только исторические changelog/docs references, если они не влияют на runtime/tests. + +### 9. Обновить tests + +Файлы с текущими ожиданиями старого command/wrapper: + +- `packages/lib/tests/core/templates.test.ts` +- `packages/app/tests/docker-git/core-templates.test.ts` +- `packages/lib/tests/usecases/mcp-playwright.test.ts` +- `packages/lib/tests/usecases/auth-gemini.test.ts` +- возможно app-level tests под `packages/app/tests/docker-git/*`. + +Новые assertions: + +- generated Dockerfile содержит: + - `cargo install --git https://github.com/ProverCoderAI/rust-browser-connection` + - `--bins` + - `browser-connection --version` + - `docker-git-browser-connection --version` +- generated Dockerfile НЕ содержит: + - `npm install -g "@playwright/mcp` + - `/usr/local/bin/docker-git-playwright-mcp` +- generated entrypoint содержит: + - `docker_git_start_rust_browser_connection` + - `docker-git-browser-connection start` + - `browser-connection` + - `--project` + - `--network` +- generated configs содержат: + - `command = "browser-connection"` для Codex; + - `command: "browser-connection"` / JSON equivalent для Claude/Gemini/Grok; + - не содержат `docker-git-playwright-mcp`. + +### 10. Local verification before PR + +Запустить из `/home/dev/app`: + +```bash +bun run --filter @effect-template/lib typecheck +bun run --filter @effect-template/lib test +bun run --filter @prover-coder-ai/docker-git typecheck +bun run --filter @prover-coder-ai/docker-git test +bun run check +``` + +Статические проверки: + +```bash +rg "docker-git-playwright-mcp|@playwright/mcp|playwright-browser-runtime|Dockerfile.browser|mcp-playwright-start-extra|docker-git-cdp-guard" packages +rg "command = \"browser-connection\"|\"command\": \"browser-connection\"|browser-connection" packages +``` + +Ожидание: + +- runtime code не содержит старый wrapper; +- tests отражают Rust-only integration; +- нет локального Rust repo/package copy внутри docker-git. + +### 11. Runtime smoke test + +После unit/typecheck: + +1. Собрать docker-git CLI: + +```bash +bun run --cwd packages/app build:docker-git +``` + +2. Создать test project с browser enabled, например на маленьком repo/issue: + +```bash +bun ./packages/app/dist/src/docker-git/main.js clone https://github.com/ProverCoderAI/docker-git/issues/122 --force --mcp-playwright +``` + +3. Внутри project container проверить: + +```bash +which docker-git-browser-connection +which browser-connection +docker-git-browser-connection status --project "$DOCKER_GIT_PROJECT_CONTAINER_NAME" +``` + +4. Проверить контейнеры: + +```bash +docker ps --format '{{.Names}} {{.Ports}}' | grep -- '-browser' +``` + +5. MCP smoke: + +```bash +printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \ +| browser-connection --project "$DOCKER_GIT_PROJECT_CONTAINER_NAME" --network "container:$DOCKER_GIT_PROJECT_CONTAINER_NAME" --no-start-browser +``` + +Expected: + +- server name `browser-connection`; +- tools: `browser_navigate`, `browser_snapshot`, `browser_evaluate`, etc. + +6. noVNC/CDP proof: + +- open docker-git Browser UI/noVNC URL; +- navigate via MCP `browser_navigate`; +- visually confirm same noVNC session changed; +- confirm only one browser container for the project. + +### 12. PR flow + +- Commit with conventional message, e.g.: + +```text +feat(browser): delegate MCP/noVNC runtime to rust-browser-connection +``` + +- Push feature branch. +- Open PR to `main`. +- Include summary: + - removes docker-git Playwright MCP wrapper; + - installs Rust binaries from `rust-browser-connection`; + - uses `browser-connection` in MCP configs; + - lifecycle uses `docker-git-browser-connection start`; + - no TS browser creation duplication remains. +- Wait for CI and fix failures. + +## Files likely to change + +Core generated Dockerfile / entrypoint: + +- `packages/lib/src/core/templates/dockerfile-prelude.ts` +- `packages/app/src/lib/core/templates/dockerfile-prelude.ts` +- `packages/lib/src/core/templates/dockerfile.ts` +- `packages/app/src/lib/core/templates/dockerfile.ts` +- `packages/lib/src/core/templates-entrypoint/tasks.ts` +- `packages/app/src/lib/core/templates-entrypoint/tasks.ts` + +Remove old wrapper template: + +- `packages/lib/src/core/templates/dockerfile-playwright-mcp.ts` +- `packages/app/src/lib/core/templates/dockerfile-playwright-mcp.ts` + +MCP config generation: + +- `packages/lib/src/core/templates-entrypoint/codex.ts` +- `packages/app/src/lib/core/templates-entrypoint/codex.ts` +- `packages/lib/src/core/templates-entrypoint/claude.ts` +- `packages/app/src/lib/core/templates-entrypoint/claude.ts` +- `packages/lib/src/core/templates-entrypoint/gemini.ts` +- `packages/app/src/lib/core/templates-entrypoint/gemini.ts` +- `packages/lib/src/core/templates-entrypoint/grok.ts` +- `packages/app/src/lib/core/templates-entrypoint/grok.ts` + +Auth helper defaults: + +- `packages/lib/src/usecases/auth-gemini-helpers.ts` +- `packages/app/src/lib/usecases/auth-gemini-helpers.ts` +- `packages/lib/src/usecases/auth-grok-helpers.ts` +- `packages/app/src/lib/usecases/auth-grok-helpers.ts` + +Tests: + +- `packages/lib/tests/core/templates.test.ts` +- `packages/app/tests/docker-git/core-templates.test.ts` +- `packages/lib/tests/usecases/mcp-playwright.test.ts` +- `packages/lib/tests/usecases/auth-gemini.test.ts` +- any failing app/API tests that assert the old wrapper name. + +Docs/changelog if required by repo convention: + +- `README.md` if user-facing flags/help mention old wrapper behavior. +- `changelog.d/_rust_browser_connection_docker_git.md` if CI requires a fragment. + +## Risks / tradeoffs / open questions + +- `browser-connection` starts the browser on MCP server startup. If entrypoint also calls `docker-git-browser-connection start`, this is still safe only if Rust lifecycle is idempotent. This should be verified in runtime smoke. +- If we remove `@playwright/mcp`, tool names/semantics are now the Rust MCP implementation's tools, not upstream Playwright MCP. That is intended, but tests/docs must reflect it. +- Account-level Gemini/Grok helper defaults may not know project id. Runtime entrypoint sync should remain authoritative and overwrite project-specific MCP config. +- Current branch is behind `origin/main`; rebasing may create conflicts in template/test files. +- Docker build now depends on GitHub cargo install from pinned `rust-browser-connection` rev `c36f263ebc5d0acdf155113914f08cafefa69c56`. Future Rust module upgrades require an intentional rev bump in docker-git. +- Do not create or commit a local copy of the Rust repo under docker-git (`packages/rust-browser-connection`, `packages/browser-connection`, etc.). The separate GitHub repo remains the single source of truth. + +## Definition of done + +- `rg "docker-git-playwright-mcp|@playwright/mcp" packages` has no runtime hits. +- Generated Dockerfile installs both Rust binaries. +- Generated MCP configs use `browser-connection`. +- Generated entrypoint delegates browser lifecycle to `docker-git-browser-connection`. +- Tests/typecheck pass. +- Runtime smoke proves MCP navigation changes the same noVNC-visible Chromium session. +- PR is open against docker-git `main` with green CI. diff --git a/experiments/render-examples-output.txt b/experiments/render-examples-output.txt index bb9a9527..3e7a21d9 100644 --- a/experiments/render-examples-output.txt +++ b/experiments/render-examples-output.txt @@ -5,8 +5,16 @@ CLAUDE.md prompt setup (~/.claude/CLAUDE.md) # Claude Code: managed global memory (CLAUDE.md is auto-loaded by Claude Code) CLAUDE_GLOBAL_PROMPT_FILE="/home/dev/.claude/CLAUDE.md" +docker_git_decode_unicode_escapes() { + local value="$1" + if printf "%s" "$value" | grep -q '\\u[0-9a-fA-F]'; then + printf "%b" "$value" + else + printf "%s" "$value" + fi +} CLAUDE_AUTO_SYSTEM_PROMPT="${CLAUDE_AUTO_SYSTEM_PROMPT:-1}" -CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: repository" +CLAUDE_WORKSPACE_CONTEXT="\u041A\u043E\u043D\u0442\u0435\u043A\u0441\u0442 workspace: repository" REPO_REF_VALUE="${REPO_REF:-issue-237}" REPO_URL_VALUE="${REPO_URL:-https://github.com/ProverCoderAI/docker-git.git}" @@ -20,9 +28,9 @@ if [[ "$REPO_REF_VALUE" == issue-* ]]; then fi fi if [[ -n "$ISSUE_URL_VALUE" ]]; then - CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID_VALUE ($ISSUE_URL_VALUE)" + CLAUDE_WORKSPACE_CONTEXT="\u041A\u043E\u043D\u0442\u0435\u043A\u0441\u0442 workspace: issue #$ISSUE_ID_VALUE ($ISSUE_URL_VALUE)" else - CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID_VALUE" + CLAUDE_WORKSPACE_CONTEXT="\u041A\u043E\u043D\u0442\u0435\u043A\u0441\u0442 workspace: issue #$ISSUE_ID_VALUE" fi elif [[ "$REPO_REF_VALUE" == refs/pull/*/head ]]; then PR_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" @@ -34,27 +42,28 @@ elif [[ "$REPO_REF_VALUE" == refs/pull/*/head ]]; then fi fi if [[ -n "$PR_ID_VALUE" && -n "$PR_URL_VALUE" ]]; then - CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID_VALUE ($PR_URL_VALUE)" + CLAUDE_WORKSPACE_CONTEXT="\u041A\u043E\u043D\u0442\u0435\u043A\u0441\u0442 workspace: PR #$PR_ID_VALUE ($PR_URL_VALUE)" elif [[ -n "$PR_ID_VALUE" ]]; then - CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID_VALUE" + CLAUDE_WORKSPACE_CONTEXT="\u041A\u043E\u043D\u0442\u0435\u043A\u0441\u0442 workspace: PR #$PR_ID_VALUE" else - CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: pull request ($REPO_REF_VALUE)" + CLAUDE_WORKSPACE_CONTEXT="\u041A\u043E\u043D\u0442\u0435\u043A\u0441\u0442 workspace: pull request ($REPO_REF_VALUE)" fi fi CLAUDE_SYSTEM_PROMPT_OVERRIDE_FILE="${CLAUDE_SYSTEM_PROMPT_OVERRIDE_FILE:-}" CLAUDE_SYSTEM_PROMPT_OVERRIDE="${CLAUDE_SYSTEM_PROMPT_OVERRIDE:-}" CLAUDE_DEFAULT_PROMPT_BODY="$(cat < "$AGENTS_PATH" -Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, bun, codex, opencode, oh-my-opencode, sshpass, git, node и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ +\u0422\u044B \u0430\u0432\u0442\u043E\u043D\u043E\u043C\u043D\u044B\u0439 \u0430\u0433\u0435\u043D\u0442, \u043A\u043E\u0442\u043E\u0440\u044B\u0439 \u0438\u043C\u0435\u0435\u0442 \u043F\u043E\u043B\u043D\u043E\u0441\u0442\u044C\u044E \u0432\u0441\u0435 \u043F\u0440\u0430\u0432\u0430 \u0443\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u044F \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u043E\u043C. \u0423 \u0442\u0435\u0431\u044F \u0435\u0441\u0442\u044C \u0434\u043E\u0441\u0442\u0443\u043F \u043A \u043A\u043E\u043C\u0430\u043D\u0434\u0430\u043C sudo, gh, bun, codex, opencode, oh-my-opencode, sshpass, git, node \u0438 \u0432\u0441\u0435\u043C \u043E\u0441\u0442\u0430\u043B\u044C\u043D\u044B\u043C \u0434\u0440\u0443\u0433\u0438\u043C. \u041F\u0440\u043E\u0435\u043A\u0442\u044B \u0441 \u043A\u043E\u0442\u043E\u0440\u044B\u043C\u0438 \u0438\u0434\u0451\u0442 \u0440\u0430\u0431\u043E\u0442\u0430 \u043B\u0435\u0436\u0430\u0442 \u043F\u043E \u043F\u0443\u0442\u0438 ~ $MANAGED_BLOCK -Если ты видишь файлы AGENTS.md внутри проекта, ты обязан их читать и соблюдать инструкции. +\u0415\u0441\u043B\u0438 \u0442\u044B \u0432\u0438\u0434\u0438\u0448\u044C \u0444\u0430\u0439\u043B\u044B AGENTS.md \u0432\u043D\u0443\u0442\u0440\u0438 \u043F\u0440\u043E\u0435\u043A\u0442\u0430, \u0442\u044B \u043E\u0431\u044F\u0437\u0430\u043D \u0438\u0445 \u0447\u0438\u0442\u0430\u0442\u044C \u0438 \u0441\u043E\u0431\u043B\u044E\u0434\u0430\u0442\u044C \u0438\u043D\u0441\u0442\u0440\u0443\u043A\u0446\u0438\u0438. EOF chown 1000:1000 "$AGENTS_PATH" || true fi @@ -173,13 +191,13 @@ EOF ' "$AGENTS_PATH" > "$TMP_AGENTS_PATH" else sed \ - -e '/^Рабочая папка проекта (git clone):/d' \ - -e '/^Доступные workspace пути:/d' \ - -e '/^Контекст workspace:/d' \ - -e '/^Фокус задачи:/d' \ + -e '/^\u0420\u0430\u0431\u043E\u0447\u0430\u044F \u043F\u0430\u043F\u043A\u0430 \u043F\u0440\u043E\u0435\u043A\u0442\u0430 (git clone):/d' \ + -e '/^\u0414\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0435 workspace \u043F\u0443\u0442\u0438:/d' \ + -e '/^\u041A\u043E\u043D\u0442\u0435\u043A\u0441\u0442 workspace:/d' \ + -e '/^\u0424\u043E\u043A\u0443\u0441 \u0437\u0430\u0434\u0430\u0447\u0438:/d' \ -e '/^Issue AGENTS.md:/d' \ - -e '/^Доступ к интернету:/d' \ - -e '/^Для решения задач обязательно используй subagents[.]/d' \ + -e '/^\u0414\u043E\u0441\u0442\u0443\u043F \u043A \u0438\u043D\u0442\u0435\u0440\u043D\u0435\u0442\u0443:/d' \ + -e '/^\u0414\u043B\u044F \u0440\u0435\u0448\u0435\u043D\u0438\u044F \u0437\u0430\u0434\u0430\u0447 \u043E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439 subagents[.]/d' \ "$AGENTS_PATH" > "$TMP_AGENTS_PATH" if [[ -s "$TMP_AGENTS_PATH" ]]; then printf "\n" >> "$TMP_AGENTS_PATH" @@ -189,6 +207,13 @@ EOF mv "$TMP_AGENTS_PATH" "$AGENTS_PATH" chown 1000:1000 "$AGENTS_PATH" || true fi +if [[ -f "$AGENTS_PATH" ]] && grep -qF "$MANAGED_START" "$AGENTS_PATH" && grep -q '\\u[0-9a-fA-F]' "$AGENTS_PATH"; then + TMP_AGENTS_PATH="$(mktemp)" + docker_git_decode_unicode_escapes "$(cat "$AGENTS_PATH")" > "$TMP_AGENTS_PATH" + printf "\n" >> "$TMP_AGENTS_PATH" + mv "$TMP_AGENTS_PATH" "$AGENTS_PATH" + chown 1000:1000 "$AGENTS_PATH" || true +fi if [[ -f "$LEGACY_AGENTS_PATH" && -f "$AGENTS_PATH" ]]; then LEGACY_SUM="$(cksum "$LEGACY_AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" CODEX_SUM="$(cksum "$AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" @@ -353,13 +378,6 @@ cat <<'EOF' > "$GEMINI_CONFIG_SETTINGS_FILE" "selectedType": "oauth-personal" }, "disableYoloMode": false - }, - "mcpServers": { - "playwright": { - "command": "docker-git-playwright-mcp", - "args": [], - "trust": true - } } } EOF @@ -377,9 +395,43 @@ EOF chown -R 1000:1000 "$GEMINI_SETTINGS_DIR" || true chmod 0600 "$GEMINI_TRUST_SETTINGS_FILE" "$GEMINI_CONFIG_SETTINGS_FILE" 2>/dev/null || true -# 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. +# Gemini CLI: keep Playwright MCP config in sync with container settings +docker_git_sync_gemini_playwright_mcp() { + local browser_project="${DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"; [[ -n "$browser_project" ]] || browser_project="$(hostname)" + local browser_network="container:$browser_project" + GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${MCP_PLAYWRIGHT_ENABLE:-0}" DOCKER_GIT_BROWSER_PROJECT="$browser_project" DOCKER_GIT_BROWSER_NETWORK="$browser_network" 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 browserProject = process.env.DOCKER_GIT_BROWSER_PROJECT || "" +const browserArgs = browserProject.length > 0 ? ["--project", browserProject, "--network", process.env.DOCKER_GIT_BROWSER_NETWORK || "container:" + browserProject] : [] +const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } +if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") { + nextServers.playwright = { command: "browser-connection", args: browserArgs, 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 # Gemini CLI: allow passwordless sudo for agent tasks if [[ -d /etc/sudoers.d ]]; then @@ -409,7 +461,15 @@ docker_git_upsert_ssh_env "GEMINI_CLI_APPROVAL_MODE" "yolo" # Ensure global GEMINI.md exists for container context GEMINI_MD_PATH="/home/dev/.gemini/GEMINI.md" -GEMINI_WORKSPACE_CONTEXT="Контекст workspace: repository" +docker_git_decode_unicode_escapes() { + local value="$1" + if printf "%s" "$value" | grep -q '\\u[0-9a-fA-F]'; then + printf "%b" "$value" + else + printf "%s" "$value" + fi +} +GEMINI_WORKSPACE_CONTEXT="\u041A\u043E\u043D\u0442\u0435\u043A\u0441\u0442 workspace: repository" if [[ "$REPO_REF" == issue-* ]]; then ISSUE_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^issue-##')" ISSUE_URL="" @@ -420,9 +480,9 @@ if [[ "$REPO_REF" == issue-* ]]; then fi fi if [[ -n "$ISSUE_URL" ]]; then - GEMINI_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID ($ISSUE_URL)" + GEMINI_WORKSPACE_CONTEXT="\u041A\u043E\u043D\u0442\u0435\u043A\u0441\u0442 workspace: issue #$ISSUE_ID ($ISSUE_URL)" else - GEMINI_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID" + GEMINI_WORKSPACE_CONTEXT="\u041A\u043E\u043D\u0442\u0435\u043A\u0441\u0442 workspace: issue #$ISSUE_ID" fi elif [[ "$REPO_REF" == refs/pull/*/head ]]; then PR_ID="$(printf "%s" "$REPO_REF" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" @@ -434,27 +494,28 @@ elif [[ "$REPO_REF" == refs/pull/*/head ]]; then fi fi if [[ -n "$PR_ID" && -n "$PR_URL" ]]; then - GEMINI_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID ($PR_URL)" + GEMINI_WORKSPACE_CONTEXT="\u041A\u043E\u043D\u0442\u0435\u043A\u0441\u0442 workspace: PR #$PR_ID ($PR_URL)" elif [[ -n "$PR_ID" ]]; then - GEMINI_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID" + GEMINI_WORKSPACE_CONTEXT="\u041A\u043E\u043D\u0442\u0435\u043A\u0441\u0442 workspace: PR #$PR_ID" else - GEMINI_WORKSPACE_CONTEXT="Контекст workspace: pull request ($REPO_REF)" + GEMINI_WORKSPACE_CONTEXT="\u041A\u043E\u043D\u0442\u0435\u043A\u0441\u0442 workspace: pull request ($REPO_REF)" fi fi GEMINI_SYSTEM_PROMPT_OVERRIDE_FILE="${GEMINI_SYSTEM_PROMPT_OVERRIDE_FILE:-}" GEMINI_SYSTEM_PROMPT_OVERRIDE="${GEMINI_SYSTEM_PROMPT_OVERRIDE:-}" GEMINI_DEFAULT_PROMPT_BODY="$(cat < 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) + MCP_PLAYWRIGHT_ISOLATED=1|0 Legacy-compatible hint; default 0 shares the Rust-managed noVNC session Auth providers: github, gh GitHub CLI auth (tokens saved to env file) diff --git a/packages/app/src/lib/core/templates-entrypoint.ts b/packages/app/src/lib/core/templates-entrypoint.ts index 02641d16..d4135d00 100644 --- a/packages/app/src/lib/core/templates-entrypoint.ts +++ b/packages/app/src/lib/core/templates-entrypoint.ts @@ -27,10 +27,9 @@ 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" +import { renderEntrypointBackgroundTasks, renderEntrypointRustBrowserConnection } from "./templates-entrypoint/tasks.js" import { renderEntrypointBashCompletion, renderEntrypointBashHistory, @@ -60,7 +59,7 @@ export const renderEntrypoint = (config: TemplateConfig): string => renderEntrypointProjectAgentRules(), renderEntrypointAgentsNotice(config), renderEntrypointDockerSocket(config), - renderEntrypointPlaywrightBrowserRuntime(config), + renderEntrypointRustBrowserConnection(), renderEntrypointMcpPlaywright(config), renderEntrypointGitConfig(config), renderEntrypointClaudeConfig(config), diff --git a/packages/app/src/lib/core/templates-entrypoint/base.ts b/packages/app/src/lib/core/templates-entrypoint/base.ts index 8fd17d56..beb71439 100644 --- a/packages/app/src/lib/core/templates-entrypoint/base.ts +++ b/packages/app/src/lib/core/templates-entrypoint/base.ts @@ -41,8 +41,6 @@ AGENT_MODE="\${AGENT_MODE:-}" AGENT_AUTO="\${AGENT_AUTO:-}" MCP_PLAYWRIGHT_ENABLE="\${MCP_PLAYWRIGHT_ENABLE:-${config.enableMcpPlaywright ? "1" : "0"}}" MCP_PLAYWRIGHT_ISOLATED="\${MCP_PLAYWRIGHT_ISOLATED:-0}" -MCP_PLAYWRIGHT_CDP_GUARD="\${MCP_PLAYWRIGHT_CDP_GUARD:-1}" -MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE="\${MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE:-1}" SSH_ENV_PATH="/home/${config.sshUser}/.ssh/environment" diff --git a/packages/app/src/lib/core/templates-entrypoint/claude.ts b/packages/app/src/lib/core/templates-entrypoint/claude.ts index fb43458b..eafff7cb 100644 --- a/packages/app/src/lib/core/templates-entrypoint/claude.ts +++ b/packages/app/src/lib/core/templates-entrypoint/claude.ts @@ -193,16 +193,18 @@ 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:-0}" node - <<'NODE' + local browser_project="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"; [[ -n "$browser_project" ]] || browser_project="$(hostname)" + local browser_network="container:$browser_project" + CLAUDE_SETTINGS_FILE="$CLAUDE_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" DOCKER_GIT_BROWSER_PROJECT="$browser_project" DOCKER_GIT_BROWSER_NETWORK="$browser_network" node - <<'NODE' const fs = require("node:fs") const path = require("node:path") const settingsPath = process.env.CLAUDE_SETTINGS_FILE -if (typeof settingsPath !== "string" || settingsPath.length === 0) { - process.exit(0) -} +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) const enablePlaywright = process.env.MCP_PLAYWRIGHT_ENABLE === "1" +const browserProject = process.env.DOCKER_GIT_BROWSER_PROJECT || "" +const browserArgs = browserProject.length > 0 ? ["--project", browserProject, "--network", process.env.DOCKER_GIT_BROWSER_NETWORK || "container:" + browserProject] : [] const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) let settings = {} @@ -210,17 +212,15 @@ try { const raw = fs.readFileSync(settingsPath, "utf8") const parsed = JSON.parse(raw) settings = isRecord(parsed) ? parsed : {} -} catch { - settings = {} -} +} catch { settings = {} } const currentServers = isRecord(settings.mcpServers) ? settings.mcpServers : {} const nextServers = { ...currentServers } if (enablePlaywright) { nextServers.playwright = { type: "stdio", - command: "docker-git-playwright-mcp", - args: [], + command: "browser-connection", + args: browserArgs, env: {} } } else { diff --git a/packages/app/src/lib/core/templates-entrypoint/codex.ts b/packages/app/src/lib/core/templates-entrypoint/codex.ts index 0fe8ae56..3f83c3ee 100644 --- a/packages/app/src/lib/core/templates-entrypoint/codex.ts +++ b/packages/app/src/lib/core/templates-entrypoint/codex.ts @@ -51,16 +51,22 @@ if [[ "$CODEX_SHARE_AUTH" == "1" ]]; then docker_git_upsert_ssh_env "CODEX_AUTH_LABEL" "$CODEX_AUTH_LABEL" fi` -const entrypointMcpPlaywrightTemplate = String.raw`# Optional: configure Playwright MCP for Codex (browser automation) +const entrypointMcpPlaywrightTemplate = String.raw`# Optional: configure Browser MCP for Codex (Rust browser-connection) CODEX_CONFIG_FILE="__CODEX_HOME__/config.toml" +DOCKER_GIT_BROWSER_PROJECT="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}" +if [[ -z "$DOCKER_GIT_BROWSER_PROJECT" ]]; then + DOCKER_GIT_BROWSER_PROJECT="$(hostname)" +fi +DOCKER_GIT_BROWSER_NETWORK="container:$DOCKER_GIT_BROWSER_PROJECT" # Keep config.toml consistent with the container build. -# If Playwright MCP is disabled for this container, remove the block so Codex -# doesn't try (and fail) to spawn docker-git-playwright-mcp. +# If browser MCP is disabled for this container, remove the block so Codex +# doesn't try (and fail) to spawn browser-connection. if [[ "$MCP_PLAYWRIGHT_ENABLE" != "1" ]]; then if [[ -f "$CODEX_CONFIG_FILE" ]] && grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then awk ' BEGIN { skip=0 } + /^# docker-git: Browser MCP/ { next } /^# docker-git: Playwright MCP/ { next } /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } skip==1 && /^\[/ { skip=0 } @@ -98,10 +104,11 @@ EOF chown 1000:1000 "$CODEX_CONFIG_FILE" || true fi - # Replace the docker-git Playwright block to allow upgrades via --force without manual edits. + # Replace the docker-git Browser MCP block to allow upgrades via --force without manual edits. if grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then awk ' BEGIN { skip=0 } + /^# docker-git: Browser MCP/ { next } /^# docker-git: Playwright MCP/ { next } /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } skip==1 && /^\[/ { skip=0 } @@ -112,10 +119,10 @@ EOF cat <> "$CODEX_CONFIG_FILE" -# docker-git: Playwright MCP (connects to Chromium via CDP) +# docker-git: Browser MCP (rust-browser-connection) [mcp_servers.playwright] -command = "docker-git-playwright-mcp" -args = [] +command = "browser-connection" +args = ["--project", "$DOCKER_GIT_BROWSER_PROJECT", "--network", "$DOCKER_GIT_BROWSER_NETWORK"] EOF fi` diff --git a/packages/app/src/lib/core/templates-entrypoint/gemini.ts b/packages/app/src/lib/core/templates-entrypoint/gemini.ts index 7d1572b0..3a8f308a 100644 --- a/packages/app/src/lib/core/templates-entrypoint/gemini.ts +++ b/packages/app/src/lib/core/templates-entrypoint/gemini.ts @@ -200,7 +200,9 @@ fi` 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' + local browser_project="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"; [[ -n "$browser_project" ]] || browser_project="$(hostname)" + local browser_network="container:$browser_project" + GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" DOCKER_GIT_BROWSER_PROJECT="$browser_project" DOCKER_GIT_BROWSER_NETWORK="$browser_network" node - <<'NODE' const fs = require("node:fs") const path = require("node:path") const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE @@ -213,9 +215,11 @@ try { if (isRecord(parsed)) settings = parsed } catch {} +const browserProject = process.env.DOCKER_GIT_BROWSER_PROJECT || "" +const browserArgs = browserProject.length > 0 ? ["--project", browserProject, "--network", process.env.DOCKER_GIT_BROWSER_NETWORK || "container:" + browserProject] : [] const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") { - nextServers.playwright = { command: "docker-git-playwright-mcp", args: [], trust: true } + nextServers.playwright = { command: "browser-connection", args: browserArgs, trust: true } } else { delete nextServers.playwright } diff --git a/packages/app/src/lib/core/templates-entrypoint/grok.ts b/packages/app/src/lib/core/templates-entrypoint/grok.ts index 30998746..60ffd5b2 100644 --- a/packages/app/src/lib/core/templates-entrypoint/grok.ts +++ b/packages/app/src/lib/core/templates-entrypoint/grok.ts @@ -185,7 +185,12 @@ chmod 0600 "$GROK_CONFIG_SETTINGS_FILE" "$GROK_USER_SETTINGS_FILE" 2>/dev/null | 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' + local browser_project="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}" + if [[ -z "$browser_project" ]]; then + browser_project="$(hostname)" + fi + local browser_network="container:$browser_project" + GROK_CONFIG_SETTINGS_FILE="$GROK_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" DOCKER_GIT_BROWSER_PROJECT="$browser_project" DOCKER_GIT_BROWSER_NETWORK="$browser_network" node - <<'NODE' const fs = require("node:fs") const path = require("node:path") const settingsPath = process.env.GROK_CONFIG_SETTINGS_FILE @@ -198,9 +203,12 @@ try { if (isRecord(parsed)) settings = parsed } catch {} +const browserProject = process.env.DOCKER_GIT_BROWSER_PROJECT || "" +const browserNetwork = process.env.DOCKER_GIT_BROWSER_NETWORK || (browserProject.length > 0 ? "container:" + browserProject : "") +const browserArgs = browserProject.length > 0 ? ["--project", browserProject, "--network", browserNetwork] : [] const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") { - nextServers.playwright = { command: "docker-git-playwright-mcp", args: [], trust: true } + nextServers.playwright = { command: "browser-connection", args: browserArgs, trust: true } } else { delete nextServers.playwright } diff --git a/packages/app/src/lib/core/templates-entrypoint/playwright-browser.ts b/packages/app/src/lib/core/templates-entrypoint/playwright-browser.ts deleted file mode 100644 index 53be6516..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/playwright-browser.ts +++ /dev/null @@ -1,28 +0,0 @@ -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-entrypoint/tasks.ts b/packages/app/src/lib/core/templates-entrypoint/tasks.ts index 1889eb05..b2bdac48 100644 --- a/packages/app/src/lib/core/templates-entrypoint/tasks.ts +++ b/packages/app/src/lib/core/templates-entrypoint/tasks.ts @@ -268,3 +268,82 @@ fi ${renderAgentLaunch(config)} ) &` +// CHANGE: start the external Rust browser module from the project entrypoint. +// WHY: issue #347 moves browser ownership to ProverCoderAI/rust-browser-connection while keeping docker-git as caller. +// QUOTE(ТЗ): "Вынести noVNC + MCP Playright в единый модуль." +// REF: issue-347 +// SOURCE: n/a +// FORMAT THEOREM: MCP_PLAYWRIGHT_ENABLE=1 -> eventually running(DOCKER_GIT_BROWSER_CONTAINER_NAME) +// PURITY: SHELL +// EFFECT: generated bash calls docker-git-browser-connection, which calls Docker. +// INVARIANT: browser shares the project container network namespace, so CDP is http://127.0.0.1:9223 from agents. +// COMPLEXITY: O(1) entrypoint orchestration; Docker build/run is delegated to Rust. +const renderEntrypointRustBrowserConnectionStart = (): ReadonlyArray => [ + "# Unified Rust browser connection (noVNC + CDP) for MCP Playwright + Hermes — per #347.", + "# Defaults are safe no-ops unless MCP Playwright is enabled.", + "docker_git_start_rust_browser_connection() {", + " if [[ \"${MCP_PLAYWRIGHT_ENABLE:-0}\" != \"1\" ]]; then", + " return 0", + " fi", + "", + " local browser_bin=\"\"", + " local candidate", + " for candidate in /usr/local/bin/docker-git-browser-connection /root/.cargo/bin/docker-git-browser-connection /usr/local/cargo/bin/docker-git-browser-connection $(command -v docker-git-browser-connection 2>/dev/null || true); do", + " if [[ -x \"$candidate\" ]]; then", + " browser_bin=\"$candidate\"", + " break", + " fi", + " done", + "", + " if [[ -z \"$browser_bin\" ]]; then", + " echo \"[browser] WARNING: docker-git-browser-connection not found; Playwright MCP browser is unavailable\" >&2", + " MCP_PLAYWRIGHT_ENABLE=0", + " export MCP_PLAYWRIGHT_ENABLE", + " return 0", + " fi", + "", + " local project_container=\"${DOCKER_GIT_PROJECT_CONTAINER_NAME:-$(hostname)}\"", + " local network_mode=\"container:${project_container}\"", + " mkdir -p /var/log", + " \"$browser_bin\" start --project \"$project_container\" --network \"$network_mode\" >> /var/log/docker-git-browser.log 2>&1 || {", + " echo \"[browser] WARNING: Rust browser connection failed; see /var/log/docker-git-browser.log\" >&2", + " MCP_PLAYWRIGHT_ENABLE=0", + " export MCP_PLAYWRIGHT_ENABLE", + " return 0", + " }", + " echo \"[browser] Rust browser connection is ready via $browser_bin on $network_mode\"", + "}" +] + +const renderEntrypointRustBrowserConnectionStop = (): ReadonlyArray => [ + "docker_git_stop_playwright_browser() {", + " if [[ \"${MCP_PLAYWRIGHT_ENABLE:-0}\" != \"1\" ]]; then", + " return 0", + " fi", + "", + " local browser_bin=\"\"", + " local candidate", + " for candidate in /usr/local/bin/docker-git-browser-connection /root/.cargo/bin/docker-git-browser-connection /usr/local/cargo/bin/docker-git-browser-connection $(command -v docker-git-browser-connection 2>/dev/null || true); do", + " if [[ -x \"$candidate\" ]]; then", + " browser_bin=\"$candidate\"", + " break", + " fi", + " done", + "", + " if [[ -z \"$browser_bin\" ]]; then", + " return 0", + " fi", + "", + " local project_container=\"${DOCKER_GIT_PROJECT_CONTAINER_NAME:-$(hostname)}\"", + " \"$browser_bin\" stop --project \"$project_container\" >> /var/log/docker-git-browser.log 2>&1 || true", + "}" +] + +export const renderEntrypointRustBrowserConnection = (): string => + [ + ...renderEntrypointRustBrowserConnectionStart(), + "", + ...renderEntrypointRustBrowserConnectionStop(), + "", + "docker_git_start_rust_browser_connection" + ].join("\n") diff --git a/packages/app/src/lib/core/templates.ts b/packages/app/src/lib/core/templates.ts index 129ac50f..475f07bf 100644 --- a/packages/app/src/lib/core/templates.ts +++ b/packages/app/src/lib/core/templates.ts @@ -2,19 +2,33 @@ import type { TemplateConfig } from "./domain.js" import type { ResolvedComposeResourceLimits } from "./resource-limits.js" import { renderEntrypoint } from "./templates-entrypoint.js" -import { type ComposeResourceLimits, renderDockerCompose } from "./templates/docker-compose.js" -import { renderDockerfile } from "./templates/dockerfile.js" -import { renderPlaywrightBrowserRuntime } from "./templates/playwright-browser-runtime.js" import { - renderPlaywrightBrowserDockerfile, - renderPlaywrightCdpGuard, - renderPlaywrightStartExtra -} from "./templates/playwright.js" + type ComposeResourceLimits, + type DockerComposeRenderOptions, + renderDockerCompose +} from "./templates/docker-compose.js" +import { renderDockerfile } from "./templates/dockerfile.js" + +// NOTE (Rust migration #347): +// The unified single-browser (noVNC + CDP) is now managed by the Rust binary +// `docker-git-browser-connection` (separate repo ProverCoderAI/rust-browser-connection). +// It guarantees exactly one `dg-{project}-browser` container per project. +// Legacy TS/shell browser runtime files have been replaced to avoid duplication. +// The Rust lifecycle CLI is started in background from entrypoint (see renderEntrypointRustBrowserConnection). +// MCP clients use the Rust `browser-connection` stdio server for the same shared browser container. export type FileSpec = | { readonly _tag: "File"; readonly relativePath: string; readonly contents: string; readonly mode?: number } | { readonly _tag: "Dir"; readonly relativePath: string } +export type TemplateRenderOptions = { + readonly compose: DockerComposeRenderOptions +} + +const defaultTemplateRenderOptions: TemplateRenderOptions = { + compose: { enableLocalDockerSocket: false } +} + const renderGitignore = (): string => `# docker-git project files # NOTE: bootstrap secrets stay local-only and should not be committed. @@ -51,31 +65,13 @@ const renderConfigJson = (config: TemplateConfig): string => export const planFiles = ( config: TemplateConfig, - composeResourceLimits?: ResolvedComposeResourceLimits | ComposeResourceLimits + composeResourceLimits?: ResolvedComposeResourceLimits | ComposeResourceLimits, + options: TemplateRenderOptions = defaultTemplateRenderOptions ): ReadonlyArray => { - const maybePlaywrightFiles = config.enableMcpPlaywright - ? ([ - { _tag: "File", relativePath: "Dockerfile.browser", contents: renderPlaywrightBrowserDockerfile() }, - { - _tag: "File", - relativePath: "docker-git-cdp-guard", - contents: renderPlaywrightCdpGuard(), - mode: 0o755 - }, - { - _tag: "File", - relativePath: "mcp-playwright-start-extra.sh", - contents: renderPlaywrightStartExtra(), - mode: 0o755 - }, - { - _tag: "File", - relativePath: "docker-git-browser-runtime.sh", - contents: renderPlaywrightBrowserRuntime(), - mode: 0o755 - } - ] satisfies ReadonlyArray) - : ([] satisfies ReadonlyArray) + // Old separate browser files removed — unified browser is provided by Rust module + // (started via background task in entrypoint.sh). + // No more duplication with packages/browser-connection or playwright-browser TS. + const maybePlaywrightFiles: ReadonlyArray = [] return [ { _tag: "File", relativePath: "Dockerfile", contents: renderDockerfile(config) }, @@ -83,7 +79,7 @@ export const planFiles = ( { _tag: "File", relativePath: "docker-compose.yml", - contents: renderDockerCompose(config, composeResourceLimits) + contents: renderDockerCompose(config, composeResourceLimits, options.compose) }, { _tag: "File", relativePath: ".dockerignore", contents: renderDockerignore() }, { _tag: "File", relativePath: "docker-git.json", contents: renderConfigJson(config) }, diff --git a/packages/app/src/lib/core/templates/dockerfile-prelude.ts b/packages/app/src/lib/core/templates/dockerfile-prelude.ts new file mode 100644 index 00000000..d8cb01b9 --- /dev/null +++ b/packages/app/src/lib/core/templates/dockerfile-prelude.ts @@ -0,0 +1,98 @@ +// CHANGE: use the shared link-foundation JS box as the generated project base image +// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base; the Docker Hub JS image is public and version-pinned to avoid latest drift +// QUOTE(ТЗ): "Что бы не зависить только от своих обновлений, а иметь единую инфраструктру есть смысл юзать готовый репозиторий" +// REF: issue-267 +// SOURCE: https://github.com/link-foundation/box#docker-hub---combo-boxes +// FORMAT THEOREM: renderDockerfile(config) -> base_image_default(rendered) = konard/box-js:2.1.1 +// PURITY: CORE +// INVARIANT: the rendered Dockerfile inherits JS/runtime tooling from link-foundation/box while preserving docker-git bootstrap layers +// COMPLEXITY: O(1)/O(1) +const dockerGitBaseImage = "konard/box-js:2.1.1" + +// CHANGE: include tmux and build-essential in generated project images for durable sessions and Rust crate installation. +// WHY: stable project SSH links need persisted tmux sessions, and cargo install of proc-macro/build-script dependencies requires a C linker. +// QUOTE(ТЗ): n/a +// REF: PR-309 +// SOURCE: n/a +// PURITY: CORE +// INVARIANT: generated base image contains both the terminal multiplexer and cc toolchain required before Rust browser CLI installation. +// COMPLEXITY: O(1)/O(1) +const renderDockerfileBase = (): string => + `ARG DOCKER_GIT_BASE_IMAGE=${dockerGitBaseImage} +FROM \${DOCKER_GIT_BASE_IMAGE} + +#checkov:skip=CKV_DOCKER_8: docker-git entrypoint must start as root to prepare SSH/auth/bootstrap and run sshd +USER root +ARG UBUNTU_APT_MIRROR= +ENV DEBIAN_FRONTEND=noninteractive +ENV NVM_DIR=/usr/local/nvm + +RUN set -eu; \ + if [ -n "\${UBUNTU_APT_MIRROR:-}" ]; then \ + sed -i \ + -e "s|http://archive.ubuntu.com/ubuntu|\${UBUNTU_APT_MIRROR}|g" \ + -e "s|http://security.ubuntu.com/ubuntu|\${UBUNTU_APT_MIRROR}|g" \ + /etc/apt/sources.list /etc/apt/sources.list.d/ubuntu.sources 2>/dev/null || true; \ + fi; \ + for attempt in 1 2 3 4 5; do \ + rm -rf /var/lib/apt/lists/*; \ + if apt-get -o Acquire::Retries=3 -o Acquire::By-Hash=force update; then \ + break; \ + fi; \ + if [ "$attempt" = "5" ]; then \ + echo "apt-get update failed after retries" >&2; \ + exit 1; \ + fi; \ + echo "apt-get update attempt \${attempt} failed; retrying..." >&2; \ + sleep $((attempt * 2)); \ + done; \ + apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ + openssh-server git gh ca-certificates curl unzip bsdutils sudo tmux \ + make build-essential docker.io docker-compose-v2 bash-completion zsh zsh-autosuggestions xauth \ + ncurses-term jq \ + && rm -rf /var/lib/apt/lists/*` + +// CHANGE: install the unified Rust browser connection with a current Rust toolchain. +// WHY: rust-browser-connection uses modern Cargo metadata; Ubuntu apt cargo 1.75 cannot resolve edition-2024 dependencies pulled by current crates. +// QUOTE(ТЗ): "Rust-only отдельный модуль для noVNC/browser, без TS-дублирования" +// REF: issue-347 +// SOURCE: n/a +// FORMAT THEOREM: image_build_success -> executables(/usr/local/bin/docker-git-browser-connection, /usr/local/bin/browser-connection) +// PURITY: SHELL +// EFFECT: Docker build downloads rustup and installs a pinned Git revision of the Rust crate. +// INVARIANT: generated images use rustup stable and expose both Rust lifecycle and MCP stdio binaries from an immutable upstream revision on runtime PATH. +// COMPLEXITY: O(network + cargo_build) +const renderDockerfileRustBrowserConnection = (): string => + `ENV CARGO_HOME=/usr/local/cargo +ENV RUSTUP_HOME=/usr/local/rustup +ENV PATH="/usr/local/cargo/bin:/root/.cargo/bin:/home/box/.cargo/bin:\${PATH}" +RUN set -eu; \ + curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs -o /tmp/rustup-init.sh; \ + HOME=/root sh /tmp/rustup-init.sh -y --profile minimal --default-toolchain stable --no-modify-path; \ + rm -f /tmp/rustup-init.sh; \ + rustc --version; \ + cargo --version + +# Install unified Rust browser connection (noVNC + CDP + single dg-*-browser guarantee) +# Replaces all previous TS/MCP browser-connection duplication (per issue #347) +RUN cargo install --git https://github.com/ProverCoderAI/rust-browser-connection --rev acd76d19a96763c8b5616076443d15be59fc7f78 --locked --bins --root /usr/local \ + && /usr/local/bin/docker-git-browser-connection --version \ + && /usr/local/bin/browser-connection --version + +# Passwordless sudo for all users (container is disposable) +RUN printf "%s\\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all \ + && chmod 0440 /etc/sudoers.d/zz-all` + +/** + * Renders the base image, package prelude, Rust toolchain, and browser module install. + * + * @returns Dockerfile fragment that establishes the shared project container base. + * @pure true + * @effect none; CORE template renderer only constructs a string. + * @invariant the returned fragment starts from the configured shared JS box image and installs the Rust browser lifecycle + MCP CLIs. + * @precondition docker-git generated entrypoint remains the container entrypoint. + * @postcondition the fragment keeps root available for setup and publishes both Rust browser binaries on PATH. + * @complexity O(1) time / O(1) space. + */ +export const renderDockerfilePrelude = (): string => + [renderDockerfileBase(), renderDockerfileRustBrowserConnection()].join("\n\n") diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index 37bb7a2f..2f063e8a 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -1,79 +1,10 @@ import type { TemplateConfig } from "../domain.js" import { shellSingleQuote } from "../shell-literals.js" import { renderDockerfilePrompt } from "../templates-prompt.js" +import { renderDockerfilePrelude } from "./dockerfile-prelude.js" import { renderDockerfileGlab } from "./glab.js" import { renderDockerfileGitleaks, renderDockerfileOpenCode } from "./tools.js" -// CHANGE: use the shared link-foundation JS box as the generated project base image -// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base; the Docker Hub JS image is public and version-pinned to avoid latest drift -// QUOTE(ТЗ): "Что бы не зависить только от своих обновлений, а иметь единую инфраструктру есть смысл юзать готовый репозиторий" -// REF: issue-267 -// SOURCE: https://github.com/link-foundation/box#docker-hub---combo-boxes -// FORMAT THEOREM: renderDockerfile(config) -> base_image_default(rendered) = konard/box-js:2.1.1 -// PURITY: CORE -// INVARIANT: the rendered Dockerfile inherits JS/runtime tooling from link-foundation/box while preserving docker-git bootstrap layers -// COMPLEXITY: O(1)/O(1) -const dockerGitBaseImage = "konard/box-js:2.1.1" - -// CHANGE: include tmux in generated project images for durable terminal multiplexing. -// WHY: stable project SSH links attach to persisted tmux sessions instead of one-off shell processes. -// QUOTE(ТЗ): n/a -// REF: PR-309 -// SOURCE: n/a -// PURITY: CORE -// INVARIANT: generated base image contains the terminal multiplexer required by project SSH sessions. -// COMPLEXITY: O(1)/O(1) - -/** - * Renders the base image, root user, apt mirror, core packages, and sudo prelude. - * - * @returns Dockerfile fragment that establishes the shared project container base. - * @pure true - * @effect none; CORE template renderer only constructs a string. - * @invariant the returned fragment starts from the configured shared JS box image. - * @precondition docker-git generated entrypoint remains the container entrypoint. - * @postcondition the fragment keeps root available for setup and runtime bootstrap. - * @complexity O(1) time / O(1) space. - */ -const renderDockerfilePrelude = (): string => - `ARG DOCKER_GIT_BASE_IMAGE=${dockerGitBaseImage} -FROM \${DOCKER_GIT_BASE_IMAGE} - -#checkov:skip=CKV_DOCKER_8: docker-git entrypoint must start as root to prepare SSH/auth/bootstrap and run sshd -USER root -ARG UBUNTU_APT_MIRROR= -ENV DEBIAN_FRONTEND=noninteractive -ENV NVM_DIR=/usr/local/nvm - -RUN set -eu; \ - if [ -n "\${UBUNTU_APT_MIRROR:-}" ]; then \ - sed -i \ - -e "s|http://archive.ubuntu.com/ubuntu|\${UBUNTU_APT_MIRROR}|g" \ - -e "s|http://security.ubuntu.com/ubuntu|\${UBUNTU_APT_MIRROR}|g" \ - /etc/apt/sources.list /etc/apt/sources.list.d/ubuntu.sources 2>/dev/null || true; \ - fi; \ - for attempt in 1 2 3 4 5; do \ - rm -rf /var/lib/apt/lists/*; \ - if apt-get -o Acquire::Retries=3 -o Acquire::By-Hash=force update; then \ - break; \ - fi; \ - if [ "$attempt" = "5" ]; then \ - echo "apt-get update failed after retries" >&2; \ - exit 1; \ - fi; \ - echo "apt-get update attempt \${attempt} failed; retrying..." >&2; \ - sleep $((attempt * 2)); \ - done; \ - apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ - openssh-server git gh ca-certificates curl unzip bsdutils sudo tmux \ - make docker.io docker-compose-v2 bash-completion zsh zsh-autosuggestions xauth \ - ncurses-term jq \ - && rm -rf /var/lib/apt/lists/* - -# Passwordless sudo for all users (container is disposable) -RUN printf "%s\\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all \ - && chmod 0440 /etc/sudoers.d/zz-all` - const renderDockerfileNode = (): string => `# Tooling: Node 24 (NodeSource) + nvm RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ @@ -153,92 +84,11 @@ RUN set -eu; \ const dockerGitSessionSyncPackage = "@prover-coder-ai/docker-git-session-sync@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 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 nested browser startup). -for arg in "$@"; do - case "$arg" in - -h|--help|-V|--version) - exec playwright-mcp "$@" - ;; - esac -done - -CDP_ENDPOINT="http://127.0.0.1:9223" - -# 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(fixed_nested_browser_endpoint) -> mcp_stdio_ready_before_browser_connection -# PURITY: SHELL -# 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 - -# The guarded endpoint is the nested browser opened by docker-git Open browser. -# Passing the fixed 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 -} - -JSON="" -for attempt in $(seq 1 "$MCP_PLAYWRIGHT_RETRY_ATTEMPTS"); do - if JSON="$(fetch_cdp_version)"; then - break - fi - if [[ "$attempt" -lt "$MCP_PLAYWRIGHT_RETRY_ATTEMPTS" ]]; then - echo "docker-git-playwright-mcp: waiting for nested browser runtime (attempt $attempt/$MCP_PLAYWRIGHT_RETRY_ATTEMPTS)..." >&2 - sleep "$MCP_PLAYWRIGHT_RETRY_DELAY" - fi -done - -if [[ -z "$JSON" ]]; then - echo "docker-git-playwright-mcp: failed to connect to CDP endpoint $CDP_ENDPOINT after $MCP_PLAYWRIGHT_RETRY_ATTEMPTS attempts" >&2 - exit 1 -fi - -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 - exit 1 -fi - -# Rewrite ws origin to match the CDP endpoint origin (docker DNS). -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" --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 docker-git-cdp-guard mcp-playwright-start-extra.sh docker-git-browser-runtime.sh /opt/docker-git/browser/ -RUN chmod +x /opt/docker-git/browser/docker-git-cdp-guard /opt/docker-git/browser/mcp-playwright-start-extra.sh /opt/docker-git/browser/docker-git-browser-runtime.sh` + ? `# Unified Rust browser (dg-*-browser) is started by docker-git-browser-connection binary +# No more COPY of separate browser files — single session guaranteed by Rust module (see entrypoint and rust-browser-connection repo) +# Old browser-vnc + cdp-guard duplication removed per #347` : "" /** @@ -259,11 +109,6 @@ const renderDockerfileBunProfile = (): string => const renderDockerfileBun = (config: TemplateConfig): string => [ renderDockerfileBunPrelude(config), - config.enableMcpPlaywright - ? dockerfilePlaywrightMcpBlock - .replaceAll("\\${", "${") - .replaceAll("__SERVICE_NAME__", config.serviceName) - : "", renderDockerfileBunProfile() ] .filter((chunk) => chunk.trim().length > 0) diff --git a/packages/app/src/lib/core/templates/playwright-browser-runtime.ts b/packages/app/src/lib/core/templates/playwright-browser-runtime.ts deleted file mode 100644 index b4455aca..00000000 --- a/packages/app/src/lib/core/templates/playwright-browser-runtime.ts +++ /dev/null @@ -1,217 +0,0 @@ -/* jscpd:ignore-start */ -const playwrightBrowserRuntimeScript = `#!/usr/bin/env bash -set -euo pipefail - -declare -a DOCKER_GIT_BROWSER_TEMP_FILES=() - -docker_git_browser_log() { - printf '%s\\n' "[docker-git-browser] $*" >&2 -} - -docker_git_browser_cleanup_temp_files() { - if (( \${#DOCKER_GIT_BROWSER_TEMP_FILES[@]} > 0 )); then - rm -f -- "\${DOCKER_GIT_BROWSER_TEMP_FILES[@]}" || true - fi -} - -docker_git_browser_register_temp_file() { - DOCKER_GIT_BROWSER_TEMP_FILES+=("$1") - trap docker_git_browser_cleanup_temp_files EXIT -} - -docker_git_browser_has_docker() { - command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1 -} - -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' "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 - return 0 - fi - if ! docker_git_browser_has_docker; then - return 0 - fi - docker rm -f "$container_name" >/dev/null 2>&1 || true -} - -docker_git_cleanup_orphaned_playwright_browsers() { - if ! docker_git_browser_has_docker; then - return 0 - fi - - local browser_id - while IFS= read -r browser_id; do - if [[ -z "$browser_id" ]]; then - continue - fi - - local project_container - project_container="$(docker inspect --format '{{ index .Config.Labels "docker-git.project-container" }}' "$browser_id" 2>/dev/null || true)" - - local project_running - project_running="false" - if [[ -n "$project_container" && "$project_container" != "" ]]; then - project_running="$(docker inspect --format '{{ .State.Running }}' "$project_container" 2>/dev/null || true)" - fi - - if [[ "$project_running" == "true" ]]; then - continue - fi - - local browser_name - browser_name="$(docker inspect --format '{{ .Name }}' "$browser_id" 2>/dev/null | sed 's#^/##' || true)" - docker_git_browser_log "removing orphaned browser container \${browser_name:-$browser_id}" - docker rm -f "$browser_id" >/dev/null 2>&1 || true - done < <(docker ps -a -q --filter "label=docker-git.browser=1" --filter "label=docker-git.project-container") -} - -docker_git_start_playwright_browser() { - if [[ "\${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then - docker_git_stop_playwright_browser || true - return 0 - fi - - local container_name="\${DOCKER_GIT_BROWSER_CONTAINER_NAME:-}" - local image_name="\${DOCKER_GIT_BROWSER_IMAGE_NAME:-}" - local volume_name="\${DOCKER_GIT_BROWSER_VOLUME_NAME:-}" - local main_container="\${DOCKER_GIT_PROJECT_CONTAINER_NAME:-}" - local context_dir - context_dir="$(docker_git_browser_context_dir)" - - if [[ -z "$container_name" || -z "$image_name" || -z "$volume_name" || -z "$main_container" ]]; then - docker_git_disable_playwright_mcp "missing browser runtime configuration" - return 0 - fi - if ! docker_git_browser_has_docker; then - docker_git_disable_playwright_mcp "Docker API is unavailable" - return 0 - fi - if [[ ! -f "$context_dir/Dockerfile.browser" ]]; then - docker_git_disable_playwright_mcp "browser Dockerfile is missing at $context_dir/Dockerfile.browser" - return 0 - fi - - docker_git_stop_playwright_browser || true - docker_git_cleanup_orphaned_playwright_browsers || true - - local build_log - if ! build_log="$(mktemp "\${TMPDIR:-/tmp}/docker-git-browser-build.XXXXXX.log" 2>/dev/null)"; then - docker_git_disable_playwright_mcp "failed to create browser build log" - return 0 - fi - docker_git_browser_register_temp_file "$build_log" - - local build_timeout - build_timeout="\${DOCKER_GIT_BROWSER_BUILD_TIMEOUT_SECONDS:-600}" - - docker_git_browser_log "building $image_name" - timeout "$build_timeout" docker build -t "$image_name" -f "$context_dir/Dockerfile.browser" "$context_dir" >"$build_log" 2>&1 || { - 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" - - if ! docker volume create "$volume_name" >/dev/null 2>&1; then - docker_git_browser_log "failed to create browser data volume $volume_name; continuing" - fi - - local args=( - run - -d - --name "$container_name" - --label "docker-git.browser=1" - --label "docker-git.project-container=$main_container" - --network "container:$main_container" - --shm-size "2g" - -e "VNC_NOPW=1" - -e "MCP_PLAYWRIGHT_CDP_GUARD=\${MCP_PLAYWRIGHT_CDP_GUARD:-1}" - -e "MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE=\${MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE:-1}" - -v "$volume_name:/data" - ) - - if [[ -n "\${DOCKER_GIT_BROWSER_CPU_LIMIT:-}" ]]; then - args+=(--cpus "$DOCKER_GIT_BROWSER_CPU_LIMIT") - fi - if [[ -n "\${DOCKER_GIT_BROWSER_RAM_LIMIT:-}" ]]; then - args+=(--memory "$DOCKER_GIT_BROWSER_RAM_LIMIT" --memory-swap "$DOCKER_GIT_BROWSER_RAM_LIMIT") - fi - - docker_git_browser_log "starting $container_name inside $main_container network namespace" - docker "\${args[@]}" "$image_name" >/dev/null || { - 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 - } -} -` - -// CHANGE: manage the Playwright browser as a nested Docker container owned by the project container. -// WHY: issue #306 follow-up requires browser containers to inherit project lifecycle while keeping separate limits. -// QUOTE(ТЗ): "пусть он поднимается внутри dg-issues1 а не где-то из вне" -// REF: issue-306-browser-nested-runtime -// SOURCE: n/a -// FORMAT THEOREM: start(main) -> running(browser) with network(browser) = container:main OR logged_warning -// PURITY: SHELL -// EFFECT: shell commands executed by generated entrypoint -// INVARIANT: browser data volume is preserved; runtime cleanup removes only browser-labeled containers -// COMPLEXITY: O(b + build + docker-run)/O(1), where b = browser-labeled containers -export const renderPlaywrightBrowserRuntime = (): string => playwrightBrowserRuntimeScript -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates/playwright.ts b/packages/app/src/lib/core/templates/playwright.ts deleted file mode 100644 index c7c58c48..00000000 --- a/packages/app/src/lib/core/templates/playwright.ts +++ /dev/null @@ -1,284 +0,0 @@ -/* jscpd:ignore-start */ -export const renderPlaywrightBrowserDockerfile = (): string => - `FROM kechangdev/browser-vnc:latest - -# bash for noVNC startup, procps for ps -p used by novnc_proxy, socat for CDP fallback -# nodejs/npm/ws for the CDP guard, python3/net-tools for diagnostics -RUN apk add --no-cache bash procps socat nodejs npm python3 net-tools -RUN npm install --omit=dev --prefix /opt/docker-git-cdp-guard ws@8.18.3 - -COPY docker-git-cdp-guard /usr/local/bin/docker-git-cdp-guard -RUN chmod +x /usr/local/bin/docker-git-cdp-guard - -COPY mcp-playwright-start-extra.sh /usr/local/bin/mcp-playwright-start-extra.sh -RUN chmod +x /usr/local/bin/mcp-playwright-start-extra.sh - -# Start extra services in background, keep base stack in foreground -# Clear stale Chromium profile locks before boot -ENTRYPOINT ["/bin/sh", "-lc", "rm -f /data/SingletonLock /data/SingletonCookie /data/SingletonSocket || true; /usr/local/bin/mcp-playwright-start-extra.sh & exec /start.sh"]` - -const cdpGuardScript = String.raw`#!/usr/bin/env node -"use strict"; - -const http = require("node:http"); -const { URL } = require("node:url"); -const { WebSocket, WebSocketServer } = require("/opt/docker-git-cdp-guard/node_modules/ws"); - -const upstreamHost = "127.0.0.1"; -const upstreamPort = 9222; -const listenHost = "0.0.0.0"; -const listenPort = 9223; -const blockedMethods = new Set(["Browser.close", "Browser.crash", "Browser.crashGpuProcess"]); - -const log = (message) => process.stderr.write("[docker-git-cdp-guard] " + message + "\n"); - -const shouldBlockBrowserClose = () => process.env.MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE !== "0"; - -const requestHost = (request) => { - const host = request.headers.host; - return typeof host === "string" && host.length > 0 ? host : "127.0.0.1:" + listenPort; -}; - -const rewriteWebSocketUrl = (value, host) => { - try { - const url = new URL(value); - url.protocol = "ws:"; - url.host = host; - return url.toString(); - } catch { - return value; - } -}; - -const rewriteDebuggerUrls = (value, host) => { - if (Array.isArray(value)) { - return value.map((item) => rewriteDebuggerUrls(item, host)); - } - if (value === null || typeof value !== "object") { - return value; - } - return Object.fromEntries( - Object.entries(value).map(([key, child]) => [ - key, - key === "webSocketDebuggerUrl" && typeof child === "string" - ? rewriteWebSocketUrl(child, host) - : rewriteDebuggerUrls(child, host) - ]) - ); -}; - -const rewriteJsonBody = (body, host) => { - try { - return Buffer.from(JSON.stringify(rewriteDebuggerUrls(JSON.parse(body.toString("utf8")), host))); - } catch { - return body; - } -}; - -const proxyHttp = (request, response) => { - const chunks = []; - request.on("data", (chunk) => chunks.push(chunk)); - request.on("end", () => { - const headers = { ...request.headers, host: upstreamHost + ":" + upstreamPort }; - delete headers.connection; - delete headers["content-length"]; - const upstream = http.request( - { - hostname: upstreamHost, - port: upstreamPort, - method: request.method, - path: request.url || "/", - headers - }, - (upstreamResponse) => { - const upstreamChunks = []; - upstreamResponse.on("data", (chunk) => upstreamChunks.push(chunk)); - upstreamResponse.on("end", () => { - const rawBody = Buffer.concat(upstreamChunks); - const body = (request.url || "/").startsWith("/json") - ? rewriteJsonBody(rawBody, requestHost(request)) - : rawBody; - const responseHeaders = { ...upstreamResponse.headers }; - delete responseHeaders["content-length"]; - delete responseHeaders["content-encoding"]; - response.writeHead(upstreamResponse.statusCode || 502, responseHeaders); - response.end(body); - }); - } - ); - upstream.on("error", (error) => { - response.writeHead(502, { "content-type": "text/plain; charset=utf-8" }); - response.end("CDP upstream unavailable: " + error.message + "\n"); - }); - upstream.end(Buffer.concat(chunks)); - }); -}; - -const fetchCurrentBrowserPath = () => - new Promise((resolve, reject) => { - const request = http.get( - { - hostname: upstreamHost, - port: upstreamPort, - path: "/json/version", - headers: { host: upstreamHost + ":" + upstreamPort } - }, - (response) => { - const chunks = []; - response.on("data", (chunk) => chunks.push(chunk)); - response.on("end", () => { - try { - const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); - const raw = typeof parsed.webSocketDebuggerUrl === "string" ? parsed.webSocketDebuggerUrl : ""; - const path = raw.length > 0 ? new URL(raw).pathname : ""; - path.length > 0 ? resolve(path) : reject(new Error("webSocketDebuggerUrl missing")); - } catch (error) { - reject(error); - } - }); - } - ); - request.on("error", reject); - }); - -const upstreamPathFor = async (rawPath) => { - const path = rawPath || "/"; - return path.startsWith("/devtools/browser/") ? await fetchCurrentBrowserPath() : path; -}; - -const parseMessage = (data) => JSON.parse(Buffer.isBuffer(data) ? data.toString("utf8") : String(data)); - -const isBlockedCdpMessage = (data) => { - if (!shouldBlockBrowserClose()) { - return false; - } - try { - const message = parseMessage(data); - return message !== null && typeof message === "object" && blockedMethods.has(message.method); - } catch { - return false; - } -}; - -const blockedCdpResponse = (data) => { - try { - const message = parseMessage(data); - return Object.prototype.hasOwnProperty.call(message, "id") - ? JSON.stringify({ id: message.id, result: {} }) - : ""; - } catch { - return ""; - } -}; - -const handleWebSocket = async (client, request) => { - const pending = []; - let upstream = null; - const forwardToUpstream = (data, isBinary) => { - if (!upstream || upstream.readyState !== WebSocket.OPEN) { - pending.push([data, isBinary]); - return; - } - if (!isBinary && isBlockedCdpMessage(data)) { - const response = blockedCdpResponse(data); - if (response.length > 0 && client.readyState === WebSocket.OPEN) { - client.send(response); - } - return; - } - upstream.send(data, { binary: isBinary }); - }; - - client.on("message", forwardToUpstream); - - try { - const upstreamPath = await upstreamPathFor(request.url || "/"); - upstream = new WebSocket("ws://" + upstreamHost + ":" + upstreamPort + upstreamPath, { - headers: { host: upstreamHost + ":" + upstreamPort } - }); - upstream.on("open", () => { - for (const [data, isBinary] of pending.splice(0)) { - forwardToUpstream(data, isBinary); - } - }); - upstream.on("message", (data, isBinary) => { - if (client.readyState === WebSocket.OPEN) { - client.send(data, { binary: isBinary }); - } - }); - upstream.on("close", (code, reason) => { - if (client.readyState === WebSocket.OPEN) { - client.close(code, reason); - } - }); - upstream.on("error", (error) => { - log("upstream websocket error: " + error.message); - if (client.readyState === WebSocket.OPEN) { - client.close(1011, "CDP upstream websocket error"); - } - }); - client.on("close", () => { - if (upstream && upstream.readyState === WebSocket.OPEN) { - upstream.close(); - } - }); - } catch (error) { - log("websocket setup failed: " + error.message); - client.close(1011, "CDP upstream unavailable"); - } -}; - -const server = http.createServer(proxyHttp); -const wss = new WebSocketServer({ noServer: true }); - -server.on("upgrade", (request, socket, head) => { - wss.handleUpgrade(request, socket, head, (client) => { - handleWebSocket(client, request); - }); -}); - -server.listen(listenPort, listenHost, () => { - log("listening on " + listenHost + ":" + listenPort + " -> " + upstreamHost + ":" + upstreamPort); -}); -` - -export const renderPlaywrightCdpGuard = (): string => cdpGuardScript - -export const renderPlaywrightStartExtra = (): string => - `#!/bin/sh -set -eu - -# Clear stale Chromium locks from previous container runs -rm -f /data/SingletonLock /data/SingletonCookie /data/SingletonSocket || true - -# Wait for chromium/x11vnc/noVNC to come up -sleep 2 - -start_cdp_fallback() { - socat TCP-LISTEN:9223,fork,reuseaddr TCP:127.0.0.1:9222 >/var/log/socat-9223.log 2>&1 & -} - -# CDP guard: expose 9223 on the docker network and block browser-level destructive CDP methods -if [ "\${MCP_PLAYWRIGHT_CDP_GUARD:-1}" = "1" ]; then - docker-git-cdp-guard >/var/log/docker-git-cdp-guard.log 2>&1 & - guard_pid="$!" - sleep 1 - if ! kill -0 "$guard_pid" 2>/dev/null; then - echo "docker-git-cdp-guard exited during startup; falling back to socat" >&2 - sed -n '1,120p' /var/log/docker-git-cdp-guard.log 2>/dev/null >&2 || true - start_cdp_fallback - fi -else - start_cdp_fallback -fi - -# Optional VNC password disabling (useful if you publish VNC/noVNC ports) -if [ "\${VNC_NOPW:-1}" = "1" ]; then - pkill x11vnc || true - x11vnc -display :99 -rfbport 5900 -nopw -forever -shared -bg -o /var/log/x11vnc-nopw.log -fi - -echo "extra services started" -exit 0 -` -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/usecases/actions/prepare-files.ts b/packages/app/src/lib/usecases/actions/prepare-files.ts index 6abcc4e4..e83d0282 100644 --- a/packages/app/src/lib/usecases/actions/prepare-files.ts +++ b/packages/app/src/lib/usecases/actions/prepare-files.ts @@ -231,8 +231,6 @@ const defaultProjectEnvContents = [ "DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=fg=8,italic", "DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=history completion", "MCP_PLAYWRIGHT_ISOLATED=0", - "MCP_PLAYWRIGHT_CDP_GUARD=1", - "MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE=1", "" ].join("\n") diff --git a/packages/app/src/lib/usecases/auth-gemini-helpers.ts b/packages/app/src/lib/usecases/auth-gemini-helpers.ts index 6a74b0ae..be8faa7f 100644 --- a/packages/app/src/lib/usecases/auth-gemini-helpers.ts +++ b/packages/app/src/lib/usecases/auth-gemini-helpers.ts @@ -299,7 +299,7 @@ export const defaultGeminiSettings = { }, mcpServers: { playwright: { - command: "docker-git-playwright-mcp", + command: "browser-connection", args: [], trust: true } diff --git a/packages/app/src/lib/usecases/auth-grok-helpers.ts b/packages/app/src/lib/usecases/auth-grok-helpers.ts index e03efbc1..27e714ff 100644 --- a/packages/app/src/lib/usecases/auth-grok-helpers.ts +++ b/packages/app/src/lib/usecases/auth-grok-helpers.ts @@ -239,7 +239,7 @@ export const defaultGrokProjectSettings = { sandboxMode: "off", mcpServers: { playwright: { - command: "docker-git-playwright-mcp", + command: "browser-connection", args: [], trust: true } diff --git a/packages/app/tests/docker-git/core-templates.test.ts b/packages/app/tests/docker-git/core-templates.test.ts index aa91a40e..79266604 100644 --- a/packages/app/tests/docker-git/core-templates.test.ts +++ b/packages/app/tests/docker-git/core-templates.test.ts @@ -51,50 +51,25 @@ describe("app planFiles", () => { expect(entrypoint.contents).toContain("sync_dir_entries \"$BOOTSTRAP_GROK_AUTH_DIR\" \"$DOCKER_GIT_GROK_AUTH_DIR\"") }) - it("includes nested browser runtime artifacts when Playwright is enabled", () => { + it("uses the Rust browser connection module when Playwright is enabled", () => { const files = planFiles(makeTemplateConfig({ enableMcpPlaywright: true })) const filePaths = getGeneratedFilePaths(files) - const runtime = getGeneratedFile(files, "docker-git-browser-runtime.sh") - const cdpGuard = getGeneratedFile(files, "docker-git-cdp-guard") - const browserDockerfile = getGeneratedFile(files, "Dockerfile.browser") - const startExtra = getGeneratedFile(files, "mcp-playwright-start-extra.sh") const dockerfile = getGeneratedFile(files, "Dockerfile") + const entrypoint = getGeneratedFile(files, "entrypoint.sh") - expect(filePaths).toContain("Dockerfile.browser") - expect(filePaths).toContain("docker-git-cdp-guard") - expect(filePaths).toContain("mcp-playwright-start-extra.sh") - expect(filePaths).toContain("docker-git-browser-runtime.sh") - expect(cdpGuard.mode).toBe(0o755) - expect(cdpGuard.contents).toContain("#!/usr/bin/env node") - expect(cdpGuard.contents).toContain("const upstreamHost = \"127.0.0.1\";") - expect(cdpGuard.contents).toContain("const upstreamPort = 9222;") - expect(cdpGuard.contents).toContain("const listenHost = \"0.0.0.0\";") - expect(cdpGuard.contents).toContain("const listenPort = 9223;") - expect(cdpGuard.contents).not.toContain("MCP_PLAYWRIGHT_UPSTREAM_CDP_HOST") - expect(cdpGuard.contents).not.toContain("MCP_PLAYWRIGHT_CDP_GUARD_PORT") - expect(cdpGuard.contents).toContain("Browser.close") - expect(browserDockerfile.contents).toContain("COPY docker-git-cdp-guard /usr/local/bin/docker-git-cdp-guard") - expect(browserDockerfile.contents).not.toContain("RUN cat <<'EOF' > /usr/local/bin/docker-git-cdp-guard") - expect(startExtra.contents).toContain("guard_pid=\"$!\"") - expect(startExtra.contents).toContain("falling back to socat") - expect(startExtra.contents).toContain("socat TCP-LISTEN:9223,fork,reuseaddr TCP:127.0.0.1:9222") - expect(runtime.mode).toBe(0o755) - expect(runtime.contents).toContain("if [[ \"${MCP_PLAYWRIGHT_ENABLE:-0}\" != \"1\" ]]; then") - expect(runtime.contents).toContain(String.raw`printf '%s\n' "http://127.0.0.1:9223"`) - expect(runtime.contents).not.toContain("printf '%s\\n' \"${MCP_PLAYWRIGHT_CDP_ENDPOINT:-http://127.0.0.1:9223}\"") - 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(filePaths).not.toContain("Dockerfile.browser") + expect(filePaths).not.toContain("docker-git-cdp-guard") + expect(filePaths).not.toContain("docker-git-browser-runtime.sh") expect(dockerfile.contents).toContain( - "COPY Dockerfile.browser docker-git-cdp-guard mcp-playwright-start-extra.sh docker-git-browser-runtime.sh /opt/docker-git/browser/" + "cargo install --git https://github.com/ProverCoderAI/rust-browser-connection" ) - 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("CDP_ENDPOINT=\"http://127.0.0.1:9223\"") - expect(dockerfile.contents).not.toContain("CDP_ENDPOINT=\"${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}\"") - 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\")") + expect(dockerfile.contents).toContain("make build-essential docker.io") + expect(dockerfile.contents).toContain("/usr/local/bin/browser-connection --version") + expect(dockerfile.contents).not.toContain("docker-git-playwright-mcp") + expect(entrypoint.contents).toContain("docker_git_start_rust_browser_connection") + expect(entrypoint.contents).toContain("docker_git_stop_playwright_browser()") + expect(entrypoint.contents).toContain("docker-git-browser-connection") + expect(entrypoint.contents).toContain("local network_mode=\"container:${project_container}\"") + expect(entrypoint.contents).toContain("stop --project \"$project_container\"") }) }) diff --git a/packages/lib/src/core/templates-entrypoint.ts b/packages/lib/src/core/templates-entrypoint.ts index e3dcbab4..96fdd684 100644 --- a/packages/lib/src/core/templates-entrypoint.ts +++ b/packages/lib/src/core/templates-entrypoint.ts @@ -26,10 +26,9 @@ 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" +import { renderEntrypointBackgroundTasks, renderEntrypointRustBrowserConnection } from "./templates-entrypoint/tasks.js" import { renderEntrypointBashCompletion, renderEntrypointBashHistory, @@ -59,7 +58,7 @@ export const renderEntrypoint = (config: TemplateConfig): string => renderEntrypointProjectAgentRules(), renderEntrypointAgentsNotice(config), renderEntrypointDockerSocket(config), - renderEntrypointPlaywrightBrowserRuntime(config), + renderEntrypointRustBrowserConnection(), renderEntrypointMcpPlaywright(config), renderEntrypointGitConfig(config), renderEntrypointClaudeConfig(config), diff --git a/packages/lib/src/core/templates-entrypoint/base.ts b/packages/lib/src/core/templates-entrypoint/base.ts index 24461844..85b352fc 100644 --- a/packages/lib/src/core/templates-entrypoint/base.ts +++ b/packages/lib/src/core/templates-entrypoint/base.ts @@ -41,8 +41,6 @@ AGENT_MODE="\${AGENT_MODE:-}" AGENT_AUTO="\${AGENT_AUTO:-}" MCP_PLAYWRIGHT_ENABLE="\${MCP_PLAYWRIGHT_ENABLE:-${config.enableMcpPlaywright ? "1" : "0"}}" MCP_PLAYWRIGHT_ISOLATED="\${MCP_PLAYWRIGHT_ISOLATED:-0}" -MCP_PLAYWRIGHT_CDP_GUARD="\${MCP_PLAYWRIGHT_CDP_GUARD:-1}" -MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE="\${MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE:-1}" SSH_ENV_PATH="/home/${config.sshUser}/.ssh/environment" diff --git a/packages/lib/src/core/templates-entrypoint/claude.ts b/packages/lib/src/core/templates-entrypoint/claude.ts index 25b7f84d..b7659728 100644 --- a/packages/lib/src/core/templates-entrypoint/claude.ts +++ b/packages/lib/src/core/templates-entrypoint/claude.ts @@ -192,16 +192,18 @@ 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:-0}" node - <<'NODE' + local browser_project="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"; [[ -n "$browser_project" ]] || browser_project="$(hostname)" + local browser_network="container:$browser_project" + CLAUDE_SETTINGS_FILE="$CLAUDE_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" DOCKER_GIT_BROWSER_PROJECT="$browser_project" DOCKER_GIT_BROWSER_NETWORK="$browser_network" node - <<'NODE' const fs = require("node:fs") const path = require("node:path") const settingsPath = process.env.CLAUDE_SETTINGS_FILE -if (typeof settingsPath !== "string" || settingsPath.length === 0) { - process.exit(0) -} +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) const enablePlaywright = process.env.MCP_PLAYWRIGHT_ENABLE === "1" +const browserProject = process.env.DOCKER_GIT_BROWSER_PROJECT || "" +const browserArgs = browserProject.length > 0 ? ["--project", browserProject, "--network", process.env.DOCKER_GIT_BROWSER_NETWORK || "container:" + browserProject] : [] const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) let settings = {} @@ -209,17 +211,15 @@ try { const raw = fs.readFileSync(settingsPath, "utf8") const parsed = JSON.parse(raw) settings = isRecord(parsed) ? parsed : {} -} catch { - settings = {} -} +} catch { settings = {} } const currentServers = isRecord(settings.mcpServers) ? settings.mcpServers : {} const nextServers = { ...currentServers } if (enablePlaywright) { nextServers.playwright = { type: "stdio", - command: "docker-git-playwright-mcp", - args: [], + command: "browser-connection", + args: browserArgs, env: {} } } else { diff --git a/packages/lib/src/core/templates-entrypoint/codex.ts b/packages/lib/src/core/templates-entrypoint/codex.ts index 9099e78d..be5b033f 100644 --- a/packages/lib/src/core/templates-entrypoint/codex.ts +++ b/packages/lib/src/core/templates-entrypoint/codex.ts @@ -50,16 +50,22 @@ if [[ "$CODEX_SHARE_AUTH" == "1" ]]; then docker_git_upsert_ssh_env "CODEX_AUTH_LABEL" "$CODEX_AUTH_LABEL" fi` -const entrypointMcpPlaywrightTemplate = String.raw`# Optional: configure Playwright MCP for Codex (browser automation) +const entrypointMcpPlaywrightTemplate = String.raw`# Optional: configure Browser MCP for Codex (Rust browser-connection) CODEX_CONFIG_FILE="__CODEX_HOME__/config.toml" +DOCKER_GIT_BROWSER_PROJECT="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}" +if [[ -z "$DOCKER_GIT_BROWSER_PROJECT" ]]; then + DOCKER_GIT_BROWSER_PROJECT="$(hostname)" +fi +DOCKER_GIT_BROWSER_NETWORK="container:$DOCKER_GIT_BROWSER_PROJECT" # Keep config.toml consistent with the container build. -# If Playwright MCP is disabled for this container, remove the block so Codex -# doesn't try (and fail) to spawn docker-git-playwright-mcp. +# If browser MCP is disabled for this container, remove the block so Codex +# doesn't try (and fail) to spawn browser-connection. if [[ "$MCP_PLAYWRIGHT_ENABLE" != "1" ]]; then if [[ -f "$CODEX_CONFIG_FILE" ]] && grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then awk ' BEGIN { skip=0 } + /^# docker-git: Browser MCP/ { next } /^# docker-git: Playwright MCP/ { next } /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } skip==1 && /^\[/ { skip=0 } @@ -97,10 +103,11 @@ EOF chown 1000:1000 "$CODEX_CONFIG_FILE" || true fi - # Replace the docker-git Playwright block to allow upgrades via --force without manual edits. + # Replace the docker-git Browser MCP block to allow upgrades via --force without manual edits. if grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then awk ' BEGIN { skip=0 } + /^# docker-git: Browser MCP/ { next } /^# docker-git: Playwright MCP/ { next } /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } skip==1 && /^\[/ { skip=0 } @@ -111,10 +118,10 @@ EOF cat <> "$CODEX_CONFIG_FILE" -# docker-git: Playwright MCP (connects to Chromium via CDP) +# docker-git: Browser MCP (rust-browser-connection) [mcp_servers.playwright] -command = "docker-git-playwright-mcp" -args = [] +command = "browser-connection" +args = ["--project", "$DOCKER_GIT_BROWSER_PROJECT", "--network", "$DOCKER_GIT_BROWSER_NETWORK"] EOF fi` diff --git a/packages/lib/src/core/templates-entrypoint/gemini.ts b/packages/lib/src/core/templates-entrypoint/gemini.ts index f41fba74..6d76ea22 100644 --- a/packages/lib/src/core/templates-entrypoint/gemini.ts +++ b/packages/lib/src/core/templates-entrypoint/gemini.ts @@ -199,7 +199,9 @@ fi` 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' + local browser_project="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"; [[ -n "$browser_project" ]] || browser_project="$(hostname)" + local browser_network="container:$browser_project" + GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" DOCKER_GIT_BROWSER_PROJECT="$browser_project" DOCKER_GIT_BROWSER_NETWORK="$browser_network" node - <<'NODE' const fs = require("node:fs") const path = require("node:path") const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE @@ -212,9 +214,11 @@ try { if (isRecord(parsed)) settings = parsed } catch {} +const browserProject = process.env.DOCKER_GIT_BROWSER_PROJECT || "" +const browserArgs = browserProject.length > 0 ? ["--project", browserProject, "--network", process.env.DOCKER_GIT_BROWSER_NETWORK || "container:" + browserProject] : [] const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") { - nextServers.playwright = { command: "docker-git-playwright-mcp", args: [], trust: true } + nextServers.playwright = { command: "browser-connection", args: browserArgs, trust: true } } else { delete nextServers.playwright } diff --git a/packages/lib/src/core/templates-entrypoint/grok.ts b/packages/lib/src/core/templates-entrypoint/grok.ts index d297b5af..1d2b46eb 100644 --- a/packages/lib/src/core/templates-entrypoint/grok.ts +++ b/packages/lib/src/core/templates-entrypoint/grok.ts @@ -184,7 +184,12 @@ chmod 0600 "$GROK_CONFIG_SETTINGS_FILE" "$GROK_USER_SETTINGS_FILE" 2>/dev/null | 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' + local browser_project="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}" + if [[ -z "$browser_project" ]]; then + browser_project="$(hostname)" + fi + local browser_network="container:$browser_project" + GROK_CONFIG_SETTINGS_FILE="$GROK_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" DOCKER_GIT_BROWSER_PROJECT="$browser_project" DOCKER_GIT_BROWSER_NETWORK="$browser_network" node - <<'NODE' const fs = require("node:fs") const path = require("node:path") const settingsPath = process.env.GROK_CONFIG_SETTINGS_FILE @@ -197,9 +202,12 @@ try { if (isRecord(parsed)) settings = parsed } catch {} +const browserProject = process.env.DOCKER_GIT_BROWSER_PROJECT || "" +const browserNetwork = process.env.DOCKER_GIT_BROWSER_NETWORK || (browserProject.length > 0 ? "container:" + browserProject : "") +const browserArgs = browserProject.length > 0 ? ["--project", browserProject, "--network", browserNetwork] : [] const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") { - nextServers.playwright = { command: "docker-git-playwright-mcp", args: [], trust: true } + nextServers.playwright = { command: "browser-connection", args: browserArgs, trust: true } } else { delete nextServers.playwright } diff --git a/packages/lib/src/core/templates-entrypoint/playwright-browser.ts b/packages/lib/src/core/templates-entrypoint/playwright-browser.ts deleted file mode 100644 index d1fdf90d..00000000 --- a/packages/lib/src/core/templates-entrypoint/playwright-browser.ts +++ /dev/null @@ -1,28 +0,0 @@ -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/lib/src/core/templates-entrypoint/tasks.ts b/packages/lib/src/core/templates-entrypoint/tasks.ts index 1889eb05..b2bdac48 100644 --- a/packages/lib/src/core/templates-entrypoint/tasks.ts +++ b/packages/lib/src/core/templates-entrypoint/tasks.ts @@ -268,3 +268,82 @@ fi ${renderAgentLaunch(config)} ) &` +// CHANGE: start the external Rust browser module from the project entrypoint. +// WHY: issue #347 moves browser ownership to ProverCoderAI/rust-browser-connection while keeping docker-git as caller. +// QUOTE(ТЗ): "Вынести noVNC + MCP Playright в единый модуль." +// REF: issue-347 +// SOURCE: n/a +// FORMAT THEOREM: MCP_PLAYWRIGHT_ENABLE=1 -> eventually running(DOCKER_GIT_BROWSER_CONTAINER_NAME) +// PURITY: SHELL +// EFFECT: generated bash calls docker-git-browser-connection, which calls Docker. +// INVARIANT: browser shares the project container network namespace, so CDP is http://127.0.0.1:9223 from agents. +// COMPLEXITY: O(1) entrypoint orchestration; Docker build/run is delegated to Rust. +const renderEntrypointRustBrowserConnectionStart = (): ReadonlyArray => [ + "# Unified Rust browser connection (noVNC + CDP) for MCP Playwright + Hermes — per #347.", + "# Defaults are safe no-ops unless MCP Playwright is enabled.", + "docker_git_start_rust_browser_connection() {", + " if [[ \"${MCP_PLAYWRIGHT_ENABLE:-0}\" != \"1\" ]]; then", + " return 0", + " fi", + "", + " local browser_bin=\"\"", + " local candidate", + " for candidate in /usr/local/bin/docker-git-browser-connection /root/.cargo/bin/docker-git-browser-connection /usr/local/cargo/bin/docker-git-browser-connection $(command -v docker-git-browser-connection 2>/dev/null || true); do", + " if [[ -x \"$candidate\" ]]; then", + " browser_bin=\"$candidate\"", + " break", + " fi", + " done", + "", + " if [[ -z \"$browser_bin\" ]]; then", + " echo \"[browser] WARNING: docker-git-browser-connection not found; Playwright MCP browser is unavailable\" >&2", + " MCP_PLAYWRIGHT_ENABLE=0", + " export MCP_PLAYWRIGHT_ENABLE", + " return 0", + " fi", + "", + " local project_container=\"${DOCKER_GIT_PROJECT_CONTAINER_NAME:-$(hostname)}\"", + " local network_mode=\"container:${project_container}\"", + " mkdir -p /var/log", + " \"$browser_bin\" start --project \"$project_container\" --network \"$network_mode\" >> /var/log/docker-git-browser.log 2>&1 || {", + " echo \"[browser] WARNING: Rust browser connection failed; see /var/log/docker-git-browser.log\" >&2", + " MCP_PLAYWRIGHT_ENABLE=0", + " export MCP_PLAYWRIGHT_ENABLE", + " return 0", + " }", + " echo \"[browser] Rust browser connection is ready via $browser_bin on $network_mode\"", + "}" +] + +const renderEntrypointRustBrowserConnectionStop = (): ReadonlyArray => [ + "docker_git_stop_playwright_browser() {", + " if [[ \"${MCP_PLAYWRIGHT_ENABLE:-0}\" != \"1\" ]]; then", + " return 0", + " fi", + "", + " local browser_bin=\"\"", + " local candidate", + " for candidate in /usr/local/bin/docker-git-browser-connection /root/.cargo/bin/docker-git-browser-connection /usr/local/cargo/bin/docker-git-browser-connection $(command -v docker-git-browser-connection 2>/dev/null || true); do", + " if [[ -x \"$candidate\" ]]; then", + " browser_bin=\"$candidate\"", + " break", + " fi", + " done", + "", + " if [[ -z \"$browser_bin\" ]]; then", + " return 0", + " fi", + "", + " local project_container=\"${DOCKER_GIT_PROJECT_CONTAINER_NAME:-$(hostname)}\"", + " \"$browser_bin\" stop --project \"$project_container\" >> /var/log/docker-git-browser.log 2>&1 || true", + "}" +] + +export const renderEntrypointRustBrowserConnection = (): string => + [ + ...renderEntrypointRustBrowserConnectionStart(), + "", + ...renderEntrypointRustBrowserConnectionStop(), + "", + "docker_git_start_rust_browser_connection" + ].join("\n") diff --git a/packages/lib/src/core/templates.ts b/packages/lib/src/core/templates.ts index 541276ba..0a53822a 100644 --- a/packages/lib/src/core/templates.ts +++ b/packages/lib/src/core/templates.ts @@ -7,12 +7,14 @@ import { renderDockerCompose } from "./templates/docker-compose.js" import { renderDockerfile } from "./templates/dockerfile.js" -import { renderPlaywrightBrowserRuntime } from "./templates/playwright-browser-runtime.js" -import { - renderPlaywrightBrowserDockerfile, - renderPlaywrightCdpGuard, - renderPlaywrightStartExtra -} from "./templates/playwright.js" + +// NOTE (Rust migration #347): +// The unified single-browser (noVNC + CDP) is now managed by the Rust binary +// `docker-git-browser-connection` (separate repo ProverCoderAI/rust-browser-connection). +// It guarantees exactly one `dg-{project}-browser` container per project. +// Legacy TS/shell browser runtime files have been replaced to avoid duplication. +// The Rust lifecycle CLI is started in background from entrypoint (see renderEntrypointRustBrowserConnection). +// MCP clients use the Rust `browser-connection` stdio server for the same shared browser container. export type FileSpec = | { readonly _tag: "File"; readonly relativePath: string; readonly contents: string; readonly mode?: number } @@ -65,29 +67,10 @@ export const planFiles = ( composeResourceLimits?: ResolvedComposeResourceLimits | ComposeResourceLimits, options: TemplateRenderOptions = defaultTemplateRenderOptions ): ReadonlyArray => { - const maybePlaywrightFiles = config.enableMcpPlaywright - ? ([ - { _tag: "File", relativePath: "Dockerfile.browser", contents: renderPlaywrightBrowserDockerfile() }, - { - _tag: "File", - relativePath: "docker-git-cdp-guard", - contents: renderPlaywrightCdpGuard(), - mode: 0o755 - }, - { - _tag: "File", - relativePath: "mcp-playwright-start-extra.sh", - contents: renderPlaywrightStartExtra(), - mode: 0o755 - }, - { - _tag: "File", - relativePath: "docker-git-browser-runtime.sh", - contents: renderPlaywrightBrowserRuntime(), - mode: 0o755 - } - ] satisfies ReadonlyArray) - : ([] satisfies ReadonlyArray) + // Old separate browser files removed — unified browser is provided by Rust module + // (started via background task in entrypoint.sh). + // No more duplication with packages/browser-connection or playwright-browser TS. + const maybePlaywrightFiles: ReadonlyArray = [] return [ { _tag: "File", relativePath: "Dockerfile", contents: renderDockerfile(config) }, diff --git a/packages/lib/src/core/templates/dockerfile-prelude.ts b/packages/lib/src/core/templates/dockerfile-prelude.ts new file mode 100644 index 00000000..d8cb01b9 --- /dev/null +++ b/packages/lib/src/core/templates/dockerfile-prelude.ts @@ -0,0 +1,98 @@ +// CHANGE: use the shared link-foundation JS box as the generated project base image +// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base; the Docker Hub JS image is public and version-pinned to avoid latest drift +// QUOTE(ТЗ): "Что бы не зависить только от своих обновлений, а иметь единую инфраструктру есть смысл юзать готовый репозиторий" +// REF: issue-267 +// SOURCE: https://github.com/link-foundation/box#docker-hub---combo-boxes +// FORMAT THEOREM: renderDockerfile(config) -> base_image_default(rendered) = konard/box-js:2.1.1 +// PURITY: CORE +// INVARIANT: the rendered Dockerfile inherits JS/runtime tooling from link-foundation/box while preserving docker-git bootstrap layers +// COMPLEXITY: O(1)/O(1) +const dockerGitBaseImage = "konard/box-js:2.1.1" + +// CHANGE: include tmux and build-essential in generated project images for durable sessions and Rust crate installation. +// WHY: stable project SSH links need persisted tmux sessions, and cargo install of proc-macro/build-script dependencies requires a C linker. +// QUOTE(ТЗ): n/a +// REF: PR-309 +// SOURCE: n/a +// PURITY: CORE +// INVARIANT: generated base image contains both the terminal multiplexer and cc toolchain required before Rust browser CLI installation. +// COMPLEXITY: O(1)/O(1) +const renderDockerfileBase = (): string => + `ARG DOCKER_GIT_BASE_IMAGE=${dockerGitBaseImage} +FROM \${DOCKER_GIT_BASE_IMAGE} + +#checkov:skip=CKV_DOCKER_8: docker-git entrypoint must start as root to prepare SSH/auth/bootstrap and run sshd +USER root +ARG UBUNTU_APT_MIRROR= +ENV DEBIAN_FRONTEND=noninteractive +ENV NVM_DIR=/usr/local/nvm + +RUN set -eu; \ + if [ -n "\${UBUNTU_APT_MIRROR:-}" ]; then \ + sed -i \ + -e "s|http://archive.ubuntu.com/ubuntu|\${UBUNTU_APT_MIRROR}|g" \ + -e "s|http://security.ubuntu.com/ubuntu|\${UBUNTU_APT_MIRROR}|g" \ + /etc/apt/sources.list /etc/apt/sources.list.d/ubuntu.sources 2>/dev/null || true; \ + fi; \ + for attempt in 1 2 3 4 5; do \ + rm -rf /var/lib/apt/lists/*; \ + if apt-get -o Acquire::Retries=3 -o Acquire::By-Hash=force update; then \ + break; \ + fi; \ + if [ "$attempt" = "5" ]; then \ + echo "apt-get update failed after retries" >&2; \ + exit 1; \ + fi; \ + echo "apt-get update attempt \${attempt} failed; retrying..." >&2; \ + sleep $((attempt * 2)); \ + done; \ + apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ + openssh-server git gh ca-certificates curl unzip bsdutils sudo tmux \ + make build-essential docker.io docker-compose-v2 bash-completion zsh zsh-autosuggestions xauth \ + ncurses-term jq \ + && rm -rf /var/lib/apt/lists/*` + +// CHANGE: install the unified Rust browser connection with a current Rust toolchain. +// WHY: rust-browser-connection uses modern Cargo metadata; Ubuntu apt cargo 1.75 cannot resolve edition-2024 dependencies pulled by current crates. +// QUOTE(ТЗ): "Rust-only отдельный модуль для noVNC/browser, без TS-дублирования" +// REF: issue-347 +// SOURCE: n/a +// FORMAT THEOREM: image_build_success -> executables(/usr/local/bin/docker-git-browser-connection, /usr/local/bin/browser-connection) +// PURITY: SHELL +// EFFECT: Docker build downloads rustup and installs a pinned Git revision of the Rust crate. +// INVARIANT: generated images use rustup stable and expose both Rust lifecycle and MCP stdio binaries from an immutable upstream revision on runtime PATH. +// COMPLEXITY: O(network + cargo_build) +const renderDockerfileRustBrowserConnection = (): string => + `ENV CARGO_HOME=/usr/local/cargo +ENV RUSTUP_HOME=/usr/local/rustup +ENV PATH="/usr/local/cargo/bin:/root/.cargo/bin:/home/box/.cargo/bin:\${PATH}" +RUN set -eu; \ + curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs -o /tmp/rustup-init.sh; \ + HOME=/root sh /tmp/rustup-init.sh -y --profile minimal --default-toolchain stable --no-modify-path; \ + rm -f /tmp/rustup-init.sh; \ + rustc --version; \ + cargo --version + +# Install unified Rust browser connection (noVNC + CDP + single dg-*-browser guarantee) +# Replaces all previous TS/MCP browser-connection duplication (per issue #347) +RUN cargo install --git https://github.com/ProverCoderAI/rust-browser-connection --rev acd76d19a96763c8b5616076443d15be59fc7f78 --locked --bins --root /usr/local \ + && /usr/local/bin/docker-git-browser-connection --version \ + && /usr/local/bin/browser-connection --version + +# Passwordless sudo for all users (container is disposable) +RUN printf "%s\\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all \ + && chmod 0440 /etc/sudoers.d/zz-all` + +/** + * Renders the base image, package prelude, Rust toolchain, and browser module install. + * + * @returns Dockerfile fragment that establishes the shared project container base. + * @pure true + * @effect none; CORE template renderer only constructs a string. + * @invariant the returned fragment starts from the configured shared JS box image and installs the Rust browser lifecycle + MCP CLIs. + * @precondition docker-git generated entrypoint remains the container entrypoint. + * @postcondition the fragment keeps root available for setup and publishes both Rust browser binaries on PATH. + * @complexity O(1) time / O(1) space. + */ +export const renderDockerfilePrelude = (): string => + [renderDockerfileBase(), renderDockerfileRustBrowserConnection()].join("\n\n") diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 63eef67d..2f063e8a 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -1,79 +1,10 @@ import type { TemplateConfig } from "../domain.js" import { shellSingleQuote } from "../shell-literals.js" import { renderDockerfilePrompt } from "../templates-prompt.js" +import { renderDockerfilePrelude } from "./dockerfile-prelude.js" import { renderDockerfileGlab } from "./glab.js" import { renderDockerfileGitleaks, renderDockerfileOpenCode } from "./tools.js" -// CHANGE: use the shared link-foundation JS box as the generated project base image -// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base; the Docker Hub JS image is public and version-pinned to avoid latest drift -// QUOTE(ТЗ): "Что бы не зависить только от своих обновлений, а иметь единую инфраструктру есть смысл юзать готовый репозиторий" -// REF: issue-267 -// SOURCE: https://github.com/link-foundation/box#docker-hub---combo-boxes -// FORMAT THEOREM: renderDockerfile(config) -> base_image_default(rendered) = konard/box-js:2.1.1 -// PURITY: CORE -// INVARIANT: the rendered Dockerfile inherits JS/runtime tooling from link-foundation/box while preserving docker-git bootstrap layers -// COMPLEXITY: O(1)/O(1) -const dockerGitBaseImage = "konard/box-js:2.1.1" - -// CHANGE: include tmux in generated project images for durable terminal multiplexing. -// WHY: stable project SSH links attach to persisted tmux sessions instead of one-off shell processes. -// QUOTE(ТЗ): n/a -// REF: PR-309 -// SOURCE: n/a -// PURITY: CORE -// INVARIANT: generated base image contains the terminal multiplexer required by project SSH sessions. -// COMPLEXITY: O(1)/O(1) - -/** - * Renders the base image, root user, apt mirror, core packages, and sudo prelude. - * - * @returns Dockerfile fragment that establishes the shared project container base. - * @pure true - * @effect none; CORE template renderer only constructs a string. - * @invariant the returned fragment starts from the configured shared JS box image. - * @precondition docker-git generated entrypoint remains the container entrypoint. - * @postcondition the fragment keeps root available for setup and runtime bootstrap. - * @complexity O(1) time / O(1) space. - */ -const renderDockerfilePrelude = (): string => - `ARG DOCKER_GIT_BASE_IMAGE=${dockerGitBaseImage} -FROM \${DOCKER_GIT_BASE_IMAGE} - -#checkov:skip=CKV_DOCKER_8: docker-git entrypoint must start as root to prepare SSH/auth/bootstrap and run sshd -USER root -ARG UBUNTU_APT_MIRROR= -ENV DEBIAN_FRONTEND=noninteractive -ENV NVM_DIR=/usr/local/nvm - -RUN set -eu; \ - if [ -n "\${UBUNTU_APT_MIRROR:-}" ]; then \ - sed -i \ - -e "s|http://archive.ubuntu.com/ubuntu|\${UBUNTU_APT_MIRROR}|g" \ - -e "s|http://security.ubuntu.com/ubuntu|\${UBUNTU_APT_MIRROR}|g" \ - /etc/apt/sources.list /etc/apt/sources.list.d/ubuntu.sources 2>/dev/null || true; \ - fi; \ - for attempt in 1 2 3 4 5; do \ - rm -rf /var/lib/apt/lists/*; \ - if apt-get -o Acquire::Retries=3 -o Acquire::By-Hash=force update; then \ - break; \ - fi; \ - if [ "$attempt" = "5" ]; then \ - echo "apt-get update failed after retries" >&2; \ - exit 1; \ - fi; \ - echo "apt-get update attempt \${attempt} failed; retrying..." >&2; \ - sleep $((attempt * 2)); \ - done; \ - apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ - openssh-server git gh ca-certificates curl unzip bsdutils sudo tmux \ - make docker.io docker-compose-v2 bash-completion zsh zsh-autosuggestions xauth \ - ncurses-term jq \ - && rm -rf /var/lib/apt/lists/* - -# Passwordless sudo for all users (container is disposable) -RUN printf "%s\\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all \ - && chmod 0440 /etc/sudoers.d/zz-all` - const renderDockerfileNode = (): string => `# Tooling: Node 24 (NodeSource) + nvm RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ @@ -153,92 +84,11 @@ RUN set -eu; \ const dockerGitSessionSyncPackage = "@prover-coder-ai/docker-git-session-sync@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 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 nested browser runtime). -for arg in "$@"; do - case "$arg" in - -h|--help|-V|--version) - exec playwright-mcp "$@" - ;; - esac -done - -CDP_ENDPOINT="http://127.0.0.1:9223" - -# 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(fixed_nested_browser_endpoint) -> mcp_stdio_ready_before_browser_connection -# PURITY: SHELL -# 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 - -# The guarded endpoint is the nested browser opened by docker-git Open browser. -# Passing the fixed 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 -} - -JSON="" -for attempt in $(seq 1 "$MCP_PLAYWRIGHT_RETRY_ATTEMPTS"); do - if JSON="$(fetch_cdp_version)"; then - break - fi - if [[ "$attempt" -lt "$MCP_PLAYWRIGHT_RETRY_ATTEMPTS" ]]; then - echo "docker-git-playwright-mcp: waiting for nested browser runtime (attempt $attempt/$MCP_PLAYWRIGHT_RETRY_ATTEMPTS)..." >&2 - sleep "$MCP_PLAYWRIGHT_RETRY_DELAY" - fi -done - -if [[ -z "$JSON" ]]; then - echo "docker-git-playwright-mcp: failed to connect to CDP endpoint $CDP_ENDPOINT after $MCP_PLAYWRIGHT_RETRY_ATTEMPTS attempts" >&2 - exit 1 -fi - -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 - exit 1 -fi - -# Rewrite ws origin to match the CDP endpoint origin (docker DNS). -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" --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 docker-git-cdp-guard mcp-playwright-start-extra.sh docker-git-browser-runtime.sh /opt/docker-git/browser/ -RUN chmod +x /opt/docker-git/browser/docker-git-cdp-guard /opt/docker-git/browser/mcp-playwright-start-extra.sh /opt/docker-git/browser/docker-git-browser-runtime.sh` + ? `# Unified Rust browser (dg-*-browser) is started by docker-git-browser-connection binary +# No more COPY of separate browser files — single session guaranteed by Rust module (see entrypoint and rust-browser-connection repo) +# Old browser-vnc + cdp-guard duplication removed per #347` : "" /** @@ -259,11 +109,6 @@ const renderDockerfileBunProfile = (): string => const renderDockerfileBun = (config: TemplateConfig): string => [ renderDockerfileBunPrelude(config), - config.enableMcpPlaywright - ? dockerfilePlaywrightMcpBlock - .replaceAll("\\${", "${") - .replaceAll("__SERVICE_NAME__", config.serviceName) - : "", renderDockerfileBunProfile() ] .filter((chunk) => chunk.trim().length > 0) diff --git a/packages/lib/src/core/templates/playwright-browser-runtime.ts b/packages/lib/src/core/templates/playwright-browser-runtime.ts deleted file mode 100644 index 5afebc63..00000000 --- a/packages/lib/src/core/templates/playwright-browser-runtime.ts +++ /dev/null @@ -1,215 +0,0 @@ -const playwrightBrowserRuntimeScript = `#!/usr/bin/env bash -set -euo pipefail - -declare -a DOCKER_GIT_BROWSER_TEMP_FILES=() - -docker_git_browser_log() { - printf '%s\\n' "[docker-git-browser] $*" >&2 -} - -docker_git_browser_cleanup_temp_files() { - if (( \${#DOCKER_GIT_BROWSER_TEMP_FILES[@]} > 0 )); then - rm -f -- "\${DOCKER_GIT_BROWSER_TEMP_FILES[@]}" || true - fi -} - -docker_git_browser_register_temp_file() { - DOCKER_GIT_BROWSER_TEMP_FILES+=("$1") - trap docker_git_browser_cleanup_temp_files EXIT -} - -docker_git_browser_has_docker() { - command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1 -} - -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' "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 - return 0 - fi - if ! docker_git_browser_has_docker; then - return 0 - fi - docker rm -f "$container_name" >/dev/null 2>&1 || true -} - -docker_git_cleanup_orphaned_playwright_browsers() { - if ! docker_git_browser_has_docker; then - return 0 - fi - - local browser_id - while IFS= read -r browser_id; do - if [[ -z "$browser_id" ]]; then - continue - fi - - local project_container - project_container="$(docker inspect --format '{{ index .Config.Labels "docker-git.project-container" }}' "$browser_id" 2>/dev/null || true)" - - local project_running - project_running="false" - if [[ -n "$project_container" && "$project_container" != "" ]]; then - project_running="$(docker inspect --format '{{ .State.Running }}' "$project_container" 2>/dev/null || true)" - fi - - if [[ "$project_running" == "true" ]]; then - continue - fi - - local browser_name - browser_name="$(docker inspect --format '{{ .Name }}' "$browser_id" 2>/dev/null | sed 's#^/##' || true)" - docker_git_browser_log "removing orphaned browser container \${browser_name:-$browser_id}" - docker rm -f "$browser_id" >/dev/null 2>&1 || true - done < <(docker ps -a -q --filter "label=docker-git.browser=1" --filter "label=docker-git.project-container") -} - -docker_git_start_playwright_browser() { - if [[ "\${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then - docker_git_stop_playwright_browser || true - return 0 - fi - - local container_name="\${DOCKER_GIT_BROWSER_CONTAINER_NAME:-}" - local image_name="\${DOCKER_GIT_BROWSER_IMAGE_NAME:-}" - local volume_name="\${DOCKER_GIT_BROWSER_VOLUME_NAME:-}" - local main_container="\${DOCKER_GIT_PROJECT_CONTAINER_NAME:-}" - local context_dir - context_dir="$(docker_git_browser_context_dir)" - - if [[ -z "$container_name" || -z "$image_name" || -z "$volume_name" || -z "$main_container" ]]; then - docker_git_disable_playwright_mcp "missing browser runtime configuration" - return 0 - fi - if ! docker_git_browser_has_docker; then - docker_git_disable_playwright_mcp "Docker API is unavailable" - return 0 - fi - if [[ ! -f "$context_dir/Dockerfile.browser" ]]; then - docker_git_disable_playwright_mcp "browser Dockerfile is missing at $context_dir/Dockerfile.browser" - return 0 - fi - - docker_git_stop_playwright_browser || true - docker_git_cleanup_orphaned_playwright_browsers || true - - local build_log - if ! build_log="$(mktemp "\${TMPDIR:-/tmp}/docker-git-browser-build.XXXXXX.log" 2>/dev/null)"; then - docker_git_disable_playwright_mcp "failed to create browser build log" - return 0 - fi - docker_git_browser_register_temp_file "$build_log" - - local build_timeout - build_timeout="\${DOCKER_GIT_BROWSER_BUILD_TIMEOUT_SECONDS:-600}" - - docker_git_browser_log "building $image_name" - timeout "$build_timeout" docker build -t "$image_name" -f "$context_dir/Dockerfile.browser" "$context_dir" >"$build_log" 2>&1 || { - 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" - - if ! docker volume create "$volume_name" >/dev/null 2>&1; then - docker_git_browser_log "failed to create browser data volume $volume_name; continuing" - fi - - local args=( - run - -d - --name "$container_name" - --label "docker-git.browser=1" - --label "docker-git.project-container=$main_container" - --network "container:$main_container" - --shm-size "2g" - -e "VNC_NOPW=1" - -e "MCP_PLAYWRIGHT_CDP_GUARD=\${MCP_PLAYWRIGHT_CDP_GUARD:-1}" - -e "MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE=\${MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE:-1}" - -v "$volume_name:/data" - ) - - if [[ -n "\${DOCKER_GIT_BROWSER_CPU_LIMIT:-}" ]]; then - args+=(--cpus "$DOCKER_GIT_BROWSER_CPU_LIMIT") - fi - if [[ -n "\${DOCKER_GIT_BROWSER_RAM_LIMIT:-}" ]]; then - args+=(--memory "$DOCKER_GIT_BROWSER_RAM_LIMIT" --memory-swap "$DOCKER_GIT_BROWSER_RAM_LIMIT") - fi - - docker_git_browser_log "starting $container_name inside $main_container network namespace" - docker "\${args[@]}" "$image_name" >/dev/null || { - 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 - } -} -` - -// CHANGE: manage the Playwright browser as a nested Docker container owned by the project container. -// WHY: issue #306 follow-up requires browser containers to inherit project lifecycle while keeping separate limits. -// QUOTE(ТЗ): "пусть он поднимается внутри dg-issues1 а не где-то из вне" -// REF: issue-306-browser-nested-runtime -// SOURCE: n/a -// FORMAT THEOREM: start(main) -> running(browser) with network(browser) = container:main OR logged_warning -// PURITY: SHELL -// EFFECT: shell commands executed by generated entrypoint -// INVARIANT: browser data volume is preserved; runtime cleanup removes only browser-labeled containers -// COMPLEXITY: O(b + build + docker-run)/O(1), where b = browser-labeled containers -export const renderPlaywrightBrowserRuntime = (): string => playwrightBrowserRuntimeScript diff --git a/packages/lib/src/core/templates/playwright.ts b/packages/lib/src/core/templates/playwright.ts deleted file mode 100644 index 17f09a76..00000000 --- a/packages/lib/src/core/templates/playwright.ts +++ /dev/null @@ -1,282 +0,0 @@ -export const renderPlaywrightBrowserDockerfile = (): string => - `FROM kechangdev/browser-vnc:latest - -# bash for noVNC startup, procps for ps -p used by novnc_proxy, socat for CDP fallback -# nodejs/npm/ws for the CDP guard, python3/net-tools for diagnostics -RUN apk add --no-cache bash procps socat nodejs npm python3 net-tools -RUN npm install --omit=dev --prefix /opt/docker-git-cdp-guard ws@8.18.3 - -COPY docker-git-cdp-guard /usr/local/bin/docker-git-cdp-guard -RUN chmod +x /usr/local/bin/docker-git-cdp-guard - -COPY mcp-playwright-start-extra.sh /usr/local/bin/mcp-playwright-start-extra.sh -RUN chmod +x /usr/local/bin/mcp-playwright-start-extra.sh - -# Start extra services in background, keep base stack in foreground -# Clear stale Chromium profile locks before boot -ENTRYPOINT ["/bin/sh", "-lc", "rm -f /data/SingletonLock /data/SingletonCookie /data/SingletonSocket || true; /usr/local/bin/mcp-playwright-start-extra.sh & exec /start.sh"]` - -const cdpGuardScript = String.raw`#!/usr/bin/env node -"use strict"; - -const http = require("node:http"); -const { URL } = require("node:url"); -const { WebSocket, WebSocketServer } = require("/opt/docker-git-cdp-guard/node_modules/ws"); - -const upstreamHost = "127.0.0.1"; -const upstreamPort = 9222; -const listenHost = "0.0.0.0"; -const listenPort = 9223; -const blockedMethods = new Set(["Browser.close", "Browser.crash", "Browser.crashGpuProcess"]); - -const log = (message) => process.stderr.write("[docker-git-cdp-guard] " + message + "\n"); - -const shouldBlockBrowserClose = () => process.env.MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE !== "0"; - -const requestHost = (request) => { - const host = request.headers.host; - return typeof host === "string" && host.length > 0 ? host : "127.0.0.1:" + listenPort; -}; - -const rewriteWebSocketUrl = (value, host) => { - try { - const url = new URL(value); - url.protocol = "ws:"; - url.host = host; - return url.toString(); - } catch { - return value; - } -}; - -const rewriteDebuggerUrls = (value, host) => { - if (Array.isArray(value)) { - return value.map((item) => rewriteDebuggerUrls(item, host)); - } - if (value === null || typeof value !== "object") { - return value; - } - return Object.fromEntries( - Object.entries(value).map(([key, child]) => [ - key, - key === "webSocketDebuggerUrl" && typeof child === "string" - ? rewriteWebSocketUrl(child, host) - : rewriteDebuggerUrls(child, host) - ]) - ); -}; - -const rewriteJsonBody = (body, host) => { - try { - return Buffer.from(JSON.stringify(rewriteDebuggerUrls(JSON.parse(body.toString("utf8")), host))); - } catch { - return body; - } -}; - -const proxyHttp = (request, response) => { - const chunks = []; - request.on("data", (chunk) => chunks.push(chunk)); - request.on("end", () => { - const headers = { ...request.headers, host: upstreamHost + ":" + upstreamPort }; - delete headers.connection; - delete headers["content-length"]; - const upstream = http.request( - { - hostname: upstreamHost, - port: upstreamPort, - method: request.method, - path: request.url || "/", - headers - }, - (upstreamResponse) => { - const upstreamChunks = []; - upstreamResponse.on("data", (chunk) => upstreamChunks.push(chunk)); - upstreamResponse.on("end", () => { - const rawBody = Buffer.concat(upstreamChunks); - const body = (request.url || "/").startsWith("/json") - ? rewriteJsonBody(rawBody, requestHost(request)) - : rawBody; - const responseHeaders = { ...upstreamResponse.headers }; - delete responseHeaders["content-length"]; - delete responseHeaders["content-encoding"]; - response.writeHead(upstreamResponse.statusCode || 502, responseHeaders); - response.end(body); - }); - } - ); - upstream.on("error", (error) => { - response.writeHead(502, { "content-type": "text/plain; charset=utf-8" }); - response.end("CDP upstream unavailable: " + error.message + "\n"); - }); - upstream.end(Buffer.concat(chunks)); - }); -}; - -const fetchCurrentBrowserPath = () => - new Promise((resolve, reject) => { - const request = http.get( - { - hostname: upstreamHost, - port: upstreamPort, - path: "/json/version", - headers: { host: upstreamHost + ":" + upstreamPort } - }, - (response) => { - const chunks = []; - response.on("data", (chunk) => chunks.push(chunk)); - response.on("end", () => { - try { - const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); - const raw = typeof parsed.webSocketDebuggerUrl === "string" ? parsed.webSocketDebuggerUrl : ""; - const path = raw.length > 0 ? new URL(raw).pathname : ""; - path.length > 0 ? resolve(path) : reject(new Error("webSocketDebuggerUrl missing")); - } catch (error) { - reject(error); - } - }); - } - ); - request.on("error", reject); - }); - -const upstreamPathFor = async (rawPath) => { - const path = rawPath || "/"; - return path.startsWith("/devtools/browser/") ? await fetchCurrentBrowserPath() : path; -}; - -const parseMessage = (data) => JSON.parse(Buffer.isBuffer(data) ? data.toString("utf8") : String(data)); - -const isBlockedCdpMessage = (data) => { - if (!shouldBlockBrowserClose()) { - return false; - } - try { - const message = parseMessage(data); - return message !== null && typeof message === "object" && blockedMethods.has(message.method); - } catch { - return false; - } -}; - -const blockedCdpResponse = (data) => { - try { - const message = parseMessage(data); - return Object.prototype.hasOwnProperty.call(message, "id") - ? JSON.stringify({ id: message.id, result: {} }) - : ""; - } catch { - return ""; - } -}; - -const handleWebSocket = async (client, request) => { - const pending = []; - let upstream = null; - const forwardToUpstream = (data, isBinary) => { - if (!upstream || upstream.readyState !== WebSocket.OPEN) { - pending.push([data, isBinary]); - return; - } - if (!isBinary && isBlockedCdpMessage(data)) { - const response = blockedCdpResponse(data); - if (response.length > 0 && client.readyState === WebSocket.OPEN) { - client.send(response); - } - return; - } - upstream.send(data, { binary: isBinary }); - }; - - client.on("message", forwardToUpstream); - - try { - const upstreamPath = await upstreamPathFor(request.url || "/"); - upstream = new WebSocket("ws://" + upstreamHost + ":" + upstreamPort + upstreamPath, { - headers: { host: upstreamHost + ":" + upstreamPort } - }); - upstream.on("open", () => { - for (const [data, isBinary] of pending.splice(0)) { - forwardToUpstream(data, isBinary); - } - }); - upstream.on("message", (data, isBinary) => { - if (client.readyState === WebSocket.OPEN) { - client.send(data, { binary: isBinary }); - } - }); - upstream.on("close", (code, reason) => { - if (client.readyState === WebSocket.OPEN) { - client.close(code, reason); - } - }); - upstream.on("error", (error) => { - log("upstream websocket error: " + error.message); - if (client.readyState === WebSocket.OPEN) { - client.close(1011, "CDP upstream websocket error"); - } - }); - client.on("close", () => { - if (upstream && upstream.readyState === WebSocket.OPEN) { - upstream.close(); - } - }); - } catch (error) { - log("websocket setup failed: " + error.message); - client.close(1011, "CDP upstream unavailable"); - } -}; - -const server = http.createServer(proxyHttp); -const wss = new WebSocketServer({ noServer: true }); - -server.on("upgrade", (request, socket, head) => { - wss.handleUpgrade(request, socket, head, (client) => { - handleWebSocket(client, request); - }); -}); - -server.listen(listenPort, listenHost, () => { - log("listening on " + listenHost + ":" + listenPort + " -> " + upstreamHost + ":" + upstreamPort); -}); -` - -export const renderPlaywrightCdpGuard = (): string => cdpGuardScript - -export const renderPlaywrightStartExtra = (): string => - `#!/bin/sh -set -eu - -# Clear stale Chromium locks from previous container runs -rm -f /data/SingletonLock /data/SingletonCookie /data/SingletonSocket || true - -# Wait for chromium/x11vnc/noVNC to come up -sleep 2 - -start_cdp_fallback() { - socat TCP-LISTEN:9223,fork,reuseaddr TCP:127.0.0.1:9222 >/var/log/socat-9223.log 2>&1 & -} - -# CDP guard: expose 9223 on the docker network and block browser-level destructive CDP methods -if [ "\${MCP_PLAYWRIGHT_CDP_GUARD:-1}" = "1" ]; then - docker-git-cdp-guard >/var/log/docker-git-cdp-guard.log 2>&1 & - guard_pid="$!" - sleep 1 - if ! kill -0 "$guard_pid" 2>/dev/null; then - echo "docker-git-cdp-guard exited during startup; falling back to socat" >&2 - sed -n '1,120p' /var/log/docker-git-cdp-guard.log 2>/dev/null >&2 || true - start_cdp_fallback - fi -else - start_cdp_fallback -fi - -# Optional VNC password disabling (useful if you publish VNC/noVNC ports) -if [ "\${VNC_NOPW:-1}" = "1" ]; then - pkill x11vnc || true - x11vnc -display :99 -rfbport 5900 -nopw -forever -shared -bg -o /var/log/x11vnc-nopw.log -fi - -echo "extra services started" -exit 0 -` diff --git a/packages/lib/src/usecases/actions/prepare-files.ts b/packages/lib/src/usecases/actions/prepare-files.ts index 099f89a4..a720975e 100644 --- a/packages/lib/src/usecases/actions/prepare-files.ts +++ b/packages/lib/src/usecases/actions/prepare-files.ts @@ -230,8 +230,6 @@ const defaultProjectEnvContents = [ "DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=fg=8,italic", "DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=history completion", "MCP_PLAYWRIGHT_ISOLATED=0", - "MCP_PLAYWRIGHT_CDP_GUARD=1", - "MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE=1", "" ].join("\n") diff --git a/packages/lib/src/usecases/auth-gemini-helpers.ts b/packages/lib/src/usecases/auth-gemini-helpers.ts index c77af7d7..61f4c63b 100644 --- a/packages/lib/src/usecases/auth-gemini-helpers.ts +++ b/packages/lib/src/usecases/auth-gemini-helpers.ts @@ -298,7 +298,7 @@ export const defaultGeminiSettings = { }, mcpServers: { playwright: { - command: "docker-git-playwright-mcp", + command: "browser-connection", args: [], trust: true } diff --git a/packages/lib/src/usecases/auth-grok-helpers.ts b/packages/lib/src/usecases/auth-grok-helpers.ts index 23c8eb9b..b0699c75 100644 --- a/packages/lib/src/usecases/auth-grok-helpers.ts +++ b/packages/lib/src/usecases/auth-grok-helpers.ts @@ -238,7 +238,7 @@ export const defaultGrokProjectSettings = { sandboxMode: "off", mcpServers: { playwright: { - command: "docker-git-playwright-mcp", + command: "browser-connection", args: [], trust: true } diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index deb35533..c56ecd17 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -8,7 +8,6 @@ import { defaultTemplateConfig, type TemplateConfig } from "../../src/core/domai import { planFiles } from "../../src/core/templates.js" import { renderDockerCompose } from "../../src/core/templates/docker-compose.js" import { renderDockerfile } from "../../src/core/templates/dockerfile.js" -import { renderPlaywrightBrowserRuntime } from "../../src/core/templates/playwright-browser-runtime.js" import { renderEntrypoint } from "../../src/core/templates-entrypoint.js" import { renderEntrypointDnsRepair } from "../../src/core/templates-entrypoint/dns-repair.js" import { renderEntrypointGitHooks } from "../../src/core/templates-entrypoint/git.js" @@ -90,6 +89,7 @@ describe("renderDockerfile", () => { expect(dockerfile).toContain("ARG DOCKER_GIT_BASE_IMAGE=konard/box-js:2.1.1") expect(dockerfile).toContain("FROM ${DOCKER_GIT_BASE_IMAGE}") + expect(dockerfile).toContain("make build-essential docker.io") expect(dockerfile).toContain( "#checkov:skip=CKV_DOCKER_8: docker-git entrypoint must start as root to prepare SSH/auth/bootstrap and run sshd" ) @@ -230,20 +230,19 @@ describe("renderDockerfile", () => { expect(dockerfile).not.toContain("grok --version >/dev/null || true") }) - it("renders Playwright MCP without blocking stdio initialization on CDP readiness", () => { + it("renders Rust browser binaries without the legacy Playwright MCP wrapper", () => { 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, [ - 'CDP_ENDPOINT="http://127.0.0.1:9223"', - '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"' + "cargo install --git https://github.com/ProverCoderAI/rust-browser-connection --rev acd76d19a96763c8b5616076443d15be59fc7f78 --locked --bins --root /usr/local", + "/usr/local/bin/docker-git-browser-connection --version", + "/usr/local/bin/browser-connection --version", + "# Unified Rust browser (dg-*-browser) is started by docker-git-browser-connection binary" ]) - expect(dockerfile).not.toContain('CDP_ENDPOINT="${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}"') - expect(guardedExecIndex).toBeGreaterThanOrEqual(0) - expect(fetchIndex).toBeGreaterThan(guardedExecIndex) + expect(dockerfile).not.toContain("docker-git-playwright-mcp") + expect(dockerfile).not.toContain("@playwright/mcp") + expect(dockerfile).not.toContain("playwright-mcp --cdp-endpoint") + expect(dockerfile).not.toContain("MCP_PLAYWRIGHT_CDP_TIMEOUT") }) }) @@ -444,7 +443,7 @@ describe("renderEntrypoint auth bridge", () => { expectContainsAll(entrypoint, [ "nextServers.playwright = {", - "command: \"docker-git-playwright-mcp\"", + "command: \"browser-connection\"", "docker_git_sync_project_codex_skills()", "project_skills_root=\"$codex_home/skills/.docker-git-project\"", "docker_git_prepare_active_agent_project_rules()", @@ -453,7 +452,9 @@ describe("renderEntrypoint auth bridge", () => { "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', + 'local browser_project="${DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"', + 'DOCKER_GIT_BROWSER_PROJECT="${DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"', + 'MCP_PLAYWRIGHT_ENABLE="${MCP_PLAYWRIGHT_ENABLE:-0}" DOCKER_GIT_BROWSER_PROJECT="$browser_project" DOCKER_GIT_BROWSER_NETWORK="$browser_network" node', "DOCKER_GIT_RTK_ENABLE=\"${DOCKER_GIT_RTK_ENABLE:-1}\"", "DOCKER_GIT_RTK_ENABLE=1", "docker_git_rtk_init_as_user()", @@ -742,93 +743,49 @@ describe("renderDockerCompose", () => { expect((compose.match(/\n dns:\n/g) ?? []).length).toBe(1) }) - it("renders live shell expansion in the nested browser runtime script", () => { - const runtime = renderPlaywrightBrowserRuntime() - - expect(runtime).toContain('if [[ "${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then') - expect(runtime).not.toContain('if [[ "\\${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then') - expect(runtime).toContain('printf \'%s\\n\' "${DOCKER_GIT_BROWSER_CONTEXT_DIR:-/opt/docker-git/browser}"') - expect(runtime).not.toContain('printf \'%s\\n\' "\\${DOCKER_GIT_BROWSER_CONTEXT_DIR:-/opt/docker-git/browser}"') - expect(runtime).toContain('printf \'%s\\n\' "http://127.0.0.1:9223"') - expect(runtime).not.toContain('printf \'%s\\n\' "${MCP_PLAYWRIGHT_CDP_ENDPOINT:-http://127.0.0.1:9223}"') - expect(runtime).toContain('mktemp "${TMPDIR:-/tmp}/docker-git-browser-build.XXXXXX.log"') - 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", () => { + it("plans Rust browser connection artifacts when Playwright is enabled", () => { const files = planFiles(makeTemplateConfig({ enableMcpPlaywright: true })) const filePaths = files.flatMap((file) => file._tag === "File" ? [file.relativePath] : []) const dockerfile = files.find( (file): file is Extract<(typeof files)[number], { readonly _tag: "File" }> => file._tag === "File" && file.relativePath === "Dockerfile" ) - const browserDockerfile = files.find( + const entrypoint = files.find( (file): file is Extract<(typeof files)[number], { readonly _tag: "File" }> => - file._tag === "File" && file.relativePath === "Dockerfile.browser" - ) - const cdpGuard = files.find( - (file): file is Extract<(typeof files)[number], { readonly _tag: "File" }> => - file._tag === "File" && file.relativePath === "docker-git-cdp-guard" - ) - const startExtra = files.find( - (file): file is Extract<(typeof files)[number], { readonly _tag: "File" }> => - file._tag === "File" && file.relativePath === "mcp-playwright-start-extra.sh" - ) - const runtime = files.find( - (file): file is Extract<(typeof files)[number], { readonly _tag: "File" }> => - file._tag === "File" && file.relativePath === "docker-git-browser-runtime.sh" + file._tag === "File" && file.relativePath === "entrypoint.sh" ) - expect(filePaths).toContain("Dockerfile.browser") - expect(filePaths).toContain("docker-git-cdp-guard") - expect(filePaths).toContain("mcp-playwright-start-extra.sh") - expect(filePaths).toContain("docker-git-browser-runtime.sh") - expect(dockerfile?.contents).toContain( - "COPY Dockerfile.browser docker-git-cdp-guard mcp-playwright-start-extra.sh docker-git-browser-runtime.sh /opt/docker-git/browser/" - ) - expect(browserDockerfile?.contents).toContain("COPY docker-git-cdp-guard /usr/local/bin/docker-git-cdp-guard") - expect(browserDockerfile?.contents).not.toContain("RUN cat <<'EOF' > /usr/local/bin/docker-git-cdp-guard") - expect(cdpGuard).toBeDefined() - expect(cdpGuard?.mode).toBe(0o755) - expect(cdpGuard?.contents).toContain("#!/usr/bin/env node") - expect(cdpGuard?.contents).toContain('const upstreamHost = "127.0.0.1";') - expect(cdpGuard?.contents).toContain("const upstreamPort = 9222;") - expect(cdpGuard?.contents).toContain('const listenHost = "0.0.0.0";') - expect(cdpGuard?.contents).toContain("const listenPort = 9223;") - expect(cdpGuard?.contents).not.toContain("MCP_PLAYWRIGHT_UPSTREAM_CDP_HOST") - expect(cdpGuard?.contents).not.toContain("MCP_PLAYWRIGHT_CDP_GUARD_PORT") - expect(cdpGuard?.contents).toContain("Browser.close") - expect(startExtra?.contents).toContain('guard_pid="$!"') - expect(startExtra?.contents).toContain("falling back to socat") - expect(startExtra?.contents).toContain("socat TCP-LISTEN:9223,fork,reuseaddr TCP:127.0.0.1:9222") - 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?.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", () => { + expect(filePaths).not.toContain("Dockerfile.browser") + expect(filePaths).not.toContain("docker-git-cdp-guard") + expect(filePaths).not.toContain("docker-git-browser-runtime.sh") + expect(filePaths).not.toContain("mcp-playwright-start-extra.sh") + expect(dockerfile?.contents).toContain("cargo install --git https://github.com/ProverCoderAI/rust-browser-connection") + expect(dockerfile?.contents).toContain("/usr/local/bin/browser-connection --version") + expect(dockerfile?.contents).not.toContain("docker-git-playwright-mcp") + expect(dockerfile?.contents).not.toContain("COPY Dockerfile.browser") + expect(entrypoint?.contents).toContain("docker_git_start_rust_browser_connection") + expect(entrypoint?.contents).toContain("docker_git_stop_playwright_browser()") + expect(entrypoint?.contents).toContain("docker-git-browser-connection") + expect(entrypoint?.contents).toContain('local network_mode="container:${project_container}"') + expect(entrypoint?.contents).toContain('stop --project "$project_container"') + }) + it("renders Rust browser startup before MCP client config", () => { const entrypoint = renderEntrypoint(makeTemplateConfig({ enableMcpPlaywright: true })) - const browserRuntimeIndex = entrypoint.indexOf("docker_git_start_playwright_browser") + const browserRuntimeIndex = entrypoint.indexOf("docker_git_start_rust_browser_connection") const mcpConfigIndex = entrypoint.indexOf("[mcp_servers.playwright]") expect(browserRuntimeIndex).toBeGreaterThanOrEqual(0) expect(mcpConfigIndex).toBeGreaterThan(browserRuntimeIndex) }) + it("renders Browser MCP project fallback without set -u unbound variables", () => { + const entrypoint = renderEntrypoint(makeTemplateConfig({ enableMcpPlaywright: false })) + + expect(entrypoint).toContain('DOCKER_GIT_BROWSER_PROJECT="${DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"') + expect(entrypoint).toContain('local browser_project="${DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"') + expect(entrypoint).not.toContain('"$DOCKER_GIT_PROJECT_CONTAINER_NAME"') + }) + it("renders local Docker socket mount only when explicitly enabled", () => { const compose = renderDockerCompose( makeTemplateConfig({ diff --git a/packages/lib/tests/usecases/auth-gemini.test.ts b/packages/lib/tests/usecases/auth-gemini.test.ts index 108098f2..756c1f65 100644 --- a/packages/lib/tests/usecases/auth-gemini.test.ts +++ b/packages/lib/tests/usecases/auth-gemini.test.ts @@ -89,7 +89,7 @@ describe("authGeminiLogin", () => { expect(settings.model.name).toBe("gemini-3.1-pro-preview") expect(settings.modelConfigs.customAliases["yolo-ultra"]).toBeDefined() expect(settings.general.defaultApprovalMode).toBe("auto_edit") - expect(settings.mcpServers.playwright.command).toBe("docker-git-playwright-mcp") + expect(settings.mcpServers.playwright.command).toBe("browser-connection") expect(settings.security.folderTrust.enabled).toBe(false) expect(settings.tools.allowed).toContain("googleSearch") }) diff --git a/packages/lib/tests/usecases/auth-grok.test.ts b/packages/lib/tests/usecases/auth-grok.test.ts index 778a6293..b88a044c 100644 --- a/packages/lib/tests/usecases/auth-grok.test.ts +++ b/packages/lib/tests/usecases/auth-grok.test.ts @@ -151,7 +151,7 @@ describe("authGrokLogin", () => { expect(Number(credentialsInfo.mode ?? 0) & 0o777).toBe(0o700) expect(userSettings.apiKey).toBe("xai-test-api-key") expect(userSettings.sandboxMode).toBe("off") - expect(projectSettings.mcpServers.playwright.command).toBe("docker-git-playwright-mcp") + expect(projectSettings.mcpServers.playwright.command).toBe("browser-connection") expect(projectSettings.mcpServers.playwright.trust).toBe(true) }) ) diff --git a/packages/lib/tests/usecases/mcp-playwright.test.ts b/packages/lib/tests/usecases/mcp-playwright.test.ts index 9f092851..0187707c 100644 --- a/packages/lib/tests/usecases/mcp-playwright.test.ts +++ b/packages/lib/tests/usecases/mcp-playwright.test.ts @@ -125,84 +125,31 @@ 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("ARG PLAYWRIGHT_MCP_VERSION=0.0.75") - expect(dockerfileAfter).toContain('RUN npm install -g "@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}"') - - // 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" --cdp-timeout "$MCP_PLAYWRIGHT_CDP_TIMEOUT"' - ) - expect(dockerfileAfter).toContain( - "COPY Dockerfile.browser docker-git-cdp-guard mcp-playwright-start-extra.sh docker-git-browser-runtime.sh /opt/docker-git/browser/" - ) + expect(dockerfileAfter).toContain("cargo install --git https://github.com/ProverCoderAI/rust-browser-connection") + expect(dockerfileAfter).toContain("--locked --bins --root /usr/local") + expect(dockerfileAfter).toContain("/usr/local/bin/docker-git-browser-connection --version") + expect(dockerfileAfter).toContain("/usr/local/bin/browser-connection --version") + expect(dockerfileAfter).not.toContain("docker-git-playwright-mcp") + expect(dockerfileAfter).not.toContain("@playwright/mcp") + expect(dockerfileAfter).not.toContain("playwright-mcp --cdp-endpoint") + expect(dockerfileAfter).not.toContain("COPY Dockerfile.browser") + + const entrypointAfter = yield* _(fs.readFileString(path.join(outDir, "entrypoint.sh"))) + expect(entrypointAfter).toContain("docker_git_start_rust_browser_connection") + expect(entrypointAfter).toContain("docker_git_stop_playwright_browser()") + expect(entrypointAfter).toContain("docker-git-browser-connection") + expect(entrypointAfter).toContain('stop --project "$project_container"') + expect(entrypointAfter).toContain('command = "browser-connection"') + expect(entrypointAfter).toContain('args = ["--project", "$DOCKER_GIT_BROWSER_PROJECT", "--network", "$DOCKER_GIT_BROWSER_NETWORK"]') const browserDockerfileExists = yield* _(fs.exists(path.join(outDir, "Dockerfile.browser"))) const cdpGuardExists = yield* _(fs.exists(path.join(outDir, "docker-git-cdp-guard"))) const startExtraExists = yield* _(fs.exists(path.join(outDir, "mcp-playwright-start-extra.sh"))) const browserRuntimeExists = yield* _(fs.exists(path.join(outDir, "docker-git-browser-runtime.sh"))) - expect(browserDockerfileExists).toBe(true) - expect(cdpGuardExists).toBe(true) - expect(startExtraExists).toBe(true) - expect(browserRuntimeExists).toBe(true) - const browserDockerfile = yield* _(fs.readFileString(path.join(outDir, "Dockerfile.browser"))) - const cdpGuard = yield* _(fs.readFileString(path.join(outDir, "docker-git-cdp-guard"))) - const startExtra = yield* _(fs.readFileString(path.join(outDir, "mcp-playwright-start-extra.sh"))) - const browserRuntime = yield* _(fs.readFileString(path.join(outDir, "docker-git-browser-runtime.sh"))) - expect(browserDockerfile).toContain("COPY docker-git-cdp-guard /usr/local/bin/docker-git-cdp-guard") - expect(browserDockerfile).not.toContain("RUN cat <<'EOF' > /usr/local/bin/docker-git-cdp-guard") - expect(browserDockerfile).toContain("ws@8.18.3") - expect(cdpGuard).toContain("#!/usr/bin/env node") - expect(cdpGuard).toContain("WebSocketServer") - expect(cdpGuard).toContain('const upstreamHost = "127.0.0.1";') - expect(cdpGuard).toContain("const upstreamPort = 9222;") - expect(cdpGuard).toContain('const listenHost = "0.0.0.0";') - expect(cdpGuard).toContain("const listenPort = 9223;") - expect(cdpGuard).not.toContain("MCP_PLAYWRIGHT_UPSTREAM_CDP_HOST") - expect(cdpGuard).not.toContain("MCP_PLAYWRIGHT_CDP_GUARD_PORT") - expect(cdpGuard).toContain("Browser.close") - expect(cdpGuard).toContain("Browser.crash") - expect(startExtra).toContain('MCP_PLAYWRIGHT_CDP_GUARD:-1') - expect(startExtra).toContain("docker-git-cdp-guard") - expect(startExtra).toContain('guard_pid="$!"') - expect(startExtra).toContain("falling back to socat") - expect(startExtra).toContain("socat TCP-LISTEN:9223,fork,reuseaddr TCP:127.0.0.1:9222") - expect(browserRuntime).toContain('if [[ "${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then') - expect(browserRuntime).not.toContain('if [[ "\\${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then') - expect(browserRuntime).toContain('printf \'%s\\n\' "http://127.0.0.1:9223"') - expect(browserRuntime).not.toContain('printf \'%s\\n\' "${MCP_PLAYWRIGHT_CDP_ENDPOINT:-http://127.0.0.1:9223}"') - expect(browserRuntime).toContain('--network "container:$main_container"') - expect(browserRuntime).toContain("docker_git_cleanup_orphaned_playwright_browsers") - expect(browserRuntime).toContain("docker_git_browser_cleanup_temp_files") - expect(browserRuntime).toContain('mktemp "${TMPDIR:-/tmp}/docker-git-browser-build.XXXXXX.log"') - expect(browserRuntime).not.toContain('mktemp "\\${TMPDIR:-/tmp}/docker-git-browser-build.XXXXXX.log"') - 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') - expect(browserRuntime).toContain('failed to create browser data volume $volume_name; continuing') - expect(browserRuntime).toContain('args+=(--cpus "$DOCKER_GIT_BROWSER_CPU_LIMIT")') - expect(browserRuntime).toContain('args+=(--memory "$DOCKER_GIT_BROWSER_RAM_LIMIT" --memory-swap "$DOCKER_GIT_BROWSER_RAM_LIMIT")') - expect(browserRuntime).toContain('docker "${args[@]}" "$image_name" >/dev/null || {') - expect(browserRuntime).not.toContain('docker "\\${args[@]}" "$image_name" >/dev/null || {') + expect(browserDockerfileExists).toBe(false) + expect(cdpGuardExists).toBe(false) + expect(startExtraExists).toBe(false) + expect(browserRuntimeExists).toBe(false) const configAfterText = yield* _(fs.readFileString(path.join(outDir, "docker-git.json"))) const configAfter = yield* _(Effect.sync((): unknown => JSON.parse(configAfterText))) diff --git a/packages/lib/tests/usecases/prepare-files.test.ts b/packages/lib/tests/usecases/prepare-files.test.ts index 6dcece97..1ef7596f 100644 --- a/packages/lib/tests/usecases/prepare-files.test.ts +++ b/packages/lib/tests/usecases/prepare-files.test.ts @@ -295,8 +295,8 @@ describe("prepareProjectFiles", () => { expect(composeAfter).toContain(` - '${path.join(outDir, ".orch/env/global.env")}'`) expect(composeAfter).toContain(` - '${path.join(outDir, ".orch/env/project.env")}'`) expect(envProjectAfter).toContain("MCP_PLAYWRIGHT_ISOLATED=0") - expect(envProjectAfter).toContain("MCP_PLAYWRIGHT_CDP_GUARD=1") - expect(envProjectAfter).toContain("MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE=1") + expect(envProjectAfter).not.toContain("MCP_PLAYWRIGHT_CDP_GUARD") + expect(envProjectAfter).not.toContain("MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE") expect(composeAfter).toContain("docker-git-shared") expect(composeAfter).toContain("external: true") expect(countOccurrences(composeAfter, dnsBlock)).toBe(1)