@mastra/docker 0.8.0-alpha.0 → 0.8.0-alpha.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/index.js CHANGED
@@ -1,10 +1,111 @@
1
1
  import { posix } from "path";
2
2
  import { isDeepStrictEqual } from "util";
3
- import { MastraSandbox, ProcessHandle, SandboxError, SandboxNotReadyError, SandboxProcessManager } from "@mastra/core/workspace";
3
+ import { MastraSandbox, ProcessHandle, SandboxAbortError, SandboxError, SandboxNotReadyError, SandboxProcessManager, validateSandboxFileMode } from "@mastra/core/workspace";
4
4
  import Docker from "dockerode";
5
5
  import { pack } from "tar-stream";
6
+ import { randomUUID } from "crypto";
6
7
  //#region src/sandbox/process-manager.ts
7
8
  /**
9
+ * Docker Process Manager
10
+ *
11
+ * Implements SandboxProcessManager for Docker containers.
12
+ * Uses `container.exec()` to run commands inside a long-lived container.
13
+ * Each spawned process gets a dedicated exec instance with separate
14
+ * stdout/stderr streams.
15
+ */
16
+ /**
17
+ * Directory (inside the container) where each spawned process records the PGID
18
+ * of its process group. Created with mode 700 so only the (root) exec user can
19
+ * write the PGID files.
20
+ */
21
+ const PROC_DIR = "/tmp/.mastra-proc";
22
+ /**
23
+ * Wrapper (run as the exec command) that places the user command in its own
24
+ * process group and records the group's PGID so kill() can signal the whole
25
+ * group later.
26
+ *
27
+ * Docker's exec-inspect `Pid` is a host/daemon-namespace PID and cannot be used
28
+ * with an in-container `kill`, so we need a container-namespace identity. We use
29
+ * a *kernel-enforced* process group as that identity:
30
+ *
31
+ * 1. `setsid -w` re-execs the command as a new session/process-group leader,
32
+ * so its PID == PGID. Every descendant inherits that PGID (unless it calls
33
+ * `setsid` itself) and stays reachable even if it re-parents to PID 1.
34
+ * `-w` keeps the wrapper (and thus the exec) alive for the whole lifetime
35
+ * and propagates the child's exit status — without it `setsid` forks and
36
+ * returns immediately, so the exec would appear to finish while the real
37
+ * work keeps running.
38
+ * 2. The leader writes its own PID (`$$`) — the PGID — to a private file that
39
+ * only this process wrote, so the identity is kernel-owned and cannot be
40
+ * forged by another container process.
41
+ *
42
+ * If `setsid -w` is unavailable in the image (e.g. BusyBox), we degrade
43
+ * gracefully: the command runs directly and we record its PID so kill() can
44
+ * still signal it (descendant coverage is then best-effort). The probe
45
+ * `setsid -w true` also covers images without setsid at all.
46
+ *
47
+ * Positional args: $1 = pgid file path, $2 = user command. The script text is a
48
+ * static constant; runtime values travel only as argv, never interpolated into
49
+ * the command string.
50
+ */
51
+ const SPAWN_WRAPPER = `
52
+ umask 077
53
+ d="\${1%/*}"
54
+ mkdir -p "$d" 2>/dev/null
55
+ chmod 700 "$d" 2>/dev/null
56
+ if setsid -w true >/dev/null 2>&1; then
57
+ exec setsid -w sh -c 'echo $$ > "$1" || exit 126; sh -c "$2"; ret=$?; rm -f "$1" 2>/dev/null; exit $ret' sh "$1" "$2"
58
+ fi
59
+ echo $$ > "$1" || exit 126
60
+ sh -c "$2"
61
+ ret=$?
62
+ rm -f "$1" 2>/dev/null
63
+ exit $ret
64
+ `;
65
+ /**
66
+ * Kill script: read the recorded PGID and SIGKILL the whole process group.
67
+ * A negative PID targets the kernel-owned process group, so descendants that
68
+ * re-parented to PID 1 are still caught. We SIGSTOP the
69
+ * group first to freeze fork races, then SIGKILL. The file may not exist yet if
70
+ * kill races the leader's first write, so we briefly wait for it.
71
+ *
72
+ * Positional arg: $1 = pgid file path (static script; no interpolation).
73
+ */
74
+ const KILL_SCRIPT = `
75
+ f="$1"
76
+ i=0
77
+ while [ ! -r "$f" ] && [ "$i" -lt 40 ]; do sleep 0.05; i=$((i + 1)); done
78
+ # If the PGID was never recorded (file absent/unreadable after the wait, or
79
+ # empty), we have no group to signal or verify — report failure rather than
80
+ # falsely claiming the tree was terminated.
81
+ [ -r "$f" ] || exit 1
82
+ pgid=$(cat "$f" 2>/dev/null)
83
+ rm -f "$f" 2>/dev/null
84
+ [ -n "$pgid" ] || exit 1
85
+ kill -STOP -"$pgid" 2>/dev/null
86
+ kill -KILL -"$pgid" 2>/dev/null
87
+ # Fallback for images without setsid: the leader is not a group leader, so also
88
+ # signal it directly.
89
+ kill -KILL "$pgid" 2>/dev/null
90
+ # Verify the group is actually gone before reporting success. kill -0 probes
91
+ # for the group's existence without sending a signal, and we poll while it
92
+ # still reports the group alive. When the probe finally fails we must inspect
93
+ # why: ESRCH ("no such process") means every member was reaped, so report
94
+ # success; EPERM or any other error means termination is unconfirmed (e.g. a
95
+ # member dropped privileges and became unsignalable), so exit nonzero and let
96
+ # kill() report failure instead of falsely claiming the tree was terminated.
97
+ j=0
98
+ while err=$(kill -0 -"$pgid" 2>&1); do
99
+ j=$((j + 1))
100
+ [ "$j" -ge 40 ] && exit 1
101
+ sleep 0.05
102
+ done
103
+ case "$err" in
104
+ *[Ss]uch\\ process*) exit 0 ;;
105
+ *) exit 1 ;;
106
+ esac
107
+ `;
108
+ /**
8
109
  * Wraps a Docker exec instance to conform to Mastra's ProcessHandle.
9
110
  * Not exported — internal to this module.
10
111
  *
@@ -24,13 +125,16 @@ var DockerProcessHandle = class extends ProcessHandle {
24
125
  _waitPromise = null;
25
126
  _stdinStream = null;
26
127
  _execStream = null;
27
- constructor(exec, container, startTime, stdinStream, options) {
128
+ /** @internal Container path of the file holding this process group's PGID. */
129
+ _pgidFile;
130
+ constructor(exec, container, startTime, stdinStream, pgidFile, options) {
28
131
  super(options);
29
132
  this.pid = exec.id;
30
133
  this._exec = exec;
31
134
  this._container = container;
32
135
  this._startTime = startTime;
33
136
  this._stdinStream = stdinStream;
137
+ this._pgidFile = pgidFile;
34
138
  }
35
139
  get exitCode() {
36
140
  return this._exitCode;
@@ -61,37 +165,28 @@ var DockerProcessHandle = class extends ProcessHandle {
61
165
  async kill() {
62
166
  if (this._exitCode !== void 0) return false;
63
167
  try {
64
- let info = await this._inspectExec();
65
- if (!info.Running || !info.Pid) {
66
- await new Promise((r) => setTimeout(r, 50));
67
- info = await this._inspectExec();
68
- }
69
- if (!info.Running) {
70
- this._killed = true;
71
- this._destroyStream();
72
- return false;
73
- }
74
- const pid = info.Pid;
75
- if (!pid) {
76
- this._killed = true;
77
- this._destroyStream();
78
- return false;
79
- }
80
- await (await this._container.exec({
168
+ const killExec = await this._container.exec({
81
169
  Cmd: [
82
170
  "sh",
83
171
  "-c",
84
- `kill -9 -${pid} 2>/dev/null || kill -9 ${pid}`
172
+ KILL_SCRIPT,
173
+ "sh",
174
+ this._pgidFile
85
175
  ],
86
176
  AttachStdout: false,
87
177
  AttachStderr: false
88
- })).start({});
178
+ });
179
+ await killExec.start({});
180
+ let killInfo = await killExec.inspect();
181
+ while (killInfo.Running) {
182
+ await new Promise((resolve) => setTimeout(resolve, 10));
183
+ killInfo = await killExec.inspect();
184
+ }
185
+ if (killInfo.ExitCode !== 0) throw new Error(`kill helper exited with code ${killInfo.ExitCode}`);
89
186
  this._killed = true;
90
187
  this._destroyStream();
91
188
  return true;
92
189
  } catch (error) {
93
- this._killed = true;
94
- this._destroyStream();
95
190
  const msg = error instanceof Error ? error.message.toLowerCase() : "";
96
191
  if (!msg.includes("no such process") && !msg.includes("esrch")) console.warn(`[DockerProcessManager] kill(${this.pid}) failed unexpectedly:`, error);
97
192
  return false;
@@ -145,11 +240,15 @@ var DockerProcessManager = class extends SandboxProcessManager {
145
240
  }
146
241
  async spawn(command, options = {}) {
147
242
  const container = this.container;
243
+ const pgidFile = `${PROC_DIR}/${randomUUID()}`;
148
244
  const envArray = Object.entries({ ...options.env }).filter((entry) => entry[1] !== void 0).map(([k, v]) => `${k}=${v}`);
149
245
  const exec = await container.exec({
150
246
  Cmd: [
151
247
  "sh",
152
248
  "-c",
249
+ SPAWN_WRAPPER,
250
+ "sh",
251
+ pgidFile,
153
252
  command
154
253
  ],
155
254
  AttachStdout: true,
@@ -164,7 +263,7 @@ var DockerProcessManager = class extends SandboxProcessManager {
164
263
  stdin: true
165
264
  });
166
265
  const startTime = Date.now();
167
- const handle = new DockerProcessHandle(exec, container, startTime, stream, options);
266
+ const handle = new DockerProcessHandle(exec, container, startTime, stream, pgidFile, options);
168
267
  handle._setExecStream(stream);
169
268
  const waitPromise = new Promise((resolve) => {
170
269
  const buffer = [];
@@ -240,8 +339,15 @@ var DockerProcessManager = class extends SandboxProcessManager {
240
339
  if (handle.exitCode === void 0) {
241
340
  handle._killed = true;
242
341
  handle._timedOut = true;
243
- handle.kill().catch(() => {});
244
- handle._destroyStream();
342
+ const forceClose = () => {
343
+ if (handle.exitCode === void 0) {
344
+ handle._killed = true;
345
+ handle._destroyStream();
346
+ }
347
+ };
348
+ handle.kill().then((killed) => {
349
+ if (!killed) forceClose();
350
+ }).catch(forceClose);
245
351
  }
246
352
  }, resolvedTimeout);
247
353
  waitPromise.then(() => clearTimeout(timer));
@@ -334,12 +440,14 @@ var DockerSandbox = class DockerSandbox extends MastraSandbox {
334
440
  _cpuQuota;
335
441
  _cpuPeriod;
336
442
  _pidsLimit;
443
+ _init;
337
444
  _readonlyRootfs;
338
445
  _capDrop;
339
446
  _capAdd;
340
447
  _securityOpt;
341
448
  _ulimits;
342
449
  _tmpfs;
450
+ _mounts;
343
451
  _labels;
344
452
  _instructionsOverride;
345
453
  _constructorOptions;
@@ -373,12 +481,14 @@ var DockerSandbox = class DockerSandbox extends MastraSandbox {
373
481
  this._cpuQuota = options.cpuQuota;
374
482
  this._cpuPeriod = options.cpuPeriod;
375
483
  this._pidsLimit = options.pidsLimit;
484
+ this._init = options.init ?? true;
376
485
  this._readonlyRootfs = options.readonlyRootfs;
377
486
  this._capDrop = options.capDrop;
378
487
  this._capAdd = options.capAdd;
379
488
  this._securityOpt = options.securityOpt;
380
489
  this._ulimits = options.ulimits;
381
490
  this._tmpfs = options.tmpfs;
491
+ this._mounts = options.mounts;
382
492
  this.setWorkingDirectory(options.workingDirectory ?? options.workingDir ?? "/workspace");
383
493
  this._labels = {
384
494
  ...options.labels,
@@ -460,12 +570,14 @@ var DockerSandbox = class DockerSandbox extends MastraSandbox {
460
570
  CpuQuota: this._cpuQuota,
461
571
  CpuPeriod: this._cpuPeriod,
462
572
  PidsLimit: this._pidsLimit,
573
+ Init: this._init,
463
574
  ReadonlyRootfs: this._readonlyRootfs,
464
575
  CapDrop: this._capDrop,
465
576
  CapAdd: this._capAdd,
466
577
  SecurityOpt: this._securityOpt,
467
578
  Ulimits: this._ulimits?.map(toDockerUlimit),
468
- Tmpfs: this._tmpfs
579
+ Tmpfs: this._tmpfs,
580
+ Mounts: this._mounts?.map(toDockerMount)
469
581
  },
470
582
  OpenStdin: true,
471
583
  Tty: false
@@ -519,7 +631,8 @@ var DockerSandbox = class DockerSandbox extends MastraSandbox {
519
631
  ["CapAdd", this._capAdd],
520
632
  ["SecurityOpt", this._securityOpt],
521
633
  ["Ulimits", this._ulimits],
522
- ["Tmpfs", this._tmpfs]
634
+ ["Tmpfs", this._tmpfs],
635
+ ["Mounts", this._mounts?.map(toDockerMount)]
523
636
  ];
524
637
  if (this._privilegedWasSet || hostConfig?.Privileged === true) entries.unshift(["Privileged", this._privileged]);
525
638
  return entries.filter((entry) => isPresentHostConfigValue(entry[1]));
@@ -565,32 +678,60 @@ var DockerSandbox = class DockerSandbox extends MastraSandbox {
565
678
  * - Existing destinations are overwritten (contents and mode).
566
679
  * - Exact bytes are preserved for both `string` and `Buffer` content,
567
680
  * including empty files and binary data.
568
- * - New files are created with mode `0644`; directories created implicitly
569
- * default to Docker's `0755`. Overwriting a file replaces its mode with `0644`.
681
+ * - New files use the per-file `mode` when provided (validated integer
682
+ * `0o001`–`0o777`), otherwise `0644`; directories created implicitly default
683
+ * to Docker's `0755`. Overwriting a file replaces its mode with the
684
+ * requested `mode` (or `0644` when omitted).
570
685
  * - Not atomic across files: on failure the promise rejects and earlier or
571
686
  * partially written files may remain.
572
687
  *
688
+ * Cancellation (`options.abortSignal`):
689
+ * - If the signal is already aborted, rejects with {@link SandboxAbortError}
690
+ * before creating the archive or starting the upload.
691
+ * - If the signal aborts during transfer, the underlying `putArchive` request
692
+ * is terminated by destroying the tar stream (which ends the request body),
693
+ * and the promise rejects with {@link SandboxAbortError}.
694
+ * - Upload and cancellation race: an upload that completes before the abort is
695
+ * observed resolves normally.
696
+ * - No rollback: files Docker already received or extracted may remain. The
697
+ * daemon may continue extraction after rejection, so the caller is
698
+ * responsible for any cleanup or sandbox disposal.
699
+ * - Behavior is unchanged when no signal is supplied.
700
+ *
573
701
  * @throws {SandboxNotReadyError} If the sandbox has not been started.
702
+ * @throws {SandboxAbortError} If the write is cancelled via `options.abortSignal`.
574
703
  * @throws {SandboxError} If the archive upload fails.
575
704
  */
576
- async writeFiles(files) {
705
+ async writeFiles(files, options) {
577
706
  const container = this.container;
707
+ const signal = options?.abortSignal;
708
+ if (signal?.aborted) throw new SandboxAbortError("writeFiles");
578
709
  if (files.length === 0) return;
579
710
  const pack$1 = pack();
580
711
  for (const file of files) {
712
+ if (file.mode !== void 0) validateSandboxFileMode(file.mode);
581
713
  const resolved = posix.isAbsolute(file.path) ? posix.normalize(file.path) : posix.resolve(this.workingDirectory, file.path);
582
714
  const data = Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content);
715
+ const mode = file.mode ?? 420;
583
716
  pack$1.entry({
584
717
  name: resolved.replace(/^\/+/, ""),
585
718
  size: data.length,
586
- mode: 420
719
+ mode
587
720
  }, data);
588
721
  }
589
722
  pack$1.finalize();
723
+ const onAbort = () => pack$1.destroy();
724
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
590
725
  try {
591
- await container.putArchive(pack$1, { path: "/" });
726
+ await container.putArchive(pack$1, {
727
+ path: "/",
728
+ abortSignal: signal
729
+ });
592
730
  } catch (error) {
731
+ if (signal?.aborted) throw new SandboxAbortError("writeFiles");
593
732
  throw new SandboxError(`Failed to write files to sandbox: ${error instanceof Error ? error.message : String(error)}`, "EXECUTION_FAILED", { reason: "write_files_failed" });
733
+ } finally {
734
+ if (signal) signal.removeEventListener("abort", onAbort);
594
735
  }
595
736
  }
596
737
  getInstructions(opts) {
@@ -767,6 +908,31 @@ function toDockerUlimit(ulimit) {
767
908
  Hard: ulimit.hard
768
909
  };
769
910
  }
911
+ function toDockerMount(mount) {
912
+ const settings = {
913
+ Type: mount.type,
914
+ Target: mount.target,
915
+ Source: mount.type === "tmpfs" ? "" : mount.source
916
+ };
917
+ if (mount.readOnly !== void 0) settings.ReadOnly = mount.readOnly;
918
+ if (mount.type === "volume" && mount.volumeOptions) {
919
+ const { subpath, noCopy, labels } = mount.volumeOptions;
920
+ const volumeOptions = {};
921
+ if (subpath !== void 0) volumeOptions.Subpath = subpath;
922
+ if (noCopy !== void 0) volumeOptions.NoCopy = noCopy;
923
+ if (labels !== void 0) volumeOptions.Labels = labels;
924
+ if (Object.keys(volumeOptions).length > 0) settings.VolumeOptions = volumeOptions;
925
+ }
926
+ if (mount.type === "bind" && mount.bindOptions?.propagation !== void 0) settings.BindOptions = { Propagation: mount.bindOptions.propagation };
927
+ if (mount.type === "tmpfs" && mount.tmpfsOptions) {
928
+ const { sizeBytes, mode } = mount.tmpfsOptions;
929
+ const tmpfsOptions = {};
930
+ if (sizeBytes !== void 0) tmpfsOptions.SizeBytes = sizeBytes;
931
+ if (mode !== void 0) tmpfsOptions.Mode = mode;
932
+ if (Object.keys(tmpfsOptions).length > 0) settings.TmpfsOptions = tmpfsOptions;
933
+ }
934
+ return settings;
935
+ }
770
936
  function toDockerSandboxOptionName(field) {
771
937
  return {
772
938
  Privileged: "privileged",
@@ -781,7 +947,8 @@ function toDockerSandboxOptionName(field) {
781
947
  CapAdd: "capAdd",
782
948
  SecurityOpt: "securityOpt",
783
949
  Ulimits: "ulimits",
784
- Tmpfs: "tmpfs"
950
+ Tmpfs: "tmpfs",
951
+ Mounts: "mounts"
785
952
  }[field] ?? String(field);
786
953
  }
787
954
  //#endregion
@@ -892,6 +1059,87 @@ const dockerSandboxProvider = {
892
1059
  type: "object",
893
1060
  description: "tmpfs mount paths with options",
894
1061
  additionalProperties: { type: "string" }
1062
+ },
1063
+ mounts: {
1064
+ type: "array",
1065
+ description: "Mounts mapped 1:1 onto Docker HostConfig.Mounts (supports volume subpath)",
1066
+ items: { oneOf: [
1067
+ {
1068
+ type: "object",
1069
+ required: [
1070
+ "type",
1071
+ "target",
1072
+ "source"
1073
+ ],
1074
+ additionalProperties: false,
1075
+ properties: {
1076
+ type: { const: "volume" },
1077
+ target: { type: "string" },
1078
+ source: { type: "string" },
1079
+ readOnly: { type: "boolean" },
1080
+ volumeOptions: {
1081
+ type: "object",
1082
+ additionalProperties: false,
1083
+ properties: {
1084
+ subpath: { type: "string" },
1085
+ noCopy: { type: "boolean" },
1086
+ labels: {
1087
+ type: "object",
1088
+ additionalProperties: { type: "string" }
1089
+ }
1090
+ }
1091
+ }
1092
+ }
1093
+ },
1094
+ {
1095
+ type: "object",
1096
+ required: [
1097
+ "type",
1098
+ "target",
1099
+ "source"
1100
+ ],
1101
+ additionalProperties: false,
1102
+ properties: {
1103
+ type: { const: "bind" },
1104
+ target: { type: "string" },
1105
+ source: { type: "string" },
1106
+ readOnly: { type: "boolean" },
1107
+ bindOptions: {
1108
+ type: "object",
1109
+ additionalProperties: false,
1110
+ properties: { propagation: {
1111
+ type: "string",
1112
+ enum: [
1113
+ "private",
1114
+ "rprivate",
1115
+ "shared",
1116
+ "rshared",
1117
+ "slave",
1118
+ "rslave"
1119
+ ]
1120
+ } }
1121
+ }
1122
+ }
1123
+ },
1124
+ {
1125
+ type: "object",
1126
+ required: ["type", "target"],
1127
+ additionalProperties: false,
1128
+ properties: {
1129
+ type: { const: "tmpfs" },
1130
+ target: { type: "string" },
1131
+ readOnly: { type: "boolean" },
1132
+ tmpfsOptions: {
1133
+ type: "object",
1134
+ additionalProperties: false,
1135
+ properties: {
1136
+ sizeBytes: { type: "number" },
1137
+ mode: { type: "number" }
1138
+ }
1139
+ }
1140
+ }
1141
+ }
1142
+ ] }
895
1143
  }
896
1144
  }
897
1145
  },