@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
@@ -45,12 +45,8 @@ function formatArg(arg) {
45
45
  return String(arg);
46
46
  }
47
47
  function redactSecrets(message) {
48
- let keep = MAX_LOG_MESSAGE_LENGTH;
49
- if (message.length > keep) {
50
- const lastKeptUnit = message.charCodeAt(keep - 1);
51
- if (lastKeptUnit >= 55296 && lastKeptUnit <= 56319) keep -= 1;
52
- }
53
- let out = message.length > MAX_LOG_MESSAGE_LENGTH ? `${message.slice(0, keep)}\u2026[truncated ${message.length - keep} chars]` : message;
48
+ const rawLength = message.length;
49
+ let out = rawLength > REDACT_SCAN_LENGTH ? message.slice(0, REDACT_SCAN_LENGTH) : message;
54
50
  out = out.replace(ANSI_PATTERN, "");
55
51
  out = out.replace(URL_CREDENTIALS_PATTERN, "$1[REDACTED]@");
56
52
  out = out.replace(JWT_PATTERN, "[REDACTED]");
@@ -61,12 +57,20 @@ function redactSecrets(message) {
61
57
  out = out.replace(KNOWN_TOKEN_PATTERN, "[REDACTED]");
62
58
  out = out.replace(PROVIDER_TOKEN_PATTERN, "[REDACTED]");
63
59
  out = out.replace(LONG_HEX_SECRET_PATTERN, "[REDACTED]");
60
+ let keep = MAX_LOG_MESSAGE_LENGTH;
61
+ if (out.length > keep) {
62
+ const lastKeptUnit = out.charCodeAt(keep - 1);
63
+ if (lastKeptUnit >= 55296 && lastKeptUnit <= 56319) keep -= 1;
64
+ }
65
+ if (out.length > MAX_LOG_MESSAGE_LENGTH) {
66
+ out = `${out.slice(0, keep)}\u2026[truncated ${rawLength - keep} chars]`;
67
+ }
64
68
  return out;
65
69
  }
66
70
  function stripTerminalEscapes(message) {
67
71
  return message.replace(TERMINAL_ESCAPE_PATTERN, "").replace(CONTROL_CHARS_PATTERN, " ");
68
72
  }
69
- var logContextStorage, MAX_LOG_MESSAGE_LENGTH, ANSI_PATTERN, URL_CREDENTIALS_PATTERN, COOKIE_HEADER_PATTERN, SENSITIVE_KV_PATTERN, KNOWN_TOKEN_PATTERN, JWT_PATTERN, BASIC_AUTH_PATTERN, BEARER_AUTH_PATTERN, PROVIDER_TOKEN_PATTERN, LONG_HEX_SECRET_PATTERN, CONTROL_CHARS_PATTERN, TERMINAL_ESCAPE_PATTERN;
73
+ var logContextStorage, MAX_LOG_MESSAGE_LENGTH, ANSI_PATTERN, URL_CREDENTIALS_PATTERN, COOKIE_HEADER_PATTERN, SENSITIVE_KV_PATTERN, KNOWN_TOKEN_PATTERN, JWT_PATTERN, BASIC_AUTH_PATTERN, BEARER_AUTH_PATTERN, PROVIDER_TOKEN_PATTERN, LONG_HEX_SECRET_PATTERN, CONTROL_CHARS_PATTERN, REDACT_SCAN_LENGTH, TERMINAL_ESCAPE_PATTERN;
70
74
  var init_log_utils = __esm({
71
75
  "src/utils/log-utils.ts"() {
72
76
  "use strict";
@@ -75,7 +79,7 @@ var init_log_utils = __esm({
75
79
  ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
76
80
  URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s:@]+@/gi;
77
81
  COOKIE_HEADER_PATTERN = /\b(set[_-]?cookie|cookie)\b(["']?\s*[:=]\s*["']?)([^\r\n]+)/gi;
78
- SENSITIVE_KV_PATTERN = /\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;
82
+ SENSITIVE_KV_PATTERN = /\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;
79
83
  KNOWN_TOKEN_PATTERN = /\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;
80
84
  JWT_PATTERN = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
81
85
  BASIC_AUTH_PATTERN = /\bBasic\s+[A-Za-z0-9+/]{16,}={0,2}/gi;
@@ -83,6 +87,7 @@ var init_log_utils = __esm({
83
87
  PROVIDER_TOKEN_PATTERN = /\b(AIza[0-9A-Za-z_-]{35}|ya29\.[0-9A-Za-z_-]{20,}|[sp]k_(?:live|test)_[0-9A-Za-z]{16,})\b/g;
84
88
  LONG_HEX_SECRET_PATTERN = /\b[0-9a-fA-F]{32,}\b/g;
85
89
  CONTROL_CHARS_PATTERN = /[\x00-\x1f\x7f]/g;
90
+ REDACT_SCAN_LENGTH = MAX_LOG_MESSAGE_LENGTH * 4;
86
91
  TERMINAL_ESCAPE_PATTERN = new RegExp(
87
92
  "\\x1b\\[[0-?]*[ -/]*[@-~]?|\\x1b\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)?|\\x1b[PX^_][^\\x1b]*(?:\\x1b\\\\)?|\\x1b.?",
88
93
  "g"
@@ -354,10 +359,17 @@ var init_error_tracker = __esm({
354
359
  // src/utils/log-rotation.ts
355
360
  import * as fs2 from "node:fs";
356
361
  import * as path2 from "node:path";
357
- var LogRotation;
362
+ function touchOwnerOnly(filePath, mode) {
363
+ try {
364
+ fs2.closeSync(fs2.openSync(filePath, APPEND_CREATE_NOFOLLOW_FLAGS, mode));
365
+ } catch {
366
+ }
367
+ }
368
+ var APPEND_CREATE_NOFOLLOW_FLAGS, LogRotation;
358
369
  var init_log_rotation = __esm({
359
370
  "src/utils/log-rotation.ts"() {
360
371
  "use strict";
372
+ APPEND_CREATE_NOFOLLOW_FLAGS = fs2.constants.O_WRONLY | fs2.constants.O_APPEND | fs2.constants.O_CREAT | (typeof fs2.constants.O_NOFOLLOW === "number" ? fs2.constants.O_NOFOLLOW : 0);
361
373
  LogRotation = class {
362
374
  MAX_LOG_SIZE = 10 * 1024 * 1024;
363
375
  // 10MB max file size
@@ -375,10 +387,7 @@ var init_log_rotation = __esm({
375
387
  try {
376
388
  if (fs2.existsSync(logFile)) {
377
389
  fs2.unlinkSync(logFile);
378
- try {
379
- fs2.closeSync(fs2.openSync(logFile, "a", 384));
380
- } catch {
381
- }
390
+ touchOwnerOnly(logFile, 384);
382
391
  try {
383
392
  fs2.chmodSync(logFile, 384);
384
393
  } catch {
@@ -418,10 +427,7 @@ var init_log_rotation = __esm({
418
427
  this.logger.log("[Logger] Log rotation skipped (rename failed; file likely in use):", renameErr);
419
428
  return;
420
429
  }
421
- try {
422
- fs2.closeSync(fs2.openSync(logFile, "a", 384));
423
- } catch {
424
- }
430
+ touchOwnerOnly(logFile, 384);
425
431
  try {
426
432
  fs2.chmodSync(logFile, 384);
427
433
  } catch {
@@ -588,7 +594,10 @@ async function captureStdio(logFile, hasSufficientDiskSpace, task, sessionId) {
588
594
  unkeyedCaptureDepth++;
589
595
  isAnyLoggerCapturingOutput = true;
590
596
  }
591
- const decoder = new TextDecoder();
597
+ const stderrDecoder = new TextDecoder();
598
+ const stdoutDecoder = new TextDecoder();
599
+ const fd1SyncDecoder = new TextDecoder();
600
+ const fd2SyncDecoder = new TextDecoder();
592
601
  const originalStderrWrite = process.stderr.write;
593
602
  const originalStdoutWrite = process.stdout.write;
594
603
  const originalFsWriteSync = fs4.writeSync;
@@ -634,7 +643,11 @@ async function captureStdio(logFile, hasSufficientDiskSpace, task, sessionId) {
634
643
  console.debug = createConsolePatch("debug", logFile, hasSufficientDiskSpace);
635
644
  process.stderr.write = (chunk, encodingOrCb, callback) => {
636
645
  const cb = typeof encodingOrCb === "function" ? encodingOrCb : callback;
637
- const message = typeof chunk === "string" ? chunk : decoder.decode(chunk);
646
+ const message = typeof chunk === "string" ? chunk : stderrDecoder.decode(chunk, { stream: true });
647
+ if (message.length === 0) {
648
+ if (typeof cb === "function") cb();
649
+ return true;
650
+ }
638
651
  if (!hasSufficientDiskSpace()) {
639
652
  if (typeof cb === "function") cb();
640
653
  return true;
@@ -655,7 +668,7 @@ async function captureStdio(logFile, hasSufficientDiskSpace, task, sessionId) {
655
668
  return true;
656
669
  };
657
670
  process.stdout.write = (chunk, encodingOrCb, callback) => {
658
- const message = typeof chunk === "string" ? chunk : decoder.decode(chunk);
671
+ const message = typeof chunk === "string" ? chunk : stdoutDecoder.decode(chunk, { stream: true });
659
672
  const isNative = isNativeLog(message);
660
673
  const complexTui = isComplexTui(message);
661
674
  const boxDrawing = isBoxDrawing(message);
@@ -709,7 +722,11 @@ async function captureStdio(logFile, hasSufficientDiskSpace, task, sessionId) {
709
722
  if (!descriptor || (descriptor.writable || descriptor.set)) {
710
723
  fs4.writeSync = (fd, chunk, ...args) => {
711
724
  if (fd === 1 || fd === 2) {
712
- const message = typeof chunk === "string" ? chunk : decoder.decode(chunk);
725
+ const fdDecoder = fd === 1 ? fd1SyncDecoder : fd2SyncDecoder;
726
+ const message = typeof chunk === "string" ? chunk : fdDecoder.decode(chunk, { stream: true });
727
+ if (fd === 2 && message.length === 0) {
728
+ return typeof chunk === "string" ? Buffer.from(chunk).length : chunk.length;
729
+ }
713
730
  const isNative = isNativeLog(message);
714
731
  const boxDrawing = isBoxDrawing(message);
715
732
  const complexTui = isComplexTui(message);
@@ -1489,6 +1506,7 @@ function isBenignScrapeFailure(error) {
1489
1506
  return msg.startsWith("Fetch blocked:") || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
1490
1507
  msg.startsWith("Fetch returned stub:") || // nav-only / near-empty page
1491
1508
  msg.startsWith("Could not extract content from PDF") || // malformed/encrypted/scanned-only PDF
1509
+ msg.startsWith("PDF too large") || // exceeds the deliberate 100MB policy cap
1492
1510
  /^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
1493
1511
  isCloudflareBlockError(error) || isTaskTimeoutError(error) || isPoolShutdownError(error);
1494
1512
  }
@@ -1906,6 +1924,7 @@ init_scraper_types();
1906
1924
  import { appendFileSync as appendFileSync2 } from "node:fs";
1907
1925
  var workerId2 = "";
1908
1926
  var pageCreationLock = Promise.resolve();
1927
+ var PAGE_CREATION_HARD_RELEASE_MS = 5 * 60 * 1e3;
1909
1928
  async function createPageSafe(context2) {
1910
1929
  let release;
1911
1930
  const nextLock = new Promise((resolve) => {
@@ -1914,37 +1933,54 @@ async function createPageSafe(context2) {
1914
1933
  const currentLock = pageCreationLock;
1915
1934
  pageCreationLock = currentLock.then(() => nextLock);
1916
1935
  await currentLock;
1917
- try {
1918
- const pagePromise = context2.newPage();
1919
- pagePromise.catch((err) => logToDebugFile2("DEBUG", `[ThreadWorker] Background page creation rejection: ${err.message}`));
1920
- let timeoutId;
1921
- try {
1922
- const result = await Promise.race([
1923
- pagePromise,
1924
- new Promise((_, reject) => {
1925
- timeoutId = setTimeout(() => reject(new Error("Browser page creation timed out after 60000ms")), 6e4);
1926
- })
1927
- ]);
1928
- if (timeoutId) clearTimeout(timeoutId);
1929
- return result;
1930
- } catch (err) {
1931
- if (timeoutId) clearTimeout(timeoutId);
1932
- pagePromise.then((p) => {
1933
- try {
1934
- p?.close?.();
1935
- } catch {
1936
- }
1937
- }).catch(() => {
1938
- });
1939
- throw err;
1940
- }
1941
- } finally {
1936
+ const pagePromise = context2.newPage();
1937
+ let hardReleased = false;
1938
+ const releaseOnce = () => {
1939
+ if (hardReleased) return;
1940
+ hardReleased = true;
1941
+ clearTimeout(hardReleaseTimer);
1942
1942
  release();
1943
+ };
1944
+ const hardReleaseTimer = setTimeout(() => {
1945
+ logToDebugFile2("WARN", "[ThreadWorker] page-creation mutex force-released \u2014 newPage() did not settle within the hard timeout");
1946
+ releaseOnce();
1947
+ }, PAGE_CREATION_HARD_RELEASE_MS);
1948
+ pagePromise.then(releaseOnce, releaseOnce);
1949
+ pagePromise.catch((err) => logToDebugFile2("DEBUG", `[ThreadWorker] Background page creation rejection: ${err.message}`));
1950
+ let timeoutId;
1951
+ try {
1952
+ const result = await Promise.race([
1953
+ pagePromise,
1954
+ new Promise((_, reject) => {
1955
+ timeoutId = setTimeout(() => reject(new Error("Browser page creation timed out after 60000ms")), 6e4);
1956
+ })
1957
+ ]);
1958
+ if (timeoutId) clearTimeout(timeoutId);
1959
+ return result;
1960
+ } catch (err) {
1961
+ if (timeoutId) clearTimeout(timeoutId);
1962
+ pagePromise.then((p) => {
1963
+ try {
1964
+ p?.close?.();
1965
+ } catch {
1966
+ }
1967
+ }).catch(() => {
1968
+ });
1969
+ throw err;
1943
1970
  }
1944
1971
  }
1945
1972
  function setWorkerId2(id) {
1946
1973
  workerId2 = id;
1947
1974
  }
1975
+ function parseTimeoutMs(envVar, def) {
1976
+ if (envVar === void 0 || envVar.trim() === "") return def;
1977
+ const n = Number(envVar.trim());
1978
+ if (!Number.isFinite(n) || n < 0) {
1979
+ logToDebugFile2("WARN", `[ThreadWorker] Invalid timeout value "${envVar}", using default: ${def}ms`);
1980
+ return def;
1981
+ }
1982
+ return n;
1983
+ }
1948
1984
  function logToDebugFile2(level, ...args) {
1949
1985
  const logFile = process.env["PI_RESEARCH_LOG_FILE"];
1950
1986
  if (!logFile) return;
@@ -2054,7 +2090,7 @@ async function extractSearchResults(page) {
2054
2090
  }
2055
2091
  async function executeSearchTask(_context, query) {
2056
2092
  const page = await createPageSafe(_context);
2057
- const SEARCH_TIMEOUT = parseInt(process.env["PI_RESEARCH_SEARCH_TIMEOUT_MS"] || "45000", 10);
2093
+ const SEARCH_TIMEOUT = parseTimeoutMs(process.env["PI_RESEARCH_SEARCH_TIMEOUT_MS"], 45e3);
2058
2094
  page.setDefaultTimeout(SEARCH_TIMEOUT);
2059
2095
  page.setDefaultNavigationTimeout(SEARCH_TIMEOUT);
2060
2096
  try {
@@ -2123,7 +2159,7 @@ function seedSubresourceVerdict(validatedUrl) {
2123
2159
  }
2124
2160
  async function executeScrapeTask(_context, url, signal) {
2125
2161
  const page = await createPageSafe(_context);
2126
- const SCRAPE_TIMEOUT = parseInt(process.env["PI_RESEARCH_SCRAPE_TIMEOUT_MS"] || "15000", 10);
2162
+ const SCRAPE_TIMEOUT = parseTimeoutMs(process.env["PI_RESEARCH_SCRAPE_TIMEOUT_MS"], 15e3);
2127
2163
  page.setDefaultTimeout(SCRAPE_TIMEOUT);
2128
2164
  page.setDefaultNavigationTimeout(SCRAPE_TIMEOUT);
2129
2165
  const onAbort = () => {
@@ -2289,7 +2325,7 @@ async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PR
2289
2325
  }
2290
2326
  }
2291
2327
  async function executeHealthCheck(_context, initMs) {
2292
- const configuredMs = parseInt(process.env["PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS"] || "0", 10);
2328
+ const configuredMs = parseTimeoutMs(process.env["PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS"], 0);
2293
2329
  const HEALTH_TIMEOUT = configuredMs > 0 ? Math.max(1e4, configuredMs) : 1e4;
2294
2330
  if (initMs > 3e3) {
2295
2331
  logToDebugFile2("WARN", `[Worker-${workerId2}] Browser init was slow: ${initMs}ms \u2014 leaving less headroom for DDG navigation.`);
@@ -2339,6 +2375,7 @@ init_logger();
2339
2375
 
2340
2376
  // src/utils/url-utils.ts
2341
2377
  init_logger();
2378
+ init_log_utils();
2342
2379
 
2343
2380
  // src/config.ts
2344
2381
  var ConfigSchema = Type.Object({
@@ -2508,6 +2545,7 @@ var initPromise = null;
2508
2545
  var workerId3 = "";
2509
2546
  var activeTaskCount = 0;
2510
2547
  var resetPending = false;
2548
+ var resetPendingForBrowser = null;
2511
2549
  function setWorkerId3(id) {
2512
2550
  workerId3 = id;
2513
2551
  }
@@ -2518,7 +2556,11 @@ function taskFinished() {
2518
2556
  activeTaskCount = Math.max(0, activeTaskCount - 1);
2519
2557
  if (activeTaskCount === 0 && resetPending) {
2520
2558
  resetPending = false;
2521
- doResetBrowser();
2559
+ const capturedBrowser = resetPendingForBrowser;
2560
+ resetPendingForBrowser = null;
2561
+ if (browser === capturedBrowser) {
2562
+ doResetBrowser();
2563
+ }
2522
2564
  }
2523
2565
  }
2524
2566
  var isBrowserConnected = () => {
@@ -2576,6 +2618,35 @@ async function initBrowser() {
2576
2618
  height: 1080,
2577
2619
  colorDepth: 24,
2578
2620
  pixelRatio: 1
2621
+ },
2622
+ // Mute all audio output at the engine level. The general scrape
2623
+ // path drives a REAL Firefox (Camoufox), and a researcher can be
2624
+ // pointed at a youtube.com watch page through the ordinary
2625
+ // `scrape` tool (as opposed to the dedicated `youtube_transcript`
2626
+ // tool, which never touches the browser) — YouTube watch pages
2627
+ // autoplay video with sound, so without an explicit mute the
2628
+ // process plays audible audio through the host's sound device.
2629
+ //
2630
+ // media.volume_scale forces the browser's effective output
2631
+ // volume to 0 regardless of per-tab mute/autoplay state or any
2632
+ // page script re-enabling audio — the most reliable, engine-level
2633
+ // mute available for headless Firefox/Camoufox (the same pref
2634
+ // Selenium/Playwright Firefox test grids use to silence CI runs).
2635
+ // media.autoplay.default=5 additionally blocks autoplay outright
2636
+ // (belt-and-suspenders; some sites route around the volume scale
2637
+ // via Web Audio gain nodes). Neither pref affects rendering — the
2638
+ // page still loads and paints normally, only the audio output
2639
+ // device is silenced.
2640
+ //
2641
+ // Set on every platform/headless-mode combination, not just
2642
+ // Linux: this was only *observed* on Linux (true headless and
2643
+ // Xvfb-backed 'virtual' mode both retain a real PulseAudio/
2644
+ // PipeWire/ALSA sink for the Firefox process to play through),
2645
+ // but headless Firefox on macOS/Windows is not guaranteed to be
2646
+ // silent either, so the mute is applied unconditionally.
2647
+ firefox_user_prefs: {
2648
+ "media.volume_scale": "0.0",
2649
+ "media.autoplay.default": 5
2579
2650
  }
2580
2651
  });
2581
2652
  launchPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser launch rejection: ${err.message}`));
@@ -2655,6 +2726,7 @@ function doResetBrowser() {
2655
2726
  function resetBrowser() {
2656
2727
  if (activeTaskCount > 1) {
2657
2728
  resetPending = true;
2729
+ resetPendingForBrowser = browser;
2658
2730
  return;
2659
2731
  }
2660
2732
  doResetBrowser();
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,82 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.5] - 2026-08-10
9
+
10
+ Two rounds of workflow-backed code review on the clean 1.3.4 tree (high-effort,
11
+ then a wider max-effort pass explicitly told not to re-find the first round's
12
+ results), followed by a triage of every open CodeQL alert. Every fix below is
13
+ covered by a passing regression test; two bugs (the OSV null-CWE crash and the
14
+ duration-formatter rounding bug) had no prior test coverage at all, so tests
15
+ were added alongside the fixes.
16
+
17
+ ### Fixed
18
+
19
+ - **The browser page-creation mutex could still hang forever.** 1.3.4 fixed the mutex releasing too early (before `newPage()` itself settled). That fix had no bound of its own: if `newPage()` never settles at all — not just slow, but genuinely wedged — the lock is now held forever, livelocking every future `createPageSafe()` call on that worker thread. Added a 5-minute hard-release timer that force-releases the lock if `newPage()` still hasn't settled, independent of the settlement-based release for the normal case.
20
+ - **`reconcileSkillInstalls()`'s stale-symlink re-point had no copy fallback.** The sibling `installSkill()` already falls back to a copy when `symlinkSync` fails after the old link is removed (a Windows cross-volume junction failure, most commonly). The periodic reconciler's structurally identical re-point lacked that fallback, so a `symlinkSync` failure there silently left the manifest claiming a skill was installed after the file was already deleted from disk. Added the same fallback — and staged the copy into a dot-prefixed temp dir with an atomic rename into place, so a copy that fails partway (disk full, interrupted process) can never leave an unrecognized, SKILL.md-less partial directory orphaned at the skill's path.
21
+ - **The scrape tool's own gating used a different config than its actual fetch.** `createScrapeTool()` resolves `config = options.config || getConfig(options.ctx.cwd)` and uses that resolved value for every batching/gating decision (knowledge-store mode, concurrency caps, token-fraction limits, pool capacity) — except the real `scrape()` call, which passed the raw, possibly-`undefined` `options.config`. A caller that omits `config` (the parameter is typed optional) got gating computed for one cwd while the actual network fetch silently fell back to whatever `getConfig()` resolves for `process.cwd()` — a different worker/pool concurrency or provider config than what the visible gating decision assumed, with nothing logged to indicate the mismatch.
22
+ - **`redactSecrets()`'s truncate-before-redact fix (1.3.4) removed its own cost bound.** Moving truncation after the full regex chain (correctly, to stop a secret leaking at the truncation boundary) also removed the implicit cap that used to limit those ~10 sequential regex passes to `MAX_LOG_MESSAGE_LENGTH` characters regardless of input size. A verbose/debug log call carrying a multi-megabyte scraped payload now ran the whole chain across the untruncated string. Pre-slices to 4× `MAX_LOG_MESSAGE_LENGTH` before redacting — generous enough that no in-bounds secret is ever missed (patterns only shrink text, never expand it, and content beyond that window can never survive into the final truncated output anyway) — and reports the truncation count against the true raw length instead of the post-redaction length.
23
+ - **Six more truncate-before-redact / unredacted-error-text sites, the same bug class 1.3.4 fixed at five others.** `scrape.ts`'s and `research-tool-definition.ts`'s error-catch paths returned raw `error.message`/`String(error)` directly as tool output; `browser-client.ts`'s JSON-parse-failure handler embedded a raw 200-char response preview in the thrown `Error`; `transcript-client.ts`'s debug log sliced YouTube's raw `timedtext` response to 120 chars before redaction instead of after. Each of these can carry a credential (a failing URL's userinfo, an echoed `Authorization` header, an embedded token in a malformed page) into a log file, a thrown error, or the agent's own conversation/report output. All six now redact before truncating or returning.
24
+ - **A `null` entry in an OSV/GHSA advisory's `database_specific.cwe` crashed the entire lookup.** `typeof cwe === 'object'` is `true` for `null`, so `mapOsvItemToVulnerability` threw on `(null).id` — and since it runs uncaught inside `osv-client.ts`'s per-term fetch (only wrapped in `Promise.allSettled`), one malformed record silently discarded every vulnerability found for that search term, replaced by a generic failure note. Added the missing `cwe !== null` check.
25
+ - **The YouTube PoToken mint mutex had the same unbounded-hang exposure as the browser mutex above.** `doMint()` awaits BotGuard VM execution and interpreter eval against a hostile/malformed attestation script from Google, neither of which take an abort signal — if either hangs instead of rejecting, the module-level `mintChain` mutex never settles, and every subsequent `mintPoTokens()` call across all researchers queues behind it forever. Added the same style of hard-release bound as the browser mutex fix, via a `Promise.race` against a 5-minute (unref'd) timeout.
26
+ - **Three timeout env vars silently became near-zero on a malformed value.** `PI_RESEARCH_SEARCH_TIMEOUT_MS`, `PI_RESEARCH_SCRAPE_TIMEOUT_MS`, and `PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS` were read with `parseInt()`, which truncates at the first non-digit instead of rejecting the value — `parseInt('1m', 10)` is `1`, not `NaN`, so a plausible duration-shorthand typo silently became a 1ms browser timeout instead of falling back to the default, failing every search/scrape/health-check on that worker for the rest of its life with no warning. Replaced with a shared helper using `Number()` (which rejects trailing garbage) plus a warning log on an invalid value.
27
+ - **`formatDuration()` could render the nonsensical "1m 60s".** Rounding `remainingSeconds` independently of `minutes` let a value like 119,601ms (1m 59.601s) round its seconds component up to 60 without rolling into the next minute. Now rounds the total seconds first and derives minutes/seconds from that integer.
28
+ - **A theoretical symlink race on log rotation's file reopen.** After renaming the live log aside during rotation (or unlinking it during `clearLogs`), the code reopens the same path with `fs.openSync(path, 'a', 0o600)` to recreate it owner-only — a window in which a local attacker with write access to the log directory could plant a symlink at that path, causing the reopen to follow it and (re)create/chmod an arbitrary file with this process's permissions. Added `O_NOFOLLOW` to the reopen (a no-op on Windows, where the flag isn't defined) so it refuses to follow a symlink instead.
29
+ - **Scraping a YouTube watch page could play audible audio through the host's speakers.** The general `scrape` tool drives a real stealth Firefox (Camoufox) — unlike the dedicated `youtube_transcript` tool, which only calls YouTube's API (`youtubei.js`/`timedtext`) and never touches a browser. A researcher agent scraping a `youtube.com` watch-page URL through the ordinary `scrape` tool hit YouTube's default video autoplay with no engine-level mute configured, so the browser process played sound through whatever audio sink it could reach. Reported on Linux, where both true-headless and the Xvfb-backed `'virtual'` mode keep a live PulseAudio/PipeWire/ALSA sink reachable — but headless Firefox is not guaranteed silent on macOS/Windows either, so the fix (`media.volume_scale: '0.0'` plus `media.autoplay.default: 5` in the shared Camoufox launch options) is unconditional across platforms and headless modes, not Linux-specific.
30
+
31
+ ### Reviewed, not changed
32
+
33
+ A full CodeQL alert triage found 12 of 13 open alerts to be false positives or out-of-scope for a code fix, each verified individually rather than dismissed on assumption:
34
+ - Two `js/missing-await` alerts in `embedder.ts` are reference-identity checks (`this.recoveryPromise === recovery`) misread as a forgotten `await` — awaiting either side would break the logic.
35
+ - A `js/file-system-race` alert in `file-lock-service.ts` is on code that already reopens with the `'wx'` exclusive-create flag specifically to make the check-then-open atomic.
36
+ - A `js/insecure-temporary-file` alert in `browser/cleanup-utils.ts` is a read-only liveness probe on a Firefox lock file, not a file creation.
37
+ - Six alerts (`js/file-system-race`, `js/insecure-temporary-file`) are all in test files creating throwaway, randomly-named fixtures under `os.tmpdir()` for the test's own private use.
38
+ - One (`js/unused-local-variable`) was a genuine unused test import; removed.
39
+
40
+ Patch, not minor: every fix is an internal correctness/security/robustness
41
+ change with no change to pi-research's own CLI, tool, or SDK contract —
42
+ matching this project's established precedent for audit-round releases
43
+ (1.3.1 through 1.3.4).
44
+
45
+ ## [1.3.4] - 2026-08-09
46
+
47
+ Two rounds on the clean 1.3.3 tree: a full-tree re-audit (10 independent
48
+ reviews, each a disjoint `src/` slice) followed by a targeted security/
49
+ cross-platform/dead-code sweep (parallel reviews of secret-redaction
50
+ call sites, path/URL handling, Windows/macOS compatibility, and unused
51
+ exports). Every fix below is paired with a regression test confirmed (via
52
+ a local revert-and-rerun) to fail against the pre-fix code.
53
+
54
+ ### Fixed
55
+
56
+ - **`createPageSafe()`'s page-creation mutex released before `newPage()` itself had settled.** The lock released in an outer `finally` as soon as its internal 60s race settled — including when the timeout won, while the real `context.newPage()` call was still pending in the background. That let the next queued caller start its own `newPage()` concurrently with the still-unsettled prior one — exactly the concurrent-`newPage()` Firefox/Juggler deadlock scenario the lock exists to prevent, and it happens precisely when the browser is already stalling under stress. Release is now tied to the underlying `newPage()` promise itself settling, independent of which branch of the timeout race resolves.
57
+ - **A deferred `resetBrowser()` could tear down a later, unrelated, healthy browser.** The deferred-reset flag was a bare boolean with no identity tied to which browser instance it was requested against. A sibling task's own crash could take the *immediate* reset path (activeTaskCount ≤ 1) and relaunch a fresh browser without ever clearing the stale deferred flag; once the task counter later hit zero, the stale flag fired against whatever browser was current — tearing down a healthy instance a subsequent task had just started using. The flag now captures the specific browser instance at defer time and only applies if that instance is still current.
58
+ - **`ServiceContainer.disposeAll()` could double-invoke a service's `dispose()`.** Teardown snapshotted "instances to dispose" without waiting for a `clear()`/`replace()` disposal already in flight for the same service — if that disposal had called `instance.dispose()` but not yet nulled/replaced `registration.instance`, the snapshot included it, and the DAG-teardown loop called `dispose()` a second time on the same instance, concurrently with the original call. `get()` already guarded this window for reads; `disposeAll()` now waits it out the same way before reading instances.
59
+ - **The `WRITER_QUEUE` DI registration cached its first writer-queue object forever.** Once resolved, the registry never re-resolved it — but the writer queue is rebuilt whenever the knowledge store re-initializes (a Knowledge Mode or cwd change via `/research-config`), so every write after such a change silently landed on a disposed, closed writer queue. `storeLinkDescriptions` now resolves the writer queue directly from the already-fresh `KnowledgeStoreService` instead of through the stale cache.
60
+ - **`KnowledgeStoreService.dispose()` lacked the identity guard `initialize()` already has.** Two concurrent `dispose()` calls arriving while an `initialize()` was in flight could both pass the early checks and both build their own teardown closure from the same instance snapshot — double-invoking `dispose()` on the embedder/store/writer-queue, the same class of race fixed in the service registry above.
61
+ - **Chunker UTF-16 surrogate corruption at tight overlap ratios.** At a `targetSize`/`overlap` combination that left less than one code unit of slack (e.g. `targetSize:2, overlap:1`), the surrogate-pair read-side guard's two escape branches (pull back / push forward) could both be simultaneously blocked, leaving a chunk boundary mid-surrogate-pair and opening the next chunk with a lone low surrogate — silently corrupted on UTF-8 encode. The push-forward bound was off by one; landing exactly on `end` (zero overlap for that transition) is a safe position that was wrongly excluded.
62
+ - **`redactSecrets()` truncated before redacting.** A secret straddling the 10,000-char truncation boundary had its patterns matched against the already-cut string — a credential split mid-token was too short for any pattern to recognize, leaking a fragment in the clear right before the "…[truncated N chars]" marker. Redaction now always runs on the full message before truncation.
63
+ - **The same truncate-before-redact bug had already spread to five other call sites** that built their own short log-line summaries by truncating `error.message` before ever passing it through `redactSecrets` — `task-execution-service.ts` (5 retry-path WARN logs), `sdk.ts`'s end-of-run error summary, and `url-utils.ts`'s unparseable-URL debug log. A failing scrape/navigation routinely echoes the failing URL verbatim, which can carry userinfo credentials; each site now redacts the full message before truncating for display.
64
+ - **`redactSecrets()`'s key/value pattern had no bare `key` alternative.** Several provider APIs (e.g. StackExchange's `?key=<apikey>` query parameter) name their credential with no qualifying prefix like `api_` or `private_`, so it fell through every existing pattern uncaught. Added `key` to the sensitive-key-name alternation, after the more specific `*_key` variants so those still match first.
65
+ - **`stdio-capture.ts`'s `TextDecoder` instances were missing `{stream: true}`.** A single native write/writeSync call can split a multi-byte UTF-8 character's bytes across two separate calls; without streaming mode each half decoded independently to U+FFFD instead of reconstructing the original character. Also split the previously-shared decoder into one per logical byte stream (stdout, stderr, FD1, FD2) — a shared decoder's carried-over partial bytes are only valid within one continuous stream, and mixing streams could try to complete one stream's dangling sequence with another's bytes.
66
+ - **Path traversal in `github-advisories.ts`'s repo-scoped search.** An owner or repo name of exactly `.` or `..` passed the character-allowlist regex (dots are permitted, for names like `node.js`) and, once interpolated into the API path, was collapsed by the URL parser before the request was sent — `owner=".."` turned `/repos/../rate_limit/...` into a request for `/rate_limit/...`, escaping the intended `/repos/{owner}/{name}/` scope with this tool's own GitHub credentials. The allowlist now additionally rejects a segment that is entirely dots.
67
+ - **PDF-extraction failures conflated a broken native install with a routine malformed PDF.** `pdf-oxide-wasm` failing to load (missing prebuilt binary, corrupted install) meant PDF extraction was broken for every URL, not just one — but it shared a catch block with genuine per-PDF parse failures, so both logged at `debug` and were classified as the same benign, routine outcome. The two are now caught separately: a native-module load failure surfaces at `error` with its own metric label and is never classified as benign. Also added the missing `PDF too large` case to the benign-failure classifier (it exceeds a deliberate size cap, exactly as routine as a malformed PDF, but had never been added).
68
+ - **Skill-installer copy-version laundering.** An ordinary re-run of `installSkill()` against an already-installed owned *copy* stamped the manifest's recorded version to the current package version without ever refreshing the actual on-disk copy — permanently defeating `reconcileSkillInstalls()`'s later staleness check, which compares the recorded version against current. The unrefreshed branch now preserves whatever version was already recorded instead of claiming it's current.
69
+ - **`installSkill()`'s stale-symlink re-point could silently report success after deleting the skill.** The re-point unlinks the old symlink, then creates the new one; if the second step failed (e.g. a Windows junction cannot cross volumes, and `pi update` can relocate the package to a different drive) the error escaped to an outer catch that fell through to reporting "already-installed" — even though the skill directory no longer existed on disk at all. It now falls back to a copy, mirroring the fresh-install path's existing fallback for the identical failure.
70
+ - **StackExchange REST client ignored an already-aborted signal.** The client chained an external `AbortSignal` via a bare `addEventListener('abort', ...)`, which never fires for a signal that was already aborted before the call started — an already-cancelled request still went out to the network. Replaced the hand-rolled `AbortController` + listener wiring with `createTimeoutSignal` (already used by the `security/*.ts` clients), whose `AbortSignal.any` combination correctly produces an already-aborted signal up front.
71
+ - **StackExchange `format=compact` with `command=get` fell through to a raw JSON dump.** The compact formatter only branched on an array result (search/user/site); `get`'s single-question-with-answers object shape had no matching branch, unlike the table formatter's equivalent case. Wired up the existing (previously unused) `formatQuestionsCompact`/`formatAnswersCompact` compact formatters for this shape.
72
+ - **A misleading, permanently-zero "sessions" healthcheck stat.** The `StateManager` health diagnostic reported `stats.activeSessions` from `state.sessions` — a field nothing in production code ever writes to (the session-tracking write API has no live caller anywhere in the codebase), so the figure was always 0, presented as if it were live data. Removed from the diagnostic; the underlying write-side API is still dead code, flagged for a future cleanup round rather than a risky mid-release refactor of state-manager's DI wiring.
73
+
74
+ ### Removed
75
+
76
+ - `neutralizeControlChars()` (superseded by `stripTerminalEscapes()`, which already performs the same control-character neutralization as part of a superset of its job; zero remaining callers).
77
+ - Six other dead exported utilities with zero production callers, independently verified: `didResearcherScrape`/`getResearcherScrapes` (`shared-links.ts`), `validateComplexity` (`input-validation.ts`), `normalizeStringArrayDetailed`/`NormalizeArrayResult` (`json-utils.ts`), `getCurrentRunRegistry` (`metrics.ts` — its own internal consumer already reads the underlying `AsyncLocalStorage` directly).
78
+
79
+ Patch, not minor: every fix is an internal correctness/security/robustness
80
+ change with no change to pi-research's own CLI, tool, or SDK contract —
81
+ matching this project's established precedent for audit-round releases
82
+ (1.3.1, 1.3.2, 1.3.3).
83
+
8
84
  ## [1.3.3] - 2026-08-07
9
85
 
10
86
  pi 0.84.1 compatibility. Triggered by updating the local `pi` install and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.3.3",
3
+ "version": "1.3.5",
4
4
  "description": "A set of web research tools (search, scrape, and others) and research agent system that uses them safely and efficiently.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -2,7 +2,7 @@
2
2
  name: pi-research
3
3
  description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
4
4
  license: MIT
5
- metadata: { "author": "Lincoln Deen", "version": "1.3.3", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.3.5", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
@@ -18,9 +18,10 @@ import { AsyncLocalStorage } from 'node:async_hooks';
18
18
 
19
19
 
20
20
  /**
21
- * How long teardown waits for an initialization that is still in flight. Long
22
- * enough to cover a normal service coming up, short enough that a service blocked
23
- * on something slow (a model download) cannot hold shutdown open indefinitely.
21
+ * How long teardown waits for an initialization or a clear()/replace()
22
+ * disposal that is still in flight. Long enough to cover a normal service
23
+ * coming up or tearing down, short enough that a service blocked on
24
+ * something slow (a model download) cannot hold shutdown open indefinitely.
24
25
  */
25
26
  const DISPOSE_INFLIGHT_INIT_TIMEOUT_MS = 5_000;
26
27
 
@@ -521,6 +522,39 @@ export class ServiceContainer {
521
522
  if (settleTimer) clearTimeout(settleTimer);
522
523
  }
523
524
 
525
+ // A concurrent clear()/replace() can be mid-disposal right now: its
526
+ // async IIFE has already called instance.dispose() (or is about to)
527
+ // but hasn't yet nulled/replaced registration.instance. Snapshotting
528
+ // activeServices without waiting for this would include that service,
529
+ // and the loop below would call instance.dispose() a SECOND time on
530
+ // the very same instance concurrently with clear()/replace()'s own
531
+ // call — get() already guards against reading mid-disposal state (see
532
+ // its own disposalPromise wait above); teardown needs the same guard
533
+ // before it reads/touches registration.instance below. Bounded for the
534
+ // same reason as the init wait: a service stuck disposing must not
535
+ // hold overall teardown open indefinitely — the snapshot below still
536
+ // catches whatever state is left after the timeout.
537
+ const inFlightDisposals = Array.from(this.services.values())
538
+ .map((r) => r.disposalPromise)
539
+ .filter((p): p is Promise<void> => p != null);
540
+ if (inFlightDisposals.length > 0) {
541
+ if (this.defaultOptions.enableLogging) {
542
+ logger.debug(`[ServiceContainer] Waiting for ${inFlightDisposals.length} in-flight disposal(s) before teardown`);
543
+ }
544
+ let disposalSettleTimer: ReturnType<typeof setTimeout> | undefined;
545
+ await Promise.race([
546
+ Promise.allSettled(inFlightDisposals),
547
+ new Promise<void>((resolve) => {
548
+ disposalSettleTimer = setTimeout(() => {
549
+ logger.warn('[ServiceContainer] Timed out waiting for in-flight service disposal; proceeding with teardown');
550
+ resolve();
551
+ }, DISPOSE_INFLIGHT_INIT_TIMEOUT_MS);
552
+ disposalSettleTimer.unref?.();
553
+ }),
554
+ ]);
555
+ if (disposalSettleTimer) clearTimeout(disposalSettleTimer);
556
+ }
557
+
524
558
  const activeServices = Array.from(this.services.keys()).filter(name =>
525
559
  this.services.get(name)?.instance !== null
526
560
  );
@@ -233,17 +233,19 @@ export function registerHealthChecks(registry: IHealthRegistryService, container
233
233
  registry.register('StateManager', async () => {
234
234
  try {
235
235
  const stateManager = await getService<IStateManager>(ServiceNames.STATE_MANAGER, { container }, container);
236
- const stats = await stateManager.getMetrics();
237
236
  const gpuOwner = await stateManager.getGpuOwner();
238
-
239
- return {
240
- healthy: true,
241
- diagnostic: {
237
+
238
+ // No `sessions` field here: the session-tracking API (addSession/
239
+ // removeSession/updateHeartbeat) that would ever populate state.sessions
240
+ // has no production caller anywhere in the codebase — stats.activeSessions
241
+ // was always 0, permanently, presenting fake data as a live diagnostic.
242
+ return {
243
+ healthy: true,
244
+ diagnostic: {
242
245
  status: 'operational',
243
- sessions: stats.activeSessions,
244
246
  gpuLocked: !!gpuOwner,
245
247
  gpuOwner: gpuOwner?.sessionId || 'none'
246
- }
248
+ }
247
249
  };
248
250
  } catch (e) {
249
251
  return { healthy: false, error: `State manager healthcheck failed: ${e instanceof Error ? e.message : String(e)}` };
@@ -12,6 +12,7 @@ import type { SearchResult } from '../../web-research/types.ts';
12
12
  import type { IScheduler } from '../../core/interfaces/scheduler-interfaces.ts';
13
13
  import { logger } from '../../logger.ts';
14
14
  import { errorTracker } from '../../utils/error-tracker.ts';
15
+ import { redactSecrets } from '../../utils/log-utils.ts';
15
16
  import { Utf8Body } from '../../utils/http-body.ts';
16
17
  import { getClientAgent } from './client-agent.ts';
17
18
  import type { NodeError } from '../../types/index.ts';
@@ -200,8 +201,10 @@ export class BrowserClient implements IScheduler {
200
201
  resolve(parsed);
201
202
  }
202
203
  } catch (_e) {
203
- // FIX (#23): Truncate response body in error to prevent data leakage
204
- const raw = collected.toString();
204
+ // FIX (#23): Truncate response body in error to prevent data leakage.
205
+ // Redact BEFORE truncating — slicing first can cut a secret-shaped
206
+ // token mid-way, leaving a fragment too short to be recognized.
207
+ const raw = redactSecrets(collected.toString());
205
208
  const preview = raw.length > 200 ? raw.slice(0, 200) + '...' : raw;
206
209
  const error = new Error(`Failed to parse response (status ${res.statusCode}): ${preview}`);
207
210
  errorTracker.trackError(error, {
@@ -9,6 +9,7 @@ import type { Config } from '../../config.ts';
9
9
  import type { SearchResult } from '../../web-research/types.ts';
10
10
  import type { BrowserTask } from '../../types/index.ts';
11
11
  import { logger } from '../../logger.ts';
12
+ import { redactSecrets } from '../../utils/log-utils.ts';
12
13
  import { errorTracker } from '../../utils/error-tracker.ts';
13
14
  import { getBrowserCircuitBreaker, browserCircuitBreaker, isTransientSocketError, isPoolShutdownError, isTaskTimeoutError, isCloudflareBlockError } from './browser-error-utils.ts';
14
15
  import { getScheduler as _getScheduler, forceSchedulerRestart as _forceSchedulerRestart } from './scheduler-factory.ts';
@@ -42,6 +43,21 @@ const LEADER_HANDOVER_RETRIES = 3;
42
43
  /** Backoff between handover attempts — long enough for an election to complete. */
43
44
  const HANDOVER_BACKOFF_MS = 1200;
44
45
 
46
+ /**
47
+ * A short, log-line-sized summary of an error — redacted BEFORE truncating.
48
+ * A raw error.message can echo the failing URL verbatim (Playwright/undici
49
+ * navigation errors routinely do), and a URL can carry userinfo credentials
50
+ * (https://user:pass@host/...). Truncating first — the old `.substring(0,
51
+ * 100)` at each call site below — can cut a credential mid-token, leaving a
52
+ * short unredacted fragment in the log: redactSecrets' patterns need to see
53
+ * the whole token to recognize it. Same class of bug as the truncate-before-
54
+ * redact fix in log-utils.ts itself; redact the full message first here too.
55
+ */
56
+ export function briefErrorMessage(error: unknown): string {
57
+ const msg = error instanceof Error ? error.message : String(error);
58
+ return redactSecrets(msg).substring(0, 100);
59
+ }
60
+
45
61
  /**
46
62
  * Recover from a leader handover before retrying.
47
63
  *
@@ -225,7 +241,7 @@ export async function runBrowserTask<T>(
225
241
  taskType: type,
226
242
  errorType: 'leader_handover',
227
243
  });
228
- logger.warn(`[BrowserManager] Leader handover during ${type} task (handover attempts left: ${handoverRetries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
244
+ logger.warn(`[BrowserManager] Leader handover during ${type} task (handover attempts left: ${handoverRetries}): ${briefErrorMessage(error)}...`);
229
245
  await recoverFromLeaderHandover(container);
230
246
  await new Promise((resolve) => setTimeout(resolve, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
231
247
  return runBrowserTask<T>(taskOrUrl, type, config, signal, retries, container, handoverRetries - 1);
@@ -238,7 +254,7 @@ export async function runBrowserTask<T>(
238
254
  taskType: type,
239
255
  errorType: 'transient_socket_error',
240
256
  });
241
- logger.warn(`[BrowserManager] Transient socket error during ${type} task (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
257
+ logger.warn(`[BrowserManager] Transient socket error during ${type} task (retries left: ${retries}): ${briefErrorMessage(error)}...`);
242
258
 
243
259
  if (isPoolShutdownError(error)) {
244
260
  // Handover budget already spent — fall back to a plain drain wait.
@@ -280,7 +296,7 @@ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal
280
296
  operation: 'healthcheck',
281
297
  errorType: 'transient_socket_error',
282
298
  });
283
- logger.warn(`[BrowserManager] Transient socket error during healthcheck (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
299
+ logger.warn(`[BrowserManager] Transient socket error during healthcheck (retries left: ${retries}): ${briefErrorMessage(error)}...`);
284
300
 
285
301
  if (isPoolShutdownError(error)) {
286
302
  // Same stale-handle trap as the task paths: waiting alone leaves the
@@ -329,7 +345,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
329
345
  query,
330
346
  errorType: 'leader_handover',
331
347
  });
332
- logger.warn(`[BrowserManager] Leader handover during search (handover attempts left: ${handoverRetries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
348
+ logger.warn(`[BrowserManager] Leader handover during search (handover attempts left: ${handoverRetries}): ${briefErrorMessage(error)}...`);
333
349
  await recoverFromLeaderHandover(container);
334
350
  await new Promise((resolve) => setTimeout(resolve, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
335
351
  return runWorkerSearch(query, config, signal, retries, sessionId, container, handoverRetries - 1);
@@ -342,7 +358,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
342
358
  query,
343
359
  errorType: 'transient_socket_error',
344
360
  });
345
- logger.warn(`[BrowserManager] Transient socket error during search (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
361
+ logger.warn(`[BrowserManager] Transient socket error during search (retries left: ${retries}): ${briefErrorMessage(error)}...`);
346
362
 
347
363
  if (isPoolShutdownError(error)) {
348
364
  await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));