@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.
- package/README.md +4 -4
- package/dist/cli.mjs +1404 -600
- package/dist/thread-worker.mjs +40 -11
- package/docs/AGENT-SKILL.md +11 -4
- package/docs/ARCHITECTURE.md +19 -9
- package/docs/CHANGELOG.md +107 -1
- package/docs/CONFIGURATION.md +10 -8
- package/docs/KNOWLEDGE-STORE.md +5 -4
- package/docs/PI-EXTENSION.md +1 -1
- package/docs/SDK.md +63 -5
- package/docs/THIRD-PARTY-NOTICES.md +1 -1
- package/package.json +5 -4
- package/scripts/build.cjs +23 -5
- package/scripts/cleanup.cjs +50 -10
- package/scripts/prepare.cjs +15 -5
- package/scripts/setup.cjs +24 -4
- package/skills/pi-research/README.md +9 -2
- package/skills/pi-research/SKILL.md +8 -5
- package/skills/pi-research/references/configuration.md +7 -3
- package/skills/pi-research/scripts/run.mjs +24 -2
- package/src/cli.ts +161 -21
- package/src/config.ts +56 -15
- package/src/constants.ts +31 -0
- package/src/core/interfaces/knowledge-interfaces.ts +6 -1
- package/src/core/interfaces/state-manager-interfaces.ts +1 -1
- package/src/core/llm/model-registry-factory.ts +40 -3
- package/src/core/llm/research-model-resolver.ts +2 -2
- package/src/core/pi-version.ts +136 -0
- package/src/core/service-registry.ts +117 -8
- package/src/index.ts +11 -18
- package/src/infrastructure/browser/browser-client.ts +16 -4
- package/src/infrastructure/browser/browser-error-utils.ts +12 -1
- package/src/infrastructure/browser/browser-server.ts +17 -5
- package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
- package/src/infrastructure/browser/cleanup-utils.ts +45 -7
- package/src/infrastructure/browser/client-agent.ts +4 -3
- package/src/infrastructure/browser/priority-task-queue.ts +10 -3
- package/src/infrastructure/browser/scheduler-factory.ts +26 -1
- package/src/infrastructure/browser/task-execution-service.ts +92 -28
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
- package/src/infrastructure/browser/thread-worker.mjs +40 -11
- package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
- package/src/infrastructure/embedding/embedding-client.ts +22 -8
- package/src/infrastructure/embedding/embedding-factory.ts +28 -11
- package/src/infrastructure/embedding/embedding-server.ts +65 -4
- package/src/infrastructure/file-lock-service.ts +223 -26
- package/src/infrastructure/process-lifecycle-service.ts +108 -31
- package/src/infrastructure/state/state-backup-manager.ts +5 -7
- package/src/infrastructure/state/state-manager-service.ts +1 -1
- package/src/infrastructure/state/state-manager.ts +19 -20
- package/src/infrastructure/types/state-types.ts +9 -0
- package/src/knowledge/embedder-init.ts +1 -1
- package/src/knowledge/embedder.ts +52 -3
- package/src/knowledge/index.ts +1 -1
- package/src/knowledge/store.ts +35 -9
- package/src/knowledge/webgpu-viability.ts +9 -4
- package/src/knowledge/writer-queue.ts +88 -46
- package/src/orchestration/deep-research-orchestrator.ts +13 -2
- package/src/orchestration/quick-research-orchestrator.ts +2 -2
- package/src/orchestration/research-orchestration-service.ts +2 -4
- package/src/orchestration/research-synthesis-service.ts +34 -92
- package/src/security/cisa-kev.ts +10 -3
- package/src/security/github-advisories.ts +18 -7
- package/src/security/nvd-client.ts +17 -6
- package/src/security/osv-client.ts +14 -4
- package/src/skill-install/skill-installer.ts +135 -9
- package/src/stackexchange/rest-client.ts +6 -2
- package/src/tools/research-knowledge-search.ts +23 -2
- package/src/tools/search.ts +6 -1
- package/src/utils/atomic-replace.ts +101 -0
- package/src/utils/circuit-breaker.ts +20 -0
- package/src/utils/citation-utils.ts +106 -8
- package/src/utils/http-body.ts +176 -0
- package/src/utils/llm-usage.ts +37 -0
- package/src/utils/metrics-summary.ts +25 -2
- package/src/utils/research-export.ts +9 -2
- package/src/web-research/browser-search.ts +50 -4
- package/src/web-research/retry-utils.ts +13 -1
- package/src/web-research/search.ts +20 -5
- package/src/web-research/types.ts +17 -4
- package/src/web-research/web-scraper.ts +7 -50
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
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
|
-
|
|
101
|
-
res.on('data', (chunk: Buffer) => {
|
|
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(
|
|
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: ${
|
|
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
|
-
|
|
168
|
-
res.on('data', (chunk: Buffer) => {
|
|
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(
|
|
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: ${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
if (
|
|
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(
|
|
544
|
+
const data = JSON.parse(collected.toString());
|
|
484
545
|
|
|
485
546
|
switch (req.url) {
|
|
486
547
|
case '/embed': {
|