From 466c7b2d93f622c212b76093d94e0b508176e06e Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 02:47:38 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(opencode):=20boot=20build-parity=20ass?= =?UTF-8?q?ertion=20=E2=80=94=20three-outcome=20record,=20fail-open=20(ami?= =?UTF-8?q?code#295=20AC1=20unit=20+=20AC2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D3 (spec-20260905-045114): every client and the hub assert their build (channel + version + sha) against the release channel's dist-tags at boot and record exactly one of parity-ok | parity-drift | channel-unreachable in the log. An unreachable / erroring / hung / malformed channel fails OPEN but is recorded as its own outcome — never as parity-ok. A local build has no release channel and records channel-unreachable without probing. The boot sha is baked at build time (OPENCODE_SHA define). --- packages/core/src/installation/version.ts | 5 + packages/opencode/script/build.ts | 4 + packages/opencode/src/installation/parity.ts | 98 +++++++++++ .../opencode/test/installation/parity.test.ts | 163 ++++++++++++++++++ 4 files changed, 270 insertions(+) create mode 100644 packages/opencode/src/installation/parity.ts create mode 100644 packages/opencode/test/installation/parity.test.ts diff --git a/packages/core/src/installation/version.ts b/packages/core/src/installation/version.ts index 25d9cd99aa..4673e2f518 100644 --- a/packages/core/src/installation/version.ts +++ b/packages/core/src/installation/version.ts @@ -1,8 +1,13 @@ declare global { const OPENCODE_VERSION: string const OPENCODE_CHANNEL: string + const OPENCODE_SHA: string } export const InstallationVersion = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" export const InstallationChannel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" +// The git sha the binary was built from — "unknown" outside a release build +// (the define is only set by script/build.ts). Carried in the boot parity +// record; the comparison itself is tag-based (dist-tags carry versions). +export const InstallationSha = typeof OPENCODE_SHA === "string" ? OPENCODE_SHA : "unknown" export const InstallationLocal = InstallationChannel === "local" diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 1adc6c35ea..889dcf7dc6 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -23,6 +23,9 @@ const sourcemapsFlag = process.argv.includes("--sourcemaps") const plugin = createSolidTransformPlugin() const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui") +// The git sha baked into the binary (boot parity record); "unknown" outside a git checkout. +const buildSha = (await $`git rev-parse HEAD`.nothrow().text()).trim() || "unknown" + const createEmbeddedWebUIBundle = async () => { console.log(`Building Web UI to embed in the binary`) const appDir = path.join(import.meta.dirname, "../../app") @@ -219,6 +222,7 @@ for (const item of buildTargets) { OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + treeSitterWorkerPath, OPENCODE_WORKER_PATH: workerPath, OPENCODE_CHANNEL: `'${Script.channel}'`, + OPENCODE_SHA: `'${buildSha}'`, OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "", ...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}), }, diff --git a/packages/opencode/src/installation/parity.ts b/packages/opencode/src/installation/parity.ts new file mode 100644 index 0000000000..92b2324f83 --- /dev/null +++ b/packages/opencode/src/installation/parity.ts @@ -0,0 +1,98 @@ +import { serviceUse } from "@opencode-ai/core/effect/service-use" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" +import { makeRuntime } from "@opencode-ai/core/effect/runtime" +import { InstallationChannel, InstallationSha, InstallationVersion } from "@opencode-ai/core/installation/version" +import { Context, Duration, Effect, Layer, Schema } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" + +// D3 (spec-20260905-045114): binary currency is asserted at boot on EVERY +// client and the hub — base behavior, present without any entitlement. The +// build (channel + version + sha) is compared against the release channel's +// dist-tags, and the outcome is recorded in the log as exactly one of +// +// parity-ok | parity-drift | channel-unreachable +// +// The check FAILS OPEN: an unreachable channel never blocks boot — but it is +// recorded as its own outcome, so an assertion that never ran is never +// mistaken for one that passed. A build with channel "local" has no release +// channel to assert against and records channel-unreachable too. + +export type Outcome = "parity-ok" | "parity-drift" | "channel-unreachable" + +export interface Build { + readonly channel: string + readonly version: string + readonly sha: string +} + +const DistTags = Schema.Record(Schema.String, Schema.String) + +const PROBE_URL = "https://registry.npmjs.org/-/package/opencode-ai/dist-tags" + +export function resolveParity(local: Build, channelLatest: string | undefined): Outcome { + if (local.channel === "local") return "channel-unreachable" + if (channelLatest === undefined) return "channel-unreachable" + return channelLatest === local.version ? "parity-ok" : "parity-drift" +} + +export interface Interface { + /** + * Assert the running build against the release channel and record the + * outcome in the log. Never fails and never blocks boot: any channel + * failure (unreachable, error status, malformed answer, timeout) resolves + * to "channel-unreachable". + */ + readonly assert: (options?: { local?: Build; timeout?: Duration.Input }) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/InstallationParity") {} + +export const use = serviceUse(Service) + +const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + + const probe = Effect.fnUntraced(function* (local: Build) { + if (local.channel === "local") return undefined + const response = yield* http.execute(HttpClientRequest.get(PROBE_URL)) + const body = yield* response.text + const tags = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(DistTags))(body) + return tags[local.channel] + }) + + const assert = Effect.fn("Parity.assert")(function* (options?: { local?: Build; timeout?: Duration.Input }) { + const local = options?.local ?? { + channel: InstallationChannel, + version: InstallationVersion, + sha: InstallationSha, + } + const latest = yield* probe(local).pipe( + Effect.timeout(options?.timeout ?? "5 seconds"), + Effect.catch(() => Effect.succeed(undefined)), + ) + const outcome = resolveParity(local, latest) + yield* Effect.logInfo("build parity", { + outcome, + channel: local.channel, + version: local.version, + sha: local.sha, + latest: latest ?? "unknown", + }) + return outcome + }) + + return Service.of({ assert }) + }), +) + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [httpClient] }) + +const { runPromise } = makeRuntime(Service, AppNodeBuilder.build(node)) + +export const assertBoot = (options?: { timeout?: Duration.Input }) => runPromise((s) => s.assert(options)) + +export * as Parity from "./parity" diff --git a/packages/opencode/test/installation/parity.test.ts b/packages/opencode/test/installation/parity.test.ts new file mode 100644 index 0000000000..879ea0e3a3 --- /dev/null +++ b/packages/opencode/test/installation/parity.test.ts @@ -0,0 +1,163 @@ +import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import * as TestConsole from "effect/testing/TestConsole" +import { Parity } from "../../src/installation/parity" +import { testEffect } from "../lib/effect" + +function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest) => Response) { + const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request)))) + return Layer.succeed(HttpClient.HttpClient, client) +} + +function failingHttpClient() { + const client = HttpClient.make((request) => + Effect.fail(new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({ request }) })), + ) + return Layer.succeed(HttpClient.HttpClient, client) +} + +function hangingHttpClient() { + const client = HttpClient.make(() => Effect.never) + return Layer.succeed(HttpClient.HttpClient, client) +} + +function distTagsResponse(tags: Record) { + return new Response(JSON.stringify(tags), { status: 200, headers: { "content-type": "application/json" } }) +} + +function testLayer(handler: (request: HttpClientRequest.HttpClientRequest) => Response) { + return LayerNode.compile(Parity.node, [[httpClient, mockHttpClient(handler)]]) +} + +const local = { channel: "latest", version: "1.2.3", sha: "abc1234" } + +// The boot parity record is one structured log entry: an object carrying the +// outcome plus the build identity it was asserted for. +function isRecord(line: unknown): line is { outcome: string; channel: string; version: string; sha: string } { + return typeof line === "object" && line !== null && "outcome" in line && "channel" in line +} + +function recordedLines() { + return Effect.map(TestConsole.logLines, (lines) => lines.filter(isRecord)) +} + +describe("installation parity", () => { + testEffect(testLayer(() => distTagsResponse({ latest: "1.2.3", dev: "1.3.0" }))).effect( + "channel in parity with the local build records exactly one parity-ok", + () => + Effect.gen(function* () { + const outcome = yield* Parity.use.assert({ local }) + expect(outcome).toBe("parity-ok") + const records = yield* recordedLines() + expect(records).toHaveLength(1) + expect(records[0]?.outcome).toBe("parity-ok") + expect(records[0]?.version).toBe("1.2.3") + expect(records[0]?.sha).toBe("abc1234") + }), + ) + + testEffect(testLayer(() => distTagsResponse({ latest: "9.9.9", dev: "1.2.3" }))).effect( + "the channel tag is picked by the local build's channel, not by latest", + () => + Effect.gen(function* () { + const outcome = yield* Parity.use.assert({ local: { ...local, channel: "dev" } }) + expect(outcome).toBe("parity-ok") + }), + ) + + testEffect(testLayer(() => distTagsResponse({ latest: "2.0.0" }))).effect( + "a lagging build records parity-drift against the channel", + () => + Effect.gen(function* () { + const outcome = yield* Parity.use.assert({ local }) + expect(outcome).toBe("parity-drift") + const records = yield* recordedLines() + expect(records).toHaveLength(1) + expect(records[0]?.outcome).toBe("parity-drift") + }), + ) + + testEffect(testLayer(() => distTagsResponse({ latest: "9.9.9" }))).effect( + "a channel tag missing the local build's channel records channel-unreachable", + () => + Effect.gen(function* () { + const outcome = yield* Parity.use.assert({ local: { ...local, channel: "dev" } }) + expect(outcome).toBe("channel-unreachable") + }), + ) + + testEffect(testLayer(() => new Response("gateway timeout", { status: 504 }))).effect( + "a channel that answers with an error status records channel-unreachable and fails open", + () => + Effect.gen(function* () { + const outcome = yield* Parity.use.assert({ local }) + expect(outcome).toBe("channel-unreachable") + const records = yield* recordedLines() + expect(records).toHaveLength(1) + expect(records[0]?.outcome).toBe("channel-unreachable") + }), + ) + + testEffect(testLayer(() => new Response("not json", { status: 200 }))).effect( + "a channel answering garbage records channel-unreachable and fails open", + () => + Effect.gen(function* () { + const outcome = yield* Parity.use.assert({ local }) + expect(outcome).toBe("channel-unreachable") + }), + ) + + testEffect(LayerNode.compile(Parity.node, [[httpClient, failingHttpClient()]])).effect( + "an unreachable channel records channel-unreachable and never fails the boot", + () => + Effect.gen(function* () { + const outcome = yield* Parity.use.assert({ local }) + expect(outcome).toBe("channel-unreachable") + const records = yield* recordedLines() + expect(records).toHaveLength(1) + expect(records[0]?.outcome).toBe("channel-unreachable") + }), + ) + + testEffect(LayerNode.compile(Parity.node, [[httpClient, hangingHttpClient()]])).live( + "a hung channel is bounded and records channel-unreachable", + () => + Effect.gen(function* () { + const outcome = yield* Parity.use.assert({ local, timeout: "100 millis" }) + expect(outcome).toBe("channel-unreachable") + }), + 5_000, + ) + + const probeCalls: string[] = [] + testEffect( + testLayer((request) => { + probeCalls.push(request.url) + return distTagsResponse({ latest: "1.2.3" }) + }), + ).effect("a local build has no release channel — recorded channel-unreachable, never parity-ok, no probe", () => + Effect.gen(function* () { + const outcome = yield* Parity.use.assert({ + local: { channel: "local", version: "0.0.0", sha: "dev" }, + }) + expect(outcome).toBe("channel-unreachable") + expect(probeCalls).toHaveLength(0) + }), + ) + + const distTagCalls: string[] = [] + testEffect( + testLayer((request) => { + distTagCalls.push(request.url) + return distTagsResponse({ latest: "1.2.3" }) + }), + ).effect("the probe hits the release channel dist-tags endpoint", () => + Effect.gen(function* () { + yield* Parity.use.assert({ local }) + expect(distTagCalls).toContain("https://registry.npmjs.org/-/package/opencode-ai/dist-tags") + }), + ) +}) From 34a3a348187b616ebb9e212b041a37b558dcce4e Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 02:49:21 -0400 Subject: [PATCH 2/3] feat(opencode): hub + client boot hooks record the parity outcome (amicode#295 AC1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit serve (hub), run (client), and attach (client) each assert the build at boot via Parity.assertBoot — bounded (5s hub, 2s clients), fail-open, one record per boot in opencode.log. Subprocess tests spawn the real CLI and assert the log file carries exactly one record; a local channel reads channel-unreachable, never parity-ok. --- packages/opencode/src/cli/cmd/attach.ts | 4 ++ packages/opencode/src/cli/cmd/run.ts | 5 ++ packages/opencode/src/cli/cmd/serve.ts | 4 ++ .../opencode/test/cli/build-parity.test.ts | 69 +++++++++++++++++++ 4 files changed, 82 insertions(+) create mode 100644 packages/opencode/test/cli/build-parity.test.ts diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 6f5aea6a12..05fc03d075 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -3,6 +3,7 @@ import { UI } from "@/cli/ui" import { errorMessage } from "@opencode-ai/tui/util/error" import { validateSession } from "../tui/validate-session" import { ServerAuth } from "@/server/auth" +import { Parity } from "../../installation/parity" export const AttachCommand = cmd({ command: "attach ", @@ -60,6 +61,9 @@ export const AttachCommand = cmd({ describe: "cap visible mini replay to the newest N messages", }), handler: async (args) => { + // D3: a client boot asserts its build against the release channel — + // fail-open, bounded; the outcome lands in the log, never blocks attach. + await Parity.assertBoot({ timeout: "2 seconds" }).catch(() => {}) if (args.replay === true) { UI.error("--replay is not supported; replay is enabled by default") process.exitCode = 1 diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3927f615a0..5758eb603a 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -20,6 +20,7 @@ import { open } from "node:fs/promises" import { Effect } from "effect" import { UI } from "../ui" import { effectCmd } from "../effect-cmd" +import { Parity } from "../../installation/parity" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" @@ -261,6 +262,10 @@ export const RunCommand = effectCmd({ describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately", }), handler: Effect.fn("Cli.run")(function* (args) { + // D3: the client asserts its build against the release channel at boot + // and records the parity outcome — fail-open, bounded so the prompt never + // waits on a hung channel. + yield* Effect.promise(() => Parity.assertBoot({ timeout: "2 seconds" })) const { Agent } = yield* Effect.promise(() => import("@/agent/agent")) const { RuntimeFlags } = yield* Effect.promise(() => import("@/effect/runtime-flags")) const { InstanceRef } = yield* Effect.promise(() => import("@/effect/instance-ref")) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index c0f62b3ca0..b1ec85dd51 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -2,6 +2,7 @@ import { Effect } from "effect" import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" +import { Parity } from "../../installation/parity" export const ServeCommand = effectCmd({ command: "serve", @@ -15,6 +16,9 @@ export const ServeCommand = effectCmd({ if (!Flag.OPENCODE_SERVER_PASSWORD) { console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } + // D3: the hub asserts its build against the release channel at boot and + // records the parity outcome before serving — fail-open, bounded. + yield* Effect.promise(() => Parity.assertBoot()) const opts = yield* resolveNetworkOptions(args) const server = yield* Effect.promise(() => Server.listen(opts)) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) diff --git a/packages/opencode/test/cli/build-parity.test.ts b/packages/opencode/test/cli/build-parity.test.ts new file mode 100644 index 0000000000..61ed7ffbbc --- /dev/null +++ b/packages/opencode/test/cli/build-parity.test.ts @@ -0,0 +1,69 @@ +// Subprocess tests for the boot build-parity record (amicode#295 AC1): every +// boot — hub and client — records exactly one of parity-ok | parity-drift | +// channel-unreachable in its log. These spawn the REAL CLI, so the record is +// asserted where it actually lands: the opencode.log file under the isolated +// XDG data dir. The subprocess runs channel "local" (no release channel), so +// the honest boot outcome is channel-unreachable — and it must never read +// parity-ok. +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { cliIt, testModelID } from "../lib/cli-process" + +const OUTCOMES = ["parity-ok", "parity-drift", "channel-unreachable"] as const + +function parityRecords(log: string) { + return log + .split("\n") + .filter((line) => line.includes("build parity")) + .map((line) => ({ + line, + outcome: OUTCOMES.find((outcome) => line.includes(`outcome=${outcome}`)), + })) +} + +function readBootLog(home: string) { + return Effect.promise(() => Bun.file(`${home}/.local/share/opencode/log/opencode.log`).text()) +} + +// The file logger batches writes, so a long-lived hub may not have flushed +// the boot record when serve() returns — poll until it lands. +function waitForParityRecord(home: string) { + return Effect.gen(function* () { + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + const log = yield* readBootLog(home) + const records = parityRecords(log) + if (records.length > 0) return records + yield* Effect.sleep("250 millis") + } + return [] + }) +} + +describe("boot build parity (subprocess)", () => { + cliIt.live( + "the hub boot records exactly one parity outcome in its log", + ({ opencode, home }) => + Effect.gen(function* () { + yield* opencode.serve() + const records = yield* waitForParityRecord(home) + expect(records).toHaveLength(1) + // channel "local" in the subprocess — no release channel to assert + // against, so the honest outcome is channel-unreachable, never parity-ok. + expect(records[0]?.outcome).toBe("channel-unreachable") + }), + 60_000, + ) + + cliIt.live( + "the client boot records exactly one parity outcome in its log", + ({ opencode, home }) => + Effect.gen(function* () { + yield* opencode.run("hello", { model: testModelID }) + const records = yield* waitForParityRecord(home) + expect(records).toHaveLength(1) + expect(records[0]?.outcome).toBe("channel-unreachable") + }), + 60_000, + ) +}) From bc20b85385bafe526abe84dee9080f532e6f42be Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 02:56:16 -0400 Subject: [PATCH 3/3] =?UTF-8?q?feat(opencode):=20hub-smoke-gate=20?= =?UTF-8?q?=E2=80=94=20smoke-run=20record=20gates=20every=20swap,=20rename?= =?UTF-8?q?-only=20proceed=20(amicode#295=20AC3+AC4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DB-snapshot boot smoke is the only road to a hub swap: a passing ops/hub-upgrade-smoke.sh run records .smoke.json (outcome=pass + sha256 of the smoked binary), and the gate refuses any swap without a passing, sha-matching record — the refusal NAMES the missing gate (hub-upgrade-smoke). On pass, swap is rename-only (mv) with a sha sidecar refresh; no process stop, so a running hub is never left down. Tested against the real script with real files and a real long-running process holding the live binary open across the rename. The swap/restart driver itself lives in the ops surface (outside this repo); adopting the gate there is a one-line call in hub-restart.sh's swap mode. --- packages/opencode/script/hub-smoke-gate.sh | 88 ++++++++++ packages/opencode/test/ops/smoke-gate.test.ts | 164 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100755 packages/opencode/script/hub-smoke-gate.sh create mode 100644 packages/opencode/test/ops/smoke-gate.test.ts diff --git a/packages/opencode/script/hub-smoke-gate.sh b/packages/opencode/script/hub-smoke-gate.sh new file mode 100755 index 0000000000..4366c0835c --- /dev/null +++ b/packages/opencode/script/hub-smoke-gate.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# hub-smoke-gate.sh — the smoke gate: the ONLY road to a hub swap is a passing +# DB-snapshot boot smoke (amicode#295, D3 of spec-20260905-045114). +# +# Born from the 2026-08-30 incident contract (ops/hub-restart.sh + +# ops/hub-upgrade-smoke.sh): a candidate binary is boot-smoked against a copy +# of the production DB before any swap touches the hub. The smoke harness +# records its verdict next to the staged binary — +# +# .smoke.json {"outcome":"pass","sha256":"", +# "harness":"hub-upgrade-smoke.sh","recorded_at":""} +# +# — and this gate is the reader every swap goes through: +# +# hub-smoke-gate.sh check +# exit 0 = a passing, sha-matching smoke record exists +# exit 1 = refused (the refusal NAMES the missing gate) +# hub-smoke-gate.sh swap +# gate first; on pass, rename-only mv staged → live + sha sidecar +# refresh. NO process stop: rename(2) over a running executable is +# legal and atomic, the new image loads at the next restart, and the +# hub is never left down. On refusal nothing moves. +# +# The gate guards BASE correctness (it lives in the base, not the fleet +# overlay); the swap is rename-only per the 2026-08-30 safety laws. +set -uo pipefail + +usage() { echo "usage: hub-smoke-gate.sh check | swap "; exit 2; } + +MODE="${1:-}" +STAGED="${2:-}" +LIVE="${3:-}" + +[ "$MODE" = "check" ] || [ "$MODE" = "swap" ] || usage +[ -n "$STAGED" ] || usage +if [ "$MODE" = "swap" ]; then + [ -n "$LIVE" ] || usage + [ -f "$LIVE" ] || { echo "REFUSED: live binary $LIVE does not exist (missing gate: hub-upgrade-smoke)"; exit 1; } +fi +[ -f "$STAGED" ] || { echo "REFUSED: staged binary $STAGED does not exist (missing gate: hub-upgrade-smoke)"; exit 1; } + +RECORD="$STAGED.smoke.json" + +command -v python3 >/dev/null || { echo "REFUSED: python3 required to read the smoke record (missing gate: hub-upgrade-smoke)"; exit 1; } +command -v sha256sum >/dev/null || { echo "REFUSED: sha256sum required (missing gate: hub-upgrade-smoke)"; exit 1; } + +refuse() { + echo "REFUSED: $1 — the DB-snapshot boot smoke is the only road to a hub swap (missing gate: hub-upgrade-smoke; run ops/hub-upgrade-smoke.sh first)" >&2 + exit 1 +} + +# The smoke verdict: outcome + sha of the binary the harness actually smoked. +read -r REC_OUTCOME REC_SHA < <( + python3 - "$RECORD" <<'EOF' +import json, sys +try: + with open(sys.argv[1]) as f: + record = json.load(f) + print(record.get("outcome", ""), record.get("sha256", "")) +except Exception: + print("", "") +EOF +) || true + +if [ -z "$REC_OUTCOME" ]; then + refuse "no smoke record at $RECORD" +fi +if [ "$REC_OUTCOME" != "pass" ]; then + refuse "smoke record at $RECORD is outcome=$REC_OUTCOME, not pass" +fi + +STAGED_SHA="$(sha256sum "$STAGED" | awk '{print $1}')" +if [ "$REC_SHA" != "$STAGED_SHA" ]; then + refuse "smoke record is stale — its sha256 does not match the staged binary (record=$REC_SHA staged=$STAGED_SHA)" +fi + +if [ "$MODE" = "check" ]; then + echo "OK: smoke gate passed for $STAGED sha=${STAGED_SHA:0:16}… (outcome=$REC_OUTCOME)" + exit 0 +fi + +# swap — rename-only, per the 2026-08-30 safety laws: if anything dies +# mid-swap the old binary is either still running or already replaced — +# never half-written, and no process is ever stopped or started here. +mv -f "$STAGED" "$LIVE" || refuse "mv failed; nothing changed" +chmod 0755 "$LIVE" 2>/dev/null || true +sha256sum "$LIVE" | awk '{print $1}' > "$LIVE.sha256" +echo "OK swapped sha=$(cut -c1-16 "$LIVE.sha256")… — restart to load it (rename-only; the running hub was never stopped)" diff --git a/packages/opencode/test/ops/smoke-gate.test.ts b/packages/opencode/test/ops/smoke-gate.test.ts new file mode 100644 index 0000000000..b2a3ef8659 --- /dev/null +++ b/packages/opencode/test/ops/smoke-gate.test.ts @@ -0,0 +1,164 @@ +// The smoke gate (amicode#295 AC3 + AC4): a hub swap without a passing +// DB-snapshot boot smoke (ops/hub-upgrade-smoke.sh) is refused, and the +// refusal NAMES the missing gate. A swap with a passing smoke record proceeds +// through the rename-only path — no process stop, so the running hub is +// never left down (rename(2) over a running executable is atomic). +// +// These tests run the REAL gate script against real files and real processes +// in a temp dir — the same artifact the ops surface consumes. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" + +const GATE = path.resolve(import.meta.dir, "../../script/hub-smoke-gate.sh") + +async function sha256File(file: string) { + const bytes = new Uint8Array(await Bun.file(file).arrayBuffer()) + return new Bun.CryptoHasher("sha256").update(bytes).digest("hex") +} + +async function writeBinary(dir: string, name: string, body: string) { + const file = path.join(dir, name) + await fs.writeFile(file, body, { mode: 0o755 }) + return file +} + +function writeSmokeRecord(staged: string, record: { outcome: string; sha256?: string }) { + return sha256File(staged).then((actual) => + fs.writeFile( + `${staged}.smoke.json`, + JSON.stringify({ + outcome: record.outcome, + sha256: record.sha256 ?? actual, + harness: "hub-upgrade-smoke.sh", + recorded_at: new Date().toISOString(), + }), + ), + ) +} + +function gate(mode: "check" | "swap", staged: string, live?: string) { + const args = live ? [GATE, mode, staged, live] : [GATE, mode, staged] + const proc = Bun.spawn(["bash", ...args], { stdout: "pipe", stderr: "pipe" }) + return Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]).then( + ([stdout, stderr, code]) => ({ stdout, stderr, code }), + ) +} + +const LIVE_BODY = "#!/bin/sh\necho live\n" +const CANDIDATE_BODY = "#!/bin/sh\necho candidate\n" + +describe("hub smoke gate", () => { + let dir: string + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join("/tmp", "smoke-gate-")) + }) + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + describe("refusal path (AC3)", () => { + test("a swap with no smoke record is refused and names the missing gate", async () => { + const staged = await writeBinary(dir, "staged.bin", CANDIDATE_BODY) + const live = await writeBinary(dir, "live.bin", LIVE_BODY) + + const result = await gate("swap", staged, live) + + expect(result.code).toBe(1) + expect(result.stderr).toContain("hub-upgrade-smoke") + // nothing moved + expect(await Bun.file(live).text()).toBe(LIVE_BODY) + expect(await Bun.file(staged).text()).toBe(CANDIDATE_BODY) + }) + + test("a smoke record that did not pass is refused and names the missing gate", async () => { + const staged = await writeBinary(dir, "staged.bin", CANDIDATE_BODY) + const live = await writeBinary(dir, "live.bin", LIVE_BODY) + await writeSmokeRecord(staged, { outcome: "fail" }) + + const result = await gate("swap", staged, live) + + expect(result.code).toBe(1) + expect(result.stderr).toContain("hub-upgrade-smoke") + expect(result.stderr).toContain("fail") + expect(await Bun.file(live).text()).toBe(LIVE_BODY) + }) + + test("a smoke record for a different binary (sha mismatch) is refused and names the missing gate", async () => { + const staged = await writeBinary(dir, "staged.bin", CANDIDATE_BODY) + const live = await writeBinary(dir, "live.bin", LIVE_BODY) + await writeSmokeRecord(staged, { outcome: "pass", sha256: "deadbeef".repeat(8) }) + + const result = await gate("swap", staged, live) + + expect(result.code).toBe(1) + expect(result.stderr).toContain("hub-upgrade-smoke") + expect(result.stderr).toContain("sha") + expect(await Bun.file(live).text()).toBe(LIVE_BODY) + }) + + test("check mode refuses without a passing record, same contract", async () => { + const staged = await writeBinary(dir, "staged.bin", CANDIDATE_BODY) + + const result = await gate("check", staged) + + expect(result.code).toBe(1) + expect(result.stderr).toContain("hub-upgrade-smoke") + }) + }) + + describe("proceed path (AC4)", () => { + test("a swap with a passing smoke record proceeds through the rename-only path", async () => { + const staged = await writeBinary(dir, "staged.bin", CANDIDATE_BODY) + const live = await writeBinary(dir, "live.bin", LIVE_BODY) + await writeSmokeRecord(staged, { outcome: "pass" }) + + const result = await gate("swap", staged, live) + + expect(result.code).toBe(0) + // staged was renamed over live, byte-identical + expect(await Bun.file(live).text()).toBe(CANDIDATE_BODY) + // the staged path is gone (renamed, not copied) + expect(await Bun.file(staged).exists()).toBe(false) + // the sha sidecar records the now-live binary's sha + const sidecar = await Bun.file(`${live}.sha256`).text() + expect(sidecar.trim()).toBe(await sha256File(live)) + }) + + test("the rename-only swap never leaves a running hub down", async () => { + const staged = await writeBinary(dir, "staged.bin", "#!/bin/sh\necho candidate\n") + const live = await writeBinary(dir, "live.bin", "#!/bin/sh\nwhile true; do sleep 1; done\n") + await writeSmokeRecord(staged, { outcome: "pass" }) + + // A "hub": a long-lived process executing the live binary path. + const proc = Bun.spawn([live], { stdout: "ignore", stderr: "ignore" }) + try { + await Bun.sleep(200) + expect(proc.exitCode).toBeNull() + + const result = await gate("swap", staged, live) + expect(result.code).toBe(0) + + // rename(2) over a running executable is atomic — the process kept the + // old inode and is STILL RUNNING after the swap; the hub never went down. + await Bun.sleep(200) + expect(proc.exitCode).toBeNull() + expect(proc.signalCode).toBeNull() + } finally { + proc.kill() + } + }) + + test("check mode passes for a staged binary with a matching passing record", async () => { + const staged = await writeBinary(dir, "staged.bin", CANDIDATE_BODY) + await writeSmokeRecord(staged, { outcome: "pass" }) + + const result = await gate("check", staged) + + expect(result.code).toBe(0) + expect(result.stdout).toContain("pass") + }) + }) +})