diff --git a/src/core/audit/self-upgrade-audit.ts b/src/core/audit/self-upgrade-audit.ts new file mode 100644 index 000000000..876e1fc86 --- /dev/null +++ b/src/core/audit/self-upgrade-audit.ts @@ -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({ + featureName: 'self-upgrade', + errorLabel: 'self-upgrade-audit', + errorTrailer: '; continuing', +}); + +export function logSelfUpgrade(event: Omit & { 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); +} diff --git a/src/core/binary-self-update.ts b/src/core/binary-self-update.ts new file mode 100644 index 000000000..957b9b05e --- /dev/null +++ b/src/core/binary-self-update.ts @@ -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; + /** Smoke-test the staged binary; returns true if ` --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 { + 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 { + 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); +} diff --git a/src/core/self-upgrade.ts b/src/core/self-upgrade.ts new file mode 100644 index 000000000..7c0b82cc2 --- /dev/null +++ b/src/core/self-upgrade.ts @@ -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): 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'; +} diff --git a/src/core/semver.ts b/src/core/semver.ts new file mode 100644 index 000000000..db2b6796b --- /dev/null +++ b/src/core/semver.ts @@ -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; +} diff --git a/test/binary-self-update.test.ts b/test/binary-self-update.test.ts new file mode 100644 index 000000000..a5f5c13f2 --- /dev/null +++ b/test/binary-self-update.test.ts @@ -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(fn: (dir: string) => Promise): Promise { + 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'); + }); + }); +}); diff --git a/test/e2e/self-upgrade-binary-swap.test.ts b/test/e2e/self-upgrade-binary-swap.test.ts new file mode 100644 index 000000000..c104bcec3 --- /dev/null +++ b/test/e2e/self-upgrade-binary-swap.test.ts @@ -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; +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 }); + } + }); +}); diff --git a/test/self-upgrade.test.ts b/test/self-upgrade.test.ts new file mode 100644 index 000000000..a2fe58c7c --- /dev/null +++ b/test/self-upgrade.test.ts @@ -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 { + return { + mode: 'notify', + currentVersion: '0.42.0', + latestVersion: '0.43.0', + failedVersions: [], + channel: 'invocation', + ...over, + }; +} + +async function withTmpHome(fn: () => T | Promise): Promise { + 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 = {}) => + 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'); + }); + }); +});