Launch a tmux workspace and carry dotfiles into the sandbox
AI_SBX_LAUNCH=tmux (or run --launch tmux) attaches to a three-window tmux session - agent, edit, shell - instead of the bare agent. The launcher is vendored at tasks/ai/workspace and installed into the sandbox, so a custom image and this task cannot drift. It is invoked through a login shell because /etc/sandbox-persistent.sh is where PATH, the mise shims, the AWS credentials and every secret placeholder live, and the tmux server hands that environment to all three windows. AI_SBX_DOTFILES=chezmoi renders the host chezmoi target state and unpacks it into the sandbox, so no dotfiles repository, decryption key or network access is needed inside. chezmoi archive decrypts as it renders, so the target list is an allowlist, encrypted files resolving inside it are refused, and the rendered archive is scanned for credential shapes before it enters the sandbox. Both default to off; with neither set, run behaves exactly as before.
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
# Spec: tmux workspace and dotfiles — `ai-sandbox`
|
||||
|
||||
Implementation spec for the task side. Background and the decisions behind it are in
|
||||
[`tmux-workspace-plan.md`](tmux-workspace-plan.md); the image side is
|
||||
`mroberts/claude-sbx` → `docs/base-image-spec.md`.
|
||||
|
||||
## Scope
|
||||
|
||||
1. `AI_SBX_LAUNCH` — attach to a tmux workspace instead of the bare agent.
|
||||
2. `AI_SBX_DOTFILES` — render the host's chezmoi dotfiles into the sandbox.
|
||||
|
||||
Both default to off. With neither set, behaviour is byte-for-byte what it is today.
|
||||
|
||||
## 1. Launch mode
|
||||
|
||||
### Configuration
|
||||
|
||||
| Surface | Values | Default |
|
||||
| --- | --- | --- |
|
||||
| `AI_SBX_LAUNCH` | `agent`, `tmux` | `agent` |
|
||||
| `run --launch MODE` | same | overrides the variable for one run |
|
||||
|
||||
Read as `DEFAULT_LAUNCH="${AI_SBX_LAUNCH:-agent}"`, matching the existing
|
||||
`AI_SBX_AGENT` / `AI_SBX_TEMPLATE` / `AI_SBX_TOOLS` / `AI_SBX_NETWORK` pattern.
|
||||
|
||||
An unrecognised value must `die`, not fall through to `agent`. A typo that silently
|
||||
does the wrong thing is worse than a stopped run.
|
||||
|
||||
Not persisted in the per-repository config. It is a property of this session, not of
|
||||
the repository; the environment variable already covers the durable case.
|
||||
|
||||
### Dispatch
|
||||
|
||||
`run_command` keeps its current preamble — `load_config`, `sandbox_exists`,
|
||||
`install_sandbox_aws_files`, `install_sandbox_mise` — and then branches:
|
||||
|
||||
```bash
|
||||
case "$launch" in
|
||||
agent)
|
||||
exec sbx run "$SANDBOX_NAME" ${1:+-- "$@"}
|
||||
;;
|
||||
tmux)
|
||||
exec sbx exec -it -w "$REPO_ROOT" "$SANDBOX_NAME" \
|
||||
bash -lc 'ai-sbx-workspace'
|
||||
;;
|
||||
esac
|
||||
```
|
||||
|
||||
`bash -lc` is mandatory. `/etc/sandbox-persistent.sh` is where PATH, the mise shims,
|
||||
the AWS credentials and every secret placeholder live; a non-login shell loses all of
|
||||
it, and the symptom is "npm cannot authenticate", nothing that points at tmux.
|
||||
|
||||
Agent arguments (`run -- --foo`) apply to `agent` mode only. In `tmux` mode they are
|
||||
rejected with an explanatory error rather than silently dropped.
|
||||
|
||||
### The launcher
|
||||
|
||||
Shipped as a file in this repository at `tasks/ai/workspace`, installed into the
|
||||
sandbox at `~/.local/bin/ai-sbx-workspace` by a new `install_sandbox_workspace`,
|
||||
alongside the existing mise install. If the image already provides
|
||||
`/usr/local/bin/ai-sbx-workspace` (see the image spec) the copy is skipped, and the
|
||||
image's copy is built from this same file so the two cannot diverge.
|
||||
|
||||
Behaviour:
|
||||
|
||||
| Requirement | Detail |
|
||||
| --- | --- |
|
||||
| Attach-or-create | If session `ai-sbx` exists, attach. Never create a second one |
|
||||
| Three windows | `agent`, `edit`, `shell`, in that order |
|
||||
| Working directory | All three start in the workspace root |
|
||||
| Agent window | Runs `claude --dangerously-skip-permissions` |
|
||||
| Edit window | Runs `nvim .` |
|
||||
| Shell window | Left at a prompt |
|
||||
| Selected window | `agent` |
|
||||
|
||||
The agent command is **observed**, not assumed: attaching with `sbx run` and sampling
|
||||
the process table inside the sandbox shows `claude --dangerously-skip-permissions`.
|
||||
|
||||
Because it is agent-specific, resolve it through a small mapping keyed on
|
||||
`CONFIG_AGENT`, defaulting to the bare agent name for agents whose invocation has not
|
||||
been observed. Getting this wrong for `claude` would silently change the agent's
|
||||
permission model, so the `claude` entry must be exact.
|
||||
|
||||
Degrade rather than fail: if `tmux` is missing in the sandbox, print how to install it
|
||||
(`AI_SBX_TOOLS`, or the custom image) and fall back to launching the agent directly.
|
||||
|
||||
## 2. Dotfiles
|
||||
|
||||
### Configuration
|
||||
|
||||
| Surface | Values | Default |
|
||||
| --- | --- | --- |
|
||||
| `AI_SBX_DOTFILES` | `chezmoi`, unset | unset (off) |
|
||||
| `~/.config/ai-sbx/dotfiles` | newline-separated target allowlist | required when enabled |
|
||||
|
||||
### Mechanism
|
||||
|
||||
Host-side render, copy in. No repo clone, no age key, no network inside the sandbox:
|
||||
|
||||
```bash
|
||||
DEV_CONTAINER=1 chezmoi archive --format tar <target>... |
|
||||
sbx exec -i "$SANDBOX_NAME" tar -x -C "$sandbox_home"
|
||||
```
|
||||
|
||||
`DEV_CONTAINER=1` is required. The existing `.chezmoi.toml.tmpl` branches on it and
|
||||
setting it disables `git.autoCommit` and `git.autoPush` — without it an agent in the
|
||||
sandbox could push to the dotfiles repo.
|
||||
|
||||
### The allowlist is a security control, not a convenience
|
||||
|
||||
`chezmoi archive` **decrypts as it renders**. A full archive of the current source
|
||||
contains, in plaintext:
|
||||
|
||||
```text
|
||||
.config/gh/hosts.yml GitHub CLI auth tokens
|
||||
.npmrc npm registry tokens
|
||||
.nuget/NuGet/NuGet.Config NuGet credentials
|
||||
.ssh/config
|
||||
.mcp.json
|
||||
.aider.conf.yml
|
||||
```
|
||||
|
||||
Copying those in would hand the agent the credentials this project deliberately keeps
|
||||
out — the GitHub token is proxy-injected as an unreadable placeholder, and
|
||||
`hosts.yml` would defeat that in one step.
|
||||
|
||||
Therefore:
|
||||
|
||||
1. **Allowlist only.** A denylist rots as new encrypted files appear, and the failure
|
||||
mode is silent credential exfiltration.
|
||||
2. **Refuse on overlap.** Enumerate every `encrypted_*` file in `chezmoi source-path`,
|
||||
resolve each with `chezmoi target-path`, and `die` if any resolved target is inside
|
||||
the allowlist. Verified working: all six current entries resolve correctly.
|
||||
3. **Scan the rendered archive** for credential shapes before it enters the sandbox,
|
||||
reusing the guard already in the template build script.
|
||||
|
||||
Note `.config/fish/conf.d/tokens.fish` is **not** encrypted but is named as though it
|
||||
holds secrets. Anything selected must be reviewed once by a human; the tooling cannot
|
||||
infer intent from a filename.
|
||||
|
||||
### Suggested starting allowlist
|
||||
|
||||
```text
|
||||
.config/nvim
|
||||
.tmux.conf
|
||||
.gitconfig
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
Following the existing suite: pure functions and source-level assertions, no sandbox.
|
||||
|
||||
| Test | Asserts |
|
||||
| --- | --- |
|
||||
| launch default | unset `AI_SBX_LAUNCH` → `agent` |
|
||||
| launch override | `--launch tmux` beats the variable |
|
||||
| launch validation | an unknown value exits non-zero |
|
||||
| dispatch | stubbed `sbx` shows `sbx run` for `agent`, `sbx exec -it` for `tmux` |
|
||||
| login shell | the tmux branch contains `bash -lc` |
|
||||
| agent command | the `claude` mapping is exactly `claude --dangerously-skip-permissions` |
|
||||
| launcher | `bash -n` clean, shellcheck clean, creates exactly three windows |
|
||||
| dotfiles overlap | an allowlist containing an encrypted target exits non-zero |
|
||||
| dotfiles off | unset `AI_SBX_DOTFILES` performs no chezmoi call |
|
||||
|
||||
Each must fail when its guard is removed — the same regression check used for the
|
||||
non-interactive and multi-line-network tests.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Neither variable set → `run` behaves exactly as today.
|
||||
2. `AI_SBX_LAUNCH=tmux` → three windows, agent running in the first, all in the
|
||||
workspace root.
|
||||
3. Detach, re-run → reattaches to the same session with the agent's context intact.
|
||||
4. In the shell window, `npm ci` in `webui/` still authenticates, proving the
|
||||
environment survived the login shell.
|
||||
5. `--launch agent` overrides the variable for one run.
|
||||
6. `AI_SBX_DOTFILES=chezmoi` with a valid allowlist → those targets appear in the
|
||||
sandbox and no file from the encrypted set does.
|
||||
7. Adding an encrypted target to the allowlist → setup fails with a clear message.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- A `kind: sandbox` kit that makes `sbx run` itself open tmux. Considered and
|
||||
rejected in the plan: it requires declaring the whole agent and satisfying the base
|
||||
image contract, for a launch preference.
|
||||
- Persisting launch mode per repository.
|
||||
- Dotfiles managers other than chezmoi.
|
||||
Reference in New Issue
Block a user