@lincoln504/pi-research 0.1.12 → 1.0.0
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/LICENSE +1 -1
- package/README.md +64 -115
- package/dist/cli.mjs +23211 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +102 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +213 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +113 -0
- package/docs/THIRD-PARTY-NOTICES.md +64 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1044 -0
- package/src/config.ts +1036 -147
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +264 -7
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -174
- package/src/infrastructure/searxng-lifecycle.ts +0 -577
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- package/tsconfig.json +0 -40
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread Worker Messaging Logic
|
|
3
|
+
*
|
|
4
|
+
* Handles message processing and task execution for the thread worker.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
let workerId: string = '';
|
|
8
|
+
|
|
9
|
+
import { appendFileSync } from 'node:fs';
|
|
10
|
+
import { redactSecrets } from '../../utils/log-utils.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Mutex lock to serialize page creation. Playwright/Firefox can deadlock if newPage()
|
|
14
|
+
* is called concurrently on the exact same context instance.
|
|
15
|
+
*/
|
|
16
|
+
let pageCreationLock = Promise.resolve();
|
|
17
|
+
|
|
18
|
+
async function createPageSafe(context: any): Promise<any> {
|
|
19
|
+
let release!: () => void;
|
|
20
|
+
const nextLock = new Promise<void>(resolve => { release = resolve; });
|
|
21
|
+
const currentLock = pageCreationLock;
|
|
22
|
+
pageCreationLock = currentLock.then(() => nextLock);
|
|
23
|
+
|
|
24
|
+
await currentLock;
|
|
25
|
+
try {
|
|
26
|
+
const pagePromise = context.newPage();
|
|
27
|
+
pagePromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background page creation rejection: ${err.message}`));
|
|
28
|
+
let timeoutId: NodeJS.Timeout | undefined;
|
|
29
|
+
try {
|
|
30
|
+
const result = await Promise.race([
|
|
31
|
+
pagePromise,
|
|
32
|
+
new Promise<never>((_, reject) => {
|
|
33
|
+
timeoutId = setTimeout(() => reject(new Error('Browser page creation timed out after 60000ms')), 60000);
|
|
34
|
+
})
|
|
35
|
+
]);
|
|
36
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
37
|
+
return result;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
40
|
+
// The timeout (or another error) won the race. If newPage() later resolves,
|
|
41
|
+
// close that orphaned page so it doesn't accumulate in the long-lived context.
|
|
42
|
+
pagePromise.then((p: any) => { try { p?.close?.(); } catch { /* already gone */ } }).catch(() => {});
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
} finally {
|
|
46
|
+
release();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Set the worker ID for logging purposes
|
|
52
|
+
*/
|
|
53
|
+
export function setWorkerId(id: string): void {
|
|
54
|
+
workerId = id;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Log to debug file
|
|
59
|
+
*/
|
|
60
|
+
function logToDebugFile(level: string, ...args: any[]): void {
|
|
61
|
+
const logFile = process.env['PI_RESEARCH_LOG_FILE'];
|
|
62
|
+
if (!logFile) return;
|
|
63
|
+
// Match the main-process logger: WARN/ERROR are always recorded (crash diagnostics);
|
|
64
|
+
// INFO/DEBUG only when verbose. Without this gate a non-verbose scaling run still
|
|
65
|
+
// streamed thousands of worker DEBUG lines to PI_RESEARCH_LOG_FILE — often a tmpfs
|
|
66
|
+
// (RAM) path — adding memory and I/O pressure for no benefit during sustained loops.
|
|
67
|
+
if ((level === 'INFO' || level === 'DEBUG') && process.env['PI_RESEARCH_DEBUG'] !== 'true') return;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const timestamp = new Date().toISOString();
|
|
71
|
+
const entry = {
|
|
72
|
+
timestamp,
|
|
73
|
+
level,
|
|
74
|
+
workerId,
|
|
75
|
+
// Redact secrets + bound size; worker logs include full scrape/search
|
|
76
|
+
// URLs, which can carry credentials in userinfo or query strings.
|
|
77
|
+
message: redactSecrets(args.map(arg => {
|
|
78
|
+
if (arg instanceof Error) return arg.stack || arg.message;
|
|
79
|
+
if (typeof arg === 'object' && arg !== null) return JSON.stringify(arg);
|
|
80
|
+
return String(arg);
|
|
81
|
+
}).join(' '))
|
|
82
|
+
};
|
|
83
|
+
appendFileSync(logFile, `${JSON.stringify(entry)}\n`);
|
|
84
|
+
} catch {
|
|
85
|
+
// ignore
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Setup mocking for the browser context if configured via environment variables.
|
|
91
|
+
* This is used in CI to make integration tests fast and reliable without hitting real networks.
|
|
92
|
+
*/
|
|
93
|
+
export async function setupMocking(context: any): Promise<void> {
|
|
94
|
+
const mockSearch = process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true';
|
|
95
|
+
const mockScrape = process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
|
|
96
|
+
|
|
97
|
+
if (!mockSearch && !mockScrape) return;
|
|
98
|
+
|
|
99
|
+
logToDebugFile('INFO', `[Worker-${workerId}] Setting up browser mocking (Search: ${mockSearch}, Scrape: ${mockScrape})`);
|
|
100
|
+
|
|
101
|
+
await context.route('**', (route: any, request: any) => {
|
|
102
|
+
const urlStr = request.url();
|
|
103
|
+
let url: URL;
|
|
104
|
+
try {
|
|
105
|
+
url = new URL(urlStr);
|
|
106
|
+
} catch {
|
|
107
|
+
route.continue();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const isDuckDuckGo = url.hostname === 'duckduckgo.com' ||
|
|
112
|
+
url.hostname === 'lite.duckduckgo.com' ||
|
|
113
|
+
url.hostname.endsWith('.duckduckgo.com');
|
|
114
|
+
|
|
115
|
+
if (mockSearch && isDuckDuckGo) {
|
|
116
|
+
logToDebugFile('DEBUG', `[Worker-${workerId}] Mocking search response for: ${urlStr}`);
|
|
117
|
+
route.fulfill({
|
|
118
|
+
status: 200,
|
|
119
|
+
contentType: 'text/html',
|
|
120
|
+
body: `
|
|
121
|
+
<html><head><title>DuckDuckGo Lite</title></head><body>
|
|
122
|
+
<form action="/lite/" method="post">
|
|
123
|
+
<input name="q" type="text">
|
|
124
|
+
<input type="submit" value="Search">
|
|
125
|
+
</form>
|
|
126
|
+
<table class="result-link">
|
|
127
|
+
<tr>
|
|
128
|
+
<td><a class="result-link" href="https://example.com/mock-result-1?uddg=https%3A%2F%2Fexample.com%2Ftarget-1">Mock Result 1</a></td>
|
|
129
|
+
</tr>
|
|
130
|
+
<tr>
|
|
131
|
+
<td class="result-snippet">This is a mocked search result snippet for testing purposes.</td>
|
|
132
|
+
</tr>
|
|
133
|
+
<tr>
|
|
134
|
+
<td><a class="result-link" href="https://example.com/mock-result-2?uddg=https%3A%2F%2Fexample.com%2Ftarget-2">Mock Result 2</a></td>
|
|
135
|
+
</tr>
|
|
136
|
+
<tr>
|
|
137
|
+
<td class="result-snippet">Another mocked snippet to verify multiple result extraction.</td>
|
|
138
|
+
</tr>
|
|
139
|
+
</table>
|
|
140
|
+
</body></html>
|
|
141
|
+
`
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (mockScrape && !isDuckDuckGo) {
|
|
147
|
+
logToDebugFile('DEBUG', `[Worker-${workerId}] Mocking scrape response for: ${urlStr}`);
|
|
148
|
+
route.fulfill({
|
|
149
|
+
status: 200,
|
|
150
|
+
contentType: 'text/html',
|
|
151
|
+
body: `<html><body><h1>Mocked Page Content</h1><p>This content was mocked for ${url} during integration testing.</p></body></html>`
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
route.continue();
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Extract search results from DuckDuckGo Lite page
|
|
162
|
+
*/
|
|
163
|
+
async function extractSearchResults(page: any): Promise<any[]> {
|
|
164
|
+
return await page.evaluate(() => {
|
|
165
|
+
const found: any[] = [];
|
|
166
|
+
// @ts-ignore - document is available in browser context
|
|
167
|
+
// eslint-disable-next-line no-undef
|
|
168
|
+
const links = Array.from(document.querySelectorAll('a.result-link'));
|
|
169
|
+
links.forEach((link: any) => {
|
|
170
|
+
const row = link.closest('tr');
|
|
171
|
+
const snippet = row?.nextElementSibling?.querySelector('td.result-snippet')?.textContent?.trim() || '';
|
|
172
|
+
const title = link.textContent?.trim() || '';
|
|
173
|
+
let url = link.href;
|
|
174
|
+
try {
|
|
175
|
+
const u = new URL(url);
|
|
176
|
+
const uddg = u.searchParams.get('uddg');
|
|
177
|
+
if (uddg) url = decodeURIComponent(uddg || '');
|
|
178
|
+
} catch {
|
|
179
|
+
// ignore
|
|
180
|
+
}
|
|
181
|
+
let urlHostname = '';
|
|
182
|
+
try { urlHostname = new URL(url).hostname; } catch { /* ignore malformed */ }
|
|
183
|
+
if (title && url && urlHostname !== 'duckduckgo.com' && !urlHostname.endsWith('.duckduckgo.com') && url.startsWith('http')) {
|
|
184
|
+
found.push({ title, url, content: snippet });
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
return found;
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Execute a search task
|
|
193
|
+
*/
|
|
194
|
+
export async function executeSearchTask(
|
|
195
|
+
_context: any,
|
|
196
|
+
query: string
|
|
197
|
+
): Promise<{ results: any[]; jitter: number }> {
|
|
198
|
+
const page = await createPageSafe(_context);
|
|
199
|
+
const SEARCH_TIMEOUT = parseInt(process.env['PI_RESEARCH_SEARCH_TIMEOUT_MS'] || '45000', 10);
|
|
200
|
+
page.setDefaultTimeout(SEARCH_TIMEOUT);
|
|
201
|
+
page.setDefaultNavigationTimeout(SEARCH_TIMEOUT);
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
logToDebugFile('DEBUG', `[Worker-${workerId}] Starting search for: ${query}`);
|
|
205
|
+
|
|
206
|
+
// In mock mode, we jump directly to the results URL to avoid slow form-fill and
|
|
207
|
+
// interactability checks which can be brittle in CI environments.
|
|
208
|
+
if (process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true') {
|
|
209
|
+
await page.goto(`https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}`, { waitUntil: 'domcontentloaded' });
|
|
210
|
+
} else {
|
|
211
|
+
// Relaxed timeout to accommodate network latency and concurrent worker load
|
|
212
|
+
await page.goto('https://lite.duckduckgo.com/lite/', { waitUntil: 'domcontentloaded' });
|
|
213
|
+
await page.fill('input[name="q"]', query);
|
|
214
|
+
await Promise.all([
|
|
215
|
+
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
|
|
216
|
+
page.keyboard.press('Enter')
|
|
217
|
+
]);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const results = await extractSearchResults(page);
|
|
221
|
+
|
|
222
|
+
await page.close();
|
|
223
|
+
|
|
224
|
+
// Reduce jitter in CI/mock mode to speed up tests
|
|
225
|
+
const jitterBase = process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' ? 10 : 500;
|
|
226
|
+
const jitterRange = process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' ? 10 : 1000;
|
|
227
|
+
const jitter = Math.floor(Math.random() * jitterRange) + jitterBase;
|
|
228
|
+
await new Promise(r => setTimeout(r, jitter));
|
|
229
|
+
|
|
230
|
+
return { results, jitter };
|
|
231
|
+
} catch (error) {
|
|
232
|
+
await page.close().catch((err: any) => logToDebugFile('DEBUG', `[ThreadWorker] Swallowed page close/wait error: ${err.message || String(err)}`));
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Short-TTL, per-worker cache of per-hostname DNS-aware SSRF verdicts for
|
|
239
|
+
* SUBRESOURCE requests. Subresources previously got only the synchronous
|
|
240
|
+
* literal/pattern screen (no DNS), so a hostile page — whose own navigation
|
|
241
|
+
* DNS-validated as public — could fetch() a hostname that resolves to a
|
|
242
|
+
* private/link-local/metadata address (TTL-0 DNS rebinding) and read the
|
|
243
|
+
* response into the DOM that page.content() returns. Every http(s) subresource
|
|
244
|
+
* hostname now gets the same DNS-aware validateUrlForSSRF as navigations; this
|
|
245
|
+
* cache keeps that to ONE resolution per hostname per TTL (the sync screen's
|
|
246
|
+
* whole purpose was avoiding per-asset DNS stalls — a page with 40 assets on
|
|
247
|
+
* one CDN host costs one lookup, not 40, and same-origin assets are seeded by
|
|
248
|
+
* the navigation's own validation). The TTL is deliberately short so a verdict
|
|
249
|
+
* cannot stay stale for long, and this pre-validation is only the first layer:
|
|
250
|
+
* DNS pre-checks are inherently TOCTOU-racy against a rebinding resolver
|
|
251
|
+
* (Firefox resolves independently at connect time; camoufox has no connect-time
|
|
252
|
+
* IP pinning), so the response-side serverAddr() backstop in executeScrapeTask
|
|
253
|
+
* is the authoritative rebinding defense.
|
|
254
|
+
*/
|
|
255
|
+
const SUBRESOURCE_DNS_VERDICT_TTL_MS = 15_000;
|
|
256
|
+
const SUBRESOURCE_DNS_VERDICT_MAX_ENTRIES = 256;
|
|
257
|
+
const subresourceDnsVerdicts = new Map<string, { expires: number; verdict: Promise<Error | null> }>();
|
|
258
|
+
|
|
259
|
+
function getCachedSubresourceVerdict(
|
|
260
|
+
reqUrl: string,
|
|
261
|
+
validate: (u: string) => Promise<void>,
|
|
262
|
+
): Promise<Error | null> {
|
|
263
|
+
let hostname: string;
|
|
264
|
+
try {
|
|
265
|
+
hostname = new URL(reqUrl).hostname.toLowerCase();
|
|
266
|
+
} catch (e) {
|
|
267
|
+
return Promise.resolve(e instanceof Error ? e : new Error(String(e)));
|
|
268
|
+
}
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
const cached = subresourceDnsVerdicts.get(hostname);
|
|
271
|
+
if (cached && cached.expires > now) return cached.verdict;
|
|
272
|
+
// Store the SETTLED outcome (null = allowed, Error = blocked) instead of a
|
|
273
|
+
// rejecting promise so a cached denial can never surface as an unhandled
|
|
274
|
+
// rejection, and concurrent requests for the same hostname share one lookup.
|
|
275
|
+
const verdict = validate(reqUrl).then(
|
|
276
|
+
() => null,
|
|
277
|
+
(e: unknown) => (e instanceof Error ? e : new Error(String(e))),
|
|
278
|
+
);
|
|
279
|
+
if (subresourceDnsVerdicts.size >= SUBRESOURCE_DNS_VERDICT_MAX_ENTRIES) {
|
|
280
|
+
for (const [key, entry] of subresourceDnsVerdicts) {
|
|
281
|
+
if (entry.expires <= now) subresourceDnsVerdicts.delete(key);
|
|
282
|
+
}
|
|
283
|
+
// Still full after dropping expired entries: evict oldest-inserted.
|
|
284
|
+
while (subresourceDnsVerdicts.size >= SUBRESOURCE_DNS_VERDICT_MAX_ENTRIES) {
|
|
285
|
+
const oldest = subresourceDnsVerdicts.keys().next().value;
|
|
286
|
+
if (oldest === undefined) break;
|
|
287
|
+
subresourceDnsVerdicts.delete(oldest);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
subresourceDnsVerdicts.set(hostname, { expires: now + SUBRESOURCE_DNS_VERDICT_TTL_MS, verdict });
|
|
291
|
+
return verdict;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Seed the subresource verdict cache with a PASS for a hostname whose full DNS-aware validation just succeeded (the navigation), so same-origin assets don't pay a second resolution. */
|
|
295
|
+
function seedSubresourceVerdict(validatedUrl: string): void {
|
|
296
|
+
try {
|
|
297
|
+
const hostname = new URL(validatedUrl).hostname.toLowerCase();
|
|
298
|
+
subresourceDnsVerdicts.set(hostname, {
|
|
299
|
+
expires: Date.now() + SUBRESOURCE_DNS_VERDICT_TTL_MS,
|
|
300
|
+
verdict: Promise.resolve(null),
|
|
301
|
+
});
|
|
302
|
+
} catch {
|
|
303
|
+
/* unparseable URLs are rejected by the validator itself */
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Execute a scrape task
|
|
309
|
+
*/
|
|
310
|
+
export async function executeScrapeTask(
|
|
311
|
+
_context: any,
|
|
312
|
+
url: string,
|
|
313
|
+
signal?: AbortSignal
|
|
314
|
+
): Promise<{ contentType: string; html?: string; buffer?: Buffer; jitter: number }> {
|
|
315
|
+
const page = await createPageSafe(_context);
|
|
316
|
+
const SCRAPE_TIMEOUT = parseInt(process.env['PI_RESEARCH_SCRAPE_TIMEOUT_MS'] || '15000', 10);
|
|
317
|
+
page.setDefaultTimeout(SCRAPE_TIMEOUT);
|
|
318
|
+
page.setDefaultNavigationTimeout(SCRAPE_TIMEOUT);
|
|
319
|
+
|
|
320
|
+
// Bind the task-timeout signal to this page: page.setDefaultTimeout only bounds
|
|
321
|
+
// Playwright's action/navigation verbs, NOT Response reads — a large/slow PDF's
|
|
322
|
+
// response.body() (or a slowloris trickle) has no ceiling and would pin the poolifier
|
|
323
|
+
// worker slot indefinitely past the task deadline. Closing the page on abort rejects
|
|
324
|
+
// any in-flight body()/content()/goto with 'Target closed', which the catch below
|
|
325
|
+
// handles, freeing the slot at the deadline. Fire-and-forget close; { once } so the
|
|
326
|
+
// listener is auto-removed.
|
|
327
|
+
const onAbort = () => { page.close().catch(() => {}); };
|
|
328
|
+
if (signal) {
|
|
329
|
+
if (signal.aborted) onAbort();
|
|
330
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Set by the response-side SSRF backstop (see page.on('response') below) when
|
|
334
|
+
// ANY response was served from a private/reserved address: the scrape is
|
|
335
|
+
// poisoned and must fail without returning content. Declared outside the try
|
|
336
|
+
// so the catch can surface it instead of the secondary 'Target closed' error
|
|
337
|
+
// produced by the poison-triggered page.close().
|
|
338
|
+
let poisonedError: Error | null = null;
|
|
339
|
+
const pendingAddrChecks: Array<Promise<void>> = [];
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
logToDebugFile('DEBUG', `[Worker-${workerId}] Starting scrape for: ${url}`);
|
|
343
|
+
|
|
344
|
+
// Intercept EVERY request and validate it against SSRF rules: navigations
|
|
345
|
+
// (server 3xx AND client-side meta-refresh / window.location / form submit)
|
|
346
|
+
// get the full DNS-aware check; subresources (img/script/xhr/fetch) get the
|
|
347
|
+
// SAME DNS-aware check, deduplicated per hostname by a short-TTL verdict
|
|
348
|
+
// cache so a page full of same-host assets costs one resolution, not one
|
|
349
|
+
// per asset. Without the DNS pass on subresources, a hostile page could
|
|
350
|
+
// fetch() a TTL-0 rebinding hostname that resolves to an internal IP /
|
|
351
|
+
// cloud metadata and read the response into the DOM that page.content()
|
|
352
|
+
// serializes into the research report.
|
|
353
|
+
// Must use page.route (a BLOCKING intercept), NOT page.on('request') — the
|
|
354
|
+
// latter does not pause navigation, so the abort raced the redirect and the
|
|
355
|
+
// metadata endpoint could be reached before it fired. route.fallback() defers
|
|
356
|
+
// to the next handler (the CI mock context.route) or the network, so this
|
|
357
|
+
// preserves mocking while actually gating each request.
|
|
358
|
+
const { validateUrlForSSRF: validateForWorker, validateUrlForSSRFSync: validateForWorkerSync } =
|
|
359
|
+
await import('../../web-research/scraper-utils.ts');
|
|
360
|
+
await page.route('**', async (route: any, req: any) => {
|
|
361
|
+
const reqUrl: string = req.url();
|
|
362
|
+
// Only http(s) requests can reach a private host over the network; in-page
|
|
363
|
+
// data:/blob:/about: schemes are not an SSRF vector and must pass through
|
|
364
|
+
// untouched (else pages using data: images/fonts would break).
|
|
365
|
+
if (/^https?:/i.test(reqUrl)) {
|
|
366
|
+
try {
|
|
367
|
+
if (req.isNavigationRequest()) {
|
|
368
|
+
// ANY navigation — a server 3xx redirect OR a client-side hop (meta
|
|
369
|
+
// refresh, window.location, form submit). Full DNS-aware check;
|
|
370
|
+
// navigations are infrequent so the resolution cost is negligible.
|
|
371
|
+
// Closes full-content SSRF exfil where a page redirects itself to
|
|
372
|
+
// 169.254.169.254 / an internal host and the rendered body is returned.
|
|
373
|
+
await validateForWorker(reqUrl);
|
|
374
|
+
// Same-origin assets of a just-validated document ride this verdict.
|
|
375
|
+
seedSubresourceVerdict(reqUrl);
|
|
376
|
+
} else {
|
|
377
|
+
// Subresource (img/script/style/xhr/fetch). Synchronous literal/
|
|
378
|
+
// pattern screen first: it is free, and a `true` return means the
|
|
379
|
+
// test-only loopback affordance definitively accepted the target
|
|
380
|
+
// (a DNS pass would wrongly reject localhost→127.0.0.1).
|
|
381
|
+
const loopbackAccepted = validateForWorkerSync(reqUrl);
|
|
382
|
+
if (!loopbackAccepted) {
|
|
383
|
+
// DNS-aware pass, one resolution per hostname per short TTL.
|
|
384
|
+
// Same-origin subresources are a cache hit (seeded by the
|
|
385
|
+
// navigation) — for those, the rebinding TOCTOU window is closed
|
|
386
|
+
// by the response-side serverAddr() backstop below, not here.
|
|
387
|
+
const verdictError = await getCachedSubresourceVerdict(reqUrl, validateForWorker);
|
|
388
|
+
if (verdictError) throw verdictError;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
} catch (_ssrfErr: unknown) {
|
|
392
|
+
await route.abort('blockedbyclient').catch(() => {});
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
await route.fallback().catch(() => {});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Track the ACTUAL connected IP of each main-frame document response. The post-goto check below
|
|
400
|
+
// only re-validates the initial navigation; a page that passes it can then client-navigate
|
|
401
|
+
// (window.location / meta-refresh / form submit) to a TTL-0 host that rebinds to a private /
|
|
402
|
+
// metadata IP. Re-validating the FINAL main-frame document's serverAddr before its body is
|
|
403
|
+
// returned closes that second-hop DNS-rebind window.
|
|
404
|
+
//
|
|
405
|
+
// ADDITIONALLY: response-side rebinding backstop for EVERY response. The request-time DNS
|
|
406
|
+
// pass above is inherently TOCTOU-racy — Firefox resolves hostnames itself at connect time
|
|
407
|
+
// (camoufox has no connect-time IP pinning), so a TTL-0 resolver can answer public during
|
|
408
|
+
// validation and private at connect. Firefox's juggler reports the IP each network-served
|
|
409
|
+
// response ACTUALLY came from via serverAddr() (null for cached/route-fulfilled responses).
|
|
410
|
+
// If any response arrived from a private/loopback/link-local/metadata address that wasn't
|
|
411
|
+
// explicitly allowed (loopback test flag), internal data may already be in the DOM — poison
|
|
412
|
+
// the scrape: close the page (aborting the load) and fail with a 'Fetch blocked:' error,
|
|
413
|
+
// which isBenignScrapeFailure classifies as an expected per-URL outcome (debug, not ERROR).
|
|
414
|
+
let finalMainFrameAddr: Promise<{ ipAddress?: string } | null> = Promise.resolve(null);
|
|
415
|
+
page.on('response', (resp: any) => {
|
|
416
|
+
try {
|
|
417
|
+
if (resp.request().resourceType() === 'document' && resp.frame() === page.mainFrame()) {
|
|
418
|
+
finalMainFrameAddr = resp.serverAddr().catch(() => null);
|
|
419
|
+
}
|
|
420
|
+
const check: Promise<void> = Promise.resolve(resp.serverAddr())
|
|
421
|
+
.then((addr: { ipAddress?: string } | null) => {
|
|
422
|
+
const ip = addr?.ipAddress;
|
|
423
|
+
if (!ip || poisonedError) return;
|
|
424
|
+
try {
|
|
425
|
+
validateForWorkerSync(ip.includes(':') ? `http://[${ip}]/` : `http://${ip}/`);
|
|
426
|
+
} catch (cause: unknown) {
|
|
427
|
+
poisonedError = new Error(
|
|
428
|
+
'Fetch blocked: a response was served from a private/reserved address (SSRF DNS-rebinding defense)',
|
|
429
|
+
{ cause },
|
|
430
|
+
);
|
|
431
|
+
logToDebugFile('WARN', `[Worker-${workerId}] SSRF rebinding defense: a response connected to a private/reserved address; poisoning scrape of ${url}`);
|
|
432
|
+
// Abort the in-flight load; goto/content() reject with 'Target
|
|
433
|
+
// closed', and the catch below surfaces poisonedError instead.
|
|
434
|
+
page.close().catch(() => {});
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
.catch(() => {});
|
|
438
|
+
pendingAddrChecks.push(check);
|
|
439
|
+
} catch {
|
|
440
|
+
/* frame detached / teardown — ignore */
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// High-fidelity wait: try domcontentloaded first for speed
|
|
445
|
+
const response = await page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
446
|
+
|
|
447
|
+
// DNS-rebinding defense-in-depth. The page.route guard's validateForWorker resolves the
|
|
448
|
+
// hostname independently of the browser's own connect-time resolution, so a TTL-0 rebind can
|
|
449
|
+
// pass validation yet have the browser connect to a private/metadata IP (169.254.169.254 /
|
|
450
|
+
// RFC1918). The browser reports the IP it ACTUALLY connected to via serverAddr — re-check it
|
|
451
|
+
// against the same SSRF policy and refuse to return the body if it is internal. This does not
|
|
452
|
+
// prevent the TCP connection itself, but it stops the rendered response (e.g. cloud IAM
|
|
453
|
+
// credentials) from being exfiltrated to the researcher/report, which is the real harm here.
|
|
454
|
+
const serverAddr = await response?.serverAddr().catch(() => null);
|
|
455
|
+
if (serverAddr?.ipAddress) {
|
|
456
|
+
const ip: string = serverAddr.ipAddress;
|
|
457
|
+
validateForWorkerSync(ip.includes(':') ? `http://[${ip}]/` : `http://${ip}/`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const contentType = (await response?.headerValue('content-type')) || '';
|
|
461
|
+
|
|
462
|
+
if (contentType.includes('application/pdf')) {
|
|
463
|
+
if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
|
|
464
|
+
const buffer = await response.body();
|
|
465
|
+
// Discard the bytes if any observed response came from a private address.
|
|
466
|
+
await Promise.allSettled(pendingAddrChecks);
|
|
467
|
+
if (poisonedError) throw poisonedError;
|
|
468
|
+
await page.close();
|
|
469
|
+
return { contentType, buffer, jitter: 0 };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// If it's HTML, check if we need to wait longer (JS-heavy sites)
|
|
473
|
+
let html = await page.content();
|
|
474
|
+
|
|
475
|
+
// DETECT CLOUDFLARE CHALLENGE
|
|
476
|
+
const cfPatterns = ['_cf_chl_', 'cdn-cgi/challenge-platform', 'cf_chl_opt', 'Just a moment...', 'Checking your browser before accessing'];
|
|
477
|
+
const hasCloudflare = cfPatterns.some((pattern: string) => html.includes(pattern));
|
|
478
|
+
|
|
479
|
+
if (hasCloudflare) {
|
|
480
|
+
logToDebugFile('WARN', `[Worker-${workerId}] Cloudflare challenge detected for: ${url}`);
|
|
481
|
+
|
|
482
|
+
// Wait up to 5 seconds for challenge to resolve
|
|
483
|
+
try {
|
|
484
|
+
await page.waitForFunction(
|
|
485
|
+
() => {
|
|
486
|
+
// @ts-ignore - document is available in browser context
|
|
487
|
+
// eslint-disable-next-line no-undef
|
|
488
|
+
const body = document.body.innerHTML;
|
|
489
|
+
return !body.includes('_cf_chl_') &&
|
|
490
|
+
!body.includes('cdn-cgi/challenge-platform') &&
|
|
491
|
+
!body.includes('cf_chl_opt') &&
|
|
492
|
+
!body.includes('Just a moment...') &&
|
|
493
|
+
!body.includes('Checking your browser before accessing') &&
|
|
494
|
+
body.length > 200; // Ensure content loaded
|
|
495
|
+
},
|
|
496
|
+
{ timeout: 5000 }
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
// Challenge resolved, get updated content
|
|
500
|
+
html = await page.content();
|
|
501
|
+
logToDebugFile('INFO', `[Worker-${workerId}] Cloudflare challenge resolved for: ${url}`);
|
|
502
|
+
} catch (_waitError: unknown) {
|
|
503
|
+
// A Cloudflare challenge that doesn't clear is an expected block, not a worker
|
|
504
|
+
// fault (the thrown 'Fetch blocked' is demoted to WARN at the server boundary
|
|
505
|
+
// via isCloudflareBlockError). Label the debug-file line WARN to match.
|
|
506
|
+
logToDebugFile('WARN', `[Worker-${workerId}] Cloudflare challenge failed for: ${url}`);
|
|
507
|
+
const error = new Error('Fetch blocked: Cloudflare challenge');
|
|
508
|
+
error.cause = _waitError;
|
|
509
|
+
throw error;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Check if we need to wait longer for JS-heavy sites
|
|
514
|
+
const needsWait = html.length < 5000 ||
|
|
515
|
+
html.includes('id="root"') ||
|
|
516
|
+
html.includes('id="app"') ||
|
|
517
|
+
html.includes('<noscript>');
|
|
518
|
+
|
|
519
|
+
if (needsWait) {
|
|
520
|
+
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch((err: any) => logToDebugFile('DEBUG', `[ThreadWorker] Swallowed page close/wait error: ${err.message || String(err)}`));
|
|
521
|
+
html = await page.content();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Skip jitter in mock mode; in real browsing, add 500–1500ms to mimic human behavior
|
|
525
|
+
const jitter = process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true'
|
|
526
|
+
? 0
|
|
527
|
+
: Math.floor(Math.random() * 1000) + 500;
|
|
528
|
+
if (jitter > 0) await new Promise(r => setTimeout(r, jitter));
|
|
529
|
+
|
|
530
|
+
// Re-validate the final rendered main-frame document's connected IP (catches a client-side
|
|
531
|
+
// navigation to a DNS-rebinding host that occurred after the initial goto passed).
|
|
532
|
+
const finalAddr = await finalMainFrameAddr;
|
|
533
|
+
if (finalAddr?.ipAddress) {
|
|
534
|
+
const fip: string = finalAddr.ipAddress;
|
|
535
|
+
validateForWorkerSync(fip.includes(':') ? `http://[${fip}]/` : `http://${fip}/`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Response-side SSRF backstop: settle every serverAddr() inspection recorded
|
|
539
|
+
// so far and refuse to return the captured HTML if any response — main frame
|
|
540
|
+
// OR subresource (the same-origin fetch() rebinding case) — was served from
|
|
541
|
+
// a private/reserved address.
|
|
542
|
+
await Promise.allSettled(pendingAddrChecks);
|
|
543
|
+
if (poisonedError) throw poisonedError;
|
|
544
|
+
|
|
545
|
+
await page.close();
|
|
546
|
+
return { contentType, html, jitter };
|
|
547
|
+
} catch (error) {
|
|
548
|
+
await page.close().catch((err: any) => logToDebugFile('DEBUG', `[ThreadWorker] Swallowed page close/wait error: ${err.message || String(err)}`));
|
|
549
|
+
// A poison-triggered page.close() makes the in-flight goto/content() reject
|
|
550
|
+
// with 'Target closed'; report the SSRF poisoning (a benign-classified
|
|
551
|
+
// 'Fetch blocked:' failure), not the secondary teardown error.
|
|
552
|
+
if (poisonedError) throw poisonedError;
|
|
553
|
+
throw error;
|
|
554
|
+
} finally {
|
|
555
|
+
signal?.removeEventListener('abort', onAbort);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Primary health probe target: the live search provider. Verifying it is the most
|
|
560
|
+
// representative precondition for research, but DDG is also the endpoint most prone
|
|
561
|
+
// to bot-blocking/rate-limiting automated browser traffic — so a failure here is NOT
|
|
562
|
+
// proof the web is unreachable.
|
|
563
|
+
const HEALTH_PRIMARY_URL = 'https://lite.duckduckgo.com/lite/';
|
|
564
|
+
// Neutral, automation-tolerant reachability fallback (IANA-operated, no bot
|
|
565
|
+
// protection, returns a stable non-empty title). A successful load here proves the
|
|
566
|
+
// browser CAN reach the open web, which disambiguates "the search provider blocked
|
|
567
|
+
// or hiccupped" from a genuine connectivity outage.
|
|
568
|
+
const HEALTH_FALLBACK_URL = 'https://example.com/';
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Execute a health check attempt against a given URL (defaults to the search provider).
|
|
572
|
+
*/
|
|
573
|
+
async function executeHealthCheckAttempt(
|
|
574
|
+
_context: any,
|
|
575
|
+
navTimeoutMs: number,
|
|
576
|
+
url: string = HEALTH_PRIMARY_URL
|
|
577
|
+
): Promise<{ success: boolean; navMs: number }> {
|
|
578
|
+
const page = await createPageSafe(_context);
|
|
579
|
+
page.setDefaultTimeout(navTimeoutMs);
|
|
580
|
+
page.setDefaultNavigationTimeout(navTimeoutMs);
|
|
581
|
+
|
|
582
|
+
try {
|
|
583
|
+
const navStart = Date.now();
|
|
584
|
+
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
585
|
+
const navMs = Date.now() - navStart;
|
|
586
|
+
|
|
587
|
+
if (navMs > 3000) {
|
|
588
|
+
logToDebugFile('WARN', `[Worker-${workerId}] Health navigation to ${url} was slow: ${navMs}ms (expected <1s). Possible rate-limit or network congestion.`);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const title = await page.title();
|
|
592
|
+
await page.close();
|
|
593
|
+
return { success: !!title, navMs };
|
|
594
|
+
} catch (error) {
|
|
595
|
+
await page.close().catch((err: any) => logToDebugFile('DEBUG', `[ThreadWorker] Swallowed page close/wait error: ${err.message || String(err)}`));
|
|
596
|
+
throw error;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Execute a health check task
|
|
602
|
+
*/
|
|
603
|
+
export async function executeHealthCheck(
|
|
604
|
+
_context: any,
|
|
605
|
+
initMs: number
|
|
606
|
+
): Promise<{ success: boolean; navMs: number }> {
|
|
607
|
+
// Nav timeout: read from env (passed through getBrowserEnv), floor at 10s.
|
|
608
|
+
// The outer BrowserTaskScheduler.runHealthCheck() has its own 45s hard deadline.
|
|
609
|
+
const configuredMs = parseInt(process.env['PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS'] || '0', 10);
|
|
610
|
+
const HEALTH_TIMEOUT = configuredMs > 0 ? Math.max(10000, configuredMs) : 10000;
|
|
611
|
+
|
|
612
|
+
if (initMs > 3000) {
|
|
613
|
+
logToDebugFile('WARN', `[Worker-${workerId}] Browser init was slow: ${initMs}ms — leaving less headroom for DDG navigation.`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
|
|
618
|
+
} catch (firstError: unknown) {
|
|
619
|
+
// One retry: if the first attempt fails, the page may have been in a bad state
|
|
620
|
+
// (challenge redirect, partial load, transient network blip). A second attempt
|
|
621
|
+
// on a fresh page helps distinguish a real outage from a one-off failure.
|
|
622
|
+
logToDebugFile('WARN', `[Worker-${workerId}] Health check attempt 1 failed: ${firstError instanceof Error ? firstError.message : String(firstError)}. Retrying once...`);
|
|
623
|
+
try {
|
|
624
|
+
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
|
|
625
|
+
} catch (retryError: unknown) {
|
|
626
|
+
const retryMsg = retryError instanceof Error ? retryError.message : String(retryError);
|
|
627
|
+
// A TIMEOUT is left to propagate: upstream (isBusyPoolHealthFailure) already
|
|
628
|
+
// treats timed-out probes as a congested-but-live pool and proceeds, and
|
|
629
|
+
// re-probing would only add latency to an already-slow path.
|
|
630
|
+
const isTimeout = /tim(e|ed)\s*out|timeout/i.test(retryMsg);
|
|
631
|
+
if (!isTimeout) {
|
|
632
|
+
// The search provider failed twice with a non-timeout (e.g. net::ERR*) error.
|
|
633
|
+
// That is most often DDG-specific bot-blocking, NOT a dead connection — the
|
|
634
|
+
// observed false negative where the open web was fully reachable yet research
|
|
635
|
+
// aborted with "check your internet". Confirm raw web reachability against a
|
|
636
|
+
// neutral endpoint before declaring failure; if it loads, the browser is
|
|
637
|
+
// healthy enough to research (search has its own retries/fallbacks downstream).
|
|
638
|
+
logToDebugFile('WARN', `[Worker-${workerId}] Search-provider health probe failed twice (${retryMsg}); verifying open-web reachability via fallback endpoint...`);
|
|
639
|
+
try {
|
|
640
|
+
const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL);
|
|
641
|
+
logToDebugFile('WARN', `[Worker-${workerId}] Fallback endpoint reachable — open web is up, search provider is degraded. Proceeding with research.`);
|
|
642
|
+
return fallback;
|
|
643
|
+
} catch (fallbackError: unknown) {
|
|
644
|
+
logToDebugFile('ERROR', `[Worker-${workerId}] Health check failed after retry + fallback probe: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);
|
|
645
|
+
throw retryError;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
logToDebugFile('ERROR', `[Worker-${workerId}] Health check failed after retry: ${retryMsg}`);
|
|
649
|
+
throw retryError;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Check if browser error requires reset
|
|
656
|
+
*/
|
|
657
|
+
export function shouldResetBrowser(errorMsg: string): boolean {
|
|
658
|
+
return errorMsg.includes('Target closed') ||
|
|
659
|
+
errorMsg.includes('browser has disconnected') ||
|
|
660
|
+
errorMsg.includes('Protocol error') ||
|
|
661
|
+
errorMsg.includes('Session closed');
|
|
662
|
+
}
|