@mastra/docker 0.8.0-alpha.1 → 0.8.0

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/README.md CHANGED
@@ -29,6 +29,39 @@ const agent = new Agent({
29
29
  });
30
30
  ```
31
31
 
32
+ ### Volume subpath mounts
33
+
34
+ Use `mounts` (mapped 1:1 onto Docker's `HostConfig.Mounts`) when you need mount
35
+ options that the `-v`/`volumes` syntax cannot express — most notably mounting a
36
+ subdirectory of a named volume. Requires Docker Engine 26.0+ (API v1.45+) for
37
+ `subpath`.
38
+
39
+ ```typescript
40
+ const workspace = new Workspace({
41
+ sandbox: new DockerSandbox({
42
+ image: 'node:22-slim',
43
+ mounts: [
44
+ // Read-only parent from a named volume
45
+ { type: 'volume', source: 'project-data', target: '/shared', readOnly: true },
46
+ // Writable per-conversation subdirectory of the same volume
47
+ {
48
+ type: 'volume',
49
+ source: 'project-data',
50
+ target: '/work',
51
+ volumeOptions: { subpath: 'conversations/abc123' },
52
+ },
53
+ ],
54
+ }),
55
+ });
56
+ ```
57
+
58
+ `volumes` and `mounts` can be combined; both are passed through to Docker.
59
+
60
+ > **Note:** Docker does not create `volumeOptions.subpath` for you — the
61
+ > subdirectory must already exist inside the named volume before the container
62
+ > starts, otherwise the mount fails. Provision it ahead of time (for example,
63
+ > with a one-off container that creates `conversations/abc123` in the volume).
64
+
32
65
  ## Documentation
33
66
 
34
67
  - [Docker Sandbox integration guide](https://mastra.ai/integrations/sandboxes/docker)
package/dist/index.cjs CHANGED
@@ -27,8 +27,109 @@ let _mastra_core_workspace = require("@mastra/core/workspace");
27
27
  let dockerode = require("dockerode");
28
28
  dockerode = __toESM(dockerode, 1);
29
29
  let tar_stream = require("tar-stream");
30
+ let crypto = require("crypto");
30
31
  //#region src/sandbox/process-manager.ts
31
32
  /**
33
+ * Docker Process Manager
34
+ *
35
+ * Implements SandboxProcessManager for Docker containers.
36
+ * Uses `container.exec()` to run commands inside a long-lived container.
37
+ * Each spawned process gets a dedicated exec instance with separate
38
+ * stdout/stderr streams.
39
+ */
40
+ /**
41
+ * Directory (inside the container) where each spawned process records the PGID
42
+ * of its process group. Created with mode 700 so only the (root) exec user can
43
+ * write the PGID files.
44
+ */
45
+ const PROC_DIR = "/tmp/.mastra-proc";
46
+ /**
47
+ * Wrapper (run as the exec command) that places the user command in its own
48
+ * process group and records the group's PGID so kill() can signal the whole
49
+ * group later.
50
+ *
51
+ * Docker's exec-inspect `Pid` is a host/daemon-namespace PID and cannot be used
52
+ * with an in-container `kill`, so we need a container-namespace identity. We use
53
+ * a *kernel-enforced* process group as that identity:
54
+ *
55
+ * 1. `setsid -w` re-execs the command as a new session/process-group leader,
56
+ * so its PID == PGID. Every descendant inherits that PGID (unless it calls
57
+ * `setsid` itself) and stays reachable even if it re-parents to PID 1.
58
+ * `-w` keeps the wrapper (and thus the exec) alive for the whole lifetime
59
+ * and propagates the child's exit status — without it `setsid` forks and
60
+ * returns immediately, so the exec would appear to finish while the real
61
+ * work keeps running.
62
+ * 2. The leader writes its own PID (`$$`) — the PGID — to a private file that
63
+ * only this process wrote, so the identity is kernel-owned and cannot be
64
+ * forged by another container process.
65
+ *
66
+ * If `setsid -w` is unavailable in the image (e.g. BusyBox), we degrade
67
+ * gracefully: the command runs directly and we record its PID so kill() can
68
+ * still signal it (descendant coverage is then best-effort). The probe
69
+ * `setsid -w true` also covers images without setsid at all.
70
+ *
71
+ * Positional args: $1 = pgid file path, $2 = user command. The script text is a
72
+ * static constant; runtime values travel only as argv, never interpolated into
73
+ * the command string.
74
+ */
75
+ const SPAWN_WRAPPER = `
76
+ umask 077
77
+ d="\${1%/*}"
78
+ mkdir -p "$d" 2>/dev/null
79
+ chmod 700 "$d" 2>/dev/null
80
+ if setsid -w true >/dev/null 2>&1; then
81
+ exec setsid -w sh -c 'echo $$ > "$1" || exit 126; sh -c "$2"; ret=$?; rm -f "$1" 2>/dev/null; exit $ret' sh "$1" "$2"
82
+ fi
83
+ echo $$ > "$1" || exit 126
84
+ sh -c "$2"
85
+ ret=$?
86
+ rm -f "$1" 2>/dev/null
87
+ exit $ret
88
+ `;
89
+ /**
90
+ * Kill script: read the recorded PGID and SIGKILL the whole process group.
91
+ * A negative PID targets the kernel-owned process group, so descendants that
92
+ * re-parented to PID 1 are still caught. We SIGSTOP the
93
+ * group first to freeze fork races, then SIGKILL. The file may not exist yet if
94
+ * kill races the leader's first write, so we briefly wait for it.
95
+ *
96
+ * Positional arg: $1 = pgid file path (static script; no interpolation).
97
+ */
98
+ const KILL_SCRIPT = `
99
+ f="$1"
100
+ i=0
101
+ while [ ! -r "$f" ] && [ "$i" -lt 40 ]; do sleep 0.05; i=$((i + 1)); done
102
+ # If the PGID was never recorded (file absent/unreadable after the wait, or
103
+ # empty), we have no group to signal or verify — report failure rather than
104
+ # falsely claiming the tree was terminated.
105
+ [ -r "$f" ] || exit 1
106
+ pgid=$(cat "$f" 2>/dev/null)
107
+ rm -f "$f" 2>/dev/null
108
+ [ -n "$pgid" ] || exit 1
109
+ kill -STOP -"$pgid" 2>/dev/null
110
+ kill -KILL -"$pgid" 2>/dev/null
111
+ # Fallback for images without setsid: the leader is not a group leader, so also
112
+ # signal it directly.
113
+ kill -KILL "$pgid" 2>/dev/null
114
+ # Verify the group is actually gone before reporting success. kill -0 probes
115
+ # for the group's existence without sending a signal, and we poll while it
116
+ # still reports the group alive. When the probe finally fails we must inspect
117
+ # why: ESRCH ("no such process") means every member was reaped, so report
118
+ # success; EPERM or any other error means termination is unconfirmed (e.g. a
119
+ # member dropped privileges and became unsignalable), so exit nonzero and let
120
+ # kill() report failure instead of falsely claiming the tree was terminated.
121
+ j=0
122
+ while err=$(kill -0 -"$pgid" 2>&1); do
123
+ j=$((j + 1))
124
+ [ "$j" -ge 40 ] && exit 1
125
+ sleep 0.05
126
+ done
127
+ case "$err" in
128
+ *[Ss]uch\\ process*) exit 0 ;;
129
+ *) exit 1 ;;
130
+ esac
131
+ `;
132
+ /**
32
133
  * Wraps a Docker exec instance to conform to Mastra's ProcessHandle.
33
134
  * Not exported — internal to this module.
34
135
  *
@@ -48,13 +149,16 @@ var DockerProcessHandle = class extends _mastra_core_workspace.ProcessHandle {
48
149
  _waitPromise = null;
49
150
  _stdinStream = null;
50
151
  _execStream = null;
51
- constructor(exec, container, startTime, stdinStream, options) {
152
+ /** @internal Container path of the file holding this process group's PGID. */
153
+ _pgidFile;
154
+ constructor(exec, container, startTime, stdinStream, pgidFile, options) {
52
155
  super(options);
53
156
  this.pid = exec.id;
54
157
  this._exec = exec;
55
158
  this._container = container;
56
159
  this._startTime = startTime;
57
160
  this._stdinStream = stdinStream;
161
+ this._pgidFile = pgidFile;
58
162
  }
59
163
  get exitCode() {
60
164
  return this._exitCode;
@@ -85,37 +189,28 @@ var DockerProcessHandle = class extends _mastra_core_workspace.ProcessHandle {
85
189
  async kill() {
86
190
  if (this._exitCode !== void 0) return false;
87
191
  try {
88
- let info = await this._inspectExec();
89
- if (!info.Running || !info.Pid) {
90
- await new Promise((r) => setTimeout(r, 50));
91
- info = await this._inspectExec();
92
- }
93
- if (!info.Running) {
94
- this._killed = true;
95
- this._destroyStream();
96
- return false;
97
- }
98
- const pid = info.Pid;
99
- if (!pid) {
100
- this._killed = true;
101
- this._destroyStream();
102
- return false;
103
- }
104
- await (await this._container.exec({
192
+ const killExec = await this._container.exec({
105
193
  Cmd: [
106
194
  "sh",
107
195
  "-c",
108
- `kill -9 -${pid} 2>/dev/null || kill -9 ${pid}`
196
+ KILL_SCRIPT,
197
+ "sh",
198
+ this._pgidFile
109
199
  ],
110
200
  AttachStdout: false,
111
201
  AttachStderr: false
112
- })).start({});
202
+ });
203
+ await killExec.start({});
204
+ let killInfo = await killExec.inspect();
205
+ while (killInfo.Running) {
206
+ await new Promise((resolve) => setTimeout(resolve, 10));
207
+ killInfo = await killExec.inspect();
208
+ }
209
+ if (killInfo.ExitCode !== 0) throw new Error(`kill helper exited with code ${killInfo.ExitCode}`);
113
210
  this._killed = true;
114
211
  this._destroyStream();
115
212
  return true;
116
213
  } catch (error) {
117
- this._killed = true;
118
- this._destroyStream();
119
214
  const msg = error instanceof Error ? error.message.toLowerCase() : "";
120
215
  if (!msg.includes("no such process") && !msg.includes("esrch")) console.warn(`[DockerProcessManager] kill(${this.pid}) failed unexpectedly:`, error);
121
216
  return false;
@@ -169,11 +264,15 @@ var DockerProcessManager = class extends _mastra_core_workspace.SandboxProcessMa
169
264
  }
170
265
  async spawn(command, options = {}) {
171
266
  const container = this.container;
267
+ const pgidFile = `${PROC_DIR}/${(0, crypto.randomUUID)()}`;
172
268
  const envArray = Object.entries({ ...options.env }).filter((entry) => entry[1] !== void 0).map(([k, v]) => `${k}=${v}`);
173
269
  const exec = await container.exec({
174
270
  Cmd: [
175
271
  "sh",
176
272
  "-c",
273
+ SPAWN_WRAPPER,
274
+ "sh",
275
+ pgidFile,
177
276
  command
178
277
  ],
179
278
  AttachStdout: true,
@@ -188,7 +287,7 @@ var DockerProcessManager = class extends _mastra_core_workspace.SandboxProcessMa
188
287
  stdin: true
189
288
  });
190
289
  const startTime = Date.now();
191
- const handle = new DockerProcessHandle(exec, container, startTime, stream, options);
290
+ const handle = new DockerProcessHandle(exec, container, startTime, stream, pgidFile, options);
192
291
  handle._setExecStream(stream);
193
292
  const waitPromise = new Promise((resolve) => {
194
293
  const buffer = [];
@@ -264,8 +363,15 @@ var DockerProcessManager = class extends _mastra_core_workspace.SandboxProcessMa
264
363
  if (handle.exitCode === void 0) {
265
364
  handle._killed = true;
266
365
  handle._timedOut = true;
267
- handle.kill().catch(() => {});
268
- handle._destroyStream();
366
+ const forceClose = () => {
367
+ if (handle.exitCode === void 0) {
368
+ handle._killed = true;
369
+ handle._destroyStream();
370
+ }
371
+ };
372
+ handle.kill().then((killed) => {
373
+ if (!killed) forceClose();
374
+ }).catch(forceClose);
269
375
  }
270
376
  }, resolvedTimeout);
271
377
  waitPromise.then(() => clearTimeout(timer));
@@ -358,12 +464,14 @@ var DockerSandbox = class DockerSandbox extends _mastra_core_workspace.MastraSan
358
464
  _cpuQuota;
359
465
  _cpuPeriod;
360
466
  _pidsLimit;
467
+ _init;
361
468
  _readonlyRootfs;
362
469
  _capDrop;
363
470
  _capAdd;
364
471
  _securityOpt;
365
472
  _ulimits;
366
473
  _tmpfs;
474
+ _mounts;
367
475
  _labels;
368
476
  _instructionsOverride;
369
477
  _constructorOptions;
@@ -397,12 +505,14 @@ var DockerSandbox = class DockerSandbox extends _mastra_core_workspace.MastraSan
397
505
  this._cpuQuota = options.cpuQuota;
398
506
  this._cpuPeriod = options.cpuPeriod;
399
507
  this._pidsLimit = options.pidsLimit;
508
+ this._init = options.init ?? true;
400
509
  this._readonlyRootfs = options.readonlyRootfs;
401
510
  this._capDrop = options.capDrop;
402
511
  this._capAdd = options.capAdd;
403
512
  this._securityOpt = options.securityOpt;
404
513
  this._ulimits = options.ulimits;
405
514
  this._tmpfs = options.tmpfs;
515
+ this._mounts = options.mounts;
406
516
  this.setWorkingDirectory(options.workingDirectory ?? options.workingDir ?? "/workspace");
407
517
  this._labels = {
408
518
  ...options.labels,
@@ -484,12 +594,14 @@ var DockerSandbox = class DockerSandbox extends _mastra_core_workspace.MastraSan
484
594
  CpuQuota: this._cpuQuota,
485
595
  CpuPeriod: this._cpuPeriod,
486
596
  PidsLimit: this._pidsLimit,
597
+ Init: this._init,
487
598
  ReadonlyRootfs: this._readonlyRootfs,
488
599
  CapDrop: this._capDrop,
489
600
  CapAdd: this._capAdd,
490
601
  SecurityOpt: this._securityOpt,
491
602
  Ulimits: this._ulimits?.map(toDockerUlimit),
492
- Tmpfs: this._tmpfs
603
+ Tmpfs: this._tmpfs,
604
+ Mounts: this._mounts?.map(toDockerMount)
493
605
  },
494
606
  OpenStdin: true,
495
607
  Tty: false
@@ -543,7 +655,8 @@ var DockerSandbox = class DockerSandbox extends _mastra_core_workspace.MastraSan
543
655
  ["CapAdd", this._capAdd],
544
656
  ["SecurityOpt", this._securityOpt],
545
657
  ["Ulimits", this._ulimits],
546
- ["Tmpfs", this._tmpfs]
658
+ ["Tmpfs", this._tmpfs],
659
+ ["Mounts", this._mounts?.map(toDockerMount)]
547
660
  ];
548
661
  if (this._privilegedWasSet || hostConfig?.Privileged === true) entries.unshift(["Privileged", this._privileged]);
549
662
  return entries.filter((entry) => isPresentHostConfigValue(entry[1]));
@@ -819,6 +932,31 @@ function toDockerUlimit(ulimit) {
819
932
  Hard: ulimit.hard
820
933
  };
821
934
  }
935
+ function toDockerMount(mount) {
936
+ const settings = {
937
+ Type: mount.type,
938
+ Target: mount.target,
939
+ Source: mount.type === "tmpfs" ? "" : mount.source
940
+ };
941
+ if (mount.readOnly !== void 0) settings.ReadOnly = mount.readOnly;
942
+ if (mount.type === "volume" && mount.volumeOptions) {
943
+ const { subpath, noCopy, labels } = mount.volumeOptions;
944
+ const volumeOptions = {};
945
+ if (subpath !== void 0) volumeOptions.Subpath = subpath;
946
+ if (noCopy !== void 0) volumeOptions.NoCopy = noCopy;
947
+ if (labels !== void 0) volumeOptions.Labels = labels;
948
+ if (Object.keys(volumeOptions).length > 0) settings.VolumeOptions = volumeOptions;
949
+ }
950
+ if (mount.type === "bind" && mount.bindOptions?.propagation !== void 0) settings.BindOptions = { Propagation: mount.bindOptions.propagation };
951
+ if (mount.type === "tmpfs" && mount.tmpfsOptions) {
952
+ const { sizeBytes, mode } = mount.tmpfsOptions;
953
+ const tmpfsOptions = {};
954
+ if (sizeBytes !== void 0) tmpfsOptions.SizeBytes = sizeBytes;
955
+ if (mode !== void 0) tmpfsOptions.Mode = mode;
956
+ if (Object.keys(tmpfsOptions).length > 0) settings.TmpfsOptions = tmpfsOptions;
957
+ }
958
+ return settings;
959
+ }
822
960
  function toDockerSandboxOptionName(field) {
823
961
  return {
824
962
  Privileged: "privileged",
@@ -833,7 +971,8 @@ function toDockerSandboxOptionName(field) {
833
971
  CapAdd: "capAdd",
834
972
  SecurityOpt: "securityOpt",
835
973
  Ulimits: "ulimits",
836
- Tmpfs: "tmpfs"
974
+ Tmpfs: "tmpfs",
975
+ Mounts: "mounts"
837
976
  }[field] ?? String(field);
838
977
  }
839
978
  //#endregion
@@ -944,6 +1083,87 @@ const dockerSandboxProvider = {
944
1083
  type: "object",
945
1084
  description: "tmpfs mount paths with options",
946
1085
  additionalProperties: { type: "string" }
1086
+ },
1087
+ mounts: {
1088
+ type: "array",
1089
+ description: "Mounts mapped 1:1 onto Docker HostConfig.Mounts (supports volume subpath)",
1090
+ items: { oneOf: [
1091
+ {
1092
+ type: "object",
1093
+ required: [
1094
+ "type",
1095
+ "target",
1096
+ "source"
1097
+ ],
1098
+ additionalProperties: false,
1099
+ properties: {
1100
+ type: { const: "volume" },
1101
+ target: { type: "string" },
1102
+ source: { type: "string" },
1103
+ readOnly: { type: "boolean" },
1104
+ volumeOptions: {
1105
+ type: "object",
1106
+ additionalProperties: false,
1107
+ properties: {
1108
+ subpath: { type: "string" },
1109
+ noCopy: { type: "boolean" },
1110
+ labels: {
1111
+ type: "object",
1112
+ additionalProperties: { type: "string" }
1113
+ }
1114
+ }
1115
+ }
1116
+ }
1117
+ },
1118
+ {
1119
+ type: "object",
1120
+ required: [
1121
+ "type",
1122
+ "target",
1123
+ "source"
1124
+ ],
1125
+ additionalProperties: false,
1126
+ properties: {
1127
+ type: { const: "bind" },
1128
+ target: { type: "string" },
1129
+ source: { type: "string" },
1130
+ readOnly: { type: "boolean" },
1131
+ bindOptions: {
1132
+ type: "object",
1133
+ additionalProperties: false,
1134
+ properties: { propagation: {
1135
+ type: "string",
1136
+ enum: [
1137
+ "private",
1138
+ "rprivate",
1139
+ "shared",
1140
+ "rshared",
1141
+ "slave",
1142
+ "rslave"
1143
+ ]
1144
+ } }
1145
+ }
1146
+ }
1147
+ },
1148
+ {
1149
+ type: "object",
1150
+ required: ["type", "target"],
1151
+ additionalProperties: false,
1152
+ properties: {
1153
+ type: { const: "tmpfs" },
1154
+ target: { type: "string" },
1155
+ readOnly: { type: "boolean" },
1156
+ tmpfsOptions: {
1157
+ type: "object",
1158
+ additionalProperties: false,
1159
+ properties: {
1160
+ sizeBytes: { type: "number" },
1161
+ mode: { type: "number" }
1162
+ }
1163
+ }
1164
+ }
1165
+ }
1166
+ ] }
947
1167
  }
948
1168
  }
949
1169
  },