Add tmux, a pinned Neovim and the workspace launcher
build / Build and push image (push) Successful in 4m51s

Removes the per-sandbox setup cost AI_SBX_TOOLS pays on every setup, and
makes the sandbox a terminal environment worth working in.

Neovim comes from the upstream tarball rather than apt: Ubuntu's build is
far behind what a LazyVim config needs. Upstream publishes no checksums, so
NEOVIM_SHA256 is taken from the release asset and verified at build time.
The version is pinned and labelled because a config that works on the host
and breaks in the sandbox on a version skew is expensive to diagnose.

The launcher is fetched from ai-sandbox v1.11.0's tasks/ai/workspace rather
than vendored here. The task installs its own copy into stock-image
sandboxes and skips that when the image supplies one, so two copies could
drift and make behaviour depend on which image you are on. A bash -n guard
rejects a forge error page served with a 200.
This commit is contained in:
2026-08-03 15:38:31 -05:00
parent 9be4122580
commit 1617bb15df
3 changed files with 196 additions and 1 deletions
+32 -1
View File
@@ -1,10 +1,41 @@
FROM docker/sandbox-templates:claude-code-docker FROM docker/sandbox-templates:claude-code-docker
# Pinned deliberately: a Neovim config that works on the host and breaks in the
# sandbox because of a version skew is expensive to diagnose. Upstream publishes
# no checksums, so this digest was taken from the release asset itself.
ARG NEOVIM_VERSION=v0.12.4
ARG NEOVIM_SHA256=012bf3fcac5ade43914df3f174668bf64d05e049a4f032a388c027b1ebd78628
# The launcher is built from ai-sandbox's tasks/ai/workspace, never written
# independently — the task skips installing its own copy when the image supplies
# one, so a drifting copy would make behaviour depend on which image you are on.
ARG AI_SANDBOX_REF=v1.11.0
LABEL dev.mroberts.claude-sbx.neovim="${NEOVIM_VERSION}" \
dev.mroberts.claude-sbx.ai-sandbox-ref="${AI_SANDBOX_REF}"
USER root USER root
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends jq \ && apt-get install -y --no-install-recommends jq tmux \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# ponytail: amd64 only — the sandbox microVM is x86_64. Add a uname case and a
# second digest if this ever needs to build on arm64.
RUN curl -fsSL -o /tmp/nvim.tar.gz \
"https://github.com/neovim/neovim/releases/download/${NEOVIM_VERSION}/nvim-linux-x86_64.tar.gz" \
&& printf '%s /tmp/nvim.tar.gz\n' "${NEOVIM_SHA256}" | sha256sum -c - \
&& tar -xzf /tmp/nvim.tar.gz -C /usr/local --strip-components=1 \
&& rm -f /tmp/nvim.tar.gz \
&& nvim --version | head -1
ADD --chmod=755 \
"https://git.mroberts.dev/mroberts/ai-sandbox/raw/tag/${AI_SANDBOX_REF}/tasks/ai/workspace" \
/usr/local/bin/ai-sbx-workspace
# A forge that answers a missing path with an HTML error page would otherwise
# produce an executable that fails only when someone tries to use it.
RUN bash -n /usr/local/bin/ai-sbx-workspace
USER agent USER agent
COPY --chown=agent:agent claude/ /home/agent/.claude/ COPY --chown=agent:agent claude/ /home/agent/.claude/
+27
View File
@@ -43,6 +43,19 @@ created from it sees any of that — and it will not until #366 is fixed.
| `claude/hooks`, `claude/skills` | `~/.claude`, verbatim | | `claude/hooks`, `claude/skills` | `~/.claude`, verbatim |
| `plugins.json` | generated from `~/.claude/plugins/known_marketplaces.json` and `settings.json` | | `plugins.json` | generated from `~/.claude/plugins/known_marketplaces.json` and `settings.json` |
| mise | `https://mise.run`, with shims on `PATH` via `.bashrc` | | mise | `https://mise.run`, with shims on `PATH` via `.bashrc` |
| `tmux` | Ubuntu `apt`, 3.6a |
| `nvim` | upstream release tarball into `/usr/local`, pinned and checksummed |
| `/usr/local/bin/ai-sbx-workspace` | fetched from `ai-sandbox` at the tag in `AI_SANDBOX_REF` |
The pinned versions are recorded in image labels
(`dev.mroberts.claude-sbx.neovim`, `dev.mroberts.claude-sbx.ai-sandbox-ref`), so a
sandbox that disagrees with the host config can be diagnosed from the image alone.
Neovim comes from upstream rather than `apt` because Ubuntu's build is far behind what
a LazyVim config needs. Upstream publishes no checksums, so `NEOVIM_SHA256` was taken
from the release asset itself and is verified at build time.
With tmux and Neovim in the image, `AI_SBX_TOOLS` can drop back to `bun`.
Credentials, transcripts, `history.jsonl`, `projects/`, `file-history/` and Credentials, transcripts, `history.jsonl`, `projects/`, `file-history/` and
`session-env/` are never copied. `~/.claude/plugins/` is not copied either — the build `session-env/` are never copied. `~/.claude/plugins/` is not copied either — the build
@@ -60,6 +73,20 @@ docker build -t git.mroberts.dev/mroberts/claude-sbx:v1 .
Requires no access to `~/.claude`. The build installs 10 marketplaces and 17 plugins Requires no access to `~/.claude`. The build installs 10 marketplaces and 17 plugins
and fails if the resulting enabled-plugin count does not match the manifest. and fails if the resulting enabled-plugin count does not match the manifest.
The launcher is fetched from the `ai-sandbox` tag named by `AI_SANDBOX_REF`, rather
than vendored here. The task installs its own copy into sandboxes running a stock
image and skips that when the image supplies one, so two copies that could drift would
make behaviour depend on which image you happen to be on. Build against a different
release with:
```bash
docker build --build-arg AI_SANDBOX_REF=v1.12.0 -t git.mroberts.dev/mroberts/claude-sbx:v2 .
```
The default is `v1.11.0`, the `ai-sandbox` release that introduced the launcher. A tag
without `tasks/ai/workspace` fails the build at the fetch rather than shipping a
broken launcher.
## Pushing ## Pushing
`docker push` **fails** against this registry: `git.mroberts.dev` is behind Cloudflare, `docker push` **fails** against this registry: `git.mroberts.dev` is behind Cloudflare,
+137
View File
@@ -0,0 +1,137 @@
# Spec: base sandbox image — `claude-sbx`
Implementation spec for the image. The consuming task and its configuration surfaces
are specified in `mroberts/ai-sandbox` → `docs/tmux-workspace-spec.md`. Registry,
workflow, and plugin-manifest work are in [`HANDOFF.md`](HANDOFF.md).
## Purpose
Remove the per-sandbox setup cost that `AI_SBX_TOOLS` currently pays on every `setup`,
and provide a terminal environment good enough to work in rather than only to watch an
agent in.
The image carries **tools and the launcher**. It carries **no dotfiles and no
credentials** — those arrive at runtime from the host, because they are personal,
change often, and in the dotfiles case contain secrets.
## What the base image already provides
Verified against `docker/sandbox-templates:claude-code-docker`:
| Present | Absent |
| --- | --- |
| `git`, `node`, `npm`, `docker` (29.6.1) | `tmux`, `nvim`, `vim` |
Also guaranteed by the Sandboxes base image contract, and must be preserved:
- non-root `agent` at UID 1000 with passwordless sudo
- `/home/agent/` owned by `agent`
- `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` preserved across sudo
## Contents
### 1. tmux
`apt-get install -y --no-install-recommends tmux` as `root`. Ubuntu's build is current
enough; nothing here depends on a recent tmux.
### 2. Neovim
**Not from `apt`.** Ubuntu ships a version far behind what a LazyVim-based config
needs. Install the upstream release tarball to `/usr/local`, or keep neovim on mise.
Pin the version explicitly and record it in the image labels — a config that works on
the host and breaks in the sandbox because of a version skew is a bad afternoon.
### 3. The launcher
`/usr/local/bin/ai-sbx-workspace`, mode 755.
**Built from `ai-sandbox`'s `tasks/ai/workspace`, not written independently.** The task
installs its own copy into sandboxes running a stock image; if the image's copy drifts,
behaviour depends on which image you happen to be on. Vendor it at build time (fetch
from the tagged `ai-sandbox` release the image is built against) and record that tag in
a label.
Behaviour is specified in the task spec. In summary: attach-or-create session `ai-sbx`,
three windows (`agent`, `edit`, `shell`), all in the workspace root, agent window
running `claude --dangerously-skip-permissions`.
### 4. Optional: pre-warmed plugins
The full Neovim config clones 93 plugins and installs up to 55 mason packages on first
launch. Every host that needs is already permitted by the `Balanced` network policy, so
this is slow rather than blocked.
Pre-warming `~/.local/share/nvim` into the image removes that wait at the cost of a
much larger image and staleness against `lazy-lock.json`. **Defer this.** Measure the
first-launch cost with the plain image before deciding it is a problem worth an extra
gigabyte.
### 5. Deliberately excluded
| Excluded | Why |
| --- | --- |
| Dotfiles | Personal, change often, and the image is pushed to a registry |
| Credentials | Provisioned at runtime as proxy-substituted placeholders |
| `~/.claude` config and plugins | The task copies these per sandbox from the host |
| The chezmoi age key | Never leaves the host |
## Interaction with the task
The image and the task must agree on exactly two things:
| Contract | Owner |
| --- | --- |
| `/usr/local/bin/ai-sbx-workspace` exists and behaves as specified | image |
| Skip installing the launcher when the image supplies it | task |
Everything else — Claude config, plugins, mise, secrets, network policy — is already
handled at runtime and needs nothing from the image.
Consumed by setting, in `~/.config/mise/config.toml`:
```toml
AI_SBX_TEMPLATE = "git.mroberts.dev/mroberts/claude-sbx:v1"
```
Once the image carries tmux and neovim, `AI_SBX_TOOLS` can drop back to `bun`.
## Build and publish
Per `HANDOFF.md`: Gitea Actions, pushing to `git.mroberts.dev/mroberts/claude-sbx`.
- Tag immutably. `AI_SBX_TEMPLATE` pins one ref, and a moving tag means an unreviewed
image runs on the developer's workstation with their credentials.
- Confirm an Actions runner is registered before writing the workflow —
`has_actions: true` only means the feature is enabled. Fall back to
`docker build --push` from a workstation if there is none.
- The sandbox pulls from the registry directly and does not share the host image
store, so a local build is invisible to it. Use `sbx secret set --registry
git.mroberts.dev` for pull credentials.
## Acceptance
1. `docker build` succeeds from a clean checkout with no access to `~/.claude` or
`~/.local/share/chezmoi`.
2. In a sandbox created with `AI_SBX_TEMPLATE` set to the pushed image:
- `tmux -V` and `nvim --version` both report the pinned versions
- `ai-sbx-workspace` exists and is executable
- `id -u` is 1000 and `sudo true` succeeds
- `printenv HTTPS_PROXY` is non-empty under `sudo -E`
3. `mise run ai:sbx -- run` with `AI_SBX_LAUNCH=tmux` opens three windows without the
task installing anything.
4. No credential, transcript, or dotfile is present in any layer — checked by
extracting layers, not by inspecting the build context.
5. `AI_SBX_TOOLS="bun"` is sufficient; nothing regresses from dropping tmux and neovim
out of it.
## Open questions
- **Neovim delivery.** Upstream tarball, mise, or a PPA. Tarball is the most
predictable and the easiest to pin.
- **Image size.** Measure before and after. The `-docker` base is already substantial;
tmux and neovim are small, pre-warmed plugins are not.
- **Rebuild cadence.** The image lags the host config by definition. Decide whether it
tracks a tagged `claude-sbx` release or is rebuilt on dotfile changes — the answer
determines whether pre-warming is even coherent.