@ijfw/memory-server 1.4.1 → 1.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/active-extension-writer.js +284 -4
- package/src/cross-orchestrator.js +164 -2
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client-planning.html +273 -0
- package/src/dashboard-client.html +213 -1
- package/src/dashboard-server.js +186 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +40 -0
- package/src/dispatch/quota-cli.js +42 -0
- package/src/dispatch/registry-cli.js +339 -0
- package/src/dispatch/signer-cli.js +311 -0
- package/src/dispatch/wave-cli.js +128 -0
- package/src/extension-manifest-schema.js +25 -0
- package/src/extension-permission-check.mjs +61 -0
- package/src/extension-quota-tracker.js +305 -0
- package/src/extension-registry-ws.js +347 -0
- package/src/extension-registry.js +819 -149
- package/src/extension-signer.js +105 -0
- package/src/fs-lock.js +205 -0
- package/src/hardware-signer.js +493 -0
- package/src/ide-detect.js +122 -0
- package/src/orchestrator/review.js +101 -0
- package/src/orchestrator/status-protocol.js +168 -0
- package/src/orchestrator/verification-gate.js +97 -0
- package/src/orchestrator/wave-state.js +255 -0
- package/src/runtime-mediator.js +31 -0
- package/src/server.js +180 -18
- package/src/swarm-config.js +32 -8
|
@@ -593,6 +593,31 @@ tr:hover td{background:var(--surface)}
|
|
|
593
593
|
</div>
|
|
594
594
|
</div>
|
|
595
595
|
|
|
596
|
+
<!-- Sub-section: Charts (B19) -->
|
|
597
|
+
<div class="card">
|
|
598
|
+
<div class="ctitle">Audit Charts</div>
|
|
599
|
+
<div id="ext-charts-content">
|
|
600
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:12px">
|
|
601
|
+
<div>
|
|
602
|
+
<div style="color:var(--fg-dim);margin-bottom:4px">Events / hour (24h)</div>
|
|
603
|
+
<canvas id="ext-chart-hourly" width="320" height="80" style="width:100%;height:80px;display:block"></canvas>
|
|
604
|
+
</div>
|
|
605
|
+
<div>
|
|
606
|
+
<div style="color:var(--fg-dim);margin-bottom:4px">Deny rate by extension</div>
|
|
607
|
+
<canvas id="ext-chart-byext" width="320" height="80" style="width:100%;height:80px;display:block"></canvas>
|
|
608
|
+
</div>
|
|
609
|
+
<div>
|
|
610
|
+
<div style="color:var(--fg-dim);margin-bottom:4px">Top denied tools</div>
|
|
611
|
+
<canvas id="ext-chart-bytool" width="320" height="120" style="width:100%;height:120px;display:block"></canvas>
|
|
612
|
+
</div>
|
|
613
|
+
<div>
|
|
614
|
+
<div style="color:var(--fg-dim);margin-bottom:4px">Quota usage</div>
|
|
615
|
+
<div id="ext-chart-quotas" style="display:flex;flex-direction:column;gap:6px"></div>
|
|
616
|
+
</div>
|
|
617
|
+
</div>
|
|
618
|
+
</div>
|
|
619
|
+
</div>
|
|
620
|
+
|
|
596
621
|
<!-- Sub-section: Permission events -->
|
|
597
622
|
<div class="card">
|
|
598
623
|
<div class="ctitle" style="justify-content:space-between">
|
|
@@ -1377,7 +1402,18 @@ async function loadExtensionActive() {
|
|
|
1377
1402
|
permsEl.setAttribute('style', 'font-size:12px;color:var(--fg-dim)');
|
|
1378
1403
|
var reads = (a.permissions.reads || []).join(', ') || 'none';
|
|
1379
1404
|
var writes = (a.permissions.writes || []).join(', ') || 'none';
|
|
1380
|
-
|
|
1405
|
+
var bReads = document.createElement('b');
|
|
1406
|
+
bReads.setAttribute('style', 'color:var(--fg)');
|
|
1407
|
+
bReads.textContent = 'reads:';
|
|
1408
|
+
var textReads = document.createTextNode(' ' + reads + '\u00A0\u00A0');
|
|
1409
|
+
var bWrites = document.createElement('b');
|
|
1410
|
+
bWrites.setAttribute('style', 'color:var(--fg)');
|
|
1411
|
+
bWrites.textContent = 'writes:';
|
|
1412
|
+
var textWrites = document.createTextNode(' ' + writes);
|
|
1413
|
+
permsEl.appendChild(bReads);
|
|
1414
|
+
permsEl.appendChild(textReads);
|
|
1415
|
+
permsEl.appendChild(bWrites);
|
|
1416
|
+
permsEl.appendChild(textWrites);
|
|
1381
1417
|
wrap.appendChild(permsEl);
|
|
1382
1418
|
}
|
|
1383
1419
|
el.appendChild(wrap);
|
|
@@ -1526,7 +1562,183 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
1526
1562
|
loadExtensions();
|
|
1527
1563
|
loadExtensionActive();
|
|
1528
1564
|
loadExtensionEvents();
|
|
1565
|
+
loadExtensionCharts();
|
|
1529
1566
|
});
|
|
1567
|
+
|
|
1568
|
+
// ====== AUDIT CHARTS (B19) ======
|
|
1569
|
+
// Inlined to keep dashboard a single self-contained HTML file.
|
|
1570
|
+
|
|
1571
|
+
function _ijfwChartTheme(el) {
|
|
1572
|
+
var fg = '#9ad2ff', bg = 'rgba(154,210,255,0.18)', warn = '#ff9b3a', txt = '#cfd6dd';
|
|
1573
|
+
try {
|
|
1574
|
+
var cs = getComputedStyle(el);
|
|
1575
|
+
fg = (cs.getPropertyValue('--ijfw-chart-fg') || '').trim() || fg;
|
|
1576
|
+
bg = (cs.getPropertyValue('--ijfw-chart-bg') || '').trim() || bg;
|
|
1577
|
+
warn = (cs.getPropertyValue('--ijfw-chart-warning') || '').trim() || warn;
|
|
1578
|
+
txt = (cs.getPropertyValue('--ijfw-chart-text') || '').trim() || txt;
|
|
1579
|
+
} catch (e) {}
|
|
1580
|
+
return { fg: fg, bg: bg, warn: warn, text: txt };
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
function _ijfwLineChart(canvas, points) {
|
|
1584
|
+
if (!canvas || !canvas.getContext) return;
|
|
1585
|
+
var ctx = canvas.getContext('2d');
|
|
1586
|
+
var W = canvas.width, H = canvas.height;
|
|
1587
|
+
var theme = _ijfwChartTheme(canvas);
|
|
1588
|
+
ctx.clearRect(0, 0, W, H);
|
|
1589
|
+
if (!points || !points.length) return;
|
|
1590
|
+
var xs = points.map(function(p){ return p.x; });
|
|
1591
|
+
var ys = points.map(function(p){ return Math.max(0, p.y); });
|
|
1592
|
+
var xMin = Math.min.apply(null, xs), xMax = Math.max.apply(null, xs);
|
|
1593
|
+
if (xMax === xMin) xMax = xMin + 1;
|
|
1594
|
+
var yMax = Math.max(1, Math.max.apply(null, ys));
|
|
1595
|
+
var pad = 4, innerW = W - pad*2, innerH = H - pad*2;
|
|
1596
|
+
function px(x){ return pad + ((x - xMin)/(xMax - xMin)) * innerW; }
|
|
1597
|
+
function py(y){ return pad + (1 - (y / yMax)) * innerH; }
|
|
1598
|
+
ctx.beginPath(); ctx.moveTo(px(points[0].x), H - pad);
|
|
1599
|
+
for (var i = 0; i < points.length; i++) ctx.lineTo(px(points[i].x), py(points[i].y));
|
|
1600
|
+
ctx.lineTo(px(points[points.length-1].x), H - pad);
|
|
1601
|
+
ctx.closePath(); ctx.fillStyle = theme.bg; ctx.fill();
|
|
1602
|
+
ctx.beginPath(); ctx.moveTo(px(points[0].x), py(points[0].y));
|
|
1603
|
+
for (var j = 1; j < points.length; j++) ctx.lineTo(px(points[j].x), py(points[j].y));
|
|
1604
|
+
ctx.strokeStyle = theme.fg; ctx.lineWidth = 1.5; ctx.stroke();
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function _ijfwBarChart(canvas, bars, horizontal) {
|
|
1608
|
+
if (!canvas || !canvas.getContext) return;
|
|
1609
|
+
var ctx = canvas.getContext('2d');
|
|
1610
|
+
var W = canvas.width, H = canvas.height;
|
|
1611
|
+
var theme = _ijfwChartTheme(canvas);
|
|
1612
|
+
ctx.clearRect(0, 0, W, H);
|
|
1613
|
+
if (!bars || !bars.length) return;
|
|
1614
|
+
var values = bars.map(function(b){ return Math.max(0, b.value || 0); });
|
|
1615
|
+
var maxVal = Math.max(1, Math.max.apply(null, values));
|
|
1616
|
+
var pad = 4;
|
|
1617
|
+
var labelGutter = horizontal ? 80 : 14;
|
|
1618
|
+
var innerW = W - pad*2 - (horizontal ? labelGutter : 0);
|
|
1619
|
+
var innerH = H - pad*2 - (horizontal ? 0 : labelGutter);
|
|
1620
|
+
var slot = (horizontal ? innerH : innerW) / bars.length;
|
|
1621
|
+
var barW = Math.max(1, slot * 0.7);
|
|
1622
|
+
for (var i = 0; i < bars.length; i++) {
|
|
1623
|
+
var v = values[i];
|
|
1624
|
+
var color = bars[i].color || theme.fg;
|
|
1625
|
+
if (horizontal) {
|
|
1626
|
+
var y = pad + i*slot + (slot-barW)/2;
|
|
1627
|
+
var len = (v/maxVal) * innerW;
|
|
1628
|
+
ctx.fillStyle = theme.bg; ctx.fillRect(pad + labelGutter, y, innerW, barW);
|
|
1629
|
+
ctx.fillStyle = color; ctx.fillRect(pad + labelGutter, y, len, barW);
|
|
1630
|
+
ctx.fillStyle = theme.text; ctx.fillText(String(bars[i].label || ''), pad, y + barW*0.75);
|
|
1631
|
+
} else {
|
|
1632
|
+
var x = pad + i*slot + (slot-barW)/2;
|
|
1633
|
+
var len2 = (v/maxVal) * innerH;
|
|
1634
|
+
ctx.fillStyle = theme.bg; ctx.fillRect(x, pad, barW, innerH);
|
|
1635
|
+
ctx.fillStyle = color; ctx.fillRect(x, pad + (innerH - len2), barW, len2);
|
|
1636
|
+
ctx.fillStyle = theme.text; ctx.fillText(String(bars[i].label || '').slice(0, 8), x, H - pad);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
function _ijfwProgressBar(parent, data) {
|
|
1642
|
+
if (!parent) return;
|
|
1643
|
+
var theme = _ijfwChartTheme(parent);
|
|
1644
|
+
var cur = Math.max(0, data.current || 0);
|
|
1645
|
+
var lim = (data.limit === null || data.limit === undefined) ? null : data.limit;
|
|
1646
|
+
var label = data.label || '';
|
|
1647
|
+
var warn = Boolean(data.warning);
|
|
1648
|
+
var row = document.createElement('div');
|
|
1649
|
+
row.className = 'ijfw-progress' + (warn ? ' ijfw-progress--warn' : '');
|
|
1650
|
+
row.setAttribute('style', 'display:flex;align-items:center;gap:6px;font-size:11px');
|
|
1651
|
+
var lbl = document.createElement('span'); lbl.textContent = label; lbl.setAttribute('style','min-width:88px;color:var(--fg-dim)');
|
|
1652
|
+
row.appendChild(lbl);
|
|
1653
|
+
if (lim === null) {
|
|
1654
|
+
var val = document.createElement('span'); val.textContent = cur + ' / unlimited';
|
|
1655
|
+
val.setAttribute('style','color:var(--fg-dim)');
|
|
1656
|
+
row.appendChild(val);
|
|
1657
|
+
} else {
|
|
1658
|
+
var rail = document.createElement('span');
|
|
1659
|
+
rail.setAttribute('style', 'background:' + theme.bg + ';display:inline-block;height:6px;width:120px;border-radius:3px;overflow:hidden;vertical-align:middle');
|
|
1660
|
+
var fill = document.createElement('span');
|
|
1661
|
+
var pct = lim > 0 ? Math.min(100, (cur/lim)*100) : 0;
|
|
1662
|
+
fill.setAttribute('style', 'display:block;height:100%;width:' + pct.toFixed(1) + '%;background:' + (warn ? theme.warn : theme.fg));
|
|
1663
|
+
rail.appendChild(fill);
|
|
1664
|
+
row.appendChild(rail);
|
|
1665
|
+
var val2 = document.createElement('span'); val2.textContent = cur + ' / ' + lim;
|
|
1666
|
+
row.appendChild(val2);
|
|
1667
|
+
}
|
|
1668
|
+
parent.appendChild(row);
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function loadExtensionCharts() {
|
|
1672
|
+
// Hourly line chart.
|
|
1673
|
+
fetch('/api/extensions/aggregates?window=24h&kind=hourly')
|
|
1674
|
+
.then(function(r){ return r.json(); })
|
|
1675
|
+
.then(function(o){
|
|
1676
|
+
var canvas = document.getElementById('ext-chart-hourly');
|
|
1677
|
+
if (!canvas) return;
|
|
1678
|
+
var buckets = (o && o.buckets) || [];
|
|
1679
|
+
var points = buckets.map(function(b, i){ return { x: i, y: b.count }; });
|
|
1680
|
+
_ijfwLineChart(canvas, points);
|
|
1681
|
+
}).catch(function(){});
|
|
1682
|
+
|
|
1683
|
+
// Deny rate per extension (horizontal bar).
|
|
1684
|
+
fetch('/api/extensions/aggregates?window=24h&kind=by_ext')
|
|
1685
|
+
.then(function(r){ return r.json(); })
|
|
1686
|
+
.then(function(o){
|
|
1687
|
+
var canvas = document.getElementById('ext-chart-byext');
|
|
1688
|
+
if (!canvas) return;
|
|
1689
|
+
var rows = (o && o.rows) || [];
|
|
1690
|
+
var bars = rows.slice(0, 8).map(function(r){
|
|
1691
|
+
var total = (r.allowed||0) + (r.denied||0);
|
|
1692
|
+
return { label: r.ext, value: total > 0 ? (r.denied/total) * 100 : 0 };
|
|
1693
|
+
});
|
|
1694
|
+
_ijfwBarChart(canvas, bars, true);
|
|
1695
|
+
}).catch(function(){});
|
|
1696
|
+
|
|
1697
|
+
// Top denied tools.
|
|
1698
|
+
fetch('/api/extensions/aggregates?window=24h&kind=by_tool')
|
|
1699
|
+
.then(function(r){ return r.json(); })
|
|
1700
|
+
.then(function(o){
|
|
1701
|
+
var canvas = document.getElementById('ext-chart-bytool');
|
|
1702
|
+
if (!canvas) return;
|
|
1703
|
+
var rows = (o && o.rows) || [];
|
|
1704
|
+
var bars = rows.map(function(r){ return { label: r.tool, value: r.count }; });
|
|
1705
|
+
_ijfwBarChart(canvas, bars, true);
|
|
1706
|
+
}).catch(function(){});
|
|
1707
|
+
|
|
1708
|
+
// Quotas: per-extension progress bars + bash-bypass warning chip.
|
|
1709
|
+
fetch('/api/extensions/aggregates?window=24h&kind=quotas')
|
|
1710
|
+
.then(function(r){ return r.json(); })
|
|
1711
|
+
.then(function(o){
|
|
1712
|
+
var parent = document.getElementById('ext-chart-quotas');
|
|
1713
|
+
if (!parent) return;
|
|
1714
|
+
while (parent.firstChild) parent.removeChild(parent.firstChild);
|
|
1715
|
+
var rows = (o && o.rows) || [];
|
|
1716
|
+
if (rows.length === 0) {
|
|
1717
|
+
var empty = document.createElement('div');
|
|
1718
|
+
empty.setAttribute('style','color:var(--fg-dim);font-size:12px');
|
|
1719
|
+
empty.textContent = 'No active extension.';
|
|
1720
|
+
parent.appendChild(empty);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
rows.forEach(function(row){
|
|
1724
|
+
var header = document.createElement('div');
|
|
1725
|
+
header.setAttribute('style','font-weight:600;font-size:12px;margin-top:4px');
|
|
1726
|
+
header.textContent = row.ext_name + ' (' + (row.scope || 'user') + ')';
|
|
1727
|
+
parent.appendChild(header);
|
|
1728
|
+
if (row.warn_bash_bypass) {
|
|
1729
|
+
var chip = document.createElement('div');
|
|
1730
|
+
chip.className = 'ijfw-warn-chip';
|
|
1731
|
+
chip.setAttribute('style','background:rgba(255,155,58,0.12);color:var(--ijfw-chart-warning,#ff9b3a);font-size:11px;padding:4px 8px;border-radius:4px;margin:4px 0');
|
|
1732
|
+
chip.textContent = '⚠ This extension has tool:bash and a strict files_written quota. Bash content bypasses per-file accounting.';
|
|
1733
|
+
parent.appendChild(chip);
|
|
1734
|
+
}
|
|
1735
|
+
var dims = row.dimensions || {};
|
|
1736
|
+
_ijfwProgressBar(parent, { current: (dims.files_written||{}).current||0, limit: (dims.files_written||{}).limit, label: 'files_written', warning: row.warn_bash_bypass });
|
|
1737
|
+
_ijfwProgressBar(parent, { current: (dims.bytes_written||{}).current||0, limit: (dims.bytes_written||{}).limit, label: 'bytes_written', warning: row.warn_bash_bypass });
|
|
1738
|
+
_ijfwProgressBar(parent, { current: (dims.wall_clock_ms||{}).current||0, limit: (dims.wall_clock_ms||{}).limit, label: 'wall_clock_ms' });
|
|
1739
|
+
});
|
|
1740
|
+
}).catch(function(){});
|
|
1741
|
+
}
|
|
1530
1742
|
</script>
|
|
1531
1743
|
</body>
|
|
1532
1744
|
</html>
|
package/src/dashboard-server.js
CHANGED
|
@@ -22,6 +22,8 @@ import { searchMemory } from './memory/search.js';
|
|
|
22
22
|
import { buildRecallCounts, mergeRecallCounts, topRecalled } from './memory/recall-counter.js';
|
|
23
23
|
import { PLACEHOLDER_HTML } from './design-companion.js';
|
|
24
24
|
import { listExtensions } from './extension-installer.js';
|
|
25
|
+
import { aggregateEvents, computeWarnBashBypass, readActiveManifest } from './dashboard-aggregator.js';
|
|
26
|
+
import { getQuotaUsage } from './extension-quota-tracker.js';
|
|
25
27
|
|
|
26
28
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
29
|
// REPO_ROOT: IJFW_PROJECT_ROOT override > user's interactive shell cwd (PWD) > process.cwd() fallback.
|
|
@@ -432,6 +434,85 @@ export async function startServer(options = {}) {
|
|
|
432
434
|
}));
|
|
433
435
|
}],
|
|
434
436
|
|
|
437
|
+
// v1.4.4 N8 — Planning doc viewer. Same path-traversal guard as /api/memory/file,
|
|
438
|
+
// but allowed roots are .planning/, .ijfw/memory/, and .ijfw/wave-*/ all under REPO_ROOT.
|
|
439
|
+
['/api/planning', (req, res, url) => {
|
|
440
|
+
const rawPath = url.searchParams.get('path') || '';
|
|
441
|
+
if (!rawPath) {
|
|
442
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
443
|
+
res.end(JSON.stringify({ error: 'path query param required' }));
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (isAbsolute(rawPath) || rawPath.split(/[\\/]/).some((seg) => seg === '..')) {
|
|
447
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
448
|
+
res.end(JSON.stringify({ error: 'path traversal not allowed' }));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const reqPath = resolve(REPO_ROOT, rawPath);
|
|
452
|
+
function canonOrNull(p) {
|
|
453
|
+
try { return realpathSync(p); } catch { return null; }
|
|
454
|
+
}
|
|
455
|
+
function isUnder(allowedRoot, canonChild) {
|
|
456
|
+
const canonRoot = canonOrNull(allowedRoot);
|
|
457
|
+
if (!canonRoot || !canonChild) return false;
|
|
458
|
+
const rel = relative(canonRoot, canonChild);
|
|
459
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
460
|
+
}
|
|
461
|
+
function isUnderWaveRoot(canonChild) {
|
|
462
|
+
// r13-M-05: restrict to STATE.md / SUMMARY.md filenames within
|
|
463
|
+
// .ijfw/wave-*/ subdirs. Previously allowed ANY file in a wave dir;
|
|
464
|
+
// wave directories may contain .tmp, lock files, or partial blackboard
|
|
465
|
+
// data that shouldn't be browser-readable.
|
|
466
|
+
const ijfwDir = canonOrNull(join(REPO_ROOT, '.ijfw'));
|
|
467
|
+
if (!ijfwDir || !canonChild) return false;
|
|
468
|
+
const rel = relative(ijfwDir, canonChild);
|
|
469
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return false;
|
|
470
|
+
const first = rel.split(/[\\/]/)[0];
|
|
471
|
+
if (!first.startsWith('wave-') || first.length === 'wave-'.length) return false;
|
|
472
|
+
return rel.endsWith('STATE.md') || rel.endsWith('SUMMARY.md');
|
|
473
|
+
}
|
|
474
|
+
const canonPath = canonOrNull(reqPath);
|
|
475
|
+
if (!canonPath) {
|
|
476
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
477
|
+
res.end(JSON.stringify({ error: 'file not found' }));
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const allowed = (
|
|
481
|
+
isUnder(join(REPO_ROOT, '.planning'), canonPath) ||
|
|
482
|
+
isUnder(join(REPO_ROOT, '.ijfw', 'memory'), canonPath) ||
|
|
483
|
+
isUnderWaveRoot(canonPath)
|
|
484
|
+
);
|
|
485
|
+
if (!allowed) {
|
|
486
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
487
|
+
res.end(JSON.stringify({ error: 'outside allowed planning roots' }));
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
const body = readFileSync(canonPath, 'utf8');
|
|
492
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
493
|
+
res.end(JSON.stringify({ body: body.slice(0, 200000), path: rawPath }));
|
|
494
|
+
} catch (err) {
|
|
495
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
496
|
+
res.end(JSON.stringify({ error: err.message, endpoint: '/api/planning' }));
|
|
497
|
+
}
|
|
498
|
+
}],
|
|
499
|
+
|
|
500
|
+
// v1.4.4 N8 — Planning-docs viewer (HTML SPA).
|
|
501
|
+
['/planning', async (req, res) => {
|
|
502
|
+
try {
|
|
503
|
+
const html = await readFile(join(__dirname, 'dashboard-client-planning.html'), 'utf8');
|
|
504
|
+
res.writeHead(200, {
|
|
505
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
506
|
+
'Cache-Control': 'no-store',
|
|
507
|
+
'Content-Security-Policy': "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'",
|
|
508
|
+
});
|
|
509
|
+
res.end(html);
|
|
510
|
+
} catch (err) {
|
|
511
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
512
|
+
res.end('Planning viewer not found: ' + err.message);
|
|
513
|
+
}
|
|
514
|
+
}],
|
|
515
|
+
|
|
435
516
|
['/api/memory/file', (req, res, url) => {
|
|
436
517
|
const rawPath = url.searchParams.get('path') || '';
|
|
437
518
|
if (!rawPath) {
|
|
@@ -931,6 +1012,111 @@ export async function startServer(options = {}) {
|
|
|
931
1012
|
res.end(JSON.stringify(events));
|
|
932
1013
|
}],
|
|
933
1014
|
|
|
1015
|
+
// ---------- extensions: aggregates (B19) ----------
|
|
1016
|
+
// Server-side aggregation of permission-events.jsonl for the per-tool
|
|
1017
|
+
// audit charts. Filters are strictly allowlisted.
|
|
1018
|
+
// ?window=24h|30m|7d (regex: \d+[hmd])
|
|
1019
|
+
// ?kind=hourly|by_ext|by_tool|quotas
|
|
1020
|
+
['/api/extensions/aggregates', async (req, res, url) => {
|
|
1021
|
+
const ALLOWED_KINDS = new Set(['hourly', 'by_ext', 'by_tool', 'quotas']);
|
|
1022
|
+
const WINDOW_RE = /^\d+(h|m|d)$/;
|
|
1023
|
+
const ALLOWED_KEYS = new Set(['window', 'kind']);
|
|
1024
|
+
try {
|
|
1025
|
+
for (const key of url.searchParams.keys()) {
|
|
1026
|
+
if (!ALLOWED_KEYS.has(key)) {
|
|
1027
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1028
|
+
res.end(JSON.stringify({ error: `unknown filter parameter: ${key}` }));
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
const kind = url.searchParams.get('kind') || 'hourly';
|
|
1033
|
+
if (!ALLOWED_KINDS.has(kind)) {
|
|
1034
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1035
|
+
res.end(JSON.stringify({ error: `invalid kind: ${kind}` }));
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
const rawWindow = url.searchParams.get('window') || '24h';
|
|
1039
|
+
if (!WINDOW_RE.test(rawWindow)) {
|
|
1040
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1041
|
+
res.end(JSON.stringify({ error: `invalid window: ${rawWindow}` }));
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
// Parse window into ms.
|
|
1045
|
+
const num = parseInt(rawWindow.slice(0, -1), 10);
|
|
1046
|
+
const unit = rawWindow.slice(-1);
|
|
1047
|
+
const mult = unit === 'h' ? 3600_000 : unit === 'm' ? 60_000 : 86_400_000;
|
|
1048
|
+
const windowMs = num * mult;
|
|
1049
|
+
|
|
1050
|
+
const home = homedir();
|
|
1051
|
+
const eventsPath = join(home, '.ijfw', 'state', 'permission-events.jsonl');
|
|
1052
|
+
|
|
1053
|
+
if (kind === 'quotas') {
|
|
1054
|
+
// Walk the active extension state and compute per-extension usage.
|
|
1055
|
+
let active = null;
|
|
1056
|
+
try {
|
|
1057
|
+
const activePath = join(home, '.ijfw', 'state', 'active-extension.json');
|
|
1058
|
+
if (existsSync(activePath)) {
|
|
1059
|
+
active = JSON.parse(readFileSync(activePath, 'utf8'));
|
|
1060
|
+
}
|
|
1061
|
+
} catch { active = null; }
|
|
1062
|
+
const rows = [];
|
|
1063
|
+
if (active && typeof active === 'object') {
|
|
1064
|
+
// active may be a single record or a map keyed by name.
|
|
1065
|
+
const entries = Array.isArray(active.extensions)
|
|
1066
|
+
? active.extensions
|
|
1067
|
+
: (active.name ? [active] : []);
|
|
1068
|
+
for (const ent of entries) {
|
|
1069
|
+
if (!ent || !ent.name) continue;
|
|
1070
|
+
const scope = ent.scope || 'user';
|
|
1071
|
+
const manifest = readActiveManifest({ scope, name: ent.name, home, projectRoot: REPO_ROOT });
|
|
1072
|
+
const quotas = (manifest && manifest.quotas) || {};
|
|
1073
|
+
const usage = await getQuotaUsage(ent.name, { homeDir: home, limits: quotas });
|
|
1074
|
+
rows.push({
|
|
1075
|
+
...usage,
|
|
1076
|
+
scope,
|
|
1077
|
+
warn_bash_bypass: computeWarnBashBypass(manifest),
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1082
|
+
res.end(JSON.stringify({ rows }));
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const agg = await aggregateEvents(eventsPath, { windowMs });
|
|
1087
|
+
|
|
1088
|
+
if (kind === 'hourly') {
|
|
1089
|
+
const buckets = Object.entries(agg.hourly)
|
|
1090
|
+
.map(([hour, count]) => ({ hour, count }))
|
|
1091
|
+
.sort((a, b) => a.hour.localeCompare(b.hour));
|
|
1092
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1093
|
+
res.end(JSON.stringify({ buckets }));
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (kind === 'by_ext') {
|
|
1098
|
+
const rows = Object.entries(agg.by_extension)
|
|
1099
|
+
.map(([ext, v]) => ({ ext, allowed: v.allowed, denied: v.denied }))
|
|
1100
|
+
.sort((a, b) => b.denied - a.denied);
|
|
1101
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1102
|
+
res.end(JSON.stringify({ rows }));
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// kind === 'by_tool'
|
|
1107
|
+
const rows = Object.entries(agg.by_tool_denied)
|
|
1108
|
+
.map(([tool, count]) => ({ tool, count }))
|
|
1109
|
+
.sort((a, b) => b.count - a.count)
|
|
1110
|
+
.slice(0, 10);
|
|
1111
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1112
|
+
res.end(JSON.stringify({ rows }));
|
|
1113
|
+
} catch (err) {
|
|
1114
|
+
process.stderr.write(`[ijfw-mcp] /api/extensions/aggregates: ${err && err.message ? err.message : err}\n`);
|
|
1115
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1116
|
+
res.end(JSON.stringify({ buckets: [], rows: [], error: 'aggregation failed' }));
|
|
1117
|
+
}
|
|
1118
|
+
}],
|
|
1119
|
+
|
|
934
1120
|
// ---------- extensions health (W3/t15) ----------
|
|
935
1121
|
// Reads .ijfw/state/extension-registry.json (project) plus org/user via
|
|
936
1122
|
// listExtensions(). Missing or malformed registry yields {extensions: []}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch/active-cli.js — IJFW v1.4.3 W9-B (B18)
|
|
3
|
+
*
|
|
4
|
+
* Frozen CLI module contract:
|
|
5
|
+
* export const handlers — { '<subcommand>': async (args, ctx) => { ok, output?, error? } }
|
|
6
|
+
* export const subcommandHelp — { '<subcommand>': 'one-line description' }
|
|
7
|
+
*
|
|
8
|
+
* Subcommands:
|
|
9
|
+
* active --check — report current active extension + last-writer IDE + divergence
|
|
10
|
+
* activate <name> [--ide <id>] [--strict-ide]
|
|
11
|
+
* — activate <name> stamping the host IDE id; refuse
|
|
12
|
+
* activation when --strict-ide is set AND a different
|
|
13
|
+
* IDE is the current writer of stale state.
|
|
14
|
+
*
|
|
15
|
+
* Phase D wires these into `dispatch/extension.js`'s main switch by iterating
|
|
16
|
+
* Object.entries(handlers). Until then, this module is callable in isolation.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFile } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
findInstalledManifest,
|
|
25
|
+
writeActiveExtension,
|
|
26
|
+
detectCrossIdeDivergence,
|
|
27
|
+
} from '../active-extension-writer.js';
|
|
28
|
+
import { detectIde } from '../ide-detect.js';
|
|
29
|
+
|
|
30
|
+
function homeFromCtx(ctx) {
|
|
31
|
+
if (ctx && typeof ctx.homedir === 'string') return ctx.homedir;
|
|
32
|
+
if (ctx && typeof ctx.homeDir === 'string') return ctx.homeDir;
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function projectRootFromCtx(ctx) {
|
|
37
|
+
if (ctx && typeof ctx.projectRoot === 'string') return ctx.projectRoot;
|
|
38
|
+
return process.cwd();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseArgs(args) {
|
|
42
|
+
// Accept either a token array (preferred per registry-cli contract) or a
|
|
43
|
+
// raw string (fallback for direct callers).
|
|
44
|
+
let tokens;
|
|
45
|
+
if (Array.isArray(args)) {
|
|
46
|
+
tokens = args.slice();
|
|
47
|
+
} else if (typeof args === 'string') {
|
|
48
|
+
tokens = args.split(/\s+/).filter(Boolean);
|
|
49
|
+
} else {
|
|
50
|
+
tokens = [];
|
|
51
|
+
}
|
|
52
|
+
const flags = { check: false, strictIde: false, ide: null };
|
|
53
|
+
const positional = [];
|
|
54
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
55
|
+
const t = tokens[i];
|
|
56
|
+
if (t === '--check') { flags.check = true; continue; }
|
|
57
|
+
if (t === '--strict-ide') { flags.strictIde = true; continue; }
|
|
58
|
+
if (t === '--ide' && tokens[i + 1]) {
|
|
59
|
+
flags.ide = tokens[i + 1];
|
|
60
|
+
i++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
positional.push(t);
|
|
64
|
+
}
|
|
65
|
+
return { positional, flags };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function activeHandler(args, ctx) {
|
|
69
|
+
const { flags } = parseArgs(args);
|
|
70
|
+
const home = homeFromCtx(ctx) || process.env.HOME || homedir();
|
|
71
|
+
if (!flags.check) {
|
|
72
|
+
return { ok: false, error: "active: --check required (usage: active --check)" };
|
|
73
|
+
}
|
|
74
|
+
// Read current active.json (best-effort).
|
|
75
|
+
const activePath = join(home, '.ijfw', 'state', 'active-extension.json');
|
|
76
|
+
let active = null;
|
|
77
|
+
try {
|
|
78
|
+
const raw = await readFile(activePath, 'utf8');
|
|
79
|
+
active = JSON.parse(raw);
|
|
80
|
+
} catch {
|
|
81
|
+
// null
|
|
82
|
+
}
|
|
83
|
+
const verdict = await detectCrossIdeDivergence({ homeDir: home });
|
|
84
|
+
const out = {
|
|
85
|
+
active: active ? {
|
|
86
|
+
name: active.name ?? null,
|
|
87
|
+
scope: active.scope ?? null,
|
|
88
|
+
activated_at: active.activated_at ?? null,
|
|
89
|
+
activated_by_ide: active.activated_by_ide ?? null,
|
|
90
|
+
activated_by_pid: active.activated_by_pid ?? null,
|
|
91
|
+
} : null,
|
|
92
|
+
current_ide: verdict.current_ide,
|
|
93
|
+
divergent: !!verdict.divergent,
|
|
94
|
+
last_writer: verdict.last_writer ?? null,
|
|
95
|
+
age_seconds: verdict.age_seconds ?? null,
|
|
96
|
+
};
|
|
97
|
+
return { ok: true, output: JSON.stringify(out, null, 2) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function activateHandler(args, ctx) {
|
|
101
|
+
const { positional, flags } = parseArgs(args);
|
|
102
|
+
const name = positional[0];
|
|
103
|
+
if (!name || typeof name !== 'string') {
|
|
104
|
+
return { ok: false, error: 'activate: extension name required (usage: activate <name> [--ide <id>] [--strict-ide])' };
|
|
105
|
+
}
|
|
106
|
+
const home = homeFromCtx(ctx) || process.env.HOME || homedir();
|
|
107
|
+
const projectRoot = projectRootFromCtx(ctx);
|
|
108
|
+
|
|
109
|
+
const ideId = flags.ide && /^[a-z0-9-]+$/.test(flags.ide) ? flags.ide : detectIde();
|
|
110
|
+
|
|
111
|
+
// Strict-IDE gate: if active.json was last touched by a different IDE AND
|
|
112
|
+
// divergence semantic flags it, refuse before writing.
|
|
113
|
+
if (flags.strictIde) {
|
|
114
|
+
const verdict = await detectCrossIdeDivergence({ homeDir: home, currentIde: ideId });
|
|
115
|
+
if (verdict && verdict.divergent) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
error: `[ijfw] activate refused: --strict-ide and active extension last activated by '${verdict.last_writer}'`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const lookup = await findInstalledManifest(name, projectRoot, { homeDir: home });
|
|
124
|
+
if (!lookup.ok) return { ok: false, error: lookup.error };
|
|
125
|
+
const result = await writeActiveExtension(lookup.manifest, lookup.scope, { homeDir: home, ideId });
|
|
126
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
output: JSON.stringify({ name, scope: lookup.scope, activated_by_ide: ideId, path: result.path }, null, 2),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const handlers = Object.freeze({
|
|
134
|
+
'active': activeHandler,
|
|
135
|
+
'activate': activateHandler,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
export const subcommandHelp = Object.freeze({
|
|
139
|
+
'active': 'active --check — report current active extension + cross-IDE divergence status',
|
|
140
|
+
'activate': 'activate <name> [--ide <id>] [--strict-ide] — activate extension and stamp host IDE',
|
|
141
|
+
});
|
|
@@ -606,8 +606,48 @@ async function cmdDeactivate() {
|
|
|
606
606
|
}
|
|
607
607
|
}
|
|
608
608
|
|
|
609
|
+
// v1.4.3 Phase D — CLI module registry. Each module exports the frozen
|
|
610
|
+
// { handlers, subcommandHelp } shape; we union their handlers into a single
|
|
611
|
+
// lookup and consult it BEFORE the legacy switch, so new --ide / --backend /
|
|
612
|
+
// quota / federation subcommands take precedence.
|
|
613
|
+
let _v143Handlers = null;
|
|
614
|
+
async function loadV143Handlers() {
|
|
615
|
+
if (_v143Handlers !== null) return _v143Handlers;
|
|
616
|
+
const [registry, signer, quota, active, wave] = await Promise.all([
|
|
617
|
+
import('./registry-cli.js'),
|
|
618
|
+
import('./signer-cli.js'),
|
|
619
|
+
import('./quota-cli.js'),
|
|
620
|
+
import('./active-cli.js'),
|
|
621
|
+
import('./wave-cli.js'), // v1.4.4 N9 — wave-status / wave-list
|
|
622
|
+
]);
|
|
623
|
+
_v143Handlers = Object.assign(
|
|
624
|
+
Object.create(null),
|
|
625
|
+
registry.handlers || {},
|
|
626
|
+
signer.handlers || {},
|
|
627
|
+
quota.handlers || {},
|
|
628
|
+
active.handlers || {},
|
|
629
|
+
wave.handlers || {},
|
|
630
|
+
);
|
|
631
|
+
return _v143Handlers;
|
|
632
|
+
}
|
|
633
|
+
|
|
609
634
|
export async function extensionDispatch({ command, args = '', projectRoot }) {
|
|
610
635
|
const ctx = { command, args: String(args || ''), projectRoot: String(projectRoot || process.cwd()) };
|
|
636
|
+
|
|
637
|
+
// v1.4.3 Phase D — CLI modules take precedence over legacy switch.
|
|
638
|
+
const handlers = await loadV143Handlers();
|
|
639
|
+
if (typeof handlers[command] === 'function') {
|
|
640
|
+
try {
|
|
641
|
+
const r = await handlers[command](ctx.args, ctx);
|
|
642
|
+
if (r && r.ok === false) {
|
|
643
|
+
return { ok: false, command, error: r.error || 'unknown error' };
|
|
644
|
+
}
|
|
645
|
+
return { ok: true, command, result: r };
|
|
646
|
+
} catch (err) {
|
|
647
|
+
return { ok: false, command, error: err && err.message ? err.message : String(err) };
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
611
651
|
switch (command) {
|
|
612
652
|
case 'add': return cmdAdd(ctx);
|
|
613
653
|
case 'list': return cmdList(ctx);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch/quota-cli.js — IJFW v1.4.3 W9-A3 (B16)
|
|
3
|
+
*
|
|
4
|
+
* Frozen CLI module contract:
|
|
5
|
+
* export const handlers — { '<subcommand>': async (args, ctx) => { ok, output?, error? } }
|
|
6
|
+
* export const subcommandHelp — { '<subcommand>': 'one-line description' }
|
|
7
|
+
*
|
|
8
|
+
* Phase D wires these into `dispatch/extension.js`'s main switch by iterating
|
|
9
|
+
* Object.entries(handlers). Until then, this module is callable in isolation.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getQuotaUsage, resetExtensionQuotas } from '../extension-quota-tracker.js';
|
|
13
|
+
|
|
14
|
+
function homeFromCtx(ctx) {
|
|
15
|
+
if (ctx && typeof ctx.homedir === 'string') return ctx.homedir;
|
|
16
|
+
if (ctx && typeof ctx.homeDir === 'string') return ctx.homeDir;
|
|
17
|
+
return undefined; // tracker will fall back to env HOME / homedir()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const handlers = Object.freeze({
|
|
21
|
+
'quota-status': async (args, ctx) => {
|
|
22
|
+
const name = Array.isArray(args) ? args[0] : undefined;
|
|
23
|
+
if (!name || typeof name !== 'string') {
|
|
24
|
+
return { ok: false, error: 'quota-status: extension name required' };
|
|
25
|
+
}
|
|
26
|
+
const usage = await getQuotaUsage(name, { homeDir: homeFromCtx(ctx) });
|
|
27
|
+
return { ok: true, output: JSON.stringify(usage, null, 2) };
|
|
28
|
+
},
|
|
29
|
+
'quota-reset': async (args, ctx) => {
|
|
30
|
+
const name = Array.isArray(args) ? args[0] : undefined;
|
|
31
|
+
if (!name || typeof name !== 'string') {
|
|
32
|
+
return { ok: false, error: 'quota-reset: extension name required' };
|
|
33
|
+
}
|
|
34
|
+
await resetExtensionQuotas(name, { homeDir: homeFromCtx(ctx) });
|
|
35
|
+
return { ok: true, output: 'reset' };
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const subcommandHelp = Object.freeze({
|
|
40
|
+
'quota-status': 'quota-status [<ext-name>] — print current usage vs limits',
|
|
41
|
+
'quota-reset': 'quota-reset <ext-name> — admin: manually reset counters',
|
|
42
|
+
});
|