@indigoai-us/hq-cli 5.108.4 → 5.108.6

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,430 @@
1
+ /**
2
+ * Full-history log bundle for `hq feedback` — the out-of-band companion to the
3
+ * inline `diagnostics.logs` blob in `feedback-logs.ts`.
4
+ *
5
+ * Inline logs are capped twice over: the feedback endpoint rejects a request
6
+ * body above 64 KiB, and `diagnostics` is written into a DynamoDB item, which
7
+ * caps at 400 KB. Neither can be tuned into carrying a real log history, so the
8
+ * inline blob is deliberately a ~40 KiB summary of 16 KiB tails. This module
9
+ * produces the other half: a gzipped bundle uploaded direct to S3, carrying the
10
+ * files whole rather than in tail-sized slivers.
11
+ *
12
+ * Three properties are load-bearing and none may be traded away:
13
+ *
14
+ * 1. SAME ALLOWLIST. Discovery reuses `feedback-logs.ts` verbatim —
15
+ * `discoverLogFiles`, `discoverStateFiles`, `discoverFlatDirFiles`,
16
+ * `walkJsonFiles`. The bundle carries MORE OF the same files, never more
17
+ * files. Credential material lives outside `~/.hq` (`~/.codex/auth.json`,
18
+ * `~/.hq-agent/machine-creds.json`), so widening discovery here — not
19
+ * raising the size — is what would turn this into an exfiltration bug.
20
+ *
21
+ * 2. SAME REDACTION. Every line goes through `redactLogText` before it is
22
+ * compressed. Shipping a raw archive would be far simpler and would
23
+ * silently undo the entire security model of the inline path.
24
+ *
25
+ * 3. BOUNDED MEMORY. 50 MB compressed is roughly a gigabyte of raw log text
26
+ * at the ratio these files compress at. Nothing is ever fully materialised:
27
+ * files are read in slices, redacted a line at a time, and streamed into
28
+ * gzip, with only the compressed output retained.
29
+ *
30
+ * Output format — gzipped NDJSON, one JSON record per line:
31
+ * {"kind":"manifest","version":1,...} exactly one, first
32
+ * {"kind":"file","name":"logs/hq-sync.log",...} one per file
33
+ * {"kind":"chunk","name":"logs/hq-sync.log","seq":0,...} many per file
34
+ * {"kind":"summary","fileCount":12,...} exactly one, last
35
+ *
36
+ * NDJSON rather than tar so there is no archive dependency, so a truncated
37
+ * bundle is still parseable line-by-line up to the cut, and so the records
38
+ * carry the same redaction metadata the inline blob already reports.
39
+ */
40
+ import * as fs from "node:fs";
41
+ import * as os from "node:os";
42
+ import * as path from "node:path";
43
+ import * as zlib from "node:zlib";
44
+ import { StringDecoder } from "node:string_decoder";
45
+ import { FEEDBACK_JOBS_MAX_DEPTH, FEEDBACK_JOBS_MAX_FILES, FEEDBACK_JOBS_DIRNAME, FEEDBACK_LOCKS_DIRNAME, FEEDBACK_LOCKS_MAX_FILES, discoverFlatDirFiles, discoverLogFiles, discoverStateFiles, redactLogText, walkJsonFiles, } from "./feedback-logs.js";
46
+ import { vaultApiFetch } from "./vault-api.js";
47
+ /**
48
+ * Compressed ceiling. Mirrors `MAX_LOG_BUNDLE_BYTES` in the hq-pro handler
49
+ * `feedback-log-bundles.ts`, which refuses to presign above it — the two must
50
+ * stay in step or the CLI will build bundles the server will not accept.
51
+ */
52
+ export const LOG_BUNDLE_MAX_BYTES = 50 * 1024 * 1024;
53
+ /**
54
+ * Headroom between the size we stop feeding at and the hard cap.
55
+ *
56
+ * gzip reports compressed bytes only as its internal buffer flushes, so the
57
+ * running total lags the bytes actually consumed. The lag is bounded by that
58
+ * buffer (tens of KiB); a 1 MiB margin covers it with three orders of magnitude
59
+ * to spare, and the final size is asserted against the real cap regardless.
60
+ */
61
+ export const LOG_BUNDLE_SAFETY_MARGIN_BYTES = 1024 * 1024;
62
+ /**
63
+ * Per-file raw ceiling. The desktop logger rotates at 32 MiB (hq-desktop-core
64
+ * `logfile.rs`), so this admits a full generation with headroom while stopping
65
+ * one pathological file from consuming the whole bundle. Files above it are
66
+ * read from the TAIL — the end of a log is what explains a failure.
67
+ */
68
+ export const LOG_BUNDLE_MAX_FILE_RAW_BYTES = 64 * 1024 * 1024;
69
+ /** Read slice size. Bounds peak memory; unrelated to the output chunking. */
70
+ const READ_SLICE_BYTES = 4 * 1024 * 1024;
71
+ /** Target raw text per emitted chunk record. Keeps NDJSON lines manageable. */
72
+ const CHUNK_TEXT_BYTES = 256 * 1024;
73
+ /**
74
+ * A chunk is written whole before the budget is re-checked, so the cap can only
75
+ * be honoured if one chunk is small relative to it. Scale the chunk down for
76
+ * small budgets; at the production 50 MB cap this returns CHUNK_TEXT_BYTES.
77
+ */
78
+ function chunkBytesForCap(maxBytes) {
79
+ return Math.min(CHUNK_TEXT_BYTES, Math.max(4096, Math.floor(maxBytes / 8)));
80
+ }
81
+ /**
82
+ * Split point for a "line" that is longer than a whole chunk.
83
+ *
84
+ * Some logs are a single line: one-line JSON, a file with CRLF-only or no line
85
+ * terminators at all. Without this the reader would buffer the entire file as
86
+ * one line and emit it as one chunk, defeating both the memory bound and the
87
+ * size cap.
88
+ *
89
+ * The cut is taken at the last WHITESPACE inside the limit, because the
90
+ * secrets redaction looks for are unbroken tokens — a JWT, a key id, a bearer
91
+ * value. Cutting on whitespace means a split can never bisect one and let the
92
+ * halves slip past a pattern that would have matched the whole. Only a segment
93
+ * with no whitespace at all falls back to a hard cut, and that is already
94
+ * unparseable content rather than a log line.
95
+ */
96
+ function splitOversizedLine(text, limit) {
97
+ const window = text.slice(0, limit);
98
+ const ws = Math.max(window.lastIndexOf(" "), window.lastIndexOf("\t"), window.lastIndexOf("\r"));
99
+ return ws > 0 ? ws + 1 : limit;
100
+ }
101
+ /**
102
+ * True when `text` opens a PEM block it does not close.
103
+ *
104
+ * Redaction runs per chunk rather than per line — redacting each line
105
+ * separately re-runs a dozen patterns hundreds of thousands of times over a
106
+ * large log, which is the dominant cost of building a bundle. The one pattern
107
+ * that genuinely spans lines is a PEM block, so a chunk boundary is held open
108
+ * rather than cutting one in half and letting the halves escape the
109
+ * whole-block pattern.
110
+ */
111
+ function hasOpenPemBlock(text) {
112
+ const begins = text.match(/-----BEGIN [^-\n]+-----/g)?.length ?? 0;
113
+ const ends = text.match(/-----END [^-\n]+-----/g)?.length ?? 0;
114
+ return begins > ends;
115
+ }
116
+ /**
117
+ * Order candidates so that, when the cap truncates collection, what survives is
118
+ * what a triager reads first.
119
+ *
120
+ * State documents lead: they are tiny and answer questions a log cannot (which
121
+ * operation is claimed, what the sync cursor is). Log files follow, newest
122
+ * first. Reversing this would let one large old log crowd out every status
123
+ * file — the same class of ordering defect that let ordinary lock entries
124
+ * crowd out a stale claim in the inline collector.
125
+ */
126
+ export function orderBundleCandidates(hqDir) {
127
+ const state = [
128
+ ...discoverStateFiles(hqDir),
129
+ ...discoverFlatDirFiles(hqDir, FEEDBACK_LOCKS_DIRNAME, FEEDBACK_LOCKS_MAX_FILES),
130
+ ...walkJsonFiles(hqDir, FEEDBACK_JOBS_DIRNAME, FEEDBACK_JOBS_MAX_DEPTH, FEEDBACK_JOBS_MAX_FILES),
131
+ ];
132
+ const logs = discoverLogFiles(hqDir).sort((a, b) => b.modifiedMs - a.modifiedMs || a.name.localeCompare(b.name));
133
+ return [...state, ...logs];
134
+ }
135
+ function createGzipSink() {
136
+ const gzip = zlib.createGzip({ level: 9 });
137
+ const parts = [];
138
+ let compressed = 0;
139
+ let failure = null;
140
+ gzip.on("data", (chunk) => {
141
+ parts.push(chunk);
142
+ compressed += chunk.byteLength;
143
+ });
144
+ gzip.on("error", (err) => {
145
+ failure = err;
146
+ });
147
+ return {
148
+ compressedBytes: () => compressed,
149
+ write: (line) => new Promise((resolve, reject) => {
150
+ if (failure)
151
+ return reject(failure);
152
+ gzip.write(line, "utf8", (err) => (err ? reject(err) : resolve()));
153
+ }),
154
+ // Z_SYNC_FLUSH costs a handful of bytes per boundary and a little ratio.
155
+ // That is the price of a budget that is enforced rather than estimated:
156
+ // without it deflate can hold the bulk of its output until end-of-stream,
157
+ // the running total reads near zero throughout, and the cap is only
158
+ // discovered to have been blown once the whole bundle has been built.
159
+ sync: () => new Promise((resolve, reject) => {
160
+ if (failure)
161
+ return reject(failure);
162
+ gzip.flush(zlib.constants.Z_SYNC_FLUSH, () => resolve());
163
+ }),
164
+ finish: () => new Promise((resolve, reject) => {
165
+ gzip.on("end", () => (failure ? reject(failure) : resolve(Buffer.concat(parts))));
166
+ gzip.on("error", reject);
167
+ gzip.end();
168
+ gzip.resume();
169
+ }),
170
+ };
171
+ }
172
+ /** One NDJSON line. Newline-terminated; JSON.stringify escapes any interior one. */
173
+ function record(value) {
174
+ return `${JSON.stringify(value)}\n`;
175
+ }
176
+ /**
177
+ * Read `absPath` in slices, redacting whole lines, invoking `onChunk` with
178
+ * roughly {@link CHUNK_TEXT_BYTES} of redacted text at a time.
179
+ *
180
+ * Reads from the tail when the file exceeds `maxRawBytes`, and drops the first
181
+ * partial line after seeking so a chunk never begins mid-record. `onChunk`
182
+ * returns false to stop early (the cap was hit).
183
+ */
184
+ export async function streamRedactedFile(absPath, sizeBytes, maxRawBytes, chunkBytes, onChunk) {
185
+ const fromTail = sizeBytes > maxRawBytes;
186
+ let position = fromTail ? sizeBytes - maxRawBytes : 0;
187
+ // The ceiling has to bound the bytes CONSUMED, not just where reading starts.
188
+ // Seeking alone happens to read the right amount when the on-disk size is
189
+ // accurate, but a file being appended to while we read it would otherwise
190
+ // stream without limit.
191
+ let remaining = maxRawBytes;
192
+ const fd = fs.openSync(absPath, "r");
193
+ try {
194
+ const decoder = new StringDecoder("utf8");
195
+ const buffer = Buffer.allocUnsafe(READ_SLICE_BYTES);
196
+ let carry = "";
197
+ let pending = "";
198
+ // Tracked incrementally: measuring the whole accumulator on every line is
199
+ // quadratic in the chunk size and dominates the runtime on a large log.
200
+ let pendingBytes = 0;
201
+ // After seeking into the middle of a file the first line is a fragment;
202
+ // drop it so every emitted line is whole.
203
+ let dropPartialLine = fromTail;
204
+ const flush = async () => {
205
+ if (pendingBytes === 0)
206
+ return true;
207
+ const { text, redactions } = redactLogText(pending);
208
+ pending = "";
209
+ pendingBytes = 0;
210
+ return onChunk(text, redactions);
211
+ };
212
+ const push = async (line) => {
213
+ pending += `${line}\n`;
214
+ pendingBytes += Buffer.byteLength(line, "utf8") + 1;
215
+ if (pendingBytes < chunkBytes)
216
+ return true;
217
+ // Hold the boundary open rather than bisect a PEM block — but not
218
+ // without limit, so a stray unclosed BEGIN cannot grow the buffer.
219
+ if (pendingBytes < chunkBytes * 4 && hasOpenPemBlock(pending))
220
+ return true;
221
+ return flush();
222
+ };
223
+ for (;;) {
224
+ const want = Math.min(READ_SLICE_BYTES, remaining);
225
+ const read = fs.readSync(fd, buffer, 0, want, position);
226
+ if (read === 0)
227
+ break;
228
+ position += read;
229
+ remaining -= read;
230
+ carry += decoder.write(buffer.subarray(0, read));
231
+ const lines = carry.split("\n");
232
+ // The final element is an incomplete line (or ""); hold it for the next slice.
233
+ carry = lines.pop() ?? "";
234
+ // ...unless it has grown past a whole chunk with no terminator in sight,
235
+ // in which case it is not a line and must be cut so the bundle stays
236
+ // bounded. See splitOversizedLine for why the cut lands on whitespace.
237
+ while (Buffer.byteLength(carry, "utf8") >= chunkBytes) {
238
+ const at = splitOversizedLine(carry, chunkBytes);
239
+ const segment = carry.slice(0, at);
240
+ carry = carry.slice(at);
241
+ if (dropPartialLine) {
242
+ dropPartialLine = false;
243
+ continue;
244
+ }
245
+ if (!(await push(segment)))
246
+ return { fromTail, stopped: true };
247
+ }
248
+ for (const line of lines) {
249
+ if (dropPartialLine) {
250
+ dropPartialLine = false;
251
+ continue;
252
+ }
253
+ if (!(await push(line)))
254
+ return { fromTail, stopped: true };
255
+ }
256
+ }
257
+ carry += decoder.end();
258
+ if (carry.length > 0 && !dropPartialLine) {
259
+ if (!(await push(carry)))
260
+ return { fromTail, stopped: true };
261
+ }
262
+ if (!(await flush()))
263
+ return { fromTail, stopped: true };
264
+ return { fromTail, stopped: false };
265
+ }
266
+ finally {
267
+ fs.closeSync(fd);
268
+ }
269
+ }
270
+ /**
271
+ * Build a gzipped NDJSON bundle of the submitter's redacted `~/.hq` logs.
272
+ *
273
+ * Returns `undefined` when nothing eligible exists, so the caller can skip the
274
+ * upload entirely. Never throws: a bug report must not fail over its own
275
+ * diagnostics, so any unexpected error yields `undefined` and the submission
276
+ * proceeds with inline logs alone.
277
+ */
278
+ export async function buildLogBundle(opts = {}) {
279
+ const maxBytes = Math.max(0, Math.min(opts.maxBytes ?? LOG_BUNDLE_MAX_BYTES, LOG_BUNDLE_MAX_BYTES));
280
+ // Scale the margin down for very small caps so the stop line never lands
281
+ // at zero (which would truncate before the first byte). At the production
282
+ // 50 MB cap this is exactly LOG_BUNDLE_SAFETY_MARGIN_BYTES.
283
+ const margin = Math.min(LOG_BUNDLE_SAFETY_MARGIN_BYTES, Math.floor(maxBytes / 2));
284
+ const stopAt = Math.max(0, maxBytes - margin);
285
+ const chunkBytes = chunkBytesForCap(maxBytes);
286
+ const hqDir = opts.hqDir ?? path.join(opts.homeDir ?? os.homedir(), ".hq");
287
+ let candidates;
288
+ try {
289
+ candidates = orderBundleCandidates(hqDir);
290
+ }
291
+ catch {
292
+ return undefined;
293
+ }
294
+ if (candidates.length === 0)
295
+ return undefined;
296
+ try {
297
+ const sink = createGzipSink();
298
+ let fileCount = 0;
299
+ let rawBytes = 0;
300
+ let redactions = 0;
301
+ let truncated = false;
302
+ await sink.write(record({
303
+ kind: "manifest",
304
+ version: 1,
305
+ tool: "hq-cli",
306
+ generatedAt: new Date().toISOString(),
307
+ maxBytes,
308
+ candidateCount: candidates.length,
309
+ }));
310
+ for (const candidate of candidates) {
311
+ await sink.sync();
312
+ if (sink.compressedBytes() > stopAt) {
313
+ truncated = true;
314
+ break;
315
+ }
316
+ let seq = 0;
317
+ let wroteHeader = false;
318
+ let stopped = false;
319
+ try {
320
+ const result = await streamRedactedFile(candidate.absPath, candidate.sizeBytes, opts.maxFileRawBytes ?? LOG_BUNDLE_MAX_FILE_RAW_BYTES, chunkBytes, async (text, chunkRedactions) => {
321
+ if (!wroteHeader) {
322
+ await sink.write(record({
323
+ kind: "file",
324
+ name: candidate.name,
325
+ sizeBytes: candidate.sizeBytes,
326
+ modifiedIso: new Date(candidate.modifiedMs).toISOString(),
327
+ }));
328
+ wroteHeader = true;
329
+ fileCount++;
330
+ }
331
+ await sink.write(record({ kind: "chunk", name: candidate.name, seq: seq++, text }));
332
+ rawBytes += Buffer.byteLength(text, "utf8");
333
+ redactions += chunkRedactions;
334
+ // Stop feeding once the compressed total reaches the stop line.
335
+ // The sync is what makes that total trustworthy.
336
+ await sink.sync();
337
+ return sink.compressedBytes() <= stopAt;
338
+ });
339
+ stopped = result.stopped;
340
+ }
341
+ catch {
342
+ // An unreadable or vanished file is not a reason to lose the bundle;
343
+ // the inline blob already reports skips, so drop it silently here.
344
+ continue;
345
+ }
346
+ if (stopped) {
347
+ truncated = true;
348
+ break;
349
+ }
350
+ }
351
+ await sink.write(record({ kind: "summary", fileCount, rawBytes, redactions, truncated }));
352
+ const gzip = await sink.finish();
353
+ // Nothing but a manifest and a summary is not worth uploading.
354
+ if (fileCount === 0)
355
+ return undefined;
356
+ // Final authority. The stop line plus margin should make this unreachable,
357
+ // but the server refuses to presign above the cap, so a bundle that
358
+ // overshot is useless and must not be offered.
359
+ if (gzip.byteLength > maxBytes)
360
+ return undefined;
361
+ return {
362
+ gzip,
363
+ sizeBytes: gzip.byteLength,
364
+ fileCount,
365
+ truncated,
366
+ rawBytes,
367
+ redactions,
368
+ };
369
+ }
370
+ catch {
371
+ return undefined;
372
+ }
373
+ }
374
+ /**
375
+ * Build, presign, and upload a log bundle; return the reference the submission
376
+ * should carry, or `undefined` if anything at all did not work out.
377
+ *
378
+ * Every failure path is silent and non-fatal by design. The bundle is an
379
+ * enrichment on top of the inline logs that already ship in the request body,
380
+ * so a missing endpoint, a disabled bucket, a refused presign, or a failed PUT
381
+ * must all degrade to "submit without it" rather than cost the user their bug
382
+ * report. In particular a 404 is expected and unremarkable while a CLI that
383
+ * knows about bundles is running against a server that does not yet.
384
+ */
385
+ export async function uploadLogBundle(opts) {
386
+ if (!opts.enabled)
387
+ return undefined;
388
+ const build = opts.build ?? buildLogBundle;
389
+ const apiFetch = opts.apiFetch ?? vaultApiFetch;
390
+ try {
391
+ const bundle = await build();
392
+ if (!bundle)
393
+ return undefined;
394
+ const res = await apiFetch({
395
+ token: opts.token,
396
+ path: "/v1/feedback/logs/presign",
397
+ method: "POST",
398
+ body: { sizeBytes: bundle.sizeBytes },
399
+ });
400
+ if (!res.ok)
401
+ return undefined;
402
+ const parsed = (await res.json());
403
+ const slot = parsed?.bundle;
404
+ if (!slot || typeof slot.key !== "string" || typeof slot.url !== "string") {
405
+ return undefined;
406
+ }
407
+ const doFetch = opts.fetchImpl ?? fetch;
408
+ const put = await doFetch(slot.url, {
409
+ method: "PUT",
410
+ headers: {
411
+ "Content-Type": typeof slot.contentType === "string" ? slot.contentType : "application/gzip",
412
+ // Must match the length bound into the signature, or S3 rejects it.
413
+ "Content-Length": String(bundle.sizeBytes),
414
+ },
415
+ body: new Uint8Array(bundle.gzip),
416
+ });
417
+ if (!put.ok)
418
+ return undefined;
419
+ return {
420
+ key: slot.key,
421
+ sizeBytes: bundle.sizeBytes,
422
+ fileCount: bundle.fileCount,
423
+ truncated: bundle.truncated,
424
+ };
425
+ }
426
+ catch {
427
+ return undefined;
428
+ }
429
+ }
430
+ //# sourceMappingURL=feedback-log-bundle.js.map