From 41301c3c2dd1cf7a71cd74a03645ceb151c015fe Mon Sep 17 00:00:00 2001 From: AndreasS Date: Sat, 19 Sep 2026 23:10:08 +0200 Subject: [PATCH 1/2] fix(cli): stop auto-updating over a local or dev-channel build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upgrade.ts only exempted the literal "local" channel from the self-upgrade check. A local build on any other channel — including "dev", the stable default build-local.ts now uses for every local build — was treated as a real release and compared against the latest published version. Confirmed live: a dev build's version read as needing "a patch", and the auto-updater silently curl-installed a real release over the running binary mid-session, corrupting the TUI's rendered screen with the installer's raw progress output and leaving the process spinning afterward. Replace the "local"-only check with InstallationIsRelease (channel is latest/beta/prod — the only channels a real release ever carries, already used by database.ts to pick the session-database path). Comparing a never-published build's version against "the latest release" is meaningless at best; silently replacing it mid-session is actively harmful. --- packages/core/src/database/database.ts | 4 ++-- packages/core/src/installation/version.ts | 8 ++++++++ packages/opencode/src/cli/upgrade.ts | 20 +++++++++++++------- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index 9b72f7a8632c..7679d63701af 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -7,7 +7,7 @@ import { Global } from "../global" import { Flag } from "../flag/flag" import { isAbsolute, join } from "path" import { DatabaseMigration } from "./migration" -import { InstallationChannel } from "../installation/version" +import { InstallationChannel, InstallationIsRelease } from "../installation/version" import { LayerNode } from "../effect/layer-node" import { makeGlobalNode } from "../effect/app-node" @@ -57,7 +57,7 @@ export function path() { return join(Global.Path.data, Flag.OPENCODE_DB) } if ( - ["latest", "beta", "prod"].includes(InstallationChannel) || + InstallationIsRelease || process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" || process.env.OPENCODE_DISABLE_CHANNEL_DB === "true" ) diff --git a/packages/core/src/installation/version.ts b/packages/core/src/installation/version.ts index 25d9cd99aa6c..2462c277c9b9 100644 --- a/packages/core/src/installation/version.ts +++ b/packages/core/src/installation/version.ts @@ -6,3 +6,11 @@ declare global { export const InstallationVersion = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" export const InstallationChannel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" export const InstallationLocal = InstallationChannel === "local" +// The only channels a real published release ever carries. Anything else — +// "local", "dev", or a one-off OPENCODE_CHANNEL override — is a build that +// was never uploaded anywhere, so comparing its version against the latest +// published release is meaningless and self-upgrading over it is actively +// harmful (it silently replaces a binary someone is deliberately running for +// its own changes, mid-session, with unrelated published code). +export const InstallationReleaseChannels = ["latest", "beta", "prod"] +export const InstallationIsRelease = InstallationReleaseChannels.includes(InstallationChannel) diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index 95160d310478..69c282843321 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -2,17 +2,23 @@ import { Config } from "@/config/config" import { AppRuntime } from "@/effect/app-runtime" import { Flag } from "@opencode-ai/core/flag/flag" import { Installation } from "@/installation" -import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" +import { InstallationIsRelease, InstallationVersion } from "@opencode-ai/core/installation/version" import { GlobalBus } from "@/bus/global" export async function upgrade() { - // A local dev build (working-tree dist, channel "local") must never be - // nudged toward a release build — 0.0.0-dev-* vs 1.x always reads as a - // major update. Only real installs (curl/brew/npm channels) check. + // Only a real published release (channel latest/beta/prod) has a + // meaningful version to compare against "the latest release" in the first + // place. A local or dev build — including a local build on the stable + // "dev" channel (see build-local.ts) — was never published, so the + // comparison is meaningless at best and actively harmful at worst: it can + // read as needing "a patch" and silently curl-install a real release over + // a binary someone is deliberately running for its own changes, mid-session + // (confirmed live 2026-09-19 — replaced a running dev build and corrupted + // the TUI's rendered screen with the installer's raw progress output). // OPENCODE_ALWAYS_NOTIFY_UPDATE is the explicit test hook and overrides - // this: it forces the toast even on a dev build, for verifying the - // notification path without installing a release. - if (InstallationLocal && !Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) return + // this: it forces the toast even on a non-release build, for verifying the + // notification path without installing anything. + if (!InstallationIsRelease && !Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) return const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return const method = await Installation.method() From 9baa986b8d30ad62731b1c00a4cc59740aee5f34 Mon Sep 17 00:00:00 2001 From: AndreasS Date: Sat, 19 Sep 2026 23:18:01 +0200 Subject: [PATCH 2/2] chore(fork): update upgrade.ts marker, register version.ts divergence The previous commit replaced upgrade.ts's InstallationLocal check with InstallationIsRelease, dropping the tracked marker string; updated it to match. version.ts is a newly-diverged upstream file (adds InstallationReleaseChannels/InstallationIsRelease) and needed a fresh patched entry. --- fork/manifest.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/fork/manifest.json b/fork/manifest.json index a11d63c33520..3c7139cc8dbc 100644 --- a/fork/manifest.json +++ b/fork/manifest.json @@ -346,6 +346,11 @@ "marker": "fork: 5s was not enough headroom on a mu", "note": "Fork edit to an upstream file (+11/-1 vs baseline). Marker is a string the fork's edit introduces and upstream does not contain, so a merge that reverts the edit fails fork:verify." }, + { + "file": "packages/core/src/installation/version.ts", + "marker": "InstallationReleaseChannels", + "note": "Fork edit to an upstream file. Adds InstallationIsRelease (channel is latest/beta/prod) alongside the existing InstallationLocal, shared by database.ts's session-db path and upgrade.ts's self-upgrade eligibility check so any non-release build — not just literally channel \"local\" — is treated as never having been published. Marker is a string the fork's edit introduces and upstream does not contain, so a merge that reverts the edit fails fork:verify." + }, { "file": "packages/core/src/database/sqlite.bun.ts", "marker": "fork: this DB file is shared by every co", @@ -408,8 +413,8 @@ }, { "file": "packages/opencode/src/cli/upgrade.ts", - "marker": "InstallationLocal", - "note": "Fork edit to an upstream file (+8/-1 vs baseline). Marker is a string the fork's edit introduces and upstream does not contain, so a merge that reverts the edit fails fork:verify." + "marker": "InstallationIsRelease", + "note": "Fork edit to an upstream file. Skips the self-upgrade check for any non-release channel (was only the literal \"local\" channel, which missed a local build on the fork's own \"dev\" channel — confirmed live: it read as needing a release patch and silently curl-installed over a running dev binary mid-session). Marker is a string the fork's edit introduces and upstream does not contain, so a merge that reverts the edit fails fork:verify." }, { "file": "packages/opencode/src/command/index.ts",