Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion src/lib/statusline.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()))');

Expand Down
24 changes: 24 additions & 0 deletions src/templates/statusline-footer.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 48 additions & 2 deletions tests/kit/statusline.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
`;

Expand Down Expand Up @@ -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 });
Expand Down
Loading