@moldesk/runtime 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/docker/index.d.ts +41 -4
  2. package/dist/docker/index.js +140 -14
  3. package/dist/docker/index.test.d.ts +1 -0
  4. package/dist/docker/index.test.js +108 -0
  5. package/dist/filesystem/companion-inputs.d.ts +34 -0
  6. package/dist/filesystem/companion-inputs.js +123 -0
  7. package/dist/filesystem/companion-inputs.test.d.ts +1 -0
  8. package/dist/filesystem/companion-inputs.test.js +256 -0
  9. package/dist/filesystem/index.d.ts +4 -0
  10. package/dist/filesystem/index.js +2 -0
  11. package/dist/filesystem/paths.d.ts +20 -0
  12. package/dist/filesystem/paths.js +79 -0
  13. package/dist/filesystem/paths.test.d.ts +1 -0
  14. package/dist/filesystem/paths.test.js +35 -0
  15. package/dist/hardware/index.d.ts +21 -0
  16. package/dist/hardware/index.js +62 -0
  17. package/dist/hardware/index.test.d.ts +1 -0
  18. package/dist/hardware/index.test.js +27 -0
  19. package/dist/index.d.ts +22 -5
  20. package/dist/index.js +17 -12
  21. package/dist/install/index.d.ts +83 -0
  22. package/dist/install/index.js +487 -0
  23. package/dist/install/index.test.d.ts +1 -0
  24. package/dist/install/index.test.js +628 -0
  25. package/dist/nvidia/index.d.ts +29 -0
  26. package/dist/nvidia/index.js +119 -0
  27. package/dist/nvidia/index.test.d.ts +1 -0
  28. package/dist/nvidia/index.test.js +91 -0
  29. package/dist/process/index.d.ts +34 -0
  30. package/dist/process/index.js +99 -4
  31. package/dist/process/index.test.d.ts +1 -0
  32. package/dist/process/index.test.js +36 -0
  33. package/dist/providers/python.d.ts +14 -0
  34. package/dist/providers/python.js +80 -0
  35. package/dist/providers/python.test.d.ts +1 -0
  36. package/dist/providers/python.test.js +68 -0
  37. package/dist/providers.d.ts +49 -0
  38. package/dist/providers.js +1 -0
  39. package/dist/providers.test.d.ts +1 -0
  40. package/dist/providers.test.js +10 -0
  41. package/dist/python/index.d.ts +14 -3
  42. package/dist/python/index.js +30 -12
  43. package/dist/redact.d.ts +6 -0
  44. package/dist/redact.js +16 -0
  45. package/dist/redact.test.d.ts +1 -0
  46. package/dist/redact.test.js +16 -0
  47. package/dist/system.d.ts +39 -0
  48. package/dist/system.js +42 -0
  49. package/dist/system.test.d.ts +1 -0
  50. package/dist/system.test.js +138 -0
  51. package/package.json +8 -3
@@ -0,0 +1,628 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { afterEach, describe, expect, it } from "vitest";
7
+ import { getMoldeskPaths } from "../filesystem/index.js";
8
+ import { cacheAsset, materializeAsset, ensureManagedUv, MANAGED_UV_VERSION, prepareDockerImage, preparePythonEnvironment, UV_RELEASES, } from "./index.js";
9
+ const platformId = `${process.platform}-${process.arch}`;
10
+ const release = UV_RELEASES[platformId];
11
+ const homes = [];
12
+ function tempPaths() {
13
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk-runtime-install-"));
14
+ homes.push(home);
15
+ return getMoldeskPaths({ MOLDESK_HOME: home });
16
+ }
17
+ function response(bytes, status = 200) {
18
+ let sent = false;
19
+ return {
20
+ ok: status >= 200 && status < 300,
21
+ status,
22
+ headers: { get: (name) => name.toLowerCase() === "content-length" ? String(bytes.byteLength) : null },
23
+ body: {
24
+ getReader: () => ({
25
+ read: async () => sent ? { done: true } : (sent = true, { done: false, value: bytes }),
26
+ }),
27
+ },
28
+ };
29
+ }
30
+ afterEach(() => {
31
+ for (const home of homes.splice(0))
32
+ fs.rmSync(home, { recursive: true, force: true });
33
+ });
34
+ describe("installation runtime", () => {
35
+ it.skipIf(process.platform === "win32").each(["tar", "tar.gz"])("extracts %s assets with local ownership rather than archive uid/gid", async (archive) => {
36
+ const paths = tempPaths();
37
+ const source = path.join(paths.home, "fixture");
38
+ fs.mkdirSync(source);
39
+ fs.writeFileSync(path.join(source, "weights.bin"), "verified checkpoint fixture");
40
+ const object = path.join(paths.home, `fixture.${archive}`);
41
+ const uid = process.getuid?.() ?? 0;
42
+ const gid = process.getgid?.() ?? 0;
43
+ const ownerArgs = process.platform === "darwin"
44
+ ? ["--uid", String(uid + 10000), "--gid", String(gid + 10000)]
45
+ : [`--owner=${uid + 10000}`, `--group=${gid + 10000}`];
46
+ execFileSync("tar", [archive === "tar.gz" ? "-czf" : "-cf", object, ...ownerArgs, "-C", source, "weights.bin"]);
47
+ const target = await materializeAsset({ id: "fixture", url: "https://example.test/fixture", sha256: createHash("sha256").update(fs.readFileSync(object)).digest("hex"), target: "weights", archive }, object, path.join(paths.home, "assets"));
48
+ const extracted = path.join(target, "weights.bin");
49
+ expect(fs.readFileSync(extracted, "utf8")).toBe("verified checkpoint fixture");
50
+ expect(fs.statSync(extracted).uid).toBe(uid);
51
+ expect(fs.statSync(extracted).gid).toBe(gid);
52
+ });
53
+ it("resumes downloads, verifies SHA-256, and reuses the content-addressed object", async () => {
54
+ const paths = tempPaths();
55
+ const bytes = Buffer.from("checkpoint-content");
56
+ const checksum = createHash("sha256").update(bytes).digest("hex");
57
+ fs.mkdirSync(paths.downloads, { recursive: true });
58
+ fs.writeFileSync(path.join(paths.downloads, `${checksum}.part`), bytes.subarray(0, 5));
59
+ const calls = [];
60
+ const fetcher = async (_url, init) => {
61
+ calls.push(init ?? {});
62
+ return response(bytes.subarray(5), 206);
63
+ };
64
+ const asset = {
65
+ id: "weights",
66
+ url: "https://example.test/weights.pt",
67
+ sha256: checksum,
68
+ target: "weights.pt",
69
+ archive: "none",
70
+ };
71
+ const object = await cacheAsset(asset, paths, { fetch: fetcher });
72
+ expect(calls[0]?.headers?.["Range"]).toBe("bytes=5-");
73
+ expect(fs.readFileSync(object)).toEqual(bytes);
74
+ await cacheAsset(asset, paths, { fetch: async () => { throw new Error("cache miss"); } });
75
+ });
76
+ it("rejects a checksum mismatch and serializes concurrent downloads by digest", async () => {
77
+ const paths = tempPaths();
78
+ const bytes = Buffer.from("verified bytes");
79
+ const checksum = createHash("sha256").update(bytes).digest("hex");
80
+ const asset = { id: "asset", url: "https://example.test/asset", sha256: checksum, target: "asset" };
81
+ await expect(cacheAsset(asset, paths, { fetch: async () => response(Buffer.from("wrong")) })).rejects.toMatchObject({ code: "ASSET_CHECKSUM_MISMATCH" });
82
+ let fetches = 0;
83
+ const fetcher = async () => {
84
+ fetches += 1;
85
+ await new Promise((resolve) => setTimeout(resolve, 20));
86
+ return response(bytes);
87
+ };
88
+ const [first, second] = await Promise.all([
89
+ cacheAsset(asset, paths, { fetch: fetcher }),
90
+ cacheAsset(asset, paths, { fetch: fetcher }),
91
+ ]);
92
+ expect(first).toBe(second);
93
+ expect(fetches).toBe(1);
94
+ });
95
+ it("uses shell-free pinned git and uv commands, and forces the managed Python interpreter", async () => {
96
+ const paths = tempPaths();
97
+ const targetDir = path.join(paths.home, "model");
98
+ const calls = [];
99
+ const revision = "a".repeat(40);
100
+ const result = await preparePythonEnvironment({
101
+ targetDir,
102
+ repository: "https://github.com/example/model",
103
+ revision,
104
+ runtime: {
105
+ kind: "python",
106
+ python: "3.11",
107
+ installer: "uv",
108
+ requirements: [{ name: "numpy", version: "1.26.4" }],
109
+ },
110
+ uvExecutable: "uv",
111
+ paths,
112
+ runner: async (command, args, options) => {
113
+ calls.push({ command, args, env: options?.env });
114
+ if (args.includes("rev-parse"))
115
+ return { code: 0, stdout: `${revision}\n`, stderr: "" };
116
+ if (args.includes("freeze"))
117
+ return { code: 0, stdout: "numpy==1.26.4\n", stderr: "" };
118
+ if (args[0] === "--version")
119
+ return { code: 0, stdout: "Python 3.11.9\n", stderr: "" };
120
+ return { code: 0, stdout: "", stderr: "" };
121
+ },
122
+ });
123
+ expect(result.lock).toEqual(["numpy==1.26.4"]);
124
+ expect(calls).toContainEqual(expect.objectContaining({ command: "git", args: ["-C", path.join(targetDir, "source"), "fetch", "--depth", "1", "origin", revision] }));
125
+ expect(calls).toContainEqual(expect.objectContaining({ command: "uv", args: ["pip", "install", "--python", path.join(targetDir, ".venv", "bin", "python"), "numpy==1.26.4"] }));
126
+ // --relocatable: installs land in a `.partial-*` staging dir that gets
127
+ // renamed to its final target on success — without this, console-script
128
+ // shebangs bake in the staging path and break after the rename (confirmed
129
+ // by a real install+run: `exec: .../.partial-.../.venv/bin/python: No such
130
+ // file or directory`).
131
+ expect(calls).toContainEqual(expect.objectContaining({ command: "uv", args: ["venv", "--relocatable", "--python", "3.11", path.join(targetDir, ".venv")] }));
132
+ // Every uv invocation must force provisioning/using a managed interpreter, never a host one —
133
+ // this is what lets the Python provider work with host Python absent from PATH.
134
+ const uvCalls = calls.filter((call) => call.command === "uv");
135
+ expect(uvCalls.length).toBeGreaterThan(0);
136
+ for (const call of uvCalls) {
137
+ expect(call.env?.["UV_PYTHON_PREFERENCE"]).toBe("only-managed");
138
+ expect(call.env?.["UV_PYTHON_INSTALL_DIR"]).toContain(paths.home);
139
+ }
140
+ });
141
+ it("wires extraIndexUrls into UV_EXTRA_INDEX_URL and findLinks into UV_FIND_LINKS, never replacing the default PyPI index, never as a raw CLI arg", async () => {
142
+ const paths = tempPaths();
143
+ const targetDir = path.join(paths.home, "model-index");
144
+ const calls = [];
145
+ const revision = "d".repeat(40);
146
+ await preparePythonEnvironment({
147
+ targetDir,
148
+ repository: "https://github.com/example/cuda-model",
149
+ revision,
150
+ runtime: {
151
+ kind: "python",
152
+ python: "3.11",
153
+ installer: "uv",
154
+ extraIndexUrls: ["https://download.pytorch.org/whl/cu117"],
155
+ findLinks: ["https://data.pyg.org/whl/torch-1.13.1+cu117.html"],
156
+ requirements: [{ name: "torch", version: "1.13.1+cu117" }],
157
+ },
158
+ uvExecutable: "uv",
159
+ paths,
160
+ runner: async (command, args, options) => {
161
+ calls.push({ command, args, env: options?.env });
162
+ if (args.includes("rev-parse"))
163
+ return { code: 0, stdout: `${revision}\n`, stderr: "" };
164
+ if (args.includes("freeze"))
165
+ return { code: 0, stdout: "torch==1.13.1+cu117\n", stderr: "" };
166
+ if (args[0] === "--version")
167
+ return { code: 0, stdout: "Python 3.11.9\n", stderr: "" };
168
+ return { code: 0, stdout: "", stderr: "" };
169
+ },
170
+ });
171
+ const uvCalls = calls.filter((call) => call.command === "uv");
172
+ expect(uvCalls.length).toBeGreaterThan(0);
173
+ for (const call of uvCalls) {
174
+ // Default PyPI is never overridden — only an extra index is added.
175
+ expect(call.env?.["UV_INDEX_URL"]).toBeUndefined();
176
+ expect(call.env?.["UV_EXTRA_INDEX_URL"]).toBe("https://download.pytorch.org/whl/cu117");
177
+ expect(call.env?.["UV_FIND_LINKS"]).toBe("https://data.pyg.org/whl/torch-1.13.1+cu117.html");
178
+ // Never passed as a bare positional/CLI arg — only via env, so it can never be
179
+ // mistaken for (or smuggled in as) an arbitrary pip command fragment.
180
+ expect(call.args.join(" ")).not.toContain("download.pytorch.org");
181
+ }
182
+ });
183
+ it("omits UV_EXTRA_INDEX_URL/UV_FIND_LINKS when a runtime entry declares neither", async () => {
184
+ const paths = tempPaths();
185
+ const targetDir = path.join(paths.home, "model-no-index");
186
+ const calls = [];
187
+ const revision = "e".repeat(40);
188
+ await preparePythonEnvironment({
189
+ targetDir,
190
+ repository: "https://github.com/example/plain-model",
191
+ revision,
192
+ runtime: { kind: "python", python: "3.11", installer: "uv", requirements: [{ name: "numpy", version: "1.26.4" }] },
193
+ uvExecutable: "uv",
194
+ paths,
195
+ runner: async (command, args, options) => {
196
+ calls.push({ command, env: options?.env });
197
+ if (args.includes("rev-parse"))
198
+ return { code: 0, stdout: `${revision}\n`, stderr: "" };
199
+ if (args.includes("freeze"))
200
+ return { code: 0, stdout: "numpy==1.26.4\n", stderr: "" };
201
+ if (args[0] === "--version")
202
+ return { code: 0, stdout: "Python 3.11.9\n", stderr: "" };
203
+ return { code: 0, stdout: "", stderr: "" };
204
+ },
205
+ });
206
+ for (const call of calls.filter((c) => c.command === "uv")) {
207
+ expect(call.env?.["UV_INDEX_URL"]).toBeUndefined();
208
+ expect(call.env?.["UV_EXTRA_INDEX_URL"]).toBeUndefined();
209
+ expect(call.env?.["UV_FIND_LINKS"]).toBeUndefined();
210
+ expect(call.env?.["UV_TORCH_BACKEND"]).toBeUndefined();
211
+ }
212
+ });
213
+ it("wires torchBackend into UV_TORCH_BACKEND on every uv call, never as a raw CLI arg", async () => {
214
+ const paths = tempPaths();
215
+ const targetDir = path.join(paths.home, "model-torch-backend");
216
+ const calls = [];
217
+ const revision = "f".repeat(40);
218
+ await preparePythonEnvironment({
219
+ targetDir,
220
+ repository: "https://github.com/example/torch-model",
221
+ revision,
222
+ runtime: {
223
+ kind: "python",
224
+ python: "3.11",
225
+ installer: "uv",
226
+ torchBackend: "cu126",
227
+ requirements: [{ name: "torch", version: "2.7.1+cu126" }],
228
+ },
229
+ uvExecutable: "uv",
230
+ paths,
231
+ runner: async (command, args, options) => {
232
+ calls.push({ command, args, env: options?.env });
233
+ if (args.includes("rev-parse"))
234
+ return { code: 0, stdout: `${revision}\n`, stderr: "" };
235
+ if (args.includes("freeze"))
236
+ return { code: 0, stdout: "torch==2.7.1+cu126\n", stderr: "" };
237
+ if (args[0] === "--version")
238
+ return { code: 0, stdout: "Python 3.11.9\n", stderr: "" };
239
+ return { code: 0, stdout: "", stderr: "" };
240
+ },
241
+ });
242
+ const uvCalls = calls.filter((call) => call.command === "uv");
243
+ expect(uvCalls.length).toBeGreaterThan(0);
244
+ for (const call of uvCalls) {
245
+ expect(call.env?.["UV_TORCH_BACKEND"]).toBe("cu126");
246
+ expect(call.env?.["UV_INDEX_URL"]).toBeUndefined();
247
+ expect(call.args).not.toContain("--torch-backend");
248
+ }
249
+ });
250
+ it("passes --only-binary for every binaryOnly-named package, on both the hashed and main install passes", async () => {
251
+ const paths = tempPaths();
252
+ const targetDir = path.join(paths.home, "model-binary-only");
253
+ const installCalls = [];
254
+ const torchHash = createHash("sha256").update("torch").digest("hex");
255
+ await preparePythonEnvironment({
256
+ targetDir,
257
+ repository: "https://github.com/example/binary-only-model",
258
+ revision: "f".repeat(40),
259
+ runtime: {
260
+ kind: "python",
261
+ python: "3.10",
262
+ installer: "uv",
263
+ binaryOnly: ["torch", "torch-scatter"],
264
+ requirements: [
265
+ { name: "torch", version: "1.13.1+cu117", hashes: [torchHash] },
266
+ { name: "torch-scatter", version: "2.1.0+pt113cu117" },
267
+ { name: "torch-geometric", version: "2.2.0" },
268
+ ],
269
+ },
270
+ uvExecutable: "uv",
271
+ paths,
272
+ runner: async (command, args) => {
273
+ if (args[0] === "pip" && args[1] === "install")
274
+ installCalls.push(args);
275
+ if (args.includes("rev-parse"))
276
+ return { code: 0, stdout: `${"f".repeat(40)}\n`, stderr: "" };
277
+ if (args.includes("freeze"))
278
+ return { code: 0, stdout: "torch==1.13.1+cu117\n", stderr: "" };
279
+ if (args[0] === "--version")
280
+ return { code: 0, stdout: "Python 3.10.13\n", stderr: "" };
281
+ return { code: 0, stdout: "", stderr: "" };
282
+ },
283
+ });
284
+ expect(installCalls.length).toBeGreaterThan(0);
285
+ for (const args of installCalls) {
286
+ expect(args).toContain("--only-binary");
287
+ const binaryOnlyValues = args.flatMap((a, i) => (args[i - 1] === "--only-binary" ? [a] : []));
288
+ expect(binaryOnlyValues.sort()).toEqual(["torch", "torch-scatter"]);
289
+ // torch-geometric has no --only-binary flag of its own (no wheel exists for it at all).
290
+ expect(binaryOnlyValues).not.toContain("torch-geometric");
291
+ }
292
+ });
293
+ it("installs preInstall requirements first, each in its own call, before any other dependency install", async () => {
294
+ const paths = tempPaths();
295
+ const targetDir = path.join(paths.home, "model-pre-install");
296
+ const calls = [];
297
+ const revision = "1".repeat(40);
298
+ await preparePythonEnvironment({
299
+ targetDir,
300
+ repository: "https://github.com/example/pre-install-model",
301
+ revision,
302
+ runtime: {
303
+ kind: "python",
304
+ python: "3.10",
305
+ installer: "uv",
306
+ preInstall: [{ name: "setuptools", version: "69.5.1" }],
307
+ requirements: [{ name: "torch", version: "1.13.1+cu117" }],
308
+ },
309
+ uvExecutable: "uv",
310
+ paths,
311
+ runner: async (command, args) => {
312
+ calls.push({ command, args });
313
+ if (args.includes("rev-parse"))
314
+ return { code: 0, stdout: `${revision}\n`, stderr: "" };
315
+ if (args.includes("freeze"))
316
+ return { code: 0, stdout: "torch==1.13.1+cu117\n", stderr: "" };
317
+ if (args[0] === "--version")
318
+ return { code: 0, stdout: "Python 3.10.13\n", stderr: "" };
319
+ return { code: 0, stdout: "", stderr: "" };
320
+ },
321
+ });
322
+ const installCalls = calls.filter((c) => c.command === "uv" && c.args[0] === "pip" && c.args[1] === "install");
323
+ expect(installCalls[0]?.args).toEqual(expect.arrayContaining(["setuptools==69.5.1"]));
324
+ expect(installCalls[0]?.args).not.toContain("torch==1.13.1+cu117");
325
+ // preInstall's own call never bundles other requirements into the same invocation.
326
+ expect(installCalls[0]?.args.filter((a) => a === "setuptools==69.5.1")).toHaveLength(1);
327
+ });
328
+ it("runs preInstall against PyPI only, without extra indexes or find-links", async () => {
329
+ const paths = tempPaths();
330
+ const targetDir = path.join(paths.home, "model-pre-install-index");
331
+ const envs = [];
332
+ const revision = "3".repeat(40);
333
+ await preparePythonEnvironment({
334
+ targetDir,
335
+ repository: "https://github.com/example/pre-install-index-model",
336
+ revision,
337
+ runtime: {
338
+ kind: "python",
339
+ python: "3.10",
340
+ installer: "uv",
341
+ preInstall: [{ name: "setuptools", version: "69.5.1" }],
342
+ requirements: [{ name: "torch", version: "1.13.1+cu117" }],
343
+ extraIndexUrls: ["https://download.pytorch.org/whl/cu117"],
344
+ findLinks: ["https://data.pyg.org/whl/torch-1.13.1+cu117.html"],
345
+ },
346
+ uvExecutable: "uv",
347
+ paths,
348
+ runner: async (_command, args, options) => {
349
+ envs.push({ args, env: options?.env });
350
+ if (args.includes("rev-parse"))
351
+ return { code: 0, stdout: `${revision}\n`, stderr: "" };
352
+ if (args.includes("freeze"))
353
+ return { code: 0, stdout: "torch==1.13.1+cu117\n", stderr: "" };
354
+ if (args[0] === "--version")
355
+ return { code: 0, stdout: "Python 3.10.13\n", stderr: "" };
356
+ return { code: 0, stdout: "", stderr: "" };
357
+ },
358
+ });
359
+ const installs = envs.filter((c) => c.args[0] === "pip" && c.args[1] === "install");
360
+ expect(installs[0]?.args).toContain("setuptools==69.5.1");
361
+ expect(installs[0]?.env?.["UV_EXTRA_INDEX_URL"]).toBeUndefined();
362
+ expect(installs[0]?.env?.["UV_FIND_LINKS"]).toBeUndefined();
363
+ expect(installs[1]?.env?.["UV_EXTRA_INDEX_URL"]).toBe("https://download.pytorch.org/whl/cu117");
364
+ expect(installs[1]?.env?.["UV_INDEX_STRATEGY"]).toBe("unsafe-best-match");
365
+ expect(installs[0]?.env?.["UV_INDEX_STRATEGY"]).toBeUndefined();
366
+ });
367
+ it("defers buildAfter requirements to their own final install call, strictly after every other requirement", async () => {
368
+ const paths = tempPaths();
369
+ const targetDir = path.join(paths.home, "model-build-after");
370
+ const calls = [];
371
+ const revision = "2".repeat(40);
372
+ await preparePythonEnvironment({
373
+ targetDir,
374
+ repository: "https://github.com/example/build-after-model",
375
+ revision,
376
+ runtime: {
377
+ kind: "python",
378
+ python: "3.10",
379
+ installer: "uv",
380
+ buildAfter: ["openfold"],
381
+ requirements: [
382
+ { name: "torch", version: "1.13.1+cu117" },
383
+ { name: "openfold", source: "https://github.com/aqlaboratory/openfold", revision: "a".repeat(40) },
384
+ ],
385
+ },
386
+ uvExecutable: "uv",
387
+ paths,
388
+ runner: async (command, args) => {
389
+ calls.push({ command, args });
390
+ if (args.includes("rev-parse"))
391
+ return { code: 0, stdout: `${revision}\n`, stderr: "" };
392
+ if (args.includes("freeze"))
393
+ return { code: 0, stdout: "torch==1.13.1+cu117\nopenfold==0.0.0\n", stderr: "" };
394
+ if (args[0] === "--version")
395
+ return { code: 0, stdout: "Python 3.10.13\n", stderr: "" };
396
+ return { code: 0, stdout: "", stderr: "" };
397
+ },
398
+ });
399
+ const installCalls = calls.filter((c) => c.command === "uv" && c.args[0] === "pip" && c.args[1] === "install");
400
+ expect(installCalls).toHaveLength(2);
401
+ expect(installCalls[0]?.args).toContain("torch==1.13.1+cu117");
402
+ expect(installCalls[0]?.args.join(" ")).not.toContain("openfold");
403
+ expect(installCalls[1]?.args.join(" ")).toContain("openfold @ git+https://github.com/aqlaboratory/openfold");
404
+ expect(installCalls[1]?.args).not.toContain("torch==1.13.1+cu117");
405
+ // The deferred build must see the venv's torch, so it cannot use uv's isolated build env.
406
+ expect(installCalls[1]?.args).toContain("--no-build-isolation");
407
+ expect(installCalls[0]?.args).not.toContain("--no-build-isolation");
408
+ });
409
+ it("reuses an already-verified managed uv install without downloading", async () => {
410
+ if (!release)
411
+ return; // unsupported platform in UV_RELEASES; nothing to assert here.
412
+ const paths = tempPaths();
413
+ const toolDir = path.join(paths.home, "tools", "uv", MANAGED_UV_VERSION);
414
+ fs.mkdirSync(toolDir, { recursive: true });
415
+ fs.writeFileSync(path.join(toolDir, "uv"), "fake-uv-binary");
416
+ fs.writeFileSync(path.join(toolDir, "verified.json"), JSON.stringify({ version: MANAGED_UV_VERSION, sha256: release.sha256 }));
417
+ let fetchCalled = false;
418
+ const executable = await ensureManagedUv({
419
+ paths,
420
+ fetch: async () => { fetchCalled = true; throw new Error("must not fetch when the cached install already verifies"); },
421
+ runner: async (command, args) => {
422
+ if (command === path.join(toolDir, "uv") && args[0] === "--version") {
423
+ return { code: 0, stdout: `uv ${MANAGED_UV_VERSION}\n`, stderr: "" };
424
+ }
425
+ throw new Error(`unexpected command: ${command} ${args.join(" ")}`);
426
+ },
427
+ });
428
+ expect(executable).toBe(path.join(toolDir, "uv"));
429
+ expect(fetchCalled).toBe(false);
430
+ });
431
+ it("does not silently reuse a managed uv install with a corrupted receipt", async () => {
432
+ if (!release)
433
+ return; // unsupported platform in UV_RELEASES; nothing to assert here.
434
+ const paths = tempPaths();
435
+ const toolDir = path.join(paths.home, "tools", "uv", MANAGED_UV_VERSION);
436
+ fs.mkdirSync(toolDir, { recursive: true });
437
+ fs.writeFileSync(path.join(toolDir, "uv"), "fake-uv-binary");
438
+ // Receipt sha256 does not match the pinned release — the install must be treated as unverifiable.
439
+ fs.writeFileSync(path.join(toolDir, "verified.json"), JSON.stringify({ version: MANAGED_UV_VERSION, sha256: "0".repeat(64) }));
440
+ let fetchCalled = false;
441
+ await expect(ensureManagedUv({
442
+ paths,
443
+ fetch: async () => {
444
+ fetchCalled = true;
445
+ return response(Buffer.from("not the real uv archive"));
446
+ },
447
+ runner: async () => { throw new Error("must not run --version against an unverified receipt"); },
448
+ })).rejects.toMatchObject({ code: "ASSET_SIZE_MISMATCH" });
449
+ expect(fetchCalled).toBe(true);
450
+ });
451
+ it("installs via a hashed requirements file with --require-hashes when every requirement declares hashes", async () => {
452
+ const paths = tempPaths();
453
+ const targetDir = path.join(paths.home, "model-hashed");
454
+ const calls = [];
455
+ const revision = "b".repeat(40);
456
+ const result = await preparePythonEnvironment({
457
+ targetDir,
458
+ repository: "https://github.com/example/hashed-model",
459
+ revision,
460
+ runtime: {
461
+ kind: "python",
462
+ python: "3.11",
463
+ installer: "uv",
464
+ requirements: [
465
+ { name: "torch", version: "2.6.0", hashes: [createHash("sha256").update("torch").digest("hex")] },
466
+ { name: "numpy", version: "1.26.4", hashes: [createHash("sha256").update("numpy").digest("hex")] },
467
+ ],
468
+ },
469
+ uvExecutable: "uv",
470
+ paths,
471
+ runner: async (command, args) => {
472
+ calls.push({ command, args });
473
+ if (args.includes("rev-parse"))
474
+ return { code: 0, stdout: `${revision}\n`, stderr: "" };
475
+ if (args.includes("freeze"))
476
+ return { code: 0, stdout: "numpy==1.26.4\ntorch==2.6.0\n", stderr: "" };
477
+ if (args[0] === "--version")
478
+ return { code: 0, stdout: "Python 3.11.9\n", stderr: "" };
479
+ return { code: 0, stdout: "", stderr: "" };
480
+ },
481
+ });
482
+ expect(result.lock).toEqual(["numpy==1.26.4", "torch==2.6.0"]);
483
+ const installCall = calls.find((c) => c.command === "uv" && c.args[0] === "pip" && c.args[1] === "install");
484
+ expect(installCall?.args).toContain("--require-hashes");
485
+ const requirementsFileIndex = installCall.args.indexOf("-r") + 1;
486
+ const requirementsFile = installCall.args[requirementsFileIndex];
487
+ const content = fs.readFileSync(requirementsFile, "utf8");
488
+ expect(content).toContain(`torch==2.6.0 --hash=sha256:${createHash("sha256").update("torch").digest("hex")}`);
489
+ expect(content).toContain(`numpy==1.26.4 --hash=sha256:${createHash("sha256").update("numpy").digest("hex")}`);
490
+ });
491
+ it("verifies hashed requirements by artifact digest (--no-deps) then installs the full closure normally", async () => {
492
+ const paths = tempPaths();
493
+ const targetDir = path.join(paths.home, "model-partial-hash");
494
+ const installCalls = [];
495
+ const torchHash = createHash("sha256").update("torch").digest("hex");
496
+ await preparePythonEnvironment({
497
+ targetDir,
498
+ repository: "https://github.com/example/partial-hash-model",
499
+ revision: "c".repeat(40),
500
+ runtime: {
501
+ kind: "python",
502
+ python: "3.11",
503
+ installer: "uv",
504
+ requirements: [
505
+ { name: "torch", version: "2.6.0", hashes: [torchHash] },
506
+ { name: "numpy", version: "1.26.4" },
507
+ ],
508
+ },
509
+ uvExecutable: "uv",
510
+ paths,
511
+ runner: async (command, args) => {
512
+ if (command === "uv" && args[0] === "pip" && args[1] === "install")
513
+ installCalls.push(args);
514
+ if (args.includes("rev-parse"))
515
+ return { code: 0, stdout: `${"c".repeat(40)}\n`, stderr: "" };
516
+ if (args.includes("freeze"))
517
+ return { code: 0, stdout: "numpy==1.26.4\ntorch==2.6.0\n", stderr: "" };
518
+ if (args[0] === "--version")
519
+ return { code: 0, stdout: "Python 3.11.9\n", stderr: "" };
520
+ return { code: 0, stdout: "", stderr: "" };
521
+ },
522
+ });
523
+ expect(installCalls).toHaveLength(2);
524
+ const hashedCall = installCalls.find((args) => args.includes("--require-hashes"));
525
+ expect(hashedCall).toContain("--no-deps");
526
+ const requirementsFile = hashedCall[hashedCall.indexOf("-r") + 1];
527
+ expect(fs.readFileSync(requirementsFile, "utf8")).toContain(`torch==2.6.0 --hash=sha256:${torchHash}`);
528
+ const closureCall = installCalls.find((args) => !args.includes("--require-hashes"));
529
+ expect(closureCall).toContain("torch==2.6.0");
530
+ expect(closureCall).toContain("numpy==1.26.4");
531
+ });
532
+ it("does not use --require-hashes when no requirement declares a hash (existing behavior unchanged)", async () => {
533
+ const paths = tempPaths();
534
+ const targetDir = path.join(paths.home, "model-no-hash");
535
+ const calls = [];
536
+ const revision = "d".repeat(40);
537
+ await preparePythonEnvironment({
538
+ targetDir,
539
+ repository: "https://github.com/example/no-hash-model",
540
+ revision,
541
+ runtime: { kind: "python", python: "3.11", installer: "uv", requirements: [{ name: "numpy", version: "1.26.4" }] },
542
+ uvExecutable: "uv",
543
+ paths,
544
+ runner: async (command, args) => {
545
+ calls.push({ command, args });
546
+ if (args.includes("rev-parse"))
547
+ return { code: 0, stdout: `${revision}\n`, stderr: "" };
548
+ if (args.includes("freeze"))
549
+ return { code: 0, stdout: "numpy==1.26.4\n", stderr: "" };
550
+ if (args[0] === "--version")
551
+ return { code: 0, stdout: "Python 3.11.9\n", stderr: "" };
552
+ return { code: 0, stdout: "", stderr: "" };
553
+ },
554
+ });
555
+ const installCall = calls.find((c) => c.command === "uv" && c.args[0] === "pip" && c.args[1] === "install");
556
+ expect(installCall?.args).toEqual(["pip", "install", "--python", path.join(targetDir, ".venv", "bin", "python"), "numpy==1.26.4"]);
557
+ expect(installCall?.args).not.toContain("--require-hashes");
558
+ });
559
+ it("runs a declared postInstall hook exactly once via the venv bin dir, with no shell and no manifest-string interpolation", async () => {
560
+ const paths = tempPaths();
561
+ const targetDir = path.join(paths.home, "model-post-install");
562
+ const calls = [];
563
+ const revision = "e".repeat(40);
564
+ const result = await preparePythonEnvironment({
565
+ targetDir,
566
+ repository: "https://github.com/example/post-install-model",
567
+ revision,
568
+ runtime: {
569
+ kind: "python",
570
+ python: "3.11",
571
+ installer: "uv",
572
+ postInstall: ["boltz-fix-macos-libomp"],
573
+ requirements: [{ name: "boltz-community", version: "2.10.12" }],
574
+ },
575
+ uvExecutable: "uv",
576
+ paths,
577
+ runner: async (command, args) => {
578
+ calls.push({ command, args });
579
+ if (args.includes("rev-parse"))
580
+ return { code: 0, stdout: `${revision}\n`, stderr: "" };
581
+ if (args.includes("freeze"))
582
+ return { code: 0, stdout: "boltz-community==2.10.12\n", stderr: "" };
583
+ if (args[0] === "--version")
584
+ return { code: 0, stdout: "Python 3.11.9\n", stderr: "" };
585
+ if (command === path.join(targetDir, ".venv", "bin", "boltz-fix-macos-libomp")) {
586
+ return { code: 0, stdout: "fixed libomp\n", stderr: "" };
587
+ }
588
+ return { code: 0, stdout: "", stderr: "" };
589
+ },
590
+ });
591
+ expect(result.postInstall).toEqual([{ hook: "boltz-fix-macos-libomp", stdout: "fixed libomp\n", stderr: "" }]);
592
+ const hookCalls = calls.filter((c) => c.command === path.join(targetDir, ".venv", "bin", "boltz-fix-macos-libomp"));
593
+ expect(hookCalls).toHaveLength(1);
594
+ expect(hookCalls[0]?.args).toEqual([]);
595
+ });
596
+ it("returns an empty postInstall array when the runtime declares no hooks", async () => {
597
+ const paths = tempPaths();
598
+ const targetDir = path.join(paths.home, "model-no-post-install");
599
+ const result = await preparePythonEnvironment({
600
+ targetDir,
601
+ repository: "https://github.com/example/plain-model",
602
+ revision: "f".repeat(40),
603
+ runtime: { kind: "python", python: "3.11", installer: "uv", requirements: [{ name: "numpy", version: "1.26.4" }] },
604
+ uvExecutable: "uv",
605
+ paths,
606
+ runner: async (_command, args) => {
607
+ if (args.includes("rev-parse"))
608
+ return { code: 0, stdout: `${"f".repeat(40)}\n`, stderr: "" };
609
+ if (args.includes("freeze"))
610
+ return { code: 0, stdout: "numpy==1.26.4\n", stderr: "" };
611
+ if (args[0] === "--version")
612
+ return { code: 0, stdout: "Python 3.11.9\n", stderr: "" };
613
+ return { code: 0, stdout: "", stderr: "" };
614
+ },
615
+ });
616
+ expect(result.postInstall).toEqual([]);
617
+ });
618
+ it("fails clearly when the Docker daemon is stopped", async () => {
619
+ await expect(prepareDockerImage({
620
+ kind: "docker",
621
+ image: "example/model:1",
622
+ digest: `sha256:${"a".repeat(64)}`,
623
+ gpu: "none",
624
+ }, async () => ({ code: 1, stdout: "", stderr: "Cannot connect to the Docker daemon" }))).rejects.toMatchObject({
625
+ code: "INSTALL_COMMAND_FAILED",
626
+ });
627
+ });
628
+ });
@@ -0,0 +1,29 @@
1
+ import type { CaptureFn } from "../process/index.js";
2
+ export interface NvidiaGpu {
3
+ index: number;
4
+ name: string;
5
+ totalVramBytes?: number;
6
+ }
7
+ export interface NvidiaCapability {
8
+ available: boolean;
9
+ driverVersion?: string;
10
+ /** Driver-reported max-supported CUDA version (from `nvidia-smi -q -x`), major.minor. */
11
+ driverCudaVersion?: string;
12
+ /** Locally installed CUDA toolkit version (from `nvcc --version`). Diagnostic only — never used in compatibility checks. */
13
+ cudaToolkitVersion?: string;
14
+ gpus: NvidiaGpu[];
15
+ error?: string;
16
+ }
17
+ /**
18
+ * Extract the driver's max-supported CUDA version from `nvidia-smi -q -x` XML
19
+ * output. `<cuda_version>` is a singular top-level tag (appears once, before
20
+ * any per-GPU `<gpu>` repetition), so a first-match regex is unambiguous
21
+ * without needing a full XML parser. Never parse the decorative ASCII table.
22
+ */
23
+ export declare function parseDriverCudaVersionXml(xml: string): string | undefined;
24
+ /**
25
+ * Probe NVIDIA GPUs via `nvidia-smi` CSV output only (never the
26
+ * decorative table). Any missing tool / error / unparsable output
27
+ * becomes `{available:false}` rather than throwing.
28
+ */
29
+ export declare function detectNvidia(capture?: CaptureFn, timeoutMs?: number): Promise<NvidiaCapability>;