@moldesk/registry 0.0.1 → 0.0.2
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/errors.d.ts +14 -0
- package/dist/errors.js +14 -0
- package/dist/index.d.ts +7 -14
- package/dist/index.js +4 -29
- package/dist/loader.d.ts +5 -0
- package/dist/loader.js +128 -0
- package/dist/loader.test.d.ts +1 -0
- package/dist/loader.test.js +72 -0
- package/dist/models/bindcraft2/manifest.yaml +178 -0
- package/dist/models/boltz/manifest.yaml +155 -0
- package/dist/models/diffdock/manifest.yaml +219 -0
- package/dist/models/ligandmpnn/manifest.yaml +67 -0
- package/dist/models/opendde/manifest.yaml +169 -0
- package/dist/models/proteinmpnn/manifest.yaml +43 -0
- package/dist/runtime-selection.d.ts +24 -0
- package/dist/runtime-selection.js +54 -0
- package/dist/runtime-selection.test.d.ts +1 -0
- package/dist/runtime-selection.test.js +84 -0
- package/dist/schema.d.ts +1226 -0
- package/dist/schema.js +337 -0
- package/dist/schema.test.d.ts +1 -0
- package/dist/schema.test.js +335 -0
- package/dist/types.d.ts +14 -0
- package/dist/types.js +1 -0
- package/package.json +8 -5
package/dist/schema.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const kebabCase = z
|
|
3
|
+
.string()
|
|
4
|
+
.min(1)
|
|
5
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "must be lowercase kebab-case");
|
|
6
|
+
const sha256Hex = z
|
|
7
|
+
.string()
|
|
8
|
+
.regex(/^[0-9a-fA-F]{64}$/, "must be a 64-char hex SHA-256");
|
|
9
|
+
const dockerDigest = z
|
|
10
|
+
.string()
|
|
11
|
+
.regex(/^sha256:[0-9a-fA-F]{64}$/, "must be `sha256:<64 hex>`");
|
|
12
|
+
const httpsUrl = z.string().url("must be a valid URL").refine((v) => v.startsWith("https://"), "must use https protocol");
|
|
13
|
+
const safeVersionSegment = z
|
|
14
|
+
.string()
|
|
15
|
+
.min(1)
|
|
16
|
+
.regex(/^[A-Za-z0-9][A-Za-z0-9._+-]*$/, "must be a safe single path segment");
|
|
17
|
+
const semver = z
|
|
18
|
+
.string()
|
|
19
|
+
.regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/, "must be a semantic version");
|
|
20
|
+
function isSafeRelativeTarget(target) {
|
|
21
|
+
if (!target || target.length === 0)
|
|
22
|
+
return false;
|
|
23
|
+
if (target.includes("\\"))
|
|
24
|
+
return false;
|
|
25
|
+
if (target.startsWith("/") || /^[A-Za-z]:/.test(target))
|
|
26
|
+
return false;
|
|
27
|
+
const parts = target.split("/");
|
|
28
|
+
for (const part of parts) {
|
|
29
|
+
if (part === "" || part === "." || part === "..")
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
function isSafeRelativeGlob(glob) {
|
|
35
|
+
if (!glob || glob.includes("\\") || glob.startsWith("/") || /^[A-Za-z]:/.test(glob))
|
|
36
|
+
return false;
|
|
37
|
+
return glob.split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
38
|
+
}
|
|
39
|
+
const safeTarget = z
|
|
40
|
+
.string()
|
|
41
|
+
.min(1)
|
|
42
|
+
.refine(isSafeRelativeTarget, "must be a safe path relative to the model assets directory (no absolute paths, .., or backslashes)");
|
|
43
|
+
const modelStatus = z.enum(["available", "beta", "planned"]);
|
|
44
|
+
const runtimeKind = z.enum(["python", "docker"]);
|
|
45
|
+
const requirementLevel = z.enum(["required", "recommended", "unsupported"]);
|
|
46
|
+
const category = z.enum(["sequence-design", "structure-prediction", "docking", "other"]);
|
|
47
|
+
const platformId = z.enum(["darwin-arm64", "linux-x64"]);
|
|
48
|
+
/** Closed enum of named, non-shell post-install fixups. Never a free-form command
|
|
49
|
+
* string — this keeps the manifest from becoming a generic shell escape hatch. */
|
|
50
|
+
const postInstallHookSchema = z.enum(["boltz-fix-macos-libomp"]);
|
|
51
|
+
const cudaSchema = z
|
|
52
|
+
.object({
|
|
53
|
+
level: requirementLevel,
|
|
54
|
+
// Minimum driver-supported CUDA version, e.g. "12.1" — compared against the
|
|
55
|
+
// driver's max-supported CUDA (not the local CUDA toolkit) as a major.minor
|
|
56
|
+
// floor, not a semver range.
|
|
57
|
+
minDriverCudaVersion: z
|
|
58
|
+
.string()
|
|
59
|
+
.regex(/^\d+\.\d+$/, "must be a major.minor CUDA version, e.g. \"12.1\"")
|
|
60
|
+
.optional(),
|
|
61
|
+
})
|
|
62
|
+
.strict();
|
|
63
|
+
const sourceSchema = z
|
|
64
|
+
.object({
|
|
65
|
+
repository: httpsUrl,
|
|
66
|
+
revision: z
|
|
67
|
+
.string()
|
|
68
|
+
.regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i, "must be a full immutable commit digest"),
|
|
69
|
+
})
|
|
70
|
+
.strict();
|
|
71
|
+
const pythonRequirementSchema = z
|
|
72
|
+
.object({
|
|
73
|
+
name: z.string().min(1),
|
|
74
|
+
version: z.string().min(1).optional(),
|
|
75
|
+
source: z.string().min(1).optional(),
|
|
76
|
+
revision: z.string().min(1).optional(),
|
|
77
|
+
// Optional PEP 508 extras, e.g. ["cuda"] for `boltz[cuda]`. Valid with either a
|
|
78
|
+
// PyPI version pin or a git source+revision pin (pip/uv both support
|
|
79
|
+
// `name[extra] @ git+url@rev`).
|
|
80
|
+
extras: z.array(z.string().min(1)).min(1).optional(),
|
|
81
|
+
// sha256 hex digests of the expected wheel/sdist artifact(s), enforced natively
|
|
82
|
+
// by uv/pip's hash-checking install mode when present.
|
|
83
|
+
hashes: z.array(sha256Hex).min(1).optional(),
|
|
84
|
+
})
|
|
85
|
+
.strict();
|
|
86
|
+
const pythonRuntimeSchema = z
|
|
87
|
+
.object({
|
|
88
|
+
kind: z.literal("python"),
|
|
89
|
+
python: z.string().min(1),
|
|
90
|
+
installer: z.literal("uv"),
|
|
91
|
+
estimatedDownloadBytes: z.number().int().nonnegative().optional(),
|
|
92
|
+
estimatedDiskBytes: z.number().int().nonnegative().optional(),
|
|
93
|
+
requirements: z.array(pythonRequirementSchema).min(1),
|
|
94
|
+
// When present, this runtime entry only applies to hosts matching one of these
|
|
95
|
+
// platforms; absent means it applies unconditionally (legacy single-runtime
|
|
96
|
+
// manifests, e.g. ProteinMPNN/LigandMPNN, always match this way).
|
|
97
|
+
platforms: z.array(platformId).min(1).optional(),
|
|
98
|
+
// Per-entry source override, falling back to the manifest-level `source` when
|
|
99
|
+
// absent — needed when different platforms build from genuinely different repos.
|
|
100
|
+
source: sourceSchema.optional(),
|
|
101
|
+
postInstall: z.array(postInstallHookSchema).min(1).optional(),
|
|
102
|
+
// Per-platform-runtime-entry accelerator requirements, same shape as the
|
|
103
|
+
// manifest-level `hardware.nvidiaGpu`/`hardware.cuda`/`hardware.minVramGb`, so a
|
|
104
|
+
// single manifest can declare CUDA on one platform and MPS (no CUDA) on another.
|
|
105
|
+
nvidiaGpu: requirementLevel.optional(),
|
|
106
|
+
cuda: cudaSchema.optional(),
|
|
107
|
+
minVramGb: z.number().positive().optional(),
|
|
108
|
+
// Additional PEP-503-style pip/uv package indexes, ADDED alongside the default
|
|
109
|
+
// PyPI index (never replacing it — a manifest never loses access to ordinary
|
|
110
|
+
// packages like pandas/scipy just because it needs a CUDA-specific extra
|
|
111
|
+
// index). Maps to `UV_EXTRA_INDEX_URL`. For a pinned build whose exact wheels
|
|
112
|
+
// (e.g. a CUDA-specific PyTorch build) live on a PEP-503-compatible extra
|
|
113
|
+
// index rather than default PyPI.
|
|
114
|
+
extraIndexUrls: z.array(httpsUrl).min(1).max(4).optional(),
|
|
115
|
+
// Flat `--find-links` wheel-listing pages (not PEP-503 indexes — a single page
|
|
116
|
+
// listing wheel files directly, e.g. PyTorch Geometric's CUDA extension wheel
|
|
117
|
+
// pages). Maps to `UV_FIND_LINKS`. Kept structurally distinct from
|
|
118
|
+
// `extraIndexUrls` because the two have different pip/uv semantics and mixing
|
|
119
|
+
// them up silently breaks resolution (this is exactly what an earlier version
|
|
120
|
+
// of this schema got wrong for DiffDock-L).
|
|
121
|
+
findLinks: z.array(httpsUrl).min(1).max(4).optional(),
|
|
122
|
+
// Package names (matching a `requirements[].name`) that must resolve to a
|
|
123
|
+
// prebuilt wheel and must never be built from source, mapped to `--only-binary
|
|
124
|
+
// <name>` on every install invocation. Exists so an ABI-sensitive CUDA
|
|
125
|
+
// extension package (torch-scatter, torch-sparse, …) can never silently fall
|
|
126
|
+
// back to a from-source sdist build if wheel resolution ever fails for some
|
|
127
|
+
// reason — install fails closed instead.
|
|
128
|
+
binaryOnly: z.array(z.string().min(1)).min(1).optional(),
|
|
129
|
+
// Requirements installed first, each in its own `uv pip install` invocation, in
|
|
130
|
+
// array order, before anything in `requirements` — for a package (e.g. a pinned
|
|
131
|
+
// `setuptools` version) that a later requirement's own source build depends on
|
|
132
|
+
// being fully resolved beforehand, not just present somewhere in the same
|
|
133
|
+
// resolver graph.
|
|
134
|
+
preInstall: z.array(pythonRequirementSchema).min(1).optional(),
|
|
135
|
+
// Names (matching `requirements[].name`) deferred to their own final install
|
|
136
|
+
// invocation, strictly after every other requirement (including `preInstall`)
|
|
137
|
+
// has finished installing — for a package whose own build process imports an
|
|
138
|
+
// earlier dependency (e.g. an unpackaged git build that imports `torch` at
|
|
139
|
+
// setup time to select CUDA architecture flags), which a single combined
|
|
140
|
+
// resolver invocation cannot reliably sequence.
|
|
141
|
+
buildAfter: z.array(z.string().min(1)).min(1).optional(),
|
|
142
|
+
// PyTorch build selection for uv's native `--torch-backend` (mapped to
|
|
143
|
+
// `UV_TORCH_BACKEND`), which routes torch-ecosystem packages to the matching
|
|
144
|
+
// official PyTorch index. A closed enum, never a free-form index URL or pip
|
|
145
|
+
// argument fragment — extend it only alongside a manifest that actually pins
|
|
146
|
+
// and verifies that backend's wheels.
|
|
147
|
+
torchBackend: z.enum(["cpu", "cu126"]).optional(),
|
|
148
|
+
})
|
|
149
|
+
.strict();
|
|
150
|
+
const dockerRuntimeSchema = z
|
|
151
|
+
.object({
|
|
152
|
+
kind: z.literal("docker"),
|
|
153
|
+
image: z.string().min(1),
|
|
154
|
+
digest: dockerDigest,
|
|
155
|
+
gpu: z.enum(["none", "optional", "required"]),
|
|
156
|
+
estimatedDownloadBytes: z.number().int().nonnegative().optional(),
|
|
157
|
+
estimatedDiskBytes: z.number().int().nonnegative().optional(),
|
|
158
|
+
})
|
|
159
|
+
.strict();
|
|
160
|
+
const runtimeSpecSchema = z.discriminatedUnion("kind", [
|
|
161
|
+
pythonRuntimeSchema,
|
|
162
|
+
dockerRuntimeSchema,
|
|
163
|
+
]);
|
|
164
|
+
const hardwareSchema = z
|
|
165
|
+
.object({
|
|
166
|
+
platforms: z.array(platformId).min(1).optional(),
|
|
167
|
+
minRamGb: z.number().positive().optional(),
|
|
168
|
+
minDiskGb: z.number().positive().optional(),
|
|
169
|
+
nvidiaGpu: requirementLevel.optional(),
|
|
170
|
+
minVramGb: z.number().positive().optional(),
|
|
171
|
+
cuda: cudaSchema.optional(),
|
|
172
|
+
})
|
|
173
|
+
.strict();
|
|
174
|
+
const assetSchema = z
|
|
175
|
+
.object({
|
|
176
|
+
id: z.string().min(1),
|
|
177
|
+
url: httpsUrl,
|
|
178
|
+
source: z.string().min(1).optional(),
|
|
179
|
+
license: z.string().min(1).optional(),
|
|
180
|
+
sha256: sha256Hex,
|
|
181
|
+
target: safeTarget,
|
|
182
|
+
sizeBytes: z.number().int().nonnegative().optional(),
|
|
183
|
+
archive: z.enum(["none", "tar.gz", "tar", "zip"]).optional(),
|
|
184
|
+
})
|
|
185
|
+
.strict();
|
|
186
|
+
const inputSpecSchema = z
|
|
187
|
+
.object({
|
|
188
|
+
formats: z.array(z.string().min(1)).min(1),
|
|
189
|
+
required: z.boolean(),
|
|
190
|
+
})
|
|
191
|
+
.strict();
|
|
192
|
+
const outputSpecSchema = z
|
|
193
|
+
.object({
|
|
194
|
+
id: z.string().min(1),
|
|
195
|
+
glob: z.string().min(1),
|
|
196
|
+
required: z.boolean(),
|
|
197
|
+
})
|
|
198
|
+
.strict()
|
|
199
|
+
.refine((v) => isSafeRelativeGlob(v.glob), {
|
|
200
|
+
message: "must be a safe path relative to the staged output directory",
|
|
201
|
+
path: ["glob"],
|
|
202
|
+
});
|
|
203
|
+
const adapterVerificationSchema = z
|
|
204
|
+
.object({
|
|
205
|
+
lastVerifiedAt: z.string().datetime({ offset: true }),
|
|
206
|
+
platforms: z.array(z.string().min(1)).min(1),
|
|
207
|
+
notes: z.string().optional(),
|
|
208
|
+
})
|
|
209
|
+
.strict();
|
|
210
|
+
export const allowedCommandTokens = ["{{input}}", "{{outputDir}}", "{{modelDir}}", "{{assetsDir}}"];
|
|
211
|
+
const allowedCommandTokenSet = new Set(allowedCommandTokens);
|
|
212
|
+
function hasOnlyAllowedCommandTokens(value) {
|
|
213
|
+
let remainder = value;
|
|
214
|
+
for (const token of allowedCommandTokenSet)
|
|
215
|
+
remainder = remainder.split(token).join("");
|
|
216
|
+
return !remainder.includes("{{") && !remainder.includes("}}");
|
|
217
|
+
}
|
|
218
|
+
const commandValue = z
|
|
219
|
+
.string()
|
|
220
|
+
.refine((v) => !v.includes("\0"), "must not contain a NUL byte")
|
|
221
|
+
.refine(hasOnlyAllowedCommandTokens, "contains an unsupported command template token");
|
|
222
|
+
export const commandSpecSchema = z
|
|
223
|
+
.object({
|
|
224
|
+
executable: z
|
|
225
|
+
.string()
|
|
226
|
+
.min(1)
|
|
227
|
+
.refine((v) => !v.includes("\0"), "must not contain a NUL byte")
|
|
228
|
+
.refine((v) => !v.includes("{{"), "executable must not contain template tokens"),
|
|
229
|
+
args: z.array(commandValue),
|
|
230
|
+
env: z.record(z.string().min(1), commandValue).optional(),
|
|
231
|
+
})
|
|
232
|
+
.strict();
|
|
233
|
+
export const modelManifestV1Schema = z
|
|
234
|
+
.object({
|
|
235
|
+
schemaVersion: z.literal(1),
|
|
236
|
+
name: kebabCase,
|
|
237
|
+
displayName: z.string().min(1),
|
|
238
|
+
modelVersion: safeVersionSegment,
|
|
239
|
+
adapterVersion: semver,
|
|
240
|
+
description: z.string().min(1),
|
|
241
|
+
category,
|
|
242
|
+
status: modelStatus,
|
|
243
|
+
license: z.string().min(1).optional(),
|
|
244
|
+
homepage: httpsUrl.optional(),
|
|
245
|
+
source: sourceSchema.optional(),
|
|
246
|
+
adapterVerification: adapterVerificationSchema.optional(),
|
|
247
|
+
runtimes: z.array(runtimeSpecSchema).min(1),
|
|
248
|
+
hardware: hardwareSchema,
|
|
249
|
+
assets: z.array(assetSchema).optional(),
|
|
250
|
+
input: inputSpecSchema,
|
|
251
|
+
outputs: z.array(outputSpecSchema).min(1),
|
|
252
|
+
})
|
|
253
|
+
.strict()
|
|
254
|
+
.superRefine((val, ctx) => {
|
|
255
|
+
if (val.hardware.minVramGb !== undefined && val.hardware.nvidiaGpu === undefined) {
|
|
256
|
+
ctx.addIssue({
|
|
257
|
+
code: z.ZodIssueCode.custom,
|
|
258
|
+
message: "hardware.nvidiaGpu must be set (\"required\" or \"recommended\") whenever minVramGb is specified",
|
|
259
|
+
path: ["hardware", "nvidiaGpu"],
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
for (const [runtimeIndex, runtime] of val.runtimes.entries()) {
|
|
263
|
+
if (runtime.kind !== "python")
|
|
264
|
+
continue;
|
|
265
|
+
if (runtime.minVramGb !== undefined && runtime.nvidiaGpu === undefined) {
|
|
266
|
+
ctx.addIssue({
|
|
267
|
+
code: z.ZodIssueCode.custom,
|
|
268
|
+
message: "nvidiaGpu must be set (\"required\" or \"recommended\") whenever this runtime entry's minVramGb is specified",
|
|
269
|
+
path: ["runtimes", runtimeIndex, "nvidiaGpu"],
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (runtime.buildAfter) {
|
|
273
|
+
const requirementNames = new Set(runtime.requirements.map((r) => r.name));
|
|
274
|
+
for (const deferredName of runtime.buildAfter) {
|
|
275
|
+
if (!requirementNames.has(deferredName)) {
|
|
276
|
+
ctx.addIssue({
|
|
277
|
+
code: z.ZodIssueCode.custom,
|
|
278
|
+
message: `buildAfter references "${deferredName}", which is not in this runtime entry's requirements`,
|
|
279
|
+
path: ["runtimes", runtimeIndex, "buildAfter"],
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const names = new Set();
|
|
286
|
+
for (const out of val.outputs) {
|
|
287
|
+
if (names.has(out.id)) {
|
|
288
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate output id "${out.id}"`, path: ["outputs"] });
|
|
289
|
+
}
|
|
290
|
+
names.add(out.id);
|
|
291
|
+
}
|
|
292
|
+
if (val.assets) {
|
|
293
|
+
const assetIds = new Set();
|
|
294
|
+
const targets = new Set();
|
|
295
|
+
for (const a of val.assets) {
|
|
296
|
+
if (assetIds.has(a.id)) {
|
|
297
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate asset id "${a.id}"`, path: ["assets"] });
|
|
298
|
+
}
|
|
299
|
+
assetIds.add(a.id);
|
|
300
|
+
if (targets.has(a.target)) {
|
|
301
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate asset target "${a.target}"`, path: ["assets"] });
|
|
302
|
+
}
|
|
303
|
+
targets.add(a.target);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (val.status === "available") {
|
|
307
|
+
if (!val.source) {
|
|
308
|
+
ctx.addIssue({
|
|
309
|
+
code: z.ZodIssueCode.custom,
|
|
310
|
+
message: "available models require source provenance",
|
|
311
|
+
path: ["source"],
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (!val.adapterVerification) {
|
|
315
|
+
ctx.addIssue({
|
|
316
|
+
code: z.ZodIssueCode.custom,
|
|
317
|
+
message: "available models require adapter verification metadata",
|
|
318
|
+
path: ["adapterVerification"],
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
for (const [runtimeIndex, runtime] of val.runtimes.entries()) {
|
|
322
|
+
if (runtime.kind !== "python")
|
|
323
|
+
continue;
|
|
324
|
+
for (const [requirementIndex, requirement] of runtime.requirements.entries()) {
|
|
325
|
+
const pinnedPackage = requirement.version !== undefined;
|
|
326
|
+
const pinnedSource = requirement.source !== undefined && requirement.revision !== undefined;
|
|
327
|
+
if (!pinnedPackage && !pinnedSource) {
|
|
328
|
+
ctx.addIssue({
|
|
329
|
+
code: z.ZodIssueCode.custom,
|
|
330
|
+
message: "available models require every Python dependency to be pinned by version or source revision",
|
|
331
|
+
path: ["runtimes", runtimeIndex, "requirements", requirementIndex],
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { MoldeskError } from "./errors.js";
|
|
3
|
+
import { parseManifest } from "./loader.js";
|
|
4
|
+
import { commandSpecSchema } from "./schema.js";
|
|
5
|
+
function baseManifest() {
|
|
6
|
+
return {
|
|
7
|
+
schemaVersion: 1,
|
|
8
|
+
name: "test-model",
|
|
9
|
+
displayName: "Test Model",
|
|
10
|
+
modelVersion: "1.0.0",
|
|
11
|
+
adapterVersion: "0.1.0",
|
|
12
|
+
description: "A test model",
|
|
13
|
+
category: "other",
|
|
14
|
+
status: "planned",
|
|
15
|
+
runtimes: [
|
|
16
|
+
{ kind: "python", python: "3.11", installer: "uv", requirements: [{ name: "torch" }] },
|
|
17
|
+
],
|
|
18
|
+
hardware: { platforms: ["darwin-arm64"] },
|
|
19
|
+
input: { formats: [".pdb"], required: true },
|
|
20
|
+
outputs: [{ id: "out", glob: "*.fa", required: true }],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
describe("parseManifest", () => {
|
|
24
|
+
it("accepts a minimal valid v1 manifest", () => {
|
|
25
|
+
const m = parseManifest(baseManifest(), "test/manifest.yaml");
|
|
26
|
+
expect(m.name).toBe("test-model");
|
|
27
|
+
expect(m.modelVersion).toBe("1.0.0");
|
|
28
|
+
});
|
|
29
|
+
it("rejects unknown schema versions with a typed error", () => {
|
|
30
|
+
try {
|
|
31
|
+
parseManifest({ ...baseManifest(), schemaVersion: 99 }, "test/manifest.yaml");
|
|
32
|
+
expect.unreachable();
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
expect(error).toBeInstanceOf(MoldeskError);
|
|
36
|
+
const e = error;
|
|
37
|
+
expect(e.code).toBe("UNKNOWN_SCHEMA_VERSION");
|
|
38
|
+
expect(e.message).toContain("test/manifest.yaml");
|
|
39
|
+
expect(e.remediation).toMatch(/schemaVersion/i);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
it("rejects legacy top-level version without modelVersion", () => {
|
|
43
|
+
const raw = baseManifest();
|
|
44
|
+
delete raw["modelVersion"];
|
|
45
|
+
raw["version"] = "1.0.0";
|
|
46
|
+
try {
|
|
47
|
+
parseManifest(raw, "models/x/manifest.yaml");
|
|
48
|
+
expect.unreachable();
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const e = error;
|
|
52
|
+
expect(e.code).toBe("INVALID_MANIFEST");
|
|
53
|
+
expect(e.message).toContain("modelVersion");
|
|
54
|
+
expect(e.details?.["field"]).toBe("version");
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
it.each(["modelVersion", "adapterVersion"])("rejects missing %s", (field) => {
|
|
58
|
+
const raw = baseManifest();
|
|
59
|
+
delete raw[field];
|
|
60
|
+
expect(() => parseManifest(raw, "m.yaml")).toThrowError(MoldeskError);
|
|
61
|
+
});
|
|
62
|
+
it("rejects unsafe model versions and invalid adapter semver", () => {
|
|
63
|
+
expect(() => parseManifest({ ...baseManifest(), modelVersion: "../../escape" }, "m.yaml")).toThrowError(MoldeskError);
|
|
64
|
+
expect(() => parseManifest({ ...baseManifest(), adapterVersion: "latest" }, "m.yaml")).toThrowError(MoldeskError);
|
|
65
|
+
});
|
|
66
|
+
it("rejects unsafe asset target paths", () => {
|
|
67
|
+
for (const target of ["../evil.bin", "/abs/path.bin", "a\\b.bin", "a/./b.bin"]) {
|
|
68
|
+
const raw = baseManifest();
|
|
69
|
+
raw["assets"] = [
|
|
70
|
+
{ id: "w", url: "https://example.com/w.bin", sha256: "a".repeat(64), target },
|
|
71
|
+
];
|
|
72
|
+
try {
|
|
73
|
+
parseManifest(raw, "m.yaml");
|
|
74
|
+
expect.unreachable(`target ${target} should fail`);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
expect(error.code).toBe("INVALID_MANIFEST");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
it("rejects duplicate model-relevant ids conservatively", () => {
|
|
82
|
+
const raw = baseManifest();
|
|
83
|
+
raw["outputs"] = [
|
|
84
|
+
{ id: "dup", glob: "*.a", required: true },
|
|
85
|
+
{ id: "dup", glob: "*.b", required: false },
|
|
86
|
+
];
|
|
87
|
+
expect(() => parseManifest(raw, "m.yaml")).toThrowError(MoldeskError);
|
|
88
|
+
});
|
|
89
|
+
it("rejects unknown keys in high-risk runtime sections", () => {
|
|
90
|
+
const raw = baseManifest();
|
|
91
|
+
raw["runtimes"] = [
|
|
92
|
+
{ kind: "python", python: "3.11", installer: "uv", requirements: [{ name: "torch" }], installScript: "curl | sh" },
|
|
93
|
+
];
|
|
94
|
+
try {
|
|
95
|
+
parseManifest(raw, "m.yaml");
|
|
96
|
+
expect.unreachable();
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const e = error;
|
|
100
|
+
expect(e.code).toBe("INVALID_MANIFEST");
|
|
101
|
+
expect(e.message).toContain("m.yaml");
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
it("accepts only known command tokens without applying shell restrictions", () => {
|
|
105
|
+
const valid = {
|
|
106
|
+
executable: "/Applications/MoleculeDesk (Preview)/python#1",
|
|
107
|
+
args: ["--input={{input}}", "{{outputDir}}"],
|
|
108
|
+
env: { MODEL_DIR: "{{modelDir}}" },
|
|
109
|
+
};
|
|
110
|
+
expect(commandSpecSchema.parse(valid)).toEqual(valid);
|
|
111
|
+
expect(() => commandSpecSchema.parse({ executable: "tool", args: ["{{unknown}}"] })).toThrow();
|
|
112
|
+
expect(() => commandSpecSchema.parse({ executable: "tool", args: ["{{input"] })).toThrow();
|
|
113
|
+
expect(() => commandSpecSchema.parse({ executable: "{{modelDir}}/tool", args: [] })).toThrow();
|
|
114
|
+
});
|
|
115
|
+
it("validates output globs by path segment", () => {
|
|
116
|
+
expect(() => parseManifest({ ...baseManifest(), outputs: [{ id: "out", glob: "foo..bar/*.cif", required: true }] }, "m.yaml")).not.toThrow();
|
|
117
|
+
for (const glob of ["../*.cif", "/tmp/*.cif", "dir\\*.cif", "dir//*.cif"]) {
|
|
118
|
+
expect(() => parseManifest({ ...baseManifest(), outputs: [{ id: "out", glob, required: true }] }, "m.yaml")).toThrowError(MoldeskError);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
it("requires provenance, verification, and pinned dependencies for available models", () => {
|
|
122
|
+
expect(() => parseManifest({ ...baseManifest(), status: "available" }, "m.yaml")).toThrowError(MoldeskError);
|
|
123
|
+
const available = baseManifest();
|
|
124
|
+
available["status"] = "available";
|
|
125
|
+
available["source"] = {
|
|
126
|
+
repository: "https://example.com/model.git",
|
|
127
|
+
revision: "a".repeat(40),
|
|
128
|
+
};
|
|
129
|
+
available["adapterVerification"] = {
|
|
130
|
+
lastVerifiedAt: "2026-09-07T00:00:00Z",
|
|
131
|
+
platforms: ["darwin-arm64"],
|
|
132
|
+
};
|
|
133
|
+
available["runtimes"] = [
|
|
134
|
+
{ kind: "python", python: "3.11", installer: "uv", requirements: [{ name: "torch", version: "2.2.1" }] },
|
|
135
|
+
];
|
|
136
|
+
expect(() => parseManifest(available, "m.yaml")).not.toThrow();
|
|
137
|
+
});
|
|
138
|
+
it("requires nvidiaGpu whenever minVramGb is set", () => {
|
|
139
|
+
const raw = baseManifest();
|
|
140
|
+
raw["hardware"] = { platforms: ["darwin-arm64"], minVramGb: 16 };
|
|
141
|
+
expect(() => parseManifest(raw, "m.yaml")).toThrowError(MoldeskError);
|
|
142
|
+
raw["hardware"] = { platforms: ["darwin-arm64"], minVramGb: 16, nvidiaGpu: "recommended" };
|
|
143
|
+
expect(() => parseManifest(raw, "m.yaml")).not.toThrow();
|
|
144
|
+
});
|
|
145
|
+
it("validates cuda.minDriverCudaVersion as major.minor", () => {
|
|
146
|
+
const raw = baseManifest();
|
|
147
|
+
raw["hardware"] = {
|
|
148
|
+
platforms: ["darwin-arm64"],
|
|
149
|
+
nvidiaGpu: "required",
|
|
150
|
+
cuda: { level: "required", minDriverCudaVersion: "12.1" },
|
|
151
|
+
};
|
|
152
|
+
expect(() => parseManifest(raw, "m.yaml")).not.toThrow();
|
|
153
|
+
raw["hardware"] = {
|
|
154
|
+
platforms: ["darwin-arm64"],
|
|
155
|
+
nvidiaGpu: "required",
|
|
156
|
+
cuda: { level: "required", minDriverCudaVersion: "12.1.0" },
|
|
157
|
+
};
|
|
158
|
+
expect(() => parseManifest(raw, "m.yaml")).toThrowError(MoldeskError);
|
|
159
|
+
});
|
|
160
|
+
it("accepts per-platform python runtime fields: platforms, source override, postInstall, accelerator, hashes", () => {
|
|
161
|
+
const raw = baseManifest();
|
|
162
|
+
raw["runtimes"] = [
|
|
163
|
+
{
|
|
164
|
+
kind: "python",
|
|
165
|
+
python: "3.11",
|
|
166
|
+
installer: "uv",
|
|
167
|
+
platforms: ["darwin-arm64"],
|
|
168
|
+
source: { repository: "https://github.com/example/darwin-fork", revision: "a".repeat(40) },
|
|
169
|
+
postInstall: ["boltz-fix-macos-libomp"],
|
|
170
|
+
nvidiaGpu: "unsupported",
|
|
171
|
+
requirements: [{ name: "torch", version: "2.6.0", hashes: ["b".repeat(64)] }],
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
kind: "python",
|
|
175
|
+
python: "3.11",
|
|
176
|
+
installer: "uv",
|
|
177
|
+
platforms: ["linux-x64"],
|
|
178
|
+
source: { repository: "https://github.com/example/official", revision: "c".repeat(40) },
|
|
179
|
+
nvidiaGpu: "required",
|
|
180
|
+
cuda: { level: "required", minDriverCudaVersion: "12.1" },
|
|
181
|
+
minVramGb: 16,
|
|
182
|
+
requirements: [{ name: "torch", version: "2.2.1" }],
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
expect(() => parseManifest(raw, "m.yaml")).not.toThrow();
|
|
186
|
+
const parsed = parseManifest(raw, "m.yaml");
|
|
187
|
+
expect(parsed.runtimes).toHaveLength(2);
|
|
188
|
+
});
|
|
189
|
+
it("rejects an unlisted postInstall value", () => {
|
|
190
|
+
const raw = baseManifest();
|
|
191
|
+
raw["runtimes"] = [
|
|
192
|
+
{
|
|
193
|
+
kind: "python",
|
|
194
|
+
python: "3.11",
|
|
195
|
+
installer: "uv",
|
|
196
|
+
postInstall: ["curl-pipe-sh"],
|
|
197
|
+
requirements: [{ name: "torch" }],
|
|
198
|
+
},
|
|
199
|
+
];
|
|
200
|
+
expect(() => parseManifest(raw, "m.yaml")).toThrowError(MoldeskError);
|
|
201
|
+
});
|
|
202
|
+
it("still rejects unknown keys on a python runtime entry (.strict())", () => {
|
|
203
|
+
const raw = baseManifest();
|
|
204
|
+
raw["runtimes"] = [
|
|
205
|
+
{
|
|
206
|
+
kind: "python",
|
|
207
|
+
python: "3.11",
|
|
208
|
+
installer: "uv",
|
|
209
|
+
requirements: [{ name: "torch" }],
|
|
210
|
+
platforms: ["darwin-arm64"],
|
|
211
|
+
extraField: "not allowed",
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
expect(() => parseManifest(raw, "m.yaml")).toThrowError(MoldeskError);
|
|
215
|
+
});
|
|
216
|
+
it("accepts a python runtime entry's extraIndexUrls and findLinks (kept structurally distinct)", () => {
|
|
217
|
+
const raw = baseManifest();
|
|
218
|
+
raw["runtimes"] = [
|
|
219
|
+
{
|
|
220
|
+
kind: "python",
|
|
221
|
+
python: "3.10",
|
|
222
|
+
installer: "uv",
|
|
223
|
+
extraIndexUrls: ["https://download.pytorch.org/whl/cu117"],
|
|
224
|
+
findLinks: ["https://data.pyg.org/whl/torch-1.13.1+cu117.html"],
|
|
225
|
+
binaryOnly: ["torch", "torch-scatter"],
|
|
226
|
+
requirements: [{ name: "torch", version: "1.13.1+cu117", hashes: ["b".repeat(64)] }],
|
|
227
|
+
},
|
|
228
|
+
];
|
|
229
|
+
const parsed = parseManifest(raw, "m.yaml");
|
|
230
|
+
expect(parsed.runtimes[0]).toMatchObject({
|
|
231
|
+
extraIndexUrls: ["https://download.pytorch.org/whl/cu117"],
|
|
232
|
+
findLinks: ["https://data.pyg.org/whl/torch-1.13.1+cu117.html"],
|
|
233
|
+
binaryOnly: ["torch", "torch-scatter"],
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
it("rejects a non-https extraIndexUrls/findLinks URL", () => {
|
|
237
|
+
const raw = baseManifest();
|
|
238
|
+
raw["runtimes"] = [
|
|
239
|
+
{ kind: "python", python: "3.11", installer: "uv", extraIndexUrls: ["http://insecure.example/whl"], requirements: [{ name: "torch" }] },
|
|
240
|
+
];
|
|
241
|
+
expect(() => parseManifest(raw, "m.yaml")).toThrowError(MoldeskError);
|
|
242
|
+
const raw2 = baseManifest();
|
|
243
|
+
raw2["runtimes"] = [
|
|
244
|
+
{ kind: "python", python: "3.11", installer: "uv", findLinks: ["http://insecure.example/whl"], requirements: [{ name: "torch" }] },
|
|
245
|
+
];
|
|
246
|
+
expect(() => parseManifest(raw2, "m.yaml")).toThrowError(MoldeskError);
|
|
247
|
+
});
|
|
248
|
+
it("accepts a known torchBackend and rejects anything outside the closed enum", () => {
|
|
249
|
+
const raw = baseManifest();
|
|
250
|
+
raw["runtimes"] = [
|
|
251
|
+
{ kind: "python", python: "3.11", installer: "uv", torchBackend: "cu126", requirements: [{ name: "torch", version: "2.7.1+cu126" }] },
|
|
252
|
+
];
|
|
253
|
+
expect(parseManifest(raw, "m.yaml").runtimes[0]).toMatchObject({ torchBackend: "cu126" });
|
|
254
|
+
for (const bad of ["auto", "cu999", "--index-url https://evil.example", "https://download.pytorch.org/whl/cu126", ""]) {
|
|
255
|
+
const rawBad = baseManifest();
|
|
256
|
+
rawBad["runtimes"] = [
|
|
257
|
+
{ kind: "python", python: "3.11", installer: "uv", torchBackend: bad, requirements: [{ name: "torch" }] },
|
|
258
|
+
];
|
|
259
|
+
expect(() => parseManifest(rawBad, "m.yaml")).toThrowError(MoldeskError);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
it("rejects an empty extraIndexUrls array", () => {
|
|
263
|
+
const raw = baseManifest();
|
|
264
|
+
raw["runtimes"] = [
|
|
265
|
+
{ kind: "python", python: "3.11", installer: "uv", extraIndexUrls: [], requirements: [{ name: "torch" }] },
|
|
266
|
+
];
|
|
267
|
+
expect(() => parseManifest(raw, "m.yaml")).toThrowError(MoldeskError);
|
|
268
|
+
});
|
|
269
|
+
it("accepts preInstall requirements and a buildAfter list referencing a real requirement", () => {
|
|
270
|
+
const raw = baseManifest();
|
|
271
|
+
raw["runtimes"] = [
|
|
272
|
+
{
|
|
273
|
+
kind: "python",
|
|
274
|
+
python: "3.10",
|
|
275
|
+
installer: "uv",
|
|
276
|
+
preInstall: [{ name: "setuptools", version: "69.5.1" }],
|
|
277
|
+
buildAfter: ["openfold"],
|
|
278
|
+
requirements: [
|
|
279
|
+
{ name: "torch", version: "1.13.1+cu117" },
|
|
280
|
+
{ name: "openfold", source: "https://github.com/aqlaboratory/openfold", revision: "a".repeat(40) },
|
|
281
|
+
],
|
|
282
|
+
},
|
|
283
|
+
];
|
|
284
|
+
const parsed = parseManifest(raw, "m.yaml");
|
|
285
|
+
expect(parsed.runtimes[0]).toMatchObject({ buildAfter: ["openfold"] });
|
|
286
|
+
});
|
|
287
|
+
it("rejects a buildAfter entry that does not match any requirement name", () => {
|
|
288
|
+
const raw = baseManifest();
|
|
289
|
+
raw["runtimes"] = [
|
|
290
|
+
{
|
|
291
|
+
kind: "python",
|
|
292
|
+
python: "3.10",
|
|
293
|
+
installer: "uv",
|
|
294
|
+
buildAfter: ["not-a-real-requirement"],
|
|
295
|
+
requirements: [{ name: "torch", version: "1.13.1+cu117" }],
|
|
296
|
+
},
|
|
297
|
+
];
|
|
298
|
+
expect(() => parseManifest(raw, "m.yaml")).toThrowError(MoldeskError);
|
|
299
|
+
});
|
|
300
|
+
it("rejects a python requirement with an invalid hash shape", () => {
|
|
301
|
+
const raw = baseManifest();
|
|
302
|
+
raw["runtimes"] = [
|
|
303
|
+
{ kind: "python", python: "3.11", installer: "uv", requirements: [{ name: "torch", hashes: ["not-a-hash"] }] },
|
|
304
|
+
];
|
|
305
|
+
expect(() => parseManifest(raw, "m.yaml")).toThrowError(MoldeskError);
|
|
306
|
+
});
|
|
307
|
+
it("requires nvidiaGpu on a runtime entry whenever that entry sets minVramGb", () => {
|
|
308
|
+
const raw = baseManifest();
|
|
309
|
+
raw["runtimes"] = [
|
|
310
|
+
{ kind: "python", python: "3.11", installer: "uv", minVramGb: 16, requirements: [{ name: "torch" }] },
|
|
311
|
+
];
|
|
312
|
+
expect(() => parseManifest(raw, "m.yaml")).toThrowError(MoldeskError);
|
|
313
|
+
raw["runtimes"] = [
|
|
314
|
+
{ kind: "python", python: "3.11", installer: "uv", minVramGb: 16, nvidiaGpu: "required", requirements: [{ name: "torch" }] },
|
|
315
|
+
];
|
|
316
|
+
expect(() => parseManifest(raw, "m.yaml")).not.toThrow();
|
|
317
|
+
});
|
|
318
|
+
it("existing single-runtime manifests with no platforms field still validate unmodified", () => {
|
|
319
|
+
const m = parseManifest(baseManifest(), "test/manifest.yaml");
|
|
320
|
+
expect(m.runtimes).toHaveLength(1);
|
|
321
|
+
expect(m.runtimes[0].platforms).toBeUndefined();
|
|
322
|
+
});
|
|
323
|
+
it("corrupt fixtures name manifest, field, and remediation", () => {
|
|
324
|
+
try {
|
|
325
|
+
parseManifest({ schemaVersion: 1, name: "Bad Name!" }, "models/bad/manifest.yaml");
|
|
326
|
+
expect.unreachable();
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
const e = error;
|
|
330
|
+
expect(e.message).toContain("models/bad/manifest.yaml");
|
|
331
|
+
expect(e.remediation).toBeTruthy();
|
|
332
|
+
expect(e.details?.["manifestPath"]).toBe("models/bad/manifest.yaml");
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
});
|