Skip to content

feat: rootless docker-in-sandbox (DinD) behind a per-session flag - #252

Open
yourbuddyconner wants to merge 19 commits into
dev-v2from
feat/sandbox-docker
Open

feat: rootless docker-in-sandbox (DinD) behind a per-session flag#252
yourbuddyconner wants to merge 19 commits into
dev-v2from
feat/sandbox-docker

Conversation

@yourbuddyconner

Copy link
Copy Markdown
Collaborator

What

Sessions can opt into a rootless docker daemon inside their sandbox, on both the docker and kubernetes providers. Spec: docs/specs/2026-08-15-sandbox-docker-design.md; plan: docs/plans/2026-08-15-sandbox-docker.md.

Opt-in (either switch): docker: true in the repo's .valet/prebuild.yaml, or docker: true at session create. The flag threads session meta → SandboxCreateOpts.docker → provider deltas. Sandboxes without the flag are byte-identical to today (unit-pinned on both providers).

What an opted-in sandbox gets (never --privileged, no host socket, all other caps dropped):

  • seccomp / AppArmor / system paths unconfined
  • CAP_SYS_ADMIN + CAP_NET_ADMIN
  • /dev/fuse + /dev/net/tun
  • VALET_SANDBOX_DOCKER=1 → the image's start scripts launch dockerd-rootless as a dedicated non-root dockerd user

The sandbox image bakes the toolchain dormant; sandboxes that do not opt in pay image size only (verified: no daemon process without the env flag).

Spec amendment made during implementation

The original design said "no added capabilities". Empirically false for rootless DinD in an unprivileged container: newuidmap needs SYS_ADMIN to write uid_map, and rootlesskit's netns sysctl needs NET_ADMIN + unmasked /proc/sys. Verified by three live container experiments (spec-exact flags fail; caps alone fail; caps + unmasked proc works). The spec's decision 2 and security-model.md document the final posture: strictly weaker than privileged, confined to opted-in sandboxes.

Validation

  • Full make e2e: 31 passed / 0 failed / 4 skipped (skips are missing external creds, unrelated). Includes the new sandbox-dind row: live rootless DinD round-trip — docker run hello-world, docker build, published port + curl, all inside a docker: true sandbox.
  • Live smokes: daemon dormant without the flag; hello-world/build/port with it.
  • pnpm typecheck clean.

Known limits / follow-ups

  • Task 9 acceptance (manual, pending): run valet's own docker-gated suites inside a docker: true sandbox (make e2e E2E_ARGS="--only sandbox-docker,store-postgres,prebuilds-docker" in-sandbox) and record the scorecard here.
  • procMount: Unmasked on kubernetes requires the ProcMountType feature gate; where unavailable the k8s DinD path does not converge (noted in spec; check at k8s acceptance).
  • Docker state is ephemeral in v1 (emptyDir): images re-pull after sandbox recreate.
  • Repo-config flag resolution at session build is best-effort (5s timeout, 10-min cache); failure = no docker, corrective action = the session-create flag.

Adds repoDockerFlag() to source-service.ts: reads .valet/prebuild.yaml
via the GitHub Contents API, returns override.docker === true, caches
per owner/repo@ref for 10 minutes. Best-effort throughout — any failure
(no token, network error, bad YAML, non-GitHub host, no repos) resolves
false without blocking the session build.

Wires resolveRepoDockerFlag() into EngineHost.buildSession(): ORs
meta.docker with the repo flag before assembling sandboxOpts. Guards on
githubTokenDeps/db presence (mirrors buildCredentialResolver pattern).
Only github.com-hosted primary repos qualify.
resolveRepoDockerFlag in host.ts now races repoDockerFlag against a
5-second Promise.race; on timeout it console.errors the session id and
"timed out" and returns false without caching (a timeout is not a repo
answer). repoDockerCache clears() when size >= 1000 before each insert
to prevent unbounded growth. Two new tests: a hanging-fetch seam test
verifying the result is not cached on timeout, and a 1001-key cap test.
- Add packages/sandbox-docker/test/dind.e2e.test.ts: creates a docker:true
  sandbox, polls for daemon ready, then runs hello-world, builds a Dockerfile,
  and runs nginx:alpine with a published port.
- Register sandbox-dind row in scripts/e2e/lib.ts (group: docker, needs:
  docker, 15 min timeout); update lib.test.ts counts from 34 to 35 rows.
- Add Docker-in-sandbox subsection to docs/security-model.md matching the
  amended spec (CAP_SYS_ADMIN + CAP_NET_ADMIN, /dev/fuse + /dev/net/tun,
  seccomp/AppArmor/systempaths unconfined; not privileged, no host socket).
- Fix pre-existing test fixture drift: add docker:false to SessionDetail
  fixtures in session-header.test.tsx and transcript.test.ts; add
  dockerSupport:true to capabilities() assertion in docker-sandbox.test.ts.

@valet-valet-turnkey-dev valet-valet-turnkey-dev Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR is well-structured and the implementation matches the stated intent. Two real issues worth flagging: a subuid/subgid range collision in the Dockerfile that could silently break rootless uid-mapping, and a subtle TOCTOU bug in the start-docker.sh idempotency check that can corrupt the symlink under concurrent invocations.

  1. docker/Dockerfile.sandbox-k8s:lines 143-147 — The subuid/subgid range 200000:65536 for dockerd collides with any existing user that already owns that range in the base image. The script removes the dockerd line first, then appends it — so if the base image already has another user mapped to 200000+, those entries remain and newuidmap will refuse to write an overlapping range, causing dockerd-rootless.sh to fail at startup with a cryptic uid-map error. The earlier plan doc shows 100000:65536 (the conventional subuid start for the first added user); the Dockerfile was changed to 200000:65536 without removing or auditing the existing base entries. At minimum, add a RUN awk -F: '$2 >= 200000 && $2 < 265536 {exit 1}' /etc/subuid /etc/subgid || { echo "subuid/subgid collision"; exit 1; } check, or switch to a range that is verifiably unoccupied in the base (e.g., 500000:65536).

  2. docker/start-docker.sh:lines 18-31 — The idempotency guard checks for a live socket, then conditionally launches the daemon — but the symlink ln -sf "$SOCK" /var/run/docker.sock only runs when the socket appears after a fresh launch, not on the re-entry path (the exit 0 at line 20 returns before the symlink). This means if the sandbox container restarts (PID 1 respawns start-docker.sh) while the daemon is already running, /var/run/docker.sock will not be recreated, so any root-run docker CLI invocation will fail with "no such file". Move the ln -sf before the exit 0 early return, or unconditionally run it after the for poll loop regardless of how the daemon came up.

    • Secondary nit on the same file: su -s /bin/bash dockerd -c "... >> '$LOG' 2>&1 &" — the single-quoted $RUNTIME_DIR and $LOG inside the double-quoted heredoc work because they're actually double-quoted in the outer shell, but only because the shell expands them before passing to su. This is fine, but a path containing spaces (unlikely here, but /tmp/valet-docker is fine) would still break. Not blocking, just worth noting if the path ever becomes configurable.

Created on behalf of Xiangan He xiangan@turnkey.io

@yourbuddyconner

Copy link
Copy Markdown
Collaborator Author

Task 9 acceptance: valet's docker-gated suites inside a docker: true sandbox — DONE

Ran on the local docker backend (make dev-local from this branch, image built from this branch's Dockerfile.sandbox-k8s): created a docker: true session, cloned this branch into its workspace, installed the toolchain (what the prebuild bake automates), and ran the suites against the sandbox's inner rootless daemon.

Scorecard (inside the sandbox):

  • sandbox-dockerpassed (full provider suite; inner containers via rootless dockerd)
  • store-postgrespassed (43s; real Postgres container inside the sandbox, 234 conformance tests)
  • prebuilds-dockerpassed (19.5s; real docker build through the inner daemon)

Findings fixed during acceptance:

  • cb363a67 — Docker 29's docker build needs the buildx plugin; added docker-buildx-plugin to the image.

Findings for follow-up (not blocking):

  1. Workloads that run inner containers must run as the dockerd user. The agent execs as container-root; root-owned workspace files are unmapped inside the rootless daemon's userns, so inner containers fail with chdir /workspace: permission denied. Acceptance worked by chown-ing the tree to dockerd and running as that user. Follow-up: run docker-enabled sessions' exec as dockerd (or uid-shift the workspace).
  2. Per-user corepack cache: the dockerd user resolves pnpm independently (grabbed pnpm 11 until pinned). The prebuild bake or image should pin pnpm for that user too, or the repo should add a packageManager field.
  3. CI=true disables the docker-gated suites by design (!process.env.CI gate) — do not set it when running these suites in-sandbox.

k8s-backend acceptance (ProcMountType feature gate check) still pending.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant