From a0aeba98261f826048c8df2fa27347be9c0d84c2 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 14 Jul 2026 13:47:05 -0700 Subject: [PATCH] fix(heal): deterministic native better-sqlite3 via escalation ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain 'npm install' only yields a native binding when npm's content store happens to hold an already-built copy of the exact version — cache history, not scripts. On a cold store, npm >=11.17's allow-scripts skips the build script and the heal raced whatever eventually built the binding (transient FAILED noise, WASM-fallback windows). ensureNativeBsq3(dir) now escalates, verifying after each rung: 1. plain install (npm <11.17, or warm content store) 2. npm approve-scripts better-sqlite3 + npm rebuild — npm's sanctioned path; approve-scripts pins the exact version into the location's package.json (harmless no-op on older npm); rebuild also recovers a stale half-built build/ dir 3. npm run install in the resolved package root — explicit 'npm run' is user-invoked and never gated by allow-scripts natives.mjs: extract bsq3Root() from bsq3IsNative() for reuse. tests/kit/natives.test.mjs: hermetic fixture tests for both helpers. E2E-verified against a script-skipped install: pre-state no binding, ladder returns 'native rebuilt (scripts approved)', binding present. --- src/lib/heal.mjs | 40 ++++++++++++++++++++++++-------- src/lib/natives.mjs | 21 ++++++++++------- tests/kit/natives.test.mjs | 47 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 tests/kit/natives.test.mjs diff --git a/src/lib/heal.mjs b/src/lib/heal.mjs index 6ad0bffa..7d86e16f 100644 --- a/src/lib/heal.mjs +++ b/src/lib/heal.mjs @@ -7,7 +7,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { run } from './exec.mjs'; import { rufloRoot, aqeRoot } from './paths.mjs'; -import { agentdbLocations, bsq3IsNative, aidefencePresent } from './natives.mjs'; +import { agentdbLocations, bsq3IsNative, bsq3Root, aidefencePresent } from './natives.mjs'; import { KIT_PKG } from './versions.mjs'; import { scanRvf, quarantine } from './rvf.mjs'; @@ -28,6 +28,34 @@ async function npmInstallInto(dir, spec) { { cwd: dir, timeout: 300_000 }); } +const failTail = (r) => + `FAILED (${(r.stderr || `exit ${r.code}`).trim().split('\n').slice(-2).join(' ').slice(0, 200)})`; + +/** Deterministic native better-sqlite3 for one location — an escalation + * ladder, verifying after each rung, stopping at the first binding: + * 1. plain install — enough on npm <11.17, or when npm's content store + * already holds a built copy of the exact version (why plain installs + * look like they "work": it depends on cache history, not on scripts). + * 2. npm approve-scripts + rebuild — npm ≥11.17's sanctioned path for the + * blocked install script (approve-scripts pins the exact version into + * the location's package.json; harmless no-op failure on older npm). + * rebuild also recovers a stale half-built build/ dir. + * 3. run the package's own install script directly — explicit `npm run` + * is user-invoked and never gated by allow-scripts. */ +export async function ensureNativeBsq3(dir) { + let r = await npmInstallInto(dir, 'better-sqlite3@^12'); + if (bsq3IsNative(dir)) return { ok: true, how: 'native installed' }; + await run('npm', ['approve-scripts', 'better-sqlite3'], { cwd: dir, timeout: 60_000 }); + r = await run('npm', ['rebuild', 'better-sqlite3'], { cwd: dir, timeout: 300_000 }); + if (bsq3IsNative(dir)) return { ok: true, how: 'native rebuilt (scripts approved)' }; + const pkgRoot = bsq3Root(dir); + if (pkgRoot) { + r = await run('npm', ['run', 'install'], { cwd: pkgRoot, timeout: 300_000 }); + if (bsq3IsNative(dir)) return { ok: true, how: 'native built via package install script' }; + } + return { ok: false, how: failTail(r) }; +} + /** Native better-sqlite3 into every agentdb location that lacks it. */ export async function healNatives() { const details = []; @@ -37,16 +65,10 @@ export async function healNatives() { // the 3.29.0 tree) between enumeration and heal. if (!fs.existsSync(dir)) continue; if (bsq3IsNative(dir)) continue; - const r = await npmInstallInto(dir, 'better-sqlite3@^12'); - details.push(`${dir}: ${r.code === 0 && bsq3IsNative(dir) - ? 'native installed' - : `FAILED (${(r.stderr || `exit ${r.code}`).trim().split('\n').slice(-2).join(' ').slice(0, 200)})`}`); + details.push(`${dir}: ${(await ensureNativeBsq3(dir)).how}`); } if (fs.existsSync(aqeRoot()) && !bsq3IsNative(aqeRoot())) { - const r = await npmInstallInto(aqeRoot(), 'better-sqlite3@^12'); - details.push(`agentic-qe: ${r.code === 0 && bsq3IsNative(aqeRoot()) - ? 'native installed' - : `FAILED (${(r.stderr || `exit ${r.code}`).trim().split('\n').slice(-2).join(' ').slice(0, 200)})`}`); + details.push(`agentic-qe: ${(await ensureNativeBsq3(aqeRoot())).how}`); } return { ok: !details.some((d) => d.includes('FAILED')), detail: details.join('; ') || 'already native everywhere' }; } diff --git a/src/lib/natives.mjs b/src/lib/natives.mjs index 376c3b1d..d2b7d52e 100644 --- a/src/lib/natives.mjs +++ b/src/lib/natives.mjs @@ -16,18 +16,23 @@ export function agentdbLocations() { .filter((p) => fs.existsSync(p)); } -/** Does better-sqlite3, as resolved from `fromDir` (real Node resolution), - * have a native binding? Mirrors ruflo-patch-native's check. */ -export function bsq3IsNative(fromDir) { - let entry; +/** Package root of better-sqlite3 as resolved from `fromDir` (real Node + * resolution), or null if not resolvable. */ +export function bsq3Root(fromDir) { try { const req = createRequire(path.join(fromDir, 'noop.js')); - entry = req.resolve('better-sqlite3'); // …/better-sqlite3/lib/index.js + const entry = req.resolve('better-sqlite3'); // …/better-sqlite3/lib/index.js + return path.join(entry.slice(0, entry.lastIndexOf(`${path.sep}better-sqlite3${path.sep}`)), 'better-sqlite3'); } catch { - return false; // not resolvable at all + return null; } - const pkgRoot = path.join(entry.slice(0, entry.lastIndexOf(`${path.sep}better-sqlite3${path.sep}`)), 'better-sqlite3'); - return fs.existsSync(path.join(pkgRoot, 'build', 'Release', 'better_sqlite3.node')); +} + +/** Does better-sqlite3, as resolved from `fromDir`, have a native binding? + * Mirrors ruflo-patch-native's check. */ +export function bsq3IsNative(fromDir) { + const root = bsq3Root(fromDir); + return !!root && fs.existsSync(path.join(root, 'build', 'Release', 'better_sqlite3.node')); } export function nativesStatus() { diff --git a/tests/kit/natives.test.mjs b/tests/kit/natives.test.mjs new file mode 100644 index 00000000..6bd6fcac --- /dev/null +++ b/tests/kit/natives.test.mjs @@ -0,0 +1,47 @@ +// bsq3Root / bsq3IsNative — the resolution + binding check behind the +// natives heal ladder. Uses a synthetic node_modules fixture so the test +// is hermetic (no npm, no network) and runs on the full CI matrix. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { bsq3Root, bsq3IsNative } from '../../src/lib/natives.mjs'; + +function makeFixture({ withBinding }) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-natives-')); + const pkg = path.join(dir, 'node_modules', 'better-sqlite3'); + fs.mkdirSync(path.join(pkg, 'lib'), { recursive: true }); + fs.writeFileSync(path.join(pkg, 'package.json'), + JSON.stringify({ name: 'better-sqlite3', version: '12.0.0', main: 'lib/index.js' })); + fs.writeFileSync(path.join(pkg, 'lib', 'index.js'), 'module.exports = {};\n'); + if (withBinding) { + fs.mkdirSync(path.join(pkg, 'build', 'Release'), { recursive: true }); + fs.writeFileSync(path.join(pkg, 'build', 'Release', 'better_sqlite3.node'), ''); + } + return { dir, pkg }; +} + +test('bsq3Root resolves the package root through node resolution', () => { + const { dir, pkg } = makeFixture({ withBinding: true }); + assert.equal(fs.realpathSync(bsq3Root(dir)), fs.realpathSync(pkg)); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('bsq3Root returns null when better-sqlite3 is not resolvable', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-natives-empty-')); + assert.equal(bsq3Root(dir), null); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('bsq3IsNative is true when the compiled binding exists', () => { + const { dir } = makeFixture({ withBinding: true }); + assert.equal(bsq3IsNative(dir), true); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('bsq3IsNative is false for a WASM-fallback install (no binding file)', () => { + const { dir } = makeFixture({ withBinding: false }); + assert.equal(bsq3IsNative(dir), false); + fs.rmSync(dir, { recursive: true, force: true }); +});