@mostra/sdk 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,375 @@
1
+ /**
2
+ * The local cache of runtime bundles (RT-01): one directory per archive digest, so a bundle is
3
+ * found by what it is, not by what it's called. A bundle enters the cache only through
4
+ * {@link installPinned}: downloaded against its pin, extracted into a temporary directory, its
5
+ * files checked, renamed into place, its browser fetched, and only then marked ready. Concurrent
6
+ * installers of one bundle share a lock, so it is downloaded once; a crash leaves a temporary
7
+ * directory the next install removes, never a half-ready bundle.
8
+ */
9
+ import { randomBytes } from "node:crypto";
10
+ import {
11
+ link,
12
+ mkdir,
13
+ open,
14
+ readdir,
15
+ readFile,
16
+ rename,
17
+ rm,
18
+ stat,
19
+ statfs,
20
+ utimes,
21
+ } from "node:fs/promises";
22
+ import { homedir } from "node:os";
23
+ import { join, resolve } from "node:path";
24
+ import {
25
+ type BundleAsset,
26
+ BundleAsset as BundleAssetSchema,
27
+ type BundlePins,
28
+ type BundlePlatform,
29
+ bundleAssetName,
30
+ createDiagnostic,
31
+ RpcError,
32
+ } from "@mostra/protocol";
33
+ import { type DownloadProgress, download } from "./download.ts";
34
+ import { extractBundle } from "./extract.ts";
35
+ import {
36
+ type Bundle,
37
+ checkFiles,
38
+ type Environment,
39
+ offline,
40
+ readBundle,
41
+ readSetup,
42
+ type SetupStep,
43
+ setupBundle,
44
+ } from "./setup.ts";
45
+
46
+ /** Where the cache is: `MOSTRA_CACHE_DIR`, else the platform's per-user cache directory. */
47
+ export function cacheRoot(env: Environment, platform: string = process.platform): string {
48
+ const override = env["MOSTRA_CACHE_DIR"];
49
+ if (override) return resolve(override);
50
+ const home = env["HOME"] || homedir();
51
+ if (platform === "darwin") return join(home, "Library", "Caches", "mostra");
52
+ const xdg = env["XDG_CACHE_HOME"];
53
+ return join(xdg?.startsWith("/") ? xdg : join(home, ".cache"), "mostra");
54
+ }
55
+
56
+ /** The directory a bundle with this archive digest lives in. */
57
+ export function bundleDir(env: Environment, sha256: string): string {
58
+ return join(cacheRoot(env), "bundles", sha256);
59
+ }
60
+
61
+ /** What an install reports while it works. */
62
+ export type InstallProgress =
63
+ | ({ readonly step: "download" } & DownloadProgress)
64
+ | { readonly step: "extract" | "lock" }
65
+ | { readonly step: SetupStep };
66
+
67
+ /** Options of {@link installPinned}. */
68
+ export interface InstallOptions {
69
+ /** The environment: the cache, mirror and offline variables, proxies. */
70
+ readonly env: Environment;
71
+ /** The platform whose archive to install. */
72
+ readonly platform: BundlePlatform;
73
+ /** Gives up the install; nothing partial is kept. */
74
+ readonly signal?: AbortSignal | undefined;
75
+ /** Each step, and the bytes received while downloading. */
76
+ readonly onProgress?: ((progress: InstallProgress) => void) | undefined;
77
+ }
78
+
79
+ /** The space an install needs beyond the archive and its files: the browser and some margin. */
80
+ const BROWSER_ALLOWANCE = 500 * 1024 * 1024;
81
+
82
+ /**
83
+ * The pinned bundle for this platform, ready to run: from the cache when it is there and ready,
84
+ * else installed. `MOSTRA_BUNDLE_MIRROR` replaces the release URL (files keep their names).
85
+ */
86
+ export async function installPinned(pins: BundlePins, options: InstallOptions): Promise<Bundle> {
87
+ const asset = pins.assets[options.platform];
88
+ // Checked before any I/O: the asset's name becomes a path in the cache's scratch directory and
89
+ // its digest the bundle's directory (Opus review finding 8), so the asset must be well-formed
90
+ // and named exactly as its release's archive for this platform.
91
+ if (
92
+ asset !== undefined &&
93
+ (!BundleAssetSchema.safeParse(asset).success ||
94
+ asset.file !== bundleAssetName(pins.version, options.platform))
95
+ ) {
96
+ throw new RpcError(
97
+ createDiagnostic("MOSTRA_ENV_BUNDLE_INVALID", {
98
+ message: `The pins name ${JSON.stringify(asset.file)} for ${options.platform}, not Mostra ${pins.version}'s archive ${bundleAssetName(pins.version, options.platform)}.`,
99
+ }),
100
+ );
101
+ }
102
+ if (asset === undefined) {
103
+ throw new RpcError(
104
+ createDiagnostic("MOSTRA_ENV_BUNDLE_UNAVAILABLE", {
105
+ message: `Mostra ${pins.version} has no runtime bundle for ${options.platform}.`,
106
+ }),
107
+ );
108
+ }
109
+ const dir = bundleDir(options.env, asset.sha256);
110
+ const ready = await readyAt(dir);
111
+ if (ready) return ready;
112
+ if (offline(options.env)) {
113
+ throw new RpcError(
114
+ createDiagnostic("MOSTRA_ENV_BUNDLE_UNAVAILABLE", {
115
+ message: `Mostra ${pins.version}'s runtime bundle isn't in the cache (${dir}), and MOSTRA_OFFLINE keeps Mostra from fetching it.`,
116
+ }),
117
+ );
118
+ }
119
+ const bundles = join(cacheRoot(options.env), "bundles");
120
+ await mkdir(bundles, { recursive: true, mode: 0o700 });
121
+ options.onProgress?.({ step: "lock" });
122
+ const release = await lock(join(bundles, `${asset.sha256}.lock`), options.signal);
123
+ try {
124
+ const raced = await readyAt(dir);
125
+ if (raced) return raced;
126
+ await sweep(bundles);
127
+ if (await exists(dir)) {
128
+ // A bundle whose setup didn't finish (no network for the browser, a crash). Only its own
129
+ // files decide whether it is downloaded afresh: a file that differs from the pins can't be
130
+ // repaired in place, but the browser can be fetched again, and downloading 200 MB again
131
+ // never changes what Playwright serves (Opus review finding 5). Anything else, such as a
132
+ // permission error, is thrown: it says nothing about the bundle, so nothing is deleted.
133
+ if (await filesIntact(dir)) return await finish(dir, { ...options, filesChecked: true });
134
+ await rm(dir, { recursive: true, force: true });
135
+ }
136
+ await fetchInto(pins, asset, dir, bundles, options);
137
+ return await finish(dir, { ...options, filesChecked: true });
138
+ } finally {
139
+ await release();
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Whether the bundle at `dir` holds exactly what its `files.json` lists. `false` only for
145
+ * `MOSTRA_ENV_BUNDLE_INVALID` (a missing or changed file, a `bundle.json` that doesn't read);
146
+ * every other error is thrown.
147
+ */
148
+ async function filesIntact(dir: string): Promise<boolean> {
149
+ try {
150
+ await checkFiles(await readBundle(dir));
151
+ return true;
152
+ } catch (error) {
153
+ if (error instanceof RpcError && error.diagnostic.code === "MOSTRA_ENV_BUNDLE_INVALID")
154
+ return false;
155
+ throw error;
156
+ }
157
+ }
158
+
159
+ /** The bundle at `dir` when it exists and its setup marker matches it. */
160
+ async function readyAt(dir: string): Promise<Bundle | undefined> {
161
+ const bundle = await readBundle(dir).catch(() => undefined);
162
+ if (bundle === undefined) return undefined;
163
+ return (await readSetup(bundle)) ? bundle : undefined;
164
+ }
165
+
166
+ async function finish(
167
+ dir: string,
168
+ options: InstallOptions & { readonly filesChecked?: boolean },
169
+ ): Promise<Bundle> {
170
+ await setupBundle(dir, {
171
+ env: options.env,
172
+ signal: options.signal,
173
+ filesChecked: options.filesChecked,
174
+ onStep: (step) => options.onProgress?.({ step }),
175
+ });
176
+ return readBundle(dir);
177
+ }
178
+
179
+ /** Downloads, extracts and checks the archive, then renames it into `dir`. */
180
+ async function fetchInto(
181
+ pins: BundlePins,
182
+ asset: BundleAsset,
183
+ dir: string,
184
+ bundles: string,
185
+ options: InstallOptions,
186
+ ): Promise<void> {
187
+ await requireSpace(bundles, asset);
188
+ const scratch = join(bundles, `.tmp-${process.pid}-${randomBytes(6).toString("hex")}`);
189
+ await mkdir(scratch, { mode: 0o700 });
190
+ try {
191
+ const archive = join(scratch, asset.file);
192
+ const base = options.env["MOSTRA_BUNDLE_MIRROR"] || pins.release;
193
+ const url = `${withoutTrailingSlashes(base)}/${encodeURIComponent(asset.file)}`;
194
+ await download(url, archive, asset, {
195
+ env: options.env,
196
+ signal: options.signal,
197
+ onProgress: (progress) => options.onProgress?.({ step: "download", ...progress }),
198
+ });
199
+ options.onProgress?.({ step: "extract" });
200
+ const root = join(scratch, "root");
201
+ await mkdir(root);
202
+ const top = asset.file.replace(/\.tar\.gz$/, "");
203
+ if (top !== bundleAssetName(pins.version, options.platform).replace(/\.tar\.gz$/, "")) {
204
+ throw new RpcError(
205
+ createDiagnostic("MOSTRA_ENV_BUNDLE_INVALID", {
206
+ message: `The pinned archive ${asset.file} isn't Mostra ${pins.version}'s bundle for ${options.platform}.`,
207
+ }),
208
+ );
209
+ }
210
+ await extractBundle(archive, root, top);
211
+ await rm(archive, { force: true });
212
+ const bundle = await readBundle(root);
213
+ if (bundle.info.version !== pins.version || bundle.info.platform !== options.platform) {
214
+ throw new RpcError(
215
+ createDiagnostic("MOSTRA_ENV_BUNDLE_INVALID", {
216
+ message: `The archive holds Mostra ${bundle.info.version} for ${bundle.info.platform}, not ${pins.version} for ${options.platform}.`,
217
+ }),
218
+ );
219
+ }
220
+ options.onProgress?.({ step: "files" });
221
+ await checkFiles(bundle);
222
+ try {
223
+ await rename(root, dir);
224
+ } catch (error) {
225
+ // Another installer finished first (the lock is an optimization, never what correctness
226
+ // rests on): its directory is used, whose files it checked before renaming them into place.
227
+ const code = (error as NodeJS.ErrnoException).code;
228
+ if (!(code === "ENOTEMPTY" || code === "EEXIST") || !(await exists(dir))) throw error;
229
+ }
230
+ } finally {
231
+ await rm(scratch, { recursive: true, force: true });
232
+ }
233
+ }
234
+
235
+ /** Fails early when the cache's file system can't hold the bundle. */
236
+ async function requireSpace(dir: string, asset: BundleAsset): Promise<void> {
237
+ const fs = await statfs(dir).catch(() => undefined);
238
+ if (fs === undefined) return;
239
+ const free = Number(fs.bavail) * Number(fs.bsize);
240
+ const needed = asset.size + asset.unpackedSize + BROWSER_ALLOWANCE;
241
+ if (free < needed) {
242
+ const gib = (bytes: number): string => `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
243
+ throw new RpcError(
244
+ createDiagnostic("MOSTRA_ENV_BUNDLE_DOWNLOAD_FAILED", {
245
+ message: `The runtime bundle needs about ${gib(needed)} free in ${dir}, and ${gib(free)} is free (the disk is full).`,
246
+ }),
247
+ );
248
+ }
249
+ }
250
+
251
+ /** Removes temporary directories an interrupted install left, when their process is gone. */
252
+ async function sweep(bundles: string): Promise<void> {
253
+ for (const name of await readdir(bundles).catch(() => [] as string[])) {
254
+ const match = /^\.tmp-(\d+)-/.exec(name);
255
+ if (match && !alive(Number(match[1]))) {
256
+ await rm(join(bundles, name), { recursive: true, force: true });
257
+ }
258
+ }
259
+ }
260
+
261
+ async function exists(path: string): Promise<boolean> {
262
+ return stat(path).then(
263
+ () => true,
264
+ () => false,
265
+ );
266
+ }
267
+
268
+ function alive(pid: number): boolean {
269
+ try {
270
+ process.kill(pid, 0);
271
+ return true;
272
+ } catch (error) {
273
+ return (error as NodeJS.ErrnoException).code === "EPERM";
274
+ }
275
+ }
276
+
277
+ /** How old a lock may get before another installer takes it over whatever its owner. */
278
+ const STALE_LOCK_MS = 60 * 60_000;
279
+ /**
280
+ * How often a held lock is touched, so an install longer than {@link STALE_LOCK_MS} (a slow
281
+ * network, a large browser) is never taken from its live owner (Opus review of WP-12, finding 10).
282
+ */
283
+ const LOCK_TOUCH_MS = 60_000;
284
+
285
+ /** `url` without trailing slashes, in linear time (a regex would be quadratic on many slashes). */
286
+ function withoutTrailingSlashes(url: string): string {
287
+ let end = url.length;
288
+ while (end > 0 && url[end - 1] === "/") end -= 1;
289
+ return url.slice(0, end);
290
+ }
291
+
292
+ /**
293
+ * Removes the lock at `path` when it is stale (its owner gone, or older than an hour) and says
294
+ * whether it did. The lock is read and judged through one descriptor, then moved aside, and only
295
+ * the file judged is removed: a fresh lock another installer took in between is put back (or,
296
+ * if a third took one meanwhile, left to the next round). The lock only saves a second download;
297
+ * the scratch directory and its rename keep the cache correct whatever happens here.
298
+ */
299
+ async function takeOverIfStale(path: string): Promise<boolean> {
300
+ let handle: Awaited<ReturnType<typeof open>>;
301
+ try {
302
+ handle = await open(path, "r");
303
+ } catch {
304
+ return true; // gone already: try to take it
305
+ }
306
+ let judged: { ino: number; dev: number };
307
+ try {
308
+ const info = await handle.stat();
309
+ const owner = Number.parseInt(await handle.readFile("utf8"), 10);
310
+ const stale =
311
+ Date.now() - info.mtimeMs > STALE_LOCK_MS ||
312
+ (Number.isInteger(owner) && owner > 0 && !alive(owner));
313
+ if (!stale) return false;
314
+ judged = { ino: info.ino, dev: info.dev };
315
+ } finally {
316
+ await handle.close();
317
+ }
318
+ const aside = `${path}.stale-${randomBytes(6).toString("hex")}`;
319
+ try {
320
+ await rename(path, aside);
321
+ } catch {
322
+ return true; // someone else moved or removed it
323
+ }
324
+ const moved = await stat(aside).catch(() => undefined);
325
+ if (moved && (moved.ino !== judged.ino || moved.dev !== judged.dev)) {
326
+ // Not the file judged stale: a fresh lock. Put it back unless a newer one exists.
327
+ await link(aside, path).catch(() => undefined);
328
+ }
329
+ await rm(aside, { force: true });
330
+ return true;
331
+ }
332
+
333
+ /**
334
+ * Takes the install lock at `path`: an exclusive create holding the owner's PID. A lock whose
335
+ * owner is gone, or older than an hour, is taken over; otherwise the caller waits for it.
336
+ */
337
+ async function lock(path: string, signal: AbortSignal | undefined): Promise<() => Promise<void>> {
338
+ const token = randomBytes(8).toString("hex");
339
+ for (;;) {
340
+ signal?.throwIfAborted();
341
+ try {
342
+ const handle = await open(path, "wx", 0o600);
343
+ await handle.writeFile(`${process.pid} ${token}\n`);
344
+ await handle.close();
345
+ const touch = setInterval(() => {
346
+ void readFile(path, "utf8")
347
+ .then((text) => {
348
+ if (text.includes(token)) return utimes(path, new Date(), new Date());
349
+ return undefined;
350
+ })
351
+ .catch(() => undefined);
352
+ }, LOCK_TOUCH_MS);
353
+ touch.unref();
354
+ // Released by moving it aside and removing it only if it is still this installer's own.
355
+ return async () => {
356
+ clearInterval(touch);
357
+ const aside = `${path}.done-${randomBytes(6).toString("hex")}`;
358
+ if (
359
+ !(await rename(path, aside).then(
360
+ () => true,
361
+ () => false,
362
+ ))
363
+ )
364
+ return;
365
+ const text = await readFile(aside, "utf8").catch(() => "");
366
+ if (!text.includes(token)) await link(aside, path).catch(() => undefined);
367
+ await rm(aside, { force: true });
368
+ };
369
+ } catch (error) {
370
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
371
+ }
372
+ if (await takeOverIfStale(path)) continue;
373
+ await new Promise((done) => setTimeout(done, 250));
374
+ }
375
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Fetching a pinned archive (RT-01): streamed to disk while it is hashed, never longer than the
3
+ * pin says, through the environment's proxy (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`), retried on
4
+ * failures that a retry can cure, and checked against the pin before anyone reads it.
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import { createReadStream, createWriteStream } from "node:fs";
8
+ import { rm } from "node:fs/promises";
9
+ import { Readable } from "node:stream";
10
+ import { pipeline } from "node:stream/promises";
11
+ import { fileURLToPath } from "node:url";
12
+ import { createDiagnostic, RpcError } from "@mostra/protocol";
13
+ import { EnvHttpProxyAgent, fetch } from "undici";
14
+
15
+ /** What a download must match. */
16
+ export interface Expected {
17
+ readonly sha256: string;
18
+ readonly size: number;
19
+ }
20
+
21
+ /** Progress of one download, in bytes. */
22
+ export interface DownloadProgress {
23
+ /** Bytes received so far. */
24
+ readonly received: number;
25
+ /** Bytes the pin says the archive has. */
26
+ readonly total: number;
27
+ }
28
+
29
+ /** Options of {@link download}. */
30
+ export interface DownloadOptions {
31
+ /**
32
+ * The environment whose proxy settings apply (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, either
33
+ * case): a host's own, never silently `process.env`'s. Default `process.env`.
34
+ */
35
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
36
+ readonly signal?: AbortSignal | undefined;
37
+ readonly onProgress?: ((progress: DownloadProgress) => void) | undefined;
38
+ /** How long the connection may stay silent. Default 60 s. */
39
+ readonly stallMs?: number | undefined;
40
+ /** Attempts in all, for failures a retry can cure. Default 3. */
41
+ readonly attempts?: number | undefined;
42
+ }
43
+
44
+ /** A failure a retry won't cure (an HTTP 4xx other than 408 and 429, a wrong size or digest). */
45
+ class Final extends Error {}
46
+
47
+ /**
48
+ * Downloads `url` (`https:`, `http:` or `file:`) to `dest` and checks its size and SHA-256.
49
+ * Throws `MOSTRA_ENV_BUNDLE_INVALID` when the bytes aren't the pinned ones and
50
+ * `MOSTRA_ENV_BUNDLE_DOWNLOAD_FAILED` otherwise; `dest` never survives a failure.
51
+ */
52
+ export async function download(
53
+ url: string,
54
+ dest: string,
55
+ expected: Expected,
56
+ options: DownloadOptions = {},
57
+ ): Promise<void> {
58
+ const attempts = options.attempts ?? 3;
59
+ let last: unknown;
60
+ for (let attempt = 1; attempt <= attempts; attempt++) {
61
+ try {
62
+ const actual = await once(url, dest, expected, options);
63
+ if (actual !== expected.sha256) {
64
+ throw invalid(
65
+ `The download from ${redactUrl(url)} has SHA-256 ${actual}, not the pinned ${expected.sha256}.`,
66
+ );
67
+ }
68
+ return;
69
+ } catch (error) {
70
+ await rm(dest, { force: true });
71
+ last = error;
72
+ if (error instanceof RpcError || error instanceof Final || options.signal?.aborted) break;
73
+ if ((error as NodeJS.ErrnoException).code === "ENOSPC") break;
74
+ if (attempt < attempts) await delay(1000 * 4 ** (attempt - 1), options.signal);
75
+ }
76
+ }
77
+ if (last instanceof RpcError) throw last;
78
+ if (options.signal?.aborted) throw options.signal.reason;
79
+ throw new RpcError(
80
+ createDiagnostic("MOSTRA_ENV_BUNDLE_DOWNLOAD_FAILED", {
81
+ message: `Couldn't download ${redactUrl(url)}: ${scrubUrls(describe(last))}`,
82
+ }),
83
+ );
84
+ }
85
+
86
+ async function once(
87
+ url: string,
88
+ dest: string,
89
+ expected: Expected,
90
+ options: DownloadOptions,
91
+ ): Promise<string> {
92
+ const hash = createHash("sha256");
93
+ let received = 0;
94
+ const stallMs = options.stallMs ?? 60_000;
95
+ const controller = new AbortController();
96
+ const abort = (): void => controller.abort(options.signal?.reason);
97
+ options.signal?.addEventListener("abort", abort, { once: true });
98
+ let timer: ReturnType<typeof setTimeout> | undefined;
99
+ const arm = (): void => {
100
+ if (timer !== undefined) clearTimeout(timer);
101
+ timer = setTimeout(
102
+ () => controller.abort(new Error(`no data for ${stallMs / 1000} s`)),
103
+ stallMs,
104
+ );
105
+ };
106
+ try {
107
+ arm();
108
+ const body = await open(url, controller.signal, options.env ?? process.env);
109
+ const counter = async function* (source: AsyncIterable<Uint8Array>) {
110
+ for await (const chunk of source) {
111
+ arm();
112
+ received += chunk.length;
113
+ if (received > expected.size) {
114
+ throw invalid(
115
+ `The download from ${redactUrl(url)} is larger than the pinned ${expected.size} bytes.`,
116
+ );
117
+ }
118
+ hash.update(chunk);
119
+ options.onProgress?.({ received, total: expected.size });
120
+ yield chunk;
121
+ }
122
+ };
123
+ await pipeline(body, counter, createWriteStream(dest, { mode: 0o600 }), {
124
+ signal: controller.signal,
125
+ });
126
+ if (received !== expected.size) {
127
+ throw invalid(
128
+ `The download from ${redactUrl(url)} has ${received} bytes, not the pinned ${expected.size}.`,
129
+ );
130
+ }
131
+ return hash.digest("hex");
132
+ } catch (error) {
133
+ if (controller.signal.aborted && !(error instanceof RpcError)) {
134
+ const reason: unknown = controller.signal.reason;
135
+ throw reason instanceof Error ? reason : new Error(String(reason));
136
+ }
137
+ throw error;
138
+ } finally {
139
+ if (timer !== undefined) clearTimeout(timer);
140
+ options.signal?.removeEventListener("abort", abort);
141
+ }
142
+ }
143
+
144
+ /** The body of `url` as a stream. */
145
+ async function open(
146
+ url: string,
147
+ signal: AbortSignal,
148
+ env: Readonly<Record<string, string | undefined>>,
149
+ ): Promise<AsyncIterable<Uint8Array>> {
150
+ const parsed = new URL(url);
151
+ if (parsed.protocol === "file:") {
152
+ return createReadStream(fileURLToPath(parsed), { signal }) as AsyncIterable<Uint8Array>;
153
+ }
154
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
155
+ throw new Final(`${parsed.protocol} URLs aren't supported`);
156
+ }
157
+ // fetch refuses a URL with credentials, so a mirror's (`https://user:pass@host/…`) travel as
158
+ // Basic authorization, which fetch drops on a redirect to another origin.
159
+ const headers: Record<string, string> = {
160
+ "user-agent": "mostra-sdk",
161
+ accept: "application/octet-stream",
162
+ };
163
+ if (parsed.username !== "" || parsed.password !== "") {
164
+ const user = decodeURIComponent(parsed.username);
165
+ const password = decodeURIComponent(parsed.password);
166
+ headers["authorization"] = `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`;
167
+ parsed.username = "";
168
+ parsed.password = "";
169
+ }
170
+ const response = await fetch(parsed, {
171
+ dispatcher: proxyAgent(env),
172
+ redirect: "follow",
173
+ signal,
174
+ headers,
175
+ });
176
+ if (!response.ok || response.body === null) {
177
+ const status = response.status;
178
+ await response.body?.cancel().catch(() => undefined);
179
+ const message = `HTTP ${status}${response.statusText ? ` ${response.statusText}` : ""}`;
180
+ if (status >= 400 && status < 500 && status !== 408 && status !== 429) throw new Final(message);
181
+ throw new Error(message);
182
+ }
183
+ return Readable.fromWeb(response.body as import("node:stream/web").ReadableStream<Uint8Array>);
184
+ }
185
+
186
+ const agents = new Map<string, EnvHttpProxyAgent>();
187
+ /**
188
+ * The agent for `env`'s `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY` (lowercase first, as curl
189
+ * reads them), one per distinct setting. Each is passed explicitly, an empty one included, so
190
+ * undici never falls back to `process.env` (GPT review finding 6).
191
+ */
192
+ function proxyAgent(env: Readonly<Record<string, string | undefined>>): EnvHttpProxyAgent {
193
+ const settings = {
194
+ httpProxy: env["http_proxy"] ?? env["HTTP_PROXY"] ?? "",
195
+ httpsProxy: env["https_proxy"] ?? env["HTTPS_PROXY"] ?? "",
196
+ noProxy: env["no_proxy"] ?? env["NO_PROXY"] ?? "",
197
+ };
198
+ const key = JSON.stringify(settings);
199
+ let agent = agents.get(key);
200
+ if (agent === undefined) {
201
+ agent = new EnvHttpProxyAgent(settings);
202
+ agents.set(key, agent);
203
+ }
204
+ return agent;
205
+ }
206
+
207
+ /**
208
+ * `text` with every URL in it reduced to {@link redactUrl}'s form: an error from the network stack
209
+ * repeats the URL it failed on, credentials, query and all (a mirror's or a proxy's), and a
210
+ * diagnostic is printed (GPT review finding 2).
211
+ */
212
+ export function scrubUrls(text: string): string {
213
+ return text.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s"'<>()]+/gi, (url) => redactUrl(url));
214
+ }
215
+
216
+ function invalid(message: string): RpcError {
217
+ return new RpcError(createDiagnostic("MOSTRA_ENV_BUNDLE_INVALID", { message }));
218
+ }
219
+
220
+ /** A URL without credentials or a query, safe to print. */
221
+ export function redactUrl(url: string): string {
222
+ try {
223
+ const parsed = new URL(url);
224
+ parsed.username = "";
225
+ parsed.password = "";
226
+ parsed.search = "";
227
+ parsed.hash = "";
228
+ return parsed.toString();
229
+ } catch {
230
+ return "(an invalid URL)";
231
+ }
232
+ }
233
+
234
+ function describe(error: unknown): string {
235
+ if (!(error instanceof Error)) return String(error);
236
+ const cause = (error as { cause?: unknown }).cause;
237
+ const code = (cause as { code?: string } | undefined)?.code ?? (error as { code?: string }).code;
238
+ return code !== undefined && !error.message.includes(code)
239
+ ? `${error.message} (${code})`
240
+ : error.message;
241
+ }
242
+
243
+ function delay(ms: number, signal: AbortSignal | undefined): Promise<void> {
244
+ return new Promise((done) => {
245
+ const timer = setTimeout(done, ms);
246
+ signal?.addEventListener(
247
+ "abort",
248
+ () => {
249
+ clearTimeout(timer);
250
+ done();
251
+ },
252
+ { once: true },
253
+ );
254
+ });
255
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Unpacking a downloaded archive (RT-01). Only regular files, directories and symbolic links are
3
+ * accepted, all under the archive's one top directory, which is stripped. node-tar refuses
4
+ * absolute paths, `..` and writing through a link; it does create a link whatever its target, so
5
+ * setup then checks the whole tree against the bundle's file list (`checkTree`), which refuses any
6
+ * file or link it doesn't list and any link that leaves the bundle. A pinned archive's SHA-256 is
7
+ * checked before any of this.
8
+ */
9
+ import { createDiagnostic, RpcError } from "@mostra/protocol";
10
+ import { x } from "tar";
11
+
12
+ const ACCEPTED = new Set(["File", "OldFile", "ContiguousFile", "Directory", "SymbolicLink"]);
13
+
14
+ /** Extracts `archive` (a `.tar.gz` with one top directory `top`) into the empty directory `into`. */
15
+ export async function extractBundle(archive: string, into: string, top: string): Promise<void> {
16
+ let refused: string | undefined;
17
+ try {
18
+ await x({
19
+ file: archive,
20
+ cwd: into,
21
+ strip: 1,
22
+ strict: true,
23
+ preservePaths: false,
24
+ preserveOwner: false,
25
+ // Owner-only directories are tightened later; files keep their 0644/0755 modes.
26
+ noChmod: false,
27
+ filter: (path, entry) => {
28
+ const type = "type" in entry ? String(entry.type) : "File";
29
+ const inside = path === `${top}/` || path === top || path.startsWith(`${top}/`);
30
+ if (!inside || !ACCEPTED.has(type)) {
31
+ refused ??= `${path} (${type})`;
32
+ return false;
33
+ }
34
+ return true;
35
+ },
36
+ });
37
+ } catch (error) {
38
+ const code = (error as NodeJS.ErrnoException).code;
39
+ // The file system failing (a full disk, a permission) isn't the archive's fault.
40
+ if (typeof code === "string" && /^E[A-Z]+$/.test(code) && code !== "EOF") {
41
+ throw new RpcError(
42
+ createDiagnostic("MOSTRA_ENV_BUNDLE_DOWNLOAD_FAILED", {
43
+ message: `The runtime bundle couldn't be written to ${into}: ${(error as Error).message}${code === "ENOSPC" ? " (the disk is full)" : ""}`,
44
+ }),
45
+ );
46
+ }
47
+ throw new RpcError(
48
+ createDiagnostic("MOSTRA_ENV_BUNDLE_INVALID", {
49
+ message: `The runtime bundle's archive couldn't be read: ${(error as Error).message}`,
50
+ }),
51
+ );
52
+ }
53
+ if (refused !== undefined) {
54
+ throw new RpcError(
55
+ createDiagnostic("MOSTRA_ENV_BUNDLE_INVALID", {
56
+ message: `The runtime bundle's archive holds an entry a bundle never has: ${refused}.`,
57
+ }),
58
+ );
59
+ }
60
+ }