Open the network policy for hosts a sandbox actually needs
Sandboxes default to a deny-everything-else policy, so the registry credentials provisioned as custom secrets were unusable: npm.fontawesome.com, proget.careevolution.com and localstack.cloud were all denied, and the request never left the sandbox for the proxy to substitute a token into. The failure looked like a connection error rather than a policy decision. Provisioning a secret now allows its hosts in the same step, since a credential for a denied host cannot be used by definition. AI_SBX_NETWORK declares any further hosts, comma or space separated, for private registries that back no secret. The marketplace loop's inline policy call moves into the shared helper so the two cannot drift. The Balanced policy already permits github.com, codeload and the githubusercontent hosts, registry.npmjs.org, pypi.org, files.pythonhosted.org, crates.io and the Go proxies, so npm, pip, cargo, go and a plugin-managed Neovim need nothing declared. Only private hosts do.
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
# Plan: launch a tmux workspace instead of the bare agent
|
||||
|
||||
Give `ai:sbx` an option to attach to a tmux session with three windows — agent, editor,
|
||||
shell — rather than dropping straight into the agent.
|
||||
|
||||
Spans two repositories: the option and launch logic here, the tools and dotfiles in
|
||||
the base image (`mroberts/claude-sbx`).
|
||||
|
||||
## What was established
|
||||
|
||||
Measured against `sbx` 0.37.0 and a live sandbox, not assumed:
|
||||
|
||||
| Finding | Consequence |
|
||||
| --- | --- |
|
||||
| PID 1 is `tini -- sh -c … sleep infinity`; no agent process runs until attach | `sbx run` execs the agent on attach. Launching tmux instead displaces nothing |
|
||||
| `sbx exec -it SANDBOX CMD` allocates a TTY and keeps stdin open | A tmux session can be attached without a kit or a custom image |
|
||||
| `sandbox.entrypoint.run` in a `kind: sandbox` kit replaces the image entrypoint | The alternative route: `sbx run` itself opens tmux |
|
||||
| `tmux`, `nvim`, `vim` are all absent from `claude-code-docker` | Something must supply them |
|
||||
| mise resolves `tmux` 3.7b (aqua/asdf) and `neovim` 0.12.4 (aqua) | `AI_SBX_TOOLS` can supply both with no image work |
|
||||
| `files/home/` in a kit maps to `/home/agent/` | Dotfiles can ship without an image rebuild too |
|
||||
| `/etc/sandbox-persistent.sh` carries PATH, AWS, and secret placeholders | Any window started via a **login** shell inherits the environment |
|
||||
|
||||
That last row is load-bearing: tmux windows must start login shells (`bash -l`), or
|
||||
they lose mise shims, AWS credentials, and every secret placeholder.
|
||||
|
||||
## Two decisions
|
||||
|
||||
### How to launch
|
||||
|
||||
**Recommended: `sbx exec -it`, from `run_command`.**
|
||||
|
||||
```bash
|
||||
exec sbx exec -it -w "$REPO_ROOT" "$SANDBOX_NAME" \
|
||||
bash -lc 'ai-sbx-workspace'
|
||||
```
|
||||
|
||||
No kit, no custom image, no change to how the sandbox is created. Reversible per run.
|
||||
`sbx run` remains available untouched for anyone who wants the plain agent.
|
||||
|
||||
The alternative — a `kind: sandbox` kit with `entrypoint.run` — makes `sbx run`
|
||||
itself open tmux, which is tidier semantically. It costs more: the kit must declare
|
||||
the whole agent (image and entrypoint), the image must satisfy the base image
|
||||
contract (non-root `agent` at UID 1000, passwordless sudo, proxy variables preserved
|
||||
across sudo), and the agent's own launch flags must be reproduced. Not worth it for a
|
||||
launch preference.
|
||||
|
||||
### Where tmux and neovim come from
|
||||
|
||||
**Recommended: start with `AI_SBX_TOOLS`, move to the image once it settles.**
|
||||
|
||||
```toml
|
||||
AI_SBX_TOOLS = "bun tmux neovim"
|
||||
```
|
||||
|
||||
Already implemented and needs no new code. Costs a per-sandbox install on first
|
||||
`setup`, which is why the image is the eventual home — but proving the layout is
|
||||
worth more than saving that minute, and the image cannot be iterated on as quickly.
|
||||
|
||||
## Part 1 — changes here
|
||||
|
||||
### 1. `AI_SBX_LAUNCH`
|
||||
|
||||
Follows the existing `AI_SBX_AGENT` / `AI_SBX_TEMPLATE` / `AI_SBX_TOOLS` pattern:
|
||||
an environment variable read at the top of the task, overridable per invocation.
|
||||
|
||||
| Value | Behaviour |
|
||||
| --- | --- |
|
||||
| `agent` | Current behaviour: `sbx run`. **Default** |
|
||||
| `tmux` | Attach to the workspace session |
|
||||
|
||||
Plus `--launch agent|tmux` on `run` for a one-off override. Persisting it per
|
||||
repository in `save_config` is the wrong call — it is a preference about *this*
|
||||
session, not a property of the repository, and the environment variable already
|
||||
covers the durable case.
|
||||
|
||||
### 2. The launcher
|
||||
|
||||
A script the task installs into the sandbox, not an inline `sbx exec` string. Three
|
||||
reasons: it must be idempotent, it needs real logic, and quoting a multi-window tmux
|
||||
invocation through two shells is how mistakes happen.
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
session=ai-sbx
|
||||
|
||||
if tmux has-session -t "$session" 2>/dev/null; then
|
||||
exec tmux attach-session -t "$session"
|
||||
fi
|
||||
|
||||
tmux new-session -d -s "$session" -n agent -c "$PWD"
|
||||
tmux new-window -t "$session:" -n edit -c "$PWD"
|
||||
tmux new-window -t "$session:" -n shell -c "$PWD"
|
||||
|
||||
tmux send-keys -t "$session:agent" "$AGENT_COMMAND" C-m
|
||||
tmux send-keys -t "$session:edit" "nvim ." C-m
|
||||
|
||||
tmux select-window -t "$session:agent"
|
||||
exec tmux attach-session -t "$session"
|
||||
```
|
||||
|
||||
Attach-or-create matters: detaching and re-running must land back in the same session
|
||||
with the agent's context intact, which is most of the point.
|
||||
|
||||
Install it the way `mise` already is — copied to `~/.local/bin/` inside the sandbox
|
||||
during `install_sandbox_mise`, or a sibling `install_sandbox_workspace`.
|
||||
|
||||
### 3. Resolve the agent command — do this first
|
||||
|
||||
**The one real unknown.** `sbx run` execs the agent with flags this project has never
|
||||
seen, because the container only sleeps until attach. Claude Code is very likely
|
||||
started with `--dangerously-skip-permissions` — the Sandboxes FAQ describes a kit
|
||||
that exists specifically to *drop* that flag — but that is inference, not
|
||||
observation.
|
||||
|
||||
Determine it before writing the launcher:
|
||||
|
||||
```bash
|
||||
sbx run --name <sandbox> &
|
||||
sbx exec <sandbox> ps -eo args | grep -i claude
|
||||
```
|
||||
|
||||
If it cannot be recovered, fall back to `claude` plain and document the difference,
|
||||
because silently changing the agent's permission model would be worse than the
|
||||
inconvenience.
|
||||
|
||||
### 4. Tests
|
||||
|
||||
Consistent with the existing suite — pure functions and source-level assertions, no
|
||||
sandbox required:
|
||||
|
||||
- `AI_SBX_LAUNCH` defaults to `agent`; `--launch` overrides it; an unknown value is
|
||||
rejected rather than silently treated as `agent`.
|
||||
- `run_command` dispatches to `sbx run` for `agent` and `sbx exec -it` for `tmux`,
|
||||
asserted with a stubbed `sbx`, as `create_sandbox` already is.
|
||||
- The launcher script is syntax-checked and shellcheck-clean.
|
||||
- The launcher uses a **login** shell, since a non-login shell loses the whole
|
||||
environment. Assert `bash -lc` appears.
|
||||
|
||||
## Part 2 — what to bake into the base image
|
||||
|
||||
Once the layout settles, move it out of `AI_SBX_TOOLS` and into
|
||||
`mroberts/claude-sbx`, per `docs/HANDOFF.md` there.
|
||||
|
||||
### Packages
|
||||
|
||||
```dockerfile
|
||||
USER root
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends tmux \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
```
|
||||
|
||||
Neovim is the awkward one: Ubuntu ships an old version, and a modern config will
|
||||
expect ≥ 0.10. Prefer the upstream tarball or keep it on mise rather than `apt`.
|
||||
|
||||
### Dotfiles
|
||||
|
||||
`files/home/` in a kit, or `COPY --chown=agent:agent` in the template:
|
||||
|
||||
```
|
||||
.tmux.conf mouse on, sane scrollback, obvious status line
|
||||
.config/nvim/ the smallest config that is pleasant on a fresh machine
|
||||
```
|
||||
|
||||
Keep the nvim config deliberately minimal. A plugin-manager bootstrap that fetches
|
||||
from the network on first launch will hit the default-deny policy, and debugging that
|
||||
inside a microVM is a bad first experience.
|
||||
|
||||
### The launcher
|
||||
|
||||
Bake the same script at `/usr/local/bin/ai-sbx-workspace` so the task can call it
|
||||
without installing anything. Keep the task's copy as the fallback for stock images —
|
||||
the two must not drift, so it should live in one place here and be copied into the
|
||||
image build rather than maintained twice.
|
||||
|
||||
### Verify
|
||||
|
||||
Check what the base image already provides before adding anything:
|
||||
|
||||
```bash
|
||||
docker run --rm docker/sandbox-templates:claude-code-docker \
|
||||
bash -lc 'for c in tmux nvim vim git node python3; do printf "%-8s %s\n" "$c" "$(command -v $c || echo MISSING)"; done'
|
||||
```
|
||||
|
||||
## Dotfiles (chezmoi)
|
||||
|
||||
Short answer: **mise supplies the binary, but not the dotfiles, and baking them into
|
||||
the image is the wrong home for them.** A third path fits better — render on the host,
|
||||
copy the result in — which is how `~/.claude` is already handled.
|
||||
|
||||
### Why not mise alone
|
||||
|
||||
`mise` installs chezmoi fine (`aqua:twpayne/chezmoi`, 2.71.1), so
|
||||
`AI_SBX_TOOLS = "bun tmux neovim chezmoi"` puts the binary in every sandbox. Getting
|
||||
the *content* in is where it stops, for two independent reasons:
|
||||
|
||||
| Blocker | Detail |
|
||||
| --- | --- |
|
||||
| The repo is private | `github.com/mickeyr/DotFiles` returns 404 anonymously. The sandbox's GitHub token is a fine-grained PAT scoped to the repository being worked on, so it cannot clone a personal repo |
|
||||
| Six files are age-encrypted | The identity lives at `~/.config/chezmoi/key.txt` on the host. `chezmoi apply` in the sandbox would need that private key copied in |
|
||||
|
||||
So `chezmoi init --apply github.com/mickeyr/DotFiles` inside a sandbox fails twice
|
||||
over. Fixing it by copying an age private key into an environment an agent can read is
|
||||
worse than the problem.
|
||||
|
||||
### Why not the image
|
||||
|
||||
Dotfiles are personal and change often; the image is shared and slow to rebuild. Every
|
||||
nvim tweak would mean a rebuild and a registry push. Worse, the image is pushed to a
|
||||
registry — anything baked in travels with it.
|
||||
|
||||
### Recommended: `chezmoi archive` on the host, allowlisted
|
||||
|
||||
`chezmoi archive` renders the target state on the host, where the age key already is,
|
||||
and emits a tar. No repo access, no key, and no network needed inside the sandbox.
|
||||
|
||||
```bash
|
||||
DEV_CONTAINER=1 chezmoi archive --format tar <target>... |
|
||||
sbx exec -i "$SANDBOX_NAME" tar -x -C "$sandbox_home"
|
||||
```
|
||||
|
||||
Measured: 241 entries, 542 KB for the full set; `chezmoi archive ~/.config/nvim`
|
||||
scopes it to 62.
|
||||
|
||||
**`DEV_CONTAINER=1` is required, not cosmetic.** The existing `.chezmoi.toml.tmpl`
|
||||
already branches on it, and setting it disables `git.autoCommit` and `git.autoPush` —
|
||||
without it, an agent operating in the sandbox could push to the dotfiles repo.
|
||||
|
||||
### The part that must not be got wrong
|
||||
|
||||
`chezmoi archive` **decrypts** as it renders. A full archive therefore contains, in
|
||||
plaintext:
|
||||
|
||||
```text
|
||||
.config/gh/hosts.yml GitHub CLI auth tokens
|
||||
.npmrc npm registry tokens
|
||||
.nuget/NuGet/NuGet.Config NuGet credentials
|
||||
.ssh/config
|
||||
.config/fish/conf.d/tokens.fish
|
||||
.mcp.json
|
||||
.aider.conf.yml
|
||||
```
|
||||
|
||||
Copying that in would hand the agent the very credentials this project spends its
|
||||
effort keeping out — the GitHub token is proxy-injected as a placeholder precisely so
|
||||
it is unreadable, and `.config/gh/hosts.yml` would undo that in one step.
|
||||
|
||||
So the copy must be an **allowlist of targets**, matching `CLAUDE_CONFIG_ALLOW`:
|
||||
|
||||
```bash
|
||||
CHEZMOI_TARGET_ALLOW=(
|
||||
.config/nvim
|
||||
.config/fish # audit: conf.d/tokens.fish must not be included
|
||||
.tmux.conf
|
||||
.gitconfig
|
||||
)
|
||||
```
|
||||
|
||||
A denylist is not good enough. New encrypted files appear over time, and the failure
|
||||
mode is silent credential exfiltration into an agent's environment.
|
||||
|
||||
Two mechanical checks worth building in, since both are cheap:
|
||||
|
||||
- Enumerate encrypted targets and refuse to proceed if any is inside the allowlist:
|
||||
`chezmoi target-path` resolves each `encrypted_*` source file to its target, and all
|
||||
six mapped correctly when tested.
|
||||
- Scan the rendered archive for credential shapes before it enters the sandbox — the
|
||||
same guard the template build script already uses.
|
||||
|
||||
### Where it goes
|
||||
|
||||
A `install_sandbox_dotfiles` alongside `install_sandbox_claude_config`, gated on
|
||||
`AI_SBX_DOTFILES` (unset = off). It runs on the host, so it needs no chezmoi in the
|
||||
sandbox at all — which makes the mise entry optional, useful only if the agent should
|
||||
be able to run `chezmoi` itself.
|
||||
|
||||
## Risks
|
||||
|
||||
**Environment loss.** Any window not started as a login shell loses PATH, AWS
|
||||
credentials, and secret placeholders. This will look like "npm suddenly cannot
|
||||
authenticate" rather than anything to do with tmux.
|
||||
|
||||
**Detach semantics.** `sbx exec -it` with a detached tmux session leaves the agent
|
||||
running inside the sandbox after the terminal closes. Desirable, but different from
|
||||
today, where closing the terminal ends the session. Worth stating in the README.
|
||||
|
||||
**Nested tmux.** A developer already inside tmux on the host gets a nested session.
|
||||
Setting a distinct prefix in the sandbox `.tmux.conf` avoids a confusing fight over
|
||||
`C-b`.
|
||||
|
||||
**Terminal type.** `TERM` must survive into the sandbox or nvim renders badly.
|
||||
`sbx exec -t` should handle it; confirm rather than assume.
|
||||
|
||||
**LazyVim bootstrap — measured, and not a problem.** Every host the config needs is
|
||||
already permitted by the `Balanced` policy, checked individually with
|
||||
`sbx policy check network`:
|
||||
|
||||
```text
|
||||
github.com codeload.github.com raw/objects.githubusercontent.com Allowed
|
||||
registry.npmjs.org pypi.org files.pythonhosted.org Allowed
|
||||
crates.io static.crates.io proxy.golang.org sum.golang.org Allowed
|
||||
```
|
||||
|
||||
That covers all 93 pinned lazy.nvim plugins and all 55 mason packages (22 npm, 4 pypi,
|
||||
29 GitHub releases). First launch will be slow, not blocked.
|
||||
|
||||
The hosts that *were* denied are the work ones — `npm.fontawesome.com`,
|
||||
`proget.careevolution.com`, `localstack.cloud` — now handled by `AI_SBX_NETWORK` and
|
||||
by automatic allowance of any host backing a provisioned secret.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. `AI_SBX_LAUNCH` unset → `mise run ai:sbx -- run` behaves exactly as today.
|
||||
2. `AI_SBX_LAUNCH=tmux` → three windows, agent running in the first, all three in the
|
||||
repository root.
|
||||
3. Detach and re-run → reattaches to the same session, agent context intact.
|
||||
4. Inside the shell window, `npm ci` in `webui/` still authenticates — proving the
|
||||
environment survived.
|
||||
5. `--launch agent` overrides the variable for one run.
|
||||
Reference in New Issue
Block a user