@mastra/docker 0.8.0-alpha.1 → 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
@@ -3,8 +3,109 @@ import { isDeepStrictEqual } from "util";
3
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]));
@@ -795,6 +908,31 @@ function toDockerUlimit(ulimit) {
795
908
  Hard: ulimit.hard
796
909
  };
797
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
+ }
798
936
  function toDockerSandboxOptionName(field) {
799
937
  return {
800
938
  Privileged: "privileged",
@@ -809,7 +947,8 @@ function toDockerSandboxOptionName(field) {
809
947
  CapAdd: "capAdd",
810
948
  SecurityOpt: "securityOpt",
811
949
  Ulimits: "ulimits",
812
- Tmpfs: "tmpfs"
950
+ Tmpfs: "tmpfs",
951
+ Mounts: "mounts"
813
952
  }[field] ?? String(field);
814
953
  }
815
954
  //#endregion
@@ -920,6 +1059,87 @@ const dockerSandboxProvider = {
920
1059
  type: "object",
921
1060
  description: "tmpfs mount paths with options",
922
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
+ ] }
923
1143
  }
924
1144
  }
925
1145
  },