mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 19:01:39 +00:00
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.
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Self-upgrade audit trail (v0.42). One JSONL line per self-upgrade decision /
|
||||
* outcome at `~/.gbrain/audit/self-upgrade-YYYY-Www.jsonl` (honors
|
||||
* GBRAIN_AUDIT_DIR). Built on the shared `audit-writer` primitive. Read back by
|
||||
* `gbrain doctor`'s `self_upgrade_health` check. Best-effort: never throws.
|
||||
*
|
||||
* Privacy: records only versions + outcome + reason. No paths, no content.
|
||||
*/
|
||||
|
||||
import { createAuditWriter } from './audit-writer.ts';
|
||||
|
||||
export interface SelfUpgradeAuditEvent {
|
||||
ts: string;
|
||||
/** Which channel made the decision. */
|
||||
channel: 'invocation' | 'autopilot';
|
||||
/** The SelfUpgradeAction (`apply` / `notify` / `busy` / ...). */
|
||||
action: string;
|
||||
current: string;
|
||||
latest?: string | null;
|
||||
/** Terminal outcome when an apply was attempted. */
|
||||
outcome?: 'applied' | 'failed' | 'skipped';
|
||||
reason?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const writer = createAuditWriter<SelfUpgradeAuditEvent>({
|
||||
featureName: 'self-upgrade',
|
||||
errorLabel: 'self-upgrade-audit',
|
||||
errorTrailer: '; continuing',
|
||||
});
|
||||
|
||||
export function logSelfUpgrade(event: Omit<SelfUpgradeAuditEvent, 'ts'> & { ts?: string }): void {
|
||||
writer.log(event);
|
||||
}
|
||||
|
||||
export function readRecentSelfUpgrades(days = 7, now?: Date): SelfUpgradeAuditEvent[] {
|
||||
return writer.readRecent(days, now);
|
||||
}
|
||||
|
||||
export function selfUpgradeAuditFilename(now?: Date): string {
|
||||
return writer.computeFilename(now);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* Self-upgrade decision + state foundation (v0.42 self-upgrading-gbrain wave).
|
||||
*
|
||||
* Mirrors gstack's invocation-riding update mechanism for gbrain: a throttled
|
||||
* check that rides every `gbrain` invocation (CLI / MCP), emits a marker, and
|
||||
* either prompts (mode=notify) or silently upgrades (mode=auto, opt-in). A
|
||||
* second silent channel lives in the autopilot daemon. Both channels share the
|
||||
* cache + snooze + lock state defined here so they can never double-upgrade.
|
||||
*
|
||||
* This module is the PURE + state foundation:
|
||||
* - `decideSelfUpgrade()` — pure decision over already-resolved inputs.
|
||||
* - cache helpers — atomic temp+rename writes, strict parse, mtime-TTL.
|
||||
* - snooze helpers — escalating 24h/48h/7d, version-reset.
|
||||
* - `formatMarker` / `parseMarker` — ONE marker grammar shared by the CLI
|
||||
* emit, the MCP emit, and the agent skill (no drift; forged markers
|
||||
* rejected via the version regex).
|
||||
*
|
||||
* Hot-path contract: the CLI startup read is cache-read-only (statSync + read,
|
||||
* sub-ms). Network refresh is detached + single-flighted; it NEVER blocks a
|
||||
* command. The version string is regex-validated AND monotonic-checked before
|
||||
* it reaches the agent's context (a malicious brain page / MCP response can
|
||||
* neither forge a "downgrade-as-upgrade" nor change the action — the action is
|
||||
* always the hardcoded `gbrain upgrade` / `gbrain self-upgrade`).
|
||||
*
|
||||
* NO DB. The hot path runs before `connectEngine()` and thin clients have no
|
||||
* local DB, so all state is file-based under `~/.gbrain/` (honors GBRAIN_HOME).
|
||||
*/
|
||||
|
||||
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { gbrainPath } from './config.ts';
|
||||
import { acquirePackLock, type PackLockOpts } from './schema-pack/pack-lock.ts';
|
||||
import { isMinorOrMajorBump, isValidVersionString, parseSemver, semverGt, semverLte } from './semver.ts';
|
||||
|
||||
// ── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Cache freshness: short when up-to-date (detect releases fast), long when an
|
||||
* upgrade is already pending (don't re-fetch on every invocation). Mirrors
|
||||
* gstack (60min / 12h). */
|
||||
export const CACHE_TTL_UP_TO_DATE_MS = 60 * 60 * 1000;
|
||||
export const CACHE_TTL_UPGRADE_AVAILABLE_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
/** Auto channel only checks once per this interval. */
|
||||
export const AUTO_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Escalating snooze durations by level (gstack-style). Level >=3 caps at 7d. */
|
||||
export const SNOOZE_DURATIONS_MS = [
|
||||
24 * 60 * 60 * 1000, // level 1 → 24h
|
||||
48 * 60 * 60 * 1000, // level 2 → 48h
|
||||
7 * 24 * 60 * 60 * 1000, // level 3+ → 7d
|
||||
] as const;
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SelfUpgradeMode = 'auto' | 'notify' | 'off';
|
||||
|
||||
export type SelfUpgradeChannel = 'invocation' | 'autopilot';
|
||||
|
||||
/**
|
||||
* The decision outcome. `apply` = silently run the upgrade (autopilot/auto
|
||||
* only). `notify` = surface the marker / 4-option prompt. Everything else is a
|
||||
* no-op with a named reason (for the audit trail + doctor).
|
||||
*/
|
||||
export type SelfUpgradeAction =
|
||||
| 'off'
|
||||
| 'not_behind'
|
||||
| 'downgrade_or_yanked'
|
||||
| 'known_bad'
|
||||
| 'throttled'
|
||||
| 'busy'
|
||||
| 'outside_quiet_hours'
|
||||
| 'unsupported_install'
|
||||
| 'notify'
|
||||
| 'apply';
|
||||
|
||||
export interface SelfUpgradeDecision {
|
||||
action: SelfUpgradeAction;
|
||||
reason: string;
|
||||
current: string;
|
||||
latest: string | null;
|
||||
}
|
||||
|
||||
export interface DecideSelfUpgradeInputs {
|
||||
mode: SelfUpgradeMode;
|
||||
currentVersion: string;
|
||||
/** Latest known version (from cache/marker). null when unknown → fail-open. */
|
||||
latestVersion: string | null;
|
||||
/** Versions that failed a prior auto-upgrade (never auto-retried). */
|
||||
failedVersions: string[];
|
||||
channel: SelfUpgradeChannel;
|
||||
// autopilot-channel gates (ignored for invocation):
|
||||
idle?: boolean;
|
||||
inQuietHours?: boolean;
|
||||
canSelfUpdate?: boolean;
|
||||
/** True when the last auto-check was < AUTO_CHECK_INTERVAL_MS ago. */
|
||||
throttledByInterval?: boolean;
|
||||
// invocation-channel gate (ignored for autopilot):
|
||||
/** True when an unexpired snooze covers `latestVersion`. */
|
||||
snoozed?: boolean;
|
||||
}
|
||||
|
||||
export type MarkerKind = 'up_to_date' | 'upgrade_available';
|
||||
|
||||
export interface UpdateMarker {
|
||||
kind: MarkerKind;
|
||||
current: string;
|
||||
/** Present only when kind === 'upgrade_available'. */
|
||||
latest?: string;
|
||||
}
|
||||
|
||||
export interface SnoozeRecord {
|
||||
version: string;
|
||||
level: number;
|
||||
/** Epoch ms when the snooze was written. */
|
||||
ts: number;
|
||||
}
|
||||
|
||||
// ── Pure decision ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Decide what to do about a possible upgrade. Pure: all I/O-derived inputs are
|
||||
* resolved by the caller. The version comparison is monotonic — we only ever
|
||||
* act when `latest` is a real minor/major bump strictly greater than `current`,
|
||||
* so a downgrade / yanked / prerelease-local-build can never trigger an upgrade.
|
||||
*/
|
||||
export function decideSelfUpgrade(inp: DecideSelfUpgradeInputs): SelfUpgradeDecision {
|
||||
const base = { current: inp.currentVersion, latest: inp.latestVersion };
|
||||
|
||||
if (inp.mode === 'off') {
|
||||
return { action: 'off', reason: 'self_upgrade.mode=off', ...base };
|
||||
}
|
||||
|
||||
if (!inp.latestVersion || !isValidVersionString(inp.latestVersion)) {
|
||||
return { action: 'not_behind', reason: 'latest version unknown or invalid', ...base };
|
||||
}
|
||||
|
||||
const cur = parseSemver(inp.currentVersion);
|
||||
const lat = parseSemver(inp.latestVersion);
|
||||
if (!cur || !lat) {
|
||||
return { action: 'not_behind', reason: 'unparseable version', ...base };
|
||||
}
|
||||
|
||||
if (semverLte(lat, cur)) {
|
||||
// Equal → up to date; strictly-less → a downgrade / yanked release. Never act.
|
||||
if (semverGt(cur, lat)) {
|
||||
return { action: 'downgrade_or_yanked', reason: `latest ${inp.latestVersion} < current ${inp.currentVersion}`, ...base };
|
||||
}
|
||||
return { action: 'not_behind', reason: 'already current', ...base };
|
||||
}
|
||||
|
||||
if (!isMinorOrMajorBump(inp.currentVersion, inp.latestVersion)) {
|
||||
return { action: 'not_behind', reason: 'patch/micro bump only (ignored)', ...base };
|
||||
}
|
||||
|
||||
if (inp.failedVersions.includes(inp.latestVersion)) {
|
||||
return { action: 'known_bad', reason: `${inp.latestVersion} previously failed; not retrying`, ...base };
|
||||
}
|
||||
|
||||
// Genuinely behind by a minor/major bump and not known-bad.
|
||||
if (inp.channel === 'invocation') {
|
||||
if (inp.snoozed) {
|
||||
return { action: 'throttled', reason: 'snoozed for this version', ...base };
|
||||
}
|
||||
return { action: 'notify', reason: `update available: ${inp.currentVersion} -> ${inp.latestVersion}`, ...base };
|
||||
}
|
||||
|
||||
// autopilot channel (silent auto)
|
||||
if (inp.throttledByInterval) {
|
||||
return { action: 'throttled', reason: 'auto-check ran within 24h', ...base };
|
||||
}
|
||||
if (!inp.idle) {
|
||||
return { action: 'busy', reason: 'brain not idle', ...base };
|
||||
}
|
||||
if (!inp.inQuietHours) {
|
||||
return { action: 'outside_quiet_hours', reason: 'outside quiet hours', ...base };
|
||||
}
|
||||
if (!inp.canSelfUpdate) {
|
||||
return { action: 'unsupported_install', reason: 'install method cannot self-update', ...base };
|
||||
}
|
||||
return { action: 'apply', reason: `auto-upgrading ${inp.currentVersion} -> ${inp.latestVersion}`, ...base };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this install method + platform/arch can apply an upgrade unattended.
|
||||
* `bun` / `bun-link` / `clawhub` delegate to their package managers; `binary`
|
||||
* self-updates via atomic rename ONLY where a release asset is published —
|
||||
* today that's darwin-arm64 and linux-x64 (see `.github/workflows/release.yml`).
|
||||
* Other binary platform/arch combos (darwin-x64, linux-arm64, win32) have no
|
||||
* asset and stay notify-only. `unknown` cannot self-update.
|
||||
*/
|
||||
export function canSelfUpdate(
|
||||
installMethod: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: NodeJS.Architecture = process.arch,
|
||||
): boolean {
|
||||
switch (installMethod) {
|
||||
case 'bun':
|
||||
case 'bun-link':
|
||||
case 'clawhub':
|
||||
return true;
|
||||
case 'binary':
|
||||
return (platform === 'darwin' && arch === 'arm64') || (platform === 'linux' && arch === 'x64');
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Marker grammar (shared by CLI + MCP + agent skill) ───────────────────────
|
||||
|
||||
/** Serialize a marker line. The cache file content IS this string. */
|
||||
export function formatMarker(m: UpdateMarker): string {
|
||||
if (m.kind === 'upgrade_available' && m.latest) {
|
||||
return `UPGRADE_AVAILABLE ${m.current} ${m.latest}`;
|
||||
}
|
||||
return `UP_TO_DATE ${m.current}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a marker line. Strict: both versions must pass the version regex, or we
|
||||
* return null. This is the forged-marker guard — a malicious string can't smuggle
|
||||
* a non-version token (or a command) into the agent's context as an "upgrade".
|
||||
*/
|
||||
export function parseMarker(line: string): UpdateMarker | null {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts[0] === 'UP_TO_DATE' && parts.length === 2 && isValidVersionString(parts[1])) {
|
||||
return { kind: 'up_to_date', current: parts[1] };
|
||||
}
|
||||
if (
|
||||
parts[0] === 'UPGRADE_AVAILABLE' &&
|
||||
parts.length === 3 &&
|
||||
isValidVersionString(parts[1]) &&
|
||||
isValidVersionString(parts[2])
|
||||
) {
|
||||
return { kind: 'upgrade_available', current: parts[1], latest: parts[2] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── State file paths ────────────────────────────────────────────────────────
|
||||
|
||||
export function updateCachePath(): string {
|
||||
return gbrainPath('last-update-check');
|
||||
}
|
||||
|
||||
export function snoozePath(): string {
|
||||
return gbrainPath('update-snoozed');
|
||||
}
|
||||
|
||||
export function justUpgradedPath(): string {
|
||||
return gbrainPath('just-upgraded-from');
|
||||
}
|
||||
|
||||
/** Directory for the self-upgrade + refresh single-flight locks. */
|
||||
export function locksDir(): string {
|
||||
return gbrainPath('.locks');
|
||||
}
|
||||
|
||||
// ── Cache (untrusted local state: atomic write, strict parse, mtime-TTL) ─────
|
||||
|
||||
export interface CacheEntry {
|
||||
marker: UpdateMarker;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
let _tmpCounter = 0;
|
||||
function atomicWrite(path: string, content: string): void {
|
||||
const dir = dirname(path);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${path}.tmp.${process.pid}.${_tmpCounter++}`;
|
||||
const fd = openSync(tmp, 'w', 0o600);
|
||||
try {
|
||||
writeFileSync(fd, content);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
/** Read + strict-parse the cache. Returns null on missing / corrupt. */
|
||||
export function readUpdateCache(): CacheEntry | null {
|
||||
const path = updateCachePath();
|
||||
let content: string;
|
||||
let mtimeMs: number;
|
||||
try {
|
||||
content = readFileSync(path, 'utf8');
|
||||
mtimeMs = statSync(path).mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const marker = parseMarker(content);
|
||||
if (!marker) return null;
|
||||
return { marker, mtimeMs };
|
||||
}
|
||||
|
||||
/** Atomically write the cache (marker line, 0600). Best-effort: throws only on
|
||||
* truly unexpected fs errors (callers in the detached refresh swallow). */
|
||||
export function writeUpdateCache(marker: UpdateMarker): void {
|
||||
atomicWrite(updateCachePath(), formatMarker(marker) + '\n');
|
||||
}
|
||||
|
||||
/** Clear the cache (e.g. after a successful upgrade) so the next run re-checks. */
|
||||
export function clearUpdateCache(): void {
|
||||
try {
|
||||
unlinkSync(updateCachePath());
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
export function isCacheFresh(entry: CacheEntry, now: number): boolean {
|
||||
const ttl = entry.marker.kind === 'upgrade_available' ? CACHE_TTL_UPGRADE_AVAILABLE_MS : CACHE_TTL_UP_TO_DATE_MS;
|
||||
return now - entry.mtimeMs < ttl;
|
||||
}
|
||||
|
||||
// ── Snooze (interactive prompting only; never overrides mode=off) ────────────
|
||||
|
||||
export function readSnooze(): SnoozeRecord | null {
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(snoozePath(), 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const parts = content.trim().split(/\s+/);
|
||||
if (parts.length !== 3) return null;
|
||||
const version = parts[0];
|
||||
const level = Number(parts[1]);
|
||||
const ts = Number(parts[2]);
|
||||
if (!isValidVersionString(version) || !Number.isInteger(level) || level < 1 || !Number.isFinite(ts)) {
|
||||
return null;
|
||||
}
|
||||
return { version, level, ts };
|
||||
}
|
||||
|
||||
/** Snooze duration for a level (1-indexed; >=3 caps at 7d). */
|
||||
export function snoozeDurationMs(level: number): number {
|
||||
const idx = Math.min(Math.max(level, 1), SNOOZE_DURATIONS_MS.length) - 1;
|
||||
return SNOOZE_DURATIONS_MS[idx];
|
||||
}
|
||||
|
||||
/** True iff an unexpired snooze covers `latestVersion`. A snooze for a
|
||||
* different (older) version never suppresses a newer one (version-reset). */
|
||||
export function isSnoozeActive(snooze: SnoozeRecord | null, latestVersion: string, now: number): boolean {
|
||||
if (!snooze) return false;
|
||||
if (snooze.version !== latestVersion) return false;
|
||||
return now - snooze.ts < snoozeDurationMs(snooze.level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record (or escalate) a snooze for `latestVersion`. If the existing snooze is
|
||||
* for the same version, escalate its level (capped); otherwise start at level 1.
|
||||
* Returns the new level.
|
||||
*/
|
||||
export function writeSnooze(latestVersion: string, now: number): number {
|
||||
const existing = readSnooze();
|
||||
let level = 1;
|
||||
if (existing && existing.version === latestVersion) {
|
||||
level = Math.min(existing.level + 1, SNOOZE_DURATIONS_MS.length);
|
||||
}
|
||||
atomicWrite(snoozePath(), `${latestVersion} ${level} ${now}\n`);
|
||||
return level;
|
||||
}
|
||||
|
||||
export function clearSnooze(): void {
|
||||
try {
|
||||
unlinkSync(snoozePath());
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
// ── Refresh single-flight (anti-stampede) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Try to acquire the short-lived refresh lock so only ONE detached refresh runs
|
||||
* when many invocations see a stale cache at once. Returns the lock path on
|
||||
* success (caller must release it), or null if another process holds it.
|
||||
* Separate from the upgrade mutex.
|
||||
*/
|
||||
export function tryAcquireRefreshLock(opts?: Pick<PackLockOpts, 'now' | 'isPidAlive'>): string | null {
|
||||
try {
|
||||
const { lockPath } = acquirePackLock('update-refresh', {
|
||||
lockDir: locksDir(),
|
||||
ttlMs: 30_000,
|
||||
...opts,
|
||||
});
|
||||
return lockPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseRefreshLock(lockPath: string): void {
|
||||
try {
|
||||
unlinkSync(lockPath);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
// ── Breadcrumb reconciliation (attribution for crash-on-launch) ──────────────
|
||||
|
||||
/** The mutable slice of `config.self_upgrade` the autopilot channel manages. */
|
||||
export interface SelfUpgradeState {
|
||||
mode?: SelfUpgradeMode;
|
||||
mode_prompted?: boolean;
|
||||
quiet_hours?: { start?: number; end?: number; tz?: string };
|
||||
failed_versions?: string[];
|
||||
attempting_version?: string;
|
||||
last_check_ts?: number;
|
||||
last_applied_version?: string;
|
||||
}
|
||||
|
||||
export type BreadcrumbTransition = 'applied' | 'failed' | null;
|
||||
|
||||
/**
|
||||
* Reconcile the pre-swap breadcrumb at daemon boot (the post-swap "doctor gate"
|
||||
* via attribution). Called by the relaunched binary:
|
||||
*
|
||||
* - No breadcrumb → nothing to do.
|
||||
* - breadcrumb === currentVersion → the swap+relaunch worked. Clear it and
|
||||
* record `last_applied_version`. (`applied`)
|
||||
* - breadcrumb !== currentVersion → we're NOT the attempted version (the new
|
||||
* binary crashed on launch and the supervisor relaunched the old one, or a
|
||||
* stale breadcrumb). Record the attempted version in `failed_versions` so
|
||||
* `decideSelfUpgrade` never retries it, and clear the breadcrumb. (`failed`)
|
||||
*
|
||||
* Pure: returns the next state + the transition; the caller persists + audits.
|
||||
*/
|
||||
export function reconcileBreadcrumb(
|
||||
su: SelfUpgradeState | undefined,
|
||||
currentVersion: string,
|
||||
): { state: SelfUpgradeState; transition: BreadcrumbTransition } {
|
||||
const state: SelfUpgradeState = { ...(su ?? {}) };
|
||||
const attempting = state.attempting_version;
|
||||
if (!attempting) return { state, transition: null };
|
||||
|
||||
if (attempting === currentVersion) {
|
||||
delete state.attempting_version;
|
||||
state.last_applied_version = currentVersion;
|
||||
return { state, transition: 'applied' };
|
||||
}
|
||||
|
||||
const failed = new Set(state.failed_versions ?? []);
|
||||
failed.add(attempting);
|
||||
state.failed_versions = [...failed];
|
||||
delete state.attempting_version;
|
||||
return { state, transition: 'failed' };
|
||||
}
|
||||
|
||||
// ── Mode resolution (file plane; no DB on the hot path) ──────────────────────
|
||||
|
||||
function normalizeMode(raw: unknown): SelfUpgradeMode | null {
|
||||
if (raw === 'auto' || raw === 'notify' || raw === 'off') return raw;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective mode from env > file-plane config > default `notify`.
|
||||
* Takes a loosely-typed config so it doesn't pull the full GBrainConfig type
|
||||
* onto the hot path. Env (`GBRAIN_SELF_UPGRADE_MODE`) is the operator / CI
|
||||
* escape hatch.
|
||||
*/
|
||||
export function resolveSelfUpgradeMode(
|
||||
cfg: { self_upgrade?: { mode?: string } } | null | undefined,
|
||||
): SelfUpgradeMode {
|
||||
const env = normalizeMode(process.env.GBRAIN_SELF_UPGRADE_MODE);
|
||||
if (env) return env;
|
||||
const fromCfg = normalizeMode(cfg?.self_upgrade?.mode);
|
||||
if (fromCfg) return fromCfg;
|
||||
return 'notify';
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Tiny semver comparison helpers, extracted from `check-update.ts` so both
|
||||
* the update-check path and the new self-upgrade decision module
|
||||
* (`src/core/self-upgrade.ts`) can depend on them without an import cycle
|
||||
* (self-upgrade ← check-update would cycle once check-update imports the
|
||||
* cache helpers back from self-upgrade). `check-update.ts` re-exports
|
||||
* `parseSemver` / `isMinorOrMajorBump` for back-compat with existing importers.
|
||||
*
|
||||
* Supports both 3-segment (`0.41.38`) and 4-segment (`0.42.3.0`) gbrain
|
||||
* version strings. The 4th `.MICRO` segment is gbrain's dot-suffix
|
||||
* follow-up channel; comparisons use it as a 4th ordering key.
|
||||
*/
|
||||
|
||||
/** A parsed version tuple (major, minor, patch). The 4th `.MICRO` segment is
|
||||
* deliberately NOT compared — micro bumps collapse to "equal" with the patch,
|
||||
* which is the desired "ignored" behavior for the self-upgrade decision (we
|
||||
* only ever act on minor/major bumps). Kept 3-wide for back-compat with
|
||||
* existing `parseSemver` callers/tests. */
|
||||
export type SemverTuple = [number, number, number];
|
||||
|
||||
/** Strict shape gate for a remote version string before it reaches the agent.
|
||||
* Accepts both 3-segment (`0.41.38`) and 4-segment (`0.42.3.0`) gbrain versions. */
|
||||
export const VERSION_RE = /^\d+\.\d+(?:\.\d+){0,2}$/;
|
||||
|
||||
/** True iff `v` (optionally `v`-prefixed) is a plain numeric dotted version. */
|
||||
export function isValidVersionString(v: string): boolean {
|
||||
return VERSION_RE.test(v.replace(/^v/, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a version string into a (major, minor, patch) tuple. Returns null on
|
||||
* any non-numeric or too-short input. Accepts a leading `v`. A 4th `.MICRO`
|
||||
* segment is accepted by the shape gate but truncated here.
|
||||
*/
|
||||
export function parseSemver(v: string): SemverTuple | null {
|
||||
const clean = v.replace(/^v/, '');
|
||||
const parts = clean.split('.');
|
||||
if (parts.length < 3) return null;
|
||||
const nums = parts.slice(0, 3).map(Number);
|
||||
if (nums.some((n) => !Number.isFinite(n))) return null;
|
||||
return [nums[0], nums[1], nums[2]];
|
||||
}
|
||||
|
||||
/** Strict greater-than over the tuple. */
|
||||
export function semverGt(a: SemverTuple, b: SemverTuple): boolean {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a[i] !== b[i]) return a[i] > b[i];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** a <= b. */
|
||||
export function semverLte(a: SemverTuple, b: SemverTuple): boolean {
|
||||
return !semverGt(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `latest` is a minor or major bump over `current` (patch / micro
|
||||
* bumps are deliberately ignored, matching `gbrain check-update`'s
|
||||
* established posture — patch noise should not nag every invocation).
|
||||
* Unparseable inputs are treated as "not a bump" (fail-open to up-to-date).
|
||||
*/
|
||||
export function isMinorOrMajorBump(current: string, latest: string): boolean {
|
||||
const cur = parseSemver(current);
|
||||
const lat = parseSemver(latest);
|
||||
if (!cur || !lat) return false;
|
||||
if (lat[0] > cur[0]) return true;
|
||||
if (lat[0] === cur[0] && lat[1] > cur[1]) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
expectedAssetName,
|
||||
resolvePlatformAsset,
|
||||
runBinarySelfUpdate,
|
||||
type ReleaseAsset,
|
||||
} from '../src/core/binary-self-update.ts';
|
||||
|
||||
const ASSETS: ReleaseAsset[] = [
|
||||
{ name: 'gbrain-darwin-arm64', url: 'https://example.com/darwin-arm64' },
|
||||
{ name: 'gbrain-linux-x64', url: 'https://example.com/linux-x64' },
|
||||
];
|
||||
|
||||
describe('expectedAssetName / resolvePlatformAsset', () => {
|
||||
test('maps the two published targets', () => {
|
||||
expect(expectedAssetName('darwin', 'arm64')).toBe('gbrain-darwin-arm64');
|
||||
expect(expectedAssetName('linux', 'x64')).toBe('gbrain-linux-x64');
|
||||
});
|
||||
test('null for unpublished platform/arch', () => {
|
||||
expect(expectedAssetName('darwin', 'x64')).toBeNull();
|
||||
expect(expectedAssetName('linux', 'arm64')).toBeNull();
|
||||
expect(expectedAssetName('win32', 'x64')).toBeNull();
|
||||
});
|
||||
test('resolvePlatformAsset returns the matching URL or null', () => {
|
||||
expect(resolvePlatformAsset(ASSETS, 'darwin', 'arm64')).toBe('https://example.com/darwin-arm64');
|
||||
expect(resolvePlatformAsset(ASSETS, 'linux', 'x64')).toBe('https://example.com/linux-x64');
|
||||
expect(resolvePlatformAsset(ASSETS, 'win32', 'x64')).toBeNull();
|
||||
expect(resolvePlatformAsset([], 'darwin', 'arm64')).toBeNull(); // asset missing from release
|
||||
});
|
||||
});
|
||||
|
||||
async function withTmp<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-binup-'));
|
||||
try {
|
||||
return await fn(dir);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('runBinarySelfUpdate', () => {
|
||||
test('happy path: stages, smokes, atomically replaces the target', async () => {
|
||||
await withTmp(async (dir) => {
|
||||
const target = join(dir, 'gbrain');
|
||||
writeFileSync(target, 'OLD BINARY');
|
||||
const res = await runBinarySelfUpdate(target, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
|
||||
download: async (_url, dest) => writeFileSync(dest, 'NEW BINARY'),
|
||||
smoke: () => true,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.asset).toBe('gbrain-darwin-arm64');
|
||||
expect(readFileSync(target, 'utf8')).toBe('NEW BINARY');
|
||||
});
|
||||
});
|
||||
|
||||
test('unsupported platform → unsupported_platform, target untouched', async () => {
|
||||
await withTmp(async (dir) => {
|
||||
const target = join(dir, 'gbrain.exe');
|
||||
writeFileSync(target, 'OLD');
|
||||
const res = await runBinarySelfUpdate(target, {
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe('unsupported_platform');
|
||||
expect(readFileSync(target, 'utf8')).toBe('OLD');
|
||||
});
|
||||
});
|
||||
|
||||
test('fetch failure → fetch_failed, target untouched', async () => {
|
||||
await withTmp(async (dir) => {
|
||||
const target = join(dir, 'gbrain');
|
||||
writeFileSync(target, 'OLD');
|
||||
const res = await runBinarySelfUpdate(target, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
fetchRelease: async () => null,
|
||||
});
|
||||
expect(res.reason).toBe('fetch_failed');
|
||||
expect(readFileSync(target, 'utf8')).toBe('OLD');
|
||||
});
|
||||
});
|
||||
|
||||
test('asset missing from release → no_asset, target untouched', async () => {
|
||||
await withTmp(async (dir) => {
|
||||
const target = join(dir, 'gbrain');
|
||||
writeFileSync(target, 'OLD');
|
||||
const res = await runBinarySelfUpdate(target, {
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
fetchRelease: async () => ({ tag: 'v9.9.9', assets: [] }),
|
||||
});
|
||||
expect(res.reason).toBe('no_asset');
|
||||
expect(readFileSync(target, 'utf8')).toBe('OLD');
|
||||
});
|
||||
});
|
||||
|
||||
test('download throws → download_failed, no staged leftover, target untouched', async () => {
|
||||
await withTmp(async (dir) => {
|
||||
const target = join(dir, 'gbrain');
|
||||
writeFileSync(target, 'OLD');
|
||||
const res = await runBinarySelfUpdate(target, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
|
||||
download: async () => {
|
||||
throw new Error('disk full');
|
||||
},
|
||||
});
|
||||
expect(res.reason).toBe('download_failed');
|
||||
expect(res.error).toContain('disk full');
|
||||
expect(readFileSync(target, 'utf8')).toBe('OLD');
|
||||
});
|
||||
});
|
||||
|
||||
test('smoke fail → smoke_failed, staged discarded, target untouched', async () => {
|
||||
await withTmp(async (dir) => {
|
||||
const target = join(dir, 'gbrain');
|
||||
writeFileSync(target, 'OLD');
|
||||
const res = await runBinarySelfUpdate(target, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
|
||||
download: async (_url, dest) => writeFileSync(dest, 'CORRUPT'),
|
||||
smoke: () => false,
|
||||
});
|
||||
expect(res.reason).toBe('smoke_failed');
|
||||
expect(readFileSync(target, 'utf8')).toBe('OLD');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* E2E: real atomic binary self-update against a live local release server.
|
||||
*
|
||||
* Unlike test/binary-self-update.test.ts (which stubs `download` + `smoke` to
|
||||
* exercise the orchestration), this drives the REAL dangerous path end-to-end:
|
||||
* real HTTP download (defaultDownload) → real chmod → real `--version` smoke
|
||||
* (defaultSmoke / execFileSync) → real renameSync over a running "binary" →
|
||||
* re-exec the swapped binary and assert it reports the new version.
|
||||
*
|
||||
* Only `fetchRelease` is injected (to point at the local server instead of the
|
||||
* GitHub API). The "binary" is a `#!/bin/sh` script so the swap mechanics are
|
||||
* exercised identically on darwin + linux; platform/arch are pinned to
|
||||
* linux/x64 so `expectedAssetName` resolves deterministically regardless of host.
|
||||
*
|
||||
* No DB — runs in every environment.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { chmodSync, existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { runBinarySelfUpdate, type ReleaseAsset } from '../../src/core/binary-self-update.ts';
|
||||
|
||||
const NEW_BINARY = '#!/bin/sh\necho "gbrain 0.43.0"\n';
|
||||
const OLD_BINARY = '#!/bin/sh\necho "gbrain 0.42.0"\n';
|
||||
const NON_GBRAIN = '#!/bin/sh\necho "not the tool"\n';
|
||||
|
||||
let server: ReturnType<typeof Bun.serve>;
|
||||
let base: string;
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const path = new URL(req.url).pathname;
|
||||
if (path === '/good-asset') return new Response(NEW_BINARY, { status: 200 });
|
||||
if (path === '/bad-smoke-asset') return new Response(NON_GBRAIN, { status: 200 });
|
||||
if (path === '/404-asset') return new Response('nope', { status: 404 });
|
||||
if (path === '/empty-asset') return new Response('', { status: 200 });
|
||||
return new Response('not found', { status: 404 });
|
||||
},
|
||||
});
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.stop(true);
|
||||
});
|
||||
|
||||
function makeTargetBinary(): { dir: string; target: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-swap-'));
|
||||
const target = join(dir, 'gbrain');
|
||||
writeFileSync(target, OLD_BINARY);
|
||||
chmodSync(target, 0o755);
|
||||
return { dir, target };
|
||||
}
|
||||
|
||||
function assets(url: string): ReleaseAsset[] {
|
||||
return [{ name: 'gbrain-linux-x64', url }];
|
||||
}
|
||||
|
||||
function versionOf(path: string): string {
|
||||
return execFileSync(path, ['--version'], { encoding: 'utf-8' }).trim();
|
||||
}
|
||||
|
||||
function tmpLeftovers(dir: string): string[] {
|
||||
return readdirSync(dir).filter((f) => f.includes('.tmp.'));
|
||||
}
|
||||
|
||||
describe('binary self-update — real swap E2E', () => {
|
||||
test('happy path: downloads, smokes, atomically replaces the running binary', async () => {
|
||||
const { dir, target } = makeTargetBinary();
|
||||
try {
|
||||
expect(versionOf(target)).toBe('gbrain 0.42.0');
|
||||
const result = await runBinarySelfUpdate(target, {
|
||||
fetchRelease: async () => ({ tag: 'v0.43.0', assets: assets(`${base}/good-asset`) }),
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.asset).toBe('gbrain-linux-x64');
|
||||
// The running "binary" was atomically replaced; a fresh exec sees the new version.
|
||||
expect(versionOf(target)).toBe('gbrain 0.43.0');
|
||||
expect(tmpLeftovers(dir)).toEqual([]); // staged temp renamed away, none left
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('smoke failure leaves the old binary untouched (no brick)', async () => {
|
||||
const { dir, target } = makeTargetBinary();
|
||||
try {
|
||||
const result = await runBinarySelfUpdate(target, {
|
||||
fetchRelease: async () => ({ tag: 'v0.43.0', assets: assets(`${base}/bad-smoke-asset`) }),
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe('smoke_failed');
|
||||
expect(versionOf(target)).toBe('gbrain 0.42.0'); // old binary intact
|
||||
expect(tmpLeftovers(dir)).toEqual([]); // staged temp cleaned up on failure
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('download HTTP error leaves the old binary untouched', async () => {
|
||||
const { dir, target } = makeTargetBinary();
|
||||
try {
|
||||
const result = await runBinarySelfUpdate(target, {
|
||||
fetchRelease: async () => ({ tag: 'v0.43.0', assets: assets(`${base}/404-asset`) }),
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe('download_failed');
|
||||
expect(versionOf(target)).toBe('gbrain 0.42.0');
|
||||
expect(existsSync(target)).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('empty downloaded asset is rejected, old binary intact', async () => {
|
||||
const { dir, target } = makeTargetBinary();
|
||||
try {
|
||||
const result = await runBinarySelfUpdate(target, {
|
||||
fetchRelease: async () => ({ tag: 'v0.43.0', assets: assets(`${base}/empty-asset`) }),
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe('download_failed');
|
||||
expect(versionOf(target)).toBe('gbrain 0.42.0');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('no matching asset for platform → no_asset, no download attempted', async () => {
|
||||
const { dir, target } = makeTargetBinary();
|
||||
try {
|
||||
const result = await runBinarySelfUpdate(target, {
|
||||
fetchRelease: async () => ({ tag: 'v0.43.0', assets: [{ name: 'gbrain-darwin-arm64', url: `${base}/good-asset` }] }),
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe('no_asset');
|
||||
expect(versionOf(target)).toBe('gbrain 0.42.0');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('unsupported platform short-circuits before any network call', async () => {
|
||||
const { dir, target } = makeTargetBinary();
|
||||
let fetched = false;
|
||||
try {
|
||||
const result = await runBinarySelfUpdate(target, {
|
||||
fetchRelease: async () => {
|
||||
fetched = true;
|
||||
return { tag: 'v0.43.0', assets: assets(`${base}/good-asset`) };
|
||||
},
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe('unsupported_platform');
|
||||
expect(fetched).toBe(false); // never hit the network
|
||||
expect(versionOf(target)).toBe('gbrain 0.42.0');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
import {
|
||||
canSelfUpdate,
|
||||
clearSnooze,
|
||||
clearUpdateCache,
|
||||
decideSelfUpgrade,
|
||||
formatMarker,
|
||||
isCacheFresh,
|
||||
isSnoozeActive,
|
||||
parseMarker,
|
||||
readSnooze,
|
||||
readUpdateCache,
|
||||
reconcileBreadcrumb,
|
||||
resolveSelfUpgradeMode,
|
||||
snoozeDurationMs,
|
||||
writeSnooze,
|
||||
writeUpdateCache,
|
||||
type DecideSelfUpgradeInputs,
|
||||
} from '../src/core/self-upgrade.ts';
|
||||
|
||||
function baseInputs(over: Partial<DecideSelfUpgradeInputs> = {}): DecideSelfUpgradeInputs {
|
||||
return {
|
||||
mode: 'notify',
|
||||
currentVersion: '0.42.0',
|
||||
latestVersion: '0.43.0',
|
||||
failedVersions: [],
|
||||
channel: 'invocation',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
async function withTmpHome<T>(fn: () => T | Promise<T>): Promise<T> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-selfupgrade-'));
|
||||
try {
|
||||
return await withEnv({ GBRAIN_HOME: dir, GBRAIN_SELF_UPGRADE_MODE: undefined }, fn);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('decideSelfUpgrade — pure branches', () => {
|
||||
test('mode=off short-circuits', () => {
|
||||
expect(decideSelfUpgrade(baseInputs({ mode: 'off' })).action).toBe('off');
|
||||
});
|
||||
|
||||
test('null/invalid latest → not_behind (fail-open)', () => {
|
||||
expect(decideSelfUpgrade(baseInputs({ latestVersion: null })).action).toBe('not_behind');
|
||||
expect(decideSelfUpgrade(baseInputs({ latestVersion: 'garbage' })).action).toBe('not_behind');
|
||||
expect(decideSelfUpgrade(baseInputs({ latestVersion: 'rm -rf /' })).action).toBe('not_behind');
|
||||
});
|
||||
|
||||
test('equal version → not_behind', () => {
|
||||
expect(decideSelfUpgrade(baseInputs({ latestVersion: '0.42.0' })).action).toBe('not_behind');
|
||||
});
|
||||
|
||||
test('latest < current → downgrade_or_yanked (never acts)', () => {
|
||||
const d = decideSelfUpgrade(baseInputs({ currentVersion: '0.43.0', latestVersion: '0.42.0' }));
|
||||
expect(d.action).toBe('downgrade_or_yanked');
|
||||
});
|
||||
|
||||
test('patch/micro bump only → not_behind (ignored)', () => {
|
||||
expect(decideSelfUpgrade(baseInputs({ currentVersion: '0.42.0', latestVersion: '0.42.1' })).action).toBe('not_behind');
|
||||
expect(decideSelfUpgrade(baseInputs({ currentVersion: '0.42.3.0', latestVersion: '0.42.3.1' })).action).toBe('not_behind');
|
||||
});
|
||||
|
||||
test('minor bump → behind', () => {
|
||||
expect(decideSelfUpgrade(baseInputs({ currentVersion: '0.42.0', latestVersion: '0.43.0' })).action).toBe('notify');
|
||||
});
|
||||
|
||||
test('major bump → behind', () => {
|
||||
expect(decideSelfUpgrade(baseInputs({ currentVersion: '0.42.0', latestVersion: '1.0.0' })).action).toBe('notify');
|
||||
});
|
||||
|
||||
test('known-bad latest → known_bad (no retry)', () => {
|
||||
const d = decideSelfUpgrade(baseInputs({ failedVersions: ['0.43.0'] }));
|
||||
expect(d.action).toBe('known_bad');
|
||||
});
|
||||
|
||||
test('invocation channel: snoozed → throttled', () => {
|
||||
expect(decideSelfUpgrade(baseInputs({ snoozed: true })).action).toBe('throttled');
|
||||
});
|
||||
|
||||
test('invocation channel: not snoozed → notify', () => {
|
||||
expect(decideSelfUpgrade(baseInputs({ snoozed: false })).action).toBe('notify');
|
||||
});
|
||||
|
||||
describe('autopilot channel gates', () => {
|
||||
const auto = (over: Partial<DecideSelfUpgradeInputs> = {}) =>
|
||||
decideSelfUpgrade(
|
||||
baseInputs({
|
||||
mode: 'auto',
|
||||
channel: 'autopilot',
|
||||
idle: true,
|
||||
inQuietHours: true,
|
||||
canSelfUpdate: true,
|
||||
throttledByInterval: false,
|
||||
...over,
|
||||
}),
|
||||
);
|
||||
|
||||
test('all gates pass → apply', () => {
|
||||
expect(auto().action).toBe('apply');
|
||||
});
|
||||
test('throttledByInterval → throttled', () => {
|
||||
expect(auto({ throttledByInterval: true }).action).toBe('throttled');
|
||||
});
|
||||
test('not idle → busy', () => {
|
||||
expect(auto({ idle: false }).action).toBe('busy');
|
||||
});
|
||||
test('outside quiet hours → outside_quiet_hours', () => {
|
||||
expect(auto({ inQuietHours: false }).action).toBe('outside_quiet_hours');
|
||||
});
|
||||
test('cannot self-update → unsupported_install', () => {
|
||||
expect(auto({ canSelfUpdate: false }).action).toBe('unsupported_install');
|
||||
});
|
||||
test('gate order: known_bad beats idle/quiet gates', () => {
|
||||
expect(auto({ failedVersions: ['0.43.0'], idle: false }).action).toBe('known_bad');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('canSelfUpdate', () => {
|
||||
test('bun / bun-link / clawhub always self-update', () => {
|
||||
for (const m of ['bun', 'bun-link', 'clawhub']) {
|
||||
expect(canSelfUpdate(m, 'darwin', 'arm64')).toBe(true);
|
||||
expect(canSelfUpdate(m, 'win32', 'x64')).toBe(true);
|
||||
}
|
||||
});
|
||||
test('binary self-updates only where a release asset exists (darwin-arm64, linux-x64)', () => {
|
||||
expect(canSelfUpdate('binary', 'darwin', 'arm64')).toBe(true);
|
||||
expect(canSelfUpdate('binary', 'linux', 'x64')).toBe(true);
|
||||
expect(canSelfUpdate('binary', 'darwin', 'x64')).toBe(false); // no asset published
|
||||
expect(canSelfUpdate('binary', 'linux', 'arm64')).toBe(false); // no asset published
|
||||
expect(canSelfUpdate('binary', 'win32', 'x64')).toBe(false);
|
||||
});
|
||||
test('unknown method cannot self-update', () => {
|
||||
expect(canSelfUpdate('unknown', 'darwin', 'arm64')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('marker grammar', () => {
|
||||
test('round-trips up_to_date and upgrade_available', () => {
|
||||
const a = { kind: 'up_to_date' as const, current: '0.42.0' };
|
||||
expect(parseMarker(formatMarker(a))).toEqual(a);
|
||||
const b = { kind: 'upgrade_available' as const, current: '0.42.0', latest: '0.43.0' };
|
||||
expect(parseMarker(formatMarker(b))).toEqual(b);
|
||||
});
|
||||
test('rejects forged / malformed markers', () => {
|
||||
expect(parseMarker('UPGRADE_AVAILABLE 0.42.0 $(rm -rf /)')).toBeNull();
|
||||
expect(parseMarker('UPGRADE_AVAILABLE 0.42.0')).toBeNull();
|
||||
expect(parseMarker('EVIL 0.42.0 0.43.0')).toBeNull();
|
||||
expect(parseMarker('UP_TO_DATE not-a-version')).toBeNull();
|
||||
expect(parseMarker('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('snooze', () => {
|
||||
test('escalating durations cap at 7d', () => {
|
||||
expect(snoozeDurationMs(1)).toBe(24 * 3600 * 1000);
|
||||
expect(snoozeDurationMs(2)).toBe(48 * 3600 * 1000);
|
||||
expect(snoozeDurationMs(3)).toBe(7 * 24 * 3600 * 1000);
|
||||
expect(snoozeDurationMs(99)).toBe(7 * 24 * 3600 * 1000);
|
||||
});
|
||||
test('isSnoozeActive: only for matching version, within window', () => {
|
||||
const now = 1_000_000_000_000;
|
||||
const rec = { version: '0.43.0', level: 1, ts: now };
|
||||
expect(isSnoozeActive(rec, '0.43.0', now + 1000)).toBe(true);
|
||||
expect(isSnoozeActive(rec, '0.43.0', now + 25 * 3600 * 1000)).toBe(false); // expired
|
||||
expect(isSnoozeActive(rec, '0.44.0', now + 1000)).toBe(false); // different version
|
||||
expect(isSnoozeActive(null, '0.43.0', now)).toBe(false);
|
||||
});
|
||||
test('writeSnooze escalates level for same version, resets for new version', async () => {
|
||||
await withTmpHome(() => {
|
||||
const now = 1_700_000_000_000;
|
||||
expect(writeSnooze('0.43.0', now)).toBe(1);
|
||||
expect(writeSnooze('0.43.0', now)).toBe(2);
|
||||
expect(writeSnooze('0.43.0', now)).toBe(3);
|
||||
expect(writeSnooze('0.43.0', now)).toBe(3); // capped
|
||||
expect(writeSnooze('0.44.0', now)).toBe(1); // new version resets
|
||||
const rec = readSnooze();
|
||||
expect(rec?.version).toBe('0.44.0');
|
||||
expect(rec?.level).toBe(1);
|
||||
clearSnooze();
|
||||
expect(readSnooze()).toBeNull();
|
||||
});
|
||||
});
|
||||
test('corrupt snooze file → null', async () => {
|
||||
await withTmpHome(async () => {
|
||||
const { writeFileSync, mkdirSync } = await import('node:fs');
|
||||
const { gbrainPath } = await import('../src/core/config.ts');
|
||||
mkdirSync(gbrainPath(), { recursive: true });
|
||||
writeFileSync(gbrainPath('update-snoozed'), 'garbage not three fields here');
|
||||
expect(readSnooze()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache', () => {
|
||||
test('write → read round-trip, atomic, fresh check', async () => {
|
||||
await withTmpHome(() => {
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: '0.42.0', latest: '0.43.0' });
|
||||
const entry = readUpdateCache();
|
||||
expect(entry?.marker).toEqual({ kind: 'upgrade_available', current: '0.42.0', latest: '0.43.0' });
|
||||
expect(isCacheFresh(entry!, entry!.mtimeMs + 1000)).toBe(true);
|
||||
expect(isCacheFresh(entry!, entry!.mtimeMs + 13 * 3600 * 1000)).toBe(false); // > 12h
|
||||
clearUpdateCache();
|
||||
expect(readUpdateCache()).toBeNull();
|
||||
});
|
||||
});
|
||||
test('up_to_date uses the 60min TTL', async () => {
|
||||
await withTmpHome(() => {
|
||||
writeUpdateCache({ kind: 'up_to_date', current: '0.42.0' });
|
||||
const entry = readUpdateCache()!;
|
||||
expect(isCacheFresh(entry, entry.mtimeMs + 59 * 60 * 1000)).toBe(true);
|
||||
expect(isCacheFresh(entry, entry.mtimeMs + 61 * 60 * 1000)).toBe(false);
|
||||
});
|
||||
});
|
||||
test('corrupt cache file → null (fail-open)', async () => {
|
||||
await withTmpHome(async () => {
|
||||
const { writeFileSync, mkdirSync } = await import('node:fs');
|
||||
const { gbrainPath } = await import('../src/core/config.ts');
|
||||
mkdirSync(gbrainPath(), { recursive: true });
|
||||
writeFileSync(gbrainPath('last-update-check'), 'EVIL not a marker');
|
||||
expect(readUpdateCache()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileBreadcrumb', () => {
|
||||
test('no breadcrumb → no transition', () => {
|
||||
expect(reconcileBreadcrumb(undefined, '0.42.0').transition).toBeNull();
|
||||
expect(reconcileBreadcrumb({}, '0.42.0').transition).toBeNull();
|
||||
});
|
||||
test('breadcrumb matches running version → applied, breadcrumb cleared, last_applied set', () => {
|
||||
const r = reconcileBreadcrumb({ attempting_version: '0.43.0' }, '0.43.0');
|
||||
expect(r.transition).toBe('applied');
|
||||
expect(r.state.attempting_version).toBeUndefined();
|
||||
expect(r.state.last_applied_version).toBe('0.43.0');
|
||||
});
|
||||
test('breadcrumb != running version → failed, recorded known-bad, breadcrumb cleared', () => {
|
||||
const r = reconcileBreadcrumb({ attempting_version: '0.43.0' }, '0.42.0');
|
||||
expect(r.transition).toBe('failed');
|
||||
expect(r.state.attempting_version).toBeUndefined();
|
||||
expect(r.state.failed_versions).toContain('0.43.0');
|
||||
});
|
||||
test('failed dedups into existing failed_versions', () => {
|
||||
const r = reconcileBreadcrumb({ attempting_version: '0.43.0', failed_versions: ['0.43.0', '0.41.0'] }, '0.42.0');
|
||||
expect(r.state.failed_versions?.filter((v) => v === '0.43.0').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSelfUpgradeMode', () => {
|
||||
test('defaults to notify', async () => {
|
||||
await withEnv({ GBRAIN_SELF_UPGRADE_MODE: undefined }, () => {
|
||||
expect(resolveSelfUpgradeMode(null)).toBe('notify');
|
||||
expect(resolveSelfUpgradeMode({})).toBe('notify');
|
||||
expect(resolveSelfUpgradeMode({ self_upgrade: { mode: 'bogus' } })).toBe('notify');
|
||||
});
|
||||
});
|
||||
test('config plane honored', async () => {
|
||||
await withEnv({ GBRAIN_SELF_UPGRADE_MODE: undefined }, () => {
|
||||
expect(resolveSelfUpgradeMode({ self_upgrade: { mode: 'auto' } })).toBe('auto');
|
||||
expect(resolveSelfUpgradeMode({ self_upgrade: { mode: 'off' } })).toBe('off');
|
||||
});
|
||||
});
|
||||
test('env overrides config', async () => {
|
||||
await withEnv({ GBRAIN_SELF_UPGRADE_MODE: 'off' }, () => {
|
||||
expect(resolveSelfUpgradeMode({ self_upgrade: { mode: 'auto' } })).toBe('off');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user