From c9128f65cb018472e6926add9299e2bdfcbe32f6 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 3 Nov 2026 14:39:57 -0800 Subject: [PATCH] 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); }); });