@ijfw/memory-server 1.4.0 → 1.4.1
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/README.md +67 -0
- package/package.json +1 -1
- package/src/.registry-meta-key.pem +3 -0
- package/src/active-extension-writer.js +30 -4
- package/src/dashboard-client.html +210 -1
- package/src/dashboard-server.js +243 -0
- package/src/dispatch/extension.js +234 -1
- package/src/extension-installer.js +39 -0
- package/src/extension-permission-check.mjs +79 -0
- package/src/extension-registry.js +619 -0
- package/src/extension-signer.js +165 -0
- package/src/memory-feedback.js +194 -10
- package/src/runtime-mediator.js +30 -1
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# @ijfw/memory-server
|
|
2
|
+
|
|
3
|
+
IJFW MCP memory server — the runtime backend that powers memory, metrics,
|
|
4
|
+
update checks, and the extension sandbox for all supported AI coding agents.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
This package is installed automatically by `@ijfw/install`. You generally
|
|
9
|
+
do not need to install it manually.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g @ijfw/memory-server
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Extension CLI
|
|
16
|
+
|
|
17
|
+
IJFW ships a full extension system for installing and sandboxing third-party skills.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Publisher key management
|
|
21
|
+
ijfw extension keygen <author> # Generate an Ed25519 publisher keypair
|
|
22
|
+
ijfw extension trust <keyId> <publicKey> # Add a publisher to your trusted store
|
|
23
|
+
ijfw extension trust-registry [<url>] # Pull + apply the hosted publisher registry
|
|
24
|
+
ijfw extension untrust <keyId> # Remove a publisher from your trusted store
|
|
25
|
+
ijfw extension trusted # List all trusted publishers
|
|
26
|
+
|
|
27
|
+
# Extension lifecycle
|
|
28
|
+
ijfw extension add <source> [flags] # Install an extension (npm name, local path, or https git URL)
|
|
29
|
+
--allow-unsigned # Accept extensions with no signature
|
|
30
|
+
--accept-untrusted # Accept extensions signed by an untrusted publisher (prompts on TTY)
|
|
31
|
+
--activate # Auto-activate after install
|
|
32
|
+
ijfw extension activate <name> # Activate an installed extension (enforces declared permissions)
|
|
33
|
+
ijfw extension deactivate # Deactivate the current extension
|
|
34
|
+
|
|
35
|
+
# Admin / registry maintainer (rare)
|
|
36
|
+
ijfw extension rotate-keys <oldKeyId> <newKeyId> # Produce a signed rotation token
|
|
37
|
+
ijfw extension keygen-meta <author> # Generate the registry meta-keypair
|
|
38
|
+
ijfw extension sign-registry <path> # Sign a registry JSON file in place
|
|
39
|
+
ijfw extension verify-registry <path> # Verify a registry JSON signature
|
|
40
|
+
ijfw extension registry-status # Show registry cache age + signature status
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The rotation flow and registry maintainer docs live in `docs/REGISTRY-MAINTAINER.md`.
|
|
44
|
+
|
|
45
|
+
## MCP Tools
|
|
46
|
+
|
|
47
|
+
| Tool | Description |
|
|
48
|
+
|------|-------------|
|
|
49
|
+
| `ijfw_memory_store` | Store a memory entry |
|
|
50
|
+
| `ijfw_memory_recall` | Recall memory entries |
|
|
51
|
+
| `ijfw_memory_search` | Full-text search over memories |
|
|
52
|
+
| `ijfw_memory_prelude` | Load project context at session start |
|
|
53
|
+
| `ijfw_cross_project_search` | Search memories across projects |
|
|
54
|
+
| `ijfw_metrics` | Read cost + usage metrics |
|
|
55
|
+
| `ijfw_update_check` | Check for IJFW updates |
|
|
56
|
+
| `ijfw_update_apply` | Apply a pending IJFW update |
|
|
57
|
+
| `ijfw_prompt_check` | Validate a prompt against IJFW rules |
|
|
58
|
+
| `ijfw_run` | Run a sandboxed IJFW command |
|
|
59
|
+
|
|
60
|
+
## Build (contributors)
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
cd mcp-server
|
|
64
|
+
npm install
|
|
65
|
+
npm test
|
|
66
|
+
node --experimental-sqlite --test test-*.js
|
|
67
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ijfw/memory-server",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Cross-platform persistent memory server for IJFW. 10 MCP tools (memory + admin/update). Works with 13 MCP-using platforms (Claude Code, Codex, Gemini CLI, Cursor, Windsurf, Copilot, Hermes, Wayland, OpenCode, QwenCode, Cline, KimiCode, OpenClaw) plus Aider via rules-only tier.",
|
|
5
5
|
"author": "Sean Donahoe",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
|
11
11
|
import { join, dirname } from 'node:path';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
|
+
import { randomBytes } from 'node:crypto';
|
|
13
14
|
|
|
14
15
|
const STATE_PATH_REL = ['.ijfw', 'state', 'active-extension.json'];
|
|
15
16
|
|
|
@@ -50,7 +51,7 @@ export async function writeActiveExtension(manifest, scope, opts = {}) {
|
|
|
50
51
|
const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
|
|
51
52
|
const path = statePath(home);
|
|
52
53
|
await mkdir(dirname(path), { recursive: true });
|
|
53
|
-
const tmp = `${path}.tmp.${
|
|
54
|
+
const tmp = `${path}.tmp.${randomBytes(4).toString('hex')}`;
|
|
54
55
|
await writeFile(tmp, JSON.stringify(out, null, 2) + '\n', 'utf8');
|
|
55
56
|
const { rename } = await import('node:fs/promises');
|
|
56
57
|
await rename(tmp, path);
|
|
@@ -86,7 +87,7 @@ export async function clearActiveExtension(opts = {}) {
|
|
|
86
87
|
*
|
|
87
88
|
* @param {string} name
|
|
88
89
|
* @param {string} [projectRoot]
|
|
89
|
-
* @param {{ homeDir?: string }} [opts]
|
|
90
|
+
* @param {{ homeDir?: string, strictShadow?: boolean }} [opts]
|
|
90
91
|
* @returns {Promise<{ ok: boolean, manifest?: object, scope?: string, path?: string, error?: string }>}
|
|
91
92
|
*/
|
|
92
93
|
export async function findInstalledManifest(name, projectRoot, opts = {}) {
|
|
@@ -102,15 +103,40 @@ export async function findInstalledManifest(name, projectRoot, opts = {}) {
|
|
|
102
103
|
candidates.push({ scope: 'org', path: join(home, '.ijfw', 'extensions-org', name, 'manifest.json') });
|
|
103
104
|
candidates.push({ scope: 'user', path: join(home, '.ijfw', 'extensions-user', name, 'manifest.json') });
|
|
104
105
|
|
|
106
|
+
// Collect all found manifests to detect project-scope shadowing.
|
|
107
|
+
const found = [];
|
|
105
108
|
for (const c of candidates) {
|
|
106
109
|
try {
|
|
107
110
|
const raw = await readFile(c.path, 'utf8');
|
|
108
111
|
const manifest = JSON.parse(raw);
|
|
109
|
-
|
|
112
|
+
found.push({ scope: c.scope, path: c.path, manifest });
|
|
110
113
|
} catch (err) {
|
|
111
114
|
if (err && err.code === 'ENOENT') continue;
|
|
112
115
|
if (err instanceof SyntaxError) continue;
|
|
113
116
|
}
|
|
114
117
|
}
|
|
115
|
-
|
|
118
|
+
|
|
119
|
+
if (found.length === 0) {
|
|
120
|
+
return { ok: false, error: `extension "${name}" not found in project/org/user scope` };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const winner = found[0];
|
|
124
|
+
|
|
125
|
+
// B13.1: warn when project-scope shadows a lower-priority scope entry.
|
|
126
|
+
if (winner.scope === 'project' && found.length > 1) {
|
|
127
|
+
const shadowed = found[1];
|
|
128
|
+
const winnerKeyId = winner.manifest.signature?.keyId ?? '(unsigned)';
|
|
129
|
+
const shadowedKeyId = shadowed.manifest.signature?.keyId ?? '(unsigned)';
|
|
130
|
+
if (opts && opts.strictShadow) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
error: `extension activate: project-scope "${name}" shadows ${shadowed.scope}-scope "${name}" (keyId ${winnerKeyId} vs ${shadowedKeyId}) — refused by strictShadow`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
process.stderr.write(
|
|
137
|
+
`[ijfw] extension activate: project-scope "${name}" shadows ${shadowed.scope}-scope "${name}" (keyId ${winnerKeyId} vs ${shadowedKeyId}) — using project\n`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { ok: true, manifest: winner.manifest, scope: winner.scope, path: winner.path };
|
|
116
142
|
}
|
|
@@ -143,6 +143,15 @@ tr:hover td{background:var(--surface)}
|
|
|
143
143
|
.empty{text-align:center;padding:40px 20px;color:var(--fg-dim)}
|
|
144
144
|
.empty-icon{font-size:32px;margin-bottom:10px;opacity:.4}
|
|
145
145
|
|
|
146
|
+
/* Extension events */
|
|
147
|
+
.evt-row{display:flex;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);align-items:flex-start}
|
|
148
|
+
.evt-row:last-child{border-bottom:none}
|
|
149
|
+
.evt-ts{color:var(--fg-dim);white-space:nowrap;font-size:11px;min-width:80px}
|
|
150
|
+
.evt-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:700;white-space:nowrap}
|
|
151
|
+
.evt-allow{background:rgba(46,204,113,0.15);color:var(--success)}
|
|
152
|
+
.evt-deny{background:rgba(239,68,68,0.15);color:var(--danger)}
|
|
153
|
+
.evt-body{flex:1;color:var(--fg);font-size:11px;word-break:break-all}
|
|
154
|
+
|
|
146
155
|
/* Memory tree */
|
|
147
156
|
.mem-layout{display:flex;gap:0;height:calc(100vh - 180px);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
|
|
148
157
|
.mem-l{width:300px;flex-shrink:0;border-right:1px solid var(--border);display:flex;flex-direction:column;background:var(--bg-elevated)}
|
|
@@ -563,8 +572,9 @@ tr:hover td{background:var(--surface)}
|
|
|
563
572
|
</div>
|
|
564
573
|
</div>
|
|
565
574
|
|
|
566
|
-
<!-- ======== EXTENSIONS (W3/t15) ======== -->
|
|
575
|
+
<!-- ======== EXTENSIONS (W3/t15 + B9) ======== -->
|
|
567
576
|
<div class="section" data-section="extensions">
|
|
577
|
+
<!-- Sub-section: Installed -->
|
|
568
578
|
<div class="card">
|
|
569
579
|
<div class="ctitle"><span id="ext-count">Extensions</span></div>
|
|
570
580
|
<div id="extensions-content">
|
|
@@ -574,6 +584,35 @@ tr:hover td{background:var(--surface)}
|
|
|
574
584
|
</div>
|
|
575
585
|
</div>
|
|
576
586
|
</div>
|
|
587
|
+
|
|
588
|
+
<!-- Sub-section: Active extension -->
|
|
589
|
+
<div class="card">
|
|
590
|
+
<div class="ctitle">Active Extension</div>
|
|
591
|
+
<div id="ext-active-content">
|
|
592
|
+
<div class="empty"><p style="font-size:13px">Loading...</p></div>
|
|
593
|
+
</div>
|
|
594
|
+
</div>
|
|
595
|
+
|
|
596
|
+
<!-- Sub-section: Permission events -->
|
|
597
|
+
<div class="card">
|
|
598
|
+
<div class="ctitle" style="justify-content:space-between">
|
|
599
|
+
<span>Permission Events <span id="ext-events-live" style="display:none"><span class="pulse"></span></span></span>
|
|
600
|
+
<span style="display:flex;gap:8px;align-items:center">
|
|
601
|
+
<select id="ext-evt-filter-ext" class="btn-g" style="padding:3px 8px;font-size:11px" aria-label="Filter by extension">
|
|
602
|
+
<option value="">All extensions</option>
|
|
603
|
+
</select>
|
|
604
|
+
<select id="ext-evt-filter-denied" class="btn-g" style="padding:3px 8px;font-size:11px" aria-label="Filter by outcome">
|
|
605
|
+
<option value="">All outcomes</option>
|
|
606
|
+
<option value="true">Denied only</option>
|
|
607
|
+
<option value="false">Allowed only</option>
|
|
608
|
+
</select>
|
|
609
|
+
<button class="btn-g" id="ext-evt-clear" style="font-size:11px" aria-label="Clear events">Clear</button>
|
|
610
|
+
</span>
|
|
611
|
+
</div>
|
|
612
|
+
<div id="ext-events-content" style="max-height:320px;overflow-y:auto;font-size:12px;font-family:ui-monospace,monospace">
|
|
613
|
+
<div class="empty"><p>No permission events yet.</p></div>
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
577
616
|
</div>
|
|
578
617
|
|
|
579
618
|
<!-- ======== SUBSCRIPTIONS ======== -->
|
|
@@ -1303,6 +1342,174 @@ async function loadExtensions() {
|
|
|
1303
1342
|
}
|
|
1304
1343
|
}
|
|
1305
1344
|
|
|
1345
|
+
// ====== ACTIVE EXTENSION LOADER (B9) ======
|
|
1346
|
+
async function loadExtensionActive() {
|
|
1347
|
+
var el = document.getElementById('ext-active-content');
|
|
1348
|
+
if (!el) return;
|
|
1349
|
+
try {
|
|
1350
|
+
var r = await fetch('/api/extensions/active');
|
|
1351
|
+
var d = await r.json();
|
|
1352
|
+
while (el.firstChild) el.removeChild(el.firstChild);
|
|
1353
|
+
if (!d.active) {
|
|
1354
|
+
var p = document.createElement('p');
|
|
1355
|
+
p.setAttribute('style', 'font-size:13px;color:var(--fg-dim);padding:4px 0');
|
|
1356
|
+
p.textContent = 'None — bundled IJFW context active';
|
|
1357
|
+
el.appendChild(p);
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
var a = d.active;
|
|
1361
|
+
var wrap = document.createElement('div');
|
|
1362
|
+
wrap.setAttribute('style', 'font-size:13px');
|
|
1363
|
+
var nameRow = document.createElement('div');
|
|
1364
|
+
nameRow.setAttribute('style', 'margin-bottom:6px');
|
|
1365
|
+
var strong = document.createElement('strong');
|
|
1366
|
+
strong.textContent = a.name || 'unknown';
|
|
1367
|
+
nameRow.appendChild(strong);
|
|
1368
|
+
if (a.scope) {
|
|
1369
|
+
var sc = document.createElement('span');
|
|
1370
|
+
sc.setAttribute('style', 'margin-left:8px;font-size:11px;color:var(--fg-dim)');
|
|
1371
|
+
sc.textContent = '(' + a.scope + ')';
|
|
1372
|
+
nameRow.appendChild(sc);
|
|
1373
|
+
}
|
|
1374
|
+
wrap.appendChild(nameRow);
|
|
1375
|
+
if (a.permissions) {
|
|
1376
|
+
var permsEl = document.createElement('div');
|
|
1377
|
+
permsEl.setAttribute('style', 'font-size:12px;color:var(--fg-dim)');
|
|
1378
|
+
var reads = (a.permissions.reads || []).join(', ') || 'none';
|
|
1379
|
+
var writes = (a.permissions.writes || []).join(', ') || 'none';
|
|
1380
|
+
permsEl.innerHTML = '<b style="color:var(--fg)">reads:</b> ' + reads + ' <b style="color:var(--fg)">writes:</b> ' + writes;
|
|
1381
|
+
wrap.appendChild(permsEl);
|
|
1382
|
+
}
|
|
1383
|
+
el.appendChild(wrap);
|
|
1384
|
+
} catch (err) {
|
|
1385
|
+
if (el) { while (el.firstChild) el.removeChild(el.firstChild); }
|
|
1386
|
+
var errP = document.createElement('p');
|
|
1387
|
+
errP.setAttribute('style', 'font-size:12px;color:var(--fg-dim)');
|
|
1388
|
+
errP.textContent = 'Could not load active extension state.';
|
|
1389
|
+
if (el) el.appendChild(errP);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// ====== PERMISSION EVENTS LOADER (B9) ======
|
|
1394
|
+
var _evtSource = null;
|
|
1395
|
+
var _evtRows = [];
|
|
1396
|
+
var _evtExtensions = new Set();
|
|
1397
|
+
|
|
1398
|
+
function _renderEvtRows() {
|
|
1399
|
+
var el = document.getElementById('ext-events-content');
|
|
1400
|
+
if (!el) return;
|
|
1401
|
+
var filterExt = (document.getElementById('ext-evt-filter-ext') || {}).value || '';
|
|
1402
|
+
var filterDenied = (document.getElementById('ext-evt-filter-denied') || {}).value || '';
|
|
1403
|
+
var visible = _evtRows.filter(function(e) {
|
|
1404
|
+
if (filterExt && e.extension !== filterExt) return false;
|
|
1405
|
+
if (filterDenied === 'true' && e.allowed !== false) return false;
|
|
1406
|
+
if (filterDenied === 'false' && e.allowed === false) return false;
|
|
1407
|
+
return true;
|
|
1408
|
+
});
|
|
1409
|
+
while (el.firstChild) el.removeChild(el.firstChild);
|
|
1410
|
+
if (visible.length === 0) {
|
|
1411
|
+
var empty = document.createElement('div');
|
|
1412
|
+
empty.className = 'empty';
|
|
1413
|
+
var ep = document.createElement('p');
|
|
1414
|
+
ep.textContent = 'No permission events match the current filters.';
|
|
1415
|
+
empty.appendChild(ep);
|
|
1416
|
+
el.appendChild(empty);
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
var frag = document.createDocumentFragment();
|
|
1420
|
+
visible.slice(-200).forEach(function(e) {
|
|
1421
|
+
var row = document.createElement('div');
|
|
1422
|
+
row.className = 'evt-row';
|
|
1423
|
+
var ts = document.createElement('span');
|
|
1424
|
+
ts.className = 'evt-ts';
|
|
1425
|
+
ts.textContent = e.ts ? new Date(e.ts).toLocaleTimeString() : '';
|
|
1426
|
+
row.appendChild(ts);
|
|
1427
|
+
var badge = document.createElement('span');
|
|
1428
|
+
badge.className = 'evt-badge ' + (e.allowed === false ? 'evt-deny' : 'evt-allow');
|
|
1429
|
+
badge.textContent = e.allowed === false ? 'DENY' : 'ALLOW';
|
|
1430
|
+
row.appendChild(badge);
|
|
1431
|
+
var body = document.createElement('span');
|
|
1432
|
+
body.className = 'evt-body';
|
|
1433
|
+
body.textContent = (e.extension || '') + ' → ' + (e.tool || e.action || '') + (e.target ? ':' + e.target : '');
|
|
1434
|
+
row.appendChild(body);
|
|
1435
|
+
frag.appendChild(row);
|
|
1436
|
+
});
|
|
1437
|
+
el.appendChild(frag);
|
|
1438
|
+
el.scrollTop = el.scrollHeight;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function _addEvtRow(obj) {
|
|
1442
|
+
_evtRows.push(obj);
|
|
1443
|
+
if (obj.extension) {
|
|
1444
|
+
_evtExtensions.add(obj.extension);
|
|
1445
|
+
// Rebuild extension filter options
|
|
1446
|
+
var sel = document.getElementById('ext-evt-filter-ext');
|
|
1447
|
+
if (sel) {
|
|
1448
|
+
var existing = new Set(Array.from(sel.options).map(function(o) { return o.value; }));
|
|
1449
|
+
_evtExtensions.forEach(function(name) {
|
|
1450
|
+
if (!existing.has(name)) {
|
|
1451
|
+
var opt = document.createElement('option');
|
|
1452
|
+
opt.value = name; opt.textContent = name;
|
|
1453
|
+
sel.appendChild(opt);
|
|
1454
|
+
}
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function loadExtensionEvents() {
|
|
1461
|
+
var liveEl = document.getElementById('ext-events-live');
|
|
1462
|
+
|
|
1463
|
+
// Close any previous SSE connection.
|
|
1464
|
+
if (_evtSource) { try { _evtSource.close(); } catch {} _evtSource = null; }
|
|
1465
|
+
if (liveEl) liveEl.style.display = 'none';
|
|
1466
|
+
|
|
1467
|
+
// Pre-load current tail via JSON (no SSE until section is active).
|
|
1468
|
+
fetch('/api/extensions/events?limit=200')
|
|
1469
|
+
.then(function(r) { return r.json(); })
|
|
1470
|
+
.then(function(arr) {
|
|
1471
|
+
if (!Array.isArray(arr)) return;
|
|
1472
|
+
_evtRows = [];
|
|
1473
|
+
arr.forEach(_addEvtRow);
|
|
1474
|
+
_renderEvtRows();
|
|
1475
|
+
// Open SSE for live updates.
|
|
1476
|
+
try {
|
|
1477
|
+
_evtSource = new EventSource('/api/extensions/events?limit=1');
|
|
1478
|
+
_evtSource.onopen = function() { if (liveEl) liveEl.style.display = ''; };
|
|
1479
|
+
_evtSource.onmessage = function(e) {
|
|
1480
|
+
try {
|
|
1481
|
+
var obj = JSON.parse(e.data);
|
|
1482
|
+
if (obj && typeof obj === 'object' && !obj.showing) {
|
|
1483
|
+
_addEvtRow(obj);
|
|
1484
|
+
_renderEvtRows();
|
|
1485
|
+
}
|
|
1486
|
+
} catch {}
|
|
1487
|
+
};
|
|
1488
|
+
_evtSource.onerror = function() { if (liveEl) liveEl.style.display = 'none'; };
|
|
1489
|
+
} catch {}
|
|
1490
|
+
})
|
|
1491
|
+
.catch(function() {
|
|
1492
|
+
var el = document.getElementById('ext-events-content');
|
|
1493
|
+
if (!el) return;
|
|
1494
|
+
while (el.firstChild) el.removeChild(el.firstChild);
|
|
1495
|
+
var p = document.createElement('p');
|
|
1496
|
+
p.setAttribute('style', 'font-size:12px;color:var(--fg-dim);padding:8px 0');
|
|
1497
|
+
p.textContent = 'Permission events unavailable.';
|
|
1498
|
+
el.appendChild(p);
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
// Wire filter controls.
|
|
1502
|
+
var filterExt = document.getElementById('ext-evt-filter-ext');
|
|
1503
|
+
var filterDenied = document.getElementById('ext-evt-filter-denied');
|
|
1504
|
+
var clearBtn = document.getElementById('ext-evt-clear');
|
|
1505
|
+
if (filterExt) filterExt.onchange = _renderEvtRows;
|
|
1506
|
+
if (filterDenied) filterDenied.onchange = _renderEvtRows;
|
|
1507
|
+
if (clearBtn) clearBtn.onclick = function() {
|
|
1508
|
+
_evtRows = [];
|
|
1509
|
+
_renderEvtRows();
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1306
1513
|
// ====== RUN ALL LOADERS ======
|
|
1307
1514
|
document.addEventListener('DOMContentLoaded', function() {
|
|
1308
1515
|
loadCostToday();
|
|
@@ -1317,6 +1524,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
1317
1524
|
loadTrendSparkline();
|
|
1318
1525
|
loadBlockUsage();
|
|
1319
1526
|
loadExtensions();
|
|
1527
|
+
loadExtensionActive();
|
|
1528
|
+
loadExtensionEvents();
|
|
1320
1529
|
});
|
|
1321
1530
|
</script>
|
|
1322
1531
|
</body>
|
package/src/dashboard-server.js
CHANGED
|
@@ -688,6 +688,249 @@ export async function startServer(options = {}) {
|
|
|
688
688
|
res.end(JSON.stringify(config));
|
|
689
689
|
}],
|
|
690
690
|
|
|
691
|
+
// ---------- extensions: installed (B9) ----------
|
|
692
|
+
// Enumerate ~/.ijfw/state-org/extension-registry.json,
|
|
693
|
+
// ~/.ijfw/state-user/extension-registry.json, and project-scope registry.
|
|
694
|
+
// Returns JSON list with name, scope, version, publisher_keyId, permissions,
|
|
695
|
+
// last_activated_time. Path-traversal defence: resolve + assert under HOME.
|
|
696
|
+
['/api/extensions/installed', async (req, res) => {
|
|
697
|
+
try {
|
|
698
|
+
const home = homedir();
|
|
699
|
+
// realpath both sides — on macOS /var/folders -> /private/var/folders is a symlink,
|
|
700
|
+
// so the registry's realpathed path won't show as under un-realpathed HOME.
|
|
701
|
+
let homeCanon;
|
|
702
|
+
try { homeCanon = realpathSync(home); } catch { homeCanon = home; }
|
|
703
|
+
function isUnderHome(p) {
|
|
704
|
+
try {
|
|
705
|
+
const canon = realpathSync(p);
|
|
706
|
+
const rel = relative(homeCanon, canon);
|
|
707
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
708
|
+
} catch { return false; }
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const REGISTRY_FILENAME = 'extension-registry.json';
|
|
712
|
+
const registryPaths = [
|
|
713
|
+
{ scope: 'org', path: join(home, '.ijfw', 'state-org', REGISTRY_FILENAME) },
|
|
714
|
+
{ scope: 'user', path: join(home, '.ijfw', 'state-user', REGISTRY_FILENAME) },
|
|
715
|
+
{ scope: 'project', path: join(REPO_ROOT, '.ijfw', 'state', REGISTRY_FILENAME) },
|
|
716
|
+
];
|
|
717
|
+
|
|
718
|
+
const seen = new Map();
|
|
719
|
+
for (const { scope, path: regPath } of registryPaths) {
|
|
720
|
+
// Path-traversal check on each registry path.
|
|
721
|
+
const resolvedReg = resolve(regPath);
|
|
722
|
+
const underHome = isUnderHome(resolvedReg) ||
|
|
723
|
+
resolvedReg.startsWith(resolve(REPO_ROOT));
|
|
724
|
+
if (!underHome) continue;
|
|
725
|
+
if (!existsSync(resolvedReg)) continue;
|
|
726
|
+
let registry;
|
|
727
|
+
try {
|
|
728
|
+
const raw = readFileSync(resolvedReg, 'utf8');
|
|
729
|
+
const parsed = JSON.parse(raw);
|
|
730
|
+
registry = Array.isArray(parsed.extensions) ? parsed.extensions : [];
|
|
731
|
+
} catch { continue; }
|
|
732
|
+
|
|
733
|
+
for (const e of registry) {
|
|
734
|
+
if (!e || !e.name || !e.version) continue;
|
|
735
|
+
const key = `${e.name}@${e.version}`;
|
|
736
|
+
if (seen.has(key)) continue;
|
|
737
|
+
const manifest = (e.manifest && typeof e.manifest === 'object') ? e.manifest : null;
|
|
738
|
+
seen.set(key, {
|
|
739
|
+
name: e.name,
|
|
740
|
+
scope,
|
|
741
|
+
version: e.version,
|
|
742
|
+
publisher_keyId: manifest ? (manifest.publisher_keyId || null) : null,
|
|
743
|
+
permissions: manifest ? (manifest.permissions || null) : null,
|
|
744
|
+
last_activated_time: e.last_activated_time || null,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
749
|
+
res.end(JSON.stringify({ extensions: Array.from(seen.values()) }));
|
|
750
|
+
} catch (err) {
|
|
751
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
752
|
+
res.end(JSON.stringify({ extensions: [], error: err.message }));
|
|
753
|
+
}
|
|
754
|
+
}],
|
|
755
|
+
|
|
756
|
+
// ---------- extensions: active (B9) ----------
|
|
757
|
+
['/api/extensions/active', async (req, res) => {
|
|
758
|
+
try {
|
|
759
|
+
const home = homedir();
|
|
760
|
+
const activePath = join(home, '.ijfw', 'state', 'active-extension.json');
|
|
761
|
+
const resolvedActive = resolve(activePath);
|
|
762
|
+
// Return {active:null} BEFORE realpath — realpathSync throws on non-existent paths.
|
|
763
|
+
if (!existsSync(resolvedActive)) {
|
|
764
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
765
|
+
res.end(JSON.stringify({ active: null }));
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
// Path-traversal: realpath BOTH home and target so macOS /private symlinks
|
|
769
|
+
// (e.g. /var/folders -> /private/var/folders) resolve correctly AND symlinks
|
|
770
|
+
// pointing outside HOME are rejected.
|
|
771
|
+
let homeCanon;
|
|
772
|
+
try { homeCanon = realpathSync(home); } catch { homeCanon = home; }
|
|
773
|
+
let activeCanon;
|
|
774
|
+
try {
|
|
775
|
+
activeCanon = realpathSync(resolvedActive);
|
|
776
|
+
} catch {
|
|
777
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
778
|
+
res.end(JSON.stringify({ error: 'path traversal rejected' }));
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const rel = relative(homeCanon, activeCanon);
|
|
782
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
783
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
784
|
+
res.end(JSON.stringify({ error: 'path traversal rejected' }));
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const raw = readFileSync(activeCanon, 'utf8');
|
|
788
|
+
const parsed = JSON.parse(raw);
|
|
789
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
790
|
+
res.end(JSON.stringify({ active: parsed }));
|
|
791
|
+
} catch (err) {
|
|
792
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
793
|
+
res.end(JSON.stringify({ active: null, error: err.message }));
|
|
794
|
+
}
|
|
795
|
+
}],
|
|
796
|
+
|
|
797
|
+
// ---------- extensions: events (B9) ----------
|
|
798
|
+
// Tail-streams ~/.ijfw/state/permission-events.jsonl with optional filters.
|
|
799
|
+
// Allowlisted query params: limit, extension, tool, denied.
|
|
800
|
+
// SSE mode: Accept: text/event-stream. JSON array otherwise.
|
|
801
|
+
// Never reads full file into memory: streams line-by-line.
|
|
802
|
+
['/api/extensions/events', async (req, res, url) => {
|
|
803
|
+
const ALLOWED_FILTER_KEYS = new Set(['limit', 'extension', 'tool', 'denied']);
|
|
804
|
+
// Reject any non-allowlisted filter key.
|
|
805
|
+
for (const key of url.searchParams.keys()) {
|
|
806
|
+
if (!ALLOWED_FILTER_KEYS.has(key)) {
|
|
807
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
808
|
+
res.end(JSON.stringify({ error: `unknown filter parameter: ${key}` }));
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const home = homedir();
|
|
814
|
+
const eventsPath = join(home, '.ijfw', 'state', 'permission-events.jsonl');
|
|
815
|
+
|
|
816
|
+
const rawLimit = url.searchParams.get('limit');
|
|
817
|
+
let limit = 200;
|
|
818
|
+
if (rawLimit !== null) {
|
|
819
|
+
const n = safeIntegerParam(rawLimit, 10_000);
|
|
820
|
+
if (n === null) {
|
|
821
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
822
|
+
res.end(JSON.stringify({ error: 'invalid limit' }));
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
limit = n;
|
|
826
|
+
}
|
|
827
|
+
const filterExtension = url.searchParams.get('extension') || null;
|
|
828
|
+
const filterTool = url.searchParams.get('tool') || null;
|
|
829
|
+
const filterDenied = url.searchParams.has('denied')
|
|
830
|
+
? (url.searchParams.get('denied') !== 'false')
|
|
831
|
+
: null;
|
|
832
|
+
|
|
833
|
+
// Stream-tail: read only the last TAIL_CHUNK bytes, never slurp the full file.
|
|
834
|
+
// For permission-events.jsonl which rotates at 10_000 lines, 2MB covers
|
|
835
|
+
// many thousands of events without loading the entire file into memory.
|
|
836
|
+
const TAIL_CHUNK = 2 * 1024 * 1024; // 2MB
|
|
837
|
+
function tailEvents() {
|
|
838
|
+
if (!existsSync(eventsPath)) return [];
|
|
839
|
+
let st;
|
|
840
|
+
try { st = statSync(eventsPath); } catch { return []; }
|
|
841
|
+
if (st.size === 0) return [];
|
|
842
|
+
let lines = [];
|
|
843
|
+
try {
|
|
844
|
+
const fullBuf = readFileSync(eventsPath);
|
|
845
|
+
const slice = fullBuf.subarray(Math.max(0, fullBuf.length - TAIL_CHUNK));
|
|
846
|
+
const text = slice.toString('utf8');
|
|
847
|
+
lines = text.split('\n').filter(Boolean);
|
|
848
|
+
// If we sliced mid-line, the first element may be truncated — drop it.
|
|
849
|
+
if (fullBuf.length > TAIL_CHUNK) lines = lines.slice(1);
|
|
850
|
+
} catch { return []; }
|
|
851
|
+
|
|
852
|
+
const results = [];
|
|
853
|
+
for (let i = lines.length - 1; i >= 0 && results.length < limit; i--) {
|
|
854
|
+
let obj;
|
|
855
|
+
try { obj = JSON.parse(lines[i]); } catch { continue; }
|
|
856
|
+
if (filterExtension !== null && obj.extension !== filterExtension) continue;
|
|
857
|
+
if (filterTool !== null && obj.tool !== filterTool) continue;
|
|
858
|
+
if (filterDenied !== null && Boolean(!obj.allowed) !== filterDenied) continue;
|
|
859
|
+
results.unshift(obj);
|
|
860
|
+
}
|
|
861
|
+
return results;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const isSSE = (req.headers['accept'] || '').includes('text/event-stream');
|
|
865
|
+
if (isSSE) {
|
|
866
|
+
res.writeHead(200, {
|
|
867
|
+
'Content-Type': 'text/event-stream',
|
|
868
|
+
'Cache-Control': 'no-cache',
|
|
869
|
+
'Connection': 'keep-alive',
|
|
870
|
+
'X-Accel-Buffering': 'no',
|
|
871
|
+
});
|
|
872
|
+
res.write(': connected\n\n');
|
|
873
|
+
// Send current tail as initial batch.
|
|
874
|
+
const initial = tailEvents();
|
|
875
|
+
for (const evt of initial) {
|
|
876
|
+
try { res.write(`data: ${JSON.stringify(evt)}\n\n`); } catch { break; }
|
|
877
|
+
}
|
|
878
|
+
// Watch for new events. lastLineCount must match what the watcher
|
|
879
|
+
// measures (tail-chunk lines), NOT the limited initial batch length —
|
|
880
|
+
// mismatching them causes a replay storm when the file is bigger than
|
|
881
|
+
// the limit.
|
|
882
|
+
let evtWatcher = null;
|
|
883
|
+
let lastLineCount = 0;
|
|
884
|
+
try {
|
|
885
|
+
const buf0 = readFileSync(eventsPath);
|
|
886
|
+
const slice0 = buf0.subarray(Math.max(0, buf0.length - TAIL_CHUNK));
|
|
887
|
+
let lines0 = slice0.toString('utf8').split('\n').filter(Boolean);
|
|
888
|
+
if (buf0.length > TAIL_CHUNK) lines0 = lines0.slice(1);
|
|
889
|
+
lastLineCount = lines0.length;
|
|
890
|
+
} catch { /* eventsPath missing or unreadable — start from 0 */ }
|
|
891
|
+
try {
|
|
892
|
+
evtWatcher = watch(existsSync(eventsPath) ? eventsPath : join(home, '.ijfw', 'state'), () => {
|
|
893
|
+
if (!existsSync(eventsPath)) return;
|
|
894
|
+
try {
|
|
895
|
+
// Use the tail-chunk reader (bounded read) rather than slurping the
|
|
896
|
+
// full file. At 10K lines × ~1-2KB each = 10-20MB sync read per watch
|
|
897
|
+
// event, which is unacceptable for a long-lived SSE connection.
|
|
898
|
+
try { statSync(eventsPath); } catch { return; }
|
|
899
|
+
const buf = (() => {
|
|
900
|
+
try { return readFileSync(eventsPath); } catch { return null; }
|
|
901
|
+
})();
|
|
902
|
+
if (!buf) return;
|
|
903
|
+
const slice = buf.subarray(Math.max(0, buf.length - TAIL_CHUNK));
|
|
904
|
+
const text = slice.toString('utf8');
|
|
905
|
+
let lines = text.split('\n').filter(Boolean);
|
|
906
|
+
if (buf.length > TAIL_CHUNK) lines = lines.slice(1);
|
|
907
|
+
if (lines.length > lastLineCount) {
|
|
908
|
+
const newLines = lines.slice(lastLineCount);
|
|
909
|
+
lastLineCount = lines.length;
|
|
910
|
+
for (const line of newLines) {
|
|
911
|
+
let obj; try { obj = JSON.parse(line); } catch { continue; }
|
|
912
|
+
if (filterExtension !== null && obj.extension !== filterExtension) continue;
|
|
913
|
+
if (filterTool !== null && obj.tool !== filterTool) continue;
|
|
914
|
+
if (filterDenied !== null && Boolean(!obj.allowed) !== filterDenied) continue;
|
|
915
|
+
try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch {}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
} catch {}
|
|
919
|
+
});
|
|
920
|
+
if (evtWatcher) evtWatcher.on('error', () => {});
|
|
921
|
+
} catch {}
|
|
922
|
+
req.on('close', () => {
|
|
923
|
+
if (evtWatcher) { try { evtWatcher.close(); } catch {} }
|
|
924
|
+
});
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// JSON array response.
|
|
929
|
+
const events = tailEvents();
|
|
930
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
931
|
+
res.end(JSON.stringify(events));
|
|
932
|
+
}],
|
|
933
|
+
|
|
691
934
|
// ---------- extensions health (W3/t15) ----------
|
|
692
935
|
// Reads .ijfw/state/extension-registry.json (project) plus org/user via
|
|
693
936
|
// listExtensions(). Missing or malformed registry yields {extensions: []}
|