← all scripts

.github/scripts/autopilot_opencode_boot_probe.sh

150 of 150 statements covered (100.00%).

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

118#!/usr/bin/env bash
2# =============================================================================
3# autopilot_opencode_boot_probe.sh — boot the real `gco autopilot` OpenCode session
4# =============================================================================
5#
6# Drives `gco autopilot --engine opencode` end-to-end the way a first-time
7# user does, and verifies the session boots to the last point reachable
8# without real AWS credentials. Used by integration:autopilot:opencode-boot
9# (integration-tests.yml). The Claude Code and Codex twins live in
10# autopilot_claude_code_boot_probe.sh and autopilot_codex_boot_probe.sh; the
11# phases are parallel on purpose so the probes stay comparable engine to
12# engine.
13#
14# What runs for real (nothing about autopilot is mocked):
15#
16# 1. `gco autopilot --engine opencode --print-config` resolves the session
17# plan from this checkout (the in-tree gco MCP server plus the curated
18# companion registry, rendered as OpenCode's opencode.json).
19# 2. Every mcp.* entry in the generated JSON is pre-warmed by running its
20# exact launch recipe (uvx/npx resolve, install, boot, exit on stdin
21# EOF). Warm caches keep the integrated boot inside OpenCode's configured
22# per-server timeout on cold runners.
23# 3. `gco autopilot --engine opencode -y -- --version` exercises autopilot's
24# own install path: detect the missing binary, npm-install the pinned
25# release (with the postinstall that fetches OpenCode's native binary),
26# re-detect it, write the isolated OPENCODE_CONFIG file, and exec
27# opencode with the session-precedence overrides. The passthrough
28# `--version` makes that exec exit 0 deterministically.
29# 4. `gco autopilot --engine opencode -- mcp list` connects every planned
30# MCP server under OpenCode itself and prints a per-server status. This
31# is the positive MCP proof for this engine: the pinned OpenCode release
32# logs MCP servers only when they FAIL ("server unavailable"), so a
33# quiet session log is not evidence — `mcp list` is.
34# 5. `gco autopilot --engine opencode -- run "..."` boots the full
35# non-interactive stack: opencode loads the generated config, connects
36# the MCP servers, and dispatches to Amazon Bedrock (Converse, via the
37# AI SDK's amazon-bedrock provider) with the shipped default model. With
38# the fail-closed fake credentials exported below, SigV4 validation
39# answers UnrecognizedClientException — proving a signed request left
40# the wire. The probe asserts, from opencode's own --print-logs stderr:
41#
42# - loading path=.../opencode/opencode.json (the generated config)
43# - stream providerID=amazon-bedrock modelID=<default> agent=build
44# - stream ... small=true agent=title modelID=<default>
45# (the small_model pin: title generation stays on the session model
46# instead of OpenCode's auto-picked Claude Haiku)
47# - The security token included in the request is invalid.
48#
49# and that the SDK credential chain was NOT bypassed: "Could not load
50# credentials from any providers" is the signature of a generated config
51# that pinned an AWS profile over the exported static keys, which makes
52# the AWS SDK skip them entirely.
53#
54# Engine delta vs the other probes, asserted honestly: Codex logs an MCP
55# initialize handshake and Claude Code a per-server "Successfully connected"
56# line; OpenCode is silent about healthy servers. Per-server *launch* proof
57# therefore lives in the pre-warm phase, per-server *connection under
58# OpenCode* in the `mcp list` phase, and the session phase proves the Bedrock
59# dispatch and the credential boundary.
60#
61# Marker stability: the log markers above were captured from the pinned
62# OpenCode release (cli/autopilot.py OPENCODE_VERSION). A pin bump can
63# rephrase them; the failure output names the missing marker so the bump PR
64# can refresh this probe alongside the pin.
65#
66# Requirements: gco (this checkout, installed), node+npm (pinned via
67# .github/scripts/use-pinned-npm.sh; npm >= 11.5 so --allow-scripts is
68# honoured), uv/uvx, python3, GNU coreutils `timeout`. The `opencode` binary
69# must NOT be preinstalled — the probe exists to prove autopilot's own install
70# path works.
71#
72# =============================================================================
73
7418set -euo pipefail
75
7672REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
7718cd "$REPO_ROOT"
78
7918WORK_DIR="${RUNNER_TEMP:-$(mktemp -d)}/autopilot-opencode-boot-probe"
8018mkdir -p "$WORK_DIR"
81
82# Autopilot writes the session config (the isolated opencode.json beneath it)
83# here instead of ~/.gco/autopilot.
8436export GCO_AUTOPILOT_CONFIG_DIR="${WORK_DIR}/config"
85
8618SESSION_LOG="${WORK_DIR}/session.log"
8718MCP_LIST_LOG="${WORK_DIR}/mcp-list.log"
8818PREWARM_DIR="${WORK_DIR}/prewarm"
8918mkdir -p "$PREWARM_DIR"
90
91# How long the integrated session may take to reach the credential boundary.
92# OpenCode fails the turn on the first rejected request (no retry loop), so
93# this budget is dominated by MCP connection time on a cold runner.
9418BOOT_TIMEOUT_SECONDS="${BOOT_TIMEOUT_SECONDS:-420}"
95
96# The one EXIT trap: gather evidence for the always-uploaded artifact. The
97# generated JSON already lives under WORK_DIR (GCO_AUTOPILOT_CONFIG_DIR), so
98# only the session log needs copying.
99collect_and_cleanup() {
10036 cp -f "$SESSION_LOG" "${WORK_DIR}/session.log" 2>/dev/null || true
101}
10218trap collect_and_cleanup EXIT
103
104fail() {
10515 echo "✗ $1" >&2
10615 exit 1
107}
108
109pass() {
110130 echo "✓ $1"
111}
112
113# ── Fail-closed credential environment ──────────────────────────────────────
114# The probe must never reach Bedrock with usable credentials, even if the
115# surrounding job one day exports some. A syntactically valid but fabricated
116# static key pair wins the SDK provider chain ahead of every file/role
117# source, and the file/IMDS sources are disabled outright. The key id is
118# assembled at runtime so repository secret scanners (gitleaks, trufflehog)
119# never see a contiguous AKIA-shaped literal in the tree.
12036AWS_ACCESS_KEY_ID="$(printf 'AKIA%s' '00000000000fake0')"
12136AWS_SECRET_ACCESS_KEY="$(printf '%040d' 0)"
12218export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
12336export AWS_SHARED_CREDENTIALS_FILE=/dev/null
12436export AWS_CONFIG_FILE=/dev/null
12536export AWS_EC2_METADATA_DISABLED=true
12618unset AWS_SESSION_TOKEN AWS_PROFILE AWS_ROLE_ARN AWS_WEB_IDENTITY_TOKEN_FILE 2>/dev/null || true
12718unset AWS_BEARER_TOKEN_BEDROCK AWS_CONTAINER_CREDENTIALS_RELATIVE_URI AWS_CONTAINER_CREDENTIALS_FULL_URI 2>/dev/null || true
128
129# ── Preflight ────────────────────────────────────────────────────────────────
130
13187for tool in gco npm uvx python3 timeout; do
13288 command -v "$tool" >/dev/null || fail "required tool missing: $tool"
133done
134
13517if command -v opencode >/dev/null; then
1362 fail "opencode is already installed at $(command -v opencode) — this probe must exercise autopilot's own install path"
137fi
138
139# Facts come from the shared autopilot CI contract — the same single source
140# unit:cli:autopilot, the dev-container step, and the other probes assert
141# against.
14216CONTRACT=".github/scripts/autopilot_ci_contract.py"
14332OPENCODE_PIN="$(python3 "$CONTRACT" pin --engine opencode)"
14432EXPECTED_MODEL="$(python3 "$CONTRACT" default-model --engine opencode)"
14516pass "preflight OK (pin ${OPENCODE_PIN}, default model ${EXPECTED_MODEL})"
146
147# ── Phase 1: resolve the session plan from this checkout ────────────────────
148
14916GENERATED_CONFIG="${WORK_DIR}/print-config.json"
15016gco autopilot --engine opencode --print-config > "$GENERATED_CONFIG"
151
152# Full structural validation from the shared contract (model/small_model
153# selectors, provider region, update/share/permission policy, exact expected
154# server set, local MCP entry shapes). `--no-profile` is the regression guard
155# for the credential chain: with static keys exported, the config must not
156# pin an AWS profile, or the SDK would skip those keys.
15716python3 "$CONTRACT" verify-opencode-config "$GENERATED_CONFIG" --no-profile \
1581 || fail "generated OpenCode config failed the shared autopilot CI contract"
15930mapfile -t SERVER_NAMES < <(python3 "$CONTRACT" expected-servers)
16015[ "${#SERVER_NAMES[@]}" -ge 2 ] || fail "contract lists ${#SERVER_NAMES[@]} servers; expected the gco server plus companions"
16115pass "session plan resolves: ${#SERVER_NAMES[@]} MCP servers (${SERVER_NAMES[*]})"
162
163# ── Phase 2: pre-warm every server's exact launch recipe ────────────────────
164# Each companion is launched exactly as the generated JSON specifies and
165# handed EOF on stdin, which a stdio MCP server treats as client disconnect.
166# This resolves and installs every uvx/npx package (an independent
167# per-package install check with a pinpointed log on failure) and warms the
168# caches so the integrated boot below is not racing package managers against
169# OpenCode's per-server timeout.
170
17130mapfile -t PREWARM_CMDS < <(python3 - "$GENERATED_CONFIG" <<'PY'
172import json, shlex, sys
173with open(sys.argv[1], encoding="utf-8") as handle:
174 config = json.load(handle)
175for name, entry in sorted(config["mcp"].items()):
176 env_prefix = " ".join(
177 f"{key}={shlex.quote(str(value))}" for key, value in entry.get("environment", {}).items()
178 )
179 command = " ".join(shlex.quote(str(part)) for part in entry["command"])
180 print(f"{name}\t{env_prefix} {command}".replace("\t ", "\t", 1))
181PY
182)
18330
18415PREWARM_FAILURES=0
18560for line in "${PREWARM_CMDS[@]}"; do
18660 name="${line%%$'\t'*}"
18760 launch="${line#*$'\t'}"
18860 rc=0
18963 timeout 240 bash -c "$launch" </dev/null >"${PREWARM_DIR}/${name}.log" 2>&1 || rc=$?
19060 case "$rc" in
19130 0)
19257 pass "pre-warm ${name}: launched and exited on stdin EOF" ;;
19330 124)
19430 # Ran the full 240s before timeout killed it: the package
19530 # resolved, installed, and booted (cache warmed) — it just
19630 # doesn't exit on EOF. The in-session behavior happens later
19730 # under opencode, where connection management is opencode's job.
1981 pass "pre-warm ${name}: launched and ran until the warm-up timeout" ;;
19930 125 | 126 | 127)
20030 # timeout itself failed / command not executable / not found:
20130 # the launch recipe is broken.
2021 echo "── ${PREWARM_DIR}/${name}.log ──"
2031 cat "${PREWARM_DIR}/${name}.log" || true
2041 echo "✗ pre-warm ${name}: launch recipe failed (exit ${rc}): ${launch}" >&2
2051 PREWARM_FAILURES=$((PREWARM_FAILURES + 1)) ;;
20630 *)
20730 # Any other non-zero exit on EOF is server-specific and fine;
20830 # the process launched, which is all warming needs.
2091 pass "pre-warm ${name}: launched and exited on stdin EOF (rc ${rc})" ;;
21030 esac
21130done
21216[ "$PREWARM_FAILURES" -eq 0 ] || fail "${PREWARM_FAILURES} companion launch recipe(s) failed to start at all"
21330
21430# ── Phase 3: autopilot's own install path, exec verified by --version ───────
21530# opencode is absent, so `-y` makes autopilot npm-install the exact pin
21630# (allowing only opencode-ai's own postinstall, which fetches the native
21730# binary), re-detect the binary, write the isolated opencode.json, and exec it
21830# with the session-precedence argv. `--version` in the passthrough position
21930# makes that real exec terminate deterministically.
22030
22142VERSION_OUTPUT="$(gco autopilot --engine opencode -y -- --version 2>&1 | tee "${WORK_DIR}/version-probe.log")"
22228echo "$VERSION_OUTPUT" | grep -qF "$OPENCODE_PIN" \
2231 || fail "autopilot exec'd opencode, but its --version output does not carry the pin ${OPENCODE_PIN}: ${VERSION_OUTPUT}"
22414command -v opencode >/dev/null || fail "autopilot reported an install but opencode is not on PATH"
22512pass "autopilot installed the pin and exec'd opencode ${OPENCODE_PIN} with the session-precedence argv"
22630
22712WRITTEN_CONFIG="${GCO_AUTOPILOT_CONFIG_DIR}/opencode/opencode.json"
22813[ -f "$WRITTEN_CONFIG" ] || fail "autopilot did not write the isolated OpenCode config to ${WRITTEN_CONFIG}"
22930python3 - "$GENERATED_CONFIG" "$WRITTEN_CONFIG" <<'PY'
230import json, sys
231def servers(path):
232 with open(path, encoding="utf-8") as handle:
233 return set(json.load(handle)["mcp"])
234planned, written = servers(sys.argv[1]), servers(sys.argv[2])
235assert planned == written, f"planned {sorted(planned)} != written {sorted(written)}"
236PY
23710pass "written OPENCODE_CONFIG matches the printed plan (${WRITTEN_CONFIG})"
238
239# ── Phase 4: every planned MCP server connects under opencode itself ────────
240# `mcp list` is an OpenCode utility subcommand: autopilot hands it the
241# generated config through the same environment a session gets and withholds
242# the session-only flags. OpenCode connects each configured server and prints
243# one status line per server (`✓ <name> connected`; `✗ <name> failed`,
244# `○ <name> disabled`, `⚠ <name> needs authentication` otherwise) plus a
245# `<N> server(s)` trailer. The status text is dimmed with an ANSI colour code
246# even when stdout is not a terminal, so the output is de-coloured before it
247# is matched.
248
24910MCP_LIST_RC=0
25010timeout "$BOOT_TIMEOUT_SECONDS" gco autopilot --engine opencode -- mcp list \
2512 </dev/null >"$MCP_LIST_LOG" 2>&1 || MCP_LIST_RC=$?
25210[ "$MCP_LIST_RC" -eq 0 ] || {
2532 echo "── mcp list output (${MCP_LIST_LOG}) ──"
2542 tail -60 "$MCP_LIST_LOG" || true
2552 fail "gco autopilot --engine opencode -- mcp list exited ${MCP_LIST_RC}"
256}
25716ESC="$(printf '\033')"
25816MCP_LIST="$(sed "s/${ESC}\[[0-9;]*m//g" "$MCP_LIST_LOG")"
2598MISSING=""
26032for name in "${SERVER_NAMES[@]}"; do
26134 grep -qF "✓ ${name} connected" <<<"$MCP_LIST" || MISSING+="mcp-list:${name} "
26234done
2639grep -qF "${#SERVER_NAMES[@]} server(s)" <<<"$MCP_LIST" || MISSING+="mcp-list:count "
2648if grep -qE '(✗|○|⚠) [^ ]+ ' <<<"$MCP_LIST"; then
2651 MISSING+="mcp-list:unhealthy-entry "
2661fi
2678if [ -n "$MISSING" ]; then
2682 echo "── mcp list output (${MCP_LIST_LOG}) ──"
2692 tail -60 "$MCP_LIST_LOG" || true
2702 fail "opencode mcp list did not report every planned server connected: ${MISSING}"
271fi
2726pass "opencode connected all ${#SERVER_NAMES[@]} planned MCP servers from the generated config"
273
274# ── Phase 5: full session boot, stopped at the credential boundary ──────────
275# --print-logs surfaces opencode's structured log on stderr: the config files
276# it loads, the Bedrock stream events (provider, model, agent) and the
277# terminal error. `opencode run` fails the turn on the first rejected request
278# and exits nonzero by itself, so this run is awaited in the foreground; the
279# expected exit is nonzero and asserted as such.
280
2816echo "booting the full session (budget ${BOOT_TIMEOUT_SECONDS}s): gco autopilot --engine opencode -- run ..."
2826SESSION_RC=0
2836timeout "$BOOT_TIMEOUT_SECONDS" \
284 gco autopilot --engine opencode -- run --print-logs --log-level INFO "Reply with the single word OK." \
2855 </dev/null >"$SESSION_LOG" 2>&1 || SESSION_RC=$?
286
2876if [ "$SESSION_RC" -eq 124 ]; then
2881 echo "── session stdout/stderr (${SESSION_LOG}) ──"
2891 tail -50 "$SESSION_LOG" || true
2901 fail "session still running after ${BOOT_TIMEOUT_SECONDS}s — it never reached opencode's own failed-turn exit"
291fi
2925[ "$SESSION_RC" -ne 0 ] \
2931 || fail "session exited 0 with fabricated credentials — the credential boundary was never enforced"
2944pass "session ran to opencode's own failed-turn exit (rc ${SESSION_RC})"
295
2968LOGS="$(cat "$SESSION_LOG")"
297
2984MISSING=""
2994grep -qF "loading path=${WRITTEN_CONFIG}" <<<"$LOGS" \
3001 || MISSING+="generated-config-loaded "
3014grep -qE "message=stream providerID=amazon-bedrock modelID=${EXPECTED_MODEL}([[:space:]].*)?agent=build" <<<"$LOGS" \
3022 || MISSING+="bedrock-dispatch:${EXPECTED_MODEL} "
3034grep -qE "message=stream providerID=amazon-bedrock modelID=${EXPECTED_MODEL}([[:space:]].*)?small=true" <<<"$LOGS" \
3041 || MISSING+="small-model-pin:${EXPECTED_MODEL} "
3054grep -qE 'security token included in the request is invalid|UnrecognizedClientException|InvalidSignatureException' <<<"$LOGS" \
3062 || MISSING+="credential-boundary-sigv4-rejection "
3072
3084if grep -qF "Could not load credentials from any providers" <<<"$LOGS"; then
3091 echo "── session stdout/stderr (${SESSION_LOG}) ──"
3101 tail -80 "$SESSION_LOG" || true
3111 fail "opencode never used the exported static keys — the generated config bypassed the SDK credential chain (pinned AWS profile?)"
312fi
313
3143if [ -n "$MISSING" ]; then
3151 echo "── session stdout/stderr (${SESSION_LOG}) ──"
3161 tail -80 "$SESSION_LOG" || true
3171 fail "session did not produce these boot markers: ${MISSING}"
318fi
319
3202pass "opencode loaded the generated plan (${WRITTEN_CONFIG})"
3212pass "opencode dispatched to Bedrock with the shipped default model (${EXPECTED_MODEL})"
3222pass "title generation stayed on the session model (small_model pin honoured)"
3232pass "AWS rejected the fabricated credentials — the exact credential boundary"
324
325# Per-server connection evidence for the job summary (from Phase 4).
3262echo ""
3272echo "MCP connection report (opencode mcp list):"
3284grep -F '✓ ' <<<"$MCP_LIST" | sed 's/^[^✓]*✓/ ✓/' || true
329
3302echo ""
3312echo "autopilot opencode boot probe: PASS"