@cortexkit/aft-bridge 0.18.5 → 0.19.1

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.
@@ -0,0 +1,722 @@
1
+ /**
2
+ * Auto-download and manage ONNX Runtime shared library for semantic search.
3
+ *
4
+ * Downloads the CPU-only ONNX Runtime from Microsoft's GitHub releases.
5
+ * The library is cached in the storage directory alongside semantic index data.
6
+ *
7
+ * Audit-3 v0.17 #1 hardening (v0.17.1): the previous implementation used
8
+ * `curl` with no size cap, no archive containment validation, no install
9
+ * lock, and no integrity verification — leaving an entire parallel install
10
+ * path that bypassed every defense the LSP GitHub installer had earned in
11
+ * Phase A through Phase E. This rewrite brings ONNX onto the same security
12
+ * floor:
13
+ *
14
+ * - Streaming size cap via fetch + ReadableStream transformer (`MAX_DOWNLOAD_BYTES`).
15
+ * - Streaming SHA-256 of the downloaded archive, persisted in `.aft-onnx-installed`.
16
+ * - Atomic O_EXCL install lock with PID-aware stale-lock recovery.
17
+ * - Containment-checked extraction: every file under the staging dir
18
+ * must be inside the staging root, no symlinks allowed before move.
19
+ * - Total extracted size cap (`MAX_EXTRACT_BYTES`) to defeat decompression
20
+ * bombs.
21
+ * - TOFU verification: if `.aft-onnx-installed` already records a hash for
22
+ * this version, refuse to use a binary that doesn't match.
23
+ *
24
+ * Supported platforms:
25
+ * - macOS ARM64 (osx-arm64)
26
+ * - Linux x64 (linux-x64)
27
+ * - Linux ARM64 (linux-aarch64)
28
+ * - Windows x64 (win-x64)
29
+ * - Windows ARM64 (win-arm64)
30
+ *
31
+ * macOS x64 (Intel) is not provided by Microsoft — users must install via:
32
+ * brew install onnxruntime
33
+ */
34
+ import { execFileSync } from "node:child_process";
35
+ import { createHash } from "node:crypto";
36
+ import { chmodSync, closeSync, copyFileSync, createWriteStream, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs";
37
+ import { dirname, join, relative, resolve } from "node:path";
38
+ import { Readable } from "node:stream";
39
+ import { pipeline } from "node:stream/promises";
40
+ import { error, log, warn } from "./active-logger.js";
41
+ const ORT_VERSION = "1.24.4";
42
+ const ORT_REPO = "microsoft/onnxruntime";
43
+ // Audit-3 v0.17 #1: streaming + extraction size caps.
44
+ //
45
+ // ONNX Runtime archives are around 60–80 MB on most platforms and 250 MB
46
+ // extracted (Windows ships extra debug binaries). 256 MB and 1 GiB give
47
+ // generous headroom while preventing a malicious or corrupted CDN response
48
+ // from filling the user's disk. Same numbers as the GitHub LSP installer.
49
+ const MAX_DOWNLOAD_BYTES = 256 * 1024 * 1024;
50
+ const MAX_EXTRACT_BYTES = 1 * 1024 * 1024 * 1024;
51
+ const ONNX_LOCK_FILE = ".aft-onnx-installing";
52
+ const ONNX_INSTALLED_META_FILE = ".aft-onnx-installed";
53
+ // 5 minutes is well under any reasonable real download time (60–80 MB archive
54
+ // on a working connection) but short enough that a SIGKILL'd plugin process
55
+ // recovers fast on the next launch. Previously 30 min — that left users
56
+ // blocked for a very long time after closing OpenCode mid-download (issue
57
+ // reports of "blackscreen on launch then ONNX broken").
58
+ const STALE_LOCK_MS = 5 * 60 * 1000;
59
+ const ORT_PLATFORM_MAP = {
60
+ darwin: {
61
+ arm64: {
62
+ assetName: `onnxruntime-osx-arm64-${ORT_VERSION}`,
63
+ libName: "libonnxruntime.dylib",
64
+ archiveType: "tgz",
65
+ },
66
+ // x64 not available from Microsoft — users need brew install onnxruntime
67
+ },
68
+ linux: {
69
+ x64: {
70
+ assetName: `onnxruntime-linux-x64-${ORT_VERSION}`,
71
+ libName: "libonnxruntime.so",
72
+ archiveType: "tgz",
73
+ },
74
+ arm64: {
75
+ assetName: `onnxruntime-linux-aarch64-${ORT_VERSION}`,
76
+ libName: "libonnxruntime.so",
77
+ archiveType: "tgz",
78
+ },
79
+ },
80
+ win32: {
81
+ x64: {
82
+ assetName: `onnxruntime-win-x64-${ORT_VERSION}`,
83
+ libName: "onnxruntime.dll",
84
+ archiveType: "zip",
85
+ },
86
+ arm64: {
87
+ assetName: `onnxruntime-win-arm64-${ORT_VERSION}`,
88
+ libName: "onnxruntime.dll",
89
+ archiveType: "zip",
90
+ },
91
+ },
92
+ };
93
+ /** Get platform info for the current system, or null if unsupported */
94
+ function getPlatformInfo() {
95
+ const platformMap = ORT_PLATFORM_MAP[process.platform];
96
+ if (!platformMap)
97
+ return null;
98
+ return platformMap[process.arch] || null;
99
+ }
100
+ /** Check if this platform can auto-download ONNX Runtime */
101
+ export function isOrtAutoDownloadSupported() {
102
+ return getPlatformInfo() !== null;
103
+ }
104
+ /** Get the install hint for platforms where auto-download isn't available */
105
+ export function getManualInstallHint() {
106
+ if (process.platform === "darwin" && process.arch === "x64") {
107
+ return "brew install onnxruntime";
108
+ }
109
+ if (process.platform === "linux") {
110
+ return "apt install libonnxruntime or download from https://github.com/microsoft/onnxruntime/releases";
111
+ }
112
+ return "Download from https://github.com/microsoft/onnxruntime/releases";
113
+ }
114
+ /**
115
+ * Ensure ONNX Runtime is available. Returns the directory containing the library,
116
+ * or null if unavailable.
117
+ *
118
+ * Resolution order:
119
+ * 1. Cached in storageDir/onnxruntime/<version>/ (with TOFU verification)
120
+ * 2. System install (brew, apt, etc.)
121
+ * 3. Auto-download from GitHub releases (if platform supported)
122
+ * 4. null (user needs manual install)
123
+ */
124
+ export async function ensureOnnxRuntime(storageDir) {
125
+ const info = getPlatformInfo();
126
+ // 1. Cached location with TOFU.
127
+ const ortDir = join(storageDir, "onnxruntime", ORT_VERSION);
128
+ const libPath = join(ortDir, info?.libName ?? "libonnxruntime.dylib");
129
+ if (existsSync(libPath)) {
130
+ // Audit-3 v0.17 #1 (TOFU): if we recorded a hash for this version,
131
+ // verify the library still matches. A mismatch means tampering or
132
+ // partial install corruption. Refuse to use it and let the caller
133
+ // either retry the download (after the user clears the cache) or
134
+ // fall back to system install.
135
+ const meta = readOnnxInstalledMeta(ortDir);
136
+ if (meta?.sha256) {
137
+ try {
138
+ const currentHash = sha256File(libPath);
139
+ if (currentHash !== meta.sha256) {
140
+ error(`ONNX Runtime at ${ortDir}: TOFU sha256 mismatch — refusing to use ` +
141
+ `tampered binary. Recorded ${meta.sha256}, current ${currentHash}. ` +
142
+ `Run \`aft doctor --clear\` to re-download from scratch.`);
143
+ // Fall through to system path / re-download attempt below.
144
+ }
145
+ else {
146
+ log(`ONNX Runtime found at ${ortDir} (TOFU verified)`);
147
+ return ortDir;
148
+ }
149
+ }
150
+ catch (err) {
151
+ warn(`Could not verify ONNX Runtime hash at ${ortDir}: ${err}`);
152
+ // Treat unreadable hash as "trust on existence" since we already
153
+ // owned this install — better than blocking semantic search.
154
+ return ortDir;
155
+ }
156
+ }
157
+ else {
158
+ log(`ONNX Runtime found at ${ortDir} (no recorded hash, accepting)`);
159
+ return ortDir;
160
+ }
161
+ }
162
+ // 2. System locations.
163
+ const systemPath = findSystemOnnxRuntime(info?.libName);
164
+ if (systemPath) {
165
+ log(`ONNX Runtime found at system path: ${systemPath}`);
166
+ return systemPath;
167
+ }
168
+ // 3. Auto-download.
169
+ if (!info) {
170
+ warn(`ONNX Runtime auto-download not available for ${process.platform}/${process.arch}. Install manually: ${getManualInstallHint()}`);
171
+ return null;
172
+ }
173
+ // Audit-3 v0.17 #1: serialize concurrent installs.
174
+ //
175
+ // Two AFT plugin instances starting at the same time would otherwise both
176
+ // download and extract into overlapping temp dirs and clobber each other.
177
+ // The lock is held for the full install duration via the manual try/finally
178
+ // below (we don't reuse withInstallLock from lsp-cache because that helper
179
+ // is keyed on lspPackageDir, while ONNX lives in storageDir).
180
+ const onnxBaseDir = join(storageDir, "onnxruntime");
181
+ mkdirSync(onnxBaseDir, { recursive: true });
182
+ const lockPath = join(onnxBaseDir, ONNX_LOCK_FILE);
183
+ // Recover from SIGKILL'd previous attempts before acquiring the lock.
184
+ // When the host process is killed mid-download (user closes OpenCode while
185
+ // ONNX is still downloading), the staging dir at `${ortDir}.tmp.<pid>.<ts>`
186
+ // and a half-populated `ortDir` can survive without a meta file. The
187
+ // existing TOFU branch above already handles a tampered-but-complete
188
+ // install, but this branch covers the "abandoned, incomplete" case where
189
+ // the lib file isn't present (we wouldn't be here otherwise). Sweep them
190
+ // out so the next download starts from a clean slate.
191
+ cleanupAbandonedOnnxAttempts(onnxBaseDir, ortDir);
192
+ if (!acquireLock(lockPath)) {
193
+ warn(`ONNX Runtime install already in progress in another process (lock: ${lockPath}). Skipping.`);
194
+ return null;
195
+ }
196
+ try {
197
+ return await downloadOnnxRuntime(info, ortDir);
198
+ }
199
+ finally {
200
+ releaseLock(lockPath);
201
+ }
202
+ }
203
+ /**
204
+ * Sweep abandoned `*.tmp.<pid>.<ts>` staging directories left behind by
205
+ * killed download attempts, and remove an empty/half-populated target dir
206
+ * so the next download retries cleanly. Safe to call before lock acquisition
207
+ * because we only delete dirs whose owning PID is dead (or when the parent
208
+ * dir's mtime exceeds STALE_LOCK_MS — covers Windows where we can't check
209
+ * process liveness reliably).
210
+ */
211
+ function cleanupAbandonedOnnxAttempts(onnxBaseDir, ortDir) {
212
+ // Sweep .tmp.* staging dirs whose pid is dead or are sufficiently old.
213
+ try {
214
+ const entries = readdirSync(onnxBaseDir);
215
+ const ortDirBaseName = ortDir.slice(onnxBaseDir.length + 1);
216
+ for (const entry of entries) {
217
+ if (!entry.startsWith(`${ortDirBaseName}.tmp.`))
218
+ continue;
219
+ const stagingDir = join(onnxBaseDir, entry);
220
+ // Format: `${ORT_VERSION}.tmp.<pid>.<ts>`. Extract pid; if dead, sweep.
221
+ const parts = entry.split(".");
222
+ const pidStr = parts[parts.length - 2];
223
+ const pid = pidStr ? Number.parseInt(pidStr, 10) : NaN;
224
+ let abandoned = false;
225
+ if (Number.isFinite(pid) && pid > 0) {
226
+ if (process.platform === "win32") {
227
+ // No reliable cross-process liveness check on Windows; fall back
228
+ // to mtime-based age comparison.
229
+ try {
230
+ const ageMs = Date.now() - statSync(stagingDir).mtimeMs;
231
+ abandoned = ageMs > STALE_LOCK_MS;
232
+ }
233
+ catch {
234
+ abandoned = true;
235
+ }
236
+ }
237
+ else {
238
+ abandoned = !isProcessAlive(pid);
239
+ }
240
+ }
241
+ else {
242
+ abandoned = true;
243
+ }
244
+ if (abandoned) {
245
+ log(`[onnx] removing abandoned staging dir ${stagingDir}`);
246
+ try {
247
+ rmSync(stagingDir, { recursive: true, force: true });
248
+ }
249
+ catch (err) {
250
+ warn(`[onnx] failed to remove ${stagingDir}: ${err}`);
251
+ }
252
+ }
253
+ }
254
+ }
255
+ catch {
256
+ // base dir doesn't exist yet; nothing to sweep
257
+ }
258
+ // If the target dir exists but doesn't contain a meta file, the previous
259
+ // attempt was killed mid-copy. Wipe it so download can recreate cleanly.
260
+ try {
261
+ if (existsSync(ortDir) && !existsSync(join(ortDir, ONNX_INSTALLED_META_FILE))) {
262
+ log(`[onnx] removing half-populated install dir ${ortDir} (no meta file)`);
263
+ rmSync(ortDir, { recursive: true, force: true });
264
+ }
265
+ }
266
+ catch (err) {
267
+ warn(`[onnx] failed to sweep ${ortDir}: ${err}`);
268
+ }
269
+ }
270
+ /** Check common system locations for ONNX Runtime */
271
+ function findSystemOnnxRuntime(libName) {
272
+ if (!libName)
273
+ return null;
274
+ const searchPaths = [];
275
+ if (process.platform === "darwin") {
276
+ // Homebrew locations
277
+ searchPaths.push("/opt/homebrew/lib", "/usr/local/lib");
278
+ }
279
+ else if (process.platform === "linux") {
280
+ searchPaths.push("/usr/lib", "/usr/lib/x86_64-linux-gnu", "/usr/lib/aarch64-linux-gnu", "/usr/local/lib");
281
+ }
282
+ for (const dir of searchPaths) {
283
+ if (existsSync(join(dir, libName))) {
284
+ return dir;
285
+ }
286
+ }
287
+ return null;
288
+ }
289
+ /**
290
+ * Streaming download with size cap. Mirrors the hardened path in
291
+ * `lsp-github-install.ts:downloadFile` so ONNX gets the same defenses
292
+ * the LSP installer already has.
293
+ *
294
+ * The URL is hardcoded to `https://github.com/${ORT_REPO}/...` so we don't
295
+ * need a hostname allowlist — the constant cannot be attacker-influenced.
296
+ */
297
+ async function downloadFileWithCap(url, destPath) {
298
+ const controller = new AbortController();
299
+ const timeout = setTimeout(() => controller.abort(), 300_000);
300
+ try {
301
+ const res = await fetch(url, {
302
+ headers: { accept: "application/octet-stream" },
303
+ redirect: "follow",
304
+ signal: controller.signal,
305
+ });
306
+ if (!res.ok || !res.body) {
307
+ throw new Error(`download failed (HTTP ${res.status})`);
308
+ }
309
+ const advertised = Number.parseInt(res.headers.get("content-length") ?? "", 10);
310
+ if (Number.isFinite(advertised) && advertised > MAX_DOWNLOAD_BYTES) {
311
+ throw new Error(`Content-Length ${advertised} exceeds max ${MAX_DOWNLOAD_BYTES}`);
312
+ }
313
+ mkdirSync(dirname(destPath), { recursive: true });
314
+ let bytesWritten = 0;
315
+ const guard = new TransformStream({
316
+ transform(chunk, transformController) {
317
+ bytesWritten += chunk.byteLength;
318
+ if (bytesWritten > MAX_DOWNLOAD_BYTES) {
319
+ transformController.error(new Error(`download exceeded ${MAX_DOWNLOAD_BYTES} bytes after streaming (server lied about size or sent unbounded body)`));
320
+ return;
321
+ }
322
+ transformController.enqueue(chunk);
323
+ },
324
+ });
325
+ const guarded = res.body.pipeThrough(guard);
326
+ // biome-ignore lint/suspicious/noExplicitAny: ReadableStream→Node stream conversion
327
+ const nodeStream = Readable.fromWeb(guarded);
328
+ await pipeline(nodeStream, createWriteStream(destPath), { signal: controller.signal });
329
+ }
330
+ catch (err) {
331
+ try {
332
+ unlinkSync(destPath);
333
+ }
334
+ catch {
335
+ // partial file may not exist — fine
336
+ }
337
+ throw err;
338
+ }
339
+ finally {
340
+ clearTimeout(timeout);
341
+ }
342
+ }
343
+ /**
344
+ * Validate that every file/dir under `stagingRoot` is contained inside it
345
+ * AND that the total extracted bytes do not exceed `MAX_EXTRACT_BYTES`.
346
+ *
347
+ * Audit-3 v0.17 #1: zip-slip + symlink containment + decompression-bomb
348
+ * defense. tar and unzip both can produce paths like `../../etc/passwd`;
349
+ * Windows tar.exe and unzip can also produce symlinks that point outside
350
+ * the destination. We walk the tree post-extraction and reject anything
351
+ * suspicious before moving files into the final cache.
352
+ */
353
+ function validateExtractedTree(stagingRoot) {
354
+ const realRoot = realpathSync(stagingRoot);
355
+ let totalBytes = 0;
356
+ const walk = (dir) => {
357
+ const entries = readdirSync(dir);
358
+ for (const entry of entries) {
359
+ const fullPath = join(dir, entry);
360
+ const lst = lstatSync(fullPath);
361
+ if (lst.isSymbolicLink()) {
362
+ // Symlinks must be either rejected outright or contained. Tarballs
363
+ // from Microsoft's official ONNX Runtime release ship with versioned
364
+ // .so symlinks (libonnxruntime.so → libonnxruntime.so.1.24.4), so
365
+ // we cannot simply reject all of them. Instead resolve the link
366
+ // target (relative to the symlink's directory) and verify it stays
367
+ // inside the staging root.
368
+ const linkTarget = readlinkSync(fullPath);
369
+ const resolvedTarget = resolve(dirname(fullPath), linkTarget);
370
+ const rel = relative(realRoot, resolvedTarget);
371
+ if (rel.startsWith("..") || (process.platform !== "win32" && rel.startsWith("/"))) {
372
+ throw new Error(`extracted symlink ${fullPath} points outside staging root: ${linkTarget}`);
373
+ }
374
+ continue;
375
+ }
376
+ const rel = relative(realRoot, fullPath);
377
+ if (rel.startsWith("..") || (process.platform !== "win32" && rel.startsWith("/"))) {
378
+ throw new Error(`extracted entry ${fullPath} escapes staging root`);
379
+ }
380
+ if (lst.isDirectory()) {
381
+ walk(fullPath);
382
+ continue;
383
+ }
384
+ if (lst.isFile()) {
385
+ totalBytes += lst.size;
386
+ if (totalBytes > MAX_EXTRACT_BYTES) {
387
+ throw new Error(`extracted size ${totalBytes} exceeds max ${MAX_EXTRACT_BYTES} (decompression bomb defense)`);
388
+ }
389
+ }
390
+ }
391
+ };
392
+ walk(realRoot);
393
+ }
394
+ /** Download and extract ONNX Runtime from GitHub releases */
395
+ async function downloadOnnxRuntime(info, targetDir) {
396
+ const url = `https://github.com/${ORT_REPO}/releases/download/v${ORT_VERSION}/${info.assetName}.${info.archiveType === "tgz" ? "tgz" : "zip"}`;
397
+ log(`Downloading ONNX Runtime v${ORT_VERSION} for ${process.platform}/${process.arch}...`);
398
+ // Use a parent-of-targetDir staging path so the validation walk can compare
399
+ // resolved paths via realpathSync without rejecting symlinks that happen to
400
+ // resolve into the parent (e.g. when storageDir is itself behind a symlink).
401
+ const tmpDir = `${targetDir}.tmp.${process.pid}.${Date.now().toString(36)}`;
402
+ try {
403
+ mkdirSync(tmpDir, { recursive: true });
404
+ const archivePath = join(tmpDir, `onnxruntime.${info.archiveType}`);
405
+ // Audit-3 v0.17 #1: download with streaming size cap (no more curl).
406
+ await downloadFileWithCap(url, archivePath);
407
+ // Audit-3 v0.17 #1: hash the archive for TOFU.
408
+ const archiveSha256 = sha256File(archivePath);
409
+ log(`ONNX Runtime archive sha256=${archiveSha256}`);
410
+ // Extract.
411
+ if (info.archiveType === "tgz") {
412
+ execFileSync("tar", ["xzf", archivePath, "-C", tmpDir], {
413
+ stdio: "pipe",
414
+ timeout: 120_000,
415
+ });
416
+ }
417
+ else {
418
+ await extractZipArchive(archivePath, tmpDir);
419
+ }
420
+ // Drop the archive itself before validation so it doesn't double-count
421
+ // toward the extracted-size budget.
422
+ try {
423
+ unlinkSync(archivePath);
424
+ }
425
+ catch {
426
+ // ignore
427
+ }
428
+ // Audit-3 v0.17 #1: containment + size-bomb check.
429
+ validateExtractedTree(tmpDir);
430
+ // Find and copy the library file.
431
+ const extractedDir = join(tmpDir, info.assetName, "lib");
432
+ if (!existsSync(extractedDir)) {
433
+ throw new Error(`Expected directory not found: ${extractedDir}`);
434
+ }
435
+ // Create target directory and copy library files.
436
+ mkdirSync(targetDir, { recursive: true });
437
+ // Copy all library files (main + versioned symlinks).
438
+ // On Linux, .so files are often symlinks (libonnxruntime.so → libonnxruntime.so.1.24.4).
439
+ // Process real files first, then recreate symlinks in the target directory to avoid
440
+ // ENOENT when renaming a symlink whose target was already moved.
441
+ const libFiles = readdirSync(extractedDir).filter((f) => f.startsWith("libonnxruntime") || f.startsWith("onnxruntime"));
442
+ // Separate real files from symlinks
443
+ const realFiles = [];
444
+ const symlinks = [];
445
+ for (const libFile of libFiles) {
446
+ const src = join(extractedDir, libFile);
447
+ try {
448
+ const stat = lstatSync(src);
449
+ log(`ORT extract: ${libFile} — isSymlink=${stat.isSymbolicLink()}, isFile=${stat.isFile()}, size=${stat.size}`);
450
+ if (stat.isSymbolicLink()) {
451
+ symlinks.push({ name: libFile, target: readlinkSync(src) });
452
+ }
453
+ else {
454
+ realFiles.push(libFile);
455
+ }
456
+ }
457
+ catch (e) {
458
+ log(`ORT extract: ${libFile} — stat failed: ${e}`);
459
+ realFiles.push(libFile);
460
+ }
461
+ }
462
+ // Copy real files first
463
+ for (const libFile of realFiles) {
464
+ const src = join(extractedDir, libFile);
465
+ const dst = join(targetDir, libFile);
466
+ try {
467
+ copyFileSync(src, dst);
468
+ if (process.platform !== "win32") {
469
+ chmodSync(dst, 0o755);
470
+ }
471
+ }
472
+ catch (copyErr) {
473
+ log(`ORT extract: failed to copy ${libFile}: ${copyErr}`);
474
+ }
475
+ }
476
+ // Recreate symlinks in target directory
477
+ for (const link of symlinks) {
478
+ const dst = join(targetDir, link.name);
479
+ try {
480
+ unlinkSync(dst); // remove if exists from a previous partial install
481
+ }
482
+ catch {
483
+ // ignore
484
+ }
485
+ symlinkSync(link.target, dst);
486
+ }
487
+ // Audit-3 v0.17 #1: persist version + archive sha256 for TOFU on
488
+ // future sessions. Hash the actual main library file (not the
489
+ // archive) because that's what we'll re-hash on the next ensure call.
490
+ const libPath = join(targetDir, info.libName);
491
+ let libHash = null;
492
+ try {
493
+ libHash = sha256File(libPath);
494
+ }
495
+ catch (err) {
496
+ // If we can't even hash our just-installed library, skip TOFU rather
497
+ // than blocking semantic search. Future sessions will trust the path.
498
+ warn(`Could not hash newly-installed ONNX library at ${libPath}: ${err}`);
499
+ }
500
+ writeOnnxInstalledMeta(targetDir, ORT_VERSION, libHash, archiveSha256);
501
+ // Cleanup temp directory
502
+ rmSync(tmpDir, { recursive: true, force: true });
503
+ log(`ONNX Runtime v${ORT_VERSION} installed to ${targetDir}`);
504
+ return targetDir;
505
+ }
506
+ catch (err) {
507
+ error(`Failed to download ONNX Runtime: ${err}`);
508
+ // Cleanup on failure — both the staging dir and any partially populated
509
+ // target dir, so the next attempt starts from a clean slate.
510
+ try {
511
+ rmSync(tmpDir, { recursive: true, force: true });
512
+ }
513
+ catch {
514
+ // ignore cleanup errors
515
+ }
516
+ try {
517
+ rmSync(targetDir, { recursive: true, force: true });
518
+ }
519
+ catch {
520
+ // ignore
521
+ }
522
+ return null;
523
+ }
524
+ }
525
+ async function extractZipArchive(archivePath, destinationDir) {
526
+ if (process.platform === "win32") {
527
+ // Audit-2 v0.17 #12: drop PowerShell. Even via execFileSync, PowerShell
528
+ // applies its own quoting rules to `$args[N]` lookups that could allow
529
+ // attacker-controlled fragments to escape into command interpretation.
530
+ // tar.exe ships in System32 on Windows 10 build 17063+ — execFileSync
531
+ // with argv has no shell parser in the chain.
532
+ execFileSync("tar.exe", ["-xf", archivePath, "-C", destinationDir], {
533
+ stdio: "pipe",
534
+ timeout: 120_000,
535
+ });
536
+ return;
537
+ }
538
+ execFileSync("unzip", ["-q", archivePath, "-d", destinationDir], {
539
+ stdio: "pipe",
540
+ timeout: 120_000,
541
+ });
542
+ }
543
+ function writeOnnxInstalledMeta(installDir, version, sha256, archiveSha256) {
544
+ try {
545
+ const meta = {
546
+ version,
547
+ installedAt: new Date().toISOString(),
548
+ ...(sha256 ? { sha256 } : {}),
549
+ archiveSha256,
550
+ };
551
+ writeFileSync(join(installDir, ONNX_INSTALLED_META_FILE), JSON.stringify(meta), "utf8");
552
+ }
553
+ catch (err) {
554
+ log(`[onnx] failed to write installed-meta in ${installDir}: ${err}`);
555
+ }
556
+ }
557
+ function readOnnxInstalledMeta(installDir) {
558
+ const path = join(installDir, ONNX_INSTALLED_META_FILE);
559
+ try {
560
+ if (!statSync(path).isFile())
561
+ return null;
562
+ const raw = readFileSync(path, "utf8");
563
+ const parsed = JSON.parse(raw);
564
+ if (typeof parsed.version !== "string" || parsed.version.length === 0)
565
+ return null;
566
+ return {
567
+ version: parsed.version,
568
+ installedAt: typeof parsed.installedAt === "string" ? parsed.installedAt : "",
569
+ ...(typeof parsed.sha256 === "string" && parsed.sha256.length > 0
570
+ ? { sha256: parsed.sha256 }
571
+ : {}),
572
+ ...(typeof parsed.archiveSha256 === "string" && parsed.archiveSha256.length > 0
573
+ ? { archiveSha256: parsed.archiveSha256 }
574
+ : {}),
575
+ };
576
+ }
577
+ catch {
578
+ return null;
579
+ }
580
+ }
581
+ /**
582
+ * Synchronous SHA-256 of a file. ONNX libs are ~50 MB so a single
583
+ * `readFileSync` is fine — we accept the brief blocking read in exchange
584
+ * for keeping the call sites simple (no awaits inside the path-resolution
585
+ * fast path that runs every plugin start).
586
+ */
587
+ function sha256File(path) {
588
+ const hash = createHash("sha256");
589
+ hash.update(readFileSync(path));
590
+ return hash.digest("hex");
591
+ }
592
+ /* ─────────────────────────── install lock ─────────────────────────── */
593
+ /**
594
+ * Acquire a process-exclusive lock. Atomic O_EXCL create with PID-aware
595
+ * stale-lock recovery. Mirrors `acquireInstallLock` in lsp-cache.ts but
596
+ * lives here because the ONNX install dir is keyed on `storageDir`,
597
+ * not `lspPackageDir`.
598
+ */
599
+ function acquireLock(lockPath) {
600
+ const tryClaim = () => {
601
+ try {
602
+ const fd = openSync(lockPath, "wx");
603
+ try {
604
+ writeFileSync(fd, `${process.pid}\n${new Date().toISOString()}\n`);
605
+ }
606
+ finally {
607
+ closeSync(fd);
608
+ }
609
+ return true;
610
+ }
611
+ catch (err) {
612
+ const code = err.code;
613
+ if (code === "EEXIST")
614
+ return false;
615
+ warn(`[onnx] unexpected error acquiring lock ${lockPath}: ${err}`);
616
+ return false;
617
+ }
618
+ };
619
+ if (tryClaim())
620
+ return true;
621
+ let owningPid = null;
622
+ let lockMtimeMs = 0;
623
+ try {
624
+ const raw = readFileSync(lockPath, "utf8");
625
+ const firstLine = raw.split(/\r?\n/, 1)[0]?.trim() ?? "";
626
+ const parsed = Number.parseInt(firstLine, 10);
627
+ if (Number.isFinite(parsed) && parsed > 0)
628
+ owningPid = parsed;
629
+ lockMtimeMs = statSync(lockPath).mtimeMs;
630
+ }
631
+ catch {
632
+ return tryClaim();
633
+ }
634
+ const age = Date.now() - lockMtimeMs;
635
+ const ageWithinFresh = Math.abs(age) < STALE_LOCK_MS;
636
+ const skipLiveness = process.platform === "win32";
637
+ const ownerAlive = !skipLiveness && owningPid !== null && isProcessAlive(owningPid);
638
+ if (skipLiveness ? ageWithinFresh : ownerAlive && ageWithinFresh) {
639
+ return false;
640
+ }
641
+ log(`[onnx] reclaiming install lock (owner_pid=${owningPid ?? "unknown"}, alive=${ownerAlive}, age_ms=${age})`);
642
+ try {
643
+ unlinkSync(lockPath);
644
+ }
645
+ catch {
646
+ // ignore
647
+ }
648
+ return tryClaim();
649
+ }
650
+ function releaseLock(lockPath) {
651
+ // Same TOCTOU-safe release as releaseInstallLock — only unlink if our PID owns it.
652
+ try {
653
+ let owningPid = null;
654
+ try {
655
+ const raw = readFileSync(lockPath, "utf8");
656
+ const firstLine = raw.split(/\r?\n/, 1)[0]?.trim() ?? "";
657
+ const parsed = Number.parseInt(firstLine, 10);
658
+ if (Number.isFinite(parsed) && parsed > 0)
659
+ owningPid = parsed;
660
+ }
661
+ catch (readErr) {
662
+ const code = readErr.code;
663
+ if (code === "ENOENT")
664
+ return;
665
+ warn(`[onnx] could not read lock ${lockPath} during release: ${readErr}`);
666
+ return;
667
+ }
668
+ if (owningPid !== process.pid) {
669
+ log(`[onnx] not releasing lock ${lockPath}: owned by pid ${owningPid ?? "unknown"} (we are ${process.pid})`);
670
+ return;
671
+ }
672
+ try {
673
+ unlinkSync(lockPath);
674
+ }
675
+ catch (unlinkErr) {
676
+ const code = unlinkErr.code;
677
+ if (code !== "ENOENT") {
678
+ warn(`[onnx] failed to release lock ${lockPath}: ${unlinkErr}`);
679
+ }
680
+ }
681
+ }
682
+ catch (err) {
683
+ warn(`[onnx] unexpected error releasing lock ${lockPath}: ${err}`);
684
+ }
685
+ }
686
+ function isProcessAlive(pid) {
687
+ try {
688
+ process.kill(pid, 0);
689
+ return true;
690
+ }
691
+ catch (err) {
692
+ const code = err.code;
693
+ if (code === "ESRCH")
694
+ return false;
695
+ return true;
696
+ }
697
+ }
698
+ /**
699
+ * Remove ONNX Runtime from temp files. Cleanup helper for test isolation.
700
+ */
701
+ export function cleanupOnnxRuntime(storageDir) {
702
+ try {
703
+ const ortBase = join(storageDir, "onnxruntime");
704
+ if (existsSync(ortBase)) {
705
+ rmSync(ortBase, { recursive: true, force: true });
706
+ }
707
+ }
708
+ catch {
709
+ // ignore
710
+ }
711
+ }
712
+ /**
713
+ * Test-only exports. Intentionally not part of the published surface — these
714
+ * are internal helpers we want to exercise from unit tests without forcing
715
+ * an actual ONNX download. Don't use from production code.
716
+ */
717
+ export const __test__ = {
718
+ cleanupAbandonedOnnxAttempts,
719
+ ORT_VERSION,
720
+ ONNX_INSTALLED_META_FILE,
721
+ };
722
+ //# sourceMappingURL=onnx-runtime.js.map