From 595ad0c26e5e9931ad1e029aba051686dca9bc98 Mon Sep 17 00:00:00 2001 From: Harry Beckwith Date: Sun, 26 Jul 2026 14:54:11 -0700 Subject: [PATCH 1/2] Verify decrees, and never strand an operator on an old version Two fixes, the first a genuine defect. 1. The silent update dead-end. Both the installer and the app updated with 'merge --ff-only', and the app swallowed the failure into a log nobody reads. Since a node that has been vibed even once has diverged, every engaged user silently stopped receiving updates forever -- security fixes included. The more someone used the product, the more stranded they became. The console now owns updating: it reports what upstream has, and replays the operator's decrees on top with a rebase. Conflicts abort cleanly and say which code upstream also touched, leaving the node exactly as it was. Verified end to end on a diverged clone: 1 commit behind with 3 decrees -> absorbed, decrees replayed in order, behind back to 0. A dirty working tree is reported as itself rather than blamed on upstream -- an interrupted decree leaves exactly that state, and sending someone hunting an imaginary conflict would be worse than saying nothing. 2. Decrees are now verified, not merely compiled. Compiling proves the code is grammatical, not that it is still a Bitcoin node. Every build is now checked in a throwaway regtest datadir -- mine 101 blocks, read the chain, confirm it still rejects impossible amounts -- before it becomes the node the operator runs. If it fails, the old binary keeps running and the console says so plainly. Found a bug in the checker itself while testing: bitcoind rejects '-port=0' outright, so the probe never started. Dropped it; -listen=0 keeps it off the network anyway. Passes in 3s on a good binary. Co-Authored-By: Claude Opus 5 --- contrib/vibes/bitcoin-vibes | 208 ++++++++++++++++++++++++++++++++++++ contrib/vibes/index.html | 35 ++++++ 2 files changed, 243 insertions(+) diff --git a/contrib/vibes/bitcoin-vibes b/contrib/vibes/bitcoin-vibes index 5eca9d64f782..637b58899191 100755 --- a/contrib/vibes/bitcoin-vibes +++ b/contrib/vibes/bitcoin-vibes @@ -25,9 +25,11 @@ import re import secrets import shlex import shutil +import socket import signal import subprocess import sys +import tempfile import threading import time import urllib.request @@ -461,6 +463,7 @@ class Console: self.git_mutex = threading.Lock() self._engine_cache = None self._discovered = None + self._upstream_cache = None self.node_proc = None self.node_log = None @@ -533,6 +536,7 @@ class Console: "job": self.job.summary() if self.job else None, "vibes": self.vibe_history(), "engine_ready": self.engine_status(), + "update": self.upstream_status(), } def find_claude(self): @@ -910,6 +914,111 @@ class Console: self._engine_cache = (now, state) return state + # ----- staying current with upstream ---------------------------------- + + def upstream_status(self, fetch=False): + """Is there a newer Bitcoin Vibes, and can this node take it? + + A node that has been vibed even once has diverged from origin, so + fast-forward is exactly the case that stops working the moment somebody + uses the product. This reports the real situation instead.""" + if fetch: + self.git("fetch", "origin", self.branch_name(), timeout=120) + self._upstream_cache = None + if self._upstream_cache and time.monotonic() - self._upstream_cache[0] < 60: + return self._upstream_cache[1] + + branch = self.branch_name() + ref = f"origin/{branch}" + code, _, _ = self.git("rev-parse", "--verify", ref) + if code != 0: + state = {"known": False} + else: + _, behind, _ = self.git("rev-list", "--count", f"HEAD..{ref}") + _, ahead, _ = self.git("rev-list", "--count", f"{ref}..HEAD") + behind_n = int(behind.strip() or 0) + ahead_n = int(ahead.strip() or 0) + _, decrees, _ = self.git("rev-list", "--count", "--grep=^vibe:", f"{ref}..HEAD") + state = {"known": True, "behind": behind_n, "ahead": ahead_n, + "decrees": int(decrees.strip() or 0), + "available": behind_n > 0, + "diverged": ahead_n > 0} + self._upstream_cache = (time.monotonic(), state) + return state + + def branch_name(self): + _, out, _ = self.git("rev-parse", "--abbrev-ref", "HEAD") + return out.strip() or "master" + + def update_runner(self, autobuild=True, autorestart=True): + """Bring in new upstream work, replaying the operator's decrees on top.""" + def go(job): + branch = self.branch_name() + job.emit(t="phase", name="update", detail="asking what has changed upstream") + st = self.upstream_status(fetch=True) + if not st.get("known"): + raise RuntimeError(f"there is no origin/{branch} to compare against") + if not st["available"]: + job.emit(t="result", detail="Already current. Upstream has nothing to " + "teach you today.") + return + + n, d = st["behind"], st["decrees"] + job.emit(t="log", line=f"{n} new commit(s) upstream; " + f"{d} decree(s) of your own to replay") + + # A rebase over uncommitted work fails for a reason that has nothing + # to do with conflicts, and blaming upstream for it would send the + # operator hunting the wrong problem. A cancelled decree can leave + # the tree in exactly this state. + dirty = self._changed_files() + if dirty: + job.state = "error" + job.emit(t="phase", name="error", + detail=f"There are {len(dirty)} uncommitted change(s) in the " + f"source — probably a decree that was interrupted. " + f"Nothing was updated. Commit them as a decree, or " + f"discard them, then update again. " + f"({', '.join(dirty[:3])}" + f"{' …' if len(dirty) > 3 else ''})") + return + + if not st["diverged"]: + code, _, err = self.git("merge", "--ff-only", f"origin/{branch}") + if code != 0: + raise RuntimeError(f"could not fast-forward: {err.strip()[:200]}") + job.emit(t="log", line="fast-forwarded cleanly") + else: + # The interesting case, and the one that used to fail silently. + job.emit(t="phase", name="rebase", + detail=f"replaying your {d} decree(s) onto the new upstream") + code, _, err = self.git("rebase", f"origin/{branch}", timeout=300) + if code != 0: + self.git("rebase", "--abort") + job.state = "error" + job.emit(t="phase", name="error", + detail="Upstream has changed the same code your decrees " + "touch, so they cannot be replayed automatically. " + "Nothing was changed — your node is exactly as it " + "was. Resolve it by hand, or issue a decree that " + "reconciles the two. " + f"({err.strip()[:180]})") + return + job.emit(t="log", line="your decrees survived, and now sit on top") + + if autobuild: + if not self._build(job): + return + if not self._smoke_test(job): + return + if autorestart: + self._restart_node(job) + self._upstream_cache = None + job.emit(t="result", + detail=f"Updated. {n} upstream commit(s) absorbed; your decrees " + f"remain law.") + return go + def vibe_history(self): code, out, _ = self.git("log", "--grep=^vibe:", "--format=%H%x1f%ct%x1f%s", "-n", "50") vibes = [] @@ -994,6 +1103,11 @@ class Console: sha = self._commit_vibe(job, prompt, files) if sha and autobuild: ok = self._build(job) + # Compiling proves the code is grammatical, not that it is a + # Bitcoin node. Before this decree becomes the node the operator + # actually runs, make the new binary prove it can still validate. + if ok: + ok = self._smoke_test(job) if ok and autorestart: self._restart_node(job) job.emit(t="result", detail=random.choice(BENEDICTIONS) if sha else @@ -1204,6 +1318,96 @@ class Console: low = max(3, 40 // max(1, jobs)) return low, low * 2 + def _smoke_test(self, job): + """Make the freshly built binary prove it is still a Bitcoin node. + + Runs against a throwaway regtest datadir, never the operator's chain, so + a decree that compiles but cannot validate is caught here rather than by + their peers.""" + bitcoind = self.bin_path("bitcoind") + cli = self.bin_path("bitcoin-cli") + if not (bitcoind and cli): + return True # nothing built to test + job.emit(t="phase", name="verify", + detail="making the new binary prove it can still validate") + + sandbox = Path(tempfile.mkdtemp(prefix="vibes-verify-")) + args = [f"-datadir={sandbox}", "-chain=regtest"] + proc = None + try: + proc = subprocess.Popen( + # -listen=0 keeps it off the network entirely, so no -port is + # needed (and bitcoind rejects -port=0 outright). + [str(bitcoind), *args, "-server=1", "-listen=0", + f"-rpcport={self._free_port()}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + + def rpc(*a): + code, out, _ = run([str(cli), *args, + f"-rpcport={self._verify_rpcport}", *a], timeout=30) + return (out.strip() if code == 0 else None) + + deadline = time.time() + 60 + while time.time() < deadline and rpc("getblockcount") is None: + if proc.poll() is not None: + job.emit(t="log", line="the new binary would not even start") + return self._smoke_failed(job, "it would not start") + time.sleep(0.5) + + checks = [] + rpc("createwallet", "verify") + addr = rpc("-rpcwallet=verify", "getnewaddress") + if not addr: + return self._smoke_failed(job, "it could not make an address") + if rpc("generatetoaddress", "101", addr) is None: + return self._smoke_failed(job, "it could not mine a block") + height = rpc("getblockcount") + checks.append(("mined 101 blocks", height == "101")) + + info = rpc("getblockchaininfo") + checks.append(("chain state readable", bool(info))) + # It must still reject nonsense: an amount above its own ceiling. + bad = run([str(cli), *args, f"-rpcport={self._verify_rpcport}", + "createrawtransaction", "[]", + f'[{{"{addr}":210000000}}]'], timeout=20)[0] + checks.append(("rejects impossible amounts", bad != 0)) + + failed = [name for name, ok in checks if not ok] + for name, ok in checks: + job.emit(t="log", line=f" {'✓' if ok else '✗'} {name}") + if failed: + return self._smoke_failed(job, "; ".join(failed)) + job.emit(t="log", line="the node still validates — proceeding") + return True + except Exception as e: # noqa: BLE001 + job.emit(t="log", line=f"could not run the check ({e}) — proceeding anyway") + return True + finally: + if proc and proc.poll() is None: + run([str(cli), *args, f"-rpcport={self._verify_rpcport}", "stop"], timeout=15) + try: + proc.wait(20) + except subprocess.TimeoutExpired: + proc.kill() + shutil.rmtree(sandbox, ignore_errors=True) + + def _free_port(self): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + self._verify_rpcport = s.getsockname()[1] + return self._verify_rpcport + + @staticmethod + def _smoke_failed(job, why): + job.state = "error" + job.emit(t="phase", name="error", + detail=f"This decree compiles, but the node it produces cannot do its " + f"job: {why}. Your node has NOT been restarted — it is still " + f"running the last binary that worked. Repent this decree, or " + f"issue another that fixes it.") + return False + def build_blocking(self): """First-run build, streamed straight to the terminal (no job/UI yet).""" jobs = str(self.build_jobs()) @@ -1549,6 +1753,10 @@ class Handler(BaseHTTPRequestHandler): job = c.start_job("revert", sha, c.revert_runner( sha, body.get("autobuild", True), body.get("autorestart", True))) self._job_reply(job) + elif path == "/api/update": + job = c.start_job("update", "", c.update_runner( + body.get("autobuild", True), body.get("autorestart", True))) + self._job_reply(job) elif path == "/api/settings": problems, clean = c.validate_settings(body.get("values") or {}) if problems: diff --git a/contrib/vibes/index.html b/contrib/vibes/index.html index 290b9a7417ca..c090d687ac94 100644 --- a/contrib/vibes/index.html +++ b/contrib/vibes/index.html @@ -104,6 +104,14 @@ .setup-step{margin-top:12px;font-family:var(--mono);font-size:13.5px;color:var(--ink-2)} body.is-setup .tablet,body.is-setup #book-section{opacity:.45;pointer-events:none} + /* ---- upstream has moved ---- */ + .update{display:flex;align-items:center;gap:16px;flex-wrap:wrap; + border:1px solid var(--gold-line);background:var(--marble-3); + border-radius:3px;padding:14px 18px;margin-bottom:20px} + .update-text{flex:1;min-width:220px;color:var(--ink);font-size:16px} + .update-text b{color:var(--gold-ink)} + .update-go{min-height:40px;padding:9px 20px;font-size:13px} + /* ---- the engine notice ---- */ .engine{border:2px solid var(--porphyry);background:#fdf6f4;border-radius:2px; padding:15px 18px;margin-bottom:20px} @@ -382,6 +390,11 @@

You are a Bitcoin God.

starting…

+ +