@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/README.md +21 -26
- package/dist/cli.mjs +108 -40
- package/dist/thread-worker.mjs +113 -25
- package/docs/CHANGELOG.md +54 -0
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/infrastructure/browser/browser-client.ts +5 -2
- package/src/infrastructure/browser/thread-worker-browser.ts +29 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +67 -8
- package/src/infrastructure/browser/thread-worker.mjs +113 -25
- package/src/security/osv-types.ts +1 -1
- package/src/skill-install/skill-installer.ts +43 -1
- package/src/tools/research-tool-definition.ts +3 -2
- package/src/tools/scrape.ts +6 -4
- package/src/tools/security.ts +13 -4
- package/src/utils/log-rotation.ts +44 -22
- package/src/utils/log-utils.ts +37 -2
- package/src/utils/text-utils.ts +6 -2
- package/src/youtube/potoken.ts +48 -2
- package/src/youtube/transcript-client.ts +5 -2
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
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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
|