@mastra/docker 0.9.3 → 0.10.0-alpha.1

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
@@ -169,6 +169,8 @@ const sandbox = new DockerSandbox({
169
169
  });
170
170
  ```
171
171
 
172
+ If `baseImage` runs as a non-root `USER`, set `owner` to that user (for example `owner: 'node'` or `owner: '1000:1000'`). The checkout is then copied with `COPY --chown`, so git and setup commands can write to it. Without `owner`, the checkout is owned by root.
173
+
172
174
  ## Documentation
173
175
 
174
176
  - [Docker Sandbox integration guide](https://mastra.ai/integrations/sandboxes/docker)
package/dist/index.cjs CHANGED
@@ -1600,6 +1600,11 @@ function renderSecretRun(command, secrets) {
1600
1600
  const prefix = names.length > 0 ? [`export ${exports.join(" ")}`] : [];
1601
1601
  return `RUN ${[...mounts, ""].join(" ")}${[...prefix, ...toCommandList(command)].join(" && ")}`;
1602
1602
  }
1603
+ const OWNER_PATTERN = /^[A-Za-z0-9_.-]+(:[A-Za-z0-9_.-]+)?$/;
1604
+ /** Throws unless `owner` is a `user[:group]` or `uid[:gid]` usable in `COPY --chown`. */
1605
+ function assertValidOwner(owner) {
1606
+ if (typeof owner !== "string" || !OWNER_PATTERN.test(owner)) throw new TypeError(`owner must be user[:group] or uid[:gid], got ${JSON.stringify(owner)}`);
1607
+ }
1603
1608
  function synthesizeDockerfile(definition) {
1604
1609
  const lines = [];
1605
1610
  let mainIndex = 0;
@@ -1621,13 +1626,14 @@ function synthesizeDockerfile(definition) {
1621
1626
  lines.push(`RUN ${toCommandList(operation.args[0]).join(" && ")}`);
1622
1627
  break;
1623
1628
  case "runWithSecrets": {
1624
- const [command, { secrets, output }] = operation.args;
1629
+ const [command, { secrets, output, owner }] = operation.args;
1630
+ if (owner !== void 0) assertValidOwner(owner);
1625
1631
  const snapshot = mainStageName(mainIndex);
1626
1632
  const stage = secretStageName(index);
1627
1633
  lines.push(`FROM ${snapshot} AS ${stage}`, renderSecretRun(command, secrets));
1628
1634
  mainIndex += 1;
1629
1635
  openMain(snapshot);
1630
- lines.push(`COPY --from=${stage} ${output} ${output}`);
1636
+ lines.push(`COPY ${owner !== void 0 ? `--chown=${owner} ` : ""}--from=${stage} ${output} ${output}`);
1631
1637
  break;
1632
1638
  }
1633
1639
  case "aptInstall":
@@ -1658,12 +1664,13 @@ function canonicalOperation(operation) {
1658
1664
  args: [sortedEntries(operation.args[0])]
1659
1665
  };
1660
1666
  case "runWithSecrets": {
1661
- const [command, { secrets, output }] = operation.args;
1667
+ const [command, { secrets, output, owner }] = operation.args;
1662
1668
  return {
1663
1669
  method: "runWithSecrets",
1664
1670
  args: [command, {
1665
1671
  secrets: [...secrets].sort(),
1666
- output
1672
+ output,
1673
+ ...owner !== void 0 ? { owner } : {}
1667
1674
  }]
1668
1675
  };
1669
1676
  }
@@ -1809,11 +1816,17 @@ var DockerTemplate = class DockerTemplate {
1809
1816
  });
1810
1817
  const output = validateString(options.output, "output");
1811
1818
  if (!output.startsWith("/")) throw new TypeError("output must be an absolute path");
1819
+ let owner;
1820
+ if (options.owner !== void 0) {
1821
+ owner = validateString(options.owner, "owner");
1822
+ assertValidOwner(owner);
1823
+ }
1812
1824
  return this.#append({
1813
1825
  method: "runWithSecrets",
1814
1826
  args: [validateStringOrStrings(command, "command"), {
1815
1827
  secrets,
1816
- output
1828
+ output,
1829
+ ...owner !== void 0 ? { owner } : {}
1817
1830
  }]
1818
1831
  });
1819
1832
  }
@@ -2161,6 +2174,8 @@ function isImageNotFoundError(error) {
2161
2174
  }
2162
2175
  //#endregion
2163
2176
  //#region ../../packages/_internals/workspace/dist/index.js
2177
+ /** Directory, relative to the template's build cwd, that holds every marker below. */
2178
+ const SETUP_MARKER_DIR = ".mastra-sandbox";
2164
2179
  /**
2165
2180
  * Setup completion marker shared by repo templates and their consumers.
2166
2181
  *
@@ -2170,8 +2185,29 @@ function isImageNotFoundError(error) {
2170
2185
  * booted from the image tell whether the setup it is about to run already
2171
2186
  * happened. Relative to the template's build cwd, which is also the runtime
2172
2187
  * working directory the repo was cloned into.
2188
+ *
2189
+ * This is the single-repository marker. Templates built from a repository
2190
+ * list write one marker per repository instead ({@link repoSetupMarkerPath}).
2173
2191
  */
2174
2192
  const SETUP_MARKER_PATH = ".mastra-sandbox/setup";
2193
+ /**
2194
+ * Marker for the workspace-level setup of a multi-repository template: the
2195
+ * commands that ran at the working directory after every repository was set
2196
+ * up. Written even when there were no such commands, so a consumer can tell
2197
+ * "ran nothing" from "never built".
2198
+ */
2199
+ const WORKSPACE_SETUP_MARKER_PATH = ".mastra-sandbox/workspace-setup";
2200
+ /**
2201
+ * One line per repository directory whose setup failed while the build was
2202
+ * told to continue. Absent when nothing failed. Consumers check this before
2203
+ * trusting a per-repository marker: that marker means every step ran, not
2204
+ * that every step succeeded.
2205
+ */
2206
+ const SETUP_FAILED_MARKER_PATH = ".mastra-sandbox/setup-failed";
2207
+ /** Per-repository marker of a multi-repository template, keyed by the clone directory name. */
2208
+ function repoSetupMarkerPath(repoDir) {
2209
+ return `${SETUP_MARKER_DIR}/repos/${repoDir}`;
2210
+ }
2175
2211
  /** Blank entries never become build steps, so they never count toward the digest either. */
2176
2212
  function normalizeSetupCommands(setupCommand) {
2177
2213
  return (setupCommand === void 0 ? [] : Array.isArray(setupCommand) ? setupCommand : [setupCommand]).filter((command) => command.trim() !== "");
@@ -2181,8 +2217,26 @@ function setupMarkerContent(setupCommand) {
2181
2217
  return `sha256:${(0, crypto.createHash)("sha256").update(normalizeSetupCommands(setupCommand).join("\n")).digest("hex")}`;
2182
2218
  }
2183
2219
  /** Shell step that writes the marker relative to the cwd. `content` is a digest, so it is shell-safe. */
2184
- function setupMarkerCommand(content) {
2185
- return `mkdir -p "$(dirname "${SETUP_MARKER_PATH}")" && printf '%s' '${content}' > "${SETUP_MARKER_PATH}"`;
2220
+ function setupMarkerCommand(content, markerPath = SETUP_MARKER_PATH) {
2221
+ return `mkdir -p "$(dirname "${markerPath}")" && printf '%s' '${content}' > "${markerPath}"`;
2222
+ }
2223
+ /** A clone directory name is one path segment of word characters, dots and dashes, so it never needs quoting. */
2224
+ const REPO_DIR_PATTERN = /^[\w.-]+$/;
2225
+ /**
2226
+ * Shell step that runs one setup command inside a repository clone.
2227
+ *
2228
+ * Without the guard this is the plain `cd "<dir>" && <cmd>` step. With it,
2229
+ * the `cd` sits inside the parenthesized group so that the failure branch
2230
+ * runs back at the build cwd and the failure list lands at the workspace
2231
+ * level, never inside the repository.
2232
+ */
2233
+ function guardedSetupCommand({ repoDir, command, continueOnFailure }) {
2234
+ if (!REPO_DIR_PATTERN.test(repoDir)) throw new TypeError(`Repository directory "${repoDir}" must match ${REPO_DIR_PATTERN}.`);
2235
+ if (!continueOnFailure) return `cd "${repoDir}" && ${command}`;
2236
+ return `( cd "${repoDir}" && sh -c ${shellQuote$1(command)} ) || { mkdir -p "${SETUP_MARKER_DIR}" && grep -qxF -- '${repoDir}' "${SETUP_FAILED_MARKER_PATH}" 2>/dev/null || printf '%s\\n' '${repoDir}' >> "${SETUP_FAILED_MARKER_PATH}"; }`;
2237
+ }
2238
+ function shellQuote$1(value) {
2239
+ return `'${value.replace(/'/g, `'\\''`)}'`;
2186
2240
  }
2187
2241
  //#endregion
2188
2242
  //#region src/template/repo-template.ts
@@ -2198,7 +2252,11 @@ function setupMarkerCommand(content) {
2198
2252
  * the template identity. A moved branch therefore yields a fresh image on the
2199
2253
  * next new sandbox, and an unmoved one reuses the cached image. When the head
2200
2254
  * cannot be resolved the resolver rejects: an unpinned clone cached under a
2201
- * stable tag would otherwise serve stale repository state forever.
2255
+ * stable tag would otherwise serve stale repository state forever. This holds
2256
+ * for every entry of the `repos` form too, where the platform and E2B
2257
+ * templates clone such a repository unpinned instead: those use a shallow
2258
+ * clone whose tip the session fetches past, while this template clones full
2259
+ * history and pins by `checkout`, so there is no cheap unpinned mode here.
2202
2260
  *
2203
2261
  * The clone runs in a throwaway build stage; the credential is passed by value
2204
2262
  * to that stage only and never enters the template identity or the image.
@@ -2214,38 +2272,67 @@ function setupMarkerCommand(content) {
2214
2272
  * ```
2215
2273
  */
2216
2274
  const execFileAsync = (0, util.promisify)(child_process.execFile);
2217
- /** Env var the build's clone reads the credential from (see `cloneFull`). */
2275
+ /** Env var the build's clone reads the credential from (see `cloneAndPin`). */
2218
2276
  const BUILD_TOKEN_ENV = "GH_TOKEN";
2219
2277
  const DEFAULT_BASE_IMAGE = "node:22-slim";
2220
2278
  const DEFAULT_WORKING_DIRECTORY = "/workspace";
2221
2279
  const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/i;
2222
- const SHA_PATTERN = /^[0-9a-f]{40}$/i;
2223
2280
  const CLONE_URL_ALLOWED_CHARS = /^[a-z0-9:/._-]+$/i;
2224
2281
  const CLONE_URL_HOST_PATTERN = /^[a-z0-9.-]+$/i;
2225
2282
  const CLONE_URL_SEGMENT_PATTERN = /^[\w.-]+$/;
2226
2283
  /** Refs interpolate into shell too; git ref names are already restricted, so allowlist tightly. */
2227
2284
  const REF_PATTERN = /^[\w./-]+$/;
2228
2285
  function createDockerRepoTemplate(options) {
2229
- if (!options.getRepositoryAccess) return void 0;
2230
- if (options.ref !== void 0 && !REF_PATTERN.test(options.ref)) throw new Error(`Invalid ref '${options.ref}': expected a git ref name`);
2286
+ if (options.getRepositoryAccess && options.repos) throw new TypeError("createDockerRepoTemplate: pass either getRepositoryAccess or repos, not both");
2287
+ if (!options.repos && (options.workspaceSetupCommand !== void 0 || options.continueOnSetupFailure !== void 0)) throw new TypeError("createDockerRepoTemplate: workspaceSetupCommand and continueOnSetupFailure require repos");
2288
+ if (!options.getRepositoryAccess && !options.repos?.length) return void 0;
2289
+ const entries = options.repos ?? [{
2290
+ getRepositoryAccess: options.getRepositoryAccess,
2291
+ ref: options.ref,
2292
+ setupCommand: options.setupCommand
2293
+ }];
2294
+ for (const entry of entries) {
2295
+ if (entry.ref !== void 0 && !REF_PATTERN.test(entry.ref)) throw new Error(`Invalid ref '${entry.ref}': expected a git ref name`);
2296
+ assertSingleLineCommands(entry.setupCommand, "setupCommand");
2297
+ }
2298
+ assertSingleLineCommands(options.workspaceSetupCommand, "workspaceSetupCommand");
2231
2299
  const workingDirectory = trimTrailingSlashes(options.workingDirectory ?? DEFAULT_WORKING_DIRECTORY);
2232
2300
  if (!workingDirectory.startsWith("/")) throw new Error(`workingDirectory must be an absolute path, got '${options.workingDirectory}'`);
2233
- return (resolveOptions) => resolveRepoTemplate(options, workingDirectory, resolveOptions);
2301
+ return (resolveOptions) => resolveRepoTemplate(options, entries, workingDirectory, resolveOptions);
2234
2302
  }
2235
- async function resolveRepoTemplate(options, workingDirectory, resolveOptions = {}) {
2303
+ async function resolveRepoTemplate(options, entries, workingDirectory, resolveOptions = {}) {
2236
2304
  const { abortSignal } = resolveOptions;
2305
+ const isList = options.repos !== void 0;
2237
2306
  throwIfAborted(abortSignal, "resolve Docker repository template");
2238
- let access;
2239
- try {
2240
- access = await options.getRepositoryAccess(resolveOptions);
2241
- } catch (error) {
2242
- throw normalizeAbortError(error, "resolve Docker repository template");
2307
+ const repos = [];
2308
+ const seenDirs = /* @__PURE__ */ new Map();
2309
+ for (const [index, entry] of entries.entries()) {
2310
+ let access;
2311
+ try {
2312
+ access = await entry.getRepositoryAccess(resolveOptions);
2313
+ } catch (error) {
2314
+ throw normalizeAbortError(error, "resolve Docker repository template");
2315
+ }
2316
+ throwIfAborted(abortSignal, "resolve Docker repository template");
2317
+ const cloneUrl = access?.cloneUrl;
2318
+ if (!cloneUrl) throw new Error(`Repo template has no clone URL: repository access returned none${isList ? ` (repos[${index}])` : ""}.`);
2319
+ assertCloneUrl(cloneUrl);
2320
+ const repoDir = repoDirName(cloneUrl);
2321
+ const previous = seenDirs.get(repoDir);
2322
+ if (previous) throw new Error(`createDockerRepoTemplate: repositories ${previous} and ${cloneUrl} would both clone into "${repoDir}"`);
2323
+ seenDirs.set(repoDir, cloneUrl);
2324
+ const token = access?.authorization?.token;
2325
+ const sha = await resolveHead(cloneUrl, entry.ref, token, abortSignal);
2326
+ throwIfAborted(abortSignal, "resolve Docker repository template");
2327
+ if (!sha) throw new Error(`Could not resolve ${entry.ref ?? "HEAD"} of ${cloneUrl} with git ls-remote; check the ref, the credential and network access`);
2328
+ repos.push({
2329
+ cloneUrl,
2330
+ sha,
2331
+ token,
2332
+ tokenEnv: token ? isList ? `${BUILD_TOKEN_ENV}_${index}` : BUILD_TOKEN_ENV : void 0,
2333
+ setupCommand: entry.setupCommand
2334
+ });
2243
2335
  }
2244
- throwIfAborted(abortSignal, "resolve Docker repository template");
2245
- const cloneUrl = access?.cloneUrl;
2246
- if (!cloneUrl) throw new Error("Repo template has no clone URL: repository access returned none.");
2247
- assertCloneUrl(cloneUrl);
2248
- const token = access?.authorization?.token;
2249
2336
  let buildEnv;
2250
2337
  try {
2251
2338
  buildEnv = typeof options.buildEnv === "function" ? await options.buildEnv(resolveOptions) : options.buildEnv;
@@ -2253,18 +2340,28 @@ async function resolveRepoTemplate(options, workingDirectory, resolveOptions = {
2253
2340
  throw normalizeAbortError(error, "resolve Docker repository template");
2254
2341
  }
2255
2342
  throwIfAborted(abortSignal, "resolve Docker repository template");
2256
- const sha = await resolveHead(cloneUrl, options.ref, token, abortSignal);
2257
- throwIfAborted(abortSignal, "resolve Docker repository template");
2258
- 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`);
2259
- return buildRepoTemplate({
2260
- cloneUrl,
2261
- sha,
2262
- token,
2343
+ const shared = {
2263
2344
  buildEnv,
2264
- setupCommand: options.setupCommand,
2265
2345
  workingDirectory,
2266
2346
  baseImage: options.baseImage,
2347
+ owner: options.owner,
2267
2348
  dockerOptions: options.dockerOptions
2349
+ };
2350
+ if (!isList) {
2351
+ const [repo] = repos;
2352
+ return buildRepoTemplate({
2353
+ ...shared,
2354
+ cloneUrl: repo.cloneUrl,
2355
+ sha: repo.sha,
2356
+ token: repo.token,
2357
+ setupCommand: repo.setupCommand
2358
+ });
2359
+ }
2360
+ return buildMultiRepoTemplate({
2361
+ ...shared,
2362
+ repos,
2363
+ workspaceSetupCommand: options.workspaceSetupCommand,
2364
+ continueOnSetupFailure: options.continueOnSetupFailure ?? false
2268
2365
  });
2269
2366
  }
2270
2367
  /**
@@ -2283,14 +2380,15 @@ function buildRepoTemplate(inputs) {
2283
2380
  if (inputs.baseImage === void 0) template = template.aptInstall(["git", "ca-certificates"]);
2284
2381
  if (buildEnv && Object.keys(buildEnv).length > 0) template = template.setEnvs(buildEnv);
2285
2382
  const tokenEnv = token ? BUILD_TOKEN_ENV : void 0;
2286
- const clone = [cloneFull({
2383
+ template = template.runWithSecrets(cloneAndPin({
2287
2384
  cloneUrl,
2288
2385
  destination,
2289
- tokenEnv
2290
- }), `git -C ${shellQuote(destination)} checkout --detach ${shellQuote(sha)}`];
2291
- template = template.runWithSecrets(clone, {
2386
+ tokenEnv,
2387
+ sha
2388
+ }), {
2292
2389
  secrets: tokenEnv ? [tokenEnv] : [],
2293
- output: destination
2390
+ output: destination,
2391
+ ...inputs.owner !== void 0 ? { owner: inputs.owner } : {}
2294
2392
  }).setWorkdir(destination);
2295
2393
  const setupCommands = normalizeSetupCommands(inputs.setupCommand);
2296
2394
  for (const command of setupCommands) template = template.runCmd(command);
@@ -2298,6 +2396,60 @@ function buildRepoTemplate(inputs) {
2298
2396
  return template;
2299
2397
  }
2300
2398
  /**
2399
+ * List-form assembly: the working directory is the cwd, every repository is
2400
+ * laid out under it (public first) around its own pin, then the workspace
2401
+ * steps and marker. Exported for tests.
2402
+ * @internal
2403
+ */
2404
+ function buildMultiRepoTemplate(inputs) {
2405
+ const { repos, buildEnv, continueOnSetupFailure } = inputs;
2406
+ const workingDirectory = trimTrailingSlashes(inputs.workingDirectory);
2407
+ const secretValues = {};
2408
+ for (const repo of repos) if (repo.tokenEnv && repo.token) secretValues[repo.tokenEnv] = repo.token;
2409
+ let template = new DockerTemplate({
2410
+ baseImage: inputs.baseImage ?? DEFAULT_BASE_IMAGE,
2411
+ dockerOptions: inputs.dockerOptions,
2412
+ ...Object.keys(secretValues).length > 0 ? { secrets: secretValues } : {}
2413
+ });
2414
+ if (inputs.baseImage === void 0) template = template.aptInstall(["git", "ca-certificates"]);
2415
+ if (buildEnv && Object.keys(buildEnv).length > 0) template = template.setEnvs(buildEnv);
2416
+ template = template.setWorkdir(workingDirectory);
2417
+ const ordered = [...repos.filter((repo) => !repo.token), ...repos.filter((repo) => repo.token)];
2418
+ for (const repo of ordered) {
2419
+ const repoDir = repoDirName(repo.cloneUrl);
2420
+ const destination = `${workingDirectory}/${repoDir}`;
2421
+ const secrets = repo.tokenEnv ? [repo.tokenEnv] : [];
2422
+ const setupCommands = normalizeSetupCommands(repo.setupCommand);
2423
+ template = template.runWithSecrets(cloneAndPin({
2424
+ cloneUrl: repo.cloneUrl,
2425
+ destination,
2426
+ tokenEnv: repo.tokenEnv,
2427
+ sha: repo.sha
2428
+ }), {
2429
+ secrets,
2430
+ output: destination,
2431
+ ...inputs.owner !== void 0 ? { owner: inputs.owner } : {}
2432
+ });
2433
+ for (const command of setupCommands) template = template.runCmd(guardedSetupCommand({
2434
+ repoDir,
2435
+ command,
2436
+ continueOnFailure: continueOnSetupFailure
2437
+ }));
2438
+ template = template.runCmd(setupMarkerCommand(setupMarkerContent(setupCommands), repoSetupMarkerPath(repoDir)));
2439
+ }
2440
+ const workspaceCommands = normalizeSetupCommands(inputs.workspaceSetupCommand);
2441
+ for (const command of workspaceCommands) template = template.runCmd(command);
2442
+ template = template.runCmd(setupMarkerCommand(setupMarkerContent(workspaceCommands), WORKSPACE_SETUP_MARKER_PATH));
2443
+ return template;
2444
+ }
2445
+ /**
2446
+ * A Dockerfile `RUN` only spans lines through `\\` continuation, which the
2447
+ * parser folds away; a bare newline would start a new instruction.
2448
+ */
2449
+ function assertSingleLineCommands(commands, option) {
2450
+ for (const command of normalizeSetupCommands(commands)) if (/(?<!\\)\n|\r/.test(command)) throw new Error(`createDockerRepoTemplate: ${option} entries cannot contain a bare newline; end the line with \\ or pass separate commands`);
2451
+ }
2452
+ /**
2301
2453
  * Resolve `ref` (or the default branch) to a commit sha with `git ls-remote`
2302
2454
  * on the host, without cloning. A full sha is returned as is. Any failure
2303
2455
  * yields undefined; the caller decides how to surface it.
@@ -2328,14 +2480,14 @@ async function resolveHead(cloneUrl, ref, token, abortSignal) {
2328
2480
  });
2329
2481
  const lines = stdout.split("\n").filter(Boolean).map((line) => line.split(" "));
2330
2482
  const sha = (lines.find(([, name]) => name?.endsWith("^{}")) ?? lines[0])?.[0]?.trim();
2331
- return sha && SHA_PATTERN.test(sha) ? sha.toLowerCase() : void 0;
2483
+ return sha && FULL_SHA_PATTERN.test(sha) ? sha.toLowerCase() : void 0;
2332
2484
  } catch (error) {
2333
2485
  if (abortSignal?.aborted) throw normalizeAbortError(error, "resolve Docker repository template");
2334
2486
  return;
2335
2487
  }
2336
2488
  }
2337
- function cloneFull({ cloneUrl, destination, tokenEnv }) {
2338
- return `git ${tokenEnv ? `-c http.extraheader="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$${tokenEnv}" | base64 -w0)" ` : ""}clone ${shellQuote(cloneUrl)} ${shellQuote(destination)}`;
2489
+ function cloneAndPin({ cloneUrl, destination, tokenEnv, sha }) {
2490
+ return [`git ${tokenEnv ? `-c http.extraheader="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$${tokenEnv}" | base64 -w0)" ` : ""}clone ${shellQuote(cloneUrl)} ${shellQuote(destination)}`, `git -C ${shellQuote(destination)} checkout --detach ${shellQuote(sha)}`];
2339
2491
  }
2340
2492
  function assertCloneUrl(cloneUrl) {
2341
2493
  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`);