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