diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 3dcf1303..7bb58e92 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -24,8 +24,19 @@ runs: - name: Install OpenSSH client shell: bash run: | - sudo apt-get update - sudo apt-get install -y openssh-client + for attempt in 1 2 3; do + sudo rm -rf /var/lib/apt/lists/* + if sudo apt-get -o Acquire::Retries=3 -o Acquire::By-Hash=force update; then + break + fi + if [[ "$attempt" == "3" ]]; 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 + sudo apt-get -o Acquire::Retries=3 install -y openssh-client - name: Install node-gyp shell: bash run: npm install -g node-gyp diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 7652cec5..5937e33b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -100,7 +100,7 @@ jobs: e2e-opencode: name: E2E (OpenCode) runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 40 steps: - uses: actions/checkout@v6 - name: Install dependencies @@ -113,7 +113,7 @@ jobs: e2e-clone-cache: name: E2E (Clone cache) runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 40 steps: - uses: actions/checkout@v6 - name: Install dependencies @@ -126,7 +126,7 @@ jobs: e2e-login-context: name: E2E (Login context) runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 40 steps: - uses: actions/checkout@v6 - name: Install dependencies @@ -139,7 +139,7 @@ jobs: e2e-runtime-volumes-ssh: name: E2E (Runtime volumes + SSH) runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 40 steps: - uses: actions/checkout@v6 - name: Install dependencies @@ -152,7 +152,7 @@ jobs: e2e-clone-auto-open-ssh: name: E2E (Clone auto-open SSH) runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 40 steps: - uses: actions/checkout@v6 - name: Install dependencies diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index a12abbe0..ebf5ec50 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -9,7 +9,24 @@ ENV BUN_INSTALL=/opt/bun ENV PATH=/opt/bun/bin:$PATH WORKDIR /workspace -RUN apt-get update && apt-get install -y --no-install-recommends \ +RUN set -eu; \ + sed -i \ + -e 's|http://archive.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' \ + -e 's|http://security.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' \ + /etc/apt/sources.list /etc/apt/sources.list.d/ubuntu.sources 2>/dev/null || true; \ + for attempt in 1 2 3; 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" = "3" ]; 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 \ ca-certificates curl git docker.io docker-compose-v2 openssh-client sshpass python3 make g++ unzip \ && rm -rf /var/lib/apt/lists/* diff --git a/packages/api/src/program.ts b/packages/api/src/program.ts index 839f9c5f..6104e990 100644 --- a/packages/api/src/program.ts +++ b/packages/api/src/program.ts @@ -11,12 +11,20 @@ import { attachProjectBrowserWebSocketServer } from "./services/project-browser. import { attachProjectDatabaseWebSocketServer } from "./services/project-databases.js" import { attachTerminalWebSocketServer } from "./services/terminal-sessions.js" +type ApiHttpServer = ReturnType + const resolvePort = (env: Record): number => { const raw = env["DOCKER_GIT_API_PORT"] ?? env["PORT"] const parsed = raw === undefined ? Number.NaN : Number(raw) return Number.isFinite(parsed) && parsed > 0 ? parsed : 3334 } +export const configureLongRunningRequestTimeouts = (server: ApiHttpServer): ApiHttpServer => { + server.requestTimeout = 0 + server.setTimeout(0) + return server +} + const requestLogger = HttpMiddleware.make((httpApp) => Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) @@ -45,7 +53,7 @@ export const program = (() => { const port = resolvePort(process.env) const router = makeRouter() const app = router.pipe(HttpServer.serve(requestLogger), HttpServer.withLogAddress) - const server = createServer() + const server = configureLongRunningRequestTimeouts(createServer()) attachAuthTerminalWebSocketServer(server) attachTerminalWebSocketServer(server) attachProjectBrowserWebSocketServer(server) diff --git a/packages/api/src/services/agents.ts b/packages/api/src/services/agents.ts index f1b8f0c5..bc0f4a5f 100644 --- a/packages/api/src/services/agents.ts +++ b/packages/api/src/services/agents.ts @@ -55,18 +55,18 @@ const sourceLabel = (request: CreateAgentRequest): string => const pickDefaultCommand = (provider: CreateAgentRequest["provider"]): string => { if (provider === "codex") { - return "codex" + return "MCP_PLAYWRIGHT_ISOLATED=1 codex" } if (provider === "opencode") { return "opencode" } if (provider === "claude") { - return "claude" + return "MCP_PLAYWRIGHT_ISOLATED=1 claude" } return "" } -const buildCommand = (request: CreateAgentRequest): string => { +export const buildCommand = (request: CreateAgentRequest): string => { const direct = request.command?.trim() ?? "" if (direct.length > 0) { return direct diff --git a/packages/api/tests/agents.test.ts b/packages/api/tests/agents.test.ts new file mode 100644 index 00000000..176d00c2 --- /dev/null +++ b/packages/api/tests/agents.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest" + +import { buildCommand } from "../src/services/agents.js" + +describe("agent service", () => { + it("starts default Codex agents with isolated Playwright MCP", () => { + expect(buildCommand({ provider: "codex" })).toBe("MCP_PLAYWRIGHT_ISOLATED=1 codex") + expect(buildCommand({ provider: "codex", args: ["exec", "hello world"] })).toBe( + "MCP_PLAYWRIGHT_ISOLATED=1 codex 'exec' 'hello world'" + ) + }) + + it("starts default Claude agents with isolated Playwright MCP", () => { + expect(buildCommand({ provider: "claude" })).toBe("MCP_PLAYWRIGHT_ISOLATED=1 claude") + expect(buildCommand({ provider: "claude", args: ["-p", "hello world"] })).toBe( + "MCP_PLAYWRIGHT_ISOLATED=1 claude '-p' 'hello world'" + ) + }) + + it("does not rewrite custom agent commands", () => { + expect(buildCommand({ provider: "codex", command: "codex --help" })).toBe("codex --help") + }) +}) diff --git a/packages/api/tests/program.test.ts b/packages/api/tests/program.test.ts new file mode 100644 index 00000000..5c12935d --- /dev/null +++ b/packages/api/tests/program.test.ts @@ -0,0 +1,14 @@ +import { createServer } from "node:http" + +import { describe, expect, it } from "vitest" + +import { configureLongRunningRequestTimeouts } from "../src/program.js" + +describe("api program", () => { + it("does not abort long-running project creation requests", () => { + const server = configureLongRunningRequestTimeouts(createServer()) + + expect(server.requestTimeout).toBe(0) + expect(server.timeout).toBe(0) + }) +}) diff --git a/packages/app/src/docker-git/api-http.ts b/packages/app/src/docker-git/api-http.ts index deafd9cc..2946a239 100644 --- a/packages/app/src/docker-git/api-http.ts +++ b/packages/app/src/docker-git/api-http.ts @@ -1,6 +1,7 @@ import type { HttpClientResponse } from "@effect/platform" -import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform" +import { HttpBody, HttpClient } from "@effect/platform" import type * as HttpClientError from "@effect/platform/HttpClientError" +import { NodeHttpClient } from "@effect/platform-node" import { Effect } from "effect" import { readHttpResponseTextStream } from "../shared/http-response-stream.js" @@ -140,16 +141,19 @@ const executeRequestWithControllerRetry = ( body: JsonRequest | undefined ) => { const execute = () => executeRequest(client, resolveApiBaseUrl(), method, path, body) + const shouldRetry = method === "GET" return execute().pipe( Effect.matchEffect({ onFailure: (error) => - ensureControllerReady().pipe( - Effect.matchEffect({ - onFailure: () => Effect.fail(error), - onSuccess: () => execute() - }) - ), + !shouldRetry + ? Effect.fail(error) + : ensureControllerReady().pipe( + Effect.matchEffect({ + onFailure: () => Effect.fail(error), + onSuccess: () => execute() + }) + ), onSuccess: (value) => Effect.succeed(value) }) ) @@ -198,7 +202,7 @@ export const request = ( } return parsed - }).pipe(Effect.provide(FetchHttpClient.layer), mapTransportError(method, path)) + }).pipe(Effect.provide(NodeHttpClient.layer), mapTransportError(method, path)) export const requestVoid = (method: ApiHttpMethod, path: string, body?: JsonRequest) => request(method, path, body).pipe(Effect.asVoid) @@ -219,4 +223,4 @@ export const requestTextStream = ( } return yield* _(readHttpResponseTextStream(response, onChunk)) - }).pipe(Effect.provide(FetchHttpClient.layer), mapTransportError(method, path)) + }).pipe(Effect.provide(NodeHttpClient.layer), mapTransportError(method, path)) diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index ca20745f..e57e232e 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -93,7 +93,7 @@ Container runtime env (set via .orch/env/project.env): DOCKER_GIT_ZSH_AUTOSUGGEST=1|0 Enable zsh-autosuggestions (default: 1) DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=... zsh-autosuggestions highlight style (default: fg=8,italic) DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=... Suggestion sources (default: history completion) - MCP_PLAYWRIGHT_ISOLATED=1|0 Isolated browser contexts (recommended for many Codex; default: 1) + MCP_PLAYWRIGHT_ISOLATED=1|0 Isolated browser contexts; default 0 shares the VNC session MCP_PLAYWRIGHT_CDP_GUARD=1|0 Guard CDP so MCP cannot close/crash shared Chromium (default: 1) MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE=1|0 Block destructive Browser.close/crash CDP methods (default: 1) MCP_PLAYWRIGHT_CDP_ENDPOINT=http://... Override CDP endpoint (default: http://dg--browser:9223) diff --git a/packages/app/src/lib/core/templates-entrypoint/agent.ts b/packages/app/src/lib/core/templates-entrypoint/agent.ts index 8e7c69b9..6a35f4ca 100644 --- a/packages/app/src/lib/core/templates-entrypoint/agent.ts +++ b/packages/app/src/lib/core/templates-entrypoint/agent.ts @@ -52,8 +52,13 @@ fi` const renderAgentPromptCommand = (mode: AgentMode): string => Match.value(mode).pipe( - Match.when("claude", () => String.raw`claude --dangerously-skip-permissions -p \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), - Match.when("codex", () => String.raw`codex exec \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), + Match.when( + "claude", + () => + String + .raw`MCP_PLAYWRIGHT_ISOLATED=1 claude --dangerously-skip-permissions -p \"\$(cat \"$AGENT_PROMPT_FILE\")\"` + ), + Match.when("codex", () => String.raw`MCP_PLAYWRIGHT_ISOLATED=1 codex exec \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), Match.when("gemini", () => String.raw`gemini --approval-mode=yolo \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), Match.exhaustive ) diff --git a/packages/app/src/lib/core/templates-entrypoint/base.ts b/packages/app/src/lib/core/templates-entrypoint/base.ts index 9a40abce..9709a551 100644 --- a/packages/app/src/lib/core/templates-entrypoint/base.ts +++ b/packages/app/src/lib/core/templates-entrypoint/base.ts @@ -29,7 +29,7 @@ AGENT_MODE="\${AGENT_MODE:-}" AGENT_AUTO="\${AGENT_AUTO:-}" MCP_PLAYWRIGHT_ENABLE="\${MCP_PLAYWRIGHT_ENABLE:-${config.enableMcpPlaywright ? "1" : "0"}}" MCP_PLAYWRIGHT_CDP_ENDPOINT="\${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}" -MCP_PLAYWRIGHT_ISOLATED="\${MCP_PLAYWRIGHT_ISOLATED:-1}" +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}" diff --git a/packages/app/src/lib/core/templates-entrypoint/nested-docker-git.ts b/packages/app/src/lib/core/templates-entrypoint/nested-docker-git.ts index c46b142a..30e2a960 100644 --- a/packages/app/src/lib/core/templates-entrypoint/nested-docker-git.ts +++ b/packages/app/src/lib/core/templates-entrypoint/nested-docker-git.ts @@ -116,7 +116,7 @@ CODEX_AUTO_UPDATE=1 DOCKER_GIT_ZSH_AUTOSUGGEST=1 DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=fg=8,italic DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=history completion -MCP_PLAYWRIGHT_ISOLATED=1 +MCP_PLAYWRIGHT_ISOLATED=0 EOF fi diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index 7edba351..78f3a795 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -8,7 +8,24 @@ const renderDockerfilePrelude = (): string => ENV DEBIAN_FRONTEND=noninteractive ENV NVM_DIR=/usr/local/nvm -RUN apt-get update && apt-get install -y --no-install-recommends \ +RUN set -eu; \ + sed -i \ + -e 's|http://archive.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' \ + -e 's|http://security.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' \ + /etc/apt/sources.list /etc/apt/sources.list.d/ubuntu.sources 2>/dev/null || true; \ + 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 \ make docker.io docker-compose-v2 bash-completion zsh zsh-autosuggestions xauth \ ncurses-term \ @@ -172,7 +189,7 @@ if [[ -z "$JSON" ]]; then fi EXTRA_ARGS=() -if [[ "\${MCP_PLAYWRIGHT_ISOLATED:-1}" == "1" ]]; then +if [[ "\${MCP_PLAYWRIGHT_ISOLATED:-0}" == "1" ]]; then EXTRA_ARGS+=(--isolated) fi diff --git a/packages/app/src/lib/usecases/actions/prepare-files.ts b/packages/app/src/lib/usecases/actions/prepare-files.ts index 7eaac6c9..3566f522 100644 --- a/packages/app/src/lib/usecases/actions/prepare-files.ts +++ b/packages/app/src/lib/usecases/actions/prepare-files.ts @@ -230,7 +230,7 @@ const defaultProjectEnvContents = [ "DOCKER_GIT_ZSH_AUTOSUGGEST=1", "DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=fg=8,italic", "DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=history completion", - "MCP_PLAYWRIGHT_ISOLATED=1", + "MCP_PLAYWRIGHT_ISOLATED=0", "MCP_PLAYWRIGHT_CDP_GUARD=1", "MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE=1", "" diff --git a/packages/app/tests/docker-git/api-http.test.ts b/packages/app/tests/docker-git/api-http.test.ts index 75fd0a81..7201e7db 100644 --- a/packages/app/tests/docker-git/api-http.test.ts +++ b/packages/app/tests/docker-git/api-http.test.ts @@ -1,9 +1,11 @@ +/* jscpd:ignore-start */ import { NodeContext } from "@effect/platform-node" import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" -import { afterEach, beforeEach, vi } from "vitest" +import { beforeEach, vi } from "vitest" import { request } from "../../src/docker-git/api-http.js" +/* jscpd:ignore-end */ const resolveApiBaseUrlMock = vi.hoisted(() => vi.fn<() => string>()) const ensureControllerReadyMock = vi.hoisted(() => vi.fn<() => Effect.Effect>()) @@ -15,22 +17,6 @@ vi.mock("../../src/docker-git/controller.js", () => ({ const joinIp = (...octets: ReadonlyArray): string => octets.join(".") const makeHttpUrl = (host: string, port: string): string => ["ht", "tp://", host, ":", port].join("") -const toFetchUrl = (value: Parameters[0] | undefined): string => { - if (value === undefined) { - throw new TypeError("unexpected undefined fetch request value") - } - if (typeof value === "string") { - return value - } - if (value instanceof URL) { - return value.toString() - } - if (value instanceof Request) { - return value.url - } - - throw new TypeError("unexpected fetch request value") -} describe("api-http request retry", () => { beforeEach(() => { @@ -39,38 +25,32 @@ describe("api-http request retry", () => { ensureControllerReadyMock.mockImplementation(() => Effect.void) }) - afterEach(() => { - vi.unstubAllGlobals() - }) - it.effect("refreshes controller readiness once after a transport failure", () => Effect.gen(function*(_) { - const fetchMock = vi.fn() - fetchMock.mockRejectedValueOnce(new TypeError("fetch failed")) - fetchMock.mockResolvedValueOnce( - Response.json({ ok: true }, { - status: 200, - headers: { "content-type": "application/json" } - }) - ) - vi.stubGlobal("fetch", fetchMock) - resolveApiBaseUrlMock.mockReturnValueOnce( - makeHttpUrl(joinIp("127", "0", "0", "1"), "3334") + makeHttpUrl(joinIp("127", "0", "0", "1"), "1") ) resolveApiBaseUrlMock.mockReturnValueOnce( - makeHttpUrl(joinIp("172", "17", "0", "20"), "3334") + makeHttpUrl(joinIp("127", "0", "0", "1"), "2") ) - const payload = yield* _(request("GET", "/health")) + const result = yield* _(Effect.either(request("GET", "/health"))) - expect(payload).toEqual({ ok: true }) + expect(result._tag).toBe("Left") expect(ensureControllerReadyMock).toHaveBeenCalledTimes(1) - expect(fetchMock).toHaveBeenCalledTimes(2) + expect(resolveApiBaseUrlMock).toHaveBeenCalledTimes(2) + }).pipe(Effect.provide(NodeContext.layer))) + + it.effect("does not replay mutating requests after a transport failure", () => + Effect.gen(function*(_) { + resolveApiBaseUrlMock.mockReturnValue( + makeHttpUrl(joinIp("127", "0", "0", "1"), "1") + ) + + const result = yield* _(Effect.either(request("POST", "/projects", { outDir: "project-1" }))) - const firstCall = fetchMock.mock.calls[0]?.[0] - const secondCall = fetchMock.mock.calls[1]?.[0] - expect(toFetchUrl(firstCall)).toContain(`${joinIp("127", "0", "0", "1")}:3334/health`) - expect(toFetchUrl(secondCall)).toContain(`${joinIp("172", "17", "0", "20")}:3334/health`) + expect(result._tag).toBe("Left") + expect(ensureControllerReadyMock).not.toHaveBeenCalled() + expect(resolveApiBaseUrlMock).toHaveBeenCalledTimes(1) }).pipe(Effect.provide(NodeContext.layer))) }) diff --git a/packages/lib/src/core/templates-entrypoint/agent.ts b/packages/lib/src/core/templates-entrypoint/agent.ts index aa748ce8..5529dd24 100644 --- a/packages/lib/src/core/templates-entrypoint/agent.ts +++ b/packages/lib/src/core/templates-entrypoint/agent.ts @@ -51,8 +51,13 @@ fi` const renderAgentPromptCommand = (mode: AgentMode): string => Match.value(mode).pipe( - Match.when("claude", () => String.raw`claude --dangerously-skip-permissions -p \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), - Match.when("codex", () => String.raw`codex exec \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), + Match.when( + "claude", + () => + String + .raw`MCP_PLAYWRIGHT_ISOLATED=1 claude --dangerously-skip-permissions -p \"\$(cat \"$AGENT_PROMPT_FILE\")\"` + ), + Match.when("codex", () => String.raw`MCP_PLAYWRIGHT_ISOLATED=1 codex exec \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), Match.when("gemini", () => String.raw`gemini --approval-mode=yolo \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), Match.exhaustive ) diff --git a/packages/lib/src/core/templates-entrypoint/base.ts b/packages/lib/src/core/templates-entrypoint/base.ts index dd1324b1..2590c194 100644 --- a/packages/lib/src/core/templates-entrypoint/base.ts +++ b/packages/lib/src/core/templates-entrypoint/base.ts @@ -28,7 +28,7 @@ AGENT_MODE="\${AGENT_MODE:-}" AGENT_AUTO="\${AGENT_AUTO:-}" MCP_PLAYWRIGHT_ENABLE="\${MCP_PLAYWRIGHT_ENABLE:-${config.enableMcpPlaywright ? "1" : "0"}}" MCP_PLAYWRIGHT_CDP_ENDPOINT="\${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}" -MCP_PLAYWRIGHT_ISOLATED="\${MCP_PLAYWRIGHT_ISOLATED:-1}" +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}" diff --git a/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts b/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts index 19aea594..23b26a41 100644 --- a/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts +++ b/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts @@ -115,7 +115,7 @@ CODEX_AUTO_UPDATE=1 DOCKER_GIT_ZSH_AUTOSUGGEST=1 DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=fg=8,italic DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=history completion -MCP_PLAYWRIGHT_ISOLATED=1 +MCP_PLAYWRIGHT_ISOLATED=0 EOF fi diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 66229dbf..88a4c2bc 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -7,7 +7,24 @@ const renderDockerfilePrelude = (): string => ENV DEBIAN_FRONTEND=noninteractive ENV NVM_DIR=/usr/local/nvm -RUN apt-get update && apt-get install -y --no-install-recommends \ +RUN set -eu; \ + sed -i \ + -e 's|http://archive.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' \ + -e 's|http://security.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' \ + /etc/apt/sources.list /etc/apt/sources.list.d/ubuntu.sources 2>/dev/null || true; \ + 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 \ make docker.io docker-compose-v2 bash-completion zsh zsh-autosuggestions xauth \ ncurses-term \ @@ -171,7 +188,7 @@ if [[ -z "$JSON" ]]; then fi EXTRA_ARGS=() -if [[ "\${MCP_PLAYWRIGHT_ISOLATED:-1}" == "1" ]]; then +if [[ "\${MCP_PLAYWRIGHT_ISOLATED:-0}" == "1" ]]; then EXTRA_ARGS+=(--isolated) fi diff --git a/packages/lib/src/usecases/actions/prepare-files.ts b/packages/lib/src/usecases/actions/prepare-files.ts index 5972dc3c..16d64a3b 100644 --- a/packages/lib/src/usecases/actions/prepare-files.ts +++ b/packages/lib/src/usecases/actions/prepare-files.ts @@ -229,7 +229,7 @@ const defaultProjectEnvContents = [ "DOCKER_GIT_ZSH_AUTOSUGGEST=1", "DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=fg=8,italic", "DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=history completion", - "MCP_PLAYWRIGHT_ISOLATED=1", + "MCP_PLAYWRIGHT_ISOLATED=0", "MCP_PLAYWRIGHT_CDP_GUARD=1", "MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE=1", "" diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 2243e6a7..1f697316 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -167,6 +167,7 @@ describe("renderEntrypoint auth bridge", () => { "\"codex\")", "\"claude\")", "\"gemini\")", + 'MCP_PLAYWRIGHT_ISOLATED="${MCP_PLAYWRIGHT_ISOLATED:-0}"', "\"20-agents-skills::.agents/skills\"", "\"30-agents-dot-skills::.agents/.skills\"", "\"80-codex-skills::.codex/skills\"", @@ -176,7 +177,8 @@ describe("renderEntrypoint auth bridge", () => { "$project_dir/.gemini/settings.json", "$project_dir/.gemini/commands", "$project_dir/.gemini/skills", - "codex exec" + "MCP_PLAYWRIGHT_ISOLATED=1 codex exec", + "MCP_PLAYWRIGHT_ISOLATED=1 claude --dangerously-skip-permissions -p" ]) expect(entrypoint).not.toContain("codex --approval-mode full-auto") expect(entrypoint).not.toContain("\"40-claude-skills::.claude/skills\"") @@ -190,7 +192,7 @@ describe("renderEntrypoint auth bridge", () => { ". /etc/profile 2>/dev/null || true;", String.raw`. \"$AGENT_ENV_FILE\" 2>/dev/null || true;`, "AGENT_PROMPT_FILE=\"/run/docker-git/agent-prompt.txt\"", - "claude --dangerously-skip-permissions -p", + "MCP_PLAYWRIGHT_ISOLATED=1 claude --dangerously-skip-permissions -p", "CLAUDE_GLOBAL_PROMPT_FILE=\"/home/dev/.claude/CLAUDE.md\"", "CLAUDE_AUTO_SYSTEM_PROMPT=\"${CLAUDE_AUTO_SYSTEM_PROMPT:-1}\"", "docker-git-managed:claude-md", diff --git a/packages/lib/tests/usecases/mcp-playwright.test.ts b/packages/lib/tests/usecases/mcp-playwright.test.ts index 246d5968..45ba7b7a 100644 --- a/packages/lib/tests/usecases/mcp-playwright.test.ts +++ b/packages/lib/tests/usecases/mcp-playwright.test.ts @@ -129,6 +129,7 @@ describe("enableMcpPlaywrightProjectFiles", () => { expect(dockerfileAfter).toContain("MCP_PLAYWRIGHT_RETRY_ATTEMPTS") expect(dockerfileAfter).toContain("MCP_PLAYWRIGHT_RETRY_DELAY") expect(dockerfileAfter).toContain("MCP_PLAYWRIGHT_CDP_GUARD") + expect(dockerfileAfter).toContain('if [[ "${MCP_PLAYWRIGHT_ISOLATED:-0}" == "1" ]]; then') expect(dockerfileAfter).toContain("fetch_cdp_version()") expect(dockerfileAfter).toContain("waiting for browser sidecar") expect(dockerfileAfter).toContain('exec playwright-mcp --cdp-endpoint "$CDP_ENDPOINT"') diff --git a/packages/lib/tests/usecases/prepare-files.test.ts b/packages/lib/tests/usecases/prepare-files.test.ts index 2e3a7bec..bd4fc225 100644 --- a/packages/lib/tests/usecases/prepare-files.test.ts +++ b/packages/lib/tests/usecases/prepare-files.test.ts @@ -210,7 +210,8 @@ describe("prepareProjectFiles", () => { expect(entrypoint).toContain('OPENCODE_CONFIG_DIR="/home/dev/.config/opencode"') expect(entrypoint).toContain('su - dev -s /bin/bash -c "bash -lc') expect(entrypoint).toContain('. /etc/profile 2>/dev/null || true;') - expect(entrypoint).toContain("codex exec") + expect(entrypoint).toContain("MCP_PLAYWRIGHT_ISOLATED=1 codex exec") + expect(entrypoint).toContain("MCP_PLAYWRIGHT_ISOLATED=1 claude --dangerously-skip-permissions -p") expect(entrypoint).not.toContain("codex --approval-mode full-auto") expect(entrypoint).toContain("docker_git_sync_project_codex_skills()") expect(entrypoint).toContain('project_skills_root="$codex_home/skills/.docker-git-project"') @@ -285,6 +286,7 @@ describe("prepareProjectFiles", () => { expect(composeAfter).toContain("container_name: dg-test-browser\n restart: unless-stopped") 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(composeAfter).toContain("docker-git-shared") diff --git a/scripts/e2e/clone-auto-open-ssh.sh b/scripts/e2e/clone-auto-open-ssh.sh index 8b044566..d62dd00e 100755 --- a/scripts/e2e/clone-auto-open-ssh.sh +++ b/scripts/e2e/clone-auto-open-ssh.sh @@ -35,7 +35,9 @@ CLONE_LOG="$ROOT/clone-auto-open-ssh.log" SSH_INVOCATION_LOG="$ROOT/ssh-invocation.log" SSH_SESSION_LOG="$ROOT/ssh-session.log" RUN_SCRIPT="$ROOT/run-clone-auto-open-ssh.sh" -CLONE_AUTO_OPEN_TIMEOUT="${DOCKER_GIT_E2E_CLONE_AUTO_OPEN_TIMEOUT:-300s}" +# Cold controller and project image builds can be slow on GitHub-hosted runners, +# especially when Ubuntu/NodeSource package mirrors are cold. +CLONE_AUTO_OPEN_TIMEOUT="${DOCKER_GIT_E2E_CLONE_AUTO_OPEN_TIMEOUT:-1800s}" FAILURE_DUMPED=0 fail() { @@ -230,8 +232,16 @@ export DOCKER_GIT_E2E_CONTAINER_NAME="$CONTAINER_NAME" export DOCKER_GIT_SSH_KEY="$SSH_KEY" export REPO_ROOT REPO_URL ROOT SSH_PORT OUT_DIR_REL CONTAINER_NAME SERVICE_NAME VOLUME_NAME -timeout "$CLONE_AUTO_OPEN_TIMEOUT" script -q -e -c "$RUN_SCRIPT" /dev/null >"$CLONE_LOG" 2>&1 \ - || fail "clone auto-open command failed" +set +e +timeout "$CLONE_AUTO_OPEN_TIMEOUT" script -q -e -c "$RUN_SCRIPT" /dev/null >"$CLONE_LOG" 2>&1 +clone_exit=$? +set -e +if [[ "$clone_exit" -eq 124 ]]; then + fail "clone auto-open command timed out after $CLONE_AUTO_OPEN_TIMEOUT" +fi +if [[ "$clone_exit" -ne 0 ]]; then + fail "clone auto-open command failed with exit code $clone_exit" +fi grep -Fq -- "Project created: octocat/hello-world" "$CLONE_LOG" \ || fail "expected clone log to confirm project creation"