@lincoln504/pi-research 1.3.4 → 1.3.6

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.
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,60 @@ 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.6] - 2026-08-10
9
+
10
+ An independent fresh-eyes review of the entire 1.3.4→1.3.5 diff — the previous
11
+ release's own fixes had only ever been reviewed by their author. It found seven
12
+ further defects, several in the exact hard-release-timer and redaction-boundary
13
+ patterns 1.3.5 introduced. Every fix below is covered by a regression test
14
+ confirmed (via a local revert-and-rerun) to fail against the pre-fix code.
15
+
16
+ ### Fixed
17
+
18
+ - **The page-creation and PoToken-mint hard-release timers (new in 1.3.5) could still let two calls race.** Both mutexes force-release after a 5-minute bound so a truly wedged call can't livelock the queue forever — but the bound has no way to tell "permanently hung" apart from "just very slow." A call that resolves a moment after the hard release fires was already let straight through to the next queued caller, who then ran its own `newPage()`/`doMint()` concurrently with the still-in-flight first one: the exact race each mutex exists to prevent (Playwright/Firefox deadlock on concurrent `newPage()`; corrupted `globalThis.window`/`document`/`navigator` from two overlapping bridge/unbridge pairs). Both now mark the wedged resource (the browser context; the whole PoToken minter, since `globalThis` is process-global) and reject further calls against it fast — no second `newPage()`/`doMint()` call — until the original stuck call actually settles.
19
+ - **`redactSecrets()`'s own cost-bounding pre-slice (new in 1.3.5) could itself cut a secret in half.** The 4×`MAX_LOG_MESSAGE_LENGTH` pre-slice added to bound redaction's cost on huge inputs runs before any pattern sees the text — the same class of mid-token truncation the truncate-before-redact fix (1.3.4) closed at the *final* boundary, just reopened at this new earlier one. A secret straddling that cut, in a message dense enough with other redactable content that the cut point's post-redaction position falls inside the final visible window, leaked its un-redacted fragment. The slice boundary now extends forward past any run of token-shaped characters (bounded to 2KB extra) so it never lands mid-secret.
20
+ - **`fs.chmodSync` right after the new `O_NOFOLLOW`-hardened `touchOwnerOnly()` (1.3.5) still followed symlinks.** The O_NOFOLLOW open correctly refused to write through a symlink planted at the log path, but the very next line re-stamped the file's mode with a plain, path-based `chmodSync` — which happily dereferences a symlink and silently chmods whatever it points at, undoing the hardening one line later. `touchOwnerOnly()` now stamps the mode itself via `fchmodSync` on the same already-open, already-verified file descriptor, and the separate path-based chmod calls are gone.
21
+ - **The truncation-count in a redacted log message could go negative.** 1.3.5 switched the reported "truncated N chars" count to the pre-redaction raw length so it would reflect content discarded by the earlier cost-bounding slice — but several redaction patterns *expand* text (`key=1` → `key=[REDACTED]`), so a short, redaction-dense message could end up with a post-redaction length exceeding the raw length, making the subtraction negative and printing the literal `…[truncated -1 chars]`. Now takes the larger of the raw and post-redaction lengths.
22
+ - **`reconcileSkillInstalls()`'s new (1.3.5) symlink-repoint copy fallback never swept its own staging leftovers.** The fallback stages a fresh copy into a dot-prefixed temp dir before an atomic rename into place; a process killed between the copy succeeding and the rename could leave that temp dir behind. The sibling copy-refresh branch already sweeps its own leftovers before every attempt — this branch didn't, and since a crash also frequently makes the manifest entry look deleted (pruning it before the branch runs again), repeated failures could accumulate orphaned staging directories with nothing ever revisiting them. Now sweeps unconditionally on every visit to a symlink entry, independent of whether that entry survives.
23
+ - **The page-creation mutex's hard-release timer (1.3.5) was never unref'd**, unlike the identical PoToken-mint timer in the same release. A bare `setTimeout` keeps the event loop alive for up to 5 minutes; any shutdown path that doesn't route through the cluster worker's forced `process.exit(0)` would hang waiting on it instead of exiting promptly. Now unref'd like its sibling.
24
+
25
+ ## [1.3.5] - 2026-08-10
26
+
27
+ Two rounds of workflow-backed code review on the clean 1.3.4 tree (high-effort,
28
+ then a wider max-effort pass explicitly told not to re-find the first round's
29
+ results), followed by a triage of every open CodeQL alert. Every fix below is
30
+ covered by a passing regression test; two bugs (the OSV null-CWE crash and the
31
+ duration-formatter rounding bug) had no prior test coverage at all, so tests
32
+ were added alongside the fixes.
33
+
34
+ ### Fixed
35
+
36
+ - **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.
37
+ - **`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.
38
+ - **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.
39
+ - **`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.
40
+ - **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.
41
+ - **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.
42
+ - **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.
43
+ - **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.
44
+ - **`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.
45
+ - **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.
46
+ - **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.
47
+
48
+ ### Reviewed, not changed
49
+
50
+ 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:
51
+ - 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.
52
+ - 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.
53
+ - 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.
54
+ - 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.
55
+ - One (`js/unused-local-variable`) was a genuine unused test import; removed.
56
+
57
+ Patch, not minor: every fix is an internal correctness/security/robustness
58
+ change with no change to pi-research's own CLI, tool, or SDK contract —
59
+ matching this project's established precedent for audit-round releases
60
+ (1.3.1 through 1.3.4).
61
+
8
62
  ## [1.3.4] - 2026-08-09
9
63
 
10
64
  Two rounds on the clean 1.3.3 tree: a full-tree re-audit (10 independent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
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.4", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.3.6", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
@@ -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, {
@@ -193,6 +193,35 @@ export async function initBrowser(): Promise<void> {
193
193
  colorDepth: 24,
194
194
  pixelRatio: 1,
195
195
  },
196
+ // Mute all audio output at the engine level. The general scrape
197
+ // path drives a REAL Firefox (Camoufox), and a researcher can be
198
+ // pointed at a youtube.com watch page through the ordinary
199
+ // `scrape` tool (as opposed to the dedicated `youtube_transcript`
200
+ // tool, which never touches the browser) — YouTube watch pages
201
+ // autoplay video with sound, so without an explicit mute the
202
+ // process plays audible audio through the host's sound device.
203
+ //
204
+ // media.volume_scale forces the browser's effective output
205
+ // volume to 0 regardless of per-tab mute/autoplay state or any
206
+ // page script re-enabling audio — the most reliable, engine-level
207
+ // mute available for headless Firefox/Camoufox (the same pref
208
+ // Selenium/Playwright Firefox test grids use to silence CI runs).
209
+ // media.autoplay.default=5 additionally blocks autoplay outright
210
+ // (belt-and-suspenders; some sites route around the volume scale
211
+ // via Web Audio gain nodes). Neither pref affects rendering — the
212
+ // page still loads and paints normally, only the audio output
213
+ // device is silenced.
214
+ //
215
+ // Set on every platform/headless-mode combination, not just
216
+ // Linux: this was only *observed* on Linux (true headless and
217
+ // Xvfb-backed 'virtual' mode both retain a real PulseAudio/
218
+ // PipeWire/ALSA sink for the Firefox process to play through),
219
+ // but headless Firefox on macOS/Windows is not guaranteed to be
220
+ // silent either, so the mute is applied unconditionally.
221
+ firefox_user_prefs: {
222
+ 'media.volume_scale': '0.0',
223
+ 'media.autoplay.default': 5,
224
+ },
196
225
  });
197
226
  // Guard with a hard timeout. We catch the launchPromise rejection so it
198
227
  // doesn't surface as an UnhandledPromiseRejection if it rejects after the
@@ -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 { safeUnref } from '../../utils/safe-unref.ts';
11
12
  import { MAX_HTML_SIZE, MAX_PDF_SIZE } from '../../web-research/scraper-types.ts';
12
13
 
13
14
  /**
@@ -16,6 +17,18 @@ import { MAX_HTML_SIZE, MAX_PDF_SIZE } from '../../web-research/scraper-types.ts
16
17
  */
17
18
  let pageCreationLock = Promise.resolve();
18
19
 
20
+ /** How long createPageSafe() waits for a straggling newPage() before force-releasing
21
+ * the mutex. Must stay well above the 60s soft timeout below — it exists only to
22
+ * bound the pathological case where newPage() never settles at all (see comment below). */
23
+ const PAGE_CREATION_HARD_RELEASE_MS = 5 * 60 * 1000;
24
+
25
+ /** Set when a hard-release fires while its newPage() call is still pending —
26
+ * identifies the context that call was creating a page on. A concurrent
27
+ * newPage() on that SAME context is exactly what pageCreationLock exists to
28
+ * prevent, so any call for this context is rejected fast (without ever
29
+ * calling newPage()) until the stuck call actually settles and clears this. */
30
+ let wedgedContext: any = null;
31
+
19
32
  /** Exported for unit testing of the page-creation mutex's timeout behavior. */
20
33
  export async function createPageSafe(context: any): Promise<any> {
21
34
  let release!: () => void;
@@ -24,17 +37,47 @@ export async function createPageSafe(context: any): Promise<any> {
24
37
  pageCreationLock = currentLock.then(() => nextLock);
25
38
 
26
39
  await currentLock;
40
+ if (context === wedgedContext) {
41
+ // Release immediately so the queue keeps flowing for OTHER contexts (or
42
+ // this one, once the stuck call clears wedgedContext) instead of stalling
43
+ // every future caller behind the still-pending call.
44
+ release();
45
+ throw new Error('Browser context is wedged from a prior page-creation call that never settled; a new browser context is required.');
46
+ }
27
47
  const pagePromise = context.newPage();
28
- // Release the mutex only once newPage() itself has actually settled — NOT when
29
- // the timeout race below settles. Releasing in an outer `finally` (the old
30
- // shape) fired as soon as the 60s timeout won the race, while newPage() was
31
- // still pending in the background — so the NEXT queued caller started its OWN
48
+ // Release the mutex once newPage() itself has actually settled — NOT when the
49
+ // timeout race below settles. Releasing in an outer `finally` (the old shape)
50
+ // fired as soon as the 60s timeout won the race, while newPage() was still
51
+ // pending in the background — so the NEXT queued caller started its OWN
32
52
  // newPage() concurrently with this still-unsettled one, exactly the concurrent-
33
53
  // newPage() scenario this lock exists to prevent (see the comment above), and
34
54
  // it happens precisely when the browser is already under the stress that
35
55
  // causes 60s+ page-creation stalls in the first place. Attached before the
36
56
  // race starts so it's independent of which branch below returns/throws.
37
- pagePromise.then(release, release);
57
+ //
58
+ // A hard release timer bounds the case where newPage() never settles at all
59
+ // (rather than just being slow) — without it, a single wedged newPage() call
60
+ // would hold this lock forever and livelock every future createPageSafe() call
61
+ // on this worker thread. It marks the context wedged rather than letting the
62
+ // next caller straight through: a merely-slow (not hung) newPage() would
63
+ // otherwise still be in flight when a new one starts on the same context.
64
+ let hardReleased = false;
65
+ const releaseOnce = (): void => {
66
+ if (hardReleased) return;
67
+ hardReleased = true;
68
+ clearTimeout(hardReleaseTimer);
69
+ release();
70
+ };
71
+ const hardReleaseTimer = setTimeout(() => {
72
+ logToDebugFile('WARN', '[ThreadWorker] page-creation mutex force-released — newPage() did not settle within the hard timeout');
73
+ wedgedContext = context;
74
+ releaseOnce();
75
+ }, PAGE_CREATION_HARD_RELEASE_MS);
76
+ safeUnref(hardReleaseTimer);
77
+ pagePromise.then(
78
+ () => { if (wedgedContext === context) wedgedContext = null; releaseOnce(); },
79
+ () => { if (wedgedContext === context) wedgedContext = null; releaseOnce(); },
80
+ );
38
81
  pagePromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background page creation rejection: ${err.message}`));
39
82
 
40
83
  let timeoutId: NodeJS.Timeout | undefined;
@@ -63,6 +106,22 @@ export function setWorkerId(id: string): void {
63
106
  workerId = id;
64
107
  }
65
108
 
109
+ /**
110
+ * Parse a millisecond timeout from an env var. Uses Number() rather than
111
+ * parseInt() so trailing garbage is REJECTED rather than silently truncated —
112
+ * parseInt('1m', 10) === 1, which turned a plausible duration-shorthand typo
113
+ * into a near-instant timeout instead of falling back to the default.
114
+ */
115
+ function parseTimeoutMs(envVar: string | undefined, def: number): number {
116
+ if (envVar === undefined || envVar.trim() === '') return def;
117
+ const n = Number(envVar.trim());
118
+ if (!Number.isFinite(n) || n < 0) {
119
+ logToDebugFile('WARN', `[ThreadWorker] Invalid timeout value "${envVar}", using default: ${def}ms`);
120
+ return def;
121
+ }
122
+ return n;
123
+ }
124
+
66
125
  /**
67
126
  * Log to debug file
68
127
  */
@@ -205,7 +264,7 @@ export async function executeSearchTask(
205
264
  query: string
206
265
  ): Promise<{ results: any[]; jitter: number }> {
207
266
  const page = await createPageSafe(_context);
208
- const SEARCH_TIMEOUT = parseInt(process.env['PI_RESEARCH_SEARCH_TIMEOUT_MS'] || '45000', 10);
267
+ const SEARCH_TIMEOUT = parseTimeoutMs(process.env['PI_RESEARCH_SEARCH_TIMEOUT_MS'], 45000);
209
268
  page.setDefaultTimeout(SEARCH_TIMEOUT);
210
269
  page.setDefaultNavigationTimeout(SEARCH_TIMEOUT);
211
270
 
@@ -322,7 +381,7 @@ export async function executeScrapeTask(
322
381
  signal?: AbortSignal
323
382
  ): Promise<{ contentType: string; html?: string; bufferB64?: string; jitter: number }> {
324
383
  const page = await createPageSafe(_context);
325
- const SCRAPE_TIMEOUT = parseInt(process.env['PI_RESEARCH_SCRAPE_TIMEOUT_MS'] || '15000', 10);
384
+ const SCRAPE_TIMEOUT = parseTimeoutMs(process.env['PI_RESEARCH_SCRAPE_TIMEOUT_MS'], 15000);
326
385
  page.setDefaultTimeout(SCRAPE_TIMEOUT);
327
386
  page.setDefaultNavigationTimeout(SCRAPE_TIMEOUT);
328
387
 
@@ -666,7 +725,7 @@ export async function executeHealthCheck(
666
725
  ): Promise<{ success: boolean; navMs: number }> {
667
726
  // Nav timeout: read from env (passed through getBrowserEnv), floor at 10s.
668
727
  // The outer BrowserTaskScheduler.runHealthCheck() has its own 45s hard deadline.
669
- const configuredMs = parseInt(process.env['PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS'] || '0', 10);
728
+ const configuredMs = parseTimeoutMs(process.env['PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS'], 0);
670
729
  const HEALTH_TIMEOUT = configuredMs > 0 ? Math.max(10000, configuredMs) : 10000;
671
730
 
672
731
  if (initMs > 3000) {
@@ -45,7 +45,15 @@ function formatArg(arg) {
45
45
  return String(arg);
46
46
  }
47
47
  function redactSecrets(message) {
48
- let out = message;
48
+ const rawLength = message.length;
49
+ let scanEnd = REDACT_SCAN_LENGTH;
50
+ if (rawLength > scanEnd) {
51
+ const extensionLimit = Math.min(rawLength, scanEnd + REDACT_SCAN_EXTENSION_MAX);
52
+ while (scanEnd < extensionLimit && TOKEN_CHAR_PATTERN.test(message.charAt(scanEnd))) {
53
+ scanEnd += 1;
54
+ }
55
+ }
56
+ let out = rawLength > scanEnd ? message.slice(0, scanEnd) : message;
49
57
  out = out.replace(ANSI_PATTERN, "");
50
58
  out = out.replace(URL_CREDENTIALS_PATTERN, "$1[REDACTED]@");
51
59
  out = out.replace(JWT_PATTERN, "[REDACTED]");
@@ -62,14 +70,14 @@ function redactSecrets(message) {
62
70
  if (lastKeptUnit >= 55296 && lastKeptUnit <= 56319) keep -= 1;
63
71
  }
64
72
  if (out.length > MAX_LOG_MESSAGE_LENGTH) {
65
- out = `${out.slice(0, keep)}\u2026[truncated ${out.length - keep} chars]`;
73
+ out = `${out.slice(0, keep)}\u2026[truncated ${Math.max(rawLength, out.length) - keep} chars]`;
66
74
  }
67
75
  return out;
68
76
  }
69
77
  function stripTerminalEscapes(message) {
70
78
  return message.replace(TERMINAL_ESCAPE_PATTERN, "").replace(CONTROL_CHARS_PATTERN, " ");
71
79
  }
72
- 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;
80
+ 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, TOKEN_CHAR_PATTERN, REDACT_SCAN_EXTENSION_MAX, TERMINAL_ESCAPE_PATTERN;
73
81
  var init_log_utils = __esm({
74
82
  "src/utils/log-utils.ts"() {
75
83
  "use strict";
@@ -86,6 +94,9 @@ var init_log_utils = __esm({
86
94
  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;
87
95
  LONG_HEX_SECRET_PATTERN = /\b[0-9a-fA-F]{32,}\b/g;
88
96
  CONTROL_CHARS_PATTERN = /[\x00-\x1f\x7f]/g;
97
+ REDACT_SCAN_LENGTH = MAX_LOG_MESSAGE_LENGTH * 4;
98
+ TOKEN_CHAR_PATTERN = /[A-Za-z0-9_\-./+=]/;
99
+ REDACT_SCAN_EXTENSION_MAX = 2048;
89
100
  TERMINAL_ESCAPE_PATTERN = new RegExp(
90
101
  "\\x1b\\[[0-?]*[ -/]*[@-~]?|\\x1b\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)?|\\x1b[PX^_][^\\x1b]*(?:\\x1b\\\\)?|\\x1b.?",
91
102
  "g"
@@ -357,10 +368,26 @@ var init_error_tracker = __esm({
357
368
  // src/utils/log-rotation.ts
358
369
  import * as fs2 from "node:fs";
359
370
  import * as path2 from "node:path";
360
- var LogRotation;
371
+ function touchOwnerOnly(filePath, mode) {
372
+ let fd;
373
+ try {
374
+ fd = fs2.openSync(filePath, APPEND_CREATE_NOFOLLOW_FLAGS, mode);
375
+ fs2.fchmodSync(fd, mode);
376
+ } catch {
377
+ } finally {
378
+ if (fd !== void 0) {
379
+ try {
380
+ fs2.closeSync(fd);
381
+ } catch {
382
+ }
383
+ }
384
+ }
385
+ }
386
+ var APPEND_CREATE_NOFOLLOW_FLAGS, LogRotation;
361
387
  var init_log_rotation = __esm({
362
388
  "src/utils/log-rotation.ts"() {
363
389
  "use strict";
390
+ 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);
364
391
  LogRotation = class {
365
392
  MAX_LOG_SIZE = 10 * 1024 * 1024;
366
393
  // 10MB max file size
@@ -378,14 +405,7 @@ var init_log_rotation = __esm({
378
405
  try {
379
406
  if (fs2.existsSync(logFile)) {
380
407
  fs2.unlinkSync(logFile);
381
- try {
382
- fs2.closeSync(fs2.openSync(logFile, "a", 384));
383
- } catch {
384
- }
385
- try {
386
- fs2.chmodSync(logFile, 384);
387
- } catch {
388
- }
408
+ touchOwnerOnly(logFile, 384);
389
409
  }
390
410
  const files = fs2.readdirSync(logDir);
391
411
  const baseName = path2.basename(logFile);
@@ -421,14 +441,7 @@ var init_log_rotation = __esm({
421
441
  this.logger.log("[Logger] Log rotation skipped (rename failed; file likely in use):", renameErr);
422
442
  return;
423
443
  }
424
- try {
425
- fs2.closeSync(fs2.openSync(logFile, "a", 384));
426
- } catch {
427
- }
428
- try {
429
- fs2.chmodSync(logFile, 384);
430
- } catch {
431
- }
444
+ touchOwnerOnly(logFile, 384);
432
445
  try {
433
446
  const files = fs2.readdirSync(logDir);
434
447
  const logFiles = files.filter((f) => f.startsWith(path2.basename(logFile)) && f !== path2.basename(logFile)).sort();
@@ -1917,10 +1930,21 @@ import { platform as platform2 } from "node:os";
1917
1930
 
1918
1931
  // src/infrastructure/browser/thread-worker-messaging.ts
1919
1932
  init_log_utils();
1920
- init_scraper_types();
1921
1933
  import { appendFileSync as appendFileSync2 } from "node:fs";
1934
+
1935
+ // src/utils/safe-unref.ts
1936
+ function safeUnref(timer) {
1937
+ if (timer && typeof timer.unref === "function") {
1938
+ timer.unref();
1939
+ }
1940
+ }
1941
+
1942
+ // src/infrastructure/browser/thread-worker-messaging.ts
1943
+ init_scraper_types();
1922
1944
  var workerId2 = "";
1923
1945
  var pageCreationLock = Promise.resolve();
1946
+ var PAGE_CREATION_HARD_RELEASE_MS = 5 * 60 * 1e3;
1947
+ var wedgedContext = null;
1924
1948
  async function createPageSafe(context2) {
1925
1949
  let release;
1926
1950
  const nextLock = new Promise((resolve) => {
@@ -1929,8 +1953,34 @@ async function createPageSafe(context2) {
1929
1953
  const currentLock = pageCreationLock;
1930
1954
  pageCreationLock = currentLock.then(() => nextLock);
1931
1955
  await currentLock;
1956
+ if (context2 === wedgedContext) {
1957
+ release();
1958
+ throw new Error("Browser context is wedged from a prior page-creation call that never settled; a new browser context is required.");
1959
+ }
1932
1960
  const pagePromise = context2.newPage();
1933
- pagePromise.then(release, release);
1961
+ let hardReleased = false;
1962
+ const releaseOnce = () => {
1963
+ if (hardReleased) return;
1964
+ hardReleased = true;
1965
+ clearTimeout(hardReleaseTimer);
1966
+ release();
1967
+ };
1968
+ const hardReleaseTimer = setTimeout(() => {
1969
+ logToDebugFile2("WARN", "[ThreadWorker] page-creation mutex force-released \u2014 newPage() did not settle within the hard timeout");
1970
+ wedgedContext = context2;
1971
+ releaseOnce();
1972
+ }, PAGE_CREATION_HARD_RELEASE_MS);
1973
+ safeUnref(hardReleaseTimer);
1974
+ pagePromise.then(
1975
+ () => {
1976
+ if (wedgedContext === context2) wedgedContext = null;
1977
+ releaseOnce();
1978
+ },
1979
+ () => {
1980
+ if (wedgedContext === context2) wedgedContext = null;
1981
+ releaseOnce();
1982
+ }
1983
+ );
1934
1984
  pagePromise.catch((err) => logToDebugFile2("DEBUG", `[ThreadWorker] Background page creation rejection: ${err.message}`));
1935
1985
  let timeoutId;
1936
1986
  try {
@@ -1957,6 +2007,15 @@ async function createPageSafe(context2) {
1957
2007
  function setWorkerId2(id) {
1958
2008
  workerId2 = id;
1959
2009
  }
2010
+ function parseTimeoutMs(envVar, def) {
2011
+ if (envVar === void 0 || envVar.trim() === "") return def;
2012
+ const n = Number(envVar.trim());
2013
+ if (!Number.isFinite(n) || n < 0) {
2014
+ logToDebugFile2("WARN", `[ThreadWorker] Invalid timeout value "${envVar}", using default: ${def}ms`);
2015
+ return def;
2016
+ }
2017
+ return n;
2018
+ }
1960
2019
  function logToDebugFile2(level, ...args) {
1961
2020
  const logFile = process.env["PI_RESEARCH_LOG_FILE"];
1962
2021
  if (!logFile) return;
@@ -2066,7 +2125,7 @@ async function extractSearchResults(page) {
2066
2125
  }
2067
2126
  async function executeSearchTask(_context, query) {
2068
2127
  const page = await createPageSafe(_context);
2069
- const SEARCH_TIMEOUT = parseInt(process.env["PI_RESEARCH_SEARCH_TIMEOUT_MS"] || "45000", 10);
2128
+ const SEARCH_TIMEOUT = parseTimeoutMs(process.env["PI_RESEARCH_SEARCH_TIMEOUT_MS"], 45e3);
2070
2129
  page.setDefaultTimeout(SEARCH_TIMEOUT);
2071
2130
  page.setDefaultNavigationTimeout(SEARCH_TIMEOUT);
2072
2131
  try {
@@ -2135,7 +2194,7 @@ function seedSubresourceVerdict(validatedUrl) {
2135
2194
  }
2136
2195
  async function executeScrapeTask(_context, url, signal) {
2137
2196
  const page = await createPageSafe(_context);
2138
- const SCRAPE_TIMEOUT = parseInt(process.env["PI_RESEARCH_SCRAPE_TIMEOUT_MS"] || "15000", 10);
2197
+ const SCRAPE_TIMEOUT = parseTimeoutMs(process.env["PI_RESEARCH_SCRAPE_TIMEOUT_MS"], 15e3);
2139
2198
  page.setDefaultTimeout(SCRAPE_TIMEOUT);
2140
2199
  page.setDefaultNavigationTimeout(SCRAPE_TIMEOUT);
2141
2200
  const onAbort = () => {
@@ -2301,7 +2360,7 @@ async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PR
2301
2360
  }
2302
2361
  }
2303
2362
  async function executeHealthCheck(_context, initMs) {
2304
- const configuredMs = parseInt(process.env["PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS"] || "0", 10);
2363
+ const configuredMs = parseTimeoutMs(process.env["PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS"], 0);
2305
2364
  const HEALTH_TIMEOUT = configuredMs > 0 ? Math.max(1e4, configuredMs) : 1e4;
2306
2365
  if (initMs > 3e3) {
2307
2366
  logToDebugFile2("WARN", `[Worker-${workerId2}] Browser init was slow: ${initMs}ms \u2014 leaving less headroom for DDG navigation.`);
@@ -2594,6 +2653,35 @@ async function initBrowser() {
2594
2653
  height: 1080,
2595
2654
  colorDepth: 24,
2596
2655
  pixelRatio: 1
2656
+ },
2657
+ // Mute all audio output at the engine level. The general scrape
2658
+ // path drives a REAL Firefox (Camoufox), and a researcher can be
2659
+ // pointed at a youtube.com watch page through the ordinary
2660
+ // `scrape` tool (as opposed to the dedicated `youtube_transcript`
2661
+ // tool, which never touches the browser) — YouTube watch pages
2662
+ // autoplay video with sound, so without an explicit mute the
2663
+ // process plays audible audio through the host's sound device.
2664
+ //
2665
+ // media.volume_scale forces the browser's effective output
2666
+ // volume to 0 regardless of per-tab mute/autoplay state or any
2667
+ // page script re-enabling audio — the most reliable, engine-level
2668
+ // mute available for headless Firefox/Camoufox (the same pref
2669
+ // Selenium/Playwright Firefox test grids use to silence CI runs).
2670
+ // media.autoplay.default=5 additionally blocks autoplay outright
2671
+ // (belt-and-suspenders; some sites route around the volume scale
2672
+ // via Web Audio gain nodes). Neither pref affects rendering — the
2673
+ // page still loads and paints normally, only the audio output
2674
+ // device is silenced.
2675
+ //
2676
+ // Set on every platform/headless-mode combination, not just
2677
+ // Linux: this was only *observed* on Linux (true headless and
2678
+ // Xvfb-backed 'virtual' mode both retain a real PulseAudio/
2679
+ // PipeWire/ALSA sink for the Firefox process to play through),
2680
+ // but headless Firefox on macOS/Windows is not guaranteed to be
2681
+ // silent either, so the mute is applied unconditionally.
2682
+ firefox_user_prefs: {
2683
+ "media.volume_scale": "0.0",
2684
+ "media.autoplay.default": 5
2597
2685
  }
2598
2686
  });
2599
2687
  launchPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser launch rejection: ${err.message}`));
@@ -237,7 +237,7 @@ export function mapOsvItemToVulnerability(item: OsvVulnerability): Vulnerability
237
237
  for (const cwe of item.database_specific.cwe) {
238
238
  if (typeof cwe === 'string') {
239
239
  cwes.push(cwe);
240
- } else if (typeof cwe === 'object' && typeof (cwe as { id?: string }).id === 'string') {
240
+ } else if (typeof cwe === 'object' && cwe !== null && typeof (cwe as { id?: string }).id === 'string') {
241
241
  cwes.push((cwe as { id: string }).id);
242
242
  }
243
243
  }
@@ -631,6 +631,22 @@ export function reconcileSkillInstalls(opts: InstallOptions = {}): ReconcileResu
631
631
  continue;
632
632
  }
633
633
 
634
+ // Sweep any `.{base}.new-*` staging leftover from a previous crashed
635
+ // symlink-repoint attempt at this path, unconditionally — a process kill
636
+ // between copyDir succeeding and the final renameSync below can leave one
637
+ // behind, and this branch is not guaranteed to run again for the same
638
+ // entry (a crash also often leaves isSymlinkPresent() false, pruning the
639
+ // entry on the very next line before it would otherwise get a chance to).
640
+ const dir = path.dirname(e.path);
641
+ const base = path.basename(e.path);
642
+ try {
643
+ for (const name of fs.readdirSync(dir)) {
644
+ if (name.startsWith(`.${base}.new-`)) {
645
+ fs.rmSync(path.join(dir, name), { recursive: true, force: true });
646
+ }
647
+ }
648
+ } catch { /* hygiene only */ }
649
+
634
650
  // Link the user deleted by hand — nothing on disk; drop the stale entry.
635
651
  if (!isSymlinkPresent(e.path)) { result.pruned.push(e.path); continue; }
636
652
 
@@ -647,10 +663,36 @@ export function reconcileSkillInstalls(opts: InstallOptions = {}): ReconcileResu
647
663
  // Re-point to the current package source (handles update/reinstall).
648
664
  try {
649
665
  fs.unlinkSync(e.path);
666
+ } catch { keep.push(e); continue; /* old link untouched; retry next startup */ }
667
+ try {
650
668
  fs.symlinkSync(source, e.path, process.platform === 'win32' ? 'junction' : 'dir');
651
669
  keep.push({ ...e, type: 'symlink', source });
652
670
  result.repointed.push(e.path);
653
- } catch { keep.push(e); /* leave entry; retry next startup */ }
671
+ } catch {
672
+ // The old link is already gone at this point (unlinkSync above
673
+ // succeeded). Mirror installSkill()'s fallback: try a copy rather than
674
+ // leaving the skill MISSING on disk while the manifest still claims
675
+ // it's installed (e.g. a Windows cross-volume junction failure).
676
+ // Stage into a dot-prefixed temp dir and rename into place, same as
677
+ // the copy-refresh path above — copying directly into e.path left a
678
+ // partial, SKILL.md-less directory permanently orphaned on disk
679
+ // (unrecognized by isOwnedCopy) when copyDir failed partway (disk
680
+ // full, interrupted process).
681
+ const suffix = crypto.randomBytes(4).toString('hex');
682
+ const fresh = path.join(dir, `.${base}.new-${suffix}`);
683
+ try {
684
+ copyDir(source, fresh);
685
+ fs.renameSync(fresh, e.path);
686
+ keep.push({ ...e, type: 'copy', source });
687
+ result.repointed.push(e.path);
688
+ } catch {
689
+ try { fs.rmSync(fresh, { recursive: true, force: true }); } catch { /* best effort */ }
690
+ // Both the re-point and the copy fallback failed — the skill really
691
+ // is gone from disk now. Drop the entry instead of leaving the
692
+ // manifest claiming a missing skill is still installed.
693
+ result.pruned.push(e.path);
694
+ }
695
+ }
654
696
  } else if (!targetLive) {
655
697
  // Dangling with no resolvable source (package fully removed): remove the
656
698
  // dead link and drop the entry so the agent's skills dir stays clean.
@@ -24,6 +24,7 @@ import { ServiceNames } from '../core/service-interfaces.ts';
24
24
  import type { IResearchOrchestration, IResearchSynthesisService } from '../core/service-interfaces.ts';
25
25
  import { metrics, MetricsRegistry, runWithRunRegistry } from '../utils/metrics.ts';
26
26
  import { createResearchRunId, logger, createLogger, isVerboseFromEnv, runWithLogger } from '../logger.ts';
27
+ import { redactSecrets } from '../utils/log-utils.ts';
27
28
  import { exportResearchReport, appendExportMessage } from '../utils/research-export.ts';
28
29
  import { validateAndSanitizeQuery } from '../utils/input-validation.ts';
29
30
  import { validateInitialLinks, MAX_INITIAL_LINKS, MAX_INITIAL_LINK_CHARS } from '../utils/url-utils.ts';
@@ -507,14 +508,14 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
507
508
  return {
508
509
  content: [{
509
510
  type: 'text',
510
- text: `[SYSTEM MESSAGE]: The research operation was halted gracefully because an API rate limit (HTTP 429) was reached. Please inform the user that the operation was stopped due to provider rate limits and they should wait a moment before trying again.\n\nDetails: ${String(error)}`
511
+ text: `[SYSTEM MESSAGE]: The research operation was halted gracefully because an API rate limit (HTTP 429) was reached. Please inform the user that the operation was stopped due to provider rate limits and they should wait a moment before trying again.\n\nDetails: ${redactSecrets(String(error))}`
511
512
  }],
512
513
  details: {}
513
514
  };
514
515
  }
515
516
 
516
517
  logger.error('[research] run failed', error);
517
- return { content: [{ type: 'text', text: `Research failed: ${String(error)}` }], details: {} };
518
+ return { content: [{ type: 'text', text: `Research failed: ${redactSecrets(String(error))}` }], details: {} };
518
519
  } finally {
519
520
  // Guarantees panel teardown even when the run fails before the inner
520
521
  // research loop is reached (e.g. health-check failure), where the inner