@lincoln504/pi-research 1.3.11 → 1.3.12

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/src/sdk.ts CHANGED
@@ -44,7 +44,7 @@ import { runHealthCheck } from './healthcheck/index.ts';
44
44
  import { buildModelRegistry as sharedBuildModelRegistry, resolveModel } from './core/llm/model-registry-factory.ts';
45
45
  import { scrapeSingle } from './web-research/web-scraper.ts';
46
46
  import { validateInitialLinks } from './utils/url-utils.ts';
47
- import { validateUrlForSSRF } from './web-research/scraper-utils.ts';
47
+ import { validateUrlForSSRF, disposeSsrfSafeFetcher } from './web-research/scraper-utils.ts';
48
48
  import type { ScrapeResult } from './core/interfaces/scheduler-interfaces.ts';
49
49
  import { randomUUID } from 'node:crypto';
50
50
  import type { ResearchDepth } from './types/index.ts';
@@ -954,6 +954,15 @@ async function _doShutdown(): Promise<void> {
954
954
  errors.push(err instanceof Error ? err : new Error(String(err)));
955
955
  }
956
956
 
957
+ // Release the module-level undici Agent's keep-alive sockets. Nothing else
958
+ // owns it, so a long-lived host would otherwise hold them past teardown.
959
+ try {
960
+ await disposeSsrfSafeFetcher();
961
+ } catch (err) {
962
+ logger.error('[SDK] Error disposing SSRF fetcher:', err);
963
+ errors.push(err instanceof Error ? err : new Error(String(err)));
964
+ }
965
+
957
966
  if (globalContainer) {
958
967
  // Dispose all services in one DAG-ordered teardown. disposeCoreServices first
959
968
  // clears the embedding-server state while the StateManager is still alive, then
@@ -22,7 +22,6 @@ import type {
22
22
  } from './nvd-types.ts';
23
23
  import { createTimeoutSignal, retryWithBackoff, isTransientError } from '../web-research/retry-utils.ts';
24
24
  import { CircuitBreaker } from '../utils/circuit-breaker.ts';
25
- import { safeUnref } from '../utils/safe-unref.ts';
26
25
  import { metrics } from '../utils/metrics.ts';
27
26
  import { readJsonCapped, BodyTooLargeError, MAX_BULK_JSON_BODY_BYTES } from '../utils/http-body.ts';
28
27
  import {
@@ -77,7 +76,15 @@ class NVDRateLimiter {
77
76
  signal?.removeEventListener('abort', onAbort);
78
77
  resolve();
79
78
  }, waitTime);
80
- safeUnref(timeoutId);
79
+ // NOT unref'd. This timer IS the operation, not a watchdog racing one:
80
+ // nothing else will ever settle this promise, so unref'ing it let Node
81
+ // consider the process idle while an NVD search was mid-spacing. With no
82
+ // other ref'd handle the search simply never resumed — the awaited
83
+ // promise stayed unsettled and the caller got no result and no error.
84
+ // Every other unref in this codebase guards a timeout against real I/O
85
+ // that holds the loop open by itself; this one had nothing behind it.
86
+ // Bounded (≤6s, or ≤600ms with NVD_API_KEY) and abortable below, so
87
+ // keeping it ref'd cannot delay shutdown beyond one spacing interval.
81
88
  const onAbort = () => {
82
89
  clearTimeout(timeoutId as unknown as ReturnType<typeof setTimeout>);
83
90
  reject(Object.assign(new Error('NVD search aborted'), { name: 'AbortError' }));
@@ -392,6 +399,8 @@ async function fetchPaginated(
392
399
  const signal = options?.signal;
393
400
  let startIndex = 0;
394
401
  let totalPagesFetched = 0;
402
+ // Whether the one-time jump to the newest window (below) has been performed.
403
+ let seekedToNewest = false;
395
404
 
396
405
  while (totalPagesFetched < maxPages && allVulnerabilities.length < maxResults) {
397
406
  if (signal?.aborted) break; // stop paginating promptly on cancel
@@ -433,10 +442,40 @@ async function fetchPaginated(
433
442
  }
434
443
  metrics.increment('nvd_cache_hits_total', 1);
435
444
 
445
+ const responseData = data as Record<string, unknown> | undefined;
446
+ const reportedTotal = typeof responseData?.['totalResults'] === 'number'
447
+ ? responseData['totalResults'] as number
448
+ : undefined;
449
+
450
+ // NVD 2.0 returns keyword matches OLDEST FIRST and offers no sort parameter
451
+ // (`sortBy` is a 404), so paging from index 0 and stopping at maxResults kept
452
+ // precisely the least useful window: a `security_search` for "openssl"
453
+ // returned CVE-1999-0428 through 2004 out of 566 matches and could never
454
+ // reach the ones published this month. Seek once to the tail instead, so the
455
+ // rows we keep are the most recent. Only fires when the match set is larger
456
+ // than what we return — a query whose results all fit is already complete, so
457
+ // the common (and every mocked) case still costs exactly one request.
458
+ //
459
+ // Interaction with the `v2` supplemental pass (searchSingleTerm), deliberate:
460
+ // that pass admits only entries carrying NO v3/v4 metric, and NVD scores
461
+ // essentially every modern CVE with v3 — so v2-only rows live at the OLD end.
462
+ // Seeking to the tail therefore makes the supplement return little or nothing
463
+ // once a match set is large. That is the intended trade, not an oversight: the
464
+ // supplement was the channel feeding 1999-era advisories into results that are
465
+ // supposed to describe the current picture. A genuinely legacy product still
466
+ // works, because its own v2 match set is small enough that no seek fires.
467
+
468
+ if (!seekedToNewest && startIndex === 0 && reportedTotal !== undefined && reportedTotal > maxResults) {
469
+ seekedToNewest = true;
470
+ startIndex = reportedTotal - maxResults;
471
+ allVulnerabilities.length = 0; // the oldest window we just read is not what we want
472
+ totalPagesFetched++;
473
+ continue;
474
+ }
475
+
436
476
  allVulnerabilities.push(...admitted);
437
477
 
438
- const responseData = data as Record<string, unknown> | undefined;
439
- if (responseData?.['totalResults'] !== undefined && typeof responseData['totalResults'] === 'number' && startIndex + pageSize >= responseData['totalResults']) {
478
+ if (reportedTotal !== undefined && startIndex + pageSize >= reportedTotal) {
440
479
  break;
441
480
  }
442
481
 
@@ -447,6 +486,18 @@ async function fetchPaginated(
447
486
  metrics.increment('nvd_pagination_requests_total', totalPagesFetched);
448
487
  metrics.observe('nvd_pagination_pages_fetched', totalPagesFetched);
449
488
 
489
+ // Newest first within the window. NVD's own order is ascending, so without
490
+ // this the caller's slice() would again favour the older end of whatever we
491
+ // gathered. Undated entries sort last rather than jumping to the front.
492
+ allVulnerabilities.sort((a, b) => {
493
+ const ta = a.published ? Date.parse(a.published) : NaN;
494
+ const tb = b.published ? Date.parse(b.published) : NaN;
495
+ if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
496
+ if (Number.isNaN(ta)) return 1;
497
+ if (Number.isNaN(tb)) return -1;
498
+ return tb - ta;
499
+ });
500
+
450
501
  return allVulnerabilities.slice(0, maxResults);
451
502
  }
452
503
 
@@ -230,7 +230,19 @@ export async function searchOSV(
230
230
  }
231
231
  }
232
232
 
233
- vulnerabilities.push(...Array.from(uniqueVulns.values()));
233
+ // Newest first, so the slice(0, maxResults) below keeps the advisories most
234
+ // likely to matter. OSV returns a package's vulnerabilities in ID order,
235
+ // which is effectively alphabetical — truncating that dropped whichever
236
+ // advisories happened to sort late, recency playing no part. Undated entries
237
+ // sort last rather than displacing dated ones.
238
+ vulnerabilities.push(...Array.from(uniqueVulns.values()).sort((a, b) => {
239
+ const ta = a.published ? Date.parse(a.published) : NaN;
240
+ const tb = b.published ? Date.parse(b.published) : NaN;
241
+ if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
242
+ if (Number.isNaN(ta)) return 1;
243
+ if (Number.isNaN(tb)) return -1;
244
+ return tb - ta;
245
+ }));
234
246
 
235
247
  if (failures.length > 0 && failures.length === terms.length) {
236
248
  throw new Error(`All OSV lookups failed: ${failures.join('; ')}`);
@@ -114,6 +114,17 @@ export function formatQueriesBlock(queries: string[]): string {
114
114
  /** Widget ID for the knowledge search TUI panel */
115
115
  const KNOWLEDGE_WIDGET_ID = 'pi-research-knowledge-search';
116
116
 
117
+ /**
118
+ * How many knowledge searches are currently showing the widget.
119
+ *
120
+ * The widget ID is a single global slot and `setWidget` replaces rather than
121
+ * stacks, but this tool is `executionMode: 'parallel'` and can also be driven
122
+ * concurrently by the `/knowledge-store` slash command. Without a refcount the
123
+ * first call to finish tears the widget down while a sibling is still running,
124
+ * leaving an in-flight search with no indicator. Only the last one out removes it.
125
+ */
126
+ let knowledgeWidgetRefCount = 0;
127
+
117
128
  /**
118
129
  * System string returned when the answer is NOT found in the research
119
130
  * knowledge store. The exact phrasing is critical — the main pi agent
@@ -140,6 +151,7 @@ export const RESEARCH_KNOWLEDGE_MAYBE_STRING =
140
151
  */
141
152
  function showKnowledgeSearchWidget(ctx: ExtensionContext): void {
142
153
  if (ctx.mode !== 'tui' || !ctx.hasUI) return;
154
+ knowledgeWidgetRefCount++;
143
155
  try {
144
156
  const panelFactory = createKnowledgeSearchPanel();
145
157
  ctx.ui.setWidget(KNOWLEDGE_WIDGET_ID, panelFactory as any, { placement: 'aboveEditor' });
@@ -149,10 +161,15 @@ function showKnowledgeSearchWidget(ctx: ExtensionContext): void {
149
161
  }
150
162
 
151
163
  /**
152
- * Remove the knowledge search TUI widget.
164
+ * Remove the knowledge search TUI widget once the last concurrent search is done.
153
165
  */
154
166
  function hideKnowledgeSearchWidget(ctx: ExtensionContext): void {
155
167
  if (ctx.mode !== 'tui' || !ctx.hasUI) return;
168
+ // show() incremented under the identical guard, so the counter stays balanced.
169
+ // Clamp anyway: a stray hide must never drive it negative and wedge the
170
+ // widget on-screen for the rest of the session.
171
+ knowledgeWidgetRefCount = Math.max(0, knowledgeWidgetRefCount - 1);
172
+ if (knowledgeWidgetRefCount > 0) return;
156
173
  try {
157
174
  ctx.ui.setWidget(KNOWLEDGE_WIDGET_ID, undefined as any, { placement: 'aboveEditor' });
158
175
  } catch (err) {
@@ -133,7 +133,22 @@ const KNOWN_TOKEN_PATTERN = new RegExp(
133
133
  'g',
134
134
  );
135
135
  // JSON Web Tokens — header.payload.signature, each a base64url segment.
136
- const JWT_PATTERN = new RegExp(`${B_LEFT}eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+`, 'g');
136
+ //
137
+ // The segment lengths are bounded for the same load-bearing reason the URL
138
+ // scheme above is, and this pattern was missed when that one was fixed. `.` is
139
+ // NOT in the segment class, so on a run of base64url characters containing no
140
+ // dot, `[A-Za-z0-9_-]+` consumes to the end of the run and then backtracks one
141
+ // character at a time looking for one — and `_`/`-` count as non-alphanumeric,
142
+ // so B_LEFT admits a fresh start position every few characters inside that same
143
+ // run. `_eyJ` repeated across the 40_000-character scan window measured 393ms of
144
+ // blocked event loop per log message; bounding the segments brings the identical
145
+ // input to 41ms (~10x) with no loss of coverage. The bounds are far above any
146
+ // real token: a 1024-char header covers even an x5c certificate chain, and 8192
147
+ // covers payloads no bearer credential approaches.
148
+ const JWT_PATTERN = new RegExp(
149
+ `${B_LEFT}eyJ[A-Za-z0-9_-]{1,1024}\\.[A-Za-z0-9_-]{1,8192}\\.[A-Za-z0-9_-]{1,4096}`,
150
+ 'g',
151
+ );
137
152
  // HTTP Basic credentials: "Basic <base64>".
138
153
  const BASIC_AUTH_PATTERN = new RegExp(`${B_LEFT}Basic\\s+[A-Za-z0-9+/]{16,}={0,2}`, 'gi');
139
154
  // HTTP Bearer credentials: "Bearer <token>". The token is often opaque — many
@@ -277,10 +277,18 @@ export interface SsrfSafeFetcher {
277
277
  dispatcher: unknown;
278
278
  }
279
279
 
280
- // Cached after first construction. `undefined` = not yet attempted, `null` =
281
- // undici unavailable (fall back to the global fetch with request-time
282
- // validation only).
283
- let cachedSsrfFetcher: SsrfSafeFetcher | null | undefined = undefined;
280
+ // Cached after first construction. `undefined` = not yet attempted; the promise
281
+ // resolves to `null` when undici is unavailable (fall back to the global fetch
282
+ // with request-time validation only).
283
+ //
284
+ // The PROMISE is cached, not the resolved value. Caching the value left a window
285
+ // between the `undefined` check and the assignment — an `await import('undici')`
286
+ // wide — during which every concurrent first caller built its OWN Agent. Only the
287
+ // last assignment survived in the cache, so the others were handed live Agents
288
+ // that nothing tracked: they opened keep-alive sockets and no shutdown path could
289
+ // ever close them, which is precisely the leak disposeSsrfSafeFetcher exists to
290
+ // prevent. Scrapes run concurrently by design, so the very first burst hit this.
291
+ let cachedSsrfFetcher: Promise<SsrfSafeFetcher | null> | undefined = undefined;
284
292
 
285
293
  /**
286
294
  * Build (once) an undici Agent whose connector resolves via ssrfSafeLookup, so
@@ -304,19 +312,52 @@ let cachedSsrfFetcher: SsrfSafeFetcher | null | undefined = undefined;
304
312
  * Returns null if undici can't be loaded; callers then use the global fetch with
305
313
  * no dispatcher (request-time validation still applies).
306
314
  */
307
- export async function getSsrfSafeFetcher(): Promise<SsrfSafeFetcher | null> {
308
- if (cachedSsrfFetcher !== undefined) return cachedSsrfFetcher;
315
+ export function getSsrfSafeFetcher(): Promise<SsrfSafeFetcher | null> {
316
+ // Assigned before the first await, so concurrent callers all join this one
317
+ // construction rather than each starting their own.
318
+ cachedSsrfFetcher ??= (async () => {
319
+ try {
320
+ const { Agent, fetch: undiciFetch } = await import('undici');
321
+ return {
322
+ fetch: undiciFetch as unknown as SsrfSafeFetcher['fetch'],
323
+ dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } }),
324
+ };
325
+ } catch (e) {
326
+ logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
327
+ return null;
328
+ }
329
+ })();
330
+ return cachedSsrfFetcher;
331
+ }
332
+
333
+ /**
334
+ * Close the cached SSRF Agent and drop the cache.
335
+ *
336
+ * The Agent is a process-lifetime singleton holding keep-alive sockets to every
337
+ * host scraped so far. The CLI hard-exits so it never notices, but an embedding
338
+ * host (the pi extension) keeps the process alive across SDK
339
+ * init/shutdown cycles, where those sockets would linger past a teardown that
340
+ * claims to have released everything.
341
+ *
342
+ * Clearing the cache is not optional: the SDK can be re-initialized in the same
343
+ * process, and a closed Agent left in the cache would fail every subsequent
344
+ * scrape. Resetting to `undefined` (not `null`) makes the next call rebuild it.
345
+ */
346
+ export async function disposeSsrfSafeFetcher(): Promise<void> {
347
+ const pending = cachedSsrfFetcher;
348
+ cachedSsrfFetcher = undefined;
349
+ if (pending === undefined) return;
350
+ // Await the in-flight construction rather than skipping it: a dispose racing
351
+ // the first getSsrfSafeFetcher() would otherwise leave the Agent it is still
352
+ // building unreferenced and unclosed.
353
+ const cached = await pending.catch(() => null);
354
+ const closable = cached?.dispatcher as { close?: () => Promise<void> } | undefined;
355
+ if (!closable?.close) return;
309
356
  try {
310
- const { Agent, fetch: undiciFetch } = await import('undici');
311
- cachedSsrfFetcher = {
312
- fetch: undiciFetch as unknown as SsrfSafeFetcher['fetch'],
313
- dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } }),
314
- };
357
+ await closable.close();
315
358
  } catch (e) {
316
- logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
317
- cachedSsrfFetcher = null;
359
+ logger.debug(`[Scrapers] Error closing SSRF dispatcher: ${e instanceof Error ? e.message : String(e)}`);
318
360
  }
319
- return cachedSsrfFetcher;
320
361
  }
321
362
 
322
363
  /**