@ijfw/memory-server 1.6.1 → 1.6.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.
@@ -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.1",
3
+ "version": "1.6.3",
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
  }
@@ -21,6 +21,7 @@ import {
21
21
  } from 'node:fs';
22
22
  import { join, dirname } from 'node:path';
23
23
  import { resolveBrainPaths } from './paths.js';
24
+ import { shouldSeedProject } from './seed-gate.js';
24
25
  import { scanInbox, writeManifest, commitProcessed, isProcessed } from './dump-ingest.js';
25
26
  import { extractFile } from './extractors/index.js';
26
27
  import { BudgetGuard } from './budget-guard.js';
@@ -217,9 +218,24 @@ function isProcessedDouble(db, processedDir, fileName) {
217
218
  export async function runDreamCycle({ db, repoRoot, env = process.env, cycleId, extractFacts } = {}) {
218
219
  if (!db) throw new Error('dream-pipeline: db required');
219
220
  if (!repoRoot) throw new Error('dream-pipeline: repoRoot required');
221
+ // Seed gate: the dream cycle materializes the VISIBLE `ijfw/` layer
222
+ // (dump/inbox, dump/processed, wiki/...). Do not create it in a directory
223
+ // that is not a real project -- a throwaway scratch dir or an ephemeral
224
+ // "temporary space" (Wayland) running a one-shot chat should stay clean.
225
+ // A project marker (.git, a manifest) or an explicit `ijfw init` re-enables
226
+ // it. Memory recall still works in-session; only the on-disk content layer
227
+ // is withheld. Honest no-op return so callers/receipts see why nothing ran.
228
+ const cid0 = cycleId || `cycle-${Date.now()}`;
229
+ if (!shouldSeedProject(repoRoot)) {
230
+ return {
231
+ processed: 0, pagesCompiled: 0, factsInserted: 0,
232
+ budgetExhausted: false, cycleId: cid0, errors: [],
233
+ skipped: 'no-project-marker',
234
+ };
235
+ }
220
236
  ensureFactsTable(db);
221
237
  const paths = resolveBrainPaths(repoRoot);
222
- const cid = cycleId || `cycle-${Date.now()}`;
238
+ const cid = cid0;
223
239
  // Parse budget caps from env explicitly so zero is respected (Number('0')||default
224
240
  // would silently fall back to the default; we need the caller's $0 to mean $0).
225
241
  const cycleUsdRaw = env.IJFW_DREAM_BUDGET_USD != null ? Number(env.IJFW_DREAM_BUDGET_USD) : undefined;
@@ -0,0 +1,108 @@
1
+ // IJFW seed gate -- "should we materialize on-disk content in this directory?"
2
+ //
3
+ // Single rule, shared across the whole product: IJFW only writes project
4
+ // artifacts (the visible `ijfw/` brain layer, AGENTS.md, CLAUDE.md, the
5
+ // codebase index, the `.ijfw/project.type` cold scan) into a directory that is
6
+ // actually a project. "A project" means it carries a recognized marker (a VCS
7
+ // dir, a language manifest) OR the operator explicitly blessed it with
8
+ // `ijfw init` (which drops `.ijfw/project`).
9
+ //
10
+ // Why this exists: session-start hooks fire on EVERY new chat, including
11
+ // throwaway scratch dirs and ephemeral "temporary spaces" (e.g. Wayland). The
12
+ // old behavior seeded those unconditionally, so a one-shot `print(7*6)` chat
13
+ // littered `ijfw/`, AGENTS.md, and CLAUDE.md into a dir the user never meant to
14
+ // keep. Memory recall still works in-session for those dirs -- we just don't
15
+ // write anything to disk until the dir proves it's a real project.
16
+ //
17
+ // This is the JS mirror of the bash `ijfw_should_seed` (seed-gate.sh) and the
18
+ // indexer's `ijfw_has_project_marker` (scripts/build-codebase-index.sh). The
19
+ // three marker lists are kept identical by a drift test
20
+ // (test/brain/test-seed-gate-drift.js). Edit all three together or the test
21
+ // fails.
22
+
23
+ import { existsSync, realpathSync } from 'node:fs';
24
+ import { join, dirname, parse as parsePath, sep } from 'node:path';
25
+ import { homedir } from 'node:os';
26
+
27
+ // Canonical project-marker list. MUST stay byte-identical (same set) to the
28
+ // bash list in seed-gate.sh and the indexer's ijfw_has_project_marker. The
29
+ // `.ijfw/project` entry is the `ijfw init` override.
30
+ export const PROJECT_MARKERS = Object.freeze([
31
+ '.git',
32
+ 'package.json',
33
+ 'go.mod',
34
+ 'Cargo.toml',
35
+ 'pyproject.toml',
36
+ 'setup.py',
37
+ 'tsconfig.json',
38
+ 'pom.xml',
39
+ 'build.gradle',
40
+ 'build.gradle.kts',
41
+ 'Gemfile',
42
+ 'composer.json',
43
+ 'deno.json',
44
+ 'deno.jsonc',
45
+ 'mix.exs',
46
+ 'Package.swift',
47
+ 'requirements.txt',
48
+ '.hg',
49
+ '.svn',
50
+ '.ijfw/project',
51
+ ]);
52
+
53
+ // True when `dir` carries any recognized project marker. Pure existence checks;
54
+ // never throws.
55
+ export function hasProjectMarker(dir) {
56
+ if (!dir || typeof dir !== 'string') return false;
57
+ for (const m of PROJECT_MARKERS) {
58
+ try {
59
+ // `.ijfw/project` is a nested path; join handles both flat and nested.
60
+ if (existsSync(join(dir, ...m.split('/')))) return true;
61
+ } catch {
62
+ // Unreadable candidate -- treat as absent, keep scanning.
63
+ }
64
+ }
65
+ return false;
66
+ }
67
+
68
+ // Resolve the physical path of a directory, falling back to the input on error
69
+ // so callers always get a usable string.
70
+ function physOf(dir) {
71
+ try {
72
+ return realpathSync(dir);
73
+ } catch {
74
+ return dir;
75
+ }
76
+ }
77
+
78
+ // The full seed decision: refuse the filesystem root, the home directory, and
79
+ // any ancestor of home (the issue #16 privacy hole -- seeding /Users or /home
80
+ // would walk every user's home), THEN require a project marker.
81
+ //
82
+ // Returns true only when `dir` is a real, safe project directory to write into.
83
+ export function shouldSeedProject(dir) {
84
+ if (!dir || typeof dir !== 'string') return false;
85
+ const phys = physOf(dir);
86
+ if (!phys || phys === sep) return false;
87
+
88
+ // A filesystem/drive root is its own parent (covers POSIX '/' and Windows
89
+ // drive roots like 'C:\\').
90
+ const root = parsePath(phys).root;
91
+ if (phys === root) return false;
92
+ if (dirname(phys) === phys) return false;
93
+
94
+ // Refuse the home directory and any ancestor of it. Fail closed when home is
95
+ // unresolvable -- we cannot prove the target isn't home, so do not seed.
96
+ let homePhys;
97
+ try {
98
+ homePhys = realpathSync(homedir());
99
+ } catch {
100
+ homePhys = homedir();
101
+ }
102
+ if (!homePhys) return false;
103
+ if (phys === homePhys) return false;
104
+ // phys is an ancestor of home when home lives under phys/.
105
+ if ((homePhys + sep).startsWith(phys + sep)) return false;
106
+
107
+ return hasProjectMarker(phys);
108
+ }
@@ -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
@@ -508,20 +508,12 @@ function parseArgsInner(args) {
508
508
  return { cmd: 'cross-project-audit', rule, dryRun };
509
509
  }
510
510
 
511
- const target = args[2];
512
- let only = null;
513
- let confirm = false;
514
- let expand = false;
515
- let chunk = false;
516
-
517
- for (let i = 3; i < args.length; i++) {
518
- if (args[i] === '--confirm') { confirm = true; }
519
- else if (args[i] === '--expand') { expand = true; }
520
- else if (args[i] === '--chunk') { chunk = true; } // v1.5.1 H1.6 — wire chunker
521
- else if (args[i] === '--with' && args[i + 1]) { only = args[++i]; }
522
- }
523
-
524
- 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));
525
517
  }
526
518
 
527
519
  return { cmd: 'unknown', raw: args[0] };
@@ -1373,7 +1365,7 @@ async function cmdCross({ mode, target, only, confirm, expand, chunk }) {
1373
1365
  console.log(`Auditors fired (union): ${[...auditorIds].join(', ') || '(none)'}`);
1374
1366
  if (!firedAny) {
1375
1367
  console.log('No auditors fired -- run `ijfw doctor` to see the install hints.');
1376
- process.exit(2); // r17.1 degraded exit code
1368
+ process.exit(3); // zero picks contributed -- INCONCLUSIVE, same code as the normal path
1377
1369
  }
1378
1370
  for (const f of merged) {
1379
1371
  const sev = (f.severity || 'note').toUpperCase();
@@ -1401,7 +1393,10 @@ async function cmdCross({ mode, target, only, confirm, expand, chunk }) {
1401
1393
  console.log('Trident is standing by -- no auditors reachable yet.');
1402
1394
  console.log('Wire one in 30 seconds: run `ijfw doctor` for the exact install commands.');
1403
1395
  console.log('Tip: any one of codex / gemini / claude / copilot is enough to start.');
1404
- 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);
1405
1400
  }
1406
1401
 
1407
1402
  const projectDir = process.cwd();
@@ -1425,7 +1420,7 @@ async function cmdCross({ mode, target, only, confirm, expand, chunk }) {
1425
1420
  if (picks.length === 0) {
1426
1421
  console.log('\nIJFW has the Trident ready -- install codex or gemini (or set OPENAI_API_KEY / GEMINI_API_KEY), then run `ijfw demo`.');
1427
1422
  console.log('Run `ijfw doctor` to see which auditors are available on this machine.');
1428
- return;
1423
+ process.exit(3); // zero picks contributed -- INCONCLUSIVE per the exit contract
1429
1424
  }
1430
1425
 
1431
1426
  console.log(`Fired: ${picks.map(p => p.id).join(', ')}`);
@@ -2972,8 +2967,14 @@ function cmdInit(parsed = {}) {
2972
2967
  try { phys = realpathSync(cwd); } catch { phys = resolve(cwd); }
2973
2968
  let homePhys;
2974
2969
  try { homePhys = realpathSync(homedir()); } catch { homePhys = homedir(); }
2975
- if (phys === '/' || phys === homePhys) {
2976
- console.error('ijfw init: refusing to bless your home directory or the filesystem root for indexing.');
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.');
2977
2978
  console.error('Run `ijfw init` from inside an actual project folder.');
2978
2979
  process.exit(1);
2979
2980
  }
@@ -3007,6 +3008,9 @@ function cmdInstall() {
3007
3008
  process.exit(1);
3008
3009
  }
3009
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}`);
3010
3014
  process.exit(res.status ?? 1);
3011
3015
  }
3012
3016
  function cmdUninstall() {
@@ -3016,6 +3020,7 @@ function cmdUninstall() {
3016
3020
  process.exit(1);
3017
3021
  }
3018
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}`);
3019
3024
  process.exit(res.status ?? 1);
3020
3025
  }
3021
3026
  function cmdPreflight() {
@@ -3025,6 +3030,7 @@ function cmdPreflight() {
3025
3030
  process.exit(1);
3026
3031
  }
3027
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}`);
3028
3034
  process.exit(res.status ?? 1);
3029
3035
  }
3030
3036
  function cmdDashboard(sub) {
@@ -3044,6 +3050,7 @@ function cmdDashboard(sub) {
3044
3050
  // stays authoritative. argv = [node, cli, 'dashboard', <sub>, ...flags].
3045
3051
  const passthrough = process.argv.slice(4);
3046
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}`);
3047
3054
  process.exit(res.status ?? 1);
3048
3055
  }
3049
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';
@@ -178,14 +178,31 @@ function requireLocalhost(req, res) {
178
178
  // stamp Sec-Fetch-Site; the dashboard's own page is 'same-origin', direct tools
179
179
  // (curl, address bar) send 'none'/nothing. Only same-machine cross-origin pages
180
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.
181
187
  function rejectCrossSiteApi(req, res, path) {
182
188
  if (!path.startsWith('/api')) return false;
183
- const sfs = req.headers['sec-fetch-site'];
184
- if (sfs === 'cross-site' || sfs === 'same-site') {
189
+ function reject() {
185
190
  res.writeHead(403, { 'Content-Type': 'application/json' });
186
191
  res.end('{"error":"cross-origin request rejected"}');
187
192
  return true;
188
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();
189
206
  return false;
190
207
  }
191
208
 
@@ -205,14 +222,43 @@ function route(req, res, routes) {
205
222
  }
206
223
 
207
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
+
208
233
  function readObservations(ledgerPath) {
209
- 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;
210
239
  try {
211
- return readFileSync(ledgerPath, 'utf8')
212
- .split('\n')
213
- .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
214
258
  .map(l => { try { return JSON.parse(l); } catch { return null; } })
215
259
  .filter(Boolean);
260
+ obsCache.set(ledgerPath, { key, data });
261
+ return data;
216
262
  } catch {
217
263
  return [];
218
264
  }
@@ -830,8 +876,11 @@ export async function startServer(options = {}) {
830
876
  req.on('end', () => {
831
877
  try {
832
878
  const parsed = JSON.parse(body);
833
- mkdirSync(join(homedir(), '.ijfw'), { recursive: true });
834
- 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 {}
835
884
  res.writeHead(200, { 'Content-Type': 'application/json' });
836
885
  res.end(JSON.stringify({ ok: true }));
837
886
  } catch (err) {
@@ -1497,6 +1546,50 @@ if (process.argv.includes('--daemon')) {
1497
1546
  const pidFile = process.env.IJFW_PID_FILE || join(homedir(), '.ijfw', 'dashboard.pid');
1498
1547
  const portFile = process.env.IJFW_PORT_FILE || join(homedir(), '.ijfw', 'dashboard.port');
1499
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
+
1500
1593
  // Optional preferred-port override (forwarded by the launcher's `--port N`).
1501
1594
  // Unset = default 37891-37900 walk. Invalid values fall back to the default.
1502
1595
  const startOpts = {};
@@ -1504,10 +1597,6 @@ if (process.argv.includes('--daemon')) {
1504
1597
  if (Number.isInteger(envPort) && envPort > 0 && envPort < 65536) startOpts.port = envPort;
1505
1598
 
1506
1599
  startServer(startOpts).then(({ port }) => {
1507
- const ijfwDir = dirname(pidFile);
1508
- mkdirSync(ijfwDir, { recursive: true });
1509
- // PID file: plain write (single writer; pid is meaningless mid-write)
1510
- writeFileSync(pidFile, String(process.pid), 'utf8');
1511
1600
  // Port file: atomic write via tmp+rename so readers never see a partial value (W4.2).
1512
1601
  // Cleanup tmp on rename failure so it doesn't leak (W9-M1).
1513
1602
  const portTmp = `${portFile}.tmp.${process.pid}.${Date.now()}`;
@@ -1519,6 +1608,7 @@ if (process.argv.includes('--daemon')) {
1519
1608
  throw new Error(`atomic write failed for ${portFile}: ${err.message}`);
1520
1609
  }
1521
1610
  }).catch(err => {
1611
+ releasePidFile();
1522
1612
  process.stderr.write('[ijfw-dashboard] ' + err.message + '\n');
1523
1613
  process.exit(1);
1524
1614
  });