@lincoln504/pi-research 1.3.3 → 1.3.5

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.
Files changed (39) hide show
  1. package/README.md +7 -2
  2. package/dist/cli.mjs +242 -95
  3. package/dist/thread-worker.mjs +122 -50
  4. package/docs/CHANGELOG.md +76 -0
  5. package/package.json +1 -1
  6. package/skills/pi-research/SKILL.md +1 -1
  7. package/src/core/service-registry.ts +37 -3
  8. package/src/healthcheck/index.ts +9 -7
  9. package/src/infrastructure/browser/browser-client.ts +5 -2
  10. package/src/infrastructure/browser/task-execution-service.ts +21 -5
  11. package/src/infrastructure/browser/thread-worker-browser.ts +49 -1
  12. package/src/infrastructure/browser/thread-worker-messaging.ts +70 -25
  13. package/src/infrastructure/browser/thread-worker.mjs +122 -50
  14. package/src/infrastructure/knowledge-store-service.ts +27 -2
  15. package/src/knowledge/chunker.ts +14 -1
  16. package/src/orchestration/quick-research-orchestrator.ts +13 -2
  17. package/src/orchestration/research-orchestration-service.ts +16 -3
  18. package/src/sdk.ts +11 -2
  19. package/src/security/github-advisories.ts +13 -4
  20. package/src/security/osv-types.ts +1 -1
  21. package/src/skill-install/skill-installer.ts +69 -6
  22. package/src/stackexchange/index.ts +15 -0
  23. package/src/stackexchange/rest-client.ts +13 -24
  24. package/src/tools/research-tool-definition.ts +3 -2
  25. package/src/tools/scrape.ts +6 -4
  26. package/src/tools/security.ts +13 -4
  27. package/src/utils/input-validation.ts +0 -10
  28. package/src/utils/json-utils.ts +0 -78
  29. package/src/utils/log-rotation.ts +21 -6
  30. package/src/utils/log-utils.ts +43 -33
  31. package/src/utils/metrics.ts +0 -8
  32. package/src/utils/shared-links.ts +0 -14
  33. package/src/utils/stdio-capture.ts +44 -7
  34. package/src/utils/text-utils.ts +6 -2
  35. package/src/utils/url-utils.ts +7 -1
  36. package/src/web-research/scraper-utils.ts +8 -3
  37. package/src/web-research/web-scraper.ts +33 -1
  38. package/src/youtube/potoken.ts +26 -2
  39. package/src/youtube/transcript-client.ts +5 -2
@@ -91,9 +91,14 @@ const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s:@]+@/gi
91
91
  // later one exposed. A cookie string is a ;-and-space separated list of secrets,
92
92
  // not a single token — mask the ENTIRE header value to end-of-line.
93
93
  const COOKIE_HEADER_PATTERN = /\b(set[_-]?cookie|cookie)\b(["']?\s*[:=]\s*["']?)([^\r\n]+)/gi;
94
- // key=value / key: "value" pairs whose key names a secret.
94
+ // key=value / key: "value" pairs whose key names a secret. Includes bare
95
+ // `key` (not just `api_key`/`private_key`): several provider query-param
96
+ // APIs (e.g. StackExchange's `?key=<apikey>`) name the credential parameter
97
+ // with no qualifying prefix. Over-redacting an unrelated "key" field (a
98
+ // sort key, a map key) is the accepted trade-off — same one already made for
99
+ // the equally generic bare `session`/`cookie` entries below.
95
100
  const SENSITIVE_KV_PATTERN =
96
- /\b(api[_-]?key|apikey|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|client[_-]?secret|password|passwd|pwd|authorization|bearer|set[_-]?cookie|cookie|session[_-]?id|session|csrf[_-]?token|xsrf[_-]?token|private[_-]?key)\b(["']?\s*[:=]\s*["']?)([^\s"',&)]+)/gi;
101
+ /\b(api[_-]?key|apikey|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|client[_-]?secret|password|passwd|pwd|authorization|bearer|set[_-]?cookie|cookie|session[_-]?id|session|csrf[_-]?token|xsrf[_-]?token|private[_-]?key|key)\b(["']?\s*[:=]\s*["']?)([^\s"',&)]+)/gi;
97
102
  // Well-known opaque credential formats (OpenAI, GitHub, AWS, Slack, Anthropic).
98
103
  const KNOWN_TOKEN_PATTERN =
99
104
  /\b(sk-[A-Za-z0-9_-]{16,}|gh[posru]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g;
@@ -128,22 +133,25 @@ const CONTROL_CHARS_PATTERN = /[\x00-\x1f\x7f]/g;
128
133
  * Applied to every message before it reaches the log file or the console, so
129
134
  * neither sink receives clear-text secrets or unbounded network data.
130
135
  */
136
+ // How much raw input the regex chain below is allowed to scan before a final
137
+ // truncation to MAX_LOG_MESSAGE_LENGTH. A multiple of MAX_LOG_MESSAGE_LENGTH,
138
+ // not 1:1, because most patterns below SHRINK text (a 64-char secret becomes
139
+ // "[REDACTED]"), so more than `keep` raw characters can be needed to fully
140
+ // populate the final truncated output. Nothing past this point can ever
141
+ // survive into the visible output regardless, so skipping it is free — this
142
+ // is what bounds redactSecrets() to O(MAX_LOG_MESSAGE_LENGTH) instead of
143
+ // O(input length) on multi-megabyte scraped payloads.
144
+ const REDACT_SCAN_LENGTH = MAX_LOG_MESSAGE_LENGTH * 4;
145
+
131
146
  export function redactSecrets(message: string): string {
132
- // slice() counts UTF-16 code units, so a naive cut at MAX_LOG_MESSAGE_LENGTH
133
- // can land between the two halves of a surrogate pair (non-BMP characters —
134
- // routine in scraped CJK/emoji content). The console sink then writes a
135
- // lone surrogate, silently mangled to U+FFFD on UTF-8 encode; the JSON-file
136
- // sink emits an unpaired \uXXXX escape that many strict JSON consumers
137
- // reject outright. Same back-off truncateWithMarker (text-utils.ts) already
138
- // applies for the same reason.
139
- let keep = MAX_LOG_MESSAGE_LENGTH;
140
- if (message.length > keep) {
141
- const lastKeptUnit = message.charCodeAt(keep - 1);
142
- if (lastKeptUnit >= 0xd800 && lastKeptUnit <= 0xdbff) keep -= 1;
143
- }
144
- let out = message.length > MAX_LOG_MESSAGE_LENGTH
145
- ? `${message.slice(0, keep)}…[truncated ${message.length - keep} chars]`
146
- : message;
147
+ // Redact BEFORE truncating. Every pattern below needs to see a secret in
148
+ // full to match it (e.g. LONG_HEX_SECRET_PATTERN requires 32+ contiguous
149
+ // hex chars) — truncating first can cut a secret mid-token, leaving
150
+ // whatever fragment survives the cut (e.g. the first 20 chars of a 64-hex
151
+ // browser auth secret) too short for any pattern to recognize, so it was
152
+ // written out in the clear right before the "…[truncated N chars]" marker.
153
+ const rawLength = message.length;
154
+ let out = rawLength > REDACT_SCAN_LENGTH ? message.slice(0, REDACT_SCAN_LENGTH) : message;
147
155
  out = out.replace(ANSI_PATTERN, '');
148
156
  out = out.replace(URL_CREDENTIALS_PATTERN, '$1[REDACTED]@');
149
157
  // Redact whole-token formats BEFORE the key/value pass: the KV value class
@@ -161,6 +169,22 @@ export function redactSecrets(message: string): string {
161
169
  out = out.replace(KNOWN_TOKEN_PATTERN, '[REDACTED]');
162
170
  out = out.replace(PROVIDER_TOKEN_PATTERN, '[REDACTED]');
163
171
  out = out.replace(LONG_HEX_SECRET_PATTERN, '[REDACTED]');
172
+
173
+ // slice() counts UTF-16 code units, so a naive cut at MAX_LOG_MESSAGE_LENGTH
174
+ // can land between the two halves of a surrogate pair (non-BMP characters —
175
+ // routine in scraped CJK/emoji content). The console sink then writes a
176
+ // lone surrogate, silently mangled to U+FFFD on UTF-8 encode; the JSON-file
177
+ // sink emits an unpaired \uXXXX escape that many strict JSON consumers
178
+ // reject outright. Same back-off truncateWithMarker (text-utils.ts) already
179
+ // applies for the same reason.
180
+ let keep = MAX_LOG_MESSAGE_LENGTH;
181
+ if (out.length > keep) {
182
+ const lastKeptUnit = out.charCodeAt(keep - 1);
183
+ if (lastKeptUnit >= 0xd800 && lastKeptUnit <= 0xdbff) keep -= 1;
184
+ }
185
+ if (out.length > MAX_LOG_MESSAGE_LENGTH) {
186
+ out = `${out.slice(0, keep)}…[truncated ${rawLength - keep} chars]`;
187
+ }
164
188
  return out;
165
189
  }
166
190
 
@@ -189,23 +213,9 @@ const TERMINAL_ESCAPE_PATTERN = new RegExp(
189
213
  * otherwise reach the user's terminal verbatim under PI_RESEARCH_CONSOLE_LOG.
190
214
  * The console sink keeps its own explicit CR/LF→"" replace (the form CodeQL
191
215
  * recognizes as the log-injection sanitizer); this handles everything else,
192
- * spacing out any leftover C0/DEL byte (BEL, backspace, …) like
193
- * neutralizeControlChars does.
216
+ * spacing out any leftover C0/DEL byte (BEL, backspace, …) via
217
+ * CONTROL_CHARS_PATTERN.
194
218
  */
195
219
  export function stripTerminalEscapes(message: string): string {
196
220
  return message.replace(TERMINAL_ESCAPE_PATTERN, '').replace(CONTROL_CHARS_PATTERN, ' ');
197
221
  }
198
-
199
- /**
200
- * Neutralize control characters (newlines, carriage returns, etc.) so that
201
- * untrusted content cannot forge or corrupt a raw console log line
202
- * (log-injection defense). Use for sinks that are NOT already structurally
203
- * escaped — the JSON log file escapes control chars on its own.
204
- */
205
- export function neutralizeControlChars(message: string): string {
206
- // Strip CR/LF explicitly first. These are the actual log-injection vector
207
- // (forged/corrupted log lines) and an explicit line-break replacement is the
208
- // form static analysis recognizes as a log-injection sanitizer. The second
209
- // pass removes any remaining C0 control chars + DEL.
210
- return message.replace(/\r\n|\r|\n/g, ' ').replace(CONTROL_CHARS_PATTERN, ' ');
211
- }
@@ -198,14 +198,6 @@ export function runWithRunRegistry<T>(
198
198
  return runRegistryStorage.run(registry, fn);
199
199
  }
200
200
 
201
- /**
202
- * Returns the run-scoped registry if called inside a runWithRunRegistry context,
203
- * undefined otherwise.
204
- */
205
- export function getCurrentRunRegistry(): MetricsRegistry | undefined {
206
- return runRegistryStorage.getStore();
207
- }
208
-
209
201
  // ── Session-level manager ──────────────────────────────────────────────────
210
202
 
211
203
  const MAX_RUN_HISTORY = 10;
@@ -201,20 +201,6 @@ export function registerResearcherScrapes(researchId: string, researcherId: stri
201
201
  urls.forEach(url => set.add(normalizeUrl(url)));
202
202
  }
203
203
 
204
- /**
205
- * Check if a specific researcher scraped a given URL.
206
- */
207
- export function didResearcherScrape(researchId: string, researcherId: string, url: string): boolean {
208
- return researcherScrapes.get(researchId)?.get(researcherId)?.has(normalizeUrl(url)) ?? false;
209
- }
210
-
211
- /**
212
- * Get all URLs scraped by a specific researcher.
213
- */
214
- export function getResearcherScrapes(researchId: string, researcherId: string): string[] {
215
- return Array.from(researcherScrapes.get(researchId)?.get(researcherId) ?? []);
216
- }
217
-
218
204
  /**
219
205
  * Build the Session URL Pool footer for scrape responses.
220
206
  * Shows URLs previously scraped in the session, filtered to exclude the current batch.
@@ -217,7 +217,21 @@ export async function captureStdio<T>(
217
217
  isAnyLoggerCapturingOutput = true;
218
218
  }
219
219
 
220
- const decoder = new TextDecoder();
220
+ // Separate decoder PER logical byte stream, each used with {stream: true}.
221
+ // A single native write/writeSync call can split a multi-byte UTF-8
222
+ // character across two calls — {stream: true} is what lets a TextDecoder
223
+ // carry an incomplete trailing sequence over to the next decode() and
224
+ // reconstruct it, rather than replacing each half with U+FFFD independently
225
+ // (decode() with no options treats every call as a complete, final input).
226
+ // A SHARED decoder across genuinely independent streams would be its own
227
+ // bug even with {stream: true}: stdout, stderr, and raw FD1/FD2 writes can
228
+ // interleave, and a decoder's carried-over partial bytes are only valid
229
+ // when the next call continues the SAME byte stream — mixing streams would
230
+ // try to complete one stream's dangling sequence with another's bytes.
231
+ const stderrDecoder = new TextDecoder();
232
+ const stdoutDecoder = new TextDecoder();
233
+ const fd1SyncDecoder = new TextDecoder();
234
+ const fd2SyncDecoder = new TextDecoder();
221
235
 
222
236
  // Save originals
223
237
  const originalStderrWrite = process.stderr.write;
@@ -269,7 +283,18 @@ export async function captureStdio<T>(
269
283
  // Patch stderr.write
270
284
  (process.stderr.write as any) = (chunk: string | Uint8Array, encodingOrCb?: any, callback?: any) => {
271
285
  const cb = typeof encodingOrCb === 'function' ? encodingOrCb : callback;
272
- const message = typeof chunk === 'string' ? chunk : decoder.decode(chunk);
286
+ const message = typeof chunk === 'string' ? chunk : stderrDecoder.decode(chunk, { stream: true });
287
+
288
+ // A chunk that lands entirely inside a multi-byte character's bytes
289
+ // decodes to '' with {stream: true} — the bytes are buffered inside
290
+ // stderrDecoder and will surface once the completing chunk arrives. This
291
+ // patch (unlike stdout's) always diverts rather than passing through, so
292
+ // without this guard it would write an empty log entry every time a
293
+ // native write happens to split mid-character.
294
+ if (message.length === 0) {
295
+ if (typeof cb === 'function') cb();
296
+ return true;
297
+ }
273
298
 
274
299
  // Check disk space before writing
275
300
  if (!hasSufficientDiskSpace()) {
@@ -294,7 +319,7 @@ export async function captureStdio<T>(
294
319
 
295
320
  // Patch stdout.write
296
321
  (process.stdout.write as any) = (chunk: string | Uint8Array, encodingOrCb?: any, callback?: any) => {
297
- const message = typeof chunk === 'string' ? chunk : decoder.decode(chunk);
322
+ const message = typeof chunk === 'string' ? chunk : stdoutDecoder.decode(chunk, { stream: true });
298
323
 
299
324
  // Native log patterns or plain text that we want to divert from TUI
300
325
  const isNative = isNativeLog(message);
@@ -359,15 +384,27 @@ export async function captureStdio<T>(
359
384
  if (!descriptor || (descriptor.writable || descriptor.set)) {
360
385
  fs.writeSync = (fd: number, chunk: any, ...args: any[]) => {
361
386
  if (fd === 1 || fd === 2) {
362
- const message = typeof chunk === 'string' ? chunk : decoder.decode(chunk);
363
-
387
+ const fdDecoder = fd === 1 ? fd1SyncDecoder : fd2SyncDecoder;
388
+ const message = typeof chunk === 'string' ? chunk : fdDecoder.decode(chunk, { stream: true });
389
+
390
+ // FD 2 diverts unconditionally below (fd === 2 short-circuits
391
+ // shouldDivert), so unlike FD 1 — whose divert conditions already
392
+ // require non-empty content and naturally fall through to a
393
+ // passthrough write otherwise — an empty message here (a chunk that
394
+ // landed entirely inside a multi-byte character, buffered inside
395
+ // fdDecoder for the completing chunk) must be skipped explicitly to
396
+ // avoid writing an empty log entry on every mid-character split.
397
+ if (fd === 2 && message.length === 0) {
398
+ return (typeof chunk === 'string' ? Buffer.from(chunk).length : (chunk as any).length);
399
+ }
400
+
364
401
  const isNative = isNativeLog(message);
365
402
  const boxDrawing = isBoxDrawing(message);
366
403
  const complexTui = isComplexTui(message);
367
404
  const hasAnsi = message.includes('\x1b[');
368
405
 
369
- const shouldDivert = fd === 2 ||
370
- (isNative && !boxDrawing && !complexTui) ||
406
+ const shouldDivert = fd === 2 ||
407
+ (isNative && !boxDrawing && !complexTui) ||
371
408
  (!hasAnsi && !boxDrawing && message.trim().length > 0);
372
409
 
373
410
  if (shouldDivert) {
@@ -188,8 +188,12 @@ export function formatDuration(ms: number): string {
188
188
  if (ms < 1000) return `${ms}ms`;
189
189
  const seconds = ms / 1000;
190
190
  if (seconds < 60) return `${seconds.toFixed(1)}s`;
191
- const minutes = Math.floor(seconds / 60);
192
- const remainingSeconds = Math.round(seconds % 60);
191
+ // Round the total first, then derive minutes/seconds from that integer —
192
+ // rounding remainingSeconds independently of minutes let e.g. 59.6s round up
193
+ // to a nonsensical "Xm 60s" instead of rolling over to the next minute.
194
+ const totalSeconds = Math.round(seconds);
195
+ const minutes = Math.floor(totalSeconds / 60);
196
+ const remainingSeconds = totalSeconds % 60;
193
197
  if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
194
198
  const hours = Math.floor(minutes / 60);
195
199
  const remainingMinutes = minutes % 60;
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { logger } from '../logger.ts';
9
+ import { redactSecrets } from './log-utils.ts';
9
10
 
10
11
  /**
11
12
  * Strips trailing punctuation an LLM commonly appends to a URL in prose
@@ -130,7 +131,12 @@ export function normalizeUrl(url: string): string {
130
131
  // Fallback for invalid URLs: aggressive best-effort normalization.
131
132
  // LLM hallucinations often produce broken URLs — log a warning so we can
132
133
  // trace them, then do our best to clean them for dedup purposes.
133
- logger.debug(`[url-utils] normalizing unparseable URL (${url.slice(0, 200)}): ${_err instanceof Error ? _err.message : String(_err)}`);
134
+ // Redact BEFORE truncating: `url` can still carry userinfo credentials
135
+ // (https://user:pass@host/...) even when unparseable, and cutting at 200
136
+ // chars first can slice a credential mid-token before redactSecrets'
137
+ // patterns ever see the whole thing — same class of bug as the truncate-
138
+ // before-redact fix in log-utils.ts's redactSecrets itself.
139
+ logger.debug(`[url-utils] normalizing unparseable URL (${redactSecrets(url).slice(0, 200)}): ${_err instanceof Error ? _err.message : String(_err)}`);
134
140
 
135
141
  let cleaned = url.trim()
136
142
  // Strip leading/trailing markdown and punctuation often added by LLMs.
@@ -32,10 +32,14 @@ import { isCloudflareBlockError, isTaskTimeoutError, isPoolShutdownError } from
32
32
  * logger.error's error-tracker re-track, the diagnostic error count) focused on
33
33
  * genuine faults. The messages matched here are produced by validateContent
34
34
  * (`Fetch blocked:` / `Fetch returned stub:`), scrapeWithFetch (`HTTP <code>`)
35
- * and extractPdfToMarkdown (`Could not extract content from PDF`) — a
36
- * malformed/encrypted/scanned-image-only PDF is routine on the open web and
35
+ * and extractPdfToMarkdown (`Could not extract content from PDF`, `PDF too
36
+ * large`) — a malformed/encrypted/scanned-image-only PDF, or one that simply
37
+ * exceeds the deliberate 100MB policy cap, is routine on the open web and
37
38
  * just as non-actionable as a stub page or a 404; the remaining cases
38
- * delegate to the shared browser-layer predicates.
39
+ * delegate to the shared browser-layer predicates. Deliberately does NOT
40
+ * match 'PDF extraction unavailable' (a pdf-oxide-wasm native-module load
41
+ * failure) — that is an infrastructure fault affecting every URL, not a
42
+ * routine per-PDF outcome, and must still surface at ERROR.
39
43
  */
40
44
  export function isBenignScrapeFailure(error: unknown): boolean {
41
45
  const msg = error instanceof Error ? error.message : String(error);
@@ -43,6 +47,7 @@ export function isBenignScrapeFailure(error: unknown): boolean {
43
47
  msg.startsWith('Fetch blocked:') || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
44
48
  msg.startsWith('Fetch returned stub:') || // nav-only / near-empty page
45
49
  msg.startsWith('Could not extract content from PDF') || // malformed/encrypted/scanned-only PDF
50
+ msg.startsWith('PDF too large') || // exceeds the deliberate 100MB policy cap
46
51
  /^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
47
52
  isCloudflareBlockError(error) ||
48
53
  isTaskTimeoutError(error) ||
@@ -88,8 +88,40 @@ async function extractPdfToMarkdown(bytes: Uint8Array): Promise<string> {
88
88
  }
89
89
 
90
90
  const pdfExtractionStart = Date.now();
91
+
92
+ // The native module load is caught SEPARATELY from the parse/extraction
93
+ // logic below: a failure here (missing prebuilt binary for this platform,
94
+ // corrupted install) is an INFRASTRUCTURE fault — PDF extraction is broken
95
+ // for every URL, not just this one — not the routine, expected outcome of
96
+ // a malformed/encrypted/scanned-image-only PDF. Sharing one catch with the
97
+ // parse logic used to log both at `debug` and lump both under
98
+ // 'extraction_failed', so a broken install produced an endless stream of
99
+ // silent per-PDF "failures" instead of ever surfacing the real cause.
100
+ let WasmPdfDocument: (new (bytes: Uint8Array) => {
101
+ pageCount(): number;
102
+ toMarkdownAll(): string;
103
+ toMarkdown(page: number): string;
104
+ free(): void;
105
+ });
106
+ try {
107
+ ({ WasmPdfDocument } = await import('pdf-oxide-wasm'));
108
+ } catch (e) {
109
+ const msg = e instanceof Error ? e.message : String(e);
110
+ logger.error(`[Scrapers] pdf-oxide-wasm failed to load — PDF extraction is unavailable (not just for this URL): ${msg}`);
111
+ metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'native_module_unavailable' });
112
+ errorTracker.trackError(e instanceof Error ? e : String(e), {
113
+ component: 'scrapers',
114
+ operation: 'pdf-extract',
115
+ contentType: 'pdf',
116
+ errorType: 'native_module_unavailable',
117
+ });
118
+ // Deliberately does NOT start with 'Could not extract content from PDF' —
119
+ // isBenignScrapeFailure must NOT classify this as the routine per-PDF
120
+ // outcome it is for the parse-failure branch below.
121
+ throw new Error(`PDF extraction unavailable (pdf-oxide-wasm failed to load: ${msg})`, { cause: e });
122
+ }
123
+
91
124
  try {
92
- const { WasmPdfDocument } = await import('pdf-oxide-wasm');
93
125
  const doc = new WasmPdfDocument(bytes);
94
126
  const pageCount = doc.pageCount();
95
127
 
@@ -42,6 +42,7 @@
42
42
  */
43
43
 
44
44
  import { logger } from '../logger.ts';
45
+ import { safeUnref } from '../utils/safe-unref.ts';
45
46
  import { metrics } from '../utils/metrics.ts';
46
47
 
47
48
  /**
@@ -76,6 +77,15 @@ function describeError(error: unknown): string {
76
77
  // Module-level mutex: serialize all globalThis mutation across concurrent callers.
77
78
  let mintChain: Promise<unknown> = Promise.resolve();
78
79
 
80
+ // How long the mutex waits for a straggling doMint() before force-releasing
81
+ // the chain for the next caller. doMint() awaits non-fetch operations with no
82
+ // abort signal (interpreter eval, BotGuard VM execution against a
83
+ // hostile/malformed attestation script) that could in principle hang forever;
84
+ // without this bound, a single wedged call would permanently break YouTube
85
+ // transcript fetching for the rest of the process. Mirrors the hard-release
86
+ // timer on the browser page-creation mutex in thread-worker-messaging.ts.
87
+ const MINT_HARD_RELEASE_MS = 5 * 60 * 1000;
88
+
79
89
  /**
80
90
  * Mint a session-bound token (for `identifiers[0]`, the visitorData) plus one
81
91
  * content-bound token per remaining identifier (the video IDs), all from a
@@ -90,8 +100,22 @@ export async function mintPoTokens(
90
100
  ): Promise<Map<string, string>> {
91
101
  // Chain on the mutex so only one bridged section runs at a time.
92
102
  const run = mintChain.then(() => doMint(identifiers, opts), () => doMint(identifiers, opts));
93
- // Keep the chain alive regardless of this call's success/failure.
94
- mintChain = run.then(() => undefined, () => undefined);
103
+ // Keep the chain alive regardless of this call's success/failure, but bound
104
+ // how long the NEXT caller waits on it: if `run` never settles, force the
105
+ // chain open after the hard timeout rather than wedging every future
106
+ // mintPoTokens() call behind it forever. The abandoned `run` may still
107
+ // mutate globalThis in the background after this — an accepted trade-off
108
+ // in the pathological case, same as the sibling browser-worker fix.
109
+ const settled = run.then(() => undefined, () => undefined);
110
+ let hardTimer: NodeJS.Timeout;
111
+ const hardRelease = new Promise<void>(resolve => {
112
+ hardTimer = setTimeout(() => {
113
+ logger.warn('[youtube] potoken mint mutex force-released — doMint() did not settle within the hard timeout');
114
+ resolve();
115
+ }, MINT_HARD_RELEASE_MS);
116
+ safeUnref(hardTimer);
117
+ });
118
+ mintChain = Promise.race([settled, hardRelease]).finally(() => clearTimeout(hardTimer));
95
119
  return run;
96
120
  }
97
121
 
@@ -20,6 +20,7 @@
20
20
  import { withTimeout, retryWithBackoff, createTimeoutSignal } from '../web-research/retry-utils.ts';
21
21
  import { CircuitBreaker } from '../utils/circuit-breaker.ts';
22
22
  import { logger } from '../logger.ts';
23
+ import { redactSecrets } from '../utils/log-utils.ts';
23
24
  import { metrics } from '../utils/metrics.ts';
24
25
  import { readTextCapped } from '../utils/http-body.ts';
25
26
  import { mintPoTokens } from './potoken.ts';
@@ -355,8 +356,10 @@ function parseJson3(body: string): string {
355
356
  // Log the real cause: an unparseable body is NOT necessarily an empty
356
357
  // bot-protection response (the caller's default diagnosis) — it can be an HTML
357
358
  // interstitial or a response-shape change. A short snippet disambiguates without
358
- // dumping a huge/again-sensitive body.
359
- logger.debug(`[youtube] timedtext body was not valid json3 (first 120 chars): ${body.slice(0, 120)}`);
359
+ // dumping a huge/again-sensitive body. Redact BEFORE truncating to 120 chars —
360
+ // slicing first can cut a secret-shaped token mid-way, leaving a fragment too
361
+ // short for redactSecrets' whole-token patterns to recognize.
362
+ logger.debug(`[youtube] timedtext body was not valid json3 (first 120 chars): ${redactSecrets(body).slice(0, 120)}`);
360
363
  return '';
361
364
  }
362
365
  const events = (parsed as { events?: unknown[] }).events;