@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/README.md
CHANGED
|
@@ -24,38 +24,31 @@
|
|
|
24
24
|
|
|
25
25
|
<a href="https://github.com/Lincoln504/pi-research/actions/workflows/ci.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/Lincoln504/pi-research/ci.yml?style=flat-square&branch=main" /></a> <a href="https://www.npmjs.com/package/@lincoln504/pi-research"><img alt="npm version" src="https://img.shields.io/npm/v/@lincoln504/pi-research.svg?style=flat-square" /></a>
|
|
26
26
|
|
|
27
|
-
**Free, unlimited web search & scraping — no monthly quota, no subscription.**
|
|
28
|
-
You pay only for the LLM tokens you use, and it builds your own private, searchable
|
|
29
|
-
knowledge store on your machine.
|
|
27
|
+
**Free, unlimited web search & scraping — no monthly quota, no subscription.** You pay only for the LLM tokens you use, and it can build a searchable knowledge store locally.
|
|
30
28
|
|
|
31
29
|
### How it works
|
|
32
30
|
|
|
33
|
-
A research run
|
|
34
|
-
opening search, parallel researcher agents scrape and read sources through a fixed,
|
|
35
|
-
sandboxed toolkit (no shell access, and no ability to write or modify files), and an evaluator decides whether
|
|
36
|
-
to run another round or synthesize — returning one cited Markdown report while its
|
|
37
|
-
sources are saved to the knowledge store in the background.
|
|
31
|
+
A research run loops through agent teams: a coordinator plans and opens with a search, researcher agents scrape and read in parallel, and an evaluator decides whether to go another round or synthesize — ending in one cited Markdown report, with findings optionally saved to the knowledge store.
|
|
38
32
|
|
|
39
|
-
Three depth levels — normal, deep, ultra — set the team size and number of rounds;
|
|
40
|
-
ask in natural language and the tool picks the right one:
|
|
33
|
+
Three depth levels — normal, deep, ultra — set the team size and number of rounds; ask in natural language and the tool picks the right one:
|
|
41
34
|
|
|
42
35
|

|
|
43
36
|
|
|
44
37
|
### Use cases
|
|
45
38
|
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
- Populating a dataset or
|
|
39
|
+
- Researching for your [Pi](https://github.com/earendil-works/pi) Coding Agent — plug-and-play, no API key needed.
|
|
40
|
+
- Researching from Claude Code, Codex, or another coding agent, with a cheaper lightweight or local model driving the research so it doesn't spend your main agent's budget.
|
|
41
|
+
- Populating a dataset or index of web sources.
|
|
49
42
|
- Holding research in the knowledge store with a configurable scope — project-specific or globally user-scoped, set per directory from the `/research-config` TUI.
|
|
50
43
|
- Using the pi-research agent skill as OpenClaw's web access.
|
|
51
|
-
- Building agent systems that
|
|
44
|
+
- Building agent systems that find and read web content.
|
|
52
45
|
|
|
53
46
|
### Why pi-research
|
|
54
47
|
|
|
55
|
-
- **Free, unlimited search & scrape — no quota, no monthly fee.** Most AI-search providers cap free searches per month and then charge; results run on *their* index on *their* servers. pi-research's search and scraping layer (DuckDuckGo via a stealth browser) is free and unlimited — you pay only for the LLM tokens you use
|
|
56
|
-
- **You own your index.** Every finding is written into a local [LanceDB](https://lancedb.com) knowledge store on *your* machine — a persistent, searchable index you control and keep, which also seeds future runs so repeat questions get faster, cheaper answers.
|
|
57
|
-
- **Cited reports, not raw dumps.** It returns one synthesized, sourced Markdown report
|
|
58
|
-
- **Read-only by design.** Web access runs inside a
|
|
48
|
+
- **Free, unlimited search & scrape — no quota, no monthly fee.** Most AI-search providers cap free searches per month and then charge; results run on *their* index on *their* servers. pi-research's search and scraping layer (DuckDuckGo via a stealth browser) is free and unlimited — you pay only for the LLM tokens you use.
|
|
49
|
+
- **You own your index.** Every finding is written into a local [LanceDB](https://lancedb.com) knowledge store on *your* machine — a persistent, searchable index you control and keep, which also seeds future runs so repeat questions get faster, cheaper answers. Findings can also be kept globally, scoped to a single project, or not kept at all — set per directory, changed whenever you like.
|
|
50
|
+
- **Cited reports, not raw dumps.** It returns one synthesized, sourced Markdown report instead of pasting raw web content into the conversation — context-efficient and easy to verify.
|
|
51
|
+
- **Read-only by design.** Web access runs inside a limited research agent that cannot run shell commands or write, edit, or delete anything. It can read and search files, so treat it as you would any read-only agent with access to your machine.
|
|
59
52
|
- **Search a little or a lot** — three depth levels in the pi tool (four via the SDK and standalone CLI, which add the quick depth-0 path). Levels 1 and 2 are recommended for everyday workflow use; level 3 is for larger-scale investigations.
|
|
60
53
|
|
|
61
54
|
### Requirements / limitations
|
|
@@ -63,9 +56,9 @@ ask in natural language and the tool picks the right one:
|
|
|
63
56
|
- Node.js >= 22.19.0
|
|
64
57
|
- An LLM with a 100k+ context window (bring your own key or use a local model)
|
|
65
58
|
- Internet access
|
|
66
|
-
- A residential IP address — search, scraping, and YouTube transcripts all rely on a residential connection. A datacenter/VPS/cloud IP gets bot-blocked by
|
|
59
|
+
- A residential IP address — search, scraping, and YouTube transcripts all rely on a residential connection. A datacenter/VPS/cloud IP gets bot-blocked by these providers.
|
|
67
60
|
- The pi runtime the engine builds on — `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui`. The pi extension uses the host's copies; the standalone CLI / agent skill install them as dependencies.
|
|
68
|
-
- Cloudflare and similar anti-automation systems block scraping on some sites, so
|
|
61
|
+
- Cloudflare and similar anti-automation systems block scraping on some sites, so a run will flag pages it can't reach. pi-research compensates with volume: the search tool pulls a large set of results off free DuckDuckGo, giving the model a wide selection of reachable content to cite.
|
|
69
62
|
|
|
70
63
|
### Install
|
|
71
64
|
|
|
@@ -73,21 +66,23 @@ ask in natural language and the tool picks the right one:
|
|
|
73
66
|
pi install npm:@lincoln504/pi-research
|
|
74
67
|
```
|
|
75
68
|
|
|
76
|
-
See [Agent skill](docs/AGENT-SKILL.md) or [SDK](docs/SDK.md)
|
|
69
|
+
See [Agent skill](docs/AGENT-SKILL.md) or [SDK](docs/SDK.md) to install standalone (no pi extension).
|
|
77
70
|
|
|
78
|
-
The pi extension works out of the box — it runs on your pi session's model and pi's own configuration.
|
|
71
|
+
The pi extension works out of the box — it runs on your pi session's model and pi's own configuration. To use it standalone (agent skill / CLI), configure a model: set `PI_RESEARCH_MODEL` (e.g. `openai/gpt-4o`) as an environment variable or in `~/.pi/research/config.env` (the CLI also accepts a per-run `--model`), plus `PI_RESEARCH_API_KEY` if you don't use pi; the SDK takes a `model` option. See [Configuration](docs/CONFIGURATION.md).
|
|
79
72
|
|
|
80
73
|
The first install pulls the stealth browser engine, which takes a few minutes.
|
|
81
74
|
|
|
82
75
|
### Stability (v1.x.x)
|
|
83
76
|
|
|
84
|
-
v0.1.13 (April 2026) was the last release before a rebuild against current pi APIs
|
|
85
|
-
and the stealth-browser stack. v1.0.0 is the first stable release since. Channels:
|
|
77
|
+
v0.1.13 (April 2026) was the last release before a rebuild against current pi APIs and the stealth-browser stack. v1.0.0 is the first stable release since. Channels:
|
|
86
78
|
|
|
87
79
|
- npm (`npm:@lincoln504/pi-research`) is the stable channel, kept current with breaking pi changes.
|
|
88
80
|
- A git install is the development channel: latest commits, first to break.
|
|
89
81
|
|
|
90
82
|
### License
|
|
91
83
|
|
|
92
|
-
MIT. Bundled third-party licenses are listed in
|
|
93
|
-
|
|
84
|
+
MIT. Bundled third-party licenses are listed in [docs/THIRD-PARTY-NOTICES.md](docs/THIRD-PARTY-NOTICES.md).
|
|
85
|
+
|
|
86
|
+
### Package identity
|
|
87
|
+
|
|
88
|
+
This project is always `@lincoln504/pi-research` on npm. The `pi-research` (unscoped) package is unrelated and deprecated.
|
package/dist/cli.mjs
CHANGED
|
@@ -215,7 +215,15 @@ function formatArg(arg) {
|
|
|
215
215
|
return String(arg);
|
|
216
216
|
}
|
|
217
217
|
function redactSecrets(message) {
|
|
218
|
-
|
|
218
|
+
const rawLength = message.length;
|
|
219
|
+
let scanEnd = REDACT_SCAN_LENGTH;
|
|
220
|
+
if (rawLength > scanEnd) {
|
|
221
|
+
const extensionLimit = Math.min(rawLength, scanEnd + REDACT_SCAN_EXTENSION_MAX);
|
|
222
|
+
while (scanEnd < extensionLimit && TOKEN_CHAR_PATTERN.test(message.charAt(scanEnd))) {
|
|
223
|
+
scanEnd += 1;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
let out = rawLength > scanEnd ? message.slice(0, scanEnd) : message;
|
|
219
227
|
out = out.replace(ANSI_PATTERN, "");
|
|
220
228
|
out = out.replace(URL_CREDENTIALS_PATTERN, "$1[REDACTED]@");
|
|
221
229
|
out = out.replace(JWT_PATTERN, "[REDACTED]");
|
|
@@ -232,14 +240,14 @@ function redactSecrets(message) {
|
|
|
232
240
|
if (lastKeptUnit >= 55296 && lastKeptUnit <= 56319) keep -= 1;
|
|
233
241
|
}
|
|
234
242
|
if (out.length > MAX_LOG_MESSAGE_LENGTH) {
|
|
235
|
-
out = `${out.slice(0, keep)}\u2026[truncated ${out.length - keep} chars]`;
|
|
243
|
+
out = `${out.slice(0, keep)}\u2026[truncated ${Math.max(rawLength, out.length) - keep} chars]`;
|
|
236
244
|
}
|
|
237
245
|
return out;
|
|
238
246
|
}
|
|
239
247
|
function stripTerminalEscapes(message) {
|
|
240
248
|
return message.replace(TERMINAL_ESCAPE_PATTERN, "").replace(CONTROL_CHARS_PATTERN, " ");
|
|
241
249
|
}
|
|
242
|
-
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;
|
|
250
|
+
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;
|
|
243
251
|
var init_log_utils = __esm({
|
|
244
252
|
"src/utils/log-utils.ts"() {
|
|
245
253
|
"use strict";
|
|
@@ -256,6 +264,9 @@ var init_log_utils = __esm({
|
|
|
256
264
|
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;
|
|
257
265
|
LONG_HEX_SECRET_PATTERN = /\b[0-9a-fA-F]{32,}\b/g;
|
|
258
266
|
CONTROL_CHARS_PATTERN = /[\x00-\x1f\x7f]/g;
|
|
267
|
+
REDACT_SCAN_LENGTH = MAX_LOG_MESSAGE_LENGTH * 4;
|
|
268
|
+
TOKEN_CHAR_PATTERN = /[A-Za-z0-9_\-./+=]/;
|
|
269
|
+
REDACT_SCAN_EXTENSION_MAX = 2048;
|
|
259
270
|
TERMINAL_ESCAPE_PATTERN = new RegExp(
|
|
260
271
|
"\\x1b\\[[0-?]*[ -/]*[@-~]?|\\x1b\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)?|\\x1b[PX^_][^\\x1b]*(?:\\x1b\\\\)?|\\x1b.?",
|
|
261
272
|
"g"
|
|
@@ -266,10 +277,26 @@ var init_log_utils = __esm({
|
|
|
266
277
|
// src/utils/log-rotation.ts
|
|
267
278
|
import * as fs from "node:fs";
|
|
268
279
|
import * as path2 from "node:path";
|
|
269
|
-
|
|
280
|
+
function touchOwnerOnly(filePath, mode) {
|
|
281
|
+
let fd;
|
|
282
|
+
try {
|
|
283
|
+
fd = fs.openSync(filePath, APPEND_CREATE_NOFOLLOW_FLAGS, mode);
|
|
284
|
+
fs.fchmodSync(fd, mode);
|
|
285
|
+
} catch {
|
|
286
|
+
} finally {
|
|
287
|
+
if (fd !== void 0) {
|
|
288
|
+
try {
|
|
289
|
+
fs.closeSync(fd);
|
|
290
|
+
} catch {
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
var APPEND_CREATE_NOFOLLOW_FLAGS, LogRotation;
|
|
270
296
|
var init_log_rotation = __esm({
|
|
271
297
|
"src/utils/log-rotation.ts"() {
|
|
272
298
|
"use strict";
|
|
299
|
+
APPEND_CREATE_NOFOLLOW_FLAGS = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0);
|
|
273
300
|
LogRotation = class {
|
|
274
301
|
MAX_LOG_SIZE = 10 * 1024 * 1024;
|
|
275
302
|
// 10MB max file size
|
|
@@ -287,14 +314,7 @@ var init_log_rotation = __esm({
|
|
|
287
314
|
try {
|
|
288
315
|
if (fs.existsSync(logFile)) {
|
|
289
316
|
fs.unlinkSync(logFile);
|
|
290
|
-
|
|
291
|
-
fs.closeSync(fs.openSync(logFile, "a", 384));
|
|
292
|
-
} catch {
|
|
293
|
-
}
|
|
294
|
-
try {
|
|
295
|
-
fs.chmodSync(logFile, 384);
|
|
296
|
-
} catch {
|
|
297
|
-
}
|
|
317
|
+
touchOwnerOnly(logFile, 384);
|
|
298
318
|
}
|
|
299
319
|
const files = fs.readdirSync(logDir);
|
|
300
320
|
const baseName = path2.basename(logFile);
|
|
@@ -330,14 +350,7 @@ var init_log_rotation = __esm({
|
|
|
330
350
|
this.logger.log("[Logger] Log rotation skipped (rename failed; file likely in use):", renameErr);
|
|
331
351
|
return;
|
|
332
352
|
}
|
|
333
|
-
|
|
334
|
-
fs.closeSync(fs.openSync(logFile, "a", 384));
|
|
335
|
-
} catch {
|
|
336
|
-
}
|
|
337
|
-
try {
|
|
338
|
-
fs.chmodSync(logFile, 384);
|
|
339
|
-
} catch {
|
|
340
|
-
}
|
|
353
|
+
touchOwnerOnly(logFile, 384);
|
|
341
354
|
try {
|
|
342
355
|
const files = fs.readdirSync(logDir);
|
|
343
356
|
const logFiles = files.filter((f) => f.startsWith(path2.basename(logFile)) && f !== path2.basename(logFile)).sort();
|
|
@@ -9911,19 +9924,19 @@ function reconcileSkillInstalls(opts = {}) {
|
|
|
9911
9924
|
if (e.type !== "symlink") {
|
|
9912
9925
|
const stale2 = currentVersion !== null && e.version !== currentVersion;
|
|
9913
9926
|
if (stale2 && source !== null && fs16.existsSync(e.path) && isOwnedCopy(e.path)) {
|
|
9914
|
-
const
|
|
9915
|
-
const
|
|
9927
|
+
const dir2 = path17.dirname(e.path);
|
|
9928
|
+
const base2 = path17.basename(e.path);
|
|
9916
9929
|
try {
|
|
9917
|
-
for (const name of fs16.readdirSync(
|
|
9918
|
-
if (name.startsWith(`.${
|
|
9919
|
-
fs16.rmSync(path17.join(
|
|
9930
|
+
for (const name of fs16.readdirSync(dir2)) {
|
|
9931
|
+
if (name.startsWith(`.${base2}.new-`) || name.startsWith(`.${base2}.old-`)) {
|
|
9932
|
+
fs16.rmSync(path17.join(dir2, name), { recursive: true, force: true });
|
|
9920
9933
|
}
|
|
9921
9934
|
}
|
|
9922
9935
|
} catch {
|
|
9923
9936
|
}
|
|
9924
9937
|
const suffix = crypto10.randomBytes(4).toString("hex");
|
|
9925
|
-
const fresh = path17.join(
|
|
9926
|
-
const retired = path17.join(
|
|
9938
|
+
const fresh = path17.join(dir2, `.${base2}.new-${suffix}`);
|
|
9939
|
+
const retired = path17.join(dir2, `.${base2}.old-${suffix}`);
|
|
9927
9940
|
try {
|
|
9928
9941
|
copyDir(source, fresh);
|
|
9929
9942
|
} catch {
|
|
@@ -9969,6 +9982,16 @@ function reconcileSkillInstalls(opts = {}) {
|
|
|
9969
9982
|
}
|
|
9970
9983
|
continue;
|
|
9971
9984
|
}
|
|
9985
|
+
const dir = path17.dirname(e.path);
|
|
9986
|
+
const base = path17.basename(e.path);
|
|
9987
|
+
try {
|
|
9988
|
+
for (const name of fs16.readdirSync(dir)) {
|
|
9989
|
+
if (name.startsWith(`.${base}.new-`)) {
|
|
9990
|
+
fs16.rmSync(path17.join(dir, name), { recursive: true, force: true });
|
|
9991
|
+
}
|
|
9992
|
+
}
|
|
9993
|
+
} catch {
|
|
9994
|
+
}
|
|
9972
9995
|
if (!isSymlinkPresent(e.path)) {
|
|
9973
9996
|
result.pruned.push(e.path);
|
|
9974
9997
|
continue;
|
|
@@ -9988,11 +10011,29 @@ function reconcileSkillInstalls(opts = {}) {
|
|
|
9988
10011
|
if (source !== null) {
|
|
9989
10012
|
try {
|
|
9990
10013
|
fs16.unlinkSync(e.path);
|
|
10014
|
+
} catch {
|
|
10015
|
+
keep.push(e);
|
|
10016
|
+
continue;
|
|
10017
|
+
}
|
|
10018
|
+
try {
|
|
9991
10019
|
fs16.symlinkSync(source, e.path, process.platform === "win32" ? "junction" : "dir");
|
|
9992
10020
|
keep.push({ ...e, type: "symlink", source });
|
|
9993
10021
|
result.repointed.push(e.path);
|
|
9994
10022
|
} catch {
|
|
9995
|
-
|
|
10023
|
+
const suffix = crypto10.randomBytes(4).toString("hex");
|
|
10024
|
+
const fresh = path17.join(dir, `.${base}.new-${suffix}`);
|
|
10025
|
+
try {
|
|
10026
|
+
copyDir(source, fresh);
|
|
10027
|
+
fs16.renameSync(fresh, e.path);
|
|
10028
|
+
keep.push({ ...e, type: "copy", source });
|
|
10029
|
+
result.repointed.push(e.path);
|
|
10030
|
+
} catch {
|
|
10031
|
+
try {
|
|
10032
|
+
fs16.rmSync(fresh, { recursive: true, force: true });
|
|
10033
|
+
} catch {
|
|
10034
|
+
}
|
|
10035
|
+
result.pruned.push(e.path);
|
|
10036
|
+
}
|
|
9996
10037
|
}
|
|
9997
10038
|
} else if (!targetLive) {
|
|
9998
10039
|
try {
|
|
@@ -11439,6 +11480,7 @@ function isFullMockMode() {
|
|
|
11439
11480
|
init_config();
|
|
11440
11481
|
init_logger();
|
|
11441
11482
|
init_error_tracker();
|
|
11483
|
+
init_log_utils();
|
|
11442
11484
|
init_http_body();
|
|
11443
11485
|
import * as http2 from "node:http";
|
|
11444
11486
|
|
|
@@ -11574,7 +11616,7 @@ var BrowserClient = class {
|
|
|
11574
11616
|
resolve9(parsed);
|
|
11575
11617
|
}
|
|
11576
11618
|
} catch (_e) {
|
|
11577
|
-
const raw = collected.toString();
|
|
11619
|
+
const raw = redactSecrets(collected.toString());
|
|
11578
11620
|
const preview = raw.length > 200 ? raw.slice(0, 200) + "..." : raw;
|
|
11579
11621
|
const error = new Error(`Failed to parse response (status ${res.statusCode}): ${preview}`);
|
|
11580
11622
|
errorTracker.trackError(error, {
|
|
@@ -17545,6 +17587,7 @@ async function scrape(urls, maxConcurrency = 5, signal, config, sessionId, onUrl
|
|
|
17545
17587
|
// src/tools/scrape.ts
|
|
17546
17588
|
init_constants();
|
|
17547
17589
|
init_text_utils();
|
|
17590
|
+
init_log_utils();
|
|
17548
17591
|
init_config();
|
|
17549
17592
|
init_service_registry();
|
|
17550
17593
|
init_service_interfaces();
|
|
@@ -17716,18 +17759,19 @@ ${evictedDuplicates.map((u) => `- ${u}`).join("\n")}
|
|
|
17716
17759
|
let freshResults;
|
|
17717
17760
|
const concurrency = p.maxConcurrency || defaultConcurrency;
|
|
17718
17761
|
try {
|
|
17719
|
-
const results = await scrape(urlsToFetch, concurrency, signal,
|
|
17762
|
+
const results = await scrape(urlsToFetch, concurrency, signal, config, getGlobalState().researchId, (result) => {
|
|
17720
17763
|
options.onUrlScrapeResult?.(result.url, result.success);
|
|
17721
17764
|
}, container);
|
|
17722
17765
|
freshResults = Array.isArray(results) ? results : [];
|
|
17723
17766
|
} catch (error) {
|
|
17724
|
-
|
|
17767
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
17768
|
+
logger.error(`[scrape tool] Scrape failed: ${message}`);
|
|
17725
17769
|
metrics.increment("tool_scrape_calls_total", 1, { status: "error" });
|
|
17726
17770
|
return {
|
|
17727
17771
|
content: [{ type: "text", text: `# Scrape Failed
|
|
17728
17772
|
|
|
17729
|
-
${
|
|
17730
|
-
details: { error: String(error) }
|
|
17773
|
+
${redactSecrets(message)}` }],
|
|
17774
|
+
details: { error: redactSecrets(String(error)) }
|
|
17731
17775
|
};
|
|
17732
17776
|
}
|
|
17733
17777
|
const successfulFresh = freshResults.filter((r) => r.success);
|
|
@@ -18974,7 +19018,7 @@ function mapOsvItemToVulnerability(item) {
|
|
|
18974
19018
|
for (const cwe of item.database_specific.cwe) {
|
|
18975
19019
|
if (typeof cwe === "string") {
|
|
18976
19020
|
cwes.push(cwe);
|
|
18977
|
-
} else if (typeof cwe === "object" && typeof cwe.id === "string") {
|
|
19021
|
+
} else if (typeof cwe === "object" && cwe !== null && typeof cwe.id === "string") {
|
|
18978
19022
|
cwes.push(cwe.id);
|
|
18979
19023
|
}
|
|
18980
19024
|
}
|
|
@@ -19355,6 +19399,10 @@ async function searchSecurityDatabases(params, signal) {
|
|
|
19355
19399
|
// src/tools/security.ts
|
|
19356
19400
|
init_constants();
|
|
19357
19401
|
init_metrics();
|
|
19402
|
+
var MAX_DESCRIPTION_CHARS = 300;
|
|
19403
|
+
function truncateDescription(description) {
|
|
19404
|
+
return description.length > MAX_DESCRIPTION_CHARS ? `${description.substring(0, MAX_DESCRIPTION_CHARS)}...` : description;
|
|
19405
|
+
}
|
|
19358
19406
|
function createSecuritySearchTool(options) {
|
|
19359
19407
|
const SecuritySearchParamsSchema = Type6.Object({
|
|
19360
19408
|
databases: Type6.Optional(Type6.Array(Type6.String({
|
|
@@ -19557,7 +19605,7 @@ Unable to search security databases. This may be a temporary issue - try again l
|
|
|
19557
19605
|
}
|
|
19558
19606
|
}
|
|
19559
19607
|
const description = vuln.description;
|
|
19560
|
-
markdown += `- **Description:** ${description
|
|
19608
|
+
markdown += `- **Description:** ${truncateDescription(description)}
|
|
19561
19609
|
`;
|
|
19562
19610
|
if (vuln.knownExploited === true) {
|
|
19563
19611
|
markdown += "- **[Actively Exploited]**\n";
|
|
@@ -19609,7 +19657,7 @@ Unable to search security databases. This may be a temporary issue - try again l
|
|
|
19609
19657
|
`;
|
|
19610
19658
|
}
|
|
19611
19659
|
const description = vuln.description;
|
|
19612
|
-
markdown += `- **Description:** ${description
|
|
19660
|
+
markdown += `- **Description:** ${truncateDescription(description)}
|
|
19613
19661
|
`;
|
|
19614
19662
|
if (vuln.dueDate !== void 0) {
|
|
19615
19663
|
markdown += `- **Due Date:** ${vuln.dueDate}
|
|
@@ -19657,7 +19705,7 @@ Unable to search security databases. This may be a temporary issue - try again l
|
|
|
19657
19705
|
markdown += `- **Summary:** ${adv.summary}
|
|
19658
19706
|
`;
|
|
19659
19707
|
const description = adv.description ?? "";
|
|
19660
|
-
markdown += `- **Description:** ${description
|
|
19708
|
+
markdown += `- **Description:** ${truncateDescription(description)}
|
|
19661
19709
|
`;
|
|
19662
19710
|
if (adv.published) {
|
|
19663
19711
|
markdown += `- **Published:** ${adv.published}
|
|
@@ -19699,7 +19747,7 @@ Unable to search security databases. This may be a temporary issue - try again l
|
|
|
19699
19747
|
markdown += `- **Severity:** ${vuln.severity}
|
|
19700
19748
|
`;
|
|
19701
19749
|
const description = vuln.description;
|
|
19702
|
-
markdown += `- **Description:** ${description
|
|
19750
|
+
markdown += `- **Description:** ${truncateDescription(description)}
|
|
19703
19751
|
`;
|
|
19704
19752
|
if (vuln.affectedProducts && vuln.affectedProducts.length > 0) {
|
|
19705
19753
|
markdown += `- **Affected:** ${vuln.affectedProducts.join(", ")}
|
|
@@ -20628,11 +20676,13 @@ function watchUrl(videoId) {
|
|
|
20628
20676
|
init_retry_utils();
|
|
20629
20677
|
init_circuit_breaker();
|
|
20630
20678
|
init_logger();
|
|
20679
|
+
init_log_utils();
|
|
20631
20680
|
init_metrics();
|
|
20632
20681
|
init_http_body();
|
|
20633
20682
|
|
|
20634
20683
|
// src/youtube/potoken.ts
|
|
20635
20684
|
init_logger();
|
|
20685
|
+
init_safe_unref();
|
|
20636
20686
|
init_metrics();
|
|
20637
20687
|
var DEFAULT_REQUEST_KEY = "O43z0dpjhgX20SCx4KAo";
|
|
20638
20688
|
var BRIDGED_GLOBALS = ["window", "document", "location", "navigator", "origin"];
|
|
@@ -20645,9 +20695,27 @@ function describeError(error) {
|
|
|
20645
20695
|
return String(error);
|
|
20646
20696
|
}
|
|
20647
20697
|
var mintChain = Promise.resolve();
|
|
20698
|
+
var wedged = false;
|
|
20699
|
+
var MINT_HARD_RELEASE_MS = 5 * 60 * 1e3;
|
|
20648
20700
|
async function mintPoTokens(identifiers, opts = {}) {
|
|
20701
|
+
if (wedged) {
|
|
20702
|
+
throw new Error("PoToken minter is wedged by a prior mint that never settled; refusing to start a new mint until it resolves.");
|
|
20703
|
+
}
|
|
20649
20704
|
const run = mintChain.then(() => doMint(identifiers, opts), () => doMint(identifiers, opts));
|
|
20650
|
-
|
|
20705
|
+
const settled = run.then(() => void 0, () => void 0);
|
|
20706
|
+
settled.then(() => {
|
|
20707
|
+
wedged = false;
|
|
20708
|
+
});
|
|
20709
|
+
let hardTimer;
|
|
20710
|
+
const hardRelease = new Promise((resolve9) => {
|
|
20711
|
+
hardTimer = setTimeout(() => {
|
|
20712
|
+
logger.warn("[youtube] potoken mint mutex force-released \u2014 doMint() did not settle within the hard timeout");
|
|
20713
|
+
wedged = true;
|
|
20714
|
+
resolve9();
|
|
20715
|
+
}, MINT_HARD_RELEASE_MS);
|
|
20716
|
+
safeUnref(hardTimer);
|
|
20717
|
+
});
|
|
20718
|
+
mintChain = Promise.race([settled, hardRelease]).finally(() => clearTimeout(hardTimer));
|
|
20651
20719
|
return run;
|
|
20652
20720
|
}
|
|
20653
20721
|
async function doMint(identifiers, opts) {
|
|
@@ -20929,7 +20997,7 @@ function parseJson3(body) {
|
|
|
20929
20997
|
try {
|
|
20930
20998
|
parsed = JSON.parse(body);
|
|
20931
20999
|
} catch {
|
|
20932
|
-
logger.debug(`[youtube] timedtext body was not valid json3 (first 120 chars): ${body.slice(0, 120)}`);
|
|
21000
|
+
logger.debug(`[youtube] timedtext body was not valid json3 (first 120 chars): ${redactSecrets(body).slice(0, 120)}`);
|
|
20933
21001
|
return "";
|
|
20934
21002
|
}
|
|
20935
21003
|
const events = parsed.events;
|
package/dist/thread-worker.mjs
CHANGED
|
@@ -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}`));
|