From 89d3cb3d2ca8ce0e697c2c2b299ff255f4983749 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Fri, 17 Jul 2026 07:43:21 -0700 Subject: [PATCH] fix(statusline): resolve the real CLI bins so delegation stops falling to stale npx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A machine running ruflo 3.32.2 β€” whose getSecurityStatus is fixed, and whose kit CVE overlay had therefore correctly retired itself β€” still rendered the fabricated "⚠ 1 CVE" and a perpetually stuck "πŸ›‘ scanning…". Upstream's resolveCliBinCandidates probes filenames no shipped package ships: ruflo's bin map is {"ruflo": "bin/ruflo.js"} (no cli.js), and @claude-flow/cli β€” which does ship bin/cli.js β€” is ruflo's nested dependency, never a top-level global. Every candidate misses, always, so the statusline silently falls through to `npx --prefer-offline @claude-flow/cli`: whatever stale version the npx cache holds. Here that was 3.28.0, whose hardcoded {totalCves: 3, cvesFixed: 2} renders as "1 CVE" and IN_PROGRESS ("scanning…") forever β€” there is no scan running. The retirement gate misfire is the instructive part: the gate probed the globally installed CLI (genuinely fixed) while the render path executed a stale npx copy the gate never saw. Fix: a ruflo-bin wrapper block (same function-declaration-hoisting mechanism as the security overlay) prepends bins verified to exist on disk β€” bin/ruflo.js under the global/local ruflo root, plus the nested @claude-flow/cli/bin/cli.js β€” and keeps upstream's candidates as the tail. Deliberately NO retirement gate: the wrapper only prepends existing paths and falls back to upstream's list on any error, so on a fixed upstream it converges to the same delegation instead of fighting it. A gate would be one more proxy-probe that can misfire. Verified live: with the wrapper injected, a fresh render delegates to the real 3.32.2 bin and shows "πŸ›‘ βœ“" with no CVE; the two new tests fail without the fix (missing wrapper; candidates stay ['/orig']). --- src/lib/statusline.mjs | 48 ++++++++++++++++++++++++++- src/templates/statusline-footer.cjs | 24 ++++++++++++++ tests/kit/statusline.test.mjs | 50 +++++++++++++++++++++++++++-- 3 files changed, 119 insertions(+), 3 deletions(-) diff --git a/src/lib/statusline.mjs b/src/lib/statusline.mjs index 418f5c15..fe749651 100644 --- a/src/lib/statusline.mjs +++ b/src/lib/statusline.mjs @@ -53,6 +53,46 @@ const SEC_WRAP = [ ].join('\n'); const SEC_WRAP_STRIP = /\/\* ruflo-sec:BEGIN \*\/[\s\S]*?\/\* ruflo-sec:END \*\/\n?/g; +// (e) Bin-resolution wrapper. Upstream's resolveCliBinCandidates probes filenames +// that no shipped package ships: ruflo's bin map is {"ruflo": "bin/ruflo.js"} (no +// cli.js), and @claude-flow/cli β€” which DOES ship bin/cli.js β€” is ruflo's nested +// dependency, not a top-level global install. Every candidate therefore misses and +// the statusline silently falls through to `npx --prefer-offline @claude-flow/cli`, +// i.e. whatever stale version the npx cache holds. That is how a machine whose +// installed 3.32.2 carried the CVE-counter fix still rendered the fabricated +// "⚠ 1 CVE" / perpetual "scanning…" from a cached 3.28.0. +// +// Unlike the security overlay there is deliberately NO retirement gate: the wrapper +// only PREPENDS bins verified to exist on disk (rufloRealCliBins, injected with the +// footer) and keeps upstream's own candidates as the tail, so on a fixed upstream it +// converges to the same delegation instead of fighting it. A gate would be one more +// proxy-probe that can misfire β€” the CVE gate watched the global install while the +// render path executed a stale npx copy. Same function-declaration-hoisting +// mechanism as the security wrapper; typeof-guarded so it is inert on templates +// without the function (e.g. the minimal statusline-v3.cjs). The inner try around +// the CWD read absorbs the TDZ ReferenceError if a future template declares CWD +// with let/const after this block yet calls the resolver during top-level eval. +const BIN_WRAP = [ + '/* ruflo-bin:BEGIN */', + 'try {', + ' if (typeof resolveCliBinCandidates === "function") {', + ' var _rufloOrigResolveCliBins = resolveCliBinCandidates;', + ' resolveCliBinCandidates = function(){', + ' var orig = [];', + ' try { orig = _rufloOrigResolveCliBins.apply(this, arguments) || []; } catch(e){}', + ' try {', + ' var cwd = process.cwd();', + ' try { if (typeof CWD === "string" && CWD) cwd = CWD; } catch(e){}', + ' var real = (typeof rufloRealCliBins === "function") ? rufloRealCliBins(cwd) : [];', + ' return real.concat(orig.filter(function(p){ return real.indexOf(p) === -1; }));', + ' } catch(e){ return orig; }', + ' };', + ' }', + '} catch(e){}', + '/* ruflo-bin:END */', +].join('\n'); +const BIN_WRAP_STRIP = /\/\* ruflo-bin:BEGIN \*\/[\s\S]*?\/\* ruflo-bin:END \*\/\n?/g; + /** Upstream defect: ruvnet/ruflo#2694. * True while ruflo's getSecurityStatus() still FABRICATES the CVE count β€” i.e. the * installed CLI still has `const totalCves = 3` (a hardcoded constant naming ruflo's @@ -93,10 +133,16 @@ export function fixStatusline(root = process.cwd(), { dryRun = false } = {}) { // (d) security overlay: stripped unconditionally BEFORE the gate is consulted, so the // stopgap retires itself on the first sync after upstream fixes getSecurityStatus. s = s.replace(SEC_WRAP_STRIP, ''); + // (e) bin wrapper: stripped unconditionally like the others, re-injected always β€” + // no gate (see BIN_WRAP), it self-neutralizes on a template it doesn't fit. + s = s.replace(BIN_WRAP_STRIP, ''); const securityOverlay = upstreamCveCounterFabricated(); const lines = s.split('\n'); const at = lines[0]?.startsWith('#!') ? 1 : 0; - lines.splice(at, 0, securityOverlay ? footer + '\n' + SEC_WRAP : footer); + const blocks = [footer]; + if (securityOverlay) blocks.push(SEC_WRAP); + blocks.push(BIN_WRAP); + lines.splice(at, 0, blocks.join('\n')); s = lines.join('\n'); s = s.replace(/console\.log\(generateStatusline\(\)\)/, 'console.log(generateStatusline() + rufloActivationSegments(process.cwd()))'); diff --git a/src/templates/statusline-footer.cjs b/src/templates/statusline-footer.cjs index 84322fa3..24ee6802 100644 --- a/src/templates/statusline-footer.cjs +++ b/src/templates/statusline-footer.cjs @@ -351,6 +351,30 @@ function rufloFindRufloRoot(){ return ""; } catch(e){ return ""; } } +// ── real CLI bins (companion to the ruflo-bin wrapper) ────────────────────── +// Upstream's resolveCliBinCandidates looks for `ruflo/bin/cli.js`, but the ruflo +// package ships `bin/ruflo.js` (package.json bin: {"ruflo": "bin/ruflo.js"}) β€” +// a filename that never exists. @claude-flow/cli DOES ship bin/cli.js, but it is +// ruflo's nested dependency, not a global top-level install, so that candidate +// misses too. Every candidate therefore fails and the statusline silently falls +// through to `npx --prefer-offline @claude-flow/cli`, which serves whatever stale +// version happens to sit in the npx cache β€” that is how a machine running a fixed +// ruflo 3.32.2 still rendered the FABRICATED "⚠ 1 CVE" from a cached 3.28.0. +// Returns only paths that exist; [] means "nothing found", never a guess. +function rufloRealCliBins(cwd){ + try { + var fs = require("fs"), path = require("path"); + var roots = [], out = []; + var g = rufloFindRufloRoot(); + if (g) roots.push(g); + if (cwd) roots.push(path.join(cwd, "node_modules", "ruflo")); + for (var i = 0; i < roots.length; i++) { + out.push(path.join(roots[i], "bin", "ruflo.js")); + out.push(path.join(roots[i], "node_modules", "@claude-flow", "cli", "bin", "cli.js")); + } + return out.filter(function(p){ try { return fs.existsSync(p); } catch(e){ return false; } }); + } catch(e){ return []; } +} // Three states, not two β€” the distinction IS the fail-safe. "off" is asserted only on // positive evidence: a real ruflo install that does not contain aidefence. Anything we // cannot verify is "unknown" and stays silent, because a false "your injection defense diff --git a/tests/kit/statusline.test.mjs b/tests/kit/statusline.test.mjs index 9db02abc..c5c5043b 100644 --- a/tests/kit/statusline.test.mjs +++ b/tests/kit/statusline.test.mjs @@ -16,12 +16,16 @@ import { execFileSync } from 'node:child_process'; import { _setGlobalRootForTest } from '../../src/lib/paths.mjs'; import { fixStatusline, upstreamCveCounterFabricated } from '../../src/lib/statusline.mjs'; -// Minimal stand-in for ruflo's real statusline: only the shapes fixStatusline keys off. +// Minimal stand-in for ruflo's real statusline: only the shapes fixStatusline keys +// off. resolveCliBinCandidates models the upstream defect β€” candidates that never +// exist β€” and generateStatusline prints what the resolver returns so a test can RUN +// the patched file and observe the wrapper's effect, not just its presence. const HOST = `#!/usr/bin/env node let ver = "3.0.0"; function applyLocalOverlays(data) { return data; } function getStatuslineData() { return { security: { status: 'IN_PROGRESS', cvesFixed: 2, totalCves: 3 } }; } -function generateStatusline() { return 'x'; } +function resolveCliBinCandidates() { return ['/orig']; } +function generateStatusline() { return 'BINS:' + resolveCliBinCandidates().join('|'); } console.log(generateStatusline()) `; @@ -80,9 +84,51 @@ test('injection is idempotent β€” repeated syncs never stack blocks', () => { const out = fs.readFileSync(sl, 'utf8'); assert.equal(count(out, /ruflo-sec:BEGIN/g), 1); assert.equal(count(out, /ruflo-seg:BEGIN/g), 1); + assert.equal(count(out, /ruflo-bin:BEGIN/g), 1); assert.equal(r3.applied, false, 'a converged file must report no change'); }); +// ── bin-resolution wrapper ──────────────────────────────────────────────────── +// Upstream's resolveCliBinCandidates probes filenames no shipped package ships, +// so delegation silently falls through to a possibly-stale npx cache. The wrapper +// prepends bins that actually exist. Crucially it has NO retirement gate β€” the +// fabricated "⚠ 1 CVE" survived on a machine whose gate had correctly retired the +// security overlay, precisely because the render path executed a stale npx copy +// the gate never probed. + +test('bin wrapper is injected even when the security overlay is retired', () => { + // buggyUpstream:false = the exact state that bit us: CVE gate retired, bin path broken. + const { proj, sl } = fixture({ buggyUpstream: false }); + const r = fixStatusline(proj); + assert.equal(r.securityOverlay, false, 'precondition: the gated overlay must be off'); + const out = fs.readFileSync(sl, 'utf8'); + assert.match(out, /ruflo-bin:BEGIN/); + assert.match(out, /function rufloRealCliBins/, 'footer helper the wrapper depends on'); +}); + +test('bin wrapper prepends real bins ahead of upstream candidates at run time', () => { + const { proj, sl } = fixture({ buggyUpstream: false }); + // A project-local ruflo whose REAL bin layout (bin/ruflo.js, nested cli) exists on disk. + const rufloBin = path.join(proj, 'node_modules', 'ruflo', 'bin'); + fs.mkdirSync(rufloBin, { recursive: true }); + fs.writeFileSync(path.join(rufloBin, 'ruflo.js'), ''); + fixStatusline(proj); + const stdout = execFileSync(process.execPath, [sl], { cwd: proj, encoding: 'utf8' }); + const real = stdout.indexOf(path.join(rufloBin, 'ruflo.js')); + const orig = stdout.indexOf('/orig'); + assert.notEqual(real, -1, 'the on-disk bin upstream can never find must be a candidate'); + assert.notEqual(orig, -1, "upstream's own candidates must survive as the tail"); + assert.ok(real < orig, 'real bins come first β€” they are the ones verified to exist'); +}); + +test('bin wrapper is inert on a template without resolveCliBinCandidates', () => { + const { proj, sl } = fixture({ buggyUpstream: false }); + fs.writeFileSync(sl, '#!/usr/bin/env node\nlet ver = "3.0.0";\nconsole.log("x")\n'); + fixStatusline(proj); + const stdout = execFileSync(process.execPath, [sl], { cwd: proj, encoding: 'utf8' }); + assert.match(stdout, /^x/, 'typeof guard: the wrapper must not break a template it does not fit'); +}); + // The self-retirement contract: no version pin, no manual cleanup step. test('overlay retires itself once upstream is fixed', () => { const { proj, sl } = fixture({ buggyUpstream: true });