← all scripts

.github/scripts/autopilot_claude_code_boot_probe.sh

134 of 134 statements covered (100.00%).

coveredmissednever traced by Bash (not counted)A line ending in … continues the statement above it and shares its fate.

114#!/usr/bin/env bash
2# =============================================================================
3# autopilot_claude_code_boot_probe.sh — boot the real `gco autopilot` Claude Code session
4# =============================================================================
5#
6# Drives `gco autopilot` end-to-end the way a first-time user does, and
7# verifies the session boots to the last point reachable without real AWS
8# credentials. Used by integration:autopilot:claude-code-boot
9# (integration-tests.yml). The Codex twin lives in
10# autopilot_codex_boot_probe.sh; the phases are parallel on purpose so the
11# two probes stay comparable engine to engine.
12#
13# What runs for real (nothing about autopilot is mocked):
14#
15# 1. `gco autopilot --print-config` resolves the session plan from this
16# checkout (in-tree gco MCP server + the curated companion registry).
17# 2. Every server entry in the generated config is pre-warmed by running
18# its exact launch recipe (uvx/npx resolve, install, boot, exit on
19# stdin EOF). Warm caches keep the integrated boot inside Claude
20# Code's fixed 30s per-server MCP connection timeout on cold runners.
21# 3. `gco autopilot -y -- --version` exercises autopilot's own install
22# path: detect the missing binary, npm-install the pinned release,
23# re-detect it, write the session MCP config, and exec claude with
24# `--mcp-config <generated> --strict-mcp-config`. The passthrough
25# `--version` makes that exec exit 0 deterministically.
26# 4. `gco autopilot -- --debug -p "..."` boots the full interactive
27# stack in print mode: claude connects every MCP server in the
28# generated config and dispatches to Amazon Bedrock with the shipped
29# default model. With the fail-closed fake credentials exported
30# below, AWS answers 403 — proving a signed request left the wire.
31# The probe asserts, from Claude Code's own debug log:
32#
33# - MCP server "<name>": Successfully connected (for EVERY server)
34# - dispatching to bedrock model=<configured default>
35# - API error (attempt N/M): 403
36#
37# and then terminates the session. The 403 is the success condition:
38# it is the exact credential boundary, the only part of the launch a
39# credential-less CI runner cannot cross.
40#
41# Marker stability: the three debug-log markers above were captured from
42# the pinned Claude Code release (cli/autopilot.py CLAUDE_CODE_VERSION).
43# A pin bump can rephrase them; the failure output names the missing
44# marker so the bump PR can refresh this probe alongside the pin.
45#
46# Requirements: gco (this checkout, installed), node+npm (pinned via
47# .github/scripts/use-pinned-npm.sh), uv/uvx, python3, GNU coreutils
48# `timeout`. The `claude` binary must NOT be preinstalled — the probe
49# exists to prove autopilot's own install path works.
50#
51# =============================================================================
52
5314set -euo pipefail
54
5556REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
5614cd "$REPO_ROOT"
57
5814WORK_DIR="${RUNNER_TEMP:-$(mktemp -d)}/autopilot-claude-code-boot-probe"
5914mkdir -p "$WORK_DIR"
60
61# Autopilot writes the session MCP config here instead of ~/.gco/autopilot.
6228export GCO_AUTOPILOT_CONFIG_DIR="${WORK_DIR}/config"
63
64# Where claude keeps its per-session debug logs (always written; --debug
65# additionally mirrors them to stderr).
6614CLAUDE_DEBUG_DIR="${HOME}/.claude/debug"
67
6814SESSION_LOG="${WORK_DIR}/session.log"
6914PREWARM_DIR="${WORK_DIR}/prewarm"
7014mkdir -p "$PREWARM_DIR"
71
72# How long the integrated session may take to show every boot marker.
7314BOOT_TIMEOUT_SECONDS="${BOOT_TIMEOUT_SECONDS:-300}"
74
75# The one EXIT trap: stop a still-running session and gather evidence
76# (claude's debug logs live under a hidden directory the artifact upload
77# would otherwise skip) into WORK_DIR for the always-uploaded artifact.
7814SESSION_PID=""
79collect_and_cleanup() {
8014 if [ -n "$SESSION_PID" ]; then
819 kill "$SESSION_PID" 2>/dev/null || true
826 wait "$SESSION_PID" 2>/dev/null || true
83 fi
8414 if compgen -G "${CLAUDE_DEBUG_DIR}/*.txt" >/dev/null 2>&1; then
854 mkdir -p "${WORK_DIR}/claude-debug"
864 cp "${CLAUDE_DEBUG_DIR}"/*.txt "${WORK_DIR}/claude-debug/" 2>/dev/null || true
87 fi
88}
8914trap collect_and_cleanup EXIT
90
91fail() {
929 echo "✗ $1" >&2
939 exit 1
94}
95
96pass() {
9781 echo "✓ $1"
98}
99
100# ── Fail-closed credential environment ──────────────────────────────────────
101# The probe must never reach Bedrock with usable credentials, even if the
102# surrounding job one day exports some. A syntactically valid but fabricated
103# static key pair wins the SDK provider chain ahead of every file/role
104# source, and the file/IMDS sources are disabled outright. The key id is
105# assembled at runtime so repository secret scanners (gitleaks, trufflehog)
106# never see a contiguous AKIA-shaped literal in the tree.
10728AWS_ACCESS_KEY_ID="$(printf 'AKIA%s' '00000000000fake0')"
10828AWS_SECRET_ACCESS_KEY="$(printf '%040d' 0)"
10914export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
11028export AWS_SHARED_CREDENTIALS_FILE=/dev/null
11128export AWS_CONFIG_FILE=/dev/null
11228export AWS_EC2_METADATA_DISABLED=true
11314unset AWS_SESSION_TOKEN AWS_PROFILE AWS_ROLE_ARN AWS_WEB_IDENTITY_TOKEN_FILE 2>/dev/null || true
114
115# ── Preflight ────────────────────────────────────────────────────────────────
116
11768for tool in gco npm uvx python3 timeout; do
11869 command -v "$tool" >/dev/null || fail "required tool missing: $tool"
119done
120
12113if command -v claude >/dev/null; then
1222 fail "claude is already installed at $(command -v claude) — this probe must exercise autopilot's own install path"
123fi
124
125# Facts come from the shared autopilot CI contract — the same single
126# source unit:cli:autopilot and the dev-container step assert against.
12712CONTRACT=".github/scripts/autopilot_ci_contract.py"
12824CLAUDE_PIN="$(python3 "$CONTRACT" pin)"
12924EXPECTED_MODEL="$(python3 "$CONTRACT" default-model)"
13012pass "preflight OK (pin ${CLAUDE_PIN}, default model ${EXPECTED_MODEL})"
131
132# ── Phase 1: resolve the session plan from this checkout ────────────────────
133
13412GENERATED_CONFIG="${WORK_DIR}/print-config.json"
13512gco autopilot --print-config > "$GENERATED_CONFIG"
136
137# Full structural validation from the shared contract (exact expected
138# server set, entry shapes, pruned-package bans), then load the expected
139# names for the per-server pre-warm and handshake assertions below.
14011python3 "$CONTRACT" verify-config "$GENERATED_CONFIG" \
1411 || fail "generated config failed the shared autopilot CI contract"
14220mapfile -t SERVER_NAMES < <(python3 "$CONTRACT" expected-servers)
14310[ "${#SERVER_NAMES[@]}" -ge 2 ] || fail "contract lists ${#SERVER_NAMES[@]} servers; expected the gco server plus companions"
14410pass "session plan resolves: ${#SERVER_NAMES[@]} MCP servers (${SERVER_NAMES[*]})"
145
146# ── Phase 2: pre-warm every server's exact launch recipe ────────────────────
147# Each companion is launched exactly as the generated config specifies and
148# handed EOF on stdin, which a stdio MCP server treats as client
149# disconnect. This resolves and installs every uvx/npx package (an
150# independent per-package install check with a pinpointed log on failure)
151# and warms the caches so the integrated boot below is not racing package
152# managers against claude's 30s per-server connection timeout.
153
15420mapfile -t PREWARM_CMDS < <(python3 - "$GENERATED_CONFIG" <<'PY'
155import json, shlex, sys
156with open(sys.argv[1]) as handle:
157 config = json.load(handle)
158for name, entry in sorted(config["mcpServers"].items()):
159 env_prefix = " ".join(
160 f"{key}={shlex.quote(str(value))}" for key, value in entry.get("env", {}).items()
161 )
162 command = " ".join(shlex.quote(str(part)) for part in [entry["command"], *entry["args"]])
163 print(f"{name}\t{env_prefix} {command}".replace("\t ", "\t", 1))
164PY
165)
16620
16710PREWARM_FAILURES=0
16840for line in "${PREWARM_CMDS[@]}"; do
16940 name="${line%%$'\t'*}"
17040 launch="${line#*$'\t'}"
17140 rc=0
17244 timeout 240 bash -c "$launch" </dev/null >"${PREWARM_DIR}/${name}.log" 2>&1 || rc=$?
17340 case "$rc" in
17420 0)
17536 pass "pre-warm ${name}: launched and exited on stdin EOF" ;;
17620 124)
17720 # Ran the full 240s before timeout killed it: the package
17820 # resolved, installed, and booted (cache warmed) — it just
17920 # doesn't exit on EOF. The handshake assertion happens later
18020 # under claude, where connection management is claude's job.
1811 pass "pre-warm ${name}: launched and ran until the warm-up timeout" ;;
18220 125 | 126 | 127)
18320 # timeout itself failed / command not executable / not found:
18420 # the launch recipe is broken.
1852 echo "── ${PREWARM_DIR}/${name}.log ──"
1862 cat "${PREWARM_DIR}/${name}.log" || true
1872 echo "✗ pre-warm ${name}: launch recipe failed (exit ${rc}): ${launch}" >&2
1882 PREWARM_FAILURES=$((PREWARM_FAILURES + 1)) ;;
18920 *)
19020 # Any other non-zero exit on EOF is server-specific and fine;
19120 # the process launched, which is all warming needs.
1921 pass "pre-warm ${name}: launched and exited on stdin EOF (rc ${rc})" ;;
19320 esac
19420done
19511[ "$PREWARM_FAILURES" -eq 0 ] || fail "${PREWARM_FAILURES} companion launch recipe(s) failed to start at all"
19620
19720# ── Phase 3: autopilot's own install path, exec verified by --version ───────
19820# claude is absent, so `-y` makes autopilot npm-install the exact pin,
19920# re-detect the binary, write the session config, and exec it with the
20020# generated `--mcp-config`/`--strict-mcp-config` argv. `--version` in the
20120# passthrough position makes that real exec terminate deterministically.
20220
20327VERSION_OUTPUT="$(gco autopilot -y -- --version 2>&1 | tee "${WORK_DIR}/version-probe.log")"
20418echo "$VERSION_OUTPUT" | grep -qF "$CLAUDE_PIN" \
2051 || fail "autopilot exec'd claude, but its --version output does not carry the pin ${CLAUDE_PIN}: ${VERSION_OUTPUT}"
2069command -v claude >/dev/null || fail "autopilot reported an install but claude is not on PATH"
2077pass "autopilot installed the pin and exec'd claude ${CLAUDE_PIN} with the generated config argv"
20820
2097WRITTEN_CONFIG="${GCO_AUTOPILOT_CONFIG_DIR}/mcp.json"
2108[ -f "$WRITTEN_CONFIG" ] || fail "autopilot did not write the session config to ${WRITTEN_CONFIG}"
21120python3 - "$GENERATED_CONFIG" "$WRITTEN_CONFIG" <<'PY'
212import json, sys
213with open(sys.argv[1]) as handle:
214 planned = set(json.load(handle)["mcpServers"])
215with open(sys.argv[2]) as handle:
216 written = set(json.load(handle)["mcpServers"])
217assert planned == written, f"planned {sorted(planned)} != written {sorted(written)}"
218PY
2195pass "written session config matches the printed plan (${WRITTEN_CONFIG})"
220
221# ── Phase 4: full session boot, stopped at the credential boundary ──────────
222
2235echo "booting the full session (budget ${BOOT_TIMEOUT_SECONDS}s): gco autopilot -- --debug -p ..."
224# `gco autopilot` execvpe()s claude, so SESSION_PID *is* the claude process
225# (reaped by the EXIT trap above).
2265gco autopilot -- --debug -p "Reply with the single word OK." \
227 </dev/null >"$SESSION_LOG" 2>&1 &
2285SESSION_PID=$!
229
230# Collect the required markers from claude's own debug logs. HOME is
231# job-fresh, so every log under CLAUDE_DEBUG_DIR belongs to this probe.
232missing_markers() {
233155 local logs="$1"
234155 local missing=""
235155 local name
236620 for name in "${SERVER_NAMES[@]}"; do
237620 grep -qF "MCP server \"${name}\": Successfully connected" <<<"$logs" \
238604 || missing+="mcp-connect:${name} "
239604 done
240155 grep -qF "dispatching to bedrock model=${EXPECTED_MODEL}" <<<"$logs" \
241151 || missing+="bedrock-dispatch:${EXPECTED_MODEL} "
242155 grep -qE 'API error \(attempt [0-9]+/[0-9]+\): 403' <<<"$logs" \
243152 || missing+="credential-boundary-403 "
244155 echo "$missing"
245}
246
2475DEADLINE=$((SECONDS + BOOT_TIMEOUT_SECONDS))
2485MISSING="initial"
249152while [ "$SECONDS" -lt "$DEADLINE" ]; do
250453 LOGS="$(cat "${CLAUDE_DEBUG_DIR}"/*.txt 2>/dev/null || true)"
251302 MISSING="$(missing_markers "$LOGS")"
252151 [ -z "$MISSING" ] && break
253151 if ! kill -0 "$SESSION_PID" 2>/dev/null; then
254 # claude exited before every marker appeared (e.g. it gave up its
255 # API retries) — take one final look at the logs it left behind.
2568 LOGS="$(cat "${CLAUDE_DEBUG_DIR}"/*.txt 2>/dev/null || true)"
2578 MISSING="$(missing_markers "$LOGS")"
2584 break
259 fi
260147 sleep 5
261done
262
2635if [ -n "$MISSING" ]; then
2642 echo "── session stdout/stderr (${SESSION_LOG}) ──"
2652 tail -50 "$SESSION_LOG" || true
2662 echo "── claude debug logs (${CLAUDE_DEBUG_DIR}) ──"
2673 tail -100 "${CLAUDE_DEBUG_DIR}"/*.txt 2>/dev/null || echo "(no debug logs found)"
2682 fail "session did not reach these boot markers within ${BOOT_TIMEOUT_SECONDS}s: ${MISSING}"
269fi
270
2713pass "all ${#SERVER_NAMES[@]} MCP servers completed the handshake under claude"
2723pass "claude dispatched to Bedrock with the shipped default model (${EXPECTED_MODEL})"
2733pass "AWS rejected the fabricated credentials with 403 — the exact credential boundary"
274
275# One-line-per-server evidence for the job summary.
2763echo ""
2773echo "MCP connection report:"
2783grep -hoE 'MCP server "[^"]+": Successfully connected \(transport: [a-z]+\) in [0-9]+ms' \
2796 "${CLAUDE_DEBUG_DIR}"/*.txt 2>/dev/null | sort -u | sed 's/^/ /' || true
280
2813echo ""
2823echo "autopilot boot probe: PASS"