@gajae-code/utils 0.11.8 → 0.11.10

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.
@@ -8,6 +8,31 @@ export declare enum Reason {
8
8
  UNHANDLED_REJECTION = "unhandled_rejection",// Unhandled promise rejection
9
9
  MANUAL = "manual"
10
10
  }
11
+ /** Cap for the durable crash log; it is reset past this so a crash loop cannot fill the disk. */
12
+ export declare const CRASH_LOG_MAX_BYTES: number;
13
+ /**
14
+ * Per-record budget so a single oversized error body cannot bypass the file
15
+ * cap: every persisted record is truncated to this many bytes (UTF-8 safe,
16
+ * with a marker) before the append/reset decision.
17
+ */
18
+ export declare const CRASH_RECORD_MAX_BYTES: number;
19
+ /**
20
+ * Append a fatal-crash record to the dedicated, rotation-immune crash log
21
+ * (`~/.gjc/agent/gjc-crash.log`).
22
+ *
23
+ * The daily logger file is gzip-archived at date rollover by every gjc process
24
+ * independently; that shared-archive race can truncate a day's log to an empty
25
+ * `.gz`, destroying the `logger.error` crash record written here. This
26
+ * append-only file is never rotated, so a crash stays diagnosable regardless.
27
+ *
28
+ * Fully defensive: it never throws (a failing crash writer must not mask the
29
+ * original fatal) and uses synchronous IO so the record lands before
30
+ * `process.exit`. Returns the path written, or `undefined` on failure.
31
+ */
32
+ export declare function recordFatalCrash(label: string, reason: unknown, options?: {
33
+ path?: string;
34
+ now?: Date;
35
+ }): string | undefined;
11
36
  /**
12
37
  * Register a process cleanup callback, to be run on shutdown, signal, or fatal error.
13
38
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/utils",
4
- "version": "0.11.8",
4
+ "version": "0.11.10",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@gajae-code/natives": "0.11.8",
34
+ "@gajae-code/natives": "0.11.10",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
package/src/postmortem.ts CHANGED
@@ -5,9 +5,12 @@
5
5
  * in response to process exit, signals, or fatal exceptions. It is intended to
6
6
  * allow reliably releasing resources or shutting down subprocesses, files, sockets, etc.
7
7
  */
8
+ import * as fs from "node:fs";
8
9
  import inspector from "node:inspector";
10
+ import * as path from "node:path";
9
11
  import { isMainThread } from "node:worker_threads";
10
12
  import { BROKEN_PIPE_EXIT_CODE, createProcessStdoutEpipeClassifier } from "./broken-pipe";
13
+ import { getCrashLogPath } from "./dirs";
11
14
  import * as logger from "./logger";
12
15
  import { safeStderrWrite } from "./safe-stderr";
13
16
 
@@ -204,6 +207,104 @@ function formatFatalError(label: string, err: Error): string {
204
207
  const formattedStack = stackLines.length > 0 ? `\n${stackLines.join("\n")}` : "";
205
208
  return `\n[${label}] ${name}: ${message}${formattedStack}\n`;
206
209
  }
210
+ /** Cap for the durable crash log; it is reset past this so a crash loop cannot fill the disk. */
211
+ export const CRASH_LOG_MAX_BYTES = 512 * 1024;
212
+ /**
213
+ * Per-record budget so a single oversized error body cannot bypass the file
214
+ * cap: every persisted record is truncated to this many bytes (UTF-8 safe,
215
+ * with a marker) before the append/reset decision.
216
+ */
217
+ export const CRASH_RECORD_MAX_BYTES = 64 * 1024;
218
+ const CRASH_RECORD_TRUNCATION_MARKER = "\n… [crash record truncated]\n\n";
219
+
220
+ /**
221
+ * Best-effort scrub of credential material from a crash record before it is
222
+ * persisted indefinitely. Covers bearer/basic-style headers, key=value or
223
+ * JSON key forms of common credential names, and well-known vendor token
224
+ * shapes. Normal messages and stack frames are untouched; matches are
225
+ * replaced in place so surrounding diagnostic context survives.
226
+ */
227
+ function redactCrashSecrets(text: string): string {
228
+ let redacted = text;
229
+ redacted = redacted.replace(/\b(?:Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "«redacted-auth»");
230
+ redacted = redacted.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, "«redacted-jwt»");
231
+ redacted = redacted.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "«redacted-api-key»");
232
+ redacted = redacted.replace(/\bgh[opsur]_[A-Za-z0-9]{16,}\b/g, "«redacted-github-token»");
233
+ redacted = redacted.replace(/\bxox[baprs]-[A-Za-z0-9-]{8,}\b/g, "«redacted-slack-token»");
234
+ redacted = redacted.replace(/\bAKIA[0-9A-Z]{16}\b/g, "«redacted-aws-key»");
235
+ redacted = redacted.replace(
236
+ /(["']?(?:api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|secret[_-]?key|password|passwd|authorization)["']?\s*[=:]\s*["']?)[^\s"',;}\]]{8,}/gi,
237
+ "$1«redacted»",
238
+ );
239
+ return redacted;
240
+ }
241
+
242
+ /**
243
+ * Bound one record to CRASH_RECORD_MAX_BYTES without splitting a UTF-8
244
+ * sequence. Keeps the header (timestamp/label/message) at the front, where
245
+ * the diagnostic value is highest.
246
+ */
247
+ function boundCrashRecord(report: string): string {
248
+ if (Buffer.byteLength(report, "utf8") <= CRASH_RECORD_MAX_BYTES) return report;
249
+ const bytes = Buffer.from(report, "utf8");
250
+ const budget = CRASH_RECORD_MAX_BYTES - Buffer.byteLength(CRASH_RECORD_TRUNCATION_MARKER, "utf8");
251
+ let end = budget;
252
+ // Drop trailing continuation bytes of a truncated multi-byte sequence.
253
+ while (end > 0 && (bytes[end - 1] & 0xc0) === 0x80) end--;
254
+ // Drop the now-incomplete lead byte, if any.
255
+ if (end > 0 && bytes[end - 1] >= 0xc0) end--;
256
+ return bytes.subarray(0, end).toString("utf8") + CRASH_RECORD_TRUNCATION_MARKER;
257
+ }
258
+
259
+ /**
260
+ * Append a fatal-crash record to the dedicated, rotation-immune crash log
261
+ * (`~/.gjc/agent/gjc-crash.log`).
262
+ *
263
+ * The daily logger file is gzip-archived at date rollover by every gjc process
264
+ * independently; that shared-archive race can truncate a day's log to an empty
265
+ * `.gz`, destroying the `logger.error` crash record written here. This
266
+ * append-only file is never rotated, so a crash stays diagnosable regardless.
267
+ *
268
+ * Fully defensive: it never throws (a failing crash writer must not mask the
269
+ * original fatal) and uses synchronous IO so the record lands before
270
+ * `process.exit`. Returns the path written, or `undefined` on failure.
271
+ */
272
+ export function recordFatalCrash(
273
+ label: string,
274
+ reason: unknown,
275
+ options: { path?: string; now?: Date } = {},
276
+ ): string | undefined {
277
+ try {
278
+ const err = errorForDiagnostic(reason);
279
+ const target = options.path ?? getCrashLogPath();
280
+ const now = options.now ?? new Date();
281
+ const report = boundCrashRecord(
282
+ `${now.toISOString()} pid=${process.pid} [${label}] ` +
283
+ `${err.name || "Error"}: ${redactCrashSecrets(err.message || "(no message)")}\n` +
284
+ `${redactCrashSecrets(err.stack ?? "")}\n\n`,
285
+ );
286
+ fs.mkdirSync(path.dirname(target), { recursive: true });
287
+ let existingSize = 0;
288
+ try {
289
+ existingSize = fs.statSync(target).size;
290
+ } catch {}
291
+ // Reset (rather than append) when the file would exceed the cap so the
292
+ // newest crash is always retained without unbounded growth. Every record
293
+ // is individually bounded above, so no single crash can bypass the cap.
294
+ if (existingSize + Buffer.byteLength(report, "utf8") > CRASH_LOG_MAX_BYTES) {
295
+ fs.writeFileSync(target, report, { mode: 0o600 });
296
+ } else {
297
+ fs.appendFileSync(target, report, { mode: 0o600 });
298
+ }
299
+ // A pre-existing file may carry looser permissions; enforce owner-only.
300
+ try {
301
+ fs.chmodSync(target, 0o600);
302
+ } catch {}
303
+ return target;
304
+ } catch {
305
+ return undefined;
306
+ }
307
+ }
207
308
 
208
309
  async function exitQuietlyForAttributableStdoutEpipe(reason: Reason): Promise<void> {
209
310
  if (ordinaryFatalStarted || quietShutdownStarted) return;
@@ -226,7 +327,12 @@ async function handleFatalError(label: string, reason: unknown, cleanupReason: R
226
327
  ordinaryFatalStarted = true;
227
328
  process.exitCode = 1;
228
329
  const err = errorForDiagnostic(reason);
330
+ // Persist first: the rotation-immune record must land before any
331
+ // best-effort stderr output, so a slow or failing stderr cannot cost the
332
+ // crash record. Cleanup (which may itself hang or fail) runs afterwards.
333
+ const crashLogPath = recordFatalCrash(label, err);
229
334
  safeStderrWrite(formatFatalError(label, err));
335
+ if (crashLogPath) safeStderrWrite(`[${label}] crash recorded at ${crashLogPath}\n`);
230
336
  if (!quietShutdownStarted) {
231
337
  logger.error(label === "Uncaught Exception" ? "Uncaught exception" : "Unhandled rejection", {
232
338
  err,