@lincoln504/pi-research 1.3.3 → 1.3.4

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.
@@ -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;
@@ -129,21 +134,13 @@ const CONTROL_CHARS_PATTERN = /[\x00-\x1f\x7f]/g;
129
134
  * neither sink receives clear-text secrets or unbounded network data.
130
135
  */
131
136
  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;
137
+ // Redact BEFORE truncating. Every pattern below needs to see a secret in
138
+ // full to match it (e.g. LONG_HEX_SECRET_PATTERN requires 32+ contiguous
139
+ // hex chars) — truncating first can cut a secret mid-token, leaving
140
+ // whatever fragment survives the cut (e.g. the first 20 chars of a 64-hex
141
+ // browser auth secret) too short for any pattern to recognize, so it was
142
+ // written out in the clear right before the "…[truncated N chars]" marker.
143
+ let out = message;
147
144
  out = out.replace(ANSI_PATTERN, '');
148
145
  out = out.replace(URL_CREDENTIALS_PATTERN, '$1[REDACTED]@');
149
146
  // Redact whole-token formats BEFORE the key/value pass: the KV value class
@@ -161,6 +158,22 @@ export function redactSecrets(message: string): string {
161
158
  out = out.replace(KNOWN_TOKEN_PATTERN, '[REDACTED]');
162
159
  out = out.replace(PROVIDER_TOKEN_PATTERN, '[REDACTED]');
163
160
  out = out.replace(LONG_HEX_SECRET_PATTERN, '[REDACTED]');
161
+
162
+ // slice() counts UTF-16 code units, so a naive cut at MAX_LOG_MESSAGE_LENGTH
163
+ // can land between the two halves of a surrogate pair (non-BMP characters —
164
+ // routine in scraped CJK/emoji content). The console sink then writes a
165
+ // lone surrogate, silently mangled to U+FFFD on UTF-8 encode; the JSON-file
166
+ // sink emits an unpaired \uXXXX escape that many strict JSON consumers
167
+ // reject outright. Same back-off truncateWithMarker (text-utils.ts) already
168
+ // applies for the same reason.
169
+ let keep = MAX_LOG_MESSAGE_LENGTH;
170
+ if (out.length > keep) {
171
+ const lastKeptUnit = out.charCodeAt(keep - 1);
172
+ if (lastKeptUnit >= 0xd800 && lastKeptUnit <= 0xdbff) keep -= 1;
173
+ }
174
+ if (out.length > MAX_LOG_MESSAGE_LENGTH) {
175
+ out = `${out.slice(0, keep)}…[truncated ${out.length - keep} chars]`;
176
+ }
164
177
  return out;
165
178
  }
166
179
 
@@ -189,23 +202,9 @@ const TERMINAL_ESCAPE_PATTERN = new RegExp(
189
202
  * otherwise reach the user's terminal verbatim under PI_RESEARCH_CONSOLE_LOG.
190
203
  * The console sink keeps its own explicit CR/LF→"" replace (the form CodeQL
191
204
  * recognizes as the log-injection sanitizer); this handles everything else,
192
- * spacing out any leftover C0/DEL byte (BEL, backspace, …) like
193
- * neutralizeControlChars does.
205
+ * spacing out any leftover C0/DEL byte (BEL, backspace, …) via
206
+ * CONTROL_CHARS_PATTERN.
194
207
  */
195
208
  export function stripTerminalEscapes(message: string): string {
196
209
  return message.replace(TERMINAL_ESCAPE_PATTERN, '').replace(CONTROL_CHARS_PATTERN, ' ');
197
210
  }
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) {
@@ -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