Skip to content
Draft
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
5 changes: 5 additions & 0 deletions packages/core/src/installation/version.ts
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 4 additions & 0 deletions packages/opencode/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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") } : {}),
},
Expand Down
88 changes: 88 additions & 0 deletions packages/opencode/script/hub-smoke-gate.sh
Original file line number Diff line number Diff line change
@@ -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 —
#
# <staged>.smoke.json {"outcome":"pass","sha256":"<sha of staged>",
# "harness":"hub-upgrade-smoke.sh","recorded_at":"<iso>"}
#
# — and this gate is the reader every swap goes through:
#
# hub-smoke-gate.sh check <staged-binary>
# exit 0 = a passing, sha-matching smoke record exists
# exit 1 = refused (the refusal NAMES the missing gate)
# hub-smoke-gate.sh swap <staged-binary> <live-binary>
# 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 <staged-binary> | swap <staged-binary> <live-binary>"; 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)"
4 changes: 4 additions & 0 deletions packages/opencode/src/cli/cmd/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>",
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"))
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/cli/cmd/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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}`)
Expand Down
98 changes: 98 additions & 0 deletions packages/opencode/src/installation/parity.ts
Original file line number Diff line number Diff line change
@@ -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<Outcome>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/InstallationParity") {}

export const use = serviceUse(Service)

const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = 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"
69 changes: 69 additions & 0 deletions packages/opencode/test/cli/build-parity.test.ts
Original file line number Diff line number Diff line change
@@ -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,
)
})
Loading
Loading