@mastra/docker 0.0.0-alpha.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/CHANGELOG.md +35 -0
- package/README.md +80 -0
- package/dist/index.cjs +572 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +564 -0
- package/dist/index.js.map +1 -0
- package/dist/provider.d.ts +29 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/sandbox/index.d.ts +151 -0
- package/dist/sandbox/index.d.ts.map +1 -0
- package/dist/sandbox/process-manager.d.ts +32 -0
- package/dist/sandbox/process-manager.d.ts.map +1 -0
- package/package.json +68 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @mastra/docker
|
|
2
|
+
|
|
3
|
+
## 0.1.0-alpha.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Added @mastra/docker, a Docker container sandbox provider for Mastra workspaces. Executes commands inside local Docker containers using long-lived containers with `docker exec`. Supports bind mounts, environment variables, container reconnection by label, custom images, and network configuration. Targets local development, CI/CD, air-gapped deployments, and cost-sensitive scenarios where cloud sandboxes are unnecessary. ([#14500](https://github.com/mastra-ai/mastra/pull/14500))
|
|
8
|
+
|
|
9
|
+
**Usage**
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import { Agent } from '@mastra/core/agent';
|
|
13
|
+
import { Workspace } from '@mastra/core/workspace';
|
|
14
|
+
import { DockerSandbox } from '@mastra/docker';
|
|
15
|
+
|
|
16
|
+
const workspace = new Workspace({
|
|
17
|
+
sandbox: new DockerSandbox({
|
|
18
|
+
image: 'node:22-slim',
|
|
19
|
+
timeout: 60_000,
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const agent = new Agent({
|
|
24
|
+
name: 'dev-agent',
|
|
25
|
+
model: 'anthropic/claude-opus-4-6',
|
|
26
|
+
workspace,
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Patch Changes
|
|
31
|
+
|
|
32
|
+
- Fixed process kill to target the entire process group (negative PID) with fallback, ensuring child processes spawned inside the container are properly cleaned up. Tracked process handles are now cleared after container stop or destroy to prevent stale references. ([#14500](https://github.com/mastra-ai/mastra/pull/14500))
|
|
33
|
+
|
|
34
|
+
- Updated dependencies [[`0474c2b`](https://github.com/mastra-ai/mastra/commit/0474c2b2e7c7e1ad8691dca031284841391ff1ef), [`f607106`](https://github.com/mastra-ai/mastra/commit/f607106854c6416c4a07d4082604b9f66d047221), [`62919a6`](https://github.com/mastra-ai/mastra/commit/62919a6ee0fbf3779ad21a97b1ec6696515d5104), [`0fd90a2`](https://github.com/mastra-ai/mastra/commit/0fd90a215caf5fca8099c15a67ca03e4427747a3)]:
|
|
35
|
+
- @mastra/core@1.26.0-alpha.4
|
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @mastra/docker
|
|
2
|
+
|
|
3
|
+
Docker container sandbox provider for Mastra workspaces. Uses long-lived containers with `docker exec` for command execution. Targets local development, CI/CD, air-gapped deployments, and cost-sensitive scenarios where cloud sandboxes are unnecessary.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @mastra/docker
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires [Docker Engine](https://docs.docker.com/engine/install/) running on the host machine.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { Agent } from '@mastra/core/agent';
|
|
17
|
+
import { Workspace } from '@mastra/core/workspace';
|
|
18
|
+
import { DockerSandbox } from '@mastra/docker';
|
|
19
|
+
|
|
20
|
+
const workspace = new Workspace({
|
|
21
|
+
sandbox: new DockerSandbox({
|
|
22
|
+
image: 'node:22-slim',
|
|
23
|
+
timeout: 60_000, // 60 second timeout (default: 5 minutes)
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const agent = new Agent({
|
|
28
|
+
name: 'my-agent',
|
|
29
|
+
model: 'anthropic/claude-opus-4-6',
|
|
30
|
+
workspace,
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Bind Mounts
|
|
35
|
+
|
|
36
|
+
Mount host directories into the container:
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
const workspace = new Workspace({
|
|
40
|
+
sandbox: new DockerSandbox({
|
|
41
|
+
image: 'node:22-slim',
|
|
42
|
+
volumes: {
|
|
43
|
+
'/my/project': '/workspace/project',
|
|
44
|
+
'/shared/data': '/data',
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Reconnection
|
|
51
|
+
|
|
52
|
+
Containers can be reconnected by providing a fixed `id`. On `start()`, an existing container with a matching label is reused instead of creating a new one:
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
const workspace = new Workspace({
|
|
56
|
+
sandbox: new DockerSandbox({
|
|
57
|
+
id: 'persistent-sandbox',
|
|
58
|
+
image: 'node:22-slim',
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Docker Connection Options
|
|
64
|
+
|
|
65
|
+
Connect to remote Docker hosts or use custom socket paths:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
const workspace = new Workspace({
|
|
69
|
+
sandbox: new DockerSandbox({
|
|
70
|
+
dockerOptions: {
|
|
71
|
+
host: '192.168.1.100',
|
|
72
|
+
port: 2376,
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Documentation
|
|
79
|
+
|
|
80
|
+
For more information, see the [DockerSandbox reference](https://mastra.ai/docs/reference/workspace/docker-sandbox).
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var workspace = require('@mastra/core/workspace');
|
|
4
|
+
var Docker = require('dockerode');
|
|
5
|
+
|
|
6
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
7
|
+
|
|
8
|
+
var Docker__default = /*#__PURE__*/_interopDefault(Docker);
|
|
9
|
+
|
|
10
|
+
// src/sandbox/index.ts
|
|
11
|
+
var DockerProcessHandle = class extends workspace.ProcessHandle {
|
|
12
|
+
pid;
|
|
13
|
+
_exec;
|
|
14
|
+
_container;
|
|
15
|
+
_startTime;
|
|
16
|
+
_exitCode;
|
|
17
|
+
/** @internal Set by kill() and timeout to distinguish forced termination from natural exit */
|
|
18
|
+
_killed = false;
|
|
19
|
+
_waitPromise = null;
|
|
20
|
+
_stdinStream = null;
|
|
21
|
+
_execStream = null;
|
|
22
|
+
constructor(exec, container, startTime, stdinStream, options) {
|
|
23
|
+
super(options);
|
|
24
|
+
this.pid = exec.id;
|
|
25
|
+
this._exec = exec;
|
|
26
|
+
this._container = container;
|
|
27
|
+
this._startTime = startTime;
|
|
28
|
+
this._stdinStream = stdinStream;
|
|
29
|
+
}
|
|
30
|
+
get exitCode() {
|
|
31
|
+
return this._exitCode;
|
|
32
|
+
}
|
|
33
|
+
/** @internal Set exit code when stream closes */
|
|
34
|
+
_setExitCode(code) {
|
|
35
|
+
this._exitCode = code;
|
|
36
|
+
}
|
|
37
|
+
/** @internal Set the wait promise from spawn */
|
|
38
|
+
_setWaitPromise(p) {
|
|
39
|
+
this._waitPromise = p;
|
|
40
|
+
}
|
|
41
|
+
/** @internal Set the exec stream so kill() can destroy it */
|
|
42
|
+
_setExecStream(stream) {
|
|
43
|
+
this._execStream = stream;
|
|
44
|
+
}
|
|
45
|
+
async wait() {
|
|
46
|
+
if (this._waitPromise) {
|
|
47
|
+
return this._waitPromise;
|
|
48
|
+
}
|
|
49
|
+
const info = await this._inspectExec();
|
|
50
|
+
return {
|
|
51
|
+
success: (info.ExitCode ?? 1) === 0,
|
|
52
|
+
exitCode: info.ExitCode ?? 1,
|
|
53
|
+
stdout: this.stdout,
|
|
54
|
+
stderr: this.stderr,
|
|
55
|
+
executionTimeMs: Date.now() - this._startTime
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
async kill() {
|
|
59
|
+
if (this._exitCode !== void 0) return false;
|
|
60
|
+
try {
|
|
61
|
+
let info = await this._inspectExec();
|
|
62
|
+
if (!info.Running || !info.Pid) {
|
|
63
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
64
|
+
info = await this._inspectExec();
|
|
65
|
+
}
|
|
66
|
+
if (!info.Running) {
|
|
67
|
+
this._killed = true;
|
|
68
|
+
this._destroyStream();
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const pid = info.Pid;
|
|
72
|
+
if (!pid) {
|
|
73
|
+
this._killed = true;
|
|
74
|
+
this._destroyStream();
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
const killExec = await this._container.exec({
|
|
78
|
+
Cmd: ["sh", "-c", `kill -9 -${pid} 2>/dev/null || kill -9 ${pid}`],
|
|
79
|
+
AttachStdout: false,
|
|
80
|
+
AttachStderr: false
|
|
81
|
+
});
|
|
82
|
+
await killExec.start({});
|
|
83
|
+
this._killed = true;
|
|
84
|
+
this._destroyStream();
|
|
85
|
+
return true;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
this._killed = true;
|
|
88
|
+
this._destroyStream();
|
|
89
|
+
const msg = error instanceof Error ? error.message.toLowerCase() : "";
|
|
90
|
+
if (!msg.includes("no such process") && !msg.includes("esrch")) {
|
|
91
|
+
console.warn(`[DockerProcessManager] kill(${this.pid}) failed unexpectedly:`, error);
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async sendStdin(data) {
|
|
97
|
+
if (this._exitCode !== void 0) {
|
|
98
|
+
throw new Error(`Process ${this.pid} has already exited with code ${this._exitCode}`);
|
|
99
|
+
}
|
|
100
|
+
if (!this._stdinStream) {
|
|
101
|
+
throw new Error(`Process ${this.pid} was not started with stdin support`);
|
|
102
|
+
}
|
|
103
|
+
this._stdinStream.write(data);
|
|
104
|
+
}
|
|
105
|
+
/** @internal Force-close the exec stream to unblock wait(). */
|
|
106
|
+
_destroyStream() {
|
|
107
|
+
const stream = this._execStream;
|
|
108
|
+
if (stream && typeof stream.destroy === "function") {
|
|
109
|
+
stream.destroy();
|
|
110
|
+
this._execStream = null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async _inspectExec() {
|
|
114
|
+
return this._exec.inspect();
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
var DockerProcessManager = class extends workspace.SandboxProcessManager {
|
|
118
|
+
_container = null;
|
|
119
|
+
_defaultTimeout;
|
|
120
|
+
constructor(options) {
|
|
121
|
+
super(options);
|
|
122
|
+
this._defaultTimeout = options.defaultTimeout ?? 0;
|
|
123
|
+
}
|
|
124
|
+
/** @internal Called by DockerSandbox after container is ready */
|
|
125
|
+
setContainer(container) {
|
|
126
|
+
this._container = container;
|
|
127
|
+
}
|
|
128
|
+
/** Get the container, throwing if not set */
|
|
129
|
+
get container() {
|
|
130
|
+
if (!this._container) {
|
|
131
|
+
throw new Error("Docker container not available. Has the sandbox been started?");
|
|
132
|
+
}
|
|
133
|
+
return this._container;
|
|
134
|
+
}
|
|
135
|
+
async spawn(command, options = {}) {
|
|
136
|
+
const container = this.container;
|
|
137
|
+
const mergedEnv = { ...this.env, ...options.env };
|
|
138
|
+
const envArray = Object.entries(mergedEnv).filter((entry) => entry[1] !== void 0).map(([k, v]) => `${k}=${v}`);
|
|
139
|
+
const exec = await container.exec({
|
|
140
|
+
Cmd: ["sh", "-c", command],
|
|
141
|
+
AttachStdout: true,
|
|
142
|
+
AttachStderr: true,
|
|
143
|
+
AttachStdin: true,
|
|
144
|
+
Tty: false,
|
|
145
|
+
Env: envArray.length > 0 ? envArray : void 0,
|
|
146
|
+
WorkingDir: options.cwd
|
|
147
|
+
});
|
|
148
|
+
const stream = await exec.start({ hijack: true, stdin: true });
|
|
149
|
+
const startTime = Date.now();
|
|
150
|
+
const handle = new DockerProcessHandle(exec, container, startTime, stream, options);
|
|
151
|
+
handle._setExecStream(stream);
|
|
152
|
+
const waitPromise = new Promise((resolve) => {
|
|
153
|
+
const buffer = [];
|
|
154
|
+
stream.on("data", (chunk) => {
|
|
155
|
+
buffer.push(chunk);
|
|
156
|
+
let combined = Buffer.concat(buffer);
|
|
157
|
+
buffer.length = 0;
|
|
158
|
+
while (combined.length >= 8) {
|
|
159
|
+
const type = combined[0];
|
|
160
|
+
const size = combined.readUInt32BE(4);
|
|
161
|
+
if (combined.length < 8 + size) {
|
|
162
|
+
buffer.push(combined);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
const payload = combined.subarray(8, 8 + size).toString("utf-8");
|
|
166
|
+
if (type === 1) {
|
|
167
|
+
handle.emitStdout(payload);
|
|
168
|
+
} else if (type === 2) {
|
|
169
|
+
handle.emitStderr(payload);
|
|
170
|
+
}
|
|
171
|
+
combined = combined.subarray(8 + size);
|
|
172
|
+
}
|
|
173
|
+
if (combined.length > 0 && buffer.length === 0) {
|
|
174
|
+
buffer.push(combined);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
stream.on("end", async () => {
|
|
178
|
+
try {
|
|
179
|
+
const info = await exec.inspect();
|
|
180
|
+
const exitCode = info.ExitCode ?? 1;
|
|
181
|
+
handle._setExitCode(exitCode);
|
|
182
|
+
resolve({
|
|
183
|
+
success: exitCode === 0,
|
|
184
|
+
exitCode,
|
|
185
|
+
stdout: handle.stdout,
|
|
186
|
+
stderr: handle.stderr,
|
|
187
|
+
executionTimeMs: Date.now() - startTime
|
|
188
|
+
});
|
|
189
|
+
} catch {
|
|
190
|
+
handle._setExitCode(1);
|
|
191
|
+
resolve({
|
|
192
|
+
success: false,
|
|
193
|
+
exitCode: 1,
|
|
194
|
+
stdout: handle.stdout,
|
|
195
|
+
stderr: handle.stderr,
|
|
196
|
+
executionTimeMs: Date.now() - startTime
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
stream.on("close", () => {
|
|
201
|
+
if (handle.exitCode !== void 0) return;
|
|
202
|
+
if (!handle._killed) return;
|
|
203
|
+
handle._setExitCode(137);
|
|
204
|
+
resolve({
|
|
205
|
+
success: false,
|
|
206
|
+
exitCode: 137,
|
|
207
|
+
stdout: handle.stdout,
|
|
208
|
+
stderr: handle.stderr,
|
|
209
|
+
executionTimeMs: Date.now() - startTime
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
stream.on("error", () => {
|
|
213
|
+
if (handle.exitCode !== void 0) return;
|
|
214
|
+
handle._setExitCode(1);
|
|
215
|
+
resolve({
|
|
216
|
+
success: false,
|
|
217
|
+
exitCode: 1,
|
|
218
|
+
stdout: handle.stdout,
|
|
219
|
+
stderr: handle.stderr || "Stream error",
|
|
220
|
+
executionTimeMs: Date.now() - startTime
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
const resolvedTimeout = options.timeout ?? this._defaultTimeout;
|
|
225
|
+
if (resolvedTimeout > 0) {
|
|
226
|
+
const timeoutMs = resolvedTimeout;
|
|
227
|
+
const timer = setTimeout(() => {
|
|
228
|
+
if (handle.exitCode === void 0) {
|
|
229
|
+
handle._killed = true;
|
|
230
|
+
handle.kill().catch(() => {
|
|
231
|
+
});
|
|
232
|
+
handle._destroyStream();
|
|
233
|
+
}
|
|
234
|
+
}, timeoutMs);
|
|
235
|
+
void waitPromise.then(() => clearTimeout(timer));
|
|
236
|
+
}
|
|
237
|
+
handle._setWaitPromise(waitPromise);
|
|
238
|
+
this._tracked.set(handle.pid, handle);
|
|
239
|
+
return handle;
|
|
240
|
+
}
|
|
241
|
+
/** Clear all tracked process handles and release the container reference (e.g., after container stop/destroy) */
|
|
242
|
+
reset() {
|
|
243
|
+
this._tracked.clear();
|
|
244
|
+
this._container = null;
|
|
245
|
+
}
|
|
246
|
+
async list() {
|
|
247
|
+
const results = [];
|
|
248
|
+
for (const [pid, handle] of this._tracked) {
|
|
249
|
+
results.push({
|
|
250
|
+
pid,
|
|
251
|
+
command: handle.command,
|
|
252
|
+
running: handle.exitCode === void 0,
|
|
253
|
+
exitCode: handle.exitCode
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return results;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// src/sandbox/index.ts
|
|
261
|
+
var LOG_PREFIX = "[DockerSandbox]";
|
|
262
|
+
var DockerSandbox = class extends workspace.MastraSandbox {
|
|
263
|
+
id;
|
|
264
|
+
name = "DockerSandbox";
|
|
265
|
+
provider = "docker";
|
|
266
|
+
status = "pending";
|
|
267
|
+
/** Underlying Docker client */
|
|
268
|
+
_docker;
|
|
269
|
+
/** Container reference (set after start) */
|
|
270
|
+
_container = null;
|
|
271
|
+
/** Configuration */
|
|
272
|
+
_image;
|
|
273
|
+
_command;
|
|
274
|
+
_env;
|
|
275
|
+
_volumes;
|
|
276
|
+
_network;
|
|
277
|
+
_privileged;
|
|
278
|
+
_workingDir;
|
|
279
|
+
_labels;
|
|
280
|
+
_instructionsOverride;
|
|
281
|
+
constructor(options = {}) {
|
|
282
|
+
const processManager = new DockerProcessManager({
|
|
283
|
+
env: options.env ?? {},
|
|
284
|
+
defaultTimeout: options.timeout ?? 3e5
|
|
285
|
+
});
|
|
286
|
+
super({
|
|
287
|
+
...options,
|
|
288
|
+
name: "DockerSandbox",
|
|
289
|
+
processes: processManager
|
|
290
|
+
});
|
|
291
|
+
this.id = options.id ?? this._generateId();
|
|
292
|
+
this._image = options.image ?? "node:22-slim";
|
|
293
|
+
this._command = options.command ?? ["sleep", "infinity"];
|
|
294
|
+
this._env = options.env ?? {};
|
|
295
|
+
this._volumes = options.volumes ?? {};
|
|
296
|
+
this._network = options.network;
|
|
297
|
+
this._privileged = options.privileged ?? false;
|
|
298
|
+
this._workingDir = options.workingDir ?? "/workspace";
|
|
299
|
+
this._labels = {
|
|
300
|
+
...options.labels,
|
|
301
|
+
"mastra.sandbox": "true",
|
|
302
|
+
"mastra.sandbox.id": this.id
|
|
303
|
+
};
|
|
304
|
+
this._instructionsOverride = options.instructions;
|
|
305
|
+
this._docker = new Docker__default.default(options.dockerOptions);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Get the underlying Docker container for direct access.
|
|
309
|
+
* @throws {SandboxNotReadyError} If the sandbox has not been started.
|
|
310
|
+
*/
|
|
311
|
+
get container() {
|
|
312
|
+
if (!this._container) {
|
|
313
|
+
throw new workspace.SandboxNotReadyError(this.id);
|
|
314
|
+
}
|
|
315
|
+
return this._container;
|
|
316
|
+
}
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// Lifecycle
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
async start() {
|
|
321
|
+
this.logger.debug(`${LOG_PREFIX} Starting sandbox ${this.id}...`);
|
|
322
|
+
const existing = await this._findExistingContainer();
|
|
323
|
+
if (existing) {
|
|
324
|
+
this.logger.debug(`${LOG_PREFIX} Found existing container ${existing.Id}`);
|
|
325
|
+
this._container = this._docker.getContainer(existing.Id);
|
|
326
|
+
const info = await this._container.inspect();
|
|
327
|
+
const actualState = info.State?.Running ? "running" : "stopped";
|
|
328
|
+
if (actualState !== "running") {
|
|
329
|
+
this.logger.debug(`${LOG_PREFIX} Container exists but not running (${actualState}), starting...`);
|
|
330
|
+
await this._container.start();
|
|
331
|
+
}
|
|
332
|
+
this.processes.setContainer(this._container);
|
|
333
|
+
this.logger.debug(`${LOG_PREFIX} Reconnected to container ${existing.Id}`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
await this._ensureImage();
|
|
337
|
+
const envArray = Object.entries(this._env).map(([k, v]) => `${k}=${v}`);
|
|
338
|
+
const binds = Object.entries(this._volumes).map(([host, container]) => `${host}:${container}`);
|
|
339
|
+
this.logger.debug(`${LOG_PREFIX} Creating container with image ${this._image}...`);
|
|
340
|
+
this._container = await this._docker.createContainer({
|
|
341
|
+
Image: this._image,
|
|
342
|
+
Cmd: this._command,
|
|
343
|
+
Env: envArray,
|
|
344
|
+
WorkingDir: this._workingDir,
|
|
345
|
+
Labels: this._labels,
|
|
346
|
+
HostConfig: {
|
|
347
|
+
Binds: binds.length > 0 ? binds : void 0,
|
|
348
|
+
NetworkMode: this._network,
|
|
349
|
+
Privileged: this._privileged
|
|
350
|
+
},
|
|
351
|
+
// Keep stdin open for interactive use
|
|
352
|
+
OpenStdin: true,
|
|
353
|
+
Tty: false
|
|
354
|
+
});
|
|
355
|
+
await this._container.start();
|
|
356
|
+
this.processes.setContainer(this._container);
|
|
357
|
+
this.logger.debug(`${LOG_PREFIX} Container started: ${this._container.id}`);
|
|
358
|
+
}
|
|
359
|
+
async stop() {
|
|
360
|
+
const container = await this._resolveContainer();
|
|
361
|
+
if (!container) return;
|
|
362
|
+
this.logger.debug(`${LOG_PREFIX} Stopping container ${container.id}...`);
|
|
363
|
+
try {
|
|
364
|
+
await container.stop({ t: 10 });
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (!isContainerNotRunningError(error)) {
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
this.processes.reset();
|
|
371
|
+
this.logger.debug(`${LOG_PREFIX} Container stopped`);
|
|
372
|
+
}
|
|
373
|
+
async destroy() {
|
|
374
|
+
const container = await this._resolveContainer();
|
|
375
|
+
if (!container) return;
|
|
376
|
+
this.logger.debug(`${LOG_PREFIX} Destroying container ${container.id}...`);
|
|
377
|
+
try {
|
|
378
|
+
await container.remove({ force: true, v: true });
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (!isContainerNotFoundError(error)) {
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
this.processes.reset();
|
|
385
|
+
this._container = null;
|
|
386
|
+
this.logger.debug(`${LOG_PREFIX} Container destroyed`);
|
|
387
|
+
}
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// Instructions
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
getInstructions(opts) {
|
|
392
|
+
const defaultInstructions = [
|
|
393
|
+
`You are working inside a Docker container (image: ${this._image}).`,
|
|
394
|
+
`The working directory is ${this._workingDir}.`,
|
|
395
|
+
"You can execute shell commands using executeCommand().",
|
|
396
|
+
"You can spawn background processes using processes.spawn()."
|
|
397
|
+
].join("\n");
|
|
398
|
+
if (this._instructionsOverride === void 0) return defaultInstructions;
|
|
399
|
+
if (typeof this._instructionsOverride === "string") return this._instructionsOverride;
|
|
400
|
+
return this._instructionsOverride({ defaultInstructions, requestContext: opts?.requestContext });
|
|
401
|
+
}
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// Info
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
async getInfo() {
|
|
406
|
+
const info = {
|
|
407
|
+
id: this.id,
|
|
408
|
+
name: this.name,
|
|
409
|
+
provider: this.provider,
|
|
410
|
+
status: this.status,
|
|
411
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
412
|
+
metadata: {
|
|
413
|
+
image: this._image,
|
|
414
|
+
workingDir: this._workingDir,
|
|
415
|
+
labels: this._labels
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
if (this._container) {
|
|
419
|
+
try {
|
|
420
|
+
const inspect = await this._container.inspect();
|
|
421
|
+
info.createdAt = new Date(inspect.Created);
|
|
422
|
+
info.metadata = {
|
|
423
|
+
...info.metadata,
|
|
424
|
+
containerId: inspect.Id,
|
|
425
|
+
containerName: inspect.Name,
|
|
426
|
+
state: inspect.State.Status
|
|
427
|
+
};
|
|
428
|
+
} catch {
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return info;
|
|
432
|
+
}
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// Private helpers
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
_generateId() {
|
|
437
|
+
return `docker-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Resolve the container reference, looking up by label if `_container` is unset.
|
|
441
|
+
* This ensures `stop()` and `destroy()` work even when the instance was created
|
|
442
|
+
* with an existing container's ID but `start()` was never called.
|
|
443
|
+
*/
|
|
444
|
+
async _resolveContainer() {
|
|
445
|
+
if (this._container) return this._container;
|
|
446
|
+
const existing = await this._findExistingContainer();
|
|
447
|
+
if (!existing) return null;
|
|
448
|
+
this._container = this._docker.getContainer(existing.Id);
|
|
449
|
+
return this._container;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Find an existing container matching this sandbox's ID via labels.
|
|
453
|
+
*/
|
|
454
|
+
async _findExistingContainer() {
|
|
455
|
+
try {
|
|
456
|
+
const containers = await this._docker.listContainers({
|
|
457
|
+
all: true,
|
|
458
|
+
filters: {
|
|
459
|
+
label: [`mastra.sandbox.id=${this.id}`]
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
return containers[0] ?? null;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
this.logger.debug(
|
|
465
|
+
`${LOG_PREFIX} Failed to list containers: ${error instanceof Error ? error.message : String(error)}`
|
|
466
|
+
);
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Ensure the Docker image is available locally. Pulls if needed.
|
|
472
|
+
*/
|
|
473
|
+
async _ensureImage() {
|
|
474
|
+
try {
|
|
475
|
+
await this._docker.getImage(this._image).inspect();
|
|
476
|
+
this.logger.debug(`${LOG_PREFIX} Image ${this._image} available locally`);
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if (!isImageNotFoundError(error)) {
|
|
479
|
+
throw error;
|
|
480
|
+
}
|
|
481
|
+
this.logger.debug(`${LOG_PREFIX} Pulling image ${this._image}...`);
|
|
482
|
+
try {
|
|
483
|
+
const stream = await this._docker.pull(this._image);
|
|
484
|
+
await new Promise((resolve, reject) => {
|
|
485
|
+
this._docker.modem.followProgress(stream, (err) => {
|
|
486
|
+
if (err) reject(err);
|
|
487
|
+
else resolve();
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
this.logger.debug(`${LOG_PREFIX} Image ${this._image} pulled successfully`);
|
|
491
|
+
} catch (error2) {
|
|
492
|
+
throw new workspace.SandboxError(
|
|
493
|
+
`Failed to pull Docker image '${this._image}': ${error2 instanceof Error ? error2.message : String(error2)}`,
|
|
494
|
+
"NOT_READY",
|
|
495
|
+
{ image: this._image, reason: "image_pull_failed" }
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
function isContainerNotRunningError(error) {
|
|
502
|
+
if (error instanceof Error) {
|
|
503
|
+
return error.message.includes("is not running") || error.message.includes("container already stopped");
|
|
504
|
+
}
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
function isContainerNotFoundError(error) {
|
|
508
|
+
if (error instanceof Error) {
|
|
509
|
+
const msg = error.message.toLowerCase();
|
|
510
|
+
return msg.includes("no such container") || msg.includes("removal") && msg.includes("is already in progress");
|
|
511
|
+
}
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
function isImageNotFoundError(error) {
|
|
515
|
+
if (error instanceof Error) {
|
|
516
|
+
return error.message.toLowerCase().includes("no such image");
|
|
517
|
+
}
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// src/provider.ts
|
|
522
|
+
var dockerSandboxProvider = {
|
|
523
|
+
id: "docker",
|
|
524
|
+
name: "Docker Sandbox",
|
|
525
|
+
description: "Local container sandbox powered by Docker",
|
|
526
|
+
configSchema: {
|
|
527
|
+
type: "object",
|
|
528
|
+
properties: {
|
|
529
|
+
image: {
|
|
530
|
+
type: "string",
|
|
531
|
+
description: "Docker image to use",
|
|
532
|
+
default: "node:22-slim"
|
|
533
|
+
},
|
|
534
|
+
timeout: {
|
|
535
|
+
type: "number",
|
|
536
|
+
description: "Default command timeout in milliseconds",
|
|
537
|
+
default: 3e5
|
|
538
|
+
},
|
|
539
|
+
env: {
|
|
540
|
+
type: "object",
|
|
541
|
+
description: "Environment variables",
|
|
542
|
+
additionalProperties: { type: "string" }
|
|
543
|
+
},
|
|
544
|
+
volumes: {
|
|
545
|
+
type: "object",
|
|
546
|
+
description: "Host-to-container bind mounts (host path \u2192 container path)",
|
|
547
|
+
additionalProperties: { type: "string" }
|
|
548
|
+
},
|
|
549
|
+
network: {
|
|
550
|
+
type: "string",
|
|
551
|
+
description: "Docker network to join"
|
|
552
|
+
},
|
|
553
|
+
workingDir: {
|
|
554
|
+
type: "string",
|
|
555
|
+
description: "Working directory inside the container",
|
|
556
|
+
default: "/workspace"
|
|
557
|
+
},
|
|
558
|
+
privileged: {
|
|
559
|
+
type: "boolean",
|
|
560
|
+
description: "Run in privileged mode",
|
|
561
|
+
default: false
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
createSandbox: (config) => new DockerSandbox(config)
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
exports.DockerProcessManager = DockerProcessManager;
|
|
569
|
+
exports.DockerSandbox = DockerSandbox;
|
|
570
|
+
exports.dockerSandboxProvider = dockerSandboxProvider;
|
|
571
|
+
//# sourceMappingURL=index.cjs.map
|
|
572
|
+
//# sourceMappingURL=index.cjs.map
|