@indigoai-us/hq-cli 5.108.5 → 5.108.7
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 +134 -0
- package/dist/commands/auth.js +11 -4
- package/dist/commands/feedback.d.ts +38 -0
- package/dist/commands/feedback.js +80 -0
- package/dist/commands/signals.js +12 -0
- package/dist/commands/sources.js +12 -0
- package/dist/commands/whoami.js +7 -2
- package/dist/utils/cognito-session.d.ts +58 -16
- package/dist/utils/cognito-session.js +229 -39
- package/dist/utils/feedback-diagnostics.d.ts +11 -0
- package/dist/utils/feedback-log-bundle.d.ts +149 -0
- package/dist/utils/feedback-log-bundle.js +430 -0
- package/dist/utils/feedback-logs.d.ts +329 -0
- package/dist/utils/feedback-logs.js +669 -0
- package/package.json +2 -2
|
@@ -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
|