crbro-memory 2.10.0 → 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 -465
- package/SECURITY.md +114 -12
- package/bin/crbro.mjs +539 -300
- 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 +139 -19
- package/dist/engine/brain.js +22 -6
- package/dist/engine/codexconfig.js +476 -0
- package/dist/engine/cortex.js +667 -232
- package/dist/engine/doctor.js +312 -14
- package/dist/engine/heat.js +81 -37
- package/dist/engine/hippocampus.js +18 -7
- 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 +721 -162
- package/dist/engine/maintenance.js +355 -87
- package/dist/engine/modinstall.js +98 -45
- package/dist/engine/prefrontal.js +29 -6
- package/dist/engine/quarantine.js +204 -0
- package/dist/engine/secrets.js +41 -5
- package/dist/engine/shelf.js +25 -3
- 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 +797 -176
- package/dist/search/semantic.js +208 -18
- package/dist/search/tokenize.js +44 -0
- package/dist/server.js +583 -373
- package/dist/sync/git.js +203 -72
- package/dist/sync/space.js +100 -25
- 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 +94 -7
- 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 +9 -5
- 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 -682
- 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/doctor.d.ts +0 -93
- package/dist/engine/doctor.d.ts.map +0 -1
- package/dist/engine/doctor.js.map +0 -1
- package/dist/engine/evidence.d.ts +0 -26
- package/dist/engine/evidence.d.ts.map +0 -1
- package/dist/engine/evidence.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 -64
- package/dist/engine/prefrontal.d.ts.map +0 -1
- package/dist/engine/prefrontal.js.map +0 -1
- package/dist/engine/projects.d.ts +0 -20
- package/dist/engine/projects.d.ts.map +0 -1
- package/dist/engine/projects.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 -155
- 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 -328
- package/dist/search/index.d.ts.map +0 -1
- package/dist/search/index.js.map +0 -1
- package/dist/search/quality.d.ts +0 -18
- package/dist/search/quality.d.ts.map +0 -1
- package/dist/search/quality.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 -36
- package/dist/sync/materialize.d.ts.map +0 -1
- package/dist/sync/materialize.js.map +0 -1
- package/dist/sync/ops.d.ts +0 -147
- package/dist/sync/ops.d.ts.map +0 -1
- package/dist/sync/ops.js.map +0 -1
- package/dist/sync/space.d.ts +0 -118
- package/dist/sync/space.d.ts.map +0 -1
- package/dist/sync/space.js.map +0 -1
- package/dist/types/index.d.ts +0 -369
- 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 -13
- 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
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ─── What the server says before it does anything ────────────────
|
|
3
|
+
//
|
|
4
|
+
// `initialize`, `ping` and `tools/list` have the same answer for every
|
|
5
|
+
// conversation of one build: who the server is, what it can do, how it is
|
|
6
|
+
// meant to be used and which tools it has. A proxy that waited for a backend
|
|
7
|
+
// to say so kept the client in silence while it found, started or gave up on
|
|
8
|
+
// the daemon — past 12 s in the worst case, beyond Codex's 10 s start-up
|
|
9
|
+
// limit. So the proxy answers them itself, at once.
|
|
10
|
+
//
|
|
11
|
+
// The answers are never written by hand. They are taken from the real server
|
|
12
|
+
// (createServer, with engines that are built but never started): at build
|
|
13
|
+
// time into dist/mcp-surface.json, or in this process when that file is
|
|
14
|
+
// missing or belongs to another build. tests/daemon.startup.test.ts holds the
|
|
15
|
+
// proxy's answers against the real server's.
|
|
16
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
17
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
18
|
+
};
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.SURFACE_FILE = void 0;
|
|
21
|
+
exports.loadSurface = loadSurface;
|
|
22
|
+
exports.computeSurface = computeSurface;
|
|
23
|
+
exports.getSurface = getSurface;
|
|
24
|
+
exports.answerFromSurface = answerFromSurface;
|
|
25
|
+
const node_fs_1 = require("node:fs");
|
|
26
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
27
|
+
exports.SURFACE_FILE = 'mcp-surface.json';
|
|
28
|
+
function isSurface(v) {
|
|
29
|
+
return !!v && typeof v.build === 'string' && typeof v.latest === 'string' && Array.isArray(v.supported)
|
|
30
|
+
&& v.supported.every((x) => typeof x === 'string')
|
|
31
|
+
&& !!v.initialize && typeof v.initialize.capabilities === 'object' && typeof v.initialize.serverInfo === 'object'
|
|
32
|
+
&& !!v.tools && Array.isArray(v.tools.tools);
|
|
33
|
+
}
|
|
34
|
+
/** The snapshot next to the compiled code, if it is this build's. */
|
|
35
|
+
function loadSurface(build, dir = node_path_1.default.join(__dirname, '..')) {
|
|
36
|
+
try {
|
|
37
|
+
const s = JSON.parse((0, node_fs_1.readFileSync)(node_path_1.default.join(dir, exports.SURFACE_FILE), 'utf8'));
|
|
38
|
+
return isSurface(s) && s.build === build ? s : null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Ask a real server, in this process. Its engines are constructed (that reads
|
|
46
|
+
* nothing) and never started: no brain is opened, no index loaded.
|
|
47
|
+
*/
|
|
48
|
+
async function computeSurface(build) {
|
|
49
|
+
const { createServer, createEngines } = await import('../server.js');
|
|
50
|
+
const { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } = await import('@modelcontextprotocol/sdk/types.js');
|
|
51
|
+
const { LoopbackTransport } = await import('./lines.js');
|
|
52
|
+
const answers = new Map();
|
|
53
|
+
const transport = new LoopbackTransport(line => { const m = JSON.parse(line); if (m.id !== undefined)
|
|
54
|
+
answers.set(String(m.id), m); });
|
|
55
|
+
const server = createServer(createEngines());
|
|
56
|
+
await server.connect(transport);
|
|
57
|
+
transport.receive(JSON.stringify({
|
|
58
|
+
jsonrpc: '2.0', id: 'surface-init', method: 'initialize',
|
|
59
|
+
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'crbro-surface', version: '0' } },
|
|
60
|
+
}));
|
|
61
|
+
transport.receive(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }));
|
|
62
|
+
transport.receive(JSON.stringify({ jsonrpc: '2.0', id: 'surface-tools', method: 'tools/list', params: {} }));
|
|
63
|
+
const until = Date.now() + 10_000;
|
|
64
|
+
while ((!answers.has('surface-init') || !answers.has('surface-tools')) && Date.now() < until) {
|
|
65
|
+
await new Promise(r => setImmediate(r));
|
|
66
|
+
}
|
|
67
|
+
await server.close().catch(() => undefined);
|
|
68
|
+
const init = answers.get('surface-init')?.result;
|
|
69
|
+
const tools = answers.get('surface-tools')?.result;
|
|
70
|
+
if (!init || !tools)
|
|
71
|
+
throw new Error('the server did not describe itself');
|
|
72
|
+
const { protocolVersion: _negotiated, ...initialize } = init;
|
|
73
|
+
return { build, latest: LATEST_PROTOCOL_VERSION, supported: [...SUPPORTED_PROTOCOL_VERSIONS], initialize, tools };
|
|
74
|
+
}
|
|
75
|
+
const computed = new Map();
|
|
76
|
+
/** The snapshot if there is one for this build, else the real server asked here (once per process). Null if neither can be had. */
|
|
77
|
+
async function getSurface(build) {
|
|
78
|
+
const s = loadSurface(build);
|
|
79
|
+
if (s)
|
|
80
|
+
return s;
|
|
81
|
+
let p = computed.get(build);
|
|
82
|
+
if (!p) {
|
|
83
|
+
p = computeSurface(build).catch(() => null);
|
|
84
|
+
computed.set(build, p);
|
|
85
|
+
}
|
|
86
|
+
return p;
|
|
87
|
+
}
|
|
88
|
+
/** Only an initialize the server itself would accept is answered here; anything else goes to it and gets its own answer. */
|
|
89
|
+
function wellFormedInitialize(m) {
|
|
90
|
+
const p = m?.params;
|
|
91
|
+
return !!p && typeof p === 'object' && typeof p.protocolVersion === 'string'
|
|
92
|
+
&& !!p.capabilities && typeof p.capabilities === 'object'
|
|
93
|
+
&& !!p.clientInfo && typeof p.clientInfo === 'object' && typeof p.clientInfo.name === 'string' && typeof p.clientInfo.version === 'string';
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The answer the server would give to this request, or null when it is not
|
|
97
|
+
* one of the requests whose answer is fixed per build. The protocol version
|
|
98
|
+
* is negotiated exactly as the SDK does: the client's if supported, else the
|
|
99
|
+
* latest.
|
|
100
|
+
*/
|
|
101
|
+
function answerFromSurface(s, m) {
|
|
102
|
+
if (!m || m.jsonrpc !== '2.0' || m.id === undefined || m.id === null || typeof m.method !== 'string')
|
|
103
|
+
return null;
|
|
104
|
+
if (m.method === 'initialize') {
|
|
105
|
+
if (!wellFormedInitialize(m))
|
|
106
|
+
return null;
|
|
107
|
+
const protocolVersion = s.supported.includes(m.params.protocolVersion) ? m.params.protocolVersion : s.latest;
|
|
108
|
+
return { jsonrpc: '2.0', id: m.id, result: { protocolVersion, ...s.initialize } };
|
|
109
|
+
}
|
|
110
|
+
if (m.method === 'ping')
|
|
111
|
+
return { jsonrpc: '2.0', id: m.id, result: {} };
|
|
112
|
+
if (m.method === 'tools/list')
|
|
113
|
+
return { jsonrpc: '2.0', id: m.id, result: s.tools };
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=surface.js.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ─── CRBRO_STARTUP_TRACE=1: where the start-up time goes ──────────
|
|
3
|
+
//
|
|
4
|
+
// Until 2.11 "the client says CRBRO is slow to start" could not be broken
|
|
5
|
+
// down: not in the client, not in the server. With the variable set, each
|
|
6
|
+
// process writes one line per phase — milliseconds since the process started,
|
|
7
|
+
// the phase, a detail — to stderr and to <brain>/.daemon/startup-trace.log
|
|
8
|
+
// (a detached daemon has no stderr anybody reads). Phases: process start,
|
|
9
|
+
// initialize answered, backend connected or in-process fallback, engine
|
|
10
|
+
// ready, first tool result.
|
|
11
|
+
//
|
|
12
|
+
// Off, it costs one boolean test per call site: nothing is formatted, nothing
|
|
13
|
+
// is written.
|
|
14
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
15
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
16
|
+
};
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.TRACE_ON = void 0;
|
|
19
|
+
exports.traceStart = traceStart;
|
|
20
|
+
exports.trace = trace;
|
|
21
|
+
const node_fs_1 = require("node:fs");
|
|
22
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
23
|
+
const node_perf_hooks_1 = require("node:perf_hooks");
|
|
24
|
+
exports.TRACE_ON = (process.env.CRBRO_STARTUP_TRACE || '').trim() === '1';
|
|
25
|
+
const LOG_MAX_BYTES = 512 * 1024;
|
|
26
|
+
let file = null;
|
|
27
|
+
let role = 'process';
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
/** Where the log goes and who is writing (proxy, daemon, classic). Writes the "process start" line. */
|
|
30
|
+
function traceStart(brainRoot, who) {
|
|
31
|
+
if (!exports.TRACE_ON)
|
|
32
|
+
return;
|
|
33
|
+
role = who;
|
|
34
|
+
if (brainRoot) {
|
|
35
|
+
try {
|
|
36
|
+
const dir = node_path_1.default.join(brainRoot, '.daemon');
|
|
37
|
+
(0, node_fs_1.mkdirSync)(dir, { recursive: true });
|
|
38
|
+
file = node_path_1.default.join(dir, 'startup-trace.log');
|
|
39
|
+
// Left on for weeks, it keeps one previous file and starts again.
|
|
40
|
+
try {
|
|
41
|
+
if ((0, node_fs_1.statSync)(file).size > LOG_MAX_BYTES)
|
|
42
|
+
(0, node_fs_1.renameSync)(file, `${file}.1`);
|
|
43
|
+
}
|
|
44
|
+
catch { /* no log yet */ }
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
file = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
trace('process start', `pid ${process.pid} · started ${new Date(node_perf_hooks_1.performance.timeOrigin).toISOString()}`);
|
|
51
|
+
}
|
|
52
|
+
/** One phase. `once`: only the first time this phase is reached in this process. */
|
|
53
|
+
function trace(phase, detail = '', once = false) {
|
|
54
|
+
if (!exports.TRACE_ON)
|
|
55
|
+
return;
|
|
56
|
+
if (once) {
|
|
57
|
+
if (seen.has(phase))
|
|
58
|
+
return;
|
|
59
|
+
seen.add(phase);
|
|
60
|
+
}
|
|
61
|
+
const line = `[crbro trace] ${role} +${node_perf_hooks_1.performance.now().toFixed(1)}ms ${phase}${detail ? ` · ${detail}` : ''}`;
|
|
62
|
+
try {
|
|
63
|
+
process.stderr.write(`${line}\n`);
|
|
64
|
+
}
|
|
65
|
+
catch { /* no stderr (detached) */ }
|
|
66
|
+
if (file) {
|
|
67
|
+
try {
|
|
68
|
+
(0, node_fs_1.appendFileSync)(file, `${new Date().toISOString()} [${process.pid}] ${line}\n`);
|
|
69
|
+
}
|
|
70
|
+
catch { /* the trace never breaks start-up */ }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=trace.js.map
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ─── Encryption at rest, opt-in: backups and quarantine ─────────
|
|
3
|
+
//
|
|
4
|
+
// The brain is plain JSON on purpose — people read it, diff it and fix it by
|
|
5
|
+
// hand. Its two copies are a different matter: a backup is made to be carried
|
|
6
|
+
// to a synced folder, and the quarantine holds exactly what somebody asked to
|
|
7
|
+
// forget, credentials included. Whoever syncs those to a cloud drive may want
|
|
8
|
+
// them sealed. Off by default; nothing changes for anyone who does not ask.
|
|
9
|
+
//
|
|
10
|
+
// CRBRO_ENCRYPT_AT_REST=1 seal new backups and quarantine copies
|
|
11
|
+
//
|
|
12
|
+
// The key: 32 random bytes, base64, kept in the OS keychain under
|
|
13
|
+
// CRBRO_AT_REST_KEY (crbro_secret / `crbro-memory secret set`), never in the
|
|
14
|
+
// brain and never next to the files it protects. With the switch on and no key
|
|
15
|
+
// CRBRO fails loudly: it never falls back to writing in clear and never deletes
|
|
16
|
+
// anything for lack of a key. Create the key once, explicitly:
|
|
17
|
+
//
|
|
18
|
+
// node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" \
|
|
19
|
+
// | npx crbro-memory secret set CRBRO_AT_REST_KEY --description "crbro - at-rest key"
|
|
20
|
+
//
|
|
21
|
+
// Lose the key and the sealed copies are gone with it: that is what sealing
|
|
22
|
+
// means. Clear files stay readable whatever the switch says.
|
|
23
|
+
//
|
|
24
|
+
// Format: MAGIC (8 bytes) | IV (12) | GCM tag (16) | ciphertext. AES-256-GCM
|
|
25
|
+
// from node:crypto, the magic bound as additional data. Readers recognise a
|
|
26
|
+
// sealed file by its first bytes, never by its name.
|
|
27
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
+
exports.AtRestKeyError = exports.AT_REST_KEY_NAME = void 0;
|
|
29
|
+
exports.atRestEnabled = atRestEnabled;
|
|
30
|
+
exports.generateAtRestKey = generateAtRestKey;
|
|
31
|
+
exports._resetAtRestKeyCache = _resetAtRestKeyCache;
|
|
32
|
+
exports.loadAtRestKey = loadAtRestKey;
|
|
33
|
+
exports.isSealed = isSealed;
|
|
34
|
+
exports.seal = seal;
|
|
35
|
+
exports.unseal = unseal;
|
|
36
|
+
exports.maybeSeal = maybeSeal;
|
|
37
|
+
const node_crypto_1 = require("node:crypto");
|
|
38
|
+
const keychain_js_1 = require("./keychain.js");
|
|
39
|
+
exports.AT_REST_KEY_NAME = 'CRBRO_AT_REST_KEY';
|
|
40
|
+
const MAGIC = Buffer.from('CRBRENC1', 'ascii');
|
|
41
|
+
const IV_LEN = 12;
|
|
42
|
+
const TAG_LEN = 16;
|
|
43
|
+
class AtRestKeyError extends Error {
|
|
44
|
+
constructor(message) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = 'AtRestKeyError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
exports.AtRestKeyError = AtRestKeyError;
|
|
50
|
+
/** The switch. Only an explicit "1"/"true"/"on" turns it on. */
|
|
51
|
+
function atRestEnabled(env = process.env) {
|
|
52
|
+
return /^(1|true|on|yes)$/i.test((env['CRBRO_ENCRYPT_AT_REST'] || '').trim());
|
|
53
|
+
}
|
|
54
|
+
/** A fresh key, base64. For the person setting it up; CRBRO never stores one by itself. */
|
|
55
|
+
function generateAtRestKey() {
|
|
56
|
+
return (0, node_crypto_1.randomBytes)(32).toString('base64');
|
|
57
|
+
}
|
|
58
|
+
let cache = null;
|
|
59
|
+
const CACHE_MS = 60_000;
|
|
60
|
+
/** Tests switch keys and keychain folders between cases. */
|
|
61
|
+
function _resetAtRestKeyCache() {
|
|
62
|
+
cache = null;
|
|
63
|
+
}
|
|
64
|
+
/** The key from the keychain, or a loud error saying how to create it. */
|
|
65
|
+
function loadAtRestKey() {
|
|
66
|
+
if (cache && Date.now() - cache.at < CACHE_MS)
|
|
67
|
+
return cache.key;
|
|
68
|
+
let raw;
|
|
69
|
+
try {
|
|
70
|
+
raw = (0, keychain_js_1.getSecret)(exports.AT_REST_KEY_NAME);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
throw new AtRestKeyError(`Encryption at rest is on (CRBRO_ENCRYPT_AT_REST) but the keychain could not be read: ${err.message}. ` +
|
|
74
|
+
'Nothing was written in clear and nothing was deleted.');
|
|
75
|
+
}
|
|
76
|
+
if (!raw || !raw.trim()) {
|
|
77
|
+
throw new AtRestKeyError(`Encryption at rest is on (CRBRO_ENCRYPT_AT_REST) but there is no ${exports.AT_REST_KEY_NAME} in the keychain. ` +
|
|
78
|
+
'Nothing was written in clear and nothing was deleted. Create the key once with: ' +
|
|
79
|
+
`node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" | npx crbro-memory secret set ${exports.AT_REST_KEY_NAME}`);
|
|
80
|
+
}
|
|
81
|
+
const key = Buffer.from(raw.trim(), 'base64');
|
|
82
|
+
if (key.length !== 32) {
|
|
83
|
+
throw new AtRestKeyError(`${exports.AT_REST_KEY_NAME} must be 32 bytes in base64 (got ${key.length} bytes). Nothing was written.`);
|
|
84
|
+
}
|
|
85
|
+
cache = { key, at: Date.now() };
|
|
86
|
+
return key;
|
|
87
|
+
}
|
|
88
|
+
function isSealed(data) {
|
|
89
|
+
return data.length >= MAGIC.length + IV_LEN + TAG_LEN && data.subarray(0, MAGIC.length).equals(MAGIC);
|
|
90
|
+
}
|
|
91
|
+
function seal(plain, key = loadAtRestKey()) {
|
|
92
|
+
const iv = (0, node_crypto_1.randomBytes)(IV_LEN);
|
|
93
|
+
const cipher = (0, node_crypto_1.createCipheriv)('aes-256-gcm', key, iv);
|
|
94
|
+
cipher.setAAD(MAGIC);
|
|
95
|
+
const body = Buffer.concat([cipher.update(plain), cipher.final()]);
|
|
96
|
+
return Buffer.concat([MAGIC, iv, cipher.getAuthTag(), body]);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Clear data comes back as it is; sealed data is opened with the keychain key
|
|
100
|
+
* whatever the switch says (turning encryption off must not orphan old copies).
|
|
101
|
+
*/
|
|
102
|
+
function unseal(data, key) {
|
|
103
|
+
if (!isSealed(data))
|
|
104
|
+
return data;
|
|
105
|
+
const k = key ?? (() => {
|
|
106
|
+
try {
|
|
107
|
+
return loadAtRestKey();
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
throw new AtRestKeyError(`This file is encrypted at rest and the key is not available: ${err.message}`);
|
|
111
|
+
}
|
|
112
|
+
})();
|
|
113
|
+
const iv = data.subarray(MAGIC.length, MAGIC.length + IV_LEN);
|
|
114
|
+
const tag = data.subarray(MAGIC.length + IV_LEN, MAGIC.length + IV_LEN + TAG_LEN);
|
|
115
|
+
const body = data.subarray(MAGIC.length + IV_LEN + TAG_LEN);
|
|
116
|
+
const decipher = (0, node_crypto_1.createDecipheriv)('aes-256-gcm', k, iv);
|
|
117
|
+
decipher.setAAD(MAGIC);
|
|
118
|
+
decipher.setAuthTag(tag);
|
|
119
|
+
try {
|
|
120
|
+
return Buffer.concat([decipher.update(body), decipher.final()]);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
throw new AtRestKeyError('Encrypted file could not be opened: wrong key or damaged file.');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Seal when the switch is on, else return the bytes untouched. Throws (never writes clear) if on and keyless. */
|
|
127
|
+
function maybeSeal(plain, env = process.env) {
|
|
128
|
+
if (!atRestEnabled(env))
|
|
129
|
+
return { data: plain, sealed: false };
|
|
130
|
+
return { data: seal(plain), sealed: true };
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=atrest.js.map
|
package/dist/engine/backup.js
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
// ─── Brain backup ────────────────────────────────────────────────
|
|
3
3
|
// One gzipped JSON file per backup, with rotation.
|
|
4
4
|
//
|
|
5
|
+
// 2.11: --keep is validated (a whole number ≥ 1), each new copy is read back
|
|
6
|
+
// before anything is rotated, the copy just written is never rotated, a
|
|
7
|
+
// future-dated copy no longer counts as the newest, retention is tiered
|
|
8
|
+
// (newest N + one per day for N days + per ISO week for 4 weeks + per month
|
|
9
|
+
// for 6 months), and with CRBRO_ENCRYPT_AT_REST=1 copies are sealed
|
|
10
|
+
// (`.json.gz.enc`, see atrest.ts).
|
|
11
|
+
//
|
|
5
12
|
// Why a single file and not a copied directory: it is written with
|
|
6
13
|
// tmp + rename, so a backup either exists whole or does not exist; a copied
|
|
7
14
|
// tree interrupted half-way looks like a backup and is not one. It is also
|
|
@@ -25,9 +32,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
25
32
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
26
33
|
};
|
|
27
34
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
-
exports.AUTO_BACKUP_INTERVAL_MS = exports.DEFAULT_KEEP = exports.BACKUP_FORMAT = void 0;
|
|
35
|
+
exports.DEFAULT_RETENTION = exports.AUTO_BACKUP_INTERVAL_MS = exports.DEFAULT_KEEP = exports.BACKUP_FORMAT = void 0;
|
|
36
|
+
exports.validateKeep = validateKeep;
|
|
29
37
|
exports.resolveBackupDir = resolveBackupDir;
|
|
30
38
|
exports.listBackups = listBackups;
|
|
39
|
+
exports.retainedBackups = retainedBackups;
|
|
31
40
|
exports.createBackup = createBackup;
|
|
32
41
|
exports.readBackup = readBackup;
|
|
33
42
|
exports.restoreBackup = restoreBackup;
|
|
@@ -37,7 +46,10 @@ const path_1 = __importDefault(require("path"));
|
|
|
37
46
|
const os_1 = __importDefault(require("os"));
|
|
38
47
|
const zlib_1 = __importDefault(require("zlib"));
|
|
39
48
|
const util_1 = require("util");
|
|
49
|
+
const fs_js_1 = require("../utils/fs.js");
|
|
40
50
|
const gzip = (0, util_1.promisify)(zlib_1.default.gzip);
|
|
51
|
+
/** Loaded on first use: doctor and listing never need the keychain. */
|
|
52
|
+
const atRest = () => import('./atrest.js');
|
|
41
53
|
const gunzip = (0, util_1.promisify)(zlib_1.default.gunzip);
|
|
42
54
|
exports.BACKUP_FORMAT = 1;
|
|
43
55
|
exports.DEFAULT_KEEP = 7;
|
|
@@ -51,6 +63,17 @@ const SKIP_TOP = new Set(['.quarantine', '.search', '.semantic', '.device-token'
|
|
|
51
63
|
const SKIP_ANYWHERE = new Set(['.git', 'node_modules']);
|
|
52
64
|
const PREFIX = 'brain-';
|
|
53
65
|
const SUFFIX = '.json.gz';
|
|
66
|
+
/** A copy sealed with the at-rest key (CRBRO_ENCRYPT_AT_REST=1). */
|
|
67
|
+
const SUFFIX_ENC = '.json.gz.enc';
|
|
68
|
+
/** A copy whose name is further ahead of the clock than this is future-dated. */
|
|
69
|
+
const FUTURE_TOLERANCE_MS = 24 * 60 * 60 * 1000;
|
|
70
|
+
exports.DEFAULT_RETENTION = { keep: exports.DEFAULT_KEEP, daily: 7, weekly: 4, monthly: 6 };
|
|
71
|
+
/** An integer ≥ 1, or an error that says so. `backup --keep siete` used to mean "delete every copy". */
|
|
72
|
+
function validateKeep(keep) {
|
|
73
|
+
if (typeof keep === 'number' && Number.isInteger(keep) && keep >= 1)
|
|
74
|
+
return keep;
|
|
75
|
+
throw new Error(`--keep must be a whole number of at least 1 (got ${JSON.stringify(keep)}). Nothing was written or rotated.`);
|
|
76
|
+
}
|
|
54
77
|
/**
|
|
55
78
|
* Where backups go: a sibling of the brain they belong to — `~/.crbro` →
|
|
56
79
|
* `~/.crbro-backups/brain`.
|
|
@@ -104,7 +127,10 @@ async function walk(root, rel, out) {
|
|
|
104
127
|
}
|
|
105
128
|
}
|
|
106
129
|
}
|
|
107
|
-
|
|
130
|
+
function isBackupName(n) {
|
|
131
|
+
return n.startsWith(PREFIX) && (n.endsWith(SUFFIX) || n.endsWith(SUFFIX_ENC));
|
|
132
|
+
}
|
|
133
|
+
async function listBackups(dir, now = new Date()) {
|
|
108
134
|
let names;
|
|
109
135
|
try {
|
|
110
136
|
names = await fs_1.promises.readdir(dir);
|
|
@@ -113,23 +139,69 @@ async function listBackups(dir) {
|
|
|
113
139
|
return [];
|
|
114
140
|
}
|
|
115
141
|
const found = [];
|
|
142
|
+
const limite = now.getTime() + FUTURE_TOLERANCE_MS;
|
|
116
143
|
for (const n of names) {
|
|
117
|
-
if (!
|
|
144
|
+
if (!isBackupName(n))
|
|
118
145
|
continue;
|
|
119
146
|
try {
|
|
120
147
|
const st = await fs_1.promises.stat(path_1.default.join(dir, n));
|
|
121
|
-
const m = n.slice(PREFIX.length
|
|
122
|
-
|
|
123
|
-
|
|
148
|
+
const m = n.slice(PREFIX.length).match(/^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})/);
|
|
149
|
+
let created = m ? `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}.000Z` : st.mtime.toISOString();
|
|
150
|
+
// A clock set ahead once must not make that copy "the newest" for
|
|
151
|
+
// months: it blocked every automatic copy and got the good ones rotated
|
|
152
|
+
// out first. Its name is not evidence; the file's own date is.
|
|
153
|
+
let future = false;
|
|
154
|
+
if (!Number.isFinite(Date.parse(created)) || Date.parse(created) > limite) {
|
|
155
|
+
future = true;
|
|
156
|
+
// A file date ahead too: it sorts as the oldest copy, never the newest.
|
|
157
|
+
created = new Date(st.mtimeMs <= limite ? st.mtimeMs : 0).toISOString();
|
|
158
|
+
}
|
|
159
|
+
found.push({
|
|
160
|
+
file: n, path: path_1.default.join(dir, n), created, size_bytes: st.size,
|
|
161
|
+
...(n.endsWith(SUFFIX_ENC) ? { encrypted: true } : {}),
|
|
162
|
+
...(future ? { future_dated: true } : {}),
|
|
163
|
+
});
|
|
124
164
|
}
|
|
125
165
|
catch { /* gone */ }
|
|
126
166
|
}
|
|
127
|
-
return found.sort((a, b) => (a.file < b.file ? 1 : -1)); // newest first
|
|
167
|
+
return found.sort((a, b) => (a.created < b.created ? 1 : a.created > b.created ? -1 : a.file < b.file ? 1 : -1)); // newest first
|
|
168
|
+
}
|
|
169
|
+
const isoWeek = (d) => {
|
|
170
|
+
const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
171
|
+
const dia = t.getUTCDay() || 7;
|
|
172
|
+
t.setUTCDate(t.getUTCDate() + 4 - dia);
|
|
173
|
+
const inicio = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));
|
|
174
|
+
const semana = Math.ceil(((t.getTime() - inicio.getTime()) / 86_400_000 + 1) / 7);
|
|
175
|
+
return `${t.getUTCFullYear()}-W${String(semana).padStart(2, '0')}`;
|
|
176
|
+
};
|
|
177
|
+
/** Which copies a retention policy keeps, by file name. Pure. `all` is newest first. */
|
|
178
|
+
function retainedBackups(all, policy = exports.DEFAULT_RETENTION) {
|
|
179
|
+
const keep = new Set();
|
|
180
|
+
for (const b of all.slice(0, policy.keep))
|
|
181
|
+
keep.add(b.file);
|
|
182
|
+
const tier = (key, n) => {
|
|
183
|
+
const vistos = new Set();
|
|
184
|
+
for (const b of all) {
|
|
185
|
+
const k = key(new Date(b.created));
|
|
186
|
+
if (vistos.has(k))
|
|
187
|
+
continue;
|
|
188
|
+
if (vistos.size >= n)
|
|
189
|
+
break;
|
|
190
|
+
vistos.add(k);
|
|
191
|
+
keep.add(b.file); // newest of that bucket: `all` is newest first
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
tier(d => d.toISOString().slice(0, 10), policy.daily);
|
|
195
|
+
tier(isoWeek, policy.weekly);
|
|
196
|
+
tier(d => d.toISOString().slice(0, 7), policy.monthly);
|
|
197
|
+
return keep;
|
|
128
198
|
}
|
|
129
199
|
async function createBackup(paths, opts = {}) {
|
|
130
200
|
const dir = opts.dir || resolveBackupDir(paths.root);
|
|
131
|
-
const keep =
|
|
201
|
+
const keep = opts.keep === undefined ? exports.DEFAULT_KEEP : validateKeep(opts.keep);
|
|
202
|
+
const retention = { ...exports.DEFAULT_RETENTION, daily: keep, ...opts.retention, keep };
|
|
132
203
|
const when = opts.now || new Date();
|
|
204
|
+
const env = opts.env || process.env;
|
|
133
205
|
const files = {};
|
|
134
206
|
await walk(paths.root, '', files);
|
|
135
207
|
const keys = Object.keys(files);
|
|
@@ -151,28 +223,74 @@ async function createBackup(paths, opts = {}) {
|
|
|
151
223
|
// An empty bundle would rotate a real backup out. Refuse instead.
|
|
152
224
|
throw new Error(`Nothing to back up at ${paths.root}: no neurons and no sessions found.`);
|
|
153
225
|
}
|
|
226
|
+
// A neuron that does not parse is copied as it is — its bytes may be all
|
|
227
|
+
// that is left of it — and named, so nobody restores it believing it fine.
|
|
228
|
+
const unreadable = [];
|
|
229
|
+
for (const k of keys) {
|
|
230
|
+
if (!k.startsWith('cortex/') || !k.endsWith('.json'))
|
|
231
|
+
continue;
|
|
232
|
+
try {
|
|
233
|
+
JSON.parse((0, fs_js_1.stripBOM)(files[k]));
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
unreadable.push(k);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const gz = await gzip(Buffer.from(JSON.stringify(bundle), 'utf8'), { level: 6 });
|
|
240
|
+
// Sealed when encryption at rest is on; throws before writing anything when
|
|
241
|
+
// it is on and the key is missing — never a clear copy instead.
|
|
242
|
+
const { maybeSeal } = await atRest();
|
|
243
|
+
const { data: packed, sealed } = maybeSeal(gz, env);
|
|
154
244
|
await fs_1.promises.mkdir(dir, { recursive: true });
|
|
155
|
-
const name = `${PREFIX}${stamp(when)}${SUFFIX}`;
|
|
245
|
+
const name = `${PREFIX}${stamp(when)}${sealed ? SUFFIX_ENC : SUFFIX}`;
|
|
156
246
|
const target = path_1.default.join(dir, name);
|
|
157
247
|
const tmp = `${target}.tmp-${process.pid}`;
|
|
158
|
-
const
|
|
159
|
-
|
|
248
|
+
const fh = await fs_1.promises.open(tmp, 'w');
|
|
249
|
+
try {
|
|
250
|
+
await fh.writeFile(packed);
|
|
251
|
+
await fh.sync();
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
await fh.close();
|
|
255
|
+
}
|
|
160
256
|
await fs_1.promises.rename(tmp, target);
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
257
|
+
// Read it back before trusting it. A copy cut short by a crash or a full
|
|
258
|
+
// disk used to be followed straight away by the rotation of the oldest GOOD
|
|
259
|
+
// one. A copy that does not read back is removed (it would otherwise count
|
|
260
|
+
// as the newest) and nothing is rotated.
|
|
261
|
+
try {
|
|
262
|
+
const back = await readBackup(target);
|
|
263
|
+
if (Object.keys(back.files).length !== bundle.counts.files) {
|
|
264
|
+
throw new Error(`holds ${Object.keys(back.files).length} files, expected ${bundle.counts.files}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
await fs_1.promises.unlink(target).catch(() => { });
|
|
269
|
+
throw new Error(`The new backup did not read back (${err.message}); it was removed and nothing was rotated.`);
|
|
270
|
+
}
|
|
271
|
+
// Rotation only ever removes files this module named, never the one just
|
|
272
|
+
// written, and only after that one has been read back.
|
|
273
|
+
const all = await listBackups(dir, when);
|
|
274
|
+
const guardar = retainedBackups(all, retention);
|
|
275
|
+
guardar.add(name);
|
|
164
276
|
const rotated = [];
|
|
165
|
-
for (const old of all
|
|
277
|
+
for (const old of all) {
|
|
278
|
+
if (guardar.has(old.file))
|
|
279
|
+
continue;
|
|
166
280
|
try {
|
|
167
281
|
await fs_1.promises.unlink(old.path);
|
|
168
282
|
rotated.push(old.file);
|
|
169
283
|
}
|
|
170
284
|
catch { /* keep going */ }
|
|
171
285
|
}
|
|
172
|
-
return {
|
|
286
|
+
return {
|
|
287
|
+
file: name, path: target, created: bundle.created, size_bytes: packed.length, counts: bundle.counts, rotated_out: rotated, dir,
|
|
288
|
+
verified: true, unreadable, retention, ...(sealed ? { encrypted: true } : {}),
|
|
289
|
+
};
|
|
173
290
|
}
|
|
174
291
|
async function readBackup(file) {
|
|
175
|
-
const
|
|
292
|
+
const bytes = await fs_1.promises.readFile(file);
|
|
293
|
+
const raw = (await atRest()).unseal(bytes);
|
|
176
294
|
const bundle = JSON.parse((await gunzip(raw)).toString('utf8'));
|
|
177
295
|
if (!bundle || typeof bundle !== 'object' || !bundle.files || typeof bundle.files !== 'object') {
|
|
178
296
|
throw new Error('Not a CRBRO backup: no files section.');
|
|
@@ -218,11 +336,13 @@ async function autoBackupIfDue(paths, opts = {}) {
|
|
|
218
336
|
try {
|
|
219
337
|
const dir = opts.dir || resolveBackupDir(paths.root, env);
|
|
220
338
|
const now = opts.now || new Date();
|
|
221
|
-
|
|
339
|
+
// A future-dated copy is listed by its file date, so it cannot hold
|
|
340
|
+
// automatic copies off until the clock catches up with its name.
|
|
341
|
+
const newest = (await listBackups(dir, now))[0];
|
|
222
342
|
if (newest && now.getTime() - Date.parse(newest.created) < exports.AUTO_BACKUP_INTERVAL_MS) {
|
|
223
343
|
return { made: false, reason: 'recent backup exists', file: newest.file };
|
|
224
344
|
}
|
|
225
|
-
const r = await createBackup(paths, { dir, now });
|
|
345
|
+
const r = await createBackup(paths, { dir, now, env });
|
|
226
346
|
return { made: true, reason: newest ? 'older than 24h' : 'first backup', file: r.file };
|
|
227
347
|
}
|
|
228
348
|
catch (err) {
|
package/dist/engine/brain.js
CHANGED
|
@@ -66,6 +66,11 @@ const MANIFEST_FILE = 'manifest.json';
|
|
|
66
66
|
// crbro_inspect view=status.
|
|
67
67
|
const CRBRO_VERSION = '1.0.0';
|
|
68
68
|
// ─── Paths ───────────────────────────────────────────────────────
|
|
69
|
+
function fileId(id) {
|
|
70
|
+
if (typeof id !== 'string' || !id || /[\\/:\0]/.test(id))
|
|
71
|
+
throw new Error(`Invalid id: ${JSON.stringify(String(id).slice(0, 80))}`);
|
|
72
|
+
return id;
|
|
73
|
+
}
|
|
69
74
|
class BrainPaths {
|
|
70
75
|
root;
|
|
71
76
|
cortex;
|
|
@@ -94,11 +99,13 @@ class BrainPaths {
|
|
|
94
99
|
manifest() {
|
|
95
100
|
return node_path_1.default.join(this.root, MANIFEST_FILE);
|
|
96
101
|
}
|
|
102
|
+
// An id is a file name, never a path: "../../x" from a tool argument (and,
|
|
103
|
+
// with serve --http, from the network) must not read or write outside the brain.
|
|
97
104
|
neuron(id) {
|
|
98
|
-
return node_path_1.default.join(this.cortex, `${id}.json`);
|
|
105
|
+
return node_path_1.default.join(this.cortex, `${fileId(id)}.json`);
|
|
99
106
|
}
|
|
100
107
|
synapse(id) {
|
|
101
|
-
return node_path_1.default.join(this.synapses, `${id}.json`);
|
|
108
|
+
return node_path_1.default.join(this.synapses, `${fileId(id)}.json`);
|
|
102
109
|
}
|
|
103
110
|
session(id) {
|
|
104
111
|
return node_path_1.default.join(this.hippocampus, `${id}.json`);
|
|
@@ -324,19 +331,28 @@ class Brain {
|
|
|
324
331
|
for (const file of files) {
|
|
325
332
|
if (!file.startsWith('protocol_') || !file.endsWith('.json'))
|
|
326
333
|
continue;
|
|
327
|
-
|
|
334
|
+
// One unreadable protocol file costs that protocol only (2.11): it
|
|
335
|
+
// used to end the loop, and every protocol after it left the boot.
|
|
336
|
+
let neuron = null;
|
|
337
|
+
try {
|
|
338
|
+
neuron = await (0, fs_js_1.readJSON)(node_path_1.default.join(cortexDir, file));
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
328
343
|
if (!neuron)
|
|
329
344
|
continue;
|
|
345
|
+
const tags = Array.isArray(neuron.tags) ? neuron.tags : [];
|
|
330
346
|
// Extract priority from tags (e.g., "priority:10")
|
|
331
|
-
const priorityTag =
|
|
347
|
+
const priorityTag = tags.find(t => t.startsWith('priority:'));
|
|
332
348
|
const priority = priorityTag ? parseInt(priorityTag.split(':')[1]) : 5;
|
|
333
349
|
// Extract source from tags (e.g., "source:zero-deck")
|
|
334
|
-
const sourceTag =
|
|
350
|
+
const sourceTag = tags.find(t => t.startsWith('source:'));
|
|
335
351
|
const source = sourceTag ? sourceTag.split(':')[1] : 'manual';
|
|
336
352
|
// Combine the ACTIVE facts into a single instruction block. Without
|
|
337
353
|
// the filter, correcting a protocol via supersedes injected both the
|
|
338
354
|
// old and the new wording into every session, side by side.
|
|
339
|
-
const instructions = neuron.facts
|
|
355
|
+
const instructions = (neuron.facts || [])
|
|
340
356
|
.filter(f => !f.status || f.status === 'active')
|
|
341
357
|
.map(f => f.text)
|
|
342
358
|
.join('\n\n');
|