From 6b9a69b4f67e145239e580c04510081c07dfed60 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 3 Nov 2026 14:20:58 -0800 Subject: [PATCH 1/6] fix(compose): remove the gateway development CPU quota Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 8 ++++ docker-compose.dev.yml | 18 ++++----- docs/architecture/ai-gateway.md | 11 +++++- scripts/docker-compose.test.ts | 70 +++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 scripts/docker-compose.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a90006f0f..ca11ea73e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,6 +87,14 @@ pnpm dev:coder-acp-copilot # A single worker natively (pnpm dev:@gateway:18000` (session ID embedded as userinfo) and `DEV_PROXY_API_URL=http://gateway:18000`. ## Kubernetes Deployment diff --git a/scripts/docker-compose.test.ts b/scripts/docker-compose.test.ts new file mode 100644 index 000000000..08c107434 --- /dev/null +++ b/scripts/docker-compose.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +interface ComposeService { + cpus?: number; + depends_on?: Record; + healthcheck?: { + test?: string[]; + start_period?: string; + }; + mem_limit?: string; + volumes?: Array<{ + source?: string; + target: string; + }>; +} + +interface ComposeConfig { + services: Record; +} + +describe("development Docker Compose configuration", () => { + it("keeps the gateway cold-build resources and Copilot worker dependency", () => { + const result = spawnSync( + "docker", + [ + "compose", + "-f", + "docker-compose.yml", + "-f", + "docker-compose.dev.yml", + "--profile", + "copilot", + "config", + "--format", + "json", + ], + { + encoding: "utf8", + env: { ...process.env, GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? "" }, + }, + ); + + expect(result.status, result.stderr).toBe(0); + + const config = JSON.parse(result.stdout) as ComposeConfig; + const gateway = config.services.gateway; + const copilotWorker = config.services["coder-acp-copilot"]; + + expect(gateway.cpus).toBeUndefined(); + expect(gateway.mem_limit).toBe(String(2 * 1024 ** 3)); + expect(gateway.healthcheck?.start_period).toBe("10m0s"); + expect(gateway.healthcheck?.test).toEqual([ + "CMD-SHELL", + "wget -qO- http://127.0.0.1:18000/health || exit 1", + ]); + expect(gateway.volumes).toContainEqual( + expect.objectContaining({ + source: "gateway_target", + target: "/build/target", + }), + ); + expect(copilotWorker.depends_on?.gateway?.condition).toBe( + "service_healthy", + ); + }); +}); From 8e3a0061f8fad96bc8b1a13d9aa34ee79e8796f5 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 3 Nov 2026 14:39:57 -0800 Subject: [PATCH 2/6] fix(compose): harden agent registration Use the pinned yq image and BusyBox wget for bounded, non-root agent registration without runtime package installation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docker-compose.yml | 10 +- docs/architecture/worker-requirements.md | 35 ++- scripts/register-agent.sh | 160 +++++++---- scripts/register-agent.test.ts | 322 ++++++++++++++--------- 4 files changed, 339 insertions(+), 188 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 31b6b191a..bf65dc69c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -384,10 +384,17 @@ services: db-migrate: condition: service_completed_successfully register-agents: - image: alpine:3.21 + image: docker.io/mikefarah/yq:4.54.1@sha256:4b3d9475d65571d28cbb19544d3820ec2945e4c8b2f18279394282b8dc3a592e depends_on: api: condition: service_healthy + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp:size=1m,mode=1777 volumes: - ./apps/workers:/workers:ro - ./scripts/register-agent.sh:/register-agent.sh:ro @@ -400,7 +407,6 @@ services: - -c - | set -eu - apk add --no-cache curl yq > /dev/null 2>&1 register_agent() { name="$$1" diff --git a/docs/architecture/worker-requirements.md b/docs/architecture/worker-requirements.md index 3bb2efbd5..17a8344ba 100644 --- a/docs/architecture/worker-requirements.md +++ b/docs/architecture/worker-requirements.md @@ -493,15 +493,36 @@ immediately for permanent errors such as invalid manifests, unknown agents, or other 4xx responses. Retry bounds can be configured with the positive-integer `SCOPE_REGISTRATION_MAX_ATTEMPTS`, `SCOPE_REGISTRATION_BASE_DELAY_SECONDS`, and -`SCOPE_REGISTRATION_MAX_DELAY_SECONDS` variables. Curl calls are bounded by -`SCOPE_REGISTRATION_CONNECT_TIMEOUT_SECONDS` (default 5) and -`SCOPE_REGISTRATION_REQUEST_TIMEOUT_SECONDS` (default 30). Only transient DNS, -connection, timeout, and transfer failures are retried; permanent curl -configuration and certificate failures exit immediately. Registration Jobs -must not hide helper failures with `|| true`. +`SCOPE_REGISTRATION_MAX_DELAY_SECONDS` variables. BusyBox wget network waits are +bounded by `SCOPE_REGISTRATION_CONNECT_TIMEOUT_SECONDS` (default 5), and the +BusyBox `timeout` applet enforces a hard total +`SCOPE_REGISTRATION_REQUEST_TIMEOUT_SECONDS` deadline (default 30). DNS, +connection, timeout, interrupted transfer, and other failures without an HTTP +response are retried within the same bound. Redirect diagnostics can contain +multiple HTTP status lines, so the helper classifies the final status. Permanent +HTTP errors exit immediately. BusyBox wget does not always write an HTTP error +body; when no response body is available, the helper reports the final status +and the client's bounded diagnostic tail instead. Registration Jobs must not +hide helper failures with `|| true`. The OSS Compose `register-agents` service uses this contract for Copilot and -Claude. It waits for the API's Docker health check (`GET /health` must return +Claude. It runs as the non-root UID/GID 1000 from the multi-architecture +`mikefarah/yq` 4.54.1 OCI index, pinned as +`docker.io/mikefarah/yq:4.54.1@sha256:4b3d9475d65571d28cbb19544d3820ec2945e4c8b2f18279394282b8dc3a592e`. +The container uses its bundled `yq`, BusyBox `sh`, `wget`, `timeout`, and CA +certificates instead of installing packages at startup. It has a read-only root +filesystem, drops all capabilities, forbids privilege escalation, and receives +only a small writable `/tmp` tmpfs; worker manifests and the registration helper +are mounted read-only. + +The reviewed upstream image is unsigned and has no authoritative signed image +SBOM. Its Alpine musl 1.2.6-r2 and zlib 1.3.2-r0 packages have known High CVEs. +Those residual findings are accepted for this one-shot registration workload: +the `yq` binary is CGO-disabled, and neither vulnerable package path is exercised +by manifest conversion or the static HTTP registration flow. Reassess the pinned +digest when upgrading the image or expanding the service's responsibilities. + +The service waits for the API's Docker health check (`GET /health` must return 200), not merely for the API container to start. The probe uses Node's built-in HTTP client, runs every 5 seconds with a 3-second timeout, and allows a 120-second startup grace period followed by 12 consecutive failures before marking the API diff --git a/scripts/register-agent.sh b/scripts/register-agent.sh index ba7db7288..09551b381 100755 --- a/scripts/register-agent.sh +++ b/scripts/register-agent.sh @@ -13,8 +13,12 @@ if [ "$#" -lt 2 ]; then exit 2 fi -command -v curl >/dev/null 2>&1 || { - echo "curl is required" >&2 +command -v wget >/dev/null 2>&1 || { + echo "wget is required" >&2 + exit 2 +} +command -v timeout >/dev/null 2>&1 || { + echo "timeout is required" >&2 exit 2 } command -v yq >/dev/null 2>&1 || { @@ -26,6 +30,14 @@ API_URL=${1%/} AGENT_MANIFEST=$2 shift 2 +case "$API_URL" in + http://* | https://*) ;; + *) + echo "API URL must use http or https: $API_URL" >&2 + exit 2 + ;; +esac + AVAILABLE_OVERRIDE= if [ "${1:-}" = "--available" ]; then if [ "$#" -lt 2 ]; then @@ -63,7 +75,12 @@ fi TMP_DIR=$(mktemp -d) RESPONSE_FILE="$TMP_DIR/response" -trap 'rm -f "$RESPONSE_FILE"; rmdir "$TMP_DIR"' EXIT HUP INT TERM +DIAGNOSTICS_FILE="$TMP_DIR/wget-diagnostics" +PAYLOAD_FILE="$TMP_DIR/request.json" +trap 'rm -f "$RESPONSE_FILE" "$DIAGNOSTICS_FILE" "$PAYLOAD_FILE"; rmdir "$TMP_DIR"' EXIT HUP INT TERM + +REQUEST_STATUS=000 +REQUEST_EXIT=0 response_body() { if [ -f "$RESPONSE_FILE" ]; then @@ -71,20 +88,25 @@ response_body() { fi } -is_transient_status() { - case "$1" in - 000 | 408 | 425 | 429 | 5??) - return 0 - ;; - *) - return 1 - ;; - esac +request_diagnostics() { + if [ -s "$DIAGNOSTICS_FILE" ]; then + tail -n 3 "$DIAGNOSTICS_FILE" | tr '\n' ' ' + else + printf 'no wget diagnostics' + fi +} + +failure_details() { + if [ -s "$RESPONSE_FILE" ]; then + response_body + else + request_diagnostics + fi } -is_transient_curl_exit() { +is_transient_status() { case "$1" in - 5 | 6 | 7 | 18 | 28 | 35 | 52 | 55 | 56) + 000 | 408 | 425 | 429 | 5??) return 0 ;; *) @@ -93,16 +115,6 @@ is_transient_curl_exit() { esac } -handle_curl_failure() { - curl_exit=$1 - description=$2 - if is_transient_curl_exit "$curl_exit"; then - return 0 - fi - echo "Permanent $description failure (curl exit $curl_exit)" >&2 - return 1 -} - sleep_with_backoff() { attempt=$1 delay=$BASE_DELAY_SECONDS @@ -117,36 +129,78 @@ sleep_with_backoff() { sleep "$delay" } -wait_for_api() { - attempt=1 - while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do - status=000 - if status=$(curl -sS --connect-timeout "$CONNECT_TIMEOUT_SECONDS" \ - --max-time "$REQUEST_TIMEOUT_SECONDS" \ - -o "$RESPONSE_FILE" -w "%{http_code}" "$API_URL/health"); then +perform_request() { + method=$1 + url=$2 + payload=${3:-} + + : > "$RESPONSE_FILE" + : > "$DIAGNOSTICS_FILE" + REQUEST_STATUS=000 + REQUEST_EXIT=0 + + if [ "$method" = "POST" ]; then + printf '%s' "$payload" > "$PAYLOAD_FILE" + if timeout "$REQUEST_TIMEOUT_SECONDS" \ + wget -S -O "$RESPONSE_FILE" \ + -T "$CONNECT_TIMEOUT_SECONDS" \ + --header "Content-Type: application/json" \ + --post-file "$PAYLOAD_FILE" \ + "$url" 2> "$DIAGNOSTICS_FILE"; then : else - curl_exit=$? - handle_curl_failure "$curl_exit" "API readiness" || return 1 - status=000 + REQUEST_EXIT=$? fi + else + if timeout "$REQUEST_TIMEOUT_SECONDS" \ + wget -S -O "$RESPONSE_FILE" \ + -T "$CONNECT_TIMEOUT_SECONDS" \ + "$url" 2> "$DIAGNOSTICS_FILE"; then + : + else + REQUEST_EXIT=$? + fi + fi + + # wget -S writes every redirect response to stderr; the final HTTP line wins. + REQUEST_STATUS=$( + awk '/^ HTTP\/[0-9.]+ [0-9][0-9][0-9]/ { status=$2 } END { print status }' \ + "$DIAGNOSTICS_FILE" + ) + REQUEST_STATUS=${REQUEST_STATUS:-000} - case "$status" in + # A received success or redirect header is not success if the transfer failed. + case "$REQUEST_STATUS:$REQUEST_EXIT" in + 2??:0 | 3??:0) ;; + 2??:* | 3??:*) REQUEST_STATUS=000 ;; + esac +} + +wait_for_api() { + attempt=1 + while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do + perform_request GET "$API_URL/health" + + case "$REQUEST_STATUS" in 2??) return 0 ;; esac - if ! is_transient_status "$status"; then - echo "Permanent API readiness failure (HTTP $status): $(response_body)" >&2 + if ! is_transient_status "$REQUEST_STATUS"; then + echo "Permanent API readiness failure (HTTP $REQUEST_STATUS): $(failure_details)" >&2 return 1 fi if [ "$attempt" -eq "$MAX_ATTEMPTS" ]; then - echo "API readiness failed after $MAX_ATTEMPTS attempts (last HTTP $status)" >&2 + echo "API readiness failed after $MAX_ATTEMPTS attempts (last HTTP $REQUEST_STATUS, wget exit $REQUEST_EXIT): $(request_diagnostics)" >&2 return 1 fi - echo "API not ready (HTTP $status), retrying..." >&2 + if [ "$REQUEST_STATUS" = "000" ]; then + echo "API not ready (no HTTP response, wget exit $REQUEST_EXIT): $(request_diagnostics); retrying..." >&2 + else + echo "API not ready (HTTP $REQUEST_STATUS), retrying..." >&2 + fi sleep_with_backoff "$attempt" attempt=$((attempt + 1)) done @@ -159,37 +213,29 @@ post_idempotent() { attempt=1 while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do - status=000 - if status=$(curl -sS --connect-timeout "$CONNECT_TIMEOUT_SECONDS" \ - --max-time "$REQUEST_TIMEOUT_SECONDS" \ - -o "$RESPONSE_FILE" -w "%{http_code}" \ - -X POST "$API_URL$path" \ - -H "Content-Type: application/json" \ - --data-binary "$payload"); then - : - else - curl_exit=$? - handle_curl_failure "$curl_exit" "$description registration" || return 1 - status=000 - fi + perform_request POST "$API_URL$path" "$payload" - case "$status" in + case "$REQUEST_STATUS" in 2??) echo "Registered $description" return 0 ;; esac - if ! is_transient_status "$status"; then - echo "Permanent $description registration failure (HTTP $status): $(response_body)" >&2 + if ! is_transient_status "$REQUEST_STATUS"; then + echo "Permanent $description registration failure (HTTP $REQUEST_STATUS): $(failure_details)" >&2 return 1 fi if [ "$attempt" -eq "$MAX_ATTEMPTS" ]; then - echo "$description registration failed after $MAX_ATTEMPTS attempts (last HTTP $status): $(response_body)" >&2 + echo "$description registration failed after $MAX_ATTEMPTS attempts (last HTTP $REQUEST_STATUS, wget exit $REQUEST_EXIT): $(failure_details)" >&2 return 1 fi - echo "Transient $description registration failure (HTTP $status), retrying..." >&2 + if [ "$REQUEST_STATUS" = "000" ]; then + echo "Transient $description registration transport failure (wget exit $REQUEST_EXIT): $(request_diagnostics); retrying..." >&2 + else + echo "Transient $description registration failure (HTTP $REQUEST_STATUS), retrying..." >&2 + fi sleep_with_backoff "$attempt" attempt=$((attempt + 1)) done diff --git a/scripts/register-agent.test.ts b/scripts/register-agent.test.ts index 23de84ed0..aaedfaadd 100644 --- a/scripts/register-agent.test.ts +++ b/scripts/register-agent.test.ts @@ -16,52 +16,102 @@ import { afterEach, describe, expect, it } from "vitest"; const temporaryDirectories: string[] = []; const script = resolve("scripts/register-agent.sh"); -const SCRIPT_TEST_TIMEOUT_MS = 15_000; +const SCRIPT_TEST_TIMEOUT_MS = 30_000; -function fixture(statuses: number[], exitCodes: number[] = []) { +interface FakeResponse { + statuses?: number[]; + exitCode?: number; + body?: string; + diagnostics?: string; +} + +function fixture(responses: FakeResponse[]) { const directory = mkdtempSync(join(tmpdir(), "scope-register-agent-")); temporaryDirectories.push(directory); const bin = join(directory, "bin"); mkdirSync(bin); - const statusFile = join(directory, "statuses"); + const responsesFile = join(directory, "responses"); const callsFile = join(directory, "calls"); - const exitCodesFile = join(directory, "exit-codes"); + const payloadsFile = join(directory, "payloads"); const agentManifest = join(directory, "agent.yaml"); const versionManifest = join(directory, "version.yaml"); - writeFileSync(statusFile, `${statuses.join("\n")}\n`); + writeFileSync( + responsesFile, + responses + .map((response) => + [ + response.statuses?.join(",") ?? "", + response.exitCode ?? 0, + response.body ?? "", + response.diagnostics ?? "", + ].join("|"), + ) + .join("\n") + "\n", + ); writeFileSync(callsFile, ""); - writeFileSync(exitCodesFile, `${exitCodes.join("\n")}\n`); + writeFileSync(payloadsFile, ""); writeFileSync(agentManifest, "_id: test-worker\n"); writeFileSync(versionManifest, "agentVersion: test-v1\n"); - const curl = join(bin, "curl"); + const wget = join(bin, "wget"); writeFileSync( - curl, + wget, `#!/bin/sh set -eu -echo "$*" >> "$FAKE_CURL_CALLS_FILE" -exit_code=$(sed -n '1p' "$FAKE_CURL_EXIT_CODES_FILE") -tail -n +2 "$FAKE_CURL_EXIT_CODES_FILE" > "$FAKE_CURL_EXIT_CODES_FILE.next" -mv "$FAKE_CURL_EXIT_CODES_FILE.next" "$FAKE_CURL_EXIT_CODES_FILE" -if [ -n "$exit_code" ] && [ "$exit_code" -ne 0 ]; then - exit "$exit_code" -fi +echo "$*" >> "$FAKE_WGET_CALLS_FILE" +response=$(sed -n '1p' "$FAKE_WGET_RESPONSES_FILE") +tail -n +2 "$FAKE_WGET_RESPONSES_FILE" > "$FAKE_WGET_RESPONSES_FILE.next" +mv "$FAKE_WGET_RESPONSES_FILE.next" "$FAKE_WGET_RESPONSES_FILE" +old_ifs=$IFS +IFS='|' +read -r statuses exit_code body diagnostics < "$FAKE_CURL_STATUS_FILE.next" -mv "$FAKE_CURL_STATUS_FILE.next" "$FAKE_CURL_STATUS_FILE" -printf '{"status":%s}' "$status" > "$output" -printf '%s' "$status" +if [ -n "$post_file" ]; then + cat "$post_file" >> "$FAKE_WGET_PAYLOADS_FILE" + printf '\\n' >> "$FAKE_WGET_PAYLOADS_FILE" +fi +old_ifs=$IFS +IFS=, +for status in $statuses; do + printf ' HTTP/1.1 %s Fake Status\\n' "$status" >&2 +done +IFS=$old_ifs +if [ -n "$diagnostics" ]; then + printf '%s\\n' "$diagnostics" >&2 +fi +printf '%s' "$body" > "$output" +exit "$exit_code" `, ); - chmodSync(curl, 0o755); + chmodSync(wget, 0o755); + + const timeout = join(bin, "timeout"); + writeFileSync( + timeout, + `#!/bin/sh +set -eu +shift +exec "$@" +`, + ); + chmodSync(timeout, 0o755); const yq = join(bin, "yq"); writeFileSync( @@ -91,19 +141,40 @@ esac return { agentManifest, versionManifest, - statusFile, callsFile, + payloadsFile, env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}`, - FAKE_CURL_STATUS_FILE: statusFile, - FAKE_CURL_CALLS_FILE: callsFile, - FAKE_CURL_EXIT_CODES_FILE: exitCodesFile, + FAKE_WGET_RESPONSES_FILE: responsesFile, + FAKE_WGET_CALLS_FILE: callsFile, + FAKE_WGET_PAYLOADS_FILE: payloadsFile, SCOPE_REGISTRATION_MAX_ATTEMPTS: "4", }, }; } +function runFixture( + test: ReturnType, + extraArguments: string[] = [], +) { + return spawnSync( + "sh", + [ + script, + "http://scope-api", + test.agentManifest, + ...extraArguments, + ], + { encoding: "utf8", env: test.env }, + ); +} + +function callCount(callsFile: string) { + const calls = readFileSync(callsFile, "utf8").trim(); + return calls === "" ? 0 : calls.split("\n").length; +} + afterEach(() => { for (const directory of temporaryDirectories.splice(0)) { rmSync(directory, { recursive: true, force: true }); @@ -112,132 +183,139 @@ afterEach(() => { describe("register-agent.sh", () => { it("retries readiness and idempotent POSTs only for transient failures", () => { - const test = fixture([503, 200, 503, 201, 200]); - const result = spawnSync( - "sh", - [ - script, - "http://scope-api", - test.agentManifest, - test.versionManifest, - ], - { encoding: "utf8", env: test.env }, - ); + const test = fixture([ + { statuses: [503], exitCode: 1, body: "starting" }, + { statuses: [200] }, + { statuses: [503], exitCode: 1, body: "retry agent" }, + { statuses: [201], body: "agent created" }, + { statuses: [200], body: "version created" }, + ]); + const result = runFixture(test, [test.versionManifest]); expect(result.status).toBe(0); expect(result.stdout).toContain("Registered agent test-worker"); expect(result.stdout).toContain("Registered version test-worker@test-v1"); - expect(readFileSync(test.callsFile, "utf8").trim().split("\n")).toHaveLength( - 5, - ); + expect(callCount(test.callsFile)).toBe(5); }, SCRIPT_TEST_TIMEOUT_MS); - it("fails immediately and non-zero on permanent agent errors", () => { - const test = fixture([200, 400, 201, 201]); - const result = spawnSync( - "sh", - [ - script, - "http://scope-api", - test.agentManifest, - test.versionManifest, - ], - { encoding: "utf8", env: test.env }, - ); + it.each([408, 425, 429, 500, 502, 503, 504])( + "retries transient HTTP %s responses", + (status) => { + const test = fixture([ + { statuses: [200] }, + { statuses: [status], exitCode: 1 }, + { statuses: [201] }, + ]); + const result = runFixture(test); - expect(result.status).toBe(1); - expect(result.stderr).toContain("Permanent agent test-worker"); - expect(readFileSync(test.callsFile, "utf8").trim().split("\n")).toHaveLength( - 2, - ); - }, SCRIPT_TEST_TIMEOUT_MS); + expect(result.status).toBe(0); + expect(callCount(test.callsFile)).toBe(3); + }, + SCRIPT_TEST_TIMEOUT_MS, + ); - it("fails immediately and non-zero on permanent version errors", () => { - const test = fixture([200, 200, 422, 201]); - const result = spawnSync( - "sh", - [ - script, - "http://scope-api", - test.agentManifest, - test.versionManifest, - ], - { encoding: "utf8", env: test.env }, - ); + it("uses the final status after redirects", () => { + const test = fixture([ + { statuses: [301, 200] }, + { statuses: [307, 201], body: "created" }, + ]); + const result = runFixture(test); - expect(result.status).toBe(1); - expect(result.stderr).toContain( - "Permanent version test-worker@test-v1 registration failure", - ); - expect(readFileSync(test.callsFile, "utf8").trim().split("\n")).toHaveLength( - 3, - ); - }, SCRIPT_TEST_TIMEOUT_MS); + expect(result.status).toBe(0); + expect(callCount(test.callsFile)).toBe(2); + }); - it("fails immediately on a permanent curl configuration error", () => { - const test = fixture([200], [3]); - const result = spawnSync( - "sh", - [script, "invalid://scope-api", test.agentManifest], - { encoding: "utf8", env: test.env }, - ); + it("fails immediately and preserves the response body on permanent errors", () => { + const test = fixture([ + { statuses: [200] }, + { + statuses: [422], + exitCode: 1, + body: '{"error":"invalid manifest"}', + }, + ]); + const result = runFixture(test); expect(result.status).toBe(1); expect(result.stderr).toContain( - "Permanent API readiness failure (curl exit 3)", + 'Permanent agent test-worker registration failure (HTTP 422): {"error":"invalid manifest"}', ); - expect(readFileSync(test.callsFile, "utf8").trim().split("\n")).toHaveLength(1); + expect(callCount(test.callsFile)).toBe(2); }); - it("retries transient curl connection failures", () => { - const test = fixture([200, 200], [7, 0, 0]); - const result = spawnSync( - "sh", - [script, "http://scope-api", test.agentManifest], - { encoding: "utf8", env: test.env }, - ); + it.each([ + ["DNS failure", 1, "bad address 'scope-api'", undefined], + ["connection failure", 1, "connection refused", undefined], + ["timeout", 143, "Terminated", undefined], + ["interrupted transfer", 1, "connection reset by peer", [200]], + ])( + "retries %s failures without a completed HTTP response", + (_, exitCode, diagnostics, statuses) => { + const test = fixture([ + { statuses, exitCode, diagnostics }, + { statuses: [200] }, + { statuses: [201] }, + ]); + const result = runFixture(test); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("no HTTP response"); + expect(result.stderr).toContain(diagnostics); + expect(callCount(test.callsFile)).toBe(3); + }, + ); + + it("posts exact JSON bytes through a temporary file with the JSON content type", () => { + const test = fixture([{ statuses: [200] }, { statuses: [201] }]); + const result = runFixture(test); + const calls = readFileSync(test.callsFile, "utf8"); + const payloads = readFileSync(test.payloadsFile, "utf8"); expect(result.status).toBe(0); - expect(readFileSync(test.callsFile, "utf8").trim().split("\n")).toHaveLength(3); + expect(calls).toContain("--header Content-Type: application/json"); + expect(calls).toContain("--post-file"); + expect(payloads).toBe( + '{"_id":"test-worker","name":"Test Worker","available":true}\n', + ); }); it("overrides agent availability in memory", () => { - const test = fixture([200, 200, 200]); - const result = spawnSync( - "sh", - [ - script, - "http://scope-api", - test.agentManifest, - "--available", - "false", - test.versionManifest, - ], - { encoding: "utf8", env: test.env }, - ); + const test = fixture([ + { statuses: [200] }, + { statuses: [200] }, + { statuses: [200] }, + ]); + const result = runFixture(test, [ + "--available", + "false", + test.versionManifest, + ]); expect(result.status).toBe(0); - expect(readFileSync(test.callsFile, "utf8")).toContain( + expect(readFileSync(test.payloadsFile, "utf8")).toContain( '{"_id":"test-worker","name":"Test Worker","available":false}', ); }); it("rejects invalid availability overrides before contacting the API", () => { - const test = fixture([200]); + const test = fixture([{ statuses: [200] }]); + const result = runFixture(test, ["--available", "sometimes"]); + + expect(result.status).toBe(2); + expect(result.stderr).toContain("--available must be true or false"); + expect(callCount(test.callsFile)).toBe(0); + }); + + it("rejects unsupported URL schemes before contacting the API", () => { + const test = fixture([{ statuses: [200] }]); const result = spawnSync( "sh", - [ - script, - "http://scope-api", - test.agentManifest, - "--available", - "sometimes", - ], + [script, "file:///tmp/api", test.agentManifest], { encoding: "utf8", env: test.env }, ); expect(result.status).toBe(2); - expect(result.stderr).toContain("--available must be true or false"); - expect(readFileSync(test.callsFile, "utf8")).toBe(""); + expect(result.stderr).toContain("API URL must use http or https"); + expect(callCount(test.callsFile)).toBe(0); }); }); From e5a990acaf597f0cb202c33299589782d2c0346a Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 3 Nov 2026 14:48:26 -0800 Subject: [PATCH 3/6] fix(docker): decouple pnpm bootstrap from the package registry Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .env.example | 6 + ENV_VARIABLES.md | 42 ++++ apps/api/Dockerfile | 22 +- apps/judge/Dockerfile | 22 +- apps/model-scanners/anthropic/Dockerfile | 22 +- apps/model-scanners/copilot/Dockerfile | 22 +- apps/portal/Dockerfile | 11 +- apps/scheduler/Dockerfile | 22 +- apps/token-manager/Dockerfile | 22 +- apps/workers/coder-acp-claude-code/Dockerfile | 17 +- apps/workers/coder-acp-copilot/Dockerfile | 17 +- apps/workers/post-processor/Dockerfile | 22 +- apps/workers/report-generator/Dockerfile | 22 +- docker-compose.yml | 6 +- packages/db-migrations/Dockerfile | 11 +- scripts/bootstrap-pnpm.sh | 81 ++++++ scripts/bootstrap-pnpm.test.ts | 232 ++++++++++++++++++ scripts/dev-compose.sh | 6 +- scripts/docker-compose.test.ts | 19 +- 19 files changed, 474 insertions(+), 150 deletions(-) create mode 100644 scripts/bootstrap-pnpm.sh create mode 100644 scripts/bootstrap-pnpm.test.ts diff --git a/.env.example b/.env.example index 916bc1e40..b457c162a 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,12 @@ # your shell before running docker compose (do NOT put it in .env, which # worktree-env regenerates per-worktree): # export NPM_CONFIG_REGISTRY=https://packagefeedproxy.microsoft.io/npm/ +# +# NPM_CONFIG_REGISTRY configures ordinary npm/pnpm package resolution only. It +# is not automatically reused for Corepack's per-version metadata requests. +# If your network provides a separate Corepack-compatible metadata endpoint, +# opt into it explicitly before the same one-command build: +# export COREPACK_NPM_REGISTRY=https://registry.npmjs.org/ # --------------------------------------------------------------------------- # CLI Configuration diff --git a/ENV_VARIABLES.md b/ENV_VARIABLES.md index bc66a4316..63f72ee4b 100644 --- a/ENV_VARIABLES.md +++ b/ENV_VARIABLES.md @@ -16,6 +16,48 @@ derives a localhost URL from `SCOPE_API_PORT` or uses `SCOPE_DEFAULT_API_URL`. ## Docker Development +### NPM_CONFIG_REGISTRY +**Default:** host npm registry for `pnpm docker:*`; otherwise `https://registry.npmjs.org/` +**Type:** HTTP(S) URL + +Registry used for ordinary npm and pnpm package installation in Docker image +builds. `scripts/dev-compose.sh` reads the host setting with +`npm config get registry`, so a private package proxy remains a one-command +workflow: + +```bash +NPM_CONFIG_REGISTRY=https://packages.example.test/npm/ pnpm docker:dev:copilot +``` + +Trailing slashes are normalized during the pinned pnpm bootstrap. Registry URLs +may point at authenticated/private package services, but credentials must be +provided through the environment's standard npm authentication mechanism rather +than embedded in the URL or build args. Credential-bearing URLs are rejected, +and bootstrap diagnostics report only the endpoint origin. + +This setting is deliberately **not** copied to `COREPACK_NPM_REGISTRY`: package +proxies that serve npm packuments are not necessarily compatible with +Corepack's per-version metadata protocol. + +### COREPACK_NPM_REGISTRY +**Default:** unset (Corepack's public default) +**Type:** optional HTTP(S) URL + +Corepack-compatible metadata endpoint used only to bootstrap the pinned pnpm +version in Docker images. Set it explicitly only when the endpoint supports +Corepack metadata: + +```bash +COREPACK_NPM_REGISTRY=https://corepack.example.test/ \ + NPM_CONFIG_REGISTRY=https://packages.example.test/npm/ \ + pnpm docker:dev:copilot +``` + +If Corepack fails, the pinned npm fallback uses `NPM_CONFIG_REGISTRY` and clears +the Corepack override, keeping the two paths in separate failure domains. Normal +`pnpm install` keeps pnpm's built-in package-fetch retries; Scope does not wrap +it in an outer retry loop. + ### API_DEBUG_PORT **Default:** `9200` **Type:** integer (Docker Compose development only) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 874efc590..50a1c0738 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,12 +1,9 @@ FROM node:22.22.0-slim AS builder ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ @@ -16,7 +13,7 @@ COPY packages/telemetry/package.json ./packages/telemetry/ COPY packages/db-migrations/package.json ./packages/db-migrations/ COPY apps/api/package.json ./apps/api/ -RUN pnpm install --frozen-lockfile +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile COPY packages/shared/tsconfig.json ./packages/shared/ COPY packages/shared/src ./packages/shared/src/ @@ -47,12 +44,9 @@ CMD ["/app/scripts/dev-entrypoint.sh"] FROM node:22.22.0-slim ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ @@ -61,7 +55,7 @@ COPY packages/telemetry/package.json ./packages/telemetry/ COPY packages/db-migrations/package.json ./packages/db-migrations/ COPY apps/api/package.json ./apps/api/ -RUN pnpm install --frozen-lockfile --prod +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile --prod COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ COPY --from=builder /app/packages/telemetry/dist ./packages/telemetry/dist/ diff --git a/apps/judge/Dockerfile b/apps/judge/Dockerfile index cb8782c92..c0190b043 100644 --- a/apps/judge/Dockerfile +++ b/apps/judge/Dockerfile @@ -7,12 +7,9 @@ FROM node:22.22.0-slim AS builder # "fetch failed" / "No CA certificates were loaded from the system". RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ @@ -21,7 +18,7 @@ COPY packages/shared/package.json ./packages/shared/ COPY packages/telemetry/package.json ./packages/telemetry/ COPY apps/judge/package.json ./apps/judge/ -RUN pnpm install --frozen-lockfile +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile COPY packages/shared/tsconfig.json ./packages/shared/ COPY packages/shared/src ./packages/shared/src/ @@ -47,12 +44,9 @@ FROM node:22.22.0-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ @@ -60,7 +54,7 @@ COPY packages/shared/package.json ./packages/shared/ COPY packages/telemetry/package.json ./packages/telemetry/ COPY apps/judge/package.json ./apps/judge/ -RUN pnpm install --frozen-lockfile --prod +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile --prod COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ COPY --from=builder /app/packages/telemetry/dist ./packages/telemetry/dist/ diff --git a/apps/model-scanners/anthropic/Dockerfile b/apps/model-scanners/anthropic/Dockerfile index 8293f780e..49ffb9078 100644 --- a/apps/model-scanners/anthropic/Dockerfile +++ b/apps/model-scanners/anthropic/Dockerfile @@ -1,12 +1,9 @@ FROM node:22.22.0-slim AS builder ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ @@ -16,7 +13,7 @@ COPY packages/telemetry/package.json ./packages/telemetry/ COPY packages/model-scanning/package.json ./packages/model-scanning/ COPY apps/model-scanners/anthropic/package.json ./apps/model-scanners/anthropic/ -RUN pnpm install --frozen-lockfile +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile COPY packages/shared/tsconfig.json ./packages/shared/ COPY packages/shared/src ./packages/shared/src/ @@ -34,12 +31,9 @@ FROM node:22.22.0-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ @@ -48,7 +42,7 @@ COPY packages/telemetry/package.json ./packages/telemetry/ COPY packages/model-scanning/package.json ./packages/model-scanning/ COPY apps/model-scanners/anthropic/package.json ./apps/model-scanners/anthropic/ -RUN pnpm install --frozen-lockfile --prod +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile --prod COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ COPY --from=builder /app/packages/telemetry/dist ./packages/telemetry/dist/ diff --git a/apps/model-scanners/copilot/Dockerfile b/apps/model-scanners/copilot/Dockerfile index 9741a758e..23666be44 100644 --- a/apps/model-scanners/copilot/Dockerfile +++ b/apps/model-scanners/copilot/Dockerfile @@ -1,12 +1,9 @@ FROM node:22.22.0-slim AS builder ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ @@ -16,7 +13,7 @@ COPY packages/telemetry/package.json ./packages/telemetry/ COPY packages/model-scanning/package.json ./packages/model-scanning/ COPY apps/model-scanners/copilot/package.json ./apps/model-scanners/copilot/ -RUN pnpm install --frozen-lockfile +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile COPY packages/shared/tsconfig.json ./packages/shared/ COPY packages/shared/src ./packages/shared/src/ @@ -34,12 +31,9 @@ FROM node:22.22.0-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ @@ -48,7 +42,7 @@ COPY packages/telemetry/package.json ./packages/telemetry/ COPY packages/model-scanning/package.json ./packages/model-scanning/ COPY apps/model-scanners/copilot/package.json ./apps/model-scanners/copilot/ -RUN pnpm install --frozen-lockfile --prod +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile --prod COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ COPY --from=builder /app/packages/telemetry/dist ./packages/telemetry/dist/ diff --git a/apps/portal/Dockerfile b/apps/portal/Dockerfile index 35e8d91f8..5b09f1909 100644 --- a/apps/portal/Dockerfile +++ b/apps/portal/Dockerfile @@ -1,16 +1,13 @@ # Shared Portal dependency layer FROM node:20-alpine AS deps ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./ COPY apps/portal/package.json apps/portal/ -RUN pnpm install --frozen-lockfile --filter portal --prod=false +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile --filter portal --prod=false # --- Dev stage: Vite dev server with HMR --- # Built from `deps` (not `builder`) so the dev image skips the production build. diff --git a/apps/scheduler/Dockerfile b/apps/scheduler/Dockerfile index 75db754fc..840c1bcf9 100644 --- a/apps/scheduler/Dockerfile +++ b/apps/scheduler/Dockerfile @@ -1,12 +1,9 @@ FROM node:22.22.0-slim AS builder ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ @@ -15,7 +12,7 @@ COPY packages/shared/package.json ./packages/shared/ COPY packages/telemetry/package.json ./packages/telemetry/ COPY apps/scheduler/package.json ./apps/scheduler/ -RUN pnpm install --frozen-lockfile +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile COPY packages/shared/tsconfig.json ./packages/shared/ COPY packages/shared/src ./packages/shared/src/ @@ -43,12 +40,9 @@ CMD ["/app/scripts/dev-entrypoint.sh"] FROM node:22.22.0-slim ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ @@ -56,7 +50,7 @@ COPY packages/shared/package.json ./packages/shared/ COPY packages/telemetry/package.json ./packages/telemetry/ COPY apps/scheduler/package.json ./apps/scheduler/ -RUN pnpm install --frozen-lockfile --prod +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile --prod COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ COPY --from=builder /app/packages/telemetry/dist ./packages/telemetry/dist/ diff --git a/apps/token-manager/Dockerfile b/apps/token-manager/Dockerfile index 664b026cc..23dee1a7e 100644 --- a/apps/token-manager/Dockerfile +++ b/apps/token-manager/Dockerfile @@ -1,12 +1,9 @@ FROM node:22.22.0-slim AS builder ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ @@ -15,7 +12,7 @@ COPY packages/shared/package.json ./packages/shared/ COPY packages/telemetry/package.json ./packages/telemetry/ COPY apps/token-manager/package.json ./apps/token-manager/ -RUN pnpm install --frozen-lockfile +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile COPY packages/shared/tsconfig.json ./packages/shared/ COPY packages/shared/src ./packages/shared/src/ @@ -40,12 +37,9 @@ FROM node:22.22.0-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ @@ -53,7 +47,7 @@ COPY packages/shared/package.json ./packages/shared/ COPY packages/telemetry/package.json ./packages/telemetry/ COPY apps/token-manager/package.json ./apps/token-manager/ -RUN pnpm install --frozen-lockfile --prod +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile --prod COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ COPY --from=builder /app/packages/telemetry/dist ./packages/telemetry/dist/ diff --git a/apps/workers/coder-acp-claude-code/Dockerfile b/apps/workers/coder-acp-claude-code/Dockerfile index 8639802bf..92e1a29cf 100644 --- a/apps/workers/coder-acp-claude-code/Dockerfile +++ b/apps/workers/coder-acp-claude-code/Dockerfile @@ -2,12 +2,9 @@ FROM node:22.22.0-slim AS base ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm # Install system tools for coding agents: Python, uv, git, PowerShell, Go, .NET, Rust, Java, Maven, Gradle RUN apt-get update && apt-get install -y \ python3 \ @@ -89,7 +86,7 @@ COPY packages/telemetry/package.json ./packages/telemetry/ COPY packages/test-utils/package.json ./packages/test-utils/ COPY apps/workers/coder-acp-claude-code/package.json ./apps/workers/coder-acp-claude-code/ -RUN pnpm install --frozen-lockfile +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile COPY packages/shared/tsconfig.json ./packages/shared/ COPY packages/shared/src ./packages/shared/src/ @@ -110,7 +107,7 @@ RUN node -e "const{parse}=require(require.resolve('yaml',{paths:['/app/packages/ FROM builder AS dev ARG CLAUDE_CODE_ACP_VERSION RUN test -n "$CLAUDE_CODE_ACP_VERSION" || (echo 'CLAUDE_CODE_ACP_VERSION build-arg is required' && exit 1) -RUN npm install -g @agentclientprotocol/claude-agent-acp@${CLAUDE_CODE_ACP_VERSION} +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" npm install -g @agentclientprotocol/claude-agent-acp@${CLAUDE_CODE_ACP_VERSION} # Use existing node user (UID 1000) so claude-code-acp allows bypassPermissions mode RUN mkdir -p /workspace && chown node:node /workspace @@ -131,7 +128,7 @@ FROM base # Install Claude Code ACP globally ARG CLAUDE_CODE_ACP_VERSION RUN test -n "$CLAUDE_CODE_ACP_VERSION" || (echo 'CLAUDE_CODE_ACP_VERSION build-arg is required' && exit 1) -RUN npm install -g @agentclientprotocol/claude-agent-acp@${CLAUDE_CODE_ACP_VERSION} +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" npm install -g @agentclientprotocol/claude-agent-acp@${CLAUDE_CODE_ACP_VERSION} WORKDIR /app @@ -140,7 +137,7 @@ COPY packages/shared/package.json ./packages/shared/ COPY packages/telemetry/package.json ./packages/telemetry/ COPY apps/workers/coder-acp-claude-code/package.json ./apps/workers/coder-acp-claude-code/ -RUN pnpm install --frozen-lockfile --prod +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile --prod COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ COPY --from=builder /app/packages/telemetry/dist ./packages/telemetry/dist/ diff --git a/apps/workers/coder-acp-copilot/Dockerfile b/apps/workers/coder-acp-copilot/Dockerfile index ec8f708d5..5682275e2 100644 --- a/apps/workers/coder-acp-copilot/Dockerfile +++ b/apps/workers/coder-acp-copilot/Dockerfile @@ -4,12 +4,9 @@ ARG COPILOT_CLI_VERSION FROM node:24-slim AS base ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm # Install system tools for coding agents: Python, uv, git, PowerShell, Go, .NET, Rust, Java, Maven, Gradle RUN apt-get update && apt-get install -y \ python3 \ @@ -84,7 +81,7 @@ COPY packages/telemetry/package.json ./packages/telemetry/ COPY packages/test-utils/package.json ./packages/test-utils/ COPY apps/workers/coder-acp-copilot/package.json ./apps/workers/coder-acp-copilot/ -RUN pnpm install --frozen-lockfile +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile COPY packages/shared/tsconfig.json ./packages/shared/ COPY packages/shared/src ./packages/shared/src/ @@ -104,7 +101,7 @@ RUN node -e "const{parse}=require(require.resolve('yaml',{paths:['/app/packages/ # --- Dev stage: hot reload with tsx watch --- FROM builder AS dev ARG COPILOT_CLI_VERSION -RUN npm install -g @github/copilot@${COPILOT_CLI_VERSION} +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" npm install -g @github/copilot@${COPILOT_CLI_VERSION} # Use existing node user (UID 1000) so coding agents allow bypassPermissions mode RUN mkdir -p /workspace && chown node:node /workspace @@ -125,7 +122,7 @@ FROM base # Install GitHub Copilot CLI globally ARG COPILOT_CLI_VERSION -RUN npm install -g @github/copilot@${COPILOT_CLI_VERSION} +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" npm install -g @github/copilot@${COPILOT_CLI_VERSION} WORKDIR /app @@ -134,7 +131,7 @@ COPY packages/shared/package.json ./packages/shared/ COPY packages/telemetry/package.json ./packages/telemetry/ COPY apps/workers/coder-acp-copilot/package.json ./apps/workers/coder-acp-copilot/ -RUN pnpm install --frozen-lockfile --prod +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile --prod COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ COPY --from=builder /app/packages/telemetry/dist ./packages/telemetry/dist/ diff --git a/apps/workers/post-processor/Dockerfile b/apps/workers/post-processor/Dockerfile index f339e9b81..0f789c8f9 100644 --- a/apps/workers/post-processor/Dockerfile +++ b/apps/workers/post-processor/Dockerfile @@ -1,12 +1,9 @@ FROM node:22.22.0-slim AS builder ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ @@ -15,7 +12,7 @@ COPY packages/shared/package.json ./packages/shared/ COPY packages/telemetry/package.json ./packages/telemetry/ COPY apps/workers/post-processor/package.json ./apps/workers/post-processor/ -RUN pnpm install --frozen-lockfile +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile COPY packages/shared/tsconfig.json ./packages/shared/ COPY packages/shared/src ./packages/shared/src/ @@ -40,12 +37,9 @@ CMD ["/app/scripts/dev-entrypoint.sh"] FROM node:22.22.0-slim ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ @@ -53,7 +47,7 @@ COPY packages/shared/package.json ./packages/shared/ COPY packages/telemetry/package.json ./packages/telemetry/ COPY apps/workers/post-processor/package.json ./apps/workers/post-processor/ -RUN pnpm install --frozen-lockfile --prod +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile --prod COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ COPY --from=builder /app/packages/telemetry/dist ./packages/telemetry/dist/ diff --git a/apps/workers/report-generator/Dockerfile b/apps/workers/report-generator/Dockerfile index 8ff340526..ff298d7d8 100644 --- a/apps/workers/report-generator/Dockerfile +++ b/apps/workers/report-generator/Dockerfile @@ -6,12 +6,9 @@ FROM node:22.22.0-slim AS builder # opaque "fetch failed" / "No CA certificates were loaded from the system". RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ @@ -20,7 +17,7 @@ COPY packages/shared/package.json ./packages/shared/ COPY packages/telemetry/package.json ./packages/telemetry/ COPY apps/workers/report-generator/package.json ./apps/workers/report-generator/ -RUN pnpm install --frozen-lockfile +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile COPY packages/shared/tsconfig.json ./packages/shared/ COPY packages/shared/src ./packages/shared/src/ @@ -46,12 +43,9 @@ FROM node:22.22.0-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ @@ -59,7 +53,7 @@ COPY packages/shared/package.json ./packages/shared/ COPY packages/telemetry/package.json ./packages/telemetry/ COPY apps/workers/report-generator/package.json ./apps/workers/report-generator/ -RUN pnpm install --frozen-lockfile --prod +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile --prod COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ COPY --from=builder /app/packages/telemetry/dist ./packages/telemetry/dist/ diff --git a/docker-compose.yml b/docker-compose.yml index bf65dc69c..0d72c6aad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,13 +50,15 @@ x-worker-deps: &worker-deps judge: condition: service_started -# npm/pnpm registry for the image builds. Defaults to the public registry, so +# npm/pnpm registry for package installation. Defaults to the public registry, so # external contributors and CI need no configuration. scripts/dev-compose.sh # auto-detects the host's registry (npm config get registry, which reads ~/.npmrc) # and forwards it here, so engineers behind a private proxy (e.g. Microsoft's npm -# proxy) build with no setup. Export NPM_CONFIG_REGISTRY to override the detected value. +# proxy) build with no setup. Corepack metadata resolution stays on its default +# registry unless COREPACK_NPM_REGISTRY explicitly selects a compatible endpoint. x-npm-build-args: &npm-build-args NPM_CONFIG_REGISTRY: ${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org/} + COREPACK_NPM_REGISTRY: ${COREPACK_NPM_REGISTRY:-} services: # =========================================================================== diff --git a/packages/db-migrations/Dockerfile b/packages/db-migrations/Dockerfile index 25bda03c9..dbbb3fcf5 100644 --- a/packages/db-migrations/Dockerfile +++ b/packages/db-migrations/Dockerfile @@ -1,12 +1,9 @@ FROM node:22.22.0-slim ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ -ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ - COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY} -# corepack bootstraps pnpm from the registry's per-version metadata endpoint, -# which some private registry proxies don't serve; fall back to a pinned global -# install (honors npm_config_registry) so restricted networks can still build. -RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) +ARG COREPACK_NPM_REGISTRY= +COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm WORKDIR /app # Copy workspace root files @@ -15,7 +12,7 @@ COPY tsconfig.base.json ./ COPY packages/db-migrations/package.json ./packages/db-migrations/ # Install dependencies (production + dev for tsx) -RUN pnpm install --frozen-lockfile --filter db-migrations +RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" pnpm install --frozen-lockfile --filter db-migrations # Copy migration source files COPY packages/db-migrations/tsconfig.json ./packages/db-migrations/ diff --git a/scripts/bootstrap-pnpm.sh b/scripts/bootstrap-pnpm.sh new file mode 100644 index 000000000..c523e2cc3 --- /dev/null +++ b/scripts/bootstrap-pnpm.sh @@ -0,0 +1,81 @@ +#!/bin/sh +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +set -eu + +PNPM_VERSION=10.29.1 +DEFAULT_NPM_REGISTRY=https://registry.npmjs.org + +# Normalize only syntax that is safe for both npm and Corepack while keeping the +# complete URL out of diagnostics because it may contain authentication data. +normalize_registry() { + registry=$1 + setting_name=$2 + + case "$registry" in + http://*|https://*) ;; + *) + printf 'Invalid %s: expected an HTTP(S) URL.\n' "$setting_name" >&2 + return 1 + ;; + esac + + while [ "${registry%/}" != "$registry" ]; do + registry=${registry%/} + done + + REGISTRY_URL=$registry node --input-type=module -e ' + const url = new URL(process.env.REGISTRY_URL); + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username || + url.password || + url.search || + url.hash + ) { + process.exit(1); + } + ' >/dev/null 2>&1 || { + printf 'Invalid %s: expected a credential-free HTTP(S) URL.\n' "$setting_name" >&2 + return 1 + } + + printf '%s\n' "$registry" +} + +# Report only the origin so private endpoint paths stay out of build output. +registry_origin() { + REGISTRY_URL=$1 node --input-type=module -e ' + const url = new URL(process.env.REGISTRY_URL); + process.stdout.write(url.origin); + ' +} + +npm_registry=$(normalize_registry "${NPM_CONFIG_REGISTRY:-$DEFAULT_NPM_REGISTRY}" NPM_CONFIG_REGISTRY) +npm_registry_origin=$(registry_origin "$npm_registry") +export NPM_CONFIG_REGISTRY=$npm_registry +export npm_config_registry=$npm_registry + +if [ -n "${COREPACK_NPM_REGISTRY:-}" ]; then + corepack_registry=$(normalize_registry "$COREPACK_NPM_REGISTRY" COREPACK_NPM_REGISTRY) + corepack_registry_origin=$(registry_origin "$corepack_registry") + export COREPACK_NPM_REGISTRY=$corepack_registry + printf 'Bootstrapping pnpm %s with explicit Corepack metadata registry origin %s.\n' \ + "$PNPM_VERSION" "$corepack_registry_origin" +else + unset COREPACK_NPM_REGISTRY + printf 'Bootstrapping pnpm %s with Corepack default registry; package registry origin is %s.\n' \ + "$PNPM_VERSION" "$npm_registry_origin" +fi + +corepack enable +if corepack prepare "pnpm@$PNPM_VERSION" --activate; then + exit 0 +fi + +# npm intentionally receives only the ordinary package registry. An explicit +# Corepack metadata override must not share the fallback's failure domain. +printf 'Corepack bootstrap failed; installing pinned pnpm %s from npm package registry origin %s.\n' \ + "$PNPM_VERSION" "$npm_registry_origin" >&2 +COREPACK_NPM_REGISTRY= npm install --global "pnpm@$PNPM_VERSION" --force diff --git a/scripts/bootstrap-pnpm.test.ts b/scripts/bootstrap-pnpm.test.ts new file mode 100644 index 000000000..7a82fff32 --- /dev/null +++ b/scripts/bootstrap-pnpm.test.ts @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawnSync } from "node:child_process"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const script = resolve("scripts/bootstrap-pnpm.sh"); +const temporaryDirectories: string[] = []; +const nodeDockerfiles = [ + "apps/api/Dockerfile", + "apps/judge/Dockerfile", + "apps/model-scanners/anthropic/Dockerfile", + "apps/model-scanners/copilot/Dockerfile", + "apps/portal/Dockerfile", + "apps/scheduler/Dockerfile", + "apps/token-manager/Dockerfile", + "apps/workers/coder-acp-claude-code/Dockerfile", + "apps/workers/coder-acp-copilot/Dockerfile", + "apps/workers/post-processor/Dockerfile", + "apps/workers/report-generator/Dockerfile", + "packages/db-migrations/Dockerfile", +]; + +interface BootstrapFixture { + callsFile: string; + env: NodeJS.ProcessEnv; +} + +/** + * Creates fake Corepack and npm executables that capture registry configuration. + */ +function createFixture(corepackPrepareExitCode = 0): BootstrapFixture { + const directory = mkdtempSync(join(tmpdir(), "scope-bootstrap-pnpm-")); + temporaryDirectories.push(directory); + const bin = join(directory, "bin"); + const callsFile = join(directory, "calls"); + mkdirSync(bin); + writeFileSync(callsFile, ""); + + const corepack = join(bin, "corepack"); + writeFileSync( + corepack, + `#!/bin/sh +set -eu +printf 'corepack|%s|%s|%s\\n' "$*" "\${NPM_CONFIG_REGISTRY:-}" "\${COREPACK_NPM_REGISTRY:-}" >> "$BOOTSTRAP_CALLS_FILE" +if [ "\${1:-}" = "prepare" ]; then + exit "$COREPACK_PREPARE_EXIT_CODE" +fi +`, + ); + chmodSync(corepack, 0o755); + + const npm = join(bin, "npm"); + writeFileSync( + npm, + `#!/bin/sh +set -eu +printf 'npm|%s|%s|%s\\n' "$*" "\${NPM_CONFIG_REGISTRY:-}" "\${COREPACK_NPM_REGISTRY:-}" >> "$BOOTSTRAP_CALLS_FILE" +`, + ); + chmodSync(npm, 0o755); + + return { + callsFile, + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? ""}`, + BOOTSTRAP_CALLS_FILE: callsFile, + COREPACK_PREPARE_EXIT_CODE: String(corepackPrepareExitCode), + }, + }; +} + +/** + * Runs the bootstrap script with deterministic fake package-manager commands. + */ +function runBootstrap( + fixture: BootstrapFixture, + registryEnvironment: NodeJS.ProcessEnv = {}, +) { + return spawnSync("sh", [script], { + encoding: "utf8", + env: { + ...fixture.env, + NPM_CONFIG_REGISTRY: "", + COREPACK_NPM_REGISTRY: "", + ...registryEnvironment, + }, + }); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("pnpm Docker bootstrap", () => { + it("uses public npm defaults without forcing a Corepack registry", () => { + const fixture = createFixture(); + + const result = runBootstrap(fixture); + + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(fixture.callsFile, "utf8")).toContain( + "corepack|prepare pnpm@10.29.1 --activate|https://registry.npmjs.org|", + ); + }); + + it("keeps an ordinary package proxy out of Corepack metadata resolution", () => { + const fixture = createFixture(); + + const result = runBootstrap(fixture, { + NPM_CONFIG_REGISTRY: "https://packages.example.test/npm", + }); + + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(fixture.callsFile, "utf8")).toContain( + "corepack|prepare pnpm@10.29.1 --activate|https://packages.example.test/npm|", + ); + }); + + it("normalizes trailing slashes for both registry roles", () => { + const fixture = createFixture(); + + const result = runBootstrap(fixture, { + NPM_CONFIG_REGISTRY: "https://packages.example.test/npm///", + COREPACK_NPM_REGISTRY: "https://corepack.example.test/metadata//", + }); + + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(fixture.callsFile, "utf8")).toContain( + "corepack|prepare pnpm@10.29.1 --activate|https://packages.example.test/npm|https://corepack.example.test/metadata", + ); + }); + + it("uses an explicit Corepack-compatible metadata registry", () => { + const fixture = createFixture(); + + const result = runBootstrap(fixture, { + COREPACK_NPM_REGISTRY: "https://corepack.example.test", + }); + + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(fixture.callsFile, "utf8")).toContain( + "corepack|prepare pnpm@10.29.1 --activate|https://registry.npmjs.org|https://corepack.example.test", + ); + }); + + it("keeps the npm fallback independent from the Corepack override", () => { + const fixture = createFixture(1); + + const result = runBootstrap(fixture, { + NPM_CONFIG_REGISTRY: "https://packages.example.test/npm/", + COREPACK_NPM_REGISTRY: "https://broken-corepack.example.test/", + }); + + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(fixture.callsFile, "utf8")).toContain( + "npm|install --global pnpm@10.29.1 --force|https://packages.example.test/npm|", + ); + }); + + it("rejects secret-bearing URLs without printing their contents", () => { + const npmFixture = createFixture(); + const secretNpmRegistry = + "https://build-user:super-secret@packages.example.test/private/token-123/"; + + const npmResult = runBootstrap(npmFixture, { + NPM_CONFIG_REGISTRY: secretNpmRegistry, + }); + + expect(npmResult.status).toBe(1); + const npmDiagnostics = `${npmResult.stdout}\n${npmResult.stderr}`; + expect(npmDiagnostics).toContain("credential-free HTTP(S) URL"); + expect(npmDiagnostics).not.toContain("build-user"); + expect(npmDiagnostics).not.toContain("super-secret"); + expect(npmDiagnostics).not.toContain("token-123"); + + const corepackFixture = createFixture(); + const corepackResult = runBootstrap(corepackFixture, { + COREPACK_NPM_REGISTRY: + "https://corepack-user:corepack-secret@corepack.example.test/private/", + }); + + expect(corepackResult.status).toBe(1); + const diagnostics = `${corepackResult.stdout}\n${corepackResult.stderr}`; + expect(diagnostics).toContain("credential-free HTTP(S) URL"); + expect(diagnostics).not.toContain("corepack-user"); + expect(diagnostics).not.toContain("corepack-secret"); + }); + + it("uses the shared bootstrap contract in every Node image", () => { + for (const dockerfile of nodeDockerfiles) { + const contents = readFileSync(dockerfile, "utf8"); + const packageInstallLines = contents + .split("\n") + .filter((line) => /RUN .*p?npm install/.test(line)); + + expect(contents, dockerfile).toContain( + "COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm", + ); + expect(contents, dockerfile).toContain("ARG COREPACK_NPM_REGISTRY="); + expect(contents, dockerfile).not.toContain( + "COREPACK_NPM_REGISTRY=${NPM_CONFIG_REGISTRY}", + ); + expect(contents, dockerfile).not.toMatch( + /^ENV .*COREPACK_NPM_REGISTRY/m, + ); + expect(contents, dockerfile).not.toMatch(/^ENV .*npm_config_registry/m); + expect(contents, dockerfile).not.toMatch( + /fetch[-_]retr(?:y|ies)|network-concurrency/i, + ); + expect(packageInstallLines.length, dockerfile).toBeGreaterThan(0); + for (const line of packageInstallLines) { + expect(line, dockerfile).toContain( + 'NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}"', + ); + } + } + }); +}); diff --git a/scripts/dev-compose.sh b/scripts/dev-compose.sh index cbaeaf8e6..abf3e53e4 100755 --- a/scripts/dev-compose.sh +++ b/scripts/dev-compose.sh @@ -19,7 +19,7 @@ for p in /usr/local/bin /opt/homebrew/bin "$HOME/.docker/bin"; do done # --------------------------------------------------------------------------- -# Forward the host's npm registry into the image builds. +# Forward package and package-manager bootstrap registries into image builds. # --------------------------------------------------------------------------- # docker-compose.yml passes ${NPM_CONFIG_REGISTRY} to every image build as a # build arg (default: the public registry). Detect the registry configured on @@ -27,6 +27,10 @@ done # cannot reach registry.npmjs.org directly — e.g. behind Microsoft's npm proxy — # build out of the box. External contributors detect the public registry and are # unaffected. An explicitly exported NPM_CONFIG_REGISTRY always wins. +# +# COREPACK_NPM_REGISTRY is intentionally not inferred from the package registry: +# only forward it when the user explicitly selected a Corepack-compatible +# metadata endpoint. if [ -z "${NPM_CONFIG_REGISTRY:-}" ]; then detected_registry="$(npm config get registry 2>/dev/null || pnpm config get registry 2>/dev/null || true)" case "$detected_registry" in diff --git a/scripts/docker-compose.test.ts b/scripts/docker-compose.test.ts index 08c107434..c98e83c4c 100644 --- a/scripts/docker-compose.test.ts +++ b/scripts/docker-compose.test.ts @@ -5,6 +5,9 @@ import { spawnSync } from "node:child_process"; import { describe, expect, it } from "vitest"; interface ComposeService { + build?: { + args?: Record; + }; cpus?: number; depends_on?: Record; healthcheck?: { @@ -40,16 +43,30 @@ describe("development Docker Compose configuration", () => { ], { encoding: "utf8", - env: { ...process.env, GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? "" }, + env: { + ...process.env, + GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? "", + NPM_CONFIG_REGISTRY: "https://packages.example.test/npm/", + COREPACK_NPM_REGISTRY: "https://corepack.example.test/", + }, }, ); expect(result.status, result.stderr).toBe(0); const config = JSON.parse(result.stdout) as ComposeConfig; + const api = config.services.api; const gateway = config.services.gateway; const copilotWorker = config.services["coder-acp-copilot"]; + expect(api.build?.args).toMatchObject({ + NPM_CONFIG_REGISTRY: "https://packages.example.test/npm/", + COREPACK_NPM_REGISTRY: "https://corepack.example.test/", + }); + expect(copilotWorker.build?.args).toMatchObject({ + NPM_CONFIG_REGISTRY: "https://packages.example.test/npm/", + COREPACK_NPM_REGISTRY: "https://corepack.example.test/", + }); expect(gateway.cpus).toBeUndefined(); expect(gateway.mem_limit).toBe(String(2 * 1024 ** 3)); expect(gateway.healthcheck?.start_period).toBe("10m0s"); From a73e0ec5715a74aa4f53c4cab6c3227dbf60ac57 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 3 Nov 2026 17:06:58 -0800 Subject: [PATCH 4/6] fix(website): add missing license headers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/src/styles/contributions.css | 4 +++- website/src/styles/contributors.css | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/website/src/styles/contributions.css b/website/src/styles/contributions.css index 6aa468603..98f3bd382 100644 --- a/website/src/styles/contributions.css +++ b/website/src/styles/contributions.css @@ -1,7 +1,9 @@ /* * Copyright (c) Microsoft Corporation. * Licensed under the MIT License. - * + */ + +/* * Shared presentation for website contribution data: status messages, * count pills, cards, and development-only sample labels. */ diff --git a/website/src/styles/contributors.css b/website/src/styles/contributors.css index 5e75ab0ed..03ef9015a 100644 --- a/website/src/styles/contributors.css +++ b/website/src/styles/contributors.css @@ -1,7 +1,9 @@ /* * Copyright (c) Microsoft Corporation. * Licensed under the MIT License. - * + */ + +/* * Contributors (). An avatar wall on the * landing page and a card grid on the Community > Contribute page. Shares * the card, message and sample-tag styles in contributions.css. From 5a621ee1f32785e657179ccccb178991f7146ef0 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 4 Nov 2026 13:11:32 -0800 Subject: [PATCH 5/6] test(compose): skip render check without Docker Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- scripts/docker-compose.test.ts | 92 +++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 41 deletions(-) diff --git a/scripts/docker-compose.test.ts b/scripts/docker-compose.test.ts index 08c107434..096b29c5f 100644 --- a/scripts/docker-compose.test.ts +++ b/scripts/docker-compose.test.ts @@ -22,49 +22,59 @@ interface ComposeConfig { services: Record; } +// Keep the default unit suite runnable without Docker while preserving strict +// merged-configuration validation whenever Compose is available. +const dockerComposeAvailable = + spawnSync("docker", ["compose", "version"], { + encoding: "utf8", + }).status === 0; + describe("development Docker Compose configuration", () => { - it("keeps the gateway cold-build resources and Copilot worker dependency", () => { - const result = spawnSync( - "docker", - [ - "compose", - "-f", - "docker-compose.yml", - "-f", - "docker-compose.dev.yml", - "--profile", - "copilot", - "config", - "--format", - "json", - ], - { - encoding: "utf8", - env: { ...process.env, GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? "" }, - }, - ); + it.skipIf(!dockerComposeAvailable)( + "keeps the gateway cold-build resources and Copilot worker dependency", + () => { + const result = spawnSync( + "docker", + [ + "compose", + "-f", + "docker-compose.yml", + "-f", + "docker-compose.dev.yml", + "--profile", + "copilot", + "config", + "--format", + "json", + ], + { + encoding: "utf8", + env: { ...process.env, GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? "" }, + }, + ); - expect(result.status, result.stderr).toBe(0); + expect(result.status, result.stderr).toBe(0); - const config = JSON.parse(result.stdout) as ComposeConfig; - const gateway = config.services.gateway; - const copilotWorker = config.services["coder-acp-copilot"]; + const config = JSON.parse(result.stdout) as ComposeConfig; + const gateway = config.services.gateway; + const copilotWorker = config.services["coder-acp-copilot"]; - expect(gateway.cpus).toBeUndefined(); - expect(gateway.mem_limit).toBe(String(2 * 1024 ** 3)); - expect(gateway.healthcheck?.start_period).toBe("10m0s"); - expect(gateway.healthcheck?.test).toEqual([ - "CMD-SHELL", - "wget -qO- http://127.0.0.1:18000/health || exit 1", - ]); - expect(gateway.volumes).toContainEqual( - expect.objectContaining({ - source: "gateway_target", - target: "/build/target", - }), - ); - expect(copilotWorker.depends_on?.gateway?.condition).toBe( - "service_healthy", - ); - }); + expect(gateway.cpus).toBeUndefined(); + expect(gateway.mem_limit).toBe(String(2 * 1024 ** 3)); + expect(gateway.healthcheck?.start_period).toBe("10m0s"); + expect(gateway.healthcheck?.test).toEqual([ + "CMD-SHELL", + "wget -qO- http://127.0.0.1:18000/health || exit 1", + ]); + expect(gateway.volumes).toContainEqual( + expect.objectContaining({ + source: "gateway_target", + target: "/build/target", + }), + ); + expect(copilotWorker.depends_on?.gateway?.condition).toBe( + "service_healthy", + ); + }, + ); }); From 768613d3f7287d2b9ea40963d36062690613c02a Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 4 Nov 2026 13:20:37 -0800 Subject: [PATCH 6/6] fix(docker): preserve worker runtime package registry Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/workers/coder-acp-claude-code/Dockerfile | 1 + apps/workers/coder-acp-copilot/Dockerfile | 1 + scripts/bootstrap-pnpm.test.ts | 12 +++++++++++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/workers/coder-acp-claude-code/Dockerfile b/apps/workers/coder-acp-claude-code/Dockerfile index 92e1a29cf..12d66efb9 100644 --- a/apps/workers/coder-acp-claude-code/Dockerfile +++ b/apps/workers/coder-acp-claude-code/Dockerfile @@ -3,6 +3,7 @@ FROM node:22.22.0-slim AS base ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ ARG COREPACK_NPM_REGISTRY= +ENV npm_config_registry=${NPM_CONFIG_REGISTRY} COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm # Install system tools for coding agents: Python, uv, git, PowerShell, Go, .NET, Rust, Java, Maven, Gradle diff --git a/apps/workers/coder-acp-copilot/Dockerfile b/apps/workers/coder-acp-copilot/Dockerfile index 5682275e2..251b8a23c 100644 --- a/apps/workers/coder-acp-copilot/Dockerfile +++ b/apps/workers/coder-acp-copilot/Dockerfile @@ -5,6 +5,7 @@ FROM node:24-slim AS base ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ ARG COREPACK_NPM_REGISTRY= +ENV npm_config_registry=${NPM_CONFIG_REGISTRY} COPY scripts/bootstrap-pnpm.sh /usr/local/bin/bootstrap-pnpm RUN NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY}" COREPACK_NPM_REGISTRY="${COREPACK_NPM_REGISTRY}" sh /usr/local/bin/bootstrap-pnpm # Install system tools for coding agents: Python, uv, git, PowerShell, Go, .NET, Rust, Java, Maven, Gradle diff --git a/scripts/bootstrap-pnpm.test.ts b/scripts/bootstrap-pnpm.test.ts index 7a82fff32..f0307618f 100644 --- a/scripts/bootstrap-pnpm.test.ts +++ b/scripts/bootstrap-pnpm.test.ts @@ -30,6 +30,10 @@ const nodeDockerfiles = [ "apps/workers/report-generator/Dockerfile", "packages/db-migrations/Dockerfile", ]; +const runtimeRegistryDockerfiles = new Set([ + "apps/workers/coder-acp-claude-code/Dockerfile", + "apps/workers/coder-acp-copilot/Dockerfile", +]); interface BootstrapFixture { callsFile: string; @@ -217,7 +221,13 @@ describe("pnpm Docker bootstrap", () => { expect(contents, dockerfile).not.toMatch( /^ENV .*COREPACK_NPM_REGISTRY/m, ); - expect(contents, dockerfile).not.toMatch(/^ENV .*npm_config_registry/m); + if (runtimeRegistryDockerfiles.has(dockerfile)) { + expect(contents, dockerfile).toContain( + "ENV npm_config_registry=${NPM_CONFIG_REGISTRY}", + ); + } else { + expect(contents, dockerfile).not.toMatch(/^ENV .*npm_config_registry/m); + } expect(contents, dockerfile).not.toMatch( /fetch[-_]retr(?:y|ies)|network-concurrency/i, );