@lincoln504/pi-research 1.2.0 → 1.3.0

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 (81) hide show
  1. package/dist/cli.mjs +1334 -750
  2. package/dist/thread-worker.mjs +194 -136
  3. package/docs/ARCHITECTURE.md +1 -1
  4. package/docs/CHANGELOG.md +114 -16
  5. package/package.json +1 -1
  6. package/scripts/cleanup.cjs +64 -2
  7. package/skills/pi-research/SKILL.md +1 -1
  8. package/src/cleanup/research-cleanup.ts +12 -4
  9. package/src/cli.ts +228 -70
  10. package/src/config.ts +78 -2
  11. package/src/core/interfaces/health-check-interfaces.ts +3 -39
  12. package/src/core/interfaces/planning-interfaces.ts +8 -0
  13. package/src/core/interfaces/service-names.ts +0 -1
  14. package/src/core/interfaces/state-manager-interfaces.ts +10 -2
  15. package/src/core/llm/agentic-repair.ts +14 -0
  16. package/src/core/planning-service.ts +43 -1
  17. package/src/core/service-initialization.ts +6 -14
  18. package/src/core/service-interfaces.ts +1 -1
  19. package/src/healthcheck/index.ts +7 -2
  20. package/src/infrastructure/browser/browser-client.ts +64 -9
  21. package/src/infrastructure/browser/browser-server.ts +24 -7
  22. package/src/infrastructure/browser/browser-task-scheduler.ts +58 -12
  23. package/src/infrastructure/browser/cleanup-utils.ts +50 -2
  24. package/src/infrastructure/browser/client-agent.ts +6 -6
  25. package/src/infrastructure/browser/config.ts +14 -3
  26. package/src/infrastructure/browser/scheduler-factory.ts +18 -3
  27. package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
  28. package/src/infrastructure/browser/thread-worker-types.ts +3 -1
  29. package/src/infrastructure/browser/thread-worker.mjs +194 -136
  30. package/src/infrastructure/browser/worker-pool-manager.ts +7 -2
  31. package/src/infrastructure/embedding/embedding-factory.ts +35 -6
  32. package/src/infrastructure/embedding/embedding-server.ts +40 -7
  33. package/src/infrastructure/file-lock-service.ts +72 -2
  34. package/src/infrastructure/knowledge-store-service.ts +48 -3
  35. package/src/infrastructure/process-lifecycle-service.ts +28 -5
  36. package/src/infrastructure/research-run-semaphore.ts +37 -3
  37. package/src/infrastructure/state/state-browser-api.ts +5 -2
  38. package/src/infrastructure/state/state-browser-manager.ts +15 -1
  39. package/src/infrastructure/state/state-manager-service.ts +4 -4
  40. package/src/infrastructure/state/state-manager.ts +15 -3
  41. package/src/knowledge/chunker.ts +88 -1
  42. package/src/knowledge/embedder.ts +136 -43
  43. package/src/knowledge/store.ts +66 -12
  44. package/src/logger.ts +5 -1
  45. package/src/observers/research-observer-impl.ts +31 -0
  46. package/src/orchestration/abort-utils.ts +60 -0
  47. package/src/orchestration/deep-research-orchestrator.ts +12 -2
  48. package/src/orchestration/headless-observer.ts +8 -0
  49. package/src/orchestration/research-orchestration-service.ts +63 -11
  50. package/src/orchestration/research-session-service.ts +37 -11
  51. package/src/orchestration/researcher-executor.ts +100 -20
  52. package/src/orchestration/researcher.ts +39 -20
  53. package/src/orchestration/session-state.ts +11 -0
  54. package/src/sdk.ts +125 -19
  55. package/src/security/index.ts +51 -30
  56. package/src/security/nvd-client.ts +7 -2
  57. package/src/skill-install/skill-installer.ts +18 -2
  58. package/src/stackexchange/index.ts +20 -5
  59. package/src/stackexchange/output/table.ts +7 -2
  60. package/src/stackexchange/rest-client.ts +35 -1
  61. package/src/tools/research-tool-definition.ts +8 -2
  62. package/src/tools/scrape.ts +48 -25
  63. package/src/tools/search.ts +10 -1
  64. package/src/tools/security.ts +68 -7
  65. package/src/tui/research-health.ts +18 -2
  66. package/src/utils/circuit-breaker.ts +9 -2
  67. package/src/utils/disk-space-checker.ts +11 -6
  68. package/src/utils/json-utils.ts +116 -76
  69. package/src/utils/llm-usage.ts +17 -1
  70. package/src/utils/log-rotation.ts +42 -10
  71. package/src/utils/log-utils.ts +40 -0
  72. package/src/utils/metrics-summary.ts +27 -9
  73. package/src/utils/stdio-capture.ts +63 -22
  74. package/src/utils/text-utils.ts +8 -1
  75. package/src/utils/tool-usage-tracker.ts +9 -2
  76. package/src/web-research/browser-search.ts +26 -7
  77. package/src/web-research/scraper-utils.ts +13 -0
  78. package/src/web-research/search.ts +6 -2
  79. package/src/web-research/types.ts +4 -2
  80. package/src/web-research/web-scraper.ts +65 -4
  81. package/src/core/health-check-service.ts +0 -265
@@ -202,9 +202,16 @@ export class BrowserTaskScheduler implements IScheduler {
202
202
 
203
203
  async startServer(): Promise<number> {
204
204
  this.server = new BrowserServer({
205
- onSearch: (q) => this.runSearch(q),
206
- onScrape: (u) => this.runScrape(u),
207
- onHealthCheck: () => this.runHealthCheck(),
205
+ // The per-request signal aborts when the follower's socket closes
206
+ // before completion, so the leader-side task releases its queue
207
+ // claim (abort-while-queued/running is the PriorityTaskQueue's own
208
+ // pinned semantics) instead of holding the slot to full timeout.
209
+ // Healthcheck mirrors search/scrape here even though its one current
210
+ // production caller (src/healthcheck/index.ts) never passes a signal —
211
+ // the wiring must not silently drop one the moment a caller does.
212
+ onSearch: (q, signal) => this.runSearch(q, undefined, signal),
213
+ onScrape: (u, signal) => this.runScrape(u, undefined, signal),
214
+ onHealthCheck: (signal) => this.runHealthCheck(undefined, signal),
208
215
  });
209
216
  // FIX (#21): Expose auth secret to child processes via env
210
217
  process.env['PI_BROWSER_AUTH_SECRET'] = getBrowserServerAuthSecret();
@@ -273,6 +280,18 @@ export class BrowserTaskScheduler implements IScheduler {
273
280
  logger.debug(`[BrowserTaskScheduler] Search abandoned during shutdown: "${query}"`);
274
281
  throw error;
275
282
  }
283
+ // The caller's own signal aborting (follower socket closed / research run
284
+ // cancelled) is what PriorityTaskQueue's abort-while-queued/running
285
+ // rejection above reports. This is routine, expected cancellation, not a
286
+ // fault — cancelling a run routinely cancels its in-flight browser tasks,
287
+ // so this fires often. Checking the signal directly (rather than matching
288
+ // the queue's rejection text) mirrors how BrowserServer's own catch block
289
+ // and task-execution-service.ts already classify this. Demote to DEBUG so
290
+ // it doesn't inflate ERROR counts / browser_search_errors_total.
291
+ if (signal?.aborted) {
292
+ logger.debug(`[BrowserTaskScheduler] Search cancelled by caller: "${query}"`);
293
+ throw error;
294
+ }
276
295
  logger.error(`[BrowserTaskScheduler] Search failed: "${query}"`, error);
277
296
  metrics.increment('browser_search_errors_total', 1);
278
297
  errorTracker.trackError(error instanceof Error ? error : String(error), {
@@ -288,8 +307,10 @@ export class BrowserTaskScheduler implements IScheduler {
288
307
  }
289
308
 
290
309
  const duration = Date.now() - startTime;
291
- metrics.observe('browser_search_duration_ms', duration, { status: 'success' });
292
- metrics.increment('browser_search_requests_total', 1, { status: 'success' });
310
+ // In-band worker error checked FIRST: a task the worker reports as failed
311
+ // must count only as an error. Recording success unconditionally before
312
+ // this check double-counted every worker-reported failure as
313
+ // success+error, so dashboards read healthy through a full outage.
293
314
  if (result.error) {
294
315
  metrics.increment('browser_search_requests_total', 1, { status: 'error' });
295
316
  errorTracker.trackError(new Error(result.error), {
@@ -301,6 +322,8 @@ export class BrowserTaskScheduler implements IScheduler {
301
322
  });
302
323
  throw new Error(result.error);
303
324
  }
325
+ metrics.observe('browser_search_duration_ms', duration, { status: 'success' });
326
+ metrics.increment('browser_search_requests_total', 1, { status: 'success' });
304
327
  return result.results;
305
328
  }
306
329
 
@@ -347,6 +370,12 @@ export class BrowserTaskScheduler implements IScheduler {
347
370
  logger.debug(`[BrowserTaskScheduler] Scrape abandoned during shutdown: "${url}"`);
348
371
  throw error;
349
372
  }
373
+ // See runSearch: the caller's own signal aborting is routine cancellation
374
+ // (follower disconnect / run cancelled), not a fault — demote to DEBUG.
375
+ if (signal?.aborted) {
376
+ logger.debug(`[BrowserTaskScheduler] Scrape cancelled by caller: "${url}"`);
377
+ throw error;
378
+ }
350
379
  metrics.increment('browser_scrape_errors_total', 1);
351
380
  errorTracker.trackError(error instanceof Error ? error : String(error), {
352
381
  component: 'browser-manager',
@@ -360,8 +389,7 @@ export class BrowserTaskScheduler implements IScheduler {
360
389
  }
361
390
 
362
391
  const duration = Date.now() - startTime;
363
- metrics.observe('browser_scrape_duration_ms', duration, { status: 'success' });
364
- metrics.increment('browser_scrape_requests_total', 1, { status: 'success' });
392
+ // In-band error first — success-side recording must not precede it (see runSearch).
365
393
  if (result.error) {
366
394
  metrics.increment('browser_scrape_requests_total', 1, { status: 'error' });
367
395
  errorTracker.trackError(new Error(result.error), {
@@ -372,6 +400,8 @@ export class BrowserTaskScheduler implements IScheduler {
372
400
  });
373
401
  throw new Error(result.error);
374
402
  }
403
+ metrics.observe('browser_scrape_duration_ms', duration, { status: 'success' });
404
+ metrics.increment('browser_scrape_requests_total', 1, { status: 'success' });
375
405
  return result;
376
406
  }
377
407
 
@@ -412,6 +442,13 @@ export class BrowserTaskScheduler implements IScheduler {
412
442
  logger.debug(`[BrowserTaskScheduler] Healthcheck abandoned during shutdown`);
413
443
  throw error;
414
444
  }
445
+ // See runSearch: the caller's own signal aborting is routine cancellation,
446
+ // not a fault — demote to DEBUG. Reachable now that startServer() forwards
447
+ // a signal into onHealthCheck (see BrowserServer's onHealthCheck wiring).
448
+ if (signal?.aborted) {
449
+ logger.debug(`[BrowserTaskScheduler] Healthcheck cancelled by caller`);
450
+ throw error;
451
+ }
415
452
  metrics.increment('browser_healthcheck_errors_total', 1);
416
453
  errorTracker.trackError(error instanceof Error ? error : String(error), {
417
454
  component: 'browser-manager',
@@ -425,10 +462,10 @@ export class BrowserTaskScheduler implements IScheduler {
425
462
  }
426
463
 
427
464
  const duration = Date.now() - startTime;
428
- metrics.observe('browser_healthcheck_duration_ms', duration, { status: 'success' });
429
- metrics.increment('browser_healthcheck_requests_total', 1, { status: 'success' });
430
- metrics.setGauge('browser_pool_health', 1);
431
- logger.debug(`[Scheduler] Healthcheck completed in ${duration}ms`);
465
+ // In-band error first — success-side recording must not precede it (see
466
+ // runSearch). This path additionally owns the browser_pool_health gauge:
467
+ // setting it to 1 before the check flickered the pool healthy on every
468
+ // failed probe during an outage.
432
469
  if (result.error) {
433
470
  metrics.increment('browser_healthcheck_requests_total', 1, { status: 'error' });
434
471
  metrics.setGauge('browser_pool_health', 0);
@@ -440,6 +477,10 @@ export class BrowserTaskScheduler implements IScheduler {
440
477
  });
441
478
  throw new Error(result.error);
442
479
  }
480
+ metrics.observe('browser_healthcheck_duration_ms', duration, { status: 'success' });
481
+ metrics.increment('browser_healthcheck_requests_total', 1, { status: 'success' });
482
+ metrics.setGauge('browser_pool_health', 1);
483
+ logger.debug(`[Scheduler] Healthcheck completed in ${duration}ms`);
443
484
  return result;
444
485
  }
445
486
 
@@ -505,7 +546,12 @@ export class BrowserTaskScheduler implements IScheduler {
505
546
  logger.warn('[Scheduler] Could not read browser server state during shutdown:', err);
506
547
  }
507
548
  if (serverInfo?.pid === process.pid && serverInfo?.schedulerId === this.schedulerId) {
508
- await this.stateManager.clearBrowserServer().catch((err) => {
549
+ // CAS: this belief-check (pid + schedulerId match) happens outside any
550
+ // lock, so pass it as `expected` rather than relying on it as an
551
+ // assumption — if a successor claimed the slot between the read above
552
+ // and this clear, the mismatch makes the clear a no-op instead of
553
+ // deregistering the new owner.
554
+ await this.stateManager.clearBrowserServer({ pid: process.pid, schedulerId: this.schedulerId }).catch((err) => {
509
555
  logger.warn('[Scheduler] Failed to clear browser server state during shutdown:', err);
510
556
  });
511
557
  }
@@ -108,6 +108,48 @@ async function isProfileLive(profileDir: string): Promise<boolean> {
108
108
  return false;
109
109
  }
110
110
 
111
+ /** Canonicalize a path for identity comparison: realpath when resolvable (macOS
112
+ * /tmp is a symlink to /private/tmp), else a plain resolve; case-folded on
113
+ * Windows, whose filesystem compares case-insensitively. */
114
+ async function canonicalizeForCompare(p: string): Promise<string> {
115
+ let resolved: string;
116
+ try {
117
+ resolved = await fs.realpath(p);
118
+ } catch {
119
+ resolved = path.resolve(p);
120
+ }
121
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
122
+ }
123
+
124
+ /**
125
+ * May the unrecognized-entry deletion branch run against this explicit baseDir?
126
+ *
127
+ * That branch recursively rm's ANY stale subdirectory, so it is only safe inside
128
+ * a directory pi-research owns outright. Two conditions gate it: the dir must not
129
+ * BE the shared system temp dir (realpath-compared on both sides — a
130
+ * PI_RESEARCH_TMP_DIR pointed at /tmp once made every same-user /tmp entry — ssh
131
+ * agents, build caches — a sweep candidate), and the path must carry a
132
+ * pi-research segment as positive evidence of ownership. When this returns false
133
+ * the prefix-matched playwright profile cleanup still runs; only unknowns are
134
+ * spared.
135
+ */
136
+ async function isPiOwnedSweepDir(dir: string): Promise<boolean> {
137
+ const [realDir, realTmp] = await Promise.all([
138
+ canonicalizeForCompare(dir),
139
+ canonicalizeForCompare(os.tmpdir()),
140
+ ]);
141
+ if (realDir === realTmp) return false;
142
+ const hasPiSegment = (p: string) =>
143
+ p.split(/[\\/]+/).some((segment) => segment.toLowerCase().startsWith('pi-research'));
144
+ // Only the CANONICAL path counts. A symlinked dir whose target lacks the
145
+ // marker is NOT sweep-safe: approving it by its configured name would send the
146
+ // recursive rm THROUGH the symlink into a directory we do not own (e.g. a
147
+ // /tmp/pi-research/profiles link pre-created by another user under a
148
+ // world-writable PI_RESEARCH_TMP_DIR). Unknowns are spared; the prefix-matched
149
+ // playwright profile cleanup still runs either way.
150
+ return hasPiSegment(realDir);
151
+ }
152
+
111
153
  /**
112
154
  * Reclaim leaked Playwright/Camoufox profile directories.
113
155
  *
@@ -134,6 +176,11 @@ export async function cleanupStaleProfiles(
134
176
  let removed = 0;
135
177
  let errors = 0;
136
178
 
179
+ // Explicit-baseDir mode considers EVERY entry a candidate, including ones it
180
+ // does not recognize; deleting unknowns is only permitted inside a dir that is
181
+ // provably pi-owned and not the shared system temp dir (see isPiOwnedSweepDir).
182
+ const sweepUnrecognized = usePrefix ? false : await isPiOwnedSweepDir(scanDir);
183
+
137
184
  try {
138
185
  const entries = await fs.readdir(scanDir);
139
186
  const now = Date.now();
@@ -171,8 +218,9 @@ export async function cleanupStaleProfiles(
171
218
  }
172
219
 
173
220
  // Unrecognized entry (only reachable with an explicit baseDir): be conservative —
174
- // only reclaim if both old AND not live, so we never delete something unknown promptly.
175
- if (now - stats.mtimeMs > staleMs && !(await isProfileLive(fullPath))) {
221
+ // only reclaim inside a pi-owned dir, and only if both old AND not live,
222
+ // so nothing unknown is ever deleted promptly or outside our ownership.
223
+ if (sweepUnrecognized && now - stats.mtimeMs > staleMs && !(await isProfileLive(fullPath))) {
176
224
  await fs.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
177
225
  removed++;
178
226
  }
@@ -10,17 +10,17 @@ import * as http from 'node:http';
10
10
  /**
11
11
  * Global HTTP Agent for high-concurrency client requests
12
12
  *
13
- * IMPORTANT: The socket timeout must be longer than the BrowserClient request timeout (60s,
14
- * REQUEST_TIMEOUT_MS in browser-client.ts) to prevent premature socket closure when the
15
- * browser is slow or the queue is deep.
13
+ * IMPORTANT: The socket timeout must exceed the largest possible BrowserClient
14
+ * request timeout (CLIENT_TIMEOUT_CAP_MS = 300s in browser-client.ts) so the
15
+ * request-level timer — whose error text is classified by the retry gates —
16
+ * always answers before the socket layer tears the connection down.
16
17
  */
17
18
  const clientAgent = new http.Agent({
18
19
  keepAlive: true,
19
20
  maxSockets: 100, // Allow up to 100 concurrent requests to the leader
20
21
  maxFreeSockets: 10,
21
- // 180s (3x the client timeout) to handle slow browser responses
22
- // and prevent "socket hang up" errors during peak load
23
- timeout: 180000
22
+ // Above CLIENT_TIMEOUT_CAP_MS (300s) with margin — see the invariant above.
23
+ timeout: 330000
24
24
  });
25
25
 
26
26
  /**
@@ -61,8 +61,9 @@ export function getBrowserCacheDir(): string {
61
61
  * os.tmpdir(): on systems where /tmp is tmpfs (RAM-backed), placing several
62
62
  * browser profiles there consumes RAM and, with an OOM killer like earlyoom
63
63
  * active, can contribute to the whole session being killed. Override with
64
- * PI_RESEARCH_TMP_DIR (config.TMP_DIR) — e.g. point it back at the system temp
65
- * dir to deliberately use tmpfs/RAM when there is memory headroom.
64
+ * PI_RESEARCH_TMP_DIR (config.TMP_DIR) — e.g. point it at the system temp dir
65
+ * to deliberately use tmpfs/RAM when there is memory headroom; profiles then
66
+ * land in <TMP_DIR>/pi-research/profiles, never in TMP_DIR itself.
66
67
  *
67
68
  * The directory is created if missing, because Playwright requires the parent
68
69
  * of its mkdtemp profile dir to already exist.
@@ -71,7 +72,17 @@ export function getBrowserProfileDir(config?: Config): string {
71
72
  const configured = (config || getConfig()).TMP_DIR;
72
73
  let dir: string;
73
74
  if (configured && configured.trim().length > 0) {
74
- dir = configured;
75
+ // Containment invariant: profiles live in a pi-research-owned
76
+ // SUBDIRECTORY of the configured dir, never the configured dir itself.
77
+ // The pool-startup sweep (cleanupStaleProfiles with an explicit baseDir)
78
+ // treats every entry of this directory as a reclaim candidate, so using
79
+ // PI_RESEARCH_TMP_DIR verbatim — e.g. pointed at /tmp per the tmpfs
80
+ // opt-in above — put unrelated same-user directories (ssh agents, build
81
+ // caches) on the sweep's candidate list. The pi-research/profiles shape
82
+ // also matches PI_BROWSER_MARKER's profile-path fallback in
83
+ // browser-cleanup.ts, so orphan detection keeps working for relocated
84
+ // profiles.
85
+ dir = join(configured, 'pi-research', 'profiles');
75
86
  } else {
76
87
  const cacheHome = process.env['XDG_CACHE_HOME'] || join(homedir(), '.cache');
77
88
  dir = join(cacheHome, 'pi-research', 'profiles');
@@ -132,7 +132,16 @@ export async function forceSchedulerRestart(forceClearRemoteState: boolean = fal
132
132
  }
133
133
 
134
134
  if (shouldClearState) {
135
- await stateManager.clearBrowserServer().catch((error) => {
135
+ // Compare-and-delete against the registration we just read: without a
136
+ // lock spanning the read-decide-clear sequence above, a concurrent
137
+ // process can independently detect the same dead leader, complete its
138
+ // own (properly locked) election in getScheduler(), and publish a
139
+ // fresh registration in this exact window. An unconditional clear here
140
+ // would wipe that brand-new, legitimate leader — it keeps running but
141
+ // becomes undiscoverable via state, so the next caller elects a
142
+ // second, redundant leader. See StateBrowserManager.clearBrowserServer.
143
+ const expected = serverInfo ? { pid: serverInfo.pid, schedulerId: serverInfo.schedulerId } : undefined;
144
+ await stateManager.clearBrowserServer(expected).catch((error) => {
136
145
  logger.warn('[Scheduler] Failed to clear browser server from state:', error);
137
146
  });
138
147
  }
@@ -222,12 +231,18 @@ export async function getScheduler(config?: Config, container: ServiceContainer
222
231
  if (serverInfo.pid !== process.pid) {
223
232
  logger.log(`[Scheduler] Bypassing stale scheduler process (PID ${serverInfo.pid}) by clearing state...`);
224
233
  }
225
- await stateManager.clearBrowserServer();
234
+ // Defense-in-depth CAS: this read-then-clear is entirely inside
235
+ // the browser-init lock, which serializes other getScheduler()
236
+ // callers, but callers that clear without taking this lock
237
+ // (forceSchedulerRestart, scheduler shutdown) are not excluded —
238
+ // pass what was just read so a mismatch no-ops instead of
239
+ // deleting a registration this code didn't observe.
240
+ await stateManager.clearBrowserServer({ pid: serverInfo.pid, schedulerId: serverInfo.schedulerId });
226
241
  } else {
227
242
  const portOk = await isPortListening(serverInfo.port);
228
243
  if (!portOk) {
229
244
  logger.warn(`[Scheduler] PID ${serverInfo.pid} is alive but port ${serverInfo.port} is not listening — clearing stale state and starting fresh.`);
230
- await stateManager.clearBrowserServer().catch((e) => {
245
+ await stateManager.clearBrowserServer({ pid: serverInfo.pid, schedulerId: serverInfo.schedulerId }).catch((e) => {
231
246
  logger.warn('[Scheduler] Failed to clear stale browser server state:', e);
232
247
  });
233
248
  } else {
@@ -8,6 +8,7 @@ let workerId: string = '';
8
8
 
9
9
  import { appendFileSync } from 'node:fs';
10
10
  import { redactSecrets } from '../../utils/log-utils.ts';
11
+ import { MAX_HTML_SIZE, MAX_PDF_SIZE } from '../../web-research/scraper-types.ts';
11
12
 
12
13
  /**
13
14
  * Mutex lock to serialize page creation. Playwright/Firefox can deadlock if newPage()
@@ -311,7 +312,7 @@ export async function executeScrapeTask(
311
312
  _context: any,
312
313
  url: string,
313
314
  signal?: AbortSignal
314
- ): Promise<{ contentType: string; html?: string; buffer?: Buffer; jitter: number }> {
315
+ ): Promise<{ contentType: string; html?: string; bufferB64?: string; jitter: number }> {
315
316
  const page = await createPageSafe(_context);
316
317
  const SCRAPE_TIMEOUT = parseInt(process.env['PI_RESEARCH_SCRAPE_TIMEOUT_MS'] || '15000', 10);
317
318
  page.setDefaultTimeout(SCRAPE_TIMEOUT);
@@ -458,15 +459,47 @@ export async function executeScrapeTask(
458
459
  }
459
460
 
460
461
  const contentType = (await response?.headerValue('content-type')) || '';
462
+ // HTTP status of the final navigation response. Never consulting it let
463
+ // 404/500/403 pages return as normal {contentType, html} results — major
464
+ // sites' rich error pages cleared the stub gate, were cached as scrape
465
+ // SUCCESS, and became citable. The fetch layer enforces !response.ok; this
466
+ // layer must match it (`HTTP <status>` is benign-classified per URL). The
467
+ // one legitimate exception is a Cloudflare challenge, which arrives with
468
+ // 403/503 — the HTML branch defers to the challenge wait below for that.
469
+ const status = response?.status?.() ?? 0;
461
470
 
462
471
  if (contentType.includes('application/pdf')) {
463
472
  if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
473
+ // An errored "PDF" is an error page: fail BEFORE buffering its body.
474
+ if (status >= 400) throw new Error(`HTTP ${status}`);
475
+ // Enforce the parent's PDF cap HERE, not only after the IPC transfer:
476
+ // buffering a multi-hundred-MB PDF (plus the base64 copy below) in the
477
+ // worker can OOM-kill the process — stranding every sibling task — or hit
478
+ // ERR_STRING_TOO_LONG. Content-Length first (free when present, and it
479
+ // skips the read entirely)...
480
+ const declaredLength = parseInt((await response.headerValue('content-length').catch(() => null)) || '', 10);
481
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_PDF_SIZE) {
482
+ const sizeMB = Math.round(declaredLength / 1024 / 1024);
483
+ throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
484
+ }
464
485
  const buffer = await response.body();
486
+ // ...then the actual bytes: the header is advisory and absent on chunked
487
+ // responses, so the byte count is the check that actually bounds memory.
488
+ // Checked BEFORE toString('base64') doubles the allocation.
489
+ if (buffer.byteLength > MAX_PDF_SIZE) {
490
+ const sizeMB = Math.round(buffer.byteLength / 1024 / 1024);
491
+ throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
492
+ }
465
493
  // Discard the bytes if any observed response came from a private address.
466
494
  await Promise.allSettled(pendingAddrChecks);
467
495
  if (poisonedError) throw poisonedError;
468
496
  await page.close();
469
- return { contentType, buffer, jitter: 0 };
497
+ // Base64, never a raw Buffer: this result crosses the poolifier cluster IPC
498
+ // channel (default JSON serialization), where a Buffer arrives as
499
+ // {type:'Buffer',data:[...]} — and the follower path adds a second JSON hop
500
+ // through the leader's browser-server. A base64 string survives both hops
501
+ // verbatim (and is ~3x smaller on the wire than the data-array form).
502
+ return { contentType, bufferB64: buffer.toString('base64'), jitter: 0 };
470
503
  }
471
504
 
472
505
  // If it's HTML, check if we need to wait longer (JS-heavy sites)
@@ -476,6 +509,15 @@ export async function executeScrapeTask(
476
509
  const cfPatterns = ['_cf_chl_', 'cdn-cgi/challenge-platform', 'cf_chl_opt', 'Just a moment...', 'Checking your browser before accessing'];
477
510
  const hasCloudflare = cfPatterns.some((pattern: string) => html.includes(pattern));
478
511
 
512
+ // HTTP-error enforcement for HTML (see the PDF branch above). A Cloudflare
513
+ // challenge legitimately arrives with 403/503 and the wait below may clear
514
+ // it to the real page, so a challenged response defers to that wait — whose
515
+ // failure path already throws its own 'Fetch blocked:' error. Everything
516
+ // else with a 4xx/5xx status is an error page, not content.
517
+ if (status >= 400 && !hasCloudflare) {
518
+ throw new Error(`HTTP ${status}`);
519
+ }
520
+
479
521
  if (hasCloudflare) {
480
522
  logToDebugFile('WARN', `[Worker-${workerId}] Cloudflare challenge detected for: ${url}`);
481
523
 
@@ -521,6 +563,16 @@ export async function executeScrapeTask(
521
563
  html = await page.content();
522
564
  }
523
565
 
566
+ // Enforce the parent's rendered-HTML cap worker-side, BEFORE the string
567
+ // crosses JSON IPC (and, for followers, a second JSON hop through the
568
+ // leader's browser-server). The parent's identical check in web-scraper.ts
569
+ // runs only after the transfer — too late to protect this process from a
570
+ // pathological/hostile page ballooning worker memory. Same error wording.
571
+ if (html.length > MAX_HTML_SIZE) {
572
+ const sizeMB = Math.round(html.length / 1024 / 1024);
573
+ throw new Error(`Browser HTML too large (${sizeMB}MB, max 25MB)`);
574
+ }
575
+
524
576
  // Skip jitter in mock mode; in real browsing, add 500–1500ms to mimic human behavior
525
577
  const jitter = process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true'
526
578
  ? 0
@@ -19,7 +19,9 @@ export interface TaskResult {
19
19
  error?: string;
20
20
  success?: boolean;
21
21
  navMs?: number;
22
- buffer?: Buffer;
22
+ /** PDF bytes, base64-encoded: a raw Buffer would be mangled to
23
+ * {type:'Buffer',data:[...]} by the cluster-IPC JSON serialization. */
24
+ bufferB64?: string;
23
25
  html?: string;
24
26
  contentType?: string;
25
27
  }