@ijfw/memory-server 1.6.0 → 1.6.2
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/bin/ijfw-dashboard +13 -4
- package/package.json +1 -1
- package/src/audit-roster.js +16 -4
- package/src/compute/fts5.js +44 -10
- package/src/compute/staleness.js +9 -8
- package/src/cost/readers/codex.js +6 -6
- package/src/cross-orchestrator-cli.js +72 -19
- package/src/dashboard-server.js +117 -11
- package/src/design/iframe-bridge.js +13 -1
- package/src/memory/fts5.js +67 -12
- package/src/memory/search.js +33 -5
- package/src/memory/staleness.js +1 -1
- package/src/model-refresh.js +4 -2
- package/src/profile/eval/corpus-from-reddit.test.mjs +1 -1
- package/src/profile/eval/gate-b-run.mjs +2 -2
- package/src/profile/eval/harness.mjs +1 -1
- package/src/profile/eval/prereg.mjs +1 -1
- package/src/profile/eval/wrong-target-control.mjs +3 -3
- package/src/profile/exemplar-store.js +1 -1
- package/src/profile/telemetry.js +2 -2
- package/src/recovery/code-fixer.js +26 -5
- package/src/runtime-mediator.js +20 -2
- package/src/server.js +110 -31
package/bin/ijfw-dashboard
CHANGED
|
@@ -40,10 +40,19 @@ function removePidFiles() {
|
|
|
40
40
|
|
|
41
41
|
function openBrowser(url) {
|
|
42
42
|
if (process.env.CI || process.env.NO_OPEN) return;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
// `start` is a cmd.exe builtin (no start.exe on PATH), so it must be run
|
|
44
|
+
// through cmd. The empty '' arg is start's window-title slot, which would
|
|
45
|
+
// otherwise swallow the URL. url is built internally from a parsed port.
|
|
46
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
47
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
|
|
48
|
+
: ['xdg-open', [url]];
|
|
49
|
+
try {
|
|
50
|
+
const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
|
|
51
|
+
// Missing opener surfaces as an async 'error' event, not a throw --
|
|
52
|
+
// swallow it so it can never become an uncaught exception.
|
|
53
|
+
child.on('error', () => {});
|
|
54
|
+
child.unref();
|
|
55
|
+
} catch {}
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
// Probe a URL for HTTP 200, retrying on connection-refused until timeoutMs elapses.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ijfw/memory-server",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
4
4
|
"description": "Cross-platform persistent memory server for IJFW. 14 MCP tools (memory + admin/update + brain). Works with 15 platforms: 14 via MCP (Claude Code, Codex, Gemini CLI, Cursor, Windsurf, Copilot, Hermes, Wayland, OpenCode, QwenCode, Cline, KimiCode, OpenClaw, Antigravity) plus Aider via the rules-only tier.",
|
|
5
5
|
"author": "Sean Donahoe",
|
|
6
6
|
"contributors": [
|
package/src/audit-roster.js
CHANGED
|
@@ -281,10 +281,22 @@ export function isInstalled(id) {
|
|
|
281
281
|
// `[ -x ]` (or be a shell builtin/keyword/function with no filesystem path,
|
|
282
282
|
// which `command -v` reports without a leading slash — those are genuinely
|
|
283
283
|
// runnable). A real installed CLI is an executable file and still passes.
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
284
|
+
//
|
|
285
|
+
// On native Windows `bash` is either absent (probe would mark every auditor
|
|
286
|
+
// uninstalled) or the WSL launcher (probe would report the Linux distro's
|
|
287
|
+
// PATH, which the Windows-side invoker cannot run). Use `where` instead:
|
|
288
|
+
// it resolves .cmd/.exe shims on the real Windows PATH, matching how the
|
|
289
|
+
// auditors are actually spawned (shell:true on win32).
|
|
290
|
+
let installed;
|
|
291
|
+
if (process.platform === 'win32') {
|
|
292
|
+
const r = spawnSync('where', [bin], { timeout: 2000, encoding: 'utf8', windowsHide: true });
|
|
293
|
+
installed = r.status === 0 && Boolean((r.stdout || '').trim());
|
|
294
|
+
} else {
|
|
295
|
+
const probe = `p=$(command -v ${JSON.stringify(bin)} 2>/dev/null) || exit 1; ` +
|
|
296
|
+
`case "$p" in /*) [ -x "$p" ] ;; *) : ;; esac`;
|
|
297
|
+
const r = spawnSync('bash', ['-lc', probe], { timeout: 2000 });
|
|
298
|
+
installed = r.status === 0;
|
|
299
|
+
}
|
|
288
300
|
_installedCache.set(id, { value: installed, ts: Date.now() });
|
|
289
301
|
return installed;
|
|
290
302
|
}
|
package/src/compute/fts5.js
CHANGED
|
@@ -242,9 +242,34 @@ function readUserVersion(db) {
|
|
|
242
242
|
return Number(row.user_version ?? row.USER_VERSION ?? 0);
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
// quick_check throttle state, keyed per db handle. First write per handle
|
|
246
|
+
// always checks; then every Nth write or once the time floor elapses.
|
|
247
|
+
const QUICK_CHECK_EVERY_N = 100;
|
|
248
|
+
const QUICK_CHECK_MIN_INTERVAL_MS = 5 * 60 * 1000;
|
|
249
|
+
const quickCheckState = new WeakMap(); // db handle -> { writes, lastTs }
|
|
250
|
+
|
|
251
|
+
function shouldQuickCheck(db, now = Date.now()) {
|
|
252
|
+
let st = quickCheckState.get(db);
|
|
253
|
+
if (!st) {
|
|
254
|
+
st = { writes: 0, lastTs: 0 };
|
|
255
|
+
quickCheckState.set(db, st);
|
|
256
|
+
}
|
|
257
|
+
st.writes++;
|
|
258
|
+
if (
|
|
259
|
+
st.writes === 1 ||
|
|
260
|
+
st.writes % QUICK_CHECK_EVERY_N === 0 ||
|
|
261
|
+
(now - st.lastTs) >= QUICK_CHECK_MIN_INTERVAL_MS
|
|
262
|
+
) {
|
|
263
|
+
st.lastTs = now;
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
|
|
245
269
|
// Insert one row into a content table inside a transaction, then run
|
|
246
|
-
// PRAGMA quick_check on the whole db
|
|
247
|
-
// other than 'ok'. Returns { id } of the
|
|
270
|
+
// PRAGMA quick_check on the whole db (throttled; see above). Throws
|
|
271
|
+
// IntegrityError on anything other than 'ok'. Returns { id } of the
|
|
272
|
+
// inserted row.
|
|
248
273
|
//
|
|
249
274
|
// Allowed tables: raw, compiled, trident_run, schema_meta. Caller passes a
|
|
250
275
|
// row object whose keys match the table's columns; binding is positional
|
|
@@ -291,19 +316,28 @@ export function safeWrite(db, table, row) {
|
|
|
291
316
|
const sql = `INSERT INTO ${table} (${cols.join(', ')}) VALUES (${placeholders})`;
|
|
292
317
|
const values = cols.map(c => row[c]);
|
|
293
318
|
|
|
294
|
-
// Run insert + quick_check inside a single transaction.
|
|
295
|
-
// either failure so we never leave a half-written FTS index
|
|
319
|
+
// Run insert + (throttled) quick_check inside a single transaction.
|
|
320
|
+
// Rollback on either failure so we never leave a half-written FTS index
|
|
321
|
+
// behind. quick_check is a full-database scan, so it can't run on every
|
|
322
|
+
// insert (O(db size) per write while the lock is held); the corruption
|
|
323
|
+
// tripwire fires on the FIRST write per handle (compute callers hold
|
|
324
|
+
// long-lived handles, so reopen-after-corruption is still caught), then
|
|
325
|
+
// every Nth write or after a time floor, whichever comes first. State is
|
|
326
|
+
// keyed per HANDLE (WeakMap), unlike memory/fts5.js which keys per
|
|
327
|
+
// filename because server.js re-opens that db per store.
|
|
296
328
|
let inserted;
|
|
297
329
|
const tx = db.txn(() => {
|
|
298
330
|
const stmt = db.prepare(sql);
|
|
299
331
|
const info = stmt.run(...values);
|
|
300
332
|
inserted = { id: info && info.lastInsertRowid != null ? Number(info.lastInsertRowid) : null };
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
333
|
+
if (shouldQuickCheck(db)) {
|
|
334
|
+
const qc = db.prepare('PRAGMA quick_check').get();
|
|
335
|
+
const status = qc && (qc.quick_check ?? qc.QUICK_CHECK);
|
|
336
|
+
if (status !== 'ok') {
|
|
337
|
+
throw new IntegrityError(
|
|
338
|
+
`PRAGMA quick_check failed after insert into ${table}: ${status || '(no result)'}.`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
307
341
|
}
|
|
308
342
|
});
|
|
309
343
|
tx();
|
package/src/compute/staleness.js
CHANGED
|
@@ -166,11 +166,11 @@ export function propagateStale(db, supersededNodeId, options = {}) {
|
|
|
166
166
|
// timeout in fts5.openDb, so concurrent reads remain unblocked.
|
|
167
167
|
const updateRaw = db.prepare(
|
|
168
168
|
`UPDATE raw SET stale_candidate = ? ` +
|
|
169
|
-
`WHERE stale_candidate < ? AND body LIKE
|
|
169
|
+
`WHERE stale_candidate < ? AND body LIKE ? ESCAPE '\\'`
|
|
170
170
|
);
|
|
171
171
|
const updateCompiled = db.prepare(
|
|
172
172
|
`UPDATE compiled SET stale_candidate = ? ` +
|
|
173
|
-
`WHERE stale_candidate < ? AND (topic LIKE ? OR body LIKE ?)`
|
|
173
|
+
`WHERE stale_candidate < ? AND (topic LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')`
|
|
174
174
|
);
|
|
175
175
|
|
|
176
176
|
const tx = (typeof db.txn === 'function')
|
|
@@ -204,12 +204,13 @@ export function propagateStale(db, supersededNodeId, options = {}) {
|
|
|
204
204
|
};
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
// LIKE pattern escape --
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
207
|
+
// LIKE pattern escape -- kg_node names routinely contain `_` (the entity
|
|
208
|
+
// extractor's snake_case/UPPER_SNAKE regexes REQUIRE underscores) and can
|
|
209
|
+
// contain `%`. Escape both, plus the backslash escape character. SQLite
|
|
210
|
+
// LIKE has NO default escape character, so every consuming statement above
|
|
211
|
+
// carries an explicit ESCAPE '\' clause -- without it, `\_` matches a
|
|
212
|
+
// literal backslash followed by ANY character and snake_case names
|
|
213
|
+
// silently flag zero rows.
|
|
213
214
|
function escapeLike(s) {
|
|
214
215
|
return String(s).replace(/[\\%_]/g, '\\$&');
|
|
215
216
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
11
|
-
import { join } from 'node:path';
|
|
11
|
+
import { join, basename } from 'node:path';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
|
|
14
14
|
const CODEX_SESSIONS_DIR = join(homedir(), '.codex', 'sessions');
|
|
@@ -63,13 +63,13 @@ function processCodexFile(filePath, turns) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
if (!sessionId) {
|
|
66
|
-
// Derive session id from filename
|
|
67
|
-
|
|
68
|
-
sessionId = base;
|
|
66
|
+
// Derive session id from filename (basename handles both path separators)
|
|
67
|
+
sessionId = basename(filePath, '.jsonl');
|
|
69
68
|
}
|
|
70
69
|
|
|
71
|
-
// Extract timestamp from session path (YYYY/MM/DD/filename)
|
|
72
|
-
|
|
70
|
+
// Extract timestamp from session path (YYYY/MM/DD/filename); paths are
|
|
71
|
+
// join()-built, so accept either separator (backslashes on Windows).
|
|
72
|
+
const dateParts = filePath.match(/(\d{4})[\\/](\d{2})[\\/](\d{2})/);
|
|
73
73
|
const datePrefix = dateParts ? `${dateParts[1]}-${dateParts[2]}-${dateParts[3]}` : null;
|
|
74
74
|
|
|
75
75
|
// Accumulate per-session totals from response_item/message content
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { readFileSync, existsSync, writeFileSync, mkdirSync, statSync, openSync, readSync, closeSync, readdirSync, rmSync, realpathSync, copyFileSync } from 'node:fs';
|
|
11
11
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
12
|
-
import { join, dirname, basename, isAbsolute, resolve } from 'node:path';
|
|
12
|
+
import { join, dirname, basename, isAbsolute, resolve, parse as parsePath, sep } from 'node:path';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { spawnSync } from 'node:child_process';
|
|
15
15
|
import { writeAtomic } from './lib/atomic-io.js';
|
|
@@ -210,7 +210,7 @@ function emitJson(value) {
|
|
|
210
210
|
process.stdout.write(JSON.stringify(value, null, 2) + '\n');
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
function parseArgs(argv) {
|
|
213
|
+
export function parseArgs(argv) {
|
|
214
214
|
const args = argv.slice(2); // strip node + script path
|
|
215
215
|
|
|
216
216
|
// Global --json flag: any command can be forced to JSON output regardless
|
|
@@ -319,6 +319,10 @@ function parseArgsInner(args) {
|
|
|
319
319
|
return { cmd: 'doctor' };
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
+
if (args[0] === 'init') {
|
|
323
|
+
return { cmd: 'init', force: args.includes('--force') };
|
|
324
|
+
}
|
|
325
|
+
|
|
322
326
|
if (args[0] === 'update') {
|
|
323
327
|
const opts = { cmd: 'update' };
|
|
324
328
|
for (let i = 1; i < args.length; i++) {
|
|
@@ -504,20 +508,12 @@ function parseArgsInner(args) {
|
|
|
504
508
|
return { cmd: 'cross-project-audit', rule, dryRun };
|
|
505
509
|
}
|
|
506
510
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
for (let i = 3; i < args.length; i++) {
|
|
514
|
-
if (args[i] === '--confirm') { confirm = true; }
|
|
515
|
-
else if (args[i] === '--expand') { expand = true; }
|
|
516
|
-
else if (args[i] === '--chunk') { chunk = true; } // v1.5.1 H1.6 — wire chunker
|
|
517
|
-
else if (args[i] === '--with' && args[i + 1]) { only = args[++i]; }
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
return { cmd: 'cross', mode, target, only, confirm, expand, chunk };
|
|
511
|
+
// Reuse the alias parser so `ijfw cross <mode>` and `ijfw cross-<mode>`
|
|
512
|
+
// share one grammar: --with=<id> form, flags-before-target ordering, and
|
|
513
|
+
// flag tokens never consumed as the target. The old position-rigid parser
|
|
514
|
+
// (target = args[2], space-separated --with only) silently dropped
|
|
515
|
+
// --with=<id> and dispatched the full paid roster.
|
|
516
|
+
return parseCrossAlias(mode, args.slice(1));
|
|
521
517
|
}
|
|
522
518
|
|
|
523
519
|
return { cmd: 'unknown', raw: args[0] };
|
|
@@ -1369,7 +1365,7 @@ async function cmdCross({ mode, target, only, confirm, expand, chunk }) {
|
|
|
1369
1365
|
console.log(`Auditors fired (union): ${[...auditorIds].join(', ') || '(none)'}`);
|
|
1370
1366
|
if (!firedAny) {
|
|
1371
1367
|
console.log('No auditors fired -- run `ijfw doctor` to see the install hints.');
|
|
1372
|
-
process.exit(
|
|
1368
|
+
process.exit(3); // zero picks contributed -- INCONCLUSIVE, same code as the normal path
|
|
1373
1369
|
}
|
|
1374
1370
|
for (const f of merged) {
|
|
1375
1371
|
const sev = (f.severity || 'note').toUpperCase();
|
|
@@ -1397,7 +1393,10 @@ async function cmdCross({ mode, target, only, confirm, expand, chunk }) {
|
|
|
1397
1393
|
console.log('Trident is standing by -- no auditors reachable yet.');
|
|
1398
1394
|
console.log('Wire one in 30 seconds: run `ijfw doctor` for the exact install commands.');
|
|
1399
1395
|
console.log('Tip: any one of codex / gemini / claude / copilot is enough to start.');
|
|
1400
|
-
|
|
1396
|
+
// No audit happened: exit 3 per the documented contract below (zero picks
|
|
1397
|
+
// contributed = INCONCLUSIVE). Exit 0 here let CI gates pass green on a
|
|
1398
|
+
// machine with no auditors installed.
|
|
1399
|
+
process.exit(3);
|
|
1401
1400
|
}
|
|
1402
1401
|
|
|
1403
1402
|
const projectDir = process.cwd();
|
|
@@ -1421,7 +1420,7 @@ async function cmdCross({ mode, target, only, confirm, expand, chunk }) {
|
|
|
1421
1420
|
if (picks.length === 0) {
|
|
1422
1421
|
console.log('\nIJFW has the Trident ready -- install codex or gemini (or set OPENAI_API_KEY / GEMINI_API_KEY), then run `ijfw demo`.');
|
|
1423
1422
|
console.log('Run `ijfw doctor` to see which auditors are available on this machine.');
|
|
1424
|
-
|
|
1423
|
+
process.exit(3); // zero picks contributed -- INCONCLUSIVE per the exit contract
|
|
1425
1424
|
}
|
|
1426
1425
|
|
|
1427
1426
|
console.log(`Fired: ${picks.map(p => p.id).join(', ')}`);
|
|
@@ -2865,6 +2864,8 @@ if (isMainModule) {
|
|
|
2865
2864
|
cmdImport(parsed).catch(err => { console.error(err.message); process.exit(1); });
|
|
2866
2865
|
} else if (parsed.cmd === 'doctor') {
|
|
2867
2866
|
cmdDoctor(parsed);
|
|
2867
|
+
} else if (parsed.cmd === 'init') {
|
|
2868
|
+
cmdInit(parsed);
|
|
2868
2869
|
} else if (parsed.cmd === 'update') {
|
|
2869
2870
|
cmdUpdate(parsed);
|
|
2870
2871
|
} else if (parsed.cmd === 'version') {
|
|
@@ -2954,6 +2955,52 @@ function findCliAsset(...rel) {
|
|
|
2954
2955
|
].filter(Boolean);
|
|
2955
2956
|
return candidates.find(p => existsSync(p)) || null;
|
|
2956
2957
|
}
|
|
2958
|
+
// `ijfw init` -- explicitly bless the current folder for codebase indexing.
|
|
2959
|
+
// The indexer (scripts/build-codebase-index.sh) refuses any folder that has no
|
|
2960
|
+
// project marker (issue #16). For a plain working folder with no .git/package.json
|
|
2961
|
+
// etc, this drops a .ijfw/project marker so the indexer will index it. It will
|
|
2962
|
+
// NOT bless the home directory or filesystem root -- that is the whole point of
|
|
2963
|
+
// the guard.
|
|
2964
|
+
function cmdInit(parsed = {}) {
|
|
2965
|
+
const cwd = process.cwd();
|
|
2966
|
+
let phys;
|
|
2967
|
+
try { phys = realpathSync(cwd); } catch { phys = resolve(cwd); }
|
|
2968
|
+
let homePhys;
|
|
2969
|
+
try { homePhys = realpathSync(homedir()); } catch { homePhys = homedir(); }
|
|
2970
|
+
// Refuse any filesystem/drive root (parse().root catches '/', 'C:\\', UNC
|
|
2971
|
+
// roots), the home directory itself, or any ANCESTOR of home (blessing
|
|
2972
|
+
// /Users or /home would let the indexer walk every user's home -- the
|
|
2973
|
+
// issue #16 privacy hole one directory up).
|
|
2974
|
+
const isFsRoot = parsePath(phys).root === phys;
|
|
2975
|
+
const isHomeOrAncestor = phys === homePhys || homePhys.startsWith(phys + sep);
|
|
2976
|
+
if (isFsRoot || isHomeOrAncestor) {
|
|
2977
|
+
console.error('ijfw init: refusing to bless your home directory, its ancestors, or a filesystem root for indexing.');
|
|
2978
|
+
console.error('Run `ijfw init` from inside an actual project folder.');
|
|
2979
|
+
process.exit(1);
|
|
2980
|
+
}
|
|
2981
|
+
const marker = join(cwd, '.ijfw', 'project');
|
|
2982
|
+
try {
|
|
2983
|
+
mkdirSync(dirname(marker), { recursive: true });
|
|
2984
|
+
if (existsSync(marker) && !parsed.force) {
|
|
2985
|
+
console.log(`This folder is already initialised for IJFW indexing (${marker}).`);
|
|
2986
|
+
process.exit(0);
|
|
2987
|
+
}
|
|
2988
|
+
const stamp = new Date().toISOString();
|
|
2989
|
+
writeFileSync(
|
|
2990
|
+
marker,
|
|
2991
|
+
`# IJFW project marker\n` +
|
|
2992
|
+
`# Created by \`ijfw init\`. This folder is approved for codebase indexing.\n` +
|
|
2993
|
+
`# Safe to commit. Delete this file to stop IJFW indexing this folder.\n` +
|
|
2994
|
+
`created_at: ${stamp}\n`,
|
|
2995
|
+
{ mode: 0o644 }
|
|
2996
|
+
);
|
|
2997
|
+
console.log('IJFW initialised. This folder is now approved for codebase indexing.');
|
|
2998
|
+
console.log(`Marker: ${marker}`);
|
|
2999
|
+
} catch (err) {
|
|
3000
|
+
console.error(`ijfw init: could not write marker -- ${err.message}`);
|
|
3001
|
+
process.exit(1);
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
2957
3004
|
function cmdInstall() {
|
|
2958
3005
|
const script = findCliAsset('scripts', 'install.sh');
|
|
2959
3006
|
if (!script) {
|
|
@@ -2961,6 +3008,9 @@ function cmdInstall() {
|
|
|
2961
3008
|
process.exit(1);
|
|
2962
3009
|
}
|
|
2963
3010
|
const res = spawnSync('bash', [script, ...process.argv.slice(3)], { stdio: 'inherit' });
|
|
3011
|
+
// spawnSync failure (bash missing, EACCES) yields status null + res.error
|
|
3012
|
+
// and stdio:'inherit' prints nothing -- surface it instead of exiting mute.
|
|
3013
|
+
if (res.error) console.error(`ijfw: failed to launch ${script}: ${res.error.message}`);
|
|
2964
3014
|
process.exit(res.status ?? 1);
|
|
2965
3015
|
}
|
|
2966
3016
|
function cmdUninstall() {
|
|
@@ -2970,6 +3020,7 @@ function cmdUninstall() {
|
|
|
2970
3020
|
process.exit(1);
|
|
2971
3021
|
}
|
|
2972
3022
|
const res = spawnSync(process.execPath, [script, ...process.argv.slice(3)], { stdio: 'inherit' });
|
|
3023
|
+
if (res.error) console.error(`ijfw: failed to launch ${script}: ${res.error.message}`);
|
|
2973
3024
|
process.exit(res.status ?? 1);
|
|
2974
3025
|
}
|
|
2975
3026
|
function cmdPreflight() {
|
|
@@ -2979,6 +3030,7 @@ function cmdPreflight() {
|
|
|
2979
3030
|
process.exit(1);
|
|
2980
3031
|
}
|
|
2981
3032
|
const res = spawnSync(process.execPath, [script, ...process.argv.slice(3)], { stdio: 'inherit' });
|
|
3033
|
+
if (res.error) console.error(`ijfw: failed to launch ${script}: ${res.error.message}`);
|
|
2982
3034
|
process.exit(res.status ?? 1);
|
|
2983
3035
|
}
|
|
2984
3036
|
function cmdDashboard(sub) {
|
|
@@ -2998,6 +3050,7 @@ function cmdDashboard(sub) {
|
|
|
2998
3050
|
// stays authoritative. argv = [node, cli, 'dashboard', <sub>, ...flags].
|
|
2999
3051
|
const passthrough = process.argv.slice(4);
|
|
3000
3052
|
const res = spawnSync(process.execPath, [launcher, sub, ...passthrough], { stdio: 'inherit' });
|
|
3053
|
+
if (res.error) console.error(`ijfw: failed to launch ${launcher}: ${res.error.message}`);
|
|
3001
3054
|
process.exit(res.status ?? 1);
|
|
3002
3055
|
}
|
|
3003
3056
|
|
package/src/dashboard-server.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { createServer } from 'node:http';
|
|
10
|
-
import { existsSync, readFileSync, watch, writeFileSync, mkdirSync, readdirSync, statSync, realpathSync, renameSync, unlinkSync } from 'node:fs';
|
|
10
|
+
import { existsSync, readFileSync, watch, writeFileSync, mkdirSync, readdirSync, statSync, realpathSync, renameSync, unlinkSync, openSync, readSync, closeSync, chmodSync } from 'node:fs';
|
|
11
11
|
import { readFile } from 'node:fs/promises';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import { join, dirname, resolve, relative, isAbsolute, basename, sep } from 'node:path';
|
|
@@ -174,10 +174,43 @@ function requireLocalhost(req, res) {
|
|
|
174
174
|
return false;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
// CSRF guard: reject cross-origin browser requests to the data API. Browsers
|
|
178
|
+
// stamp Sec-Fetch-Site; the dashboard's own page is 'same-origin', direct tools
|
|
179
|
+
// (curl, address bar) send 'none'/nothing. Only same-machine cross-origin pages
|
|
180
|
+
// hit 'cross-site'/'same-site' -- block those on /api.
|
|
181
|
+
//
|
|
182
|
+
// State-changing methods (POST/PUT/PATCH/DELETE) fail CLOSED when Sec-Fetch-Site
|
|
183
|
+
// is absent: a browser cross-origin write always carries an Origin header (even
|
|
184
|
+
// for no-preflight text/plain "simple requests"), so any Origin/Referer that does
|
|
185
|
+
// not match our own Host is rejected. Requests with no browser markers at all
|
|
186
|
+
// (curl, local scripts) carry neither header and stay allowed.
|
|
187
|
+
function rejectCrossSiteApi(req, res, path) {
|
|
188
|
+
if (!path.startsWith('/api')) return false;
|
|
189
|
+
function reject() {
|
|
190
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
191
|
+
res.end('{"error":"cross-origin request rejected"}');
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
const sfs = req.headers['sec-fetch-site'];
|
|
195
|
+
if (sfs === 'cross-site' || sfs === 'same-site') return reject();
|
|
196
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
197
|
+
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return false;
|
|
198
|
+
// Writes: 'same-origin'/'none' are browser-proven safe; otherwise require any
|
|
199
|
+
// Origin/Referer present to match the host the request was addressed to.
|
|
200
|
+
if (sfs === 'same-origin' || sfs === 'none') return false;
|
|
201
|
+
const proof = req.headers['origin'] || req.headers['referer'];
|
|
202
|
+
if (!proof) return false;
|
|
203
|
+
let proofHost = null;
|
|
204
|
+
try { proofHost = new URL(proof).host; } catch {}
|
|
205
|
+
if (proofHost === null || proofHost !== (req.headers.host || '')) return reject();
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
177
209
|
// ---------- simple router ----------
|
|
178
210
|
function route(req, res, routes) {
|
|
179
211
|
const url = new URL(req.url, 'http://localhost');
|
|
180
212
|
const path = url.pathname;
|
|
213
|
+
if (rejectCrossSiteApi(req, res, path)) return;
|
|
181
214
|
for (const [pattern, handler] of routes) {
|
|
182
215
|
if (typeof pattern === 'string' ? path === pattern : pattern.test(path)) {
|
|
183
216
|
handler(req, res, url);
|
|
@@ -189,14 +222,43 @@ function route(req, res, routes) {
|
|
|
189
222
|
}
|
|
190
223
|
|
|
191
224
|
// ---------- JSONL reader ----------
|
|
225
|
+
// observations.jsonl is append-only with no rotation, so an unbounded
|
|
226
|
+
// synchronous read+parse on every poll would stall the single-threaded server
|
|
227
|
+
// (same hazard tailEvents() was rewritten to avoid). Bound the read to the
|
|
228
|
+
// last OBS_TAIL_BYTES via fd+position, and cache the parsed array keyed on
|
|
229
|
+
// mtime+size so repeated polls of an unchanged file never re-read it.
|
|
230
|
+
const OBS_TAIL_BYTES = 5 * 1024 * 1024; // 5MB
|
|
231
|
+
const obsCache = new Map(); // ledgerPath -> { key, data }
|
|
232
|
+
|
|
192
233
|
function readObservations(ledgerPath) {
|
|
193
|
-
|
|
234
|
+
let st;
|
|
235
|
+
try { st = statSync(ledgerPath); } catch { return []; }
|
|
236
|
+
const key = `${st.mtimeMs}:${st.size}`;
|
|
237
|
+
const cached = obsCache.get(ledgerPath);
|
|
238
|
+
if (cached && cached.key === key) return cached.data;
|
|
194
239
|
try {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
240
|
+
const start = Math.max(0, st.size - OBS_TAIL_BYTES);
|
|
241
|
+
const len = st.size - start;
|
|
242
|
+
const buf = Buffer.allocUnsafe(len);
|
|
243
|
+
const fd = openSync(ledgerPath, 'r');
|
|
244
|
+
let read = 0;
|
|
245
|
+
try {
|
|
246
|
+
while (read < len) {
|
|
247
|
+
const n = readSync(fd, buf, read, len - read, start + read);
|
|
248
|
+
if (n === 0) break;
|
|
249
|
+
read += n;
|
|
250
|
+
}
|
|
251
|
+
} finally {
|
|
252
|
+
closeSync(fd);
|
|
253
|
+
}
|
|
254
|
+
let lines = buf.subarray(0, read).toString('utf8').split('\n').filter(Boolean);
|
|
255
|
+
// If we sliced mid-line, the first element may be truncated -- drop it.
|
|
256
|
+
if (start > 0) lines = lines.slice(1);
|
|
257
|
+
const data = lines
|
|
198
258
|
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
199
259
|
.filter(Boolean);
|
|
260
|
+
obsCache.set(ledgerPath, { key, data });
|
|
261
|
+
return data;
|
|
200
262
|
} catch {
|
|
201
263
|
return [];
|
|
202
264
|
}
|
|
@@ -814,8 +876,11 @@ export async function startServer(options = {}) {
|
|
|
814
876
|
req.on('end', () => {
|
|
815
877
|
try {
|
|
816
878
|
const parsed = JSON.parse(body);
|
|
817
|
-
mkdirSync(join(homedir(), '.ijfw'), { recursive: true });
|
|
818
|
-
|
|
879
|
+
mkdirSync(join(homedir(), '.ijfw'), { recursive: true, mode: 0o700 });
|
|
880
|
+
// 0o600: config holds account/subscription data; keep it owner-only
|
|
881
|
+
// (mode only applies on create, so chmod existing files too).
|
|
882
|
+
writeFileSync(configPath, JSON.stringify(parsed, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
883
|
+
try { chmodSync(configPath, 0o600); } catch {}
|
|
819
884
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
820
885
|
res.end(JSON.stringify({ ok: true }));
|
|
821
886
|
} catch (err) {
|
|
@@ -1481,6 +1546,50 @@ if (process.argv.includes('--daemon')) {
|
|
|
1481
1546
|
const pidFile = process.env.IJFW_PID_FILE || join(homedir(), '.ijfw', 'dashboard.pid');
|
|
1482
1547
|
const portFile = process.env.IJFW_PORT_FILE || join(homedir(), '.ijfw', 'dashboard.port');
|
|
1483
1548
|
|
|
1549
|
+
// Singleton guard: the spawner's pid-check (session-start.sh) is a classic
|
|
1550
|
+
// check-then-act race -- two sessions starting in the same window both see no
|
|
1551
|
+
// live daemon and both spawn --daemon. Without a guard here, the loser walks
|
|
1552
|
+
// to the next port and OVERWRITES the shared pid/port files, orphaning the
|
|
1553
|
+
// winner forever. Acquire the pid file with O_EXCL BEFORE binding: exactly
|
|
1554
|
+
// one starter wins; losers exit 0 without binding or clobbering. A pid file
|
|
1555
|
+
// whose owner is dead is stale and reclaimed once.
|
|
1556
|
+
const ijfwDir = dirname(pidFile);
|
|
1557
|
+
mkdirSync(ijfwDir, { recursive: true });
|
|
1558
|
+
|
|
1559
|
+
function pidAlive(pid) {
|
|
1560
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1561
|
+
try { process.kill(pid, 0); return true; }
|
|
1562
|
+
catch (err) { return err.code === 'EPERM'; }
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function acquirePidFile() {
|
|
1566
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1567
|
+
try {
|
|
1568
|
+
writeFileSync(pidFile, String(process.pid), { encoding: 'utf8', flag: 'wx' });
|
|
1569
|
+
return true;
|
|
1570
|
+
} catch (err) {
|
|
1571
|
+
if (err.code !== 'EEXIST') throw err;
|
|
1572
|
+
let holder = NaN;
|
|
1573
|
+
try { holder = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10); } catch {}
|
|
1574
|
+
if (pidAlive(holder)) return false; // live daemon already owns it
|
|
1575
|
+
try { unlinkSync(pidFile); } catch {} // stale -- reclaim and retry once
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
return false; // lost the reclaim race to another starter; that one serves
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
if (!acquirePidFile()) {
|
|
1582
|
+
process.stderr.write('[ijfw-dashboard] daemon already running -- exiting\n');
|
|
1583
|
+
process.exit(0);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function releasePidFile() {
|
|
1587
|
+
// Only remove the pid file if it is still ours.
|
|
1588
|
+
try {
|
|
1589
|
+
if (readFileSync(pidFile, 'utf8').trim() === String(process.pid)) unlinkSync(pidFile);
|
|
1590
|
+
} catch {}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1484
1593
|
// Optional preferred-port override (forwarded by the launcher's `--port N`).
|
|
1485
1594
|
// Unset = default 37891-37900 walk. Invalid values fall back to the default.
|
|
1486
1595
|
const startOpts = {};
|
|
@@ -1488,10 +1597,6 @@ if (process.argv.includes('--daemon')) {
|
|
|
1488
1597
|
if (Number.isInteger(envPort) && envPort > 0 && envPort < 65536) startOpts.port = envPort;
|
|
1489
1598
|
|
|
1490
1599
|
startServer(startOpts).then(({ port }) => {
|
|
1491
|
-
const ijfwDir = dirname(pidFile);
|
|
1492
|
-
mkdirSync(ijfwDir, { recursive: true });
|
|
1493
|
-
// PID file: plain write (single writer; pid is meaningless mid-write)
|
|
1494
|
-
writeFileSync(pidFile, String(process.pid), 'utf8');
|
|
1495
1600
|
// Port file: atomic write via tmp+rename so readers never see a partial value (W4.2).
|
|
1496
1601
|
// Cleanup tmp on rename failure so it doesn't leak (W9-M1).
|
|
1497
1602
|
const portTmp = `${portFile}.tmp.${process.pid}.${Date.now()}`;
|
|
@@ -1503,6 +1608,7 @@ if (process.argv.includes('--daemon')) {
|
|
|
1503
1608
|
throw new Error(`atomic write failed for ${portFile}: ${err.message}`);
|
|
1504
1609
|
}
|
|
1505
1610
|
}).catch(err => {
|
|
1611
|
+
releasePidFile();
|
|
1506
1612
|
process.stderr.write('[ijfw-dashboard] ' + err.message + '\n');
|
|
1507
1613
|
process.exit(1);
|
|
1508
1614
|
});
|
|
@@ -99,10 +99,18 @@ export async function createPreviewSandbox({ html, name } = {}) {
|
|
|
99
99
|
// surface for vercel-sandbox is evolving; we accept any JSON line that
|
|
100
100
|
// contains a `url` field.
|
|
101
101
|
try {
|
|
102
|
+
// shell:true on Windows so the vercel.cmd npm shim resolves; harmless on
|
|
103
|
+
// POSIX. sandboxId/tmpFile are internally generated (sanitized name +
|
|
104
|
+
// uuid under tmpdir), not user input.
|
|
102
105
|
const r = spawnSync('vercel', ['sandbox', 'create', '--file', tmpFile, '--name', sandboxId], {
|
|
103
106
|
encoding: 'utf8',
|
|
104
107
|
timeout: PROVISION_TIMEOUT_MS,
|
|
108
|
+
shell: process.platform === 'win32',
|
|
105
109
|
});
|
|
110
|
+
if (r.error) {
|
|
111
|
+
_advise(`createPreviewSandbox: vercel CLI not spawnable (${r.error.message}) -- falling back to static`);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
106
114
|
if (r.status !== 0) {
|
|
107
115
|
_advise(`createPreviewSandbox: vercel CLI exit ${r.status} -- falling back to static`);
|
|
108
116
|
return null;
|
|
@@ -140,7 +148,11 @@ export async function destroySandbox(sandboxId) {
|
|
|
140
148
|
return;
|
|
141
149
|
}
|
|
142
150
|
if (entry.mode === 'cli') {
|
|
143
|
-
spawnSync('vercel', ['sandbox', 'delete', sandboxId], {
|
|
151
|
+
spawnSync('vercel', ['sandbox', 'delete', sandboxId], {
|
|
152
|
+
encoding: 'utf8',
|
|
153
|
+
timeout: DESTROY_TIMEOUT_MS,
|
|
154
|
+
shell: process.platform === 'win32',
|
|
155
|
+
});
|
|
144
156
|
return;
|
|
145
157
|
}
|
|
146
158
|
} catch (err) {
|