@cortexkit/aft-bridge 0.59.0 → 0.61.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/dist/bash-format.d.ts +5 -0
- package/dist/bash-format.d.ts.map +1 -1
- package/dist/bash-format.js +12 -5
- package/dist/bash-format.js.map +1 -1
- package/dist/bash-hints.d.ts +3 -3
- package/dist/bash-hints.d.ts.map +1 -1
- package/dist/bash-hints.js +14 -13
- package/dist/bash-hints.js.map +1 -1
- package/dist/bash-host-fallback.d.ts +2 -2
- package/dist/bash-host-fallback.d.ts.map +1 -1
- package/dist/bash-host-fallback.js +2 -2
- package/dist/bash-host-fallback.js.map +1 -1
- package/dist/bridge.d.ts +87 -9
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +289 -108
- package/dist/bridge.js.map +1 -1
- package/dist/config-watch.d.ts +8 -6
- package/dist/config-watch.d.ts.map +1 -1
- package/dist/config-watch.js +17 -12
- package/dist/config-watch.js.map +1 -1
- package/dist/downloader.js +4 -3
- package/dist/downloader.js.map +1 -1
- package/dist/error-contract.d.ts +30 -2
- package/dist/error-contract.d.ts.map +1 -1
- package/dist/error-contract.js +69 -7
- package/dist/error-contract.js.map +1 -1
- package/dist/feature-config.d.ts +3 -0
- package/dist/feature-config.d.ts.map +1 -1
- package/dist/feature-config.js +124 -0
- package/dist/feature-config.js.map +1 -1
- package/dist/index.d.ts +11 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -6
- package/dist/index.js.map +1 -1
- package/dist/lsp-github-install.d.ts +104 -0
- package/dist/lsp-github-install.d.ts.map +1 -0
- package/dist/lsp-github-install.js +1455 -0
- package/dist/lsp-github-install.js.map +1 -0
- package/dist/lsp-github-probe.d.ts +81 -0
- package/dist/lsp-github-probe.d.ts.map +1 -0
- package/dist/lsp-github-probe.js +131 -0
- package/dist/lsp-github-probe.js.map +1 -0
- package/dist/lsp-github-table.d.ts +64 -0
- package/dist/lsp-github-table.d.ts.map +1 -0
- package/dist/lsp-github-table.js +174 -0
- package/dist/lsp-github-table.js.map +1 -0
- package/dist/migration.d.ts +10 -3
- package/dist/migration.d.ts.map +1 -1
- package/dist/migration.js +19 -14
- package/dist/migration.js.map +1 -1
- package/dist/onnx-runtime.d.ts +2 -0
- package/dist/onnx-runtime.d.ts.map +1 -1
- package/dist/onnx-runtime.js +12 -7
- package/dist/onnx-runtime.js.map +1 -1
- package/dist/paths.d.ts +3 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +41 -6
- package/dist/paths.js.map +1 -1
- package/dist/pool.d.ts +8 -0
- package/dist/pool.d.ts.map +1 -1
- package/dist/pool.js +31 -8
- package/dist/pool.js.map +1 -1
- package/dist/project-identity.d.ts +2 -0
- package/dist/project-identity.d.ts.map +1 -1
- package/dist/project-identity.js +8 -2
- package/dist/project-identity.js.map +1 -1
- package/dist/protocol.d.ts +3 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +14 -1
- package/dist/protocol.js.map +1 -1
- package/dist/resolver.d.ts.map +1 -1
- package/dist/resolver.js +5 -2
- package/dist/resolver.js.map +1 -1
- package/dist/revivable-transport.d.ts +16 -1
- package/dist/revivable-transport.d.ts.map +1 -1
- package/dist/revivable-transport.js +83 -11
- package/dist/revivable-transport.js.map +1 -1
- package/dist/subc-transport.d.ts +45 -4
- package/dist/subc-transport.d.ts.map +1 -1
- package/dist/subc-transport.js +659 -49
- package/dist/subc-transport.js.map +1 -1
- package/dist/tar-executable.d.ts +3 -1
- package/dist/tar-executable.d.ts.map +1 -1
- package/dist/tar-executable.js +35 -15
- package/dist/tar-executable.js.map +1 -1
- package/dist/transport-factory.d.ts.map +1 -1
- package/dist/transport-factory.js +8 -32
- package/dist/transport-factory.js.map +1 -1
- package/dist/transport.d.ts +27 -0
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +9 -0
- package/dist/transport.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,1455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub-release auto-installer for LSP servers (Pattern C).
|
|
3
|
+
*
|
|
4
|
+
* Cache layout under `<aft-cache-root>/lsp-binaries/`:
|
|
5
|
+
*
|
|
6
|
+
* <id>/
|
|
7
|
+
* bin/<binary> ← standalone binary only
|
|
8
|
+
* extracted/ ← intact distribution and runtime resources
|
|
9
|
+
* .aft-version-check ← JSON: { last_checked, latest_eligible }
|
|
10
|
+
* .aft-installing ← lockfile while a download/extract runs
|
|
11
|
+
*
|
|
12
|
+
* `lsp_paths_extra` receives the executable's real directory: `bin/` for
|
|
13
|
+
* standalone servers, or the in-tree binary directory for distributions.
|
|
14
|
+
*
|
|
15
|
+
* Extraction containment:
|
|
16
|
+
* 1. Download to `<id>/<asset-name>` with a hard size cap.
|
|
17
|
+
* 2. Extract into a staging dir `<id>/extracted.staging-<rand>/`.
|
|
18
|
+
* 3. Walk the staging tree and reject any entry that is a symlink, hardlink,
|
|
19
|
+
* or whose canonical path escapes the staging root.
|
|
20
|
+
* 4. Check the staged executable and runtime, then rename the old extraction
|
|
21
|
+
* aside and swap the staged tree in, restoring the old tree if the swap fails.
|
|
22
|
+
* 5. Stage dir is always cleaned up — success or failure.
|
|
23
|
+
*
|
|
24
|
+
* GitHub pin resolution:
|
|
25
|
+
* When `lsp.versions: { "owner/repo": "X" }` is set, we use GitHub's
|
|
26
|
+
* `/releases/tags/<tag>` endpoint directly (with `v`-prefix tolerance)
|
|
27
|
+
* instead of relying on the broader `/releases?per_page=30` probe. The
|
|
28
|
+
* probe is for "what is the latest eligible version" — not "is this
|
|
29
|
+
* specific pinned tag valid".
|
|
30
|
+
*/
|
|
31
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
32
|
+
import { closeSync, copyFileSync, createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
33
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
34
|
+
import { Readable } from "node:stream";
|
|
35
|
+
import { pipeline } from "node:stream/promises";
|
|
36
|
+
import { cachedFileSha256Sync, writeStampedFileDigest } from "./binary-identity.js";
|
|
37
|
+
import { getAftLspBinariesDir } from "./cache-paths.js";
|
|
38
|
+
import { execFile, spawn } from "./child-process.js";
|
|
39
|
+
import { assertSafeVersion, isSafeVersion, probeGithubReleases, stripTagV, } from "./lsp-github-probe.js";
|
|
40
|
+
import { detectHostPlatform, GITHUB_LSP_TABLE, } from "./lsp-github-table.js";
|
|
41
|
+
import { relativePathEscapesRoot } from "./path-display.js";
|
|
42
|
+
import { windowsTarExecutable } from "./tar-executable.js";
|
|
43
|
+
/** Bind host logging, shared-cache leases and project scanning without forking installation policy. */
|
|
44
|
+
export function createGithubInstaller(services) {
|
|
45
|
+
const { log, warn, error, readVersionCheck, writeVersionCheck, shouldRecheckVersion, withInstallLock, acquireInstallLock, hasRootMarker, relevantExtensionsInProject, } = services;
|
|
46
|
+
/* ─────────────────────────── cache layout ─────────────────────────── */
|
|
47
|
+
function ghCacheRoot() {
|
|
48
|
+
return getAftLspBinariesDir();
|
|
49
|
+
}
|
|
50
|
+
function ghPackageDir(spec) {
|
|
51
|
+
return join(ghCacheRoot(), spec.id);
|
|
52
|
+
}
|
|
53
|
+
function ghBinDir(spec) {
|
|
54
|
+
const platform = detectHostPlatform()?.platform ?? "linux";
|
|
55
|
+
return dirname(ghBinaryPath(spec, platform));
|
|
56
|
+
}
|
|
57
|
+
function ghExtractDir(spec) {
|
|
58
|
+
return join(ghPackageDir(spec), "extracted");
|
|
59
|
+
}
|
|
60
|
+
const INSTALLED_META_FILE = ".aft-installed";
|
|
61
|
+
const INSTALL_FAILED_FILE = ".aft-install-failed.json";
|
|
62
|
+
const INSTALL_FAILURE_BACKOFF_MS = 6 * 60 * 60 * 1000;
|
|
63
|
+
// Downloads and extraction can take several minutes, but a background refresh
|
|
64
|
+
// should not wait forever for a lock another process may have abandoned.
|
|
65
|
+
const INSTALL_LOCK_WAIT_MS = 10 * 60 * 1000;
|
|
66
|
+
const INSTALL_LOCK_POLL_INTERVAL_MS = 1_000;
|
|
67
|
+
const INSTALL_LOCK_WAIT_TIMEOUT_REASON = "install lock held by another process for 10 minutes";
|
|
68
|
+
function installFailureMarkerPath(spec) {
|
|
69
|
+
return join(ghPackageDir(spec), INSTALL_FAILED_FILE);
|
|
70
|
+
}
|
|
71
|
+
function readInstallFailureMarker(spec) {
|
|
72
|
+
try {
|
|
73
|
+
const marker = JSON.parse(readFileSync(installFailureMarkerPath(spec), "utf8"));
|
|
74
|
+
if (typeof marker.release !== "string" ||
|
|
75
|
+
typeof marker.sha256 !== "string" ||
|
|
76
|
+
typeof marker.error !== "string" ||
|
|
77
|
+
typeof marker.at !== "string" ||
|
|
78
|
+
!Number.isFinite(Date.parse(marker.at))) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return marker;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function writeInstallFailureMarker(spec, release, sha256, installError) {
|
|
88
|
+
const path = installFailureMarkerPath(spec);
|
|
89
|
+
const markerError = installError.replace(/\s+/g, " ").trim();
|
|
90
|
+
const tempPath = `${path}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
93
|
+
writeFileSync(tempPath, `${JSON.stringify({ release, sha256, error: markerError, at: new Date().toISOString() })}\n`, { encoding: "utf8", mode: 0o600 });
|
|
94
|
+
renameSync(tempPath, path);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
try {
|
|
98
|
+
rmSync(tempPath, { force: true });
|
|
99
|
+
}
|
|
100
|
+
catch { }
|
|
101
|
+
warn(`[lsp] could not write install failure marker ${path}: ${err}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function clearInstallFailureMarker(spec) {
|
|
105
|
+
try {
|
|
106
|
+
rmSync(installFailureMarkerPath(spec), { force: true });
|
|
107
|
+
}
|
|
108
|
+
catch { }
|
|
109
|
+
}
|
|
110
|
+
function sameRelease(left, right) {
|
|
111
|
+
return stripTagV(left) === stripTagV(right);
|
|
112
|
+
}
|
|
113
|
+
function isFreshInstallFailure(marker) {
|
|
114
|
+
return Math.abs(Date.now() - Date.parse(marker.at)) < INSTALL_FAILURE_BACKOFF_MS;
|
|
115
|
+
}
|
|
116
|
+
/** Final binary path under our cache. */
|
|
117
|
+
function ghBinaryPath(spec, platform) {
|
|
118
|
+
const meta = readGithubInstalledMetaIn(ghPackageDir(spec));
|
|
119
|
+
if (spec.layout === "distribution" &&
|
|
120
|
+
meta?.layout === "distribution" &&
|
|
121
|
+
isSafeVersion(meta.version)) {
|
|
122
|
+
const arch = detectHostPlatform()?.arch ?? "x64";
|
|
123
|
+
return join(ghExtractDir(spec), spec.binaryPathInArchive(platform, arch, stripTagV(meta.version)));
|
|
124
|
+
}
|
|
125
|
+
const ext = platform === "win32" ? ".exe" : "";
|
|
126
|
+
return join(ghPackageDir(spec), "bin", `${spec.binary}${ext}`);
|
|
127
|
+
}
|
|
128
|
+
function isGithubInstalled(spec, platform) {
|
|
129
|
+
for (const candidate of ghBinaryCandidates(spec, platform)) {
|
|
130
|
+
try {
|
|
131
|
+
if (statSync(join(ghBinDir(spec), candidate)).isFile())
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Try the next Windows shim extension.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
function ghBinaryCandidates(spec, platform) {
|
|
141
|
+
if (platform !== "win32")
|
|
142
|
+
return [spec.binary];
|
|
143
|
+
return [spec.binary, `${spec.binary}.cmd`, `${spec.binary}.exe`, `${spec.binary}.bat`];
|
|
144
|
+
}
|
|
145
|
+
function readGithubInstalledMetaIn(installDir) {
|
|
146
|
+
try {
|
|
147
|
+
const path = join(installDir, INSTALLED_META_FILE);
|
|
148
|
+
if (!statSync(path).isFile())
|
|
149
|
+
return null;
|
|
150
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
151
|
+
if (typeof parsed.version !== "string" || parsed.version.length === 0)
|
|
152
|
+
return null;
|
|
153
|
+
return {
|
|
154
|
+
version: parsed.version,
|
|
155
|
+
installedAt: typeof parsed.installedAt === "string" ? parsed.installedAt : "",
|
|
156
|
+
...(typeof parsed.sha256 === "string" && parsed.sha256.length > 0
|
|
157
|
+
? { sha256: parsed.sha256 }
|
|
158
|
+
: {}),
|
|
159
|
+
...(typeof parsed.binarySha256 === "string" && parsed.binarySha256.length > 0
|
|
160
|
+
? { binarySha256: parsed.binarySha256 }
|
|
161
|
+
: {}),
|
|
162
|
+
...(typeof parsed.smoke?.binarySha256 === "string" &&
|
|
163
|
+
(parsed.smoke.verdict === "passed" || parsed.smoke.verdict === "timeout")
|
|
164
|
+
? { smoke: parsed.smoke }
|
|
165
|
+
: {}),
|
|
166
|
+
...(parsed.layout === "distribution" || parsed.layout === "standalone"
|
|
167
|
+
? { layout: parsed.layout }
|
|
168
|
+
: {}),
|
|
169
|
+
...(typeof parsed.archiveSha256 === "string" && parsed.archiveSha256.length > 0
|
|
170
|
+
? { archiveSha256: parsed.archiveSha256 }
|
|
171
|
+
: {}),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function writeGithubInstalledMetaIn(installDir, version, binarySha256, archiveSha256, layout, smoke) {
|
|
179
|
+
try {
|
|
180
|
+
mkdirSync(installDir, { recursive: true });
|
|
181
|
+
const meta = {
|
|
182
|
+
version,
|
|
183
|
+
installedAt: new Date().toISOString(),
|
|
184
|
+
sha256: binarySha256,
|
|
185
|
+
binarySha256,
|
|
186
|
+
...(archiveSha256 ? { archiveSha256 } : {}),
|
|
187
|
+
...(layout ? { layout } : {}),
|
|
188
|
+
...(smoke ? { smoke } : {}),
|
|
189
|
+
};
|
|
190
|
+
writeFileSync(join(installDir, INSTALLED_META_FILE), JSON.stringify(meta), "utf8");
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
warn(`[lsp] failed to write github installed metadata in ${installDir}: ${err}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/* ─────────────────────────── per-call config ─────────────────────────── */
|
|
197
|
+
/* ─────────────────────────── safety constants ─────────────────────────── */
|
|
198
|
+
/**
|
|
199
|
+
* Hard cap on a single LSP-binary download (GitHub downloads are otherwise
|
|
200
|
+
* unbounded).
|
|
201
|
+
*
|
|
202
|
+
* 256 MB. The largest currently-shipped LSP we install is `clangd` at
|
|
203
|
+
* around 60 MB extracted (compressed less). The next-largest is
|
|
204
|
+
* `lua-language-server` at ~20 MB. Pinning at 256 MB leaves a 4× headroom
|
|
205
|
+
* for future-proofing while still aborting absurd payloads (TB-sized
|
|
206
|
+
* malicious responses, DoS via slow drip).
|
|
207
|
+
*
|
|
208
|
+
* If a legitimate release ever exceeds this, we'll bump it deliberately
|
|
209
|
+
* and the version-pin escape hatch lets users opt back in via `lsp.versions`.
|
|
210
|
+
*/
|
|
211
|
+
const MAX_DOWNLOAD_BYTES = 256 * 1024 * 1024;
|
|
212
|
+
/**
|
|
213
|
+
* Maximum total uncompressed size for the extracted contents of one LSP
|
|
214
|
+
* archive (1 GiB, ~4× the download cap to allow for typical compression).
|
|
215
|
+
*
|
|
216
|
+
* Even with `MAX_DOWNLOAD_BYTES` capping the compressed
|
|
217
|
+
* payload, modern compressors can achieve very high ratios on uniform
|
|
218
|
+
* data — a 256 MB ZIP can decompress to dozens of GBs of zeros and fill
|
|
219
|
+
* the user's disk before any individual file looks suspicious. This cap
|
|
220
|
+
* forces us to fail closed if extracted bytes ever exceed a reasonable
|
|
221
|
+
* upper bound for an LSP server bundle.
|
|
222
|
+
*
|
|
223
|
+
* Real LSP archives extract to far less than 1 GB — clangd ~150 MB,
|
|
224
|
+
* lua-language-server ~80 MB, zls ~30 MB. 1 GB leaves headroom for
|
|
225
|
+
* future bundles while making decompression bombs detectable.
|
|
226
|
+
*/
|
|
227
|
+
const MAX_EXTRACT_BYTES = 1024 * 1024 * 1024;
|
|
228
|
+
/**
|
|
229
|
+
* Compute the SHA-256 of `path` by streaming.
|
|
230
|
+
*
|
|
231
|
+
* Enables always-log of every downloaded archive's hash
|
|
232
|
+
* (forensics) plus TOFU verification when the same tag is reinstalled.
|
|
233
|
+
* Streaming avoids loading the (potentially 200 MB) archive into memory.
|
|
234
|
+
*/
|
|
235
|
+
function sha256OfFile(path) {
|
|
236
|
+
return new Promise((resolve, reject) => {
|
|
237
|
+
const hash = createHash("sha256");
|
|
238
|
+
const stream = createReadStream(path);
|
|
239
|
+
stream.on("error", reject);
|
|
240
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
241
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Hash a file synchronously in 1 MiB slices. Cached LSP binaries are tens to
|
|
246
|
+
* hundreds of MB; reading one whole into a single buffer costs that much memory
|
|
247
|
+
* at once.
|
|
248
|
+
*/
|
|
249
|
+
function sha256OfFileSync(path) {
|
|
250
|
+
const hash = createHash("sha256");
|
|
251
|
+
const fd = openSync(path, "r");
|
|
252
|
+
try {
|
|
253
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
254
|
+
let read = readSync(fd, buffer, 0, buffer.length, null);
|
|
255
|
+
while (read > 0) {
|
|
256
|
+
hash.update(buffer.subarray(0, read));
|
|
257
|
+
read = readSync(fd, buffer, 0, buffer.length, null);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
closeSync(fd);
|
|
262
|
+
}
|
|
263
|
+
return hash.digest("hex");
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Digests of cached binaries this process already hashed, keyed by path and
|
|
267
|
+
* guarded by the file's identity (device, inode, size, mtime, ctime).
|
|
268
|
+
*
|
|
269
|
+
* Startup validates every cached binary, and the refresh after background
|
|
270
|
+
* installs settle validates them all again. Without this memo each check read
|
|
271
|
+
* and hashed the whole binary once more. Any write, replacement or chmod
|
|
272
|
+
* changes the identity (ctime moves on every one of them), so a binary that
|
|
273
|
+
* changed since it was hashed is always hashed again.
|
|
274
|
+
*/
|
|
275
|
+
const binaryDigestMemo = new Map();
|
|
276
|
+
let binaryHashCount = 0;
|
|
277
|
+
function fileIdentity(path) {
|
|
278
|
+
const info = statSync(path, { bigint: true });
|
|
279
|
+
return `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
|
|
280
|
+
}
|
|
281
|
+
function cachedBinarySha256(path) {
|
|
282
|
+
// Take the identity before reading so a write during hashing leaves a stale
|
|
283
|
+
// identity behind, which forces the next check to hash again.
|
|
284
|
+
const identity = fileIdentity(path);
|
|
285
|
+
const known = binaryDigestMemo.get(path);
|
|
286
|
+
if (known && known.identity === identity)
|
|
287
|
+
return known.digest;
|
|
288
|
+
const digest = cachedFileSha256Sync(path, () => {
|
|
289
|
+
binaryHashCount += 1;
|
|
290
|
+
return sha256OfFileSync(path);
|
|
291
|
+
});
|
|
292
|
+
binaryDigestMemo.set(path, { identity, digest });
|
|
293
|
+
return digest;
|
|
294
|
+
}
|
|
295
|
+
/** Test hook: how many cached-binary hashes this process has computed. */
|
|
296
|
+
function __githubBinaryHashCountForTests() {
|
|
297
|
+
return binaryHashCount;
|
|
298
|
+
}
|
|
299
|
+
/* ─────────────────────────── pin resolution ─────────────────────────── */
|
|
300
|
+
/**
|
|
301
|
+
* Fetch a single release by tag from GitHub's `/releases/tags/<tag>` endpoint.
|
|
302
|
+
*
|
|
303
|
+
* Critical for `lsp.versions` pin support: the broader release-list probe
|
|
304
|
+
* only sees the most recent ~30 releases, so any older pin (clangd 18, an
|
|
305
|
+
* old terraform, etc.) returns no assets and the install path silently
|
|
306
|
+
* fails. The release-by-tag endpoint accepts arbitrary tag strings and
|
|
307
|
+
* returns full asset metadata.
|
|
308
|
+
*
|
|
309
|
+
* We try the user's pin verbatim first, then with a `v` prefix added if
|
|
310
|
+
* missing. GitHub release tags are inconsistent (clangd uses `21.1.0`,
|
|
311
|
+
* lua-language-server uses `3.15.0`, zls uses `0.16.0`, terraform-ls uses
|
|
312
|
+
* `v0.36.5`). User pins should not need to know each project's convention.
|
|
313
|
+
*
|
|
314
|
+
* Returns null on probe failure so the caller can fall back to keeping
|
|
315
|
+
* any existing install.
|
|
316
|
+
*/
|
|
317
|
+
async function fetchReleaseByTag(githubRepo, tag, fetchImpl, signal) {
|
|
318
|
+
const candidates = [];
|
|
319
|
+
candidates.push(tag);
|
|
320
|
+
if (!tag.startsWith("v")) {
|
|
321
|
+
candidates.push(`v${tag}`);
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
candidates.push(tag.slice(1));
|
|
325
|
+
}
|
|
326
|
+
const headers = {
|
|
327
|
+
accept: "application/vnd.github+json",
|
|
328
|
+
"user-agent": "aft-bridge",
|
|
329
|
+
"x-github-api-version": "2022-11-28",
|
|
330
|
+
};
|
|
331
|
+
if (process.env.GITHUB_TOKEN) {
|
|
332
|
+
headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
|
333
|
+
}
|
|
334
|
+
for (const candidate of candidates) {
|
|
335
|
+
const url = `https://api.github.com/repos/${githubRepo}/releases/tags/${encodeURIComponent(candidate)}`;
|
|
336
|
+
const timeout = controlledTimeoutSignal(15_000, signal);
|
|
337
|
+
try {
|
|
338
|
+
const res = await fetchJsonFollowingRedirects(url, headers, fetchImpl, timeout.signal);
|
|
339
|
+
if (res.status === 404)
|
|
340
|
+
continue; // try next candidate
|
|
341
|
+
if (!res.ok) {
|
|
342
|
+
warn(`[lsp] github release-by-tag ${githubRepo}@${candidate}: HTTP ${res.status}`);
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
const json = (await res.json());
|
|
346
|
+
if (!json.tag_name || !Array.isArray(json.assets)) {
|
|
347
|
+
warn(`[lsp] github release-by-tag ${githubRepo}@${candidate}: malformed response`);
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
const assets = json.assets
|
|
351
|
+
.filter((a) => typeof a.name === "string" && typeof a.browser_download_url === "string")
|
|
352
|
+
.map((a) => ({
|
|
353
|
+
name: a.name,
|
|
354
|
+
url: a.browser_download_url,
|
|
355
|
+
size: typeof a.size === "number" ? a.size : undefined,
|
|
356
|
+
}));
|
|
357
|
+
return { tag: json.tag_name, assets };
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
if (signal?.aborted) {
|
|
361
|
+
warn(`[lsp] github release-by-tag ${githubRepo}@${candidate}: aborted`);
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
warn(`[lsp] github release-by-tag ${githubRepo}@${candidate}: ${err}`);
|
|
365
|
+
// try next candidate
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
timeout.cleanup();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
async function fetchJsonFollowingRedirects(url, headers, fetchImpl, signal) {
|
|
374
|
+
const maxRedirects = 5;
|
|
375
|
+
let currentUrl = url;
|
|
376
|
+
for (let i = 0; i <= maxRedirects; i += 1) {
|
|
377
|
+
assertAllowedDownloadUrl(currentUrl);
|
|
378
|
+
const res = await fetchImpl(currentUrl, { headers, redirect: "manual", signal });
|
|
379
|
+
if (res.status >= 300 && res.status < 400) {
|
|
380
|
+
const location = res.headers.get("location");
|
|
381
|
+
if (!location)
|
|
382
|
+
throw new Error(`redirect status ${res.status} without Location`);
|
|
383
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
return res;
|
|
387
|
+
}
|
|
388
|
+
throw new Error(`too many redirects (>${maxRedirects})`);
|
|
389
|
+
}
|
|
390
|
+
/* ─────────────────────────── orchestrator ─────────────────────────── */
|
|
391
|
+
/**
|
|
392
|
+
* Resolve which GitHub release tag to install, honoring user pins,
|
|
393
|
+
* cached version checks, and the 7-day grace window.
|
|
394
|
+
*/
|
|
395
|
+
async function resolveTargetTag(spec, config, fetchImpl, signal) {
|
|
396
|
+
// 1. User pin via `lsp.versions: { "clangd/clangd": "21.1.0" }`.
|
|
397
|
+
//
|
|
398
|
+
// Use GitHub's release-by-tag endpoint directly. The
|
|
399
|
+
// previous code tried to find the pin in the latest-releases probe
|
|
400
|
+
// and returned `assets: []` if it wasn't there, silently breaking the
|
|
401
|
+
// documented escape hatch for any pin older than the latest 30 releases.
|
|
402
|
+
const pinned = config.versions[spec.githubRepo];
|
|
403
|
+
if (pinned) {
|
|
404
|
+
// Even though `lsp.versions` is user-only config,
|
|
405
|
+
// validate the pin defense-in-depth before it flows into URL paths and
|
|
406
|
+
// (eventually, via the probed tag) filesystem paths and `tar -xf`.
|
|
407
|
+
try {
|
|
408
|
+
assertSafeVersion(pinned);
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
return {
|
|
412
|
+
tag: null,
|
|
413
|
+
assets: [],
|
|
414
|
+
blockedByGrace: false,
|
|
415
|
+
reason: `invalid pinned version ${JSON.stringify(pinned)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
const release = await fetchReleaseByTag(spec.githubRepo, pinned, fetchImpl, signal);
|
|
419
|
+
if (release) {
|
|
420
|
+
return {
|
|
421
|
+
tag: release.tag,
|
|
422
|
+
assets: release.assets,
|
|
423
|
+
blockedByGrace: false,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
tag: null,
|
|
428
|
+
assets: [],
|
|
429
|
+
blockedByGrace: false,
|
|
430
|
+
reason: `pinned tag ${pinned} not found on GitHub`,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
// A fresh version check selects the cached tag without probing the release
|
|
434
|
+
// list again. Fetch that tag's assets directly; if unavailable, retry the
|
|
435
|
+
// live release list. Unsafe disk values are cache misses, never URL paths.
|
|
436
|
+
const cached = readVersionCheck(spec.githubRepo);
|
|
437
|
+
const weeklyMs = config.graceDays * 24 * 60 * 60 * 1000;
|
|
438
|
+
const cachedTag = cached?.latest_eligible ?? null;
|
|
439
|
+
const cachedSafe = isSafeVersion(cachedTag);
|
|
440
|
+
if (cached && !shouldRecheckVersion(cached, weeklyMs) && cachedSafe) {
|
|
441
|
+
// Installations still need the selected tag's asset URLs.
|
|
442
|
+
const release = await fetchReleaseByTag(spec.githubRepo, cachedTag, fetchImpl);
|
|
443
|
+
if (release) {
|
|
444
|
+
return {
|
|
445
|
+
tag: release.tag,
|
|
446
|
+
assets: release.assets,
|
|
447
|
+
blockedByGrace: false,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
// Cached tag now unreachable — fall through to live probe.
|
|
451
|
+
}
|
|
452
|
+
// 3. Live probe.
|
|
453
|
+
const probe = await probeGithubReleases(spec.githubRepo, config.graceDays, fetchImpl, warn);
|
|
454
|
+
if (!probe) {
|
|
455
|
+
// Probe failed. Return tag:null instead of
|
|
456
|
+
// `cached.latest_eligible` with empty assets. Returning a non-null
|
|
457
|
+
// tag with empty assets makes the caller try (and fail) to install,
|
|
458
|
+
// emitting misleading "asset not found" errors. Returning null lets
|
|
459
|
+
// the caller skip cleanly with a "github releases probe failed" reason.
|
|
460
|
+
return {
|
|
461
|
+
tag: null,
|
|
462
|
+
assets: [],
|
|
463
|
+
blockedByGrace: false,
|
|
464
|
+
reason: "github releases probe failed",
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
writeVersionCheck(spec.githubRepo, probe.tag);
|
|
468
|
+
return { tag: probe.tag, assets: probe.assets, blockedByGrace: probe.blockedByGrace };
|
|
469
|
+
}
|
|
470
|
+
/* ─────────────────────────── download ─────────────────────────── */
|
|
471
|
+
function controlledTimeoutSignal(timeoutMs, parent) {
|
|
472
|
+
const controller = new AbortController();
|
|
473
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
474
|
+
timeout.unref?.();
|
|
475
|
+
const abort = () => controller.abort();
|
|
476
|
+
parent?.addEventListener("abort", abort, { once: true });
|
|
477
|
+
if (parent?.aborted)
|
|
478
|
+
abort();
|
|
479
|
+
return {
|
|
480
|
+
signal: controller.signal,
|
|
481
|
+
cleanup: () => {
|
|
482
|
+
clearTimeout(timeout);
|
|
483
|
+
parent?.removeEventListener("abort", abort);
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Stream a remote URL to disk with a hard byte cap.
|
|
489
|
+
*
|
|
490
|
+
* Previously the download was unbounded, so a malicious or
|
|
491
|
+
* misconfigured release could fill the user's disk and stall plugin
|
|
492
|
+
* startup forever. We now:
|
|
493
|
+
* - Reject if `Content-Length` (when present) exceeds the cap.
|
|
494
|
+
* - Track streamed bytes during the pipeline and abort if the cap is
|
|
495
|
+
* exceeded mid-download. This handles the case where the server lies
|
|
496
|
+
* about Content-Length, omits it (chunked transfer), or sends more
|
|
497
|
+
* bytes than advertised.
|
|
498
|
+
*
|
|
499
|
+
* `assetSize` is the size hint from GitHub's release JSON. When provided
|
|
500
|
+
* we sanity-check it against the cap before even starting the download.
|
|
501
|
+
*/
|
|
502
|
+
/**
|
|
503
|
+
* Hosts we will download GitHub release artifacts from. The GitHub API
|
|
504
|
+
* returns `browser_download_url` values that almost always live on
|
|
505
|
+
* `github.com` (the canonical URL), but the actual blob is served from
|
|
506
|
+
* `objects.githubusercontent.com` after redirect. We allow both.
|
|
507
|
+
*
|
|
508
|
+
* Without this allowlist, a compromised or malicious
|
|
509
|
+
* GitHub API response could swap `browser_download_url` to an attacker-
|
|
510
|
+
* controlled host (e.g. `evil.example/payload.zip`) and we would happily
|
|
511
|
+
* follow the redirect chain. The download size cap and SHA-256 TOFU help
|
|
512
|
+
* but cannot stop the very first install on a tag we have never seen.
|
|
513
|
+
*
|
|
514
|
+
* We handle redirects manually so every hop is checked before any bytes are
|
|
515
|
+
* accepted. A compromised release URL may start on github.com and redirect to
|
|
516
|
+
* an attacker-controlled host; `redirect: "follow"` would hide that hop.
|
|
517
|
+
*/
|
|
518
|
+
const ALLOWED_DOWNLOAD_HOSTS = new Set([
|
|
519
|
+
"github.com",
|
|
520
|
+
"api.github.com",
|
|
521
|
+
"objects.githubusercontent.com",
|
|
522
|
+
"release-assets.githubusercontent.com",
|
|
523
|
+
"raw.githubusercontent.com",
|
|
524
|
+
"codeload.github.com",
|
|
525
|
+
]);
|
|
526
|
+
function assertAllowedDownloadUrl(rawUrl) {
|
|
527
|
+
let parsed;
|
|
528
|
+
try {
|
|
529
|
+
parsed = new URL(rawUrl);
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
throw new Error(`download url is not a valid URL: ${rawUrl}`);
|
|
533
|
+
}
|
|
534
|
+
if (parsed.protocol !== "https:") {
|
|
535
|
+
throw new Error(`download url must be https (got ${parsed.protocol}): ${rawUrl}`);
|
|
536
|
+
}
|
|
537
|
+
if (!ALLOWED_DOWNLOAD_HOSTS.has(parsed.hostname.toLowerCase())) {
|
|
538
|
+
throw new Error(`download url host ${parsed.hostname} is not in the GitHub allowlist: ${rawUrl}`);
|
|
539
|
+
}
|
|
540
|
+
return parsed;
|
|
541
|
+
}
|
|
542
|
+
async function downloadFile(url, destPath, fetchImpl, assetSize, signal) {
|
|
543
|
+
if (assetSize !== undefined && assetSize > MAX_DOWNLOAD_BYTES) {
|
|
544
|
+
throw new Error(`asset size ${assetSize} exceeds max ${MAX_DOWNLOAD_BYTES} (set lsp.versions to pin a smaller release if this is wrong)`);
|
|
545
|
+
}
|
|
546
|
+
const timeout = controlledTimeoutSignal(120_000, signal);
|
|
547
|
+
try {
|
|
548
|
+
const res = await fetchFollowingRedirects(url, fetchImpl, timeout.signal);
|
|
549
|
+
if (!res.ok || !res.body) {
|
|
550
|
+
throw new Error(`download failed (${res.status})`);
|
|
551
|
+
}
|
|
552
|
+
const advertised = Number.parseInt(res.headers.get("content-length") ?? "", 10);
|
|
553
|
+
if (Number.isFinite(advertised) && advertised > MAX_DOWNLOAD_BYTES) {
|
|
554
|
+
throw new Error(`Content-Length ${advertised} exceeds max ${MAX_DOWNLOAD_BYTES}`);
|
|
555
|
+
}
|
|
556
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
557
|
+
// Streaming size guard. Wrap the response body in a transformer that
|
|
558
|
+
// counts bytes and aborts on overflow. If we don't do this we'll happily
|
|
559
|
+
// stream a 100 GB malicious response straight to disk.
|
|
560
|
+
let bytesWritten = 0;
|
|
561
|
+
const guard = new TransformStream({
|
|
562
|
+
transform(chunk, controller) {
|
|
563
|
+
bytesWritten += chunk.byteLength;
|
|
564
|
+
if (bytesWritten > MAX_DOWNLOAD_BYTES) {
|
|
565
|
+
controller.error(new Error(`download exceeded ${MAX_DOWNLOAD_BYTES} bytes after streaming (server lied about size or sent unbounded body)`));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
controller.enqueue(chunk);
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
const guarded = res.body.pipeThrough(guard);
|
|
572
|
+
// biome-ignore lint/suspicious/noExplicitAny: ReadableStream→Node stream conversion
|
|
573
|
+
const nodeStream = Readable.fromWeb(guarded);
|
|
574
|
+
await pipeline(nodeStream, createWriteStream(destPath), { signal: timeout.signal });
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
// Always clean up the partial file on any download failure.
|
|
578
|
+
try {
|
|
579
|
+
unlinkSync(destPath);
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
// ignore
|
|
583
|
+
}
|
|
584
|
+
throw err;
|
|
585
|
+
}
|
|
586
|
+
finally {
|
|
587
|
+
timeout.cleanup();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
async function fetchFollowingRedirects(url, fetchImpl, signal) {
|
|
591
|
+
const maxRedirects = 5;
|
|
592
|
+
let currentUrl = url;
|
|
593
|
+
for (let i = 0; i <= maxRedirects; i += 1) {
|
|
594
|
+
assertAllowedDownloadUrl(currentUrl);
|
|
595
|
+
const res = await fetchImpl(currentUrl, {
|
|
596
|
+
headers: { accept: "application/octet-stream" },
|
|
597
|
+
redirect: "manual",
|
|
598
|
+
signal,
|
|
599
|
+
});
|
|
600
|
+
if (res.status >= 300 && res.status < 400) {
|
|
601
|
+
const location = res.headers.get("location");
|
|
602
|
+
if (!location)
|
|
603
|
+
throw new Error(`redirect status ${res.status} without Location`);
|
|
604
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
return res;
|
|
608
|
+
}
|
|
609
|
+
throw new Error(`too many redirects (>${maxRedirects})`);
|
|
610
|
+
}
|
|
611
|
+
/* ─────────────────────────── extraction ─────────────────────────── */
|
|
612
|
+
/**
|
|
613
|
+
* Recursively validate that every entry under `stagingRoot` is contained
|
|
614
|
+
* within it (zip-slip + symlink containment).
|
|
615
|
+
*
|
|
616
|
+
* Rejects:
|
|
617
|
+
* - Any symlink (regardless of where it points). Symlinks in LSP
|
|
618
|
+
* installs are extremely rare and would be a red flag.
|
|
619
|
+
* - Any hardlink that has multiple inodes pointing at the same file
|
|
620
|
+
* OUTSIDE the staging root (we can't easily detect this for hardlinks
|
|
621
|
+
* to other paths inside the same archive, but those aren't a containment
|
|
622
|
+
* escape).
|
|
623
|
+
* - Any entry whose `realpath()` resolves outside `stagingRoot`. This
|
|
624
|
+
* catches what the platform extractor missed if it failed to defend
|
|
625
|
+
* against `..` traversal.
|
|
626
|
+
*
|
|
627
|
+
* Throws on any violation. The caller cleans up the staging dir.
|
|
628
|
+
*/
|
|
629
|
+
function validateExtraction(stagingRoot) {
|
|
630
|
+
const realStagingRoot = realpathSync(stagingRoot);
|
|
631
|
+
let totalBytes = 0;
|
|
632
|
+
const walk = (dir) => {
|
|
633
|
+
let entries;
|
|
634
|
+
try {
|
|
635
|
+
entries = readdirSync(dir);
|
|
636
|
+
}
|
|
637
|
+
catch (err) {
|
|
638
|
+
throw new Error(`failed to read staging dir ${dir}: ${err}`);
|
|
639
|
+
}
|
|
640
|
+
for (const entry of entries) {
|
|
641
|
+
const full = join(dir, entry);
|
|
642
|
+
// lstatSync does NOT follow symlinks, so we can detect them.
|
|
643
|
+
let lst;
|
|
644
|
+
try {
|
|
645
|
+
lst = lstatSync(full);
|
|
646
|
+
}
|
|
647
|
+
catch (err) {
|
|
648
|
+
throw new Error(`failed to lstat ${full}: ${err}`);
|
|
649
|
+
}
|
|
650
|
+
if (lst.isSymbolicLink()) {
|
|
651
|
+
let target = "<unreadable>";
|
|
652
|
+
try {
|
|
653
|
+
target = readlinkSync(full);
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
// ignore — we're rejecting either way
|
|
657
|
+
}
|
|
658
|
+
throw new Error(`archive contains symlink ${relative(realStagingRoot, full)} → ${target}; rejecting (zip-slip defense)`);
|
|
659
|
+
}
|
|
660
|
+
// Verify the entry's REAL path stays under the staging root. This
|
|
661
|
+
// is the canonical zip-slip check — if the platform extractor wrote
|
|
662
|
+
// a file with `..` components in its path, realpath will resolve it
|
|
663
|
+
// to wherever it actually landed.
|
|
664
|
+
let realFull;
|
|
665
|
+
try {
|
|
666
|
+
realFull = realpathSync(full);
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
throw new Error(`failed to realpath ${full}: ${err}`);
|
|
670
|
+
}
|
|
671
|
+
const rel = relative(realStagingRoot, realFull);
|
|
672
|
+
if (relativePathEscapesRoot(rel) || resolve(realStagingRoot, rel) !== realFull) {
|
|
673
|
+
throw new Error(`archive entry escapes staging root: ${full} → ${realFull} (zip-slip defense)`);
|
|
674
|
+
}
|
|
675
|
+
if (lst.isDirectory()) {
|
|
676
|
+
walk(full);
|
|
677
|
+
}
|
|
678
|
+
else if (lst.isFile()) {
|
|
679
|
+
// Accumulate uncompressed sizes and abort early if
|
|
680
|
+
// we cross the cap. Walking the whole tree first would let a
|
|
681
|
+
// decompression bomb fill the disk before we noticed.
|
|
682
|
+
totalBytes += lst.size;
|
|
683
|
+
if (totalBytes > MAX_EXTRACT_BYTES) {
|
|
684
|
+
throw new Error(`extracted archive exceeds ${MAX_EXTRACT_BYTES} bytes (decompression bomb defense): saw ${totalBytes} bytes before hitting the cap`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
// Sockets, FIFOs, character devices — none of these belong in an
|
|
689
|
+
// LSP release. Reject defensively.
|
|
690
|
+
throw new Error(`archive contains non-file/non-dir entry: ${full}`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
walk(realStagingRoot);
|
|
695
|
+
}
|
|
696
|
+
function assertArchiveEntryPath(entry) {
|
|
697
|
+
const normalized = entry.replaceAll("\\", "/");
|
|
698
|
+
if (normalized.startsWith("/") ||
|
|
699
|
+
/^[A-Za-z]:\//.test(normalized) ||
|
|
700
|
+
normalized.split("/").includes("..")) {
|
|
701
|
+
throw new Error(`archive entry escapes archive root: ${entry}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
const ARCHIVE_LIST_TIMEOUT_MS = 30_000;
|
|
705
|
+
/**
|
|
706
|
+
* Run an archive tool without blocking the host's event loop. Listing and
|
|
707
|
+
* extracting a large LSP archive can take minutes (the extract timeout is
|
|
708
|
+
* 180 s); a synchronous call froze every other plugin hook for that long.
|
|
709
|
+
*/
|
|
710
|
+
function runArchiveTool(command, args, timeout) {
|
|
711
|
+
return new Promise((resolvePromise, reject) => {
|
|
712
|
+
let child;
|
|
713
|
+
// execFile's callback waits for pipe EOF, even after the direct child exits.
|
|
714
|
+
// An inherited descriptor must not keep the install promise alive forever.
|
|
715
|
+
const deadline = setTimeout(() => {
|
|
716
|
+
reject(new Error(`${command} ${args[0]} timed out after ${timeout} ms`));
|
|
717
|
+
child?.kill("SIGKILL");
|
|
718
|
+
child?.stdin?.destroy();
|
|
719
|
+
child?.stdout?.destroy();
|
|
720
|
+
child?.stderr?.destroy();
|
|
721
|
+
}, timeout);
|
|
722
|
+
try {
|
|
723
|
+
child = execFile(command, args, { encoding: "utf8", timeout, killSignal: "SIGKILL" }, (err, stdout) => {
|
|
724
|
+
clearTimeout(deadline);
|
|
725
|
+
if (err)
|
|
726
|
+
reject(err);
|
|
727
|
+
else
|
|
728
|
+
resolvePromise(stdout);
|
|
729
|
+
});
|
|
730
|
+
// Archive tools use explicit file arguments; none should wait for input.
|
|
731
|
+
child.stdin?.end();
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
clearTimeout(deadline);
|
|
735
|
+
reject(error);
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
/** Run the platform tar extractor and name the resolved executable in failures. */
|
|
740
|
+
async function runTarExtraction(args, timeout) {
|
|
741
|
+
const executable = windowsTarExecutable();
|
|
742
|
+
try {
|
|
743
|
+
await runArchiveTool(executable, args, timeout);
|
|
744
|
+
}
|
|
745
|
+
catch (cause) {
|
|
746
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
747
|
+
throw new Error(`tar extraction failed using ${executable}: ${detail}`, { cause });
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
async function precheckWithTar(archivePath, command) {
|
|
751
|
+
let totalBytes = 0;
|
|
752
|
+
const verbose = await runArchiveTool(command, ["-tvf", archivePath], ARCHIVE_LIST_TIMEOUT_MS);
|
|
753
|
+
for (const line of verbose.split("\n")) {
|
|
754
|
+
// Both bsdtar and GNU tar render hardlinks with a leading `h` type marker.
|
|
755
|
+
// Reject them before extraction because the extracted inode cannot reveal
|
|
756
|
+
// the archive link target during the post-extraction containment walk.
|
|
757
|
+
if (line.startsWith("h")) {
|
|
758
|
+
throw new Error(`archive contains hardlink entry: ${line.trim()}`);
|
|
759
|
+
}
|
|
760
|
+
const parts = line.trim().split(/\s+/);
|
|
761
|
+
if (parts.length >= 6) {
|
|
762
|
+
const numeric = parts
|
|
763
|
+
.map((part) => Number.parseInt(part, 10))
|
|
764
|
+
.filter((value) => Number.isFinite(value) && value >= 0);
|
|
765
|
+
if (numeric.length > 0)
|
|
766
|
+
totalBytes += Math.max(...numeric);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const names = await runArchiveTool(command, ["-tf", archivePath], ARCHIVE_LIST_TIMEOUT_MS);
|
|
770
|
+
for (const entry of names.split("\n")) {
|
|
771
|
+
if (entry.length > 0)
|
|
772
|
+
assertArchiveEntryPath(entry);
|
|
773
|
+
}
|
|
774
|
+
return totalBytes;
|
|
775
|
+
}
|
|
776
|
+
async function precheckArchiveContents(archivePath, archiveType, zipListingMode = "platform") {
|
|
777
|
+
let totalBytes = 0;
|
|
778
|
+
if (archiveType === "zip" && zipListingMode === "platform" && process.platform !== "win32") {
|
|
779
|
+
const out = await runArchiveTool("unzip", ["-l", archivePath], ARCHIVE_LIST_TIMEOUT_MS);
|
|
780
|
+
const match = out.match(/^\s*(\d+)\s+\d+\s+files?\s*$/m);
|
|
781
|
+
if (match)
|
|
782
|
+
totalBytes = Number.parseInt(match[1] ?? "0", 10);
|
|
783
|
+
const names = await runArchiveTool("unzip", ["-Z1", archivePath], ARCHIVE_LIST_TIMEOUT_MS);
|
|
784
|
+
for (const entry of names.split("\n")) {
|
|
785
|
+
if (entry.length > 0)
|
|
786
|
+
assertArchiveEntryPath(entry);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
totalBytes = await precheckWithTar(archivePath, windowsTarExecutable());
|
|
791
|
+
}
|
|
792
|
+
if (totalBytes > MAX_EXTRACT_BYTES) {
|
|
793
|
+
throw new Error(`archive uncompressed size ${totalBytes} exceeds ${MAX_EXTRACT_BYTES}`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
async function extractArchiveSafely(archivePath, destDir, archiveType, validateStaged) {
|
|
797
|
+
// Per-process random suffix avoids collisions if two installs of the
|
|
798
|
+
// same package somehow race past the install lock (defense in depth).
|
|
799
|
+
const suffix = randomBytes(8).toString("hex");
|
|
800
|
+
const stagingDir = `${destDir}.staging-${suffix}`;
|
|
801
|
+
// Cleanup any stale staging dir from a prior crashed install.
|
|
802
|
+
try {
|
|
803
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
// ignore
|
|
807
|
+
}
|
|
808
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
809
|
+
try {
|
|
810
|
+
await precheckArchiveContents(archivePath, archiveType);
|
|
811
|
+
await runPlatformExtractor(archivePath, stagingDir, archiveType);
|
|
812
|
+
validateExtraction(stagingDir);
|
|
813
|
+
await validateStaged?.(stagingDir);
|
|
814
|
+
// Keep the previous distribution available for rollback until the swap succeeds.
|
|
815
|
+
// Renaming over a nonempty directory is not supported on Windows.
|
|
816
|
+
const previousDir = `${destDir}.previous-${suffix}`;
|
|
817
|
+
const hadPrevious = existsSync(destDir);
|
|
818
|
+
if (hadPrevious)
|
|
819
|
+
renameSync(destDir, previousDir);
|
|
820
|
+
try {
|
|
821
|
+
renameSync(stagingDir, destDir);
|
|
822
|
+
}
|
|
823
|
+
catch (err) {
|
|
824
|
+
if (hadPrevious)
|
|
825
|
+
renameSync(previousDir, destDir);
|
|
826
|
+
throw err;
|
|
827
|
+
}
|
|
828
|
+
try {
|
|
829
|
+
rmSync(previousDir, { recursive: true, force: true });
|
|
830
|
+
}
|
|
831
|
+
catch (err) {
|
|
832
|
+
warn(`[lsp] could not remove previous extraction ${previousDir}: ${err}`);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
catch (err) {
|
|
836
|
+
// Always clean up the staging dir on any failure path.
|
|
837
|
+
try {
|
|
838
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
// ignore — cleanup failures are not as bad as the original error
|
|
842
|
+
}
|
|
843
|
+
throw err;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
function quarantineCachedGithubInstall(spec, reason) {
|
|
847
|
+
const packageDir = ghPackageDir(spec);
|
|
848
|
+
const dest = join(ghCacheRoot(), ".quarantine", encodeURIComponent(spec.id), `${Date.now()}`);
|
|
849
|
+
warn(`[lsp] tofu_mismatch ${spec.id}: ${reason}; quarantining ${packageDir} -> ${dest}`);
|
|
850
|
+
try {
|
|
851
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
852
|
+
rmSync(dest, { recursive: true, force: true });
|
|
853
|
+
renameSync(packageDir, dest);
|
|
854
|
+
}
|
|
855
|
+
catch (err) {
|
|
856
|
+
warn(`[lsp] tofu_mismatch ${spec.id}: failed to quarantine cache entry: ${err}`);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
/** A timeout is inconclusive: a cold or loaded server may simply need longer to start. */
|
|
860
|
+
async function smokeGithubBinary(spec, binaryPath) {
|
|
861
|
+
return new Promise((resolveSmoke) => {
|
|
862
|
+
let child;
|
|
863
|
+
let settled = false;
|
|
864
|
+
let output = Buffer.alloc(0);
|
|
865
|
+
const finish = (verdict, detail) => {
|
|
866
|
+
if (settled)
|
|
867
|
+
return;
|
|
868
|
+
settled = true;
|
|
869
|
+
clearTimeout(timer);
|
|
870
|
+
child?.stdout?.destroy();
|
|
871
|
+
child?.stderr?.destroy();
|
|
872
|
+
if (verdict !== "passed") {
|
|
873
|
+
warn(`[lsp] ${spec.id}: runtime smoke ${verdict} at ${binaryPath}: ${detail ?? output.toString("utf8").trim()}`);
|
|
874
|
+
}
|
|
875
|
+
resolveSmoke(verdict);
|
|
876
|
+
};
|
|
877
|
+
const timer = setTimeout(() => {
|
|
878
|
+
child?.kill("SIGKILL");
|
|
879
|
+
child?.unref();
|
|
880
|
+
finish("timeout", "exceeded 2000 ms; keeping install");
|
|
881
|
+
}, 2_000);
|
|
882
|
+
const capture = (chunk) => {
|
|
883
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
884
|
+
const available = 64 * 1024 - output.length;
|
|
885
|
+
output = Buffer.concat([output, bytes.subarray(0, available)]);
|
|
886
|
+
if (bytes.length > available) {
|
|
887
|
+
child?.kill("SIGKILL");
|
|
888
|
+
finish("failed", "exceeded 64 KiB output limit");
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
try {
|
|
892
|
+
child = spawn(binaryPath, [...spec.smokeArgs], { stdio: ["ignore", "pipe", "pipe"] });
|
|
893
|
+
child.stdout?.on("data", capture);
|
|
894
|
+
child.stderr?.on("data", capture);
|
|
895
|
+
child.once("error", (error) => finish("failed", String(error)));
|
|
896
|
+
// Do not wait for pipe EOF: descendants may inherit handles after the server exits.
|
|
897
|
+
child.once("exit", (code, signal) => finish(code === 0 ? "passed" : "failed", `exit=${code}, signal=${signal}`));
|
|
898
|
+
}
|
|
899
|
+
catch (error) {
|
|
900
|
+
finish("failed", String(error));
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
function hasRequiredRuntime(spec, extractDir, version) {
|
|
905
|
+
return (spec.requiredRuntimePaths?.(stripTagV(version)) ?? []).every(({ path, kind }) => {
|
|
906
|
+
try {
|
|
907
|
+
const info = statSync(join(extractDir, path));
|
|
908
|
+
return kind === "file" ? info.isFile() : info.isDirectory();
|
|
909
|
+
}
|
|
910
|
+
catch {
|
|
911
|
+
return false;
|
|
912
|
+
}
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
/** Return known verdicts immediately without racing an install or waiting for its lock. */
|
|
916
|
+
function validateCachedGithubInstall(spec, platform) {
|
|
917
|
+
const lease = acquireInstallLock(spec.githubRepo);
|
|
918
|
+
if (!lease)
|
|
919
|
+
return null;
|
|
920
|
+
try {
|
|
921
|
+
const validation = validateCachedGithubInstallUnderLock(spec, platform);
|
|
922
|
+
if (typeof validation !== "boolean")
|
|
923
|
+
return validation.finally(() => lease.release());
|
|
924
|
+
lease.release();
|
|
925
|
+
return validation;
|
|
926
|
+
}
|
|
927
|
+
catch (err) {
|
|
928
|
+
lease.release();
|
|
929
|
+
throw err;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
function validateCachedGithubInstallUnderLock(spec, platform) {
|
|
933
|
+
const packageDir = ghPackageDir(spec);
|
|
934
|
+
const meta = readGithubInstalledMetaIn(packageDir);
|
|
935
|
+
const binaryPath = ghBinaryCandidates(spec, platform)
|
|
936
|
+
.map((candidate) => join(ghBinDir(spec), candidate))
|
|
937
|
+
.find((candidate) => {
|
|
938
|
+
try {
|
|
939
|
+
return statSync(candidate).isFile();
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
return false;
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
if (!meta?.version || !isSafeVersion(meta.version) || !binaryPath) {
|
|
946
|
+
quarantineCachedGithubInstall(spec, "missing/unsafe metadata or binary");
|
|
947
|
+
return false;
|
|
948
|
+
}
|
|
949
|
+
if (spec.layout === "distribution" && meta.layout !== "distribution") {
|
|
950
|
+
// Layout must be checked even if an older installer recorded a successful smoke.
|
|
951
|
+
quarantineCachedGithubInstall(spec, "distribution was installed without its runtime layout");
|
|
952
|
+
return false;
|
|
953
|
+
}
|
|
954
|
+
const currentHash = cachedBinarySha256(binaryPath);
|
|
955
|
+
const recordedBinaryHash = meta.binarySha256 ?? meta.sha256;
|
|
956
|
+
// Legacy metadata recorded the archive digest in sha256. Retain its audit
|
|
957
|
+
// value while migrating to the executable digest, but only after validation.
|
|
958
|
+
const legacyArchiveHash = meta.sha256 && !meta.binarySha256 && !meta.archiveSha256;
|
|
959
|
+
if (!recordedBinaryHash || (!legacyArchiveHash && currentHash !== recordedBinaryHash)) {
|
|
960
|
+
quarantineCachedGithubInstall(spec, `recorded ${recordedBinaryHash}, current ${currentHash}`);
|
|
961
|
+
return false;
|
|
962
|
+
}
|
|
963
|
+
if (meta.sha256 === currentHash &&
|
|
964
|
+
meta.binarySha256 === currentHash &&
|
|
965
|
+
meta.layout === spec.layout &&
|
|
966
|
+
meta.smoke?.binarySha256 === currentHash &&
|
|
967
|
+
hasRequiredRuntime(spec, ghExtractDir(spec), meta.version)) {
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
return (async () => {
|
|
971
|
+
if (!hasRequiredRuntime(spec, ghExtractDir(spec), meta.version)) {
|
|
972
|
+
quarantineCachedGithubInstall(spec, "missing required runtime files");
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
const verdict = await smokeGithubBinary(spec, binaryPath);
|
|
976
|
+
if (verdict === "failed") {
|
|
977
|
+
quarantineCachedGithubInstall(spec, "cached binary failed runtime smoke check");
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
// Do not certify a different executable if it changed while the subprocess ran.
|
|
981
|
+
if (!existsSync(binaryPath) || cachedBinarySha256(binaryPath) !== currentHash)
|
|
982
|
+
return false;
|
|
983
|
+
writeGithubInstalledMetaIn(packageDir, meta.version, currentHash, legacyArchiveHash ? meta.sha256 : meta.archiveSha256, spec.layout, { binarySha256: currentHash, verdict });
|
|
984
|
+
return true;
|
|
985
|
+
})();
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Internal: run the platform extractor against a fresh empty target.
|
|
989
|
+
*
|
|
990
|
+
* The extractor's containment defenses are platform-dependent: modern
|
|
991
|
+
* `unzip` and `tar` reject absolute paths and `..` components, but we
|
|
992
|
+
* still validate post-extraction in case a defense slipped past.
|
|
993
|
+
*/
|
|
994
|
+
async function runPlatformExtractor(archivePath, destDir, archiveType) {
|
|
995
|
+
if (archiveType === "zip") {
|
|
996
|
+
if (process.platform === "win32") {
|
|
997
|
+
// Avoid PowerShell and PATH-resolved GNU tar. System32 bsdtar accepts
|
|
998
|
+
// drive-letter paths and direct argv execution adds no shell parser.
|
|
999
|
+
await runTarExtraction(["-xf", archivePath, "-C", destDir], 180_000);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
await runArchiveTool("unzip", ["-q", "-o", archivePath, "-d", destDir], 180_000);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (archiveType === "tar.gz") {
|
|
1006
|
+
await runTarExtraction(["-xzf", archivePath, "-C", destDir], 180_000);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (archiveType === "tar.xz") {
|
|
1010
|
+
await runTarExtraction(["-xf", archivePath, "-C", destDir], 180_000);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
throw new Error(`unsupported archive type: ${archiveType}`);
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Run the download + extract + binary-place flow for a single server.
|
|
1017
|
+
* Returns hashes on success, or a hash and error for the caller to persist
|
|
1018
|
+
* in `.aft-install-failed.json` so repeated failures back off.
|
|
1019
|
+
*/
|
|
1020
|
+
async function downloadAndInstall(spec, tag, assets, platform, arch, fetchImpl, signal) {
|
|
1021
|
+
const version = stripTagV(tag);
|
|
1022
|
+
const expected = spec.resolveAsset(platform, arch, version);
|
|
1023
|
+
if (!expected) {
|
|
1024
|
+
const message = `unsupported platform/arch combo ${platform}/${arch}`;
|
|
1025
|
+
warn(`[lsp] ${spec.id}: ${message}`);
|
|
1026
|
+
return { ok: false, sha256: "", error: message };
|
|
1027
|
+
}
|
|
1028
|
+
const matchingAsset = assets.find((asset) => asset.name === expected.name);
|
|
1029
|
+
if (!matchingAsset) {
|
|
1030
|
+
const message = `asset ${expected.name} not found in release ${tag} (${assets.length} assets available)`;
|
|
1031
|
+
warn(`[lsp] ${spec.id}: ${message}`);
|
|
1032
|
+
return { ok: false, sha256: "", error: message };
|
|
1033
|
+
}
|
|
1034
|
+
const pkgDir = ghPackageDir(spec);
|
|
1035
|
+
const extractDir = ghExtractDir(spec);
|
|
1036
|
+
const archivePath = join(pkgDir, expected.name);
|
|
1037
|
+
log(`[lsp] downloading ${spec.id} ${tag} → ${matchingAsset.url}`);
|
|
1038
|
+
try {
|
|
1039
|
+
await downloadFile(matchingAsset.url, archivePath, fetchImpl, matchingAsset.size, signal);
|
|
1040
|
+
}
|
|
1041
|
+
catch (err) {
|
|
1042
|
+
const message = `download failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
1043
|
+
error(`[lsp] download ${spec.id} failed: ${err}`);
|
|
1044
|
+
return { ok: false, sha256: "", error: message };
|
|
1045
|
+
}
|
|
1046
|
+
let archiveSha256;
|
|
1047
|
+
try {
|
|
1048
|
+
archiveSha256 = await sha256OfFile(archivePath);
|
|
1049
|
+
}
|
|
1050
|
+
catch (err) {
|
|
1051
|
+
const message = `hash failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
1052
|
+
error(`[lsp] hash ${spec.id} failed: ${err}`);
|
|
1053
|
+
try {
|
|
1054
|
+
unlinkSync(archivePath);
|
|
1055
|
+
}
|
|
1056
|
+
catch { }
|
|
1057
|
+
return { ok: false, sha256: "", error: message };
|
|
1058
|
+
}
|
|
1059
|
+
log(`[lsp] ${spec.id} ${tag} sha256=${archiveSha256}`);
|
|
1060
|
+
const previousMeta = readGithubInstalledMetaIn(ghPackageDir(spec));
|
|
1061
|
+
const previousArchiveSha256 = previousMeta?.archiveSha256 ??
|
|
1062
|
+
(previousMeta?.binarySha256 ? undefined : previousMeta?.sha256);
|
|
1063
|
+
if (previousMeta &&
|
|
1064
|
+
previousMeta.version === tag &&
|
|
1065
|
+
previousArchiveSha256 &&
|
|
1066
|
+
previousArchiveSha256 !== archiveSha256) {
|
|
1067
|
+
const message = `TOFU sha256 mismatch: previously installed archive sha256=${previousArchiveSha256}, ` +
|
|
1068
|
+
`downloaded sha256=${archiveSha256}`;
|
|
1069
|
+
error(`[lsp] ${spec.id} ${tag}: ${message}. ` +
|
|
1070
|
+
`This means the published release for tag ${tag} changed. Investigate before proceeding. ` +
|
|
1071
|
+
`Run \`npx @cortexkit/aft doctor --clear\` to wipe the cache and force a fresh install if you've verified the change.`);
|
|
1072
|
+
try {
|
|
1073
|
+
unlinkSync(archivePath);
|
|
1074
|
+
}
|
|
1075
|
+
catch { }
|
|
1076
|
+
return { ok: false, sha256: archiveSha256, error: message };
|
|
1077
|
+
}
|
|
1078
|
+
let binarySha256 = "";
|
|
1079
|
+
let smokeVerdict = "passed";
|
|
1080
|
+
const binaryRelativePath = spec.binaryPathInArchive(platform, arch, version);
|
|
1081
|
+
try {
|
|
1082
|
+
await extractArchiveSafely(archivePath, extractDir, expected.archive, async (stagingDir) => {
|
|
1083
|
+
const stagedBinary = join(stagingDir, binaryRelativePath);
|
|
1084
|
+
if (!existsSync(stagedBinary) || !statSync(stagedBinary).isFile()) {
|
|
1085
|
+
throw new Error(`extracted binary not found at ${stagedBinary}`);
|
|
1086
|
+
}
|
|
1087
|
+
if (!hasRequiredRuntime(spec, stagingDir, version)) {
|
|
1088
|
+
throw new Error("missing required runtime files in staged distribution");
|
|
1089
|
+
}
|
|
1090
|
+
if (platform !== "win32") {
|
|
1091
|
+
const { chmodSync } = await import("node:fs");
|
|
1092
|
+
chmodSync(stagedBinary, 0o755);
|
|
1093
|
+
}
|
|
1094
|
+
const verdict = await smokeGithubBinary(spec, stagedBinary);
|
|
1095
|
+
if (verdict === "failed")
|
|
1096
|
+
throw new Error("staged binary failed runtime smoke check");
|
|
1097
|
+
smokeVerdict = verdict;
|
|
1098
|
+
const identityBeforeHash = fileIdentity(stagedBinary);
|
|
1099
|
+
binarySha256 = await sha256OfFile(stagedBinary);
|
|
1100
|
+
if (fileIdentity(stagedBinary) !== identityBeforeHash) {
|
|
1101
|
+
throw new Error("staged executable changed while hashing");
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
catch (err) {
|
|
1106
|
+
const message = `extract failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
1107
|
+
error(`[lsp] extract ${spec.id} failed: ${err}`);
|
|
1108
|
+
return { ok: false, sha256: archiveSha256, error: message };
|
|
1109
|
+
}
|
|
1110
|
+
finally {
|
|
1111
|
+
try {
|
|
1112
|
+
unlinkSync(archivePath);
|
|
1113
|
+
}
|
|
1114
|
+
catch {
|
|
1115
|
+
// A leftover archive does not make the validated extraction unsafe.
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
const innerBinaryPath = join(extractDir, binaryRelativePath);
|
|
1119
|
+
const targetBinary = spec.layout === "distribution" ? innerBinaryPath : ghBinaryPath(spec, platform);
|
|
1120
|
+
mkdirSync(dirname(targetBinary), { recursive: true });
|
|
1121
|
+
try {
|
|
1122
|
+
if (spec.layout === "standalone") {
|
|
1123
|
+
const stagedBinary = `${targetBinary}.staging-${randomBytes(8).toString("hex")}`;
|
|
1124
|
+
try {
|
|
1125
|
+
copyFileSync(innerBinaryPath, stagedBinary);
|
|
1126
|
+
if (platform !== "win32") {
|
|
1127
|
+
const { chmodSync } = await import("node:fs");
|
|
1128
|
+
chmodSync(stagedBinary, 0o755);
|
|
1129
|
+
}
|
|
1130
|
+
renameSync(stagedBinary, targetBinary);
|
|
1131
|
+
}
|
|
1132
|
+
finally {
|
|
1133
|
+
rmSync(stagedBinary, { force: true });
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
catch (err) {
|
|
1138
|
+
const message = `failed to place binary at ${targetBinary}: ${err instanceof Error ? err.message : String(err)}`;
|
|
1139
|
+
error(`[lsp] ${spec.id}: ${message}`);
|
|
1140
|
+
return { ok: false, sha256: archiveSha256, error: message };
|
|
1141
|
+
}
|
|
1142
|
+
try {
|
|
1143
|
+
const identity = fileIdentity(targetBinary);
|
|
1144
|
+
// The staged executable was hashed before publication. Remember its new
|
|
1145
|
+
// identity so the refresh does not hash the same bytes again.
|
|
1146
|
+
binaryDigestMemo.set(targetBinary, { identity, digest: binarySha256 });
|
|
1147
|
+
writeStampedFileDigest(targetBinary, binarySha256, identity);
|
|
1148
|
+
log(`[lsp] installed ${spec.id} ${tag} at ${targetBinary}`);
|
|
1149
|
+
log(`[lsp] ${spec.id} ${tag} binary_sha256=${binarySha256}`);
|
|
1150
|
+
return {
|
|
1151
|
+
ok: true,
|
|
1152
|
+
archiveSha256,
|
|
1153
|
+
binarySha256,
|
|
1154
|
+
smoke: { binarySha256, verdict: smokeVerdict },
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
catch (err) {
|
|
1158
|
+
const message = `failed to hash installed binary: ${err instanceof Error ? err.message : String(err)}`;
|
|
1159
|
+
error(`[lsp] ${spec.id}: ${message}`);
|
|
1160
|
+
return { ok: false, sha256: archiveSha256, error: message };
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
/* ─────────────────────────── per-server flow ─────────────────────────── */
|
|
1164
|
+
async function ensureGithubInstalled(spec, config, fetchImpl, platform, arch, signal) {
|
|
1165
|
+
const outcome = await withInstallLock(spec.githubRepo, async () => {
|
|
1166
|
+
const failureMarker = readInstallFailureMarker(spec);
|
|
1167
|
+
const pinnedRelease = config.versions[spec.githubRepo];
|
|
1168
|
+
if (failureMarker) {
|
|
1169
|
+
if (isFreshInstallFailure(failureMarker) &&
|
|
1170
|
+
(!pinnedRelease || sameRelease(failureMarker.release, pinnedRelease))) {
|
|
1171
|
+
warn(`[lsp] skipping ${spec.id}: recent install failure marker ${installFailureMarkerPath(spec)} ` +
|
|
1172
|
+
`for ${failureMarker.release}: ${failureMarker.error}`);
|
|
1173
|
+
return { started: false, reason: "recent install failure marker" };
|
|
1174
|
+
}
|
|
1175
|
+
clearInstallFailureMarker(spec);
|
|
1176
|
+
}
|
|
1177
|
+
const { tag, assets, blockedByGrace, reason } = await resolveTargetTag(spec, config, fetchImpl, signal);
|
|
1178
|
+
if (!tag) {
|
|
1179
|
+
const installed = isGithubInstalled(spec, platform);
|
|
1180
|
+
if (installed) {
|
|
1181
|
+
warn(`[lsp] no eligible release of ${spec.githubRepo} (grace=${config.graceDays}d); keeping existing install`);
|
|
1182
|
+
return { started: false, reason: "kept existing install" };
|
|
1183
|
+
}
|
|
1184
|
+
const fallbackReason = reason ??
|
|
1185
|
+
(blockedByGrace
|
|
1186
|
+
? `all releases are within ${config.graceDays}-day grace window`
|
|
1187
|
+
: "github releases probe failed");
|
|
1188
|
+
warn(`[lsp] skipping ${spec.id}: ${fallbackReason}`);
|
|
1189
|
+
return { started: false, reason: fallbackReason };
|
|
1190
|
+
}
|
|
1191
|
+
if (isGithubInstalled(spec, platform)) {
|
|
1192
|
+
const installedMeta = readGithubInstalledMetaIn(ghPackageDir(spec));
|
|
1193
|
+
if (installedMeta && installedMeta.version === tag) {
|
|
1194
|
+
clearInstallFailureMarker(spec);
|
|
1195
|
+
return { started: false, reason: "already installed" };
|
|
1196
|
+
}
|
|
1197
|
+
if (installedMeta) {
|
|
1198
|
+
log(`[lsp] reinstalling ${spec.id}: cached ${installedMeta.version} ≠ target ${tag}`);
|
|
1199
|
+
}
|
|
1200
|
+
else {
|
|
1201
|
+
log(`[lsp] reinstalling ${spec.id}@${tag}: no installed-version metadata recorded`);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
const installResult = await downloadAndInstall(spec, tag, assets, platform, arch, fetchImpl, signal).catch((err) => {
|
|
1205
|
+
const message = `install crashed: ${err instanceof Error ? err.message : String(err)}`;
|
|
1206
|
+
error(`[lsp] github install ${spec.id} crashed: ${err}`);
|
|
1207
|
+
return { ok: false, sha256: "", error: message };
|
|
1208
|
+
});
|
|
1209
|
+
if (!installResult.ok) {
|
|
1210
|
+
writeInstallFailureMarker(spec, tag, installResult.sha256, installResult.error);
|
|
1211
|
+
return { started: true, reason: "install failed (see plugin log)" };
|
|
1212
|
+
}
|
|
1213
|
+
clearInstallFailureMarker(spec);
|
|
1214
|
+
writeGithubInstalledMetaIn(ghPackageDir(spec), tag, installResult.binarySha256, installResult.archiveSha256, spec.layout, installResult.smoke);
|
|
1215
|
+
return { started: true };
|
|
1216
|
+
});
|
|
1217
|
+
if (outcome === null) {
|
|
1218
|
+
return { started: false, reason: "another install in progress" };
|
|
1219
|
+
}
|
|
1220
|
+
return outcome;
|
|
1221
|
+
}
|
|
1222
|
+
/* ─────────────────────────── public entrypoint ─────────────────────────── */
|
|
1223
|
+
/** Wait `ms` milliseconds, or less if `signal` aborts first. */
|
|
1224
|
+
function sleepUntilAborted(ms, signal) {
|
|
1225
|
+
if (signal.aborted)
|
|
1226
|
+
return Promise.resolve();
|
|
1227
|
+
return new Promise((resolve) => {
|
|
1228
|
+
const finish = () => {
|
|
1229
|
+
clearTimeout(timer);
|
|
1230
|
+
signal.removeEventListener("abort", finish);
|
|
1231
|
+
resolve();
|
|
1232
|
+
};
|
|
1233
|
+
const timer = setTimeout(finish, ms);
|
|
1234
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
const inFlightGithubInstalls = new Set();
|
|
1238
|
+
function trackInFlightGithubInstall(controller, promise) {
|
|
1239
|
+
const entry = { controller, promise };
|
|
1240
|
+
inFlightGithubInstalls.add(entry);
|
|
1241
|
+
promise.then(() => inFlightGithubInstalls.delete(entry), () => inFlightGithubInstalls.delete(entry));
|
|
1242
|
+
return promise;
|
|
1243
|
+
}
|
|
1244
|
+
async function abortInFlightGithubInstalls() {
|
|
1245
|
+
const installs = Array.from(inFlightGithubInstalls);
|
|
1246
|
+
for (const install of installs) {
|
|
1247
|
+
install.controller.abort();
|
|
1248
|
+
}
|
|
1249
|
+
await Promise.allSettled(installs.map((install) => install.promise));
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Run the GitHub-release auto-install pass for every Pattern C server.
|
|
1253
|
+
*
|
|
1254
|
+
* Previously verified caches are returned synchronously for the first configure.
|
|
1255
|
+
* Unverified caches and installs settle in the background; callers await
|
|
1256
|
+
* installsComplete before refreshing paths without blocking unrelated startup.
|
|
1257
|
+
*/
|
|
1258
|
+
function runGithubAutoInstall(relevantServers, config, fetchImpl = fetch, options = {}) {
|
|
1259
|
+
const cachedBinDirs = [];
|
|
1260
|
+
const skipped = [];
|
|
1261
|
+
const installPromises = [];
|
|
1262
|
+
const installingBinaries = [];
|
|
1263
|
+
let installsStarted = 0;
|
|
1264
|
+
const lockWaitTimeoutMs = Math.max(0, options.__lockWaitTimeoutMsForTests ?? INSTALL_LOCK_WAIT_MS);
|
|
1265
|
+
const lockPollIntervalMs = Math.max(1, options.__lockPollIntervalMsForTests ?? INSTALL_LOCK_POLL_INTERVAL_MS);
|
|
1266
|
+
function notifyCachedBinDirsChanged(directory) {
|
|
1267
|
+
try {
|
|
1268
|
+
options.onCachedBinDirsChanged?.(directory);
|
|
1269
|
+
}
|
|
1270
|
+
catch (err) {
|
|
1271
|
+
warn(`[lsp] github cache path refresh callback failed: ${err}`);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Re-validate a cache whose install lock another process holds, polling
|
|
1276
|
+
* until the lock frees or the wait deadline passes. Returns null when the
|
|
1277
|
+
* cache could not be validated. An aborted signal (plugin shutdown) ends
|
|
1278
|
+
* the wait at once and returns null without recording a lock-timeout skip,
|
|
1279
|
+
* because the lock never timed out.
|
|
1280
|
+
*/
|
|
1281
|
+
async function retryBusyCacheValidation(spec, platform, signal) {
|
|
1282
|
+
const deadline = Date.now() + lockWaitTimeoutMs;
|
|
1283
|
+
while (true) {
|
|
1284
|
+
if (signal.aborted)
|
|
1285
|
+
return null;
|
|
1286
|
+
const validation = await withInstallLock(spec.githubRepo, async () => validateCachedGithubInstallUnderLock(spec, platform));
|
|
1287
|
+
if (validation !== null)
|
|
1288
|
+
return validation;
|
|
1289
|
+
if (signal.aborted)
|
|
1290
|
+
return null;
|
|
1291
|
+
const remainingMs = deadline - Date.now();
|
|
1292
|
+
if (remainingMs <= 0) {
|
|
1293
|
+
skipped.push({
|
|
1294
|
+
id: spec.id,
|
|
1295
|
+
reason: INSTALL_LOCK_WAIT_TIMEOUT_REASON,
|
|
1296
|
+
});
|
|
1297
|
+
return null;
|
|
1298
|
+
}
|
|
1299
|
+
await sleepUntilAborted(Math.min(lockPollIntervalMs, remainingMs), signal);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
const host = detectHostPlatform();
|
|
1303
|
+
if (!host) {
|
|
1304
|
+
// Without a supported platform/arch we cannot select or validate a cached executable.
|
|
1305
|
+
return {
|
|
1306
|
+
cachedBinDirs,
|
|
1307
|
+
installsStarted: 0,
|
|
1308
|
+
installingBinaries: [],
|
|
1309
|
+
skipped,
|
|
1310
|
+
installsComplete: Promise.resolve(),
|
|
1311
|
+
getCachedBinDirs: async () => [...cachedBinDirs],
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
for (const spec of GITHUB_LSP_TABLE) {
|
|
1315
|
+
// One controller per server covers both the busy-lock cache wait and
|
|
1316
|
+
// the install that follows it, so shutdown can cancel either one.
|
|
1317
|
+
const controller = new AbortController();
|
|
1318
|
+
const validation = isGithubInstalled(spec, host.platform)
|
|
1319
|
+
? validateCachedGithubInstall(spec, host.platform)
|
|
1320
|
+
: false;
|
|
1321
|
+
if (validation === true)
|
|
1322
|
+
cachedBinDirs.push(ghBinDir(spec));
|
|
1323
|
+
const cacheReady = (validation === null
|
|
1324
|
+
? retryBusyCacheValidation(spec, host.platform, controller.signal)
|
|
1325
|
+
: Promise.resolve(validation)).then((healthy) => {
|
|
1326
|
+
if (healthy && validation !== true) {
|
|
1327
|
+
const directory = ghBinDir(spec);
|
|
1328
|
+
cachedBinDirs.push(directory);
|
|
1329
|
+
notifyCachedBinDirsChanged(directory);
|
|
1330
|
+
}
|
|
1331
|
+
return healthy;
|
|
1332
|
+
});
|
|
1333
|
+
// Track the cache wait on its own: servers skipped below (disabled,
|
|
1334
|
+
// auto-install off, not relevant) still wait on a busy lock.
|
|
1335
|
+
installPromises.push(trackInFlightGithubInstall(controller, cacheReady.then(() => { })));
|
|
1336
|
+
if (config.disabled.has(spec.id)) {
|
|
1337
|
+
skipped.push({ id: spec.id, reason: "disabled by config" });
|
|
1338
|
+
continue;
|
|
1339
|
+
}
|
|
1340
|
+
if (!config.autoInstall) {
|
|
1341
|
+
skipped.push({ id: spec.id, reason: "auto_install: false" });
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
if (!relevantServers.has(spec.id)) {
|
|
1345
|
+
skipped.push({ id: spec.id, reason: "not relevant to project" });
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
installsStarted += 1;
|
|
1349
|
+
if (services.reportInstallingBinaries)
|
|
1350
|
+
installingBinaries.push(spec.binary);
|
|
1351
|
+
const promise = cacheReady
|
|
1352
|
+
.then((healthy) => validation === null && healthy === null
|
|
1353
|
+
? { started: false, reason: undefined }
|
|
1354
|
+
: ensureGithubInstalled(spec, config, fetchImpl, host.platform, host.arch, controller.signal))
|
|
1355
|
+
.then((outcome) => {
|
|
1356
|
+
if (!outcome.started)
|
|
1357
|
+
installsStarted -= 1;
|
|
1358
|
+
if (outcome.reason && outcome.reason !== "already installed") {
|
|
1359
|
+
skipped.push({ id: spec.id, reason: outcome.reason });
|
|
1360
|
+
}
|
|
1361
|
+
notifyCachedBinDirsChanged();
|
|
1362
|
+
}, (err) => {
|
|
1363
|
+
installsStarted -= 1;
|
|
1364
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1365
|
+
skipped.push({ id: spec.id, reason: `install error: ${reason}` });
|
|
1366
|
+
error(`[lsp] github install ${spec.id} promise rejected: ${reason}`);
|
|
1367
|
+
notifyCachedBinDirsChanged();
|
|
1368
|
+
});
|
|
1369
|
+
installPromises.push(trackInFlightGithubInstall(controller, promise));
|
|
1370
|
+
}
|
|
1371
|
+
return {
|
|
1372
|
+
cachedBinDirs,
|
|
1373
|
+
installingBinaries,
|
|
1374
|
+
get installsStarted() {
|
|
1375
|
+
return installsStarted;
|
|
1376
|
+
},
|
|
1377
|
+
skipped,
|
|
1378
|
+
installsComplete: Promise.all(installPromises).then(() => { }),
|
|
1379
|
+
getCachedBinDirs: async () => {
|
|
1380
|
+
const currentHost = detectHostPlatform();
|
|
1381
|
+
if (!currentHost)
|
|
1382
|
+
return [];
|
|
1383
|
+
const directories = await Promise.all(GITHUB_LSP_TABLE.map(async (spec) => isGithubInstalled(spec, currentHost.platform) &&
|
|
1384
|
+
(await validateCachedGithubInstall(spec, currentHost.platform))
|
|
1385
|
+
? ghBinDir(spec)
|
|
1386
|
+
: null));
|
|
1387
|
+
return directories.filter((directory) => directory !== null);
|
|
1388
|
+
},
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
/* ─────────────────────────── discovery helper ─────────────────────────── */
|
|
1392
|
+
/**
|
|
1393
|
+
* Cheap project-relevance scan for GitHub-distributed servers. Root markers
|
|
1394
|
+
* win immediately; otherwise use the bounded shared extension walk for
|
|
1395
|
+
* monorepos with nested source files.
|
|
1396
|
+
*/
|
|
1397
|
+
function discoverRelevantGithubServers(projectRoot) {
|
|
1398
|
+
// Match crates/aft/src/lsp/registry.rs server extensions so discovery
|
|
1399
|
+
// installs the same server kinds that Rust will select for these files.
|
|
1400
|
+
const extToServerIds = {
|
|
1401
|
+
c: ["clangd"],
|
|
1402
|
+
"c++": ["clangd"],
|
|
1403
|
+
cc: ["clangd"],
|
|
1404
|
+
cpp: ["clangd"],
|
|
1405
|
+
cxx: ["clangd"],
|
|
1406
|
+
h: ["clangd"],
|
|
1407
|
+
"h++": ["clangd"],
|
|
1408
|
+
hpp: ["clangd"],
|
|
1409
|
+
hh: ["clangd"],
|
|
1410
|
+
hxx: ["clangd"],
|
|
1411
|
+
lua: ["lua-ls"],
|
|
1412
|
+
zig: ["zls"],
|
|
1413
|
+
zon: ["zls"],
|
|
1414
|
+
typ: ["tinymist"],
|
|
1415
|
+
typc: ["tinymist"],
|
|
1416
|
+
tex: ["texlab"],
|
|
1417
|
+
bib: ["texlab"],
|
|
1418
|
+
};
|
|
1419
|
+
const rootMarkers = {
|
|
1420
|
+
clangd: ["compile_commands.json", "compile_flags.txt", ".clangd"],
|
|
1421
|
+
"lua-ls": [".luarc.json", ".luarc.jsonc", ".stylua.toml", "stylua.toml"],
|
|
1422
|
+
zls: ["build.zig"],
|
|
1423
|
+
tinymist: ["typst.toml"],
|
|
1424
|
+
texlab: [".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"],
|
|
1425
|
+
};
|
|
1426
|
+
const relevant = new Set();
|
|
1427
|
+
for (const spec of GITHUB_LSP_TABLE) {
|
|
1428
|
+
if (hasRootMarker(projectRoot, rootMarkers[spec.id]))
|
|
1429
|
+
relevant.add(spec.id);
|
|
1430
|
+
}
|
|
1431
|
+
const extensions = relevantExtensionsInProject(projectRoot, extToServerIds);
|
|
1432
|
+
for (const ext of extensions) {
|
|
1433
|
+
for (const id of extToServerIds[ext] ?? []) {
|
|
1434
|
+
relevant.add(id);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
return relevant;
|
|
1438
|
+
}
|
|
1439
|
+
return {
|
|
1440
|
+
ghBinaryPath,
|
|
1441
|
+
isGithubInstalled,
|
|
1442
|
+
__githubBinaryHashCountForTests,
|
|
1443
|
+
validateExtraction,
|
|
1444
|
+
precheckArchiveContents,
|
|
1445
|
+
abortInFlightGithubInstalls,
|
|
1446
|
+
runGithubAutoInstall,
|
|
1447
|
+
discoverRelevantGithubServers,
|
|
1448
|
+
_assertAllowedDownloadUrlForTesting: assertAllowedDownloadUrl,
|
|
1449
|
+
_downloadFileForTesting: downloadFile,
|
|
1450
|
+
_extractArchiveSafelyForTesting: extractArchiveSafely,
|
|
1451
|
+
_precheckArchiveSizeForTesting: precheckArchiveContents,
|
|
1452
|
+
_runArchiveToolForTesting: runArchiveTool,
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
//# sourceMappingURL=lsp-github-install.js.map
|