From b275a91ba883e8659eb4d60e8ef78d7c9f0b7899 Mon Sep 17 00:00:00 2001 From: Max Shugar Date: Mon, 29 Jun 2026 21:17:38 +0100 Subject: [PATCH 01/16] feat: add Apple container workspace sandbox --- .changeset/apple-container-sandbox.md | 5 + .../src/content/en/docs/workspace/sandbox.mdx | 2 + docs/src/content/en/reference/sidebars.js | 1 + .../workspace/apple-container-sandbox.mdx | 387 ++++++++++++ pnpm-lock.yaml | 36 ++ workspaces/apple-container/CHANGELOG.md | 1 + workspaces/apple-container/README.md | 81 +++ workspaces/apple-container/eslint.config.js | 6 + .../apple-container/lint-staged.config.js | 5 + workspaces/apple-container/package.json | 64 ++ workspaces/apple-container/src/index.ts | 8 + workspaces/apple-container/src/provider.ts | 130 ++++ .../src/sandbox/index.integration.test.ts | 33 + .../apple-container/src/sandbox/index.test.ts | 307 +++++++++ .../apple-container/src/sandbox/index.ts | 595 ++++++++++++++++++ .../apple-container/test/core-workspace.ts | 10 + .../apple-container/tsconfig.build.json | 9 + workspaces/apple-container/tsconfig.json | 5 + workspaces/apple-container/tsup.config.ts | 18 + workspaces/apple-container/vitest.config.ts | 21 + 20 files changed, 1724 insertions(+) create mode 100644 .changeset/apple-container-sandbox.md create mode 100644 docs/src/content/en/reference/workspace/apple-container-sandbox.mdx create mode 100644 workspaces/apple-container/CHANGELOG.md create mode 100644 workspaces/apple-container/README.md create mode 100644 workspaces/apple-container/eslint.config.js create mode 100644 workspaces/apple-container/lint-staged.config.js create mode 100644 workspaces/apple-container/package.json create mode 100644 workspaces/apple-container/src/index.ts create mode 100644 workspaces/apple-container/src/provider.ts create mode 100644 workspaces/apple-container/src/sandbox/index.integration.test.ts create mode 100644 workspaces/apple-container/src/sandbox/index.test.ts create mode 100644 workspaces/apple-container/src/sandbox/index.ts create mode 100644 workspaces/apple-container/test/core-workspace.ts create mode 100644 workspaces/apple-container/tsconfig.build.json create mode 100644 workspaces/apple-container/tsconfig.json create mode 100644 workspaces/apple-container/tsup.config.ts create mode 100644 workspaces/apple-container/vitest.config.ts diff --git a/.changeset/apple-container-sandbox.md b/.changeset/apple-container-sandbox.md new file mode 100644 index 00000000000..b9b01b9c49c --- /dev/null +++ b/.changeset/apple-container-sandbox.md @@ -0,0 +1,5 @@ +--- +'@mastra/apple-container': minor +--- + +Add an Apple container CLI workspace sandbox provider. diff --git a/docs/src/content/en/docs/workspace/sandbox.mdx b/docs/src/content/en/docs/workspace/sandbox.mdx index 649b7e14299..b1dfb370ce6 100644 --- a/docs/src/content/en/docs/workspace/sandbox.mdx +++ b/docs/src/content/en/docs/workspace/sandbox.mdx @@ -24,6 +24,7 @@ A sandbox provider executes commands in a controlled environment: - [`LocalSandbox`](/reference/workspace/local-sandbox): Executes commands on the local machine - [`AgentCoreRuntimeSandbox`](/reference/workspace/agentcore-runtime-sandbox): Executes commands in AWS Bedrock AgentCore Runtime sessions +- [`AppleContainerSandbox`](/reference/workspace/apple-container-sandbox): Executes commands in local OCI Linux containers using Apple's `container` CLI - [`BlaxelSandbox`](/reference/workspace/blaxel-sandbox): Executes commands in isolated Blaxel cloud sandboxes - [`DaytonaSandbox`](/reference/workspace/daytona-sandbox): Executes commands in isolated Daytona cloud sandboxes - [`E2BSandbox`](/reference/workspace/e2b-sandbox): Executes commands in isolated E2B cloud sandboxes @@ -239,6 +240,7 @@ For the full `SandboxProcessManager` API (spawning processes programmatically, r - [`SandboxProcessManager` reference](/reference/workspace/process-manager) - [`AgentCoreRuntimeSandbox` reference](/reference/workspace/agentcore-runtime-sandbox) +- [`AppleContainerSandbox` reference](/reference/workspace/apple-container-sandbox) - [`DaytonaSandbox` reference](/reference/workspace/daytona-sandbox) - [`E2BSandbox` reference](/reference/workspace/e2b-sandbox) - [`LocalSandbox` reference](/reference/workspace/local-sandbox) diff --git a/docs/src/content/en/reference/sidebars.js b/docs/src/content/en/reference/sidebars.js index 67806a9ae40..b912db2b0f4 100644 --- a/docs/src/content/en/reference/sidebars.js +++ b/docs/src/content/en/reference/sidebars.js @@ -773,6 +773,7 @@ const sidebars = { label: 'AgentCoreRuntimeSandbox', }, { type: 'doc', id: 'workspace/agentfs-filesystem', label: 'AgentFSFilesystem' }, + { type: 'doc', id: 'workspace/apple-container-sandbox', label: 'AppleContainerSandbox' }, { type: 'doc', id: 'workspace/archil-filesystem', label: 'ArchilFilesystem' }, { type: 'doc', id: 'workspace/azure-blob-filesystem', label: 'AzureBlobFilesystem' }, { type: 'doc', id: 'workspace/blaxel-sandbox', label: 'BlaxelSandbox' }, diff --git a/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx b/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx new file mode 100644 index 00000000000..3b3f55c02d6 --- /dev/null +++ b/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx @@ -0,0 +1,387 @@ +--- +title: "Reference: AppleContainerSandbox | Workspace" +description: "API reference for AppleContainerSandbox, a local Apple container CLI sandbox for workspace command execution." +packages: + - "@mastra/apple-container" +--- + +# AppleContainerSandbox + +Executes commands inside local OCI Linux containers through Apple's [`container`](https://github.com/apple/container) CLI. The provider starts a long-lived container and uses `container exec` for workspace commands. + +:::info + +For interface details, see [WorkspaceSandbox interface](/reference/workspace/sandbox). + +::: + +## Installation + +```bash npm2yarn +npm install @mastra/apple-container +``` + +Requires a host that can run Apple's `container` CLI. Start the container system before using the provider: + +```bash +container system start +``` + +## Usage + +Add an `AppleContainerSandbox` to a workspace and assign it to an agent: + +```typescript +import { Agent } from '@mastra/core/agent' +import { Workspace } from '@mastra/core/workspace' +import { AppleContainerSandbox } from '@mastra/apple-container' + +const workspace = new Workspace({ + sandbox: new AppleContainerSandbox({ + image: 'node:22-slim', + volumes: { + '/Users/me/project': '/workspace', + }, + workingDir: '/workspace', + }), +}) + +const agent = new Agent({ + id: 'dev-agent', + name: 'Dev Agent', + instructions: 'You are a coding assistant working in this workspace.', + model: '__GATEWAY_ANTHROPIC_MODEL_SONNET__', + workspace, +}) + +const response = await agent.generate('Run `node --version`.') +console.log(response.text) +``` + +## Constructor parameters + +', + description: 'Environment variables to set in the container and on command execs.', + isOptional: true, + }, + { + name: 'volumes', + type: 'Record', + description: 'Host-to-container bind mounts. Keys are host paths, values are container paths.', + isOptional: true, + }, + { + name: 'mounts', + type: 'string[]', + description: 'Raw `container run --mount` specs.', + isOptional: true, + }, + { + name: 'network', + type: 'string', + description: 'Apple container network attachment spec.', + isOptional: true, + }, + { + name: 'publishedPorts', + type: 'string[]', + description: 'Port publish specs passed as `--publish`.', + isOptional: true, + }, + { + name: 'publishedSockets', + type: 'string[]', + description: 'Socket publish specs passed as `--publish-socket`.', + isOptional: true, + }, + { + name: 'cpus', + type: 'number | string', + description: 'Number of CPUs to allocate.', + isOptional: true, + }, + { + name: 'memory', + type: 'string', + description: "Memory allocation, for example '1G'.", + isOptional: true, + }, + { + name: 'platform', + type: 'string', + description: "OCI platform, for example 'linux/arm64'.", + isOptional: true, + }, + { + name: 'arch', + type: 'string', + description: 'Image architecture when selecting a multi-arch image.', + isOptional: true, + }, + { + name: 'os', + type: 'string', + description: 'Image operating system when selecting a multi-platform image.', + isOptional: true, + }, + { + name: 'rosetta', + type: 'boolean', + description: 'Enable Rosetta in the container.', + isOptional: true, + defaultValue: 'false', + }, + { + name: 'readOnlyRootfs', + type: 'boolean', + description: 'Mount the container root filesystem as read-only.', + isOptional: true, + defaultValue: 'false', + }, + { + name: 'ssh', + type: 'boolean', + description: 'Forward the host SSH agent socket.', + isOptional: true, + defaultValue: 'false', + }, + { + name: 'init', + type: 'boolean', + description: "Enable Apple's init process in the container.", + isOptional: true, + defaultValue: 'true', + }, + { + name: 'virtualization', + type: 'boolean', + description: 'Expose virtualization capabilities to the container.', + isOptional: true, + defaultValue: 'false', + }, + { + name: 'capAdd', + type: 'string[]', + description: 'Linux capabilities to add.', + isOptional: true, + }, + { + name: 'capDrop', + type: 'string[]', + description: 'Linux capabilities to drop.', + isOptional: true, + }, + { + name: 'tmpfs', + type: 'string[]', + description: 'tmpfs mount specs passed as `--tmpfs`.', + isOptional: true, + }, + { + name: 'dns', + type: 'string[]', + description: 'DNS nameserver IPs.', + isOptional: true, + }, + { + name: 'dnsSearch', + type: 'string[]', + description: 'DNS search domains.', + isOptional: true, + }, + { + name: 'noDns', + type: 'boolean', + description: 'Do not configure DNS in the container.', + isOptional: true, + defaultValue: 'false', + }, + { + name: 'labels', + type: 'Record', + description: 'Additional container labels. Mastra labels (mastra.sandbox, mastra.sandbox.id) are always included.', + isOptional: true, + }, + { + name: 'workingDir', + type: 'string', + description: 'Working directory inside the container.', + isOptional: true, + defaultValue: "'/workspace'", + }, + { + name: 'timeout', + type: 'number', + description: 'Default command timeout in milliseconds.', + isOptional: true, + defaultValue: '300000 (5 minutes)', + }, + { + name: 'deleteOnDestroy', + type: 'boolean', + description: 'Delete the Apple container when the sandbox is destroyed. When false, destroy stops the container instead.', + isOptional: true, + defaultValue: 'true', + }, + { + name: 'containerBinary', + type: 'string', + description: 'Path or name for the Apple container CLI.', + isOptional: true, + defaultValue: "'container'", + }, + { + name: 'instructions', + type: 'string | function', + description: + 'Custom instructions that override the default instructions returned by getInstructions(). Pass an empty string to suppress instructions.', + isOptional: true, + }, +]} +/> + +## Properties + + + +## Environment variables + +Set environment variables at the container level with `env`. Per-command environment variables can also be passed through `executeCommand` options: + +```typescript +const sandbox = new AppleContainerSandbox({ + image: 'node:22-slim', + env: { + NODE_ENV: 'development', + }, +}) + +await sandbox.executeCommand('node', ['-e', 'console.log(process.env.TASK_ID)'], { + env: { TASK_ID: '42' }, +}) +``` + +## Bind mounts + +Mount host directories into the container using the `volumes` option: + +```typescript +const sandbox = new AppleContainerSandbox({ + image: 'node:22-slim', + volumes: { + '/Users/me/project': '/workspace/project', + '/Users/me/.npm': '/root/.npm', + }, +}) +``` + +Bind mounts are applied at container creation time. The host paths must exist before the sandbox starts. + +## Resource and platform options + +Apple container CLI options can be passed through the constructor: + +```typescript +const sandbox = new AppleContainerSandbox({ + image: 'node:22-slim', + cpus: 2, + memory: '2G', + platform: 'linux/arm64', + readOnlyRootfs: true, + tmpfs: ['/tmp:rw,size=256m'], +}) +``` + +These options are only applied when a new container is created. If the sandbox reconnects to an existing container with the same name, destroy and recreate the sandbox to apply changed runtime options. + +## Reconnection + +`AppleContainerSandbox` reconnects by inspecting a container with the configured name. When `start()` is called: + +- A running container is reused. +- A stopped container is restarted. +- A missing container is created from the configured image. + +```typescript +const sandbox = new AppleContainerSandbox({ id: 'persistent-sandbox' }) +await sandbox._start() + +const sandbox2 = new AppleContainerSandbox({ id: 'persistent-sandbox' }) +await sandbox2._start() +``` + +## Editor provider + +Register the provider with `MastraEditor` to hydrate stored sandbox configs: + +```typescript +import { MastraEditor } from '@mastra/core/editor' +import { appleContainerSandboxProvider } from '@mastra/apple-container' + +const editor = new MastraEditor({ + sandboxes: { + [appleContainerSandboxProvider.id]: appleContainerSandboxProvider, + }, +}) +``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e638db32825..557939319f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8478,6 +8478,42 @@ importers: specifier: 'catalog:' version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(msw@2.14.6(@types/node@22.19.15)(typescript@6.0.3))(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) + workspaces/apple-container: + devDependencies: + '@internal/lint': + specifier: workspace:* + version: link:../../packages/_config + '@internal/types-builder': + specifier: workspace:* + version: link:../../packages/_types-builder + '@mastra/core': + specifier: workspace:* + version: link:../../packages/core + '@types/node': + specifier: 22.19.15 + version: 22.19.15 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.8(vitest@4.1.8) + '@vitest/ui': + specifier: 'catalog:' + version: 4.1.8(vitest@4.1.8) + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + eslint: + specifier: ^10.4.1 + version: 10.5.0(jiti@2.7.0) + tsup: + specifier: ^8.5.1 + version: 8.5.1(patch_hash=78092cc873f6c3c61ff4846b4b326faa7db54bb3c1506c510614d708601e2b7f)(@microsoft/api-extractor@7.58.9(@types/node@22.19.15))(@swc/core@1.15.7(@swc/helpers@0.5.17))(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(msw@2.14.6(@types/node@22.19.15)(typescript@6.0.3))(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) + workspaces/archil: dependencies: disk: diff --git a/workspaces/apple-container/CHANGELOG.md b/workspaces/apple-container/CHANGELOG.md new file mode 100644 index 00000000000..dc1635e8051 --- /dev/null +++ b/workspaces/apple-container/CHANGELOG.md @@ -0,0 +1 @@ +# @mastra/apple-container diff --git a/workspaces/apple-container/README.md b/workspaces/apple-container/README.md new file mode 100644 index 00000000000..86d61246673 --- /dev/null +++ b/workspaces/apple-container/README.md @@ -0,0 +1,81 @@ +# @mastra/apple-container + +Apple container CLI sandbox provider for [Mastra](https://mastra.ai) workspaces. + +Implements the `WorkspaceSandbox` interface with Apple's [`container`](https://github.com/apple/container) CLI. The provider starts a long-lived OCI Linux container and runs workspace commands through `container exec`. + +## Install + +```bash +pnpm add @mastra/apple-container @mastra/core +``` + +Requires Apple silicon, macOS 26 or newer, and the Apple `container` CLI. Start Apple's container system before using the provider: + +```bash +container system start +``` + +## Usage + +```typescript +import { Workspace } from '@mastra/core/workspace'; +import { AppleContainerSandbox } from '@mastra/apple-container'; + +const sandbox = new AppleContainerSandbox({ + image: 'node:22-slim', + volumes: { + '/Users/me/project': '/workspace', + }, + workingDir: '/workspace', +}); + +const workspace = new Workspace({ sandbox }); +await workspace.init(); + +const result = await workspace.sandbox?.executeCommand?.('node', ['--version']); +console.log(result?.stdout); + +await workspace.destroy(); +``` + +## Options + +| Option | Type | Description | +| ----------------- | ---------------------------- | ------------------------------------------------------------------ | +| `id` | `string` | Unique sandbox ID. | +| `name` | `string` | Apple container name. Defaults to the sandbox ID. | +| `image` | `string` | OCI image to run. Defaults to `node:22-slim`. | +| `command` | `string[]` | Container init command. Defaults to `['sleep', 'infinity']`. | +| `env` | `Record` | Environment variables applied to the container and command execs. | +| `volumes` | `Record` | Host-to-container bind mounts. | +| `mounts` | `string[]` | Raw `--mount` specs passed to `container run`. | +| `network` | `string` | Apple container network attachment spec. | +| `publishedPorts` | `string[]` | Port publish specs passed as `--publish`. | +| `cpus` | `number \| string` | Number of CPUs allocated to the container. | +| `memory` | `string` | Memory allocation, for example `1G`. | +| `platform` | `string` | OCI platform, for example `linux/arm64`. | +| `arch` | `string` | Image architecture when selecting multi-arch images. | +| `rosetta` | `boolean` | Enable Rosetta in the container. | +| `readOnlyRootfs` | `boolean` | Start the container with a read-only root filesystem. | +| `ssh` | `boolean` | Forward the host SSH agent socket. | +| `timeout` | `number` | Default command timeout in milliseconds. | +| `deleteOnDestroy` | `boolean` | Delete the Apple container on destroy. Defaults to `true`. | +| `containerBinary` | `string` | Path or name for the Apple container CLI. Defaults to `container`. | +| `instructions` | `string \| (opts) => string` | Override or extend the default workspace sandbox instructions. | + +## Editor provider + +Register the provider with `MastraEditor` to hydrate stored sandbox configs: + +```typescript +import { appleContainerSandboxProvider } from '@mastra/apple-container'; + +const editor = new MastraEditor({ + sandboxes: { [appleContainerSandboxProvider.id]: appleContainerSandboxProvider }, +}); +``` + +## License + +Apache-2.0 diff --git a/workspaces/apple-container/eslint.config.js b/workspaces/apple-container/eslint.config.js new file mode 100644 index 00000000000..10b24d4a5ae --- /dev/null +++ b/workspaces/apple-container/eslint.config.js @@ -0,0 +1,6 @@ +import { createConfig } from '@internal/lint/eslint'; + +const config = await createConfig(); + +/** @type {import("eslint").Linter.Config[]} */ +export default config; diff --git a/workspaces/apple-container/lint-staged.config.js b/workspaces/apple-container/lint-staged.config.js new file mode 100644 index 00000000000..0b44008827b --- /dev/null +++ b/workspaces/apple-container/lint-staged.config.js @@ -0,0 +1,5 @@ +export default { + '*.{ts,tsx}': ['eslint --fix --max-warnings=0', 'prettier --write'], + '*.{js,jsx}': ['prettier --write'], + '*.{json,md,yml,yaml}': ['prettier --write'], +}; diff --git a/workspaces/apple-container/package.json b/workspaces/apple-container/package.json new file mode 100644 index 00000000000..94e4a7a2fb1 --- /dev/null +++ b/workspaces/apple-container/package.json @@ -0,0 +1,64 @@ +{ + "name": "@mastra/apple-container", + "version": "0.1.0", + "description": "Apple container CLI sandbox provider for Mastra workspaces", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsup --silent --config tsup.config.ts", + "build:lib": "pnpm build", + "build:watch": "pnpm build --watch", + "test:unit": "vitest run --exclude '**/*.integration.test.ts'", + "test:watch": "vitest watch", + "test": "vitest run ./src/**/*.integration.test.ts", + "test:cloud": "pnpm test", + "lint": "eslint ." + }, + "license": "Apache-2.0", + "devDependencies": { + "@internal/lint": "workspace:*", + "@internal/types-builder": "workspace:*", + "@mastra/core": "workspace:*", + "@types/node": "22.19.21", + "@vitest/coverage-v8": "catalog:", + "@vitest/ui": "catalog:", + "dotenv": "^17.4.2", + "eslint": "^10.4.1", + "tsup": "^8.5.1", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "@mastra/core": ">=1.12.0-0 <2.0.0-0" + }, + "files": [ + "dist", + "CHANGELOG.md" + ], + "homepage": "https://mastra.ai", + "repository": { + "type": "git", + "url": "git+https://github.com/mastra-ai/mastra.git", + "directory": "workspaces/apple-container" + }, + "bugs": { + "url": "https://github.com/mastra-ai/mastra/issues" + }, + "engines": { + "node": ">=22.13.0" + } +} diff --git a/workspaces/apple-container/src/index.ts b/workspaces/apple-container/src/index.ts new file mode 100644 index 00000000000..a580f989b05 --- /dev/null +++ b/workspaces/apple-container/src/index.ts @@ -0,0 +1,8 @@ +export { AppleContainerSandbox, DefaultAppleContainerCommandRunner } from './sandbox'; +export type { + AppleContainerCliResult, + AppleContainerCommandRunner, + AppleContainerCommandRunnerOptions, + AppleContainerSandboxOptions, +} from './sandbox'; +export { appleContainerSandboxProvider } from './provider'; diff --git a/workspaces/apple-container/src/provider.ts b/workspaces/apple-container/src/provider.ts new file mode 100644 index 00000000000..a1a7b6b7104 --- /dev/null +++ b/workspaces/apple-container/src/provider.ts @@ -0,0 +1,130 @@ +/** + * Apple container sandbox provider descriptor for MastraEditor. + */ + +import type { SandboxProvider } from '@mastra/core/editor'; +import { AppleContainerSandbox } from './sandbox'; +import type { AppleContainerSandboxOptions } from './sandbox'; + +export interface AppleContainerProviderConfig { + image?: string; + name?: string; + command?: string[]; + env?: Record; + volumes?: Record; + mounts?: string[]; + network?: string; + publishedPorts?: string[]; + cpus?: number | string; + memory?: string; + platform?: string; + arch?: string; + rosetta?: boolean; + readOnlyRootfs?: boolean; + ssh?: boolean; + workingDir?: string; + timeout?: number; + deleteOnDestroy?: boolean; + containerBinary?: string; +} + +export const appleContainerSandboxProvider: SandboxProvider = { + id: 'apple-container', + name: 'Apple Container Sandbox', + description: 'Local OCI Linux container sandbox powered by Apple container', + configSchema: { + type: 'object', + properties: { + image: { + type: 'string', + description: 'OCI image to use', + default: 'node:22-slim', + }, + name: { + type: 'string', + description: 'Apple container name. Defaults to the sandbox ID.', + }, + command: { + type: 'array', + description: 'Container init command. Must keep the container alive for exec-based command execution.', + items: { type: 'string' }, + }, + env: { + type: 'object', + description: 'Environment variables', + additionalProperties: { type: 'string' }, + }, + volumes: { + type: 'object', + description: 'Host-to-container bind mounts (host path -> container path)', + additionalProperties: { type: 'string' }, + }, + mounts: { + type: 'array', + description: 'Raw Apple container --mount specs', + items: { type: 'string' }, + }, + network: { + type: 'string', + description: 'Apple container network attachment spec', + }, + publishedPorts: { + type: 'array', + description: 'Port publish specs', + items: { type: 'string' }, + }, + cpus: { + anyOf: [{ type: 'number' }, { type: 'string' }], + description: 'Number of CPUs to allocate', + }, + memory: { + type: 'string', + description: 'Memory allocation, for example 1G', + }, + platform: { + type: 'string', + description: 'OCI platform, for example linux/arm64', + }, + arch: { + type: 'string', + description: 'Image architecture for multi-arch images', + }, + rosetta: { + type: 'boolean', + description: 'Enable Rosetta in the container', + default: false, + }, + readOnlyRootfs: { + type: 'boolean', + description: 'Mount the container root filesystem as read-only', + default: false, + }, + ssh: { + type: 'boolean', + description: 'Forward the host SSH agent socket', + default: false, + }, + workingDir: { + type: 'string', + description: 'Working directory inside the container', + default: '/workspace', + }, + timeout: { + type: 'number', + description: 'Default command timeout in milliseconds', + default: 300_000, + }, + deleteOnDestroy: { + type: 'boolean', + description: 'Delete the Apple container on destroy', + default: true, + }, + containerBinary: { + type: 'string', + description: 'Path or name for the Apple container CLI', + default: 'container', + }, + }, + }, + createSandbox: config => new AppleContainerSandbox(config as AppleContainerSandboxOptions), +}; diff --git a/workspaces/apple-container/src/sandbox/index.integration.test.ts b/workspaces/apple-container/src/sandbox/index.integration.test.ts new file mode 100644 index 00000000000..94fc7577276 --- /dev/null +++ b/workspaces/apple-container/src/sandbox/index.integration.test.ts @@ -0,0 +1,33 @@ +import { spawnSync } from 'node:child_process'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { AppleContainerSandbox } from './index'; + +const shouldRunIntegration = process.env.MASTRA_APPLE_CONTAINER_INTEGRATION === '1'; +const hasAppleContainerCli = + shouldRunIntegration && spawnSync('container', ['--version'], { stdio: 'ignore' }).status === 0; + +describe.skipIf(!hasAppleContainerCli)('AppleContainerSandbox integration', () => { + let sandbox: AppleContainerSandbox | undefined; + + afterEach(async () => { + await sandbox?._destroy(); + sandbox = undefined; + }); + + it('starts an Apple container and executes a command', async () => { + sandbox = new AppleContainerSandbox({ + id: `mastra-apple-container-test-${Date.now()}`, + image: process.env.MASTRA_APPLE_CONTAINER_IMAGE ?? 'alpine:3.20', + command: ['sleep', '3600'], + workingDir: '/', + timeout: 60_000, + }); + + await sandbox._start(); + const result = await sandbox.executeCommand('printf apple-container'); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('apple-container'); + }, 120_000); +}); diff --git a/workspaces/apple-container/src/sandbox/index.test.ts b/workspaces/apple-container/src/sandbox/index.test.ts new file mode 100644 index 00000000000..1df244452f2 --- /dev/null +++ b/workspaces/apple-container/src/sandbox/index.test.ts @@ -0,0 +1,307 @@ +import { SandboxExecutionError } from '@mastra/core/workspace'; +import { describe, expect, it, vi } from 'vitest'; + +import { AppleContainerSandbox, runAppleContainerCli } from './index'; +import type { AppleContainerCliResult, AppleContainerCommandRunner, AppleContainerCommandRunnerOptions } from './index'; + +type RunnerResponse = + | Partial + | ((args: string[], options?: AppleContainerCommandRunnerOptions) => Partial); + +function createRunner( + responses: RunnerResponse[] = [], +): AppleContainerCommandRunner & { run: ReturnType } { + const queue = [...responses]; + + return { + run: vi.fn(async (args: string[], options?: AppleContainerCommandRunnerOptions) => { + const response = queue.shift(); + const resolved = typeof response === 'function' ? response(args, options) : response; + return cliResult(resolved); + }), + }; +} + +function cliResult(overrides: Partial = {}): AppleContainerCliResult { + return { + success: true, + exitCode: 0, + stdout: '', + stderr: '', + executionTimeMs: 1, + ...overrides, + }; +} + +function inspectResult(status: string, id = 'container-123'): Partial { + return { + stdout: JSON.stringify([ + { + status, + configuration: { + id, + resources: { + cpus: 2, + memoryInBytes: 1024 * 1024 * 512, + }, + }, + }, + ]), + }; +} + +describe('AppleContainerSandbox', () => { + it('uses default identity and instructions', () => { + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner: createRunner() }); + + expect(sandbox.id).toBe('apple-test'); + expect(sandbox.name).toBe('AppleContainerSandbox'); + expect(sandbox.provider).toBe('apple-container'); + expect(sandbox.status).toBe('pending'); + expect(sandbox.getInstructions()).toContain('Apple container sandbox'); + }); + + it('creates a long-lived Apple container when none exists', async () => { + const runner = createRunner([ + { success: false, exitCode: 1, stderr: 'container not found' }, + { stdout: 'created\n' }, + ]); + const sandbox = new AppleContainerSandbox({ + id: 'apple-test', + image: 'python:3.12-slim', + command: ['sleep', '9999'], + env: { NODE_ENV: 'test' }, + volumes: { '/host/project': '/workspace' }, + mounts: ['source=/host/cache,target=/cache'], + network: 'bridge', + publishedPorts: ['127.0.0.1:8080:80'], + publishedSockets: ['/tmp/app.sock:/var/run/app.sock'], + cpus: 2, + memory: '1G', + platform: 'linux/arm64', + arch: 'arm64', + os: 'linux', + rosetta: true, + readOnlyRootfs: true, + ssh: true, + init: true, + virtualization: true, + capAdd: ['NET_BIND_SERVICE'], + capDrop: ['MKNOD'], + tmpfs: ['/tmp:rw,size=64m'], + dns: ['1.1.1.1'], + dnsSearch: ['example.test'], + noDns: true, + labels: { app: 'mastra' }, + workingDir: '/workspace', + runner, + }); + + await sandbox._start(); + + expect(runner.run).toHaveBeenNthCalledWith(1, ['inspect', 'apple-test']); + expect(runner.run).toHaveBeenNthCalledWith(2, [ + 'run', + '-d', + '--name', + 'apple-test', + '--workdir', + '/workspace', + '--env', + 'NODE_ENV=test', + '--volume', + '/host/project:/workspace', + '--mount', + 'source=/host/cache,target=/cache', + '--label', + 'app=mastra', + '--label', + 'mastra.sandbox=true', + '--label', + 'mastra.sandbox.id=apple-test', + '--publish', + '127.0.0.1:8080:80', + '--publish-socket', + '/tmp/app.sock:/var/run/app.sock', + '--cap-add', + 'NET_BIND_SERVICE', + '--cap-drop', + 'MKNOD', + '--tmpfs', + '/tmp:rw,size=64m', + '--dns', + '1.1.1.1', + '--dns-search', + 'example.test', + '--network', + 'bridge', + '--cpus', + '2', + '--memory', + '1G', + '--platform', + 'linux/arm64', + '--arch', + 'arm64', + '--os', + 'linux', + '--rosetta', + '--read-only', + '--ssh', + '--init', + '--virtualization', + '--no-dns', + 'python:3.12-slim', + 'sleep', + '9999', + ]); + expect(sandbox.status).toBe('running'); + }); + + it('reconnects to an existing stopped container', async () => { + const runner = createRunner([inspectResult('stopped', 'existing-id'), {}]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await sandbox._start(); + + expect(runner.run).toHaveBeenNthCalledWith(1, ['inspect', 'apple-test']); + expect(runner.run).toHaveBeenNthCalledWith(2, ['start', 'existing-id']); + expect(sandbox.containerId).toBe('existing-id'); + }); + + it('executes commands with env, cwd, timeout, streaming and retained output options', async () => { + const runner = createRunner([ + { success: false, exitCode: 1, stderr: 'container not found' }, + {}, + { stdout: 'hello\n' }, + ]); + const onStdout = vi.fn(); + const sandbox = new AppleContainerSandbox({ + id: 'apple-test', + env: { BASE: '1' }, + runner, + }); + + await sandbox._start(); + runner.run.mockClear(); + + const result = await sandbox.executeCommand('node', ['-e', 'console.log("hello")'], { + cwd: '/app', + env: { EXTRA: '2' }, + timeout: 1234, + onStdout, + maxRetainedBytes: 16, + }); + + expect(result).toMatchObject({ + success: true, + exitCode: 0, + stdout: 'hello\n', + command: 'node -e \'console.log("hello")\'', + args: ['-e', 'console.log("hello")'], + }); + expect(runner.run).toHaveBeenCalledWith( + [ + 'exec', + '--env', + 'BASE=1', + '--env', + 'EXTRA=2', + '--workdir', + '/app', + 'apple-test', + 'sh', + '-lc', + 'node -e \'console.log("hello")\'', + ], + expect.objectContaining({ + timeout: 1234, + onStdout, + maxRetainedBytes: 16, + }), + ); + }); + + it('stops instead of deletes when deleteOnDestroy is disabled', async () => { + const runner = createRunner([inspectResult('running'), {}]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', deleteOnDestroy: false, runner }); + + await sandbox.destroy(); + + expect(runner.run).toHaveBeenNthCalledWith(1, ['inspect', 'apple-test']); + expect(runner.run).toHaveBeenNthCalledWith(2, ['stop', 'apple-test']); + }); + + it('deletes existing containers on destroy by default', async () => { + const runner = createRunner([inspectResult('running'), {}]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await sandbox.destroy(); + + expect(runner.run).toHaveBeenNthCalledWith(1, ['inspect', 'apple-test']); + expect(runner.run).toHaveBeenNthCalledWith(2, ['delete', '--force', 'apple-test']); + }); + + it('surfaces inspect JSON parse errors with CLI output', async () => { + const runner = createRunner([{ stdout: 'not json' }]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await expect(sandbox.start()).rejects.toMatchObject({ + name: 'SandboxExecutionError', + stdout: 'not json', + }); + }); + + it('throws SandboxExecutionError when create fails', async () => { + const runner = createRunner([ + { success: false, exitCode: 1, stderr: 'container not found' }, + { success: false, exitCode: 2, stderr: 'bad image' }, + ]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await expect(sandbox.start()).rejects.toMatchObject({ + name: 'SandboxExecutionError', + exitCode: 2, + stderr: 'bad image', + }); + }); +}); + +describe('runAppleContainerCli', () => { + it('captures stdout and stderr from a child process', async () => { + const result = await runAppleContainerCli(process.execPath, [ + '-e', + 'process.stdout.write("out"); process.stderr.write("err");', + ]); + + expect(result).toMatchObject({ + success: true, + exitCode: 0, + stdout: 'out', + stderr: 'err', + timedOut: false, + killed: false, + }); + }); + + it('retains only the newest output when maxRetainedBytes is set', async () => { + const result = await runAppleContainerCli( + process.execPath, + ['-e', 'process.stdout.write("0123456789"); process.stderr.write("abcdefghij");'], + { maxRetainedBytes: 4 }, + ); + + expect(result.stdout).toBe('6789'); + expect(result.stderr).toBe('ghij'); + expect(result.stdoutTruncated).toBe(true); + expect(result.stderrTruncated).toBe(true); + expect(result.stdoutDroppedBytes).toBe(6); + expect(result.stderrDroppedBytes).toBe(6); + }); + + it('rejects with SandboxExecutionError when the CLI binary is missing', async () => { + await expect(runAppleContainerCli('/definitely/missing/container', [])).rejects.toBeInstanceOf( + SandboxExecutionError, + ); + }); +}); diff --git a/workspaces/apple-container/src/sandbox/index.ts b/workspaces/apple-container/src/sandbox/index.ts new file mode 100644 index 00000000000..5fe0638a7ba --- /dev/null +++ b/workspaces/apple-container/src/sandbox/index.ts @@ -0,0 +1,595 @@ +/** + * Apple container CLI sandbox provider. + * + * This provider maps Mastra's WorkspaceSandbox command execution contract to + * Apple's `container` CLI. It starts a long-lived OCI Linux container and uses + * `container exec` for commands. + * + * @see https://github.com/apple/container + */ + +import { spawn } from 'node:child_process'; +import type { RequestContext } from '@mastra/core/di'; +import type { + CommandResult, + ExecuteCommandOptions, + InstructionsOption, + MastraSandboxOptions, + ProviderStatus, + SandboxInfo, +} from '@mastra/core/workspace'; +import { MastraSandbox, SandboxExecutionError } from '@mastra/core/workspace'; + +const LOG_PREFIX = '[AppleContainerSandbox]'; +const DEFAULT_COMMAND_TIMEOUT_MS = 300_000; +const DEFAULT_IMAGE = 'node:22-slim'; +const DEFAULT_COMMAND = ['sleep', 'infinity']; +const DEFAULT_WORKING_DIR = '/workspace'; + +export interface AppleContainerCliResult { + success: boolean; + exitCode: number; + stdout: string; + stderr: string; + executionTimeMs: number; + timedOut?: boolean; + killed?: boolean; +} + +export interface AppleContainerCommandRunnerOptions { + timeout?: number; + abortSignal?: AbortSignal; + onStdout?: (data: string) => void; + onStderr?: (data: string) => void; + maxRetainedBytes?: number; +} + +export interface AppleContainerCommandRunner { + run(args: string[], options?: AppleContainerCommandRunnerOptions): Promise; +} + +export class DefaultAppleContainerCommandRunner implements AppleContainerCommandRunner { + constructor(private readonly binary = 'container') {} + + run(args: string[], options: AppleContainerCommandRunnerOptions = {}): Promise { + return runAppleContainerCli(this.binary, args, options); + } +} + +interface AppleContainerInspectResult { + status?: string; + networks?: Array>; + configuration?: { + id?: string; + hostname?: string; + resources?: { + cpus?: number; + memoryInBytes?: number; + }; + mounts?: unknown[]; + }; +} + +export interface AppleContainerSandboxOptions extends Omit { + /** Unique identifier for this sandbox instance. */ + id?: string; + /** Apple container name passed to `container run --name`. Defaults to the sandbox id. */ + name?: string; + /** OCI image to use. */ + image?: string; + /** Container init command. Must keep the container alive for exec-based command execution. */ + command?: string[]; + /** Environment variables available to the container and every command exec. */ + env?: Record; + /** Host-to-container bind mounts. */ + volumes?: Record; + /** Raw `container run --mount` specs. */ + mounts?: string[]; + /** Apple container network attachment spec. */ + network?: string; + /** Published port specs passed to `container run --publish`. */ + publishedPorts?: string[]; + /** Published socket specs passed to `container run --publish-socket`. */ + publishedSockets?: string[]; + /** Number of CPUs to allocate. */ + cpus?: number | string; + /** Memory allocation, for example `1G`. */ + memory?: string; + /** OCI platform, for example `linux/arm64`. */ + platform?: string; + /** Image architecture when selecting a multi-arch image. */ + arch?: string; + /** Operating system when selecting a multi-platform image. */ + os?: string; + /** Enable Rosetta in the container. */ + rosetta?: boolean; + /** Mount the container root filesystem as read-only. */ + readOnlyRootfs?: boolean; + /** Forward the host SSH agent socket. */ + ssh?: boolean; + /** Enable Apple's init process in the container. */ + init?: boolean; + /** Expose virtualization capabilities to the container. */ + virtualization?: boolean; + /** Linux capabilities to add. */ + capAdd?: string[]; + /** Linux capabilities to drop. */ + capDrop?: string[]; + /** tmpfs mount specs. */ + tmpfs?: string[]; + /** DNS nameserver IPs. */ + dns?: string[]; + /** DNS search domains. */ + dnsSearch?: string[]; + /** Do not configure DNS in the container. */ + noDns?: boolean; + /** Container labels. Mastra labels are always added. */ + labels?: Record; + /** Working directory inside the container. */ + workingDir?: string; + /** Default command timeout in milliseconds. */ + timeout?: number; + /** Delete the container on destroy. Defaults to true. */ + deleteOnDestroy?: boolean; + /** Path or name for the Apple container CLI. */ + containerBinary?: string; + /** Custom command runner, primarily for tests. */ + runner?: AppleContainerCommandRunner; + /** Custom instructions for getInstructions(). String replaces the default; function receives it. */ + instructions?: InstructionsOption; +} + +export class AppleContainerSandbox extends MastraSandbox { + readonly id: string; + readonly name = 'AppleContainerSandbox'; + readonly provider = 'apple-container'; + status: ProviderStatus = 'pending'; + + private readonly _containerName: string; + private readonly _image: string; + private readonly _command: string[]; + private readonly _env: Record; + private readonly _volumes: Record; + private readonly _mounts: string[]; + private readonly _network?: string; + private readonly _publishedPorts: string[]; + private readonly _publishedSockets: string[]; + private readonly _cpus?: number | string; + private readonly _memory?: string; + private readonly _platform?: string; + private readonly _arch?: string; + private readonly _os?: string; + private readonly _rosetta: boolean; + private readonly _readOnlyRootfs: boolean; + private readonly _ssh: boolean; + private readonly _init: boolean; + private readonly _virtualization: boolean; + private readonly _capAdd: string[]; + private readonly _capDrop: string[]; + private readonly _tmpfs: string[]; + private readonly _dns: string[]; + private readonly _dnsSearch: string[]; + private readonly _noDns: boolean; + private readonly _labels: Record; + private readonly _workingDir: string; + private readonly _timeout: number; + private readonly _deleteOnDestroy: boolean; + private readonly _runner: AppleContainerCommandRunner; + private readonly _instructionsOverride?: InstructionsOption; + private readonly _createdAt: Date; + + private _containerId?: string; + + constructor(options: AppleContainerSandboxOptions = {}) { + super({ + ...options, + name: 'AppleContainerSandbox', + }); + + this.id = options.id ?? generateId(); + this._containerName = sanitizeContainerName(options.name ?? this.id); + this._image = options.image ?? DEFAULT_IMAGE; + this._command = options.command ?? DEFAULT_COMMAND; + this._env = options.env ?? {}; + this._volumes = options.volumes ?? {}; + this._mounts = options.mounts ?? []; + this._network = options.network; + this._publishedPorts = options.publishedPorts ?? []; + this._publishedSockets = options.publishedSockets ?? []; + this._cpus = options.cpus; + this._memory = options.memory; + this._platform = options.platform; + this._arch = options.arch; + this._os = options.os; + this._rosetta = options.rosetta ?? false; + this._readOnlyRootfs = options.readOnlyRootfs ?? false; + this._ssh = options.ssh ?? false; + this._init = options.init ?? true; + this._virtualization = options.virtualization ?? false; + this._capAdd = options.capAdd ?? []; + this._capDrop = options.capDrop ?? []; + this._tmpfs = options.tmpfs ?? []; + this._dns = options.dns ?? []; + this._dnsSearch = options.dnsSearch ?? []; + this._noDns = options.noDns ?? false; + this._labels = { + ...options.labels, + 'mastra.sandbox': 'true', + 'mastra.sandbox.id': this.id, + }; + this._workingDir = options.workingDir ?? DEFAULT_WORKING_DIR; + this._timeout = options.timeout ?? DEFAULT_COMMAND_TIMEOUT_MS; + this._deleteOnDestroy = options.deleteOnDestroy ?? true; + this._runner = options.runner ?? new DefaultAppleContainerCommandRunner(options.containerBinary); + this._instructionsOverride = options.instructions; + this._createdAt = new Date(); + } + + get containerId(): string { + return this._containerId ?? this._containerName; + } + + async start(): Promise { + const existing = await this._inspectContainer(); + if (existing) { + this._containerId = existing.configuration?.id ?? this._containerName; + if (!isRunning(existing)) { + const result = await this._runner.run(['start', this.containerId]); + this._assertSuccess(result, `start Apple container ${this.containerId}`); + } + return; + } + + const result = await this._runner.run(this._buildRunArgs()); + this._assertSuccess(result, `create Apple container ${this._containerName}`); + this._containerId = this._containerName; + } + + async stop(): Promise { + const existing = await this._inspectContainer(); + if (!existing || !isRunning(existing)) { + return; + } + + const result = await this._runner.run(['stop', this.containerId]); + if (!result.success && !isMissingContainerMessage(result.stderr)) { + this.logger.warn(`${LOG_PREFIX} Failed to stop container ${this.containerId}: ${result.stderr}`); + } + } + + async destroy(): Promise { + if (!this._deleteOnDestroy) { + await this.stop(); + return; + } + + const existing = await this._inspectContainer(); + if (!existing) { + return; + } + + const result = await this._runner.run(['delete', '--force', this.containerId]); + if (!result.success && !isMissingContainerMessage(result.stderr)) { + this.logger.warn(`${LOG_PREFIX} Failed to delete container ${this.containerId}: ${result.stderr}`); + } + } + + async executeCommand( + command: string, + args: string[] = [], + options: ExecuteCommandOptions = {}, + ): Promise { + await this.ensureRunning(); + + const fullCommand = args.length > 0 ? `${command} ${args.map(shellQuote).join(' ')}` : command; + const env = { ...this._env, ...options.env }; + const cliArgs = [ + 'exec', + ...envFlags(env), + '--workdir', + options.cwd ?? this._workingDir, + this.containerId, + 'sh', + '-lc', + fullCommand, + ]; + + const result = await this._runner.run(cliArgs, { + timeout: options.timeout ?? this._timeout, + abortSignal: options.abortSignal, + onStdout: options.onStdout, + onStderr: options.onStderr, + maxRetainedBytes: options.maxRetainedBytes, + }); + + return { ...result, command: fullCommand, args }; + } + + async getInfo(): Promise { + const inspect = this._containerId ? await this._inspectContainer() : undefined; + const resources = inspect?.configuration?.resources; + + return { + id: this.id, + name: this.name, + provider: this.provider, + status: this.status, + createdAt: this._createdAt, + resources: resources + ? { + cpuCores: resources.cpus, + memoryMB: resources.memoryInBytes ? Math.round(resources.memoryInBytes / 1024 / 1024) : undefined, + } + : undefined, + metadata: { + containerName: this._containerName, + containerId: this.containerId, + image: this._image, + workingDir: this._workingDir, + ...(inspect?.status && { appleContainerStatus: inspect.status }), + ...(inspect?.networks && { networks: inspect.networks }), + }, + }; + } + + getInstructions(opts?: { requestContext?: RequestContext }): string { + const defaultInstructions = this._buildDefaultInstructions(); + if (typeof this._instructionsOverride === 'string') { + return this._instructionsOverride; + } + if (typeof this._instructionsOverride === 'function') { + return this._instructionsOverride({ defaultInstructions, requestContext: opts?.requestContext }); + } + return defaultInstructions; + } + + private _buildDefaultInstructions(): string { + const parts = [ + `Apple container sandbox: commands run inside a local OCI Linux container from image ${this._image}.`, + `Working directory: ${this._workingDir}.`, + ]; + + const volumeCount = Object.keys(this._volumes).length + this._mounts.length; + if (volumeCount > 0) { + parts.push(`${volumeCount} host mount(s) are configured.`); + } + if (this._timeout > 0) { + parts.push(`Default command timeout: ${Math.ceil(this._timeout / 1000)}s.`); + } + + return parts.join(' '); + } + + private async _inspectContainer(): Promise { + const result = await this._runner.run(['inspect', this.containerId]); + if (!result.success) { + if (!isMissingContainerMessage(result.stderr)) { + this.logger.debug(`${LOG_PREFIX} inspect failed for ${this.containerId}: ${result.stderr}`); + } + return undefined; + } + + try { + const parsed = JSON.parse(result.stdout) as AppleContainerInspectResult[] | AppleContainerInspectResult; + return Array.isArray(parsed) ? parsed[0] : parsed; + } catch (error) { + throw new SandboxExecutionError( + `Failed to parse Apple container inspect output for ${this.containerId}: ${ + error instanceof Error ? error.message : String(error) + }`, + 1, + result.stdout, + result.stderr, + ); + } + } + + private _buildRunArgs(): string[] { + const args = ['run', '-d', '--name', this._containerName, '--workdir', this._workingDir]; + + args.push(...envFlags(this._env)); + for (const [hostPath, containerPath] of Object.entries(this._volumes)) { + args.push('--volume', `${hostPath}:${containerPath}`); + } + for (const mount of this._mounts) args.push('--mount', mount); + for (const [key, value] of Object.entries(this._labels)) args.push('--label', `${key}=${value}`); + for (const port of this._publishedPorts) args.push('--publish', port); + for (const socket of this._publishedSockets) args.push('--publish-socket', socket); + for (const cap of this._capAdd) args.push('--cap-add', cap); + for (const cap of this._capDrop) args.push('--cap-drop', cap); + for (const tmpfs of this._tmpfs) args.push('--tmpfs', tmpfs); + for (const dns of this._dns) args.push('--dns', dns); + for (const domain of this._dnsSearch) args.push('--dns-search', domain); + + if (this._network) args.push('--network', this._network); + if (this._cpus !== undefined) args.push('--cpus', String(this._cpus)); + if (this._memory !== undefined) args.push('--memory', this._memory); + if (this._platform) args.push('--platform', this._platform); + if (this._arch) args.push('--arch', this._arch); + if (this._os) args.push('--os', this._os); + if (this._rosetta) args.push('--rosetta'); + if (this._readOnlyRootfs) args.push('--read-only'); + if (this._ssh) args.push('--ssh'); + if (this._init) args.push('--init'); + if (this._virtualization) args.push('--virtualization'); + if (this._noDns) args.push('--no-dns'); + + args.push(this._image, ...this._command); + return args; + } + + private _assertSuccess(result: AppleContainerCliResult, action: string): void { + if (result.success) return; + throw new SandboxExecutionError( + `${action} failed with exit code ${result.exitCode}: ${result.stderr}`, + result.exitCode, + result.stdout, + result.stderr, + ); + } +} + +export function runAppleContainerCli( + binary: string, + args: string[], + options: AppleContainerCommandRunnerOptions = {}, +): Promise { + const startedAt = Date.now(); + + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + let stdoutDroppedBytes = 0; + let stderrDroppedBytes = 0; + let settled = false; + let killed = false; + let timedOut = false; + + const child = spawn(binary, args, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const finish = (exitCode: number): void => { + if (settled) return; + settled = true; + resolve({ + success: exitCode === 0, + exitCode, + stdout, + stderr, + executionTimeMs: Date.now() - startedAt, + killed, + timedOut, + ...(stdoutDroppedBytes > 0 && { stdoutTruncated: true, stdoutDroppedBytes }), + ...(stderrDroppedBytes > 0 && { stderrTruncated: true, stderrDroppedBytes }), + }); + }; + + const kill = (): void => { + if (child.killed) return; + killed = true; + child.kill('SIGTERM'); + setTimeout(() => { + if (!settled && child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + }, 1000).unref(); + }; + + let timeout: NodeJS.Timeout | undefined; + if (options.timeout && options.timeout > 0) { + timeout = setTimeout(() => { + timedOut = true; + kill(); + }, options.timeout); + } + + const onAbort = (): void => kill(); + if (options.abortSignal) { + if (options.abortSignal.aborted) { + kill(); + } else { + options.abortSignal.addEventListener('abort', onAbort, { once: true }); + } + } + + child.stdout?.on('data', (chunk: Buffer) => { + const data = chunk.toString('utf8'); + const retained = appendRetainedOutput(stdout, stdoutDroppedBytes, data, options.maxRetainedBytes); + stdout = retained.output; + stdoutDroppedBytes = retained.droppedBytes; + options.onStdout?.(data); + }); + + child.stderr?.on('data', (chunk: Buffer) => { + const data = chunk.toString('utf8'); + const retained = appendRetainedOutput(stderr, stderrDroppedBytes, data, options.maxRetainedBytes); + stderr = retained.output; + stderrDroppedBytes = retained.droppedBytes; + options.onStderr?.(data); + }); + + child.on('error', error => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + options.abortSignal?.removeEventListener('abort', onAbort); + reject( + error instanceof Error && 'code' in error && error.code === 'ENOENT' + ? new SandboxExecutionError(`Apple container CLI not found: ${binary}`, 127, stdout, error.message) + : error, + ); + }); + + child.on('close', code => { + if (timeout) clearTimeout(timeout); + options.abortSignal?.removeEventListener('abort', onAbort); + finish(code ?? (killed ? 137 : 1)); + }); + }); +} + +function generateId(): string { + return `apple-container-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function sanitizeContainerName(name: string): string { + const sanitized = name.replace(/[^a-zA-Z0-9_.-]/g, '-'); + return /^[a-zA-Z0-9]/.test(sanitized) ? sanitized : `c-${sanitized}`; +} + +function shellQuote(arg: string): string { + if (/^[a-zA-Z0-9._\-\/=:@]+$/.test(arg)) return arg; + return `'${arg.replace(/'/g, "'\\''")}'`; +} + +function appendRetainedOutput( + currentOutput: string, + currentDroppedBytes: number, + chunk: string, + maxRetainedBytes: number | undefined, +): { output: string; droppedBytes: number } { + if (maxRetainedBytes === undefined || maxRetainedBytes === Infinity) { + return { output: currentOutput + chunk, droppedBytes: currentDroppedBytes }; + } + if (maxRetainedBytes <= 0) { + return { output: '', droppedBytes: currentDroppedBytes + Buffer.byteLength(chunk) }; + } + + let output = currentOutput + chunk; + let outputBytes = Buffer.byteLength(output); + if (outputBytes <= maxRetainedBytes) { + return { output, droppedBytes: currentDroppedBytes }; + } + + let droppedBytes = currentDroppedBytes; + while (output.length > 0 && outputBytes > maxRetainedBytes) { + const firstCodePoint = output.codePointAt(0); + if (firstCodePoint === undefined) break; + const firstChar = String.fromCodePoint(firstCodePoint); + output = output.slice(firstChar.length); + const byteLength = Buffer.byteLength(firstChar); + outputBytes -= byteLength; + droppedBytes += byteLength; + } + + return { output, droppedBytes }; +} + +function envFlags(env: Record): string[] { + const args: string[] = []; + for (const [key, value] of Object.entries(env)) { + if (value === undefined) continue; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + throw new Error(`Invalid environment variable name for Apple container command: ${key}`); + } + args.push('--env', `${key}=${value}`); + } + return args; +} + +function isRunning(inspect: AppleContainerInspectResult): boolean { + return inspect.status === 'running'; +} + +function isMissingContainerMessage(message: string): boolean { + return /not found|no such|does not exist|unknown container/i.test(message); +} diff --git a/workspaces/apple-container/test/core-workspace.ts b/workspaces/apple-container/test/core-workspace.ts new file mode 100644 index 00000000000..1032b16f8f6 --- /dev/null +++ b/workspaces/apple-container/test/core-workspace.ts @@ -0,0 +1,10 @@ +export { MastraSandbox } from '../../../packages/core/src/workspace/sandbox/mastra-sandbox'; +export { SandboxExecutionError } from '../../../packages/core/src/workspace/sandbox/errors'; +export type { ProviderStatus } from '../../../packages/core/src/workspace/lifecycle'; +export type { InstructionsOption } from '../../../packages/core/src/workspace/types'; +export type { + CommandResult, + ExecuteCommandOptions, + MastraSandboxOptions, + SandboxInfo, +} from '../../../packages/core/src/workspace/sandbox/types'; diff --git a/workspaces/apple-container/tsconfig.build.json b/workspaces/apple-container/tsconfig.build.json new file mode 100644 index 00000000000..caa0c57cf19 --- /dev/null +++ b/workspaces/apple-container/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": ["./tsconfig.json", "../../tsconfig.build.json"], + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/workspaces/apple-container/tsconfig.json b/workspaces/apple-container/tsconfig.json new file mode 100644 index 00000000000..2ad3851ec8b --- /dev/null +++ b/workspaces/apple-container/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.node.json", + "include": ["src/**/*", "test/**/*", "tsup.config.ts", "vitest.config.ts"], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/workspaces/apple-container/tsup.config.ts b/workspaces/apple-container/tsup.config.ts new file mode 100644 index 00000000000..b2d8ae0cd82 --- /dev/null +++ b/workspaces/apple-container/tsup.config.ts @@ -0,0 +1,18 @@ +import { generateTypes } from '@internal/types-builder'; +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + clean: true, + dts: false, + splitting: true, + treeshake: { + preset: 'smallest', + }, + sourcemap: true, + external: [/^@mastra\/core/], + onSuccess: async () => { + await generateTypes(process.cwd()); + }, +}); diff --git a/workspaces/apple-container/vitest.config.ts b/workspaces/apple-container/vitest.config.ts new file mode 100644 index 00000000000..92893ab30c0 --- /dev/null +++ b/workspaces/apple-container/vitest.config.ts @@ -0,0 +1,21 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + resolve: { + alias: { + '@mastra/core/di': fileURLToPath(new URL('../../packages/core/src/di/index.ts', import.meta.url)), + '@mastra/core/editor': fileURLToPath(new URL('../../packages/core/src/editor/index.ts', import.meta.url)), + '@mastra/core/workspace': fileURLToPath(new URL('./test/core-workspace.ts', import.meta.url)), + }, + }, + test: { + environment: 'node', + include: ['src/**/*.test.ts', 'src/**/*.integration.test.ts'], + setupFiles: ['dotenv/config'], + testTimeout: 60000, + coverage: { + reporter: ['text', 'json', 'html'], + }, + }, +}); From d731c4712460e82a413b8d4f31f9cc8e2e0d9bf7 Mon Sep 17 00:00:00 2001 From: Max Shugar Date: Mon, 29 Jun 2026 22:02:19 +0100 Subject: [PATCH 02/16] fix: quote apple container shell command --- .../apple-container/src/sandbox/index.test.ts | 23 +++++++++++++++++++ .../apple-container/src/sandbox/index.ts | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/workspaces/apple-container/src/sandbox/index.test.ts b/workspaces/apple-container/src/sandbox/index.test.ts index 1df244452f2..74a048a5d51 100644 --- a/workspaces/apple-container/src/sandbox/index.test.ts +++ b/workspaces/apple-container/src/sandbox/index.test.ts @@ -222,6 +222,29 @@ describe('AppleContainerSandbox', () => { ); }); + it('quotes the command before executing through the shell', async () => { + const runner = createRunner([{ success: false, exitCode: 1, stderr: 'container not found' }, {}, {}]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await sandbox._start(); + runner.run.mockClear(); + + await sandbox.executeCommand('node; touch /tmp/pwned', ['-v']); + + expect(runner.run).toHaveBeenCalledWith( + [ + 'exec', + '--workdir', + '/workspace', + 'apple-test', + 'sh', + '-lc', + "'node; touch /tmp/pwned' -v", + ], + expect.any(Object), + ); + }); + it('stops instead of deletes when deleteOnDestroy is disabled', async () => { const runner = createRunner([inspectResult('running'), {}]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', deleteOnDestroy: false, runner }); diff --git a/workspaces/apple-container/src/sandbox/index.ts b/workspaces/apple-container/src/sandbox/index.ts index 5fe0638a7ba..645a790b75e 100644 --- a/workspaces/apple-container/src/sandbox/index.ts +++ b/workspaces/apple-container/src/sandbox/index.ts @@ -281,7 +281,7 @@ export class AppleContainerSandbox extends MastraSandbox { ): Promise { await this.ensureRunning(); - const fullCommand = args.length > 0 ? `${command} ${args.map(shellQuote).join(' ')}` : command; + const fullCommand = [command, ...args].map(shellQuote).join(' '); const env = { ...this._env, ...options.env }; const cliArgs = [ 'exec', From e349af4b269add0cdc4b134ddcf939282a09c7fe Mon Sep 17 00:00:00 2001 From: Max Shugar Date: Mon, 29 Jun 2026 22:03:02 +0100 Subject: [PATCH 03/16] docs: add apple container changeset example --- .changeset/apple-container-sandbox.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.changeset/apple-container-sandbox.md b/.changeset/apple-container-sandbox.md index b9b01b9c49c..5966c00de57 100644 --- a/.changeset/apple-container-sandbox.md +++ b/.changeset/apple-container-sandbox.md @@ -3,3 +3,13 @@ --- Add an Apple container CLI workspace sandbox provider. + +```ts +import { AppleContainerSandbox } from '@mastra/apple-container'; + +const sandbox = new AppleContainerSandbox({ + id: 'local-apple-container', + image: 'node:22-slim', + volumes: { [process.cwd()]: '/workspace' }, +}); +``` From 1ae92754bd38ddbd2955c320e029359142d94ac3 Mon Sep 17 00:00:00 2001 From: Max Shugar Date: Mon, 29 Jun 2026 22:03:33 +0100 Subject: [PATCH 04/16] docs: use public apple container lifecycle --- .../en/reference/workspace/apple-container-sandbox.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx b/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx index 3b3f55c02d6..14ad33d4eaa 100644 --- a/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx +++ b/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx @@ -365,10 +365,10 @@ These options are only applied when a new container is created. If the sandbox r ```typescript const sandbox = new AppleContainerSandbox({ id: 'persistent-sandbox' }) -await sandbox._start() +await sandbox.start() const sandbox2 = new AppleContainerSandbox({ id: 'persistent-sandbox' }) -await sandbox2._start() +await sandbox2.start() ``` ## Editor provider From 8faf57d441b7aab87aa0212182aab915b69ab3a6 Mon Sep 17 00:00:00 2001 From: Max Shugar Date: Mon, 29 Jun 2026 22:04:37 +0100 Subject: [PATCH 05/16] test: run apple container unit tests by default --- workspaces/apple-container/package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/workspaces/apple-container/package.json b/workspaces/apple-container/package.json index 94e4a7a2fb1..a194e32ff20 100644 --- a/workspaces/apple-container/package.json +++ b/workspaces/apple-container/package.json @@ -24,8 +24,9 @@ "build:watch": "pnpm build --watch", "test:unit": "vitest run --exclude '**/*.integration.test.ts'", "test:watch": "vitest watch", - "test": "vitest run ./src/**/*.integration.test.ts", - "test:cloud": "pnpm test", + "test:integration": "vitest run ./src/**/*.integration.test.ts", + "test": "pnpm test:unit && pnpm test:integration", + "test:cloud": "pnpm test:integration", "lint": "eslint ." }, "license": "Apache-2.0", From 65f063df76c5e2bd76f3bb1dc72657242f005e34 Mon Sep 17 00:00:00 2001 From: Max Shugar Date: Mon, 29 Jun 2026 22:05:49 +0100 Subject: [PATCH 06/16] docs: document apple container options --- workspaces/apple-container/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/workspaces/apple-container/README.md b/workspaces/apple-container/README.md index 86d61246673..ae1658af573 100644 --- a/workspaces/apple-container/README.md +++ b/workspaces/apple-container/README.md @@ -52,16 +52,32 @@ await workspace.destroy(); | `mounts` | `string[]` | Raw `--mount` specs passed to `container run`. | | `network` | `string` | Apple container network attachment spec. | | `publishedPorts` | `string[]` | Port publish specs passed as `--publish`. | +| `publishedSockets` | `string[]` | Socket publish specs passed as `--publish-socket`. | | `cpus` | `number \| string` | Number of CPUs allocated to the container. | | `memory` | `string` | Memory allocation, for example `1G`. | | `platform` | `string` | OCI platform, for example `linux/arm64`. | | `arch` | `string` | Image architecture when selecting multi-arch images. | +| `os` | `string` | Operating system when selecting multi-platform images. | | `rosetta` | `boolean` | Enable Rosetta in the container. | | `readOnlyRootfs` | `boolean` | Start the container with a read-only root filesystem. | | `ssh` | `boolean` | Forward the host SSH agent socket. | +| `init` | `boolean` | Enable Apple's init process in the container. | +| `virtualization` | `boolean` | Expose virtualization capabilities to the container. | +| `capAdd` | `string[]` | Linux capabilities to add. | +| `capDrop` | `string[]` | Linux capabilities to drop. | +| `tmpfs` | `string[]` | tmpfs mount specs. | +| `dns` | `string[]` | DNS nameserver IPs. | +| `dnsSearch` | `string[]` | DNS search domains. | +| `noDns` | `boolean` | Do not configure DNS in the container. | +| `labels` | `Record` | Container labels. Mastra labels are always added. | +| `workingDir` | `string` | Working directory inside the container. Defaults to `/workspace`. | | `timeout` | `number` | Default command timeout in milliseconds. | | `deleteOnDestroy` | `boolean` | Delete the Apple container on destroy. Defaults to `true`. | | `containerBinary` | `string` | Path or name for the Apple container CLI. Defaults to `container`. | +| `runner` | `AppleContainerCommandRunner` | Custom command runner, primarily for tests. | +| `onStart` | `({ sandbox }) => unknown` | Lifecycle hook called after the sandbox reaches `running`. | +| `onStop` | `({ sandbox }) => unknown` | Lifecycle hook called before the sandbox stops. | +| `onDestroy` | `({ sandbox }) => unknown` | Lifecycle hook called before the sandbox is destroyed. | | `instructions` | `string \| (opts) => string` | Override or extend the default workspace sandbox instructions. | ## Editor provider From 7373b009935740f432bf3f6ce56f0cc559e3edbd Mon Sep 17 00:00:00 2001 From: Max Shugar Date: Mon, 29 Jun 2026 22:06:21 +0100 Subject: [PATCH 07/16] docs: import MastraEditor in apple container readme --- workspaces/apple-container/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/workspaces/apple-container/README.md b/workspaces/apple-container/README.md index ae1658af573..895d9882298 100644 --- a/workspaces/apple-container/README.md +++ b/workspaces/apple-container/README.md @@ -85,6 +85,7 @@ await workspace.destroy(); Register the provider with `MastraEditor` to hydrate stored sandbox configs: ```typescript +import { MastraEditor } from '@mastra/core/editor'; import { appleContainerSandboxProvider } from '@mastra/apple-container'; const editor = new MastraEditor({ From 4167246c0f0cad3d2ea1a20a4b0abbe058ec5691 Mon Sep 17 00:00:00 2001 From: Max Shugar Date: Mon, 29 Jun 2026 22:07:44 +0100 Subject: [PATCH 08/16] test: fail apple container integration when cli missing --- .../src/sandbox/index.integration.test.ts | 8 ++++++-- .../apple-container/src/sandbox/index.test.ts | 15 +++++++++++++++ workspaces/apple-container/src/sandbox/index.ts | 2 +- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/workspaces/apple-container/src/sandbox/index.integration.test.ts b/workspaces/apple-container/src/sandbox/index.integration.test.ts index 94fc7577276..2e6ded7224c 100644 --- a/workspaces/apple-container/src/sandbox/index.integration.test.ts +++ b/workspaces/apple-container/src/sandbox/index.integration.test.ts @@ -4,8 +4,12 @@ import { afterEach, describe, expect, it } from 'vitest'; import { AppleContainerSandbox } from './index'; const shouldRunIntegration = process.env.MASTRA_APPLE_CONTAINER_INTEGRATION === '1'; -const hasAppleContainerCli = - shouldRunIntegration && spawnSync('container', ['--version'], { stdio: 'ignore' }).status === 0; +const cliProbe = shouldRunIntegration ? spawnSync('container', ['--version'], { stdio: 'ignore' }) : undefined; +const hasAppleContainerCli = cliProbe?.status === 0; + +if (shouldRunIntegration && !hasAppleContainerCli) { + throw cliProbe?.error ?? new Error('MASTRA_APPLE_CONTAINER_INTEGRATION=1 but `container --version` failed'); +} describe.skipIf(!hasAppleContainerCli)('AppleContainerSandbox integration', () => { let sandbox: AppleContainerSandbox | undefined; diff --git a/workspaces/apple-container/src/sandbox/index.test.ts b/workspaces/apple-container/src/sandbox/index.test.ts index 74a048a5d51..2fb01a50ff0 100644 --- a/workspaces/apple-container/src/sandbox/index.test.ts +++ b/workspaces/apple-container/src/sandbox/index.test.ts @@ -245,6 +245,21 @@ describe('AppleContainerSandbox', () => { ); }); + it('preserves shell command strings when no args are provided', async () => { + const runner = createRunner([{ success: false, exitCode: 1, stderr: 'container not found' }, {}, {}]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await sandbox._start(); + runner.run.mockClear(); + + await sandbox.executeCommand('printf apple-container'); + + expect(runner.run).toHaveBeenCalledWith( + ['exec', '--workdir', '/workspace', 'apple-test', 'sh', '-lc', 'printf apple-container'], + expect.any(Object), + ); + }); + it('stops instead of deletes when deleteOnDestroy is disabled', async () => { const runner = createRunner([inspectResult('running'), {}]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', deleteOnDestroy: false, runner }); diff --git a/workspaces/apple-container/src/sandbox/index.ts b/workspaces/apple-container/src/sandbox/index.ts index 645a790b75e..fa7dfb6d1b8 100644 --- a/workspaces/apple-container/src/sandbox/index.ts +++ b/workspaces/apple-container/src/sandbox/index.ts @@ -281,7 +281,7 @@ export class AppleContainerSandbox extends MastraSandbox { ): Promise { await this.ensureRunning(); - const fullCommand = [command, ...args].map(shellQuote).join(' '); + const fullCommand = args.length > 0 ? [command, ...args].map(shellQuote).join(' ') : command; const env = { ...this._env, ...options.env }; const cliArgs = [ 'exec', From fcc1f1652ea5d6c2a1654809e3d8ea8bf8ded5ac Mon Sep 17 00:00:00 2001 From: Max Shugar Date: Mon, 29 Jun 2026 22:08:31 +0100 Subject: [PATCH 09/16] test: fail on unexpected apple container runner calls --- workspaces/apple-container/src/sandbox/index.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/workspaces/apple-container/src/sandbox/index.test.ts b/workspaces/apple-container/src/sandbox/index.test.ts index 2fb01a50ff0..51ebc1e3d41 100644 --- a/workspaces/apple-container/src/sandbox/index.test.ts +++ b/workspaces/apple-container/src/sandbox/index.test.ts @@ -16,6 +16,9 @@ function createRunner( return { run: vi.fn(async (args: string[], options?: AppleContainerCommandRunnerOptions) => { const response = queue.shift(); + if (response === undefined) { + throw new Error(`Unexpected runner invocation: ${JSON.stringify(args)}`); + } const resolved = typeof response === 'function' ? response(args, options) : response; return cliResult(resolved); }), From 4a90eed9e1d00cad8a3226ebf2ff26ede216c084 Mon Sep 17 00:00:00 2001 From: Max Shugar Date: Mon, 29 Jun 2026 22:09:51 +0100 Subject: [PATCH 10/16] fix: expose apple container truncation metadata --- workspaces/apple-container/src/sandbox/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/workspaces/apple-container/src/sandbox/index.ts b/workspaces/apple-container/src/sandbox/index.ts index fa7dfb6d1b8..875f369ec52 100644 --- a/workspaces/apple-container/src/sandbox/index.ts +++ b/workspaces/apple-container/src/sandbox/index.ts @@ -34,6 +34,10 @@ export interface AppleContainerCliResult { executionTimeMs: number; timedOut?: boolean; killed?: boolean; + stdoutTruncated?: boolean; + stderrTruncated?: boolean; + stdoutDroppedBytes?: number; + stderrDroppedBytes?: number; } export interface AppleContainerCommandRunnerOptions { From 43c9755d14c9d996dc4fe1c67190e75369119bad Mon Sep 17 00:00:00 2001 From: Max Shugar Date: Mon, 29 Jun 2026 22:12:48 +0100 Subject: [PATCH 11/16] fix: sync apple container plain lifecycle status --- .../apple-container/src/sandbox/index.test.ts | 22 ++++++++++ .../apple-container/src/sandbox/index.ts | 41 +++++++++++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/workspaces/apple-container/src/sandbox/index.test.ts b/workspaces/apple-container/src/sandbox/index.test.ts index 51ebc1e3d41..6270d4b3e65 100644 --- a/workspaces/apple-container/src/sandbox/index.test.ts +++ b/workspaces/apple-container/src/sandbox/index.test.ts @@ -161,6 +161,27 @@ describe('AppleContainerSandbox', () => { expect(sandbox.status).toBe('running'); }); + it('keeps status in sync when plain lifecycle methods are called', async () => { + const runner = createRunner([ + { success: false, exitCode: 1, stderr: 'container not found' }, + {}, + inspectResult('running'), + {}, + inspectResult('stopped'), + {}, + ]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await sandbox.start(); + expect(sandbox.status).toBe('running'); + + await sandbox.stop(); + expect(sandbox.status).toBe('stopped'); + + await sandbox.destroy(); + expect(sandbox.status).toBe('destroyed'); + }); + it('reconnects to an existing stopped container', async () => { const runner = createRunner([inspectResult('stopped', 'existing-id'), {}]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); @@ -305,6 +326,7 @@ describe('AppleContainerSandbox', () => { exitCode: 2, stderr: 'bad image', }); + expect(sandbox.status).toBe('error'); }); }); diff --git a/workspaces/apple-container/src/sandbox/index.ts b/workspaces/apple-container/src/sandbox/index.ts index 875f369ec52..d3c52407e53 100644 --- a/workspaces/apple-container/src/sandbox/index.ts +++ b/workspaces/apple-container/src/sandbox/index.ts @@ -234,6 +234,18 @@ export class AppleContainerSandbox extends MastraSandbox { } async start(): Promise { + await this._runPlainLifecycle('starting', 'running', () => this._startContainer()); + } + + async stop(): Promise { + await this._runPlainLifecycle('stopping', 'stopped', () => this._stopContainer()); + } + + async destroy(): Promise { + await this._runPlainLifecycle('destroying', 'destroyed', () => this._destroyContainer()); + } + + private async _startContainer(): Promise { const existing = await this._inspectContainer(); if (existing) { this._containerId = existing.configuration?.id ?? this._containerName; @@ -249,7 +261,7 @@ export class AppleContainerSandbox extends MastraSandbox { this._containerId = this._containerName; } - async stop(): Promise { + private async _stopContainer(): Promise { const existing = await this._inspectContainer(); if (!existing || !isRunning(existing)) { return; @@ -261,9 +273,9 @@ export class AppleContainerSandbox extends MastraSandbox { } } - async destroy(): Promise { + private async _destroyContainer(): Promise { if (!this._deleteOnDestroy) { - await this.stop(); + await this._stopContainer(); return; } @@ -364,6 +376,29 @@ export class AppleContainerSandbox extends MastraSandbox { return parts.join(' '); } + private async _runPlainLifecycle( + activeStatus: ProviderStatus, + completeStatus: ProviderStatus, + operation: () => Promise, + ): Promise { + const managedByBaseWrapper = this.status === activeStatus; + if (!managedByBaseWrapper) { + this.status = activeStatus; + } + + try { + await operation(); + if (!managedByBaseWrapper) { + this.status = completeStatus; + } + } catch (error) { + if (!managedByBaseWrapper) { + this.status = 'error'; + } + throw error; + } + } + private async _inspectContainer(): Promise { const result = await this._runner.run(['inspect', this.containerId]); if (!result.success) { From 36409b5680db978f2432ee0a11c4691212a1f9f1 Mon Sep 17 00:00:00 2001 From: Max Shugar Date: Mon, 29 Jun 2026 22:24:50 +0100 Subject: [PATCH 12/16] chore: align apple container provider config --- pnpm-lock.yaml | 3 + workspaces/apple-container/package.json | 1 + workspaces/apple-container/src/provider.ts | 65 ++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 557939319f2..4b3232c9b63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8486,6 +8486,9 @@ importers: '@internal/types-builder': specifier: workspace:* version: link:../../packages/_types-builder + '@internal/workspace-test-utils': + specifier: workspace:* + version: link:../_test-utils '@mastra/core': specifier: workspace:* version: link:../../packages/core diff --git a/workspaces/apple-container/package.json b/workspaces/apple-container/package.json index a194e32ff20..35c4c15bcc0 100644 --- a/workspaces/apple-container/package.json +++ b/workspaces/apple-container/package.json @@ -33,6 +33,7 @@ "devDependencies": { "@internal/lint": "workspace:*", "@internal/types-builder": "workspace:*", + "@internal/workspace-test-utils": "workspace:*", "@mastra/core": "workspace:*", "@types/node": "22.19.21", "@vitest/coverage-v8": "catalog:", diff --git a/workspaces/apple-container/src/provider.ts b/workspaces/apple-container/src/provider.ts index a1a7b6b7104..29a8bea88c8 100644 --- a/workspaces/apple-container/src/provider.ts +++ b/workspaces/apple-container/src/provider.ts @@ -15,13 +15,24 @@ export interface AppleContainerProviderConfig { mounts?: string[]; network?: string; publishedPorts?: string[]; + publishedSockets?: string[]; cpus?: number | string; memory?: string; platform?: string; arch?: string; + os?: string; rosetta?: boolean; readOnlyRootfs?: boolean; ssh?: boolean; + init?: boolean; + virtualization?: boolean; + capAdd?: string[]; + capDrop?: string[]; + tmpfs?: string[]; + dns?: string[]; + dnsSearch?: string[]; + noDns?: boolean; + labels?: Record; workingDir?: string; timeout?: number; deleteOnDestroy?: boolean; @@ -73,6 +84,11 @@ export const appleContainerSandboxProvider: SandboxProvider Date: Mon, 29 Jun 2026 22:43:05 +0100 Subject: [PATCH 13/16] chore: harden apple container review surface --- .../workspace/apple-container-sandbox.mdx | 8 +++-- workspaces/apple-container/README.md | 4 ++- workspaces/apple-container/src/provider.ts | 4 +-- .../apple-container/src/sandbox/index.test.ts | 32 ++++++++++++++++++- .../apple-container/src/sandbox/index.ts | 12 +++---- 5 files changed, 48 insertions(+), 12 deletions(-) diff --git a/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx b/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx index 14ad33d4eaa..e6c05ba73d9 100644 --- a/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx +++ b/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx @@ -165,7 +165,7 @@ console.log(response.text) defaultValue: 'false', }, { - name: 'readOnlyRootfs', + name: 'readonlyRootfs', type: 'boolean', description: 'Mount the container root filesystem as read-only.', isOptional: true, @@ -345,15 +345,19 @@ Apple container CLI options can be passed through the constructor: ```typescript const sandbox = new AppleContainerSandbox({ image: 'node:22-slim', + volumes: { + '/Users/me/project': '/workspace', + }, cpus: 2, memory: '2G', platform: 'linux/arm64', - readOnlyRootfs: true, + readonlyRootfs: true, tmpfs: ['/tmp:rw,size=256m'], }) ``` These options are only applied when a new container is created. If the sandbox reconnects to an existing container with the same name, destroy and recreate the sandbox to apply changed runtime options. +When `readonlyRootfs` is enabled, make sure `workingDir` points to a path supplied by the image, a bind mount, or a writable tmpfs. ## Reconnection diff --git a/workspaces/apple-container/README.md b/workspaces/apple-container/README.md index 895d9882298..902b5d2ba4b 100644 --- a/workspaces/apple-container/README.md +++ b/workspaces/apple-container/README.md @@ -59,7 +59,7 @@ await workspace.destroy(); | `arch` | `string` | Image architecture when selecting multi-arch images. | | `os` | `string` | Operating system when selecting multi-platform images. | | `rosetta` | `boolean` | Enable Rosetta in the container. | -| `readOnlyRootfs` | `boolean` | Start the container with a read-only root filesystem. | +| `readonlyRootfs` | `boolean` | Start the container with a read-only root filesystem. | | `ssh` | `boolean` | Forward the host SSH agent socket. | | `init` | `boolean` | Enable Apple's init process in the container. | | `virtualization` | `boolean` | Expose virtualization capabilities to the container. | @@ -80,6 +80,8 @@ await workspace.destroy(); | `onDestroy` | `({ sandbox }) => unknown` | Lifecycle hook called before the sandbox is destroyed. | | `instructions` | `string \| (opts) => string` | Override or extend the default workspace sandbox instructions. | +When `readonlyRootfs` is enabled, make sure `workingDir` points to a path supplied by the image, a bind mount, or a writable tmpfs. + ## Editor provider Register the provider with `MastraEditor` to hydrate stored sandbox configs: diff --git a/workspaces/apple-container/src/provider.ts b/workspaces/apple-container/src/provider.ts index 29a8bea88c8..1e483217b23 100644 --- a/workspaces/apple-container/src/provider.ts +++ b/workspaces/apple-container/src/provider.ts @@ -22,7 +22,7 @@ export interface AppleContainerProviderConfig { arch?: string; os?: string; rosetta?: boolean; - readOnlyRootfs?: boolean; + readonlyRootfs?: boolean; ssh?: boolean; init?: boolean; virtualization?: boolean; @@ -114,7 +114,7 @@ export const appleContainerSandboxProvider: SandboxProvider { arch: 'arm64', os: 'linux', rosetta: true, - readOnlyRootfs: true, + readonlyRootfs: true, ssh: true, init: true, virtualization: true, @@ -304,6 +304,36 @@ describe('AppleContainerSandbox', () => { expect(runner.run).toHaveBeenNthCalledWith(2, ['delete', '--force', 'apple-test']); }); + it('throws when stop fails unexpectedly', async () => { + const runner = createRunner([ + inspectResult('running'), + { success: false, exitCode: 3, stderr: 'permission denied' }, + ]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await expect(sandbox.stop()).rejects.toMatchObject({ + name: 'SandboxExecutionError', + exitCode: 3, + stderr: 'permission denied', + }); + expect(sandbox.status).toBe('error'); + }); + + it('throws when destroy fails unexpectedly', async () => { + const runner = createRunner([ + inspectResult('running'), + { success: false, exitCode: 4, stderr: 'delete failed' }, + ]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await expect(sandbox.destroy()).rejects.toMatchObject({ + name: 'SandboxExecutionError', + exitCode: 4, + stderr: 'delete failed', + }); + expect(sandbox.status).toBe('error'); + }); + it('surfaces inspect JSON parse errors with CLI output', async () => { const runner = createRunner([{ stdout: 'not json' }]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); diff --git a/workspaces/apple-container/src/sandbox/index.ts b/workspaces/apple-container/src/sandbox/index.ts index d3c52407e53..34370997669 100644 --- a/workspaces/apple-container/src/sandbox/index.ts +++ b/workspaces/apple-container/src/sandbox/index.ts @@ -108,7 +108,7 @@ export interface AppleContainerSandboxOptions extends Omit Date: Mon, 29 Jun 2026 23:01:23 +0100 Subject: [PATCH 14/16] test: cover apple container lifecycle paths --- workspaces/apple-container/src/provider.ts | 65 ++--- .../src/sandbox/index.integration.test.ts | 66 ++++- .../apple-container/src/sandbox/index.test.ts | 166 +++++++++++- .../apple-container/src/sandbox/index.ts | 239 ++++++++++-------- .../apple-container/test/core-workspace.ts | 1 + 5 files changed, 376 insertions(+), 161 deletions(-) diff --git a/workspaces/apple-container/src/provider.ts b/workspaces/apple-container/src/provider.ts index 1e483217b23..88c057050bb 100644 --- a/workspaces/apple-container/src/provider.ts +++ b/workspaces/apple-container/src/provider.ts @@ -6,38 +6,39 @@ import type { SandboxProvider } from '@mastra/core/editor'; import { AppleContainerSandbox } from './sandbox'; import type { AppleContainerSandboxOptions } from './sandbox'; -export interface AppleContainerProviderConfig { - image?: string; - name?: string; - command?: string[]; - env?: Record; - volumes?: Record; - mounts?: string[]; - network?: string; - publishedPorts?: string[]; - publishedSockets?: string[]; - cpus?: number | string; - memory?: string; - platform?: string; - arch?: string; - os?: string; - rosetta?: boolean; - readonlyRootfs?: boolean; - ssh?: boolean; - init?: boolean; - virtualization?: boolean; - capAdd?: string[]; - capDrop?: string[]; - tmpfs?: string[]; - dns?: string[]; - dnsSearch?: string[]; - noDns?: boolean; - labels?: Record; - workingDir?: string; - timeout?: number; - deleteOnDestroy?: boolean; - containerBinary?: string; -} +export type AppleContainerProviderConfig = Pick< + AppleContainerSandboxOptions, + | 'image' + | 'name' + | 'command' + | 'env' + | 'volumes' + | 'mounts' + | 'network' + | 'publishedPorts' + | 'publishedSockets' + | 'cpus' + | 'memory' + | 'platform' + | 'arch' + | 'os' + | 'rosetta' + | 'readonlyRootfs' + | 'ssh' + | 'init' + | 'virtualization' + | 'capAdd' + | 'capDrop' + | 'tmpfs' + | 'dns' + | 'dnsSearch' + | 'noDns' + | 'labels' + | 'workingDir' + | 'timeout' + | 'deleteOnDestroy' + | 'containerBinary' +>; export const appleContainerSandboxProvider: SandboxProvider = { id: 'apple-container', diff --git a/workspaces/apple-container/src/sandbox/index.integration.test.ts b/workspaces/apple-container/src/sandbox/index.integration.test.ts index 2e6ded7224c..3c21c7ecf07 100644 --- a/workspaces/apple-container/src/sandbox/index.integration.test.ts +++ b/workspaces/apple-container/src/sandbox/index.integration.test.ts @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'; import { afterEach, describe, expect, it } from 'vitest'; import { AppleContainerSandbox } from './index'; +import type { AppleContainerSandboxOptions } from './index'; const shouldRunIntegration = process.env.MASTRA_APPLE_CONTAINER_INTEGRATION === '1'; const cliProbe = shouldRunIntegration ? spawnSync('container', ['--version'], { stdio: 'ignore' }) : undefined; @@ -12,21 +13,27 @@ if (shouldRunIntegration && !hasAppleContainerCli) { } describe.skipIf(!hasAppleContainerCli)('AppleContainerSandbox integration', () => { - let sandbox: AppleContainerSandbox | undefined; + const sandboxes: AppleContainerSandbox[] = []; - afterEach(async () => { - await sandbox?._destroy(); - sandbox = undefined; - }); - - it('starts an Apple container and executes a command', async () => { - sandbox = new AppleContainerSandbox({ - id: `mastra-apple-container-test-${Date.now()}`, + function createSandbox(options: Partial = {}) { + const sandbox = new AppleContainerSandbox({ + id: `mastra-apple-container-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, image: process.env.MASTRA_APPLE_CONTAINER_IMAGE ?? 'alpine:3.20', command: ['sleep', '3600'], workingDir: '/', timeout: 60_000, + ...options, }); + sandboxes.push(sandbox); + return sandbox; + } + + afterEach(async () => { + await Promise.allSettled(sandboxes.splice(0).map(sandbox => sandbox._destroy())); + }); + + it('starts an Apple container and executes a command', async () => { + const sandbox = createSandbox(); await sandbox._start(); const result = await sandbox.executeCommand('printf apple-container'); @@ -34,4 +41,45 @@ describe.skipIf(!hasAppleContainerCli)('AppleContainerSandbox integration', () = expect(result.success).toBe(true); expect(result.stdout).toBe('apple-container'); }, 120_000); + + it('stops and restarts the same Apple container', async () => { + const sandbox = createSandbox(); + + await sandbox._start(); + await sandbox._stop(); + expect(sandbox.status).toBe('stopped'); + + await sandbox._start(); + const result = await sandbox.executeCommand('pwd'); + + expect(sandbox.status).toBe('running'); + expect(result.success).toBe(true); + expect(result.stdout.trim()).toBe('/'); + }, 120_000); + + it('reconnects to an existing running Apple container', async () => { + const id = `mastra-apple-container-reconnect-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const first = createSandbox({ id }); + const second = createSandbox({ id }); + + await first._start(); + await second._start(); + + const result = await second.executeCommand('printf reconnected'); + expect(second.status).toBe('running'); + expect(result.stdout).toBe('reconnected'); + }, 120_000); + + it('deletes the Apple container on destroy', async () => { + const sandbox = createSandbox(); + + await sandbox._start(); + const containerId = sandbox.containerId; + await sandbox._destroy(); + + const inspect = spawnSync('container', ['inspect', containerId], { encoding: 'utf8' }); + expect(sandbox.status).toBe('destroyed'); + expect(inspect.status).not.toBe(0); + expect(inspect.stderr).toMatch(/not found|no such|does not exist|unknown container/i); + }, 120_000); }); diff --git a/workspaces/apple-container/src/sandbox/index.test.ts b/workspaces/apple-container/src/sandbox/index.test.ts index f8c09429a19..13eafae7ad4 100644 --- a/workspaces/apple-container/src/sandbox/index.test.ts +++ b/workspaces/apple-container/src/sandbox/index.test.ts @@ -1,6 +1,7 @@ import { SandboxExecutionError } from '@mastra/core/workspace'; import { describe, expect, it, vi } from 'vitest'; +import { appleContainerSandboxProvider } from '../provider'; import { AppleContainerSandbox, runAppleContainerCli } from './index'; import type { AppleContainerCliResult, AppleContainerCommandRunner, AppleContainerCommandRunnerOptions } from './index'; @@ -53,6 +54,10 @@ function inspectResult(status: string, id = 'container-123'): Partial { + return { success: false, exitCode: 1, stderr: 'container not found' }; +} + describe('AppleContainerSandbox', () => { it('uses default identity and instructions', () => { const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner: createRunner() }); @@ -65,10 +70,7 @@ describe('AppleContainerSandbox', () => { }); it('creates a long-lived Apple container when none exists', async () => { - const runner = createRunner([ - { success: false, exitCode: 1, stderr: 'container not found' }, - { stdout: 'created\n' }, - ]); + const runner = createRunner([missingContainerResult(), { stdout: 'created\n' }]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', image: 'python:3.12-slim', @@ -163,7 +165,7 @@ describe('AppleContainerSandbox', () => { it('keeps status in sync when plain lifecycle methods are called', async () => { const runner = createRunner([ - { success: false, exitCode: 1, stderr: 'container not found' }, + missingContainerResult(), {}, inspectResult('running'), {}, @@ -193,12 +195,35 @@ describe('AppleContainerSandbox', () => { expect(sandbox.containerId).toBe('existing-id'); }); - it('executes commands with env, cwd, timeout, streaming and retained output options', async () => { + it('reconnects to an existing running container without restarting it', async () => { + const runner = createRunner([inspectResult('running', 'existing-id')]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await sandbox._start(); + + expect(runner.run).toHaveBeenCalledOnce(); + expect(runner.run).toHaveBeenCalledWith(['inspect', 'apple-test']); + expect(sandbox.containerId).toBe('existing-id'); + expect(sandbox.status).toBe('running'); + }); + + it('throws when reconnecting to a stopped container fails', async () => { const runner = createRunner([ - { success: false, exitCode: 1, stderr: 'container not found' }, - {}, - { stdout: 'hello\n' }, + inspectResult('stopped', 'existing-id'), + { success: false, exitCode: 5, stderr: 'start failed' }, ]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await expect(sandbox.start()).rejects.toMatchObject({ + name: 'SandboxExecutionError', + exitCode: 5, + stderr: 'start failed', + }); + expect(sandbox.status).toBe('error'); + }); + + it('executes commands with env, cwd, timeout, streaming and retained output options', async () => { + const runner = createRunner([missingContainerResult(), {}, { stdout: 'hello\n' }]); const onStdout = vi.fn(); const sandbox = new AppleContainerSandbox({ id: 'apple-test', @@ -247,7 +272,7 @@ describe('AppleContainerSandbox', () => { }); it('quotes the command before executing through the shell', async () => { - const runner = createRunner([{ success: false, exitCode: 1, stderr: 'container not found' }, {}, {}]); + const runner = createRunner([missingContainerResult(), {}, {}]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); await sandbox._start(); @@ -270,7 +295,7 @@ describe('AppleContainerSandbox', () => { }); it('preserves shell command strings when no args are provided', async () => { - const runner = createRunner([{ success: false, exitCode: 1, stderr: 'container not found' }, {}, {}]); + const runner = createRunner([missingContainerResult(), {}, {}]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); await sandbox._start(); @@ -294,6 +319,27 @@ describe('AppleContainerSandbox', () => { expect(runner.run).toHaveBeenNthCalledWith(2, ['stop', 'apple-test']); }); + it('does not stop when the container is already stopped or missing', async () => { + const stoppedRunner = createRunner([inspectResult('stopped')]); + const missingRunner = createRunner([missingContainerResult()]); + + await new AppleContainerSandbox({ id: 'apple-stopped', runner: stoppedRunner }).stop(); + await new AppleContainerSandbox({ id: 'apple-missing', runner: missingRunner }).stop(); + + expect(stoppedRunner.run).toHaveBeenCalledOnce(); + expect(missingRunner.run).toHaveBeenCalledOnce(); + }); + + it('ignores a missing container race while stopping', async () => { + const runner = createRunner([inspectResult('running'), missingContainerResult()]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await sandbox.stop(); + + expect(sandbox.status).toBe('stopped'); + expect(runner.run).toHaveBeenNthCalledWith(2, ['stop', 'apple-test']); + }); + it('deletes existing containers on destroy by default', async () => { const runner = createRunner([inspectResult('running'), {}]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); @@ -304,6 +350,26 @@ describe('AppleContainerSandbox', () => { expect(runner.run).toHaveBeenNthCalledWith(2, ['delete', '--force', 'apple-test']); }); + it('does not delete when the container is missing', async () => { + const runner = createRunner([missingContainerResult()]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await sandbox.destroy(); + + expect(sandbox.status).toBe('destroyed'); + expect(runner.run).toHaveBeenCalledOnce(); + }); + + it('ignores a missing container race while deleting', async () => { + const runner = createRunner([inspectResult('running'), missingContainerResult()]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await sandbox.destroy(); + + expect(sandbox.status).toBe('destroyed'); + expect(runner.run).toHaveBeenNthCalledWith(2, ['delete', '--force', 'apple-test']); + }); + it('throws when stop fails unexpectedly', async () => { const runner = createRunner([ inspectResult('running'), @@ -344,9 +410,21 @@ describe('AppleContainerSandbox', () => { }); }); + it('throws when inspect fails for an unexpected reason', async () => { + const runner = createRunner([{ success: false, exitCode: 7, stderr: 'container service unavailable' }]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await expect(sandbox.start()).rejects.toMatchObject({ + name: 'SandboxExecutionError', + exitCode: 7, + stderr: 'container service unavailable', + }); + expect(sandbox.status).toBe('error'); + }); + it('throws SandboxExecutionError when create fails', async () => { const runner = createRunner([ - { success: false, exitCode: 1, stderr: 'container not found' }, + missingContainerResult(), { success: false, exitCode: 2, stderr: 'bad image' }, ]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); @@ -360,6 +438,43 @@ describe('AppleContainerSandbox', () => { }); }); +describe('appleContainerSandboxProvider', () => { + it('describes the Apple container sandbox provider', () => { + expect(appleContainerSandboxProvider.id).toBe('apple-container'); + expect(appleContainerSandboxProvider.name).toBe('Apple Container Sandbox'); + expect(appleContainerSandboxProvider.description).toContain('Apple container'); + }); + + it('creates an AppleContainerSandbox from serializable config', () => { + const sandbox = appleContainerSandboxProvider.createSandbox({ + image: 'node:22-slim', + env: { NODE_ENV: 'test' }, + readonlyRootfs: true, + containerBinary: 'container', + }); + + expect(sandbox).toBeInstanceOf(AppleContainerSandbox); + }); + + it('exposes schema entries for lifecycle, command, and runtime options', () => { + expect((appleContainerSandboxProvider.configSchema as any)?.properties).toEqual( + expect.objectContaining({ + image: expect.any(Object), + command: expect.any(Object), + env: expect.any(Object), + volumes: expect.any(Object), + workingDir: expect.any(Object), + timeout: expect.any(Object), + deleteOnDestroy: expect.any(Object), + containerBinary: expect.any(Object), + readonlyRootfs: expect.any(Object), + capAdd: expect.any(Object), + capDrop: expect.any(Object), + }), + ); + }); +}); + describe('runAppleContainerCli', () => { it('captures stdout and stderr from a child process', async () => { const result = await runAppleContainerCli(process.execPath, [ @@ -392,6 +507,33 @@ describe('runAppleContainerCli', () => { expect(result.stderrDroppedBytes).toBe(6); }); + it('times out and marks the command as killed', async () => { + const result = await runAppleContainerCli(process.execPath, ['-e', 'setTimeout(() => {}, 1000);'], { + timeout: 10, + }); + + expect(result.success).toBe(false); + expect(result.timedOut).toBe(true); + expect(result.killed).toBe(true); + }); + + it('aborts a running command', async () => { + const controller = new AbortController(); + const resultPromise = runAppleContainerCli(process.execPath, ['-e', 'setTimeout(() => {}, 1000);'], { + abortSignal: controller.signal, + }); + + controller.abort(); + + const result = await resultPromise; + expect(result.success).toBe(false); + expect(result.killed).toBe(true); + }); + + it('validates retained output limits with the shared process handle rules', () => { + expect(() => runAppleContainerCli(process.execPath, ['--version'], { maxRetainedBytes: -1 })).toThrow(RangeError); + }); + it('rejects with SandboxExecutionError when the CLI binary is missing', async () => { await expect(runAppleContainerCli('/definitely/missing/container', [])).rejects.toBeInstanceOf( SandboxExecutionError, diff --git a/workspaces/apple-container/src/sandbox/index.ts b/workspaces/apple-container/src/sandbox/index.ts index 34370997669..ee4704e30e7 100644 --- a/workspaces/apple-container/src/sandbox/index.ts +++ b/workspaces/apple-container/src/sandbox/index.ts @@ -9,6 +9,9 @@ */ import { spawn } from 'node:child_process'; +import type { ChildProcessByStdio } from 'node:child_process'; +import type { Readable } from 'node:stream'; +import { StringDecoder } from 'node:string_decoder'; import type { RequestContext } from '@mastra/core/di'; import type { CommandResult, @@ -18,9 +21,8 @@ import type { ProviderStatus, SandboxInfo, } from '@mastra/core/workspace'; -import { MastraSandbox, SandboxExecutionError } from '@mastra/core/workspace'; +import { MastraSandbox, ProcessHandle, SandboxExecutionError } from '@mastra/core/workspace'; -const LOG_PREFIX = '[AppleContainerSandbox]'; const DEFAULT_COMMAND_TIMEOUT_MS = 300_000; const DEFAULT_IMAGE = 'node:22-slim'; const DEFAULT_COMMAND = ['sleep', 'infinity']; @@ -402,9 +404,8 @@ export class AppleContainerSandbox extends MastraSandbox { private async _inspectContainer(): Promise { const result = await this._runner.run(['inspect', this.containerId]); if (!result.success) { - if (!isMissingContainerMessage(result.stderr)) { - this.logger.debug(`${LOG_PREFIX} inspect failed for ${this.containerId}: ${result.stderr}`); - } + if (isMissingContainerMessage(result.stderr)) return undefined; + this._assertSuccess(result, `inspect Apple container ${this.containerId}`); return undefined; } @@ -473,97 +474,152 @@ export function runAppleContainerCli( args: string[], options: AppleContainerCommandRunnerOptions = {}, ): Promise { - const startedAt = Date.now(); + const handle = new AppleContainerCliProcess(binary, args, options); + return handle.wait() as Promise; +} - return new Promise((resolve, reject) => { - let stdout = ''; - let stderr = ''; - let stdoutDroppedBytes = 0; - let stderrDroppedBytes = 0; - let settled = false; - let killed = false; - let timedOut = false; +class AppleContainerCliProcess extends ProcessHandle { + readonly pid: string; + exitCode: number | undefined; + + private readonly child: ChildProcessByStdio; + private readonly waitPromise: Promise; + private readonly startedAt = Date.now(); + private killed = false; + private timedOut = false; + private forceKillTimeout: NodeJS.Timeout | undefined; - const child = spawn(binary, args, { + constructor(binary: string, args: string[], options: AppleContainerCommandRunnerOptions = {}) { + super({ + maxRetainedBytes: options.maxRetainedBytes ?? Infinity, + onStdout: options.onStdout, + onStderr: options.onStderr, + }); + + this.child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'], }); + this.pid = this.child.pid ? String(this.child.pid) : `${binary}:${args.join(' ')}`; + + let settled = false; + const stdoutDecoder = new StringDecoder(); + const stderrDecoder = new StringDecoder(); + let stdoutDecoderEnded = false; + let stderrDecoderEnded = false; + let timeout: NodeJS.Timeout | undefined; + const onAbort = (): void => { + void this.kill(); + }; - const finish = (exitCode: number): void => { - if (settled) return; - settled = true; - resolve({ + const flushStdout = (): void => { + if (stdoutDecoderEnded) return; + stdoutDecoderEnded = true; + const data = stdoutDecoder.end(); + if (data) this.emitStdout(data); + }; + const flushStderr = (): void => { + if (stderrDecoderEnded) return; + stderrDecoderEnded = true; + const data = stderrDecoder.end(); + if (data) this.emitStderr(data); + }; + + const finish = (exitCode: number): AppleContainerCliResult => { + this.exitCode = exitCode; + return { success: exitCode === 0, exitCode, - stdout, - stderr, - executionTimeMs: Date.now() - startedAt, - killed, - timedOut, - ...(stdoutDroppedBytes > 0 && { stdoutTruncated: true, stdoutDroppedBytes }), - ...(stderrDroppedBytes > 0 && { stderrTruncated: true, stderrDroppedBytes }), - }); + stdout: this.stdout, + stderr: this.stderr, + executionTimeMs: Date.now() - this.startedAt, + killed: this.killed, + timedOut: this.timedOut, + }; }; - const kill = (): void => { - if (child.killed) return; - killed = true; - child.kill('SIGTERM'); - setTimeout(() => { - if (!settled && child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); - }, 1000).unref(); + const cleanup = (): void => { + if (timeout) clearTimeout(timeout); + if (this.forceKillTimeout) clearTimeout(this.forceKillTimeout); + options.abortSignal?.removeEventListener('abort', onAbort); }; - let timeout: NodeJS.Timeout | undefined; - if (options.timeout && options.timeout > 0) { - timeout = setTimeout(() => { - timedOut = true; - kill(); - }, options.timeout); - } + this.waitPromise = new Promise((resolve, reject) => { + const settle = (callback: () => void): void => { + if (settled) return; + settled = true; + cleanup(); + callback(); + }; + + this.child.stdout.on('data', (chunk: Buffer) => { + const data = stdoutDecoder.write(chunk); + if (data) this.emitStdout(data); + }); + this.child.stderr.on('data', (chunk: Buffer) => { + const data = stderrDecoder.write(chunk); + if (data) this.emitStderr(data); + }); + + this.child.stdout.on('end', flushStdout); + this.child.stderr.on('end', flushStderr); + + this.child.on('error', error => { + settle(() => { + flushStdout(); + flushStderr(); + reject( + error instanceof Error && 'code' in error && error.code === 'ENOENT' + ? new SandboxExecutionError(`Apple container CLI not found: ${binary}`, 127, this.stdout, error.message) + : error, + ); + }); + }); + + this.child.on('close', code => { + settle(() => { + flushStdout(); + flushStderr(); + resolve(finish(code ?? (this.killed ? 137 : 1))); + }); + }); + }); - const onAbort = (): void => kill(); + timeout = + options.timeout && options.timeout > 0 + ? setTimeout(() => { + this.timedOut = true; + void this.kill(); + }, options.timeout) + : undefined; if (options.abortSignal) { if (options.abortSignal.aborted) { - kill(); + void this.kill(); } else { options.abortSignal.addEventListener('abort', onAbort, { once: true }); } } + } - child.stdout?.on('data', (chunk: Buffer) => { - const data = chunk.toString('utf8'); - const retained = appendRetainedOutput(stdout, stdoutDroppedBytes, data, options.maxRetainedBytes); - stdout = retained.output; - stdoutDroppedBytes = retained.droppedBytes; - options.onStdout?.(data); - }); - - child.stderr?.on('data', (chunk: Buffer) => { - const data = chunk.toString('utf8'); - const retained = appendRetainedOutput(stderr, stderrDroppedBytes, data, options.maxRetainedBytes); - stderr = retained.output; - stderrDroppedBytes = retained.droppedBytes; - options.onStderr?.(data); - }); + async wait(): Promise { + return this.waitPromise; + } - child.on('error', error => { - if (settled) return; - settled = true; - if (timeout) clearTimeout(timeout); - options.abortSignal?.removeEventListener('abort', onAbort); - reject( - error instanceof Error && 'code' in error && error.code === 'ENOENT' - ? new SandboxExecutionError(`Apple container CLI not found: ${binary}`, 127, stdout, error.message) - : error, - ); - }); + async kill(): Promise { + if (this.exitCode !== undefined || this.child.killed) return false; + this.killed = true; + this.child.kill('SIGTERM'); + this.forceKillTimeout = setTimeout(() => { + if (this.exitCode === undefined && this.child.exitCode === null && this.child.signalCode === null) { + this.child.kill('SIGKILL'); + } + }, 1000); + this.forceKillTimeout.unref(); + return true; + } - child.on('close', code => { - if (timeout) clearTimeout(timeout); - options.abortSignal?.removeEventListener('abort', onAbort); - finish(code ?? (killed ? 137 : 1)); - }); - }); + async sendStdin(): Promise { + throw new Error('Apple container CLI runner does not support stdin'); + } } function generateId(): string { @@ -580,39 +636,6 @@ function shellQuote(arg: string): string { return `'${arg.replace(/'/g, "'\\''")}'`; } -function appendRetainedOutput( - currentOutput: string, - currentDroppedBytes: number, - chunk: string, - maxRetainedBytes: number | undefined, -): { output: string; droppedBytes: number } { - if (maxRetainedBytes === undefined || maxRetainedBytes === Infinity) { - return { output: currentOutput + chunk, droppedBytes: currentDroppedBytes }; - } - if (maxRetainedBytes <= 0) { - return { output: '', droppedBytes: currentDroppedBytes + Buffer.byteLength(chunk) }; - } - - let output = currentOutput + chunk; - let outputBytes = Buffer.byteLength(output); - if (outputBytes <= maxRetainedBytes) { - return { output, droppedBytes: currentDroppedBytes }; - } - - let droppedBytes = currentDroppedBytes; - while (output.length > 0 && outputBytes > maxRetainedBytes) { - const firstCodePoint = output.codePointAt(0); - if (firstCodePoint === undefined) break; - const firstChar = String.fromCodePoint(firstCodePoint); - output = output.slice(firstChar.length); - const byteLength = Buffer.byteLength(firstChar); - outputBytes -= byteLength; - droppedBytes += byteLength; - } - - return { output, droppedBytes }; -} - function envFlags(env: Record): string[] { const args: string[] = []; for (const [key, value] of Object.entries(env)) { diff --git a/workspaces/apple-container/test/core-workspace.ts b/workspaces/apple-container/test/core-workspace.ts index 1032b16f8f6..ed59f942a8e 100644 --- a/workspaces/apple-container/test/core-workspace.ts +++ b/workspaces/apple-container/test/core-workspace.ts @@ -1,4 +1,5 @@ export { MastraSandbox } from '../../../packages/core/src/workspace/sandbox/mastra-sandbox'; +export { ProcessHandle } from '../../../packages/core/src/workspace/sandbox/process-manager'; export { SandboxExecutionError } from '../../../packages/core/src/workspace/sandbox/errors'; export type { ProviderStatus } from '../../../packages/core/src/workspace/lifecycle'; export type { InstructionsOption } from '../../../packages/core/src/workspace/types'; From 9bd98b2960b9c83009bd04b02b293fd17fdf03c2 Mon Sep 17 00:00:00 2001 From: Max Shugar Date: Mon, 29 Jun 2026 23:55:11 +0100 Subject: [PATCH 15/16] fix: harden apple container sandbox review issues --- .../workspace/apple-container-sandbox.mdx | 15 ++ workspaces/apple-container/README.md | 15 ++ workspaces/apple-container/src/provider.ts | 73 ++++++- .../src/sandbox/index.integration.test.ts | 22 ++ .../apple-container/src/sandbox/index.test.ts | 190 +++++++++++++----- .../apple-container/src/sandbox/index.ts | 98 +++++++-- 6 files changed, 345 insertions(+), 68 deletions(-) diff --git a/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx b/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx index e6c05ba73d9..35eb340115d 100644 --- a/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx +++ b/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx @@ -359,6 +359,21 @@ const sandbox = new AppleContainerSandbox({ These options are only applied when a new container is created. If the sandbox reconnects to an existing container with the same name, destroy and recreate the sandbox to apply changed runtime options. When `readonlyRootfs` is enabled, make sure `workingDir` points to a path supplied by the image, a bind mount, or a writable tmpfs. +## Security model + +`AppleContainerSandbox` runs local containers through the host Apple `container` service. Treat constructor options as trusted server-side configuration: + +- `volumes`, `mounts`, and `publishedSockets` can expose host paths to containerized code. +- `ssh` forwards the host SSH agent socket. +- `capAdd` and `virtualization` can expand what containerized code can do. +- `containerBinary` is a constructor-only escape hatch for trusted code and is not part of the serializable editor provider schema. + +Use the narrowest mounts and capabilities your workload needs. Existing containers are only reconnected when they carry Mastra ownership labels for the sandbox ID. + +## Limitations + +`AppleContainerSandbox` implements foreground workspace command execution with `executeCommand()`. It does not yet expose a `SandboxProcessManager` for background processes or LSP sessions. + ## Reconnection `AppleContainerSandbox` reconnects by inspecting a container with the configured name. When `start()` is called: diff --git a/workspaces/apple-container/README.md b/workspaces/apple-container/README.md index 902b5d2ba4b..c290de02240 100644 --- a/workspaces/apple-container/README.md +++ b/workspaces/apple-container/README.md @@ -82,6 +82,21 @@ await workspace.destroy(); When `readonlyRootfs` is enabled, make sure `workingDir` points to a path supplied by the image, a bind mount, or a writable tmpfs. +## Security model + +This provider runs local containers through the host Apple `container` service. Treat constructor options as trusted server-side configuration: + +- `volumes`, `mounts`, and `publishedSockets` can expose host paths to containerized code. +- `ssh` forwards the host SSH agent socket. +- `capAdd` and `virtualization` can expand what containerized code can do. +- `containerBinary` is intentionally a constructor-only escape hatch for trusted code and is not part of the serializable editor provider schema. + +Use the narrowest mounts and capabilities your workload needs. Existing containers are only reconnected when they carry Mastra ownership labels for the sandbox ID. + +## Limitations + +`AppleContainerSandbox` implements foreground workspace command execution with `executeCommand()`. It does not yet expose a `SandboxProcessManager` for background processes or LSP sessions. + ## Editor provider Register the provider with `MastraEditor` to hydrate stored sandbox configs: diff --git a/workspaces/apple-container/src/provider.ts b/workspaces/apple-container/src/provider.ts index 88c057050bb..bbacbc62ac2 100644 --- a/workspaces/apple-container/src/provider.ts +++ b/workspaces/apple-container/src/provider.ts @@ -37,7 +37,6 @@ export type AppleContainerProviderConfig = Pick< | 'workingDir' | 'timeout' | 'deleteOnDestroy' - | 'containerBinary' >; export const appleContainerSandboxProvider: SandboxProvider = { @@ -46,6 +45,7 @@ export const appleContainerSandboxProvider: SandboxProvider new AppleContainerSandbox(config as AppleContainerSandboxOptions), + createSandbox: config => { + const { + image, + name, + command, + env, + volumes, + mounts, + network, + publishedPorts, + publishedSockets, + cpus, + memory, + platform, + arch, + os, + rosetta, + readonlyRootfs, + ssh, + init, + virtualization, + capAdd, + capDrop, + tmpfs, + dns, + dnsSearch, + noDns, + labels, + workingDir, + timeout, + deleteOnDestroy, + } = config; + + return new AppleContainerSandbox({ + image, + name, + command, + env, + volumes, + mounts, + network, + publishedPorts, + publishedSockets, + cpus, + memory, + platform, + arch, + os, + rosetta, + readonlyRootfs, + ssh, + init, + virtualization, + capAdd, + capDrop, + tmpfs, + dns, + dnsSearch, + noDns, + labels, + workingDir, + timeout, + deleteOnDestroy, + }); + }, }; diff --git a/workspaces/apple-container/src/sandbox/index.integration.test.ts b/workspaces/apple-container/src/sandbox/index.integration.test.ts index 3c21c7ecf07..4132fb26c64 100644 --- a/workspaces/apple-container/src/sandbox/index.integration.test.ts +++ b/workspaces/apple-container/src/sandbox/index.integration.test.ts @@ -28,6 +28,14 @@ describe.skipIf(!hasAppleContainerCli)('AppleContainerSandbox integration', () = return sandbox; } + function inspectContainerState(containerId: string): string { + const inspect = spawnSync('container', ['inspect', containerId], { encoding: 'utf8' }); + expect(inspect.status, inspect.stderr).toBe(0); + + const [container] = JSON.parse(inspect.stdout) as Array<{ status?: string | { state?: string } }>; + return typeof container.status === 'string' ? container.status : (container.status?.state ?? 'unknown'); + } + afterEach(async () => { await Promise.allSettled(sandboxes.splice(0).map(sandbox => sandbox._destroy())); }); @@ -48,6 +56,7 @@ describe.skipIf(!hasAppleContainerCli)('AppleContainerSandbox integration', () = await sandbox._start(); await sandbox._stop(); expect(sandbox.status).toBe('stopped'); + expect(inspectContainerState(sandbox.containerId)).toBe('stopped'); await sandbox._start(); const result = await sandbox.executeCommand('pwd'); @@ -70,6 +79,19 @@ describe.skipIf(!hasAppleContainerCli)('AppleContainerSandbox integration', () = expect(result.stdout).toBe('reconnected'); }, 120_000); + it('cleans up timed-out commands inside the Apple container', async () => { + const sandbox = createSandbox(); + + await sandbox._start(); + const result = await sandbox.executeCommand('sleep', ['30'], { timeout: 100 }); + const pgrep = await sandbox.executeCommand("pgrep -af '[s]leep 30'", [], { timeout: 5_000 }); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(124); + expect(result.timedOut).toBe(true); + expect(pgrep.success).toBe(false); + }, 120_000); + it('deletes the Apple container on destroy', async () => { const sandbox = createSandbox(); diff --git a/workspaces/apple-container/src/sandbox/index.test.ts b/workspaces/apple-container/src/sandbox/index.test.ts index 13eafae7ad4..ea84bb01de2 100644 --- a/workspaces/apple-container/src/sandbox/index.test.ts +++ b/workspaces/apple-container/src/sandbox/index.test.ts @@ -9,9 +9,9 @@ type RunnerResponse = | Partial | ((args: string[], options?: AppleContainerCommandRunnerOptions) => Partial); -function createRunner( - responses: RunnerResponse[] = [], -): AppleContainerCommandRunner & { run: ReturnType } { +type MockRunner = AppleContainerCommandRunner & { run: ReturnType }; + +function createRunner(responses: RunnerResponse[] = []): MockRunner { const queue = [...responses]; return { @@ -26,6 +26,15 @@ function createRunner( }; } +function expectCliCall( + runner: MockRunner, + call: number, + args: string[], + options: unknown = expect.objectContaining({ timeout: 300_000 }), +): void { + expect(runner.run).toHaveBeenNthCalledWith(call, args, options); +} + function cliResult(overrides: Partial = {}): AppleContainerCliResult { return { success: true, @@ -41,9 +50,17 @@ function inspectResult(status: string, id = 'container-123'): Partial { + return { + stdout: JSON.stringify([ + { + id, + status: { state: status }, + configuration: { + id, + labels: { + app: 'not-mastra', + }, + }, + }, + ]), + }; +} + function missingContainerResult(): Partial { return { success: false, exitCode: 1, stderr: 'container not found' }; } @@ -104,8 +138,8 @@ describe('AppleContainerSandbox', () => { await sandbox._start(); - expect(runner.run).toHaveBeenNthCalledWith(1, ['inspect', 'apple-test']); - expect(runner.run).toHaveBeenNthCalledWith(2, [ + expectCliCall(runner, 1, ['inspect', 'apple-test']); + expectCliCall(runner, 2, [ 'run', '-d', '--name', @@ -190,8 +224,8 @@ describe('AppleContainerSandbox', () => { await sandbox._start(); - expect(runner.run).toHaveBeenNthCalledWith(1, ['inspect', 'apple-test']); - expect(runner.run).toHaveBeenNthCalledWith(2, ['start', 'existing-id']); + expectCliCall(runner, 1, ['inspect', 'apple-test']); + expectCliCall(runner, 2, ['start', 'existing-id']); expect(sandbox.containerId).toBe('existing-id'); }); @@ -202,11 +236,23 @@ describe('AppleContainerSandbox', () => { await sandbox._start(); expect(runner.run).toHaveBeenCalledOnce(); - expect(runner.run).toHaveBeenCalledWith(['inspect', 'apple-test']); + expect(runner.run).toHaveBeenCalledWith(['inspect', 'apple-test'], expect.objectContaining({ timeout: 300_000 })); expect(sandbox.containerId).toBe('existing-id'); expect(sandbox.status).toBe('running'); }); + it('refuses to reconnect to a container without matching Mastra labels', async () => { + const runner = createRunner([unownedInspectResult('running', 'existing-id')]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await expect(sandbox.start()).rejects.toMatchObject({ + name: 'SandboxExecutionError', + message: expect.stringContaining('not labeled as Mastra sandbox apple-test'), + }); + expect(sandbox.status).toBe('error'); + expect(runner.run).toHaveBeenCalledOnce(); + }); + it('throws when reconnecting to a stopped container fails', async () => { const runner = createRunner([ inspectResult('stopped', 'existing-id'), @@ -261,24 +307,41 @@ describe('AppleContainerSandbox', () => { 'apple-test', 'sh', '-lc', - 'node -e \'console.log("hello")\'', + expect.stringContaining("timeout 1.234s sh -lc 'node -e '\\''console.log(\"hello\")'\\'''"), ], expect.objectContaining({ - timeout: 1234, + timeout: 11_234, onStdout, maxRetainedBytes: 16, }), ); }); - it('quotes the command before executing through the shell', async () => { + it('quotes args before executing through the shell', async () => { + const runner = createRunner([missingContainerResult(), {}, {}]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await sandbox._start(); + runner.run.mockClear(); + + await sandbox.executeCommand('printf', ['hello; touch /tmp/pwned']); + + const [cliArgs, cliOptions] = runner.run.mock.calls[0]; + expect(cliArgs.slice(0, -1)).toEqual(['exec', '--workdir', '/workspace', 'apple-test', 'sh', '-lc']); + expect(cliArgs.at(-1)).toContain('printf'); + expect(cliArgs.at(-1)).toContain('hello; touch /tmp/pwned'); + expect(cliArgs.at(-1)).toContain("'\\''hello; touch /tmp/pwned'\\'''"); + expect(cliOptions).toEqual(expect.objectContaining({ timeout: 310_000 })); + }); + + it('preserves shell command strings when no args are provided', async () => { const runner = createRunner([missingContainerResult(), {}, {}]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); await sandbox._start(); runner.run.mockClear(); - await sandbox.executeCommand('node; touch /tmp/pwned', ['-v']); + await sandbox.executeCommand('printf apple-container'); expect(runner.run).toHaveBeenCalledWith( [ @@ -288,24 +351,35 @@ describe('AppleContainerSandbox', () => { 'apple-test', 'sh', '-lc', - "'node; touch /tmp/pwned' -v", + expect.stringContaining('printf apple-container'), ], - expect.any(Object), + expect.objectContaining({ timeout: 310_000 }), ); }); - it('preserves shell command strings when no args are provided', async () => { - const runner = createRunner([missingContainerResult(), {}, {}]); + it('marks in-container timeout exits as timed out', async () => { + const runner = createRunner([missingContainerResult(), {}, { success: false, exitCode: 124, stderr: 'Terminated' }]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); - await sandbox._start(); - runner.run.mockClear(); + const result = await sandbox.executeCommand('sleep', ['30'], { timeout: 10 }); - await sandbox.executeCommand('printf apple-container'); - - expect(runner.run).toHaveBeenCalledWith( - ['exec', '--workdir', '/workspace', 'apple-test', 'sh', '-lc', 'printf apple-container'], - expect.any(Object), + expect(result).toMatchObject({ + success: false, + exitCode: 124, + timedOut: true, + killed: true, + }); + expect(runner.run).toHaveBeenLastCalledWith( + [ + 'exec', + '--workdir', + '/workspace', + 'apple-test', + 'sh', + '-lc', + expect.stringContaining("timeout 0.01s sh -lc 'sleep 30'"), + ], + expect.objectContaining({ timeout: 10_010 }), ); }); @@ -315,8 +389,8 @@ describe('AppleContainerSandbox', () => { await sandbox.destroy(); - expect(runner.run).toHaveBeenNthCalledWith(1, ['inspect', 'apple-test']); - expect(runner.run).toHaveBeenNthCalledWith(2, ['stop', 'apple-test']); + expectCliCall(runner, 1, ['inspect', 'apple-test']); + expectCliCall(runner, 2, ['stop', 'apple-test']); }); it('does not stop when the container is already stopped or missing', async () => { @@ -337,7 +411,7 @@ describe('AppleContainerSandbox', () => { await sandbox.stop(); expect(sandbox.status).toBe('stopped'); - expect(runner.run).toHaveBeenNthCalledWith(2, ['stop', 'apple-test']); + expectCliCall(runner, 2, ['stop', 'apple-test']); }); it('deletes existing containers on destroy by default', async () => { @@ -346,8 +420,8 @@ describe('AppleContainerSandbox', () => { await sandbox.destroy(); - expect(runner.run).toHaveBeenNthCalledWith(1, ['inspect', 'apple-test']); - expect(runner.run).toHaveBeenNthCalledWith(2, ['delete', '--force', 'apple-test']); + expectCliCall(runner, 1, ['inspect', 'apple-test']); + expectCliCall(runner, 2, ['delete', '--force', 'apple-test']); }); it('does not delete when the container is missing', async () => { @@ -367,7 +441,7 @@ describe('AppleContainerSandbox', () => { await sandbox.destroy(); expect(sandbox.status).toBe('destroyed'); - expect(runner.run).toHaveBeenNthCalledWith(2, ['delete', '--force', 'apple-test']); + expectCliCall(runner, 2, ['delete', '--force', 'apple-test']); }); it('throws when stop fails unexpectedly', async () => { @@ -450,28 +524,52 @@ describe('appleContainerSandboxProvider', () => { image: 'node:22-slim', env: { NODE_ENV: 'test' }, readonlyRootfs: true, - containerBinary: 'container', }); expect(sandbox).toBeInstanceOf(AppleContainerSandbox); }); - it('exposes schema entries for lifecycle, command, and runtime options', () => { - expect((appleContainerSandboxProvider.configSchema as any)?.properties).toEqual( - expect.objectContaining({ - image: expect.any(Object), - command: expect.any(Object), - env: expect.any(Object), - volumes: expect.any(Object), - workingDir: expect.any(Object), - timeout: expect.any(Object), - deleteOnDestroy: expect.any(Object), - containerBinary: expect.any(Object), - readonlyRootfs: expect.any(Object), - capAdd: expect.any(Object), - capDrop: expect.any(Object), - }), + it('exposes an exact serializable Studio schema', () => { + const schema = appleContainerSandboxProvider.configSchema as { + additionalProperties?: boolean; + properties?: Record; + }; + + expect(schema.additionalProperties).toBe(false); + expect(Object.keys(schema.properties ?? {}).sort()).toEqual( + [ + 'arch', + 'capAdd', + 'capDrop', + 'command', + 'cpus', + 'deleteOnDestroy', + 'dns', + 'dnsSearch', + 'env', + 'image', + 'init', + 'labels', + 'memory', + 'mounts', + 'name', + 'network', + 'noDns', + 'os', + 'platform', + 'publishedPorts', + 'publishedSockets', + 'readonlyRootfs', + 'rosetta', + 'ssh', + 'timeout', + 'tmpfs', + 'virtualization', + 'volumes', + 'workingDir', + ].sort(), ); + expect(schema.properties).not.toHaveProperty('containerBinary'); }); }); diff --git a/workspaces/apple-container/src/sandbox/index.ts b/workspaces/apple-container/src/sandbox/index.ts index ee4704e30e7..1bfeac42717 100644 --- a/workspaces/apple-container/src/sandbox/index.ts +++ b/workspaces/apple-container/src/sandbox/index.ts @@ -27,6 +27,8 @@ const DEFAULT_COMMAND_TIMEOUT_MS = 300_000; const DEFAULT_IMAGE = 'node:22-slim'; const DEFAULT_COMMAND = ['sleep', 'infinity']; const DEFAULT_WORKING_DIR = '/workspace'; +const APPLE_CONTAINER_CLI_GRACE_TIMEOUT_MS = 10_000; +const APPLE_CONTAINER_TIMEOUT_EXIT_CODE = 124; export interface AppleContainerCliResult { success: boolean; @@ -63,11 +65,18 @@ export class DefaultAppleContainerCommandRunner implements AppleContainerCommand } interface AppleContainerInspectResult { - status?: string; - networks?: Array>; + id?: string; + status?: + | string + | { + state?: string; + networks?: Array>; + }; configuration?: { id?: string; hostname?: string; + labels?: Record; + networks?: Array>; resources?: { cpus?: number; memoryInBytes?: number; @@ -250,15 +259,16 @@ export class AppleContainerSandbox extends MastraSandbox { private async _startContainer(): Promise { const existing = await this._inspectContainer(); if (existing) { + this._assertMastraOwned(existing); this._containerId = existing.configuration?.id ?? this._containerName; if (!isRunning(existing)) { - const result = await this._runner.run(['start', this.containerId]); + const result = await this._runCli(['start', this.containerId]); this._assertSuccess(result, `start Apple container ${this.containerId}`); } return; } - const result = await this._runner.run(this._buildRunArgs()); + const result = await this._runCli(this._buildRunArgs()); this._assertSuccess(result, `create Apple container ${this._containerName}`); this._containerId = this._containerName; } @@ -268,8 +278,9 @@ export class AppleContainerSandbox extends MastraSandbox { if (!existing || !isRunning(existing)) { return; } + this._assertMastraOwned(existing); - const result = await this._runner.run(['stop', this.containerId]); + const result = await this._runCli(['stop', this.containerId]); if (!result.success && !isMissingContainerMessage(result.stderr)) { this._assertSuccess(result, `stop Apple container ${this.containerId}`); } @@ -285,8 +296,9 @@ export class AppleContainerSandbox extends MastraSandbox { if (!existing) { return; } + this._assertMastraOwned(existing); - const result = await this._runner.run(['delete', '--force', this.containerId]); + const result = await this._runCli(['delete', '--force', this.containerId]); if (!result.success && !isMissingContainerMessage(result.stderr)) { this._assertSuccess(result, `delete Apple container ${this.containerId}`); } @@ -299,7 +311,10 @@ export class AppleContainerSandbox extends MastraSandbox { ): Promise { await this.ensureRunning(); - const fullCommand = args.length > 0 ? [command, ...args].map(shellQuote).join(' ') : command; + const commandTimeout = options.timeout ?? this._timeout; + const hasCommandTimeout = Number.isFinite(commandTimeout) && commandTimeout > 0; + const fullCommand = buildShellCommand(command, args); + const shellCommand = hasCommandTimeout ? buildTimeoutShellCommand(fullCommand, commandTimeout) : fullCommand; const env = { ...this._env, ...options.env }; const cliArgs = [ 'exec', @@ -309,23 +324,30 @@ export class AppleContainerSandbox extends MastraSandbox { this.containerId, 'sh', '-lc', - fullCommand, + shellCommand, ]; const result = await this._runner.run(cliArgs, { - timeout: options.timeout ?? this._timeout, + timeout: hasCommandTimeout ? commandTimeout + APPLE_CONTAINER_CLI_GRACE_TIMEOUT_MS : undefined, abortSignal: options.abortSignal, onStdout: options.onStdout, onStderr: options.onStderr, maxRetainedBytes: options.maxRetainedBytes, }); - return { ...result, command: fullCommand, args }; + return { + ...result, + ...(result.exitCode === APPLE_CONTAINER_TIMEOUT_EXIT_CODE && { timedOut: true, killed: true }), + command: fullCommand, + args, + }; } async getInfo(): Promise { const inspect = this._containerId ? await this._inspectContainer() : undefined; const resources = inspect?.configuration?.resources; + const appleContainerStatus = inspect ? getContainerState(inspect) : undefined; + const networks = inspect ? getContainerNetworks(inspect) : undefined; return { id: this.id, @@ -344,8 +366,8 @@ export class AppleContainerSandbox extends MastraSandbox { containerId: this.containerId, image: this._image, workingDir: this._workingDir, - ...(inspect?.status && { appleContainerStatus: inspect.status }), - ...(inspect?.networks && { networks: inspect.networks }), + ...(appleContainerStatus && { appleContainerStatus }), + ...(networks && { networks }), }, }; } @@ -402,7 +424,7 @@ export class AppleContainerSandbox extends MastraSandbox { } private async _inspectContainer(): Promise { - const result = await this._runner.run(['inspect', this.containerId]); + const result = await this._runCli(['inspect', this.containerId]); if (!result.success) { if (isMissingContainerMessage(result.stderr)) return undefined; this._assertSuccess(result, `inspect Apple container ${this.containerId}`); @@ -467,6 +489,23 @@ export class AppleContainerSandbox extends MastraSandbox { result.stderr, ); } + + private _assertMastraOwned(inspect: AppleContainerInspectResult): void { + if (isMastraOwned(inspect, this.id)) return; + throw new SandboxExecutionError( + `Refusing to manage Apple container ${this.containerId} because it is not labeled as Mastra sandbox ${this.id}`, + 1, + JSON.stringify(inspect), + '', + ); + } + + private _runCli(args: string[], options: AppleContainerCommandRunnerOptions = {}): Promise { + return this._runner.run(args, { + timeout: this._timeout, + ...options, + }); + } } export function runAppleContainerCli( @@ -491,7 +530,7 @@ class AppleContainerCliProcess extends ProcessHandle { constructor(binary: string, args: string[], options: AppleContainerCommandRunnerOptions = {}) { super({ - maxRetainedBytes: options.maxRetainedBytes ?? Infinity, + maxRetainedBytes: options.maxRetainedBytes, onStdout: options.onStdout, onStderr: options.onStderr, }); @@ -631,6 +670,22 @@ function sanitizeContainerName(name: string): string { return /^[a-zA-Z0-9]/.test(sanitized) ? sanitized : `c-${sanitized}`; } +function buildShellCommand(command: string, args: string[]): string { + return args.length > 0 ? `${command} ${args.map(shellQuote).join(' ')}` : command; +} + +function buildTimeoutShellCommand(command: string, timeoutMs: number): string { + const timeoutSeconds = formatTimeoutSeconds(timeoutMs); + return `timeout ${timeoutSeconds}s sh -lc ${shellQuote(command)}; code=$?; case "$code" in 124|137|143) exit ${APPLE_CONTAINER_TIMEOUT_EXIT_CODE};; *) exit "$code";; esac`; +} + +function formatTimeoutSeconds(timeoutMs: number): string { + return Math.max(timeoutMs / 1000, 0.001) + .toFixed(3) + .replace(/0+$/, '') + .replace(/\.$/, ''); +} + function shellQuote(arg: string): string { if (/^[a-zA-Z0-9._\-\/=:@]+$/.test(arg)) return arg; return `'${arg.replace(/'/g, "'\\''")}'`; @@ -649,7 +704,20 @@ function envFlags(env: Record): string[] { } function isRunning(inspect: AppleContainerInspectResult): boolean { - return inspect.status === 'running'; + return getContainerState(inspect) === 'running'; +} + +function getContainerState(inspect: AppleContainerInspectResult): string | undefined { + return typeof inspect.status === 'string' ? inspect.status : inspect.status?.state; +} + +function getContainerNetworks(inspect: AppleContainerInspectResult): Array> | undefined { + return (typeof inspect.status === 'object' ? inspect.status.networks : undefined) ?? inspect.configuration?.networks; +} + +function isMastraOwned(inspect: AppleContainerInspectResult, sandboxId: string): boolean { + const labels = inspect.configuration?.labels; + return labels?.['mastra.sandbox'] === 'true' && labels['mastra.sandbox.id'] === sandboxId; } function isMissingContainerMessage(message: string): boolean { From 92eccfd4b523d60ab166b2bb15b85c2ae1943766 Mon Sep 17 00:00:00 2001 From: Max Shugar Date: Tue, 30 Jun 2026 00:19:23 +0100 Subject: [PATCH 16/16] fix: harden apple container lifecycle review cases --- .../workspace/apple-container-sandbox.mdx | 23 +- workspaces/apple-container/README.md | 11 +- workspaces/apple-container/src/index.ts | 1 + workspaces/apple-container/src/provider.ts | 9 +- .../src/sandbox/index.integration.test.ts | 35 +++ .../apple-container/src/sandbox/index.test.ts | 162 ++++++++-- .../apple-container/src/sandbox/index.ts | 277 ++++++++++++++++-- 7 files changed, 456 insertions(+), 62 deletions(-) diff --git a/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx b/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx index 35eb340115d..78bb51416c4 100644 --- a/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx +++ b/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx @@ -21,7 +21,7 @@ For interface details, see [WorkspaceSandbox interface](/reference/workspace/san npm install @mastra/apple-container ``` -Requires a host that can run Apple's `container` CLI. Start the container system before using the provider: +Requires an Apple silicon Mac running macOS 26 or newer with Apple's `container` CLI installed. Start the container system before using the provider: ```bash container system start @@ -207,7 +207,7 @@ console.log(response.text) { name: 'tmpfs', type: 'string[]', - description: 'tmpfs mount specs passed as `--tmpfs`.', + description: 'tmpfs destination paths passed as `--tmpfs`, for example `/tmp`.', isOptional: true, }, { @@ -352,11 +352,12 @@ const sandbox = new AppleContainerSandbox({ memory: '2G', platform: 'linux/arm64', readonlyRootfs: true, - tmpfs: ['/tmp:rw,size=256m'], + tmpfs: ['/tmp'], }) ``` These options are only applied when a new container is created. If the sandbox reconnects to an existing container with the same name, destroy and recreate the sandbox to apply changed runtime options. +Apple `--tmpfs` accepts container paths only, such as `/tmp`; it does not accept Docker-style option specs like `/tmp:rw,size=256m`. When `readonlyRootfs` is enabled, make sure `workingDir` points to a path supplied by the image, a bind mount, or a writable tmpfs. ## Security model @@ -364,16 +365,19 @@ When `readonlyRootfs` is enabled, make sure `workingDir` points to a path suppli `AppleContainerSandbox` runs local containers through the host Apple `container` service. Treat constructor options as trusted server-side configuration: - `volumes`, `mounts`, and `publishedSockets` can expose host paths to containerized code. +- `publishedPorts` can expose in-container services on the host or network; bind to `127.0.0.1` when only local access is intended. - `ssh` forwards the host SSH agent socket. - `capAdd` and `virtualization` can expand what containerized code can do. - `containerBinary` is a constructor-only escape hatch for trusted code and is not part of the serializable editor provider schema. -Use the narrowest mounts and capabilities your workload needs. Existing containers are only reconnected when they carry Mastra ownership labels for the sandbox ID. +Use the narrowest mounts and capabilities your workload needs. Existing containers are only reconnected when they carry Mastra ownership labels for the sandbox ID. Containers created by this provider also include a config-hash label; when that label is present, reconnect fails if immutable runtime options such as image, command, mounts, ports, capabilities, or working directory changed. ## Limitations `AppleContainerSandbox` implements foreground workspace command execution with `executeCommand()`. It does not yet expose a `SandboxProcessManager` for background processes or LSP sessions. +Command timeouts are enforced inside the container so timed-out commands are cleaned up by the container runtime. Abort signals cancel the host CLI wait path and should not be used as a substitute for command timeouts when in-container cleanup matters. + ## Reconnection `AppleContainerSandbox` reconnects by inspecting a container with the configured name. When `start()` is called: @@ -381,6 +385,8 @@ Use the narrowest mounts and capabilities your workload needs. Existing containe - A running container is reused. - A stopped container is restarted. - A missing container is created from the configured image. +- A container with the configured name but without matching Mastra ownership labels fails instead of being managed. +- A Mastra-owned container with a config-hash label that does not match immutable runtime options fails instead of being reused. ```typescript const sandbox = new AppleContainerSandbox({ id: 'persistent-sandbox' }) @@ -395,7 +401,7 @@ await sandbox2.start() Register the provider with `MastraEditor` to hydrate stored sandbox configs: ```typescript -import { MastraEditor } from '@mastra/core/editor' +import { MastraEditor } from '@mastra/editor' import { appleContainerSandboxProvider } from '@mastra/apple-container' const editor = new MastraEditor({ @@ -404,3 +410,10 @@ const editor = new MastraEditor({ }, }) ``` + +## Related + +- [WorkspaceSandbox interface](/reference/workspace/sandbox) +- [DockerSandbox reference](/reference/workspace/docker-sandbox) +- [LocalSandbox reference](/reference/workspace/local-sandbox) +- [Workspace overview](/docs/workspace/overview) diff --git a/workspaces/apple-container/README.md b/workspaces/apple-container/README.md index c290de02240..05c510d635a 100644 --- a/workspaces/apple-container/README.md +++ b/workspaces/apple-container/README.md @@ -65,7 +65,7 @@ await workspace.destroy(); | `virtualization` | `boolean` | Expose virtualization capabilities to the container. | | `capAdd` | `string[]` | Linux capabilities to add. | | `capDrop` | `string[]` | Linux capabilities to drop. | -| `tmpfs` | `string[]` | tmpfs mount specs. | +| `tmpfs` | `string[]` | tmpfs destination paths, for example `/tmp`. | | `dns` | `string[]` | DNS nameserver IPs. | | `dnsSearch` | `string[]` | DNS search domains. | | `noDns` | `boolean` | Do not configure DNS in the container. | @@ -74,12 +74,12 @@ await workspace.destroy(); | `timeout` | `number` | Default command timeout in milliseconds. | | `deleteOnDestroy` | `boolean` | Delete the Apple container on destroy. Defaults to `true`. | | `containerBinary` | `string` | Path or name for the Apple container CLI. Defaults to `container`. | -| `runner` | `AppleContainerCommandRunner` | Custom command runner, primarily for tests. | | `onStart` | `({ sandbox }) => unknown` | Lifecycle hook called after the sandbox reaches `running`. | | `onStop` | `({ sandbox }) => unknown` | Lifecycle hook called before the sandbox stops. | | `onDestroy` | `({ sandbox }) => unknown` | Lifecycle hook called before the sandbox is destroyed. | | `instructions` | `string \| (opts) => string` | Override or extend the default workspace sandbox instructions. | +Apple `--tmpfs` accepts container paths only, such as `/tmp`; it does not accept Docker-style option specs like `/tmp:rw,size=256m`. When `readonlyRootfs` is enabled, make sure `workingDir` points to a path supplied by the image, a bind mount, or a writable tmpfs. ## Security model @@ -87,22 +87,25 @@ When `readonlyRootfs` is enabled, make sure `workingDir` points to a path suppli This provider runs local containers through the host Apple `container` service. Treat constructor options as trusted server-side configuration: - `volumes`, `mounts`, and `publishedSockets` can expose host paths to containerized code. +- `publishedPorts` can expose in-container services on the host or network; bind to `127.0.0.1` when only local access is intended. - `ssh` forwards the host SSH agent socket. - `capAdd` and `virtualization` can expand what containerized code can do. - `containerBinary` is intentionally a constructor-only escape hatch for trusted code and is not part of the serializable editor provider schema. -Use the narrowest mounts and capabilities your workload needs. Existing containers are only reconnected when they carry Mastra ownership labels for the sandbox ID. +Use the narrowest mounts and capabilities your workload needs. Existing containers are only reconnected when they carry Mastra ownership labels for the sandbox ID. Containers created by this provider also include a config-hash label; when that label is present, reconnect fails if immutable runtime options such as image, command, mounts, ports, capabilities, or working directory changed. ## Limitations `AppleContainerSandbox` implements foreground workspace command execution with `executeCommand()`. It does not yet expose a `SandboxProcessManager` for background processes or LSP sessions. +Command timeouts are enforced inside the container so timed-out commands are cleaned up by the container runtime. Abort signals cancel the host CLI wait path and should not be used as a substitute for command timeouts when in-container cleanup matters. + ## Editor provider Register the provider with `MastraEditor` to hydrate stored sandbox configs: ```typescript -import { MastraEditor } from '@mastra/core/editor'; +import { MastraEditor } from '@mastra/editor'; import { appleContainerSandboxProvider } from '@mastra/apple-container'; const editor = new MastraEditor({ diff --git a/workspaces/apple-container/src/index.ts b/workspaces/apple-container/src/index.ts index a580f989b05..8f949ef8ffe 100644 --- a/workspaces/apple-container/src/index.ts +++ b/workspaces/apple-container/src/index.ts @@ -6,3 +6,4 @@ export type { AppleContainerSandboxOptions, } from './sandbox'; export { appleContainerSandboxProvider } from './provider'; +export type { AppleContainerProviderConfig } from './provider'; diff --git a/workspaces/apple-container/src/provider.ts b/workspaces/apple-container/src/provider.ts index bbacbc62ac2..15a0d0ec4ab 100644 --- a/workspaces/apple-container/src/provider.ts +++ b/workspaces/apple-container/src/provider.ts @@ -8,6 +8,7 @@ import type { AppleContainerSandboxOptions } from './sandbox'; export type AppleContainerProviderConfig = Pick< AppleContainerSandboxOptions, + | 'id' | 'image' | 'name' | 'command' @@ -47,6 +48,10 @@ export const appleContainerSandboxProvider: SandboxProvider { const { + id, image, name, command, @@ -221,6 +227,7 @@ export const appleContainerSandboxProvider: SandboxProvider { + const sandbox = createSandbox(); + + await sandbox._start(); + const result = await sandbox.executeCommand('sh -lc "exit 124"', [], { timeout: 5_000 }); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(124); + expect(result.timedOut).not.toBe(true); + }, 120_000); + + it('fails startup and cleans up when the init command exits', async () => { + const sandbox = createSandbox({ command: ['false'] }); + + await expect(sandbox._start()).rejects.toThrow(/is stopped|did not become ready|disappeared/); + + const inspect = spawnSync('container', ['inspect', sandbox.containerId], { encoding: 'utf8' }); + expect(inspect.status).not.toBe(0); + expect(inspect.stderr).toMatch(/not found|no such|does not exist|unknown container/i); + }, 120_000); + + it('stops but preserves the Apple container when deleteOnDestroy is disabled', async () => { + const sandbox = createSandbox({ deleteOnDestroy: false }); + + await sandbox._start(); + const containerId = sandbox.containerId; + await sandbox._destroy(); + + expect(sandbox.status).toBe('destroyed'); + expect(inspectContainerState(containerId)).toBe('stopped'); + + const cleanup = spawnSync('container', ['delete', '--force', containerId], { encoding: 'utf8' }); + expect(cleanup.status, cleanup.stderr).toBe(0); + }, 120_000); + it('deletes the Apple container on destroy', async () => { const sandbox = createSandbox(); diff --git a/workspaces/apple-container/src/sandbox/index.test.ts b/workspaces/apple-container/src/sandbox/index.test.ts index ea84bb01de2..ceb0c151046 100644 --- a/workspaces/apple-container/src/sandbox/index.test.ts +++ b/workspaces/apple-container/src/sandbox/index.test.ts @@ -46,7 +46,11 @@ function cliResult(overrides: Partial = {}): AppleConta }; } -function inspectResult(status: string, id = 'container-123'): Partial { +function inspectResult( + status: string, + id = 'container-123', + labels: Record = {}, +): Partial { return { stdout: JSON.stringify([ { @@ -60,6 +64,7 @@ function inspectResult(status: string, id = 'container-123'): Partial { expect(sandbox.getInstructions()).toContain('Apple container sandbox'); }); + it('returns serializable provider config from getInfo metadata', async () => { + const sandbox = new AppleContainerSandbox({ + id: 'apple-test', + image: 'node:22-slim', + env: { NODE_ENV: 'test' }, + volumes: { '/host/project': '/workspace' }, + workingDir: '/workspace', + deleteOnDestroy: false, + runner: createRunner(), + }); + + const info = await sandbox.getInfo(); + + expect(info.metadata).toMatchObject({ + id: 'apple-test', + image: 'node:22-slim', + command: ['sleep', 'infinity'], + env: { NODE_ENV: 'test' }, + volumes: { '/host/project': '/workspace' }, + workingDir: '/workspace', + timeout: 300_000, + deleteOnDestroy: false, + }); + expect(info.metadata).not.toHaveProperty('containerId'); + expect(info.metadata).not.toHaveProperty('containerName'); + }); + it('creates a long-lived Apple container when none exists', async () => { - const runner = createRunner([missingContainerResult(), { stdout: 'created\n' }]); + const runner = createRunner([missingContainerResult(), { stdout: 'created\n' }, inspectResult('running'), {}]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', image: 'python:3.12-slim', @@ -127,7 +159,7 @@ describe('AppleContainerSandbox', () => { virtualization: true, capAdd: ['NET_BIND_SERVICE'], capDrop: ['MKNOD'], - tmpfs: ['/tmp:rw,size=64m'], + tmpfs: ['/tmp'], dns: ['1.1.1.1'], dnsSearch: ['example.test'], noDns: true, @@ -147,7 +179,7 @@ describe('AppleContainerSandbox', () => { '--workdir', '/workspace', '--env', - 'NODE_ENV=test', + 'NODE_ENV', '--volume', '/host/project:/workspace', '--mount', @@ -158,6 +190,8 @@ describe('AppleContainerSandbox', () => { 'mastra.sandbox=true', '--label', 'mastra.sandbox.id=apple-test', + '--label', + expect.stringMatching(/^mastra\.sandbox\.config-hash=/), '--publish', '127.0.0.1:8080:80', '--publish-socket', @@ -167,7 +201,7 @@ describe('AppleContainerSandbox', () => { '--cap-drop', 'MKNOD', '--tmpfs', - '/tmp:rw,size=64m', + '/tmp', '--dns', '1.1.1.1', '--dns-search', @@ -194,6 +228,7 @@ describe('AppleContainerSandbox', () => { 'sleep', '9999', ]); + expect(runner.run).toHaveBeenNthCalledWith(2, expect.any(Array), expect.objectContaining({ env: { NODE_ENV: 'test' } })); expect(sandbox.status).toBe('running'); }); @@ -203,6 +238,8 @@ describe('AppleContainerSandbox', () => { {}, inspectResult('running'), {}, + inspectResult('running'), + {}, inspectResult('stopped'), {}, ]); @@ -219,7 +256,7 @@ describe('AppleContainerSandbox', () => { }); it('reconnects to an existing stopped container', async () => { - const runner = createRunner([inspectResult('stopped', 'existing-id'), {}]); + const runner = createRunner([inspectResult('stopped', 'existing-id'), {}, inspectResult('running', 'existing-id'), {}]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); await sandbox._start(); @@ -253,6 +290,31 @@ describe('AppleContainerSandbox', () => { expect(runner.run).toHaveBeenCalledOnce(); }); + it('refuses to reconnect when a Mastra-owned container has incompatible immutable config', async () => { + const runner = createRunner([ + inspectResult('running', 'existing-id', { 'mastra.sandbox.config-hash': 'different-config' }), + ]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await expect(sandbox.start()).rejects.toMatchObject({ + name: 'SandboxExecutionError', + message: expect.stringContaining('immutable configuration does not match'), + }); + expect(sandbox.status).toBe('error'); + }); + + it('cleans up a newly created container that exits before readiness', async () => { + const runner = createRunner([missingContainerResult(), {}, inspectResult('stopped'), {}]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await expect(sandbox.start()).rejects.toMatchObject({ + name: 'SandboxExecutionError', + message: expect.stringContaining('is stopped'), + }); + expect(sandbox.status).toBe('error'); + expectCliCall(runner, 4, ['delete', '--force', 'apple-test']); + }); + it('throws when reconnecting to a stopped container fails', async () => { const runner = createRunner([ inspectResult('stopped', 'existing-id'), @@ -268,8 +330,14 @@ describe('AppleContainerSandbox', () => { expect(sandbox.status).toBe('error'); }); + it('rejects Docker-style tmpfs specs because Apple container expects paths', () => { + expect(() => new AppleContainerSandbox({ id: 'apple-test', tmpfs: ['/tmp:rw,size=64m'] })).toThrow( + 'Apple container --tmpfs accepts container paths only', + ); + }); + it('executes commands with env, cwd, timeout, streaming and retained output options', async () => { - const runner = createRunner([missingContainerResult(), {}, { stdout: 'hello\n' }]); + const runner = createRunner([missingContainerResult(), {}, inspectResult('running'), {}, { stdout: 'hello\n' }]); const onStdout = vi.fn(); const sandbox = new AppleContainerSandbox({ id: 'apple-test', @@ -299,43 +367,44 @@ describe('AppleContainerSandbox', () => { [ 'exec', '--env', - 'BASE=1', + 'BASE', '--env', - 'EXTRA=2', + 'EXTRA', '--workdir', '/app', 'apple-test', 'sh', '-lc', - expect.stringContaining("timeout 1.234s sh -lc 'node -e '\\''console.log(\"hello\")'\\'''"), + expect.stringContaining('timeout 1.234s sh -lc'), ], expect.objectContaining({ timeout: 11_234, + env: { BASE: '1', EXTRA: '2' }, onStdout, maxRetainedBytes: 16, }), ); }); - it('quotes args before executing through the shell', async () => { - const runner = createRunner([missingContainerResult(), {}, {}]); + it('quotes command and args before executing through the shell', async () => { + const runner = createRunner([missingContainerResult(), {}, inspectResult('running'), {}, {}]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); await sandbox._start(); runner.run.mockClear(); - await sandbox.executeCommand('printf', ['hello; touch /tmp/pwned']); + const result = await sandbox.executeCommand('node; touch /tmp/pwned', ['-v']); const [cliArgs, cliOptions] = runner.run.mock.calls[0]; expect(cliArgs.slice(0, -1)).toEqual(['exec', '--workdir', '/workspace', 'apple-test', 'sh', '-lc']); - expect(cliArgs.at(-1)).toContain('printf'); - expect(cliArgs.at(-1)).toContain('hello; touch /tmp/pwned'); - expect(cliArgs.at(-1)).toContain("'\\''hello; touch /tmp/pwned'\\'''"); + expect(result.command).toBe("'node; touch /tmp/pwned' -v"); + expect(cliArgs.at(-1)).toContain('node; touch /tmp/pwned'); + expect(cliArgs.at(-1)).toContain('-v'); expect(cliOptions).toEqual(expect.objectContaining({ timeout: 310_000 })); }); it('preserves shell command strings when no args are provided', async () => { - const runner = createRunner([missingContainerResult(), {}, {}]); + const runner = createRunner([missingContainerResult(), {}, inspectResult('running'), {}, {}]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); await sandbox._start(); @@ -358,7 +427,13 @@ describe('AppleContainerSandbox', () => { }); it('marks in-container timeout exits as timed out', async () => { - const runner = createRunner([missingContainerResult(), {}, { success: false, exitCode: 124, stderr: 'Terminated' }]); + const runner = createRunner([ + missingContainerResult(), + {}, + inspectResult('running'), + {}, + { success: false, exitCode: 124, stderr: 'Terminated\n__MASTRA_APPLE_CONTAINER_TIMEOUT__\n' }, + ]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); const result = await sandbox.executeCommand('sleep', ['30'], { timeout: 10 }); @@ -366,6 +441,7 @@ describe('AppleContainerSandbox', () => { expect(result).toMatchObject({ success: false, exitCode: 124, + stderr: 'Terminated', timedOut: true, killed: true, }); @@ -377,12 +453,32 @@ describe('AppleContainerSandbox', () => { 'apple-test', 'sh', '-lc', - expect.stringContaining("timeout 0.01s sh -lc 'sleep 30'"), + expect.stringContaining('timeout 0.01s sh -lc'), ], expect.objectContaining({ timeout: 10_010 }), ); }); + it('does not mark a legitimate exit 124 as a timeout without the timeout marker', async () => { + const runner = createRunner([ + missingContainerResult(), + {}, + inspectResult('running'), + {}, + { success: false, exitCode: 124, timedOut: false, killed: false }, + ]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + const result = await sandbox.executeCommand('sh -lc "exit 124"', [], { timeout: 1_000 }); + + expect(result).toMatchObject({ + success: false, + exitCode: 124, + timedOut: false, + killed: false, + }); + }); + it('stops instead of deletes when deleteOnDestroy is disabled', async () => { const runner = createRunner([inspectResult('running'), {}]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', deleteOnDestroy: false, runner }); @@ -394,7 +490,9 @@ describe('AppleContainerSandbox', () => { }); it('does not stop when the container is already stopped or missing', async () => { - const stoppedRunner = createRunner([inspectResult('stopped')]); + const stoppedRunner = createRunner([ + inspectResult('stopped', 'container-123', { 'mastra.sandbox.id': 'apple-stopped' }), + ]); const missingRunner = createRunner([missingContainerResult()]); await new AppleContainerSandbox({ id: 'apple-stopped', runner: stoppedRunner }).stop(); @@ -404,6 +502,17 @@ describe('AppleContainerSandbox', () => { expect(missingRunner.run).toHaveBeenCalledOnce(); }); + it('refuses to stop a stopped container without matching Mastra labels', async () => { + const runner = createRunner([unownedInspectResult('stopped')]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await expect(sandbox.stop()).rejects.toMatchObject({ + name: 'SandboxExecutionError', + message: expect.stringContaining('not labeled as Mastra sandbox apple-test'), + }); + expect(sandbox.status).toBe('error'); + }); + it('ignores a missing container race while stopping', async () => { const runner = createRunner([inspectResult('running'), missingContainerResult()]); const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); @@ -521,12 +630,14 @@ describe('appleContainerSandboxProvider', () => { it('creates an AppleContainerSandbox from serializable config', () => { const sandbox = appleContainerSandboxProvider.createSandbox({ + id: 'apple-test', image: 'node:22-slim', env: { NODE_ENV: 'test' }, readonlyRootfs: true, }); expect(sandbox).toBeInstanceOf(AppleContainerSandbox); + expect(sandbox.id).toBe('apple-test'); }); it('exposes an exact serializable Studio schema', () => { @@ -548,6 +659,7 @@ describe('appleContainerSandboxProvider', () => { 'dnsSearch', 'env', 'image', + 'id', 'init', 'labels', 'memory', @@ -590,6 +702,16 @@ describe('runAppleContainerCli', () => { }); }); + it('passes child environment variables to the CLI process', async () => { + const result = await runAppleContainerCli( + process.execPath, + ['-e', 'process.stdout.write(process.env.MASTRA_APPLE_CONTAINER_ENV_TEST ?? "missing");'], + { env: { MASTRA_APPLE_CONTAINER_ENV_TEST: 'available' } }, + ); + + expect(result.stdout).toBe('available'); + }); + it('retains only the newest output when maxRetainedBytes is set', async () => { const result = await runAppleContainerCli( process.execPath, diff --git a/workspaces/apple-container/src/sandbox/index.ts b/workspaces/apple-container/src/sandbox/index.ts index 1bfeac42717..4d42d891d75 100644 --- a/workspaces/apple-container/src/sandbox/index.ts +++ b/workspaces/apple-container/src/sandbox/index.ts @@ -10,6 +10,7 @@ import { spawn } from 'node:child_process'; import type { ChildProcessByStdio } from 'node:child_process'; +import { createHash } from 'node:crypto'; import type { Readable } from 'node:stream'; import { StringDecoder } from 'node:string_decoder'; import type { RequestContext } from '@mastra/core/di'; @@ -28,7 +29,10 @@ const DEFAULT_IMAGE = 'node:22-slim'; const DEFAULT_COMMAND = ['sleep', 'infinity']; const DEFAULT_WORKING_DIR = '/workspace'; const APPLE_CONTAINER_CLI_GRACE_TIMEOUT_MS = 10_000; +const APPLE_CONTAINER_READY_TIMEOUT_MS = 10_000; +const APPLE_CONTAINER_READY_EXEC_TIMEOUT_MS = 5_000; const APPLE_CONTAINER_TIMEOUT_EXIT_CODE = 124; +const APPLE_CONTAINER_TIMEOUT_MARKER = '__MASTRA_APPLE_CONTAINER_TIMEOUT__'; export interface AppleContainerCliResult { success: boolean; @@ -46,6 +50,7 @@ export interface AppleContainerCliResult { export interface AppleContainerCommandRunnerOptions { timeout?: number; + env?: Record; abortSignal?: AbortSignal; onStdout?: (data: string) => void; onStderr?: (data: string) => void; @@ -130,7 +135,7 @@ export interface AppleContainerSandboxOptions extends Omit; @@ -185,7 +191,9 @@ export class AppleContainerSandbox extends MastraSandbox { private readonly _dns: string[]; private readonly _dnsSearch: string[]; private readonly _noDns: boolean; + private readonly _userLabels: Record; private readonly _labels: Record; + private readonly _configHash: string; private readonly _workingDir: string; private readonly _timeout: number; private readonly _deleteOnDestroy: boolean; @@ -202,6 +210,7 @@ export class AppleContainerSandbox extends MastraSandbox { }); this.id = options.id ?? generateId(); + this._configuredName = options.name; this._containerName = sanitizeContainerName(options.name ?? this.id); this._image = options.image ?? DEFAULT_IMAGE; this._command = options.command ?? DEFAULT_COMMAND; @@ -224,15 +233,19 @@ export class AppleContainerSandbox extends MastraSandbox { this._capAdd = options.capAdd ?? []; this._capDrop = options.capDrop ?? []; this._tmpfs = options.tmpfs ?? []; + validateTmpfsPaths(this._tmpfs); this._dns = options.dns ?? []; this._dnsSearch = options.dnsSearch ?? []; this._noDns = options.noDns ?? false; + this._workingDir = options.workingDir ?? DEFAULT_WORKING_DIR; + this._userLabels = options.labels ?? {}; + this._configHash = hashConfig(this._runtimeConfigForHash()); this._labels = { - ...options.labels, + ...this._userLabels, 'mastra.sandbox': 'true', 'mastra.sandbox.id': this.id, + 'mastra.sandbox.config-hash': this._configHash, }; - this._workingDir = options.workingDir ?? DEFAULT_WORKING_DIR; this._timeout = options.timeout ?? DEFAULT_COMMAND_TIMEOUT_MS; this._deleteOnDestroy = options.deleteOnDestroy ?? true; this._runner = options.runner ?? new DefaultAppleContainerCommandRunner(options.containerBinary); @@ -260,25 +273,39 @@ export class AppleContainerSandbox extends MastraSandbox { const existing = await this._inspectContainer(); if (existing) { this._assertMastraOwned(existing); + this._assertCompatibleConfig(existing); this._containerId = existing.configuration?.id ?? this._containerName; if (!isRunning(existing)) { const result = await this._runCli(['start', this.containerId]); this._assertSuccess(result, `start Apple container ${this.containerId}`); + await this._waitUntilContainerReady(`start Apple container ${this.containerId}`); } return; } - const result = await this._runCli(this._buildRunArgs()); + const env = envFlags(this._env); + const result = await this._runCli(this._buildRunArgs(env.args), { env: env.env }); this._assertSuccess(result, `create Apple container ${this._containerName}`); this._containerId = this._containerName; + try { + await this._waitUntilContainerReady(`create Apple container ${this._containerName}`); + } catch (error) { + if (this._deleteOnDestroy) { + await this._deleteContainerIgnoringMissing(); + } + throw error; + } } private async _stopContainer(): Promise { const existing = await this._inspectContainer(); - if (!existing || !isRunning(existing)) { + if (!existing) { return; } this._assertMastraOwned(existing); + if (!isRunning(existing)) { + return; + } const result = await this._runCli(['stop', this.containerId]); if (!result.success && !isMissingContainerMessage(result.stderr)) { @@ -298,10 +325,7 @@ export class AppleContainerSandbox extends MastraSandbox { } this._assertMastraOwned(existing); - const result = await this._runCli(['delete', '--force', this.containerId]); - if (!result.success && !isMissingContainerMessage(result.stderr)) { - this._assertSuccess(result, `delete Apple container ${this.containerId}`); - } + await this._deleteContainerIgnoringMissing(); } async executeCommand( @@ -315,10 +339,10 @@ export class AppleContainerSandbox extends MastraSandbox { const hasCommandTimeout = Number.isFinite(commandTimeout) && commandTimeout > 0; const fullCommand = buildShellCommand(command, args); const shellCommand = hasCommandTimeout ? buildTimeoutShellCommand(fullCommand, commandTimeout) : fullCommand; - const env = { ...this._env, ...options.env }; + const env = envFlags({ ...this._env, ...options.env }); const cliArgs = [ 'exec', - ...envFlags(env), + ...env.args, '--workdir', options.cwd ?? this._workingDir, this.containerId, @@ -329,15 +353,20 @@ export class AppleContainerSandbox extends MastraSandbox { const result = await this._runner.run(cliArgs, { timeout: hasCommandTimeout ? commandTimeout + APPLE_CONTAINER_CLI_GRACE_TIMEOUT_MS : undefined, + env: env.env, abortSignal: options.abortSignal, onStdout: options.onStdout, onStderr: options.onStderr, maxRetainedBytes: options.maxRetainedBytes, }); + const timedOut = result.exitCode === APPLE_CONTAINER_TIMEOUT_EXIT_CODE && result.stderr.includes(APPLE_CONTAINER_TIMEOUT_MARKER); + const stderr = timedOut ? stripTimeoutMarker(result.stderr) : result.stderr; + return { ...result, - ...(result.exitCode === APPLE_CONTAINER_TIMEOUT_EXIT_CODE && { timedOut: true, killed: true }), + stderr, + ...(timedOut && { timedOut: true, killed: true }), command: fullCommand, args, }; @@ -346,8 +375,6 @@ export class AppleContainerSandbox extends MastraSandbox { async getInfo(): Promise { const inspect = this._containerId ? await this._inspectContainer() : undefined; const resources = inspect?.configuration?.resources; - const appleContainerStatus = inspect ? getContainerState(inspect) : undefined; - const networks = inspect ? getContainerNetworks(inspect) : undefined; return { id: this.id, @@ -362,12 +389,7 @@ export class AppleContainerSandbox extends MastraSandbox { } : undefined, metadata: { - containerName: this._containerName, - containerId: this.containerId, - image: this._image, - workingDir: this._workingDir, - ...(appleContainerStatus && { appleContainerStatus }), - ...(networks && { networks }), + ...this._serializableConfig(), }, }; } @@ -446,10 +468,10 @@ export class AppleContainerSandbox extends MastraSandbox { } } - private _buildRunArgs(): string[] { + private _buildRunArgs(envArgs: string[]): string[] { const args = ['run', '-d', '--name', this._containerName, '--workdir', this._workingDir]; - args.push(...envFlags(this._env)); + args.push(...envArgs); for (const [hostPath, containerPath] of Object.entries(this._volumes)) { args.push('--volume', `${hostPath}:${containerPath}`); } @@ -480,6 +502,117 @@ export class AppleContainerSandbox extends MastraSandbox { return args; } + private async _waitUntilContainerReady(action: string): Promise { + const deadline = Date.now() + APPLE_CONTAINER_READY_TIMEOUT_MS; + let lastResult: AppleContainerCliResult | undefined; + + while (Date.now() < deadline) { + const inspect = await this._inspectContainer(); + if (!inspect) { + throw new SandboxExecutionError(`${action} failed because Apple container ${this.containerId} disappeared`, 1, '', ''); + } + + this._assertMastraOwned(inspect); + this._assertCompatibleConfig(inspect); + + if (!isRunning(inspect)) { + const state = getContainerState(inspect) ?? 'not running'; + throw new SandboxExecutionError(`${action} failed because Apple container ${this.containerId} is ${state}`, 1, '', ''); + } + + lastResult = await this._runCli(['exec', this.containerId, 'sh', '-lc', 'true'], { + timeout: APPLE_CONTAINER_READY_EXEC_TIMEOUT_MS, + }); + if (lastResult.success) return; + + if (!isMissingContainerMessage(lastResult.stderr) && !/not running|not yet running/i.test(lastResult.stderr)) { + break; + } + + await delay(100); + } + + throw new SandboxExecutionError( + `${action} failed because Apple container ${this.containerId} did not become ready for exec`, + lastResult?.exitCode ?? 1, + lastResult?.stdout ?? '', + lastResult?.stderr ?? '', + ); + } + + private async _deleteContainerIgnoringMissing(): Promise { + const result = await this._runCli(['delete', '--force', this.containerId]); + if (!result.success && !isMissingContainerMessage(result.stderr)) { + this._assertSuccess(result, `delete Apple container ${this.containerId}`); + } + } + + private _runtimeConfigForHash(): Record { + return { + image: this._image, + command: this._command, + env: this._env, + volumes: this._volumes, + mounts: this._mounts, + network: this._network, + publishedPorts: this._publishedPorts, + publishedSockets: this._publishedSockets, + cpus: this._cpus, + memory: this._memory, + platform: this._platform, + arch: this._arch, + os: this._os, + rosetta: this._rosetta, + readonlyRootfs: this._readonlyRootfs, + ssh: this._ssh, + init: this._init, + virtualization: this._virtualization, + capAdd: this._capAdd, + capDrop: this._capDrop, + tmpfs: this._tmpfs, + dns: this._dns, + dnsSearch: this._dnsSearch, + noDns: this._noDns, + labels: this._userLabels, + workingDir: this._workingDir, + }; + } + + private _serializableConfig(): Record { + return compactConfig({ + id: this.id, + name: this._configuredName, + image: this._image, + command: this._command, + env: this._env, + volumes: this._volumes, + mounts: this._mounts, + network: this._network, + publishedPorts: this._publishedPorts, + publishedSockets: this._publishedSockets, + cpus: this._cpus, + memory: this._memory, + platform: this._platform, + arch: this._arch, + os: this._os, + rosetta: this._rosetta, + readonlyRootfs: this._readonlyRootfs, + ssh: this._ssh, + init: this._init, + virtualization: this._virtualization, + capAdd: this._capAdd, + capDrop: this._capDrop, + tmpfs: this._tmpfs, + dns: this._dns, + dnsSearch: this._dnsSearch, + noDns: this._noDns, + labels: this._userLabels, + workingDir: this._workingDir, + timeout: this._timeout, + deleteOnDestroy: this._deleteOnDestroy, + }); + } + private _assertSuccess(result: AppleContainerCliResult, action: string): void { if (result.success) return; throw new SandboxExecutionError( @@ -495,7 +628,18 @@ export class AppleContainerSandbox extends MastraSandbox { throw new SandboxExecutionError( `Refusing to manage Apple container ${this.containerId} because it is not labeled as Mastra sandbox ${this.id}`, 1, - JSON.stringify(inspect), + '', + '', + ); + } + + private _assertCompatibleConfig(inspect: AppleContainerInspectResult): void { + const existingHash = inspect.configuration?.labels?.['mastra.sandbox.config-hash']; + if (!existingHash || existingHash === this._configHash) return; + throw new SandboxExecutionError( + `Refusing to manage Apple container ${this.containerId} because its immutable configuration does not match sandbox ${this.id}`, + 1, + '', '', ); } @@ -537,6 +681,7 @@ class AppleContainerCliProcess extends ProcessHandle { this.child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'], + env: options.env ? { ...process.env, ...options.env } : process.env, }); this.pid = this.child.pid ? String(this.child.pid) : `${binary}:${args.join(' ')}`; @@ -671,12 +816,28 @@ function sanitizeContainerName(name: string): string { } function buildShellCommand(command: string, args: string[]): string { - return args.length > 0 ? `${command} ${args.map(shellQuote).join(' ')}` : command; + return args.length > 0 ? [command, ...args].map(shellQuote).join(' ') : command; } function buildTimeoutShellCommand(command: string, timeoutMs: number): string { const timeoutSeconds = formatTimeoutSeconds(timeoutMs); - return `timeout ${timeoutSeconds}s sh -lc ${shellQuote(command)}; code=$?; case "$code" in 124|137|143) exit ${APPLE_CONTAINER_TIMEOUT_EXIT_CODE};; *) exit "$code";; esac`; + const innerScript = [ + `sh -lc ${shellQuote(command)} & child=$!`, + `trap 'kill -TERM "$child" 2>/dev/null; wait "$child" 2>/dev/null; exit ${APPLE_CONTAINER_TIMEOUT_EXIT_CODE}' TERM INT`, + 'wait "$child"; code=$?', + 'trap - TERM INT', + 'printf "%s" "$code" > "$MASTRA_TIMEOUT_RESULT_FILE"', + 'exit "$code"', + ].join('; '); + return [ + 'result_file="/tmp/.mastra-apple-container-exit-$$"', + 'rm -f "$result_file"', + `MASTRA_TIMEOUT_RESULT_FILE="$result_file" timeout ${timeoutSeconds}s sh -lc ${shellQuote(innerScript)}`, + 'timeout_code=$?', + 'if [ -f "$result_file" ]; then code="$(cat "$result_file")"; rm -f "$result_file"; exit "$code"; fi', + 'rm -f "$result_file"', + `case "$timeout_code" in 124|137|143) printf '%s\\n' ${shellQuote(APPLE_CONTAINER_TIMEOUT_MARKER)} >&2; exit ${APPLE_CONTAINER_TIMEOUT_EXIT_CODE};; *) exit "$timeout_code";; esac`, + ].join('; '); } function formatTimeoutSeconds(timeoutMs: number): string { @@ -691,16 +852,26 @@ function shellQuote(arg: string): string { return `'${arg.replace(/'/g, "'\\''")}'`; } -function envFlags(env: Record): string[] { +function stripTimeoutMarker(stderr: string): string { + return stderr + .split('\n') + .filter(line => line.trim() !== APPLE_CONTAINER_TIMEOUT_MARKER) + .join('\n') + .replace(/^\n+|\n+$/g, ''); +} + +function envFlags(env: Record): { args: string[]; env: Record } { const args: string[] = []; + const childEnv: Record = {}; for (const [key, value] of Object.entries(env)) { if (value === undefined) continue; if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { throw new Error(`Invalid environment variable name for Apple container command: ${key}`); } - args.push('--env', `${key}=${value}`); + args.push('--env', key); + childEnv[key] = value; } - return args; + return { args, env: childEnv }; } function isRunning(inspect: AppleContainerInspectResult): boolean { @@ -711,10 +882,6 @@ function getContainerState(inspect: AppleContainerInspectResult): string | undef return typeof inspect.status === 'string' ? inspect.status : inspect.status?.state; } -function getContainerNetworks(inspect: AppleContainerInspectResult): Array> | undefined { - return (typeof inspect.status === 'object' ? inspect.status.networks : undefined) ?? inspect.configuration?.networks; -} - function isMastraOwned(inspect: AppleContainerInspectResult, sandboxId: string): boolean { const labels = inspect.configuration?.labels; return labels?.['mastra.sandbox'] === 'true' && labels['mastra.sandbox.id'] === sandboxId; @@ -723,3 +890,49 @@ function isMastraOwned(inspect: AppleContainerInspectResult, sandboxId: string): function isMissingContainerMessage(message: string): boolean { return /not found|no such|does not exist|unknown container/i.test(message); } + +function validateTmpfsPaths(tmpfs: string[]): void { + for (const entry of tmpfs) { + if (!entry.startsWith('/') || /[:,]/.test(entry)) { + throw new Error( + `Invalid Apple container tmpfs path "${entry}". Apple container --tmpfs accepts container paths only, for example "/tmp".`, + ); + } + } +} + +function compactConfig(config: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(config)) { + if (value === undefined) continue; + if (Array.isArray(value) && value.length === 0) continue; + if (isPlainRecord(value) && Object.keys(value).length === 0) continue; + result[key] = value; + } + return result; +} + +function hashConfig(config: Record): string { + return createHash('sha256').update(stableStringify(compactConfig(config))).digest('hex').slice(0, 16); +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + if (isPlainRecord(value)) { + return `{${Object.keys(value) + .sort() + .map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +}