Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -400,7 +407,6 @@ services:
- -c
- |
set -eu
apk add --no-cache curl yq > /dev/null 2>&1

register_agent() {
name="$$1"
Expand Down
35 changes: 28 additions & 7 deletions docs/architecture/worker-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
160 changes: 103 additions & 57 deletions scripts/register-agent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {
Expand All @@ -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
Expand Down Expand Up @@ -63,28 +75,38 @@ 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
cat "$RESPONSE_FILE"
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
;;
*)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading