Skip to content

Commit 129be2f

Browse files
authored
Merge pull request #101 from docker/feat/host-mount-capability
feat(spec,tck): add host-shared directory capability
2 parents 31791ea + 459e7e1 commit 129be2f

40 files changed

Lines changed: 1667 additions & 34 deletions

‎Taskfile.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ vars:
1515
# so they only build once those kits have been pushed to the namespace
1616
# their `registry` arg names. Build one on its own:
1717
# `task kit:build KIT=team BUILD_ARGS='--build-arg registry=docker.io/me'`.
18-
KITS: hello gh tool shell alpine builder motd wordpress claude claude-mixin claude-acp codex codex-mixin codex-acp cursor cursor-mixin devin devin-mixin docker-agent docker-agent-mixin gemini gemini-mixin opencode opencode-mixin skills dev-tools optional-cache git-signing github-ssh review-skill
18+
KITS: hello gh tool shell alpine builder motd wordpress claude claude-mixin claude-acp codex codex-mixin codex-acp cursor cursor-mixin devin devin-mixin docker-agent docker-agent-mixin gemini gemini-mixin opencode opencode-mixin skills dev-tools optional-cache shared-cache git-signing github-ssh review-skill
1919
# Empty means "the kit's own version answers": build and push refresh an
2020
# upstream-tracked descriptor from the vendor's latest release and tag
2121
# with it, so an image tag can never name a version the kit did not

‎docs/spec/SPEC-v3.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -567,7 +567,8 @@ other.
567567

568568
**Instance-shaped types appear once per thing requested**, deduplicated on
569569
their own key: `credential@1` on (service, phase), with each listed phase
570-
participating independently, `volume@1` on path,
570+
participating independently, `volume@1` and `host-mount@1` on a shared
571+
cleaned storage path key,
571572
`agent-skills@1` on path,
572573
`agent-skill@1` on effective name, `port@1` on (container, transport),
573574
`ssh-agent@1` on each phase it names.
@@ -684,6 +685,7 @@ behavior** for a runtime supporting the type:
684685
| `com.docker.sandbox/credential@1` | [credential@1](capabilities/com.docker.sandbox/credential@1.md) | per (service, phase) |
685686
| `com.docker.sandbox/ssh-agent@1` | [ssh-agent@1](capabilities/com.docker.sandbox/ssh-agent@1.md) | per phase (one entry may name both) |
686687
| `com.docker.sandbox/volume@1` | [volume@1](capabilities/com.docker.sandbox/volume@1.md) | per path |
688+
| `com.docker.sandbox/host-mount@1` | [host-mount@1](capabilities/com.docker.sandbox/host-mount@1.md) | per path; shared storage key with volume@1 |
687689
| `com.docker.sandbox/port@1` | [port@1](capabilities/com.docker.sandbox/port@1.md) | per (container, transport) |
688690
| `com.docker.sandbox/usb-device@1` | [usb-device@1](capabilities/com.docker.sandbox/usb-device@1.md) | instance |
689691
| `com.docker.sandbox/resources@1` | [resources@1](capabilities/com.docker.sandbox/resources@1.md) | singleton |
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# `com.docker.sandbox/host-mount@1`
2+
3+
One host directory shared with the host and across sandboxes composing
4+
the same Kit. The runtime chooses its location and mount mechanism;
5+
Linux bind mounts and VM filesystem sharing can satisfy the same grant.
6+
Use [volume@1](volume@1.md) for sandbox storage without this sharing
7+
contract.
8+
9+
- **Shape**: instance — one entry per path; duplicates rejected.
10+
- **Permission surface**: yes — the path, separately marked host-shared.
11+
12+
## Config
13+
14+
```yaml
15+
- type: com.docker.sandbox/host-mount@1
16+
config:
17+
path: /home/agent/.cache/pip # REQUIRED, absolute and canonical
18+
mode: "0755" # optional initial directory permissions
19+
```
20+
21+
| Field | Type | Rules |
22+
|---|---|---|
23+
| `path` | string | REQUIRED. Absolute, canonical in-container path; no `.`, `..`, doubled separators, trailing slash, NUL, or root `/`. |
24+
| `mode` | string | optional. Octal (`755`, `0755`, `1777`), applied when the runtime creates the directory. |
25+
26+
There is no host-path field. The Kit requests where storage appears
27+
inside the sandbox, not which host files it can access.
28+
29+
## Runtime behavior
30+
31+
A conforming runtime:
32+
33+
- **MUST NOT** let the Kit choose the host directory's location. <!-- tck: host-mount@1/runtime-owned-location -->
34+
The runtime owns it; a user MAY explicitly choose a replacement.
35+
- **MUST** key the directory on the declaring Kit's identity and the <!-- tck: host-mount@1/isolated-by-kit -->
36+
cleaned `path`, so sandboxes composing the same Kit share storage,
37+
while another Kit declaring that path does not inherit it.
38+
- **MUST** keep distinct destinations of the same Kit in separate <!-- tck: host-mount@1/isolated-by-path -->
39+
directories. A Kit's identity alone is not the storage key.
40+
- **MUST** use a Kit identity another Kit cannot claim. A published <!-- tck: host-mount@1/identity-not-self-declared -->
41+
repository can supply that identity; descriptor display metadata and
42+
`source` attribution cannot. Identity for unpublished Kits is
43+
runtime-owned.
44+
- **MUST** reuse the directory across tag or digest updates within one <!-- tck: host-mount@1/survives-kit-update -->
45+
Kit identity. Updating a Kit does not allocate fresh storage.
46+
- **MUST** mount the directory at `path` before lifecycle hooks run. <!-- tck: host-mount@1/mounted-before-hooks -->
47+
- **MUST** allow concurrent sandboxes of the same Kit to read and write <!-- tck: host-mount@1/shared-concurrently -->
48+
the directory. Kits coordinate access; the grant promises no locking
49+
or transactional behavior.
50+
- **MUST** keep the directory's contents independently of any sandbox, <!-- tck: host-mount@1/survives-sandbox-removal -->
51+
including after its last sandbox is removed.
52+
- **MUST** let the user find and remove the directory and access its <!-- tck: host-mount@1/listed-and-removable -->
53+
contents from the host. Sandbox writes reach this directory.
54+
- **SHOULD** make the mount root writable by the agent user (uid 1000). <!-- tck: host-mount@1/agent-writable-root -->
55+
- **SHOULD** apply `mode` when creating the directory; reopening it <!-- tck: host-mount@1/initial-mode-applied -->
56+
preserves existing permissions and contents.
57+
58+
Filesystem semantics MAY be weaker than those of `volume@1`: ownership
59+
may be mapped from the host and overlayfs upper layers or xattrs may be
60+
unavailable. Kits requiring those semantics use `volume@1`.
61+
62+
A runtime without host-directory sharing does not advertise this type.
63+
An unclaimed required entry **MUST** fail closed; an optional one <!-- tck: host-mount@1/unadvertised-is-unmet -->
64+
**MUST** be skipped and recorded without a host-sharing grant.
65+
66+
## Composition
67+
68+
Paths union across the set. `host-mount@1` and `volume@1` share the
69+
cleaned in-container path as their storage identity key.
70+
71+
Two Kits declaring the same path, whether both use `host-mount@1` or <!-- tck: host-mount@1/no-silent-merge -->
72+
one uses `volume@1`, conflict. A runtime **MUST NOT** silently merge
73+
them, even when their configs are identical: shared storage has one
74+
declaring Kit identity.
75+
76+
## Gate
77+
78+
The permission surface **MUST** list host-shared paths separately from <!-- tck: host-mount@1/separate-permission-surface -->
79+
`volume@1` storage paths. Data written here reaches the host and other
80+
sandboxes of the same Kit. A new path widens the grant; moving a path
81+
from `volume@1` to `host-mount@1` also widens it. `mode` changes do not.

‎docs/spec/capabilities/com.docker.sandbox/volume@1.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,23 @@ A conforming runtime:
4444
[lifecycle](lifecycle@1.md) startup hook (a fresh mount may come up
4545
root-owned).
4646

47+
Whether removing a sandbox and later creating one with the same name
48+
counts as a recreate is runtime-owned. Removal may delete its storage
49+
or retain it for a later sandbox.
50+
51+
A runtime reattaching storage across sandbox removal **MUST** check <!-- tck: volume@1/reattach-checks-kit-identity -->
52+
the Kit identity before attaching it, so a different Kit cannot inherit
53+
the data. Descriptor display metadata and `source` attribution are not
54+
sufficient identity evidence.
55+
4756
## Composition
4857

4958
Paths union across the set. Two Kits declaring the same path is a
5059
composition conflict — a runtime MUST NOT silently merge them. <!-- tck: volume@1/no-silent-merge -->
5160

61+
The cleaned path also conflicts with a
62+
[host-mount@1](host-mount@1.md) declaration at that destination.
63+
5264
## Gate
5365

5466
The path is permission surface. A new path widens; `size`/`mode` changes do

‎docs/spec/conformance.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ An adapter **MUST NOT** require interactive input.
7272
| `rm` | `<id>` | — | Discard the sandbox |
7373
| `wait-idle` | `<id>` | — | Disconnect the final client session and wait beyond the normal auto-stop grace period |
7474
| `status` | `<id>` | `running` or `stopped` | Observe sandbox state without starting it or attaching a session |
75+
| `host-mounts` | `<kit-ref>` | JSON array of `{id, path, hostPath}` | List retained host directories for the resolved Kit identity |
76+
| `host-mount-read` | `<mount-id> <relative-path>` | the file's bytes | Observe sandbox writes from the host |
77+
| `host-mount-rm` | `<mount-id>` | — | Remove a runtime-owned directory by its opaque listing handle |
7578

7679
`create --env name=value` supplies a container environment override.
7780
Adapters **MUST** apply it after image defaults and Kit argument exports,
@@ -127,6 +130,42 @@ not a stopped state. The suite reads status before probing the background
127130
process, so an exec that implicitly starts a stopped sandbox cannot hide
128131
auto-stop, and reads it again after explicit stop.
129132

133+
### Host-shared directory observation
134+
135+
The `host-mount-*` verbs are required only for adapters claiming
136+
`com.docker.sandbox/host-mount@1`. They use the runtime's ordinary user
137+
interfaces for discovering, reading, and removing host directories.
138+
Adapters **MUST NOT** manufacture storage outside the runtime's
139+
provisioning path to satisfy these checks.
140+
141+
`host-mounts` resolves the Kit reference to the same identity used by
142+
`create`, and returns its retained directories even with no live sandbox.
143+
Each record **MUST** contain a nonempty opaque `id`, its canonical
144+
in-container `path`, and the host location `hostPath` a user can find.
145+
There is one record per path; an empty listing is `[]`, not `null`.
146+
Listing is an observation and **MUST NOT** allocate storage.
147+
148+
The `host-mount-version-v1` and `host-mount-version-v2` fixtures exercise
149+
updates within one published Kit identity. Adapters claiming
150+
`host-mount@1` **MUST** publish or import them as distinct versions of one
151+
suite-owned repository, resolving their supplied fixture references to
152+
those versions for `create` and `host-mounts`. Distinct tags or digests
153+
identify the versions; neither version may be substituted with the
154+
other. The suite writes through the first version, removes its sandbox,
155+
and reads through the second, then verifies writes from the second are
156+
visible when reopening the first. Testing two unrelated local Kit
157+
identities cannot establish this repository identity guarantee.
158+
159+
`host-mount-read` **MUST** read from the host directory independently of
160+
sandbox exec. The suite passes only a relative fixture filename; an
161+
absent file returns a nonzero status. `host-mount-rm` **MUST** remove the
162+
listed directory and its contents so a later create starts empty.
163+
164+
The suite uses fresh in-container paths for each check. It removes only
165+
directories at those paths for the fixture Kits, after removing their
166+
sandboxes; adapters **MUST NOT** interpret cleanup as a request to remove
167+
other Kit directories or user content.
168+
130169
### Git identity binding
131170

132171
An adapter claiming `com.docker.sandbox/git-identity@1` **MUST** accept

‎examples/shared-cache/README.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Shared pip cache
2+
3+
A declaration-only mixin using `com.docker.sandbox/host-mount@1` to
4+
share pip downloads across sandboxes composing this Kit. The runtime
5+
chooses the host directory and how it appears inside the sandbox.
6+
No image content or host path is supplied by the Kit.
7+
8+
The optional group couples the mount with pip's configuration file.
9+
A runtime without host sharing skips both requests; pip then uses its
10+
ordinary cache. The workload must already provide pip.
11+
12+
The cache is keyed by this Kit's runtime-resolved identity and the
13+
in-container path. It survives removal of the last sandbox and remains
14+
accessible to the host user, who can find and remove it through the
15+
runtime's storage interface. Concurrent sandboxes share read/write
16+
access; pip is responsible for coordinating cache writes.
17+
18+
This grants host sharing as a separate permission from sandbox-private
19+
storage. For a cache without that sharing contract, see
20+
[optional-cache](../optional-cache/README.md).
21+
22+
Build against the current frontend source:
23+
24+
```sh
25+
task kit:dev KIT=shared-cache
26+
```
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# syntax=docker/sandbox-kit:3
2+
# yaml-language-server: $schema=../../schema/kit.schema.json
3+
schemaVersion: "3"
4+
kind: mixin
5+
displayName: Shared pip cache
6+
description: Reuse pip downloads across sandboxes of this Kit and the host
7+
capabilities:
8+
- group:
9+
name: Host-shared pip cache
10+
description: Share cache files across sandboxes and retain them after removal
11+
optional: true
12+
capabilities:
13+
- type: com.docker.sandbox/host-mount@1
14+
config:
15+
path: /home/agent/.cache/pip
16+
mode: "0755"
17+
- type: com.docker.sandbox/lifecycle@1
18+
config:
19+
files:
20+
- path: /home/agent/.config/pip/pip.conf
21+
overwrite: false
22+
content: |
23+
[global]
24+
cache-dir = /home/agent/.cache/pip
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"title": "com.docker.sandbox/host-mount@1",
4+
"description": "Config schema for a runtime-owned host directory shared with the host and across sandboxes of one Kit. Instance-shaped, keyed by the cleaned in-container path, sharing its storage identity key with volume@1. No Kit-supplied host path.",
5+
"type": "object",
6+
"additionalProperties": false,
7+
"required": [
8+
"path"
9+
],
10+
"properties": {
11+
"path": {
12+
"type": "string",
13+
"anyOf": [
14+
{
15+
"pattern": "^(/([^./\\x00][^/\\x00]*|\\.[^./\\x00][^/\\x00]*|\\.\\.[^/\\x00]+))+$"
16+
},
17+
{
18+
"$ref": "../../definitions/kit-arg.schema.json#/definitions/bearing"
19+
}
20+
]
21+
},
22+
"mode": {
23+
"type": "string",
24+
"anyOf": [
25+
{
26+
"pattern": "^[0-7]{3,4}$"
27+
},
28+
{
29+
"$ref": "../../definitions/kit-arg.schema.json#/definitions/bearing"
30+
}
31+
]
32+
}
33+
}
34+
}

‎schema/kit.schema.json‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,28 @@
391391
}
392392
}
393393
},
394+
{
395+
"if": {
396+
"properties": {
397+
"type": {
398+
"const": "com.docker.sandbox/host-mount@1"
399+
}
400+
},
401+
"required": [
402+
"type"
403+
]
404+
},
405+
"then": {
406+
"properties": {
407+
"config": {
408+
"$ref": "capabilities/com.docker.sandbox/host-mount@1.schema.json"
409+
}
410+
},
411+
"required": [
412+
"config"
413+
]
414+
}
415+
},
394416
{
395417
"if": {
396418
"properties": {

‎skills/create-kit-v3/SKILL.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ often got wrong:
131131
| `ssh-agent@1` | git over SSH, SSH commit signing | Set `unrestricted: false` to bound it. `sign: [git]` is all commit signing needs; `authenticate: [git@github.com]` is a login to one server. An entry with `unrestricted: true` (the default) signs anything with every key the user's agent holds — declare that only when a client cannot bind sessions (OpenSSH can), and prefer `credential@1` with HTTPS when a token will do. `optional: true` unless the kit cannot work without it: many users have no agent running. A hook that uses it declares `SSH_AUTH_SOCK` in `env:`, and only hooks of the granted phase get it. `authenticate` grants a signature, not a connection: the server must also be reachable under the network policy. `phase` accepts a single phase or `[install, runtime]` with shared rules. Commit signing needs git config too (`gpg.format=ssh`, `gpg.ssh.defaultKeyCommand="ssh-add -L"`). |
132132
| `lifecycle@1` | install/startup hooks, staged files | Hook environments are **deny-by-default**. Declare every variable in `env:`, including ones only a child process reads — `curl`, `pip` and `npm` need `HTTP_PROXY`/`HTTPS_PROXY`, and `docker` needs `DOCKER_HOST`. |
133133
| `volume@1` | persistent paths | Always set `size`. An unsized kit volume is formatted at 512 MiB, which is a cache or a package store running out of room mid-run rather than anything visible at create. |
134+
| `host-mount@1` | host-shared caches, datasets, or artifacts | Declare only the absolute, canonical in-container `path` and optional octal `mode`. The runtime owns the host location. Data is shared across sandboxes of the declaring Kit, survives sandbox removal, and is visible to the host user. Use an optional group to couple cache setup with the mount; see `examples/shared-cache`. |
134135
| `agent-context@1` | instructions the agent reads | A workload can supply the legacy workspace-sibling `filename`. An agent workload or mixin supplies `filename` plus an absolute `directory` at its discovery location; this overrides the legacy fallback. Different explicit destinations conflict. Tool mixins contribute bodies alone. Use `contentFile:` for a static body, but inline `content:` when the body interpolates an arg — a staged body is never arg-expanded. |
135136
| `long-running@1` | workloads or service mixins that outlive client sessions | Config-less; a request from any Kit applies to the whole sandbox. A background hook or published port does not prevent session auto-stop. Required by default; use `optional: true` only if auto-stop is tolerable. This does not request restart after failure. |
136137
| `git-identity@1` | commits attributed to the runtime-provided user | Config-less; requests only user.name and user.email, not a configuration-source mount. Requires permission; optional when missing identity is tolerable. Signing/authentication are separate grants. |
@@ -139,6 +140,14 @@ often got wrong:
139140
| `agent-skills@1` | where an agent discovers skills | Declare on the agent workload or agent mixin. The runtime links or otherwise exposes every selected `agent-skill@1` bundle here, and includes host-shared skills when available and enabled. Missing host skills never block startup; `mode` bounds host-store access only. |
140141
| `port@1`, `resources@1`, `privileged@1` | inbound ports, limits, elevation | Do not declare on speculation; `privileged@1` is the largest widening available. |
141142

143+
Host sharing is a separate grant from `volume@1`, including when the
144+
in-container path stays the same. Do not supply a host path or assume a
145+
Linux bind mount: runtimes may use VM filesystem sharing. Host-shared
146+
directories may have weaker filesystem semantics than private volumes.
147+
Concurrent writers coordinate access themselves. The initial `mode`
148+
does not reset existing directory permissions on later creates.
149+
Two host mounts at one path, or a host mount and a volume there, conflict.
150+
142151
## Bundled skills
143152

144153
Use `agent-skill@1` to package a skill as a mixin; see

0 commit comments

Comments
 (0)