From 4301b26eae48965348289ff5bca7e7641a6331a6 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Fri, 17 Jul 2026 07:57:08 -0700 Subject: [PATCH] feat(sync): prune npx cache envs serving outdated ruflo-family code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npx envs (/_npx//) are snapshots keyed by requested spec: once `npx @claude-flow/cli` caches a version, --prefer-offline serves that copy forever — upgrading the global install never touches it. The statusline/hook npx fallbacks execute these verbatim, which is how a machine running a fixed ruflo 3.32.2 kept rendering the fabricated CVE counter from a cached 3.28.0 (#28): six such envs held ~6.4 GB of retired code spanning ruflo 3.10-3.28 and agentic-qe 3.11.5. New `npx` status row + sync heal automates what #28 remediated by hand. The prune rule is conservative by construction — a miss fails safe as "not pruned", never a wrong prune: - every package the env is keyed to must be kit-managed (ruflo, @claude-flow/cli, agentic-qe); an env we can't fully judge is exempt - each needs an installed global baseline (@claude-flow/cli resolves from its NESTED location under ruflo — the same layout fact behind the #28 bin fix; there is never a top-level global copy) - only a cached copy STRICTLY older than its baseline counts; equal or newer stays, since a current cache is what a pre-install machine's npx fallback runs Runs on the `npx` row or after `versions` upgrades — an upgrade is precisely what turns a previously-current cache stale. The cache dir is resolved from npm_config_cache or platform defaults without spawning npm; a custom userconfig cache path is missed, which only means an empty scan. Verified end-to-end with a synthetic stale env planted in the real cache: status detects (warn row), sync --dry-run plans it, the heal prunes exactly that env (current and foreign envs untouched), and status converges to ok. --- src/commands/status.mjs | 17 ++++++ src/commands/sync.mjs | 9 ++++ src/lib/npx.mjs | 76 ++++++++++++++++++++++++++ src/lib/paths.mjs | 13 +++++ tests/kit/npx.test.mjs | 117 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 232 insertions(+) create mode 100644 src/lib/npx.mjs create mode 100644 tests/kit/npx.test.mjs diff --git a/src/commands/status.mjs b/src/commands/status.mjs index de029373..0ff4e6eb 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -6,6 +6,7 @@ import path from 'node:path'; import { glyph, dim, bold } from '../lib/output.mjs'; import * as paths from '../lib/paths.mjs'; import { nativesStatus, aidefencePresent, securityPresent } from '../lib/natives.mjs'; +import { scanNpxStale } from '../lib/npx.mjs'; import { registrationStatus } from '../lib/mcp.mjs'; import { listDaemons, staleDaemons } from '../lib/daemons.mjs'; import { scanRvf } from '../lib/rvf.mjs'; @@ -118,6 +119,22 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { rows.push(row('natives', 'warn', `native check unavailable: ${e.message}`)); } + // npx (stale ruflo-family cache envs — `npx --prefer-offline` fallbacks in the + // statusline/hooks execute these verbatim, keeping retired defects alive) + try { + const stale = scanNpxStale(); + if (stale.length) { + const what = stale.flatMap((e) => e.stale.map((s) => `${s.pkg}@${s.cached}`)).join(', '); + rows.push(row('npx', 'warn', + `${stale.length} stale npx env(s) serve outdated code (${what})`, + 'sync prunes them (npx re-fetches on demand)')); + } else { + rows.push(row('npx', 'ok', 'npx cache holds no stale ruflo-family envs')); + } + } catch (e) { + rows.push(row('npx', 'warn', `npx cache check unavailable: ${e.message}`)); + } + // security surface if (securityPresent()) { if (aidefencePresent()) { diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 8187a59e..5c36a744 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -11,6 +11,7 @@ import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs'; import { loadKitConfig } from '../lib/config.mjs'; import { HOSTS, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter } from '../lib/providers.mjs'; import { driftReport, selfDrift } from '../lib/versions.mjs'; +import { pruneNpxStale } from '../lib/npx.mjs'; import * as paths from '../lib/paths.mjs'; import { ok, warn, fail, bold, dim } from '../lib/output.mjs'; @@ -82,6 +83,14 @@ export async function run({ flags, pkgRoot }) { if (subsystems.has('natives') || subsystems.has('versions') || subsystems.has('security')) { report('natives', await heal.healNatives()); } + // npx: prune cached envs serving outdated ruflo-family code — the statusline/ + // hook `npx --prefer-offline` fallbacks execute these verbatim, so a stale env + // keeps retired defects (the fabricated CVE counter) alive on an upgraded + // machine. Runs on `versions` too: an upgrade is precisely what turns a + // previously-current cache stale. + if (subsystems.has('npx') || subsystems.has('versions')) { + report('npx', pruneNpxStale()); + } if (subsystems.has('aqe')) { report('rvf', heal.healRvf(paths.projectAqeDir(cwd))); } diff --git a/src/lib/npx.mjs b/src/lib/npx.mjs new file mode 100644 index 00000000..92a93305 --- /dev/null +++ b/src/lib/npx.mjs @@ -0,0 +1,76 @@ +// Stale npx-cache detection for the ruflo family. npx envs (`/_npx/ +// /`) are snapshots keyed by requested spec: once `npx @claude-flow/cli` +// caches a version, `--prefer-offline` serves that copy forever — upgrading the +// global install never touches it. That is how a machine running a fixed ruflo +// 3.32.2 kept executing a cached 3.28.0 (statusline npx fallback) and rendering +// its fabricated CVE counter; six such envs held ~6.4 GB of retired code. +// +// Prune rule — conservative by construction, a miss only means "not pruned": +// · every package the env is keyed to (its package.json dependencies) must be +// kit-managed — an env we can't fully judge is left alone; +// · each managed package needs an installed global baseline to compare against +// — no baseline, no judgement, no prune; +// · at least one cached copy must be STRICTLY older than its baseline +// (equal-or-newer stays: a current cache is what a pre-install machine's +// npx fallback runs). +// Envs are pure caches; npx re-fetches on demand, so removal is always safe. +import fs from 'node:fs'; +import path from 'node:path'; +import { npxCacheDir, rufloNodeModules } from './paths.mjs'; +import { installedVersion, cmpVersions } from './versions.mjs'; + +const readPkg = (dir) => { + try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; } +}; + +/** Installed baseline per managed package. @claude-flow/cli is ruflo's NESTED + * dependency (never a top-level global), so it can't go through + * installedVersion — the same layout fact behind the statusline bin fix. */ +export function managedBaseline(pkg) { + if (pkg === '@claude-flow/cli') { + return readPkg(path.join(rufloNodeModules(), '@claude-flow', 'cli'))?.version ?? null; + } + if (pkg === 'ruflo' || pkg === 'agentic-qe') return installedVersion(pkg); + return null; // not a package the kit manages — never judged, never pruned +} + +/** Stale envs under the npx cache. Returns [{dir, stale: [{pkg, cached, installed}]}]. + * `root`/`baseline` are injectable so tests run against fixtures, no real cache. */ +export function scanNpxStale({ root = npxCacheDir(), baseline = managedBaseline } = {}) { + let entries; + try { entries = fs.readdirSync(root); } catch { return []; } // no cache dir: nothing to do + const out = []; + for (const name of entries) { + const dir = path.join(root, name); + const keyed = readPkg(dir)?.dependencies; + const pkgs = keyed ? Object.keys(keyed) : []; + if (!pkgs.length) continue; + const judged = pkgs.map((pkg) => ({ + pkg, + installed: baseline(pkg), + cached: readPkg(path.join(dir, 'node_modules', pkg))?.version ?? null, + })); + // One unjudgeable package (unmanaged, no baseline, unreadable copy) exempts + // the whole env — partial verdicts are how wrong prunes happen. + if (judged.some((j) => !j.installed || !j.cached)) continue; + const stale = judged.filter((j) => cmpVersions(j.cached, j.installed) < 0); + if (stale.length) out.push({ dir, stale }); + } + return out; +} + +/** Remove stale envs. Returns {ok, detail}; ok=false only on a failed removal. + * @param {{ root?: string, baseline?: (pkg: string) => string | null }} [opts] */ +export function pruneNpxStale({ root, baseline } = {}) { + const found = scanNpxStale({ ...(root && { root }), ...(baseline && { baseline }) }); + if (!found.length) return { ok: true, detail: 'no stale envs' }; + const removed = []; const failed = []; + for (const e of found) { + const label = e.stale.map((s) => `${s.pkg}@${s.cached}`).join('+'); + try { fs.rmSync(e.dir, { recursive: true, force: true }); removed.push(label); } catch { failed.push(label); } + } + const parts = []; + if (removed.length) parts.push(`pruned ${removed.length} env(s): ${removed.join(', ')} (npx re-fetches on demand)`); + if (failed.length) parts.push(`FAILED to remove: ${failed.join(', ')}`); + return { ok: !failed.length, detail: parts.join('; ') }; +} diff --git a/src/lib/paths.mjs b/src/lib/paths.mjs index 152fe653..810fe4b5 100644 --- a/src/lib/paths.mjs +++ b/src/lib/paths.mjs @@ -64,6 +64,19 @@ export function globalRoot() { /** For tests: override the cached global root. */ export function _setGlobalRootForTest(p) { _globalRoot = p; } +/** npm's npx cache (`/_npx`). Resolved from npm_config_cache or the + * platform default (~/.npm on POSIX, %LocalAppData%\npm-cache on npm>=7 + * Windows) WITHOUT spawning npm: a `npm config set cache` userconfig custom + * path would be missed, but a miss only means an empty scan — the stale-env + * prune quietly does nothing, it never prunes the wrong directory. */ +export const npxCacheDir = () => { + const cache = process.env.npm_config_cache + || (isWindows + ? path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'npm-cache') + : path.join(home, '.npm')); + return path.join(cache, '_npx'); +}; + export const rufloRoot = () => path.join(globalRoot(), 'ruflo'); export const rufloNodeModules = () => path.join(rufloRoot(), 'node_modules'); export const rufloCliDist = () => diff --git a/tests/kit/npx.test.mjs b/tests/kit/npx.test.mjs new file mode 100644 index 00000000..26fb782e --- /dev/null +++ b/tests/kit/npx.test.mjs @@ -0,0 +1,117 @@ +// scanNpxStale / pruneNpxStale — the stale npx-env prune behind `ak sync`. +// Uses a synthetic _npx fixture and an injected baseline, so the test is +// hermetic (no npm, no network, never the machine's real cache). +// +// The invariant under test is the conservative prune rule: a stale env is +// removed ONLY when every package it is keyed to is judgeable (managed + +// installed baseline + readable cached copy) and at least one cached copy is +// strictly older. Anything unjudgeable is left alone — misses must fail safe +// as "not pruned", never as a wrong prune. +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 { scanNpxStale, pruneNpxStale, managedBaseline } from '../../src/lib/npx.mjs'; +import { _setGlobalRootForTest } from '../../src/lib/paths.mjs'; + +// One npx env: //package.json (keyed spec) + node_modules/ copies. +function env(root, name, keyed, copies = {}) { + const dir = path.join(root, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ dependencies: keyed })); + for (const [pkg, version] of Object.entries(copies)) { + const p = path.join(dir, 'node_modules', pkg); + fs.mkdirSync(p, { recursive: true }); + fs.writeFileSync(path.join(p, 'package.json'), JSON.stringify({ name: pkg, version })); + } + return dir; +} + +const INSTALLED = { ruflo: '3.32.2', '@claude-flow/cli': '3.32.2', 'agentic-qe': '3.12.2' }; +const baseline = (pkg) => INSTALLED[pkg] ?? null; +const mkroot = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ak-npx-')); + +test('scan flags an env whose cached copy is strictly older than the baseline', () => { + const root = mkroot(); + const dir = env(root, 'aaa', { ruflo: '^3.21.1' }, { ruflo: '3.21.1' }); + const found = scanNpxStale({ root, baseline }); + assert.deepEqual(found, [{ dir, stale: [{ pkg: 'ruflo', installed: '3.32.2', cached: '3.21.1' }] }]); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('scan keeps an env whose cached copy matches the installed version', () => { + const root = mkroot(); + env(root, 'bbb', { '@claude-flow/cli': '^3.32.0' }, { '@claude-flow/cli': '3.32.2' }); + assert.deepEqual(scanNpxStale({ root, baseline }), []); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('scan keeps an env cached NEWER than the install — only strictly older is stale', () => { + const root = mkroot(); + env(root, 'ccc', { ruflo: '^3.33.0' }, { ruflo: '3.33.0' }); + assert.deepEqual(scanNpxStale({ root, baseline }), []); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('scan keeps an env keyed to any unmanaged package — partial verdicts never prune', () => { + const root = mkroot(); + // ruflo copy is stale, but the pnpm key is unjudgeable → whole env exempt. + env(root, 'ddd', { ruflo: '^3.21.1', pnpm: '^9' }, { ruflo: '3.21.1', pnpm: '9.0.0' }); + assert.deepEqual(scanNpxStale({ root, baseline }), []); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('scan keeps a managed env when no installed baseline exists to judge against', () => { + const root = mkroot(); + env(root, 'eee', { 'agentic-qe': '^3.11.5' }, { 'agentic-qe': '3.11.5' }); + assert.deepEqual(scanNpxStale({ root, baseline: () => null }), []); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('scan keeps an env whose cached copy is unreadable — no version, no verdict', () => { + const root = mkroot(); + env(root, 'fff', { ruflo: '^3.21.1' }, {}); // keyed but node_modules copy missing + assert.deepEqual(scanNpxStale({ root, baseline }), []); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('scan of a missing cache dir returns empty, never throws', () => { + assert.deepEqual(scanNpxStale({ root: path.join(os.tmpdir(), 'ak-npx-does-not-exist'), baseline }), []); +}); + +test('prune removes exactly the stale envs and reports what it removed', () => { + const root = mkroot(); + const stale = env(root, 'stale', { '@claude-flow/cli': '^3.28.0' }, { '@claude-flow/cli': '3.28.0' }); + const current = env(root, 'current', { ruflo: '^3.32.2' }, { ruflo: '3.32.2' }); + const foreign = env(root, 'foreign', { typescript: '^5' }, { typescript: '5.5.0' }); + + const r = pruneNpxStale({ root, baseline }); + + assert.equal(r.ok, true); + assert.match(r.detail, /@claude-flow\/cli@3\.28\.0/); + assert.equal(fs.existsSync(stale), false, 'stale env removed'); + assert.equal(fs.existsSync(current), true, 'current env kept'); + assert.equal(fs.existsSync(foreign), true, 'foreign env kept'); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('prune reports "no stale envs" on a clean cache without touching anything', () => { + const root = mkroot(); + const kept = env(root, 'ok', { ruflo: '^3.32.2' }, { ruflo: '3.32.2' }); + const r = pruneNpxStale({ root, baseline }); + assert.deepEqual(r, { ok: true, detail: 'no stale envs' }); + assert.equal(fs.existsSync(kept), true); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('managedBaseline resolves @claude-flow/cli from its NESTED location under ruflo', () => { + const groot = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-npx-groot-')); + const nested = path.join(groot, 'ruflo', 'node_modules', '@claude-flow', 'cli'); + fs.mkdirSync(nested, { recursive: true }); + fs.writeFileSync(path.join(nested, 'package.json'), JSON.stringify({ version: '3.32.2' })); + _setGlobalRootForTest(groot); + assert.equal(managedBaseline('@claude-flow/cli'), '3.32.2'); + assert.equal(managedBaseline('left-pad'), null, 'unmanaged packages are never judged'); + fs.rmSync(groot, { recursive: true, force: true }); +});