diff --git a/.github/workflows/containerization-build-template.yml b/.github/workflows/containerization-build-template.yml index beb3dad8..d3847193 100644 --- a/.github/workflows/containerization-build-template.yml +++ b/.github/workflows/containerization-build-template.yml @@ -50,9 +50,9 @@ jobs: make protos if ! git diff --quiet ; then echo the following files require formatting or license headers: ; git diff --name-only ; false ; fi - - name: Make containerization and docs + - name: Make containerization, examples, and docs run: | - make clean containerization docs + make clean containerization examples docs tar cfz _site.tgz _site env: BUILD_CONFIGURATION: ${{ inputs.release && 'release' || 'debug' }} diff --git a/Makefile b/Makefile index f5b47124..d31e7227 100644 --- a/Makefile +++ b/Makefile @@ -139,8 +139,8 @@ swift-fmt: @$(SWIFT) format --recursive --configuration .swift-format -i $(SWIFT_SRC) swift-fmt-check: - @echo Checking code formatting compliance... - @$(SWIFT) format lint --recursive --strict --configuration .swift-format-nolint $(SWIFT_SRC) + @echo Checking code formatting compliance... + @$(SWIFT) format lint --recursive --strict --configuration .swift-format-nolint $(SWIFT_SRC) .PHONY: update-licenses update-licenses: @@ -182,6 +182,14 @@ cleancontent: @echo Cleaning the content... @rm -rf ~/Library/Application\ Support/com.apple.containerization +.PHONY: examples +examples: + @echo Building examples... + @mkdir -p bin + @"$(MAKE)" -C examples/sandboxy build BUILD_CONFIGURATION=$(BUILD_CONFIGURATION) + @install examples/sandboxy/bin/sandboxy ./bin/ + @codesign --force --sign - --timestamp=none --entitlements=signing/vz.entitlements bin/sandboxy + .PHONY: clean clean: @echo Cleaning build files... diff --git a/examples/sandboxy/Makefile b/examples/sandboxy/Makefile new file mode 100644 index 00000000..2be0b9c0 --- /dev/null +++ b/examples/sandboxy/Makefile @@ -0,0 +1,38 @@ +# Copyright © 2026 Apple Inc. and the Containerization project authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +BUILD_CONFIGURATION ?= debug +SWIFT = /usr/bin/swift +SWIFT_STRIP := $(if $(filter release,$(BUILD_CONFIGURATION)),-Xlinker -s) +BUILD_BIN_DIR = $(shell $(SWIFT) build -c $(BUILD_CONFIGURATION) --show-bin-path) + +.PHONY: all build clean run fmt + +all: build + +build: + $(SWIFT) build -c $(BUILD_CONFIGURATION) $(SWIFT_STRIP) + @mkdir -p bin + @install "$(BUILD_BIN_DIR)/sandboxy" ./bin/ + codesign --force --sign - --entitlements sandboxy.entitlements ./bin/sandboxy + +clean: + $(SWIFT) package clean + rm -rf bin + +run: build + ./bin/sandboxy + +fmt: + $(SWIFT) format --in-place --recursive Sources/ diff --git a/examples/sandboxy/Package.resolved b/examples/sandboxy/Package.resolved new file mode 100644 index 00000000..dad21397 --- /dev/null +++ b/examples/sandboxy/Package.resolved @@ -0,0 +1,249 @@ +{ + "originHash" : "7b278fed488f6dec94de45fcf45179f7556a1b32ebb5b19499b932a307ddcad0", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "2fc4652fb4689eb24af10e55cabaa61d8ba774fd", + "version" : "1.32.0" + } + }, + { + "identity" : "containerization", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/containerization.git", + "state" : { + "revision" : "72432f148c9cb35d6a7e7c0ad61d6f9d226cbef7", + "version" : "0.30.0" + } + }, + { + "identity" : "grpc-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift.git", + "state" : { + "revision" : "ac715c584bb1e2e5cdfb7684ccb46fab8dafc641", + "version" : "1.27.4" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "24ccdeeeed4dfaae7955fcac9dbf5489ed4f1a25", + "version" : "1.18.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "8d9834a6189db730f6264db7556a7ffb751e99ee", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "e109d8b5308d0e05201d9a1dd1c475446a946a11", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "e932d3c4d8f77433c8f7093b5ebcbf91463948a0", + "version" : "2.95.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "3df009d563dc9f21a5c85b33d8c2e34d2e4f8c3b", + "version" : "1.32.1" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "b6571f3db40799df5a7fc0e92c399aa71c883edd", + "version" : "1.40.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "173cc69a058623525a58ae6710e2f5727c663793", + "version" : "2.36.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "60c3e187154421171721c1a38e800b390680fb5d", + "version" : "1.26.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "a008af1a102ff3dd6cc3764bb69bf63226d0f5f6", + "version" : "1.36.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "89888196dd79c61c50bca9a103d8114f32e1e598", + "version" : "2.10.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "zstd", + "kind" : "remoteSourceControl", + "location" : "https://github.com/facebook/zstd.git", + "state" : { + "revision" : "f8745da6ff1ad1e7bab384bd1f9d742439278e99", + "version" : "1.5.7" + } + } + ], + "version" : 3 +} diff --git a/examples/sandboxy/Package.swift b/examples/sandboxy/Package.swift new file mode 100644 index 00000000..15b0c52e --- /dev/null +++ b/examples/sandboxy/Package.swift @@ -0,0 +1,57 @@ +// swift-tools-version: 6.2 +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import PackageDescription + +let containerizationVersion = "0.26.5" + +let package = Package( + name: "sandboxy", + platforms: [ + .macOS("26.0") + ], + products: [ + .executable( + name: "sandboxy", + targets: ["sandboxy"] + ) + ], + dependencies: [ + .package(url: "https://github.com/apple/containerization.git", from: "0.30.0"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), + .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.20.1"), + ], + targets: [ + .executableTarget( + name: "sandboxy", + dependencies: [ + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationExtras", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + .product(name: "ContainerizationArchive", package: "containerization"), + .product(name: "AsyncHTTPClient", package: "async-http-client"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + ], + ) + ] +) diff --git a/examples/sandboxy/README.md b/examples/sandboxy/README.md new file mode 100644 index 00000000..3246c2e8 --- /dev/null +++ b/examples/sandboxy/README.md @@ -0,0 +1,374 @@ +# sandboxy + +``` +$ sandboxy run claude + ┌──────────────┐ + │ ░░░░░░░░░░░░ │ Sandboxy + │ ░░░░░░░░░░░░ │ Agent: Claude Code + │ ░░░░░░░░░░░░ │ Instance: claude-20260328-150531 + │ ░░░░░░░░░░░░ │ Environment: 15 hours ago + │ ░░░░░░░░░░░░ │ Workspace: /Volumes/code/vessel/containerization + │ ░░░░░░░░░░░░ │ CPUs: 4 Memory: 4 GB + └──────────────┘ + Command: claude --dangerously-skip-permissions + Allowed hosts: *.anthropic.com, npm.org, *.npmjs.org, *.github.com, *.githubusercontent.com, *.pypi.org + Mounts: + /your/code -> /your/code + /Users/you/.claude -> /root/.claude + +Welcome to Claude Code v2.1.76 +………………………………………………………………………………………………………………………………………………………… + + * █████▓▓░ + * ███▓░ ░░ + ░░░░░░ ███▓░ + ░░░ ░░░░░░░░░░ ███▓░ + ░░░░░░░░░░░░░░░░░░░ * ██▓░░ ▓ + ░▓▓███▓▓░ + * ░░░░ + ░░░░░░░░ + ░░░░░░░░░░░░░░░░ + █████████ * + ██▄█████▄██ * + █████████ * +…………………█ █ █ █……………………………………………………………………………………………………………… + + Let's get started. +``` + +`sandboxy` runs AI coding agents in sandboxed Linux environments on macOS with Apple silicon. + +One command to get an isolated agent session. Your current working directory is mounted in, your config carries over, and the environment is cached for fast subsequent runs. + +> **Note:** This is an experimental tool. Behavior/flags/commands may change across releases. Its main goal was to be a good showcase of using the `Containerization` libraries API surface to build novel tools. + +> **Note:** The tool does HTTPS/HTTP filtering today for network traffic originating from the container, but can currently reach services listening on `0.0.0.0` on the host. This should be tightened up in a future release whenever `Containerization` gains nftables support. + +## Why? + +AI coding agents work best when they can install packages, run builds, and execute code freely, but giving them unrestricted access to your host machine is risky. `sandboxy` aims to alleviate some worry by running agents inside lightweight Linux VMs on macOS. Your project directory is mounted in so the agent can read and write files, but everything else like network access, host filesystem view, and installed packages is isolated. No daemon, just a single command that gets out of your way. + +## How? + +`sandboxy` boots a Micro VM for every agent session using the [`Containerization`](https://github.com/apple/containerization) Swift package. Each agent session runs in its own VM. + +### Caching + +The first time you run an agent, `sandboxy` does a fair amount of setup: downloading a Linux kernel (unless you provide one), pulling the base OCI image, unpacking it into a root filesystem, and running the agent's install commands. All of this is cached so that subsequent runs skip straight to booting the VM. In practice, a warm start takes under a second. + +The cache has a few layers: + +- **Kernel** -- downloaded once and reused across all agents. +- **Init image** -- the minimal init process (`vminit`) that bootstraps the VM, pulled from an OCI registry and cached locally. +- **Agent rootfs** -- the fully-installed root filesystem for a given agent (e.g., `claude`). This is an ext4 disk image that includes the base image layers plus everything the agent's install commands produce. +- **Instance rootfs** -- every session saves its rootfs so it can be resumed later with `--name`. See [Instance Persistence](#instance-persistence). + +On each run, the cached rootfs is cloned (via copy-on-write when the filesystem supports it) so the original cache stays clean. You can blow away any layer independently: `sandboxy cache rm ` to rebuild a single agent, or `sandboxy cache clean --all` to start completely from scratch. + +### Agent definitions + +An agent is just a JSON file that describes how to set up and launch a particular tool. The built-in Claude Code definition specifies a base container image, a list of shell commands to install the toolchain, a launch command, and the environment variables needed at runtime. You can list available agents with `sandboxy config list --agents` and view any agent's definition with `sandboxy config list --agent `. + +You can override any built-in agent or define entirely new agents by dropping a JSON file in `~/.config/sandboxy/agents/`. Use `sandboxy config create --agent ` to scaffold a definition file (pre-filled with built-in defaults for known agents). Use `sandboxy config list --paths` to see all configuration file paths. + +If the built-in install steps don't cover what you need, `sandboxy edit ` drops you into an interactive shell inside the cached rootfs. Install extra packages, configure MCP servers, add language runtimes etc. Whatever you do is saved back to the cache and included in every future run. + +### Workspace and mounts + +Your host workspace directory is shared into the VM using virtio-fs, so reads and writes are reflected immediately on both sides. Additional host directories can be mounted with `--mount`, including read-only mounts for things like config or reference data that the agent shouldn't modify. + +Agent definitions can include default mounts (e.g., `~/.claude` for Claude Code). To skip these on a specific run, pass `--no-agent-mounts`. CLI `--mount` flags are always applied regardless. + +### Network isolation + +By default, `sandboxy` enforces network isolation by placing the workload container on a host-only network with no internet route. A HTTP CONNECT proxy runs on the host and listens on the host-only network's gateway address. The workload's `HTTP_PROXY`/`HTTPS_PROXY` environment variables point at this proxy, which checks each request's target hostname against an allowlist and either tunnels it to the internet or returns a 403. + +Additional hosts can be added at runtime with `--allow-hosts`. To disable filtering entirely, pass `--no-network-filter`. + +## Quick Start + +```bash +# Build +BUILD_CONFIGURATION=release make build + +# Run Claude Code on the current directory +.build/release/sandboxy run claude +``` + +On first run, `sandboxy` downloads a kernel, pulls a base image, and installs the agent toolchain. This is cached automatically so subsequent runs generally start in less than a second. + +## Supported Agents + +- **Claude Code** - built-in + +Additional agents can be added via JSON config files. See [Adding a New Agent](#adding-a-new-agent). + +## Commands + +### `sandboxy run ` + +Run an agent in a sandboxed container. + +```bash +# Run on the current directory +sandboxy run claude + +# Specify a workspace +sandboxy run --workspace ~/projects/myapp claude + +# Allocate more resources +sandboxy run --cpus 8 --memory 8g claude + +# Restrict network to specific hosts (in addition to agent defaults) +sandboxy run --allow-hosts api.example.com --allow-hosts internal.corp.com claude + +# Disable network filtering entirely +sandboxy run --no-network-filter claude + +# Mount additional directories (read-only or read-write) +sandboxy run --mount /tmp:/tmp:ro --mount ~/data:/data claude + +# Skip mounts defined in the agent configuration +sandboxy run --no-agent-mounts claude + +# Forward environment variables into the container +sandboxy run -e MY_TOKEN -e DEBUG=1 claude + +# Forward the host SSH agent for git-over-SSH +sandboxy run --ssh-agent claude + +# Give the instance a friendly name +sandboxy run --name my-feature claude + +# Resume a named session +sandboxy run --name my-feature claude + +# Ephemeral run (remove instance after session ends) +sandboxy run --rm claude + +# Pass flags through to the agent +sandboxy run claude -- --model foobar +``` + +**Options:** + +| Flag | Description | Default | +|------|-------------|---------| +| `-w`, `--workspace` | Host directory to mount | Current directory | +| `-m`, `--mount` | Additional mount (hostpath:containerpath[:ro\|rw], repeatable) | None | +| `-e`, `--env` | Set environment variable (KEY=VALUE or KEY to forward from host, repeatable) | None | +| `-k`, `--kernel` | Path to a Linux kernel | Auto-download | +| `--cpus` | Number of CPUs | 4 | +| `--memory` | Memory to allocate (e.g. `4g`, `512m`, `4096` for MB) | `4g` | +| `--allow-hosts` | Additional hostnames to allow (merged with agent defaults) | Agent defaults | +| `--no-network-filter` | Disable network filtering (allow unrestricted access) | Off | +| `--no-agent-mounts` | Skip mounts defined in the agent configuration | Off | +| `--name` | Persistent session name | Auto-generated | +| `--rm` | Remove instance after session ends | Off | +| `--reinstall` | Rebuild the cached environment from scratch | Off | +| `--ssh-agent` | Forward the host SSH agent socket into the container | Off | + +### `sandboxy edit ` + +Open an interactive shell in the agent's cached environment. Install packages, configure MCP tools, add language runtimes etc. +Changes are saved back to the cache when you exit. + +If no cache exists yet, the agent's install commands are run first. If any install step fails, you're dropped into the shell anyway so you can diagnose or finish the setup manually. + +```bash +sandboxy edit claude + +# Inside the container: +apt-get install -y python3-pip +pip3 install some-mcp-tool +exit # changes are saved +``` + +Every future `sandboxy run claude` will include your changes. + +### `sandboxy list` (alias: `ls`) + +Show sandbox instances and their status. + +```bash +sandboxy ls +``` + +### `sandboxy rm [...]` + +Remove one or more instances and their preserved state. + +```bash +# Remove a single instance +sandboxy rm my-feature + +# Remove multiple instances +sandboxy rm instance-1 instance-2 + +# Remove all instances +sandboxy rm --all +sandboxy rm -a +``` + +### `sandboxy cache list` + +Show cached environments and their disk usage. + +### `sandboxy cache rm ` + +Remove a specific agent's cached environment. The next run will rebuild it. + +### `sandboxy cache clean [--all] [--yes]` + +Remove all cached environments and named instance state. If named instances exist, you'll be prompted for confirmation. With `--yes`, skip the prompt. With `--all`, also removes the kernel, init image, and content store, forcing a full re-download on the next run. + +### `sandboxy config list` + +Print current configuration or agent definitions. + +```bash +# Print global defaults +sandboxy config list + +# Print a specific agent's definition +sandboxy config list --agent claude + +# List all available agents (built-in and custom) +sandboxy config list --agents + +# Print configuration file paths +sandboxy config list --paths +``` + +### `sandboxy config create` + +Create a default configuration or agent definition file. If the file already exists, you'll be prompted to confirm overwriting (use `--force` to skip). + +For built-in agents (e.g. `claude`), the file is pre-filled with the built-in definition so you have a working starting point to customize. + +```bash +# Create a global config.json with defaults +sandboxy config create + +# Create an override for the built-in claude agent +sandboxy config create --agent claude + +# Scaffold a new agent definition +sandboxy config create --agent myagent + +# Overwrite an existing definition without prompting +sandboxy config create --agent claude --force +``` + +## Instance Persistence + +Every session automatically saves its rootfs when it exits. The instance appears in `sandboxy ls` and can be resumed by passing its name to `--name`: + +```bash +# First run -- auto-named instance +sandboxy run claude +# => Instance claude-20260328-091522 saved. Resume with: sandboxy run claude --name claude-20260328-091522 + +# Resume it +sandboxy run --name claude-20260328-091522 claude + +# Or give it a memorable name upfront +sandboxy run --name my-feature claude + +# List all instances +sandboxy ls + +# Clean up +sandboxy rm my-feature + +# Ephemeral run (nothing saved) +sandboxy run --rm claude +``` + +Use `--rm` for throwaway sessions that shouldn't persist. + +## API Keys + +Agent definitions can include environment variable names without values (e.g. `"ANTHROPIC_API_KEY"`), which are automatically forwarded from the host if set. The built-in Claude Code agent forwards `ANTHROPIC_API_KEY` this way. For custom agents, add the relevant key name to the `environmentVariables` array, or pass it at runtime with `-e`. + +## Network Filtering + +Network filtering is enabled by default. Each agent definition includes an `allowedHosts` list. Additional hosts can be added at runtime with `--allow-hosts`. An empty `allowedHosts` list means all traffic is denied. To disable filtering entirely, pass `--no-network-filter`. + +The workload container runs on a **host-only network** with no internet route. A lightweight HTTP CONNECT proxy runs on the macOS host, bound to the host-only network's gateway address. The workload's proxy environment variables point at this address, so tools that respect `HTTP_PROXY`/`HTTPS_PROXY` route their traffic through the proxy automatically. + +Note that the proxy relies on applications honoring `HTTP_PROXY`/`HTTPS_PROXY` environment variables. Tools that ignore these variables won't be able to reach the internet since the container has no direct internet route. + +Agent toolchain installation (apt-get, npm install, etc.) runs with full network access on a shared network before the proxy is set up, so package repos don't need to be allowlisted. + +## Adding a New Agent + +Create a JSON file in the `agents/` directory, or use `sandboxy config create --agent ` to scaffold one: + +`~/.config/sandboxy/agents/.json` + +Example (`foo.json`): + +```json +{ + "displayName": "Foo", + "baseImage": "docker.io/library/python:3.12-slim", + "installCommands": [ + "pip install foo" + ], + "launchCommand": ["foo"], + "environmentVariables": [], + "mounts": [], + "allowedHosts": ["api.example.com", "*.cdn.example.com"] +} +``` + +Then run it with `sandboxy run foo`. + +To override a built-in agent, create a file with the same name (e.g., `claude.json`). Only the fields you include are overridden. Omitted fields keep their defaults. Use `sandboxy config list --agent claude` to see the full default definition. + +## Configuration + +Global defaults can be overridden with a config file: + +`~/.config/sandboxy/config.json` + +```json +{ + "dataDir": "/Volumes/fast/sandboxy", + "kernel": "/path/to/vmlinux", + "initfsReference": "ghcr.io/apple/containerization/vminit:0.26.5", + "defaultCPUs": 8, + "defaultMemory": "8g" +} +``` + +All fields are optional. Use `sandboxy config list` to see the defaults. + +## Kernel + +By default, `sandboxy` downloads a Linux kernel from the [Kata Containers](https://github.com/kata-containers/kata-containers) project (arm64 static release). The kernel is cached at `~/Library/Application Support/com.apple.containerization.sandboxy/kernel/vmlinux` and reused across all agent sessions. + +To use your own kernel, pass it directly: + +```bash +sandboxy run -k /path/to/vmlinux claude +``` + +Or set it permanently in `config.json`: + +```json +{ + "kernel": "/path/to/vmlinux" +} +``` + +The kernel must be an uncompressed Linux kernel binary (`vmlinux`, not `bzImage` or `zImage`) built for arm64 with virtio drivers enabled (virtio-net, virtio-blk, virtio-fs, virtio-console at minimum). + +## Building + +```bash +make build +``` + +The built binary is at `.build/release/sandboxy`. It requires macOS 26 on Apple silicon. diff --git a/examples/sandboxy/Sources/sandboxy/AgentDefinition.swift b/examples/sandboxy/Sources/sandboxy/AgentDefinition.swift new file mode 100644 index 00000000..38b8f4c8 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/AgentDefinition.swift @@ -0,0 +1,243 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +/// Defines an AI coding agent that can be run inside a sandbox container. +/// +/// Agent definitions can be built-in or loaded from JSON files in the +/// `agents/` subdirectory of the sandboxy config directory. +/// +/// Location: `~/.config/sandboxy/agents/.json` +/// +/// Example (`foo.json`): +/// ```json +/// { +/// "displayName": "Foo", +/// "baseImage": "docker.io/library/python:3.12-slim", +/// "installCommands": [ +/// "pip install foo" +/// ], +/// "launchCommand": ["foo"], +/// "environmentVariables": [], +/// "mounts": [ +/// {"hostPath": "~/.foo", "containerPath": "/root/.foo", "readOnly": true} +/// ], +/// "allowedHosts": ["api.example.com", "*.cdn.example.com"] +/// } +/// ``` +struct AgentDefinition: Codable, Sendable { + /// Human-readable name used in output messages. + let displayName: String + + /// The base container image reference (e.g., "docker.io/library/node:22"). + let baseImage: String + + /// Shell commands run sequentially inside the container to install the agent + /// and its dependencies. Each string is passed as an argument to `sh -c`. + let installCommands: [String] + + /// The command and arguments to launch the agent interactively. + let launchCommand: [String] + + /// Environment variables required by the agent (key=value format). + let environmentVariables: [String] + + /// Host paths to mount into the container. Each entry specifies a host path + /// and a container path. Paths starting with `~` are expanded to the user's + /// home directory. Only mounted if the host path exists. + let mounts: [AgentMount] + + /// Default hostnames to allow through the network filtering proxy. + /// Supports exact matches and `*.suffix` wildcard patterns. + /// Merged with CLI `--allow-hosts` values. + /// An empty list means all traffic is denied. Use `--no-network-filter` to disable filtering. + let allowedHosts: [String] +} + +/// A host-to-container mount for an agent definition. +struct AgentMount: Codable, Sendable { + /// Path on the host. Supports `~` for the user's home directory. + let hostPath: String + + /// Path inside the container where the host path is mounted. + let containerPath: String + + /// Whether the mount is read-only. Defaults to `false`. + let readOnly: Bool + + init(hostPath: String, containerPath: String, readOnly: Bool = false) { + self.hostPath = hostPath + self.containerPath = containerPath + self.readOnly = readOnly + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + hostPath = try container.decode(String.self, forKey: .hostPath) + containerPath = try container.decode(String.self, forKey: .containerPath) + readOnly = try container.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false + } + + /// Returns the resolved absolute host path, expanding `~`. + var resolvedHostPath: String { + if hostPath.hasPrefix("~/") { + let home = FileManager.default.homeDirectoryForCurrentUser.path(percentEncoded: false) + return home + String(hostPath.dropFirst(1)) + } + if hostPath == "~" { + return FileManager.default.homeDirectoryForCurrentUser.path(percentEncoded: false) + } + return hostPath + } +} + +extension AgentDefinition { + /// Built-in agent definitions, keyed by their CLI name. + static let builtIn: [String: AgentDefinition] = [ + "claude": .claude + ] + + /// Returns all available agents: built-in definitions merged with any + /// user-defined agents from `/agents/`. If a user file matches + /// a built-in agent name, non-nil fields from the user file override the + /// built-in values, allowing partial overrides. + static func allAgents(configRoot: URL) -> [String: AgentDefinition] { + var agents = builtIn + + let agentsDir = configRoot.appendingPathComponent("agents") + let agentsDirPath = agentsDir.path(percentEncoded: false) + guard FileManager.default.fileExists(atPath: agentsDirPath) else { + return agents + } + + do { + let files = try FileManager.default.contentsOfDirectory( + at: agentsDir, + includingPropertiesForKeys: nil + ).filter { $0.pathExtension == "json" } + + let decoder = JSONDecoder() + for file in files { + let name = file.deletingPathExtension().lastPathComponent + do { + let data = try Data(contentsOf: file) + let override = try decoder.decode(AgentOverride.self, from: data) + if let base = agents[name] { + agents[name] = override.merged(onto: base) + } else { + agents[name] = try override.asFullDefinition() + } + } catch { + ProgressUI.printWarning( + "Failed to load agent definition from \(file.lastPathComponent): \(error)") + } + } + } catch { + ProgressUI.printWarning("Failed to read agents directory: \(error)") + } + + return agents + } + + /// Returns the sorted list of available agent names for display in help text. + static func knownAgentNames(configRoot: URL) -> [String] { + allAgents(configRoot: configRoot).keys.sorted() + } + + static let claude = AgentDefinition( + displayName: "Claude Code", + baseImage: "docker.io/library/node:22", + installCommands: [ + "apt-get update && apt-get install -y --no-install-recommends less git procps sudo fzf zsh man-db unzip gnupg2 gh ipset iproute2 dnsutils aggregate jq nano vim ripgrep ca-certificates && apt-get clean && rm -rf /var/lib/apt/lists/*", + "npm install -g @anthropic-ai/claude-code", + "npm install -g global-agent", + ], + launchCommand: ["claude", "--dangerously-skip-permissions"], + environmentVariables: [ + "ANTHROPIC_API_KEY", + "NODE_OPTIONS=--max-old-space-size=4096", + "IS_SANDBOX=1", + ], + mounts: [ + AgentMount(hostPath: "~/.claude", containerPath: "/root/.claude") + ], + allowedHosts: [ + "*.anthropic.com", + "*.claude.com", + "npm.org", + "*.npmjs.org", + "*.github.com", + "*.githubusercontent.com", + "*.pypi.org", + "*.pythonhosted.org", + ] + ) +} + +/// All-optional mirror of `AgentDefinition` used when loading user override files. +/// For agents that match a built-in name, only the non-nil fields override the defaults. +/// For entirely new agents, all required fields must be provided. +struct AgentOverride: Codable, Sendable { + var displayName: String? + var baseImage: String? + var installCommands: [String]? + var launchCommand: [String]? + var environmentVariables: [String]? + var mounts: [AgentMount]? + var allowedHosts: [String]? + + /// Merges this override onto a base definition, replacing only the fields + /// that are non-nil in the override. + func merged(onto base: AgentDefinition) -> AgentDefinition { + AgentDefinition( + displayName: displayName ?? base.displayName, + baseImage: baseImage ?? base.baseImage, + installCommands: installCommands ?? base.installCommands, + launchCommand: launchCommand ?? base.launchCommand, + environmentVariables: environmentVariables ?? base.environmentVariables, + mounts: mounts ?? base.mounts, + allowedHosts: allowedHosts ?? base.allowedHosts + ) + } + + /// Converts this override into a full definition, throwing if any required + /// fields are missing. Used for entirely new (non-built-in) agents. + func asFullDefinition() throws -> AgentDefinition { + guard let displayName, let baseImage, let installCommands, + let launchCommand + else { + throw SandboxyError.incompleteAgentDefinition( + missing: [ + displayName == nil ? "displayName" : nil, + baseImage == nil ? "baseImage" : nil, + installCommands == nil ? "installCommands" : nil, + launchCommand == nil ? "launchCommand" : nil, + ].compactMap { $0 } + ) + } + + return AgentDefinition( + displayName: displayName, + baseImage: baseImage, + installCommands: installCommands, + launchCommand: launchCommand, + environmentVariables: environmentVariables ?? [], + mounts: mounts ?? [], + allowedHosts: allowedHosts ?? [] + ) + } +} diff --git a/examples/sandboxy/Sources/sandboxy/CacheCommand.swift b/examples/sandboxy/Sources/sandboxy/CacheCommand.swift new file mode 100644 index 00000000..a9b94b2b --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/CacheCommand.swift @@ -0,0 +1,263 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ArgumentParser +import Foundation + +extension Sandboxy { + struct Cache: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "cache", + abstract: "Manage cached rootfs images", + subcommands: [ + CacheList.self, + CacheRemove.self, + CacheClean.self, + ] + ) + } + + struct CacheList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List cached rootfs images and named instance state" + ) + + func run() async throws { + _ = try Sandboxy.loadConfig() + + let cacheDir = Sandboxy.appRoot.appendingPathComponent("cache") + let namedDir = InstanceState.namedRootfsDir(appRoot: Sandboxy.appRoot) + + let agentCaches = listRootfsFiles(in: cacheDir, suffix: "-rootfs.ext4") + let namedCaches = listRootfsFiles(in: namedDir, suffix: "-rootfs.ext4") + + if agentCaches.isEmpty && namedCaches.isEmpty { + print("No cached images found.") + return + } + + if !agentCaches.isEmpty { + print("Agent caches:") + print(pad(" NAME", to: 20) + pad("SIZE", to: 12) + "MODIFIED") + for entry in agentCaches { + print( + pad(" \(entry.name)", to: 20) + + pad(formatBytes(entry.diskSize), to: 12) + + entry.modified + ) + } + } + + if !namedCaches.isEmpty { + if !agentCaches.isEmpty { print() } + print("Named instances:") + print(pad(" NAME", to: 20) + pad("SIZE", to: 12) + "MODIFIED") + for entry in namedCaches { + print( + pad(" \(entry.name)", to: 20) + + pad(formatBytes(entry.diskSize), to: 12) + + entry.modified + ) + } + } + } + + private struct CacheEntry { + let name: String + let diskSize: UInt64 + let modified: String + } + + private func listRootfsFiles(in dir: URL, suffix: String) -> [CacheEntry] { + let dirPath = dir.path(percentEncoded: false) + guard FileManager.default.fileExists(atPath: dirPath) else { + return [] + } + + do { + let files = try FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [ + .totalFileAllocatedSizeKey, + .contentModificationDateKey, + ] + ).filter { $0.lastPathComponent.hasSuffix(suffix) } + + return files.compactMap { file -> CacheEntry? in + let cleanName = file.lastPathComponent + .replacingOccurrences(of: suffix, with: "") + + do { + let values = try file.resourceValues(forKeys: [ + .totalFileAllocatedSizeKey, + .contentModificationDateKey, + ]) + let diskSize = UInt64(values.totalFileAllocatedSize ?? 0) + let date = values.contentModificationDate ?? Date() + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return CacheEntry( + name: cleanName, + diskSize: diskSize, + modified: formatter.string(from: date) + ) + } catch { + return nil + } + }.sorted { $0.name < $1.name } + } catch { + return [] + } + } + + private func pad(_ s: String, to width: Int) -> String { + if s.count >= width { return s } + return s + String(repeating: " ", count: width - s.count) + } + + private func formatBytes(_ bytes: UInt64) -> String { + if bytes < 1024 { return "\(bytes) B" } + let kb = Double(bytes) / 1024 + if kb < 1024 { return String(format: "%.1f KB", kb) } + let mb = kb / 1024 + if mb < 1024 { return String(format: "%.1f MB", mb) } + let gb = mb / 1024 + return String(format: "%.1f GB", gb) + } + } + + struct CacheRemove: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "rm", + abstract: "Remove a specific agent cache" + ) + + @Argument(help: "Name of the agent cache to remove") + var name: String + + func run() async throws { + _ = try Sandboxy.loadConfig() + + let cacheDir = Sandboxy.appRoot.appendingPathComponent("cache") + let cachePath = cacheDir.appendingPathComponent("\(name)-rootfs.ext4") + + guard FileManager.default.fileExists(atPath: cachePath.path(percentEncoded: false)) else { + print("No cache found for agent '\(name)'.") + throw ExitCode.failure + } + + try FileManager.default.removeItem(at: cachePath) + print("Removed cache for '\(name)'.") + } + } + + struct CacheClean: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "clean", + abstract: "Remove all cached rootfs images (use --all to also remove kernel, init image, and content store)" + ) + + @Flag(name: .long, help: "Also remove kernel, init image, and content store, forcing a full re-download on next run") + var all = false + + @Flag(name: .long, help: "Skip confirmation prompts") + var yes = false + + func run() async throws { + _ = try Sandboxy.loadConfig() + + let fm = FileManager.default + + // Check for named instances and warn the user before deleting them. + let namedDir = InstanceState.namedRootfsDir(appRoot: Sandboxy.appRoot) + let namedInstances = listNamedInstances(in: namedDir) + if !namedInstances.isEmpty && !yes { + print("This will also delete the following named instances:") + for name in namedInstances { + print(" - \(name)") + } + print() + print("Are you sure? [y/N] ", terminator: "") + guard let response = readLine()?.lowercased(), response == "y" || response == "yes" else { + print("Aborted.") + return + } + } + + let cacheDir = Sandboxy.appRoot.appendingPathComponent("cache") + if fm.fileExists(atPath: cacheDir.path(percentEncoded: false)) { + try fm.removeItem(at: cacheDir) + try fm.createDirectory(at: cacheDir, withIntermediateDirectories: true) + } + + if fm.fileExists(atPath: namedDir.path(percentEncoded: false)) { + try fm.removeItem(at: namedDir) + try fm.createDirectory(at: namedDir, withIntermediateDirectories: true) + } + + if all { + let kernelDir = Sandboxy.appRoot.appendingPathComponent("kernel") + if fm.fileExists(atPath: kernelDir.path(percentEncoded: false)) { + try fm.removeItem(at: kernelDir) + print("Removed kernel.") + } + + let initfs = Sandboxy.appRoot.appendingPathComponent("initfs.ext4") + if fm.fileExists(atPath: initfs.path(percentEncoded: false)) { + try fm.removeItem(at: initfs) + print("Removed init image.") + } + + let contentDir = Sandboxy.appRoot.appendingPathComponent("content") + if fm.fileExists(atPath: contentDir.path(percentEncoded: false)) { + try fm.removeItem(at: contentDir) + print("Removed content store.") + } + + // Remove the image store reference database so stale references + // don't point to missing content. + let stateFile = Sandboxy.appRoot.appendingPathComponent("state.json") + if fm.fileExists(atPath: stateFile.path(percentEncoded: false)) { + try fm.removeItem(at: stateFile) + } + + print("All caches and downloaded artifacts removed.") + } else { + print("All caches removed. Use --all to also remove kernel, init image, and content store.") + } + } + + private func listNamedInstances(in dir: URL) -> [String] { + let path = dir.path(percentEncoded: false) + guard FileManager.default.fileExists(atPath: path) else { + return [] + } + do { + return try FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: nil + ) + .filter { $0.lastPathComponent.hasSuffix("-rootfs.ext4") } + .map { $0.lastPathComponent.replacingOccurrences(of: "-rootfs.ext4", with: "") } + .sorted() + } catch { + return [] + } + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/ConfigCommand.swift b/examples/sandboxy/Sources/sandboxy/ConfigCommand.swift new file mode 100644 index 00000000..68e81c5c --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/ConfigCommand.swift @@ -0,0 +1,170 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ArgumentParser +import Foundation + +extension Sandboxy { + struct Config: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "config", + abstract: "View and create configuration files", + subcommands: [ + ConfigList.self, + ConfigCreate.self, + ] + ) + } + + struct ConfigList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "Print current configuration and agent definitions" + ) + + @Option(name: .long, help: "Print the definition for a specific agent") + var agent: String? + + @Flag(name: .long, help: "Print built-in defaults instead of the resolved configuration") + var defaults = false + + @Flag(name: .long, help: "Print configuration file paths") + var paths = false + + @Flag(name: .long, help: "List available agents") + var agents = false + + func run() async throws { + if paths { + let configPath = Sandboxy.configRoot.appendingPathComponent("config.json") + let agentsDir = Sandboxy.configRoot.appendingPathComponent("agents") + print("Config: \(configPath.path(percentEncoded: false))") + print("Agents: \(agentsDir.path(percentEncoded: false))") + print("Data: \(Sandboxy.appRoot.path(percentEncoded: false))") + return + } + + if agents { + let allAgents = AgentDefinition.allAgents(configRoot: Sandboxy.configRoot) + let builtInNames = Set(AgentDefinition.builtIn.keys) + for name in allAgents.keys.sorted() { + let definition = allAgents[name]! + let source = builtInNames.contains(name) ? "built-in" : "custom" + print(" \(name) - \(definition.displayName) (\(source))") + } + return + } + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + if let agentName = agent { + let allAgents = AgentDefinition.allAgents(configRoot: Sandboxy.configRoot) + guard let definition = allAgents[agentName] else { + let available = allAgents.keys.sorted().joined(separator: ", ") + throw ValidationError( + "Unknown agent '\(agentName)'. Available agents: \(available)" + ) + } + let data = try encoder.encode(definition) + print(String(data: data, encoding: .utf8)!) + } else { + let config = defaults ? SandboxyConfig.defaults : try Sandboxy.loadConfig() + let data = try encoder.encode(config) + print(String(data: data, encoding: .utf8)!) + } + } + } + + struct ConfigCreate: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a default configuration or agent definition file" + ) + + @Option(name: .long, help: "Create a definition file for a new agent with this name") + var agent: String? + + @Flag(name: .long, help: "Overwrite existing files without prompting") + var force = false + + func run() async throws { + let fm = FileManager.default + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + if let agentName = agent { + let agentsDir = Sandboxy.configRoot.appendingPathComponent("agents") + try fm.createDirectory(at: agentsDir, withIntermediateDirectories: true) + + let filePath = agentsDir.appendingPathComponent("\(agentName).json") + if fm.fileExists(atPath: filePath.path(percentEncoded: false)) && !force { + guard isatty(STDIN_FILENO) != 0 else { + print("Agent definition already exists at \(filePath.path(percentEncoded: false)). Use --force to overwrite.") + throw ExitCode.failure + } + print("Agent definition already exists at \(filePath.path(percentEncoded: false))") + print("Overwrite? [y/N] ", terminator: "") + guard let response = readLine()?.lowercased(), response == "y" || response == "yes" else { + print("Aborted.") + return + } + } + + let definition: AgentDefinition + if let builtIn = AgentDefinition.builtIn[agentName] { + definition = builtIn + } else { + definition = AgentDefinition( + displayName: agentName.capitalized, + baseImage: "docker.io/library/node:22", + installCommands: [], + launchCommand: [agentName], + environmentVariables: [], + mounts: [], + allowedHosts: [] + ) + } + + let data = try encoder.encode(definition) + try data.write(to: filePath, options: .atomic) + print("Created agent definition at \(filePath.path(percentEncoded: false))") + print() + print(String(data: data, encoding: .utf8)!) + } else { + let configPath = Sandboxy.configRoot.appendingPathComponent("config.json") + if fm.fileExists(atPath: configPath.path(percentEncoded: false)) && !force { + guard isatty(STDIN_FILENO) != 0 else { + print("Configuration file already exists at \(configPath.path(percentEncoded: false)). Use --force to overwrite.") + throw ExitCode.failure + } + print("Configuration file already exists at \(configPath.path(percentEncoded: false))") + print("Overwrite? [y/N] ", terminator: "") + guard let response = readLine()?.lowercased(), response == "y" || response == "yes" else { + print("Aborted.") + return + } + } + + try fm.createDirectory(at: Sandboxy.configRoot, withIntermediateDirectories: true) + + let data = try encoder.encode(SandboxyConfig.defaults) + try data.write(to: configPath, options: .atomic) + print("Created configuration file at \(configPath.path(percentEncoded: false))") + } + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/EditCommand.swift b/examples/sandboxy/Sources/sandboxy/EditCommand.swift new file mode 100644 index 00000000..d55a81df --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/EditCommand.swift @@ -0,0 +1,245 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ArgumentParser +import Containerization +import ContainerizationExtras +import ContainerizationOS +import Foundation + +extension Sandboxy { + struct Edit: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "edit", + abstract: "Open an interactive shell in an agent's cached environment", + discussion: """ + Boots the cached rootfs for the given agent and drops you into a shell. + Any changes you make (installing packages, editing configs, etc.) are + saved back to the cache when you exit. If no cache exists, the agent + is installed from scratch first. + """ + ) + + @Argument(help: "Agent whose environment to edit (e.g. claude)") + var agent: String + + @Option( + name: [.customLong("kernel"), .customShort("k")], + help: "Path to Linux kernel binary (auto-downloads if omitted)", + completion: .file(), + transform: { str in + URL(fileURLWithPath: str, relativeTo: .currentDirectory()) + .absoluteURL.path(percentEncoded: false) + }) + var kernel: String? + + func run() async throws { + let config = try Sandboxy.loadConfig() + + let agents = AgentDefinition.allAgents(configRoot: Sandboxy.configRoot) + guard let definition = agents[agent] else { + let available = agents.keys.sorted().joined(separator: ", ") + throw ValidationError( + "Unknown agent '\(agent)'. Available agents: \(available)" + ) + } + + ProgressUI.printStatus("Opening \(definition.displayName) environment for editing...") + + let kernelPath = try await KernelManager.ensureKernel( + explicitPath: kernel, + appRoot: Sandboxy.appRoot, + config: config + ) + let vmKernel = Kernel(path: kernelPath, platform: .linuxArm) + + let enableNetworking: Bool + var sharedNetwork: VmnetNetwork? + if #available(macOS 26, *) { + sharedNetwork = try VmnetNetwork() + enableNetworking = true + } else { + sharedNetwork = nil + enableNetworking = false + } + + let vmnetMTU: UInt32 = 1400 + + let initfsReference = config.initfsReference ?? SandboxyConfig.defaults.initfsReference! + var manager = try await ContainerManager( + kernel: vmKernel, + initfsReference: initfsReference, + root: Sandboxy.appRoot + ) + + let containerId = "\(agent)-edit-\(ProcessInfo.processInfo.processIdentifier)" + + let cacheDir = Sandboxy.appRoot.appendingPathComponent("cache") + try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + let agentCachePath = cacheDir.appendingPathComponent("\(agent)-rootfs.ext4") + let containerRootfsPath = Sandboxy.appRoot + .appendingPathComponent("containers") + .appendingPathComponent(containerId) + .appendingPathComponent("rootfs.ext4") + + let hasCachedRootfs = FileManager.default.fileExists( + atPath: agentCachePath.path(percentEncoded: false)) + + let container: LinuxContainer + + if hasCachedRootfs { + ProgressUI.printDetail("Using cached environment...") + let containerDir = Sandboxy.appRoot + .appendingPathComponent("containers") + .appendingPathComponent(containerId) + try FileManager.default.createDirectory( + at: containerDir, withIntermediateDirectories: true) + + let result = Darwin.clonefile( + agentCachePath.path(percentEncoded: false), + containerRootfsPath.path(percentEncoded: false), + 0 + ) + if result != 0 { + try FileManager.default.copyItem(at: agentCachePath, to: containerRootfsPath) + } + + let rootfsMount = Mount.block( + format: "ext4", + source: containerRootfsPath.path(percentEncoded: false), + destination: "/" + ) + + let image = try await Sandboxy.imageStore.get( + reference: definition.baseImage, pull: true) + + container = try await manager.create( + containerId, + image: image, + rootfs: rootfsMount, + networking: false + ) { config in + if enableNetworking, let iface = try sharedNetwork?.createInterface(containerId, mtu: vmnetMTU) { + config.interfaces = [iface] + config.dns = .init(nameservers: [sharedNetwork!.ipv4Gateway.description]) + } + config.cpus = 4 + config.memoryInBytes = 4096 * 1024 * 1024 + config.process.arguments = ["/bin/sleep", "infinity"] + config.process.workingDirectory = "/" + config.process.capabilities = .allCapabilities + config.useInit = true + } + } else { + ProgressUI.printDetail("No cached environment, setting up from scratch...") + container = try await manager.create( + containerId, + reference: definition.baseImage, + rootfsSizeInBytes: 512.gib(), + networking: false + ) { config in + if enableNetworking, let iface = try sharedNetwork?.createInterface(containerId, mtu: vmnetMTU) { + config.interfaces = [iface] + config.dns = .init(nameservers: [sharedNetwork!.ipv4Gateway.description]) + } + config.cpus = 4 + config.memoryInBytes = 4096 * 1024 * 1024 + config.process.arguments = ["/bin/sleep", "infinity"] + config.process.workingDirectory = "/" + config.process.capabilities = .allCapabilities + config.useInit = true + } + } + + do { + try await container.create() + try await container.start() + + // If no cache existed, run the agent's install commands first. + if !hasCachedRootfs { + ProgressUI.printStatus("Installing \(definition.displayName) toolchain...") + do { + try await installAgent(in: container, definition: definition) + ProgressUI.printStatus("Installation complete.") + } catch { + ProgressUI.printError("Installation failed: \(error)") + ProgressUI.printStatus("Dropping into shell...") + } + } + + // Drop into an interactive shell. + ProgressUI.printStatus("Launching shell (exit to save changes)...\n") + + let sigwinchStream = AsyncSignalHandler.create(notify: [SIGWINCH]) + let current = try Terminal.current + try current.setraw() + defer { current.tryReset() } + + let shellProcess = try await container.exec("edit-shell") { config in + config.arguments = ["/bin/bash"] + config.environmentVariables = [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "TERM=xterm", + "HOME=/root", + ] + config.workingDirectory = "/root" + config.setTerminalIO(terminal: current) + config.capabilities = .allCapabilities + } + + try await shellProcess.start() + try? await shellProcess.resize(to: try current.size) + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + for await _ in sigwinchStream.signals { + try await shellProcess.resize(to: try current.size) + } + } + + _ = try await shellProcess.wait() + group.cancelAll() + try await shellProcess.delete() + } + + // Stop container so rootfs is cleanly unmounted before caching. + try await container.stop() + + // Save the modified rootfs back to the cache. + ProgressUI.printStatus("Saving changes to cache...") + removeIfExists(at: agentCachePath) + try FileManager.default.copyItem(at: containerRootfsPath, to: agentCachePath) + ProgressUI.printStatus("Done.") + + try manager.delete(containerId) + try? sharedNetwork?.releaseInterface(containerId) + } catch { + do { + try await container.stop() + } catch { + log.warning("Failed to stop container \(containerId): \(error)") + } + do { + try manager.delete(containerId) + } catch { + log.warning("Failed to delete container \(containerId): \(error)") + } + try? sharedNetwork?.releaseInterface(containerId) + throw error + } + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/HostProxy.swift b/examples/sandboxy/Sources/sandboxy/HostProxy.swift new file mode 100644 index 00000000..af08eda1 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/HostProxy.swift @@ -0,0 +1,460 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +// Adapted from https://github.com/apple/swift-nio-examples/tree/main/connect-proxy + +import Foundation +import NIOCore +import NIOHTTP1 +import NIOPosix + +/// A lightweight HTTP proxy that runs on the host and filters by hostname. +/// +/// Binds to the host's gateway IP on a vmnet host-only network. Workload containers +/// that have no direct internet route use this proxy (via HTTP_PROXY/HTTPS_PROXY env vars) +/// to reach the outside world. Only hostnames matching the allowlist are permitted. +/// +/// Handles both HTTPS (via CONNECT tunneling) and plain HTTP (via request forwarding). +/// For HTTPS, the client sends a CONNECT request with the target hostname in plaintext +/// before TLS begins, so we can filter without any certificate interception. +final class HostProxy: @unchecked Sendable { + private let group: MultiThreadedEventLoopGroup + private let channel: any Channel + + /// The port the proxy is listening on. + let port: Int + + /// The host address the proxy is bound to. + let host: String + + /// Start a proxy bound to the given address. + /// - Parameters: + /// - host: IP address to bind to (e.g. the vmnet gateway IP). + /// - port: Port to bind to. Use 0 for an OS-assigned port. + /// - allowedHosts: Hostname patterns to allow. Supports `*.example.com` wildcards. + init(host: String, port: Int = 0, allowedHosts: [String]) async throws { + self.host = host + let group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + self.group = group + + let bootstrap = ServerBootstrap(group: group) + .serverChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1) + .childChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1) + .childChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + ByteToMessageHandler(HTTPRequestDecoder(leftOverBytesStrategy: .forwardBytes)) + ) + try channel.pipeline.syncOperations.addHandler(HTTPResponseEncoder()) + try channel.pipeline.syncOperations.addHandler( + ConnectHandler(allowedHosts: allowedHosts) + ) + } + } + + let channel = try await bootstrap.bind( + to: SocketAddress(ipAddress: host, port: port) + ).get() + + guard let localAddress = channel.localAddress, let assignedPort = localAddress.port else { + throw SandboxyError.proxyFailed(reason: "could not determine proxy listen port") + } + + self.port = assignedPort + self.channel = channel + } + + /// Stop the proxy and release resources. + func stop() async throws { + try await channel.close() + try await group.shutdownGracefully() + } + + /// Check if a hostname matches any pattern in the allowlist. + static func isAllowed(host: String, allowedHosts: [String]) -> Bool { + let host = host.lowercased() + for pattern in allowedHosts { + let pattern = pattern.lowercased() + if pattern.hasPrefix("*.") { + let suffix = String(pattern.dropFirst(1)) // e.g. ".example.com" + if host == String(pattern.dropFirst(2)) || host.hasSuffix(suffix) { + return true + } + } else if host == pattern { + return true + } + } + return false + } +} + +/// Channel handler that processes HTTP CONNECT and plain HTTP proxy requests. +/// Checks the target hostname against an allowlist before connecting. +private final class ConnectHandler { + private var upgradeState: State + private let allowedHosts: [String] + + /// Buffered request for plain HTTP forwarding (nil for CONNECT). + private var pendingHTTPHead: HTTPRequestHead? + private var pendingHTTPBody: [ByteBuffer] = [] + + init(allowedHosts: [String]) { + self.upgradeState = .idle + self.allowedHosts = allowedHosts + } +} + +extension ConnectHandler { + fileprivate enum State { + case idle + case beganConnecting + case awaitingEnd(connectResult: Channel) + case awaitingConnection(pendingBytes: [NIOAny]) + case upgradeComplete(pendingBytes: [NIOAny]) + case upgradeFailed + } +} + +extension ConnectHandler: ChannelInboundHandler { + typealias InboundIn = HTTPServerRequestPart + typealias OutboundOut = HTTPServerResponsePart + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + switch self.upgradeState { + case .idle: + self.handleInitialMessage(context: context, data: self.unwrapInboundIn(data)) + + case .beganConnecting: + switch self.unwrapInboundIn(data) { + case .body(let body): + self.pendingHTTPBody.append(body) + case .end: + self.upgradeState = .awaitingConnection(pendingBytes: []) + self.removeDecoder(context: context) + default: + break + } + + case .awaitingEnd(let peerChannel): + switch self.unwrapInboundIn(data) { + case .body(let body): + self.pendingHTTPBody.append(body) + case .end: + self.upgradeState = .upgradeComplete(pendingBytes: []) + self.removeDecoder(context: context) + self.glue(peerChannel, context: context) + default: + break + } + + case .awaitingConnection(var pendingBytes): + self.upgradeState = .awaitingConnection(pendingBytes: []) + pendingBytes.append(data) + self.upgradeState = .awaitingConnection(pendingBytes: pendingBytes) + + case .upgradeComplete(var pendingBytes): + self.upgradeState = .upgradeComplete(pendingBytes: []) + pendingBytes.append(data) + self.upgradeState = .upgradeComplete(pendingBytes: pendingBytes) + + case .upgradeFailed: + break + } + } +} + +extension ConnectHandler: RemovableChannelHandler { + func removeHandler(context: ChannelHandlerContext, removalToken: ChannelHandlerContext.RemovalToken) { + var didRead = false + + while case .upgradeComplete(var pendingBytes) = self.upgradeState, pendingBytes.count > 0 { + self.upgradeState = .upgradeComplete(pendingBytes: []) + let nextRead = pendingBytes.removeFirst() + self.upgradeState = .upgradeComplete(pendingBytes: pendingBytes) + + context.fireChannelRead(nextRead) + didRead = true + } + + if didRead { + context.fireChannelReadComplete() + } + + context.leavePipeline(removalToken: removalToken) + } +} + +extension ConnectHandler { + private func handleInitialMessage(context: ChannelHandlerContext, data: InboundIn) { + guard case .head(let head) = data else { + self.httpErrorAndClose(context: context, status: .badRequest) + return + } + + if head.method == .CONNECT { + // HTTPS: CONNECT host:port + let components = head.uri.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + let host = String(components.first!) + let port = components.last.flatMap { Int($0, radix: 10) } ?? 443 + + guard HostProxy.isAllowed(host: host, allowedHosts: self.allowedHosts) else { + self.httpErrorAndClose(context: context, status: .forbidden) + return + } + + self.upgradeState = .beganConnecting + self.connectTo(host: host, port: port, context: context) + } else { + // Plain HTTP: GET http://host/path, POST http://host/path, etc. + guard let url = URLComponents(string: head.uri), + let hostname = url.host, !hostname.isEmpty + else { + self.httpErrorAndClose(context: context, status: .badRequest) + return + } + + guard HostProxy.isAllowed(host: hostname, allowedHosts: self.allowedHosts) else { + self.httpErrorAndClose(context: context, status: .forbidden) + return + } + + let port = url.port ?? 80 + + // Rewrite URI from absolute (http://host/path) to relative (/path). + var relativePath = url.path + if relativePath.isEmpty { relativePath = "/" } + if let query = url.query { + relativePath += "?\(query)" + } + + var rewritten = head + rewritten.uri = relativePath + self.pendingHTTPHead = rewritten + + self.upgradeState = .beganConnecting + self.connectTo(host: hostname, port: port, context: context) + } + } + + private func connectTo(host: String, port: Int, context: ChannelHandlerContext) { + ClientBootstrap(group: context.eventLoop) + .connect(host: host, port: port).assumeIsolatedUnsafeUnchecked().whenComplete { result in + switch result { + case .success(let channel): + self.connectSucceeded(channel: channel, context: context) + case .failure(let error): + self.connectFailed(error: error, context: context) + } + } + } + + private func connectSucceeded(channel: Channel, context: ChannelHandlerContext) { + switch self.upgradeState { + case .beganConnecting: + self.upgradeState = .awaitingEnd(connectResult: channel) + + case .awaitingConnection(let pendingBytes): + self.upgradeState = .upgradeComplete(pendingBytes: pendingBytes) + self.glue(channel, context: context) + + case .awaitingEnd(let peerChannel): + peerChannel.close(mode: .all, promise: nil) + context.close(promise: nil) + + case .idle, .upgradeFailed, .upgradeComplete: + context.close(promise: nil) + } + } + + private func connectFailed(error: Error, context: ChannelHandlerContext) { + switch self.upgradeState { + case .beganConnecting, .awaitingConnection: + self.httpErrorAndClose(context: context, status: .badGateway) + + case .awaitingEnd(let peerChannel): + peerChannel.close(mode: .all, promise: nil) + context.close(promise: nil) + + case .idle, .upgradeFailed, .upgradeComplete: + context.close(promise: nil) + } + + context.fireErrorCaught(error) + } + + private func glue(_ peerChannel: Channel, context: ChannelHandlerContext) { + if let httpHead = self.pendingHTTPHead { + // Plain HTTP: forward the buffered request to the peer, then glue. + var buffer = context.channel.allocator.buffer(capacity: 256) + buffer.writeString("\(httpHead.method) \(httpHead.uri) HTTP/\(httpHead.version.major).\(httpHead.version.minor)\r\n") + for (name, value) in httpHead.headers { + buffer.writeString("\(name): \(value)\r\n") + } + buffer.writeString("\r\n") + for var body in self.pendingHTTPBody { + buffer.writeBuffer(&body) + } + peerChannel.writeAndFlush(buffer, promise: nil) + self.pendingHTTPHead = nil + self.pendingHTTPBody = [] + } else { + // CONNECT: send 200 OK to the client. + // Content-Length: 0 prevents the encoder from adding chunked transfer encoding, + // which would inject a chunked terminator into the raw tunnel and break TLS. + let headers = HTTPHeaders([("Content-Length", "0")]) + let head = HTTPResponseHead(version: .init(major: 1, minor: 1), status: .ok, headers: headers) + context.write(self.wrapOutboundOut(.head(head)), promise: nil) + context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil) + } + + self.removeEncoder(context: context) + + let (localGlue, peerGlue) = GlueHandler.matchedPair() + do { + try context.channel.pipeline.syncOperations.addHandler(localGlue) + try peerChannel.pipeline.syncOperations.addHandler(peerGlue) + context.pipeline.syncOperations.removeHandler(self, promise: nil) + } catch { + peerChannel.close(mode: .all, promise: nil) + context.close(promise: nil) + } + } + + private func httpErrorAndClose(context: ChannelHandlerContext, status: HTTPResponseStatus) { + self.upgradeState = .upgradeFailed + + let headers = HTTPHeaders([("Content-Length", "0"), ("Connection", "close")]) + let head = HTTPResponseHead(version: .init(major: 1, minor: 1), status: status, headers: headers) + context.write(self.wrapOutboundOut(.head(head)), promise: nil) + context.writeAndFlush(self.wrapOutboundOut(.end(nil))).assumeIsolatedUnsafeUnchecked().whenComplete { + (_: Result) in + context.close(mode: .output, promise: nil) + } + } + + private func removeDecoder(context: ChannelHandlerContext) { + if let ctx = try? context.pipeline.syncOperations.context( + handlerType: ByteToMessageHandler.self + ) { + context.pipeline.syncOperations.removeHandler(context: ctx, promise: nil) + } + } + + private func removeEncoder(context: ChannelHandlerContext) { + if let ctx = try? context.pipeline.syncOperations.context( + handlerType: HTTPResponseEncoder.self + ) { + context.pipeline.syncOperations.removeHandler(context: ctx, promise: nil) + } + } +} + +/// Bidirectional relay handler that glues two channels together. +private final class GlueHandler { + private var partner: GlueHandler? + private var context: ChannelHandlerContext? + private var pendingRead: Bool = false + + private init() {} + + static func matchedPair() -> (GlueHandler, GlueHandler) { + let first = GlueHandler() + let second = GlueHandler() + first.partner = second + second.partner = first + return (first, second) + } +} + +extension GlueHandler { + fileprivate func partnerWrite(_ data: NIOAny) { + self.context?.write(data, promise: nil) + } + + fileprivate func partnerFlush() { + self.context?.flush() + } + + fileprivate func partnerWriteEOF() { + self.context?.close(mode: .output, promise: nil) + } + + fileprivate func partnerCloseFull() { + self.context?.close(promise: nil) + } + + fileprivate func partnerBecameWritable() { + if self.pendingRead { + self.pendingRead = false + self.context?.read() + } + } + + fileprivate var partnerWritable: Bool { + self.context?.channel.isWritable ?? false + } +} + +extension GlueHandler: ChannelDuplexHandler { + typealias InboundIn = NIOAny + typealias OutboundIn = NIOAny + typealias OutboundOut = NIOAny + + func handlerAdded(context: ChannelHandlerContext) { + self.context = context + } + + func handlerRemoved(context: ChannelHandlerContext) { + self.context = nil + self.partner = nil + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + self.partner?.partnerWrite(data) + } + + func channelReadComplete(context: ChannelHandlerContext) { + self.partner?.partnerFlush() + } + + func channelInactive(context: ChannelHandlerContext) { + self.partner?.partnerCloseFull() + } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + if let event = event as? ChannelEvent, case .inputClosed = event { + self.partner?.partnerWriteEOF() + } + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + self.partner?.partnerCloseFull() + } + + func channelWritabilityChanged(context: ChannelHandlerContext) { + if context.channel.isWritable { + self.partner?.partnerBecameWritable() + } + } + + func read(context: ChannelHandlerContext) { + if let partner = self.partner, partner.partnerWritable { + context.read() + } else { + self.pendingRead = true + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/InstanceState.swift b/examples/sandboxy/Sources/sandboxy/InstanceState.swift new file mode 100644 index 00000000..333be1e0 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/InstanceState.swift @@ -0,0 +1,120 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +/// Persistent metadata about a sandbox instance. +struct InstanceState: Codable, Sendable { + let id: String + /// User-provided name for persistent instances. Nil for ephemeral runs. + let name: String? + let agent: String + let workspace: String + let status: Status + let createdAt: Date + var stoppedAt: Date? + let cpus: Int + let memoryMB: UInt64 + + enum Status: String, Codable, Sendable { + case running + case stopped + } + + /// Whether this is a named (persistent) instance. + var isNamed: Bool { name != nil } + + /// Directory where instance state files are stored. + static func instancesDir(appRoot: URL) -> URL { + appRoot.appendingPathComponent("instances") + } + + /// Directory where named instance rootfs files are preserved. + static func namedRootfsDir(appRoot: URL) -> URL { + appRoot.appendingPathComponent("named") + } + + /// Path to the preserved rootfs for a named instance. + static func namedRootfsPath(appRoot: URL, name: String) -> URL { + namedRootfsDir(appRoot: appRoot).appendingPathComponent("\(name)-rootfs.ext4") + } + + /// Path to this instance's state file. + func statePath(appRoot: URL) -> URL { + Self.instancesDir(appRoot: appRoot).appendingPathComponent("\(id).json") + } + + /// Saves this instance state to disk. + func save(appRoot: URL) throws { + let dir = Self.instancesDir(appRoot: appRoot) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(self) + try data.write(to: statePath(appRoot: appRoot)) + } + + /// Loads all instance states from disk. + static func loadAll(appRoot: URL) throws -> [InstanceState] { + let dir = instancesDir(appRoot: appRoot) + let path = dir.path(percentEncoded: false) + guard FileManager.default.fileExists(atPath: path) else { + return [] + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let files = try FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: nil + ).filter { $0.pathExtension == "json" } + + return files.compactMap { file -> InstanceState? in + do { + let data = try Data(contentsOf: file) + return try decoder.decode(InstanceState.self, from: data) + } catch { + log.warning("Failed to load instance state from \(file.lastPathComponent): \(error)") + return nil + } + } + } + + /// Finds a named instance by name. + static func find(name: String, appRoot: URL) throws -> InstanceState? { + try loadAll(appRoot: appRoot).first { $0.name == name } + } + + /// Removes this instance's state file from disk. + func remove(appRoot: URL) throws { + try FileManager.default.removeItem(at: statePath(appRoot: appRoot)) + } + + /// Removes this instance's state file and preserved rootfs (for named instances). + func removeAll(appRoot: URL) throws { + try remove(appRoot: appRoot) + if let name { + let rootfs = Self.namedRootfsPath(appRoot: appRoot, name: name) + let path = rootfs.path(percentEncoded: false) + if FileManager.default.fileExists(atPath: path) { + try FileManager.default.removeItem(at: rootfs) + } + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/KernelManager.swift b/examples/sandboxy/Sources/sandboxy/KernelManager.swift new file mode 100644 index 00000000..fd4bcafe --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/KernelManager.swift @@ -0,0 +1,153 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import AsyncHTTPClient +import ContainerizationArchive +import ContainerizationExtras +import Foundation + +enum KernelManager { + // Hardcoded default kernel source (Kata Containers arm64 static release). + private static let defaultKernelURL = + "https://github.com/kata-containers/kata-containers/releases/download/3.26.0/kata-static-3.26.0-arm64.tar.zst" + private static let defaultKernelPathInTarball = "opt/kata/share/kata-containers/vmlinux.container" + + /// Ensures a kernel binary is available, returning its path. + /// + /// Resolution order: + /// 1. CLI flag (`-k` / `--kernel`) + /// 2. Config file (`"kernel"` field) + /// 3. Cached kernel at `appRoot/kernel/vmlinux` + /// 4. Auto-download from Kata Containers + static func ensureKernel(explicitPath: String?, appRoot: URL, config: SandboxyConfig) async throws -> URL { + // 1. CLI flag takes priority. + if let explicitPath { + let url = URL(fileURLWithPath: explicitPath) + guard FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) else { + throw SandboxyError.kernelNotFound(path: explicitPath) + } + return url + } + + // 2. Config file path. + if let configKernel = config.kernel { + let url = URL(fileURLWithPath: configKernel) + guard FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) else { + throw SandboxyError.kernelNotFound(path: configKernel) + } + return url + } + + // 3. Cached kernel. + let kernelDir = appRoot.appendingPathComponent("kernel") + let kernelPath = kernelDir.appendingPathComponent("vmlinux") + + if FileManager.default.fileExists(atPath: kernelPath.path(percentEncoded: false)) { + return kernelPath + } + + // 4. Auto-download. + try FileManager.default.createDirectory(at: kernelDir, withIntermediateDirectories: true) + + let progressConfig = try ProgressConfig( + description: "Downloading kernel", + showTasks: true, + totalTasks: 2 + ) + let progress = ProgressBar(config: progressConfig) + defer { progress.finish() } + progress.start() + + let tarballPath = kernelDir.appendingPathComponent("kata.tar.zst") + try await downloadFile(from: defaultKernelURL, to: tarballPath, progress: progress) + + progress.set(description: "Extracting kernel") + try extractKernel(from: tarballPath, kernelPathInTarball: defaultKernelPathInTarball, to: kernelPath) + + try FileManager.default.removeItem(at: tarballPath) + + return kernelPath + } + + private static func downloadFile(from urlString: String, to destination: URL, progress: ProgressBar) async throws { + guard let url = URL(string: urlString) else { + throw SandboxyError.kernelDownloadFailed(reason: "invalid URL: \(urlString)") + } + + let delegate = try FileDownloadDelegate( + path: destination.path(percentEncoded: false), + reportHead: { head in + if let contentLength = head.headers["Content-Length"].first, let totalBytes = Int64(contentLength) { + progress.add(totalSize: totalBytes) + } + }, + reportProgress: { progressUpdate in + progress.set(size: Int64(progressUpdate.receivedBytes)) + } + ) + + let request = try HTTPClient.Request(url: url) + let client = createClient(url: url) + do { + _ = try await client.execute(request: request, delegate: delegate).get() + } catch { + try? await client.shutdown() + throw error + } + try await client.shutdown() + } + + private static func createClient(url: URL) -> HTTPClient { + var httpConfiguration = HTTPClient.Configuration() + httpConfiguration.timeout = HTTPClient.Configuration.Timeout( + connect: .seconds(30), + read: .none + ) + if let host = url.host { + let proxyURL = ProxyUtils.proxyFromEnvironment(scheme: url.scheme, host: host) + if let proxyURL, let proxyHost = proxyURL.host { + httpConfiguration.proxy = HTTPClient.Configuration.Proxy.server(host: proxyHost, port: proxyURL.port ?? 8080) + } + } + + return HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration) + } + + private static func extractKernel(from tarball: URL, kernelPathInTarball: String, to destination: URL) throws { + var target = kernelPathInTarball + var reader = try ArchiveReader(file: tarball) + var (entry, data) = try reader.extractFile(path: target) + + // If the target file is a symlink, get the data for the actual file. + if entry.fileType == .symbolicLink, let symlinkRelative = entry.symlinkTarget { + reader = try ArchiveReader(file: tarball) + let symlinkTarget = URL(filePath: target).deletingLastPathComponent().appending(path: symlinkRelative) + + // Standardize so that we remove any and all ../ and ./ in the path since symlink targets + // are relative paths to the target file from the symlink's parent dir itself. + target = symlinkTarget.standardized.relativePath + let (_, targetData) = try reader.extractFile(path: target) + data = targetData + } + + try data.write(to: destination, options: .atomic) + + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: destination.path(percentEncoded: false) + ) + } +} diff --git a/examples/sandboxy/Sources/sandboxy/ListCommand.swift b/examples/sandboxy/Sources/sandboxy/ListCommand.swift new file mode 100644 index 00000000..1a15ff49 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/ListCommand.swift @@ -0,0 +1,94 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ArgumentParser +import Foundation + +extension Sandboxy { + struct List: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List sandbox instances", + aliases: ["ls"] + ) + + func run() async throws { + _ = try Sandboxy.loadConfig() + + let instances = try InstanceState.loadAll(appRoot: Sandboxy.appRoot) + + if instances.isEmpty { + print("No sandbox instances found.") + return + } + + // Deduplicate named instances, keeping only the most recent entry per name. + var seen = Set() + var deduplicated: [InstanceState] = [] + let sorted = instances.sorted { $0.createdAt > $1.createdAt } + for instance in sorted { + if let name = instance.name { + if seen.contains(name) { continue } + seen.insert(name) + } + deduplicated.append(instance) + } + + print( + pad("NAME", to: 30) + + pad("AGENT", to: 12) + + pad("STATUS", to: 12) + + pad("CREATED", to: 12) + + "WORKSPACE" + ) + + for instance in deduplicated { + let age = relativeTime(from: instance.createdAt) + let displayName = instance.name ?? "-" + print( + pad(truncate(displayName, to: 28), to: 30) + + pad(instance.agent, to: 12) + + pad(instance.status.rawValue, to: 12) + + pad(age, to: 12) + + instance.workspace + ) + } + } + + private func pad(_ s: String, to width: Int) -> String { + if s.count >= width { + return s + } + return s + String(repeating: " ", count: width - s.count) + } + + private func truncate(_ s: String, to maxLen: Int) -> String { + if s.count <= maxLen { return s } + return "..." + String(s.suffix(maxLen - 3)) + } + + private func relativeTime(from date: Date) -> String { + let seconds = Int(Date().timeIntervalSince(date)) + if seconds < 60 { return "\(seconds)s ago" } + let minutes = seconds / 60 + if minutes < 60 { return "\(minutes)m ago" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h ago" } + let days = hours / 24 + return "\(days)d ago" + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/OutputCapture.swift b/examples/sandboxy/Sources/sandboxy/OutputCapture.swift new file mode 100644 index 00000000..4f999173 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/OutputCapture.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Containerization +import Foundation +import Synchronization + +/// A Writer that captures output into a Data buffer and optionally streams it. +final class OutputCapture: Writer, Sendable { + private let storage = Mutex(Data()) + private let streamTo: FileHandle? + + var data: Data { + storage.withLock { $0 } + } + + init(streamToStdout: Bool = false, streamToStderr: Bool = false) { + if streamToStdout { + self.streamTo = .standardOutput + } else if streamToStderr { + self.streamTo = .standardError + } else { + self.streamTo = nil + } + } + + func write(_ data: Data) throws { + guard data.count > 0 else { return } + storage.withLock { $0.append(data) } + streamTo?.write(data) + } + + func close() throws {} +} diff --git a/examples/sandboxy/Sources/sandboxy/ProgressUI.swift b/examples/sandboxy/Sources/sandboxy/ProgressUI.swift new file mode 100644 index 00000000..10d5f404 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/ProgressUI.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +/// Status output helpers that write to stderr to keep stdout clean for the agent's terminal IO. +enum ProgressUI { + private static let boxLines = [ + "┌──────────────┐", + "│ ░░░░░░░░░░░░ │", + "│ ░░░░░░░░░░░░ │", + "│ ░░░░░░░░░░░░ │", + "│ ░░░░░░░░░░░░ │", + "│ ░░░░░░░░░░░░ │", + "│ ░░░░░░░░░░░░ │", + "└──────────────┘", + ] + + /// Prints the logo with info lines displayed to the right of the box. + static func printLogo(info: [String] = []) { + let yellow = "\u{1b}[33m" + let reset = "\u{1b}[0m" + let gap = " " + + for (i, boxLine) in boxLines.enumerated() { + let coloredBox = "\(yellow)\(boxLine)\(reset)" + if i < info.count { + FileHandle.standardError.write(Data(" \(coloredBox)\(gap)\(info[i])\n".utf8)) + } else { + FileHandle.standardError.write(Data(" \(coloredBox)\n".utf8)) + } + } + // Print any remaining info lines that don't fit beside the box. + if info.count > boxLines.count { + for j in boxLines.count.. \(message)\u{1b}[0m\n".utf8)) + } + + static func printDetail(_ message: String) { + FileHandle.standardError.write(Data("\u{1b}[32m \(message)\u{1b}[0m\n".utf8)) + } + + static func printWarning(_ message: String) { + FileHandle.standardError.write(Data("warning: \(message)\n".utf8)) + } + + static func printError(_ message: String) { + FileHandle.standardError.write(Data("\u{1b}[31merror: \(message)\u{1b}[0m\n".utf8)) + } +} diff --git a/examples/sandboxy/Sources/sandboxy/RemoveCommand.swift b/examples/sandboxy/Sources/sandboxy/RemoveCommand.swift new file mode 100644 index 00000000..3768f996 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/RemoveCommand.swift @@ -0,0 +1,69 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ArgumentParser +import Foundation + +extension Sandboxy { + struct Remove: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "rm", + abstract: "Remove sandbox instances and their preserved state" + ) + + @Argument(help: "Name of the instance to remove") + var names: [String] = [] + + @Flag(name: [.customShort("a"), .long], help: "Remove all instances") + var all: Bool = false + + func run() async throws { + _ = try Sandboxy.loadConfig() + + if all { + let instances = try InstanceState.loadAll(appRoot: Sandboxy.appRoot) + if instances.isEmpty { + print("No instances to remove.") + return + } + for instance in instances { + let displayName = instance.name ?? instance.id + do { + try instance.removeAll(appRoot: Sandboxy.appRoot) + print("Removed instance '\(displayName)'.") + } catch { + print("Failed to remove instance '\(displayName)': \(error)") + } + } + return + } + + guard !names.isEmpty else { + print("Specify instance name(s) to remove, or use --all (-a).") + throw ExitCode.failure + } + + for name in names { + guard let instance = try InstanceState.find(name: name, appRoot: Sandboxy.appRoot) else { + print("No instance named '\(name)' found.") + continue + } + try instance.removeAll(appRoot: Sandboxy.appRoot) + print("Removed instance '\(name)'.") + } + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/RunAgentCommand.swift b/examples/sandboxy/Sources/sandboxy/RunAgentCommand.swift new file mode 100644 index 00000000..cdad0d31 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/RunAgentCommand.swift @@ -0,0 +1,1000 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ArgumentParser +import Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import ContainerizationOS +import Foundation +import vmnet + +extension Sandboxy { + struct Run: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "run", + abstract: "Run an AI coding agent in a sandboxed Linux container", + discussion: """ + Available agents are determined by built-in definitions and any custom + agent JSON files in the agents/ subdirectory of the sandboxy application + support directory. + """ + ) + + @OptionGroup var options: AgentOptions + + @Argument(help: "Agent to run (e.g. claude)") + var agent: String + + @Argument(parsing: .captureForPassthrough) + var passthroughArgs: [String] = [] + + func run() async throws { + let config = try Sandboxy.loadConfig() + + let agents = AgentDefinition.allAgents(configRoot: Sandboxy.configRoot) + guard let definition = agents[agent] else { + let available = agents.keys.sorted().joined(separator: ", ") + throw ValidationError( + "Unknown agent '\(agent)'. Available agents: \(available)" + ) + } + + try await runAgent( + config: config, + agentName: agent, + definition: definition, + options: options, + passthroughArgs: passthroughArgs + ) + } + } +} + +struct AgentOptions: ParsableArguments { + @Option( + name: [.customLong("workspace"), .customShort("w")], + help: "Workspace directory on the host (defaults to current directory)", + completion: .directory, + transform: { str in + URL(fileURLWithPath: str, relativeTo: .currentDirectory()) + .absoluteURL.path(percentEncoded: false) + }) + var workspace: String? + + @Option(name: .long, help: "Number of CPUs to allocate") + var cpus: Int = 4 + + @Option(name: .long, help: "Memory to allocate (e.g. 4g, 512m, 4096 for MB)") + var memory: String = "4g" + + /// Parses the memory string into bytes. Supports suffixes: b, k/kb, m/mb, g/gb, t/tb. + /// A bare number is treated as megabytes for backward compatibility. + var memoryBytes: UInt64 { + get throws { + let str = memory.lowercased().trimmingCharacters(in: .whitespaces) + guard !str.isEmpty else { + throw ValidationError("Memory value cannot be empty") + } + + let suffixes: [(String, UInt64)] = [ + ("tb", 1024 * 1024 * 1024 * 1024), + ("gb", 1024 * 1024 * 1024), + ("mb", 1024 * 1024), + ("kb", 1024), + ("t", 1024 * 1024 * 1024 * 1024), + ("g", 1024 * 1024 * 1024), + ("m", 1024 * 1024), + ("k", 1024), + ("b", 1), + ] + + for (suffix, multiplier) in suffixes { + if str.hasSuffix(suffix) { + let numStr = String(str.dropLast(suffix.count)) + guard let value = Double(numStr), value > 0 else { + throw ValidationError("Invalid memory value: \(memory)") + } + return UInt64(value * Double(multiplier)) + } + } + + // Bare number: treat as megabytes. + guard let value = Double(str), value > 0 else { + throw ValidationError("Invalid memory value: \(memory)") + } + return UInt64(value * 1024 * 1024) + } + } + + /// Returns a human-readable memory string (e.g. "4 GB", "512 MB"). + var memoryDisplay: String { + get throws { + let bytes = try memoryBytes + if bytes >= 1024 * 1024 * 1024 && bytes % (1024 * 1024 * 1024) == 0 { + return "\(bytes / (1024 * 1024 * 1024)) GB" + } else if bytes >= 1024 * 1024 { + return "\(bytes / (1024 * 1024)) MB" + } else if bytes >= 1024 { + return "\(bytes / 1024) KB" + } + return "\(bytes) B" + } + } + + @Option( + name: .long, parsing: .upToNextOption, + help: "Hostnames to allow through the HTTP proxy") + var allowHosts: [String] = [] + + @Flag(name: .long, help: "Disable network filtering (allow unrestricted network access)") + var noNetworkFilter: Bool = false + + @Flag(name: .long, help: "Force reinstall of agent (ignore cached rootfs)") + var reinstall: Bool = false + + @Flag(name: .long, help: "Forward the host SSH agent socket into the container") + var sshAgent: Bool = false + + @Flag(name: .long, help: "Skip mounts defined in the agent configuration") + var noAgentMounts: Bool = false + + @Flag(name: .customLong("rm"), help: "Automatically remove the instance after the session ends") + var removeAfterRun: Bool = false + + @Option( + name: [.customLong("mount"), .customShort("m")], + parsing: .singleValue, + help: "Additional mount in hostpath:containerpath[:ro|rw] format (repeatable)") + var mount: [String] = [] + + @Option( + name: [.customLong("env"), .customShort("e")], + parsing: .singleValue, + help: "Set environment variable (KEY=VALUE or KEY to forward from host, repeatable)") + var env: [String] = [] + + @Option( + name: .long, + help: "Name for a persistent instance (preserves rootfs and resumes conversation)") + var name: String? + + @Option( + name: [.customLong("kernel"), .customShort("k")], + help: "Path to Linux kernel binary (auto-downloads if omitted)", + completion: .file(), + transform: { str in + URL(fileURLWithPath: str, relativeTo: .currentDirectory()) + .absoluteURL.path(percentEncoded: false) + }) + var kernel: String? +} + +func runAgent( + config: SandboxyConfig, + agentName: String, + definition: AgentDefinition, + options: AgentOptions, + passthroughArgs: [String] +) async throws { + signal(SIGINT) { _ in + var termios = termios() + tcgetattr(STDIN_FILENO, &termios) + termios.c_lflag |= UInt(ECHO | ICANON) + tcsetattr(STDIN_FILENO, TCSANOW, &termios) + write(STDERR_FILENO, "\u{001B}[?25h", 6) + _exit(130) + } + + let hostWorkspacePath = options.workspace ?? FileManager.default.currentDirectoryPath + let guestWorkspacePath = hostWorkspacePath + let extraMounts = try options.mount.map { try MountSpec.parse($0) } + let extraEnvVars = try options.env.map { try EnvSpec.resolve($0) } + + // Determine instance name: use --name if provided, otherwise auto-generate. + let instanceName: String + if let name = options.name { + instanceName = name + } else { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmmss" + instanceName = "\(agentName)-\(formatter.string(from: Date()))" + } + + if let old = try InstanceState.find(name: instanceName, appRoot: Sandboxy.appRoot) { + try? old.remove(appRoot: Sandboxy.appRoot) + } + + // Check cache age for display. + let cacheDir = Sandboxy.appRoot.appendingPathComponent("cache") + let agentCachePath = cacheDir.appendingPathComponent("\(agentName)-rootfs.ext4") + let cacheAgeLine: String + if let attrs = try? FileManager.default.attributesOfItem(atPath: agentCachePath.path(percentEncoded: false)), + let created = attrs[.creationDate] as? Date + { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + let age = formatter.localizedString(for: created, relativeTo: Date()) + let capitalizedAge = age.prefix(1).uppercased() + age.dropFirst() + cacheAgeLine = "\u{1b}[1mEnvironment:\u{1b}[0m \(capitalizedAge)" + } else { + cacheAgeLine = "\u{1b}[1mEnvironment:\u{1b}[0m Not yet installed" + } + + ProgressUI.printLogo(info: [ + "", + "\u{1b}[1mSandboxy\u{1b}[0m", + "\u{1b}[1mAgent:\u{1b}[0m \(definition.displayName)", + "\u{1b}[1mInstance:\u{1b}[0m \(instanceName)", + cacheAgeLine, + "\u{1b}[1mWorkspace:\u{1b}[0m \(hostWorkspacePath)", + "\u{1b}[1mCPUs:\u{1b}[0m \(options.cpus) \u{1b}[1mMemory:\u{1b}[0m \(try options.memoryDisplay)", + ]) + + let kernelPath = try await KernelManager.ensureKernel( + explicitPath: options.kernel, + appRoot: Sandboxy.appRoot, + config: config + ) + guard FileManager.default.fileExists(atPath: kernelPath.path(percentEncoded: false)) else { + throw SandboxyError.kernelNotFound(path: kernelPath.path(percentEncoded: false)) + } + let kernel = Kernel(path: kernelPath, platform: .linuxArm) + + // Merge allowed hosts from agent definition and CLI flags. + var allowedHosts = definition.allowedHosts + allowedHosts.append(contentsOf: options.allowHosts) + let filteringEnabled = !options.noNetworkFilter + + let filteredPassthroughArgs = passthroughArgs.filter { $0 != "--" } + + var fullCommand = definition.launchCommand + fullCommand.append(contentsOf: filteredPassthroughArgs) + ProgressUI.printDetail("\u{1b}[1mCommand:\u{1b}[0m \(fullCommand.joined(separator: " "))") + + if filteringEnabled { + if allowedHosts.isEmpty { + ProgressUI.printDetail("\u{1b}[1mAllowed hosts:\u{1b}[0m\u{1b}[33m none (all traffic denied)") + } else { + let hostList = allowedHosts.joined(separator: ", ") + ProgressUI.printDetail("\u{1b}[1mAllowed hosts:\u{1b}[0m\u{1b}[32m \(hostList)") + } + } else { + ProgressUI.printDetail("\u{1b}[1mAllowed hosts:\u{1b}[0m\u{1b}[32m unrestricted") + } + + // Log mounts. + ProgressUI.printDetail("\u{1b}[1mMounts:\u{1b}[0m") + ProgressUI.printDetail(" \(hostWorkspacePath) -> \(guestWorkspacePath)") + if options.noAgentMounts { + for agentMount in definition.mounts { + let ro = agentMount.readOnly ? " (ro)" : "" + ProgressUI.printDetail(" \(agentMount.resolvedHostPath) -> \(agentMount.containerPath)\(ro) \u{1b}[33m(skipped, --no-agent-mounts)\u{1b}[0m") + } + } else { + for agentMount in definition.mounts { + let hostPath = agentMount.resolvedHostPath + let ro = agentMount.readOnly ? " (ro)" : "" + if FileManager.default.fileExists(atPath: hostPath) { + ProgressUI.printDetail(" \(hostPath) -> \(agentMount.containerPath)\(ro)") + } else { + ProgressUI.printDetail(" \(hostPath) -> \(agentMount.containerPath)\(ro) \u{1b}[33m(skipped, host path not found)\u{1b}[0m") + } + } + } + for mountSpec in extraMounts { + let ro = mountSpec.readOnly ? " (ro)" : "" + ProgressUI.printDetail(" \(mountSpec.hostPath) -> \(mountSpec.containerPath)\(ro)") + } + + // Setup networking. + let enableNetworking: Bool + var sharedNetwork: VmnetNetwork? + if #available(macOS 26, *) { + sharedNetwork = try VmnetNetwork() + enableNetworking = true + } else { + sharedNetwork = nil + enableNetworking = false + } + + // Pull the init image with progress if it hasn't been cached yet. + let initfsReference = config.initfsReference ?? SandboxyConfig.defaults.initfsReference! + _ = try await pullImageWithProgress(reference: initfsReference) + + var manager = try await ContainerManager( + kernel: kernel, + initfsReference: initfsReference, + root: Sandboxy.appRoot + ) + + /// MTU for vmnet interfaces. Lowered from the default 1500 to avoid + /// PMTU black-hole issues on networks that block ICMP fragmentation-needed. + let vmnetMTU: UInt32 = 1400 + + let containerId = "\(agentName)-\(ProcessInfo.processInfo.processIdentifier)" + + // + // Workload container setup + // + + // Determine rootfs source: named instance > agent cache > fresh install. + try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + let containerRootfsPath = Sandboxy.appRoot + .appendingPathComponent("containers") + .appendingPathComponent(containerId) + .appendingPathComponent("rootfs.ext4") + + let sourceRootfs: URL? + var needsInstall = false + + if options.reinstall { + removeIfExists(at: agentCachePath) + removeIfExists( + at: InstanceState.namedRootfsPath(appRoot: Sandboxy.appRoot, name: instanceName)) + sourceRootfs = nil + needsInstall = true + } else { + let namedPath = InstanceState.namedRootfsPath(appRoot: Sandboxy.appRoot, name: instanceName) + if FileManager.default.fileExists(atPath: namedPath.path(percentEncoded: false)) { + sourceRootfs = namedPath + } else if FileManager.default.fileExists(atPath: agentCachePath.path(percentEncoded: false)) { + sourceRootfs = agentCachePath + } else { + sourceRootfs = nil + needsInstall = true + } + } + + // Create workload container with full network for installation. + // After install, we recreate with the filtered network if needed. + var container: LinuxContainer + + if let sourceRootfs { + let containerDir = Sandboxy.appRoot + .appendingPathComponent("containers") + .appendingPathComponent(containerId) + try FileManager.default.createDirectory(at: containerDir, withIntermediateDirectories: true) + + let result = Darwin.clonefile( + sourceRootfs.path(percentEncoded: false), + containerRootfsPath.path(percentEncoded: false), + 0 + ) + if result != 0 { + try FileManager.default.copyItem(at: sourceRootfs, to: containerRootfsPath) + } + + let rootfsMount = Mount.block( + format: "ext4", + source: containerRootfsPath.path(percentEncoded: false), + destination: "/", + runtimeOptions: ["vzDiskImageSynchronizationMode=fsync"] + ) + + let image = try await pullImageWithProgress(reference: definition.baseImage) + + container = try await manager.create( + containerId, + image: image, + rootfs: rootfsMount, + networking: false + ) { config in + if enableNetworking, let iface = try sharedNetwork?.createInterface(containerId, mtu: vmnetMTU) { + config.interfaces = [iface] + config.dns = .init(nameservers: [sharedNetwork!.ipv4Gateway.description]) + } + try configureContainer( + config: &config, + definition: definition, + options: options, + containerId: containerId, + hostWorkspacePath: hostWorkspacePath, + guestWorkspacePath: guestWorkspacePath, + extraMounts: extraMounts + ) + } + } else { + let progressConfig = try ProgressConfig( + showTasks: true, + showItems: true, + ignoreSmallSize: true, + totalTasks: 2 + ) + let progress = ProgressBar(config: progressConfig) + defer { progress.finish() } + progress.start() + + progress.set(description: "Pulling image (\(definition.baseImage))") + progress.set(itemsName: "blobs") + let image = try await Sandboxy.imageStore.pull( + reference: definition.baseImage, + progress: progressEventAdapter(for: progress) + ) + + progress.set(description: "Unpacking image") + container = try await manager.create( + containerId, + image: image, + rootfsSizeInBytes: 512.gib(), + networking: false + ) { config in + if enableNetworking, let iface = try sharedNetwork?.createInterface(containerId, mtu: vmnetMTU) { + config.interfaces = [iface] + config.dns = .init(nameservers: [sharedNetwork!.ipv4Gateway.description]) + } + try configureContainer( + config: &config, + definition: definition, + options: options, + containerId: containerId, + hostWorkspacePath: hostWorkspacePath, + guestWorkspacePath: guestWorkspacePath, + extraMounts: extraMounts + ) + } + } + + // Boot and install toolchain if needed (with full network). + if needsInstall { + try await container.create() + try await container.start() + + ProgressUI.printStatus("Installing \(definition.displayName) toolchain...") + try await installAgent(in: container, definition: definition) + ProgressUI.printStatus("Installation complete.") + + try await container.stop() + ProgressUI.printDetail("Caching environment for future runs...") + try FileManager.default.copyItem(at: containerRootfsPath, to: agentCachePath) + + // Delete so we can recreate (possibly on a different network). + try manager.delete(containerId) + try? sharedNetwork?.releaseInterface(containerId) + + // Recreate from the freshly-cached rootfs (unless filtering will recreate again). + if !filteringEnabled { + let cachedRootfsMount = Mount.block( + format: "ext4", + source: containerRootfsPath.path(percentEncoded: false), + destination: "/", + runtimeOptions: ["vzDiskImageSynchronizationMode=fsync"] + ) + let cachedImage = try await pullImageWithProgress(reference: definition.baseImage) + container = try await manager.create( + containerId, + image: cachedImage, + rootfs: cachedRootfsMount, + networking: false + ) { config in + if enableNetworking, let iface = try sharedNetwork?.createInterface(containerId, mtu: vmnetMTU) { + config.interfaces = [iface] + config.dns = .init(nameservers: [sharedNetwork!.ipv4Gateway.description]) + } + try configureContainer( + config: &config, + definition: definition, + options: options, + containerId: containerId, + hostWorkspacePath: hostWorkspacePath, + guestWorkspacePath: guestWorkspacePath, + extraMounts: extraMounts + ) + } + } + } + + // Host-only network setup (only when filtering is active) + var proxyIP: String? + var hostOnlyNetwork: VmnetNetwork? + + if filteringEnabled, enableNetworking, #available(macOS 26, *) { + hostOnlyNetwork = try VmnetNetwork(mode: .VMNET_HOST_MODE) + + let gatewayIP = hostOnlyNetwork!.ipv4Gateway.description + let workloadHostOnlyInterface = try hostOnlyNetwork!.createInterface(containerId, mtu: vmnetMTU) + proxyIP = gatewayIP + + // Recreate the workload container on the host-only network. + if !needsInstall { + try manager.delete(containerId) + try? sharedNetwork?.releaseInterface(containerId) + } + + let filteredContainerDir = Sandboxy.appRoot + .appendingPathComponent("containers") + .appendingPathComponent(containerId) + try FileManager.default.createDirectory(at: filteredContainerDir, withIntermediateDirectories: true) + + let filteredRootfsSource = needsInstall ? agentCachePath : (sourceRootfs ?? agentCachePath) + let cloneResult2 = Darwin.clonefile( + filteredRootfsSource.path(percentEncoded: false), + containerRootfsPath.path(percentEncoded: false), + 0 + ) + if cloneResult2 != 0 { + try FileManager.default.copyItem(at: filteredRootfsSource, to: containerRootfsPath) + } + + let filteredRootfsMount = Mount.block( + format: "ext4", + source: containerRootfsPath.path(percentEncoded: false), + destination: "/", + runtimeOptions: ["vzDiskImageSynchronizationMode=fsync"] + ) + + let image = try await pullImageWithProgress(reference: definition.baseImage) + + container = try await manager.create( + containerId, + image: image, + rootfs: filteredRootfsMount, + networking: false + ) { config in + if let iface = workloadHostOnlyInterface { + config.interfaces = [iface] + config.dns = .init(nameservers: [gatewayIP]) + } + try configureContainer( + config: &config, + definition: definition, + options: options, + containerId: containerId, + hostWorkspacePath: hostWorkspacePath, + guestWorkspacePath: guestWorkspacePath, + extraMounts: extraMounts + ) + } + } + + // Run the container session, cleaning up on both success and failure. + do { + try await runContainerSession( + container: container, + containerId: containerId, + instanceName: instanceName, + agentName: agentName, + definition: definition, + options: options, + containerRootfsPath: containerRootfsPath, + agentCachePath: agentCachePath, + hostWorkspacePath: hostWorkspacePath, + guestWorkspacePath: guestWorkspacePath, + extraEnvVars: extraEnvVars, + proxyIP: proxyIP, + allowedHosts: allowedHosts, + passthroughArgs: filteredPassthroughArgs + ) + + // Cleanup + try manager.delete(containerId) + try? sharedNetwork?.releaseInterface(containerId) + if hostOnlyNetwork != nil { + try? hostOnlyNetwork?.releaseInterface(containerId) + } + } catch { + do { + try await container.stop() + } catch { + log.warning("Failed to stop container \(containerId): \(error)") + } + do { + try manager.delete(containerId) + } catch { + log.warning("Failed to delete container \(containerId): \(error)") + } + try? sharedNetwork?.releaseInterface(containerId) + if hostOnlyNetwork != nil { + try? hostOnlyNetwork?.releaseInterface(containerId) + } + throw error + } +} + +private func runContainerSession( + container: LinuxContainer, + containerId: String, + instanceName: String, + agentName: String, + definition: AgentDefinition, + options: AgentOptions, + containerRootfsPath: URL, + agentCachePath: URL, + hostWorkspacePath: String, + guestWorkspacePath: String, + extraEnvVars: [String], + proxyIP: String?, + allowedHosts: [String], + passthroughArgs: [String] +) async throws { + // create() boots the VM and brings up the vmnet bridge on the host. + try await container.create() + + // Start the proxy now that the bridge interface is up. + var hostProxy: HostProxy? + if let proxyIP { + let proxy = try await HostProxy( + host: proxyIP, + port: 0, + allowedHosts: allowedHosts + ) + hostProxy = proxy + } + + // start() launches the container process. + try await container.start() + + // Write instance state. + let instanceState = InstanceState( + id: containerId, + name: instanceName, + agent: agentName, + workspace: hostWorkspacePath, + status: .running, + createdAt: Date(), + cpus: options.cpus, + memoryMB: try options.memoryBytes / (1024 * 1024) + ) + try instanceState.save(appRoot: Sandboxy.appRoot) + + let sigwinchStream = AsyncSignalHandler.create(notify: [SIGWINCH]) + let current = try Terminal.current + try current.setraw() + defer { current.tryReset() } + + // Build environment for the agent process. + var envVarsBuilder = [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "TERM=xterm-256color", + "HOME=/root", + ] + for envVar in definition.environmentVariables { + if envVar.contains("=") { + envVarsBuilder.append(envVar) + } else if let value = ProcessInfo.processInfo.environment[envVar] { + envVarsBuilder.append("\(envVar)=\(value)") + } + } + + envVarsBuilder.append(contentsOf: extraEnvVars) + + if options.sshAgent, ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] != nil { + envVarsBuilder.append("SSH_AUTH_SOCK=/tmp/ssh-agent.sock") + } + + if let proxyIP, let proxyPort = hostProxy?.port { + let proxyURL = "http://\(proxyIP):\(proxyPort)" + envVarsBuilder.append("HTTP_PROXY=\(proxyURL)") + envVarsBuilder.append("HTTPS_PROXY=\(proxyURL)") + envVarsBuilder.append("http_proxy=\(proxyURL)") + envVarsBuilder.append("https_proxy=\(proxyURL)") + envVarsBuilder.append("NO_PROXY=localhost,127.0.0.1") + envVarsBuilder.append("no_proxy=localhost,127.0.0.1") + envVarsBuilder.append("GLOBAL_AGENT_HTTP_PROXY=\(proxyURL)") + envVarsBuilder.append("GLOBAL_AGENT_HTTPS_PROXY=\(proxyURL)") + envVarsBuilder.append("GLOBAL_AGENT_NO_PROXY=localhost,127.0.0.1") + + // Prepend global-agent bootstrap to NODE_OPTIONS so Node.js http/https + // modules respect the proxy environment variables. + if let idx = envVarsBuilder.firstIndex(where: { $0.hasPrefix("NODE_OPTIONS=") }) { + let existing = String(envVarsBuilder[idx].dropFirst("NODE_OPTIONS=".count)) + envVarsBuilder[idx] = "NODE_OPTIONS=-r /usr/local/lib/node_modules/global-agent/dist/routines/bootstrap.js \(existing)" + } else { + envVarsBuilder.append("NODE_OPTIONS=-r /usr/local/lib/node_modules/global-agent/dist/routines/bootstrap.js") + } + } + + var launchArgsBuilder = definition.launchCommand + launchArgsBuilder.append(contentsOf: passthroughArgs) + + let envVars = envVarsBuilder + let launchArgs = launchArgsBuilder + + let agentProcess = try await container.exec("agent-session") { config in + config.arguments = launchArgs + config.environmentVariables = envVars + config.workingDirectory = guestWorkspacePath + config.terminal = true + config.stdin = current + config.stdout = current + } + + try await agentProcess.start() + try? await agentProcess.resize(to: try current.size) + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + for await _ in sigwinchStream.signals { + try await agentProcess.resize(to: try current.size) + } + } + + let status = try await agentProcess.wait() + group.cancelAll() + + try await agentProcess.delete() + + // Stop container so rootfs is cleanly unmounted before caching. + try await container.stop() + + if options.removeAfterRun { + ProgressUI.printStatus("Instance \u{1b}[1m\(instanceName)\u{1b}[0m removed (--rm).") + } else { + // Preserve the rootfs for all instances. + let namedDir = InstanceState.namedRootfsDir(appRoot: Sandboxy.appRoot) + try FileManager.default.createDirectory(at: namedDir, withIntermediateDirectories: true) + let namedPath = InstanceState.namedRootfsPath(appRoot: Sandboxy.appRoot, name: instanceName) + removeIfExists(at: namedPath) + try FileManager.default.copyItem(at: containerRootfsPath, to: namedPath) + + let stopped = InstanceState( + id: instanceState.id, + name: instanceState.name, + agent: instanceState.agent, + workspace: instanceState.workspace, + status: .stopped, + createdAt: instanceState.createdAt, + stoppedAt: Date(), + cpus: instanceState.cpus, + memoryMB: instanceState.memoryMB + ) + try stopped.save(appRoot: Sandboxy.appRoot) + + ProgressUI.printStatus("Instance \u{1b}[1m\(instanceName)\u{1b}[0m saved. Resume with: sandboxy run \(agentName) --name \(instanceName)") + } + + if status.exitCode != 0 { + throw ExitCode(status.exitCode) + } + } + + if let proxy = hostProxy { + try await proxy.stop() + } +} + +private func configureContainer( + config: inout LinuxContainer.Configuration, + definition: AgentDefinition, + options: AgentOptions, + containerId: String, + hostWorkspacePath: String, + guestWorkspacePath: String, + extraMounts: [MountSpec] +) throws { + config.cpus = options.cpus + config.memoryInBytes = try options.memoryBytes + + config.process.arguments = ["/bin/sleep", "infinity"] + config.process.workingDirectory = "/" + config.process.capabilities = .allCapabilities + config.useInit = true + + // SSH agent forwarding. + if options.sshAgent, + let authSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] + { + let guestSocketPath = "/tmp/ssh-agent.sock" + config.sockets.append( + UnixSocketConfiguration( + source: URL(fileURLWithPath: authSock), + destination: URL(fileURLWithPath: guestSocketPath), + direction: .into + ) + ) + } + + config.mounts.append( + Mount.share( + source: hostWorkspacePath, + destination: guestWorkspacePath + ) + ) + + if !options.noAgentMounts { + for agentMount in definition.mounts { + let hostPath = agentMount.resolvedHostPath + if FileManager.default.fileExists(atPath: hostPath) { + config.mounts.append( + Mount.share( + source: hostPath, + destination: agentMount.containerPath, + options: agentMount.readOnly ? ["ro"] : [] + ) + ) + } + } + } + + for mountSpec in extraMounts { + config.mounts.append(mountSpec.toMount()) + } + + var hosts = Hosts.default + if #available(macOS 26, *), !config.interfaces.isEmpty { + let interface = config.interfaces[0] + hosts.entries.append( + Hosts.Entry( + ipAddress: interface.ipv4Address.address.description, + hostnames: [containerId] + ) + ) + } + config.hosts = hosts +} + +func installAgent( + in container: LinuxContainer, + definition: AgentDefinition +) async throws { + for (index, command) in definition.installCommands.enumerated() { + let truncated = command.count > 80 ? String(command.prefix(77)) + "..." : command + ProgressUI.printDetail("[\(index + 1)/\(definition.installCommands.count)] \(truncated)") + + let buffer = OutputCapture(streamToStdout: true) + let execId = "install-\(index)" + let process = try await container.exec(execId) { config in + config.arguments = ["/bin/sh", "-c", command] + config.workingDirectory = "/" + config.stdout = buffer + config.stderr = buffer + config.capabilities = .allCapabilities + config.environmentVariables = [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "DEBIAN_FRONTEND=noninteractive", + "HOME=/root", + ] + } + + try await process.start() + let status = try await process.wait() + try await process.delete() + + guard status.exitCode == 0 else { + throw SandboxyError.installFailed( + step: index + 1, + command: command, + exitCode: status.exitCode + ) + } + } +} + +func removeIfExists(at url: URL) { + let path = url.path(percentEncoded: false) + if FileManager.default.fileExists(atPath: path) { + do { + try FileManager.default.removeItem(at: url) + } catch { + log.warning("Failed to remove \(path): \(error)") + } + } +} + +/// A parsed `hostpath:containerpath[:ro|rw]` mount specification from the CLI. +struct MountSpec { + let hostPath: String + let containerPath: String + let readOnly: Bool + + static func parse(_ spec: String) throws -> MountSpec { + let parts = spec.split(separator: ":", maxSplits: 2).map(String.init) + guard parts.count >= 2 else { + throw SandboxyError.invalidMountSpec(spec: spec) + } + + let readOnly: Bool + if parts.count == 3 { + switch parts[2] { + case "ro": + readOnly = true + case "rw": + readOnly = false + default: + throw SandboxyError.invalidMountSpec(spec: spec) + } + } else { + readOnly = false + } + + // Resolve host path to absolute. + let hostPath = URL(fileURLWithPath: parts[0], relativeTo: .currentDirectory()) + .absoluteURL.path(percentEncoded: false) + + return MountSpec(hostPath: hostPath, containerPath: parts[1], readOnly: readOnly) + } + + func toMount() -> Containerization.Mount { + Containerization.Mount.share( + source: hostPath, + destination: containerPath, + options: readOnly ? ["ro"] : [] + ) + } +} + +func progressEventAdapter(for progress: ProgressBar) -> ProgressHandler { + { events in + for event in events { + switch event.event { + case "add-size": + if let value = event.value as? Int64 { + progress.add(size: value) + } + case "add-total-size": + if let value = event.value as? Int64 { + progress.add(totalSize: value) + } + case "add-items": + if let value = event.value as? Int { + progress.add(items: value) + } + case "add-total-items": + if let value = event.value as? Int { + progress.add(totalItems: value) + } + default: + break + } + } + } +} + +/// Pulls an image, showing a progress bar only if the image isn't already cached locally. +func pullImageWithProgress(reference: String) async throws -> Containerization.Image { + do { + return try await Sandboxy.imageStore.get(reference: reference) + } catch { + let progressConfig = try ProgressConfig( + description: "Pulling image (\(reference))", + showItems: true, + ignoreSmallSize: true + ) + let progress = ProgressBar(config: progressConfig) + defer { progress.finish() } + progress.start() + progress.set(itemsName: "blobs") + return try await Sandboxy.imageStore.pull( + reference: reference, + progress: progressEventAdapter(for: progress) + ) + } +} + +/// Resolves a `KEY=VALUE` or `KEY` environment variable specification. +/// +/// - `KEY=VALUE`: passed through as-is. +/// - `KEY`: looks up the variable in the host environment and produces `KEY=`. +/// Throws if the variable is not set. +enum EnvSpec { + static func resolve(_ spec: String) throws -> String { + if let eqIndex = spec.firstIndex(of: "=") { + // KEY=VALUE: Use as-is, but validate key is non-empty. + let key = spec[spec.startIndex.. SandboxyConfig { + let fm = FileManager.default + let agentsDir = configRoot.appendingPathComponent("agents") + try fm.createDirectory(at: agentsDir, withIntermediateDirectories: true) + + let config = try SandboxyConfig.load(configRoot: configRoot) + if let dataDir = config.dataDir { + appRoot = URL(fileURLWithPath: dataDir) + } + + try fm.createDirectory(at: appRoot, withIntermediateDirectories: true) + return config + } + + /// User configuration directory (`~/.config/sandboxy/`). + /// Holds `config.json` and `agents/` definition files. + static let configRoot: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config") + .appendingPathComponent("sandboxy") + }() + + private static let _contentStore: ContentStore = { + try! LocalContentStore(path: appRoot.appendingPathComponent("content")) + }() + + private static let _imageStore: ImageStore = { + try! ImageStore( + path: appRoot, + contentStore: contentStore + ) + }() + + static var imageStore: ImageStore { + _imageStore + } + + static var contentStore: ContentStore { + _contentStore + } +} diff --git a/examples/sandboxy/Sources/sandboxy/SandboxyConfig.swift b/examples/sandboxy/Sources/sandboxy/SandboxyConfig.swift new file mode 100644 index 00000000..d946970d --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/SandboxyConfig.swift @@ -0,0 +1,81 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +/// Optional configuration file for overriding sandboxy defaults. +/// +/// If `config.json` exists in the sandboxy config directory, it is +/// loaded at startup and its values override the built-in defaults. Any field +/// can be omitted to keep the default. +/// +/// Location: `~/.config/sandboxy/config.json` +/// +/// Example: +/// ```json +/// { +/// "dataDir": "/Volumes/fast/sandboxy", +/// "kernel": "/path/to/vmlinux", +/// "initfsReference": "ghcr.io/apple/containerization/vminit:latest", +/// "defaultCPUs": 4, +/// "defaultMemory": "4g" +/// } +/// ``` +struct SandboxyConfig: Codable, Sendable { + /// Directory for runtime data (caches, content store, containers). + /// Defaults to `~/Library/Application Support/com.apple.containerization.sandboxy`. + var dataDir: String? + /// Path to a Linux kernel binary on disk. When set, the auto-download is skipped. + var kernel: String? + /// OCI reference for the VM init image. + var initfsReference: String? + /// Default number of CPUs for new containers. + var defaultCPUs: Int? + /// Default memory for new containers (e.g. "4g", "512m", "4096" for MB). + var defaultMemory: String? + + /// Built-in defaults used when no config file is present. + static let defaults = SandboxyConfig( + initfsReference: "ghcr.io/apple/containerization/vminit:0.30.0", + defaultCPUs: 4, + defaultMemory: "4g" + ) + + /// Loads the config from `/config.json`, falling back to defaults + /// for any missing fields. + static func load(configRoot: URL) throws -> SandboxyConfig { + let configPath = configRoot.appendingPathComponent("config.json") + + guard FileManager.default.fileExists(atPath: configPath.path(percentEncoded: false)) else { + return .defaults + } + + do { + let data = try Data(contentsOf: configPath) + let userConfig = try JSONDecoder().decode(SandboxyConfig.self, from: data) + + return SandboxyConfig( + dataDir: userConfig.dataDir, + kernel: userConfig.kernel, + initfsReference: userConfig.initfsReference ?? defaults.initfsReference, + defaultCPUs: userConfig.defaultCPUs ?? defaults.defaultCPUs, + defaultMemory: userConfig.defaultMemory ?? defaults.defaultMemory + ) + } catch { + throw SandboxyError.configFailedToLoad(error: error) + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/SandboxyError.swift b/examples/sandboxy/Sources/sandboxy/SandboxyError.swift new file mode 100644 index 00000000..d112c975 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/SandboxyError.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +enum SandboxyError: Error, CustomStringConvertible { + case configFailedToLoad(error: Swift.Error) + case installFailed(step: Int, command: String, exitCode: Int32) + case kernelDownloadFailed(reason: String) + case proxyFailed(reason: String) + case kernelNotFound(path: String) + case incompleteAgentDefinition(missing: [String]) + case invalidMountSpec(spec: String) + case envVarNotSet(name: String) + + var description: String { + switch self { + case .configFailedToLoad(let error): + return "Failed to load sandbox config: \(error)" + case .installFailed(let step, let command, let exitCode): + return """ + Installation step \(step) failed (exit code \(exitCode)). + Command: \(command) + """ + case .kernelDownloadFailed(let reason): + return "Failed to download kernel: \(reason)" + case .proxyFailed(let reason): + return "Proxy failed: \(reason)" + case .kernelNotFound(let path): + return "Kernel not found at \(path). Provide a valid path with -k or omit to auto-download." + case .incompleteAgentDefinition(let missing): + return "Agent definition is missing required fields: \(missing.joined(separator: ", ")). Use 'sandboxy config --agent claude' to see a complete example." + case .invalidMountSpec(let spec): + return "Invalid mount specification: '\(spec)'. Expected format: hostpath:containerpath[:ro|rw]" + case .envVarNotSet(let name): + return "Environment variable '\(name)' is not set on the host." + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/Int+Formatted.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/Int+Formatted.swift new file mode 100644 index 00000000..28f7ee21 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/Int+Formatted.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +extension Int { + func formattedTime() -> String { + let secondsInMinute = 60 + let secondsInHour = secondsInMinute * 60 + let secondsInDay = secondsInHour * 24 + + let days = self / secondsInDay + let hours = (self % secondsInDay) / secondsInHour + let minutes = (self % secondsInHour) / secondsInMinute + let seconds = self % secondsInMinute + + var components = [String]() + if days > 0 { + components.append("\(days)d") + } + if hours > 0 || days > 0 { + components.append("\(hours)h") + } + if minutes > 0 || hours > 0 || days > 0 { + components.append("\(minutes)m") + } + components.append("\(seconds)s") + return components.joined(separator: " ") + } + + func formattedNumber() -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + guard let formattedNumber = formatter.string(from: NSNumber(value: self)) else { + return "" + } + return formattedNumber + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/Int64+Formatted.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/Int64+Formatted.swift new file mode 100644 index 00000000..34b73ff0 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/Int64+Formatted.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +extension Int64 { + func formattedSize() -> String { + let formattedSize = ByteCountFormatter.string(fromByteCount: self, countStyle: .binary) + return formattedSize + } + + func formattedSizeSpeed(from startTime: DispatchTime) -> String { + let elapsedTimeNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds + let elapsedTimeSeconds = Double(elapsedTimeNanoseconds) / 1_000_000_000 + guard elapsedTimeSeconds > 0 else { + return "0 B/s" + } + + let speed = Double(self) / elapsedTimeSeconds + let formattedSpeed = ByteCountFormatter.string(fromByteCount: Int64(speed), countStyle: .binary) + return "\(formattedSpeed)/s" + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+Add.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+Add.swift new file mode 100644 index 00000000..3710895d --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+Add.swift @@ -0,0 +1,236 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +extension ProgressBar { + /// A handler function to update the progress bar. + /// - Parameter events: The events to handle. + public func handler(_ events: [ProgressUpdateEvent]) { + for event in events { + switch event { + case .setDescription(let description): + set(description: description) + case .setSubDescription(let subDescription): + set(subDescription: subDescription) + case .setItemsName(let itemsName): + set(itemsName: itemsName) + case .addTasks(let tasks): + add(tasks: tasks) + case .setTasks(let tasks): + set(tasks: tasks) + case .addTotalTasks(let totalTasks): + add(totalTasks: totalTasks) + case .setTotalTasks(let totalTasks): + set(totalTasks: totalTasks) + case .addSize(let size): + add(size: size) + case .setSize(let size): + set(size: size) + case .addTotalSize(let totalSize): + add(totalSize: totalSize) + case .setTotalSize(let totalSize): + set(totalSize: totalSize) + case .addItems(let items): + add(items: items) + case .setItems(let items): + set(items: items) + case .addTotalItems(let totalItems): + add(totalItems: totalItems) + case .setTotalItems(let totalItems): + set(totalItems: totalItems) + case .custom: + // Custom events are handled by the client. + break + } + } + } + + /// Performs a check to see if the progress bar should be finished. + public func checkIfFinished() { + let state = self.state.withLock { $0 } + + var finished = true + var defined = false + if let totalTasks = state.totalTasks, totalTasks > 0 { + // For tasks, we're showing the current task rather than the number of completed tasks. + finished = finished && state.tasks == totalTasks + defined = true + } + if let totalItems = state.totalItems, totalItems > 0 { + finished = finished && state.items == totalItems + defined = true + } + if let totalSize = state.totalSize, totalSize > 0 { + finished = finished && state.size == totalSize + defined = true + } + if defined && finished { + finish() + } + } + + /// Sets the current tasks. + /// - Parameter newTasks: The current tasks to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(tasks newTasks: Int, render: Bool = true) { + state.withLock { $0.tasks = newTasks } + if render { + self.render() + } + checkIfFinished() + } + + /// Performs an addition to the current tasks. + /// - Parameter delta: The tasks to add to the current tasks. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(tasks delta: Int, render: Bool = true) { + state.withLock { + let newTasks = $0.tasks + delta + $0.tasks = newTasks + } + if render { + self.render() + } + } + + /// Sets the total tasks. + /// - Parameter newTotalTasks: The total tasks to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(totalTasks newTotalTasks: Int, render: Bool = true) { + state.withLock { $0.totalTasks = newTotalTasks } + if render { + self.render() + } + } + + /// Performs an addition to the total tasks. + /// - Parameter delta: The tasks to add to the total tasks. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(totalTasks delta: Int, render: Bool = true) { + state.withLock { + let totalTasks = $0.totalTasks ?? 0 + let newTotalTasks = totalTasks + delta + $0.totalTasks = newTotalTasks + } + if render { + self.render() + } + } + + /// Sets the items name. + /// - Parameter newItemsName: The current items to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(itemsName newItemsName: String, render: Bool = true) { + state.withLock { $0.itemsName = newItemsName } + if render { + self.render() + } + } + + /// Sets the current items. + /// - Parameter newItems: The current items to set. + public func set(items newItems: Int, render: Bool = true) { + state.withLock { $0.items = newItems } + if render { + self.render() + } + } + + /// Performs an addition to the current items. + /// - Parameter delta: The items to add to the current items. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(items delta: Int, render: Bool = true) { + state.withLock { + let newItems = $0.items + delta + $0.items = newItems + } + if render { + self.render() + } + } + + /// Sets the total items. + /// - Parameter newTotalItems: The total items to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(totalItems newTotalItems: Int, render: Bool = true) { + state.withLock { $0.totalItems = newTotalItems } + if render { + self.render() + } + } + + /// Performs an addition to the total items. + /// - Parameter delta: The items to add to the total items. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(totalItems delta: Int, render: Bool = true) { + state.withLock { + let totalItems = $0.totalItems ?? 0 + let newTotalItems = totalItems + delta + $0.totalItems = newTotalItems + } + if render { + self.render() + } + } + + /// Sets the current size. + /// - Parameter newSize: The current size to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(size newSize: Int64, render: Bool = true) { + state.withLock { $0.size = newSize } + if render { + self.render() + } + } + + /// Performs an addition to the current size. + /// - Parameter delta: The size to add to the current size. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(size delta: Int64, render: Bool = true) { + state.withLock { + let newSize = $0.size + delta + $0.size = newSize + } + if render { + self.render() + } + } + + /// Sets the total size. + /// - Parameter newTotalSize: The total size to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(totalSize newTotalSize: Int64, render: Bool = true) { + state.withLock { $0.totalSize = newTotalSize } + if render { + self.render() + } + } + + /// Performs an addition to the total size. + /// - Parameter delta: The size to add to the total size. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(totalSize delta: Int64, render: Bool = true) { + state.withLock { + let totalSize = $0.totalSize ?? 0 + let newTotalSize = totalSize + delta + $0.totalSize = newTotalSize + } + if render { + self.render() + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+State.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+State.swift new file mode 100644 index 00000000..0771e793 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+State.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +extension ProgressBar { + /// State for the progress bar. + struct State { + /// A flag indicating whether the progress bar is finished. + var finished = false + var iteration = 0 + private let speedInterval: DispatchTimeInterval = .seconds(1) + + var description: String + var subDescription: String + var itemsName: String + + var tasks: Int + var totalTasks: Int? + + var items: Int + var totalItems: Int? + + private var sizeUpdateTime: DispatchTime? + private var sizeUpdateValue: Int64 = 0 + var size: Int64 { + didSet { + calculateSizeSpeed() + } + } + + var totalSize: Int64? + private var sizeUpdateSpeed: String? + var sizeSpeed: String? { + guard sizeUpdateTime == nil || sizeUpdateTime! > .now() - speedInterval - speedInterval else { + return Int64(0).formattedSizeSpeed(from: startTime) + } + return sizeUpdateSpeed + } + var averageSizeSpeed: String { + size.formattedSizeSpeed(from: startTime) + } + + var percent: String { + var value = 0 + if let totalSize, totalSize > 0 { + value = Int(size * 100 / totalSize) + } else if let totalItems, totalItems > 0 { + value = Int(items * 100 / totalItems) + } + value = min(value, 100) + return "\(value)%" + } + + var startTime: DispatchTime + var output = "" + var renderTask: Task? + + init( + description: String = "", subDescription: String = "", itemsName: String = "", tasks: Int = 0, totalTasks: Int? = nil, items: Int = 0, totalItems: Int? = nil, + size: Int64 = 0, totalSize: Int64? = nil, startTime: DispatchTime = .now() + ) { + self.description = description + self.subDescription = subDescription + self.itemsName = itemsName + self.tasks = tasks + self.totalTasks = totalTasks + self.items = items + self.totalItems = totalItems + self.size = size + self.totalSize = totalSize + self.startTime = startTime + } + + private mutating func calculateSizeSpeed() { + if sizeUpdateTime == nil || sizeUpdateTime! < .now() - speedInterval { + let partSize = size - sizeUpdateValue + let partStartTime = sizeUpdateTime ?? startTime + let partSizeSpeed = partSize.formattedSizeSpeed(from: partStartTime) + self.sizeUpdateSpeed = partSizeSpeed + + sizeUpdateTime = .now() + sizeUpdateValue = size + } + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+Terminal.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+Terminal.swift new file mode 100644 index 00000000..ce84cc5c --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+Terminal.swift @@ -0,0 +1,95 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerizationOS +import Foundation + +enum EscapeSequence { + static let hideCursor = "\u{001B}[?25l" + static let showCursor = "\u{001B}[?25h" + static let moveUp = "\u{001B}[1A" + static let clearToEndOfLine = "\u{001B}[K" +} + +extension ProgressBar { + var termWidth: Int { + guard + let terminalHandle = term, + let terminal = try? Terminal(descriptor: terminalHandle.fileDescriptor) + else { + return 0 + } + + return (try? Int(terminal.size.width)) ?? 0 + } + + /// Clears the progress bar and resets the cursor. + public func clearAndResetCursor() { + state.withLock { s in + clear(state: &s) + resetCursor() + } + } + + /// Clears the progress bar. + public func clear() { + state.withLock { s in + clear(state: &s) + } + } + + /// Clears the progress bar (caller must hold state lock). + func clear(state: inout State) { + displayText("", state: &state) + } + + /// Resets the cursor. + public func resetCursor() { + display(EscapeSequence.showCursor) + } + + func display(_ text: String) { + guard let term else { + return + } + termQueue.sync { + try? term.write(contentsOf: Data(text.utf8)) + try? term.synchronize() + } + } + + func displayText(_ text: String, terminating: String = "\r") { + state.withLock { s in + displayText(text, state: &s, terminating: terminating) + } + } + + func displayText(_ text: String, state: inout State, terminating: String = "\r") { + state.output = text + + // Clears previously printed lines. + var lines = "" + if terminating.hasSuffix("\r") && termWidth > 0 { + let lineCount = (text.count - 1) / termWidth + for _ in 0.. + let term: FileHandle? + let termQueue = DispatchQueue(label: "com.apple.container.ProgressBar") + + /// Returns `true` if the progress bar has finished. + public var isFinished: Bool { + state.withLock { $0.finished } + } + + /// Creates a new progress bar. + /// - Parameter config: The configuration for the progress bar. + public init(config: ProgressConfig) { + self.config = config + term = isatty(config.terminal.fileDescriptor) == 1 ? config.terminal : nil + let state = State( + description: config.initialDescription, itemsName: config.initialItemsName, totalTasks: config.initialTotalTasks, + totalItems: config.initialTotalItems, + totalSize: config.initialTotalSize) + self.state = Mutex(state) + display(EscapeSequence.hideCursor) + } + + /// Allows resetting the progress state. + public func reset() { + state.withLock { + $0 = State(description: config.initialDescription) + } + } + + /// Allows resetting the progress state of the current task. + public func resetCurrentTask() { + state.withLock { + $0 = State(description: $0.description, itemsName: $0.itemsName, tasks: $0.tasks, totalTasks: $0.totalTasks, startTime: $0.startTime) + } + } + + /// Updates the description of the progress bar and increments the tasks by one. + /// - Parameter description: The description of the action being performed. + public func set(description: String) { + resetCurrentTask() + + state.withLock { + $0.description = description + $0.subDescription = "" + $0.tasks += 1 + } + } + + /// Updates the additional description of the progress bar. + /// - Parameter subDescription: The additional description of the action being performed. + public func set(subDescription: String) { + resetCurrentTask() + + state.withLock { $0.subDescription = subDescription } + } + + private func start(intervalSeconds: TimeInterval) async { + while true { + let done = state.withLock { s -> Bool in + guard !s.finished else { + return true + } + render(state: &s) + s.iteration += 1 + return false + } + + if done { + return + } + + let intervalNanoseconds = UInt64(intervalSeconds * 1_000_000_000) + guard (try? await Task.sleep(nanoseconds: intervalNanoseconds)) != nil else { + return + } + } + } + + /// Starts an animation of the progress bar. + /// - Parameter intervalSeconds: The time interval between updates in seconds. + public func start(intervalSeconds: TimeInterval = 0.04) { + state.withLock { + if $0.renderTask != nil { + return + } + $0.renderTask = Task(priority: .utility) { + await start(intervalSeconds: intervalSeconds) + } + } + } + + /// Finishes the progress bar. + /// - Parameter clearScreen: If true, clears the progress bar from the screen. + public func finish(clearScreen: Bool = false) { + state.withLock { s in + guard !s.finished else { return } + + s.finished = true + s.renderTask?.cancel() + + let shouldClear = clearScreen || config.clearOnFinish + if !config.disableProgressUpdates && !shouldClear { + let output = draw(state: s) + displayText(output, state: &s, terminating: "\n") + } + + if shouldClear { + clear(state: &s) + } + resetCursor() + } + } +} + +extension ProgressBar { + private func secondsSinceStart(from startTime: DispatchTime) -> Int { + let timeDifferenceNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds + let timeDifferenceSeconds = Int(floor(Double(timeDifferenceNanoseconds) / 1_000_000_000)) + return timeDifferenceSeconds + } + + func render(force: Bool = false) { + guard term != nil && !config.disableProgressUpdates else { + return + } + state.withLock { s in + render(state: &s, force: force) + } + } + + func render(state: inout State, force: Bool = false) { + guard term != nil && !config.disableProgressUpdates else { + return + } + guard force || !state.finished else { + return + } + let output = draw(state: state) + displayText(output, state: &state) + } + + /// Detail levels for progressive truncation. + enum DetailLevel: Int, CaseIterable { + case full = 0 // Everything shown + case noSpeed // Drop speed from parens + case noSize // Drop size from parens + case noParens // Drop parens entirely (items, size, speed) + case noTime // Drop time + case noDescription // Drop description/subdescription + case minimal // Just spinner, tasks, percent + } + + func draw(state: State) -> String { + let width = termWidth + // If no terminal or width unknown, use full detail + guard width > 0 else { + return draw(state: state, detail: .full) + } + + // Add a small buffer to prevent wrapping issues during resize + let bufferChars = 4 + let targetWidth = max(1, width - bufferChars) + + for detail in DetailLevel.allCases { + let output = draw(state: state, detail: detail) + if output.count <= targetWidth { + return output + } + } + + return draw(state: state, detail: .minimal) + } + + func draw(state: State, detail: DetailLevel) -> String { + var components = [String]() + + // Spinner - always shown if configured (unless using progress bar) + if config.showSpinner && !config.showProgressBar { + if !state.finished { + let spinnerIcon = config.theme.getSpinnerIcon(state.iteration) + components.append("\(spinnerIcon)") + } else { + components.append("\(config.theme.done)") + } + } + + // Tasks [x/y] - always shown if configured + if config.showTasks, let totalTasks = state.totalTasks { + let tasks = min(state.tasks, totalTasks) + components.append("[\(tasks)/\(totalTasks)]") + } + + // Description - dropped at noDescription level + if detail.rawValue < DetailLevel.noDescription.rawValue { + if config.showDescription && !state.description.isEmpty { + components.append("\(state.description)") + if !state.subDescription.isEmpty { + components.append("\(state.subDescription)") + } + } + } + + let allowProgress = !config.ignoreSmallSize || state.totalSize == nil || state.totalSize! > Int64(1024 * 1024) + let value = state.totalSize != nil ? state.size : Int64(state.items) + let total = state.totalSize ?? Int64(state.totalItems ?? 0) + + // Percent - always shown if configured + if config.showPercent && total > 0 && allowProgress { + components.append("\(state.finished ? "100%" : state.percent)") + } + + // Progress bar - always shown if configured + if config.showProgressBar, total > 0, allowProgress { + let usedWidth = components.joined(separator: " ").count + 45 + let remainingWidth = max(config.width - usedWidth, 1) + let barLength = state.finished ? remainingWidth : Int(Int64(remainingWidth) * value / total) + let barPaddingLength = remainingWidth - barLength + let bar = "\(String(repeating: config.theme.bar, count: barLength))\(String(repeating: " ", count: barPaddingLength))" + components.append("|\(bar)|") + } + + // Additional components in parens - progressively dropped + if detail.rawValue < DetailLevel.noParens.rawValue { + var additionalComponents = [String]() + + // Items - dropped at noParens level + if config.showItems, state.items > 0 { + var itemsName = "" + if !state.itemsName.isEmpty { + itemsName = " \(state.itemsName)" + } + if state.finished { + if let totalItems = state.totalItems { + additionalComponents.append("\(totalItems.formattedNumber())\(itemsName)") + } + } else { + if let totalItems = state.totalItems { + additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)") + } else { + additionalComponents.append("\(state.items.formattedNumber())\(itemsName)") + } + } + } + + // Size and speed - progressively dropped + if state.size > 0 && allowProgress { + if state.finished { + // Size - dropped at noSize level + if detail.rawValue < DetailLevel.noSize.rawValue { + if config.showSize { + if let totalSize = state.totalSize { + var formattedTotalSize = totalSize.formattedSize() + formattedTotalSize = adjustFormattedSize(formattedTotalSize) + additionalComponents.append(formattedTotalSize) + } + } + } + } else { + // Size - dropped at noSize level + var formattedCombinedSize = "" + if detail.rawValue < DetailLevel.noSize.rawValue && config.showSize { + var formattedSize = state.size.formattedSize() + formattedSize = adjustFormattedSize(formattedSize) + if let totalSize = state.totalSize { + var formattedTotalSize = totalSize.formattedSize() + formattedTotalSize = adjustFormattedSize(formattedTotalSize) + formattedCombinedSize = combineSize(size: formattedSize, totalSize: formattedTotalSize) + } else { + formattedCombinedSize = formattedSize + } + } + + // Speed - dropped at noSpeed level + var formattedSpeed = "" + if detail.rawValue < DetailLevel.noSpeed.rawValue && config.showSpeed { + formattedSpeed = "\(state.sizeSpeed ?? state.averageSizeSpeed)" + formattedSpeed = adjustFormattedSize(formattedSpeed) + } + + if !formattedCombinedSize.isEmpty && !formattedSpeed.isEmpty { + additionalComponents.append(formattedCombinedSize) + additionalComponents.append(formattedSpeed) + } else if !formattedCombinedSize.isEmpty { + additionalComponents.append(formattedCombinedSize) + } else if !formattedSpeed.isEmpty { + additionalComponents.append(formattedSpeed) + } + } + } + + if additionalComponents.count > 0 { + let joinedAdditionalComponents = additionalComponents.joined(separator: ", ") + components.append("(\(joinedAdditionalComponents))") + } + } + + // Time - dropped at noTime level + if detail.rawValue < DetailLevel.noTime.rawValue && config.showTime { + let timeDifferenceSeconds = secondsSinceStart(from: state.startTime) + let formattedTime = timeDifferenceSeconds.formattedTime() + components.append("[\(formattedTime)]") + } + + return components.joined(separator: " ") + } + + private func adjustFormattedSize(_ size: String) -> String { + // Ensure we always have one digit after the decimal point to prevent flickering. + let zero = Int64(0).formattedSize() + let decimalSep = Locale.current.decimalSeparator ?? "." + guard !size.contains(decimalSep), let first = size.first, first.isNumber || !size.contains(zero) else { + return size + } + var size = size + for unit in ["MB", "GB", "TB"] { + size = size.replacingOccurrences(of: " \(unit)", with: "\(decimalSep)0 \(unit)") + } + return size + } + + private func combineSize(size: String, totalSize: String) -> String { + let sizeComponents = size.split(separator: " ", maxSplits: 1) + let totalSizeComponents = totalSize.split(separator: " ", maxSplits: 1) + guard sizeComponents.count == 2, totalSizeComponents.count == 2 else { + return "\(size)/\(totalSize)" + } + let sizeNumber = sizeComponents[0] + let sizeUnit = sizeComponents[1] + let totalSizeNumber = totalSizeComponents[0] + let totalSizeUnit = totalSizeComponents[1] + guard sizeUnit == totalSizeUnit else { + return "\(size)/\(totalSize)" + } + return "\(sizeNumber)/\(totalSizeNumber) \(totalSizeUnit)" + } + + func draw() -> String { + state.withLock { draw(state: $0) } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressConfig.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressConfig.swift new file mode 100644 index 00000000..5579ef10 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressConfig.swift @@ -0,0 +1,170 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +/// A configuration for displaying a progress bar. +public struct ProgressConfig: Sendable { + /// The file handle for progress updates. + let terminal: FileHandle + /// The initial description of the progress bar. + let initialDescription: String + /// The initial additional description of the progress bar. + let initialSubDescription: String + /// The initial items name (e.g., "files"). + let initialItemsName: String + /// A flag indicating whether to show a spinner (e.g., "⠋"). + /// The spinner is hidden when a progress bar is shown. + public let showSpinner: Bool + /// A flag indicating whether to show tasks and total tasks (e.g., "[1]" or "[1/3]"). + public let showTasks: Bool + /// A flag indicating whether to show the description (e.g., "Downloading..."). + public let showDescription: Bool + /// A flag indicating whether to show a percentage (e.g., "100%"). + /// The percentage is hidden when no total size and total items are set. + public let showPercent: Bool + /// A flag indicating whether to show a progress bar (e.g., "|███ |"). + /// The progress bar is hidden when no total size and total items are set. + public let showProgressBar: Bool + /// A flag indicating whether to show items and total items (e.g., "(22 it)" or "(22/22 it)"). + public let showItems: Bool + /// A flag indicating whether to show a size and a total size (e.g., "(22 MB)" or "(22/22 MB)"). + public let showSize: Bool + /// A flag indicating whether to show a speed (e.g., "(4.834 MB/s)"). + /// The speed is combined with the size and total size (e.g., "(22/22 MB, 4.834 MB/s)"). + /// The speed is hidden when no total size is set. + public let showSpeed: Bool + /// A flag indicating whether to show the elapsed time (e.g., "[4s]"). + public let showTime: Bool + /// The flag indicating whether to ignore small size values (less than 1 MB). For example, this may help to avoid reaching 100% after downloading metadata before downloading content. + public let ignoreSmallSize: Bool + /// The initial total tasks of the progress bar. + let initialTotalTasks: Int? + /// The initial total size of the progress bar. + let initialTotalSize: Int64? + /// The initial total items of the progress bar. + let initialTotalItems: Int? + /// The width of the progress bar in characters. + public let width: Int + /// The theme of the progress bar. + public let theme: ProgressTheme + /// The flag indicating whether to clear the progress bar before resetting the cursor. + public let clearOnFinish: Bool + /// The flag indicating whether to update the progress bar. + public let disableProgressUpdates: Bool + /// Creates a new instance of `ProgressConfig`. + /// - Parameters: + /// - terminal: The file handle for progress updates. The default value is `FileHandle.standardError`. + /// - description: The initial description of the progress bar. The default value is `""`. + /// - subDescription: The initial additional description of the progress bar. The default value is `""`. + /// - itemsName: The initial items name. The default value is `"it"`. + /// - showSpinner: A flag indicating whether to show a spinner. The default value is `true`. + /// - showTasks: A flag indicating whether to show tasks and total tasks. The default value is `false`. + /// - showDescription: A flag indicating whether to show the description. The default value is `true`. + /// - showPercent: A flag indicating whether to show a percentage. The default value is `true`. + /// - showProgressBar: A flag indicating whether to show a progress bar. The default value is `false`. + /// - showItems: A flag indicating whether to show items and a total items. The default value is `false`. + /// - showSize: A flag indicating whether to show a size and a total size. The default value is `true`. + /// - showSpeed: A flag indicating whether to show a speed. The default value is `true`. + /// - showTime: A flag indicating whether to show the elapsed time. The default value is `true`. + /// - ignoreSmallSize: A flag indicating whether to ignore small size values. The default value is `false`. + /// - totalTasks: The initial total tasks of the progress bar. The default value is `nil`. + /// - totalItems: The initial total items of the progress bar. The default value is `nil`. + /// - totalSize: The initial total size of the progress bar. The default value is `nil`. + /// - width: The width of the progress bar in characters. The default value is `120`. + /// - theme: The theme of the progress bar. The default value is `nil`. + /// - clearOnFinish: The flag indicating whether to clear the progress bar before resetting the cursor. The default is `true`. + /// - disableProgressUpdates: The flag indicating whether to update the progress bar. The default is `false`. + public init( + terminal: FileHandle = .standardError, + description: String = "", + subDescription: String = "", + itemsName: String = "it", + showSpinner: Bool = true, + showTasks: Bool = false, + showDescription: Bool = true, + showPercent: Bool = true, + showProgressBar: Bool = false, + showItems: Bool = false, + showSize: Bool = true, + showSpeed: Bool = true, + showTime: Bool = true, + ignoreSmallSize: Bool = false, + totalTasks: Int? = nil, + totalItems: Int? = nil, + totalSize: Int64? = nil, + width: Int = 120, + theme: ProgressTheme? = nil, + clearOnFinish: Bool = true, + disableProgressUpdates: Bool = false + ) throws { + if let totalTasks { + guard totalTasks > 0 else { + throw Error.invalid("totalTasks must be greater than zero") + } + } + if let totalItems { + guard totalItems > 0 else { + throw Error.invalid("totalItems must be greater than zero") + } + } + if let totalSize { + guard totalSize > 0 else { + throw Error.invalid("totalSize must be greater than zero") + } + } + + self.terminal = terminal + self.initialDescription = description + self.initialSubDescription = subDescription + self.initialItemsName = itemsName + + self.showSpinner = showSpinner + self.showTasks = showTasks + self.showDescription = showDescription + self.showPercent = showPercent + self.showProgressBar = showProgressBar + self.showItems = showItems + self.showSize = showSize + self.showSpeed = showSpeed + self.showTime = showTime + + self.ignoreSmallSize = ignoreSmallSize + self.initialTotalTasks = totalTasks + self.initialTotalItems = totalItems + self.initialTotalSize = totalSize + + self.width = width + self.theme = theme ?? DefaultProgressTheme() + self.clearOnFinish = clearOnFinish + self.disableProgressUpdates = disableProgressUpdates + } +} + +extension ProgressConfig { + /// An enumeration of errors that can occur when creating a `ProgressConfig`. + public enum Error: Swift.Error, CustomStringConvertible { + case invalid(String) + + /// The description of the error. + public var description: String { + switch self { + case .invalid(let reason): + return "failed to validate config (\(reason))" + } + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressTaskCoordinator.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressTaskCoordinator.swift new file mode 100644 index 00000000..ae09ca77 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressTaskCoordinator.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +/// A type that represents a task whose progress is being monitored. +public struct ProgressTask: Sendable, Equatable { + private var id = UUID() + private var coordinator: ProgressTaskCoordinator + + init(manager: ProgressTaskCoordinator) { + self.coordinator = manager + } + + static public func == (lhs: ProgressTask, rhs: ProgressTask) -> Bool { + lhs.id == rhs.id + } + + /// Returns `true` if this task is the currently active task, `false` otherwise. + public func isCurrent() async -> Bool { + guard let currentTask = await coordinator.currentTask else { + return false + } + return currentTask == self + } +} + +/// A type that coordinates progress tasks to ignore updates from completed tasks. +public actor ProgressTaskCoordinator { + var currentTask: ProgressTask? + + /// Creates an instance of `ProgressTaskCoordinator`. + public init() {} + + /// Returns a new task that should be monitored for progress updates. + public func startTask() -> ProgressTask { + let newTask = ProgressTask(manager: self) + currentTask = newTask + return newTask + } + + /// Performs cleanup when the monitored tasks complete. + public func finish() { + currentTask = nil + } + + /// Returns a handler that updates the progress of a given task. + /// - Parameters: + /// - task: The task whose progress is being updated. + /// - progressUpdate: The handler to invoke when progress updates are received. + public static func handler(for task: ProgressTask, from progressUpdate: @escaping ProgressUpdateHandler) -> ProgressUpdateHandler { + { events in + // Ignore updates from completed tasks. + if await task.isCurrent() { + await progressUpdate(events) + } + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressTheme.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressTheme.swift new file mode 100644 index 00000000..44b5bc37 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressTheme.swift @@ -0,0 +1,37 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +/// A theme for progress bar. +public protocol ProgressTheme: Sendable { + /// The icons used to represent a spinner. + var spinner: [String] { get } + /// The icon used to represent a progress bar. + var bar: String { get } + /// The icon used to indicate that a progress bar finished. + var done: String { get } +} + +public struct DefaultProgressTheme: ProgressTheme { + public let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + public let bar = "█" + public let done = "✔" +} + +extension ProgressTheme { + func getSpinnerIcon(_ iteration: Int) -> String { + spinner[iteration % spinner.count] + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressUpdate.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressUpdate.swift new file mode 100644 index 00000000..cf92db4e --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressUpdate.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +public enum ProgressUpdateEvent: Sendable { + case setDescription(String) + case setSubDescription(String) + case setItemsName(String) + case addTasks(Int) + case setTasks(Int) + case addTotalTasks(Int) + case setTotalTasks(Int) + case addItems(Int) + case setItems(Int) + case addTotalItems(Int) + case setTotalItems(Int) + case addSize(Int64) + case setSize(Int64) + case addTotalSize(Int64) + case setTotalSize(Int64) + case custom(String) +} + +public typealias ProgressUpdateHandler = @Sendable (_ events: [ProgressUpdateEvent]) async -> Void + +public protocol ProgressAdapter { + associatedtype T + static func handler(from progressUpdate: ProgressUpdateHandler?) -> (@Sendable ([T]) async -> Void)? +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/StandardError.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/StandardError.swift new file mode 100644 index 00000000..9295f862 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/StandardError.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +struct StandardError { + func write(_ string: String) { + if let data = string.data(using: .utf8) { + FileHandle.standardError.write(data) + } + } +} diff --git a/examples/sandboxy/sandboxy.entitlements b/examples/sandboxy/sandboxy.entitlements new file mode 100644 index 00000000..d7d0d6e8 --- /dev/null +++ b/examples/sandboxy/sandboxy.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.virtualization + + +