Files
gbrain/src/core/binary-self-update.ts
T
a57d98b813 v0.42.12.0 feat: self-upgrading gbrain — invocation-riding update check + opt-in auto-upgrade (#1798)
* feat(self-upgrade): decision/cache/snooze foundation + atomic binary self-update

Pure decideSelfUpgrade (invocation + autopilot channels), atomic untrusted
cache + escalating snooze + shared marker grammar (forged-marker rejection),
semver helpers, and real darwin-arm64/linux-x64 binary self-update
(download -> fsync -> smoke -> atomic rename; failure leaves old binary intact).
Tests incl. real-HTTP-server swap E2E.

* feat(self-upgrade): check-update cache/markers, self-upgrade command, CLI heartbeat hook

check-update gains gstack-style cache/snooze/markers + refreshUpdateCache +
exported fetchLatestRelease. New 'gbrain self-upgrade' command. cli.ts emits the
update marker on every invocation (cache-read-only hot path, detached
single-flight refresh, skip-set + recursion guard + NODE_ENV=test gate).

* feat(self-upgrade): autopilot silent channel, doctor check, runPostUpgrade setup, config + identity marker

autopilot opt-in silent channel (auto+quiet+idle, swap-only+breadcrumb+exit-relaunch)
+ installSystemd Restart=always + migrateSystemdUnitToRestartAlways. doctor
self_upgrade_health. runPostUpgrade applySelfUpgradeSetup (one-time consent +
systemd rewrite). init defaults mode=notify. config self_upgrade plane +
KNOWN_CONFIG_KEYS. get_brain_identity carries update marker.

* docs(self-upgrade): gbrain-upgrade agent skill, RESOLVER/manifest, auto-update doc reversal, HEARTBEAT

New skills/gbrain-upgrade agent flow (mirror gstack-upgrade) wired into RESOLVER +
manifest. upgrades-auto-update.md reversed to document opt-in auto + conservative
gates. HEARTBEAT self-upgrade --check-only line. llms-full regenerated.

* chore: bump version and changelog (v0.42.12.0)

Self-upgrading gbrain: invocation-riding update marker + opt-in autopilot
silent channel + real atomic binary self-update. Mirrors gstack's mechanism.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(self-upgrade): write just-upgraded-from breadcrumb + clear stale cache after upgrade

Codex ship-review P3: the CLI startup hook reads just-upgraded-from to print the
one-time JUST_UPGRADED confirmation, but nothing wrote it — dead path. runUpgrade
now writes the breadcrumb (covers full + --swap-only) and clears the update-check
cache + snooze so a now-applied 'upgrade available' marker stops nudging.

* feat(self-upgrade): surface what's-new in notify + wire agent integration (AGENTS.md, HEARTBEAT) + e2e

- self-upgrade --check-only --json now includes changelog_diff + release_url
  (export fetchChangelog); the gbrain-upgrade skill shows 3-5 what's-new bullets
  before the 4-option prompt instead of just version numbers.
- setup injects a self-upgrade marker protocol into AGENTS.md so interactive
  agents (Claude Code, Codex) act on the UPGRADE_AVAILABLE stderr marker — the
  piece that makes notify actually fire for them.
- HEARTBEAT daily beat routes through the gbrain-upgrade skill (OpenClaw/Hermes
  cron cadence); auto-mode daemons ride the autopilot tick.
- e2e: real subprocess invocation proves the marker fires (notify emits;
  off/snooze/up-to-date silent; JUST_UPGRADED fires+clears; --quiet suppresses).
  Serial test: --check-only surfaces the changelog.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:20:03 -07:00

202 lines
6.9 KiB
TypeScript

/**
* Real atomic self-update for the compiled-`binary` install method
* (v0.42 self-upgrading-gbrain wave, eng-review Finding 2 — "make the atomic
* swap claim true for the one method we own").
*
* `bun` / `bun-link` / `clawhub` delegate their swap to those package managers.
* The compiled standalone binary is the only method gbrain itself writes, so
* it's the only place we can (and now do) guarantee atomicity:
*
* resolve published asset → download to a temp sibling of the live binary →
* fsync + chmod +x → `--version` smoke test → renameSync over the live path.
*
* rename(2) over a running binary is safe on darwin/linux (the running process
* keeps the old inode; the next exec picks up the new file). Every failure
* (no asset / fetch / download / smoke / rename) leaves the OLD binary
* untouched — there is no half-written-binary brick path. Windows can't rename
* over a running .exe, and no Windows/`darwin-x64`/`linux-arm64` asset is
* published, so those degrade to notify-only via `resolvePlatformAsset`
* returning null. Trust model: TLS + GitHub, same as `gbrain upgrade` (no
* signature verification this wave — D7a TODO).
*
* Published asset matrix mirrors `.github/workflows/release.yml`:
* darwin-arm64 → gbrain-darwin-arm64
* linux-x64 → gbrain-linux-x64
*/
import { chmodSync, closeSync, fsyncSync, openSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { execFileSync } from 'node:child_process';
export interface ReleaseAsset {
name: string;
url: string;
}
export type BinarySelfUpdateReason =
| 'unsupported_platform'
| 'fetch_failed'
| 'no_asset'
| 'download_failed'
| 'smoke_failed'
| 'replace_failed';
export interface BinarySelfUpdateResult {
ok: boolean;
reason?: BinarySelfUpdateReason;
error?: string;
/** Asset name resolved (when applicable). */
asset?: string;
}
/** The release asset basename gbrain publishes for this platform/arch, or null
* when no asset is published (degrade to notify-only). */
export function expectedAssetName(platform: NodeJS.Platform, arch: NodeJS.Architecture): string | null {
if (platform === 'darwin' && arch === 'arm64') return 'gbrain-darwin-arm64';
if (platform === 'linux' && arch === 'x64') return 'gbrain-linux-x64';
return null;
}
/** Pick the download URL for this platform/arch from a release's asset list. */
export function resolvePlatformAsset(
assets: ReleaseAsset[],
platform: NodeJS.Platform = process.platform,
arch: NodeJS.Architecture = process.arch,
): string | null {
const name = expectedAssetName(platform, arch);
if (!name) return null;
const match = assets.find((a) => a.name === name);
return match?.url ?? null;
}
export interface BinarySelfUpdateDeps {
/** Fetch the latest release's tag + asset list. Default hits the GitHub API. */
fetchRelease?: () => Promise<{ tag: string; assets: ReleaseAsset[] } | null>;
/** Download `url` to `destPath`. Default streams the HTTP body to disk. */
download?: (url: string, destPath: string) => Promise<void>;
/** Smoke-test the staged binary; returns true if `<path> --version` looks like gbrain. */
smoke?: (stagedPath: string) => boolean;
platform?: NodeJS.Platform;
arch?: NodeJS.Architecture;
}
async function defaultFetchRelease(): Promise<{ tag: string; assets: ReleaseAsset[] } | null> {
try {
const res = await fetch('https://api.github.com/repos/garrytan/gbrain/releases/latest', {
headers: { 'User-Agent': 'gbrain-self-upgrade' },
signal: AbortSignal.timeout(5_000),
});
if (!res.ok) return null;
const data = (await res.json()) as any;
const assets: ReleaseAsset[] = Array.isArray(data.assets)
? data.assets.map((a: any) => ({ name: String(a.name ?? ''), url: String(a.browser_download_url ?? '') }))
: [];
return { tag: String(data.tag_name ?? ''), assets };
} catch {
return null;
}
}
async function defaultDownload(url: string, destPath: string): Promise<void> {
const res = await fetch(url, {
headers: { 'User-Agent': 'gbrain-self-upgrade' },
redirect: 'follow',
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) throw new Error(`download HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length === 0) throw new Error('downloaded asset is empty');
writeFileSync(destPath, buf);
// fsync so a crash between write and rename can't leave a torn file.
const fd = openSync(destPath, 'r');
try {
fsyncSync(fd);
} finally {
closeSync(fd);
}
}
function defaultSmoke(stagedPath: string): boolean {
try {
const out = execFileSync(stagedPath, ['--version'], { encoding: 'utf-8', timeout: 10_000 });
return /gbrain\s/i.test(out);
} catch {
return false;
}
}
let _tmpCounter = 0;
/**
* Perform a real atomic self-update of the binary at `targetPath` (defaults to
* the running binary, `process.execPath`). Returns a tagged result; never
* throws. On any failure the original binary is left untouched.
*/
export async function runBinarySelfUpdate(
targetPath: string = process.execPath,
deps: BinarySelfUpdateDeps = {},
): Promise<BinarySelfUpdateResult> {
const platform = deps.platform ?? process.platform;
const arch = deps.arch ?? process.arch;
const fetchRelease = deps.fetchRelease ?? defaultFetchRelease;
const download = deps.download ?? defaultDownload;
const smoke = deps.smoke ?? defaultSmoke;
const assetName = expectedAssetName(platform, arch);
if (!assetName) {
return { ok: false, reason: 'unsupported_platform' };
}
const release = await fetchRelease();
if (!release) {
return { ok: false, reason: 'fetch_failed', asset: assetName };
}
const url = resolvePlatformAsset(release.assets, platform, arch);
if (!url) {
return { ok: false, reason: 'no_asset', asset: assetName };
}
// Stage in a temp sibling so the rename is same-filesystem (atomic).
const staged = join(dirname(targetPath), `.${assetName}.tmp.${process.pid}.${_tmpCounter++}`);
try {
await download(url, staged);
} catch (e) {
safeUnlink(staged);
return { ok: false, reason: 'download_failed', error: errMsg(e), asset: assetName };
}
try {
chmodSync(staged, 0o755);
} catch (e) {
safeUnlink(staged);
return { ok: false, reason: 'download_failed', error: errMsg(e), asset: assetName };
}
if (!smoke(staged)) {
safeUnlink(staged);
return { ok: false, reason: 'smoke_failed', asset: assetName };
}
try {
renameSync(staged, targetPath); // atomic on same fs; old binary intact if this throws
} catch (e) {
safeUnlink(staged);
return { ok: false, reason: 'replace_failed', error: errMsg(e), asset: assetName };
}
return { ok: true, asset: assetName };
}
function safeUnlink(path: string): void {
try {
unlinkSync(path);
} catch {
/* already gone */
}
}
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}