@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/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]));
@@ -589,32 +702,60 @@ var DockerSandbox = class DockerSandbox extends _mastra_core_workspace.MastraSan
589
702
  * - Existing destinations are overwritten (contents and mode).
590
703
  * - Exact bytes are preserved for both `string` and `Buffer` content,
591
704
  * including empty files and binary data.
592
- * - New files are created with mode `0644`; directories created implicitly
593
- * default to Docker's `0755`. Overwriting a file replaces its mode with `0644`.
705
+ * - New files use the per-file `mode` when provided (validated integer
706
+ * `0o001`–`0o777`), otherwise `0644`; directories created implicitly default
707
+ * to Docker's `0755`. Overwriting a file replaces its mode with the
708
+ * requested `mode` (or `0644` when omitted).
594
709
  * - Not atomic across files: on failure the promise rejects and earlier or
595
710
  * partially written files may remain.
596
711
  *
712
+ * Cancellation (`options.abortSignal`):
713
+ * - If the signal is already aborted, rejects with {@link SandboxAbortError}
714
+ * before creating the archive or starting the upload.
715
+ * - If the signal aborts during transfer, the underlying `putArchive` request
716
+ * is terminated by destroying the tar stream (which ends the request body),
717
+ * and the promise rejects with {@link SandboxAbortError}.
718
+ * - Upload and cancellation race: an upload that completes before the abort is
719
+ * observed resolves normally.
720
+ * - No rollback: files Docker already received or extracted may remain. The
721
+ * daemon may continue extraction after rejection, so the caller is
722
+ * responsible for any cleanup or sandbox disposal.
723
+ * - Behavior is unchanged when no signal is supplied.
724
+ *
597
725
  * @throws {SandboxNotReadyError} If the sandbox has not been started.
726
+ * @throws {SandboxAbortError} If the write is cancelled via `options.abortSignal`.
598
727
  * @throws {SandboxError} If the archive upload fails.
599
728
  */
600
- async writeFiles(files) {
729
+ async writeFiles(files, options) {
601
730
  const container = this.container;
731
+ const signal = options?.abortSignal;
732
+ if (signal?.aborted) throw new _mastra_core_workspace.SandboxAbortError("writeFiles");
602
733
  if (files.length === 0) return;
603
734
  const pack = (0, tar_stream.pack)();
604
735
  for (const file of files) {
736
+ if (file.mode !== void 0) (0, _mastra_core_workspace.validateSandboxFileMode)(file.mode);
605
737
  const resolved = path.posix.isAbsolute(file.path) ? path.posix.normalize(file.path) : path.posix.resolve(this.workingDirectory, file.path);
606
738
  const data = Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content);
739
+ const mode = file.mode ?? 420;
607
740
  pack.entry({
608
741
  name: resolved.replace(/^\/+/, ""),
609
742
  size: data.length,
610
- mode: 420
743
+ mode
611
744
  }, data);
612
745
  }
613
746
  pack.finalize();
747
+ const onAbort = () => pack.destroy();
748
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
614
749
  try {
615
- await container.putArchive(pack, { path: "/" });
750
+ await container.putArchive(pack, {
751
+ path: "/",
752
+ abortSignal: signal
753
+ });
616
754
  } catch (error) {
755
+ if (signal?.aborted) throw new _mastra_core_workspace.SandboxAbortError("writeFiles");
617
756
  throw new _mastra_core_workspace.SandboxError(`Failed to write files to sandbox: ${error instanceof Error ? error.message : String(error)}`, "EXECUTION_FAILED", { reason: "write_files_failed" });
757
+ } finally {
758
+ if (signal) signal.removeEventListener("abort", onAbort);
618
759
  }
619
760
  }
620
761
  getInstructions(opts) {
@@ -791,6 +932,31 @@ function toDockerUlimit(ulimit) {
791
932
  Hard: ulimit.hard
792
933
  };
793
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
+ }
794
960
  function toDockerSandboxOptionName(field) {
795
961
  return {
796
962
  Privileged: "privileged",
@@ -805,7 +971,8 @@ function toDockerSandboxOptionName(field) {
805
971
  CapAdd: "capAdd",
806
972
  SecurityOpt: "securityOpt",
807
973
  Ulimits: "ulimits",
808
- Tmpfs: "tmpfs"
974
+ Tmpfs: "tmpfs",
975
+ Mounts: "mounts"
809
976
  }[field] ?? String(field);
810
977
  }
811
978
  //#endregion
@@ -916,6 +1083,87 @@ const dockerSandboxProvider = {
916
1083
  type: "object",
917
1084
  description: "tmpfs mount paths with options",
918
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
+ ] }
919
1167
  }
920
1168
  }
921
1169
  },