--- name: migrate-kit-to-v3 description: >- Migrates a Docker sandbox kit from the v2 `spec.yaml` grammar to the v3 kit descriptor, then builds it with docker buildx, runs it with sbx, and verifies it with the kit-tck conformance suite. Use when migrating or porting a kit to schemaVersion 3, when converting a v2 spec.yaml / sandbox.image / setup hooks / permissions block into v3 capabilities, when adding a `-mixin` variant of a workload kit, or when asked how to build, run, or conformance-check a v3 kit. --- # Migrate a kit to v3 A v2 kit is one `spec.yaml` plus a `Dockerfile` that builds a named image. A v3 kit is one OCI image: the descriptor declares what the kit needs as typed capabilities and rides in a manifest annotation, the image config carries the runtime contract (entrypoint, env, user, workdir), and the layers carry the content. ## Tooling Three published tools, nothing repo-local: - **`docker buildx`** builds kits. Nothing to install for the kit frontend — BuildKit pulls `docker/sandbox-kit:3` from the descriptor's `# syntax=` line. - **`sbx`** runs them. Install the stable CLI from [Docker Docs](https://docs.docker.com/ai/sandboxes/install/) / [sbx-releases](https://github.com/docker/sbx-releases); current releases support Kits v3 in local and cloud mode. - **`kit-tck`** judges conformance: `go install github.com/docker/sandbox-kit-spec/v3/cmd/kit-tck@latest`, or take an archive from the [releases page](https://github.com/docker/sandbox-kit-spec/releases). (A `go install` of an untagged ref reports `dev`; `@v3.x.y` and the release archives both carry the real version, which `internal/version` reads from the build info.) **This repository is private, so both routes need access to it.** The public module proxy cannot serve it — `proxy.golang.org` answers 404 — so `go install` resolves direct and needs a git credential plus `GOPRIVATE=github.com/docker/*`. In CI that means a token with read access, and a fork's token does not have one: a fork can still validate a descriptor by building it, since the frontend validates during the build, but it cannot run `kit-tck`. If the repository becomes public, that whole constraint disappears. ## Authorities In precedence order: 1. [`spec/`](https://github.com/docker/sandbox-kit-spec/tree/main/spec) — the Go implementation. Where docs and code disagree, code wins. 2. [SPEC-v3.md](https://github.com/docker/sandbox-kit-spec/blob/main/docs/spec/SPEC-v3.md) — the grammar (§3 authoring forms, §4 top-level fields, §5 provides/requires, §6 args, §7 capabilities, §8 launch modes). 3. [capability pages](https://github.com/docker/sandbox-kit-spec/tree/main/docs/spec/capabilities/com.docker.sandbox) — one per capability type, normative for its config schema and runtime behavior. 4. [examples](https://github.com/docker/sandbox-kit-spec/tree/main/examples) — worked kits. `claude` and `claude-mixin` are the migration of a real v2 kit into both shapes; read them first. Working inside the spec repo, all four are also on disk at `spec/`, `docs/spec/` and `examples/`. ## Workflow Track progress with this checklist: ```text - [ ] 1. Read the v2 kit end to end, including its README and testdata - [ ] 2. Write the v3 descriptor, recipe, and context file - [ ] 3. Add the -mixin variant (workload kits only) - [ ] 4. Validate the descriptor (fails in seconds on a bad field) - [ ] 5. Build the kit - [ ] 6. Run it with sbx and exercise the agent - [ ] 7. Verify with kit-tck - [ ] 8. Publish, and delete the CI that published the v2 pair ``` ### 1. Read the v2 kit first Read `spec.yaml`, `Dockerfile`, `README.md` and `testdata/tck.yaml` before writing anything. v2 kits carry their reasoning in comments, and that prose is the most valuable thing to carry across. `testdata/tck.yaml` is the only record of whether a working non-interactive invocation was ever established (`promptArgs`), which decides whether the migrated kit declares `agent-sessions@1`. It says nothing about interactive verbs: declare `agent-interactive-sessions@1` beside it only for verbs you have verified against the tool's real CLI, never from a pattern. ### 2. Write the v3 files For a kit named ``, migrating in place: | v2 | v3 | |---|---| | `/spec.yaml` | `/.yaml`, first line `# syntax=docker/sandbox-kit:3` | | `/Dockerfile` | `/.dockerfile` — found by filename stem, so no `dockerfile:` field | | `agentInstructions.content` | `/-context.md`, referenced as `contentFile: ./-context.md` | | `/testdata/tck.yaml` | delete — v2-only harness | | `/.dockerignore` | **keep and audit.** BuildKit still applies it to a v3 kit's build context, so deleting it puts back whatever it was excluding — secrets and large generated files included. Drop only the entries that named v2 files. | | `/_tck_test.go` | delete — it loads the v2 `spec.yaml` through `tck.NewSuiteFromDir(".")` and cannot compile once that file is gone | The complete field-by-field mapping, the capability rules, and the gotcha list are in [FIELD-MAPPING.md](FIELD-MAPPING.md). Read it before writing the descriptor. Migrate faithfully: preserve every declared host, credential, hook, volume, port, env var and instruction, and keep base images verbatim. Where v3 cannot express something, or where a v2 declaration turns out to be dead config, mark it with an inline `# MIGRATION NOTE:` comment rather than dropping it silently — the `claude` example shows the convention. Faithful does not mean literal in one respect: a v2 install **hook** is often a v2 limitation rather than a v3 requirement, because a v2 mixin had no way to ship content. Decide per hook whether it becomes a layer — the rule, and the five cases where a hook is still correct, are in [FIELD-MAPPING.md](FIELD-MAPPING.md#lifecycle1). ### 3. Add the `-mixin` variant Every workload kit gets a sibling `-mixin/` holding `-mixin.yaml`, `-mixin.dockerfile` and a context file. Name that last one `-context.md`, which is what six of the seven example mixins do — the staged name only has to avoid `kit.yaml` and `kit.dockerfile`, so the `-mixin-` infix buys nothing and the repository is already near-unanimous. The mixin declares the same credentials, network policy, volumes and hooks, minus what only the kit that owns the environment can carry. See the [mixin variants](FIELD-MAPPING.md#mixin-variants) section for the exact subtractions and the overlay recipe patterns. ### 4. Validate the descriptor The frontend decodes and validates the descriptor **before** it builds any content, so an export-less build is the fast loop *while the descriptor is wrong*: ```sh cd && docker buildx build . -f .yaml --output type=cacheonly ``` A malformed descriptor fails in about a second, naming the offending field and its line. Be clear about what `cacheonly` does, though: it suppresses the **export**, not the build. Once the descriptor is valid the frontend goes on to solve the whole recipe — downloads, installs and all — so this is fail-fast for bad input rather than a validation-only step. Iterate here until it is clean: a malformed descriptor still costs only the second it takes to reject, even though a valid one costs the whole recipe. Pass build-phase args by the **kit's** arg name, not the `buildArg` name the recipe sees, and supply anything declared `required` or validation fails: ```sh docker buildx build . -f .yaml --build-arg version=2.99.0 --output type=cacheonly ``` Because the recipe is solved too, a bad `FROM` or `COPY --from` surfaces here rather than waiting for step 5 — you just do not get an image out of it. ### 5. Build the kit Swap the export for `--load` to put an ordinary tagged image in the local store: ```sh docker buildx build . -f .yaml -t -kit: --load ``` `--load` is not optional on the `docker-container` driver, which is what `buildx create` gives you: without an output the result stays in the builder's cache and `docker run -kit:` reports no such image. To judge the artifact with `kit-tck` without a registry, export an OCI layout directory instead: ```sh docker buildx build . -f .yaml -t -kit: \ --output type=oci,dest=/tmp/-layout,tar=false ``` A workload's recipe must build on a base carrying the platform floor — `bash`, the `agent` user (uid 1000), `git`, a CA store — which the hardened `dhi.io/sbx-templates:*` images carry. A bare distro base builds fine and fails at agent launch. ### 6. Run it with sbx Point `sbx` at the kit directory — the runtime builds source-form kits on demand, keyed by source hash, so this needs no registry and no push: ```sh sbx run ./ . # a workload kit sbx run ./ --kit ./-mixin . # a mixin, composed onto a workload ``` A mixin cannot run alone; compose it onto the migrated workload or onto a shell workload. Pass kit args with `--kit-arg name=value` (or `--kit-arg kit.name=value` to target one kit), and bind a credential the kit declares with `sbx secret set ` before expecting authenticated calls to work. (The `-g` flag older docs show is deprecated; global is the default now.) Inside the sandbox, the kit is self-describing — use it to check that what you declared is what arrived: ```sh cat /usr/share/sandbox/kit//kit.yaml # the published descriptor cat /usr/share/sandbox/kit//kit.dockerfile # the recipe that built it cat /var/log/sbx-kit-startup.log # startup hook output ``` Then exercise the kit for real: run the agent's own version command, confirm install hooks left what they should, confirm a declared volume is writable by `agent`, and confirm an undeclared host is refused while a declared one is not. `sbx kit inspect ./` builds the source kit and prints its resolved declarations (kind, network counts, credentials, args) without starting a sandbox. Add `--kit-arg` to preview how args resolve. The first source build creates the shared builder sandbox and is slow; `sbx kit builder status` shows it and `sbx kit builder rm` reclaims the cache. For the published path instead of the local loop, push the kit as an ordinary image and run it by reference — a kit image that only exists in the local Docker store cannot run, because the runtime resolves kit images from registries: ```sh docker buildx build . -f .yaml --push -t docker.io//sbx-kit-: \ --platform linux/amd64,linux/arm64 --provenance=true sbx run docker.io//sbx-kit-: . ``` ### 7. Verify with kit-tck `kit-tck` judges an artifact's annotations, layers, staged sources and image config, with every check linked to the clause it enforces. The same checks run inside the frontend during a build, so running them here is how an artifact changed by an exporter or a registry on its way out gets judged — and how a kit this frontend did not build gets judged at all. ```sh kit-tck validate --layout /tmp/-layout # the OCI layout from step 5 kit-tck validate docker.io//sbx-kit-: # a published kit ``` For the layout form, `` is the tag **alone** as the layout records it (`1.0.0`), not the full `-kit:1.0.0` reference the build was tagged with. Add `--verbose` to list the checks that passed, `--format json` for every check with its spec link, and `--plain-http` for a registry served over HTTP. A published kit built on a **multi-node** builder warns that the index carries no kit annotations. That is expected, not a defect: a multi-node build merges per-node results into a fresh index client-side, which dissolves them, and §9.3 requires consumers to fall back to the platform manifest, which does carry them. The verdict is still `✓ conforms`. A single-node build shows no warning. Runtime conformance is a separate suite, for people implementing a runtime rather than authoring a kit: `kit-tck runtime --adapter ` drives hundreds of sandbox lifecycles against an adapter implementing [conformance.md](https://github.com/docker/sandbox-kit-spec/blob/main/docs/spec/conformance.md). It runs long — the bare command defaults `--timeout` to 30m and fails when that expires, so a real run needs something like `--timeout 2h` — and migrating a kit does not need it at all. ### 8. Publish One artifact means one push. A v2 kit pointed `sandbox.image` at an image published separately from the kit itself, so shipping a change meant building and pushing both and keeping the reference between them honest. In v3 the recipe's `FROM` builds the content, the frontend annotates it, and the result in the registry is the whole kit: ```sh docker buildx build . -f .yaml --platform linux/amd64,linux/arm64 --push \ -t /sbx-kit-: -t /sbx-kit-:latest ``` **Audit the CI that published the v2 pair.** A pipeline built around two artifacts does not fail once there is only one — it keeps pushing an image nothing references now that `sandbox.image` is gone, and the step that packed the kit has nothing left to pack. Both halves get deleted, not rewired. **Check what the existing tag actually names.** A repo publishing a family of kits into one repository tags them by *name* (`…/sbx-kits:`), which is a tag that says nothing about which build it points at. Carried into v3 unchanged and paired with a descriptor that never set `version:`, it yields a published kit with no version anywhere in it. Add `-` beside the bare name, and set `version:` regardless — the joined tag is not version-shaped, so it cannot stand in for the field. Signing is optional, unchanged by the migration, and described with the rest of the publish flow in [create-kit-v3](../create-kit-v3/SKILL.md#publish). ## What actually catches bugs Each check below caught real defects in a migration of 87 kits that the cheaper checks above it did not. They are ordered by what they cost. 1. **Validation** catches malformed descriptors, and the **build** catches more than you would guess: `readContextFile` and `RequireAuthoredProvides` both run before the content loop, so a `contentFile:` naming a missing file and an authored `deb/` provide fail in step 4, not later. What neither sees is a `*-context.md` no descriptor references, or a v2 instruction body that was dropped — both fail at nothing at all. Script those two file-level audits; they take seconds and they found a kit whose instructions would silently never have reached the agent. 2. **Building** catches recipes. It does not prove the content works. 3. **Reading the exported layer** catches ownership, in **two** parts. Export with `--output type=oci,dest=,tar=false` and run `kit-tck validate --layout `: `overlay-home-ownership` fails an overlay whose `/home` is not root's or whose `/home/agent` is not uid 1000's, which is what six of the migrated overlays got wrong. Then count every other owner: `for b in /blobs/sha256/*; do tar --numeric-owner -tvf "$b"; done | awk '{print ($2 ~ /\//) ? $2 : $3"/"$4}' | sort | uniq -c`. Every count must be `0/0` or `1000/1000`; that is what found four overlays shipping files owned by package publishers' uids. The awk reads GNU tar's joined `0/0` field or bsdtar's split pair, because a pipeline written for either silently reports the other's size and date. 4. **Composing the overlay and running the tool** catches the rest. Three mixins shipped dangling symlinks whose build-time `test -x` passed because the real tree was still present *in the build stage*: the installer had relocated a launcher but not its payload. `kit-tck`'s `overlay-links-resolve` now warns about those, but only the composition shows whether a link the overlay cannot resolve by itself finds its target on the base. Compose it through the assembler, which is what merges the overlay's `ENV` and `PATH` and puts it on a base carrying the platform floor: `sbx run ./ --kit ./ --detached --name t .`, then `sbx exec t tool --version`. Use `sbx exec` and not `sbx run … -- tool`: arguments after `--` are **agent** arguments and never run the binary. The `--load` plus throwaway `COPY --from= / /` trick is faster and finds the same dangling symlinks, but it transfers files only — the image config is dropped, so a mixin relying on its own `ENV` fails there for a reason the assembler would not produce. Full detail in [Verifying an overlay](../create-kit-v3/RECIPES.md#verifying-an-overlay). 5. **`kit-tck`** judges the published artifact — see step 7. When you change ownership, re-run step 4, not just step 3. A `chown` that fixes the numbers can still break the tool. ## Known gaps - `sbx kit validate` does not accept a v3 **source** kit: its load path has no kit builder configured. Validate with the build in step 4, and inspect the built artifact with `sbx kit inspect`. - A migration strands every script, workflow and test that globs the old layout. Audit them as part of the work: discovery globbing `*/spec.yaml` returns nothing, so CI passes by building nothing at all, which is the worst failure mode available. ## Migration conventions These are the conventions the `sbx-kits-contrib` v3 migration followed. Keep them unless the task says otherwise: - Migrate in place and delete the v2 `spec.yaml` and `Dockerfile` once the v3 pair exists. - Keep each kit's base images verbatim; a grammar migration is not the moment to re-point a base. - Keep `README.md`, updating the filenames and any v2 grammar it quotes; keep `README.image.md`; delete `testdata/tck.yaml`. Keep `.dockerignore` — it still governs the v3 build context — and prune only the entries that named v2 files. - Heavily commented YAML is the house style. Carry the v2 comments across — they are the reasoning behind the declarations. ## Coupled optional features Use a `group` when skipping a capability also needs to skip its hooks, files, or guidance. Put `optional: true` on the group, never on its members (even `optional: false` is invalid). Groups are nonempty and cannot nest; required and one-member groups are valid. See `examples/optional-cache/optional-cache.yaml`. The selection API includes every member or none, before composition. A runtime supplies decisions on expanded entries; the API owns atomic selection and ordering. Validate all member configs even in skipped groups. Selected entries must still satisfy cross-entry rules, and a composition conflict is an error rather than a reason to skip another group. Singleton arity is per declaration block; lifecycle lists from selected blocks concatenate, and duplicate file paths are errors. Selection lasts for one sandbox installation, including restarts. Recreation selects afresh. A failing selected hook is an execution failure, not optional unavailability. Skipping a group does not remove old files from reused volumes, omit image layers, or suppress argument environment exports. Group names are display labels, not merge keys. Groups extend the unfinished schema-3 grammar in place. Descriptors using them require a reader implementing this extension; older strict readers reject them. ## Final environment references Use `${{ kit.env.HOME }}` in capability configuration strings when a path depends on the final container environment. Image defaults compose first, argument `env` exports replace them, and runtime environment overrides win last. Expansion happens before validation and selection, including inside groups. Missing names fail; empty values remain empty. This is structural string substitution, not shell evaluation: `$HOME`, `${HOME}`, and `~/` are unchanged. Do not use environment references in mapping keys or Kit metadata, and do not pass Kit placeholders inside argument or environment values. Persist the expanded descriptor for restart.