@mindrian_os/install 1.13.0-beta.19 → 1.13.0-beta.21

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.
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * Phase 127-02 Task 2 (TDD RED -> GREEN) -- Class M Brain smoke probe tests.
6
+ *
7
+ * Covers BRAIN-MCP-127-08 acceptance (CONTEXT Deliverable 4):
8
+ * Test 1: LAYERS constant -- exactly 5 entries with stable ids
9
+ * Test 2: checkBrainSmoke() returns { ok, layers: [5 x {id,name,ok,reason,ms}], overall_ms }
10
+ * Test 3: L1 broken topology -> L2-L5 SKIPPED with reason "skipped-prior-layer-failed"
11
+ * Test 4: L1 OK + L2 no-key -> L3-L5 SKIPPED
12
+ * Test 5: L1 + L2 OK + L3 unreachable -> L4-L5 SKIPPED
13
+ * Test 6: L1 + L2 + L3 OK + L4 timeout -> L5 SKIPPED
14
+ * Test 7: All 5 layers pass when all mocks succeed; overall_ms < 30000
15
+ * Test 8: Each layer.ms >= 0 (sanity); ms fields are numbers
16
+ * Test 9: fixBrainSmoke is a no-op (diagnostic-only invariant)
17
+ * Test 10: opts injection seams (mockResolveRoot/mockResolveKey/mockSchema/mockSpawn)
18
+ *
19
+ * Hermetic via the opts injection seams -- NO real network IO, NO real spawn.
20
+ * The shell harness in tests/test-127-02-doctor-class-m.sh exercises real spawn
21
+ * against the actual shim binary.
22
+ *
23
+ * Canon parts:
24
+ * - Part 7 (reuse): LAYERS L1/L2/L3 import the existing resolver chokepoints
25
+ * (active-plugin-root, resolve-brain-key, brain-client.schema)
26
+ * - Part 8 (graph boundary): the smoke probe queries brain_schema only (a
27
+ * generic methodology handle); zero user content
28
+ * egress in the smoke surface.
29
+ *
30
+ * HARD RULE: no em-dashes.
31
+ */
32
+
33
+ const assert = require('node:assert/strict');
34
+ const fs = require('node:fs');
35
+ const path = require('node:path');
36
+
37
+ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
38
+ const SMOKE_PATH = path.join(REPO_ROOT, 'lib', 'core', 'doctor', 'class-m-brain-smoke.cjs');
39
+
40
+ let passed = 0;
41
+ let failed = 0;
42
+
43
+ function ok(name) {
44
+ passed += 1;
45
+ process.stdout.write(' ok ' + name + '\n');
46
+ }
47
+
48
+ function fail(name, err) {
49
+ failed += 1;
50
+ process.stdout.write(' FAIL ' + name + '\n');
51
+ if (err) process.stdout.write(' ' + (err.message || String(err)) + '\n');
52
+ }
53
+
54
+ // Helper: clean require cache so each test gets a fresh module instance with
55
+ // fresh closure state for the opts seams.
56
+ function freshLoad() {
57
+ delete require.cache[SMOKE_PATH];
58
+ return require(SMOKE_PATH);
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Test 1: LAYERS constant shape.
63
+ // ---------------------------------------------------------------------------
64
+ (async function test1_layers_constant() {
65
+ const label = 'LAYERS constant: 5 frozen entries with stable ids';
66
+ try {
67
+ const mod = freshLoad();
68
+ assert.ok(Array.isArray(mod.LAYERS), 'LAYERS must be an array');
69
+ assert.equal(mod.LAYERS.length, 5, 'LAYERS must have exactly 5 entries');
70
+ const ids = mod.LAYERS.map(l => l.id);
71
+ assert.deepEqual(ids, [
72
+ 'plugin_root',
73
+ 'key_resolver',
74
+ 'https_schema',
75
+ 'stdio_handshake',
76
+ 'e2e_brain_schema',
77
+ ], 'LAYERS ids must match the canonical 5-layer probe order');
78
+ for (const l of mod.LAYERS) {
79
+ assert.equal(typeof l.id, 'string', 'each layer has string id');
80
+ assert.equal(typeof l.name, 'string', 'each layer has string name');
81
+ }
82
+ ok(label);
83
+ } catch (e) { fail(label, e); }
84
+ })();
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Test 2: checkBrainSmoke() return shape.
88
+ // ---------------------------------------------------------------------------
89
+ (async function test2_return_shape() {
90
+ const label = 'checkBrainSmoke() returns { ok, layers:[5], overall_ms }';
91
+ try {
92
+ const mod = freshLoad();
93
+ const result = await mod.checkBrainSmoke({
94
+ // All-pass mock chain for shape assertion
95
+ mockResolveRoot: () => ({ root: '/tmp/fake-root', source: 'env', topology: 'dev-clone' }),
96
+ mockResolveKey: () => ({ key: 'k', source: 'env', available: true, reason: null }),
97
+ mockSchema: async () => ({ labels: ['Framework'], rel_types: [] }),
98
+ mockSpawn: async (_shimPath, opts) => ({ ok: true, reason: 'mocked ' + opts.intent }),
99
+ });
100
+ assert.equal(typeof result.ok, 'boolean', 'ok must be boolean');
101
+ assert.ok(Array.isArray(result.layers), 'layers must be an array');
102
+ assert.equal(result.layers.length, 5, 'layers must have 5 entries');
103
+ assert.equal(typeof result.overall_ms, 'number', 'overall_ms must be number');
104
+ assert.ok(result.overall_ms >= 0, 'overall_ms must be >= 0');
105
+ for (const l of result.layers) {
106
+ assert.equal(typeof l.id, 'string', 'layer.id is string');
107
+ assert.equal(typeof l.name, 'string', 'layer.name is string');
108
+ assert.equal(typeof l.ok, 'boolean', 'layer.ok is boolean');
109
+ assert.equal(typeof l.reason, 'string', 'layer.reason is string');
110
+ assert.equal(typeof l.ms, 'number', 'layer.ms is number');
111
+ }
112
+ ok(label);
113
+ } catch (e) { fail(label, e); }
114
+ })();
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Test 3: L1 broken -> L2..L5 skipped.
118
+ // ---------------------------------------------------------------------------
119
+ (async function test3_l1_broken_cascade() {
120
+ const label = 'L1 broken -> L2-L5 cascade to skipped-prior-layer-failed';
121
+ try {
122
+ const mod = freshLoad();
123
+ const result = await mod.checkBrainSmoke({
124
+ mockResolveRoot: () => ({ root: null, source: 'not-found', topology: 'not-found' }),
125
+ });
126
+ assert.equal(result.ok, false, 'overall must be false');
127
+ assert.equal(result.layers[0].id, 'plugin_root', 'L1 id');
128
+ assert.equal(result.layers[0].ok, false, 'L1 must be false');
129
+ assert.match(result.layers[0].reason, /plugin root not resolved/i, 'L1 reason matches');
130
+ for (let i = 1; i < 5; i++) {
131
+ assert.equal(result.layers[i].ok, false, 'L' + (i + 1) + ' must be false (skipped)');
132
+ assert.equal(result.layers[i].reason, 'skipped-prior-layer-failed',
133
+ 'L' + (i + 1) + ' reason must be skipped-prior-layer-failed');
134
+ }
135
+ ok(label);
136
+ } catch (e) { fail(label, e); }
137
+ })();
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Test 4: L1 OK + L2 no key -> L3-L5 skipped.
141
+ // ---------------------------------------------------------------------------
142
+ (async function test4_l2_no_key_cascade() {
143
+ const label = 'L1 OK + L2 no key -> L3-L5 cascade to skipped';
144
+ try {
145
+ const mod = freshLoad();
146
+ const result = await mod.checkBrainSmoke({
147
+ mockResolveRoot: () => ({ root: '/tmp/fake', source: 'env', topology: 'dev-clone' }),
148
+ mockResolveKey: () => ({ key: null, source: 'not-found', available: false, reason: 'MINDRIAN_BRAIN_KEY not set (env) ...' }),
149
+ });
150
+ assert.equal(result.ok, false, 'overall must be false');
151
+ assert.equal(result.layers[0].ok, true, 'L1 must be true');
152
+ assert.equal(result.layers[1].ok, false, 'L2 must be false');
153
+ assert.match(result.layers[1].reason, /MINDRIAN_BRAIN_KEY not set/, 'L2 reason carries the resolver reason');
154
+ for (let i = 2; i < 5; i++) {
155
+ assert.equal(result.layers[i].ok, false, 'L' + (i + 1) + ' must be false (skipped)');
156
+ assert.equal(result.layers[i].reason, 'skipped-prior-layer-failed',
157
+ 'L' + (i + 1) + ' reason must be skipped-prior-layer-failed');
158
+ }
159
+ ok(label);
160
+ } catch (e) { fail(label, e); }
161
+ })();
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Test 5: L1+L2 OK + L3 unreachable -> L4-L5 skipped.
165
+ // ---------------------------------------------------------------------------
166
+ (async function test5_l3_unreachable_cascade() {
167
+ const label = 'L1+L2 OK + L3 returns null -> L4-L5 cascade to skipped';
168
+ try {
169
+ const mod = freshLoad();
170
+ const result = await mod.checkBrainSmoke({
171
+ mockResolveRoot: () => ({ root: '/tmp/fake', source: 'env', topology: 'dev-clone' }),
172
+ mockResolveKey: () => ({ key: 'k', source: 'env', available: true, reason: null }),
173
+ mockSchema: async () => null,
174
+ });
175
+ assert.equal(result.ok, false, 'overall must be false');
176
+ assert.equal(result.layers[0].ok, true, 'L1 ok');
177
+ assert.equal(result.layers[1].ok, true, 'L2 ok');
178
+ assert.equal(result.layers[2].ok, false, 'L3 must be false');
179
+ assert.match(result.layers[2].reason, /HTTPS|schema|unreachable/i, 'L3 reason matches');
180
+ for (let i = 3; i < 5; i++) {
181
+ assert.equal(result.layers[i].ok, false, 'L' + (i + 1) + ' must be false (skipped)');
182
+ assert.equal(result.layers[i].reason, 'skipped-prior-layer-failed',
183
+ 'L' + (i + 1) + ' reason must be skipped-prior-layer-failed');
184
+ }
185
+ ok(label);
186
+ } catch (e) { fail(label, e); }
187
+ })();
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Test 6: L1+L2+L3 OK + L4 handshake timeout -> L5 skipped.
191
+ // ---------------------------------------------------------------------------
192
+ (async function test6_l4_handshake_timeout() {
193
+ const label = 'L1+L2+L3 OK + L4 timeout -> L5 cascade to skipped';
194
+ try {
195
+ const mod = freshLoad();
196
+ const result = await mod.checkBrainSmoke({
197
+ mockResolveRoot: () => ({ root: '/tmp/fake', source: 'env', topology: 'dev-clone' }),
198
+ mockResolveKey: () => ({ key: 'k', source: 'env', available: true, reason: null }),
199
+ mockSchema: async () => ({ labels: ['Framework'] }),
200
+ mockSpawn: async (_shimPath, opts) => {
201
+ if (opts.intent === 'handshake') return { ok: false, reason: 'handshake timed out after 10000ms' };
202
+ return { ok: true, reason: 'should-not-be-called' };
203
+ },
204
+ });
205
+ assert.equal(result.ok, false, 'overall must be false');
206
+ assert.equal(result.layers[3].ok, false, 'L4 must be false');
207
+ assert.match(result.layers[3].reason, /handshake|timeout/i, 'L4 reason matches');
208
+ assert.equal(result.layers[4].ok, false, 'L5 must be false (skipped)');
209
+ assert.equal(result.layers[4].reason, 'skipped-prior-layer-failed', 'L5 reason skipped');
210
+ ok(label);
211
+ } catch (e) { fail(label, e); }
212
+ })();
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Test 7: All 5 layers pass; overall_ms < 30000.
216
+ // ---------------------------------------------------------------------------
217
+ (async function test7_all_pass() {
218
+ const label = 'All 5 layers PASS via mocks; overall_ms < 30000 (30s budget)';
219
+ try {
220
+ const mod = freshLoad();
221
+ const result = await mod.checkBrainSmoke({
222
+ mockResolveRoot: () => ({ root: '/tmp/fake', source: 'env', topology: 'dev-clone' }),
223
+ mockResolveKey: () => ({ key: 'k', source: 'env', available: true, reason: null }),
224
+ mockSchema: async () => ({ labels: ['Framework'] }),
225
+ mockSpawn: async (_shimPath, _opts) => ({ ok: true, reason: 'mocked-success' }),
226
+ });
227
+ assert.equal(result.ok, true, 'overall must be true');
228
+ for (const l of result.layers) {
229
+ assert.equal(l.ok, true, 'layer ' + l.id + ' must be true');
230
+ }
231
+ assert.ok(result.overall_ms < 30000, 'overall_ms must be under 30s budget (got ' + result.overall_ms + ')');
232
+ ok(label);
233
+ } catch (e) { fail(label, e); }
234
+ })();
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Test 8: ms-field sanity.
238
+ // ---------------------------------------------------------------------------
239
+ (async function test8_ms_sanity() {
240
+ const label = 'Each layer.ms is non-negative number';
241
+ try {
242
+ const mod = freshLoad();
243
+ const result = await mod.checkBrainSmoke({
244
+ mockResolveRoot: () => ({ root: '/tmp/fake', source: 'env', topology: 'dev-clone' }),
245
+ mockResolveKey: () => ({ key: 'k', source: 'env', available: true, reason: null }),
246
+ mockSchema: async () => ({ labels: ['Framework'] }),
247
+ mockSpawn: async (_shimPath, _opts) => ({ ok: true, reason: 'ok' }),
248
+ });
249
+ for (const l of result.layers) {
250
+ assert.equal(typeof l.ms, 'number', 'layer.ms is number');
251
+ assert.ok(l.ms >= 0, 'layer.ms must be >= 0 (got ' + l.ms + ')');
252
+ }
253
+ ok(label);
254
+ } catch (e) { fail(label, e); }
255
+ })();
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Test 9: fixBrainSmoke is a no-op (diagnostic-only).
259
+ // ---------------------------------------------------------------------------
260
+ (async function test9_fix_is_noop() {
261
+ const label = 'fixBrainSmoke(result) is a no-op (diagnostic-only invariant)';
262
+ try {
263
+ const mod = freshLoad();
264
+ const fakeResult = { ok: false, layers: [], overall_ms: 0 };
265
+ const r = mod.fixBrainSmoke(fakeResult);
266
+ assert.equal(r.fixed, false, 'fixed must be false');
267
+ assert.match(r.reason, /diagnostic-only/, 'reason must indicate diagnostic-only nature');
268
+ ok(label);
269
+ } catch (e) { fail(label, e); }
270
+ })();
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Test 10: opts injection seams + Class M letter consistency.
274
+ // ---------------------------------------------------------------------------
275
+ (async function test10_opts_seams_and_class_m() {
276
+ const label = 'opts injection seams enable hermetic testing + Class M referenced in source';
277
+ try {
278
+ const mod = freshLoad();
279
+ // Verify all four seams individually substitute their layer.
280
+ let mockSchemaCalled = false;
281
+ let mockSpawnCalled = false;
282
+ const result = await mod.checkBrainSmoke({
283
+ mockResolveRoot: () => ({ root: '/tmp/seam', source: 'opts-mock', topology: 'dev-clone' }),
284
+ mockResolveKey: () => ({ key: 'seam-key', source: 'opts-mock', available: true, reason: null }),
285
+ mockSchema: async () => { mockSchemaCalled = true; return { labels: [], rel_types: [] }; },
286
+ mockSpawn: async (_shimPath, _opts) => { mockSpawnCalled = true; return { ok: true, reason: 'seam-mock' }; },
287
+ });
288
+ assert.equal(result.ok, true, 'all seams provided -> overall true');
289
+ assert.equal(mockSchemaCalled, true, 'mockSchema seam invoked for L3');
290
+ assert.equal(mockSpawnCalled, true, 'mockSpawn seam invoked for L4 and L5');
291
+ assert.equal(result.layers[0].reason, 'resolved (source=opts-mock, topology=dev-clone)', 'L1 reason carries source');
292
+ // Source-level class letter check (CRITICAL: plan uses Class M, not K).
293
+ const src = fs.readFileSync(SMOKE_PATH, 'utf8');
294
+ assert.match(src, /Class[- ]?M/, 'source must reference Class M (not Class K -- K is taken by --stale-first-touch)');
295
+ assert.equal(src.indexOf('Class K'), -1, 'source MUST NOT reference Class K (collides with existing --stale-first-touch)');
296
+ ok(label);
297
+ } catch (e) { fail(label, e); }
298
+ })();
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Summary -- IIFEs above are async; collect results after a microtask drain.
302
+ // ---------------------------------------------------------------------------
303
+ setImmediate(function summarize() {
304
+ // Allow any straggling async test to settle. Two passes of setImmediate is
305
+ // enough for the trivial test bodies above.
306
+ setImmediate(function () {
307
+ process.stdout.write('\nPASSED: ' + passed + '\nFAILED: ' + failed + '\n');
308
+ process.exit(failed === 0 ? 0 : 1);
309
+ });
310
+ });
@@ -19,7 +19,7 @@ const { safeReadFile } = require('./index.cjs');
19
19
  * - maxTools: soft limit on exposed tools (for context budget)
20
20
  *
21
21
  * Server keys map to entries in .mcp.json:
22
- * - brain: brain.mindrian.ai (remote, Streamable HTTP)
22
+ * - brain: mindrian-brain.onrender.com (remote, Streamable HTTP)
23
23
  * - mindrian: MindrianOS local MCP server
24
24
  * - research: web research tools
25
25
  * - pinecone: vector search
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * Phase 127-01 Task 1 -- pure helpers for the Phase 127 migration script
5
+ * (scripts/migrate-brain-mcp-from-http-to-stdio.cjs).
6
+ *
7
+ * Addresses BRAIN-MCP-127-06 (SG-2 snapshot path helper) +
8
+ * BRAIN-MCP-127-07 (SG-4 idempotency log + sha256 fingerprint + raw-identifier
9
+ * scrub).
10
+ *
11
+ * Pure module. NO network surface. NO process spawning. NO claude CLI
12
+ * invocations (those live in the orchestration script, Task 2). Canon Part 8
13
+ * delegation property: this file contains zero direct network calls.
14
+ *
15
+ * Source-name-prefixed sha256 fingerprint pattern matches TELEMETRY-121-02:
16
+ * fingerprint = sha256(SOURCE_NAME_PREFIX + JSON.stringify(entry-tuple)).slice(0, 16)
17
+ *
18
+ * The 16-hex-char truncation matches lib/core/brain-client.cjs::_hashKey (64
19
+ * bits of key space, effectively zero collision at this volume).
20
+ *
21
+ * SG-4 raw-identifier guard: appendMigrationLog rejects any record string
22
+ * matching Bearer-token / UUID / long-base64-or-hex (>= 24 chars, alphabet-
23
+ * heterogeneous) regexes. Sha256 fingerprints (exactly 16 or 64 hex chars)
24
+ * are whitelisted by-length-and-alphabet so legitimate migration metadata
25
+ * passes through.
26
+ *
27
+ * HARD RULE: zero em-dashes in this file (hyphens only).
28
+ */
29
+
30
+ const crypto = require('node:crypto');
31
+ const fs = require('node:fs');
32
+ const path = require('node:path');
33
+
34
+ const SOURCE_NAME_PREFIX = 'mindrian-brain:';
35
+
36
+ /**
37
+ * Compute a stable 16-hex-char sha256 fingerprint over a Brain MCP entry.
38
+ * Mirrors brain-client.cjs::_hashKey shape verbatim (sha256 truncated).
39
+ * Source-name prefix prevents cross-source collisions per TELEMETRY-121-02.
40
+ *
41
+ * @param {{command?: string, args?: string[], env?: object, type?: string}} entry
42
+ * @returns {string}
43
+ */
44
+ function fingerprintEntry(entry) {
45
+ const e = entry || {};
46
+ const input = SOURCE_NAME_PREFIX + JSON.stringify({
47
+ command: e.command,
48
+ args: e.args,
49
+ env: e.env,
50
+ type: e.type,
51
+ });
52
+ return crypto.createHash('sha256').update(input).digest('hex').slice(0, 16);
53
+ }
54
+
55
+ /**
56
+ * Compute the pre-migration snapshot path. Replace colons with dashes for
57
+ * Windows / CIFS filesystem safety per Tavily A127.3 cross-platform note.
58
+ *
59
+ * @param {string} homeDir
60
+ * @param {string} isoTimestamp
61
+ * @returns {string}
62
+ */
63
+ function snapshotPath(homeDir, isoTimestamp) {
64
+ const safe = String(isoTimestamp).replace(/:/g, '-');
65
+ return path.join(homeDir, '.mindrian', 'pre-migration-snapshots', safe + '.json');
66
+ }
67
+
68
+ /**
69
+ * Read the migration log (jsonl). Graceful when missing. Parse errors on a
70
+ * single line skip that line silently (project precedent: migrate-telemetry-v1
71
+ * uses the same shape).
72
+ *
73
+ * @param {string} homeDir
74
+ * @returns {Array<object>}
75
+ */
76
+ function readMigrationsLog(homeDir) {
77
+ const logPath = path.join(homeDir, '.mindrian', 'migrations.jsonl');
78
+ if (!fs.existsSync(logPath)) return [];
79
+ let text;
80
+ try { text = fs.readFileSync(logPath, 'utf8'); } catch (_) { return []; }
81
+ const rows = [];
82
+ for (const line of text.split('\n')) {
83
+ if (!line) continue;
84
+ try { rows.push(JSON.parse(line)); } catch (_) { continue; }
85
+ }
86
+ return rows;
87
+ }
88
+
89
+ /**
90
+ * SG-4 raw-identifier guard. Recursively walk a record, scan strings for
91
+ * Bearer / UUID / long-base64-or-hex (>= 24 chars) shapes. Whitelist exact
92
+ * 16-char (truncated) and 64-char (full) sha256 by length-and-alphabet so
93
+ * legitimate fingerprint fields pass through. Throw on any match.
94
+ *
95
+ * @param {object} record
96
+ */
97
+ function _scanForRawIdentifiers(record) {
98
+ const bearerRe = /Bearer\s+[A-Za-z0-9._\-]{16,}/i;
99
+ const uuidRe = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
100
+ const longRe = /[A-Za-z0-9+/=_\-]{24,}/;
101
+ const sha256Full = /^[a-f0-9]{64}$/i;
102
+
103
+ function walk(node) {
104
+ if (node == null) return;
105
+ if (typeof node === 'string') {
106
+ if (bearerRe.test(node)) throw new Error('raw user identifier detected in migration log record (Bearer)');
107
+ if (uuidRe.test(node)) throw new Error('raw user identifier detected in migration log record (UUID)');
108
+ if (longRe.test(node)) {
109
+ if (sha256Full.test(node)) return;
110
+ throw new Error('raw user identifier detected in migration log record (long alphanumeric run)');
111
+ }
112
+ return;
113
+ }
114
+ if (Array.isArray(node)) { for (const v of node) walk(v); return; }
115
+ if (typeof node === 'object') {
116
+ for (const [k, v] of Object.entries(node)) {
117
+ if (k === 'fingerprint' && typeof v === 'string' && /^[a-f0-9]{16}$/.test(v)) continue;
118
+ walk(v);
119
+ }
120
+ }
121
+ }
122
+
123
+ walk(record);
124
+ }
125
+
126
+ /**
127
+ * Append a record to the migration log. Enforces SG-4 raw-identifier scrub
128
+ * BEFORE the write. Creates parent dir with mode 0700 (POSIX). Chmods the
129
+ * log file to mode 0600 after first write per SEC-02 hygiene.
130
+ *
131
+ * @param {string} homeDir
132
+ * @param {object} record
133
+ */
134
+ function appendMigrationLog(homeDir, record) {
135
+ _scanForRawIdentifiers(record);
136
+ const dir = path.join(homeDir, '.mindrian');
137
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
138
+ const logPath = path.join(dir, 'migrations.jsonl');
139
+ const existed = fs.existsSync(logPath);
140
+ fs.appendFileSync(logPath, JSON.stringify(record) + '\n');
141
+ if (process.platform !== 'win32') {
142
+ try { fs.chmodSync(logPath, 0o600); } catch (_) { /* best-effort */ }
143
+ }
144
+ return { written: true, log_path: logPath, first_write: !existed };
145
+ }
146
+
147
+ /**
148
+ * Return true if a prior 'removed' record exists for (sourceName, fingerprint).
149
+ *
150
+ * @param {string} homeDir
151
+ * @param {string} sourceName
152
+ * @param {string} fingerprint
153
+ * @returns {boolean}
154
+ */
155
+ function isAlreadyMigrated(homeDir, sourceName, fingerprint) {
156
+ const rows = readMigrationsLog(homeDir);
157
+ for (const r of rows) {
158
+ if (r && r.source === sourceName && r.fingerprint === fingerprint && r.action === 'removed') {
159
+ return true;
160
+ }
161
+ }
162
+ return false;
163
+ }
164
+
165
+ module.exports = {
166
+ fingerprintEntry,
167
+ snapshotPath,
168
+ readMigrationsLog,
169
+ appendMigrationLog,
170
+ isAlreadyMigrated,
171
+ _scanForRawIdentifiers,
172
+ };
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * Phase 127-01 Task 1 -- RED tests for lib/core/migration-snapshot.cjs
6
+ *
7
+ * 9 tests covering BRAIN-MCP-127-06 (SG-2 snapshot path helper) +
8
+ * BRAIN-MCP-127-07 (SG-4 idempotency log + sha256 fingerprint + raw-identifier
9
+ * scrub).
10
+ *
11
+ * Pure-module suite: zero network surface, zero process spawning, zero
12
+ * external state writes outside hermetic os.tmpdir() fixtures (mkdtempSync +
13
+ * rmSync recursive cleanup per Phase 87 pattern).
14
+ *
15
+ * Tests:
16
+ * 1. fingerprintEntry returns 16 hex chars (sha256 truncated)
17
+ * 2. fingerprintEntry distinguishes inputs by args[0]
18
+ * 3. fingerprintEntry uses source-name "mindrian-brain:" prefix (TELEMETRY-121-02 pattern)
19
+ * 4. snapshotPath replaces colons with dashes (Windows filesystem safety)
20
+ * 5. readMigrationsLog returns [] when log file does not exist
21
+ * 6. appendMigrationLog creates log with mode 0600 (POSIX) + subsequent read returns the record
22
+ * 7. isAlreadyMigrated returns true after matching record appended, false for different fingerprint
23
+ * 8. appendMigrationLog rejects a record containing Bearer-shaped string (SG-4 raw-identifier guard)
24
+ * 9. appendMigrationLog rejects a record containing UUID-shaped value (SG-4 raw-identifier guard)
25
+ */
26
+
27
+ const assert = require('node:assert/strict');
28
+ const fs = require('node:fs');
29
+ const os = require('node:os');
30
+ const path = require('node:path');
31
+ const crypto = require('node:crypto');
32
+
33
+ const snap = require('./migration-snapshot.cjs');
34
+
35
+ function mkHome() {
36
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'mig-snap-'));
37
+ }
38
+
39
+ function rmHome(h) {
40
+ try { fs.rmSync(h, { recursive: true, force: true }); } catch (_) {}
41
+ }
42
+
43
+ let pass = 0;
44
+ let fail = 0;
45
+ function t(name, fn) {
46
+ try {
47
+ fn();
48
+ pass++;
49
+ console.log('PASS: ' + name);
50
+ } catch (e) {
51
+ fail++;
52
+ console.error('FAIL: ' + name);
53
+ console.error(' ' + (e && e.message ? e.message : String(e)));
54
+ if (e && e.stack) console.error(e.stack.split('\n').slice(1, 4).join('\n'));
55
+ }
56
+ }
57
+
58
+ // ---- Test 1: 16-hex-char fingerprint ----
59
+ t('T1 fingerprintEntry returns 16 hex chars', () => {
60
+ const fp = snap.fingerprintEntry({ command: 'node', args: ['x'], env: { K: 'v' }, type: 'http' });
61
+ assert.match(fp, /^[a-f0-9]{16}$/);
62
+ });
63
+
64
+ // ---- Test 2: distinguishes by args[0] + deterministic ----
65
+ t('T2 fingerprintEntry distinguishes by args[0] and is deterministic', () => {
66
+ const a = { command: 'node', args: ['x'], env: {}, type: 'http' };
67
+ const b = { command: 'node', args: ['y'], env: {}, type: 'http' };
68
+ const fpA1 = snap.fingerprintEntry(a);
69
+ const fpA2 = snap.fingerprintEntry(a);
70
+ const fpB = snap.fingerprintEntry(b);
71
+ assert.equal(fpA1, fpA2, 'identical inputs must produce identical outputs');
72
+ assert.notEqual(fpA1, fpB, 'different args[0] must produce different fingerprints');
73
+ });
74
+
75
+ // ---- Test 3: source-name prefix ----
76
+ t('T3 fingerprintEntry uses source-name "mindrian-brain:" prefix per TELEMETRY-121-02', () => {
77
+ const e = { command: 'node', args: ['x'], env: { K: 'v' }, type: 'http' };
78
+ const expectedInput = 'mindrian-brain:' + JSON.stringify({ command: e.command, args: e.args, env: e.env, type: e.type });
79
+ const expectedFp = crypto.createHash('sha256').update(expectedInput).digest('hex').slice(0, 16);
80
+ assert.equal(snap.fingerprintEntry(e), expectedFp);
81
+ });
82
+
83
+ // ---- Test 4: snapshotPath colon replacement ----
84
+ t('T4 snapshotPath replaces colons with dashes for Windows filesystem safety', () => {
85
+ const p = snap.snapshotPath('/tmp/home', '2026-05-19T20:30:00Z');
86
+ assert.equal(p, path.join('/tmp/home', '.mindrian', 'pre-migration-snapshots', '2026-05-19T20-30-00Z.json'));
87
+ });
88
+
89
+ // ---- Test 5: readMigrationsLog graceful on missing ----
90
+ t('T5 readMigrationsLog returns [] when log file does not exist', () => {
91
+ const h = mkHome();
92
+ try {
93
+ const r = snap.readMigrationsLog(h);
94
+ assert.ok(Array.isArray(r));
95
+ assert.equal(r.length, 0);
96
+ } finally {
97
+ rmHome(h);
98
+ }
99
+ });
100
+
101
+ // ---- Test 6: appendMigrationLog creates log with mode 0600 + readback ----
102
+ t('T6 appendMigrationLog creates log with mode 0600 and round-trips via readMigrationsLog', () => {
103
+ const h = mkHome();
104
+ try {
105
+ const rec = { ts: '2026-05-19T00:00:00Z', source: 'mindrian-brain', fingerprint: 'abc1234567890def', action: 'removed' };
106
+ snap.appendMigrationLog(h, rec);
107
+ const logPath = path.join(h, '.mindrian', 'migrations.jsonl');
108
+ assert.ok(fs.existsSync(logPath), 'log file must exist after append');
109
+ if (process.platform !== 'win32') {
110
+ const mode = fs.statSync(logPath).mode & 0o777;
111
+ assert.equal(mode, 0o600, 'log mode must be 0600 on POSIX, got 0' + mode.toString(8));
112
+ }
113
+ const rows = snap.readMigrationsLog(h);
114
+ assert.ok(rows.length >= 1);
115
+ const found = rows.find((r) => r.fingerprint === 'abc1234567890def');
116
+ assert.ok(found, 'appended record must be readable');
117
+ assert.equal(found.action, 'removed');
118
+ } finally {
119
+ rmHome(h);
120
+ }
121
+ });
122
+
123
+ // ---- Test 7: isAlreadyMigrated ----
124
+ t('T7 isAlreadyMigrated returns true after matching record appended, false for different fingerprint', () => {
125
+ const h = mkHome();
126
+ try {
127
+ const fp = 'abc1234567890def';
128
+ snap.appendMigrationLog(h, { ts: '2026-05-19T00:00:00Z', source: 'mindrian-brain', fingerprint: fp, action: 'removed' });
129
+ assert.equal(snap.isAlreadyMigrated(h, 'mindrian-brain', fp), true);
130
+ assert.equal(snap.isAlreadyMigrated(h, 'mindrian-brain', '0000000000000000'), false);
131
+ } finally {
132
+ rmHome(h);
133
+ }
134
+ });
135
+
136
+ // ---- Test 8: SG-4 raw-identifier guard rejects Bearer-shaped string ----
137
+ t('T8 appendMigrationLog rejects record containing Bearer-shaped string (SG-4 invariant)', () => {
138
+ const h = mkHome();
139
+ try {
140
+ const badRec = {
141
+ ts: '2026-05-19T00:00:00Z',
142
+ source: 'mindrian-brain',
143
+ fingerprint: 'abc1234567890def',
144
+ action: 'removed',
145
+ bearer: 'Bearer abcdefghijklmnopqrstuv',
146
+ };
147
+ assert.throws(() => snap.appendMigrationLog(h, badRec), /raw user identifier/);
148
+ } finally {
149
+ rmHome(h);
150
+ }
151
+ });
152
+
153
+ // ---- Test 9: SG-4 raw-identifier guard rejects UUID-shaped value ----
154
+ t('T9 appendMigrationLog rejects record containing UUID-shaped value (SG-4 invariant)', () => {
155
+ const h = mkHome();
156
+ try {
157
+ const badRec = {
158
+ ts: '2026-05-19T00:00:00Z',
159
+ source: 'mindrian-brain',
160
+ fingerprint: 'abc1234567890def',
161
+ action: 'removed',
162
+ key: 'c859eb6d-1234-5678-9abc-def012345678',
163
+ };
164
+ assert.throws(() => snap.appendMigrationLog(h, badRec), /raw user identifier/);
165
+ } finally {
166
+ rmHome(h);
167
+ }
168
+ });
169
+
170
+ console.log('');
171
+ console.log('==== RESULTS ====');
172
+ console.log('PASS: ' + pass);
173
+ console.log('FAIL: ' + fail);
174
+ process.exit(fail === 0 ? 0 : 1);