@moldesk/runtime 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/docker/index.d.ts +41 -4
- package/dist/docker/index.js +140 -14
- package/dist/docker/index.test.d.ts +1 -0
- package/dist/docker/index.test.js +108 -0
- package/dist/filesystem/companion-inputs.d.ts +34 -0
- package/dist/filesystem/companion-inputs.js +123 -0
- package/dist/filesystem/companion-inputs.test.d.ts +1 -0
- package/dist/filesystem/companion-inputs.test.js +256 -0
- package/dist/filesystem/index.d.ts +4 -0
- package/dist/filesystem/index.js +2 -0
- package/dist/filesystem/paths.d.ts +20 -0
- package/dist/filesystem/paths.js +79 -0
- package/dist/filesystem/paths.test.d.ts +1 -0
- package/dist/filesystem/paths.test.js +35 -0
- package/dist/hardware/index.d.ts +21 -0
- package/dist/hardware/index.js +62 -0
- package/dist/hardware/index.test.d.ts +1 -0
- package/dist/hardware/index.test.js +27 -0
- package/dist/index.d.ts +22 -5
- package/dist/index.js +17 -12
- package/dist/install/index.d.ts +83 -0
- package/dist/install/index.js +487 -0
- package/dist/install/index.test.d.ts +1 -0
- package/dist/install/index.test.js +628 -0
- package/dist/nvidia/index.d.ts +29 -0
- package/dist/nvidia/index.js +119 -0
- package/dist/nvidia/index.test.d.ts +1 -0
- package/dist/nvidia/index.test.js +91 -0
- package/dist/process/index.d.ts +34 -0
- package/dist/process/index.js +99 -4
- package/dist/process/index.test.d.ts +1 -0
- package/dist/process/index.test.js +36 -0
- package/dist/providers/python.d.ts +14 -0
- package/dist/providers/python.js +80 -0
- package/dist/providers/python.test.d.ts +1 -0
- package/dist/providers/python.test.js +68 -0
- package/dist/providers.d.ts +49 -0
- package/dist/providers.js +1 -0
- package/dist/providers.test.d.ts +1 -0
- package/dist/providers.test.js +10 -0
- package/dist/python/index.d.ts +14 -3
- package/dist/python/index.js +30 -12
- package/dist/redact.d.ts +6 -0
- package/dist/redact.js +16 -0
- package/dist/redact.test.d.ts +1 -0
- package/dist/redact.test.js +16 -0
- package/dist/system.d.ts +39 -0
- package/dist/system.js +42 -0
- package/dist/system.test.d.ts +1 -0
- package/dist/system.test.js +138 -0
- package/package.json +8 -3
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { MoldeskError } from "@moldesk/registry";
|
|
6
|
+
import { stageCompanionInputs } from "./companion-inputs.js";
|
|
7
|
+
const dirs = [];
|
|
8
|
+
function tempDir(prefix) {
|
|
9
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
10
|
+
dirs.push(dir);
|
|
11
|
+
// Canonicalize immediately: on macOS, os.tmpdir() lives under a symlink
|
|
12
|
+
// (/var/folders -> /private/var/folders), and stageCompanionInputs now
|
|
13
|
+
// always returns realpathSync-canonicalized paths (that's the P1 fix this
|
|
14
|
+
// file tests) — comparing against the pre-canonicalized path would fail
|
|
15
|
+
// for reasons unrelated to what each test actually checks.
|
|
16
|
+
return fs.realpathSync(dir);
|
|
17
|
+
}
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
for (const dir of dirs.splice(0))
|
|
20
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
describe("stageCompanionInputs", () => {
|
|
23
|
+
it("stages a companion file into an id-namespaced subdirectory and records its original + stored paths", () => {
|
|
24
|
+
const jobDir = tempDir("moldesk-companion-job-");
|
|
25
|
+
const jobFilePath = path.join(jobDir, "job.json");
|
|
26
|
+
fs.writeFileSync(jobFilePath, "{}");
|
|
27
|
+
const proteinPath = path.join(jobDir, "protein.pdb");
|
|
28
|
+
fs.writeFileSync(proteinPath, "ATOM\n");
|
|
29
|
+
const runDir = tempDir("moldesk-companion-run-");
|
|
30
|
+
const inputDir = path.join(runDir, "input");
|
|
31
|
+
fs.mkdirSync(inputDir, { recursive: true });
|
|
32
|
+
const staged = stageCompanionInputs({
|
|
33
|
+
jobFilePath,
|
|
34
|
+
refs: [{ id: "protein", sourcePath: "protein.pdb" }],
|
|
35
|
+
inputDir,
|
|
36
|
+
});
|
|
37
|
+
expect(staged).toHaveLength(1);
|
|
38
|
+
expect(staged[0].id).toBe("protein");
|
|
39
|
+
expect(staged[0].originalPath).toBe(proteinPath);
|
|
40
|
+
expect(staged[0].storedPath).toBe(path.join(inputDir, "protein", "protein.pdb"));
|
|
41
|
+
expect(fs.readFileSync(staged[0].storedPath, "utf8")).toBe("ATOM\n");
|
|
42
|
+
});
|
|
43
|
+
it("resolves relative sourcePath against the job file's own directory, not the process cwd", () => {
|
|
44
|
+
const jobDir = tempDir("moldesk-companion-job-");
|
|
45
|
+
const nested = path.join(jobDir, "nested");
|
|
46
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
47
|
+
const jobFilePath = path.join(nested, "job.json");
|
|
48
|
+
fs.writeFileSync(jobFilePath, "{}");
|
|
49
|
+
fs.writeFileSync(path.join(nested, "ligand.sdf"), "ligand-bytes");
|
|
50
|
+
const inputDir = path.join(tempDir("moldesk-companion-run-"), "input");
|
|
51
|
+
fs.mkdirSync(inputDir, { recursive: true });
|
|
52
|
+
const staged = stageCompanionInputs({
|
|
53
|
+
jobFilePath,
|
|
54
|
+
refs: [{ id: "ligand", sourcePath: "ligand.sdf" }],
|
|
55
|
+
inputDir,
|
|
56
|
+
});
|
|
57
|
+
expect(staged[0].originalPath).toBe(path.join(nested, "ligand.sdf"));
|
|
58
|
+
});
|
|
59
|
+
it("rejects a missing referenced file", () => {
|
|
60
|
+
const jobDir = tempDir("moldesk-companion-job-");
|
|
61
|
+
const jobFilePath = path.join(jobDir, "job.json");
|
|
62
|
+
fs.writeFileSync(jobFilePath, "{}");
|
|
63
|
+
const inputDir = path.join(tempDir("moldesk-companion-run-"), "input");
|
|
64
|
+
fs.mkdirSync(inputDir, { recursive: true });
|
|
65
|
+
expect(() => stageCompanionInputs({ jobFilePath, refs: [{ id: "protein", sourcePath: "missing.pdb" }], inputDir })).toThrowError(MoldeskError);
|
|
66
|
+
try {
|
|
67
|
+
stageCompanionInputs({ jobFilePath, refs: [{ id: "protein", sourcePath: "missing.pdb" }], inputDir });
|
|
68
|
+
expect.unreachable();
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
expect(error.code).toBe("INVALID_RUN_INPUT");
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
it("rejects ../ traversal escaping the allowed root", () => {
|
|
75
|
+
const jobDir = tempDir("moldesk-companion-job-");
|
|
76
|
+
const jobFilePath = path.join(jobDir, "job.json");
|
|
77
|
+
fs.writeFileSync(jobFilePath, "{}");
|
|
78
|
+
const outsideDir = tempDir("moldesk-companion-outside-");
|
|
79
|
+
fs.writeFileSync(path.join(outsideDir, "secret.pdb"), "secret");
|
|
80
|
+
const inputDir = path.join(tempDir("moldesk-companion-run-"), "input");
|
|
81
|
+
fs.mkdirSync(inputDir, { recursive: true });
|
|
82
|
+
const traversal = path.relative(jobDir, path.join(outsideDir, "secret.pdb"));
|
|
83
|
+
try {
|
|
84
|
+
stageCompanionInputs({ jobFilePath, refs: [{ id: "protein", sourcePath: traversal }], inputDir });
|
|
85
|
+
expect.unreachable();
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
expect(error.code).toBe("UNSAFE_COMPANION_INPUT_PATH");
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
it("rejects a sibling-prefix path a naive string.startsWith(allowedRoot) check would wrongly accept", () => {
|
|
92
|
+
const parent = tempDir("moldesk-companion-parent-");
|
|
93
|
+
const allowedRoot = path.join(parent, "allowed-root");
|
|
94
|
+
const evilSibling = path.join(parent, "allowed-root-evil");
|
|
95
|
+
fs.mkdirSync(allowedRoot, { recursive: true });
|
|
96
|
+
fs.mkdirSync(evilSibling, { recursive: true });
|
|
97
|
+
const jobFilePath = path.join(allowedRoot, "job.json");
|
|
98
|
+
fs.writeFileSync(jobFilePath, "{}");
|
|
99
|
+
fs.writeFileSync(path.join(evilSibling, "secret.pdb"), "secret");
|
|
100
|
+
const inputDir = path.join(tempDir("moldesk-companion-run-"), "input");
|
|
101
|
+
fs.mkdirSync(inputDir, { recursive: true });
|
|
102
|
+
// A plain `string.startsWith(allowedRoot)` check would incorrectly accept this,
|
|
103
|
+
// since "/…/allowed-root-evil/secret.pdb" starts with "/…/allowed-root". The
|
|
104
|
+
// correct `path.relative`-based confinement must reject it.
|
|
105
|
+
const absoluteEscape = path.join(evilSibling, "secret.pdb");
|
|
106
|
+
try {
|
|
107
|
+
stageCompanionInputs({ jobFilePath, refs: [{ id: "protein", sourcePath: absoluteEscape }], inputDir, allowedRoot });
|
|
108
|
+
expect.unreachable();
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
expect(error.code).toBe("UNSAFE_COMPANION_INPUT_PATH");
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
it("rejects a symlink whose target escapes the allowed root", () => {
|
|
115
|
+
const jobDir = tempDir("moldesk-companion-job-");
|
|
116
|
+
const jobFilePath = path.join(jobDir, "job.json");
|
|
117
|
+
fs.writeFileSync(jobFilePath, "{}");
|
|
118
|
+
const outsideDir = tempDir("moldesk-companion-outside-");
|
|
119
|
+
const secretPath = path.join(outsideDir, "secret.pdb");
|
|
120
|
+
fs.writeFileSync(secretPath, "secret");
|
|
121
|
+
const linkPath = path.join(jobDir, "link.pdb");
|
|
122
|
+
fs.symlinkSync(secretPath, linkPath);
|
|
123
|
+
const inputDir = path.join(tempDir("moldesk-companion-run-"), "input");
|
|
124
|
+
fs.mkdirSync(inputDir, { recursive: true });
|
|
125
|
+
try {
|
|
126
|
+
stageCompanionInputs({ jobFilePath, refs: [{ id: "protein", sourcePath: "link.pdb" }], inputDir });
|
|
127
|
+
expect.unreachable();
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
expect(error.code).toBe("UNSAFE_COMPANION_INPUT_PATH");
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
it("rejects a reference reached through a symlinked ANCESTOR directory, not just a symlinked leaf file", () => {
|
|
134
|
+
// allowedRoot/sub is a symlink to a directory outside allowedRoot. The leaf
|
|
135
|
+
// reference itself ("sub/secret.pdb") is a normal file, not a symlink — only
|
|
136
|
+
// an ancestor path component is. `lstat` on the leaf alone would report a
|
|
137
|
+
// regular file (the OS silently resolves the ancestor symlink during
|
|
138
|
+
// traversal), so this only fails if the full resolved path is canonicalized
|
|
139
|
+
// with `realpathSync` before the boundary check, not just the leaf.
|
|
140
|
+
const jobDir = tempDir("moldesk-companion-job-");
|
|
141
|
+
const jobFilePath = path.join(jobDir, "job.json");
|
|
142
|
+
fs.writeFileSync(jobFilePath, "{}");
|
|
143
|
+
const outsideDir = tempDir("moldesk-companion-outside-");
|
|
144
|
+
fs.writeFileSync(path.join(outsideDir, "secret.pdb"), "secret");
|
|
145
|
+
fs.symlinkSync(outsideDir, path.join(jobDir, "sub"));
|
|
146
|
+
const inputDir = path.join(tempDir("moldesk-companion-run-"), "input");
|
|
147
|
+
fs.mkdirSync(inputDir, { recursive: true });
|
|
148
|
+
try {
|
|
149
|
+
stageCompanionInputs({ jobFilePath, refs: [{ id: "protein", sourcePath: "sub/secret.pdb" }], inputDir });
|
|
150
|
+
expect.unreachable();
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
expect(error.code).toBe("UNSAFE_COMPANION_INPUT_PATH");
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
it("rejects a directory reference", () => {
|
|
157
|
+
const jobDir = tempDir("moldesk-companion-job-");
|
|
158
|
+
const jobFilePath = path.join(jobDir, "job.json");
|
|
159
|
+
fs.writeFileSync(jobFilePath, "{}");
|
|
160
|
+
fs.mkdirSync(path.join(jobDir, "a-directory"));
|
|
161
|
+
const inputDir = path.join(tempDir("moldesk-companion-run-"), "input");
|
|
162
|
+
fs.mkdirSync(inputDir, { recursive: true });
|
|
163
|
+
try {
|
|
164
|
+
stageCompanionInputs({ jobFilePath, refs: [{ id: "protein", sourcePath: "a-directory" }], inputDir });
|
|
165
|
+
expect.unreachable();
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
expect(error.code).toBe("UNSAFE_COMPANION_INPUT_TYPE");
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
it("rejects duplicate companion input ids before touching disk", () => {
|
|
172
|
+
const jobDir = tempDir("moldesk-companion-job-");
|
|
173
|
+
const jobFilePath = path.join(jobDir, "job.json");
|
|
174
|
+
fs.writeFileSync(jobFilePath, "{}");
|
|
175
|
+
fs.writeFileSync(path.join(jobDir, "a.pdb"), "a");
|
|
176
|
+
fs.writeFileSync(path.join(jobDir, "b.pdb"), "b");
|
|
177
|
+
const inputDir = path.join(tempDir("moldesk-companion-run-"), "input");
|
|
178
|
+
fs.mkdirSync(inputDir, { recursive: true });
|
|
179
|
+
try {
|
|
180
|
+
stageCompanionInputs({
|
|
181
|
+
jobFilePath,
|
|
182
|
+
refs: [{ id: "protein", sourcePath: "a.pdb" }, { id: "protein", sourcePath: "b.pdb" }],
|
|
183
|
+
inputDir,
|
|
184
|
+
});
|
|
185
|
+
expect.unreachable();
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
expect(error.code).toBe("DUPLICATE_STAGED_INPUT");
|
|
189
|
+
}
|
|
190
|
+
expect(fs.existsSync(path.join(inputDir, "protein"))).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
it("refuses to overwrite an existing staged destination", () => {
|
|
193
|
+
const jobDir = tempDir("moldesk-companion-job-");
|
|
194
|
+
const jobFilePath = path.join(jobDir, "job.json");
|
|
195
|
+
fs.writeFileSync(jobFilePath, "{}");
|
|
196
|
+
fs.writeFileSync(path.join(jobDir, "protein.pdb"), "content");
|
|
197
|
+
const inputDir = path.join(tempDir("moldesk-companion-run-"), "input");
|
|
198
|
+
fs.mkdirSync(path.join(inputDir, "protein"), { recursive: true });
|
|
199
|
+
fs.writeFileSync(path.join(inputDir, "protein", "protein.pdb"), "pre-existing");
|
|
200
|
+
try {
|
|
201
|
+
stageCompanionInputs({ jobFilePath, refs: [{ id: "protein", sourcePath: "protein.pdb" }], inputDir });
|
|
202
|
+
expect.unreachable();
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
expect(error.code).toBe("STAGED_INPUT_DESTINATION_EXISTS");
|
|
206
|
+
}
|
|
207
|
+
expect(fs.readFileSync(path.join(inputDir, "protein", "protein.pdb"), "utf8")).toBe("pre-existing");
|
|
208
|
+
});
|
|
209
|
+
it("re-validates at staging time rather than trusting an earlier discovery-time check (TOCTOU: file replaced with an escaping symlink)", () => {
|
|
210
|
+
const jobDir = tempDir("moldesk-companion-job-");
|
|
211
|
+
const jobFilePath = path.join(jobDir, "job.json");
|
|
212
|
+
fs.writeFileSync(jobFilePath, "{}");
|
|
213
|
+
const refPath = path.join(jobDir, "protein.pdb");
|
|
214
|
+
fs.writeFileSync(refPath, "original content");
|
|
215
|
+
const inputDir = path.join(tempDir("moldesk-companion-run-"), "input");
|
|
216
|
+
fs.mkdirSync(inputDir, { recursive: true });
|
|
217
|
+
// Simulate an earlier "discovery" check (e.g. an adapter's own validateInput)
|
|
218
|
+
// that inspected the file when it was still a safe regular file.
|
|
219
|
+
expect(fs.existsSync(refPath)).toBe(true);
|
|
220
|
+
expect(fs.lstatSync(refPath).isFile()).toBe(true);
|
|
221
|
+
// Between discovery and staging, the file is replaced with a symlink escaping
|
|
222
|
+
// the allowed root.
|
|
223
|
+
const outsideDir = tempDir("moldesk-companion-outside-");
|
|
224
|
+
const secretPath = path.join(outsideDir, "secret.pdb");
|
|
225
|
+
fs.writeFileSync(secretPath, "secret");
|
|
226
|
+
fs.rmSync(refPath);
|
|
227
|
+
fs.symlinkSync(secretPath, refPath);
|
|
228
|
+
try {
|
|
229
|
+
stageCompanionInputs({ jobFilePath, refs: [{ id: "protein", sourcePath: "protein.pdb" }], inputDir });
|
|
230
|
+
expect.unreachable();
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
expect(error.code).toBe("UNSAFE_COMPANION_INPUT_PATH");
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
it("stages multiple distinct companion inputs independently", () => {
|
|
237
|
+
const jobDir = tempDir("moldesk-companion-job-");
|
|
238
|
+
const jobFilePath = path.join(jobDir, "job.json");
|
|
239
|
+
fs.writeFileSync(jobFilePath, "{}");
|
|
240
|
+
fs.writeFileSync(path.join(jobDir, "protein.pdb"), "protein-bytes");
|
|
241
|
+
fs.writeFileSync(path.join(jobDir, "ligand.sdf"), "ligand-bytes");
|
|
242
|
+
const inputDir = path.join(tempDir("moldesk-companion-run-"), "input");
|
|
243
|
+
fs.mkdirSync(inputDir, { recursive: true });
|
|
244
|
+
const staged = stageCompanionInputs({
|
|
245
|
+
jobFilePath,
|
|
246
|
+
refs: [
|
|
247
|
+
{ id: "protein", sourcePath: "protein.pdb" },
|
|
248
|
+
{ id: "ligand", sourcePath: "ligand.sdf" },
|
|
249
|
+
],
|
|
250
|
+
inputDir,
|
|
251
|
+
});
|
|
252
|
+
expect(staged.map((s) => s.id).sort()).toEqual(["ligand", "protein"]);
|
|
253
|
+
expect(fs.readFileSync(path.join(inputDir, "protein", "protein.pdb"), "utf8")).toBe("protein-bytes");
|
|
254
|
+
expect(fs.readFileSync(path.join(inputDir, "ligand", "ligand.sdf"), "utf8")).toBe("ligand-bytes");
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { getMoldeskHome, getMoldeskPaths, modelInstallDir, modelVersionDir, pythonToolDir, runDir, uvToolDir } from "./paths.js";
|
|
2
|
+
export type { MoldeskPaths } from "./paths.js";
|
|
3
|
+
export { stageCompanionInputs } from "./companion-inputs.js";
|
|
4
|
+
export type { CompanionInputRef, StageCompanionInputsParams, StagedCompanionInputPath } from "./companion-inputs.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface MoldeskPaths {
|
|
2
|
+
home: string;
|
|
3
|
+
cache: string;
|
|
4
|
+
downloads: string;
|
|
5
|
+
objects: string;
|
|
6
|
+
models: string;
|
|
7
|
+
runs: string;
|
|
8
|
+
state: string;
|
|
9
|
+
locks: string;
|
|
10
|
+
tools: string;
|
|
11
|
+
}
|
|
12
|
+
/** Centralized home resolution. Honors MOLDESK_HOME for tests/advanced users. */
|
|
13
|
+
export declare function getMoldeskHome(env?: NodeJS.ProcessEnv): string;
|
|
14
|
+
/** Central path calculation. No other module should construct these paths by hand. */
|
|
15
|
+
export declare function getMoldeskPaths(env?: NodeJS.ProcessEnv): MoldeskPaths;
|
|
16
|
+
export declare function modelVersionDir(model: string, modelVersion: string, env?: NodeJS.ProcessEnv): string;
|
|
17
|
+
export declare function modelInstallDir(model: string, modelVersion: string, runtimeFingerprint: string, env?: NodeJS.ProcessEnv): string;
|
|
18
|
+
export declare function runDir(runId: string, env?: NodeJS.ProcessEnv): string;
|
|
19
|
+
export declare function uvToolDir(version: string, env?: NodeJS.ProcessEnv): string;
|
|
20
|
+
export declare function pythonToolDir(version: string, env?: NodeJS.ProcessEnv): string;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { MoldeskError } from "@moldesk/registry";
|
|
4
|
+
function defaultHome() {
|
|
5
|
+
// macOS/Linux default per spec §1.3. Windows out of v0.1 execution scope.
|
|
6
|
+
return path.join(os.homedir(), ".moldesk");
|
|
7
|
+
}
|
|
8
|
+
function unsafePath(message, details) {
|
|
9
|
+
throw new MoldeskError({
|
|
10
|
+
code: "UNSAFE_PATH",
|
|
11
|
+
message,
|
|
12
|
+
remediation: "Use a MoleculeDesk-owned directory and safe identifier values without path separators.",
|
|
13
|
+
details,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
function safeSegment(value, label, pattern) {
|
|
17
|
+
if (!pattern.test(value) || value === "." || value === ".." || value.includes("\0")) {
|
|
18
|
+
return unsafePath(`Unsafe ${label}: ${JSON.stringify(value)}`, { label, value });
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
function safeModelName(value) {
|
|
23
|
+
return safeSegment(value, "model name", /^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
|
24
|
+
}
|
|
25
|
+
function safeVersion(value, label) {
|
|
26
|
+
return safeSegment(value, label, /^[A-Za-z0-9][A-Za-z0-9._+-]*$/);
|
|
27
|
+
}
|
|
28
|
+
function safeRuntimeFingerprint(value) {
|
|
29
|
+
return safeSegment(value, "runtime fingerprint", /^(?:python|docker)-[a-f0-9]{16,64}$/);
|
|
30
|
+
}
|
|
31
|
+
const RUN_ID_PATTERN = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{3}Z-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
32
|
+
function safeRunId(value) {
|
|
33
|
+
return safeSegment(value, "run id", RUN_ID_PATTERN);
|
|
34
|
+
}
|
|
35
|
+
/** Centralized home resolution. Honors MOLDESK_HOME for tests/advanced users. */
|
|
36
|
+
export function getMoldeskHome(env = process.env) {
|
|
37
|
+
const override = env["MOLDESK_HOME"];
|
|
38
|
+
if (override && override.trim().length > 0) {
|
|
39
|
+
const resolved = path.resolve(override.trim());
|
|
40
|
+
const filesystemRoot = path.parse(resolved).root;
|
|
41
|
+
if (resolved === filesystemRoot || resolved === path.resolve(os.homedir())) {
|
|
42
|
+
return unsafePath(`Refusing unsafe MOLDESK_HOME: ${resolved}`, { resolved });
|
|
43
|
+
}
|
|
44
|
+
return resolved;
|
|
45
|
+
}
|
|
46
|
+
return defaultHome();
|
|
47
|
+
}
|
|
48
|
+
/** Central path calculation. No other module should construct these paths by hand. */
|
|
49
|
+
export function getMoldeskPaths(env = process.env) {
|
|
50
|
+
const home = getMoldeskHome(env);
|
|
51
|
+
const cache = path.join(home, "cache");
|
|
52
|
+
const state = path.join(home, "state");
|
|
53
|
+
return {
|
|
54
|
+
home,
|
|
55
|
+
cache,
|
|
56
|
+
downloads: path.join(cache, "downloads"),
|
|
57
|
+
objects: path.join(cache, "objects"),
|
|
58
|
+
models: path.join(home, "models"),
|
|
59
|
+
runs: path.join(home, "runs"),
|
|
60
|
+
state,
|
|
61
|
+
locks: path.join(state, "locks"),
|
|
62
|
+
tools: path.join(home, "tools"),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function modelVersionDir(model, modelVersion, env) {
|
|
66
|
+
return path.join(getMoldeskPaths(env).models, safeModelName(model), safeVersion(modelVersion, "model version"));
|
|
67
|
+
}
|
|
68
|
+
export function modelInstallDir(model, modelVersion, runtimeFingerprint, env) {
|
|
69
|
+
return path.join(modelVersionDir(model, modelVersion, env), safeRuntimeFingerprint(runtimeFingerprint));
|
|
70
|
+
}
|
|
71
|
+
export function runDir(runId, env) {
|
|
72
|
+
return path.join(getMoldeskPaths(env).runs, safeRunId(runId));
|
|
73
|
+
}
|
|
74
|
+
export function uvToolDir(version, env) {
|
|
75
|
+
return path.join(getMoldeskPaths(env).tools, "uv", safeVersion(version, "uv version"));
|
|
76
|
+
}
|
|
77
|
+
export function pythonToolDir(version, env) {
|
|
78
|
+
return path.join(getMoldeskPaths(env).tools, "python", safeVersion(version, "Python version"));
|
|
79
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { MoldeskError } from "@moldesk/registry";
|
|
5
|
+
import { getMoldeskHome, getMoldeskPaths, modelInstallDir, uvToolDir } from "./paths.js";
|
|
6
|
+
describe("moldesk paths", () => {
|
|
7
|
+
it("honors MOLDESK_HOME override", () => {
|
|
8
|
+
const p = getMoldeskPaths({ MOLDESK_HOME: "/tmp/custom-home" });
|
|
9
|
+
expect(p.home).toBe(path.resolve("/tmp/custom-home"));
|
|
10
|
+
expect(p.downloads).toBe(path.join(p.home, "cache", "downloads"));
|
|
11
|
+
expect(p.locks).toBe(path.join(p.home, "state", "locks"));
|
|
12
|
+
});
|
|
13
|
+
it("defaults to ~/.moldesk", () => {
|
|
14
|
+
const env = {};
|
|
15
|
+
expect(getMoldeskHome(env)).toBe(path.join(os.homedir(), ".moldesk"));
|
|
16
|
+
});
|
|
17
|
+
it("nests one model version with multiple runtime fingerprints", () => {
|
|
18
|
+
const a = modelInstallDir("boltz", "2.2.1", "python-aaaaaaaaaaaaaaaa", { MOLDESK_HOME: "/tmp/h" });
|
|
19
|
+
const b = modelInstallDir("boltz", "2.2.1", "docker-bbbbbbbbbbbbbbbb", { MOLDESK_HOME: "/tmp/h" });
|
|
20
|
+
expect(a).toContain(path.join("models", "boltz", "2.2.1", "python-aaaaaaaaaaaaaaaa"));
|
|
21
|
+
expect(b).toContain(path.join("models", "boltz", "2.2.1", "docker-bbbbbbbbbbbbbbbb"));
|
|
22
|
+
expect(a).not.toBe(b);
|
|
23
|
+
});
|
|
24
|
+
it("rejects traversal and malformed installation path segments", () => {
|
|
25
|
+
const env = { MOLDESK_HOME: "/tmp/h" };
|
|
26
|
+
expect(() => modelInstallDir("../escape", "1.0.0", "python-aaaaaaaaaaaaaaaa", env)).toThrowError(MoldeskError);
|
|
27
|
+
expect(() => modelInstallDir("boltz", "../../escape", "python-aaaaaaaaaaaaaaaa", env)).toThrowError(MoldeskError);
|
|
28
|
+
expect(() => modelInstallDir("boltz", "1.0.0", "../../escape", env)).toThrowError(MoldeskError);
|
|
29
|
+
expect(() => uvToolDir("../../escape", env)).toThrowError(MoldeskError);
|
|
30
|
+
});
|
|
31
|
+
it("rejects filesystem root and the user home as MOLDESK_HOME", () => {
|
|
32
|
+
expect(() => getMoldeskHome({ MOLDESK_HOME: path.parse(path.resolve(path.sep)).root })).toThrowError(MoldeskError);
|
|
33
|
+
expect(() => getMoldeskHome({ MOLDESK_HOME: os.homedir() })).toThrowError(MoldeskError);
|
|
34
|
+
});
|
|
35
|
+
});
|
package/dist/hardware/index.d.ts
CHANGED
|
@@ -7,3 +7,24 @@ export interface HardwareInfo {
|
|
|
7
7
|
isAppleSilicon: boolean;
|
|
8
8
|
}
|
|
9
9
|
export declare function getHardwareInfo(): HardwareInfo;
|
|
10
|
+
export interface DiskInfo {
|
|
11
|
+
availableBytes: number;
|
|
12
|
+
path: string;
|
|
13
|
+
/** True when the statfs probe failed/was unsupported — `availableBytes: 0` is a conservative placeholder, not a real reading. */
|
|
14
|
+
probeFailed?: boolean;
|
|
15
|
+
/** Captured error/reason when `probeFailed` is true. */
|
|
16
|
+
probeError?: string;
|
|
17
|
+
}
|
|
18
|
+
export type StatFsFn = (target: string) => Promise<{
|
|
19
|
+
availableBytes: number;
|
|
20
|
+
} | null>;
|
|
21
|
+
/** Default disk probe via `fs.statfs` (Node >= 19). Returns null when unavailable. */
|
|
22
|
+
export declare const defaultStatFs: StatFsFn;
|
|
23
|
+
/**
|
|
24
|
+
* Query free space at MOLDESK_HOME (or nearest existing parent).
|
|
25
|
+
* Never throws: on probe failure reports `availableBytes: 0` with
|
|
26
|
+
* `probeFailed: true` so callers can distinguish "genuinely 0 bytes free"
|
|
27
|
+
* (probeFailed absent) from "could not be determined" (probeFailed true) —
|
|
28
|
+
* the two must not be treated the same by compatibility evaluation.
|
|
29
|
+
*/
|
|
30
|
+
export declare function getDiskInfo(env?: NodeJS.ProcessEnv, statfs?: StatFsFn): Promise<DiskInfo>;
|
package/dist/hardware/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { getMoldeskHome } from "../filesystem/index.js";
|
|
2
5
|
export function getHardwareInfo() {
|
|
3
6
|
const cpus = os.cpus();
|
|
4
7
|
const arch = os.arch();
|
|
@@ -12,3 +15,62 @@ export function getHardwareInfo() {
|
|
|
12
15
|
isAppleSilicon: platform === "darwin" && arch === "arm64",
|
|
13
16
|
};
|
|
14
17
|
}
|
|
18
|
+
function nearestExistingParent(start) {
|
|
19
|
+
let current = path.resolve(start);
|
|
20
|
+
for (;;) {
|
|
21
|
+
try {
|
|
22
|
+
if (fs.existsSync(current))
|
|
23
|
+
return current;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// fall through to parent walk
|
|
27
|
+
}
|
|
28
|
+
const parent = path.dirname(current);
|
|
29
|
+
if (parent === current)
|
|
30
|
+
return current;
|
|
31
|
+
current = parent;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Default disk probe via `fs.statfs` (Node >= 19). Returns null when unavailable. */
|
|
35
|
+
export const defaultStatFs = async (target) => {
|
|
36
|
+
const statfs = fs.promises.statfs;
|
|
37
|
+
if (typeof statfs !== "function")
|
|
38
|
+
return null;
|
|
39
|
+
try {
|
|
40
|
+
const stats = await statfs(target);
|
|
41
|
+
const available = typeof stats.bavail === "number" && typeof stats.bsize === "number"
|
|
42
|
+
? stats.bavail * stats.bsize
|
|
43
|
+
: Number.NaN;
|
|
44
|
+
if (!Number.isFinite(available) || available < 0)
|
|
45
|
+
return null;
|
|
46
|
+
return { availableBytes: Math.floor(available) };
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Query free space at MOLDESK_HOME (or nearest existing parent).
|
|
54
|
+
* Never throws: on probe failure reports `availableBytes: 0` with
|
|
55
|
+
* `probeFailed: true` so callers can distinguish "genuinely 0 bytes free"
|
|
56
|
+
* (probeFailed absent) from "could not be determined" (probeFailed true) —
|
|
57
|
+
* the two must not be treated the same by compatibility evaluation.
|
|
58
|
+
*/
|
|
59
|
+
export async function getDiskInfo(env = process.env, statfs = defaultStatFs) {
|
|
60
|
+
const home = getMoldeskHome(env);
|
|
61
|
+
const target = nearestExistingParent(home);
|
|
62
|
+
try {
|
|
63
|
+
const result = await statfs(target);
|
|
64
|
+
if (result)
|
|
65
|
+
return { availableBytes: result.availableBytes, path: target };
|
|
66
|
+
return { availableBytes: 0, path: target, probeFailed: true, probeError: "disk space probe unsupported or failed" };
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return {
|
|
70
|
+
availableBytes: 0,
|
|
71
|
+
path: target,
|
|
72
|
+
probeFailed: true,
|
|
73
|
+
probeError: error instanceof Error ? error.message : "disk space probe threw an unexpected error",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getDiskInfo } from "./index.js";
|
|
3
|
+
describe("getDiskInfo — probeFailed vs genuine zero", () => {
|
|
4
|
+
it("does not set probeFailed on a genuine successful zero-byte reading", async () => {
|
|
5
|
+
const disk = await getDiskInfo({ MOLDESK_HOME: "/tmp" }, async () => ({ availableBytes: 0 }));
|
|
6
|
+
expect(disk.availableBytes).toBe(0);
|
|
7
|
+
expect(disk.probeFailed).toBeUndefined();
|
|
8
|
+
expect(disk.probeError).toBeUndefined();
|
|
9
|
+
});
|
|
10
|
+
it("sets probeFailed + probeError when statfs resolves null (unsupported)", async () => {
|
|
11
|
+
const disk = await getDiskInfo({ MOLDESK_HOME: "/tmp" }, async () => null);
|
|
12
|
+
expect(disk.probeFailed).toBe(true);
|
|
13
|
+
expect(disk.probeError).toBeTruthy();
|
|
14
|
+
});
|
|
15
|
+
it("sets probeFailed + probeError with the thrown message when statfs throws", async () => {
|
|
16
|
+
const disk = await getDiskInfo({ MOLDESK_HOME: "/tmp" }, async () => {
|
|
17
|
+
throw new Error("EACCES: permission denied");
|
|
18
|
+
});
|
|
19
|
+
expect(disk.probeFailed).toBe(true);
|
|
20
|
+
expect(disk.probeError).toBe("EACCES: permission denied");
|
|
21
|
+
});
|
|
22
|
+
it("reports a genuine positive reading without probeFailed", async () => {
|
|
23
|
+
const disk = await getDiskInfo({ MOLDESK_HOME: "/tmp" }, async () => ({ availableBytes: 5 * 1024 ** 3 }));
|
|
24
|
+
expect(disk.availableBytes).toBe(5 * 1024 ** 3);
|
|
25
|
+
expect(disk.probeFailed).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import { type HardwareInfo } from "./hardware/index.js";
|
|
2
2
|
import { type PythonInfo } from "./python/index.js";
|
|
3
3
|
import { type DockerInfo } from "./docker/index.js";
|
|
4
|
-
|
|
4
|
+
import { type DockerCapability, type DiskInfo, type NvidiaCapability, type NvidiaGpu, type SystemReport, type SystemReportDeps, type ToolCapability } from "./system.js";
|
|
5
|
+
export interface LegacySystemReport {
|
|
5
6
|
hardware: HardwareInfo;
|
|
6
7
|
python: PythonInfo;
|
|
7
8
|
docker: DockerInfo;
|
|
8
9
|
}
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Canonical system report (spec Step 2 shape). Re-exported through
|
|
12
|
+
* `@moldesk/core` for CLI + future consumers. Accepts `deps.dockerGpuAccess`
|
|
13
|
+
* so `install.ts` can inject a verified Tier-2 Docker GPU probe result.
|
|
14
|
+
*/
|
|
15
|
+
export declare function getSystemReport(deps?: SystemReportDeps): Promise<SystemReport>;
|
|
16
|
+
export { getMoldeskHome, getMoldeskPaths, modelInstallDir, modelVersionDir, pythonToolDir, runDir, uvToolDir, stageCompanionInputs, } from "./filesystem/index.js";
|
|
17
|
+
export type { MoldeskPaths, CompanionInputRef, StageCompanionInputsParams, StagedCompanionInputPath } from "./filesystem/index.js";
|
|
18
|
+
export type { ExecuteRequest, ExecutionResult, MoldeskErrorData, PrepareRuntimeRequest, PreparedRuntime, RemoveRuntimeRequest, RuntimeCapability, RuntimeProvider, } from "./providers.js";
|
|
19
|
+
export { MoldeskError } from "./providers.js";
|
|
20
|
+
export { PythonRuntimeProvider } from "./providers/python.js";
|
|
21
|
+
export { redactEnv } from "./redact.js";
|
|
22
|
+
export { getHardwareInfo, getDiskInfo } from "./hardware/index.js";
|
|
11
23
|
export { detectPython } from "./python/index.js";
|
|
12
|
-
export { detectDocker } from "./docker/index.js";
|
|
24
|
+
export { detectDocker, isImageCachedLocally, pullDockerImage, probeDockerGpuAccess, } from "./docker/index.js";
|
|
25
|
+
export { detectNvidia } from "./nvidia/index.js";
|
|
26
|
+
export { collectSystemReport } from "./system.js";
|
|
13
27
|
export { runCommand } from "./process/index.js";
|
|
14
|
-
export type {
|
|
28
|
+
export type { RunOptions, RunResult } from "./process/index.js";
|
|
29
|
+
export { cacheAsset, ensureManagedUv, managedUvDownloadBytes, materializeAsset, prepareDockerImage, preparePythonEnvironment, temporaryInstallDir, MANAGED_UV_VERSION, } from "./install/index.js";
|
|
30
|
+
export type { AssetFetch, InstallCommandRunner, InstallProgress, PostInstallHookResult, PreparedPythonEnvironment, PythonInstallRequest, } from "./install/index.js";
|
|
31
|
+
export type { DiskInfo, DockerCapability, DockerInfo, HardwareInfo, NvidiaCapability, NvidiaGpu, PythonInfo, SystemReport, SystemReportDeps, ToolCapability, };
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
docker,
|
|
10
|
-
};
|
|
1
|
+
import { collectSystemReport, } from "./system.js";
|
|
2
|
+
/**
|
|
3
|
+
* Canonical system report (spec Step 2 shape). Re-exported through
|
|
4
|
+
* `@moldesk/core` for CLI + future consumers. Accepts `deps.dockerGpuAccess`
|
|
5
|
+
* so `install.ts` can inject a verified Tier-2 Docker GPU probe result.
|
|
6
|
+
*/
|
|
7
|
+
export async function getSystemReport(deps = {}) {
|
|
8
|
+
return collectSystemReport(deps);
|
|
11
9
|
}
|
|
12
|
-
export {
|
|
10
|
+
export { getMoldeskHome, getMoldeskPaths, modelInstallDir, modelVersionDir, pythonToolDir, runDir, uvToolDir, stageCompanionInputs, } from "./filesystem/index.js";
|
|
11
|
+
export { MoldeskError } from "./providers.js";
|
|
12
|
+
export { PythonRuntimeProvider } from "./providers/python.js";
|
|
13
|
+
export { redactEnv } from "./redact.js";
|
|
14
|
+
export { getHardwareInfo, getDiskInfo } from "./hardware/index.js";
|
|
13
15
|
export { detectPython } from "./python/index.js";
|
|
14
|
-
export { detectDocker } from "./docker/index.js";
|
|
16
|
+
export { detectDocker, isImageCachedLocally, pullDockerImage, probeDockerGpuAccess, } from "./docker/index.js";
|
|
17
|
+
export { detectNvidia } from "./nvidia/index.js";
|
|
18
|
+
export { collectSystemReport } from "./system.js";
|
|
15
19
|
export { runCommand } from "./process/index.js";
|
|
20
|
+
export { cacheAsset, ensureManagedUv, managedUvDownloadBytes, materializeAsset, prepareDockerImage, preparePythonEnvironment, temporaryInstallDir, MANAGED_UV_VERSION, } from "./install/index.js";
|