From 1ac686543e8c5913290a3f53ac9851e9969806e1 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 10:12:00 +0900 Subject: [PATCH 1/2] fix(update): stage, verify, and swap npm self-updates with rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The npm self-update installed straight into the live global tree, so any failure after npm removed the old files left a file-less package skeleton with no recovery (#1849) and nothing verified the new tree before it went live (#1942). The launcher now stages the target version into a sibling directory (npm --prefix, same volume), verifies a manifest inside the stage (package.json version, launcher integrity, sentinel deps), moves live aside to a sibling backup, swaps the staged tree in, re-verifies, and rolls back by reverse rename on failure — writing a recovery marker with a one-line restore on double fault. A boot probe restores the newest backup over a broken live tree (power loss mid-swap) and reaps stale backups once live verifies. Staging and backup are SIBLINGS of the package dir, never children — a child would travel with the live rename and the live tree cannot move into its own subtree (design defect caught by the plan audit and fixed here). Closes #1942 Closes #1849 --- bin/ocx.mjs | 68 ++++++++- src/update/transactional-install.d.mts | 22 +++ src/update/transactional-install.mjs | 189 +++++++++++++++++++++++++ tests/update-transactional.test.ts | 173 ++++++++++++++++++++++ 4 files changed, 445 insertions(+), 7 deletions(-) create mode 100644 src/update/transactional-install.d.mts create mode 100644 src/update/transactional-install.mjs create mode 100644 tests/update-transactional.test.ts diff --git a/bin/ocx.mjs b/bin/ocx.mjs index bdda8bd90a..dc78befb18 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -22,6 +22,7 @@ import { runNpmCachePreflight, } from "../src/update/npm-cache-preflight.mjs"; import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "../src/update/tray-update-plan.mjs"; +import { bootRestoreProbe, transactionalNpmUpdate } from "../src/update/transactional-install.mjs"; const PKG = "@bitkyc08/opencodex"; const require = createRequire(import.meta.url); @@ -267,13 +268,52 @@ function runNpmSelfUpdate() { } } - console.log(`Updating${latest ? ` to v${latest}` : ""}...\n$ npm install -g ${PKG}@${tag}`); - const res = spawnSync(installInvocation.file, installInvocation.args, { - stdio: "inherit", - timeout: 180000, - windowsHide: true, - ...installInvocation.options, - }); + // #1942/#1849: stage -> verify -> swap -> rollback instead of installing straight + // into the live tree. A failure at any point leaves either the old or the new tree + // complete — never a file-less skeleton. Falls back to the legacy in-place install + // only when the transactional module cannot run at all. + const packageDir = resolve(here, ".."); + console.log(`Updating${latest ? ` to v${latest}` : ""} (transactional)...`); + let res; + try { + const tx = transactionalNpmUpdate({ + packageDir, + pkgName: PKG, + targetVersion: latest || undefined, + tag, + runNpm: (args) => { + const invocation = npmInvocation(args); + if (!invocation) return { status: 1 }; + return spawnSync(invocation.file, invocation.args, { + stdio: "inherit", + timeout: 180000, + windowsHide: true, + ...invocation.options, + }); + }, + log: (line) => console.log(line), + }); + if (tx.ok) { + res = { status: 0 }; + } else if (tx.phase === "stage" || tx.phase === "verify") { + // Live tree untouched: report and stop. Nothing to roll back. + console.error(`opencodex: update aborted before touching the live install (${tx.phase}): ${tx.error}`); + res = { status: 1 }; + } else { + console.error(`opencodex: update failed (${tx.phase}): ${tx.error}${tx.rolledBack ? " — previous version restored." : ""}`); + res = { status: 1 }; + } + } catch (error) { + // Transactional machinery itself failed (e.g. exotic install layout): legacy path. + console.warn(`opencodex: transactional update unavailable (${error?.message ?? error}); falling back to in-place npm install.`); + console.log(`$ npm install -g ${PKG}@${tag}`); + res = spawnSync(installInvocation.file, installInvocation.args, { + stdio: "inherit", + timeout: 180000, + windowsHide: true, + ...installInvocation.options, + }); + } if (res.status === 0) { console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`); repairCodexShimIfNeeded(); @@ -449,6 +489,20 @@ if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstal runNpmSelfUpdate(); } +// #1849 boot probe: a prior update that lost power (or double-faulted) mid-swap leaves a +// backup sibling and a broken live tree. Restore before anything tries to run from the +// broken tree; reap stale backups once the live tree verifies healthy. +if (isNodeModulesInstall() && !isBunGlobalInstall()) { + try { + const probe = bootRestoreProbe(resolve(here, "..")); + if (probe.action === "restored") { + console.warn(`opencodex: previous update left a broken install — restored the backup from ${probe.from}.`); + } else if (probe.action === "failed") { + console.warn(`opencodex: a backup from a failed update exists but could not be restored automatically: ${probe.error}`); + } + } catch { /* the probe must never block launch */ } +} + const bunRuntime = resolveBun(); const bun = bunRuntime.path; diff --git a/src/update/transactional-install.d.mts b/src/update/transactional-install.d.mts new file mode 100644 index 0000000000..398639e4f9 --- /dev/null +++ b/src/update/transactional-install.d.mts @@ -0,0 +1,22 @@ +export type InstallTreeVerification = { ok: boolean; failures: string[] }; +export function verifyInstallTree(packageDir: string, expectedVersion?: string): InstallTreeVerification; +export function bootRestoreProbe( + packageDir: string, + deps?: { rename?: (from: string, to: string) => void }, +): { action: "none" | "reaped" | "restored" | "failed"; count?: number; from?: string; error?: string }; +export function transactionalNpmUpdate(args: { + packageDir: string; + pkgName: string; + targetVersion?: string; + tag: string; + runNpm: (args: string[]) => { status: number | null }; + log?: (line: string) => void; + deps?: { rename?: (from: string, to: string) => void }; +}): { + ok: boolean; + phase: "stage" | "verify" | "swap-backup" | "swap-live" | "post-verify" | "double-fault" | "done"; + error?: string; + rolledBack?: boolean; + backup?: string; +}; + diff --git a/src/update/transactional-install.mjs b/src/update/transactional-install.mjs new file mode 100644 index 0000000000..fb6126d80b --- /dev/null +++ b/src/update/transactional-install.mjs @@ -0,0 +1,189 @@ +/** + * Transactional npm self-update: stage -> verify -> swap -> rollback (#1942 / #1849). + * + * The legacy path ran `npm install -g` straight into the live global tree, so a + * failure after npm removed the old files left a file-less package skeleton with no + * recovery. This module stages the new version into a SIBLING directory of the live + * package (same volume, so directory renames are atomic-ish and never cross devices), + * verifies the staged tree with a manifest before anything live is touched, then swaps + * live -> backup -> stage-into-live with a reverse-rename rollback on failure and a + * recovery marker on double fault. + * + * Layout (siblings, never children — a child would travel WITH the live rename and the + * live dir cannot move into its own subtree): + * /opencodex live package + * /.ocx-staging-/ npm --prefix root (contains node_modules/...) + * /.ocx-backup-/opencodex previous live tree during/after the swap + * /.ocx-recovery.json double-fault marker with a one-line restore + */ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** Verification manifest for a staged (or live) package tree. */ +export function verifyInstallTree(packageDir, expectedVersion) { + const failures = []; + let pkg; + try { + pkg = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")); + } catch (error) { + return { ok: false, failures: ["package.json unreadable: " + (error?.message ?? String(error))] }; + } + if (expectedVersion && pkg.version !== expectedVersion) { + failures.push("package.json version " + pkg.version + " != expected " + expectedVersion); + } + const launcher = join(packageDir, "bin", "ocx.mjs"); + try { + const st = statSync(launcher); + if (!st.isFile() || st.size < 1024) failures.push("bin/ocx.mjs missing or truncated"); + } catch { + failures.push("bin/ocx.mjs absent"); + } + // Sentinel direct deps: each must have an intact package.json. The bundled Bun dep is + // the load-bearing one — without it the launcher cannot start the proxy at all. + const deps = Object.keys(pkg.dependencies ?? {}); + const sentinels = deps.filter(name => name === "bun" || name === "zod").length > 0 + ? deps.filter(name => name === "bun" || name === "zod") + : deps.slice(0, 2); + for (const name of sentinels) { + const depPkg = join(packageDir, "node_modules", ...name.split("/"), "package.json"); + if (!existsSync(depPkg)) failures.push("sentinel dependency missing: " + name); + } + return failures.length === 0 ? { ok: true, failures: [] } : { ok: false, failures }; +} + +function stampedName(prefix) { + return prefix + "-" + new Date().toISOString().replace(/[:.]/g, "-"); +} + +function recoveryMarkerPath(scopeDir) { + return join(scopeDir, ".ocx-recovery.json"); +} + +/** Startup probe: restore a backup when the live tree is broken (D4 power-loss rows). */ +export function bootRestoreProbe(packageDir, deps = {}) { + const rename = deps.rename ?? renameSync; + const scopeDir = dirname(packageDir); + let backups = []; + try { + backups = readdirSync(scopeDir).filter(name => name.startsWith(".ocx-backup-")).sort(); + } catch { + return { action: "none" }; + } + if (backups.length === 0) return { action: "none" }; + const liveOk = existsSync(join(packageDir, "package.json")) + && verifyInstallTree(packageDir).ok; + const newestBackup = join(scopeDir, backups[backups.length - 1], "opencodex"); + if (liveOk) { + // Live is healthy: the backups are leftovers from a completed swap. Reap them. + for (const name of backups) { + try { rmSync(join(scopeDir, name), { recursive: true, force: true }); } catch { /* keep */ } + } + try { rmSync(recoveryMarkerPath(scopeDir), { force: true }); } catch { /* keep */ } + return { action: "reaped", count: backups.length }; + } + if (!existsSync(join(newestBackup, "package.json"))) return { action: "none" }; + try { + try { rmSync(packageDir, { recursive: true, force: true }); } catch { /* may not exist */ } + rename(newestBackup, packageDir); + return { action: "restored", from: newestBackup }; + } catch (error) { + return { action: "failed", error: error?.message ?? String(error) }; + } +} + +/** + * Run the transactional update. `runNpm(args, opts)` is injected so the caller keeps + * its hardened npm resolution (npm-invocation.mjs) and logging. + */ +export function transactionalNpmUpdate({ + packageDir, + pkgName, + targetVersion, + tag, + runNpm, + log = () => {}, + deps = {}, +}) { + const rename = deps.rename ?? renameSync; + const scopeDir = dirname(packageDir); + const stageRoot = join(scopeDir, stampedName(".ocx-staging")); + const stagedPackage = join(stageRoot, "node_modules", ...pkgName.split("/")); + + // D1: stage to the side. --prefix keeps npm entirely inside stageRoot; the live tree + // and the npm bin shims are untouched until the swap. + mkdirSync(stageRoot, { recursive: true }); + const spec = pkgName + "@" + (targetVersion || tag); + log("Staging " + spec + " into " + stageRoot); + const install = runNpm(["install", "--prefix", stageRoot, "--no-audit", "--no-fund", spec]); + if (install.status !== 0) { + try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ } + return { ok: false, phase: "stage", error: "npm staging install failed (" + (install.status ?? "?") + ")" }; + } + + // D2: verify INSIDE the stage. Live is still untouched on any failure here. + const staged = verifyInstallTree(stagedPackage, targetVersion || undefined); + if (!staged.ok) { + try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ } + return { ok: false, phase: "verify", error: "staged tree failed verification: " + staged.failures.join("; ") }; + } + + // D3: swap. live -> backup, stage -> live, re-verify live, rollback on failure. + const backupRoot = join(scopeDir, stampedName(".ocx-backup")); + const backupPackage = join(backupRoot, "opencodex"); + mkdirSync(backupRoot, { recursive: true }); + try { + rename(packageDir, backupPackage); + } catch (error) { + try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ } + try { rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ } + return { ok: false, phase: "swap-backup", error: "could not move live tree aside: " + (error?.message ?? String(error)) }; + } + try { + rename(stagedPackage, packageDir); + } catch (error) { + // Rollback: reverse the first rename. Double fault leaves the recovery marker. + try { + rename(backupPackage, packageDir); + try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ } + try { rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ } + return { ok: false, phase: "swap-live", rolledBack: true, error: "could not place staged tree: " + (error?.message ?? String(error)) }; + } catch (rollbackError) { + writeFileSync(recoveryMarkerPath(scopeDir), JSON.stringify({ + at: new Date().toISOString(), + backup: backupPackage, + live: packageDir, + restore: 'move "' + backupPackage + '" back to "' + packageDir + '"', + error: String(error?.message ?? error), + rollbackError: String(rollbackError?.message ?? rollbackError), + }, null, 2)); + return { ok: false, phase: "double-fault", rolledBack: false, error: "swap and rollback both failed; recovery marker written at " + recoveryMarkerPath(scopeDir) }; + } + } + const liveCheck = verifyInstallTree(packageDir, targetVersion || undefined); + if (!liveCheck.ok) { + try { + rmSync(packageDir, { recursive: true, force: true }); + rename(backupPackage, packageDir); + try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ } + try { rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ } + return { ok: false, phase: "post-verify", rolledBack: true, error: "live tree failed post-swap verification: " + liveCheck.failures.join("; ") }; + } catch (rollbackError) { + writeFileSync(recoveryMarkerPath(scopeDir), JSON.stringify({ + at: new Date().toISOString(), + backup: backupPackage, + live: packageDir, + restore: 'move "' + backupPackage + '" back to "' + packageDir + '"', + error: "post-swap verification failed: " + liveCheck.failures.join("; "), + rollbackError: String(rollbackError?.message ?? rollbackError), + }, null, 2)); + return { ok: false, phase: "double-fault", rolledBack: false, error: "post-verify rollback failed; recovery marker written" }; + } + } + // Success: stage scaffolding is disposable now; the backup stays until the next + // healthy boot reaps it (bootRestoreProbe) — the process that spawned this update may + // still hold the old cwd. + try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ } + return { ok: true, phase: "done", backup: backupPackage }; +} + diff --git a/tests/update-transactional.test.ts b/tests/update-transactional.test.ts new file mode 100644 index 0000000000..f2c5ed5638 --- /dev/null +++ b/tests/update-transactional.test.ts @@ -0,0 +1,173 @@ +/** + * #1942 / #1849: transactional update invariant — the live tree is always either + * old-complete or new-complete, never a partial skeleton, across every injected fault. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + bootRestoreProbe, + transactionalNpmUpdate, + verifyInstallTree, +} from "../src/update/transactional-install.mjs"; + +const PKG = "@bitkyc08/opencodex"; + +function writeTree(packageDir: string, version: string): void { + mkdirSync(join(packageDir, "bin"), { recursive: true }); + mkdirSync(join(packageDir, "node_modules", "bun"), { recursive: true }); + mkdirSync(join(packageDir, "node_modules", "zod"), { recursive: true }); + writeFileSync(join(packageDir, "package.json"), JSON.stringify({ + name: PKG, version, dependencies: { bun: "1", zod: "1" }, + })); + writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n" + "x".repeat(2048)); + writeFileSync(join(packageDir, "node_modules", "bun", "package.json"), JSON.stringify({ name: "bun" })); + writeFileSync(join(packageDir, "node_modules", "zod", "package.json"), JSON.stringify({ name: "zod" })); +} + +function liveVersion(packageDir: string): string | undefined { + try { + return JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")).version; + } catch { + return undefined; + } +} + +/** npm stub that materializes a staged tree under the --prefix root. */ +function stagingNpm(version: string, opts: { fail?: boolean; truncate?: boolean } = {}) { + return (args: string[]) => { + if (opts.fail) return { status: 1 }; + const prefixIndex = args.indexOf("--prefix"); + const stageRoot = args[prefixIndex + 1]!; + const staged = join(stageRoot, "node_modules", ...PKG.split("/")); + writeTree(staged, version); + if (opts.truncate) rmSync(join(staged, "bin", "ocx.mjs")); + return { status: 0 }; + }; +} + +describe("#1942 transactional update", () => { + let scopeDir: string; + let packageDir: string; + + beforeEach(() => { + scopeDir = mkdtempSync(join(tmpdir(), "ocx-tx-update-")); + packageDir = join(scopeDir, "opencodex"); + writeTree(packageDir, "1.0.0"); + }); + + afterEach(() => { + rmSync(scopeDir, { recursive: true, force: true }); + }); + + test("manifest verifies a complete tree and rejects a truncated one", () => { + expect(verifyInstallTree(packageDir, "1.0.0").ok).toBe(true); + expect(verifyInstallTree(packageDir, "9.9.9").ok).toBe(false); + rmSync(join(packageDir, "bin", "ocx.mjs")); + expect(verifyInstallTree(packageDir).ok).toBe(false); + }); + + test("happy path swaps and keeps a backup; stage scaffolding is gone", () => { + const result = transactionalNpmUpdate({ + packageDir, pkgName: PKG, targetVersion: "2.0.0", tag: "latest", + runNpm: stagingNpm("2.0.0"), + }); + expect(result.ok).toBe(true); + expect(liveVersion(packageDir)).toBe("2.0.0"); + expect(existsSync(result.backup!)).toBe(true); + expect(liveVersion(result.backup!)).toBe("1.0.0"); + }); + + test("stage install failure leaves live untouched (D4 row 1)", () => { + const result = transactionalNpmUpdate({ + packageDir, pkgName: PKG, targetVersion: "2.0.0", tag: "latest", + runNpm: stagingNpm("2.0.0", { fail: true }), + }); + expect(result.ok).toBe(false); + expect(result.phase).toBe("stage"); + expect(liveVersion(packageDir)).toBe("1.0.0"); + }); + + test("verification failure leaves live untouched (D4 row 2)", () => { + const result = transactionalNpmUpdate({ + packageDir, pkgName: PKG, targetVersion: "2.0.0", tag: "latest", + runNpm: stagingNpm("2.0.0", { truncate: true }), + }); + expect(result.ok).toBe(false); + expect(result.phase).toBe("verify"); + expect(liveVersion(packageDir)).toBe("1.0.0"); + }); + + test("wrong staged version is refused before any swap", () => { + const result = transactionalNpmUpdate({ + packageDir, pkgName: PKG, targetVersion: "2.0.0", tag: "latest", + runNpm: stagingNpm("3.0.0"), + }); + expect(result.ok).toBe(false); + expect(result.phase).toBe("verify"); + expect(liveVersion(packageDir)).toBe("1.0.0"); + }); + + test("swap-live failure rolls back to the old tree (D4 locked-file row)", () => { + let renames = 0; + const result = transactionalNpmUpdate({ + packageDir, pkgName: PKG, targetVersion: "2.0.0", tag: "latest", + runNpm: stagingNpm("2.0.0"), + deps: { + rename: (from: string, to: string) => { + renames += 1; + if (renames === 2) throw new Error("EBUSY: locked"); + const { renameSync } = require("node:fs"); + renameSync(from, to); + }, + }, + }); + expect(result.ok).toBe(false); + expect(result.phase).toBe("swap-live"); + expect(result.rolledBack).toBe(true); + expect(liveVersion(packageDir)).toBe("1.0.0"); + }); + + test("double fault writes the recovery marker with a one-line restore (D4 last row)", () => { + let renames = 0; + const result = transactionalNpmUpdate({ + packageDir, pkgName: PKG, targetVersion: "2.0.0", tag: "latest", + runNpm: stagingNpm("2.0.0"), + deps: { + rename: (from: string, to: string) => { + renames += 1; + if (renames >= 2) throw new Error("EPERM: still locked"); + const { renameSync } = require("node:fs"); + renameSync(from, to); + }, + }, + }); + expect(result.ok).toBe(false); + expect(result.phase).toBe("double-fault"); + const marker = JSON.parse(readFileSync(join(scopeDir, ".ocx-recovery.json"), "utf8")); + expect(marker.restore).toContain("opencodex"); + }); + + test("boot probe restores the backup over a broken live tree (D4 power-loss rows)", () => { + // Simulate: swap moved live aside, then power loss before stage landed. + const backupRoot = join(scopeDir, ".ocx-backup-2026"); + mkdirSync(backupRoot, { recursive: true }); + const { renameSync } = require("node:fs"); + renameSync(packageDir, join(backupRoot, "opencodex")); + expect(existsSync(packageDir)).toBe(false); + const probe = bootRestoreProbe(packageDir); + expect(probe.action).toBe("restored"); + expect(liveVersion(packageDir)).toBe("1.0.0"); + }); + + test("boot probe reaps stale backups when live is healthy", () => { + const backupRoot = join(scopeDir, ".ocx-backup-2026"); + mkdirSync(join(backupRoot, "opencodex"), { recursive: true }); + writeFileSync(join(backupRoot, "opencodex", "package.json"), JSON.stringify({ version: "0.9.0" })); + const probe = bootRestoreProbe(packageDir); + expect(probe.action).toBe("reaped"); + expect(existsSync(backupRoot)).toBe(false); + }); +}); + From 9f406cfa974365dcda421cc6bbc90e68874a7e86 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 10:17:03 +0900 Subject: [PATCH 2/2] fix(update): harden transactional swap per adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verify the bundled Bun binary by size (>=10MB), not just its package.json, so the boot probe can never call a Bun-less tree healthy and reap the only backup - bounded EPERM/EBUSY/EACCES rename retry for the Windows AV/indexer class - staging/backup mkdir failures return phase errors with live untouched; an unexpected throw now reports and stops instead of falling back to the destructive in-place install - the Windows service wrapper (outside the package tree) restores the newest .ocx-backup-* sibling before declaring the install incomplete — the exact power-loss window the in-launcher probe cannot reach --- bin/ocx.mjs | 16 +++---- src/service.ts | 29 ++++++++++++ src/update/transactional-install.mjs | 70 +++++++++++++++++++++++++--- tests/service.test.ts | 7 ++- tests/update-transactional.test.ts | 3 +- 5 files changed, 106 insertions(+), 19 deletions(-) diff --git a/bin/ocx.mjs b/bin/ocx.mjs index dc78befb18..a8f5a2455c 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -304,15 +304,13 @@ function runNpmSelfUpdate() { res = { status: 1 }; } } catch (error) { - // Transactional machinery itself failed (e.g. exotic install layout): legacy path. - console.warn(`opencodex: transactional update unavailable (${error?.message ?? error}); falling back to in-place npm install.`); - console.log(`$ npm install -g ${PKG}@${tag}`); - res = spawnSync(installInvocation.file, installInvocation.args, { - stdio: "inherit", - timeout: 180000, - windowsHide: true, - ...installInvocation.options, - }); + // An unexpected throw means we cannot prove the live tree is untouched, so the + // legacy in-place install (which deletes live first) is exactly the wrong rescue — + // it recreates the #1849 destruction path. Report and stop; the boot probe and the + // recovery marker cover the swap-window states. + console.error(`opencodex: transactional update failed unexpectedly (${error?.message ?? error}). ` + + "The live install was not knowingly modified; run 'ocx update' again or reinstall with npm install -g."); + res = { status: 1 }; } if (res.status === 0) { console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`); diff --git a/src/service.ts b/src/service.ts index 9adc59fee3..8ba2b417b1 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1535,6 +1535,9 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"), windowsBatchSet("OCX_BUN", bun, "path"), windowsBatchSet("OCX_CLI", cli, "path"), + // Package root for the transactional-update restore path (#1942): cli is + // \src\cli\index.ts, so the package dir is three levels up. + 'for %%I in ("%OCX_CLI%\\..\\..\\..") do set "OCX_PKG_DIR=%%~fI"', 'if exist "%OCX_API_TOKEN_FILE%" (', ' set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"', ")", @@ -1547,10 +1550,16 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ '>>"%OCX_SERVICE_LOG%" echo codex_home="%CODEX_HOME%"', '>>"%OCX_SERVICE_LOG%" echo token_file="%OCX_API_TOKEN_FILE%"', 'if not exist "%OCX_BUN%" (', + " call :restore_backup", + ")", + 'if not exist "%OCX_BUN%" (', ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: bundled Bun is missing; reinstall opencodex, then run ocx service repair', " exit /b 3", ")", 'if not exist "%OCX_CLI%" (', + " call :restore_backup", + ")", + 'if not exist "%OCX_CLI%" (', ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: CLI entry is missing; reinstall opencodex, then run ocx service repair', " exit /b 3", ")", @@ -1563,6 +1572,26 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ " goto loop", ")", "endlocal", + "goto :eof", + "", + // #1942/#1849: a power loss mid-swap leaves the live package dir missing/broken and + // a sibling .ocx-backup-* holding the previous version. This wrapper lives OUTSIDE + // the package tree, so it can restore when the launcher itself is gone — the exact + // window the in-launcher boot probe cannot reach. + ":restore_backup", + '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] install incomplete - looking for a transactional-update backup to restore', + 'for /f "delims=" %%B in (\'dir /b /ad /o-n "%OCX_PKG_DIR%\\..\\.ocx-backup-*" 2^>nul\') do (', + ' if exist "%OCX_PKG_DIR%\\..\\%%B\\opencodex\\package.json" (', + ' if exist "%OCX_PKG_DIR%" rmdir /s /q "%OCX_PKG_DIR%" 2>nul', + ' move "%OCX_PKG_DIR%\\..\\%%B\\opencodex" "%OCX_PKG_DIR%" >nul 2>&1', + ' if exist "%OCX_PKG_DIR%\\package.json" (', + ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] restored previous install from %%B', + " goto :eof", + " )", + " )", + ")", + '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] no restorable backup found', + "goto :eof", ].filter((line): line is string => Boolean(line)); return `${lines.join("\r\n")}\r\n`; } diff --git a/src/update/transactional-install.mjs b/src/update/transactional-install.mjs index fb6126d80b..4c96917fc7 100644 --- a/src/update/transactional-install.mjs +++ b/src/update/transactional-install.mjs @@ -39,6 +39,16 @@ export function verifyInstallTree(packageDir, expectedVersion) { } catch { failures.push("bin/ocx.mjs absent"); } + // The bundled Bun binary is the load-bearing artifact: without it the launcher exits + // before serving anything, and a boot probe that called this tree healthy would reap + // the only backup (review High 3). Size-gate the real binary, not just its package.json. + const bunPkgDir = join(packageDir, "node_modules", "bun"); + if (existsSync(bunPkgDir)) { + const bunBinary = findLargestFile(bunPkgDir); + if (!bunBinary || bunBinary.size < 10 * 1024 * 1024) { + failures.push("bundled Bun binary missing or truncated (< 10MB)"); + } + } // Sentinel direct deps: each must have an intact package.json. The bundled Bun dep is // the load-bearing one — without it the launcher cannot start the proxy at all. const deps = Object.keys(pkg.dependencies ?? {}); @@ -56,6 +66,42 @@ function stampedName(prefix) { return prefix + "-" + new Date().toISOString().replace(/[:.]/g, "-"); } +/** Largest regular file under a directory tree (bounded depth) — locates the Bun binary. */ +function findLargestFile(root, depth = 3) { + let best; + let names = []; + try { names = readdirSync(root); } catch { return undefined; } + for (const name of names) { + const full = join(root, name); + let st; + try { st = statSync(full); } catch { continue; } + if (st.isFile()) { + if (!best || st.size > best.size) best = { path: full, size: st.size }; + } else if (st.isDirectory() && depth > 0) { + const sub = findLargestFile(full, depth - 1); + if (sub && (!best || sub.size > best.size)) best = sub; + } + } + return best; +} + +/** Bounded Windows-class rename retry: EPERM/EBUSY/EACCES from AV/indexers clears in ms. */ +function renameWithRetry(rename, from, to, attempts = 5) { + for (let attempt = 0; ; attempt += 1) { + try { + rename(from, to); + return; + } catch (error) { + const code = error?.code; + const retryable = code === "EPERM" || code === "EBUSY" || code === "EACCES"; + if (!retryable || attempt >= attempts - 1) throw error; + // Synchronous bounded backoff (launcher context has no async loop here). + const until = Date.now() + 100 * (attempt + 1); + while (Date.now() < until) { /* spin briefly; total worst case ~1.5s */ } + } + } +} + function recoveryMarkerPath(scopeDir) { return join(scopeDir, ".ocx-recovery.json"); } @@ -111,8 +157,14 @@ export function transactionalNpmUpdate({ const stagedPackage = join(stageRoot, "node_modules", ...pkgName.split("/")); // D1: stage to the side. --prefix keeps npm entirely inside stageRoot; the live tree - // and the npm bin shims are untouched until the swap. - mkdirSync(stageRoot, { recursive: true }); + // and the npm bin shims are untouched until the swap. A failure HERE (mkdir EACCES, + // ENOSPC) must NOT fall back to the destructive legacy install (review High 4): the + // caller sees a normal phase failure with live untouched. + try { + mkdirSync(stageRoot, { recursive: true }); + } catch (error) { + return { ok: false, phase: "stage", error: "could not create staging directory: " + (error?.message ?? String(error)) }; + } const spec = pkgName + "@" + (targetVersion || tag); log("Staging " + spec + " into " + stageRoot); const install = runNpm(["install", "--prefix", stageRoot, "--no-audit", "--no-fund", spec]); @@ -131,20 +183,25 @@ export function transactionalNpmUpdate({ // D3: swap. live -> backup, stage -> live, re-verify live, rollback on failure. const backupRoot = join(scopeDir, stampedName(".ocx-backup")); const backupPackage = join(backupRoot, "opencodex"); - mkdirSync(backupRoot, { recursive: true }); try { - rename(packageDir, backupPackage); + mkdirSync(backupRoot, { recursive: true }); + } catch (error) { + try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ } + return { ok: false, phase: "swap-backup", error: "could not create backup directory: " + (error?.message ?? String(error)) }; + } + try { + renameWithRetry(rename, packageDir, backupPackage); } catch (error) { try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ } try { rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ } return { ok: false, phase: "swap-backup", error: "could not move live tree aside: " + (error?.message ?? String(error)) }; } try { - rename(stagedPackage, packageDir); + renameWithRetry(rename, stagedPackage, packageDir); } catch (error) { // Rollback: reverse the first rename. Double fault leaves the recovery marker. try { - rename(backupPackage, packageDir); + renameWithRetry(rename, backupPackage, packageDir); try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ } try { rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ } return { ok: false, phase: "swap-live", rolledBack: true, error: "could not place staged tree: " + (error?.message ?? String(error)) }; @@ -186,4 +243,3 @@ export function transactionalNpmUpdate({ try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ } return { ok: true, phase: "done", backup: backupPackage }; } - diff --git a/tests/service.test.ts b/tests/service.test.ts index 96018f8c12..d4e6b17465 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -543,8 +543,11 @@ describe("Windows service task", () => { expect(script).toContain("installation is incomplete: bundled Bun is missing"); expect(script).toContain("installation is incomplete: CLI entry is missing"); expect(script.match(/exit \/b 3/g)).toHaveLength(2); - // `goto loop` re-enters both checks before another child spawn. - expect(script.slice(loopAt, launchAt).match(/if not exist/g)).toHaveLength(2); + // #1942: each missing-artifact branch first attempts a transactional-update backup + // restore, then re-checks before the hard stop — 2 artifacts x (probe + recheck). + expect(script.slice(loopAt, launchAt).match(/if not exist/g)).toHaveLength(4); + expect(script).toContain(":restore_backup"); + expect(script).toContain(".ocx-backup-*"); }); test("rewrites profile-relative paths to env indirection so non-ASCII usernames survive OEM-codepage batch parsing", () => { diff --git a/tests/update-transactional.test.ts b/tests/update-transactional.test.ts index f2c5ed5638..92a06cee3f 100644 --- a/tests/update-transactional.test.ts +++ b/tests/update-transactional.test.ts @@ -23,6 +23,8 @@ function writeTree(packageDir: string, version: string): void { })); writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n" + "x".repeat(2048)); writeFileSync(join(packageDir, "node_modules", "bun", "package.json"), JSON.stringify({ name: "bun" })); + // The manifest size-gates the real Bun binary (>= 10MB); give the fixture one. + writeFileSync(join(packageDir, "node_modules", "bun", "bun.exe"), Buffer.alloc(10 * 1024 * 1024 + 1024)); writeFileSync(join(packageDir, "node_modules", "zod", "package.json"), JSON.stringify({ name: "zod" })); } @@ -170,4 +172,3 @@ describe("#1942 transactional update", () => { expect(existsSync(backupRoot)).toBe(false); }); }); -