From 7adcec898d941e325b89a0405a2180385e953917 Mon Sep 17 00:00:00 2001 From: "{ \"message\": \"Bad credentials\", \"documentation_url\": \"https://docs.github.com/rest\", \"status\": \"401\"}" <{ "message": "Bad credentials", "documentation_url": "https://docs.github.com/rest", "status": "401"}+{ "message": "Bad credentials", "documentation_url": "https://docs.github.com/rest", "status": "401"}@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:03:34 +0000 Subject: [PATCH 1/8] fix(mcp): isolate agents from shared browser --- packages/api/src/services/agents.ts | 6 ++--- packages/api/tests/agents.test.ts | 23 +++++++++++++++++++ packages/app/src/docker-git/cli/usage.ts | 2 +- .../lib/core/templates-entrypoint/agent.ts | 9 ++++++-- .../src/lib/core/templates-entrypoint/base.ts | 2 +- .../templates-entrypoint/nested-docker-git.ts | 2 +- .../app/src/lib/core/templates/dockerfile.ts | 2 +- .../src/lib/usecases/actions/prepare-files.ts | 2 +- .../src/core/templates-entrypoint/agent.ts | 9 ++++++-- .../lib/src/core/templates-entrypoint/base.ts | 2 +- .../templates-entrypoint/nested-docker-git.ts | 2 +- packages/lib/src/core/templates/dockerfile.ts | 2 +- .../lib/src/usecases/actions/prepare-files.ts | 2 +- packages/lib/tests/core/templates.test.ts | 6 +++-- .../lib/tests/usecases/mcp-playwright.test.ts | 1 + .../lib/tests/usecases/prepare-files.test.ts | 4 +++- 16 files changed, 57 insertions(+), 19 deletions(-) create mode 100644 packages/api/tests/agents.test.ts 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/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..2ebf17a4 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -172,7 +172,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/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..9c8f7321 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -171,7 +171,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") From 8578ef7500591ab0cc68183e3be3e4613d0c4a3e Mon Sep 17 00:00:00 2001 From: "{ \"message\": \"Bad credentials\", \"documentation_url\": \"https://docs.github.com/rest\", \"status\": \"401\"}" <{ "message": "Bad credentials", "documentation_url": "https://docs.github.com/rest", "status": "401"}+{ "message": "Bad credentials", "documentation_url": "https://docs.github.com/rest", "status": "401"}@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:31:17 +0000 Subject: [PATCH 2/8] ci: retry apt index refresh in generated dockerfiles --- packages/app/src/lib/core/templates/dockerfile.ts | 15 ++++++++++++++- packages/lib/src/core/templates/dockerfile.ts | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index 2ebf17a4..f63f952c 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -8,7 +8,20 @@ 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; \ + for attempt in 1 2 3 4 5; do \ + rm -rf /var/lib/apt/lists/*; \ + if apt-get -o Acquire::Retries=3 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 \ diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 9c8f7321..eb187360 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -7,7 +7,20 @@ 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; \ + for attempt in 1 2 3 4 5; do \ + rm -rf /var/lib/apt/lists/*; \ + if apt-get -o Acquire::Retries=3 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 \ From 8ee61347c0f95f5f8bbc9de0b0ebeba0e2470070 Mon Sep 17 00:00:00 2001 From: "{ \"message\": \"Bad credentials\", \"documentation_url\": \"https://docs.github.com/rest\", \"status\": \"401\"}" <{ "message": "Bad credentials", "documentation_url": "https://docs.github.com/rest", "status": "401"}+{ "message": "Bad credentials", "documentation_url": "https://docs.github.com/rest", "status": "401"}@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:55:03 +0000 Subject: [PATCH 3/8] ci: harden apt refreshes --- .github/actions/setup/action.yml | 15 +++++++++++++-- packages/api/Dockerfile | 17 ++++++++++++++++- .../app/src/lib/core/templates/dockerfile.ts | 2 +- packages/lib/src/core/templates/dockerfile.ts | 2 +- 4 files changed, 31 insertions(+), 5 deletions(-) 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/packages/api/Dockerfile b/packages/api/Dockerfile index a12abbe0..73f0b7fc 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -9,7 +9,22 @@ 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 's|http://archive.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/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index f63f952c..ac8dfeb3 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -11,7 +11,7 @@ ENV NVM_DIR=/usr/local/nvm RUN set -eu; \ for attempt in 1 2 3 4 5; do \ rm -rf /var/lib/apt/lists/*; \ - if apt-get -o Acquire::Retries=3 update; then \ + if apt-get -o Acquire::Retries=3 -o Acquire::By-Hash=force update; then \ break; \ fi; \ if [ "$attempt" = "5" ]; then \ diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index eb187360..198190f1 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -10,7 +10,7 @@ ENV NVM_DIR=/usr/local/nvm RUN set -eu; \ for attempt in 1 2 3 4 5; do \ rm -rf /var/lib/apt/lists/*; \ - if apt-get -o Acquire::Retries=3 update; then \ + if apt-get -o Acquire::Retries=3 -o Acquire::By-Hash=force update; then \ break; \ fi; \ if [ "$attempt" = "5" ]; then \ From df31bbdeafa132f7e7d19033b83b8ef7cac9f2a9 Mon Sep 17 00:00:00 2001 From: "{ \"message\": \"Bad credentials\", \"documentation_url\": \"https://docs.github.com/rest\", \"status\": \"401\"}" <{ "message": "Bad credentials", "documentation_url": "https://docs.github.com/rest", "status": "401"}+{ "message": "Bad credentials", "documentation_url": "https://docs.github.com/rest", "status": "401"}@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:09:26 +0000 Subject: [PATCH 4/8] ci: allow cold auto-open e2e builds --- scripts/e2e/clone-auto-open-ssh.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/e2e/clone-auto-open-ssh.sh b/scripts/e2e/clone-auto-open-ssh.sh index 8b044566..01e7197b 100755 --- a/scripts/e2e/clone-auto-open-ssh.sh +++ b/scripts/e2e/clone-auto-open-ssh.sh @@ -35,7 +35,8 @@ 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/project image builds can exceed 5 minutes on GitHub-hosted runners. +CLONE_AUTO_OPEN_TIMEOUT="${DOCKER_GIT_E2E_CLONE_AUTO_OPEN_TIMEOUT:-900s}" FAILURE_DUMPED=0 fail() { From 5de63b8d872bd87bc57361131511dafe54dd03f8 Mon Sep 17 00:00:00 2001 From: "{ \"message\": \"Bad credentials\", \"documentation_url\": \"https://docs.github.com/rest\", \"status\": \"401\"}" <{ "message": "Bad credentials", "documentation_url": "https://docs.github.com/rest", "status": "401"}+{ "message": "Bad credentials", "documentation_url": "https://docs.github.com/rest", "status": "401"}@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:39:53 +0000 Subject: [PATCH 5/8] ci: keep long project creates alive --- packages/api/src/program.ts | 10 +++++++++- packages/api/tests/program.test.ts | 14 ++++++++++++++ packages/app/src/docker-git/api-http.ts | 15 +++++++++------ packages/app/tests/docker-git/api-http.test.ts | 17 +++++++++++++++++ 4 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 packages/api/tests/program.test.ts 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/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..2aef04cd 100644 --- a/packages/app/src/docker-git/api-http.ts +++ b/packages/app/src/docker-git/api-http.ts @@ -140,16 +140,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) }) ) diff --git a/packages/app/tests/docker-git/api-http.test.ts b/packages/app/tests/docker-git/api-http.test.ts index 75fd0a81..7a5991be 100644 --- a/packages/app/tests/docker-git/api-http.test.ts +++ b/packages/app/tests/docker-git/api-http.test.ts @@ -73,4 +73,21 @@ describe("api-http request retry", () => { expect(toFetchUrl(firstCall)).toContain(`${joinIp("127", "0", "0", "1")}:3334/health`) expect(toFetchUrl(secondCall)).toContain(`${joinIp("172", "17", "0", "20")}:3334/health`) }).pipe(Effect.provide(NodeContext.layer))) + + it.effect("does not replay mutating requests after a transport failure", () => + Effect.gen(function*(_) { + const fetchMock = vi.fn() + fetchMock.mockRejectedValueOnce(new TypeError("fetch failed")) + vi.stubGlobal("fetch", fetchMock) + + resolveApiBaseUrlMock.mockReturnValue( + makeHttpUrl(joinIp("127", "0", "0", "1"), "3334") + ) + + const result = yield* _(Effect.either(request("POST", "/projects", { outDir: "project-1" }))) + + expect(result._tag).toBe("Left") + expect(ensureControllerReadyMock).not.toHaveBeenCalled() + expect(fetchMock).toHaveBeenCalledTimes(1) + }).pipe(Effect.provide(NodeContext.layer))) }) From fecbc71ba795d7672f060414734a6f1acb3c68cf Mon Sep 17 00:00:00 2001 From: "{ \"message\": \"Bad credentials\", \"documentation_url\": \"https://docs.github.com/rest\", \"status\": \"401\"}" <{ "message": "Bad credentials", "documentation_url": "https://docs.github.com/rest", "status": "401"}+{ "message": "Bad credentials", "documentation_url": "https://docs.github.com/rest", "status": "401"}@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:56:16 +0000 Subject: [PATCH 6/8] ci: avoid bun fetch timeout in host cli --- packages/app/src/docker-git/api-http.ts | 7 +- .../app/tests/docker-git/api-http.test.ts | 123 ++++++++++-------- 2 files changed, 75 insertions(+), 55 deletions(-) diff --git a/packages/app/src/docker-git/api-http.ts b/packages/app/src/docker-git/api-http.ts index 2aef04cd..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" @@ -201,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) @@ -222,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/tests/docker-git/api-http.test.ts b/packages/app/tests/docker-git/api-http.test.ts index 7a5991be..2c8040a6 100644 --- a/packages/app/tests/docker-git/api-http.test.ts +++ b/packages/app/tests/docker-git/api-http.test.ts @@ -1,9 +1,14 @@ +/* jscpd:ignore-start */ +import { createServer, type Server } from "node:http" +import type { AddressInfo } from "node:net" + 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 +20,42 @@ 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") -} + +const listen = (server: Server): Effect.Effect => + Effect.async((resume) => { + const onError = (error: Error) => { + resume(Effect.fail(error)) + } + + server.once("error", onError) + server.listen(0, "127.0.0.1", () => { + server.off("error", onError) + resume(Effect.succeed((server.address() as AddressInfo).port)) + }) + + return Effect.sync(() => { + server.off("error", onError) + }) + }) + +const close = (server: Server): Effect.Effect => + Effect.async((resume) => { + server.close((error) => { + if (error === undefined) { + resume(Effect.void) + return + } + resume(Effect.fail(error)) + }) + }) + +const reserveUnusedPort = () => + Effect.gen(function*(_) { + const server = createServer() + const port = yield* _(listen(server)) + yield* _(close(server)) + return port + }) describe("api-http request retry", () => { beforeEach(() => { @@ -39,55 +64,49 @@ 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") - ) - resolveApiBaseUrlMock.mockReturnValueOnce( - makeHttpUrl(joinIp("172", "17", "0", "20"), "3334") + const seenUrls: Array = [] + const server = createServer((incoming, response) => { + seenUrls.push(incoming.url) + response.writeHead(200, { "content-type": "application/json" }) + response.end(JSON.stringify({ ok: true })) + }) + const deadPort = yield* _(reserveUnusedPort()) + const port = yield* _(listen(server)) + + yield* _( + Effect.gen(function*(_) { + resolveApiBaseUrlMock.mockReturnValueOnce( + makeHttpUrl(joinIp("127", "0", "0", "1"), String(deadPort)) + ) + resolveApiBaseUrlMock.mockReturnValueOnce( + makeHttpUrl(joinIp("127", "0", "0", "1"), String(port)) + ) + + const payload = yield* _(request("GET", "/health")) + + expect(payload).toEqual({ ok: true }) + expect(ensureControllerReadyMock).toHaveBeenCalledTimes(1) + expect(seenUrls).toEqual(["/health"]) + }).pipe( + Effect.ensuring(close(server).pipe(Effect.catchAll(() => Effect.void))) + ) ) - - const payload = yield* _(request("GET", "/health")) - - expect(payload).toEqual({ ok: true }) - expect(ensureControllerReadyMock).toHaveBeenCalledTimes(1) - expect(fetchMock).toHaveBeenCalledTimes(2) - - 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`) }).pipe(Effect.provide(NodeContext.layer))) it.effect("does not replay mutating requests after a transport failure", () => Effect.gen(function*(_) { - const fetchMock = vi.fn() - fetchMock.mockRejectedValueOnce(new TypeError("fetch failed")) - vi.stubGlobal("fetch", fetchMock) + const deadPort = yield* _(reserveUnusedPort()) resolveApiBaseUrlMock.mockReturnValue( - makeHttpUrl(joinIp("127", "0", "0", "1"), "3334") + makeHttpUrl(joinIp("127", "0", "0", "1"), String(deadPort)) ) const result = yield* _(Effect.either(request("POST", "/projects", { outDir: "project-1" }))) expect(result._tag).toBe("Left") expect(ensureControllerReadyMock).not.toHaveBeenCalled() - expect(fetchMock).toHaveBeenCalledTimes(1) + expect(resolveApiBaseUrlMock).toHaveBeenCalledTimes(1) }).pipe(Effect.provide(NodeContext.layer))) }) From 60dac7b83c44244c8cae6d9460883cc8563c0641 Mon Sep 17 00:00:00 2001 From: "{ \"message\": \"Bad credentials\", \"documentation_url\": \"https://docs.github.com/rest\", \"status\": \"401\"}" <{ "message": "Bad credentials", "documentation_url": "https://docs.github.com/rest", "status": "401"}+{ "message": "Bad credentials", "documentation_url": "https://docs.github.com/rest", "status": "401"}@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:02:26 +0000 Subject: [PATCH 7/8] test: keep api http retry test effect-safe --- .../app/tests/docker-git/api-http.test.ts | 78 +++---------------- 1 file changed, 11 insertions(+), 67 deletions(-) diff --git a/packages/app/tests/docker-git/api-http.test.ts b/packages/app/tests/docker-git/api-http.test.ts index 2c8040a6..7201e7db 100644 --- a/packages/app/tests/docker-git/api-http.test.ts +++ b/packages/app/tests/docker-git/api-http.test.ts @@ -1,7 +1,4 @@ /* jscpd:ignore-start */ -import { createServer, type Server } from "node:http" -import type { AddressInfo } from "node:net" - import { NodeContext } from "@effect/platform-node" import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" @@ -21,42 +18,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 listen = (server: Server): Effect.Effect => - Effect.async((resume) => { - const onError = (error: Error) => { - resume(Effect.fail(error)) - } - - server.once("error", onError) - server.listen(0, "127.0.0.1", () => { - server.off("error", onError) - resume(Effect.succeed((server.address() as AddressInfo).port)) - }) - - return Effect.sync(() => { - server.off("error", onError) - }) - }) - -const close = (server: Server): Effect.Effect => - Effect.async((resume) => { - server.close((error) => { - if (error === undefined) { - resume(Effect.void) - return - } - resume(Effect.fail(error)) - }) - }) - -const reserveUnusedPort = () => - Effect.gen(function*(_) { - const server = createServer() - const port = yield* _(listen(server)) - yield* _(close(server)) - return port - }) - describe("api-http request retry", () => { beforeEach(() => { resolveApiBaseUrlMock.mockReset() @@ -66,41 +27,24 @@ describe("api-http request retry", () => { it.effect("refreshes controller readiness once after a transport failure", () => Effect.gen(function*(_) { - const seenUrls: Array = [] - const server = createServer((incoming, response) => { - seenUrls.push(incoming.url) - response.writeHead(200, { "content-type": "application/json" }) - response.end(JSON.stringify({ ok: true })) - }) - const deadPort = yield* _(reserveUnusedPort()) - const port = yield* _(listen(server)) - - yield* _( - Effect.gen(function*(_) { - resolveApiBaseUrlMock.mockReturnValueOnce( - makeHttpUrl(joinIp("127", "0", "0", "1"), String(deadPort)) - ) - resolveApiBaseUrlMock.mockReturnValueOnce( - makeHttpUrl(joinIp("127", "0", "0", "1"), String(port)) - ) + resolveApiBaseUrlMock.mockReturnValueOnce( + makeHttpUrl(joinIp("127", "0", "0", "1"), "1") + ) + resolveApiBaseUrlMock.mockReturnValueOnce( + 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(ensureControllerReadyMock).toHaveBeenCalledTimes(1) - expect(seenUrls).toEqual(["/health"]) - }).pipe( - Effect.ensuring(close(server).pipe(Effect.catchAll(() => Effect.void))) - ) - ) + expect(result._tag).toBe("Left") + expect(ensureControllerReadyMock).toHaveBeenCalledTimes(1) + expect(resolveApiBaseUrlMock).toHaveBeenCalledTimes(2) }).pipe(Effect.provide(NodeContext.layer))) it.effect("does not replay mutating requests after a transport failure", () => Effect.gen(function*(_) { - const deadPort = yield* _(reserveUnusedPort()) - resolveApiBaseUrlMock.mockReturnValue( - makeHttpUrl(joinIp("127", "0", "0", "1"), String(deadPort)) + makeHttpUrl(joinIp("127", "0", "0", "1"), "1") ) const result = yield* _(Effect.either(request("POST", "/projects", { outDir: "project-1" }))) From cd883bb06fd2f1389f92a91201c645f44970825b Mon Sep 17 00:00:00 2001 From: "{ \"message\": \"Bad credentials\", \"documentation_url\": \"https://docs.github.com/rest\", \"status\": \"401\"}" <{ "message": "Bad credentials", "documentation_url": "https://docs.github.com/rest", "status": "401"}+{ "message": "Bad credentials", "documentation_url": "https://docs.github.com/rest", "status": "401"}@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:26:12 +0000 Subject: [PATCH 8/8] ci: relax cold docker e2e timeouts --- .github/workflows/check.yml | 10 +++++----- packages/api/Dockerfile | 4 +++- .../app/src/lib/core/templates/dockerfile.ts | 4 ++++ packages/lib/src/core/templates/dockerfile.ts | 4 ++++ scripts/e2e/clone-auto-open-ssh.sh | 17 +++++++++++++---- 5 files changed, 29 insertions(+), 10 deletions(-) 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 73f0b7fc..ebf5ec50 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -10,7 +10,9 @@ ENV PATH=/opt/bun/bin:$PATH WORKDIR /workspace RUN set -eu; \ - sed -i 's|http://archive.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' \ + 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/*; \ diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index ac8dfeb3..78f3a795 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -9,6 +9,10 @@ ENV DEBIAN_FRONTEND=noninteractive ENV NVM_DIR=/usr/local/nvm 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 \ diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 198190f1..88a4c2bc 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -8,6 +8,10 @@ ENV DEBIAN_FRONTEND=noninteractive ENV NVM_DIR=/usr/local/nvm 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 \ diff --git a/scripts/e2e/clone-auto-open-ssh.sh b/scripts/e2e/clone-auto-open-ssh.sh index 01e7197b..d62dd00e 100755 --- a/scripts/e2e/clone-auto-open-ssh.sh +++ b/scripts/e2e/clone-auto-open-ssh.sh @@ -35,8 +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" -# Cold controller/project image builds can exceed 5 minutes on GitHub-hosted runners. -CLONE_AUTO_OPEN_TIMEOUT="${DOCKER_GIT_E2E_CLONE_AUTO_OPEN_TIMEOUT:-900s}" +# 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() { @@ -231,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"