@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.
- 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
package/dist/docker/index.d.ts
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import type { CaptureFn } from "../process/index.js";
|
|
2
|
+
import type { ToolCapability } from "../python/index.js";
|
|
3
|
+
export interface DockerCapability extends ToolCapability {
|
|
3
4
|
running: boolean;
|
|
4
|
-
|
|
5
|
+
gpuAccess: "available" | "unavailable" | "unknown";
|
|
5
6
|
}
|
|
6
|
-
export
|
|
7
|
+
export type DockerInfo = DockerCapability;
|
|
8
|
+
/**
|
|
9
|
+
* Detect Docker install + daemon state. Tier-1 GPU signal (a registered
|
|
10
|
+
* `nvidia` runtime) is derived for free from `docker info` — no pulls, safe
|
|
11
|
+
* for `doctor`/`list`. Pass `options.gpuAccess` to inject a Tier-2 verified
|
|
12
|
+
* probe result (`install.ts` only).
|
|
13
|
+
*/
|
|
14
|
+
export declare function detectDocker(capture?: CaptureFn, options?: {
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
gpuAccess?: DockerCapability["gpuAccess"];
|
|
17
|
+
}): Promise<DockerCapability>;
|
|
18
|
+
/** Tier-2: is the (small, digest-pinned) GPU-probe image already cached locally? */
|
|
19
|
+
export declare function isImageCachedLocally(image: string, capture?: CaptureFn): Promise<boolean>;
|
|
20
|
+
/**
|
|
21
|
+
* Tier-2: explicitly pull the GPU-probe image. Kept separate from
|
|
22
|
+
* `probeDockerGpuAccess` so a slow download uses its own (longer) timeout
|
|
23
|
+
* and can never be mistaken for a GPU-probe failure.
|
|
24
|
+
*/
|
|
25
|
+
export declare function pullDockerImage(image: string, capture?: CaptureFn, options?: {
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
}): Promise<{
|
|
28
|
+
ok: boolean;
|
|
29
|
+
error?: string;
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* Tier-2: authoritative GPU-access probe. Runs a real container with
|
|
33
|
+
* `--gpus all`; `--pull=never` guarantees this call can never itself trigger
|
|
34
|
+
* an implicit (slow) pull, so its short timeout can't produce a false
|
|
35
|
+
* failure. The image must already be present locally — call
|
|
36
|
+
* `isImageCachedLocally`/`pullDockerImage` first.
|
|
37
|
+
*/
|
|
38
|
+
export declare function probeDockerGpuAccess(image: string, capture?: CaptureFn, options?: {
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
}): Promise<{
|
|
41
|
+
access: "available" | "unavailable";
|
|
42
|
+
error?: string;
|
|
43
|
+
}>;
|
package/dist/docker/index.js
CHANGED
|
@@ -1,19 +1,145 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
|
|
1
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
2
|
+
function parseDockerVersion(stdout) {
|
|
3
|
+
const trimmed = stdout.trim();
|
|
4
|
+
// "Docker version 24.0.7, build abc" -> "24.0.7, build abc"
|
|
5
|
+
const withoutPrefix = trimmed.replace(/^Docker version\s+/i, "").trim();
|
|
6
|
+
return withoutPrefix.length > 0 ? withoutPrefix : undefined;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Tier-1 GPU signal: does the target daemon (local or remote, via `docker
|
|
10
|
+
* info`'s `Runtimes` map) have an `nvidia` container runtime registered?
|
|
11
|
+
* This does NOT prove a container can actually reach the GPU — registration
|
|
12
|
+
* alone can't confirm that — so a registered runtime only ever resolves to
|
|
13
|
+
* `"unknown"`, never `"available"`. Only an explicit Tier-2 probe (a real
|
|
14
|
+
* `docker run --gpus all ... nvidia-smi`, run by `install.ts`, never by
|
|
15
|
+
* `doctor`/`list`) can confirm `"available"`.
|
|
16
|
+
*/
|
|
17
|
+
function dockerHasNvidiaRuntime(infoJson) {
|
|
18
|
+
if (typeof infoJson !== "object" || infoJson === null)
|
|
19
|
+
return undefined;
|
|
20
|
+
const runtimes = infoJson["Runtimes"];
|
|
21
|
+
if (typeof runtimes !== "object" || runtimes === null)
|
|
22
|
+
return undefined;
|
|
23
|
+
return Object.keys(runtimes).some((k) => k.toLowerCase().includes("nvidia"));
|
|
24
|
+
}
|
|
25
|
+
function resolveGpuAccess(nvidiaRuntimeRegistered, override) {
|
|
26
|
+
if (override)
|
|
27
|
+
return override;
|
|
28
|
+
if (nvidiaRuntimeRegistered === undefined)
|
|
29
|
+
return "unknown";
|
|
30
|
+
return nvidiaRuntimeRegistered ? "unknown" : "unavailable";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Detect Docker install + daemon state. Tier-1 GPU signal (a registered
|
|
34
|
+
* `nvidia` runtime) is derived for free from `docker info` — no pulls, safe
|
|
35
|
+
* for `doctor`/`list`. Pass `options.gpuAccess` to inject a Tier-2 verified
|
|
36
|
+
* probe result (`install.ts` only).
|
|
37
|
+
*/
|
|
38
|
+
export async function detectDocker(capture, options = {}) {
|
|
39
|
+
const timeoutMs = options.timeoutMs ?? PROBE_TIMEOUT_MS;
|
|
40
|
+
let run;
|
|
41
|
+
if (capture) {
|
|
42
|
+
run = capture;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
const { defaultCapture } = await import("../process/index.js");
|
|
46
|
+
run = defaultCapture;
|
|
47
|
+
}
|
|
48
|
+
const versionResult = await run("docker", ["--version"], { timeoutMs });
|
|
49
|
+
if (versionResult.timedOut) {
|
|
50
|
+
return {
|
|
51
|
+
available: false,
|
|
52
|
+
running: false,
|
|
53
|
+
gpuAccess: "unknown",
|
|
54
|
+
error: `docker --version timed out after ${timeoutMs}ms`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (versionResult.code !== 0) {
|
|
58
|
+
const detail = `${versionResult.stdout}\n${versionResult.stderr}`.trim();
|
|
59
|
+
return {
|
|
60
|
+
available: false,
|
|
61
|
+
running: false,
|
|
62
|
+
gpuAccess: "unknown",
|
|
63
|
+
error: detail.length > 0
|
|
64
|
+
? `docker not detected: ${detail.split("\n")[0]}`
|
|
65
|
+
: "docker not detected on PATH",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const version = parseDockerVersion(versionResult.stdout);
|
|
69
|
+
const infoJsonResult = await run("docker", ["info", "--format", "{{json .}}"], { timeoutMs });
|
|
70
|
+
if (infoJsonResult.timedOut) {
|
|
71
|
+
return {
|
|
72
|
+
available: true,
|
|
73
|
+
running: false,
|
|
74
|
+
version,
|
|
75
|
+
gpuAccess: options.gpuAccess ?? "unknown",
|
|
76
|
+
error: `docker info timed out after ${timeoutMs}ms; daemon state unknown`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (infoJsonResult.code !== 0) {
|
|
80
|
+
return {
|
|
81
|
+
available: true,
|
|
82
|
+
running: false,
|
|
83
|
+
version,
|
|
84
|
+
gpuAccess: options.gpuAccess ?? "unknown",
|
|
85
|
+
error: "docker installed but daemon not running (docker info failed)",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
let nvidiaRuntimeRegistered;
|
|
5
89
|
try {
|
|
6
|
-
|
|
7
|
-
const version = stdout.trim().replace(/^Docker version\s+/, "");
|
|
8
|
-
try {
|
|
9
|
-
await execFileAsync("docker", ["info"]);
|
|
10
|
-
return { installed: true, running: true, version };
|
|
11
|
-
}
|
|
12
|
-
catch {
|
|
13
|
-
return { installed: true, running: false, version };
|
|
14
|
-
}
|
|
90
|
+
nvidiaRuntimeRegistered = dockerHasNvidiaRuntime(JSON.parse(infoJsonResult.stdout));
|
|
15
91
|
}
|
|
16
92
|
catch {
|
|
17
|
-
|
|
93
|
+
nvidiaRuntimeRegistered = undefined;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
available: true,
|
|
97
|
+
running: true,
|
|
98
|
+
version,
|
|
99
|
+
gpuAccess: resolveGpuAccess(nvidiaRuntimeRegistered, options.gpuAccess),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const DEFAULT_PULL_TIMEOUT_MS = 120_000;
|
|
103
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 10_000;
|
|
104
|
+
/** Tier-2: is the (small, digest-pinned) GPU-probe image already cached locally? */
|
|
105
|
+
export async function isImageCachedLocally(image, capture) {
|
|
106
|
+
const run = capture ?? (await import("../process/index.js")).defaultCapture;
|
|
107
|
+
const result = await run("docker", ["image", "inspect", image], { timeoutMs: DEFAULT_PROBE_TIMEOUT_MS });
|
|
108
|
+
return !result.timedOut && result.code === 0;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Tier-2: explicitly pull the GPU-probe image. Kept separate from
|
|
112
|
+
* `probeDockerGpuAccess` so a slow download uses its own (longer) timeout
|
|
113
|
+
* and can never be mistaken for a GPU-probe failure.
|
|
114
|
+
*/
|
|
115
|
+
export async function pullDockerImage(image, capture, options = {}) {
|
|
116
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_PULL_TIMEOUT_MS;
|
|
117
|
+
const run = capture ?? (await import("../process/index.js")).defaultCapture;
|
|
118
|
+
const result = await run("docker", ["pull", image], { timeoutMs });
|
|
119
|
+
if (result.timedOut)
|
|
120
|
+
return { ok: false, error: `docker pull timed out after ${timeoutMs}ms` };
|
|
121
|
+
if (result.code !== 0) {
|
|
122
|
+
const detail = `${result.stdout}\n${result.stderr}`.trim();
|
|
123
|
+
return { ok: false, error: detail.length > 0 ? detail.split("\n").at(-1) : "docker pull failed" };
|
|
124
|
+
}
|
|
125
|
+
return { ok: true };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Tier-2: authoritative GPU-access probe. Runs a real container with
|
|
129
|
+
* `--gpus all`; `--pull=never` guarantees this call can never itself trigger
|
|
130
|
+
* an implicit (slow) pull, so its short timeout can't produce a false
|
|
131
|
+
* failure. The image must already be present locally — call
|
|
132
|
+
* `isImageCachedLocally`/`pullDockerImage` first.
|
|
133
|
+
*/
|
|
134
|
+
export async function probeDockerGpuAccess(image, capture, options = {}) {
|
|
135
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
136
|
+
const run = capture ?? (await import("../process/index.js")).defaultCapture;
|
|
137
|
+
const result = await run("docker", ["run", "--rm", "--gpus", "all", "--pull=never", image, "nvidia-smi", "-L"], { timeoutMs });
|
|
138
|
+
if (result.timedOut)
|
|
139
|
+
return { access: "unavailable", error: `GPU probe timed out after ${timeoutMs}ms` };
|
|
140
|
+
if (result.code !== 0) {
|
|
141
|
+
const detail = `${result.stdout}\n${result.stderr}`.trim();
|
|
142
|
+
return { access: "unavailable", error: detail.length > 0 ? detail.split("\n")[0] : "docker GPU probe failed" };
|
|
18
143
|
}
|
|
144
|
+
return { access: "available" };
|
|
19
145
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { detectDocker, isImageCachedLocally, probeDockerGpuAccess, pullDockerImage, } from "./index.js";
|
|
3
|
+
function stubCapture(handler) {
|
|
4
|
+
return async (command, args) => {
|
|
5
|
+
const r = handler(command, args);
|
|
6
|
+
return { code: r.code, stdout: r.stdout ?? "", stderr: r.stderr ?? "", timedOut: r.timedOut };
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
const VERSION_OK = { code: 0, stdout: "Docker version 24.0.7, build abc\n" };
|
|
10
|
+
describe("detectDocker — Tier 1 (docker info runtimes)", () => {
|
|
11
|
+
it("resolves 'unknown' (not 'available') when the nvidia runtime is registered", async () => {
|
|
12
|
+
const capture = stubCapture((cmd, args) => {
|
|
13
|
+
if (args[0] === "--version")
|
|
14
|
+
return VERSION_OK;
|
|
15
|
+
if (args[0] === "info")
|
|
16
|
+
return { code: 0, stdout: JSON.stringify({ Runtimes: { runc: {}, nvidia: {} } }) };
|
|
17
|
+
return { code: 1 };
|
|
18
|
+
});
|
|
19
|
+
const result = await detectDocker(capture);
|
|
20
|
+
expect(result).toMatchObject({ available: true, running: true, gpuAccess: "unknown" });
|
|
21
|
+
});
|
|
22
|
+
it("resolves 'unavailable' when no nvidia runtime is registered", async () => {
|
|
23
|
+
const capture = stubCapture((cmd, args) => {
|
|
24
|
+
if (args[0] === "--version")
|
|
25
|
+
return VERSION_OK;
|
|
26
|
+
if (args[0] === "info")
|
|
27
|
+
return { code: 0, stdout: JSON.stringify({ Runtimes: { runc: {} } }) };
|
|
28
|
+
return { code: 1 };
|
|
29
|
+
});
|
|
30
|
+
const result = await detectDocker(capture);
|
|
31
|
+
expect(result).toMatchObject({ available: true, running: true, gpuAccess: "unavailable" });
|
|
32
|
+
});
|
|
33
|
+
it("resolves 'unknown' when the daemon is unreachable", async () => {
|
|
34
|
+
const capture = stubCapture((cmd, args) => {
|
|
35
|
+
if (args[0] === "--version")
|
|
36
|
+
return VERSION_OK;
|
|
37
|
+
return { code: 1, stderr: "Cannot connect to the Docker daemon" };
|
|
38
|
+
});
|
|
39
|
+
const result = await detectDocker(capture);
|
|
40
|
+
expect(result).toMatchObject({ available: true, running: false, gpuAccess: "unknown" });
|
|
41
|
+
});
|
|
42
|
+
it("falls back to 'unknown' on malformed JSON from docker info", async () => {
|
|
43
|
+
const capture = stubCapture((cmd, args) => {
|
|
44
|
+
if (args[0] === "--version")
|
|
45
|
+
return VERSION_OK;
|
|
46
|
+
if (args[0] === "info")
|
|
47
|
+
return { code: 0, stdout: "not json" };
|
|
48
|
+
return { code: 1 };
|
|
49
|
+
});
|
|
50
|
+
const result = await detectDocker(capture);
|
|
51
|
+
expect(result).toMatchObject({ available: true, running: true, gpuAccess: "unknown" });
|
|
52
|
+
});
|
|
53
|
+
it("an explicit options.gpuAccess override always wins over the Tier-1 signal", async () => {
|
|
54
|
+
const capture = stubCapture((cmd, args) => {
|
|
55
|
+
if (args[0] === "--version")
|
|
56
|
+
return VERSION_OK;
|
|
57
|
+
if (args[0] === "info")
|
|
58
|
+
return { code: 0, stdout: JSON.stringify({ Runtimes: { runc: {} } }) };
|
|
59
|
+
return { code: 1 };
|
|
60
|
+
});
|
|
61
|
+
const result = await detectDocker(capture, { gpuAccess: "available" });
|
|
62
|
+
expect(result.gpuAccess).toBe("available");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe("Tier 2 — isImageCachedLocally / pullDockerImage / probeDockerGpuAccess", () => {
|
|
66
|
+
it("isImageCachedLocally reports a cache hit", async () => {
|
|
67
|
+
const capture = stubCapture(() => ({ code: 0, stdout: "[{}]" }));
|
|
68
|
+
expect(await isImageCachedLocally("img@sha256:abc", capture)).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
it("isImageCachedLocally reports a cache miss", async () => {
|
|
71
|
+
const capture = stubCapture(() => ({ code: 1, stderr: "No such image" }));
|
|
72
|
+
expect(await isImageCachedLocally("img@sha256:abc", capture)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
it("pullDockerImage succeeds", async () => {
|
|
75
|
+
const capture = stubCapture(() => ({ code: 0, stdout: "Status: Downloaded" }));
|
|
76
|
+
const result = await pullDockerImage("img@sha256:abc", capture);
|
|
77
|
+
expect(result.ok).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
it("pullDockerImage reports a failure", async () => {
|
|
80
|
+
const capture = stubCapture(() => ({ code: 1, stderr: "manifest unknown" }));
|
|
81
|
+
const result = await pullDockerImage("img@sha256:abc", capture);
|
|
82
|
+
expect(result.ok).toBe(false);
|
|
83
|
+
expect(result.error).toBeTruthy();
|
|
84
|
+
});
|
|
85
|
+
it("pullDockerImage reports a timeout distinctly", async () => {
|
|
86
|
+
const capture = stubCapture(() => ({ code: 0, timedOut: true }));
|
|
87
|
+
const result = await pullDockerImage("img@sha256:abc", capture);
|
|
88
|
+
expect(result.ok).toBe(false);
|
|
89
|
+
expect(result.error).toMatch(/timed out/);
|
|
90
|
+
});
|
|
91
|
+
it("probeDockerGpuAccess reports available on success", async () => {
|
|
92
|
+
const capture = stubCapture(() => ({ code: 0, stdout: "GPU 0: NVIDIA A100" }));
|
|
93
|
+
const result = await probeDockerGpuAccess("img@sha256:abc", capture);
|
|
94
|
+
expect(result.access).toBe("available");
|
|
95
|
+
});
|
|
96
|
+
it("probeDockerGpuAccess reports unavailable on failure", async () => {
|
|
97
|
+
const capture = stubCapture(() => ({ code: 1, stderr: "could not select device driver" }));
|
|
98
|
+
const result = await probeDockerGpuAccess("img@sha256:abc", capture);
|
|
99
|
+
expect(result.access).toBe("unavailable");
|
|
100
|
+
expect(result.error).toBeTruthy();
|
|
101
|
+
});
|
|
102
|
+
it("probeDockerGpuAccess reports unavailable on timeout", async () => {
|
|
103
|
+
const capture = stubCapture(() => ({ code: 0, timedOut: true }));
|
|
104
|
+
const result = await probeDockerGpuAccess("img@sha256:abc", capture);
|
|
105
|
+
expect(result.access).toBe("unavailable");
|
|
106
|
+
expect(result.error).toMatch(/timed out/);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** A single companion-file reference an adapter extracted from a job file (e.g.
|
|
2
|
+
* DiffDock-L's `proteinPath`/`ligand.path`). `id` namespaces the staged copy on
|
|
3
|
+
* disk and must be a safe, unique-per-call directory-segment identifier — it is
|
|
4
|
+
* adapter-controlled (a fixed string like `"protein"`), never derived from user
|
|
5
|
+
* input, but is still validated defensively since it becomes a path segment. */
|
|
6
|
+
export interface CompanionInputRef {
|
|
7
|
+
id: string;
|
|
8
|
+
sourcePath: string;
|
|
9
|
+
}
|
|
10
|
+
export interface StagedCompanionInputPath {
|
|
11
|
+
id: string;
|
|
12
|
+
originalPath: string;
|
|
13
|
+
storedPath: string;
|
|
14
|
+
}
|
|
15
|
+
export interface StageCompanionInputsParams {
|
|
16
|
+
/** Absolute (or resolvable) path of the submitted job file; relative `sourcePath`
|
|
17
|
+
* references are resolved against its directory. */
|
|
18
|
+
jobFilePath: string;
|
|
19
|
+
refs: CompanionInputRef[];
|
|
20
|
+
/** The run's `input/` directory. Must already exist. */
|
|
21
|
+
inputDir: string;
|
|
22
|
+
/** Confinement boundary for every resolved reference. Defaults to
|
|
23
|
+
* `path.dirname(jobFilePath)` — the job file's own directory — so a job cannot
|
|
24
|
+
* reference arbitrary filesystem locations outside where it itself lives. */
|
|
25
|
+
allowedRoot?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Safely copies one or more adapter-declared companion files (e.g. DiffDock-L's
|
|
29
|
+
* protein PDB and ligand file) into a run's `input/` directory, confined to
|
|
30
|
+
* `allowedRoot`. Generic across models — job-format-specific reference
|
|
31
|
+
* extraction stays in the adapter (`ModelAdapterDefinition.resolveCompanionInputs`);
|
|
32
|
+
* this module only validates and copies.
|
|
33
|
+
*/
|
|
34
|
+
export declare function stageCompanionInputs(params: StageCompanionInputsParams): StagedCompanionInputPath[];
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { MoldeskError } from "@moldesk/registry";
|
|
4
|
+
const SAFE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
5
|
+
function companionError(code, message, remediation, details) {
|
|
6
|
+
return new MoldeskError({ code, message, remediation, details });
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* True when `target` (already `path.resolve`d) is `root` itself or strictly
|
|
10
|
+
* inside it. Uses `path.relative`, not `string.startsWith(root)` — a prefix
|
|
11
|
+
* check would incorrectly accept a sibling directory like `/allowed-root-evil`
|
|
12
|
+
* when `root` is `/allowed-root`, since the string literally starts with that
|
|
13
|
+
* prefix. `path.relative` returns a `..`-leading (or absolute, on Windows
|
|
14
|
+
* across drives) path whenever `target` escapes `root`, regardless of any
|
|
15
|
+
* shared string prefix.
|
|
16
|
+
*/
|
|
17
|
+
function isWithinRoot(root, target) {
|
|
18
|
+
if (target === root)
|
|
19
|
+
return true;
|
|
20
|
+
const rel = path.relative(root, target);
|
|
21
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolves and validates one companion-file reference against `allowedRoot`,
|
|
25
|
+
* re-checking existence/type/symlink-target on every call rather than trusting
|
|
26
|
+
* any earlier inspection — this is what makes the check effective immediately
|
|
27
|
+
* before staging, not just at an earlier "discovery" step, narrowing (though
|
|
28
|
+
* not eliminating) the TOCTOU window between an adapter resolving a reference
|
|
29
|
+
* and this module copying it.
|
|
30
|
+
*
|
|
31
|
+
* `allowedRootReal` must already be `fs.realpathSync`-canonicalized by the
|
|
32
|
+
* caller (once, not per-ref). The boundary check runs against the fully
|
|
33
|
+
* canonicalized path, not just the lexical one — a symlink escape is not only
|
|
34
|
+
* possible at the final path component. If an ANCESTOR directory in the
|
|
35
|
+
* reference is itself a symlink pointing outside `allowedRoot` (e.g.
|
|
36
|
+
* `sub/file.pdb` where `allowedRoot/sub` is a symlink to `/etc`), the OS
|
|
37
|
+
* silently resolves that ancestor symlink during ordinary path traversal —
|
|
38
|
+
* `lstat` on the leaf alone would report the real target file directly and
|
|
39
|
+
* never show a symlink at all, so checking only "is the final component a
|
|
40
|
+
* symlink" misses this entirely. `fs.realpathSync` on the *complete* resolved
|
|
41
|
+
* path canonicalizes every symlink in the chain (leaf and every ancestor
|
|
42
|
+
* alike), which is why it always runs here rather than being gated behind an
|
|
43
|
+
* `lstat().isSymbolicLink()` check on just the leaf.
|
|
44
|
+
*/
|
|
45
|
+
function resolveCompanionSource(ref, allowedRoot, allowedRootReal) {
|
|
46
|
+
const resolved = path.isAbsolute(ref.sourcePath)
|
|
47
|
+
? path.resolve(ref.sourcePath)
|
|
48
|
+
: path.resolve(allowedRoot, ref.sourcePath);
|
|
49
|
+
// Cheap lexical pre-check — catches obvious ../ traversal before touching
|
|
50
|
+
// the filesystem at all. Not sufficient on its own (see doc comment above),
|
|
51
|
+
// hence the canonical re-check below.
|
|
52
|
+
if (!isWithinRoot(allowedRoot, resolved)) {
|
|
53
|
+
throw companionError("UNSAFE_COMPANION_INPUT_PATH", `Companion input "${ref.id}" resolves outside the allowed source tree: ${resolved}.`, "Reference only files within the job file's own directory tree.", { id: ref.id, sourcePath: ref.sourcePath, resolved, allowedRoot });
|
|
54
|
+
}
|
|
55
|
+
let real;
|
|
56
|
+
try {
|
|
57
|
+
real = fs.realpathSync(resolved);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw companionError("INVALID_RUN_INPUT", `Companion input "${ref.id}" not found: ${resolved}.`, "Check the referenced path exists and retry.", { id: ref.id, sourcePath: ref.sourcePath, resolved });
|
|
61
|
+
}
|
|
62
|
+
if (!isWithinRoot(allowedRootReal, real)) {
|
|
63
|
+
throw companionError("UNSAFE_COMPANION_INPUT_PATH", `Companion input "${ref.id}" escapes the allowed source tree via a symlink (in the file itself or an ancestor directory): ${resolved} -> ${real}.`, "Reference only real files within the job file's own directory tree; do not use symlinks (directly or via a symlinked parent directory) that point outside it.", { id: ref.id, resolved, real, allowedRootReal });
|
|
64
|
+
}
|
|
65
|
+
let stat;
|
|
66
|
+
try {
|
|
67
|
+
stat = fs.statSync(real);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw companionError("INVALID_RUN_INPUT", `Companion input "${ref.id}" not found: ${real}.`, "Check the referenced path exists and retry.", { id: ref.id, resolved: real });
|
|
71
|
+
}
|
|
72
|
+
if (!stat.isFile()) {
|
|
73
|
+
throw companionError("UNSAFE_COMPANION_INPUT_TYPE", `Companion input "${ref.id}" is not a regular file: ${real}.`, "Reference a regular file, not a directory, socket, or device.", { id: ref.id, resolved: real });
|
|
74
|
+
}
|
|
75
|
+
return real;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Safely copies one or more adapter-declared companion files (e.g. DiffDock-L's
|
|
79
|
+
* protein PDB and ligand file) into a run's `input/` directory, confined to
|
|
80
|
+
* `allowedRoot`. Generic across models — job-format-specific reference
|
|
81
|
+
* extraction stays in the adapter (`ModelAdapterDefinition.resolveCompanionInputs`);
|
|
82
|
+
* this module only validates and copies.
|
|
83
|
+
*/
|
|
84
|
+
export function stageCompanionInputs(params) {
|
|
85
|
+
const { jobFilePath, refs, inputDir } = params;
|
|
86
|
+
const allowedRoot = path.resolve(params.allowedRoot ?? path.dirname(jobFilePath));
|
|
87
|
+
let allowedRootReal;
|
|
88
|
+
try {
|
|
89
|
+
allowedRootReal = fs.realpathSync(allowedRoot);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw companionError("UNSAFE_COMPANION_INPUT_PATH", `Allowed source root does not exist: ${allowedRoot}.`, "This is a MoleculeDesk bug — report it; the job file's own directory should always exist.", { allowedRoot });
|
|
93
|
+
}
|
|
94
|
+
const seenIds = new Set();
|
|
95
|
+
for (const ref of refs) {
|
|
96
|
+
if (!SAFE_ID_PATTERN.test(ref.id)) {
|
|
97
|
+
throw companionError("UNSAFE_COMPANION_INPUT_ID", `Unsafe companion input id: ${JSON.stringify(ref.id)}.`, "This is a MoleculeDesk adapter bug — report it; ids must match [A-Za-z0-9_-]+.", { id: ref.id });
|
|
98
|
+
}
|
|
99
|
+
if (seenIds.has(ref.id)) {
|
|
100
|
+
throw companionError("DUPLICATE_STAGED_INPUT", `Duplicate companion input id: "${ref.id}".`, "This is a MoleculeDesk adapter bug — report it; each companion input id must be unique.", { id: ref.id });
|
|
101
|
+
}
|
|
102
|
+
seenIds.add(ref.id);
|
|
103
|
+
}
|
|
104
|
+
const staged = [];
|
|
105
|
+
for (const ref of refs) {
|
|
106
|
+
// Resolve/validate immediately before copying — see resolveCompanionSource's doc.
|
|
107
|
+
const sourceFile = resolveCompanionSource(ref, allowedRoot, allowedRootReal);
|
|
108
|
+
const destDir = path.join(inputDir, ref.id);
|
|
109
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
110
|
+
const destFile = path.join(destDir, path.basename(sourceFile));
|
|
111
|
+
try {
|
|
112
|
+
fs.copyFileSync(sourceFile, destFile, fs.constants.COPYFILE_EXCL);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error.code === "EEXIST") {
|
|
116
|
+
throw companionError("STAGED_INPUT_DESTINATION_EXISTS", `Refusing to overwrite an existing staged destination for companion input "${ref.id}": ${destFile}.`, "This is a MoleculeDesk bug — report it; each run directory should be freshly allocated.", { id: ref.id, destFile });
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
staged.push({ id: ref.id, originalPath: sourceFile, storedPath: destFile });
|
|
121
|
+
}
|
|
122
|
+
return staged;
|
|
123
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|