@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.
- package/bin/ijfw-dashboard +13 -4
- package/package.json +1 -1
- package/src/audit-roster.js +16 -4
- package/src/brain/dream-pipeline.js +17 -1
- package/src/brain/seed-gate.js +108 -0
- 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 +28 -21
- package/src/dashboard-server.js +103 -13
- 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/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 +99 -27
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.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": [
|
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
|
}
|
|
@@ -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 =
|
|
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
|
+
}
|
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
|
|
@@ -508,20 +508,12 @@ function parseArgsInner(args) {
|
|
|
508
508
|
return { cmd: 'cross-project-audit', rule, dryRun };
|
|
509
509
|
}
|
|
510
510
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2976
|
-
|
|
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
|
|
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';
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
});
|