@link-assistant/hive-mind 2.15.2 ā 2.16.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/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/bot-lifecycle.lib.mjs +8 -1
- package/src/child-exit.lib.mjs +53 -1
- package/src/claude.session-tokens.lib.mjs +10 -8
- package/src/claude.session-transcript-repair.lib.mjs +65 -34
- package/src/codex.lib.mjs +11 -1
- package/src/development-log.lib.mjs +22 -7
- package/src/github-error-reporter.lib.mjs +84 -2
- package/src/github.lib.mjs +212 -152
- package/src/log-bounded-read.lib.mjs +411 -0
- package/src/log-sanitize-stream.lib.mjs +267 -0
- package/src/log-sanitize-worker-entry.mjs +31 -0
- package/src/log-sanitize-worker.lib.mjs +186 -0
- package/src/log-upload.lib.mjs +16 -4
- package/src/session-completion-state.lib.mjs +124 -0
- package/src/session-kill-diagnostics.lib.mjs +73 -10
- package/src/session-kill-policy.lib.mjs +18 -7
- package/src/session-kill-resume.lib.mjs +5 -2
- package/src/session-monitor.lib.mjs +132 -9
- package/src/session-store.lib.mjs +15 -1
- package/src/solve.config.lib.mjs +5 -2
- package/src/solve.resource-diagnostics.lib.mjs +71 -4
- package/src/telegram-log-command.lib.mjs +7 -3
- package/src/telegram-terminal-watch-command.lib.mjs +9 -1
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bounded-memory readers for execution logs (issue #2189).
|
|
5
|
+
*
|
|
6
|
+
* A `/solve` execution log is unbounded by construction: it is the verbatim
|
|
7
|
+
* transcript of an AI tool that may run for a day. The incident that motivated
|
|
8
|
+
* this module produced a **134 MB** log, and every completion-time helper that
|
|
9
|
+
* wanted a single fact out of it did `await fs.readFile(logPath, 'utf8')` ā
|
|
10
|
+
* materialising the whole transcript as one JS string, four separate times per
|
|
11
|
+
* monitor tick, against V8's ~2 GB old-space cap. The run died with
|
|
12
|
+
* `FATAL ERROR: Reached heap limit` while the machine still had 10 GB free.
|
|
13
|
+
*
|
|
14
|
+
* Every reader here is bounded by construction:
|
|
15
|
+
*
|
|
16
|
+
* - {@link readLogHeadText} / {@link readLogTailText} read at most `maxBytes`
|
|
17
|
+
* from one end of the file.
|
|
18
|
+
* - {@link readLogTextBounded} returns the whole file while it is small and
|
|
19
|
+
* otherwise a head + tail excerpt with an explicit truncation marker, so
|
|
20
|
+
* marker-style parsers (`š [DISK]`, kill diagnostics, ā¦) keep working on
|
|
21
|
+
* both ends of the transcript without ever holding the middle.
|
|
22
|
+
* - {@link scanLogChunks} streams the file forward in overlapping chunks and
|
|
23
|
+
* stops at the first chunk that answers the caller's question, so a scan
|
|
24
|
+
* that must cover the *whole* log still holds only one chunk at a time.
|
|
25
|
+
* - {@link forEachLogLine} streams a record-per-line file (JSONL transcripts)
|
|
26
|
+
* one line at a time, so peak residency is one line instead of the file plus
|
|
27
|
+
* the array of its lines.
|
|
28
|
+
*
|
|
29
|
+
* All readers are non-throwing: a missing or unreadable log yields the caller's
|
|
30
|
+
* empty value, exactly like the `readFile`-based code they replace.
|
|
31
|
+
*
|
|
32
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2189
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import fsPromises from 'node:fs/promises';
|
|
36
|
+
import { createReadStream as fsCreateReadStream } from 'node:fs';
|
|
37
|
+
import readline from 'node:readline';
|
|
38
|
+
|
|
39
|
+
/** Default ceiling for "read the log as text" helpers (head + tail combined). */
|
|
40
|
+
export const DEFAULT_BOUNDED_LOG_BYTES = 4 * 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
/** Default size of one forward scan chunk. */
|
|
43
|
+
export const DEFAULT_LOG_CHUNK_BYTES = 1024 * 1024;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Overlap between adjacent scan chunks so a marker split across a chunk
|
|
47
|
+
* boundary is still matched exactly once by the chunk that follows it.
|
|
48
|
+
*/
|
|
49
|
+
export const LOG_CHUNK_OVERLAP_BYTES = 8192;
|
|
50
|
+
|
|
51
|
+
/** Marker inserted between the head and tail excerpts of a truncated read. */
|
|
52
|
+
export const LOG_TRUNCATION_MARKER = '\nā¦[log truncated: middle omitted by bounded reader, see full log file]ā¦\n';
|
|
53
|
+
|
|
54
|
+
const toPositiveInt = (value, fallback) => (Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read a byte range of a file as UTF-8 text using a single file handle.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} fsImpl - fs.promises-compatible implementation
|
|
60
|
+
* @param {string} logPath
|
|
61
|
+
* @param {number} position - Byte offset to start at
|
|
62
|
+
* @param {number} length - Number of bytes to read
|
|
63
|
+
* @returns {Promise<string>}
|
|
64
|
+
*/
|
|
65
|
+
async function readRangeText(fsImpl, logPath, position, length) {
|
|
66
|
+
if (length <= 0) return '';
|
|
67
|
+
const handle = await fsImpl.open(logPath, 'r');
|
|
68
|
+
try {
|
|
69
|
+
const buffer = Buffer.alloc(length);
|
|
70
|
+
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
|
71
|
+
return buffer.subarray(0, bytesRead).toString('utf8');
|
|
72
|
+
} finally {
|
|
73
|
+
await handle.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read at most `maxBytes` from the START of a log file.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} logPath
|
|
81
|
+
* @param {object} [options]
|
|
82
|
+
* @param {object} [options.fsImpl=fsPromises]
|
|
83
|
+
* @param {number} [options.maxBytes=DEFAULT_BOUNDED_LOG_BYTES]
|
|
84
|
+
* @returns {Promise<string>} Text (empty when the log cannot be read)
|
|
85
|
+
*/
|
|
86
|
+
export async function readLogHeadText(logPath, { fsImpl = fsPromises, maxBytes = DEFAULT_BOUNDED_LOG_BYTES } = {}) {
|
|
87
|
+
if (!logPath) return '';
|
|
88
|
+
try {
|
|
89
|
+
const { size } = await fsImpl.stat(logPath);
|
|
90
|
+
return await readRangeText(fsImpl, logPath, 0, Math.min(size, toPositiveInt(maxBytes, DEFAULT_BOUNDED_LOG_BYTES)));
|
|
91
|
+
} catch {
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Read at most `maxBytes` from the END of a log file. The excerpt is aligned to
|
|
98
|
+
* the first newline inside the window so a caller never sees half a line.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} logPath
|
|
101
|
+
* @param {object} [options]
|
|
102
|
+
* @param {object} [options.fsImpl=fsPromises]
|
|
103
|
+
* @param {number} [options.maxBytes=DEFAULT_BOUNDED_LOG_BYTES]
|
|
104
|
+
* @returns {Promise<string>} Text (empty when the log cannot be read)
|
|
105
|
+
*/
|
|
106
|
+
export async function readLogTailText(logPath, { fsImpl = fsPromises, maxBytes = DEFAULT_BOUNDED_LOG_BYTES } = {}) {
|
|
107
|
+
if (!logPath) return '';
|
|
108
|
+
try {
|
|
109
|
+
const { size } = await fsImpl.stat(logPath);
|
|
110
|
+
const limit = Math.min(size, toPositiveInt(maxBytes, DEFAULT_BOUNDED_LOG_BYTES));
|
|
111
|
+
const start = Math.max(0, size - limit);
|
|
112
|
+
const text = await readRangeText(fsImpl, logPath, start, limit);
|
|
113
|
+
if (start === 0) return text;
|
|
114
|
+
const firstNewline = text.indexOf('\n');
|
|
115
|
+
return firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
|
|
116
|
+
} catch {
|
|
117
|
+
return '';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Read a log as text without ever holding more than `maxBytes` of it.
|
|
123
|
+
*
|
|
124
|
+
* Small logs are returned verbatim. Larger logs are returned as
|
|
125
|
+
* `head + LOG_TRUNCATION_MARKER + tail`, each half being `maxBytes / 2`. That
|
|
126
|
+
* shape is deliberate: the facts completion-time reporting needs (start-up
|
|
127
|
+
* banner, `š [DISK] phase=after_clone`, the exit footer, `š [DISK]
|
|
128
|
+
* phase=after_agent`, crash stacks) live at the two ends of a transcript.
|
|
129
|
+
*
|
|
130
|
+
* `readFile` stays injectable so existing unit tests can keep handing these
|
|
131
|
+
* helpers a synthetic reader; it is used whenever `stat` cannot size the file.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} logPath
|
|
134
|
+
* @param {object} [options]
|
|
135
|
+
* @param {object} [options.fsImpl=fsPromises]
|
|
136
|
+
* @param {Function} [options.readFile] - Injectable whole-file reader for small logs/tests
|
|
137
|
+
* @param {number} [options.maxBytes=DEFAULT_BOUNDED_LOG_BYTES]
|
|
138
|
+
* @param {boolean} [options.verbose=false]
|
|
139
|
+
* @returns {Promise<string>} Text (empty when the log cannot be read)
|
|
140
|
+
*/
|
|
141
|
+
export async function readLogTextBounded(logPath, { fsImpl = fsPromises, readFile = null, maxBytes = DEFAULT_BOUNDED_LOG_BYTES, verbose = false } = {}) {
|
|
142
|
+
if (!logPath) return '';
|
|
143
|
+
const limit = toPositiveInt(maxBytes, DEFAULT_BOUNDED_LOG_BYTES);
|
|
144
|
+
const read = readFile || fsImpl.readFile.bind(fsImpl);
|
|
145
|
+
let size = null;
|
|
146
|
+
try {
|
|
147
|
+
size = (await fsImpl.stat(logPath)).size;
|
|
148
|
+
} catch {
|
|
149
|
+
// A caller-injected reader may serve paths that do not exist on disk, so an
|
|
150
|
+
// unstattable path is not an error ā `size` simply stays null and the whole
|
|
151
|
+
// "file" is handed to that reader below.
|
|
152
|
+
}
|
|
153
|
+
if (size === null || size <= limit) {
|
|
154
|
+
try {
|
|
155
|
+
return String(await read(logPath, 'utf8'));
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (verbose) console.log(`[VERBOSE] log-bounded-read: could not read ${logPath}: ${error?.message || error}`);
|
|
158
|
+
return '';
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const half = Math.max(1, Math.floor(limit / 2));
|
|
162
|
+
const head = await readLogHeadText(logPath, { fsImpl, maxBytes: half });
|
|
163
|
+
const tail = await readLogTailText(logPath, { fsImpl, maxBytes: half });
|
|
164
|
+
if (verbose) {
|
|
165
|
+
console.log(`[VERBOSE] log-bounded-read: ${logPath} is ${size} bytes; using ${head.length}+${tail.length} char head/tail excerpt`);
|
|
166
|
+
}
|
|
167
|
+
return `${head}${LOG_TRUNCATION_MARKER}${tail}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Stream a log forward in overlapping chunks, stopping as soon as `onChunk`
|
|
172
|
+
* returns a value that is neither `undefined` nor `null`.
|
|
173
|
+
*
|
|
174
|
+
* Only one chunk (plus the overlap carried from the previous one) is resident
|
|
175
|
+
* at a time, so this covers a log of any size in constant memory.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} logPath
|
|
178
|
+
* @param {Function} onChunk - `(text, {offset, isFirst, isLast}) => any`
|
|
179
|
+
* @param {object} [options]
|
|
180
|
+
* @param {object} [options.fsImpl=fsPromises]
|
|
181
|
+
* @param {number} [options.chunkBytes=DEFAULT_LOG_CHUNK_BYTES]
|
|
182
|
+
* @param {number} [options.overlapBytes=LOG_CHUNK_OVERLAP_BYTES]
|
|
183
|
+
* @param {boolean} [options.verbose=false]
|
|
184
|
+
* @returns {Promise<any|null>} The first non-nullish result, or null
|
|
185
|
+
*/
|
|
186
|
+
export async function scanLogChunks(logPath, onChunk, { fsImpl = fsPromises, chunkBytes = DEFAULT_LOG_CHUNK_BYTES, overlapBytes = LOG_CHUNK_OVERLAP_BYTES, verbose = false } = {}) {
|
|
187
|
+
if (!logPath || typeof onChunk !== 'function') return null;
|
|
188
|
+
const step = toPositiveInt(chunkBytes, DEFAULT_LOG_CHUNK_BYTES);
|
|
189
|
+
const overlap = Math.min(toPositiveInt(overlapBytes, LOG_CHUNK_OVERLAP_BYTES), step - 1);
|
|
190
|
+
let handle = null;
|
|
191
|
+
try {
|
|
192
|
+
const { size } = await fsImpl.stat(logPath);
|
|
193
|
+
if (!size) return null;
|
|
194
|
+
handle = await fsImpl.open(logPath, 'r');
|
|
195
|
+
const buffer = Buffer.alloc(step);
|
|
196
|
+
let position = 0;
|
|
197
|
+
let carry = '';
|
|
198
|
+
let chunks = 0;
|
|
199
|
+
while (position < size) {
|
|
200
|
+
const { bytesRead } = await handle.read(buffer, 0, step, position);
|
|
201
|
+
if (!bytesRead) break;
|
|
202
|
+
const text = carry + buffer.subarray(0, bytesRead).toString('utf8');
|
|
203
|
+
chunks += 1;
|
|
204
|
+
const isLast = position + bytesRead >= size;
|
|
205
|
+
const result = onChunk(text, { offset: position, isFirst: position === 0, isLast });
|
|
206
|
+
if (result !== undefined && result !== null) {
|
|
207
|
+
if (verbose) console.log(`[VERBOSE] log-bounded-read: ${logPath} answered after ${chunks} chunk(s)`);
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
carry = text.slice(Math.max(0, text.length - overlap));
|
|
211
|
+
position += bytesRead;
|
|
212
|
+
}
|
|
213
|
+
if (verbose) console.log(`[VERBOSE] log-bounded-read: scanned all ${chunks} chunk(s) of ${logPath} with no match`);
|
|
214
|
+
return null;
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (verbose) console.log(`[VERBOSE] log-bounded-read: could not scan ${logPath}: ${error?.message || error}`);
|
|
217
|
+
return null;
|
|
218
|
+
} finally {
|
|
219
|
+
if (handle) await handle.close().catch(() => {});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Collect the lines of a log that match `pattern`, never accumulating more than
|
|
225
|
+
* `maxBytes` of them. Used for marker-style parsing (`š [DISK] ā¦`) where the
|
|
226
|
+
* interesting content is a handful of lines scattered through a huge file.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} logPath
|
|
229
|
+
* @param {RegExp} pattern - Tested per line (a global regex is reset per test)
|
|
230
|
+
* @param {object} [options]
|
|
231
|
+
* @param {object} [options.fsImpl=fsPromises]
|
|
232
|
+
* @param {number} [options.maxBytes=65536] - Ceiling on collected text
|
|
233
|
+
* @param {number} [options.chunkBytes=DEFAULT_LOG_CHUNK_BYTES]
|
|
234
|
+
* @param {boolean} [options.verbose=false]
|
|
235
|
+
* @returns {Promise<string>} Matching lines joined by newlines
|
|
236
|
+
*/
|
|
237
|
+
export async function collectLogLinesMatching(logPath, pattern, { fsImpl = fsPromises, maxBytes = 65536, chunkBytes = DEFAULT_LOG_CHUNK_BYTES, verbose = false } = {}) {
|
|
238
|
+
if (!logPath || !(pattern instanceof RegExp)) return '';
|
|
239
|
+
const limit = toPositiveInt(maxBytes, 65536);
|
|
240
|
+
const collected = [];
|
|
241
|
+
let collectedBytes = 0;
|
|
242
|
+
let residual = '';
|
|
243
|
+
await scanLogChunks(
|
|
244
|
+
logPath,
|
|
245
|
+
(text, { isLast }) => {
|
|
246
|
+
// `overlapBytes: 0` below means chunks never repeat content, so the only
|
|
247
|
+
// state to carry forward is the trailing partial line.
|
|
248
|
+
const source = residual ? residual + text : text;
|
|
249
|
+
const lines = source.split('\n');
|
|
250
|
+
residual = isLast ? '' : (lines.pop() ?? '');
|
|
251
|
+
for (const line of lines) {
|
|
252
|
+
pattern.lastIndex = 0;
|
|
253
|
+
if (!pattern.test(line)) continue;
|
|
254
|
+
if (collectedBytes + line.length > limit) continue;
|
|
255
|
+
collected.push(line);
|
|
256
|
+
collectedBytes += line.length + 1;
|
|
257
|
+
}
|
|
258
|
+
return undefined;
|
|
259
|
+
},
|
|
260
|
+
{ fsImpl, chunkBytes, overlapBytes: 0, verbose }
|
|
261
|
+
);
|
|
262
|
+
if (residual) {
|
|
263
|
+
pattern.lastIndex = 0;
|
|
264
|
+
if (pattern.test(residual) && collectedBytes + residual.length <= limit) collected.push(residual);
|
|
265
|
+
}
|
|
266
|
+
return collected.join('\n');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Whole-log scan that keeps an injectable whole-file reader working.
|
|
271
|
+
*
|
|
272
|
+
* Several call sites accept a `readFile` for tests that hand them a synthetic
|
|
273
|
+
* transcript for a path that does not exist on disk. This helper preserves that
|
|
274
|
+
* contract ā the injected reader is used when the path cannot be stat'ed or is
|
|
275
|
+
* small enough to be harmless ā while a real, large log is streamed chunk by
|
|
276
|
+
* chunk and never materialised.
|
|
277
|
+
*
|
|
278
|
+
* @param {string} logPath
|
|
279
|
+
* @param {Function} onText - `(text, {isFirst, isLast, offset}) => any`; first non-nullish result wins
|
|
280
|
+
* @param {object} [options]
|
|
281
|
+
* @param {object} [options.fsImpl=fsPromises]
|
|
282
|
+
* @param {Function} [options.readFile] - Injectable whole-file reader for small/synthetic logs
|
|
283
|
+
* @param {number} [options.chunkBytes=DEFAULT_LOG_CHUNK_BYTES]
|
|
284
|
+
* @param {number} [options.overlapBytes=LOG_CHUNK_OVERLAP_BYTES]
|
|
285
|
+
* @param {boolean} [options.verbose=false]
|
|
286
|
+
* @returns {Promise<any|null>}
|
|
287
|
+
*/
|
|
288
|
+
export async function scanLogTextChunks(logPath, onText, { fsImpl = fsPromises, readFile = null, chunkBytes = DEFAULT_LOG_CHUNK_BYTES, overlapBytes = LOG_CHUNK_OVERLAP_BYTES, verbose = false } = {}) {
|
|
289
|
+
if (!logPath || typeof onText !== 'function') return null;
|
|
290
|
+
const step = toPositiveInt(chunkBytes, DEFAULT_LOG_CHUNK_BYTES);
|
|
291
|
+
let size = null;
|
|
292
|
+
try {
|
|
293
|
+
size = (await fsImpl.stat(logPath)).size;
|
|
294
|
+
} catch {
|
|
295
|
+
// Not on disk (or not readable): fall through to the injected reader.
|
|
296
|
+
}
|
|
297
|
+
if (size === null || size <= step) {
|
|
298
|
+
const read = readFile || fsImpl.readFile.bind(fsImpl);
|
|
299
|
+
try {
|
|
300
|
+
const text = String(await read(logPath, 'utf8'));
|
|
301
|
+
const result = onText(text, { offset: 0, isFirst: true, isLast: true });
|
|
302
|
+
return result === undefined ? null : result;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (verbose) console.log(`[VERBOSE] log-bounded-read: could not read ${logPath}: ${error?.message || error}`);
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return scanLogChunks(logPath, onText, { fsImpl, chunkBytes: step, overlapBytes, verbose });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Marker-line collection that keeps an injectable whole-file reader working.
|
|
313
|
+
*
|
|
314
|
+
* Same contract as {@link scanLogTextChunks}, for the `š [DISK]`-style parsers
|
|
315
|
+
* that only ever look at individual matching lines: a real log is scanned in
|
|
316
|
+
* chunks and only the matching lines are kept, so a 134 MB transcript costs one
|
|
317
|
+
* chunk plus a few kilobytes of results.
|
|
318
|
+
*
|
|
319
|
+
* @param {string} logPath
|
|
320
|
+
* @param {RegExp} pattern
|
|
321
|
+
* @param {object} [options] - As {@link collectLogLinesMatching}, plus `readFile`
|
|
322
|
+
* @returns {Promise<string>}
|
|
323
|
+
*/
|
|
324
|
+
export async function readLogMarkerLines(logPath, pattern, { fsImpl = fsPromises, readFile = null, maxBytes = 65536, chunkBytes = DEFAULT_LOG_CHUNK_BYTES, verbose = false } = {}) {
|
|
325
|
+
if (!logPath || !(pattern instanceof RegExp)) return '';
|
|
326
|
+
let statable = true;
|
|
327
|
+
try {
|
|
328
|
+
await fsImpl.stat(logPath);
|
|
329
|
+
} catch {
|
|
330
|
+
statable = false;
|
|
331
|
+
}
|
|
332
|
+
if (statable) return collectLogLinesMatching(logPath, pattern, { fsImpl, maxBytes, chunkBytes, verbose });
|
|
333
|
+
const read = readFile || fsImpl.readFile.bind(fsImpl);
|
|
334
|
+
try {
|
|
335
|
+
const text = String(await read(logPath, 'utf8'));
|
|
336
|
+
const limit = toPositiveInt(maxBytes, 65536);
|
|
337
|
+
const collected = [];
|
|
338
|
+
let collectedBytes = 0;
|
|
339
|
+
for (const line of text.split('\n')) {
|
|
340
|
+
pattern.lastIndex = 0;
|
|
341
|
+
if (!pattern.test(line)) continue;
|
|
342
|
+
if (collectedBytes + line.length > limit) continue;
|
|
343
|
+
collected.push(line);
|
|
344
|
+
collectedBytes += line.length + 1;
|
|
345
|
+
}
|
|
346
|
+
return collected.join('\n');
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (verbose) console.log(`[VERBOSE] log-bounded-read: could not read ${logPath}: ${error?.message || error}`);
|
|
349
|
+
return '';
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Stream a record-per-line file, one line at a time.
|
|
355
|
+
*
|
|
356
|
+
* `fs.readFile(file, 'utf8').split('\n')` costs *two* full copies of the file
|
|
357
|
+
* (the string and the array of its lines) before the first record is looked at.
|
|
358
|
+
* Claude/Codex session transcripts are JSONL that grows with the session, so
|
|
359
|
+
* that shape scales with how long the AI ran ā the exact class of unbounded
|
|
360
|
+
* buffering issue #2189 removes.
|
|
361
|
+
*
|
|
362
|
+
* The line terminator is stripped, as with `split('\n')`; `\r\n` is treated as
|
|
363
|
+
* one terminator. `onLine` may return `false` to stop early. Missing/unreadable
|
|
364
|
+
* files rethrow, so callers keep their existing error handling.
|
|
365
|
+
*
|
|
366
|
+
* @param {string} logPath
|
|
367
|
+
* @param {Function} onLine - `(line, index) => void|false|Promise<void|false>`
|
|
368
|
+
* @param {object} [options]
|
|
369
|
+
* @param {Function} [options.createReadStream=fs.createReadStream]
|
|
370
|
+
* @param {number} [options.highWaterMark=DEFAULT_LOG_CHUNK_BYTES]
|
|
371
|
+
* @returns {Promise<number>} Number of lines visited
|
|
372
|
+
*/
|
|
373
|
+
export async function forEachLogLine(logPath, onLine, { createReadStream = fsCreateReadStream, highWaterMark = DEFAULT_LOG_CHUNK_BYTES } = {}) {
|
|
374
|
+
const stream = createReadStream(logPath, { highWaterMark });
|
|
375
|
+
const reader = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
376
|
+
let index = 0;
|
|
377
|
+
try {
|
|
378
|
+
for await (const line of reader) {
|
|
379
|
+
const proceed = await onLine(line, index);
|
|
380
|
+
index += 1;
|
|
381
|
+
if (proceed === false) break;
|
|
382
|
+
}
|
|
383
|
+
} finally {
|
|
384
|
+
reader.close();
|
|
385
|
+
stream.destroy();
|
|
386
|
+
}
|
|
387
|
+
return index;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* True when `filePath` ends with a newline.
|
|
392
|
+
*
|
|
393
|
+
* A rewriter that streams lines has to restore the file's original trailing
|
|
394
|
+
* newline explicitly: unlike `split('\n')`, a line reader does not report the
|
|
395
|
+
* empty final element that a trailing terminator produces.
|
|
396
|
+
*
|
|
397
|
+
* @param {string} filePath
|
|
398
|
+
* @param {object} [options]
|
|
399
|
+
* @param {object} [options.fsImpl=fsPromises]
|
|
400
|
+
* @returns {Promise<boolean>}
|
|
401
|
+
*/
|
|
402
|
+
export async function fileEndsWithNewline(filePath, { fsImpl = fsPromises } = {}) {
|
|
403
|
+
try {
|
|
404
|
+
const { size } = await fsImpl.stat(filePath);
|
|
405
|
+
if (!size) return false;
|
|
406
|
+
const text = await readRangeText(fsImpl, filePath, size - 1, 1);
|
|
407
|
+
return text === '\n' || text === '\r';
|
|
408
|
+
} catch {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Streaming, bounded-memory sanitization of an execution log into a publishable
|
|
5
|
+
* file (issue #2189).
|
|
6
|
+
*
|
|
7
|
+
* The `--attach-logs` path used to do this:
|
|
8
|
+
*
|
|
9
|
+
* ```js
|
|
10
|
+
* const rawLogContent = await fs.readFile(logFile, 'utf8'); // 134 MB string
|
|
11
|
+
* let logContent = await sanitizeForPublication(rawLogContent); // + full copy
|
|
12
|
+
* logContent = escapeCodeBlocksInLog(logContent); // + full copy
|
|
13
|
+
* ā¦
|
|
14
|
+
* await writeSanitizedPublicationFile(tempLogFile, rawLogContent); // + again
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* With V8's ~2 GB old-space cap that reliably ends in
|
|
18
|
+
* `FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of
|
|
19
|
+
* memory`, and the observed crash frame ā `Runtime_RegExpExecMultiple` ā is the
|
|
20
|
+
* global-regex replace those passes run over the whole transcript at once.
|
|
21
|
+
*
|
|
22
|
+
* This module sanitizes the same content **block by block**. Peak residency is
|
|
23
|
+
* one block (1 MiB by default) plus whatever the sanitizer allocates for it, so
|
|
24
|
+
* a 1 GB log costs the same as a 1 MB log.
|
|
25
|
+
*
|
|
26
|
+
* ## Why block boundaries are safe
|
|
27
|
+
*
|
|
28
|
+
* Blocks are cut on record boundaries and a block is only released when the
|
|
29
|
+
* bytes after it cannot belong to a credential that started inside it:
|
|
30
|
+
*
|
|
31
|
+
* - a partial line is never released (a token split mid-line would be
|
|
32
|
+
* invisible to both halves);
|
|
33
|
+
* - an unterminated `-----BEGIN ⦠PRIVATE KEY-----` block is held until its
|
|
34
|
+
* matching `-----END ā¦-----` arrives;
|
|
35
|
+
* - a trailing run of base64-only lines is held until a line that cannot
|
|
36
|
+
* continue the blob arrives (issue #2156's wrapped-payload rule).
|
|
37
|
+
*
|
|
38
|
+
* This is exactly the hold-back contract {@link createCredentialStreamSanitizer}
|
|
39
|
+
* already applies to child-process output; the difference is that this module
|
|
40
|
+
* runs the *full* fail-closed publication sanitizer (maintained patterns +
|
|
41
|
+
* Secretlint + residual re-scan) on each released block rather than the
|
|
42
|
+
* dependency-free subset.
|
|
43
|
+
*
|
|
44
|
+
* A hold is itself capped ({@link DEFAULT_MAX_HOLD_BYTES}) so a log containing a
|
|
45
|
+
* stray `-----BEGIN` marker with no terminator ā or one enormous line ā can
|
|
46
|
+
* never reintroduce the unbounded growth this module exists to remove. Real PEM
|
|
47
|
+
* keys are a few kilobytes, so the cap is unreachable by legitimate content.
|
|
48
|
+
*
|
|
49
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2189
|
|
50
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2156
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import fsPromises from 'node:fs/promises';
|
|
54
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
55
|
+
import { wrappedBase64HoldStart } from './encoded-credential-detection.lib.mjs';
|
|
56
|
+
import { sanitizeForPublication } from './token-sanitization.lib.mjs';
|
|
57
|
+
|
|
58
|
+
/** Bytes read from the source per iteration. */
|
|
59
|
+
export const DEFAULT_SANITIZE_CHUNK_BYTES = 1024 * 1024;
|
|
60
|
+
|
|
61
|
+
/** Hard ceiling on text held back waiting for a terminator. */
|
|
62
|
+
export const DEFAULT_MAX_HOLD_BYTES = 8 * 1024 * 1024;
|
|
63
|
+
|
|
64
|
+
const PEM_BEGIN_RE = /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----/;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* How many characters at the head of `pending` may be released now.
|
|
68
|
+
*
|
|
69
|
+
* Mirrors the hold-back rules of `createCredentialStreamSanitizer`, but returns
|
|
70
|
+
* the boundary instead of sanitizing, so the caller can run the asynchronous
|
|
71
|
+
* publication sanitizer over the released slice.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} pending - Buffered, not-yet-released text
|
|
74
|
+
* @returns {number} Character count that is safe to release (0 = hold all)
|
|
75
|
+
*/
|
|
76
|
+
export function computeReleaseBoundary(pending) {
|
|
77
|
+
if (!pending) return 0;
|
|
78
|
+
const begin = PEM_BEGIN_RE.exec(pending);
|
|
79
|
+
if (begin) {
|
|
80
|
+
const endMarker = `-----END ${begin[1]}-----`;
|
|
81
|
+
const endIndex = pending.indexOf(endMarker, begin.index + begin[0].length);
|
|
82
|
+
// Everything before the marker is releasable; the key itself waits for its
|
|
83
|
+
// terminator so it is always sanitized as one complete unit.
|
|
84
|
+
if (endIndex < 0) return begin.index;
|
|
85
|
+
return endIndex + endMarker.length;
|
|
86
|
+
}
|
|
87
|
+
const boundary = Math.max(pending.lastIndexOf('\n'), pending.lastIndexOf('\r'));
|
|
88
|
+
if (boundary < 0) return 0;
|
|
89
|
+
const releaseEnd = wrappedBase64HoldStart(pending, boundary + 1) ?? boundary + 1;
|
|
90
|
+
return Math.max(0, releaseEnd);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Walk `sourcePath` in hold-back-safe blocks without ever holding the file.
|
|
95
|
+
*
|
|
96
|
+
* Every consumer in this module (write a sanitized copy, re-scan a published
|
|
97
|
+
* artifact) needs the same loop: read a chunk, decide how much of the buffer can
|
|
98
|
+
* be released without cutting a credential in half, hand that slice over, keep
|
|
99
|
+
* the rest. Only the per-block action differs, so the loop lives here once.
|
|
100
|
+
*
|
|
101
|
+
* `onBlock` may return `false` to stop the walk early (used by the verifier,
|
|
102
|
+
* which has its answer as soon as one block comes back changed).
|
|
103
|
+
*
|
|
104
|
+
* @param {string} sourcePath - File to walk
|
|
105
|
+
* @param {Function} onBlock - `(text) => Promise<void|false>`
|
|
106
|
+
* @param {object} [options]
|
|
107
|
+
* @param {number} [options.chunkBytes=DEFAULT_SANITIZE_CHUNK_BYTES]
|
|
108
|
+
* @param {number} [options.maxHoldBytes=DEFAULT_MAX_HOLD_BYTES]
|
|
109
|
+
* @param {number} [options.startByte=0] - First byte to read (for slice copies)
|
|
110
|
+
* @param {number|null} [options.endByte=null] - Byte to stop at; defaults to EOF
|
|
111
|
+
* @param {object} [options.fsImpl=fsPromises]
|
|
112
|
+
* @param {Function} [options.onProgress] - `({bytesRead, sourceSize, blocks}) => void`
|
|
113
|
+
* @returns {Promise<{sourceSize: number, bytesRead: number, blocks: number, forcedReleases: number}>}
|
|
114
|
+
*/
|
|
115
|
+
export async function forEachLogBlock(sourcePath, onBlock, options = {}) {
|
|
116
|
+
const { chunkBytes = DEFAULT_SANITIZE_CHUNK_BYTES, maxHoldBytes = DEFAULT_MAX_HOLD_BYTES, startByte = 0, endByte = null, fsImpl = fsPromises, onProgress = null } = options;
|
|
117
|
+
if (!sourcePath) throw new TypeError('forEachLogBlock requires a sourcePath');
|
|
118
|
+
|
|
119
|
+
const step = Number.isFinite(chunkBytes) && chunkBytes > 0 ? Math.floor(chunkBytes) : DEFAULT_SANITIZE_CHUNK_BYTES;
|
|
120
|
+
const holdCap = Number.isFinite(maxHoldBytes) && maxHoldBytes > 0 ? Math.floor(maxHoldBytes) : DEFAULT_MAX_HOLD_BYTES;
|
|
121
|
+
const stats = { sourceSize: 0, bytesRead: 0, blocks: 0, forcedReleases: 0 };
|
|
122
|
+
|
|
123
|
+
const source = await fsImpl.open(sourcePath, 'r');
|
|
124
|
+
try {
|
|
125
|
+
stats.sourceSize = (await source.stat()).size;
|
|
126
|
+
const limit = endByte === null || !Number.isFinite(endByte) ? stats.sourceSize : Math.max(0, Math.min(Math.floor(endByte), stats.sourceSize));
|
|
127
|
+
const first = Number.isFinite(startByte) && startByte > 0 ? Math.min(Math.floor(startByte), limit) : 0;
|
|
128
|
+
|
|
129
|
+
const buffer = Buffer.alloc(step);
|
|
130
|
+
const decoder = new StringDecoder('utf8');
|
|
131
|
+
let pending = '';
|
|
132
|
+
let position = first;
|
|
133
|
+
let stopped = false;
|
|
134
|
+
|
|
135
|
+
const emit = async text => {
|
|
136
|
+
if (!text || stopped) return;
|
|
137
|
+
stats.blocks += 1;
|
|
138
|
+
if ((await onBlock(text)) === false) stopped = true;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
while (position < limit && !stopped) {
|
|
142
|
+
const { bytesRead } = await source.read(buffer, 0, Math.min(step, limit - position), position);
|
|
143
|
+
if (!bytesRead) break;
|
|
144
|
+
position += bytesRead;
|
|
145
|
+
stats.bytesRead = position - first;
|
|
146
|
+
pending += decoder.write(buffer.subarray(0, bytesRead));
|
|
147
|
+
|
|
148
|
+
let boundary = computeReleaseBoundary(pending);
|
|
149
|
+
if (boundary <= 0 && pending.length > holdCap) {
|
|
150
|
+
// Never grow without bound: fall back to the last record boundary, or
|
|
151
|
+
// the whole buffer when the file has no record boundary at all.
|
|
152
|
+
const lastRecord = Math.max(pending.lastIndexOf('\n'), pending.lastIndexOf('\r'));
|
|
153
|
+
boundary = lastRecord >= 0 ? lastRecord + 1 : pending.length;
|
|
154
|
+
stats.forcedReleases += 1;
|
|
155
|
+
}
|
|
156
|
+
if (boundary > 0) {
|
|
157
|
+
await emit(pending.slice(0, boundary));
|
|
158
|
+
pending = pending.slice(boundary);
|
|
159
|
+
}
|
|
160
|
+
if (onProgress) onProgress({ bytesRead: stats.bytesRead, sourceSize: stats.sourceSize, blocks: stats.blocks });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
pending += decoder.end();
|
|
164
|
+
await emit(pending);
|
|
165
|
+
return stats;
|
|
166
|
+
} finally {
|
|
167
|
+
await source.close().catch(() => {});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Sanitize `sourcePath` into `destPath` without ever holding the whole file.
|
|
173
|
+
*
|
|
174
|
+
* The destination is created exclusively (`wx`, mode 0600) exactly like
|
|
175
|
+
* {@link writeSanitizedPublicationFile}, so a pre-planted symlink in a shared
|
|
176
|
+
* temporary directory cannot be followed. If any block fails the fail-closed
|
|
177
|
+
* publication scan the partial destination is removed and the error rethrown ā
|
|
178
|
+
* a partially-sanitized file is never left behind for a caller to upload.
|
|
179
|
+
*
|
|
180
|
+
* @param {object} options
|
|
181
|
+
* @param {string} options.sourcePath - Log to sanitize
|
|
182
|
+
* @param {string} options.destPath - File to create
|
|
183
|
+
* @param {number} [options.chunkBytes=DEFAULT_SANITIZE_CHUNK_BYTES]
|
|
184
|
+
* @param {number} [options.maxHoldBytes=DEFAULT_MAX_HOLD_BYTES]
|
|
185
|
+
* @param {number} [options.startByte=0] - First source byte to copy
|
|
186
|
+
* @param {number|null} [options.endByte=null] - Source byte to stop at; defaults to EOF
|
|
187
|
+
* @param {Function} [options.sanitize=sanitizeForPublication] - `(text) => Promise<string>`
|
|
188
|
+
* @param {Function} [options.transform] - Optional per-block post-transform (e.g. markdown escaping)
|
|
189
|
+
* @param {object} [options.fsImpl=fsPromises]
|
|
190
|
+
* @param {Function} [options.onProgress] - `({bytesRead, sourceSize, blocks}) => void`
|
|
191
|
+
* @returns {Promise<{sourceSize: number, bytesRead: number, charsWritten: number, blocks: number, forcedReleases: number}>}
|
|
192
|
+
*/
|
|
193
|
+
export async function sanitizeLogFileToFile(options = {}) {
|
|
194
|
+
const { sourcePath, destPath, chunkBytes = DEFAULT_SANITIZE_CHUNK_BYTES, maxHoldBytes = DEFAULT_MAX_HOLD_BYTES, startByte = 0, endByte = null, sanitize = sanitizeForPublication, transform = null, fsImpl = fsPromises, onProgress = null } = options;
|
|
195
|
+
if (!sourcePath) throw new TypeError('sanitizeLogFileToFile requires a sourcePath');
|
|
196
|
+
if (!destPath) throw new TypeError('sanitizeLogFileToFile requires a destPath');
|
|
197
|
+
|
|
198
|
+
let dest = null;
|
|
199
|
+
let destCreated = false;
|
|
200
|
+
let charsWritten = 0;
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
dest = await fsImpl.open(destPath, 'wx', 0o600);
|
|
204
|
+
destCreated = true;
|
|
205
|
+
|
|
206
|
+
const stats = await forEachLogBlock(
|
|
207
|
+
sourcePath,
|
|
208
|
+
async text => {
|
|
209
|
+
const sanitized = String(await sanitize(text));
|
|
210
|
+
const out = transform ? String(transform(sanitized)) : sanitized;
|
|
211
|
+
if (!out) return;
|
|
212
|
+
await dest.write(out, null, 'utf8');
|
|
213
|
+
charsWritten += out.length;
|
|
214
|
+
},
|
|
215
|
+
{ chunkBytes, maxHoldBytes, startByte, endByte, fsImpl, onProgress }
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
await dest.chmod(0o600);
|
|
219
|
+
return { ...stats, charsWritten };
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (destCreated) {
|
|
222
|
+
try {
|
|
223
|
+
if (dest) await dest.close();
|
|
224
|
+
} catch {
|
|
225
|
+
/* closing a failed handle is best effort */
|
|
226
|
+
}
|
|
227
|
+
dest = null;
|
|
228
|
+
await fsImpl.unlink(destPath).catch(() => {});
|
|
229
|
+
}
|
|
230
|
+
throw error;
|
|
231
|
+
} finally {
|
|
232
|
+
if (dest) await dest.close().catch(() => {});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Re-scan an already-published file for residual credential material.
|
|
238
|
+
*
|
|
239
|
+
* This is the streaming form of the development-log rescan, which used to read
|
|
240
|
+
* every artifact back into a single string (`fs.readFile(filePath, 'utf8')`) and
|
|
241
|
+
* sanitize that string a second time ā the exact double-buffering issue #2189
|
|
242
|
+
* removes. The guarantee is unchanged: every byte of the file is passed through
|
|
243
|
+
* the fail-closed publication sanitizer, and any block the sanitizer would still
|
|
244
|
+
* change is reported.
|
|
245
|
+
*
|
|
246
|
+
* @param {string} filePath
|
|
247
|
+
* @param {object} [options] - Also accepts every {@link forEachLogBlock} option
|
|
248
|
+
* @param {Function} [options.sanitize=sanitizeForPublication]
|
|
249
|
+
* @returns {Promise<{blockIndex: number, length: number}|null>} `null` when clean
|
|
250
|
+
*/
|
|
251
|
+
export async function findResidualCredentialBlock(filePath, options = {}) {
|
|
252
|
+
const { sanitize = sanitizeForPublication, ...walkOptions } = options;
|
|
253
|
+
let residual = null;
|
|
254
|
+
let blockIndex = 0;
|
|
255
|
+
await forEachLogBlock(
|
|
256
|
+
filePath,
|
|
257
|
+
async text => {
|
|
258
|
+
blockIndex += 1;
|
|
259
|
+
const sanitized = String(await sanitize(text));
|
|
260
|
+
if (sanitized === text) return;
|
|
261
|
+
residual = { blockIndex, length: text.length };
|
|
262
|
+
return false;
|
|
263
|
+
},
|
|
264
|
+
walkOptions
|
|
265
|
+
);
|
|
266
|
+
return residual;
|
|
267
|
+
}
|