@ijfw/memory-server 1.4.0 → 1.4.3
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 +314 -8
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client.html +411 -1
- package/src/dashboard-server.js +350 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +272 -1
- 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/extension-installer.js +39 -0
- package/src/extension-manifest-schema.js +25 -0
- package/src/extension-permission-check.mjs +140 -0
- package/src/extension-quota-tracker.js +305 -0
- package/src/extension-registry-ws.js +347 -0
- package/src/extension-registry.js +1289 -0
- package/src/extension-signer.js +270 -0
- package/src/fs-lock.js +205 -0
- package/src/hardware-signer.js +493 -0
- package/src/ide-detect.js +122 -0
- package/src/memory-feedback.js +194 -10
- package/src/runtime-mediator.js +61 -1
- package/src/server.js +180 -18
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.3",
|
|
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",
|
|
@@ -7,23 +7,40 @@
|
|
|
7
7
|
* - installExtension when opts.activate is set
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
|
10
|
+
import { readFile, writeFile, unlink, mkdir, readdir, stat } 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';
|
|
14
|
+
|
|
15
|
+
import { resetExtensionQuotas } from './extension-quota-tracker.js';
|
|
13
16
|
|
|
14
17
|
const STATE_PATH_REL = ['.ijfw', 'state', 'active-extension.json'];
|
|
15
18
|
|
|
19
|
+
// B18 — stale last-seen-by-<ide>.json files older than this get cleaned on read.
|
|
20
|
+
const LAST_SEEN_STALE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
// Valid IDE id pattern (matches ide-detect.js). Keep in sync.
|
|
23
|
+
const IDE_ID_PATTERN = /^[a-z0-9-]+$/;
|
|
24
|
+
|
|
16
25
|
function statePath(home) {
|
|
17
26
|
return join(home || homedir(), ...STATE_PATH_REL);
|
|
18
27
|
}
|
|
19
28
|
|
|
29
|
+
function lastSeenPath(home, ideId) {
|
|
30
|
+
return join(home || homedir(), '.ijfw', 'state', `last-seen-by-${ideId}.json`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stateDir(home) {
|
|
34
|
+
return join(home || homedir(), '.ijfw', 'state');
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
/**
|
|
21
38
|
* Write the active-extension state file from a manifest + scope.
|
|
22
39
|
* Validates required fields before write. Atomic write via tmp+rename.
|
|
23
40
|
*
|
|
24
41
|
* @param {{ name: string, permissions: { reads: string[], writes: string[] } }} manifest
|
|
25
42
|
* @param {'project'|'org'|'user'} scope
|
|
26
|
-
* @param {{ homeDir?: string }} [opts]
|
|
43
|
+
* @param {{ homeDir?: string, ideId?: string|null }} [opts]
|
|
27
44
|
* @returns {Promise<{ ok: boolean, path?: string, error?: string }>}
|
|
28
45
|
*/
|
|
29
46
|
export async function writeActiveExtension(manifest, scope, opts = {}) {
|
|
@@ -41,19 +58,63 @@ export async function writeActiveExtension(manifest, scope, opts = {}) {
|
|
|
41
58
|
}
|
|
42
59
|
const reads = Array.isArray(manifest.permissions.reads) ? manifest.permissions.reads : [];
|
|
43
60
|
const writes = Array.isArray(manifest.permissions.writes) ? manifest.permissions.writes : [];
|
|
61
|
+
const activatedAt = new Date().toISOString();
|
|
62
|
+
// B18: stamp activated_by_ide + activated_by_pid when ideId is provided.
|
|
63
|
+
// Caller (CLI) is responsible for calling detectIde() and threading the
|
|
64
|
+
// value in. When opts.ideId is null/undefined, fields are omitted (so the
|
|
65
|
+
// file stays back-compatible with v1.4.1 readers).
|
|
66
|
+
const ideId = (typeof opts.ideId === 'string' && IDE_ID_PATTERN.test(opts.ideId))
|
|
67
|
+
? opts.ideId
|
|
68
|
+
: null;
|
|
44
69
|
const out = {
|
|
45
70
|
name: manifest.name,
|
|
46
71
|
scope,
|
|
47
72
|
permissions: { reads, writes },
|
|
48
|
-
activated_at:
|
|
73
|
+
activated_at: activatedAt,
|
|
49
74
|
};
|
|
75
|
+
if (ideId) {
|
|
76
|
+
out.activated_by_ide = ideId;
|
|
77
|
+
out.activated_by_pid = process.pid;
|
|
78
|
+
}
|
|
79
|
+
// R12-H-01: persist manifest.quotas so the tier-2 hook
|
|
80
|
+
// (extension-permission-check.mjs) can enforce quotas on Edit/Write/Bash
|
|
81
|
+
// dispatch. Without this the tier-2 hook reads `active.quotas` as undefined
|
|
82
|
+
// and silently bypasses the v1.4.3 quota gate that the server-side
|
|
83
|
+
// gatePermissionAndQuota path enforces. Schema (extension-manifest-schema.js):
|
|
84
|
+
// optional object whose values are positive integers — currently
|
|
85
|
+
// max_files_written / max_bytes_written / max_wall_clock_ms (forward-compat:
|
|
86
|
+
// unknown dimensions are kept as-is — schema rejects unknowns at install).
|
|
87
|
+
if (
|
|
88
|
+
manifest.quotas !== undefined &&
|
|
89
|
+
manifest.quotas !== null &&
|
|
90
|
+
typeof manifest.quotas === 'object' &&
|
|
91
|
+
!Array.isArray(manifest.quotas)
|
|
92
|
+
) {
|
|
93
|
+
const cleanQuotas = {};
|
|
94
|
+
let copied = 0;
|
|
95
|
+
for (const [k, v] of Object.entries(manifest.quotas)) {
|
|
96
|
+
if (typeof v === 'number' && Number.isFinite(v) && Number.isInteger(v) && v > 0) {
|
|
97
|
+
cleanQuotas[k] = v;
|
|
98
|
+
copied++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (copied > 0) out.quotas = cleanQuotas;
|
|
102
|
+
}
|
|
50
103
|
const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
|
|
51
104
|
const path = statePath(home);
|
|
52
105
|
await mkdir(dirname(path), { recursive: true });
|
|
53
|
-
const tmp = `${path}.tmp.${
|
|
106
|
+
const tmp = `${path}.tmp.${randomBytes(4).toString('hex')}`;
|
|
54
107
|
await writeFile(tmp, JSON.stringify(out, null, 2) + '\n', 'utf8');
|
|
55
108
|
const { rename } = await import('node:fs/promises');
|
|
56
109
|
await rename(tmp, path);
|
|
110
|
+
// B16/SEC-M-02: reset quota counters on activate; stamp activated_at so
|
|
111
|
+
// wall_clock_ms can be computed against this activation window.
|
|
112
|
+
try {
|
|
113
|
+
await resetExtensionQuotas(manifest.name, { homeDir: home, activated_at: activatedAt });
|
|
114
|
+
} catch {
|
|
115
|
+
// Quota reset failure must not block activation. Counters will self-heal
|
|
116
|
+
// on next deactivate or the next activate of the same name.
|
|
117
|
+
}
|
|
57
118
|
return { ok: true, path };
|
|
58
119
|
}
|
|
59
120
|
|
|
@@ -65,11 +126,38 @@ export async function writeActiveExtension(manifest, scope, opts = {}) {
|
|
|
65
126
|
*/
|
|
66
127
|
export async function clearActiveExtension(opts = {}) {
|
|
67
128
|
const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
|
|
129
|
+
// B16/SEC-M-02: read the active extension name BEFORE unlinking so we can
|
|
130
|
+
// clear its quota counters. Best-effort: if the file is missing or
|
|
131
|
+
// malformed, deactivate still succeeds.
|
|
132
|
+
let extName = null;
|
|
133
|
+
try {
|
|
134
|
+
const raw = await readFile(statePath(home), 'utf8');
|
|
135
|
+
const parsed = JSON.parse(raw);
|
|
136
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.name === 'string') {
|
|
137
|
+
extName = parsed.name;
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// ignore — extName stays null
|
|
141
|
+
}
|
|
68
142
|
try {
|
|
69
143
|
await unlink(statePath(home));
|
|
144
|
+
if (extName) {
|
|
145
|
+
try {
|
|
146
|
+
await resetExtensionQuotas(extName, { homeDir: home });
|
|
147
|
+
} catch {
|
|
148
|
+
// best-effort
|
|
149
|
+
}
|
|
150
|
+
}
|
|
70
151
|
return { ok: true, removed: true };
|
|
71
152
|
} catch (err) {
|
|
72
|
-
if (err && err.code === 'ENOENT')
|
|
153
|
+
if (err && err.code === 'ENOENT') {
|
|
154
|
+
if (extName) {
|
|
155
|
+
try {
|
|
156
|
+
await resetExtensionQuotas(extName, { homeDir: home });
|
|
157
|
+
} catch { /* best-effort */ }
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, removed: false };
|
|
160
|
+
}
|
|
73
161
|
return { ok: false, removed: false };
|
|
74
162
|
}
|
|
75
163
|
}
|
|
@@ -86,7 +174,7 @@ export async function clearActiveExtension(opts = {}) {
|
|
|
86
174
|
*
|
|
87
175
|
* @param {string} name
|
|
88
176
|
* @param {string} [projectRoot]
|
|
89
|
-
* @param {{ homeDir?: string }} [opts]
|
|
177
|
+
* @param {{ homeDir?: string, strictShadow?: boolean }} [opts]
|
|
90
178
|
* @returns {Promise<{ ok: boolean, manifest?: object, scope?: string, path?: string, error?: string }>}
|
|
91
179
|
*/
|
|
92
180
|
export async function findInstalledManifest(name, projectRoot, opts = {}) {
|
|
@@ -102,15 +190,233 @@ export async function findInstalledManifest(name, projectRoot, opts = {}) {
|
|
|
102
190
|
candidates.push({ scope: 'org', path: join(home, '.ijfw', 'extensions-org', name, 'manifest.json') });
|
|
103
191
|
candidates.push({ scope: 'user', path: join(home, '.ijfw', 'extensions-user', name, 'manifest.json') });
|
|
104
192
|
|
|
193
|
+
// Collect all found manifests to detect project-scope shadowing.
|
|
194
|
+
const found = [];
|
|
105
195
|
for (const c of candidates) {
|
|
106
196
|
try {
|
|
107
197
|
const raw = await readFile(c.path, 'utf8');
|
|
108
198
|
const manifest = JSON.parse(raw);
|
|
109
|
-
|
|
199
|
+
found.push({ scope: c.scope, path: c.path, manifest });
|
|
110
200
|
} catch (err) {
|
|
111
201
|
if (err && err.code === 'ENOENT') continue;
|
|
112
202
|
if (err instanceof SyntaxError) continue;
|
|
113
203
|
}
|
|
114
204
|
}
|
|
115
|
-
|
|
205
|
+
|
|
206
|
+
if (found.length === 0) {
|
|
207
|
+
return { ok: false, error: `extension "${name}" not found in project/org/user scope` };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const winner = found[0];
|
|
211
|
+
|
|
212
|
+
// B13.1: warn when project-scope shadows a lower-priority scope entry.
|
|
213
|
+
if (winner.scope === 'project' && found.length > 1) {
|
|
214
|
+
const shadowed = found[1];
|
|
215
|
+
const winnerKeyId = winner.manifest.signature?.keyId ?? '(unsigned)';
|
|
216
|
+
const shadowedKeyId = shadowed.manifest.signature?.keyId ?? '(unsigned)';
|
|
217
|
+
if (opts && opts.strictShadow) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
error: `extension activate: project-scope "${name}" shadows ${shadowed.scope}-scope "${name}" (keyId ${winnerKeyId} vs ${shadowedKeyId}) — refused by strictShadow`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
process.stderr.write(
|
|
224
|
+
`[ijfw] extension activate: project-scope "${name}" shadows ${shadowed.scope}-scope "${name}" (keyId ${winnerKeyId} vs ${shadowedKeyId}) — using project\n`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { ok: true, manifest: winner.manifest, scope: winner.scope, path: winner.path };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ============================================================================
|
|
232
|
+
// B18 — Cross-IDE Conflict Detection
|
|
233
|
+
// ============================================================================
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Write the current IDE's last-seen marker. Best-effort: never throws.
|
|
237
|
+
* Atomic via tmp+rename.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} ideId
|
|
240
|
+
* @param {{ homeDir?: string }} [opts]
|
|
241
|
+
*/
|
|
242
|
+
async function writeLastSeen(ideId, opts = {}) {
|
|
243
|
+
if (!ideId || typeof ideId !== 'string' || !IDE_ID_PATTERN.test(ideId)) return;
|
|
244
|
+
if (ideId === 'unknown') return;
|
|
245
|
+
const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
|
|
246
|
+
const path = lastSeenPath(home, ideId);
|
|
247
|
+
try {
|
|
248
|
+
await mkdir(dirname(path), { recursive: true });
|
|
249
|
+
const body = JSON.stringify({ ide: ideId, last_seen_at: new Date().toISOString() }, null, 2) + '\n';
|
|
250
|
+
const tmp = `${path}.tmp.${randomBytes(4).toString('hex')}`;
|
|
251
|
+
await writeFile(tmp, body, 'utf8');
|
|
252
|
+
const { rename } = await import('node:fs/promises');
|
|
253
|
+
await rename(tmp, path);
|
|
254
|
+
} catch {
|
|
255
|
+
// best-effort
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Read another IDE's last-seen marker. Returns null on any read error or
|
|
261
|
+
* missing file.
|
|
262
|
+
*
|
|
263
|
+
* @param {string} ideId
|
|
264
|
+
* @param {{ homeDir?: string }} [opts]
|
|
265
|
+
* @returns {Promise<{ ide: string, last_seen_at: string }|null>}
|
|
266
|
+
*/
|
|
267
|
+
async function readLastSeen(ideId, opts = {}) {
|
|
268
|
+
if (!ideId || !IDE_ID_PATTERN.test(ideId)) return null;
|
|
269
|
+
const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
|
|
270
|
+
try {
|
|
271
|
+
const raw = await readFile(lastSeenPath(home, ideId), 'utf8');
|
|
272
|
+
const parsed = JSON.parse(raw);
|
|
273
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
274
|
+
if (typeof parsed.last_seen_at !== 'string') return null;
|
|
275
|
+
return parsed;
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
116
279
|
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Scan ~/.ijfw/state/ for last-seen-by-<ide>.json files older than 30 days
|
|
283
|
+
* and unlink them. Best-effort: never throws. Returns the number removed.
|
|
284
|
+
*
|
|
285
|
+
* @param {{ homeDir?: string, now?: number }} [opts]
|
|
286
|
+
* @returns {Promise<number>}
|
|
287
|
+
*/
|
|
288
|
+
async function cleanupStaleLastSeen(opts = {}) {
|
|
289
|
+
const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
|
|
290
|
+
const now = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
291
|
+
let removed = 0;
|
|
292
|
+
let entries;
|
|
293
|
+
try {
|
|
294
|
+
entries = await readdir(stateDir(home));
|
|
295
|
+
} catch {
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
for (const entry of entries) {
|
|
299
|
+
if (!entry.startsWith('last-seen-by-') || !entry.endsWith('.json')) continue;
|
|
300
|
+
const full = join(stateDir(home), entry);
|
|
301
|
+
try {
|
|
302
|
+
const st = await stat(full);
|
|
303
|
+
if (now - st.mtimeMs > LAST_SEEN_STALE_MS) {
|
|
304
|
+
await unlink(full);
|
|
305
|
+
removed++;
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
// best-effort
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return removed;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Detect divergence between the IDE that wrote active.json and the current IDE.
|
|
316
|
+
*
|
|
317
|
+
* Semantics:
|
|
318
|
+
* - active.json missing OR no activated_by_ide field (pre-v1.4.3) → not divergent
|
|
319
|
+
* - active.activated_by_ide === current_ide → not divergent
|
|
320
|
+
* - active.activated_by_ide !== current_ide AND current_ide has a last-seen
|
|
321
|
+
* file AND active.activated_at is OLDER than current_ide's last_seen_at →
|
|
322
|
+
* divergent (this IDE was here before; a different IDE has since taken over
|
|
323
|
+
* stale state)
|
|
324
|
+
* - otherwise → not divergent (legitimate cross-IDE hand-off)
|
|
325
|
+
*
|
|
326
|
+
* Side effects:
|
|
327
|
+
* - writes current_ide's last-seen marker
|
|
328
|
+
* - cleans up stale last-seen files (>30 days)
|
|
329
|
+
*
|
|
330
|
+
* @param {{ homeDir?: string, currentIde?: string, now?: number }} [opts]
|
|
331
|
+
* @returns {Promise<{ divergent: boolean, last_writer: string|null, current_ide: string, age_seconds: number|null, reason?: string }>}
|
|
332
|
+
*/
|
|
333
|
+
export async function detectCrossIdeDivergence(opts = {}) {
|
|
334
|
+
const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
|
|
335
|
+
const now = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
336
|
+
// Lazy import to avoid a hard module dependency cycle in test scaffolds.
|
|
337
|
+
let currentIde = opts.currentIde;
|
|
338
|
+
if (!currentIde) {
|
|
339
|
+
try {
|
|
340
|
+
const { detectIde } = await import('./ide-detect.js');
|
|
341
|
+
currentIde = detectIde();
|
|
342
|
+
} catch {
|
|
343
|
+
currentIde = 'unknown';
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Best-effort stale cleanup on every divergence check.
|
|
348
|
+
await cleanupStaleLastSeen({ homeDir: home, now });
|
|
349
|
+
|
|
350
|
+
// Read active.json. Missing or unreadable → not divergent.
|
|
351
|
+
let active;
|
|
352
|
+
try {
|
|
353
|
+
const raw = await readFile(statePath(home), 'utf8');
|
|
354
|
+
active = JSON.parse(raw);
|
|
355
|
+
} catch {
|
|
356
|
+
return { divergent: false, last_writer: null, current_ide: currentIde, age_seconds: null, reason: 'no active extension' };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Pre-v1.4.3 active.json — silently no-divergence.
|
|
360
|
+
if (!active || typeof active !== 'object' || typeof active.activated_by_ide !== 'string') {
|
|
361
|
+
return { divergent: false, last_writer: null, current_ide: currentIde, age_seconds: null, reason: 'pre-v1.4.3 active.json' };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const lastWriter = active.activated_by_ide;
|
|
365
|
+
const activatedAtMs = typeof active.activated_at === 'string' ? Date.parse(active.activated_at) : NaN;
|
|
366
|
+
const ageSeconds = Number.isFinite(activatedAtMs) ? Math.max(0, Math.floor((now - activatedAtMs) / 1000)) : null;
|
|
367
|
+
|
|
368
|
+
// CRITICAL ORDERING: read prior last-seen BEFORE overwriting it. Otherwise
|
|
369
|
+
// the divergence comparison degrades to "now vs activated_at" which is
|
|
370
|
+
// always non-divergent.
|
|
371
|
+
let priorSeen = null;
|
|
372
|
+
if (lastWriter !== currentIde && currentIde !== 'unknown') {
|
|
373
|
+
priorSeen = await readLastSeen(currentIde, { homeDir: home });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Now write our own last-seen marker (best-effort; we ARE the current IDE).
|
|
377
|
+
if (currentIde !== 'unknown') {
|
|
378
|
+
await writeLastSeen(currentIde, { homeDir: home });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (lastWriter === currentIde) {
|
|
382
|
+
return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'same ide' };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// If current_ide is 'unknown', divergence detection is disabled.
|
|
386
|
+
if (currentIde === 'unknown') {
|
|
387
|
+
return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'detection disabled' };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (!priorSeen) {
|
|
391
|
+
// Current IDE has no prior history → legitimate first-time hand-off.
|
|
392
|
+
return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'first-time current ide' };
|
|
393
|
+
}
|
|
394
|
+
const seenMs = Date.parse(priorSeen.last_seen_at);
|
|
395
|
+
if (!Number.isFinite(seenMs) || !Number.isFinite(activatedAtMs)) {
|
|
396
|
+
return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'unparseable timestamps' };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Design rule (per B18 spec):
|
|
400
|
+
// divergent iff active.activated_by_ide != currentIde
|
|
401
|
+
// AND active.activated_at < currentIde.last_seen
|
|
402
|
+
//
|
|
403
|
+
// Reading: the slot says some other IDE wrote it, but the current IDE has
|
|
404
|
+
// a more recent last-seen — i.e., the current IDE has been touching state
|
|
405
|
+
// more recently than the write, yet a different IDE's name is on it. Stale
|
|
406
|
+
// cross-IDE state divergence.
|
|
407
|
+
if (activatedAtMs < seenMs) {
|
|
408
|
+
return { divergent: true, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'stale active.json: current ide last-seen is more recent than active.activated_at' };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// active was written AFTER current ide's last_seen — legitimate hand-off
|
|
412
|
+
// (another IDE took over while current was away).
|
|
413
|
+
return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'legitimate handoff: foreign ide wrote after current ide was last here' };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Internal helpers exported for tests only.
|
|
417
|
+
export const __testing = Object.freeze({
|
|
418
|
+
writeLastSeen,
|
|
419
|
+
readLastSeen,
|
|
420
|
+
cleanupStaleLastSeen,
|
|
421
|
+
LAST_SEEN_STALE_MS,
|
|
422
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dashboard-aggregator.js — IJFW v1.4.3 W9-C (B19)
|
|
3
|
+
*
|
|
4
|
+
* Server-side aggregation of ~/.ijfw/state/permission-events.jsonl for the
|
|
5
|
+
* dashboard's per-tool audit charts. Reads only the last TAIL_CHUNK bytes
|
|
6
|
+
* (same 2MB cap as the events endpoint) so this is bounded-memory even
|
|
7
|
+
* across rotations.
|
|
8
|
+
*
|
|
9
|
+
* Cache: 60s OR until the events file mtime changes, whichever first.
|
|
10
|
+
* Malformed JSONL lines are dropped silently — never crash the dashboard
|
|
11
|
+
* on a partial write.
|
|
12
|
+
*
|
|
13
|
+
* Returned shape:
|
|
14
|
+
* {
|
|
15
|
+
* hourly: { [hourISO]: count },
|
|
16
|
+
* by_extension: { [ext]: { allowed: number, denied: number } },
|
|
17
|
+
* by_tool_denied:{ [tool]: count }
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Helper `computeWarnBashBypass(manifest)` implements ARCH-M-01: an extension
|
|
21
|
+
* with `tool:bash` or `tool:exec` in writes AND a strict files/bytes quota
|
|
22
|
+
* declared in the manifest gets a warning chip in the dashboard. The chip is
|
|
23
|
+
* an information channel — quota enforcement still applies, but bash content
|
|
24
|
+
* bypasses per-file accounting at the API surface.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, statSync, readFileSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
|
|
30
|
+
// Match dashboard-server's TAIL_CHUNK. Kept here so this module is
|
|
31
|
+
// self-contained for the test harness.
|
|
32
|
+
export const TAIL_CHUNK = 2 * 1024 * 1024; // 2MB
|
|
33
|
+
const CACHE_TTL_MS = 60_000;
|
|
34
|
+
|
|
35
|
+
// Module-level cache. Key = canonical path; value = { mtimeMs, builtAt, result }.
|
|
36
|
+
const _cache = new Map();
|
|
37
|
+
|
|
38
|
+
export function _resetAggregatorCacheForTest() {
|
|
39
|
+
_cache.clear();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function _readTailLines(eventsPath) {
|
|
43
|
+
if (!existsSync(eventsPath)) return { lines: [], mtimeMs: 0 };
|
|
44
|
+
let st;
|
|
45
|
+
try { st = statSync(eventsPath); } catch { return { lines: [], mtimeMs: 0 }; }
|
|
46
|
+
if (!st.size) return { lines: [], mtimeMs: st.mtimeMs };
|
|
47
|
+
let buf;
|
|
48
|
+
try { buf = readFileSync(eventsPath); } catch { return { lines: [], mtimeMs: st.mtimeMs }; }
|
|
49
|
+
const slice = buf.subarray(Math.max(0, buf.length - TAIL_CHUNK));
|
|
50
|
+
let lines = slice.toString('utf8').split('\n').filter(Boolean);
|
|
51
|
+
// If we sliced mid-line, drop the partial leading element.
|
|
52
|
+
if (buf.length > TAIL_CHUNK) lines = lines.slice(1);
|
|
53
|
+
return { lines, mtimeMs: st.mtimeMs };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function _hourBucket(tsMs) {
|
|
57
|
+
const d = new Date(tsMs);
|
|
58
|
+
d.setUTCMinutes(0, 0, 0);
|
|
59
|
+
return d.toISOString();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Aggregate permission events within `windowMs` of `now`.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} eventsPath absolute path to permission-events.jsonl
|
|
66
|
+
* @param {{ windowMs?: number, now?: number }} [opts]
|
|
67
|
+
*/
|
|
68
|
+
export async function aggregateEvents(eventsPath, opts = {}) {
|
|
69
|
+
const windowMs = (opts && typeof opts.windowMs === 'number') ? opts.windowMs : 24 * 3600 * 1000;
|
|
70
|
+
const now = (opts && typeof opts.now === 'number') ? opts.now : Date.now();
|
|
71
|
+
|
|
72
|
+
const { lines, mtimeMs } = _readTailLines(eventsPath);
|
|
73
|
+
|
|
74
|
+
const cached = _cache.get(eventsPath);
|
|
75
|
+
if (cached
|
|
76
|
+
&& cached.mtimeMs === mtimeMs
|
|
77
|
+
&& (now - cached.builtAt) < CACHE_TTL_MS
|
|
78
|
+
&& cached.windowMs === windowMs) {
|
|
79
|
+
return cached.result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const cutoff = now - windowMs;
|
|
83
|
+
const hourly = Object.create(null);
|
|
84
|
+
const byExt = Object.create(null);
|
|
85
|
+
const byToolDenied = Object.create(null);
|
|
86
|
+
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
let obj;
|
|
89
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
90
|
+
if (!obj || typeof obj !== 'object') continue;
|
|
91
|
+
const t = typeof obj.ts === 'string' ? Date.parse(obj.ts) : (typeof obj.ts === 'number' ? obj.ts : NaN);
|
|
92
|
+
if (!Number.isFinite(t)) continue;
|
|
93
|
+
if (t < cutoff) continue;
|
|
94
|
+
|
|
95
|
+
const ext = (typeof obj.extension === 'string' && obj.extension) ? obj.extension : '<unknown>';
|
|
96
|
+
const tool = (typeof obj.tool === 'string' && obj.tool) ? obj.tool : (typeof obj.action === 'string' ? obj.action : '<unknown>');
|
|
97
|
+
const allowed = obj.allowed !== false; // anything other than explicit false is allowed.
|
|
98
|
+
|
|
99
|
+
// hourly
|
|
100
|
+
const hk = _hourBucket(t);
|
|
101
|
+
hourly[hk] = (hourly[hk] || 0) + 1;
|
|
102
|
+
|
|
103
|
+
// by_extension
|
|
104
|
+
if (!byExt[ext]) byExt[ext] = { allowed: 0, denied: 0 };
|
|
105
|
+
if (allowed) byExt[ext].allowed += 1;
|
|
106
|
+
else byExt[ext].denied += 1;
|
|
107
|
+
|
|
108
|
+
// by_tool_denied
|
|
109
|
+
if (!allowed) {
|
|
110
|
+
byToolDenied[tool] = (byToolDenied[tool] || 0) + 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const result = { hourly, by_extension: byExt, by_tool_denied: byToolDenied };
|
|
115
|
+
_cache.set(eventsPath, { mtimeMs, builtAt: now, windowMs, result });
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* ARCH-M-01: compute whether an extension's manifest combines a bash/exec
|
|
121
|
+
* write permission with a strict files/bytes quota.
|
|
122
|
+
*
|
|
123
|
+
* Returns true iff:
|
|
124
|
+
* manifest.permissions.writes includes "tool:bash" or "tool:exec"
|
|
125
|
+
* AND (quotas.max_files_written OR quotas.max_bytes_written is set)
|
|
126
|
+
*/
|
|
127
|
+
export function computeWarnBashBypass(manifest) {
|
|
128
|
+
if (!manifest || typeof manifest !== 'object') return false;
|
|
129
|
+
const perms = manifest.permissions || {};
|
|
130
|
+
const writes = Array.isArray(perms.writes) ? perms.writes : [];
|
|
131
|
+
const hasBashOrExec = writes.some((w) => w === 'tool:bash' || w === 'tool:exec');
|
|
132
|
+
if (!hasBashOrExec) return false;
|
|
133
|
+
const q = manifest.quotas || {};
|
|
134
|
+
const hasStrictQuota =
|
|
135
|
+
(typeof q.max_files_written === 'number' && Number.isFinite(q.max_files_written)) ||
|
|
136
|
+
(typeof q.max_bytes_written === 'number' && Number.isFinite(q.max_bytes_written));
|
|
137
|
+
return Boolean(hasStrictQuota);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Resolve `<scope>/<name>/manifest.json` and read it. Returns the parsed
|
|
142
|
+
* manifest object or `null` if the file is missing/unreadable/malformed.
|
|
143
|
+
*
|
|
144
|
+
* Scope→path map mirrors `active-extension-writer.js`:
|
|
145
|
+
* project: <projectRoot>/.ijfw/extensions/<name>/manifest.json
|
|
146
|
+
* org: <home>/.ijfw/extensions-org/<name>/manifest.json
|
|
147
|
+
* user: <home>/.ijfw/extensions-user/<name>/manifest.json
|
|
148
|
+
*/
|
|
149
|
+
export function readActiveManifest({ scope, name, home, projectRoot }) {
|
|
150
|
+
if (!scope || !name) return null;
|
|
151
|
+
let path = null;
|
|
152
|
+
if (scope === 'project' && projectRoot) {
|
|
153
|
+
path = join(projectRoot, '.ijfw', 'extensions', name, 'manifest.json');
|
|
154
|
+
} else if (scope === 'org' && home) {
|
|
155
|
+
path = join(home, '.ijfw', 'extensions-org', name, 'manifest.json');
|
|
156
|
+
} else if (scope === 'user' && home) {
|
|
157
|
+
path = join(home, '.ijfw', 'extensions-user', name, 'manifest.json');
|
|
158
|
+
}
|
|
159
|
+
if (!path) return null;
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|