@lincoln504/pi-research 1.1.0 → 1.2.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.
Files changed (82) hide show
  1. package/README.md +4 -4
  2. package/dist/cli.mjs +1404 -600
  3. package/dist/thread-worker.mjs +40 -11
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +19 -9
  6. package/docs/CHANGELOG.md +107 -1
  7. package/docs/CONFIGURATION.md +10 -8
  8. package/docs/KNOWLEDGE-STORE.md +5 -4
  9. package/docs/PI-EXTENSION.md +1 -1
  10. package/docs/SDK.md +63 -5
  11. package/docs/THIRD-PARTY-NOTICES.md +1 -1
  12. package/package.json +5 -4
  13. package/scripts/build.cjs +23 -5
  14. package/scripts/cleanup.cjs +50 -10
  15. package/scripts/prepare.cjs +15 -5
  16. package/scripts/setup.cjs +24 -4
  17. package/skills/pi-research/README.md +9 -2
  18. package/skills/pi-research/SKILL.md +8 -5
  19. package/skills/pi-research/references/configuration.md +7 -3
  20. package/skills/pi-research/scripts/run.mjs +24 -2
  21. package/src/cli.ts +161 -21
  22. package/src/config.ts +56 -15
  23. package/src/constants.ts +31 -0
  24. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  25. package/src/core/interfaces/state-manager-interfaces.ts +1 -1
  26. package/src/core/llm/model-registry-factory.ts +40 -3
  27. package/src/core/llm/research-model-resolver.ts +2 -2
  28. package/src/core/pi-version.ts +136 -0
  29. package/src/core/service-registry.ts +117 -8
  30. package/src/index.ts +11 -18
  31. package/src/infrastructure/browser/browser-client.ts +16 -4
  32. package/src/infrastructure/browser/browser-error-utils.ts +12 -1
  33. package/src/infrastructure/browser/browser-server.ts +17 -5
  34. package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
  35. package/src/infrastructure/browser/cleanup-utils.ts +45 -7
  36. package/src/infrastructure/browser/client-agent.ts +4 -3
  37. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  38. package/src/infrastructure/browser/scheduler-factory.ts +26 -1
  39. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  40. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  41. package/src/infrastructure/browser/thread-worker.mjs +40 -11
  42. package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
  43. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  44. package/src/infrastructure/embedding/embedding-factory.ts +28 -11
  45. package/src/infrastructure/embedding/embedding-server.ts +65 -4
  46. package/src/infrastructure/file-lock-service.ts +223 -26
  47. package/src/infrastructure/process-lifecycle-service.ts +108 -31
  48. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  49. package/src/infrastructure/state/state-manager-service.ts +1 -1
  50. package/src/infrastructure/state/state-manager.ts +19 -20
  51. package/src/infrastructure/types/state-types.ts +9 -0
  52. package/src/knowledge/embedder-init.ts +1 -1
  53. package/src/knowledge/embedder.ts +52 -3
  54. package/src/knowledge/index.ts +1 -1
  55. package/src/knowledge/store.ts +35 -9
  56. package/src/knowledge/webgpu-viability.ts +9 -4
  57. package/src/knowledge/writer-queue.ts +88 -46
  58. package/src/orchestration/deep-research-orchestrator.ts +13 -2
  59. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  60. package/src/orchestration/research-orchestration-service.ts +2 -4
  61. package/src/orchestration/research-synthesis-service.ts +34 -92
  62. package/src/security/cisa-kev.ts +10 -3
  63. package/src/security/github-advisories.ts +18 -7
  64. package/src/security/nvd-client.ts +17 -6
  65. package/src/security/osv-client.ts +14 -4
  66. package/src/skill-install/skill-installer.ts +135 -9
  67. package/src/stackexchange/rest-client.ts +6 -2
  68. package/src/tools/research-knowledge-search.ts +23 -2
  69. package/src/tools/search.ts +6 -1
  70. package/src/utils/atomic-replace.ts +101 -0
  71. package/src/utils/circuit-breaker.ts +20 -0
  72. package/src/utils/citation-utils.ts +106 -8
  73. package/src/utils/http-body.ts +176 -0
  74. package/src/utils/llm-usage.ts +37 -0
  75. package/src/utils/metrics-summary.ts +25 -2
  76. package/src/utils/research-export.ts +9 -2
  77. package/src/web-research/browser-search.ts +50 -4
  78. package/src/web-research/retry-utils.ts +13 -1
  79. package/src/web-research/search.ts +20 -5
  80. package/src/web-research/types.ts +17 -4
  81. package/src/web-research/web-scraper.ts +7 -50
  82. package/src/youtube/transcript-client.ts +5 -1
@@ -1284,6 +1284,11 @@ var init_circuit_breaker = __esm({
1284
1284
  metrics.observe("circuit_breaker_call_duration_ms", duration, { breaker: this.options.name, status: "error" });
1285
1285
  if (this.options.isTransientError(error)) {
1286
1286
  this.onFailure(error);
1287
+ } else if (admittedAsProbe && this.state === "HALF_OPEN") {
1288
+ logger.warn(
1289
+ `[CircuitBreaker] '${this.options.name}' HALF_OPEN probe failed with a non-counted error; treating as inconclusive and re-opening: ${error instanceof Error ? error.message : String(error)}`
1290
+ );
1291
+ this.transitionTo("OPEN");
1287
1292
  }
1288
1293
  throw error;
1289
1294
  } finally {
@@ -1379,7 +1384,16 @@ function isServerDrainingError(error) {
1379
1384
  function isPoolShutdownError(error) {
1380
1385
  if (!error || typeof error !== "object") return false;
1381
1386
  const err = error;
1382
- return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool"));
1387
+ return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool") || // worker-pool-manager throws this when auto-recovery is still swapping the
1388
+ // pool. Its own text says "please retry", but until it was listed here no
1389
+ // classifier matched it, so the first attempt rethrew instead of retrying —
1390
+ // and auto-recovery fires only after 3 consecutive worker failures, i.e.
1391
+ // exactly when a burst of tasks is in flight and would all die together.
1392
+ err.message.includes("Worker pool is being reset") || // poolifier's wording once destroy() has COMPLETED (vs "destroying pool"
1393
+ // while it is in progress). A queued closure holding a pool captured before
1394
+ // an auto-recovery destroy dispatches into exactly this window; it is the
1395
+ // same transient swap the "being reset" string covers, not a hard fault.
1396
+ err.message.includes("Cannot execute a task on not started pool"));
1383
1397
  }
1384
1398
  var DEFAULT_BREAKER_CONFIG, browserCircuitBreaker;
1385
1399
  var init_browser_error_utils = __esm({
@@ -1743,12 +1757,34 @@ function setupOrphanProtection() {
1743
1757
  if (!cluster.isWorker) {
1744
1758
  return;
1745
1759
  }
1746
- if (!process2.ppid) {
1760
+ const originalPpid = process2.ppid;
1761
+ if (!originalPpid) {
1747
1762
  return;
1748
1763
  }
1764
+ const exitAsOrphan = async () => {
1765
+ if (cleanupBrowser) {
1766
+ await Promise.race([
1767
+ cleanupBrowser().catch((err) => {
1768
+ logToDebugFile("WARN", `[Worker-${workerId}] Browser cleanup failed during orphan exit:`, err);
1769
+ }),
1770
+ new Promise((resolve) => {
1771
+ const t = setTimeout(resolve, 1e4);
1772
+ if (t.unref) t.unref();
1773
+ })
1774
+ ]);
1775
+ }
1776
+ cleanupOrphanProtection();
1777
+ process2.env["PI_PROCESS_EXITING"] = "1";
1778
+ process2.exit(1);
1779
+ };
1749
1780
  const checkOrphan = async () => {
1750
1781
  try {
1751
- process2.kill(process2.ppid, 0);
1782
+ if (process2.ppid !== originalPpid) {
1783
+ logToDebugFile("WARN", `[Worker-${workerId}] Re-parented (${originalPpid} -> ${process2.ppid}): parent died, shutting down...`);
1784
+ await exitAsOrphan();
1785
+ return;
1786
+ }
1787
+ process2.kill(originalPpid, 0);
1752
1788
  orphanCheckTimer = setTimeout(checkOrphan, 1e4);
1753
1789
  if (orphanCheckTimer) {
1754
1790
  orphanCheckTimer.unref();
@@ -1760,14 +1796,7 @@ function setupOrphanProtection() {
1760
1796
  return;
1761
1797
  }
1762
1798
  logToDebugFile("WARN", `[Worker-${workerId}] Parent process died or unreachable (orphaned), shutting down...`);
1763
- if (cleanupBrowser) {
1764
- await cleanupBrowser().catch((err) => {
1765
- logToDebugFile("WARN", `[Worker-${workerId}] Browser cleanup failed during orphan exit:`, err);
1766
- });
1767
- }
1768
- cleanupOrphanProtection();
1769
- process2.env["PI_PROCESS_EXITING"] = "1";
1770
- process2.exit(1);
1799
+ await exitAsOrphan();
1771
1800
  }
1772
1801
  };
1773
1802
  orphanCheckTimer = setTimeout(checkOrphan, 1e4);
@@ -112,6 +112,7 @@ export class WorkerPoolManager implements IService {
112
112
  try {
113
113
  if (this.pool && this.currentWorkerCount !== maxWorkers) {
114
114
  logger.log(`[WorkerPoolManager] Worker count changed from ${this.currentWorkerCount} to ${maxWorkers}, recreating pool...`);
115
+ this.poolEpoch++; // intentional destroy: silence the dying pool's exit events
115
116
  await this.pool.destroy();
116
117
  this.pool = null;
117
118
  }
@@ -211,9 +212,13 @@ export class WorkerPoolManager implements IService {
211
212
  }
212
213
  },
213
214
  exitHandler: (code: number) => {
214
- // null exit code means the worker was killed intentionally (e.g. pool.destroy()).
215
- // Ignore exits from a superseded pool instance (see errorHandler).
216
- if (code !== 0 && code !== null && this.poolEpoch === myEpoch) {
215
+ // A worker we destroy on purpose exits with code null — but so does one
216
+ // the OOM killer SIGKILLs, and that is exactly the death mode that leaves
217
+ // in-flight execute() promises stranded (poolifier never settles them).
218
+ // Every intentional destroy bumps poolEpoch first, so a null-code exit
219
+ // that still matches the live epoch is an unexpected death and must count
220
+ // toward auto-recovery like any other abnormal exit.
221
+ if (code !== 0 && this.poolEpoch === myEpoch) {
217
222
  logger.error(`[WorkerPoolManager] Worker exited with code ${code}`);
218
223
  this.consecutiveErrors++;
219
224
  if (this.consecutiveErrors >= 3) {
@@ -245,6 +250,7 @@ export class WorkerPoolManager implements IService {
245
250
  // Only null the shared field if it still points at the pool we built —
246
251
  // a concurrent path may already have moved on.
247
252
  if (this.pool === built) this.pool = null;
253
+ this.poolEpoch++; // intentional destroy: silence the dying pool's exit events
248
254
  await built.destroy().catch((err: any) => logger.debug('Swallowed pool destroy error:', err));
249
255
  throw new Error('Worker pool is shutting down');
250
256
  }
@@ -337,6 +343,7 @@ export class WorkerPoolManager implements IService {
337
343
  // pool (and bumped the generation), so skip — destroying again is spurious
338
344
  // and nulling shared state would clobber a pool rebuilt after the shutdown.
339
345
  if (this.isShuttingDown || this.generation !== myGen) return;
346
+ this.poolEpoch++; // intentional destroy: silence the dying pool's exit events
340
347
  if (deadPool) await deadPool.destroy();
341
348
  logger.info('[WorkerPoolManager] Auto-recovery: old pool destroyed.');
342
349
  } catch (err) {
@@ -399,6 +406,7 @@ export class WorkerPoolManager implements IService {
399
406
  // Use a timeout for pool destruction. Workers run async browser teardown
400
407
  // in their killHandler (context.close / browser.close via Playwright), so
401
408
  // allow enough time for those to complete before the IPC channel closes.
409
+ this.poolEpoch++; // intentional destroy: silence the dying pool's exit events
402
410
  const destroyPromise = this.pool.destroy();
403
411
  destroyPromise.catch((err: Error) => logger.debug(`[WorkerPoolManager] Background pool destroy rejection: ${err.message}`));
404
412
  await raceWithDeadline(destroyPromise, 5000);
@@ -8,6 +8,7 @@
8
8
  import * as http from 'node:http';
9
9
  import { logger } from '../../logger.ts';
10
10
  import type { IEmbedder } from '../../core/interfaces/knowledge-interfaces.ts';
11
+ import { Utf8Body } from '../../utils/http-body.ts';
11
12
 
12
13
  export class EmbeddingClient implements IEmbedder {
13
14
  // Public so store.ts can set the dimension from existing table schema
@@ -17,10 +18,21 @@ export class EmbeddingClient implements IEmbedder {
17
18
  constructor(
18
19
  private readonly port: number,
19
20
  private _device: string = 'unknown',
21
+ /**
22
+ * Shared secret published by the leader in state. Undefined when the leader
23
+ * predates auth (it does not check a header either), so the request simply
24
+ * carries none — an older leader keeps working rather than 403-ing.
25
+ */
26
+ private readonly authSecret?: string,
20
27
  ) {
21
28
  logger.log(`[EmbeddingClient] Connecting to embedding server at http://127.0.0.1:${port}`);
22
29
  }
23
30
 
31
+ /** Auth header for the leader, or nothing when connecting to a pre-auth leader. */
32
+ private authHeaders(): Record<string, string> {
33
+ return this.authSecret ? { 'x-embedding-auth': this.authSecret } : {};
34
+ }
35
+
24
36
  // ---- IEmbedder ----
25
37
 
26
38
  getDevice(): string {
@@ -93,25 +105,26 @@ export class EmbeddingClient implements IEmbedder {
93
105
  port: this.port,
94
106
  path,
95
107
  method: 'GET',
108
+ headers: this.authHeaders(),
96
109
  },
97
110
  (res) => {
98
111
  // Keep the timeout armed through the body read — clearing it on headers
99
112
  // would let a stalled body hang for the full process lifetime.
100
- let body = '';
101
- res.on('data', (chunk: Buffer) => { body += chunk; });
113
+ const collected = new Utf8Body();
114
+ res.on('data', (chunk: Buffer) => { collected.push(chunk); });
102
115
  res.on('end', () => {
103
116
  if (resolved) return;
104
117
  resolved = true;
105
118
  clearTimeout(timer);
106
119
  try {
107
- const parsed = JSON.parse(body) as T;
120
+ const parsed = JSON.parse(collected.toString()) as T;
108
121
  if (res.statusCode !== 200) {
109
122
  reject(new Error(`[EmbeddingClient] GET ${path} returned HTTP ${res.statusCode}`));
110
123
  } else {
111
124
  resolve(parsed);
112
125
  }
113
126
  } catch (_e) {
114
- reject(new Error(`[EmbeddingClient] Failed to parse GET ${path} response: ${body}`));
127
+ reject(new Error(`[EmbeddingClient] Failed to parse GET ${path} response: ${collected.toString()}`));
115
128
  }
116
129
  });
117
130
  res.on('error', (err) => {
@@ -160,18 +173,19 @@ export class EmbeddingClient implements IEmbedder {
160
173
  headers: {
161
174
  'Content-Type': 'application/json',
162
175
  'Content-Length': Buffer.byteLength(body),
176
+ ...this.authHeaders(),
163
177
  },
164
178
  },
165
179
  (res) => {
166
180
  // Keep the timeout armed through the body read (see getRequest).
167
- let responseBody = '';
168
- res.on('data', (chunk: Buffer) => { responseBody += chunk; });
181
+ const collected = new Utf8Body();
182
+ res.on('data', (chunk: Buffer) => { collected.push(chunk); });
169
183
  res.on('end', () => {
170
184
  if (resolved) return;
171
185
  resolved = true;
172
186
  clearTimeout(timer);
173
187
  try {
174
- const parsed = JSON.parse(responseBody) as T;
188
+ const parsed = JSON.parse(collected.toString()) as T;
175
189
  if (res.statusCode !== 200) {
176
190
  const msg = (parsed as Record<string, unknown>)?.['error'];
177
191
  reject(new Error(typeof msg === 'string' ? msg : `HTTP ${res.statusCode}`));
@@ -179,7 +193,7 @@ export class EmbeddingClient implements IEmbedder {
179
193
  resolve(parsed);
180
194
  }
181
195
  } catch (_e) {
182
- reject(new Error(`[EmbeddingClient] Failed to parse POST ${path} response: ${responseBody}`));
196
+ reject(new Error(`[EmbeddingClient] Failed to parse POST ${path} response: ${collected.toString()}`));
183
197
  }
184
198
  });
185
199
  res.on('error', (err) => {
@@ -19,7 +19,7 @@ import type { IEmbedder } from '../../core/interfaces/knowledge-interfaces.ts';
19
19
  import { Embedder } from '../../knowledge/embedder.ts';
20
20
  import { getModelEmbedderConfig } from '../../knowledge/model-config.ts';
21
21
 
22
- import { EmbeddingServer } from './embedding-server.ts';
22
+ import { EmbeddingServer, getEmbeddingServerAuthSecret } from './embedding-server.ts';
23
23
  import { EmbeddingClient } from './embedding-client.ts';
24
24
 
25
25
  // ---------------------------------------------------------------------------
@@ -119,11 +119,19 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
119
119
  return _embeddingInstance;
120
120
  }
121
121
  logger.info(`[EmbeddingFactory] Configuration change detected (${_cachedModel} on ${_cachedDevice} -> ${cfg.EMBEDDING_MODEL} on ${cfg.EMBEDDING_DEVICE}). Disposing stale instance.`);
122
- await _embeddingInstance.dispose?.();
122
+ // Claim the instance SYNCHRONOUSLY, before the first await. Two callers that
123
+ // resolve different EMBEDDING_MODEL/EMBEDDING_DEVICE values would otherwise both
124
+ // observe it, both await dispose() on the same live EmbeddingServer (double
125
+ // close(), double embedder dispose) and both proceed to init().
126
+ const stale = _embeddingInstance;
123
127
  _embeddingInstance = null;
128
+ await stale.dispose?.();
124
129
  }
125
130
 
126
- if (_embeddingInitPromise) return _embeddingInitPromise;
131
+ // An internal retry (_attempt > 0) is re-entering from INSIDE the promise stored
132
+ // here, so handing that promise back would make it await itself. Only a fresh
133
+ // external call may join an in-flight init.
134
+ if (_embeddingInitPromise && _attempt === 0) return _embeddingInitPromise;
127
135
 
128
136
  let p: Promise<IEmbedder>;
129
137
 
@@ -166,6 +174,9 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
166
174
  pid: process.pid,
167
175
  serverId,
168
176
  model: cfg.EMBEDDING_MODEL,
177
+ // Published with the claim, not with the port, so a follower that adopts
178
+ // this leader always has the secret available whenever the port is.
179
+ authSecret: getEmbeddingServerAuthSecret(),
169
180
  ...(myStartTime !== null ? { startTime: myStartTime } : {}),
170
181
  };
171
182
  iAmCandidate = true;
@@ -223,7 +234,7 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
223
234
  const portOk = await isPortListening(info.port);
224
235
  if (portOk) {
225
236
  logger.info(`[EmbeddingFactory] Connecting to embedding server on port ${info.port}`);
226
- const client = new EmbeddingClient(info.port);
237
+ const client = new EmbeddingClient(info.port, cfg.EMBEDDING_DEVICE, info.authSecret);
227
238
  await client.fetchHealth();
228
239
  _embeddingInstance = client;
229
240
  _cachedModel = cfg.EMBEDDING_MODEL;
@@ -241,8 +252,12 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
241
252
  }
242
253
  }
243
254
 
244
- // Timed out or candidate died — attempt to claim candidacy ourselves (tail-call)
245
- _embeddingInitPromise = null;
255
+ // Timed out or candidate died — attempt to claim candidacy ourselves (tail-call).
256
+ // The in-flight marker is deliberately LEFT in place: clearing it here, while
257
+ // this very promise is still running, let a concurrent getEmbedder() see no
258
+ // instance and no init in flight and start a second, competing election in the
259
+ // same process. The retry re-enters with _attempt > 0, which skips the join
260
+ // above, so leaving it set cannot deadlock.
246
261
  if (_attempt + 1 >= MAX_LEADER_WAIT_RETRIES) {
247
262
  throw new Error(
248
263
  `[EmbeddingFactory] Could not obtain an embedding server after ${MAX_LEADER_WAIT_RETRIES} attempts ` +
@@ -297,11 +312,13 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
297
312
  // between init resolving and this microtask, re-assigning would resurrect the
298
313
  // disposed instance and hand it to the next getEmbedder() caller. Only clear
299
314
  // the in-flight promise marker.
300
- p.then(() => {
301
- _embeddingInitPromise = null;
302
- }).catch(() => {
303
- _embeddingInitPromise = null;
304
- });
315
+ // Clear the marker only if it is STILL ours. An unconditional clear let a settling
316
+ // promise wipe a newer caller's in-flight marker — admitting yet another election,
317
+ // and cascading. Same guard as scheduler-factory's initialization promise.
318
+ const clearIfCurrent = (): void => {
319
+ if (_embeddingInitPromise === p) _embeddingInitPromise = null;
320
+ };
321
+ p.then(clearIfCurrent).catch(clearIfCurrent);
305
322
  return p;
306
323
  }
307
324
 
@@ -6,15 +6,49 @@
6
6
  */
7
7
 
8
8
  import * as http from 'node:http';
9
+ import * as crypto from 'node:crypto';
9
10
  import * as path from 'node:path';
10
11
  import { logger } from '../../logger.ts';
11
12
  import { captureStdio } from '../../utils/stdio-capture.ts';
12
13
  import { buildDefaultDebugLogPath } from '../../utils/log-utils.ts';
13
14
  import { DiskSpaceChecker } from '../../utils/disk-space-checker.ts';
15
+ import { Utf8Body } from '../../utils/http-body.ts';
14
16
  import type { IEmbedder } from '../../core/interfaces/knowledge-interfaces.ts';
15
17
  import type { IStateManager } from '../../core/interfaces/state-manager-interfaces.ts';
16
18
  import type { Embedder } from '../../knowledge/embedder.ts';
17
19
 
20
+ /**
21
+ * Per-process shared secret every embed request must present.
22
+ *
23
+ * The server binds 127.0.0.1, but loopback is not an authorization boundary —
24
+ * any local process could otherwise enqueue embedding work. That matters more
25
+ * than raw GPU cost: the serial queue steps the leader down when it poisons, so
26
+ * sustained abuse denies the knowledge store cluster-wide. The browser server has
27
+ * had this since #21; the embedding server was the asymmetric gap.
28
+ *
29
+ * Generated lazily and held in memory only — it is published to followers through
30
+ * the state file, which is already 0o600 and already carries the browser secret.
31
+ */
32
+ let _embeddingAuthSecret: string | null = null;
33
+
34
+ export function getEmbeddingServerAuthSecret(): string {
35
+ if (!_embeddingAuthSecret) {
36
+ _embeddingAuthSecret = crypto.randomBytes(32).toString('hex');
37
+ }
38
+ return _embeddingAuthSecret;
39
+ }
40
+
41
+ /**
42
+ * Constant-time comparison of a presented header against the expected secret.
43
+ * Length is compared first because timingSafeEqual throws on a length mismatch.
44
+ */
45
+ function isAuthorized(presented: string | string[] | undefined): boolean {
46
+ const header = Array.isArray(presented) ? presented[0] : presented;
47
+ const expected = Buffer.from(getEmbeddingServerAuthSecret(), 'utf8');
48
+ const actual = Buffer.from(header ?? '', 'utf8');
49
+ return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
50
+ }
51
+
18
52
  // ---------------------------------------------------------------------------
19
53
  // SerialQueue — ensures embed/embedMany never run concurrently on the GPU
20
54
  // ---------------------------------------------------------------------------
@@ -428,13 +462,27 @@ export class EmbeddingServer implements IEmbedder {
428
462
  // ---- HTTP request handler ----
429
463
 
430
464
  private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
465
+ // Authorize BEFORE reading the body, so an unauthenticated caller cannot make
466
+ // us buffer up to MAX_BODY_SIZE, and before routing, so it cannot probe
467
+ // leadership/health state either. Mirrors the browser server.
468
+ if (!isAuthorized(req.headers['x-embedding-auth'])) {
469
+ res.writeHead(403, { 'Content-Type': 'application/json' });
470
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
471
+ req.resume(); // drain so the socket is released rather than left half-read
472
+ return;
473
+ }
474
+
431
475
  const MAX_BODY_SIZE = 50 * 1024 * 1024; // 50 MB
432
476
 
433
- let body = '';
477
+ // Buffered, then decoded once: `body += chunk` would split multi-byte UTF-8
478
+ // across chunk boundaries and silently replace both halves with U+FFFD — and
479
+ // the text arriving here is exactly what gets embedded, so that corruption
480
+ // would be baked into the vectors. See Utf8Body.
481
+ const collected = new Utf8Body();
434
482
  let oversized = false;
435
483
  req.on('data', (chunk: Buffer) => {
436
- body += chunk;
437
- if (body.length > MAX_BODY_SIZE) {
484
+ collected.push(chunk);
485
+ if (collected.byteLength > MAX_BODY_SIZE) {
438
486
  oversized = true;
439
487
  req.destroy(new Error('Payload too large'));
440
488
  }
@@ -459,6 +507,19 @@ export class EmbeddingServer implements IEmbedder {
459
507
  finish();
460
508
  });
461
509
 
510
+ // A client that goes away mid-request emits 'aborted'/'close' and does NOT
511
+ // reliably emit 'error', so neither handler above would fire and this promise
512
+ // would dangle forever — retaining req, res and the buffered body for the rest
513
+ // of the process's life. That is not an exotic case: EmbeddingClient destroys
514
+ // the request on its own 120s timeout, which is the same as this server's
515
+ // SerialQueue soft timeout, so a slow embed reliably produces one. handleRequest
516
+ // is invoked with `void`, so nothing else would ever observe the leak.
517
+ // On a normal request this fires after 'end' and finish() is already a no-op;
518
+ // nothing follows this await (the caller invokes handleRequest with `void`), so
519
+ // an early resolve is inert either way — the 'end' handler still writes the
520
+ // response on its own.
521
+ req.on('close', finish);
522
+
462
523
  req.on('end', async () => {
463
524
  try {
464
525
  if (req.method === 'GET' && req.url === '/health') {
@@ -480,7 +541,7 @@ export class EmbeddingServer implements IEmbedder {
480
541
  return;
481
542
  }
482
543
 
483
- const data = JSON.parse(body);
544
+ const data = JSON.parse(collected.toString());
484
545
 
485
546
  switch (req.url) {
486
547
  case '/embed': {