@mastra/docker 0.8.0-alpha.2 → 0.9.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/dist/index.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { posix } from "path";
2
- import { isDeepStrictEqual } from "util";
2
+ import { isDeepStrictEqual, promisify } 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
+ import { createHash, randomUUID } from "crypto";
7
+ import posixPath from "path/posix";
8
+ import { Server, ServerCredentials } from "@grpc/grpc-js";
9
+ import { execFile } from "child_process";
7
10
  //#region src/sandbox/process-manager.ts
8
11
  /**
9
12
  * Docker Process Manager
@@ -427,7 +430,10 @@ var DockerSandbox = class DockerSandbox extends MastraSandbox {
427
430
  _container = null;
428
431
  /** Configuration */
429
432
  _containerName;
433
+ /** Image the container boots from; rewritten by a template resolution on `start()`. */
430
434
  _image;
435
+ _templateSpec;
436
+ _workingDirectoryWasSet;
431
437
  _command;
432
438
  _env;
433
439
  _volumes;
@@ -468,7 +474,10 @@ var DockerSandbox = class DockerSandbox extends MastraSandbox {
468
474
  });
469
475
  this.id = options.id ?? this._generateId();
470
476
  this._containerName = sanitizeContainerName(options.name ?? this.id);
477
+ if (options.image !== void 0 && options.template !== void 0) throw new TypeError("DockerSandbox: `image` and `template` are mutually exclusive");
478
+ this._templateSpec = options.template;
471
479
  this._image = options.image ?? "node:22-slim";
480
+ this._workingDirectoryWasSet = options.workingDirectory !== void 0 || options.workingDir !== void 0;
472
481
  this._command = options.command ?? ["sleep", "infinity"];
473
482
  this._env = options.env ?? {};
474
483
  this._volumes = options.volumes ?? {};
@@ -544,11 +553,14 @@ var DockerSandbox = class DockerSandbox extends MastraSandbox {
544
553
  this.logger.debug(`${LOG_PREFIX} Container exists but not running (${actualState}), starting...`);
545
554
  await this._container.start();
546
555
  }
556
+ const reconnectedWorkingDir = info.Config?.WorkingDir;
557
+ if (!this._workingDirectoryWasSet && reconnectedWorkingDir) this.setWorkingDirectory(reconnectedWorkingDir);
547
558
  this.processes.setContainer(this._container);
548
559
  this.logger.debug(`${LOG_PREFIX} Reconnected to container ${existing.Id}`);
549
560
  return;
550
561
  }
551
562
  this._warnOnPrivilegedHardeningConflict(this._privileged);
563
+ await this._resolveTemplate();
552
564
  await this._ensureImage();
553
565
  const envArray = Object.entries(this._env).map(([k, v]) => `${k}=${v}`);
554
566
  const binds = Object.entries(this._volumes).map(([host, container]) => `${host}:${container}`);
@@ -803,6 +815,23 @@ var DockerSandbox = class DockerSandbox extends MastraSandbox {
803
815
  }
804
816
  }
805
817
  /**
818
+ * Resolve the `template` option (if any) into the image to boot from,
819
+ * building it when no cached image exists. Runs on every `start()` that
820
+ * creates a container, so a resolver-form template re-resolves each time.
821
+ * Adopts the template's workdir unless the sandbox was given one explicitly.
822
+ */
823
+ async _resolveTemplate() {
824
+ if (!this._templateSpec) return;
825
+ const template = typeof this._templateSpec === "function" ? await this._templateSpec() : this._templateSpec;
826
+ const result = await template.build({ docker: this._docker });
827
+ if (result.status !== "ready") throw new SandboxError(`Docker template build failed: ${result.error ?? "unknown error"}`, "START_FAILED", {
828
+ templateId: result.templateId,
829
+ reason: "template_build_failed"
830
+ });
831
+ this._image = result.templateId;
832
+ if (!this._workingDirectoryWasSet && template.workdir !== void 0) this.setWorkingDirectory(template.workdir);
833
+ }
834
+ /**
806
835
  * Ensure the Docker image is available locally. Pulls if needed.
807
836
  */
808
837
  async _ensureImage() {
@@ -810,7 +839,7 @@ var DockerSandbox = class DockerSandbox extends MastraSandbox {
810
839
  await this._docker.getImage(this._image).inspect();
811
840
  this.logger.debug(`${LOG_PREFIX} Image ${this._image} available locally`);
812
841
  } catch (error) {
813
- if (!isImageNotFoundError(error)) throw error;
842
+ if (!isImageNotFoundError$1(error)) throw error;
814
843
  this.logger.debug(`${LOG_PREFIX} Pulling image ${this._image}...`);
815
844
  try {
816
845
  const stream = await this._docker.pull(this._image);
@@ -846,7 +875,7 @@ function isContainerNotFoundError(error) {
846
875
  }
847
876
  return false;
848
877
  }
849
- function isImageNotFoundError(error) {
878
+ function isImageNotFoundError$1(error) {
850
879
  if (error instanceof Error) return error.message.toLowerCase().includes("no such image");
851
880
  return false;
852
881
  }
@@ -1146,6 +1175,889 @@ const dockerSandboxProvider = {
1146
1175
  createSandbox: (config) => new DockerSandbox(config)
1147
1176
  };
1148
1177
  //#endregion
1149
- export { DockerProcessManager, DockerSandbox, dockerSandboxProvider };
1178
+ //#region src/template/build-session.ts
1179
+ /**
1180
+ * BuildKit session that serves build secrets to the daemon.
1181
+ *
1182
+ * `docker build` with BuildKit (`version=2`) can attach a long-lived gRPC
1183
+ * "session" that the daemon calls back into for things it needs mid-build.
1184
+ * `RUN --mount=type=secret,id=X` is resolved by calling
1185
+ * `/moby.buildkit.secrets.v1.Secrets/GetSecret` on that session, so the value
1186
+ * exists only in the tmpfs mount for the duration of that one RUN — never as a
1187
+ * build arg, layer, history entry, or cache metadata.
1188
+ *
1189
+ * dockerode's own session helper only registers the registry-auth service and
1190
+ * overwrites any `session` id passed to `buildImage`, so this module dials the
1191
+ * hijacked `/session` endpoint itself and registers both services.
1192
+ */
1193
+ const SECRETS_GET_METHOD = "/moby.buildkit.secrets.v1.Secrets/GetSecret";
1194
+ const AUTH_CREDENTIALS_METHOD = "/moby.filesync.v1.Auth/Credentials";
1195
+ /**
1196
+ * Open a session that answers `GetSecret` from `secrets`. The returned id must
1197
+ * be sent as the `session` query parameter of the build request.
1198
+ */
1199
+ function openBuildSession(docker, secrets) {
1200
+ const id = randomUUID();
1201
+ return new Promise((resolve, reject) => {
1202
+ docker.modem.dial({
1203
+ method: "POST",
1204
+ path: "/session",
1205
+ hijack: true,
1206
+ headers: {
1207
+ Upgrade: "h2c",
1208
+ "X-Docker-Expose-Session-Uuid": id,
1209
+ "X-Docker-Expose-Session-Name": "mastra-docker-template",
1210
+ "X-Docker-Expose-Session-Grpc-Method": [SECRETS_GET_METHOD, AUTH_CREDENTIALS_METHOD]
1211
+ },
1212
+ statusCodes: {
1213
+ 200: true,
1214
+ 500: "server error"
1215
+ }
1216
+ }, (err, socket) => {
1217
+ if (err) {
1218
+ reject(err);
1219
+ return;
1220
+ }
1221
+ const server = new Server();
1222
+ server.createConnectionInjector(ServerCredentials.createInsecure()).injectConnection(socket);
1223
+ server.addService(secretsService, { GetSecret(call, callback) {
1224
+ const value = Object.hasOwn(secrets, call.request.id) ? secrets[call.request.id] : void 0;
1225
+ if (value === void 0) {
1226
+ callback(Object.assign(/* @__PURE__ */ new Error(`no build secret registered with id '${call.request.id}'`), { code: 5 }));
1227
+ return;
1228
+ }
1229
+ callback(null, { data: Buffer.from(value, "utf8") });
1230
+ } });
1231
+ server.addService(authService, { Credentials(_call, callback) {
1232
+ callback(null, {});
1233
+ } });
1234
+ resolve({
1235
+ id,
1236
+ close() {
1237
+ server.forceShutdown();
1238
+ socket.end();
1239
+ }
1240
+ });
1241
+ });
1242
+ });
1243
+ }
1244
+ /** @internal exported for tests */
1245
+ function decodeGetSecretRequest(buffer) {
1246
+ let offset = 0;
1247
+ let id = "";
1248
+ const readVarint = () => {
1249
+ let result = 0;
1250
+ let shift = 0;
1251
+ for (;;) {
1252
+ if (offset >= buffer.length) throw new Error("truncated varint");
1253
+ const byte = buffer[offset++];
1254
+ result += (byte & 127) * 2 ** shift;
1255
+ if ((byte & 128) === 0) return result;
1256
+ shift += 7;
1257
+ }
1258
+ };
1259
+ while (offset < buffer.length) {
1260
+ const key = readVarint();
1261
+ const field = Math.floor(key / 8);
1262
+ const wireType = key % 8;
1263
+ switch (wireType) {
1264
+ case 0:
1265
+ readVarint();
1266
+ break;
1267
+ case 1:
1268
+ offset += 8;
1269
+ break;
1270
+ case 5:
1271
+ offset += 4;
1272
+ break;
1273
+ case 2: {
1274
+ const length = readVarint();
1275
+ const value = buffer.subarray(offset, offset + length);
1276
+ offset += length;
1277
+ if (field === 1) id = value.toString("utf8");
1278
+ break;
1279
+ }
1280
+ default: throw new Error(`unsupported protobuf wire type ${wireType}`);
1281
+ }
1282
+ }
1283
+ return { id };
1284
+ }
1285
+ /** @internal exported for tests */
1286
+ function encodeGetSecretResponse(response) {
1287
+ return Buffer.concat([
1288
+ Buffer.from([10]),
1289
+ encodeVarint(response.data.length),
1290
+ response.data
1291
+ ]);
1292
+ }
1293
+ function encodeVarint(value) {
1294
+ const bytes = [];
1295
+ let remaining = value;
1296
+ while (remaining >= 128) {
1297
+ bytes.push(remaining % 128 | 128);
1298
+ remaining = Math.floor(remaining / 128);
1299
+ }
1300
+ bytes.push(remaining);
1301
+ return Buffer.from(bytes);
1302
+ }
1303
+ const secretsService = { GetSecret: {
1304
+ path: SECRETS_GET_METHOD,
1305
+ requestStream: false,
1306
+ responseStream: false,
1307
+ requestSerialize: () => {
1308
+ throw new Error("server does not serialize requests");
1309
+ },
1310
+ requestDeserialize: decodeGetSecretRequest,
1311
+ responseSerialize: encodeGetSecretResponse,
1312
+ responseDeserialize: () => {
1313
+ throw new Error("server does not deserialize responses");
1314
+ }
1315
+ } };
1316
+ const authService = { Credentials: {
1317
+ path: AUTH_CREDENTIALS_METHOD,
1318
+ requestStream: false,
1319
+ responseStream: false,
1320
+ requestSerialize: () => {
1321
+ throw new Error("server does not serialize requests");
1322
+ },
1323
+ requestDeserialize: () => ({}),
1324
+ responseSerialize: () => Buffer.alloc(0),
1325
+ responseDeserialize: () => {
1326
+ throw new Error("server does not deserialize responses");
1327
+ }
1328
+ } };
1329
+ //#endregion
1330
+ //#region src/template/dockerfile.ts
1331
+ /**
1332
+ * Pure Dockerfile synthesis and content-addressed identity for DockerTemplate.
1333
+ *
1334
+ * A `DockerTemplate` records an ordered list of operations (setWorkdir, setEnvs,
1335
+ * runCmd, runWithSecrets, aptInstall, pipInstall, npmInstall) over a base image. This module
1336
+ * turns that ordered list into a deterministic Dockerfile string and a stable
1337
+ * content hash. It performs no I/O, so it is fully unit-testable without a
1338
+ * Docker daemon.
1339
+ */
1340
+ function toCommandList(command) {
1341
+ return Array.isArray(command) ? command : [command];
1342
+ }
1343
+ function sortedEntries(record) {
1344
+ return Object.entries(record).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1345
+ }
1346
+ function renderEnvLine(envs) {
1347
+ const pairs = sortedEntries(envs);
1348
+ if (pairs.length === 0) return void 0;
1349
+ return `ENV ${pairs.map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(" ")}`;
1350
+ }
1351
+ function renderAptInstall(packages, options) {
1352
+ const flags = [];
1353
+ if (options?.noInstallRecommends) flags.push("--no-install-recommends");
1354
+ if (options?.fixMissing) flags.push("--fix-missing");
1355
+ return `RUN apt-get update && apt-get install -y ${flags.length > 0 ? `${flags.join(" ")} ` : ""}${toCommandList(packages).join(" ")} && rm -rf /var/lib/apt/lists/*`;
1356
+ }
1357
+ function renderPipInstall(packages, options) {
1358
+ return `RUN pip install${options?.g === false ? " --user" : ""} ${packages === void 0 ? "." : toCommandList(packages).join(" ")}`;
1359
+ }
1360
+ function renderNpmInstall(packages, options) {
1361
+ const flags = [];
1362
+ if (options?.g) flags.push("-g");
1363
+ if (packages === void 0) {
1364
+ if (options?.dev) flags.push("--include=dev");
1365
+ return `RUN npm install${flags.length > 0 ? ` ${flags.join(" ")}` : ""}`;
1366
+ }
1367
+ return `RUN npm install${flags.length > 0 ? ` ${flags.join(" ")}` : ""} ${toCommandList(packages).join(" ")}`;
1368
+ }
1369
+ function secretStageName(index) {
1370
+ return `mastra-secret-${index}`;
1371
+ }
1372
+ function mainStageName(index) {
1373
+ return `mastra-main-${index}`;
1374
+ }
1375
+ /**
1376
+ * Render a deterministic Dockerfile from a template definition.
1377
+ *
1378
+ * Operations accumulate in a chain of "main" stages. Every `runWithSecrets`
1379
+ * operation forks a throwaway stage from the main stage as it stands at that
1380
+ * point (so it sees earlier WORKDIR/ENV/installs) and runs the command with
1381
+ * each secret exposed through a BuildKit secret mount, exported into the
1382
+ * command's environment for that single RUN. The next main stage then starts
1383
+ * from the same pre-secret snapshot and `COPY --from`s only the declared
1384
+ * output path. Secret mounts are tmpfs-backed and never part of a layer, its
1385
+ * history, or the build cache; the throwaway stage additionally keeps anything
1386
+ * else the command wrote (caches, logs) out of the template image.
1387
+ */
1388
+ /**
1389
+ * `RUN --mount=type=secret,id=X,mode=0444 ... export X="$(cat /run/secrets/X)" && <command>`.
1390
+ * Reading the file into a shell variable keeps the value visible only to this
1391
+ * RUN's process tree, without depending on the newer `env=` mount option.
1392
+ * `mode=0444` because BuildKit's default is `0400 root`, which a base image
1393
+ * with a non-root `USER` cannot read; world-readable within the RUN is no
1394
+ * wider than the exported variable already is.
1395
+ */
1396
+ function renderSecretRun(command, secrets) {
1397
+ const names = [...secrets].sort();
1398
+ const mounts = names.map((name) => `--mount=type=secret,id=${name},mode=0444`);
1399
+ const exports = names.map((name) => `${name}="$(cat /run/secrets/${name})"`);
1400
+ const prefix = names.length > 0 ? [`export ${exports.join(" ")}`] : [];
1401
+ return `RUN ${[...mounts, ""].join(" ")}${[...prefix, ...toCommandList(command)].join(" && ")}`;
1402
+ }
1403
+ function synthesizeDockerfile(definition) {
1404
+ const lines = [];
1405
+ let mainIndex = 0;
1406
+ const openMain = (from) => {
1407
+ lines.push(`FROM ${from} AS ${mainStageName(mainIndex)}`);
1408
+ };
1409
+ openMain(definition.baseImage);
1410
+ definition.operations.forEach((operation, index) => {
1411
+ switch (operation.method) {
1412
+ case "setWorkdir":
1413
+ lines.push(`WORKDIR ${operation.args[0]}`);
1414
+ break;
1415
+ case "setEnvs": {
1416
+ const line = renderEnvLine(operation.args[0]);
1417
+ if (line) lines.push(line);
1418
+ break;
1419
+ }
1420
+ case "runCmd":
1421
+ lines.push(`RUN ${toCommandList(operation.args[0]).join(" && ")}`);
1422
+ break;
1423
+ case "runWithSecrets": {
1424
+ const [command, { secrets, output }] = operation.args;
1425
+ const snapshot = mainStageName(mainIndex);
1426
+ const stage = secretStageName(index);
1427
+ lines.push(`FROM ${snapshot} AS ${stage}`, renderSecretRun(command, secrets));
1428
+ mainIndex += 1;
1429
+ openMain(snapshot);
1430
+ lines.push(`COPY --from=${stage} ${output} ${output}`);
1431
+ break;
1432
+ }
1433
+ case "aptInstall":
1434
+ lines.push(renderAptInstall(operation.args[0], operation.args[1]));
1435
+ break;
1436
+ case "pipInstall":
1437
+ lines.push(renderPipInstall(operation.args[0], operation.args[1]));
1438
+ break;
1439
+ case "npmInstall":
1440
+ lines.push(renderNpmInstall(operation.args[0], operation.args[1]));
1441
+ break;
1442
+ }
1443
+ });
1444
+ return `${lines.join("\n")}\n`;
1445
+ }
1446
+ /** Names of every secret referenced by `runWithSecrets` operations, deduplicated and sorted. */
1447
+ function secretNames(definition) {
1448
+ const names = /* @__PURE__ */ new Set();
1449
+ for (const operation of definition.operations) if (operation.method === "runWithSecrets") for (const name of operation.args[1].secrets) names.add(name);
1450
+ return [...names].sort();
1451
+ }
1452
+ /** Prefix for template image tags built locally. */
1453
+ const TEMPLATE_IMAGE_REPO = "mastra-template";
1454
+ function canonicalOperation(operation) {
1455
+ switch (operation.method) {
1456
+ case "setEnvs": return {
1457
+ method: "setEnvs",
1458
+ args: [sortedEntries(operation.args[0])]
1459
+ };
1460
+ case "runWithSecrets": {
1461
+ const [command, { secrets, output }] = operation.args;
1462
+ return {
1463
+ method: "runWithSecrets",
1464
+ args: [command, {
1465
+ secrets: [...secrets].sort(),
1466
+ output
1467
+ }]
1468
+ };
1469
+ }
1470
+ default: return operation;
1471
+ }
1472
+ }
1473
+ /**
1474
+ * Stable content hash over the base image and ordered operations. Env records
1475
+ * and secret name lists are canonicalized so insertion order does not change
1476
+ * the identity. Secret *values* never enter the definition, so the same
1477
+ * definition built with different credentials resolves to the same image tag.
1478
+ */
1479
+ function templateIdentity(definition) {
1480
+ const canonical = JSON.stringify({
1481
+ schemaVersion: 1,
1482
+ baseImage: definition.baseImage,
1483
+ operations: definition.operations.map(canonicalOperation)
1484
+ });
1485
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 24);
1486
+ }
1487
+ /** Full `mastra-template:<hash>` tag for a definition. */
1488
+ function templateImageTag(definition) {
1489
+ return `${TEMPLATE_IMAGE_REPO}:${templateIdentity(definition)}`;
1490
+ }
1491
+ //#endregion
1492
+ //#region src/template/template.ts
1493
+ /**
1494
+ * DockerTemplate — a reusable prepared baseline for the local Docker sandbox.
1495
+ *
1496
+ * Prepare an environment once (base image + ordered setup commands + env +
1497
+ * package installs), `build()` it into a content-addressed local image, then
1498
+ * spawn multiple disposable `DockerSandbox`es from that image. Each sandbox is a
1499
+ * fresh container with its own writable layer over the shared read-only image,
1500
+ * so their filesystems are independent. The built image's lifecycle is
1501
+ * controlled by `dispose()`, independent of any sandbox's `destroy()`.
1502
+ *
1503
+ * Unlike `docker commit` (which cannot capture mounted volumes and skips layer
1504
+ * caching), the baseline is produced by synthesizing a Dockerfile and running
1505
+ * `docker build`, so repo/setup content is baked into reproducible, cached
1506
+ * image layers.
1507
+ *
1508
+ * @example Prepare once, spawn many
1509
+ * ```typescript
1510
+ * import { DockerTemplate } from '@mastra/docker';
1511
+ *
1512
+ * const template = new DockerTemplate({ baseImage: 'node:22-slim' })
1513
+ * .runCmd('git clone --depth=1 https://example.com/repo /workspace/app')
1514
+ * .setWorkdir('/workspace/app')
1515
+ * .runCmd('npm ci');
1516
+ *
1517
+ * const result = await template.build();
1518
+ * if (result.status !== 'ready') throw new Error(result.error);
1519
+ *
1520
+ * const a = await template.createSandbox();
1521
+ * const b = await template.createSandbox(); // independent writable filesystem
1522
+ * ```
1523
+ */
1524
+ const MAX_OPERATIONS = 256;
1525
+ const MAX_STRING_LENGTH = 32 * 1024;
1526
+ const MAX_COLLECTION_ITEMS = 512;
1527
+ /**
1528
+ * Immutable, chainable builder + build/lifecycle for a local Docker template.
1529
+ * Operation methods return a new instance (like the platform `Template()`
1530
+ * builder); `build`/`createSandbox`/`dispose` operate against the daemon.
1531
+ */
1532
+ var DockerTemplate = class DockerTemplate {
1533
+ #baseImage;
1534
+ #operations;
1535
+ #dockerOptions;
1536
+ #secrets;
1537
+ #docker;
1538
+ #built = false;
1539
+ #inFlight;
1540
+ constructor(options = {}, state) {
1541
+ this.#baseImage = state ? state.baseImage : validateString(options.baseImage ?? "node:22-slim", "baseImage");
1542
+ this.#operations = state?.operations ?? [];
1543
+ this.#dockerOptions = options.dockerOptions;
1544
+ this.#secrets = options.secrets;
1545
+ }
1546
+ #clone(next) {
1547
+ return new DockerTemplate({
1548
+ dockerOptions: this.#dockerOptions,
1549
+ secrets: this.#secrets
1550
+ }, {
1551
+ baseImage: next.baseImage ?? this.#baseImage,
1552
+ operations: next.operations ?? this.#operations
1553
+ });
1554
+ }
1555
+ #append(operation) {
1556
+ if (this.#operations.length >= MAX_OPERATIONS) throw new RangeError(`Docker template cannot contain more than ${MAX_OPERATIONS} operations`);
1557
+ return this.#clone({ operations: [...this.#operations, operation] });
1558
+ }
1559
+ /** Set the base image. */
1560
+ from(image) {
1561
+ return this.#clone({ baseImage: validateString(image, "image") });
1562
+ }
1563
+ /** Set the working directory for subsequent steps and the runtime container. */
1564
+ setWorkdir(path) {
1565
+ return this.#append({
1566
+ method: "setWorkdir",
1567
+ args: [validateString(path, "path")]
1568
+ });
1569
+ }
1570
+ /**
1571
+ * Set environment variables. They are baked into the image via `ENV` and
1572
+ * participate in the template identity, so never put secrets here — use
1573
+ * {@link runWithSecrets} for anything that must not persist in the image.
1574
+ */
1575
+ setEnvs(envs) {
1576
+ return this.#append({
1577
+ method: "setEnvs",
1578
+ args: [validateStringRecord(envs, "envs")]
1579
+ });
1580
+ }
1581
+ /** Run a command (or `&&`-joined list of commands) as a build step. */
1582
+ runCmd(command) {
1583
+ return this.#append({
1584
+ method: "runCmd",
1585
+ args: [validateStringOrStrings(command, "command")]
1586
+ });
1587
+ }
1588
+ /**
1589
+ * Run a command that needs build-time secrets, without persisting them.
1590
+ *
1591
+ * The command runs in a throwaway build stage forked from the template as it
1592
+ * stands at that point, so earlier WORKDIR/ENV/installs apply and later
1593
+ * steps see the copied `output`. The named secrets are resolved when
1594
+ * `build()` runs — from `build({ secrets })`, then the template's `secrets`
1595
+ * option, then `process.env` — and exposed to the command as environment
1596
+ * variables. Values reach the daemon through a BuildKit secret mount
1597
+ * (`RUN --mount=type=secret`), which is tmpfs-backed and scoped to that one
1598
+ * RUN — never a build arg, layer, history entry, or cache metadata. Only
1599
+ * `output` is copied into the template image. Builds that use secrets
1600
+ * require a BuildKit-capable daemon (Docker 20.10+).
1601
+ */
1602
+ runWithSecrets(command, options) {
1603
+ if (!Array.isArray(options.secrets)) throw new TypeError("secrets must be an array of strings");
1604
+ if (options.secrets.length > MAX_COLLECTION_ITEMS) throw new RangeError(`secrets cannot contain more than ${MAX_COLLECTION_ITEMS} items`);
1605
+ const secrets = options.secrets.map((name) => {
1606
+ if (typeof name !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new TypeError(`secrets must be environment variable names, got ${JSON.stringify(name)}`);
1607
+ return name;
1608
+ });
1609
+ const output = validateString(options.output, "output");
1610
+ if (!output.startsWith("/")) throw new TypeError("output must be an absolute path");
1611
+ return this.#append({
1612
+ method: "runWithSecrets",
1613
+ args: [validateStringOrStrings(command, "command"), {
1614
+ secrets,
1615
+ output
1616
+ }]
1617
+ });
1618
+ }
1619
+ /** Install apt packages. */
1620
+ aptInstall(packages, options) {
1621
+ return this.#append({
1622
+ method: "aptInstall",
1623
+ args: [validateStringOrStrings(packages, "packages"), options]
1624
+ });
1625
+ }
1626
+ /** Install pip packages (or `pip install .` for the current workdir when omitted). */
1627
+ pipInstall(packages, options) {
1628
+ const validated = packages === void 0 ? void 0 : validateStringOrStrings(packages, "packages");
1629
+ return this.#append({
1630
+ method: "pipInstall",
1631
+ args: [validated, options]
1632
+ });
1633
+ }
1634
+ /** Install npm packages (or run `npm install` for the current workdir when omitted). */
1635
+ npmInstall(packages, options) {
1636
+ const validated = packages === void 0 ? void 0 : validateStringOrStrings(packages, "packages");
1637
+ return this.#append({
1638
+ method: "npmInstall",
1639
+ args: [validated, options]
1640
+ });
1641
+ }
1642
+ /** The resolved definition (base image + ordered operations). */
1643
+ get definition() {
1644
+ return {
1645
+ baseImage: this.#baseImage,
1646
+ operations: this.#operations
1647
+ };
1648
+ }
1649
+ /** The synthesized Dockerfile for this template. */
1650
+ get dockerfile() {
1651
+ return synthesizeDockerfile(this.definition);
1652
+ }
1653
+ /** The content-addressed local image tag this template resolves to. */
1654
+ get templateId() {
1655
+ return templateImageTag(this.definition);
1656
+ }
1657
+ /**
1658
+ * The working directory the built image ends up with, i.e. the last
1659
+ * `setWorkdir` in the chain (resolved against earlier ones when relative).
1660
+ * `undefined` when the template never sets one, in which case the base
1661
+ * image's `WORKDIR` (or the sandbox default) applies.
1662
+ */
1663
+ get workdir() {
1664
+ let current;
1665
+ for (const op of this.#operations) {
1666
+ if (op.method !== "setWorkdir") continue;
1667
+ const next = op.args[0];
1668
+ current = next.startsWith("/") || current === void 0 ? next : posixPath.join(current, next);
1669
+ }
1670
+ return current;
1671
+ }
1672
+ #getDocker() {
1673
+ if (!this.#docker) this.#docker = new Docker(this.#dockerOptions);
1674
+ return this.#docker;
1675
+ }
1676
+ /**
1677
+ * Build (or reuse) the template's image. Idempotent: if an image with the
1678
+ * computed tag already exists locally and `force` is not set, returns
1679
+ * `ready` without rebuilding. Otherwise synthesizes a Dockerfile, runs
1680
+ * `docker build`, and surfaces any build-step failure as `status: 'failed'`.
1681
+ *
1682
+ * @throws if a secret named by `runWithSecrets` cannot be resolved from
1683
+ * `options.secrets`, the template's `secrets` option, or `process.env`.
1684
+ */
1685
+ async build(options = {}) {
1686
+ if (!options.force && options.secrets === void 0 && options.docker === void 0 && this.#inFlight) return this.#inFlight;
1687
+ const run = (this.#inFlight?.catch(() => void 0) ?? Promise.resolve()).then(() => this.#build(options));
1688
+ this.#inFlight = run;
1689
+ run.finally(() => {
1690
+ if (this.#inFlight === run) this.#inFlight = void 0;
1691
+ }).catch(() => void 0);
1692
+ return run;
1693
+ }
1694
+ async #build(options) {
1695
+ const docker = options.docker ?? this.#getDocker();
1696
+ const tag = this.templateId;
1697
+ if (!options.force) try {
1698
+ await docker.getImage(tag).inspect();
1699
+ this.#built = true;
1700
+ return {
1701
+ status: "ready",
1702
+ templateId: tag
1703
+ };
1704
+ } catch (error) {
1705
+ if (!isImageNotFoundError(error)) throw error;
1706
+ }
1707
+ const secrets = await this.#resolveSecrets(options.secrets);
1708
+ const context = pack();
1709
+ context.entry({ name: "Dockerfile" }, this.dockerfile);
1710
+ context.finalize();
1711
+ try {
1712
+ const nocache = options.force === true;
1713
+ if (secrets) {
1714
+ const { stream, session } = await this.#buildWithSecrets(docker, context, tag, secrets, nocache);
1715
+ try {
1716
+ await this.#followBuild(docker, stream);
1717
+ } finally {
1718
+ session.close();
1719
+ }
1720
+ } else await this.#followBuild(docker, await docker.buildImage(context, {
1721
+ t: tag,
1722
+ nocache
1723
+ }));
1724
+ } catch (error) {
1725
+ this.#built = false;
1726
+ return {
1727
+ status: "failed",
1728
+ templateId: tag,
1729
+ error: error instanceof Error ? error.message : String(error)
1730
+ };
1731
+ }
1732
+ this.#built = true;
1733
+ return {
1734
+ status: "ready",
1735
+ templateId: tag
1736
+ };
1737
+ }
1738
+ async #resolveSecrets(override) {
1739
+ const names = secretNames(this.definition);
1740
+ if (names.length === 0) return void 0;
1741
+ const fromBuild = await readSecrets(override);
1742
+ const fromTemplate = await readSecrets(this.#secrets);
1743
+ const resolved = {};
1744
+ for (const name of names) {
1745
+ const value = fromBuild[name] ?? fromTemplate[name] ?? process.env[name];
1746
+ if (value === void 0) throw new Error(`Docker template secret ${name} was not provided (secrets option) and is not set in the environment`);
1747
+ resolved[name] = value;
1748
+ }
1749
+ return resolved;
1750
+ }
1751
+ /**
1752
+ * BuildKit build with a session serving the secret mounts. Dials `/build`
1753
+ * directly because `docker.buildImage` replaces any session id with its own
1754
+ * auth-only session when `version` is `'2'`.
1755
+ */
1756
+ async #buildWithSecrets(docker, context, tag, secrets, nocache) {
1757
+ const session = await openBuildSession(docker, secrets);
1758
+ let stream;
1759
+ try {
1760
+ stream = await new Promise((resolve, reject) => {
1761
+ docker.modem.dial({
1762
+ path: "/build?",
1763
+ method: "POST",
1764
+ file: context,
1765
+ options: {
1766
+ t: tag,
1767
+ version: "2",
1768
+ session: session.id,
1769
+ nocache
1770
+ },
1771
+ isStream: true,
1772
+ statusCodes: {
1773
+ 200: true,
1774
+ 500: "server error"
1775
+ }
1776
+ }, (err, data) => err ? reject(err) : resolve(data));
1777
+ });
1778
+ } catch (error) {
1779
+ session.close();
1780
+ throw error;
1781
+ }
1782
+ return {
1783
+ stream,
1784
+ session
1785
+ };
1786
+ }
1787
+ #followBuild(docker, stream) {
1788
+ return new Promise((resolve, reject) => {
1789
+ docker.modem.followProgress(stream, (err, output) => {
1790
+ if (err) {
1791
+ reject(err);
1792
+ return;
1793
+ }
1794
+ const failure = output?.find((entry) => entry && (entry.error !== void 0 || entry.errorDetail !== void 0));
1795
+ if (failure) {
1796
+ const detail = failure.errorDetail;
1797
+ reject(new Error(String(detail?.message ?? failure.error ?? "docker build failed")));
1798
+ return;
1799
+ }
1800
+ resolve();
1801
+ });
1802
+ });
1803
+ }
1804
+ /**
1805
+ * Create a `DockerSandbox` bound to the built image. Lazily builds the
1806
+ * template if it has not been built yet. Each call returns a fresh sandbox
1807
+ * with an independent writable layer. Invocation-specific `env`/config passed
1808
+ * via `options` reaches only the container and is never baked into the image.
1809
+ *
1810
+ * The sandbox's working directory follows the template's last `setWorkdir`
1811
+ * unless `options.workingDirectory` overrides it, so relative paths resolve
1812
+ * against the same directory the image was prepared in.
1813
+ *
1814
+ * @throws if the (lazy) build fails.
1815
+ */
1816
+ async createSandbox(options = {}) {
1817
+ if (!this.#built) {
1818
+ const result = await this.build();
1819
+ if (result.status !== "ready") throw new Error(`Docker template build failed: ${result.error ?? "unknown error"}`);
1820
+ }
1821
+ const workingDirectory = options.workingDirectory ?? options.workingDir ?? this.workdir;
1822
+ return new DockerSandbox({
1823
+ ...options,
1824
+ ...workingDirectory !== void 0 && { workingDirectory },
1825
+ template: this,
1826
+ dockerOptions: options.dockerOptions ?? this.#dockerOptions
1827
+ });
1828
+ }
1829
+ /**
1830
+ * Remove the built image (`docker rmi`). Tolerant of an already-removed
1831
+ * image. Independent of any sandbox created from this template, but the
1832
+ * daemon refuses to remove an image that a container (running or stopped)
1833
+ * still references, so destroy those sandboxes first.
1834
+ */
1835
+ async dispose() {
1836
+ const docker = this.#getDocker();
1837
+ try {
1838
+ await docker.getImage(this.templateId).remove();
1839
+ } catch (error) {
1840
+ if (!isImageNotFoundError(error)) throw error;
1841
+ }
1842
+ this.#built = false;
1843
+ }
1844
+ };
1845
+ function validateString(value, name) {
1846
+ if (typeof value !== "string") throw new TypeError(`${name} must be a string`);
1847
+ if (value.length === 0) throw new TypeError(`${name} must not be empty`);
1848
+ if (value.length > MAX_STRING_LENGTH) throw new RangeError(`${name} cannot exceed ${MAX_STRING_LENGTH} characters`);
1849
+ return value;
1850
+ }
1851
+ function validateStringOrStrings(value, name) {
1852
+ if (typeof value === "string") return validateString(value, name);
1853
+ if (!Array.isArray(value)) throw new TypeError(`${name} must be a string or an array of strings`);
1854
+ if (value.length === 0) throw new TypeError(`${name} must not be empty`);
1855
+ if (value.length > MAX_COLLECTION_ITEMS) throw new RangeError(`${name} cannot contain more than ${MAX_COLLECTION_ITEMS} items`);
1856
+ return value.map((item, index) => validateString(item, `${name}[${index}]`));
1857
+ }
1858
+ function validateStringRecord(value, name) {
1859
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError(`${name} must be a plain object`);
1860
+ const entries = Object.entries(value);
1861
+ if (entries.length > MAX_COLLECTION_ITEMS) throw new RangeError(`${name} cannot contain more than ${MAX_COLLECTION_ITEMS} items`);
1862
+ return Object.fromEntries(entries.map(([key, item]) => {
1863
+ if (typeof item !== "string") throw new TypeError(`${name}.${key} must be a string`);
1864
+ return [validateString(key, `${name} key`), item];
1865
+ }));
1866
+ }
1867
+ async function readSecrets(source) {
1868
+ if (!source) return {};
1869
+ return typeof source === "function" ? await source() : source;
1870
+ }
1871
+ function isImageNotFoundError(error) {
1872
+ if (error instanceof Error) {
1873
+ const msg = error.message.toLowerCase();
1874
+ return msg.includes("no such image") || msg.includes("404");
1875
+ }
1876
+ return false;
1877
+ }
1878
+ //#endregion
1879
+ //#region ../../packages/_internals/workspace/dist/index.js
1880
+ /**
1881
+ * Setup completion marker shared by repo templates and their consumers.
1882
+ *
1883
+ * A repo template writes this file beside the checkout as its last build
1884
+ * step, so it exists only in images where every setup command succeeded. Its
1885
+ * content is a digest of the setup commands the image ran, letting a sandbox
1886
+ * booted from the image tell whether the setup it is about to run already
1887
+ * happened. Relative to the template's build cwd, which is also the runtime
1888
+ * working directory the repo was cloned into.
1889
+ */
1890
+ const SETUP_MARKER_PATH = ".mastra-sandbox/setup";
1891
+ /** Blank entries never become build steps, so they never count toward the digest either. */
1892
+ function normalizeSetupCommands(setupCommand) {
1893
+ return (setupCommand === void 0 ? [] : Array.isArray(setupCommand) ? setupCommand : [setupCommand]).filter((command) => command.trim() !== "");
1894
+ }
1895
+ /** The marker content for a setup command list: `sha256:<hex>` over the commands joined by newlines. */
1896
+ function setupMarkerContent(setupCommand) {
1897
+ return `sha256:${createHash("sha256").update(normalizeSetupCommands(setupCommand).join("\n")).digest("hex")}`;
1898
+ }
1899
+ /** Shell step that writes the marker relative to the cwd. `content` is a digest, so it is shell-safe. */
1900
+ function setupMarkerCommand(content) {
1901
+ return `mkdir -p "$(dirname "${SETUP_MARKER_PATH}")" && printf '%s' '${content}' > "${SETUP_MARKER_PATH}"`;
1902
+ }
1903
+ //#endregion
1904
+ //#region src/template/repo-template.ts
1905
+ /**
1906
+ * createDockerRepoTemplate — a repo checkout plus setup commands as a
1907
+ * reusable, content-addressed local image, with the same contract as the
1908
+ * E2B and platform repo templates (`getRepositoryAccess`, `setupCommand`,
1909
+ * `buildEnv`, `workingDirectory`).
1910
+ *
1911
+ * Returns a template RESOLVER for `DockerSandbox`'s `template` option rather
1912
+ * than a fixed template: each resolution calls `getRepositoryAccess`, looks up
1913
+ * the current head of `ref` (`git ls-remote`, no clone) and pins that sha into
1914
+ * the template identity. A moved branch therefore yields a fresh image on the
1915
+ * next new sandbox, and an unmoved one reuses the cached image. When the head
1916
+ * cannot be resolved the resolver rejects: an unpinned clone cached under a
1917
+ * stable tag would otherwise serve stale repository state forever.
1918
+ *
1919
+ * The clone runs in a throwaway build stage; the credential is passed by value
1920
+ * to that stage only and never enters the template identity or the image.
1921
+ *
1922
+ * @example
1923
+ * ```typescript
1924
+ * const sandbox = new DockerSandbox({
1925
+ * template: createDockerRepoTemplate({
1926
+ * getRepositoryAccess: async () => ({ cloneUrl: 'https://github.com/acme/app.git' }),
1927
+ * setupCommand: ['npm ci', 'npm run build'],
1928
+ * }),
1929
+ * });
1930
+ * ```
1931
+ */
1932
+ const execFileAsync = promisify(execFile);
1933
+ /** Env var the build's clone reads the credential from (see `cloneFull`). */
1934
+ const BUILD_TOKEN_ENV = "GH_TOKEN";
1935
+ const DEFAULT_BASE_IMAGE = "node:22-slim";
1936
+ const DEFAULT_WORKING_DIRECTORY = "/workspace";
1937
+ const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/i;
1938
+ const SHA_PATTERN = /^[0-9a-f]{40}$/i;
1939
+ const CLONE_URL_ALLOWED_CHARS = /^[a-z0-9:/._-]+$/i;
1940
+ const CLONE_URL_HOST_PATTERN = /^[a-z0-9.-]+$/i;
1941
+ const CLONE_URL_SEGMENT_PATTERN = /^[\w.-]+$/;
1942
+ /** Refs interpolate into shell too; git ref names are already restricted, so allowlist tightly. */
1943
+ const REF_PATTERN = /^[\w./-]+$/;
1944
+ function createDockerRepoTemplate(options) {
1945
+ if (!options.getRepositoryAccess) return void 0;
1946
+ if (options.ref !== void 0 && !REF_PATTERN.test(options.ref)) throw new Error(`Invalid ref '${options.ref}': expected a git ref name`);
1947
+ const workingDirectory = trimTrailingSlashes(options.workingDirectory ?? DEFAULT_WORKING_DIRECTORY);
1948
+ if (!workingDirectory.startsWith("/")) throw new Error(`workingDirectory must be an absolute path, got '${options.workingDirectory}'`);
1949
+ return () => resolveRepoTemplate(options, workingDirectory);
1950
+ }
1951
+ async function resolveRepoTemplate(options, workingDirectory) {
1952
+ const access = await options.getRepositoryAccess();
1953
+ const cloneUrl = access?.cloneUrl;
1954
+ if (!cloneUrl) throw new Error("Repo template has no clone URL: repository access returned none.");
1955
+ assertCloneUrl(cloneUrl);
1956
+ const token = access?.authorization?.token;
1957
+ const buildEnv = typeof options.buildEnv === "function" ? await options.buildEnv() : options.buildEnv;
1958
+ const sha = await resolveHead(cloneUrl, options.ref, token);
1959
+ if (!sha) throw new Error(`Could not resolve ${options.ref ?? "HEAD"} of ${cloneUrl} with git ls-remote; check the ref, the credential and network access`);
1960
+ return buildRepoTemplate({
1961
+ cloneUrl,
1962
+ sha,
1963
+ token,
1964
+ buildEnv,
1965
+ setupCommand: options.setupCommand,
1966
+ workingDirectory,
1967
+ baseImage: options.baseImage,
1968
+ dockerOptions: options.dockerOptions
1969
+ });
1970
+ }
1971
+ /**
1972
+ * Pure assembly of the template from already-resolved inputs. Exported for
1973
+ * tests so the Dockerfile can be asserted without a network head lookup.
1974
+ * @internal
1975
+ */
1976
+ function buildRepoTemplate(inputs) {
1977
+ const { cloneUrl, sha, token, buildEnv } = inputs;
1978
+ const destination = `${trimTrailingSlashes(inputs.workingDirectory)}/${repoDirName(cloneUrl)}`;
1979
+ let template = new DockerTemplate({
1980
+ baseImage: inputs.baseImage ?? DEFAULT_BASE_IMAGE,
1981
+ dockerOptions: inputs.dockerOptions,
1982
+ ...token ? { secrets: { [BUILD_TOKEN_ENV]: token } } : {}
1983
+ });
1984
+ if (inputs.baseImage === void 0) template = template.aptInstall(["git", "ca-certificates"]);
1985
+ if (buildEnv && Object.keys(buildEnv).length > 0) template = template.setEnvs(buildEnv);
1986
+ const tokenEnv = token ? BUILD_TOKEN_ENV : void 0;
1987
+ const clone = [cloneFull({
1988
+ cloneUrl,
1989
+ destination,
1990
+ tokenEnv
1991
+ }), `git -C ${shellQuote(destination)} checkout --detach ${shellQuote(sha)}`];
1992
+ template = template.runWithSecrets(clone, {
1993
+ secrets: tokenEnv ? [tokenEnv] : [],
1994
+ output: destination
1995
+ }).setWorkdir(destination);
1996
+ const setupCommands = normalizeSetupCommands(inputs.setupCommand);
1997
+ for (const command of setupCommands) template = template.runCmd(command);
1998
+ if (setupCommands.length > 0) template = template.runCmd(setupMarkerCommand(setupMarkerContent(setupCommands)));
1999
+ return template;
2000
+ }
2001
+ /**
2002
+ * Resolve `ref` (or the default branch) to a commit sha with `git ls-remote`
2003
+ * on the host, without cloning. A full sha is returned as is. Any failure
2004
+ * yields undefined; the caller decides how to surface it.
2005
+ * @internal exported for tests.
2006
+ */
2007
+ async function resolveHead(cloneUrl, ref, token) {
2008
+ if (ref && FULL_SHA_PATTERN.test(ref)) return ref.toLowerCase();
2009
+ try {
2010
+ const authEnv = token ? {
2011
+ GIT_CONFIG_COUNT: "1",
2012
+ GIT_CONFIG_KEY_0: "http.extraheader",
2013
+ GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`
2014
+ } : {};
2015
+ const { stdout } = await execFileAsync("git", [
2016
+ "ls-remote",
2017
+ "--",
2018
+ cloneUrl,
2019
+ ref ?? "HEAD"
2020
+ ], {
2021
+ timeout: 1e4,
2022
+ env: {
2023
+ ...process.env,
2024
+ ...authEnv,
2025
+ GIT_TERMINAL_PROMPT: "0"
2026
+ }
2027
+ });
2028
+ const lines = stdout.split("\n").filter(Boolean).map((line) => line.split(" "));
2029
+ const sha = (lines.find(([, name]) => name?.endsWith("^{}")) ?? lines[0])?.[0]?.trim();
2030
+ return sha && SHA_PATTERN.test(sha) ? sha.toLowerCase() : void 0;
2031
+ } catch {
2032
+ return;
2033
+ }
2034
+ }
2035
+ function cloneFull({ cloneUrl, destination, tokenEnv }) {
2036
+ return `git ${tokenEnv ? `-c http.extraheader="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$${tokenEnv}" | base64 -w0)" ` : ""}clone ${shellQuote(cloneUrl)} ${shellQuote(destination)}`;
2037
+ }
2038
+ function assertCloneUrl(cloneUrl) {
2039
+ if (cloneUrl.length > 2048 || !CLONE_URL_ALLOWED_CHARS.test(cloneUrl)) throw new Error(`Invalid cloneUrl '${cloneUrl}': expected an https URL with a plain host and path`);
2040
+ let url;
2041
+ try {
2042
+ url = new URL(cloneUrl);
2043
+ } catch {
2044
+ throw new Error(`Invalid cloneUrl '${cloneUrl}': not a URL`);
2045
+ }
2046
+ const segments = url.pathname.split("/").slice(1);
2047
+ if (!(url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash && CLONE_URL_HOST_PATTERN.test(url.hostname) && segments.length > 0 && segments.every((segment) => CLONE_URL_SEGMENT_PATTERN.test(segment)))) throw new Error(`Invalid cloneUrl '${cloneUrl}': expected an https URL such as https://host/owner/repo.git`);
2048
+ }
2049
+ function repoDirName(cloneUrl) {
2050
+ return (trimTrailingSlashes(cloneUrl).split("/").at(-1) ?? "").replace(/\.git$/i, "").replace(/[^\w.-]/g, "-").replace(/^\.+/, "") || "repo";
2051
+ }
2052
+ function trimTrailingSlashes(path) {
2053
+ let end = path.length;
2054
+ while (end > 1 && path[end - 1] === "/") end--;
2055
+ return path.slice(0, end);
2056
+ }
2057
+ function shellQuote(value) {
2058
+ return `'${value.replace(/'/g, `'\\''`)}'`;
2059
+ }
2060
+ //#endregion
2061
+ export { DockerProcessManager, DockerSandbox, DockerTemplate, TEMPLATE_IMAGE_REPO, createDockerRepoTemplate, dockerSandboxProvider, secretNames, synthesizeDockerfile, templateIdentity, templateImageTag };
1150
2062
 
1151
2063
  //# sourceMappingURL=index.js.map