@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.
Files changed (51) hide show
  1. package/dist/docker/index.d.ts +41 -4
  2. package/dist/docker/index.js +140 -14
  3. package/dist/docker/index.test.d.ts +1 -0
  4. package/dist/docker/index.test.js +108 -0
  5. package/dist/filesystem/companion-inputs.d.ts +34 -0
  6. package/dist/filesystem/companion-inputs.js +123 -0
  7. package/dist/filesystem/companion-inputs.test.d.ts +1 -0
  8. package/dist/filesystem/companion-inputs.test.js +256 -0
  9. package/dist/filesystem/index.d.ts +4 -0
  10. package/dist/filesystem/index.js +2 -0
  11. package/dist/filesystem/paths.d.ts +20 -0
  12. package/dist/filesystem/paths.js +79 -0
  13. package/dist/filesystem/paths.test.d.ts +1 -0
  14. package/dist/filesystem/paths.test.js +35 -0
  15. package/dist/hardware/index.d.ts +21 -0
  16. package/dist/hardware/index.js +62 -0
  17. package/dist/hardware/index.test.d.ts +1 -0
  18. package/dist/hardware/index.test.js +27 -0
  19. package/dist/index.d.ts +22 -5
  20. package/dist/index.js +17 -12
  21. package/dist/install/index.d.ts +83 -0
  22. package/dist/install/index.js +487 -0
  23. package/dist/install/index.test.d.ts +1 -0
  24. package/dist/install/index.test.js +628 -0
  25. package/dist/nvidia/index.d.ts +29 -0
  26. package/dist/nvidia/index.js +119 -0
  27. package/dist/nvidia/index.test.d.ts +1 -0
  28. package/dist/nvidia/index.test.js +91 -0
  29. package/dist/process/index.d.ts +34 -0
  30. package/dist/process/index.js +99 -4
  31. package/dist/process/index.test.d.ts +1 -0
  32. package/dist/process/index.test.js +36 -0
  33. package/dist/providers/python.d.ts +14 -0
  34. package/dist/providers/python.js +80 -0
  35. package/dist/providers/python.test.d.ts +1 -0
  36. package/dist/providers/python.test.js +68 -0
  37. package/dist/providers.d.ts +49 -0
  38. package/dist/providers.js +1 -0
  39. package/dist/providers.test.d.ts +1 -0
  40. package/dist/providers.test.js +10 -0
  41. package/dist/python/index.d.ts +14 -3
  42. package/dist/python/index.js +30 -12
  43. package/dist/redact.d.ts +6 -0
  44. package/dist/redact.js +16 -0
  45. package/dist/redact.test.d.ts +1 -0
  46. package/dist/redact.test.js +16 -0
  47. package/dist/system.d.ts +39 -0
  48. package/dist/system.js +42 -0
  49. package/dist/system.test.d.ts +1 -0
  50. package/dist/system.test.js +138 -0
  51. package/package.json +8 -3
@@ -1,6 +1,43 @@
1
- export interface DockerInfo {
2
- installed: boolean;
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
- version?: string;
5
+ gpuAccess: "available" | "unavailable" | "unknown";
5
6
  }
6
- export declare function detectDocker(): Promise<DockerInfo>;
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
+ }>;
@@ -1,19 +1,145 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
3
- const execFileAsync = promisify(execFile);
4
- export async function detectDocker() {
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
- const { stdout } = await execFileAsync("docker", ["--version"]);
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
- return { installed: false, running: false };
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 {};