SKILL.md
---
name: kit-author
description: Author Docker Sandboxes kits (agents and mixins) — spec.yaml schema, full lifecycle from sourcing through composition, injection, and runtime, plus distribution and TCK testing.
globs:
- "**/spec.yaml"
- "**/spec.yml"
- "spec/**"
- "tck/**"
---
# Kit Author Skill
How to design, write, validate, and distribute kit artifacts (`kind: sandbox` and `kind: mixin`) for Docker Sandboxes. Kits are declarative — a `spec.yaml` plus an optional `files/` tree — and the `sbx` engine translates them into container customizations at sandbox creation or `kit add` time.
Use this skill when:
- Writing a new kit (mixin or agent) from scratch
- Editing an existing kit in this repository
- Debugging why a kit's commands, files, network rules, or credentials are not taking effect
- Packaging, publishing, or consuming kits from OCI or git sources
- Reviewing kit PRs in this repository
## References
- **Official docs**: <https://docs.docker.com/ai/sandboxes/customize/kits/>
- **Spec package** — types, validation, normalization — see [`spec/`](../../spec/) in this repository
- **TCK package** — test compatibility kit — see [`tck/`](../../tck/) in this repository
- **Repository contributor guide** — see [`CONTRIBUTING.md`](../../CONTRIBUTING.md) and [`README.md`](../../README.md)
## Topics
Primary topics describe the **v2** spec form (`schemaVersion: "2"`):
- [Spec anatomy](topics/spec-anatomy.md) — `spec.yaml` top-level fields (`mixins`, `licenses`, `extends`, `locked`, `args`) and every section (`sandbox` with `image:`/`build:` + `entrypoint`/`command`, `agentInstructions` with `filename`/`content`, `credentials[]` with `apiKey`/`oauth` and the `scheme` sugar, `permissions.network`, `ports`, `environment`, `setup` with `install`/`startup`/`files`, `volumes`, `files/`).
- [Lifecycle](topics/lifecycle.md) — Sourcing → load (schemaVersion-forked decode) → normalize → validate → extends → compose → configure → hooks → container → runtime. What happens at each stage as observed by the kit author.
- [Composition](topics/composition.md) — `extends:` inheritance vs `--kit` composition. Merge strategies per section, conflict rules, what "last wins" means.
- [Authoring guide](topics/authoring.md) — Step-by-step recipes for a minimal mixin and a full sandbox kit. Where to put files. When to use `files/` vs `setup.files`.
- [Bindings](topics/bindings.md) — The user-side `~/.config/sbx/credentials.yaml` file: how kits and users split the credential contract.
- [Image publishing](topics/image-publishing.md) — For a `kind: sandbox` kit whose image this repo builds: drop a `Dockerfile` at the kit root and name it `docker.io/sbx/<kit>-image:latest`; CI discovers it, no workflow edit. Why `sandbox.build:` is not the answer yet, and the pre-publish window where the TCK cannot pull the image.
- [Distribution](topics/distribution.md) — Local dir, OCI digests, git commit-SHA references. Strict pinning rule. Schema-version compatibility (v2 is a breaking grammar). `sbx kit push/pull/inspect/validate/delete`.
- [Testing](topics/testing.md) — TCK suite, e2e under `deny-all` (mandatory locally — CI's e2e legs are skipped for fork PRs), manual `sbx kit add` verification, proving allow-list enforcement.
- [Pitfalls](topics/pitfalls.md) — Surprises seen in practice: install-completed is exit-code only, `setup.startup` runs on **every** container start (idempotency required), `kit add` cannot apply immutable settings, `setup.install` idempotency + duplication footguns + `SBX_CRED_<SERVICE>_MODE` contract, inject/binding domain intersection.
Legacy reference:
- [v1 → v2 migration](topics/v1-migration.md) — Every v1 surface, its v2 equivalent, the `migrate-v1-to-v2.go` script's coverage, and what to migrate by hand. The loader forks on `schemaVersion`; v2 is a clean grammar with no shims, while v1 keeps loading with deprecation warnings on `Artifact.Warnings` until the Phase 6 cutover.
topics/authoring.md
# Authoring Guide
Step-by-step recipes for building a kit. Pick the section that matches what you're doing.
All recipes use the v2 spec form. If you're updating an existing v1 kit, run the migrate script first — see [`v1-migration.md`](v1-migration.md).
## Recipe: minimal mixin
A mixin adds capabilities to an existing agent. The smallest useful mixin installs one thing.
```
mcp-postgres/
└── spec.yaml
```
```yaml
schemaVersion: "2"
kind: mixin
name: mcp-postgres
displayName: PostgreSQL MCP Server
description: "Adds PostgreSQL access via MCP"
setup:
install:
- command: "npm install -g @mcp/postgres-server"
description: Install PostgreSQL MCP server
```
Use it:
```bash
sbx run claude --kit ./mcp-postgres/ .
```
If your install command needs network access beyond what the base agent allows, add `permissions.network.allow`:
```yaml
permissions:
network:
allow:
- registry.npmjs.org
- "*.npmjs.org"
```
See the repository [README](../../../README.md#declare-every-domain-your-kit-needs) for a full walkthrough of probing under a `deny-all` policy to discover exactly which domains your install hooks touch.
## Recipe: mixin with a config file
If the file content is static:
```
mcp-postgres/
├── spec.yaml
└── files/
└── workspace/
└── .mcp/postgres.json
```
If the content needs `${WORKDIR}` substitution or must not overwrite an existing file on a persistent volume, use `setup.files`:
```yaml
setup:
files:
- path: /home/agent/.copilot/config.json
content: '{"trusted_folders": ["${WORKDIR}"]}'
onlyIfMissing: true
```
Decision rule:
- **Static file under home** → `files/home/<path>`.
- **Static file under workspace** → `files/workspace/<path>`. Safe with `sbx run --clone`: the kit's hook fires after the in-container `git clone` populates the workspace, so the file lands in the cloned working copy.
- **Dynamic content** (needs `${WORKDIR}` substitution in *content*) **or** **write-once semantics** (`onlyIfMissing`) → `setup.files`.
`setup.files` cannot target a path under the in-container clone directory — under `--clone` the CLI rejects such kits up front and points you here. If you want a static file at the workspace root, use `files/workspace/`.
Heads-up on overlay: a `files/workspace/<path>` whose relative path matches a real file in the user's repo will silently overwrite that file on **every** sandbox start. Overlay is the intended semantic, but if it isn't what you want, name the file differently or move it under `files/home/<path>`. See [`pitfalls.md`](pitfalls.md).
## Recipe: mixin adding a credential + network
```yaml
schemaVersion: "2"
kind: mixin
name: github-mixin
credentials:
- service: github
description: "GitHub Personal Access Token"
apiKey:
name: GITHUB_TOKEN
proxyManaged: true
inject:
- domain: api.github.com
scheme: bearer
- domain: raw.githubusercontent.com
scheme: bearer
- domain: github.com # HTTPS git clone over HTTP Basic
scheme: basic
username: x-access-token
permissions:
network:
allow:
- "*.github.com"
- "*.githubusercontent.com"
```
The proxy picks up the credential at request time and injects the `Authorization` header. The container never sees the token — the engine sets `GITHUB_TOKEN` to the literal `proxy-managed` inside the container, and the sentinel-swap proxy replaces it on outbound requests.
The user is expected to declare where their GitHub token lives in `~/.config/sbx/credentials.yaml` — see [`bindings.md`](bindings.md). The engine refuses to inject a credential if the user's bindings don't include the kit's inject domains.
## Recipe: mixin that exposes a service port
```yaml
schemaVersion: "2"
kind: mixin
name: code-server
displayName: code-server
ports:
- container: 8080
protocol: tcp
name: web
permissions:
network:
allow:
- openvsx.eclipsecontent.org # extension marketplace
- "*.vsassets.io"
setup:
install:
- command: "curl -fsSL https://code-server.dev/install.sh | sh"
description: Install code-server
startup:
- command: ["code-server", "--bind-addr=0.0.0.0:8080"]
user: "1000"
background: true
description: Run code-server
```
The kit declares the in-container port; `sbx ports <sandbox>` lists the ephemeral host port assigned at sandbox start.
## Recipe: full sandbox kit
Use this when you're shipping a custom agent via `--kit`.
```
my-agent/
├── spec.yaml
├── testdata/
│ └── tck.yaml
└── files/
└── home/
└── .my-agent/config.json
```
```yaml
schemaVersion: "2"
kind: sandbox
name: myagent
displayName: My Agent
sandbox:
image: docker/sandbox-templates:myagent
entrypoint: [myagent]
agentInstructions:
filename: MYAGENT.md
credentials:
- service: myservice
apiKey:
name: MYSERVICE_API_KEY
inject:
- domain: api.myservice.com
scheme: bearer
permissions:
network:
allow:
- "*.myservice.com"
environment:
variables:
IS_SANDBOX: "1"
setup:
install:
- command: "curl -fsSL https://myservice.com/install.sh | bash"
description: Install my-agent
```
For user-supplied sandbox kits via `--kit`, remember `Embedded=false`, so install commands **will** run on the base image — make them idempotent.
### `testdata/tck.yaml`
`kind: sandbox` kits **should** include a `testdata/tck.yaml` file to opt in to the `prompt` subtest of `TestE2EKit`, which sends a non-interactive prompt to the agent and verifies it responds. The file is optional — the subtest is simply absent when the file is missing or `promptArgs` is empty.
```yaml
# my-agent/testdata/tck.yaml
# Required: flag(s) the binary accepts before the prompt message.
promptArgs: ["-p"]
# Required for kits with async background installation: path of the sentinel
# file written when installation completes. TestE2ERunAgent polls for it
# before sending the prompt.
readyFile: "/home/agent/.my-agent-installed"
# Optional: only needed when the sandbox entrypoint is a wrapper script whose
# name differs from the real binary. Omit for kits where entrypoint[0]
# already names the binary (amp, crush, junie, nanobot, opencode, pi, etc.).
binary: "my-agent"
```
The test skips if `tck.yaml` is absent or `promptArgs` is empty — omitting it silently opts out.
See [`testing.md`](testing.md#testdatatckyaml----kit-specific-e2e-config) for the full schema, binary resolution rules, and the reference table of known values per agent.
## When you need a configure hook
Configure hooks are Go functions registered with the engine. They are an **engine-internal extension point** — built-in agents use them for things YAML cannot express (e.g., conditional credential injection based on host state). A user-supplied kit **cannot ship a hook**: there is no mechanism to inject Go code into the `sbx` binary at runtime.
For the common OAuth case, **don't write Go** — set the `oauth` sub-block under a `credentials[]` entry in `spec.yaml` and the engine generates the equivalent for you. That covers the majority of "I need conditional credential delivery" cases.
If you find yourself wanting a true hook (e.g., reading host state at run time), file an issue describing the use case — most needs are solvable declaratively, and the engine maintainers can advise on the right shape.
## Iteration loop
Fast feedback during authoring:
```bash
# Validate the spec without running anything
sbx kit validate ./my-kit/
# Inspect normalized canonical form (sugar resolved, defaults filled)
sbx kit inspect ./my-kit/ --output json
# Apply to a running sandbox without recreating it
sbx kit add my-sandbox ./my-kit/
# Or end-to-end
sbx run claude --kit ./my-kit/ --name probe .
sbx exec probe -- <verify commands>
sbx rm probe
```
For changes that affect immutable container settings (privileged, volumes, tmpfs), `sbx kit add` will warn and skip them — you must recreate the sandbox to test those.
## Before opening a PR
Your kit's `README.md` **Usage** section needs three ways to run it, not just
the git-URL and local-path forms shown in the recipes above: lead with the
published-OCI-artifact form, `docker.io/sbx/<kit>-kit:latest` — publishing is
automatic and needs no opt-in (see [`PUBLISHING.md`](../../../PUBLISHING.md)),
so the artifact exists the moment this PR merges to `main`, which makes it the
primary way to consume the kit — then the git-URL form, then the local-path
form. Match the invocation your kit already uses (agent name as the argument
for `kind: sandbox`, `sbx run <agent> --kit <ref> [path]` for `kind: mixin`) —
only the `--kit` value changes. See any existing kit's README for the
pattern, and [CONTRIBUTING.md](../../../CONTRIBUTING.md#per-kit-readme) for the full
per-kit README convention.
CI on the repo skips the e2e legs (`e2e-release`, `e2e-nightly`) for fork PRs (Docker Hub secrets aren't exposed to fork-triggered workflows). Run e2e locally before you ask for review:
```bash
cd my-kit && ../scripts/test-kit-e2e.sh
```
The script handles the dance for you — it scopes everything to `--app-name sbx-kits-contrib-tck` (the same app-name the harness uses internally) and applies the `deny-all` default policy CI uses, so the only `permissions.network.allow` entries that survive are the ones you actually need. Your main sbx state is untouched.
One-time per machine: `sbx --app-name sbx-kits-contrib-tck login`.
When it fails, read what the proxy blocked, add the host to `permissions.network.allow`, re-run:
```bash
APP=sbx-kits-contrib-tck
sbx --app-name $APP ls
sbx --app-name $APP policy log tck-e2e-<short-uuid>
```
Full breakdown and the list of commonly-missed hosts is in [`testing.md`](testing.md#running-e2e).
## Migrating an existing v1 kit
```bash
go run scripts/migrate-v1-to-v2.go ./my-kit
sbx kit validate ./my-kit/
sbx kit inspect ./my-kit/ --output json | jq '.warnings' # should be empty
```
The script runs your v1 spec through the same normalize pass the engine uses and re-emits a canonical v2 spec (renames, credential/network/oauth consolidation, `caps.network` → `permissions.network`, `commands` → `setup`, the entrypoint split). Anything it can't express — e.g. a `settings:` block — is called out in [`v1-migration.md`](v1-migration.md) with a manual recipe.
## Style notes
- One concern per mixin. Easier to compose, easier to debug.
- Use `description:` on every install/startup command. It shows up in progress output and PR review diffs.
- Pin install URLs to a version or commit when possible — kits are cached in users' workflows.
- `permissions.network.allow` should be the minimum that makes the install succeed. The proxy denies anything else; over-broad allowlists weaken the security posture.
- Declare `sandbox.resources` only when the kit's behaviour genuinely depends on it (e.g. a GPU-bound agent). Unset means "no constraint from the spec", which is almost always the right default.
- Use lowercase-kebab for `credentials[].service` IDs — `anthropic`, `openai`, `github`, `my-service`.
topics/bindings.md
# Credential Bindings (`~/.config/sbx/credentials.yaml`)
A **kit** declares what it needs:
```yaml
# kit spec.yaml
credentials:
- service: anthropic
apiKey:
name: ANTHROPIC_API_KEY
inject:
- domain: api.anthropic.com
header: x-api-key
format: "%s" # or: scheme: bearer / scheme: basic (v2 sugar for header+format)
```
A **user** declares where it lives on their host:
```yaml
# ~/.config/sbx/credentials.yaml
bindings:
anthropic:
discovery:
- env: [ANTHROPIC_API_KEY]
- file:
path: "~/.anthropic/api_key.txt"
allowedDomains:
- api.anthropic.com
- "*.anthropic.com"
```
The split keeps the kit minimal and lets each user point `sbx` at whatever credential storage they already use.
## Configuration file location
| Platform | Path |
|---|---|
| macOS / Linux | `~/.config/sbx/credentials.yaml` |
| Unix with `$XDG_CONFIG_HOME` set | `$XDG_CONFIG_HOME/sbx/credentials.yaml` |
| Windows | `%APPDATA%\sbx\credentials.yaml` |
This file is **user-controlled** and MUST NOT be modified by kits or automated tooling without explicit user consent.
## File shape
```yaml
bindings:
<service-id>:
discovery: # required — list (may be empty)
- env: [VAR1, VAR2, …] # priority order within the entry
- file:
path: "<path>" # ~ expands to $HOME
parser: "" # see Parsers below
allowedDomains: # required — domains the engine may inject this credential into
- <domain>
- <domain>
remembered: # P2 — workspace path → variant association
"/Users/me/work/org-a":
github: github@work-org-a
"/Users/me/personal/oss":
github: github
```
- `<service-id>` matches the kit's `credentials[].service`. For named variants (P2), the form is `<service>@<variant>` — e.g. `github@work-org-a`.
- `discovery:` is a required field. See "Bindings without an env/file source" later in this page for the shape used when the credential value lives in the secret store rather than at a discoverable host location.
- Each `discovery` entry has **exactly one** of `env` or `file`.
## Parsers
`DiscoveryFile.Parser` selects how to extract the value from the file:
| Parser | Behaviour |
|---|---|
| `""` or `"raw"` | Full file contents, trailing whitespace trimmed |
| `"json:<dotted.path>"` | Walks the dotted path through the JSON; the leaf must be a string |
Misses (key not present, non-string leaf) cause the resolver to skip the entry and try the next one. Malformed parser specs (e.g. `"json:"` with no path) surface as a logged warning.
## Named variants (P2)
When a user has multiple credentials for the same service — e.g. a personal GitHub token and a work-org one — they're declared as named variants:
```yaml
bindings:
github: # the default
discovery:
- env: [GITHUB_TOKEN, GH_TOKEN]
allowedDomains:
- api.github.com
- github.com
github@work-org-a: # P2: named variant
discovery:
- env: [ORG_A_GITHUB_TOKEN]
allowedDomains:
- api.github.com
- github.com
github@work-org-b:
discovery:
- env: [ORG_B_GITHUB_TOKEN]
allowedDomains:
- api.github.com
```
The kit always references `service: github`; the variant is selected at runtime via remembered associations or the CLI override.
## Resolution order
Two stages, both ordered. **Stage 1** selects which binding entry applies to a credential lookup; **stage 2** resolves the actual value within that binding.
**Stage 1 — Which binding?**
1. **CLI override** (P2): `sbx run --credential X=variant ...` selects the variant explicitly for this run.
2. **Workspace-remembered** (P2): `remembered[<workspace-path>][X]` resolves a recorded variant association.
3. **Default binding**: `bindings[X]`.
4. **No binding, multiple variants exist** → prompt the user to select a variant (and offer to remember the choice for this workspace).
5. **No binding at all** → prompt the user to set one up interactively.
**Stage 2 — Where's the value?**
Once a binding is chosen, the engine looks for the actual credential value in this order:
1. **Secret store, sandbox-scoped** — `sbx secret get <service>` scoped to the current sandbox. Use this when a credential should only exist for one sandbox.
2. **Secret store, global** — `sbx secret get <service>` in global scope. Use this when the credential should apply to every sandbox on the host.
3. **`discovery[]`** — the binding's discovery array, walked in order. The first entry that yields a value wins.
The secret-store layers fire **before** discovery: an env-var binding doesn't get consulted if `sbx secret set` has already stored a value for that service.
### Bindings without an env/file source
When the credential value is already in the secret store (set via `sbx secret set <service> ...`), use an empty-list discovery to declare that fact while keeping the binding well-formed:
```yaml
bindings:
myservice:
discovery: []
allowedDomains:
- api.myservice.com
```
Stage-2 resolution finds the value in the sandbox-scoped or global secret store; the empty `discovery` list just says "no env vars or files to consult — the secret store is the source of truth." The binding's job is then to declare the `allowedDomains`.
## Domain intersection
The engine **only** injects a credential into a domain that appears in **both**:
- the kit's `credentials[].apiKey.inject[].domain`, **and**
- the user's `bindings[<service>].allowedDomains`.
```
Kit requests: [api.github.com, evil.com]
User allows: [api.github.com, github.com]
Result: [api.github.com] ← credential injected here only
```
Domains the kit requests but the user hasn't allowed trigger the **domain-expansion approval prompt** at sandbox-create time (next section).
## Approval flows
The engine drives three interactive prompts when something is missing or new:
**First-time credential setup** — no binding exists for a service the kit needs:
```
The kit 'claude' requires a credential for 'github'.
The credential will be sent to: api.github.com, github.com
Approve? [Y/n]: Y
Where do you want to source the 'github' credential from?
1. Environment variable
2. File on disk
3. Done adding sources
> 1
Environment variable name [GITHUB_TOKEN]: GITHUB_TOKEN
Add another source as fallback? [y/N]: n
✓ Binding saved to ~/.config/sbx/credentials.yaml
```
**Binding selection** (P2) — multiple variants exist for the service:
```
You have multiple 'github' bindings:
1. github (env: GITHUB_TOKEN)
2. github@work-org-a (env: ORG_A_GITHUB_TOKEN)
3. github@work-org-b (env: ORG_B_GITHUB_TOKEN)
Select [1]: 2
Remember this choice for workspace /Users/me/work/org-a? [Y/n]: Y
✓ Saved to ~/.config/sbx/credentials.yaml
```
The "remember this choice" step writes a `remembered[<workspace-path>][github] = github@work-org-a` entry.
**Domain-expansion approval** — a kit or mixin requests injection into a domain the user hasn't approved yet:
```
The mixin 'analytics-tools' wants to send the 'github' credential to: analytics.example.com
Currently approved domains for 'github':
- api.github.com
- github.com
Trust 'analytics.example.com'? [y/N]: N
✗ Domain not approved. The 'github' credential will not be sent to analytics.example.com.
```
A declined domain doesn't fail sandbox creation — the credential just isn't injected into that domain, and the request proceeds unauthenticated.
## CLI shortcuts
For the common case, you don't edit YAML by hand:
```bash
# Set the default binding for a service
sbx secret set anthropic <token>
# Set a named variant (P2)
sbx secret set -g github@work-org-a <token>
# Override the binding for a single run (P2)
sbx run --credential github=work-org-a --kit ./my-kit/ shell .
```
Direct YAML edits are fine for power users — the file is the authoritative state.
## Security properties
| Threat | Mitigation |
|---|---|
| Kit reads arbitrary env vars | Kit cannot declare discovery — user controls which env vars are read |
| Kit reads arbitrary files | Kit cannot declare file paths — user controls which files are read |
| Kit injects to malicious domain | User's `allowedDomains` constrains injection; new domains require approval |
| Kit update silently adds a domain | Domain expansion triggers approval prompt at sandbox-create time |
## Why split kit and user concerns?
Pre-v2, kits declared credential discovery in their own `spec.yaml`:
```yaml
# Pre-v2 — kit author guessed where the user's credential lived
credentials:
sources:
anthropic:
env: [ANTHROPIC_API_KEY]
file: { path: "~/.claude/settings.json", parser: "json:primaryApiKey" }
```
The kit author had to enumerate every reasonable host location. Users with non-standard setups (corporate password managers, hardware keys, vault-backed env, …) had to hack their `~/` to match.
v2 inverts the contract: kits declare **what** (service identity, injection target), users declare **where**. New host setups don't require kit changes.
topics/composition.md
# Composition & Inheritance
Three mechanisms — don't confuse them.
| | `extends:` | `mixins:` | `--kit` |
|---|---|---|---|
| Direction | Author-time inheritance | Author-time composition | Runtime composition |
| Cardinality | Single parent | N mixins | N kits |
| Where declared | Sandbox kit's `spec.yaml` | Sandbox kit's `spec.yaml` | CLI flag |
| Resolution | Opt-in (callers invoke the resolver) | Automatic on load | Automatic on `sbx run` |
| Strategy | Additive per field type | Additive per field type | Additive per field type |
When all three are present, the resolution order is: extends chain → kit's own fields → declared `mixins:` (in order) → runtime `--kit` (in order). All apply the same additive merge rules.
## `extends:` (single-parent inheritance)
```yaml
# my-claude.yaml
schemaVersion: "2"
kind: sandbox
name: my-claude
extends: claude
# any field set here replaces the parent's value
```
- Resolved by an explicit `ResolveExtends(artifact, resolver)` call — **callers opt in**. Loaders do not auto-resolve.
- The default resolver looks up names like `claude` from the built-in agent set. Custom resolvers can fetch from anywhere.
- Walks the chain up to **5 levels** with circular-reference detection.
- Parent kit MUST be `kind: sandbox` — mixins cannot use `extends:`.
- Merge is **additive**: child inherits the parent's configuration and adds to it. The rules per field type:
| Field type | Strategy | On conflict |
|---|---|---|
| Scalars | Child wins if set | Child overrides parent |
| Maps | Recursive merge | Child wins for conflicting keys |
| Named arrays (identity key, e.g. `credentials[].service`, `volumes[].path`) | Union by identity | Matching key: **error**; new key: appended |
| Primitive arrays (e.g. `permissions.network.allow`) | Set union | Deduplicated, parent order preserved first |
| Setup lists (`install`, `startup`, `files`) | Concatenate | Parent first, then child |
| Files | Overlay | Child overrides at same path |
| `security.privileged` | OR semantics | Any `true` → `true` |
When to use: forking a built-in agent with a small tweak — adding a credential, a domain, or an install step that builds on the parent's. The additive merge means you keep everything the parent had and layer on top.
When **not** to use: stacking multiple independent capabilities. That's composition (`--kit`).
## `mixins:` (author-time composition)
```yaml
# in the sandbox kit's spec.yaml
schemaVersion: "2"
kind: sandbox
name: claude
extends: shell
mixins:
- my-org-tools # bare name → built-in mixin
- "git+https://github.com/org/repo.git#ref=<sha>&dir=auditor"
- "oci://ghcr.io/org/mcp-postgres@sha256:<digest>"
```
- Sandbox kits only. `kind: mixin` artifacts cannot declare `mixins:` (or `extends:`).
- Same reference formats as `extends:` and the same pinning rule (Git: 40-hex SHA; OCI: digest).
- Resolved automatically on load — no opt-in needed.
- Applied in declaration order, after the `extends:` chain and the kit's own fields.
`mixins:` is the right place for *intrinsic* composition — bundles a kit's author chose to depend on. `--kit` (below) is for *extrinsic* composition — bundles the user adds at runtime.
## `--kit` (runtime composition)
```bash
sbx run claude --kit ./mcp-postgres/ --kit ./rust-toolchain/ --kit "oci://ghcr.io/org/auditor@sha256:<digest>" .
```
Pipeline:
1. Each `--kit` ref is resolved (local dir, OCI digest, git commit SHA, or built-in name).
2. The list is split into exactly one `kind: sandbox` and N `kind: mixin`. Two sandboxes → error.
3. Every artifact's `name` must be unique across the base sandbox + all mixins. Two kits sharing a name — including a mixin whose name matches the base sandbox — fail with `compose: duplicate kit name "X"`. No partial state is created.
4. Artifacts are merged in `--kit` order on top of the base sandbox.
### Merge rules (per section)
| Section | Strategy | Conflict |
|---|---|---|
| `permissions.network.allow` | Append | Always succeeds |
| `permissions.network.deny` | Append | Always succeeds; deny wins at request time |
| `credentials[]` | Union by `service` | Same service in two kits → **error** |
| `environment.variables` | Union | Last wins (later `--kit` overrides earlier) |
| `setup.install` | Concatenate in order | — |
| `setup.startup` | Concatenate in order | — |
| `setup.files` | Concatenate in order | — |
| `files` | Overlay by `target:relativePath` | Later kits override earlier |
| `ports` | Append | Two kits asking for the same container port get two host bindings (different ephemeral host ports) |
| `volumes` (incl. tmpfs entries) | Union | Last wins per `path` |
| `manifest.security` | Last wins (privileged is OR-merged in spirit) | — |
### `setup.install` runs for every kit
`setup.install` runs **once, synchronously, before the agent launches** (at sandbox creation) for every kit — built-in or user-supplied. Built-in agents have their binary baked into the template image, so they don't need `setup.install` to install the binary; instead, built-in kits use it for pre-launch setup (e.g. seeding a credential-gated settings file via `SBX_CRED_<SERVICE>_MODE`).
Implication: if you fork a built-in agent as a user-supplied `kind: sandbox` kit, any install commands you copied will run. If the base image already provides the binary, guard with `command -v <binary> || <install>` to avoid reinstalling redundantly. See [Pitfalls §5](pitfalls.md#5-setupinstall-footguns) for the full footgun checklist.
### What "last wins" actually means
For `environment.variables`, later kits silently overwrite earlier ones — useful for letting downstream kits override defaults.
For `credentials[]` (per `service`), "same key" is a **hard error**. Two kits both declaring `credentials[].service: anthropic` with different shapes is rejected at compose time.
### Order matters
`--kit A --kit B` vs `--kit B --kit A`:
- Different startup-command and install-command execution order
- Different `environment.variables` winner on conflict
- Different `files` overlay winner on path conflict
If you author a mixin that should run **before** another, document it. If it must run **after**, document that too.
## Practical patterns
- **Add a tool to any agent** — mixin with `setup.install` only. `sbx run claude --kit ./rust-toolchain/`.
- **Add a credential source** — mixin with one `credentials[]` entry.
- **Add network access** — mixin with `permissions.network.allow` only.
- **Inject a config file** — mixin with `files/home/...` or `setup.files`.
- **Expose a service port** — mixin with `ports`.
- **Fork a built-in agent** — `kind: sandbox`, `extends: claude`, change what you need.
- **Combine all of the above** — one mixin per concern, then `--kit a --kit b --kit c`.
Avoid putting unrelated concerns in one mixin. Composition is cheap; clarity isn't.
topics/distribution.md
# Distribution
Four ways a kit can be delivered. The engine picks the right loader from the reference shape.
| Source | Reference example |
|---|---|
| Embedded built-in agent | `claude` (by name) |
| Local directory | `./mcp-postgres/` or `file://./mcp-postgres` (explicit scheme) |
| Git repo | `git+https://github.com/org/repo.git#ref=<40-hex-commit-sha>&dir=mcp-postgres` |
| OCI artifact | `oci://ghcr.io/org/mcp-postgres@sha256:<digest>` |
## Reference pinning (security)
The spec **requires** immutable references for remote kits:
- **Git refs MUST be a full commit SHA — 40 hex characters.** Branch names and tags (including semver tags like `v1.2.3`) are **rejected**. Tags are mutable and can be retagged.
- **OCI refs MUST use a digest** (`@sha256:...`). The `:latest` tag and any other tag is **rejected**.
```
# Git
git+https://github.com/org/repo.git#ref=a1b2c3d4e5f67890abcdef1234567890abcdef12 # ✓ 40-hex SHA
git+https://github.com/org/repo.git#ref=v1.2.3 # ✗ tag
git+https://github.com/org/repo.git#ref=main # ✗ branch
# OCI
oci://ghcr.io/org/my-kit@sha256:abc... # ✓ digest
oci://ghcr.io/org/my-kit:1.0 # ✗ tag
oci://ghcr.io/org/my-kit:latest # ✗ tag
```
This applies everywhere a remote reference appears: `extends:`, `mixins:`, and `--kit` flags. Local directory and embedded built-in references are unaffected.
## Authoring → publishing flow
```bash
# 1. Develop locally
sbx kit validate ./my-kit/
sbx kit inspect ./my-kit/
# 2. Push to an OCI registry
sbx kit push ./my-kit/ ghcr.io/org/my-kit:1.0
# 3. Pull by digest (or reference directly in --kit)
sbx kit pull oci://ghcr.io/org/my-kit@sha256:<digest> ./my-kit/
sbx run claude --kit oci://ghcr.io/org/my-kit@sha256:<digest> .
```
`sbx kit push` accepts a tag for human ergonomics (humans pick `1.0`, the engine resolves to a digest), but **consumers** of the kit must always reference by digest. `sbx kit push` rewrites the spec to its **distribution form** (image pinned to digest, `sandbox.build` stripped). The source `spec.yaml` is never modified.
## Git references
URL grammar:
```
git+https://github.com/org/repo.git#ref=<40-hex-sha>&dir=<subdir>
git+ssh://git@github.com/org/repo.git#ref=<40-hex-sha>&dir=<subdir>
```
Fragments after `#` use URL-encoded `key=value` pairs:
- `ref` — full 40-character commit SHA. Branch names and tags are rejected.
- `dir` — subdirectory inside the repo containing `spec.yaml` (defaults to root).
The loader clones at the pinned SHA and reads from `dir`.
For this repository specifically, see the [README](../../../README.md#using-a-kit) for the common `git+https://github.com/docker/sbx-kits-contrib.git#dir=<kit>` form. Until the strict-pin rule is enforced everywhere by the consumer CLI, the README's examples show tag-based refs for ergonomics — but new kits should pin to SHAs.
## Schema version compatibility
A v2 spec.yaml only loads when the consumer's `sbx` bundles a spec library that understands the v2 grammar. Both older releases (predating any v2 support) **and** releases that shipped an earlier v2 draft reject the current grammar via strict decoding — the fields simply aren't in their decode struct:
```
artifact: invalid spec.yaml: yaml: unmarshal errors:
line N: field agentInstructions not found in type spec.specFileV2
line N: field permissions not found in type spec.specFileV2
line N: field setup not found in type spec.specFileV2
```
Because v2 is a breaking grammar, publish a kit as `schemaVersion: "2"` only once the `sbx` releases your consumers run understand it. If you must support older `sbx` releases, ship the `schemaVersion: "1"` form for now — the migrated catalog kits do exactly this until the v2-aware CLI ships. The `migrate-v1-to-v2` script is one-way; keep a v1 source branch if you need to publish both.
## OCI artifacts
Pushed and pulled via ORAS. The artifact media type is kit-specific; standard registries (GHCR, ECR, Docker Hub) accept them. Authentication uses your existing docker login.
`sbx kit push` produces:
- The kit artifact at `<ref>:<tag>` (e.g. `ghcr.io/org/my-kit:1.0`).
- A sibling **GC anchor image tag** at `<ref>:_kit_<tag>` (e.g. `ghcr.io/org/my-kit:_kit_1.0`) — keeps the underlying image from being garbage-collected as long as the kit tag exists.
- A digest reference (`@sha256:...`) consumers reference.
Multi-arch by default: `sbx kit push` builds for `linux/amd64,linux/arm64` unless overridden.
## Embedded built-in agents
These ship inside the `sbx` binary. `sbx` discovers them at startup. `Artifact.Embedded` is set to `true` for built-ins. Their agent binary is baked into the template image; `setup.install` still runs for built-ins and is used for pre-launch setup (e.g. seeding credential-gated settings files) rather than to install the binary.
Adding a built-in agent is an engine-side change in the `sbx` core, not something a contrib kit can do. Contrib kits ship as `--kit` references.
## CLI commands at a glance
| Command | Purpose |
|---|---|
| `sbx kit validate <ref>` | Load + validate, print errors |
| `sbx kit inspect <ref>` | Print normalized canonical form (JSON or summary) |
| `sbx kit push <dir> <oci-ref>` | Publish to OCI registry (transforms to distribution form) |
| `sbx kit pull <oci-ref> <dir>` | Fetch from OCI registry |
| `sbx kit delete <oci-ref>` | Remove a published kit tag and its sibling image tag |
| `sbx kit add <sandbox> <ref>` | Apply to a running container |
`sbx kit add` cannot apply immutable container settings (privileged, volumes, tmpfs). It warns and continues — you'd need to recreate the sandbox for those.
## Consumption patterns
```bash
# Local development
sbx run claude --kit ./local/ .
# Pinned release from git (always a full commit SHA)
sbx run claude --kit "git+https://github.com/org/repo.git#ref=a1b2c3d4e5f67890abcdef1234567890abcdef12&dir=mcp-postgres" .
# Production from OCI registry (always a digest)
sbx run claude --kit "oci://ghcr.io/org/mcp-postgres@sha256:<digest>" .
# Compose multiple
sbx run shell --kit ./agent/ --kit ./tools/ --kit "oci://ghcr.io/org/audit@sha256:<digest>" .
```
The order of `--kit` flags is the composition order. See [composition.md](composition.md) for merge rules.
## Verification before publish
```bash
# Schema and structural validation
sbx kit validate ./my-kit/
# What the engine actually sees after sugar normalization
sbx kit inspect ./my-kit/ --output json | jq
# Smoke test end-to-end
sbx run claude --kit ./my-kit/ --name probe . && \
sbx exec probe -- <expected-binary> --version && \
sbx rm probe
```
Run TCK tests for every kit before publishing — see [testing.md](testing.md).
topics/image-publishing.md
# Publishing a kit's own image
Applies only to a `kind: sandbox` kit whose image **this repository builds**.
Mixins, and sandbox kits that name an image they do not build (another kit's,
like `devin-enterprise`, or a template), build nothing and can skip this.
Full pipeline reference: [`PUBLISHING.md`](../../../PUBLISHING.md) at the repo
root. This topic is the author-facing subset — what you write, and the traps.
## What you write
Three things, no CI edit:
```
my-kit/
├── Dockerfile # build context is the kit directory
├── spec.yaml # sandbox.image: docker.io/sbx/my-kit-image:latest
└── README.image.md # the Hub "About" page for my-kit-image
```
`.github/workflows/build-and-publish-kits.yml` **discovers** every kit by
`spec.yaml` alone — Dockerfile presence is a separate, per-kit decision about
whether that kit *also* builds and publishes its own image, not about whether
the kit is discovered at all. There is no matrix to register and no per-kit
workflow.
`README.image.md` is optional in the sense that nothing fails without it, but
skipping it leaves `my-kit-image`'s Hub page blank forever: `.github/workflows/
hub-overview.yml` only syncs a base image's overview for a kit that has both a
`Dockerfile` (proof the kit actually owns that image, not just a shared
template or someone else's pre-built one) *and* a `README.image.md` to publish
— the sync step is guarded on both, precisely so a kit reusing
an agent's image (or, like `nanoclaw`, someone else's own image)
never overwrites a Hub repository it doesn't own the narrative for. This is
separate from `my-kit/README.md`, which documents the *kit* (how to run it);
`README.image.md` documents the *image* (what's installed, how it's built) —
someone who pulled `sbx/my-kit-image` directly, without going through the kit
at all, is the reader.
The image name is enforced as `<kit-dir>-image`, in the `docker.io/sbx`
namespace, at the rolling tag. `scripts/check-image-ref.sh` derives all three
from the kit directory and fails the build otherwise:
```console
./scripts/check-image-ref.sh docker.io/sbx latest
```
The `-image` suffix keeps `docker.io/sbx/<kit>-kit` free for the kit artifact
itself, which is also distributed as an OCI artifact — see
[Distribution](distribution.md).
## Choosing a base image
Two bases, both built and published by this repository. A kit that extends an
agent builds on that agent kit's image (e.g. `docker.io/sbx/claude-image:latest`)
instead of installing the agent itself. It inherits that image's engine and
`start-docker` label; leave the label alone, because overriding it to `"false"`
on an image that ships `dockerd` makes the sandbox fail to start with `docker
daemon failed to start inside the sandbox`.
| Base | What it is | `start-docker` label |
|---|---|---|
| `docker.io/sbx/shell-image:latest` | the sandbox toolchain, the `agent` user, the Docker CLI with compose and buildx; no engine | never |
| `docker.io/sbx/shell-docker-image:latest` | the shell image plus `containerd.io` and `docker-ce` | `"true"` |
Default to `docker.io/sbx/shell-image:latest`; reach for the `-docker` base only
when the kit's agent itself launches containers, not because a nearby kit's
Dockerfile does — existing kits split between the two bases, so the closest
example isn't evidence either way. Only set `LABEL
com.docker.sandboxes.start-docker="true"` on the `-docker` base (CI checks the
image really ships an engine — see [Pre-publish verification](../../../PUBLISHING.md#pre-publish-verification)).
For a new kit, absence of evidence isn't a reason to pick either base — check
the agent's own source or docs for whether it shells out to `docker`,
declares a container runtime, or offers a container-backed execution mode,
and default to plain if it doesn't; when converting an existing kit and that
question stays open, preserve the base it already had rather than change
behavior on a guess. The plain base runs meaningfully smaller — hundreds of
megabytes, measured against today's bases — which is why the choice is worth
making deliberately rather than by default.
Write the base as `ARG BASE_IMAGE=<base>` followed by `FROM ${BASE_IMAGE}`, as
every kit here does: the publish workflow finds a base's dependents by
grepping `*/Dockerfile` for that line. Kits on the shell or shell-docker base
rebuild when the base does; kits on an agent kit's image pick it up on the next
nightly after the agent image is published.
## Do not reach for `sandbox.build:`
[Spec anatomy](spec-anatomy.md) documents `sandbox.build:`, and it decodes — but
the runtime does **not** build from it in this release. `spec/v2.go` and
`spec/normalize.go` both emit a not-implemented warning and then reject the kit
outright if `sandbox.image` is missing:
```
sandbox.build is accepted in the schema but not yet implemented — specify sandbox.image
```
So `build:` and `image:` are not alternatives today: a kit using `build:` must
*also* carry `image:`, which makes `build:` pure documentation. In this
repository the working pattern is a plain `Dockerfile` at the kit root plus a
literal `sandbox.image`, built by CI.
## `sandbox.image` is a literal — it cannot follow CI
A spec is consumed exactly as written; there is no interpolation, `os.Expand`,
or templating anywhere in `spec/`. The image reference therefore cannot read
`REGISTRY`/`IMAGE_NAMESPACE` from the workflow.
The pipeline resolves this by reading `sandbox.image` **from the spec** and
publishing exactly that, rather than composing a name of its own — one source of
truth instead of two that drift. `check-image-ref.sh` is the guard on that
trust, refusing to build a kit whose declared image sits outside the namespace
CI authenticates to.
## Ordering trap: the image must exist before the kit is green
The TCK's `container` subtest pulls and runs `sandbox.image` through
testcontainers, so a kit whose image has not been published yet fails locally
and in CI with:
```
pull access denied for sbx/<kit>-image, repository does not exist
```
This is not only an e2e concern — it hits `TestKitTCK` too. Pull requests build
the image but never push it (push credentials are withheld from PRs), so the
first publish happens when the PR merges to `main`. Expect that window, and
verify the build locally in the meantime:
```console
docker build -t docker.io/sbx/my-kit-image:latest my-kit
./scripts/test-kit.sh my-kit
```
`scripts/test-kit.sh` builds a kit's own image before running the suite, so the
TCK works locally against an unpublished image (`SBX_KIT_SKIP_IMAGE_BUILD=1`
opts out).
## Tags
Two per build, same digest: `<sha>-<YYYYMMDD>` (immutable — pin this) and
`latest` (rolling). There is deliberately no bare `<sha>` tag, because image
content is not a function of the commit — agents install from `latest` channels
and bases are floating tags, so a nightly rebuild of an unchanged commit can
produce different bits.
Point `sandbox.image` at the rolling tag; `check-image-ref.sh` enforces that.
topics/lifecycle.md
# Kit Lifecycle
End-to-end path a kit travels, from a string reference on the CLI to a running container customized by its declarative spec. Stages are described as they appear to the kit author — not the engine internals.
Stages reflect the v2 spec form. v1 spec.yaml files take the same path; the legacy surface is folded into v2 fields during normalization (step 3) and produces deprecation entries on `Artifact.Warnings`. See [`v1-migration.md`](v1-migration.md) for the per-surface mappings.
## 0. Reference shape
A kit reference is one of:
- Local path: `./mcp-postgres/` or `file://./mcp-postgres` (explicit scheme).
- Git: `git+https://github.com/org/repo.git#ref=<40-hex-sha>&dir=subdir`, `git+ssh://...` — **MUST** be a full 40-character commit SHA; branches and tags are rejected.
- OCI: `oci://ghcr.io/org/kit@sha256:<digest>` — **MUST** be a digest; `:latest` and any tag is rejected.
- Embedded built-in agent: by name only (`claude`, `gemini`, …) — these ship inside the `sbx` binary.
`extends:` is **not** auto-resolved during load. Tools that load artifacts directly (e.g. tests, custom inspectors) must opt in explicitly via the spec library's resolver helpers.
## 1. Discovery / sourcing
The CLI classifies the reference string and picks a loader:
- `git+https://` / `git+ssh://` — git clone of the repo at the pinned commit SHA, into `dir`
- `oci://...@sha256:<digest>` — pulled via ORAS from the registry
- anything else that exists on disk — loaded as a directory
- bare name — resolved against the built-in agent set
For `sbx run` / `sbx create`, each `--kit` flag is resolved in declaration order.
## 2. Loading
The spec library reads `spec.yaml`, walks `files/home/` and `files/workspace/`, and builds an in-memory artifact. Safety:
- Symlinks must resolve inside the artifact root — escape attempts are rejected.
- Absolute static-file paths (`/etc/passwd`) and `..` traversal are rejected.
YAML decoding is strict: unknown top-level fields fail. Once a field is removed (e.g. v2's removal of the top-level `tmpfs:` and `settings:` blocks), spec.yaml files that still use it stop loading.
## 3. Normalization
The loader **forks on `schemaVersion`**: a `"2"` spec is decoded by a clean v2 grammar with no legacy shims, while a `"1"` spec runs through the normalize layer, which folds legacy fields onto the same canonical model the v2 grammar targets:
- `kind: agent` → `kind: sandbox`
- `agent:` → `sandbox:`
- `sandbox.aiFilename` → `agentInstructions.filename`
- `memory:` / `agentContext:` → `agentInstructions.content`
- `sandbox.entrypoint.run` / `args` / `ttyArgs` → `sandbox.entrypoint` (flat array) + `sandbox.command.default` / `interactive`
- `credentials.sources` + `network.serviceAuth` + `network.serviceDomains` + `environment.proxyManaged` + standalone `oauth:` → unified `credentials[]`
- `network.allowedDomains` / `deniedDomains` → `permissions.network.allow` / `deny`
- `network.publishedPorts` → top-level `ports`
- `commands:` / `commands.initFiles` → `setup:` / `setup.files`
Each fold appends one deprecation entry to `Artifact.Warnings`. See [`v1-migration.md`](v1-migration.md) for the per-surface migration recipes. A v2 spec produces no such warnings — it is already canonical.
After normalization, only the canonical fields are populated. The legacy fields are dropped — they do **not** round-trip. `sbx kit inspect --output json` shows the canonical form.
## 4. Validation
`spec.ValidateArtifact` runs from each `Load*` path:
- **Manifest** — `schemaVersion ∈ {"1", "2"}`, `kind ∈ {sandbox, mixin}` (also accepts v1 `agent` alias), `name` is lowercase alphanumeric + hyphen (1–64 chars), exactly one of `sandbox.image` / `sandbox.build` required for sandbox kits, `resources.cpu` must be a non-negative number and `resources.memory` (v2) a valid byte-size string if set.
- **permissions.network** — entry strings are an exact host (any port), an exact host with a numeric port, a single-label wildcard (`*.example.com`), a multi-label wildcard (`**.example.com`), or a port wildcard (`host:*`); a port range (`host:80-443`) is declared but never matches a request. Overlap between `allow` and `deny` is **legal** — at request time, deny wins.
- **Credentials** — each entry has `service` set; `apiKey.inject[].format` (when set) is well-formed; a v2 `apiKey.inject[].scheme` is mutually exclusive with `format` and expands to it; `oauth.tokenEndpoint` has host+path.
- **Volumes** — every entry has an absolute `path`; `type ∈ {"", "tmpfs"}`; `size` if set must parse as a byte-size string; `mode` if set must be octal.
- **PublishedPorts** — `container` in 1..65535; `protocol ∈ {"", "tcp", "udp"}`.
- **Locked** — each entry is a well-formed dotted YAML path; no duplicates.
- **setup.files** — only `${WORKDIR}` placeholder is allowed; mode is octal; container paths are absolute.
- **Static files** — relative-to-target only. Absolute paths and `..` traversal rejected. Symlink resolution must stay inside the artifact root.
Validation **never errors on legacy fields**; that's normalization's job. A v1 spec.yaml that's structurally valid in v1 will load, normalize, and pass `ValidateArtifact` — with one or more entries on `Artifact.Warnings`.
## 5. Inheritance (`extends:`)
Single-parent inheritance for authoring convenience.
- Walks the parent chain up to **5 levels**, with circular-reference detection.
- The default resolver looks up built-in agent names; alternate resolvers can pull from any source.
- Parent kit MUST be `kind: sandbox` — mixins cannot use `extends:`.
- Remote parents follow the [reference-pinning rule](#0-reference-shape): Git refs need a 40-hex commit SHA, OCI refs need a digest.
Merge is **additive** — child inherits the parent's configuration and adds to it. The rules per field type:
| Field type | Strategy | On conflict |
|---|---|---|
| Scalars (strings, numbers, booleans) | Child wins if set | Child overrides parent |
| Maps (key-value objects) | Recursive merge | Child wins for conflicting keys |
| Named arrays (objects with an identity key, e.g. `credentials[].service`, `volumes[].path`) | Union by identity key | Matching key: **error**; new key: appended |
| Primitive arrays (e.g. `permissions.network.allow`) | Set union | Deduplicated, order preserved (parent first) |
| Setup lists (`install`, `startup`, `files`) | Concatenate | Parent commands first, then child |
| Files | Overlay | Child overrides at same path |
| `security.privileged` | OR semantics | Any `true` → `true` |
Implication: child kits **inherit** parent configuration and extend it. Naming an entry that already exists in the parent — e.g. a `credentials[].service` the parent already declares — is an error, because the merge can't decide which shape wins.
## 6. Composition (`mixins:` + `--kit`)
Mixins reach the artifact two ways:
- **Author-time**: `mixins:` list inside a sandbox kit's `spec.yaml` — declared statically by the kit author. Sandbox kits only; mixin kits cannot declare `mixins:`.
- **Runtime**: `--kit` CLI flag — picked at sandbox-create time by the user.
Both apply additively, in declaration order. When a kit uses both, the resolution order is:
1. Resolve the `extends:` chain — recursively merge parent → child.
2. Apply the kit's own fields — merge with the resolved base.
3. Apply declared mixins from the `mixins:` field, in declaration order.
4. Apply runtime `--kit` flags, in declaration order.
Splitting rule: exactly one `kind: sandbox` and N `kind: mixin` across the base sandbox + all mixins (declared + runtime). Two sandboxes in the stack is an error. Every artifact's `name` must be unique across the composition — including a mixin whose name collides with the base sandbox.
Error conditions:
- Duplicate kit name → `duplicate kit name X — each kit must have a unique name`.
- Credential conflict → `credential X defined in both A and B`.
- A `mixins:` entry whose `kind` is `sandbox` → `kit X must be kind mixin, got sandbox`.
Merge rules (per section):
| Section | Rule |
|---|---|
| `permissions.network.allow` / `deny` | Append (deny wins at policy time) |
| `credentials[]` | Union by `service`; same service in two kits with different shapes is an error |
| `environment.variables` | Union; later kits override earlier (last-wins) |
| `setup.install` | Concatenate; runs for every kit, built-in or user-supplied |
| `setup.startup` / `files` | Concatenate |
| `files` | Overlay map by `target:relativePath` — later kits override earlier |
| `ports` | Append (each entry gets its own ephemeral host port) |
| `volumes` / `manifest.security` | Union with last-wins |
Order of `--kit` flags is the merge order.
## 7. Credential resolution + bindings
Independent of the customizer chain. When the engine needs a credential for a `credentials[]` entry, it walks (in order):
1. **CLI override** (P2) — `sbx run --credential <service>=<variant> ...` selects a binding for this run only.
2. **Workspace-remembered** (P2) — `remembered[<workspace-path>][<service>]` in `credentials.yaml`.
3. **Default binding** — `bindings[<service>]`. If multiple variants exist and no binding is selected, the engine prompts; if no binding exists at all, it prompts for first-time setup.
Once a binding is chosen, the engine looks up the actual credential value in this order:
1. **Secret store, sandbox-scoped** — `sbx secret get <service>` scoped to the current sandbox.
2. **Secret store, global** — `sbx secret get <service>` in global scope.
3. **`discovery[]`** — entries are walked in order; the first that yields a value wins.
See [`bindings.md`](bindings.md) for the full file shape, named variants (`<service>@<variant>`), and approval flows.
The engine only injects a credential into a domain that appears in **both** `credentials[].apiKey.inject[].domain` (kit-side) and `bindings[<service>].allowedDomains` (user-side). A domain the kit requests but the user hasn't approved triggers a **domain-expansion approval prompt** at sandbox-create time; a declined domain doesn't fail creation, the injection is just skipped.
## 8. Configuration / injection
For each kit, the engine builds a chain of container customizations. The chain emits into two buckets that execute in **different phases** of the container's post-start sequence:
**Bucket: customizers** — fires first, in declared order:
1. **Container settings** — privileged, volumes (including `type: tmpfs`). **Creation-time only** — `sbx kit add` cannot apply these to a running container.
2. **Install commands** (`setup.install`) — `sh -c <string>`, synchronous, default user `0`, runs **once at sandbox creation** before the agent launches. Runs for every kit, built-in or user-supplied. Use it to install the agent binary (if not already baked into the base image) and for any pre-launch setup such as seeding a credential-gated settings file. See [Pitfalls §5](pitfalls.md#5-setupinstall-footguns) for idempotency and duplication guidance.
3. **Environment variables** (`environment.variables`).
4. **Static home files** (`artifact.Files` where `target == home`) — copied to `/home/agent/`, mode preserved.
5. **Setup files** (`setup.files`) — written via shell exec at startup, `${WORKDIR}` substituted **in content only**, `onlyIfMissing` wraps the write in `test -f`. *Cannot* target a path under the in-container clone directory (the CLI rejects such kits up front under `--clone`).
6. **Startup commands** (`setup.startup`) — argv form, default user `1000`, optional `background: true`. Rendered into per-kit shell scripts at create time and re-run on **every** container start (initial create, stop/start cycles, daemon restarts, container resurrection). Author them idempotent.
7. **Hooks** — see step 9.
**Bucket: post-workspace-ready hooks** — fires last, after every customizer above and after the system-level customizers the CLI layers on top (DinD wiring, secrets tmpfs, `--clone` startup command, SSH-agent relay, AI file, docker config). Fires once the workspace is populated, either by the `git clone` startup command in `--clone` mode or by the bind mount in direct-mount mode:
8. **Static workspace files** (`artifact.Files` where `target == workspace`) — copied to the workspace path inside the container, mode preserved. Use `files/workspace/<path>` whenever you want a static file inside the cloned working copy.
Within each bucket, entries are appended in the order listed.
The two-bucket shape exists because the container runtime runs post-start hooks in append order and stops on the first error. A workspace-file hook that fired before the `git clone` startup command would write to a directory that doesn't exist yet in `--clone` mode and abort the start before the clone could run.
## 9. Hook execution
Configure hooks are Go functions registered with the engine per agent name. They are an **engine-internal extension point** — built-in agents use them for things YAML cannot express. A user-supplied kit cannot ship a hook because there is no way to inject Go code into the `sbx` binary at runtime.
For the common OAuth case, you don't need a hook — set the `oauth` sub-block under a `credentials[]` entry and the engine generates the equivalent hook for you.
A hook may return a "skip" sentinel to no-op (e.g., an OAuth hook skips when an API-key env var is set).
## 10. Container creation + port publishing
CLI flow on `sbx run <agent> --kit X`:
1. Resolve the base sandbox (built-in by name, or user-supplied `kind: sandbox` kit).
2. Resolve each `--kit` reference and load the artifact.
3. Compose: separate sandbox + mixins, run merge rules, build the customizer chain.
4. Create the container with all customizers applied.
5. For each `ports[]` entry, the runtime assigns an ephemeral `127.0.0.1:<host>` binding and records it. `sbx ports <sandbox>` lists the active bindings.
## 11. Runtime injection (`sbx kit add`)
`sbx kit add <sandbox> <kit-ref>` applies a kit to a running container.
- **Immutable warning** — if the artifact requires privileged mode or volume changes (including tmpfs entries), `sbx kit add` warns and skips those parts. The kit is still applied for the mutable parts.
- Install → env → files → setup files → startup are re-played against the running container: files via `docker cp`-style copies, commands via `exec`.
- A metadata file (`~/.sandbox-plugins.json`) is written inside the container to record the kit (container labels are immutable, so this JSON file is the audit trail).
What `kit add` **cannot** do: change privileged mode, attach new volumes, add new port publishings. Those need a recreate.
## 11.5 Agent-context rendering (create + kit add)
Distinct from the customizer chain, the AI file write happens as a post-start lifecycle hook:
- **Base sandbox's `agentInstructions.content`** is inlined into the AI file (`<dir-of-AIFile>/<agentInstructions.filename>`) — small, always-loaded identity content.
- **Each composed mixin with non-empty `agentInstructions.content`** gets its own file at `<dir-of-AIFile>/kits-memory/<kit-name>.md`.
- The AI file gains a sentinel-wrapped `## Kits` section pointing the agent at the kits-memory directory for progressive disclosure. Sentinels (`<!-- sbx:kits-section start --> ... end -->`) make the section detectable and replaceable on re-runs.
`sbx kit add` partially follows the same model — the engine writes the kit memory file and refreshes the `## Kits` section. **Known gap**: when the kit being added is a `kind: mixin`, the memory write is currently gated on the artifact's own `agentInstructions.filename`, which mixins intentionally don't set. The kit memory file is silently not written, and the `## Kits` section is not refreshed. Workaround until fixed: recreate the sandbox with `--kit <mixin>` instead of using `sbx kit add`. The create-time path is unaffected.
## 12. Request-time / proxy
Independent of the customizer chain. The proxy runs on the host (or in the VM) and:
- Routes outbound HTTPS by `credentials[].apiKey.inject[].domain` and `credentials[].oauth.tokenEndpoint`.
- Injects credentials per `credentials[]` using the `inject[].header` / `format` declared in the spec.
- Enforces `permissions.network.allow` / `deny` at policy-evaluation time. Use `sbx policy log <sandbox>` to see what the proxy blocked and what got through.
- For sentinel-swap credentials (opt in with `apiKey.proxyManaged: true`), the proxy swaps the literal `proxy-managed` value for the real one per request. The container never sees the real credential.
The alternative — container-resident credentials — is necessary for signature-based auth (AWS SigV4) where the signature is over canonical headers the proxy doesn't see. Today that means writing the credential to a file inside the container; the kit's `permissions.network.allow` then bounds where the credential can be sent.
## Quick mental model
```
ref string
│
▼ resolve local | oci@digest | git@commit-sha | embedded
│
▼ load read spec.yaml + walk files/ (strict decode, forked on schemaVersion)
│
▼ normalize v1 → canonical Artifact + Warnings (v2 decodes straight to canonical)
│
▼ validate schema + safety checks
│
▼ extends (opt-in) walk parent chain
│
▼ compose base sandbox + N mixins → composed artifact
│
▼ configure build customizer chain
│ (or inject: exec into running container)
│
▼ container creation customizers applied + ports allocated
│
▼ proxy credentials + permissions.network at request time
```
topics/pitfalls.md
# Pitfalls & Surprises
Things that have bitten kit authors in practice. Read this before debugging a "why isn't my kit doing X" issue.
Items below describe v2 behavior. For the analogous v1 surfaces and how they fold during load, see [`v1-migration.md`](v1-migration.md).
## 1. `Install commands completed` is an exit-code message
The CLI prints `Install commands completed` after every install block. It only means the commands exited 0. It does **not** mean the install did what it was supposed to.
A broken `curl | bash` pipe can exit 0 if `curl` fails after producing partial output and `bash` exits 0 on empty input. Always verify the outcome:
```bash
sbx exec probe -- which <expected-binary>
sbx exec probe -- <expected-binary> --version
```
## 2. `setup.startup` runs on every container start
Startup commands run on **every** container start: the initial create, subsequent stop/start cycles, daemon restarts, and Docker-engine container resurrection (e.g. after a host reboot). They are persisted as per-kit shell scripts under `/etc/durable-startup.d/` inside the container and invoked by an `sbx`-managed dispatcher after every real `ContainerStart`.
Author startup commands to be **idempotent** — they will run again. Common patterns: `apt-get update -qq -y > /dev/null 2>&1 || true &`, `mkdir -p '<dir>'`. Don't write commands that assume "first run only".
## 3. `sbx kit add` of a mixin does not write kit memory (known gap)
The engine's kit-memory write is gated on the artifact's own `agentInstructions.filename`. Mixins intentionally don't carry their own filename (a mixin contributes _to_ the base sandbox's AI file, it doesn't define one), so the gate always trips for `kind: mixin` artifacts during `sbx kit add`. The kit memory file under `kits-memory/<name>.md` is silently not written, and the `## Kits` section is not refreshed.
Workaround until the gate is fixed: recreate the sandbox with `--kit <mixin>` instead of adding it at runtime. The create-time path writes the per-kit files correctly.
## 4. `sbx kit add` cannot apply immutable settings
Container labels, privileged mode, volumes (block-backed or `type: tmpfs`), and `ports` are fixed at container creation. `sbx kit add` warns and skips them — but applies everything else. If your kit requires those, the user must recreate the sandbox with `--kit`.
## 5. `setup.install` footguns
`setup.install` runs **once, synchronously, before the agent launches** (at sandbox creation). It runs for every kit, built-in or user-supplied. Use it to install the agent binary if your base image does not already include it, and for any pre-launch setup such as seeding a credential-gated settings file.
Three traps:
**Don't duplicate an install your image already provides.** If your base image or Dockerfile bakes the agent binary in, do **not** also declare its install in `setup.install` — it runs redundantly every creation (and every recreate). Guard with `command -v <binary> || <install>` if you are unsure whether the image already has it.
**`install` re-runs on recreate.** A script that writes files must guard itself (`if [ ! -f … ]`). For a *static* file with no logic, prefer `setup.files` with `onlyIfMissing: true` — the engine provides the idempotency for you. Reach for an `install` script only when you need logic: conditional content, multiple files, or branching on `SBX_CRED_<SERVICE>_MODE`.
**`SBX_CRED_<SERVICE>_MODE` is available at install time.** The variable is injected into the container env before install commands run and takes the value `apikey`, `oauth`, or `none`. Read it defensively: `${SBX_CRED_<SERVICE>_MODE:-none}` so an unset value is treated as `none`.
```bash
# Example: seed a settings file only when an API key is wired
mode="${SBX_CRED_MYSERVICE_MODE:-none}"
if [ "$mode" = "apikey" ] && [ ! -f ~/.config/myservice/settings.json ]; then
mkdir -p ~/.config/myservice
printf '{"apiKey":"proxy-managed"}\n' > ~/.config/myservice/settings.json
fi
```
## 6. `extends:` is not auto-resolved
Loaders return an `Artifact` with `Extends` set as a string. They do **not** walk the parent chain. Callers must explicitly call `ResolveExtends(artifact, resolver)`. Forgetting this gives you an artifact that looks fine in `inspect` but is missing fields the parent would have supplied.
The `sbx` CLI does resolve extends in its kit-loading paths; this trap is mostly for tests and tooling that load artifacts directly.
## 7. `extends` merge is additive, but matching identity keys collide
A child kit using `extends:` inherits the parent's configuration **additively** — the child does not replace the parent's sections wholesale. Maps merge recursively, primitive arrays union, named arrays union by identity key (e.g. `credentials[].service`, `volumes[].path`), and commands concatenate.
The trap: when the child declares an entry whose identity key **matches** one the parent already declares, the merge fails with an error rather than picking a winner. For example, two `credentials[]` entries with `service: anthropic` (one in the parent, one in the child) is an error — you must rename the child's service to something distinct, or drop the duplicate.
If you want to add a network domain or an install step to a parent's existing configuration, just declare the new entry in the child — the merge appends it.
## 8. Composition conflicts on credentials are errors
For `credentials[]`, two kits writing the same `service` with different shapes is a hard error from compose. The error surfaces at `sbx run` time, not at `sbx kit validate` time, because validation runs per-artifact and composition is the cross-artifact step.
## 9. `environment.variables` last-wins is silent
Unlike credentials, conflicting `environment.variables` does not error — later `--kit` flags silently override earlier ones. Useful for downstream override, dangerous if you didn't intend it. Document override semantics in your mixin's `description`.
## 10. `setup.files[].content` placeholders
The only supported placeholder is **`${WORKDIR}`**. Anything else (`${HOME}`, env interpolation) fails validation. If you need richer substitution, you'll need to compose with a startup command that does the substitution at runtime.
## 11. Static `files/` paths must be relative
Files under `files/home/` and `files/workspace/` are placed relative to those targets. Absolute paths (`/etc/passwd`) and `..` traversal are rejected at validation. Symlinks must resolve inside the artifact root.
This is by design — kits are sandbox-scoped and must not write outside the agent home or the workspace.
## 12. Use `sbx policy log` to confirm enforcement
When verifying `permissions.network.allow` enforcement, run `sbx policy log <sandbox>` after triggering a request. The output lists allowed and blocked requests with the host/port, and is the authoritative view of what the proxy actually evaluated. Don't try to read daemon logs directly — the policy log is the user-facing surface and is stable.
## 13. Credential delivery: sentinel-swap vs container-resident
Two models:
- **Sentinel-swap (proxy)** — opt in with `apiKey.proxyManaged: true`. The engine sets `apiKey.name` inside the container to the literal `proxy-managed`; the proxy swaps the real value into the outbound request based on `apiKey.inject[]`. The container never sees the credential. Used by Anthropic, OpenAI, GitHub.
- **Container-resident (egress-bounded)** — the real credential lives in the container, restricted by `permissions.network.allow`. Used when signatures must be computed in-container — **AWS SigV4 forces this**, because the signature is over canonical headers the proxy doesn't see.
Pick the right model for your service. Sentinel-swap is stricter; container-resident is necessary for SigV4-style auth.
## 14. Inject-domain ∩ binding-allowedDomains intersection
The engine **only** injects a credential into a domain that appears in **both** the kit's `credentials[].apiKey.inject[].domain` and the user's `bindings[<service>].allowedDomains`. If the user's bindings don't list one of the kit's inject domains, the engine drops that injection silently (with a one-line warning in interactive contexts) and the request goes through unauthenticated.
When debugging "auth header isn't appearing": confirm the user's `~/.config/sbx/credentials.yaml` lists every domain the kit wants to inject into. See [`bindings.md`](bindings.md).
## 15. Package managers refresh **every** configured source
A subtle network trap from the repository README, worth restating here: `apt-get update` re-fetches metadata for every file in `/etc/apt/sources.list[.d/]` — including sources the base template added. If *any* of those returns non-2xx (because it's not in your `permissions.network.allow`), `apt-get` exits non-zero even if the package you want is in a different source.
For kits built on either shell base (or a `*-docker` template) that means `download.docker.com` and `cli.github.com` (Docker's and GitHub CLI's apt repos, pre-added by the base) need to be in your `permissions.network.allow` even if you're only installing something from Ubuntu's main archive.
For Ubuntu cross-arch coverage, list all three: `archive.ubuntu.com` and `security.ubuntu.com` (amd64) + `ports.ubuntu.com` (arm64). CI is amd64; many developer Macs are arm64.
## 16. Legacy v1 fields don't round-trip
v1 surfaces (`kind: agent`, `agent:` block, `memory:`, `credentials.sources`, `network.serviceAuth/serviceDomains/allowedDomains/deniedDomains/publishedPorts`, `environment.proxyManaged`, standalone `oauth:`, top-level `tmpfs:`, `settings:`) load via the normalize-layer shims and produce one `Artifact.Warnings` entry per legacy block touched. After loading they're folded into the canonical v2 fields — they do **not** round-trip. `sbx kit inspect --output json` shows the canonical form. Run `scripts/migrate-v1-to-v2.go` to rewrite v1 source files into v2.
## 17. `files/workspace/<path>` overlays the user's repo on every restart
A workspace-target file whose relative path matches a real file in the user's repo will silently overwrite that file on **every** sandbox start. Overlay is the intended semantic — that's why `files/workspace/` exists — but the consequence is worth surfacing:
- Under `sbx run --clone`, the in-container clone is repopulated on first start. The kit's post-start hook then overwrites any matching path in the cloned copy.
- Under direct mount (no `--clone`), the workspace is bind-mounted from the host, so the kit's write modifies the **host** file. Anything the user edited between restarts gets clobbered the next time the sandbox starts.
When the CLI detects a kit `files/workspace/<path>` whose relative path matches a file already on disk in the host git working copy, it emits a banner-style warning to stderr so the overlay is visible to the user before they go interactive. The warning is informational, not a refusal.
If overlay isn't what you want, rename the file or move it under `files/home/<path>`.
### Sensitive-path overlay warnings
Implementations warn (loudly) when a mixin's `files/` or `setup.files` writes to paths the user almost certainly didn't expect a mixin to touch:
- `~/.ssh/**` — anything under the agent's SSH config.
- `credentials[].oauth.credentialFile.path` already declared by the parent kit — a mixin overwriting the parent's OAuth credential file is suspicious.
- Shell rc files — `.bashrc`, `.bash_profile`, `.zshrc`, `.zprofile`, `.profile`.
The warning is informational (not a refusal); a mixin that legitimately needs to touch one of these paths should call it out in `description:`.
## 18. `setup.files` cannot target the in-container clone directory
Under `sbx run --clone`, the in-container working copy is populated by a `git clone` startup command. `setup.files` runs as a post-start hook in the same phase; if its path resolves under the clone target, the setup-file's `mkdir -p && printf > path` creates the workspace dir and writes a file inside it, and then `git clone` refuses the non-empty target.
The CLI catches this up front: a kit whose `setup.files[i].path` resolves at or under the clone target is rejected at sandbox-create time with an actionable error pointing you at `files/workspace/<path>`. See [`authoring.md`](authoring.md) for the decision rule between `files/workspace/` and `setup.files`.
## 19. `permissions.network.allow` wildcard semantics
`*.example.com` matches **exactly one** DNS label — `api.example.com` ✓, `cdn.example.com` ✓; `example.com` ✗ (zero labels), `a.b.example.com` ✗ (two labels).
`**.example.com` matches one or more labels, crossing dots, and is enforced.
Middle-position wildcards like `bedrock-runtime.*.amazonaws.com` aren't part of the spec at all. List the regions explicitly until the spec adds an entry format for them.
## 20. Missing-domain stories worth knowing
Common allowlist gaps that have caught real kits:
- **VS Code extension marketplaces**: a kit that ships `code-server` and downloads extensions at runtime needs `openvsx.eclipsecontent.org` (Open VSX content host) in addition to the marketplace API.
- **Container runtimes that use Docker's apt repo**: `download.docker.com` and `cli.github.com` are added to the base images' `sources.list.d/` — `apt-get update` will fail under `deny-all` unless you allowlist it.
- **Package mirrors**: `registry.npmjs.org` for npm; `pypi.org` + `files.pythonhosted.org` for pip; `crates.io` + `static.crates.io` for cargo. The metadata host and the content host are usually different.
When debugging "this thing can't fetch X," run under `sbx policy log` and add what shows up in the blocked list, one domain at a time. The repository README has the full recipe.
## 21. `Artifact.Warnings` is a TODO list
If `sbx kit inspect --output json | jq '.warnings'` returns a non-empty list, your kit is using v1 surfaces that fold cleanly today but will stop loading at the Phase 6 schema cutover. Run `scripts/migrate-v1-to-v2.go` and fix the remaining manual surfaces per [`v1-migration.md`](v1-migration.md) before then.
topics/spec-anatomy.md
# `spec.yaml` Anatomy
Single source of truth: the Go types in [`github.com/docker/sbx-kits-contrib/spec`](../../../spec/types.go). The `sbx` engine consumes these types via the spec library and delegates loading, normalization, and validation to it.
This page documents the **v2** form (`schemaVersion: "2"`). For the legacy v1 spelling and how it folds into v2, see [`v1-migration.md`](v1-migration.md).
### A note on priority labels
Fields below are tagged with their RFC delivery priority — P1 / P2 / P3 / P4. They mean:
| Tag | Meaning |
|---|---|
| **P1** | Baseline v2 — must ship to call something v2. |
| **P2** | Ships in v2, lower priority than P1. |
| **P3** | **Pending sbx support** — declared in the spec for forward compatibility; loads without error but runtime enforcement is a no-op until sbx implements. |
| **P4** | Niche / cloud workloads (`sandbox.lifecycle` is the only one today). |
Untagged fields are P1 or carried forward from v1 with no change.
## Top-level
```yaml
schemaVersion: "2" # required, must be exactly "2" (string, not integer)
kind: sandbox # required: "sandbox" | "mixin" (case-sensitive)
name: claude # required, must match ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$
displayName: Claude Code # optional
description: "..." # optional
licenses: # optional (P2), SPDX identifiers
- MIT
- Apache-2.0
extends: shell # optional, single-parent inheritance (opt-in resolution)
mixins: # optional (P1), multi-parent composition (sandbox kits only)
- my-org-tools
- "oci://ghcr.io/org/auditor@sha256:<digest>"
requires: # optional, base-agent affinity (mixins)
agent: claude
locked: # optional (P2), dotted paths child kits may not override
- sandbox.image
- credentials[service=anthropic]
args: # optional, arguments the installer supplies
version:
default: "latest"
```
`kind: sandbox` requires the `sandbox:` block. `kind: mixin` must not have a `sandbox:` block. Exactly one `sandbox` is allowed in a composition; mixins stack freely.
The agent-instruction fields (`agentInstructions.filename`, `agentInstructions.content`), the egress policy (`permissions.network`), and the setup hooks (`setup.install` / `setup.startup` / `setup.files`) are all **top-level** blocks — they are not nested under `sandbox:`. See their sections below.
The `name` constraint is exactly: starts and ends with `[a-z0-9]`, may contain `-` in between, 1–64 characters.
### `licenses`
Optional SPDX license list. Non-empty list of strings if present. Implementations should warn on unrecognized SPDX identifiers. In composition, licenses union across the parent chain and declared mixins.
### `args`
Declares the arguments an installer supplies, referenced anywhere in `spec.yaml` or under `files/` as `${{ kit.args.<name> }}`. Substitution happens before the spec is decoded, so an argument can parameterize any value in the grammar. Every reference must be declared — that is what makes the block a complete list of the kit's inputs.
```yaml
args:
version:
default: "latest" # optional argument; used when nothing is supplied
description: "Tool version"
pattern: '^(latest|[0-9]+\.[0-9]+)$'
channel:
default: "stable"
enum: ["stable", "beta"]
token:
required: true # installer must supply a value
```
Each argument declares exactly one of `default` or `required: true`, and constrains its value with at most one of `enum` or `pattern` (a Go RE2 regexp matched against the whole value). Values are always strings: quote the placeholder in a string-valued field (`VERSION: "${{ kit.args.version }}"`), or a value like `1.20` is read as a float. Write `$${{` where you want a literal `${{` and no substitution — except in a mapping key, which is rejected for naming an argument whether escaped or not (see [SPEC-v2 §2.1](../../../spec/SPEC-v2.md#21-args)).
`args` is v2-only, and unrelated to `sandbox.build.args` (Docker build arguments). Because the block lives in `spec.yaml`, a signature covers the declarations and defaults; the values an installer supplies do not.
In this repository the TCK is the installer that supplies them — it reads them from your kit's `testdata/tck.yaml`. See [Testing — Kits that declare `args`](testing.md#kits-that-declare-args).
### `mixins`
Multi-parent composition for `kind: sandbox` kits. Only valid for sandbox kits — mixins themselves cannot use `mixins:` (or `extends:`). The `extends` prohibition is enforced for `schemaVersion "2"` mixins; a `schemaVersion "1"` mixin that predates the rule still validates (backwards compatibility). To derive from a parent agent, use a `kind: sandbox` kit with `extends`.
```yaml
mixins:
- shell-tools # bare name → built-in mixin
- ./local-mixin/ # local directory
- "git+https://github.com/org/repo.git#ref=<40-hex-sha>&dir=<subdir>"
- "oci://ghcr.io/org/mixin@sha256:<digest>"
```
Same reference formats as `extends:` and the same pinning rule (Git: full commit SHA; OCI: digest). See [`distribution.md`](distribution.md).
Resolution order when a kit uses both `extends` and `mixins`:
1. Resolve `extends` chain — recursively merge parent → child using additive semantics.
2. Apply kit's own fields — merge with the resolved base.
3. Apply declared mixins in declaration order, using the same additive semantics.
The `--kit` CLI flag is the runtime equivalent — see [composition.md](composition.md).
### `requires`
Optional composition preconditions. Today it carries only **base-agent affinity**:
```yaml
kind: mixin
requires:
agent: claude # single base-agent name
```
A mixin often injects agent-specific configuration — Claude Code's `ANTHROPIC_*`
environment variables, for instance, mean nothing to a `codex` sandbox. Set
`requires.agent` to the base agent the mixin is designed for; composing it onto
any other base agent is a composition error (rather than silently producing a
nonsensical-but-valid sandbox).
`requires` is **only valid on `kind: mixin`**. A `kind: sandbox` *is* the base
agent, so affinity is meaningless there and is rejected at validation time
rather than silently ignored.
`requires.agent` is a **single agent name**, not a set — affinity exists to
prevent misapplication, so an "any of these" list would defeat the guarantee.
The spec library validates only that the name is well-formed (same charset as
`name`); the affinity itself is enforced at composition time by the consumer.
Broader family matching (e.g. `claude` and its `claude-vertex` / `claude-bedrock`
variants) is left to the consumer's `extends`-lineage check. Absent or empty
means the kit declares no affinity and layers onto any base agent.
## `sandbox:` (only for `kind: sandbox`)
A sandbox kit MUST specify `image` (unless it inherits one via `extends:`). `build` is an *additional*, forward-compatible block — not an alternative: because builds are not yet implemented, a kit that sets `build` must set `image` too, and a build-only kit is rejected at load. See [Use `build:` to build from a Dockerfile](#use-build-to-build-from-a-dockerfile) and [Image publishing](image-publishing.md).
### Use `image:` to layer onto a pre-built image
```yaml
sandbox:
image: docker/sandbox-templates:claude-code
resources: # optional (P3) — container limits
cpu: 4.0 # float, cores (must be non-negative if set)
memory: 8g # byte-size string ("8g", "8192m", "4096mib")
gpu: "1" # consumer-defined string
entrypoint: [claude, "--dangerously-skip-permissions"] # binary + always-on args
command: # mode-specific arg tail (optional)
default: ["-l"] # appended for a non-interactive / --task run
interactive: [] # appended for an interactive session
```
Use `image:` when you can layer the kit's behaviour onto an existing base image via `setup.install` and `setup.files`.
### `entrypoint` + `command`
`entrypoint` is a **flat string array** — the fixed process prefix. `entrypoint[0]` is the binary (recorded as `Manifest.Binary`); the remaining elements are always-on arguments applied in **both** run modes.
`command` carries the mode-specific tail appended after the entrypoint. It is polymorphic:
```yaml
command: ["-l"] # shorthand: a list = the default tail; interactive falls back to it
```
```yaml
command: # structured form: distinct tails per mode
default: ["-l"] # appended for a non-interactive / --task launch
interactive: [] # appended for an interactive (TTY) session
```
The effective launch args are:
| Mode | Args |
|---|---|
| Default (non-interactive / `--task`) | `entrypoint[1:]` + `command.default` |
| Interactive (TTY) | `entrypoint[1:]` + `command.interactive` (falls back to `command.default` when `interactive` is unset) |
When `command` is omitted entirely, both modes run `entrypoint` as-is. There is no `pipeMode` field in v2 — the v1 `entrypoint.pipeMode` was dropped and has no v2 home.
### Use `build:` to build from a Dockerfile
```yaml
sandbox:
build: # P1
context: . # default ".", relative to spec.yaml
dockerfile: Dockerfile # default "Dockerfile", relative to context
args: # passed as --build-arg
AGENT_VERSION: "1.4.2"
target: runtime # optional Dockerfile build stage
platforms: # default [linux/amd64, linux/arm64]
- linux/amd64
- linux/arm64
entrypoint: [my-agent, "--yolo"]
```
Use `build:` when you need custom binaries, complex setup, or full control over the container contents. `sbx kit push` transforms a `build:` source spec into a distribution form: it runs the build, pins the resulting image by digest, and rewrites `sandbox.build` away. The source `spec.yaml` is never modified; the published kit consumers see only `sandbox.image: <ref>@sha256:<digest>`.
> [!IMPORTANT]
> **`build:` is not wired up in this release.** It decodes, and it emits a not-implemented warning; the runtime does not build from it. A kit that sets `build:` must **also** set `sandbox.image`, or it is rejected at load with `sandbox.build is accepted in the schema but not yet implemented — specify sandbox.image` (`spec/v2.go`, `spec/normalize.go`). The paragraph above therefore describes intent, not current behaviour.
>
> To ship a kit with its own image today, see [Image publishing](image-publishing.md): a `Dockerfile` at the kit root plus a literal `sandbox.image`, built and pushed by this repository's CI.
### Validation
- `sandbox.image` MUST be present for `kind: sandbox` (unless inherited via `extends:`) — including when `sandbox.build` is set, since builds are not yet implemented. A build-only kit is rejected at load.
- `sandbox.resources.cpu` MUST be non-negative if specified.
- `sandbox.resources.memory` MUST parse as a byte-size string (`units.RAMInBytes`, e.g. `4096m`, `8g`) if specified.
- `sandbox.entrypoint` MUST be a flat string array; `entrypoint[0]` is the binary.
- `sandbox.command` MUST be either a string list or a mapping with `default` / `interactive` keys.
## `credentials`
A list. Each entry describes **what the kit needs** (a service identity, where to inject the resolved value); the user-side [bindings file](bindings.md) declares **where the credential lives**.
Per-entry fields:
| Field | Required | Notes |
|---|---|---|
| `service` | yes | Must match `^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$`. Auto-detects the provider when the name matches a known provider registry entry (anthropic, openai, github, …). |
| `description` | no | Surfaced in interactive prompts (first-time setup, binding selection). |
| `required` | no | Default `false`. When `true`, sandbox creation fails if no binding is available. |
| `provider` | conditional | Explicit provider registry entry. Only needed when `service` doesn't match a known provider — sets the auth defaults the registry would otherwise derive from the name. |
| `apiKey` | conditional | api-key shape (see below). |
| `oauth` | conditional | OAuth shape (see below). |
For custom services not in the provider registry, **at least one** of `apiKey.inject` or `oauth` MUST be specified.
### api-key shape
```yaml
credentials:
- service: anthropic # auto-detects provider "anthropic"
description: "Anthropic API key" # surfaced in interactive prompts
required: false # resolver fails fast if true and unbound
apiKey:
name: ANTHROPIC_API_KEY # env var the engine names
proxyManaged: true # required for the engine to set the sentinel in-container
inject:
- domain: api.anthropic.com
header: x-api-key
format: "%s" # must contain exactly one %s
- service: github
apiKey:
name: GITHUB_TOKEN
proxyManaged: true
inject:
- domain: api.github.com
scheme: bearer # sugar for header: Authorization, format: "Bearer %s"
- domain: github.com # HTTPS git clone over HTTP Basic
scheme: basic # sugar for HTTP Basic Auth
username: x-access-token # required with scheme: basic
```
With `proxyManaged: true`, `apiKey.name` is set to the literal `proxy-managed` inside the container by the engine — the sentinel-swap proxy replaces it on outbound requests. Authors **don't** put real values in the spec.
### `scheme` — header-encoding sugar (v2)
`inject[].scheme` is decode-time sugar that avoids hand-spelling `header` + `format`:
| `scheme` | Expands to | Notes |
|---|---|---|
| `bearer` | `header: Authorization`, `format: "Bearer %s"` | `username` is rejected. |
| `basic` | HTTP Basic Auth (username-driven at the proxy) | `username` is **required**. |
`scheme` and a raw `format` are **mutually exclusive** — set one or the other, not both. On the normalized artifact `scheme` is expanded away, so consumers only ever read `header` / `format` / `username`. You can still write `header` + `format` directly when you need a header the sugar doesn't cover.
`bearer` supplies `header: Authorization` only when you left `header` empty, so writing an explicit `header:` alongside it still wins. `basic` is username-driven at the proxy rather than a header encoding, so it sets no `header` at all — write one yourself if the service needs a specific one.
**Enforcement:** every `apiKey.inject[].domain` MUST appear in `permissions.network.allow`. There is no auto-derived egress from credentials. `sbx kit validate` warns (but does not fail) when it detects an uncovered domain; the engine performs the authoritative enforcement, and a missing domain surfaces at load or sandbox-create time (SPEC-v2 §6).
An inject entry SHOULD set at least one of `header` or `username` — one with neither injects nothing into requests and only maps the domain to the credential's service for routing/policy purposes; that's a legitimate shape (e.g. associating a domain with a service without a header to inject), but `sbx kit validate` warns since it usually means a forgotten `header`/`username`. A `header`-bearing entry with no `username` MUST also set `format`, or there is no template to substitute the credential into. `username` MUST NOT contain `:` — HTTP Basic (RFC 7617) treats the first colon as the user/password delimiter, so a colon-bearing username can't authenticate as declared; `sbx kit validate` rejects it.
Whenever `apiKey.name` is set (v1 or v2) it MUST be a valid shell identifier — letters, digits, underscores, not starting with a digit — since the engine uses it as an environment-variable name. Setting `name` alone does not populate it in-container: the engine only derives the sentinel when `proxyManaged: true` is also set. An empty `name` on a v2 spec is a legitimate shape too — the credential is handled entirely proxy-side, with no in-container environment variable — so `sbx kit validate` warns rather than rejects it.
### OAuth shape
```yaml
credentials:
- service: anthropic
oauth:
tokenEndpoint:
host: platform.claude.com # required
path: /v1/oauth/token # required
sentinels:
accessToken: sk-ant-oat01-proxy-managed # required unless passthrough is set
refreshToken: sk-ant-ort01-proxy-managed # required unless passthrough is set
credentialFile: # optional
path: "~/.claude/.credentials.json"
structure: # declarative JSON shape (preferred)
claudeAiOauth:
accessToken: "{{.AccessToken}}"
refreshToken: "{{.RefreshToken}}"
responseFields: # optional — for non-standard OAuth responses
accessToken: "access_token" # default values shown; override for camelCase / non-standard names
refreshToken: "refresh_token"
expiresIn: "expires_in"
scope: "scope"
# passthrough: true # opt-out of sentinel masking — see below
```
**`credentialFile.structure`** is a declarative JSON map with `{{.AccessToken}}` / `{{.RefreshToken}}` / `{{.ExpiresAt}}` / `{{.Scopes}}` placeholders. `ExpiresAt` is a Unix-millisecond timestamp. The engine encodes the map as JSON, then substitutes placeholders — output is guaranteed well-formed.
**`responseFields`** maps logical OAuth token field names to the actual JSON field names returned by the token endpoint. Defaults match the OAuth 2.0 RFC (`access_token`, `refresh_token`, `expires_in`, `scope`); set explicit overrides for providers that use camelCase or vendor-specific names.
**`passthrough: true`** bypasses sentinel masking: the proxy returns the real OAuth response to the container instead of swapping in sentinels. This is a security downgrade (the container sees the real token), so say why the kit needs it in the PR description; the typical reason is a provider that returns a JWT the agent must inspect locally. When `passthrough: true`, the validator stops requiring `sentinels`. There is no `passthroughReason` field in the schema.
A credential entry can declare **both** `apiKey` and `oauth`. The precedence rule is: **api key wins when found**. If no API key value is present on the host, the user can authenticate via OAuth (e.g. `/login`). Setting both lets the kit support either auth method without the kit author choosing one.
## `permissions` — capability grants
The top-level capability-grant block. It is the v2 home for what v1 spelled as the top-level `network:` block (and what an earlier v2 draft spelled `caps:`). Today it carries only `network`.
### `permissions.network` (P2 / P3 extended)
Declares the egress allow / deny lists. Replaces the v1 `network` block entirely.
```yaml
permissions:
network:
allow:
- "*.anthropic.com"
- "registry.npmjs.org"
- "api.example.com:443" # exact + port also accepted
deny:
- "telemetry.example.com"
```
Entry formats:
| Pattern | Example | Matches | Status |
|---|---|---|---|
| `<domain>` | `api.example.com` | Exact host, no port — matches any port | **P2 — implemented** |
| `<domain>:<port>` | `api.example.com:8080` | Exact host, specific port | **P2 — implemented** |
| `*.<domain>` | `*.example.com` | Exactly one DNS label (e.g. `api.example.com`, `cdn.example.com`). Does **not** match `example.com` itself or `a.b.example.com`. | **P2 — implemented** |
| `**.<domain>` | `**.example.com` | One or more DNS labels (e.g. `api.example.com`, `a.b.example.com`). | **Enforced** |
| `<domain>:<lo>-<hi>` | `api.example.com:80-443` | Port range | **P3 — pending**; never matches a request |
| `<domain>:*` | `api.example.com:*` | Port wildcard | **Enforced** — identical to omitting the port |
| CIDR | `10.0.0.0/8` | IP block | **P3 — pending** |
**Deny precedence.** When the same host matches both `allow` and `deny`, **deny wins** — the request is rejected. Overlap is legal (and intentional: a parent kit can allow `*.example.com` while a child or mixin denies `telemetry.example.com`).
**All-domains-declared rule.** Every domain a credential injects into MUST appear in `permissions.network.allow`. There is no auto-derived egress — see the `credentials` validation note above.
Composition: allow / deny lists append across kits. Use `sbx policy log <sandbox>` to see what got through.
## `ports` (top-level)
Ports the kit wants the sandbox runtime to publish on the host when the sandbox starts. (This is the v2 home for what v1 spelled as `network.publishedPorts` / top-level `publishedPorts`.)
```yaml
ports:
- container: 8080
protocol: tcp # "tcp" (default) or "udp"
name: web # informational label for `sbx ports`
- container: 9418 # git-daemon
- container: 53
protocol: udp
name: dns
```
Host port allocation is **always ephemeral** on `127.0.0.1`. Users wanting a pinned host port still use `sbx ports --publish <host>:<container>` on top of the kit's declaration. A kit can't pick a host port because two kits requesting the same one would collide on the user's machine.
Port publishing is **inbound service exposure** — a separate concern from outbound egress under `permissions.network`.
## `environment` (P2)
The block is **P2** because v2 removed its `proxyManaged` field as part of the credentials redesign — the proxy-managed semantic now lives on `credentials[].apiKey.proxyManaged` (paired with `apiKey.name`).
```yaml
environment:
variables:
IS_SANDBOX: "1" # static, keys must be [A-Za-z_][A-Za-z0-9_]*
```
Composition: `variables` union with last-wins.
The proxy-managed env-var semantic that lived under `environment.proxyManaged` in v1 now lives on `credentials[].apiKey.proxyManaged`. There's no `proxyManaged` list to maintain separately.
### Reserved env-var prefixes
Kits MUST NOT declare environment variables — neither in `environment.variables` nor as `credentials[].apiKey.name` — that start with these prefixes. They're reserved for the host runtime:
| Prefix | Reserved for |
|---|---|
| `DASH_*` | dash runtime internals |
| `SBX_*` | sandboxes runtime internals |
| `DOCKER_*` | Docker runtime |
Setting one is a validation error.
The runtime also **warns** (not rejects) on `HOME`, `USER`, `SHELL`, `LD_PRELOAD`, `LD_LIBRARY_PATH`, and `PATH` because the runtime may override these values. Set them only when you know what you're doing.
## `setup`
Three lists. All optional. (This is the v2 home for what v1 spelled as `commands`; `commands.initFiles` is now `setup.files`.)
```yaml
setup:
install: # runs once before startup, synchronous
- command: "curl -fsSL https://claude.ai/install.sh | bash" # string ONLY
user: "0" # default "0" (root)
description: Install Claude Code
startup: # runs at every container start
- command: ["sh", "-c", "apt-get update -qq -y &"] # string OR list[string]
user: "1000" # default "1000" (agent)
background: false # default false
description: ...
files: # files written at startup via shell exec
- path: /home/agent/.copilot/config.json # absolute
content: '{"trusted_folders": ["${WORKDIR}"]}'
mode: "0644" # octal string
onlyIfMissing: true # skip if file exists (e.g. persistent volume)
description: ...
```
### Command-type contract
| Field | Type | How it executes |
|---|---|---|
| `install[].command` | **`string` only** | Runs via `sh -c <string>`. Shell metachars (`&&`, `\|\|`, `;`, `\|`, redirects) work as written. A list form is a validation error. |
| `startup[].command` | **`string` OR `list[string]`** | String form runs via `sh -c`; list form runs as `exec`-style argv with no shell processing. Use the list form when you need to avoid shell quoting issues; do **not** put shell metachars as bare argv tokens (e.g. `["apt-get", "update", "&&", "apt-get", "install", …]` will pass `&&` to `apt-get` literally). The canonical pattern for "list form but I need a shell" is `["sh", "-c", "<shell command>"]`. |
| `files[].path` / `content` / `mode` / `onlyIfMissing` | strings / bool | Written via shell exec as uid `1000` (agent). |
### Other rules
- Placeholders supported only in `files[].content`: **`${WORKDIR}`**. Anything else fails validation.
- `install` user defaults to `"0"` (root); `startup` user defaults to `"1000"` (agent).
- `files` paths must be writable by uid `1000`. To write to a root-owned path such as `/etc`, use an `install` command instead and set ownership appropriately if the agent must modify the file later.
- `startup.background: true` detaches the command; the runtime moves on without waiting.
Composition: all three lists **concatenate** in `--kit` order. `install` runs for every kit, built-in or user-supplied — use `command -v <binary>` guards or `setup.files` with `onlyIfMissing: true` to keep it idempotent.
## `settings` — **removed in v2**
The v1 `settings` block (with its `containerSettings` map) hardcoded agent-specific setup. v2 removes it entirely. If you need to write an agent-specific configuration file at startup, use `setup.files` instead — that's the migration path.
A v1 kit that still ships `settings:` will load via the legacy shims, but the field has no v2 home; see [`v1-migration.md`](v1-migration.md) for the recipe.
## `volumes`
A single list. Each entry's `type` selects the backing storage.
```yaml
volumes:
- path: /workspace # absolute path inside the container
# type: "" # default — block-backed volume
size: 10g
mode: "0755"
- path: /tmp/scratch
type: tmpfs # RAM-backed mount
size: 512m
mode: "1777"
```
Composition: union by `path`; same `path` in two kits with different shapes follows last-wins.
## `agentInstructions` (top-level)
Groups the agent-instruction fields. Both are optional.
```yaml
agentInstructions:
filename: CLAUDE.md # the AI profile filename this sandbox owns (sandbox kits only)
content: | # markdown appended to the AI profile
This kit exposes a PostgreSQL MCP server. To use it, ensure DATABASE_URL
is set in the container environment, then call tools under the `postgres`
namespace from the agent.
```
`agentInstructions` is the v2 home for two v1 surfaces: `sandbox.aiFilename` (now `filename`) and the top-level `agentContext:` / v1 `memory:` field (now `content`).
### `filename` — the AI profile the sandbox owns
`filename` is the AI profile file (e.g. `CLAUDE.md`, `GEMINI.md`) the agent reads every session. It is only meaningful for a **`kind: sandbox`** kit. A mixin cannot own the profile filename — if a mixin sets `filename`, it is **ignored** (a warning on `Artifact.Warnings`, not an error), because a mixin contributes _to_ the base sandbox's profile rather than defining one.
### `content` — rendered instructions
**For a base `kind: sandbox` kit**: `content` is rendered **inline** in the AI profile file at sandbox creation. Loaded into the agent's context every session. Ignored when `filename` is unset.
**For a `kind: mixin`**: `content` is written to a separate file under `<dir-of-AIFile>/kits-memory/<kit-name>.md` and **not** inlined into the AI file. The AI file gets a sentinel-wrapped `## Kits` section pointing the agent at that directory. This is **progressive disclosure** — the agent reads kit context on demand, not at startup, so adding many kits does not bloat initial context.
The per-kit file is overwritten on every (re)write — there is no version field in the manifest today, so "what's in the file = what the kit currently provides" is the contract.
Progressive disclosure is a behavioral bet on the agent: it must read the `## Kits` section and follow the pointer when it needs a kit's docs. Claude does this reliably. Other agents may need behavioral verification.
## `files/` directory
```
my-kit/
├── spec.yaml
└── files/
├── home/
│ └── .claude/config.json → /home/agent/.claude/config.json
└── workspace/
└── .mcp/postgres.json → <workspace>/.mcp/postgres.json
```
For user kits, packed into the artifact and copied into the container at create time. Absolute paths and `..` traversal are rejected at validation. Symlinks must stay inside the artifact root.
Only `files/home/` and `files/workspace/` are recognized targets. Any other subdirectory under `files/` (e.g. `files/etc/`, `files/tmp/`) is **ignored with a warning** — kits cannot write outside the agent home or workspace.
Composition: overlay map keyed by `target:relativePath`. Later kits override earlier at the same path.
**Timing:** `files/home/<path>` writes alongside the other kit customizers at container start. `files/workspace/<path>` writes **after** the workspace is populated — including the in-container `git clone` under `sbx run --clone` — so the file always lands inside the materialised working copy. See [lifecycle step 7](lifecycle.md) for the underlying mechanism.
A `files/workspace/<path>` whose relative path matches a real file in the user's repo overlays that file — silently overwriting it on every sandbox start. Overlay is the intended semantic, but see [`pitfalls.md`](pitfalls.md) for the data-loss consideration.
## Validation cheat sheet
Run before committing:
```bash
sbx kit validate ./my-kit/
```
Or in tests, `spec.LoadFromDirectory(...)` calls `ValidateArtifact` internally; failure returns a descriptive error.
**Unknown-fields rule.** Unknown fields cause a validation error **everywhere** in the spec — implementations MUST NOT silently ignore unrecognized fields. A typo like `credenta:` or `permissions.netwrok:` is rejected at load time. This is by design: it catches typos early and consistently. The v2 decoder is a **clean grammar with no legacy shims**: a v1 field (`agent:`, `caps:`, `commands:`, `agentContext:`, `network:`, `sandbox.aiFilename`, `sandbox.entrypoint.run`, …) appearing in a `schemaVersion: "2"` spec is a hard error, not a silent fold. Use `schemaVersion: "1"` (which loads via the legacy shims) or run the migrate script.
Validation errors include the field path, the invalid value, and an actionable suggestion when one applies.
## Loading a v1 spec.yaml
v1 spec.yaml files keep loading via the legacy shims. See [`v1-migration.md`](v1-migration.md) for the per-surface mappings, the `Artifact.Warnings` channel, and the `migrate-v1-to-v2.go` script.
topics/testing.md
# Testing Kits
Four layers. Run **all four locally** before opening a PR — only the first two run on CI for fork PRs.
**Why fork contributors must run e2e locally.** The repo's CI e2e legs (`e2e-release`, which gates the PR, and the informational `e2e-nightly`/`e2e-rc`) need `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` to pull the template image, and GitHub does not expose secrets to workflows triggered from forks. So if you're contributing from a fork (the common case), the e2e legs are **skipped silently** on your PR — the reviewer sees a green check that does not include `TestE2EKit`. The only place those assertions ever run is on your laptop. See [`.github/workflows/tck.yml`](../../../.github/workflows/tck.yml) and the "Running in CI" note in [the README](../../../README.md#running-in-ci).
## 1. Spec-level validation
Fastest feedback. No Docker required.
```bash
sbx kit validate ./my-kit/
sbx kit inspect ./my-kit/ --output json | jq
```
Validation runs automatically inside every `spec.Load*` path. If a kit cannot pass `validate`, no other layer will work.
## 2. TCK (Technology Compatibility Kit)
This repository ships a TCK package at [`tck/`](../../../tck/). It validates:
1. Spec parses with required fields
2. Network policy (`permissions.network` allow/deny entries are well-formed)
3. Credentials (each `credentials[]` entry is well-formed; injection rules valid)
4. Environment variables (declared, set in container)
5. Setup (`setup.install` / `setup.startup` are well-formed)
6. Container files (files from `files/` are injected at the correct paths)
7. Volumes (block-backed and `type: tmpfs` entries — plus the implicit `/run/secrets` tmpfs)
8. Published ports (`ports[]` entries validate)
9. Arguments (in `spec.yaml` scalars outside `args:`, and in every regular file under `files/`, each `${{ kit.args.<name> }}` reference names a declared argument and resolves to a constraint-satisfying value — checked as the suite loads, before any subtest runs)
### Writing a TCK test
You **don't** write a per-kit test file. The shared `TestKitTCK` in [`tck/kit_test.go`](../../../tck/kit_test.go) reads a `KIT` env var pointing at your kit directory and runs the full suite. The pattern, if you want to invoke it programmatically:
```go
import "github.com/docker/sbx-kits-contrib/tck"
suite, err := tck.NewSuiteFromDir("./my-kit")
require.NoError(t, err)
suite.RunAll(t)
```
### Schema version awareness
The TCK loads via `spec.LoadFromDirectory`, so it accepts both v1 and v2 spec.yaml. For v1 kits, `Artifact.Warnings` is populated during load — the TCK does **not** fail on warnings, but a clean kit should aim for an empty warnings slice. The e2e wrapper script asserts a green run end-to-end including warning cleanup.
### Extending a non-default parent
For mixins that `extends:` a non-default parent agent, pass the parent's template image explicitly:
```go
suite, err := tck.NewSuiteFromDir(".", tck.WithImage("my-custom/template:latest"))
```
The TCK auto-resolves well-known parent agents (shell, shell-docker, claude, codex, copilot, cursor, docker-agent, droid, gemini, kiro, opencode) via the images their kits publish.
### Kits that declare `args`
A kit that declares [`args`](spec-anatomy.md#args) is installed with values someone supplies. The TCK is that someone: it substitutes every `${{ kit.args.<name> }}` reference in `spec.yaml` and in the regular files under `files/` before the spec is decoded, which is why an argument works in a field the schema pattern-checks, such as `requires.agent`. [SPEC-v2 §2.1](../../../spec/SPEC-v2.md#21-args) is the reference for what counts as a reference and what does not — a key, the `args:` block itself, another namespace, a substituted value.
Each value comes from the `args:` block of your kit's `testdata/tck.yaml`, or from the argument's declared `default` when that block says nothing about it:
```yaml
# my-kit/spec.yaml
args:
channel:
default: "stable"
enum: ["stable", "nightly"]
token:
required: true
description: "API token"
setup:
install:
- command: "install-my-tool --channel '${{ kit.args.channel }}'"
```
```yaml
# my-kit/testdata/tck.yaml
args:
token: "dummy-for-ci"
```
An argument declared `required: true` has no default to fall back on, so the TCK **fails** the kit until `testdata/tck.yaml` supplies a value — naming the argument and the file. That is deliberate: a skip here is how a kit ends up shipping with nothing testing it. Pick a value that is safe in a public repo and good enough for the install to run; the TCK container has no real credentials either way.
| Situation | Result |
|---|---|
| `tck.yaml` supplies a value | That value is substituted |
| No value, argument has a `default` | The default is substituted |
| No value, argument is `required` | **FAIL**, naming the argument and `testdata/tck.yaml` |
| Value violates the argument's `enum` or `pattern` | **FAIL**, naming the value and the constraint |
| A reference no `args:` entry declares | **FAIL**, naming the file and the reference |
| A reference that does not parse — a dotted name, a missing `}}` | **FAIL**, naming the file and the offending text |
| A `spec.yaml` mapping key naming an argument, escaped or not | **FAIL**, naming the key |
Values are strings, and quoting the placeholder in `spec.yaml` is what keeps the substituted value one: leave it unquoted in a field that takes a number and `1.20` is read as a number, which is what you want there and not what you want in a string field.
The e2e layer passes the very same `testdata/tck.yaml` values to `sbx create`, using its repeatable `--kit-arg <kit-name>.name=value` flag, so both layers install the kit with identical values. Arguments you leave to their defaults need no entry — the create resolves those the same way the TCK does.
### Running TCK
```bash
# From inside the kit directory:
cd my-kit
../scripts/test-kit.sh
# Or from the repo root, naming the kit:
./scripts/test-kit.sh my-kit
# Or invoke go test directly:
KIT="$PWD/my-kit" go test -v -count=1 -timeout 10m -run TestKitTCK ./tck/...
```
`KIT` must be an absolute path because `go test` runs binaries with `cwd` set to the package directory (`./tck/`).
Requires Docker (or `docker-next` inside `sbx`).
## 3. End-to-end (e2e) tests
**Required before opening a PR from a fork** (see the "Why fork contributors must run e2e locally" note at the top of this file). The e2e layer boots a **real `sbx` sandbox** from the kit and verifies the kit's content actually landed inside the running container. It catches things the default TCK can't — install commands that fail under the non-root agent user, `${WORKDIR}` placeholders that resolve differently than expected, agent-kit name mismatches, or memory blocks the engine never writes out.
```bash
# From inside the kit's directory:
cd my-kit
../scripts/test-kit-e2e.sh
# Or from the repo root, naming the kit:
./scripts/test-kit-e2e.sh my-kit
# Or invoke go test directly:
KIT_UNDER_TEST="$PWD/my-kit" \
go test -tags=e2e -v -timeout 25m -count=1 ./tck/...
```
For a kit that ships a `Dockerfile`, the script builds that image and side-loads it into the scoped daemon before creating the sandbox, so e2e runs against the branch's image rather than a published tag. It then creates the sandbox with `--pull=never`, controlled by `SBX_E2E_PULL_POLICY` (`never` by default after a side-load, unset otherwise) — set it explicitly to override.
Prerequisites: `sbx` on `PATH`, authenticated against Docker Hub, Linux with `/dev/kvm` accessible. See the repository [README](../../../README.md#end-to-end-e2e-tests) for the full setup and the precise assertions performed.
### `TestE2EKit` — the single e2e test
There is one e2e test function, `TestE2EKit`, that handles all kit kinds. It creates one sandbox and runs subtests selectively:
| Subtest | Applies to | What it checks |
|---|---|---|
| `env` | all kits | declared `environment.variables` are set in the container |
| `files` | all kits | files from `files/home` and `setup.files` exist and are non-empty |
| `tmpfs` | all kits | declared tmpfs paths (plus `/run/secrets`) are mounted |
| `agentContext` | all kits | `agentInstructions.content` rendered into the AI profile file (skipped when undeclared) |
| `prompt` | `kind: sandbox` only | non-interactive prompt sent to the agent; asserts non-empty response |
Every `sbx` call carries `--app-name sbx-kits-contrib-tck` so all commands route to the same authenticated daemon instance.
### `testdata/tck.yaml` — kit-specific e2e config
`kind: sandbox` kits **should** ship a `testdata/tck.yaml` file alongside their `spec.yaml` to opt in to the `prompt` subtest, which only exists for `kind: sandbox` kits (see the table above). The file is optional there too — the subtest is simply absent when the file is missing or `promptArgs` is empty. Kits whose agent requires a long async installation (e.g. nanoclaw, hermes-agent) may omit it until the installation reliably completes within the test timeout.
The file itself is not sandbox-only, though: a `kind: mixin` kit ships one too when it needs `requiresHostCredentials` (see "Kits whose agent needs a host-stored credential" below) or `args` (see ["Kits that declare `args`"](#kits-that-declare-args)). Only `promptArgs`, `readyFile`, `binary`, and `extractedFromBuiltin` are meaningful solely for `kind: sandbox`.
**Full schema:**
```yaml
# <kit-dir>/testdata/tck.yaml
# promptArgs: arguments prepended before the prompt message when invoking the
# agent binary non-interactively. The test runs:
# sbx exec <sandbox> -- <binary> <promptArgs...> "what version are you running"
# Absent or empty promptArgs → prompt subtest does not run (e.g. trivy).
promptArgs: ["-p"]
# readyFile: absolute path of a sentinel file written inside the sandbox when a
# background installation finishes. When set, TestE2EKit polls
# `sbx exec -- test -f <readyFile>` before running the prompt subtest.
# Leave absent for kits whose install commands run synchronously inside sbx create.
readyFile: "/home/agent/nanoclaw/.installed"
# binary: override the agent binary name used in `sbx exec`. When absent, the
# test uses filepath.Base(Manifest.Binary), which the spec loader derives
# automatically from sandbox.entrypoint[0]. Only set this when the
# entrypoint is a wrapper script whose underlying binary has a different name.
binary: "claude"
# extractedFromBuiltin: set this only on a kind:sandbox kit whose name is still
# that of an agent built into sbx. See "Kits that replace a built-in agent".
extractedFromBuiltin: true
# requiresHostCredentials: sbx secret service name(s) that must already be
# stored on the host before this kit's agent can be created at all. See
# "Kits whose agent needs a host-stored credential".
requiresHostCredentials: ["bedrock"]
# args: values for the arguments spec.yaml declares. Both the TCK layer and
# e2e install the kit with these — see "Kits that declare `args`" above.
args:
token: "dummy-for-ci"
```
#### Kits that replace a built-in agent
sbx refuses to load a kit whose name collides with a built-in agent:
```
ERROR: agent "kiro" is already registered (built-in agents cannot be overridden by a kit)
```
The check is unconditional: `sbx` exposes no flag or environment variable to let a kit take
precedence, and a built-in's deprecated aliases are refused as well. That creates a
chicken-and-egg when a built-in agent is being moved out into a kit: the kit cannot pass e2e
until a released `sbx` has dropped the built-in, but the kit is normally reviewed first.
`extractedFromBuiltin: true` breaks the cycle, for a `kind: sandbox` kit only: on that
specific collision, the test copies the kit to a temp directory, renames it `<name>-e2e`,
and retries `sbx create` from the copy. The run then covers the same subtests it would
under the real name.
| Situation | Result |
|---|---|
| Collision, flag set | Retries from a temp copy renamed `<name>-e2e`, then runs the same subtests it would under the real name, plus a `NOTICE` that the run used the renamed copy |
| Collision, flag absent | **FAIL** — almost always a kit that took a built-in's name by accident |
| No collision, flag set | Runs in full, plus a `NOTICE` that the flag is obsolete if the kit ran under its real name |
| No collision, no flag | Runs in full (the normal case) |
| Collision again under the renamed copy | **FAIL** — a genuine misnaming |
It is deliberately narrow: only the collision error triggers the rename-and-retry, every
other failure still fails, and the flag still self-obsoletes — once the built-in is gone,
no collision occurs and the test logs the obsolete-flag notice instead. Delete the line
when you see it. A passing run under the renamed copy does not prove `sbx` accepts the kit
under its real name; that's proven once a released `sbx` drops the built-in.
#### Kits whose agent needs a host-stored credential
Some agents cannot be created at all unless a credential is already stored on the host via
`sbx --app-name sbx-kits-contrib-tck secret set <service>`. `aidlc-claude`'s `claude-bedrock`
affinity is the current example: `sbx create` for that agent needs an AWS profile stored under
the `bedrock` service, and CI stores no such per-agent cloud credentials.
`requiresHostCredentials: ["bedrock"]` declares that dependency. Before `sbx create` runs, the
test probes `sbx --app-name sbx-kits-contrib-tck secret ls --service <service> --json` for each
declared service instead of matching `sbx create`'s failure text — that failure gives no
indication of a missing credential, so string-matching it would be fragile:
| Situation | Result |
|---|---|
| Declared service not stored | `SKIP`, naming the service and the scoped `sbx secret set` command |
| Declared service stored | Runs in full |
| Probe fails or is unparseable | **FAIL** |
It is deliberately not a blanket "skip e2e for this kit": only kits that declare the field are
affected, and only a missing credential produces a skip — every other failure still fails.
Unlike `extractedFromBuiltin`, this does not self-obsolete. The agent keeps needing the
credential indefinitely, so the entry stays for as long as the kit declares this agent
affinity — there is no obsolete-flag notice to watch for here.
The check applies to any kit kind — `aidlc-claude` is `kind: mixin` — since it runs before
`sbx create` regardless of kind.
#### How `binary` is resolved
The spec loader sets `Manifest.Binary = sandbox.entrypoint[0]` when loading any kit (from `sandbox.entrypoint.run[0]` on the v1 path). The e2e test uses `filepath.Base(Manifest.Binary)` as the default binary name. You **do not** need to set `binary:` in `tck.yaml` unless the entrypoint is a wrapper script whose name differs from the real binary:
| Kit | Entrypoint `[0]` | Derived binary | `tck.yaml binary` needed? |
|---|---|---|---|
| `amp`, `crush`, `junie`, `nanobot`, `pi` | same as kit name | same as kit name | no |
| `opencode-model-runner` | `opencode` | `opencode` | no |
| `claude-ollama` | `/home/agent/.local/bin/claude-ollama` | `claude-ollama` (wrapper) | yes → `claude` |
| `nanoclaw` | `/usr/local/bin/nanoclaw-start` | `nanoclaw-start` (wrapper) | yes → `claude` |
| `hermes-agent` | `/usr/local/bin/hermes-start` | `hermes-start` (wrapper) | yes → `hermes` |
| `openclaw` | `/usr/local/bin/openclaw-start` | `openclaw-start` (wrapper) | yes → `openclaw` |
Do **not** add `binary:` to `spec.yaml` — the loader rejects `binary` at the flat manifest level (v1-only field); it must come from `sandbox.entrypoint[0]`.
#### `promptArgs` reference
| Agent | `promptArgs` | Notes |
|---|---|---|
| `claude`, `nanoclaw`, `claude-ollama`, `pi` | `["-p"]` | Claude Code / pi use `-p` for non-interactive prompt |
| `nanobot` | `["-m"]` | |
| `amp` | `["-x"]` | `-x` / `--execute` |
| `crush` | `["run"]` | subcommand, prompt is a positional arg |
| `hermes` | `["chat", "-q"]` | `-q` / `--query` under the `chat` subcommand |
| `junie` | `["--task"]` | non-interactive task flag |
| `openclaw` | `["agent", "--message"]` | `agent` subcommand with `--message` flag |
| `opencode` | `["run"]` | `run` subcommand, prompt is a positional arg |
| `trivy` | *(absent)* | security scanner — no chat mode, prompt subtest skipped |
`TestE2ERunAgent` skips any kit whose `tck.yaml` is absent or has no `promptArgs`.
### Running e2e
```bash
cd my-kit
../scripts/test-kit-e2e.sh
```
That's the whole recipe — no manual policy dance. The script:
- Scopes every `sbx` call to `--app-name sbx-kits-contrib-tck`, the same app-name the e2e harness uses internally ([`tck/e2e_test.go:415`](../../../tck/e2e_test.go#L415)). The test daemon's sandboxes, policy, secrets, and cache are isolated from your day-to-day sbx state.
- Sets the scoped daemon's default network policy to `deny-all` — the same baseline CI uses, so any host your install or startup hooks reach for must be in `permissions.network.allow` or the request is blocked.
- Runs `go test -tags=e2e` with `KIT_UNDER_TEST` exported.
- On non-zero exit, prints a hint pointing at `sbx --app-name sbx-kits-contrib-tck policy log <sandbox>`.
The script is idempotent (re-runs converge on the same state) and non-interactive. Overrides: `APP_NAME` (default `sbx-kits-contrib-tck`) and `POLICY` (default `deny-all`; set `POLICY=` to skip the policy step).
One-time setup per machine — the scoped daemon has its own credential store:
```bash
sbx --app-name sbx-kits-contrib-tck login
```
When the test fails, the recurring fix is the same loop: read the proxy log, add the blocked host to `permissions.network.allow`, re-run.
```bash
APP=sbx-kits-contrib-tck
sbx --app-name $APP ls # find the tck-e2e-* sandbox
sbx --app-name $APP policy log tck-e2e-<short-uuid>
```
Every row under `Blocked requests` is a host your kit reached for under `deny-all`. Add the host (column `HOST`, e.g. `download.docker.com:443`) to `permissions.network.allow` and re-run until the block list is empty *and* the e2e test passes.
If the scoped daemon ever gets wedged: `sbx --app-name sbx-kits-contrib-tck reset --force` wipes only that daemon's state — your main sbx is untouched.
Common hosts that surface only under `deny-all` (easy to forget):
- `download.docker.com` and `cli.github.com` — pre-added to apt sources on both shell bases (and the `*-docker` templates); any `apt-get update` re-fetches them even if you're installing from Ubuntu's main archive.
- `archive.ubuntu.com` + `security.ubuntu.com` (amd64) **and** `ports.ubuntu.com` (arm64) — list all three so the kit works on both CI (amd64) and Apple Silicon.
- Registry hosts for each package manager you call: `registry.npmjs.org`, `pypi.org` + `files.pythonhosted.org`, `crates.io` + `static.crates.io`, `proxy.golang.org` + `sum.golang.org`, etc.
## 4. End-to-end manual verification
For mixins and any time you want to see real container behavior:
```bash
# 1. Create a sandbox with the kit
sbx run claude --kit ./my-kit/ --name probe .
# 2. Verify the binary / file / env you expect
sbx exec probe -- which my-binary
sbx exec probe -- cat /home/agent/.my-kit/config.json
sbx exec probe -- printenv MY_VAR
# 3. Trigger a real outbound request and confirm proxy enforcement
sbx exec probe -- curl -sS https://api.myservice.com/health
# 4. Clean up
sbx rm probe
```
For mutable changes you can also use `sbx kit add` on an existing sandbox:
```bash
sbx kit add probe ./my-kit/
```
Faster iteration loop, but immutable settings (privileged, volumes, tmpfs) won't apply — recreate for those.
## Verifying `permissions.network.allow`
The proxy enforces allow/deny at request time. The fastest way to surface exactly what your kit reaches for is to run the e2e suite — see [Running e2e](#running-e2e) above. The script applies `deny-all` to the scoped daemon for you.
For ad-hoc probing of a single sandbox without running e2e, `sbx policy log` works directly:
```bash
sbx policy log <sandbox>
```
Every entry in the "Blocked requests" section is a domain your install or startup hook reached for. Add it to `permissions.network.allow` (or accept the block) and re-probe. The repository [README](../../../README.md#declare-every-domain-your-kit-needs) has the hand-built probe-sandbox variant of this recipe.
## Common pitfall: "install commands completed" ≠ success
`Install commands completed` in the create output only proves **exit code 0**, not that the install did the right thing. Always verify with a real check:
```bash
sbx exec probe -- <binary> --version # binary on PATH
sbx exec probe -- test -f /expected/file # file present
sbx exec probe -- printenv EXPECTED_VAR # env var set
```
A broken install pipe can still exit 0 (e.g., `curl | bash` where the curl fails after partial output but bash exits 0 on empty input). Verify outcomes, not exit codes.
## `setup.startup` runs on every container start
Startup commands run on **every** container start (initial create, stop/start cycles, daemon restarts, host reboots), not just once at creation. Author them to be **idempotent**: use patterns like `apt-get update -qq -y > /dev/null 2>&1 || true &` and `mkdir -p '<dir>'`. Your test should assert the post-start state, not "did the command run exactly once".
See [Pitfalls — `setup.startup` runs on every container start](pitfalls.md#2-setupstartup-runs-on-every-container-start) for the mechanism.
## CI
The repository's CI runs the TCK on every PR — the matrix tests only the modified kit on PRs that touch a kit directory, and every kit on PRs that touch `tck/` or `spec/`. Three e2e legs exercise every detected kit against a real `sbx` CLI: `e2e-release` (latest tagged release, gates the PR via the required `e2e` check), `e2e-nightly` (rolling nightly, informational only — never blocks merge), and `e2e-rc` (latest `sbx-releases` prerelease tagged `*-rcN`, also informational only). All three **are skipped on PRs opened from forks** because the `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` secrets aren't exposed to fork-triggered workflows. Fork contributors are the common case, so you should treat e2e + `deny-all` as a **mandatory local step** before opening the PR — don't rely on a green CI check to mean "e2e passed". See [`.github/workflows/tck.yml`](../../../.github/workflows/tck.yml) and the reusable [`.github/workflows/e2e.yml`](../../../.github/workflows/e2e.yml).
topics/v1-migration.md
# v1 → v2 Migration
v2 is a **breaking grammar change**, not an additive one. The loader forks on `schemaVersion`: a `schemaVersion: "2"` spec is decoded by a clean v2 grammar with no legacy shims, while `schemaVersion: "1"` still loads through the normalize layer, which folds each legacy surface onto the same canonical model the v2 grammar targets and appends one entry per legacy block to `Artifact.Warnings`. The Phase 6 schema cutover removes the v1 shims — at that point v1 spec.yaml stops loading.
Because a v1 field in a `"2"` spec is a **hard decode error** (not a silent fold), you cannot mix grammars: pick `"1"` (legacy, with warnings) or `"2"` (clean), and run the migrate script to move from one to the other.
Until cutover: write new kits in v2, run the migrate script on existing kits, and treat `Artifact.Warnings` as a TODO list.
## Mechanical migration
```bash
go run scripts/migrate-v1-to-v2.go ~/path/to/my-kit
```
Rewrites `spec.yaml` in place and leaves `spec.yaml.bak` as the original. Running on an already-v2 spec is a no-op and does not produce a `.bak`. Refuses to clobber an existing `.bak`.
The script loads your spec through the **same** spec-package normalize pass the engine uses, then re-emits the canonical result as v2 — so it stays in lockstep with the loader. Coverage:
| v1 spelling | v2 spelling |
|---|---|
| `kind: agent` | `kind: sandbox` |
| `agent:` block | `sandbox:` block |
| `sandbox.aiFilename` | `agentInstructions.filename` |
| `memory:` / `agentContext:` | `agentInstructions.content` |
| `sandbox.entrypoint.run` | `sandbox.entrypoint` (flat array) |
| `sandbox.entrypoint.args` | `sandbox.command.default` |
| `sandbox.entrypoint.ttyArgs` | `sandbox.command.interactive` |
| `sandbox.entrypoint.pipeMode` | dropped (no v2 equivalent) |
| `sandbox.resources.memoryMB` | `sandbox.resources.memory` (byte-size string) |
| `credentials.sources` + `network.serviceDomains` + `network.serviceAuth` + `environment.proxyManaged` | unified `credentials[]` (`apiKey.name` + `.inject`) |
| standalone `oauth:` | `credentials[].oauth` |
| `network.allowedDomains` | `permissions.network.allow` |
| `network.deniedDomains` | `permissions.network.deny` |
| `network.publishedPorts` / top-level `publishedPorts` | top-level `ports` |
| `commands:` / `commands.initFiles` | `setup:` / `setup.files` |
| `settings:` | dropped (move agent setup to `setup.files`) |
Check [`scripts/README.md`](../../../scripts/README.md) for current scope.
## Manual migration — by surface
For everything the script doesn't cover yet, here's the v1 form, the v2 equivalent, and what `normalize` does with each.
### Kind, sandbox, and agent instructions — migrate script handles these
```yaml
# v1
schemaVersion: "1"
kind: agent
agent:
image: docker/sandbox-templates:claude-code
aiFilename: CLAUDE.md
memory: |
Some context.
```
```yaml
# v2
schemaVersion: "2"
kind: sandbox
sandbox:
image: docker/sandbox-templates:claude-code
agentInstructions:
filename: CLAUDE.md
content: |
Some context.
```
Normalize: v1 `agent:` decodes into the LegacyAgent shim, then folds into Sandbox. `sandbox.aiFilename` becomes `agentInstructions.filename` and v1 `memory:` (aka `agentContext:`) becomes `agentInstructions.content` — both moved out of the `sandbox:` block into the top-level `agentInstructions:` block.
### Entrypoint split — migrate script handles this
The v1 `entrypoint:` mapping (`run` / `args` / `ttyArgs` / `pipeMode`) becomes a flat `entrypoint` array plus a `command` split:
```yaml
# v1
sandbox:
entrypoint:
run: [claude, "--dangerously-skip-permissions"]
args: ["-l"]
ttyArgs: []
pipeMode: prepend
```
```yaml
# v2
sandbox:
entrypoint: [claude, "--dangerously-skip-permissions"] # was run
command:
default: ["-l"] # was args
interactive: [] # was ttyArgs
```
`pipeMode` has no v2 home and is dropped. `entrypoint[0]` remains the binary (`Manifest.Binary`).
### Setup block — migrate script handles this
The v1 `commands:` block becomes `setup:`, and `commands.initFiles` becomes `setup.files`. `install` and `startup` keep their shape.
```yaml
# v1
commands:
install:
- command: "curl -fsSL https://example.com/install.sh | bash"
initFiles:
- path: /home/agent/.config/tool.json
content: '{"workdir": "${WORKDIR}"}'
onlyIfMissing: true
```
```yaml
# v2
setup:
install:
- command: "curl -fsSL https://example.com/install.sh | bash"
files:
- path: /home/agent/.config/tool.json
content: '{"workdir": "${WORKDIR}"}'
onlyIfMissing: true
```
### Removed fields (no v2 equivalent)
A handful of v1 fields are removed outright with no v2 home. Strip them by hand:
| v1 field | Reason | What to do |
|---|---|---|
| `kitDir` | Never used; confusing semantics. | Delete the field. |
| `sandbox.persistence` | Parsed but never wired to runtime behavior in v1. | Declare volumes explicitly via `volumes:` instead. |
| `settings` | Hardcoded agent-specific logic; replaced by `setup.files`. | Move the agent-specific setup logic into `setup.files` entries. |
### Volumes redesign (Phase 2) — **manual**, **strict-rejected**
The top-level `tmpfs:` block is **gone**. v1 spec.yaml files that declare it fail strict YAML decoding:
```
artifact: invalid spec.yaml: yaml: unmarshal errors:
line N: field tmpfs not found in type spec.specFile
```
Migrate each entry into `volumes:` with `type: tmpfs`:
```yaml
# v1
tmpfs:
- path: /tmp/scratch
size: 512m
```
```yaml
# v2
volumes:
- path: /tmp/scratch
type: tmpfs
size: 512m
```
Composition of v2 `volumes:` is union by `path` with last-wins on conflicts.
### `ports` promotion — handled by normalize
```yaml
# v1
network:
publishedPorts:
- container: 8080
```
```yaml
# v2
ports:
- container: 8080
```
Normalize promotes `network.publishedPorts` to the top-level `ports` field with a deprecation warning. Port publishing is **inbound service exposure** — a separate concern from outbound egress under `permissions.network`.
### Egress allow/deny → `permissions.network` (Phase 3)
```yaml
# v1
network:
allowedDomains:
- "*.example.com"
deniedDomains:
- "tracker.example.com"
```
```yaml
# v2
permissions:
network:
allow:
- "*.example.com"
deny:
- "tracker.example.com"
```
Normalize folds v1 `network.allowedDomains` / `deniedDomains` into `permissions.network.allow` / `deny` with a deprecation warning. (An earlier v2 draft spelled this block `caps.network`; the current grammar uses `permissions.network`.)
### Credentials unification (Phase 3) — biggest change
The v1 spec had **four overlapping surfaces** describing one credential: `credentials.sources` (map), `network.serviceAuth`, `network.serviceDomains`, `environment.proxyManaged`, plus the standalone `oauth:` block. v2 collapses all four into one entry per service under `credentials[]`.
```yaml
# v1 — four blocks describe one credential
credentials:
sources:
anthropic:
env: [ANTHROPIC_API_KEY]
file:
path: "~/.claude/settings.json"
parser: "json:primaryApiKey"
network:
serviceDomains:
api.anthropic.com: anthropic
serviceAuth:
anthropic:
headerName: x-api-key
valueFormat: "%s"
environment:
proxyManaged:
- ANTHROPIC_API_KEY
```
```yaml
# v2 — one entry per service
credentials:
- service: anthropic
apiKey:
name: ANTHROPIC_API_KEY
proxyManaged: true # required to preserve v1's environment.proxyManaged behavior
inject:
- domain: api.anthropic.com
header: x-api-key
format: "%s"
```
The discovery half (`env: [...]`, `file: {path, parser}`, `priority`) **moves out of the kit** into the user-side [bindings file](bindings.md) at `~/.config/sbx/credentials.yaml`. New principle: the kit declares **what** it needs (service id + injection target); the user declares **where** the credential lives.
v2 also adds `inject[].scheme` as sugar for `header` + `format`: `scheme: bearer` expands to `header: Authorization`, `format: "Bearer %s"`; `scheme: basic` (with a required `username`) marks the entry as HTTP Basic. It is mutually exclusive with a raw `format`. The migrate script emits the explicit `header`/`format` form; switch to `scheme:` by hand if you prefer the shorthand.
`environment.proxyManaged` is gone — each listed env var becomes `apiKey.proxyManaged: true` on the credential named by `apiKey.name` (not `name` alone). Only then does the engine set the env var to the literal `proxy-managed` inside the container, with the sentinel-swap proxy replacing it on outbound requests.
### OAuth folding — Phase 3
```yaml
# v1 — standalone top-level oauth: block
oauth:
service: anthropic
tokenEndpoint:
host: platform.claude.com
path: /v1/oauth/token
sentinels:
accessToken: sk-ant-oat01-proxy-managed
refreshToken: sk-ant-ort01-proxy-managed
credentialFile:
path: "~/.claude/.credentials.json"
template: '{"claudeAiOauth":{"accessToken":"{{.AccessToken}}","refreshToken":"{{.RefreshToken}}"}}'
```
```yaml
# v2 — folded into credentials[].oauth + structure replacing template
credentials:
- service: anthropic
oauth:
tokenEndpoint:
host: platform.claude.com
path: /v1/oauth/token
sentinels:
accessToken: sk-ant-oat01-proxy-managed
refreshToken: sk-ant-ort01-proxy-managed
credentialFile:
path: "~/.claude/.credentials.json"
structure: # declarative JSON shape
claudeAiOauth:
accessToken: "{{.AccessToken}}"
refreshToken: "{{.RefreshToken}}"
```
> The v2 decoder accepts `oauth.skipIfEnv` so migrated specs load unchanged, but the field has no effect for a v2 kit: v2 credential resolution is binding-driven and never skips OAuth because of a host env var (SPEC-v2 §5.4.2). If you need conditional-OAuth behaviour ("use the API key when one is available"), use a `credentials[]` entry with both `apiKey` and `oauth`; the api-key wins when found and OAuth is the fallback.
Normalize matches the v1 standalone `oauth:` block by `service:` to a `credentials[]` entry; if no entry exists yet for that service, normalize synthesizes one.
The `credentialFile.template` (free-form Go `text/template`) is **deprecated** in favour of `credentialFile.structure` (a declarative JSON map with placeholders the engine substitutes deterministically). Both load; when both are set, `structure` wins and `template` emits a deprecation warning. Phase 6 removes `template`.
The v1 `passthroughResponse` field renamed to `passthrough` in v2, same semantic: opts out of sentinel masking, a security downgrade.
## The `Artifact.Warnings` channel
Every fold above produces one entry on `Artifact.Warnings`:
```go
artifact, err := spec.LoadFromDirectory("./my-kit")
for _, w := range artifact.Warnings {
fmt.Println("deprecation:", w)
}
```
`sbx kit inspect` surfaces these in its output. Treat them as actionable — once Phase 6 ships, the underlying field stops loading.
## Phase 6 cutover (future)
When the schema cutover lands:
- The `schemaVersion: "1"` path is removed. The v1 YAML keys (`kind: agent`, `agent:`, `memory:`, `sandbox.aiFilename`, `sandbox.entrypoint.run`/`args`/`ttyArgs`/`pipeMode`, `sandbox.resources.memoryMB`, `network.allowedDomains`/`deniedDomains`/`serviceAuth`/`serviceDomains`/`publishedPorts`, `environment.proxyManaged`, standalone `oauth:`, `credentials.sources`, `commands:`, `settings:`) **stop loading**. Strict decoding rejects them with "field X not found". (A `schemaVersion: "2"` spec already rejects them today — the v2 decoder never had shims.)
- `OAuthCredentialFile.Template` is removed; only `Structure` remains.
- The migrate script is the only escape hatch.
Cutover ships once the released `sbx` CLIs in the wild are v2-aware. Until then, v1 keeps loading.
## Quick check before publishing
```bash
# Convert
go run scripts/migrate-v1-to-v2.go ./my-kit
# Validate
sbx kit validate ./my-kit/
# Confirm there are no warnings left
sbx kit inspect ./my-kit/ --output json | jq '.warnings'
# expected: null or []
```
Empty `warnings` is the green light. Anything else points at a surface the script doesn't cover yet — migrate it by hand per the sections above.