.github/scripts/autopilot_opencode_boot_probe.sh150 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.
| 1 | 18 | #!/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 | ||
| 74 | 18 | set -euo pipefail |
| 75 | ||
| 76 | 72 | REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" |
| 77 | 18 | cd "$REPO_ROOT" |
| 78 | ||
| 79 | 18 | WORK_DIR="${RUNNER_TEMP:-$(mktemp -d)}/autopilot-opencode-boot-probe" |
| 80 | 18 | mkdir -p "$WORK_DIR" |
| 81 | ||
| 82 | # Autopilot writes the session config (the isolated opencode.json beneath it) | |
| 83 | # here instead of ~/.gco/autopilot. | |
| 84 | 36 | export GCO_AUTOPILOT_CONFIG_DIR="${WORK_DIR}/config" |
| 85 | ||
| 86 | 18 | SESSION_LOG="${WORK_DIR}/session.log" |
| 87 | 18 | MCP_LIST_LOG="${WORK_DIR}/mcp-list.log" |
| 88 | 18 | PREWARM_DIR="${WORK_DIR}/prewarm" |
| 89 | 18 | mkdir -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. | |
| 94 | 18 | BOOT_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. | |
| 99 | collect_and_cleanup() { | |
| 100 | 36 | cp -f "$SESSION_LOG" "${WORK_DIR}/session.log" 2>/dev/null || true |
| 101 | } | |
| 102 | 18 | trap collect_and_cleanup EXIT |
| 103 | ||
| 104 | fail() { | |
| 105 | 15 | echo "✗ $1" >&2 |
| 106 | 15 | exit 1 |
| 107 | } | |
| 108 | ||
| 109 | pass() { | |
| 110 | 130 | 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. | |
| 120 | 36 | AWS_ACCESS_KEY_ID="$(printf 'AKIA%s' '00000000000fake0')" |
| 121 | 36 | AWS_SECRET_ACCESS_KEY="$(printf '%040d' 0)" |
| 122 | 18 | export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY |
| 123 | 36 | export AWS_SHARED_CREDENTIALS_FILE=/dev/null |
| 124 | 36 | export AWS_CONFIG_FILE=/dev/null |
| 125 | 36 | export AWS_EC2_METADATA_DISABLED=true |
| 126 | 18 | unset AWS_SESSION_TOKEN AWS_PROFILE AWS_ROLE_ARN AWS_WEB_IDENTITY_TOKEN_FILE 2>/dev/null || true |
| 127 | 18 | unset AWS_BEARER_TOKEN_BEDROCK AWS_CONTAINER_CREDENTIALS_RELATIVE_URI AWS_CONTAINER_CREDENTIALS_FULL_URI 2>/dev/null || true |
| 128 | ||
| 129 | # ── Preflight ──────────────────────────────────────────────────────────────── | |
| 130 | ||
| 131 | 87 | for tool in gco npm uvx python3 timeout; do |
| 132 | 88 | command -v "$tool" >/dev/null || fail "required tool missing: $tool" |
| 133 | done | |
| 134 | ||
| 135 | 17 | if command -v opencode >/dev/null; then |
| 136 | 2 | fail "opencode is already installed at $(command -v opencode) — this probe must exercise autopilot's own install path" |
| 137 | fi | |
| 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. | |
| 142 | 16 | CONTRACT=".github/scripts/autopilot_ci_contract.py" |
| 143 | 32 | OPENCODE_PIN="$(python3 "$CONTRACT" pin --engine opencode)" |
| 144 | 32 | EXPECTED_MODEL="$(python3 "$CONTRACT" default-model --engine opencode)" |
| 145 | 16 | pass "preflight OK (pin ${OPENCODE_PIN}, default model ${EXPECTED_MODEL})" |
| 146 | ||
| 147 | # ── Phase 1: resolve the session plan from this checkout ──────────────────── | |
| 148 | ||
| 149 | 16 | GENERATED_CONFIG="${WORK_DIR}/print-config.json" |
| 150 | 16 | gco 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. | |
| 157 | 16 | python3 "$CONTRACT" verify-opencode-config "$GENERATED_CONFIG" --no-profile \ |
| 158 | 1 | || fail "generated OpenCode config failed the shared autopilot CI contract" |
| 159 | 30 | mapfile -t SERVER_NAMES < <(python3 "$CONTRACT" expected-servers) |
| 160 | 15 | [ "${#SERVER_NAMES[@]}" -ge 2 ] || fail "contract lists ${#SERVER_NAMES[@]} servers; expected the gco server plus companions" |
| 161 | 15 | pass "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 | ||
| 171 | 30 | mapfile -t PREWARM_CMDS < <(python3 - "$GENERATED_CONFIG" <<'PY' |
| 172 | import json, shlex, sys | |
| 173 | with open(sys.argv[1], encoding="utf-8") as handle: | |
| 174 | config = json.load(handle) | |
| 175 | for 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)) | |
| 181 | PY | |
| 182 | ) | |
| 183 | 30 | |
| 184 | 15 | PREWARM_FAILURES=0 |
| 185 | 60 | for line in "${PREWARM_CMDS[@]}"; do |
| 186 | 60 | name="${line%%$'\t'*}" |
| 187 | 60 | launch="${line#*$'\t'}" |
| 188 | 60 | rc=0 |
| 189 | 63 | timeout 240 bash -c "$launch" </dev/null >"${PREWARM_DIR}/${name}.log" 2>&1 || rc=$? |
| 190 | 60 | case "$rc" in |
| 191 | 30 | 0) |
| 192 | 57 | pass "pre-warm ${name}: launched and exited on stdin EOF" ;; |
| 193 | 30 | 124) |
| 194 | 30 | # Ran the full 240s before timeout killed it: the package |
| 195 | 30 | # resolved, installed, and booted (cache warmed) — it just |
| 196 | 30 | # doesn't exit on EOF. The in-session behavior happens later |
| 197 | 30 | # under opencode, where connection management is opencode's job. |
| 198 | 1 | pass "pre-warm ${name}: launched and ran until the warm-up timeout" ;; |
| 199 | 30 | 125 | 126 | 127) |
| 200 | 30 | # timeout itself failed / command not executable / not found: |
| 201 | 30 | # the launch recipe is broken. |
| 202 | 1 | echo "── ${PREWARM_DIR}/${name}.log ──" |
| 203 | 1 | cat "${PREWARM_DIR}/${name}.log" || true |
| 204 | 1 | echo "✗ pre-warm ${name}: launch recipe failed (exit ${rc}): ${launch}" >&2 |
| 205 | 1 | PREWARM_FAILURES=$((PREWARM_FAILURES + 1)) ;; |
| 206 | 30 | *) |
| 207 | 30 | # Any other non-zero exit on EOF is server-specific and fine; |
| 208 | 30 | # the process launched, which is all warming needs. |
| 209 | 1 | pass "pre-warm ${name}: launched and exited on stdin EOF (rc ${rc})" ;; |
| 210 | 30 | esac |
| 211 | 30 | done |
| 212 | 16 | [ "$PREWARM_FAILURES" -eq 0 ] || fail "${PREWARM_FAILURES} companion launch recipe(s) failed to start at all" |
| 213 | 30 | |
| 214 | 30 | # ── Phase 3: autopilot's own install path, exec verified by --version ─────── |
| 215 | 30 | # opencode is absent, so `-y` makes autopilot npm-install the exact pin |
| 216 | 30 | # (allowing only opencode-ai's own postinstall, which fetches the native |
| 217 | 30 | # binary), re-detect the binary, write the isolated opencode.json, and exec it |
| 218 | 30 | # with the session-precedence argv. `--version` in the passthrough position |
| 219 | 30 | # makes that real exec terminate deterministically. |
| 220 | 30 | |
| 221 | 42 | VERSION_OUTPUT="$(gco autopilot --engine opencode -y -- --version 2>&1 | tee "${WORK_DIR}/version-probe.log")" |
| 222 | 28 | echo "$VERSION_OUTPUT" | grep -qF "$OPENCODE_PIN" \ |
| 223 | 1 | || fail "autopilot exec'd opencode, but its --version output does not carry the pin ${OPENCODE_PIN}: ${VERSION_OUTPUT}" |
| 224 | 14 | command -v opencode >/dev/null || fail "autopilot reported an install but opencode is not on PATH" |
| 225 | 12 | pass "autopilot installed the pin and exec'd opencode ${OPENCODE_PIN} with the session-precedence argv" |
| 226 | 30 | |
| 227 | 12 | WRITTEN_CONFIG="${GCO_AUTOPILOT_CONFIG_DIR}/opencode/opencode.json" |
| 228 | 13 | [ -f "$WRITTEN_CONFIG" ] || fail "autopilot did not write the isolated OpenCode config to ${WRITTEN_CONFIG}" |
| 229 | 30 | python3 - "$GENERATED_CONFIG" "$WRITTEN_CONFIG" <<'PY' |
| 230 | import json, sys | |
| 231 | def servers(path): | |
| 232 | with open(path, encoding="utf-8") as handle: | |
| 233 | return set(json.load(handle)["mcp"]) | |
| 234 | planned, written = servers(sys.argv[1]), servers(sys.argv[2]) | |
| 235 | assert planned == written, f"planned {sorted(planned)} != written {sorted(written)}" | |
| 236 | PY | |
| 237 | 10 | pass "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 | ||
| 249 | 10 | MCP_LIST_RC=0 |
| 250 | 10 | timeout "$BOOT_TIMEOUT_SECONDS" gco autopilot --engine opencode -- mcp list \ |
| 251 | 2 | </dev/null >"$MCP_LIST_LOG" 2>&1 || MCP_LIST_RC=$? |
| 252 | 10 | [ "$MCP_LIST_RC" -eq 0 ] || { |
| 253 | 2 | echo "── mcp list output (${MCP_LIST_LOG}) ──" |
| 254 | 2 | tail -60 "$MCP_LIST_LOG" || true |
| 255 | 2 | fail "gco autopilot --engine opencode -- mcp list exited ${MCP_LIST_RC}" |
| 256 | } | |
| 257 | 16 | ESC="$(printf '\033')" |
| 258 | 16 | MCP_LIST="$(sed "s/${ESC}\[[0-9;]*m//g" "$MCP_LIST_LOG")" |
| 259 | 8 | MISSING="" |
| 260 | 32 | for name in "${SERVER_NAMES[@]}"; do |
| 261 | 34 | grep -qF "✓ ${name} connected" <<<"$MCP_LIST" || MISSING+="mcp-list:${name} " |
| 262 | 34 | done |
| 263 | 9 | grep -qF "${#SERVER_NAMES[@]} server(s)" <<<"$MCP_LIST" || MISSING+="mcp-list:count " |
| 264 | 8 | if grep -qE '(✗|○|⚠) [^ ]+ ' <<<"$MCP_LIST"; then |
| 265 | 1 | MISSING+="mcp-list:unhealthy-entry " |
| 266 | 1 | fi |
| 267 | 8 | if [ -n "$MISSING" ]; then |
| 268 | 2 | echo "── mcp list output (${MCP_LIST_LOG}) ──" |
| 269 | 2 | tail -60 "$MCP_LIST_LOG" || true |
| 270 | 2 | fail "opencode mcp list did not report every planned server connected: ${MISSING}" |
| 271 | fi | |
| 272 | 6 | pass "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 | ||
| 281 | 6 | echo "booting the full session (budget ${BOOT_TIMEOUT_SECONDS}s): gco autopilot --engine opencode -- run ..." |
| 282 | 6 | SESSION_RC=0 |
| 283 | 6 | timeout "$BOOT_TIMEOUT_SECONDS" \ |
| 284 | gco autopilot --engine opencode -- run --print-logs --log-level INFO "Reply with the single word OK." \ | |
| 285 | 5 | </dev/null >"$SESSION_LOG" 2>&1 || SESSION_RC=$? |
| 286 | ||
| 287 | 6 | if [ "$SESSION_RC" -eq 124 ]; then |
| 288 | 1 | echo "── session stdout/stderr (${SESSION_LOG}) ──" |
| 289 | 1 | tail -50 "$SESSION_LOG" || true |
| 290 | 1 | fail "session still running after ${BOOT_TIMEOUT_SECONDS}s — it never reached opencode's own failed-turn exit" |
| 291 | fi | |
| 292 | 5 | [ "$SESSION_RC" -ne 0 ] \ |
| 293 | 1 | || fail "session exited 0 with fabricated credentials — the credential boundary was never enforced" |
| 294 | 4 | pass "session ran to opencode's own failed-turn exit (rc ${SESSION_RC})" |
| 295 | ||
| 296 | 8 | LOGS="$(cat "$SESSION_LOG")" |
| 297 | ||
| 298 | 4 | MISSING="" |
| 299 | 4 | grep -qF "loading path=${WRITTEN_CONFIG}" <<<"$LOGS" \ |
| 300 | 1 | || MISSING+="generated-config-loaded " |
| 301 | 4 | grep -qE "message=stream providerID=amazon-bedrock modelID=${EXPECTED_MODEL}([[:space:]].*)?agent=build" <<<"$LOGS" \ |
| 302 | 2 | || MISSING+="bedrock-dispatch:${EXPECTED_MODEL} " |
| 303 | 4 | grep -qE "message=stream providerID=amazon-bedrock modelID=${EXPECTED_MODEL}([[:space:]].*)?small=true" <<<"$LOGS" \ |
| 304 | 1 | || MISSING+="small-model-pin:${EXPECTED_MODEL} " |
| 305 | 4 | grep -qE 'security token included in the request is invalid|UnrecognizedClientException|InvalidSignatureException' <<<"$LOGS" \ |
| 306 | 2 | || MISSING+="credential-boundary-sigv4-rejection " |
| 307 | 2 | |
| 308 | 4 | if grep -qF "Could not load credentials from any providers" <<<"$LOGS"; then |
| 309 | 1 | echo "── session stdout/stderr (${SESSION_LOG}) ──" |
| 310 | 1 | tail -80 "$SESSION_LOG" || true |
| 311 | 1 | fail "opencode never used the exported static keys — the generated config bypassed the SDK credential chain (pinned AWS profile?)" |
| 312 | fi | |
| 313 | ||
| 314 | 3 | if [ -n "$MISSING" ]; then |
| 315 | 1 | echo "── session stdout/stderr (${SESSION_LOG}) ──" |
| 316 | 1 | tail -80 "$SESSION_LOG" || true |
| 317 | 1 | fail "session did not produce these boot markers: ${MISSING}" |
| 318 | fi | |
| 319 | ||
| 320 | 2 | pass "opencode loaded the generated plan (${WRITTEN_CONFIG})" |
| 321 | 2 | pass "opencode dispatched to Bedrock with the shipped default model (${EXPECTED_MODEL})" |
| 322 | 2 | pass "title generation stayed on the session model (small_model pin honoured)" |
| 323 | 2 | pass "AWS rejected the fabricated credentials — the exact credential boundary" |
| 324 | ||
| 325 | # Per-server connection evidence for the job summary (from Phase 4). | |
| 326 | 2 | echo "" |
| 327 | 2 | echo "MCP connection report (opencode mcp list):" |
| 328 | 4 | grep -F '✓ ' <<<"$MCP_LIST" | sed 's/^[^✓]*✓/ ✓/' || true |
| 329 | ||
| 330 | 2 | echo "" |
| 331 | 2 | echo "autopilot opencode boot probe: PASS" |