diff --git a/.changeset/apple-container-sandbox.md b/.changeset/apple-container-sandbox.md new file mode 100644 index 00000000000..5966c00de57 --- /dev/null +++ b/.changeset/apple-container-sandbox.md @@ -0,0 +1,15 @@ +--- +'@mastra/apple-container': minor +--- + +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' }, +}); +``` 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..78bb51416c4 --- /dev/null +++ b/docs/src/content/en/reference/workspace/apple-container-sandbox.mdx @@ -0,0 +1,419 @@ +--- +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 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 +``` + +## 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 destination paths passed as `--tmpfs`, for example `/tmp`.', + 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', + volumes: { + '/Users/me/project': '/workspace', + }, + cpus: 2, + memory: '2G', + platform: 'linux/arm64', + readonlyRootfs: true, + 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 + +`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. 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: + +- 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' }) +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/editor' +import { appleContainerSandboxProvider } from '@mastra/apple-container' + +const editor = new MastraEditor({ + sandboxes: { + [appleContainerSandboxProvider.id]: appleContainerSandboxProvider, + }, +}) +``` + +## 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/pnpm-lock.yaml b/pnpm-lock.yaml index e638db32825..4b3232c9b63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8478,6 +8478,45 @@ 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 + '@internal/workspace-test-utils': + specifier: workspace:* + version: link:../_test-utils + '@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..05c510d635a --- /dev/null +++ b/workspaces/apple-container/README.md @@ -0,0 +1,118 @@ +# @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`. | +| `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 destination paths, for example `/tmp`. | +| `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`. | +| `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 + +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. 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/editor'; +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..35c4c15bcc0 --- /dev/null +++ b/workspaces/apple-container/package.json @@ -0,0 +1,66 @@ +{ + "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: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", + "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:", + "@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..8f949ef8ffe --- /dev/null +++ b/workspaces/apple-container/src/index.ts @@ -0,0 +1,9 @@ +export { AppleContainerSandbox, DefaultAppleContainerCommandRunner } from './sandbox'; +export type { + AppleContainerCliResult, + AppleContainerCommandRunner, + AppleContainerCommandRunnerOptions, + 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 new file mode 100644 index 00000000000..15a0d0ec4ab --- /dev/null +++ b/workspaces/apple-container/src/provider.ts @@ -0,0 +1,262 @@ +/** + * Apple container sandbox provider descriptor for MastraEditor. + */ + +import type { SandboxProvider } from '@mastra/core/editor'; +import { AppleContainerSandbox } from './sandbox'; +import type { AppleContainerSandboxOptions } from './sandbox'; + +export type AppleContainerProviderConfig = Pick< + AppleContainerSandboxOptions, + | 'id' + | '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' +>; + +export const appleContainerSandboxProvider: SandboxProvider = { + id: 'apple-container', + name: 'Apple Container Sandbox', + description: 'Local OCI Linux container sandbox powered by Apple container', + configSchema: { + type: 'object', + additionalProperties: false, + properties: { + id: { + type: 'string', + description: 'Stable sandbox ID used for reconnecting to the same Apple container.', + }, + 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' }, + }, + publishedSockets: { + type: 'array', + description: 'Socket 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', + }, + os: { + type: 'string', + description: 'Operating system for multi-platform 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, + }, + init: { + type: 'boolean', + description: "Enable Apple's init process in the container", + default: true, + }, + virtualization: { + type: 'boolean', + description: 'Expose virtualization capabilities to the container', + default: false, + }, + capAdd: { + type: 'array', + description: 'Linux capabilities to add', + items: { type: 'string' }, + }, + capDrop: { + type: 'array', + description: 'Linux capabilities to drop', + items: { type: 'string' }, + }, + tmpfs: { + type: 'array', + description: 'tmpfs destination paths', + items: { type: 'string' }, + }, + dns: { + type: 'array', + description: 'DNS nameserver IPs', + items: { type: 'string' }, + }, + dnsSearch: { + type: 'array', + description: 'DNS search domains', + items: { type: 'string' }, + }, + noDns: { + type: 'boolean', + description: 'Do not configure DNS in the container', + default: false, + }, + labels: { + type: 'object', + description: 'Container labels', + additionalProperties: { type: 'string' }, + }, + 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, + }, + }, + }, + createSandbox: config => { + const { + id, + 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({ + id, + 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 new file mode 100644 index 00000000000..bff9494e229 --- /dev/null +++ b/workspaces/apple-container/src/sandbox/index.integration.test.ts @@ -0,0 +1,142 @@ +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; +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', () => { + const sandboxes: AppleContainerSandbox[] = []; + + 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; + } + + 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())); + }); + + it('starts an Apple container and executes a command', async () => { + const sandbox = createSandbox(); + + await sandbox._start(); + const result = await sandbox.executeCommand('printf apple-container'); + + 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'); + expect(inspectContainerState(sandbox.containerId)).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('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('does not mark a command that exits 124 as a timeout', async () => { + 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(); + + 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 new file mode 100644 index 00000000000..ceb0c151046 --- /dev/null +++ b/workspaces/apple-container/src/sandbox/index.test.ts @@ -0,0 +1,762 @@ +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'; + +type RunnerResponse = + | Partial + | ((args: string[], options?: AppleContainerCommandRunnerOptions) => Partial); + +type MockRunner = AppleContainerCommandRunner & { run: ReturnType }; + +function createRunner(responses: RunnerResponse[] = []): MockRunner { + const queue = [...responses]; + + 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); + }), + }; +} + +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, + exitCode: 0, + stdout: '', + stderr: '', + executionTimeMs: 1, + ...overrides, + }; +} + +function inspectResult( + status: string, + id = 'container-123', + labels: Record = {}, +): Partial { + return { + stdout: JSON.stringify([ + { + id, + status: { + state: status, + networks: [{ network: 'default' }], + }, + configuration: { + id, + labels: { + 'mastra.sandbox': 'true', + 'mastra.sandbox.id': 'apple-test', + ...labels, + }, + resources: { + cpus: 2, + memoryInBytes: 1024 * 1024 * 512, + }, + }, + }, + ]), + }; +} + +function unownedInspectResult(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' }; +} + +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('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' }, inspectResult('running'), {}]); + 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'], + dns: ['1.1.1.1'], + dnsSearch: ['example.test'], + noDns: true, + labels: { app: 'mastra' }, + workingDir: '/workspace', + runner, + }); + + await sandbox._start(); + + expectCliCall(runner, 1, ['inspect', 'apple-test']); + expectCliCall(runner, 2, [ + 'run', + '-d', + '--name', + 'apple-test', + '--workdir', + '/workspace', + '--env', + 'NODE_ENV', + '--volume', + '/host/project:/workspace', + '--mount', + 'source=/host/cache,target=/cache', + '--label', + 'app=mastra', + '--label', + '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', + '/tmp/app.sock:/var/run/app.sock', + '--cap-add', + 'NET_BIND_SERVICE', + '--cap-drop', + 'MKNOD', + '--tmpfs', + '/tmp', + '--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(runner.run).toHaveBeenNthCalledWith(2, expect.any(Array), expect.objectContaining({ env: { NODE_ENV: 'test' } })); + expect(sandbox.status).toBe('running'); + }); + + it('keeps status in sync when plain lifecycle methods are called', async () => { + const runner = createRunner([ + missingContainerResult(), + {}, + inspectResult('running'), + {}, + 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'), {}, inspectResult('running', 'existing-id'), {}]); + const sandbox = new AppleContainerSandbox({ id: 'apple-test', runner }); + + await sandbox._start(); + + expectCliCall(runner, 1, ['inspect', 'apple-test']); + expectCliCall(runner, 2, ['start', 'existing-id']); + expect(sandbox.containerId).toBe('existing-id'); + }); + + 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.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('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'), + { 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('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(), {}, inspectResult('running'), {}, { 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', + '--env', + 'EXTRA', + '--workdir', + '/app', + 'apple-test', + 'sh', + '-lc', + expect.stringContaining('timeout 1.234s sh -lc'), + ], + expect.objectContaining({ + timeout: 11_234, + env: { BASE: '1', EXTRA: '2' }, + onStdout, + maxRetainedBytes: 16, + }), + ); + }); + + 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(); + + 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(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(), {}, inspectResult('running'), {}, {}]); + 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', + expect.stringContaining('printf apple-container'), + ], + expect.objectContaining({ timeout: 310_000 }), + ); + }); + + it('marks in-container timeout exits as timed out', async () => { + 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 }); + + expect(result).toMatchObject({ + success: false, + exitCode: 124, + stderr: 'Terminated', + timedOut: true, + killed: true, + }); + expect(runner.run).toHaveBeenLastCalledWith( + [ + 'exec', + '--workdir', + '/workspace', + 'apple-test', + 'sh', + '-lc', + 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 }); + + await sandbox.destroy(); + + 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 () => { + 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(); + await new AppleContainerSandbox({ id: 'apple-missing', runner: missingRunner }).stop(); + + expect(stoppedRunner.run).toHaveBeenCalledOnce(); + 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 }); + + await sandbox.stop(); + + expect(sandbox.status).toBe('stopped'); + expectCliCall(runner, 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(); + + expectCliCall(runner, 1, ['inspect', 'apple-test']); + expectCliCall(runner, 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'); + expectCliCall(runner, 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 }); + + await expect(sandbox.start()).rejects.toMatchObject({ + name: 'SandboxExecutionError', + stdout: 'not json', + }); + }); + + 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([ + missingContainerResult(), + { 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', + }); + expect(sandbox.status).toBe('error'); + }); +}); + +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({ + 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', () => { + 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', + 'id', + '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'); + }); +}); + +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('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, + ['-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('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 new file mode 100644 index 00000000000..4d42d891d75 --- /dev/null +++ b/workspaces/apple-container/src/sandbox/index.ts @@ -0,0 +1,938 @@ +/** + * 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 { 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'; +import type { + CommandResult, + ExecuteCommandOptions, + InstructionsOption, + MastraSandboxOptions, + ProviderStatus, + SandboxInfo, +} from '@mastra/core/workspace'; +import { MastraSandbox, ProcessHandle, SandboxExecutionError } from '@mastra/core/workspace'; + +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_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; + exitCode: number; + stdout: string; + stderr: string; + executionTimeMs: number; + timedOut?: boolean; + killed?: boolean; + stdoutTruncated?: boolean; + stderrTruncated?: boolean; + stdoutDroppedBytes?: number; + stderrDroppedBytes?: number; +} + +export interface AppleContainerCommandRunnerOptions { + timeout?: number; + env?: Record; + 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 { + id?: string; + status?: + | string + | { + state?: string; + networks?: Array>; + }; + configuration?: { + id?: string; + hostname?: string; + labels?: Record; + networks?: Array>; + 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 destination paths. */ + 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 _configuredName?: 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 _userLabels: Record; + private readonly _labels: Record; + private readonly _configHash: string; + 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._configuredName = options.name; + 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 ?? []; + 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 = { + ...this._userLabels, + 'mastra.sandbox': 'true', + 'mastra.sandbox.id': this.id, + 'mastra.sandbox.config-hash': this._configHash, + }; + 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 { + 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._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 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) { + return; + } + this._assertMastraOwned(existing); + if (!isRunning(existing)) { + return; + } + + const result = await this._runCli(['stop', this.containerId]); + if (!result.success && !isMissingContainerMessage(result.stderr)) { + this._assertSuccess(result, `stop Apple container ${this.containerId}`); + } + } + + private async _destroyContainer(): Promise { + if (!this._deleteOnDestroy) { + await this._stopContainer(); + return; + } + + const existing = await this._inspectContainer(); + if (!existing) { + return; + } + this._assertMastraOwned(existing); + + await this._deleteContainerIgnoringMissing(); + } + + async executeCommand( + command: string, + args: string[] = [], + options: ExecuteCommandOptions = {}, + ): Promise { + await this.ensureRunning(); + + 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 = envFlags({ ...this._env, ...options.env }); + const cliArgs = [ + 'exec', + ...env.args, + '--workdir', + options.cwd ?? this._workingDir, + this.containerId, + 'sh', + '-lc', + shellCommand, + ]; + + 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, + stderr, + ...(timedOut && { timedOut: true, killed: true }), + 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: { + ...this._serializableConfig(), + }, + }; + } + + 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 _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._runCli(['inspect', this.containerId]); + if (!result.success) { + if (isMissingContainerMessage(result.stderr)) return undefined; + this._assertSuccess(result, `inspect Apple container ${this.containerId}`); + 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(envArgs: string[]): string[] { + const args = ['run', '-d', '--name', this._containerName, '--workdir', this._workingDir]; + + args.push(...envArgs); + 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 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( + `${action} failed with exit code ${result.exitCode}: ${result.stderr}`, + result.exitCode, + result.stdout, + 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, + '', + '', + ); + } + + 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, + '', + '', + ); + } + + private _runCli(args: string[], options: AppleContainerCommandRunnerOptions = {}): Promise { + return this._runner.run(args, { + timeout: this._timeout, + ...options, + }); + } +} + +export function runAppleContainerCli( + binary: string, + args: string[], + options: AppleContainerCommandRunnerOptions = {}, +): Promise { + const handle = new AppleContainerCliProcess(binary, args, options); + return handle.wait() as Promise; +} + +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; + + constructor(binary: string, args: string[], options: AppleContainerCommandRunnerOptions = {}) { + super({ + maxRetainedBytes: options.maxRetainedBytes, + onStdout: options.onStdout, + onStderr: options.onStderr, + }); + + 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(' ')}`; + + 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 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: this.stdout, + stderr: this.stderr, + executionTimeMs: Date.now() - this.startedAt, + killed: this.killed, + timedOut: this.timedOut, + }; + }; + + const cleanup = (): void => { + if (timeout) clearTimeout(timeout); + if (this.forceKillTimeout) clearTimeout(this.forceKillTimeout); + options.abortSignal?.removeEventListener('abort', onAbort); + }; + + 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))); + }); + }); + }); + + timeout = + options.timeout && options.timeout > 0 + ? setTimeout(() => { + this.timedOut = true; + void this.kill(); + }, options.timeout) + : undefined; + if (options.abortSignal) { + if (options.abortSignal.aborted) { + void this.kill(); + } else { + options.abortSignal.addEventListener('abort', onAbort, { once: true }); + } + } + } + + async wait(): Promise { + return this.waitPromise; + } + + 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; + } + + async sendStdin(): Promise { + throw new Error('Apple container CLI runner does not support stdin'); + } +} + +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 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); + 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 { + 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, "'\\''")}'`; +} + +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); + childEnv[key] = value; + } + return { args, env: childEnv }; +} + +function isRunning(inspect: AppleContainerInspectResult): boolean { + return getContainerState(inspect) === 'running'; +} + +function getContainerState(inspect: AppleContainerInspectResult): string | undefined { + return typeof inspect.status === 'string' ? inspect.status : inspect.status?.state; +} + +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 { + 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)); +} diff --git a/workspaces/apple-container/test/core-workspace.ts b/workspaces/apple-container/test/core-workspace.ts new file mode 100644 index 00000000000..ed59f942a8e --- /dev/null +++ b/workspaces/apple-container/test/core-workspace.ts @@ -0,0 +1,11 @@ +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'; +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'], + }, + }, +});