crbro-memory 2.9.1 → 2.11.0
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/LICENSE +21 -21
- package/README.md +256 -456
- package/SECURITY.md +135 -8
- package/bin/crbro.mjs +555 -299
- package/dist/build-id.json +10 -0
- package/dist/cli/common.js +211 -0
- package/dist/cli/export.js +522 -0
- package/dist/cli/import.js +683 -0
- package/dist/cli/markdown.js +313 -0
- package/dist/cli/mcpclient.js +107 -0
- package/dist/cli/serve.js +453 -0
- package/dist/cli/view.js +358 -0
- package/dist/cli/viewer-page.js +378 -0
- package/dist/daemon/daemon.js +119 -11
- package/dist/daemon/endpoint.js +222 -21
- package/dist/daemon/lines.js +102 -13
- package/dist/daemon/proxy.js +480 -125
- package/dist/daemon/surface.js +116 -0
- package/dist/daemon/trace.js +73 -0
- package/dist/engine/atrest.js +132 -0
- package/dist/engine/backup.js +143 -19
- package/dist/engine/brain.js +22 -6
- package/dist/engine/codexconfig.js +476 -0
- package/dist/engine/cortex.js +762 -311
- package/dist/engine/doctor.js +676 -0
- package/dist/engine/evidence.js +171 -0
- package/dist/engine/heat.js +81 -37
- package/dist/engine/hippocampus.js +50 -36
- package/dist/engine/hookrefresh.js +296 -0
- package/dist/engine/hookverify.js +15 -3
- package/dist/engine/installer.js +1022 -0
- package/dist/engine/installio.js +279 -0
- package/dist/engine/keychain.js +735 -144
- package/dist/engine/maintenance.js +355 -87
- package/dist/engine/modinstall.js +98 -45
- package/dist/engine/prefrontal.js +126 -69
- package/dist/engine/projects.js +80 -0
- package/dist/engine/quarantine.js +204 -0
- package/dist/engine/secrets.js +41 -5
- package/dist/engine/shelf.js +34 -11
- package/dist/engine/synapses.js +183 -80
- package/dist/engine/triggers.js +141 -11
- package/dist/index.js +37 -3
- package/dist/mcp-surface.json +1 -0
- package/dist/miner/extractor.js +64 -17
- package/dist/miner/scheduler.js +144 -68
- package/dist/search/index.js +810 -176
- package/dist/search/quality.js +31 -0
- package/dist/search/semantic.js +208 -18
- package/dist/search/tokenize.js +44 -0
- package/dist/server.js +649 -343
- package/dist/sync/git.js +203 -72
- package/dist/sync/materialize.js +33 -8
- package/dist/sync/space.js +286 -101
- package/dist/sync/validate.js +204 -0
- package/dist/utils/budget.js +15 -2
- package/dist/utils/fs.js +40 -6
- package/dist/utils/ids.js +128 -14
- package/dist/utils/lock.js +219 -52
- package/hooks/crbro-codex.mjs +284 -0
- package/hooks/crbro-guard.mjs +281 -173
- package/hooks/crbro-hooks.shipped.json +20 -0
- package/hooks/crbro-lifecycle.mjs +16 -4
- package/hooks/crbro-subagent.mjs +180 -180
- package/package.json +12 -8
- package/dist/daemon/daemon.d.ts +0 -25
- package/dist/daemon/daemon.d.ts.map +0 -1
- package/dist/daemon/daemon.js.map +0 -1
- package/dist/daemon/endpoint.d.ts +0 -66
- package/dist/daemon/endpoint.d.ts.map +0 -1
- package/dist/daemon/endpoint.js.map +0 -1
- package/dist/daemon/lines.d.ts +0 -55
- package/dist/daemon/lines.d.ts.map +0 -1
- package/dist/daemon/lines.js.map +0 -1
- package/dist/daemon/proxy.d.ts +0 -55
- package/dist/daemon/proxy.d.ts.map +0 -1
- package/dist/daemon/proxy.js.map +0 -1
- package/dist/engine/backup.d.ts +0 -68
- package/dist/engine/backup.d.ts.map +0 -1
- package/dist/engine/backup.js.map +0 -1
- package/dist/engine/brain.d.ts +0 -79
- package/dist/engine/brain.d.ts.map +0 -1
- package/dist/engine/brain.js.map +0 -1
- package/dist/engine/cortex.d.ts +0 -670
- package/dist/engine/cortex.d.ts.map +0 -1
- package/dist/engine/cortex.js.map +0 -1
- package/dist/engine/dates.d.ts +0 -28
- package/dist/engine/dates.d.ts.map +0 -1
- package/dist/engine/dates.js.map +0 -1
- package/dist/engine/heat.d.ts +0 -28
- package/dist/engine/heat.d.ts.map +0 -1
- package/dist/engine/heat.js.map +0 -1
- package/dist/engine/hippocampus.d.ts +0 -60
- package/dist/engine/hippocampus.d.ts.map +0 -1
- package/dist/engine/hippocampus.js.map +0 -1
- package/dist/engine/hookverify.d.ts +0 -42
- package/dist/engine/hookverify.d.ts.map +0 -1
- package/dist/engine/hookverify.js.map +0 -1
- package/dist/engine/keychain.d.ts +0 -32
- package/dist/engine/keychain.d.ts.map +0 -1
- package/dist/engine/keychain.js.map +0 -1
- package/dist/engine/maintenance.d.ts +0 -218
- package/dist/engine/maintenance.d.ts.map +0 -1
- package/dist/engine/maintenance.js.map +0 -1
- package/dist/engine/modinstall.d.ts +0 -215
- package/dist/engine/modinstall.d.ts.map +0 -1
- package/dist/engine/modinstall.js.map +0 -1
- package/dist/engine/postmortem.d.ts +0 -81
- package/dist/engine/postmortem.d.ts.map +0 -1
- package/dist/engine/postmortem.js.map +0 -1
- package/dist/engine/prefrontal.d.ts +0 -61
- package/dist/engine/prefrontal.d.ts.map +0 -1
- package/dist/engine/prefrontal.js.map +0 -1
- package/dist/engine/secrets.d.ts +0 -30
- package/dist/engine/secrets.d.ts.map +0 -1
- package/dist/engine/secrets.js.map +0 -1
- package/dist/engine/shelf.d.ts +0 -156
- package/dist/engine/shelf.d.ts.map +0 -1
- package/dist/engine/shelf.js.map +0 -1
- package/dist/engine/source.d.ts +0 -6
- package/dist/engine/source.d.ts.map +0 -1
- package/dist/engine/source.js.map +0 -1
- package/dist/engine/synapses.d.ts +0 -82
- package/dist/engine/synapses.d.ts.map +0 -1
- package/dist/engine/synapses.js.map +0 -1
- package/dist/engine/triggers.d.ts +0 -53
- package/dist/engine/triggers.d.ts.map +0 -1
- package/dist/engine/triggers.js.map +0 -1
- package/dist/engine/usage.d.ts +0 -61
- package/dist/engine/usage.d.ts.map +0 -1
- package/dist/engine/usage.js.map +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/miner/extractor.d.ts +0 -12
- package/dist/miner/extractor.d.ts.map +0 -1
- package/dist/miner/extractor.js.map +0 -1
- package/dist/miner/index.d.ts +0 -61
- package/dist/miner/index.d.ts.map +0 -1
- package/dist/miner/index.js.map +0 -1
- package/dist/miner/scheduler.d.ts +0 -30
- package/dist/miner/scheduler.d.ts.map +0 -1
- package/dist/miner/scheduler.js.map +0 -1
- package/dist/miner/types.d.ts +0 -11
- package/dist/miner/types.d.ts.map +0 -1
- package/dist/miner/types.js.map +0 -1
- package/dist/search/index.d.ts +0 -323
- package/dist/search/index.d.ts.map +0 -1
- package/dist/search/index.js.map +0 -1
- package/dist/search/semantic.d.ts +0 -80
- package/dist/search/semantic.d.ts.map +0 -1
- package/dist/search/semantic.js.map +0 -1
- package/dist/search/synonyms.d.ts +0 -2
- package/dist/search/synonyms.d.ts.map +0 -1
- package/dist/search/synonyms.js.map +0 -1
- package/dist/search/tokenize.d.ts +0 -39
- package/dist/search/tokenize.d.ts.map +0 -1
- package/dist/search/tokenize.js.map +0 -1
- package/dist/server.d.ts +0 -41
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/sync/git.d.ts +0 -36
- package/dist/sync/git.d.ts.map +0 -1
- package/dist/sync/git.js.map +0 -1
- package/dist/sync/materialize.d.ts +0 -35
- package/dist/sync/materialize.d.ts.map +0 -1
- package/dist/sync/materialize.js.map +0 -1
- package/dist/sync/ops.d.ts +0 -142
- package/dist/sync/ops.d.ts.map +0 -1
- package/dist/sync/ops.js.map +0 -1
- package/dist/sync/space.d.ts +0 -116
- package/dist/sync/space.d.ts.map +0 -1
- package/dist/sync/space.js.map +0 -1
- package/dist/types/index.d.ts +0 -346
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js.map +0 -1
- package/dist/utils/budget.d.ts +0 -44
- package/dist/utils/budget.d.ts.map +0 -1
- package/dist/utils/budget.js.map +0 -1
- package/dist/utils/fs.d.ts +0 -59
- package/dist/utils/fs.d.ts.map +0 -1
- package/dist/utils/fs.js.map +0 -1
- package/dist/utils/hash.d.ts +0 -20
- package/dist/utils/hash.d.ts.map +0 -1
- package/dist/utils/hash.js.map +0 -1
- package/dist/utils/ids.d.ts +0 -47
- package/dist/utils/ids.d.ts.map +0 -1
- package/dist/utils/ids.js.map +0 -1
- package/dist/utils/lock.d.ts +0 -11
- package/dist/utils/lock.d.ts.map +0 -1
- package/dist/utils/lock.js.map +0 -1
- package/dist/utils/transcripts.d.ts +0 -63
- package/dist/utils/transcripts.d.ts.map +0 -1
- package/dist/utils/transcripts.js.map +0 -1
- package/dist/version.d.ts +0 -26
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
package/dist/daemon/endpoint.js
CHANGED
|
@@ -18,8 +18,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
18
18
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
19
19
|
};
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.DAEMON_PROTOCOL = void 0;
|
|
21
|
+
exports.BUILD_ID_FILE = exports.DAEMON_PROTOCOL = void 0;
|
|
22
22
|
exports.packageVersion = packageVersion;
|
|
23
|
+
exports.readBuildId = readBuildId;
|
|
23
24
|
exports.buildId = buildId;
|
|
24
25
|
exports.daemonDir = daemonDir;
|
|
25
26
|
exports.endpointFor = endpointFor;
|
|
@@ -37,6 +38,8 @@ exports.writeState = writeState;
|
|
|
37
38
|
exports.readState = readState;
|
|
38
39
|
exports.removeState = removeState;
|
|
39
40
|
exports.listStates = listStates;
|
|
41
|
+
exports.verifyDaemon = verifyDaemon;
|
|
42
|
+
exports.pruneDaemonDir = pruneDaemonDir;
|
|
40
43
|
exports.proof = proof;
|
|
41
44
|
exports.sameProof = sameProof;
|
|
42
45
|
exports.newNonce = newNonce;
|
|
@@ -44,39 +47,76 @@ exports.daemonEnabled = daemonEnabled;
|
|
|
44
47
|
exports.setDaemonEnabled = setDaemonEnabled;
|
|
45
48
|
const node_crypto_1 = require("node:crypto");
|
|
46
49
|
const node_fs_1 = require("node:fs");
|
|
50
|
+
const node_net_1 = __importDefault(require("node:net"));
|
|
47
51
|
const node_os_1 = __importDefault(require("node:os"));
|
|
48
52
|
const node_path_1 = __importDefault(require("node:path"));
|
|
49
53
|
const shelf_js_1 = require("../engine/shelf.js");
|
|
50
54
|
const version_js_1 = require("../version.js");
|
|
55
|
+
const lines_js_1 = require("./lines.js");
|
|
51
56
|
exports.DAEMON_PROTOCOL = 1;
|
|
52
57
|
const DAEMON_DIR = '.daemon';
|
|
53
58
|
const ENABLED_FLAG = 'enabled';
|
|
54
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* The version of the code this process runs, '0.0.0' when unreadable. Read
|
|
61
|
+
* once, when the process loaded (version.ts): npx refreshes its cache in
|
|
62
|
+
* place, and a proxy that re-read package.json at every reconnection computed
|
|
63
|
+
* the NEXT release's build and launched its daemon — served by code it never
|
|
64
|
+
* started.
|
|
65
|
+
*/
|
|
55
66
|
function packageVersion() {
|
|
56
|
-
|
|
57
|
-
return v === 'unknown' ? '0.0.0' : v;
|
|
67
|
+
return version_js_1.RUNNING_VERSION === 'unknown' ? '0.0.0' : version_js_1.RUNNING_VERSION;
|
|
58
68
|
}
|
|
69
|
+
/** Written next to the compiled code by `npm run build` (scripts/build-id.mjs). */
|
|
70
|
+
exports.BUILD_ID_FILE = 'build-id.json';
|
|
59
71
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
72
|
+
* The build id a compiled tree declares for itself: the version plus a hash
|
|
73
|
+
* of every compiled .js file, computed when it was built. Two installs of the
|
|
74
|
+
* same release hold the same bytes and so the same id — one daemon between
|
|
75
|
+
* them, where the old id (version + the mtime of server.js) gave each copy its
|
|
76
|
+
* own ~630 MB daemon. Null when the file is missing, malformed, or no longer
|
|
77
|
+
* describes the code beside it (`tsc --watch` rewrites the code, not the file).
|
|
64
78
|
*/
|
|
65
|
-
function
|
|
66
|
-
let stamp = '0';
|
|
79
|
+
function readBuildId(distDir) {
|
|
67
80
|
try {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
const meta = JSON.parse((0, node_fs_1.readFileSync)(node_path_1.default.join(distDir, exports.BUILD_ID_FILE), 'utf8'));
|
|
82
|
+
if (typeof meta?.build !== 'string' || !/^[\w.+-]{1,96}$/.test(meta.build))
|
|
83
|
+
return null;
|
|
84
|
+
const check = meta.check;
|
|
85
|
+
if (typeof check?.file !== 'string' || typeof check?.sha256 !== 'string' || check.file.includes('..'))
|
|
86
|
+
return null;
|
|
87
|
+
const sha = (0, node_crypto_1.createHash)('sha256').update((0, node_fs_1.readFileSync)(node_path_1.default.join(distDir, check.file))).digest('hex');
|
|
88
|
+
return sha === check.sha256 ? meta.build : null;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** A tree with no build-id.json (a developer's src under the test runner, or a dist built by bare tsc): version plus the timestamp of server.js. */
|
|
95
|
+
function devBuildId(here) {
|
|
96
|
+
let stamp = '0';
|
|
97
|
+
for (const f of ['../server.js', '../server.ts']) {
|
|
98
|
+
try {
|
|
99
|
+
stamp = Math.floor((0, node_fs_1.statSync)(node_path_1.default.join(here, f)).mtimeMs).toString(36);
|
|
100
|
+
break;
|
|
75
101
|
}
|
|
102
|
+
catch { /* next */ }
|
|
76
103
|
}
|
|
77
|
-
catch { /* keep 0 */ }
|
|
78
104
|
return `${packageVersion()}-${stamp}`;
|
|
79
105
|
}
|
|
106
|
+
let ownBuild = null;
|
|
107
|
+
/**
|
|
108
|
+
* Which build this process is, decided once (the first time it is asked) and
|
|
109
|
+
* never re-read: you are served by the code you launched. In a published or
|
|
110
|
+
* built tree it is dist/build-id.json; only a tree without one falls back to
|
|
111
|
+
* version plus the mtime of server.js.
|
|
112
|
+
*/
|
|
113
|
+
function buildId() {
|
|
114
|
+
if (ownBuild === null) {
|
|
115
|
+
const here = __dirname; // CommonJS output: dist/daemon, or src/daemon under the test runner
|
|
116
|
+
ownBuild = readBuildId(node_path_1.default.join(here, '..')) ?? devBuildId(here);
|
|
117
|
+
}
|
|
118
|
+
return ownBuild;
|
|
119
|
+
}
|
|
80
120
|
function daemonDir(brainRoot) {
|
|
81
121
|
return node_path_1.default.join(brainRoot, DAEMON_DIR);
|
|
82
122
|
}
|
|
@@ -232,13 +272,30 @@ async function acquireStartLock(brainRoot, build) {
|
|
|
232
272
|
function newToken() {
|
|
233
273
|
return (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
234
274
|
}
|
|
235
|
-
|
|
275
|
+
/**
|
|
276
|
+
* Write the descriptor atomically. On Windows the rename can fail for a moment
|
|
277
|
+
* (EPERM/EBUSY while an antivirus or a reading client holds the target), so it
|
|
278
|
+
* is tried a few times; a temporary that could not become the descriptor is
|
|
279
|
+
* removed, never left behind with a token in it.
|
|
280
|
+
*/
|
|
281
|
+
async function writeState(brainRoot, state, attempts = 3) {
|
|
236
282
|
const dir = daemonDir(brainRoot);
|
|
237
283
|
await node_fs_1.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
238
284
|
const file = stateFile(brainRoot, state.build);
|
|
239
285
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
240
|
-
|
|
241
|
-
|
|
286
|
+
for (let i = 0;; i++) {
|
|
287
|
+
try {
|
|
288
|
+
await node_fs_1.promises.writeFile(tmp, JSON.stringify(state), { mode: 0o600 });
|
|
289
|
+
await node_fs_1.promises.rename(tmp, file);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
await node_fs_1.promises.rm(tmp, { force: true }).catch(() => undefined);
|
|
294
|
+
if (i + 1 >= attempts)
|
|
295
|
+
throw err;
|
|
296
|
+
await new Promise(r => setTimeout(r, 100 * (i + 1)));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
242
299
|
}
|
|
243
300
|
async function readState(brainRoot, build = buildId()) {
|
|
244
301
|
try {
|
|
@@ -274,6 +331,150 @@ async function listStates(brainRoot) {
|
|
|
274
331
|
catch { /* no folder, no daemons */ }
|
|
275
332
|
return out;
|
|
276
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* Is the daemon a descriptor names really there? Asked of the endpoint, with
|
|
336
|
+
* the descriptor's own token — never of the pid: Windows hands pids out again
|
|
337
|
+
* quickly, and a descriptor weeks old whose pid now belongs to a cmd.exe was
|
|
338
|
+
* reported "alive" for ever.
|
|
339
|
+
*
|
|
340
|
+
* alive it answered and proved it holds the token
|
|
341
|
+
* refused nobody listens there (connection refused / no such pipe)
|
|
342
|
+
* dead something answers, but not the daemon this descriptor names
|
|
343
|
+
* unknown no verdict in time (busy, or closed on us): leave it alone
|
|
344
|
+
*/
|
|
345
|
+
function verifyDaemon(state, timeoutMs = 1_500) {
|
|
346
|
+
return new Promise(resolve => {
|
|
347
|
+
let done = false;
|
|
348
|
+
const nonce = newNonce();
|
|
349
|
+
const s = node_net_1.default.connect(state.endpoint);
|
|
350
|
+
const finish = (v) => {
|
|
351
|
+
if (done)
|
|
352
|
+
return;
|
|
353
|
+
done = true;
|
|
354
|
+
clearTimeout(t);
|
|
355
|
+
s.destroy();
|
|
356
|
+
resolve(v);
|
|
357
|
+
};
|
|
358
|
+
// A late answer that arrived while the loop was busy is read before the verdict.
|
|
359
|
+
const t = setTimeout(() => setImmediate(() => finish('unknown')), timeoutMs);
|
|
360
|
+
const reader = new lines_js_1.LineReader(line => {
|
|
361
|
+
let m = null;
|
|
362
|
+
try {
|
|
363
|
+
m = JSON.parse(line);
|
|
364
|
+
}
|
|
365
|
+
catch { /* not a daemon */ }
|
|
366
|
+
finish(m?.crbro === 'hello' && sameProof(String(m.proof || ''), proof(state.token, 'daemon', nonce)) ? 'alive' : 'dead');
|
|
367
|
+
}, () => finish('dead'), 64 * 1024);
|
|
368
|
+
s.on('connect', () => s.write(`${JSON.stringify({ crbro: 'hello', protocol: exports.DAEMON_PROTOCOL, nonce })}\n`));
|
|
369
|
+
s.on('data', chunk => reader.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk));
|
|
370
|
+
s.on('error', err => {
|
|
371
|
+
const code = err.code;
|
|
372
|
+
finish(code === 'ECONNREFUSED' || code === 'ENOENT' ? 'refused' : 'unknown');
|
|
373
|
+
});
|
|
374
|
+
s.on('close', () => finish('unknown'));
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
/** A *.tmp this old was left by a writer that died or failed: nobody will rename it any more. */
|
|
378
|
+
const TMP_STALE_MS = 5 * 60_000;
|
|
379
|
+
/** A start lock is held for the seconds a start takes. */
|
|
380
|
+
const LOCK_STALE_MS = 5 * 60_000;
|
|
381
|
+
/** A blocked marker matters to the proxy waiting right now, not tomorrow. */
|
|
382
|
+
const BLOCKED_STALE_MS = 24 * 3_600_000;
|
|
383
|
+
/**
|
|
384
|
+
* Sweep .daemon: descriptors whose daemon is not there (checked at the
|
|
385
|
+
* endpoint, with the token), temporaries, start locks and blocked markers that
|
|
386
|
+
* nobody will ever come back for. Before 2.11 only `daemon status|stop|off`
|
|
387
|
+
* removed anything, by pid, and nobody runs those: twelve dead descriptors and
|
|
388
|
+
* five token-bearing temporaries were found in one brain. `keep` names a build
|
|
389
|
+
* whose descriptor is not probed (the caller's own).
|
|
390
|
+
*/
|
|
391
|
+
async function pruneDaemonDir(brainRoot, opts = {}) {
|
|
392
|
+
const dir = daemonDir(brainRoot);
|
|
393
|
+
const now = opts.now ?? Date.now();
|
|
394
|
+
const verify = opts.verify ?? verifyDaemon;
|
|
395
|
+
const out = { removed: [], alive: [], unknown: [] };
|
|
396
|
+
let names;
|
|
397
|
+
try {
|
|
398
|
+
names = await node_fs_1.promises.readdir(dir);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
const keepFile = opts.keep ? node_path_1.default.basename(stateFile(brainRoot, opts.keep)) : null;
|
|
404
|
+
const rmIfOlder = async (name, maxAgeMs) => {
|
|
405
|
+
try {
|
|
406
|
+
const st = await node_fs_1.promises.stat(node_path_1.default.join(dir, name));
|
|
407
|
+
if (now - st.mtimeMs <= maxAgeMs)
|
|
408
|
+
return;
|
|
409
|
+
await node_fs_1.promises.rm(node_path_1.default.join(dir, name), { force: true });
|
|
410
|
+
out.removed.push(name);
|
|
411
|
+
}
|
|
412
|
+
catch { /* gone already */ }
|
|
413
|
+
};
|
|
414
|
+
const checks = [];
|
|
415
|
+
for (const name of names) {
|
|
416
|
+
if (/\.json\.\d+\.tmp$/.test(name)) {
|
|
417
|
+
checks.push(rmIfOlder(name, TMP_STALE_MS));
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (/^[0-9a-f]{16}\.lock$/.test(name)) {
|
|
421
|
+
checks.push(rmIfOlder(name, LOCK_STALE_MS));
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (/^[0-9a-f]{16}\.blocked$/.test(name)) {
|
|
425
|
+
checks.push(rmIfOlder(name, BLOCKED_STALE_MS));
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (!/^[0-9a-f]{16}\.json$/.test(name) || name === keepFile)
|
|
429
|
+
continue;
|
|
430
|
+
checks.push((async () => {
|
|
431
|
+
const file = node_path_1.default.join(dir, name);
|
|
432
|
+
let s;
|
|
433
|
+
try {
|
|
434
|
+
s = JSON.parse(await node_fs_1.promises.readFile(file, 'utf8'));
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
// Only descriptors that are what their name says: this brain, this build's own endpoint.
|
|
440
|
+
// Anything else is not ours to judge — nor a place to connect to.
|
|
441
|
+
if (s?.protocol !== exports.DAEMON_PROTOCOL || typeof s.build !== 'string' || typeof s.token !== 'string')
|
|
442
|
+
return;
|
|
443
|
+
if (node_path_1.default.basename(stateFile(brainRoot, s.build)) !== name || s.endpoint !== endpointFor(brainRoot, s.build))
|
|
444
|
+
return;
|
|
445
|
+
const v = await verify(s);
|
|
446
|
+
if (v === 'alive') {
|
|
447
|
+
out.alive.push(s);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (v === 'unknown') {
|
|
451
|
+
out.unknown.push(s);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
// Removed only if it is still the same descriptor: a replacement may have been written meanwhile.
|
|
455
|
+
try {
|
|
456
|
+
const again = JSON.parse(await node_fs_1.promises.readFile(file, 'utf8'));
|
|
457
|
+
if (again?.token !== s.token)
|
|
458
|
+
return;
|
|
459
|
+
await node_fs_1.promises.rm(file, { force: true });
|
|
460
|
+
out.removed.push(name);
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
// A socket file nobody listens on (never a pipe, never one somebody answers on).
|
|
466
|
+
if (v === 'refused' && process.platform !== 'win32' && node_path_1.default.dirname(s.endpoint) === dir) {
|
|
467
|
+
try {
|
|
468
|
+
await node_fs_1.promises.rm(s.endpoint, { force: true });
|
|
469
|
+
out.removed.push(node_path_1.default.basename(s.endpoint));
|
|
470
|
+
}
|
|
471
|
+
catch { /* best effort */ }
|
|
472
|
+
}
|
|
473
|
+
})());
|
|
474
|
+
}
|
|
475
|
+
await Promise.all(checks);
|
|
476
|
+
return out;
|
|
477
|
+
}
|
|
277
478
|
function proof(token, role, nonce) {
|
|
278
479
|
return (0, node_crypto_1.createHmac)('sha256', token).update(`${role}:${nonce}`).digest('hex');
|
|
279
480
|
}
|
package/dist/daemon/lines.js
CHANGED
|
@@ -4,18 +4,38 @@
|
|
|
4
4
|
// The same framing MCP uses on stdio, so a proxy can hand a client's bytes to
|
|
5
5
|
// the daemon without understanding them. One JSON value per line, UTF-8.
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
-
exports.LoopbackTransport = exports.SocketServerTransport = exports.LineReader = exports.MAX_LINE_BYTES = void 0;
|
|
7
|
+
exports.LoopbackTransport = exports.SocketServerTransport = exports.NOT_STARTED = exports.LineReader = exports.MAX_LINE_BYTES = void 0;
|
|
8
|
+
exports.idFromHead = idFromHead;
|
|
8
9
|
/** A line bigger than this is not a message, it is a mistake or an attack. */
|
|
9
10
|
exports.MAX_LINE_BYTES = 32 * 1024 * 1024;
|
|
10
|
-
/**
|
|
11
|
+
/** How much of an over-long line is kept to say which request it was. */
|
|
12
|
+
const HEAD_BYTES = 64 * 1024;
|
|
13
|
+
/**
|
|
14
|
+
* Splits a byte stream into lines. Tolerates \r\n and lines split across chunks.
|
|
15
|
+
*
|
|
16
|
+
* A line longer than the limit is reported once, with its first bytes (enough
|
|
17
|
+
* to find its id), and everything up to its newline is thrown away. It used to
|
|
18
|
+
* be cut at the limit and its tail delivered as a line of its own: a broken
|
|
19
|
+
* message, and an id that never got an answer.
|
|
20
|
+
*/
|
|
11
21
|
class LineReader {
|
|
12
22
|
onLine;
|
|
13
23
|
onOverflow;
|
|
24
|
+
maxBytes;
|
|
14
25
|
buf = [];
|
|
15
26
|
size = 0;
|
|
16
|
-
|
|
27
|
+
discarding = false;
|
|
28
|
+
constructor(onLine, onOverflow, maxBytes = exports.MAX_LINE_BYTES) {
|
|
17
29
|
this.onLine = onLine;
|
|
18
30
|
this.onOverflow = onOverflow;
|
|
31
|
+
this.maxBytes = maxBytes;
|
|
32
|
+
}
|
|
33
|
+
overflow(piece) {
|
|
34
|
+
const parts = [...this.buf, piece];
|
|
35
|
+
const head = Buffer.concat(parts).subarray(0, HEAD_BYTES).toString('utf8');
|
|
36
|
+
this.buf = [];
|
|
37
|
+
this.size = 0;
|
|
38
|
+
this.onOverflow(head);
|
|
19
39
|
}
|
|
20
40
|
push(chunk) {
|
|
21
41
|
let start = 0;
|
|
@@ -23,32 +43,60 @@ class LineReader {
|
|
|
23
43
|
if (chunk[i] !== 0x0a)
|
|
24
44
|
continue;
|
|
25
45
|
const piece = chunk.subarray(start, i);
|
|
46
|
+
start = i + 1;
|
|
47
|
+
if (this.discarding) {
|
|
48
|
+
this.discarding = false;
|
|
49
|
+
continue;
|
|
50
|
+
} // the end of a line already refused
|
|
51
|
+
if (this.size + piece.length > this.maxBytes) {
|
|
52
|
+
this.overflow(piece);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
26
55
|
const whole = this.buf.length ? Buffer.concat([...this.buf, piece]) : piece;
|
|
27
56
|
this.buf = [];
|
|
28
57
|
this.size = 0;
|
|
29
|
-
start = i + 1;
|
|
30
58
|
const line = whole.toString('utf8').replace(/\r$/, '');
|
|
31
59
|
if (line)
|
|
32
60
|
this.onLine(line);
|
|
33
61
|
}
|
|
34
|
-
if (start < chunk.length) {
|
|
62
|
+
if (start < chunk.length && !this.discarding) {
|
|
35
63
|
const rest = chunk.subarray(start);
|
|
64
|
+
if (this.size + rest.length > this.maxBytes) {
|
|
65
|
+
this.overflow(rest);
|
|
66
|
+
this.discarding = true; // until the next newline, nothing is a line
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
36
69
|
this.buf.push(rest);
|
|
37
70
|
this.size += rest.length;
|
|
38
|
-
if (this.size > exports.MAX_LINE_BYTES) {
|
|
39
|
-
this.buf = [];
|
|
40
|
-
this.size = 0;
|
|
41
|
-
this.onOverflow();
|
|
42
|
-
}
|
|
43
71
|
}
|
|
44
72
|
}
|
|
45
73
|
}
|
|
46
74
|
exports.LineReader = LineReader;
|
|
75
|
+
/** The id of a request, read from the first bytes of a line too long to parse. Null when it cannot be told. */
|
|
76
|
+
function idFromHead(head) {
|
|
77
|
+
const m = /"id"\s*:\s*("(?:[^"\\]|\\.){0,256}"|-?\d{1,16})/.exec(head);
|
|
78
|
+
if (!m)
|
|
79
|
+
return null;
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(m[1]);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const isRequest = (m) => !!m && typeof m.method === 'string' && m.id !== undefined && m.id !== null;
|
|
88
|
+
const isResponse = (m) => !!m && m.method === undefined && m.id !== undefined && m.id !== null;
|
|
89
|
+
/** The error a stopping daemon gives a request it never started. The proxy recognises it and sends the request again elsewhere. */
|
|
90
|
+
exports.NOT_STARTED = 'not-started';
|
|
47
91
|
/**
|
|
48
92
|
* The daemon's side of one client: an MCP Transport over a socket that has
|
|
49
93
|
* already passed the handshake. `scope` wraps every incoming message, which
|
|
50
94
|
* is how per-connection state (the session tally) follows the request through
|
|
51
95
|
* shared engines without any of them knowing about connections.
|
|
96
|
+
*
|
|
97
|
+
* It counts the requests it has handed to the server and not yet answered, so
|
|
98
|
+
* a daemon that is asked to stop can wait for them instead of cutting a write
|
|
99
|
+
* in half — and, while it waits, refuse new ones in a way the proxy can retry.
|
|
52
100
|
*/
|
|
53
101
|
class SocketServerTransport {
|
|
54
102
|
socket;
|
|
@@ -59,6 +107,9 @@ class SocketServerTransport {
|
|
|
59
107
|
closed = false;
|
|
60
108
|
/** Lines that arrived before the server finished connecting, in order. */
|
|
61
109
|
pending = [];
|
|
110
|
+
inflight = new Set();
|
|
111
|
+
draining = false;
|
|
112
|
+
idleWaiters = [];
|
|
62
113
|
constructor(socket, scope) {
|
|
63
114
|
this.socket = socket;
|
|
64
115
|
this.scope = scope;
|
|
@@ -74,6 +125,17 @@ class SocketServerTransport {
|
|
|
74
125
|
this.socket.on('close', () => this.finish());
|
|
75
126
|
this.socket.on('error', err => { this.onerror?.(err); this.finish(); });
|
|
76
127
|
}
|
|
128
|
+
/** Requests handed to the server and not answered yet. */
|
|
129
|
+
inFlight() {
|
|
130
|
+
return this.inflight.size;
|
|
131
|
+
}
|
|
132
|
+
/** From now on new requests are refused (NOT_STARTED); resolves once those already running are answered. */
|
|
133
|
+
drain() {
|
|
134
|
+
this.draining = true;
|
|
135
|
+
if (this.inflight.size === 0 || this.closed)
|
|
136
|
+
return Promise.resolve();
|
|
137
|
+
return new Promise(resolve => this.idleWaiters.push(resolve));
|
|
138
|
+
}
|
|
77
139
|
/** Fed by the daemon, which owns the reader (the handshake used it first). */
|
|
78
140
|
receive(line) {
|
|
79
141
|
if (this.pending) {
|
|
@@ -88,15 +150,41 @@ class SocketServerTransport {
|
|
|
88
150
|
this.onerror?.(err);
|
|
89
151
|
return;
|
|
90
152
|
}
|
|
153
|
+
if (isRequest(message)) {
|
|
154
|
+
const id = message.id;
|
|
155
|
+
if (this.draining) {
|
|
156
|
+
this.write({ jsonrpc: '2.0', id, error: { code: -32000, message: 'CRBRO is restarting: this call was not started.', data: { crbro: exports.NOT_STARTED } } });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
this.inflight.add(id);
|
|
160
|
+
}
|
|
161
|
+
else if (message.method === 'notifications/cancelled') {
|
|
162
|
+
// A cancelled request is never answered: it is not running any more either.
|
|
163
|
+
if (this.inflight.delete(message.params?.requestId))
|
|
164
|
+
queueMicrotask(() => this.settle());
|
|
165
|
+
}
|
|
91
166
|
this.scope(() => this.onmessage?.(message));
|
|
92
167
|
}
|
|
93
|
-
|
|
168
|
+
write(message) {
|
|
94
169
|
if (this.closed || this.socket.destroyed)
|
|
95
|
-
return;
|
|
96
|
-
|
|
170
|
+
return Promise.resolve();
|
|
171
|
+
return new Promise(resolve => {
|
|
97
172
|
this.socket.write(`${JSON.stringify(message)}\n`, () => resolve());
|
|
98
173
|
});
|
|
99
174
|
}
|
|
175
|
+
async send(message) {
|
|
176
|
+
await this.write(message);
|
|
177
|
+
if (isResponse(message) && this.inflight.delete(message.id))
|
|
178
|
+
this.settle();
|
|
179
|
+
}
|
|
180
|
+
settle() {
|
|
181
|
+
if (this.inflight.size > 0 && !this.closed)
|
|
182
|
+
return;
|
|
183
|
+
const waiters = this.idleWaiters;
|
|
184
|
+
this.idleWaiters = [];
|
|
185
|
+
for (const w of waiters)
|
|
186
|
+
w();
|
|
187
|
+
}
|
|
100
188
|
async close() {
|
|
101
189
|
if (!this.socket.destroyed)
|
|
102
190
|
this.socket.end();
|
|
@@ -106,6 +194,7 @@ class SocketServerTransport {
|
|
|
106
194
|
if (this.closed)
|
|
107
195
|
return;
|
|
108
196
|
this.closed = true;
|
|
197
|
+
this.settle();
|
|
109
198
|
this.onclose?.();
|
|
110
199
|
}
|
|
111
200
|
}
|