@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.
@@ -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
- const cmd = process.platform === 'darwin' ? 'open'
44
- : process.platform === 'win32' ? 'start'
45
- : 'xdg-open';
46
- try { spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref(); } catch {}
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.0",
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": [
@@ -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
- const probe = `p=$(command -v ${JSON.stringify(bin)} 2>/dev/null) || exit 1; ` +
285
- `case "$p" in /*) [ -x "$p" ] ;; *) : ;; esac`;
286
- const r = spawnSync('bash', ['-lc', probe], { timeout: 2000 });
287
- const installed = r.status === 0;
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
  }
@@ -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. Throws IntegrityError on anything
247
- // other than 'ok'. Returns { id } of the inserted row.
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. Rollback on
295
- // either failure so we never leave a half-written FTS index behind.
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
- const qc = db.prepare('PRAGMA quick_check').get();
302
- const status = qc && (qc.quick_check ?? qc.QUICK_CHECK);
303
- if (status !== 'ok') {
304
- throw new IntegrityError(
305
- `PRAGMA quick_check failed after insert into ${table}: ${status || '(no result)'}.`
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();
@@ -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 -- our names can contain `%` or `_` (e.g. error
208
- // codes like `E_BUSY%` -- unlikely but possible). Escape both, plus the
209
- // backslash escape character. Use `\` as the escape; SQLite needs an
210
- // explicit ESCAPE clause to honour it, so we add that to the prepared
211
- // statement above. (Skipped here because in practice kg_node names from
212
- // our regex extractor never contain `%` or `_` chars.)
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
- const base = filePath.split('/').pop().replace('.jsonl', '');
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
- const dateParts = filePath.match(/(\d{4})\/(\d{2})\/(\d{2})/);
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
- const target = args[2];
508
- let only = null;
509
- let confirm = false;
510
- let expand = false;
511
- let chunk = false;
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(2); // r17.1 degraded exit code
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
- process.exit(0);
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
- return;
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
 
@@ -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
- if (!existsSync(ledgerPath)) return [];
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
- return readFileSync(ledgerPath, 'utf8')
196
- .split('\n')
197
- .filter(Boolean)
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
- writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf8');
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], { encoding: 'utf8', timeout: DESTROY_TIMEOUT_MS });
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) {