Compare commits
6
Commits
v1.9.0
...
16ba06cc6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16ba06cc6d | ||
|
|
e0f6113320 | ||
|
|
b5dfae0696 | ||
|
|
53db7df812 | ||
|
|
14165503d5 | ||
|
|
ad2b33f984 |
@@ -243,6 +243,13 @@ gh pr list
|
|||||||
gh pr create --fill
|
gh pr create --fill
|
||||||
```
|
```
|
||||||
|
|
||||||
|
So is `git push`. The clone inherits `origin` from the host, which is usually an SSH
|
||||||
|
URL, and SSH cannot work in the sandbox — there is no key and port 22 is closed. A
|
||||||
|
global `insteadOf` rewrites `[email protected]:` and `ssh://[email protected]/` to
|
||||||
|
`https://github.com/`, so the push traverses the proxy and picks up the token. Remotes
|
||||||
|
on other hosts are left alone, and under `--direct` the host's own `.git/config` is
|
||||||
|
never touched.
|
||||||
|
|
||||||
AWS named profiles work as Terraform expects:
|
AWS named profiles work as Terraform expects:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -263,11 +270,11 @@ provider "aws" {
|
|||||||
|
|
||||||
| Command | Effect |
|
| Command | Effect |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `setup [options]` | Configure the repository, create the sandbox, open the token form, install AWS profiles, Claude configuration and plugins, and mise |
|
| `setup [options]` | Configure the repository, create the sandbox, open the token form if no token is stored yet, install AWS profiles, Claude configuration and plugins, and mise |
|
||||||
| `token` | Replace the GitHub token for this repository — expiry, revocation, permission change |
|
| `token` | Replace the GitHub token for this repository — expiry, revocation, permission change. Always prompts |
|
||||||
| `run [-- args...]` | Refresh AWS credentials and the repository's mise tools, then attach to the agent |
|
| `run [--launch MODE] [-- args...]` | Refresh AWS credentials and the repository's mise tools, then attach to the agent or to a tmux workspace |
|
||||||
| `refresh` | Refresh AWS credentials, without attaching |
|
| `refresh` | Refresh AWS credentials, without attaching |
|
||||||
| `config` | Re-apply your Claude configuration and plugins after the host changes, without recreating the sandbox |
|
| `config` | Re-apply your Claude configuration, plugins and dotfiles after the host changes, without recreating the sandbox |
|
||||||
| `status` | Show repository, sandbox, agent, mode, token expiry setting, profile mapping, stored secrets |
|
| `status` | Show repository, sandbox, agent, mode, token expiry setting, profile mapping, stored secrets |
|
||||||
| `remove` | Remove the sandbox and this repository's local configuration |
|
| `remove` | Remove the sandbox and this repository's local configuration |
|
||||||
|
|
||||||
@@ -294,6 +301,95 @@ provider "aws" {
|
|||||||
| `AI_SBX_TEMPLATE` | unset | `--template` / `--stock-template` |
|
| `AI_SBX_TEMPLATE` | unset | `--template` / `--stock-template` |
|
||||||
| `AI_SBX_TOOLS` | `bun` | mise tools installed globally in the sandbox; empty installs none |
|
| `AI_SBX_TOOLS` | `bun` | mise tools installed globally in the sandbox; empty installs none |
|
||||||
| `AI_SBX_NETWORK` | unset | hosts to allow through the sandbox network policy, comma or space separated |
|
| `AI_SBX_NETWORK` | unset | hosts to allow through the sandbox network policy, comma or space separated |
|
||||||
|
| `AI_SBX_LAUNCH` | `agent` | `run --launch agent\|tmux` |
|
||||||
|
| `AI_SBX_DOTFILES` | unset | `chezmoi` renders the host's dotfiles into the sandbox |
|
||||||
|
| `AI_SBX_SIGNING_KEY` | unset | host path to an SSH signing key; its private half is copied in so the sandbox can sign commits |
|
||||||
|
|
||||||
|
## The tmux workspace
|
||||||
|
|
||||||
|
`AI_SBX_LAUNCH=tmux`, or `run --launch tmux`, attaches to a three-window tmux session
|
||||||
|
inside the sandbox instead of the bare agent:
|
||||||
|
|
||||||
|
| Window | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| `agent` | The agent, started the same way `sbx run` starts it |
|
||||||
|
| `edit` | `nvim .` |
|
||||||
|
| `shell` | A prompt |
|
||||||
|
|
||||||
|
All three start in the workspace root and inherit the sandbox environment, so AWS
|
||||||
|
profiles and injected registry credentials work in every one of them.
|
||||||
|
|
||||||
|
The session is named `ai-sbx` and is attached to rather than recreated, so detaching
|
||||||
|
and re-running lands back in the same place with the agent's context intact. That also
|
||||||
|
means closing the terminal no longer ends the session — the agent keeps running inside
|
||||||
|
the sandbox until it is stopped.
|
||||||
|
|
||||||
|
Agent arguments (`run -- --resume`) apply to `agent` mode only; passing them with
|
||||||
|
`--launch tmux` is an error rather than a silent no-op.
|
||||||
|
|
||||||
|
The sandbox image must provide `tmux` and `nvim`. The stock image provides neither,
|
||||||
|
so add them with mise:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
AI_SBX_TOOLS = "bun tmux neovim"
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `tmux` the launcher says so and starts the agent directly.
|
||||||
|
|
||||||
|
## Signing commits from the sandbox
|
||||||
|
|
||||||
|
By default the sandbox holds no signing material, so its commits arrive unverified.
|
||||||
|
`AI_SBX_SIGNING_KEY` points at an SSH signing key on the host and copies **its private
|
||||||
|
half** into the sandbox:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
AI_SBX_SIGNING_KEY = "~/.ssh/id_ed25519_signing"
|
||||||
|
```
|
||||||
|
|
||||||
|
Setup then writes `gpg.format`, `user.signingkey`, `commit.gpgsign` and `tag.gpgsign`
|
||||||
|
into the sandbox's global git config, and an `allowed_signers` entry mapping the
|
||||||
|
repository's `user.email` to the key so the sandbox can verify what it just signed. jj
|
||||||
|
is pointed at the same key through `~/.config/jj/conf.d/10-ai-sbx-signing.toml`, which
|
||||||
|
is read after `config.toml` and so overrides the host path a copied dotfile carries.
|
||||||
|
|
||||||
|
This is the one place the sandbox is deliberately given a real credential, so the
|
||||||
|
constraints are narrow:
|
||||||
|
|
||||||
|
- **Use a key that only signs.** An agent that can read the key can sign as you. A
|
||||||
|
signing key grants no repository access and is revocable on its own, so the worst
|
||||||
|
case is forged attestation rather than reach. Never point this at an authentication
|
||||||
|
key.
|
||||||
|
- **No passphrase.** Nothing in the sandbox can answer a prompt. Setup refuses an
|
||||||
|
encrypted key rather than letting every commit fail at the moment of signing.
|
||||||
|
- **Both halves must exist.** git names the signing key by its `.pub`.
|
||||||
|
|
||||||
|
## Carrying your dotfiles into the sandbox
|
||||||
|
|
||||||
|
`AI_SBX_DOTFILES=chezmoi` renders your chezmoi target state **on the host** — where the
|
||||||
|
age identity already lives — and unpacks the resulting tar into the sandbox home. The
|
||||||
|
sandbox needs no dotfiles repository, no decryption key and no network for this, and
|
||||||
|
`DEV_CONTAINER=1` is set so `git.autoCommit` and `git.autoPush` stay off.
|
||||||
|
|
||||||
|
Which files travel is an allowlist of target paths, one per line, in
|
||||||
|
`~/.config/ai-sbx/dotfiles`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.config/nvim
|
||||||
|
.tmux.conf
|
||||||
|
.gitconfig
|
||||||
|
```
|
||||||
|
|
||||||
|
The allowlist is a security control, not a convenience. `chezmoi archive` **decrypts as
|
||||||
|
it renders**, so a full archive contains your `gh` tokens, `.npmrc`, NuGet credentials
|
||||||
|
and `.ssh/config` in plaintext — precisely what the proxy-injected GitHub token exists
|
||||||
|
to keep away from the agent. Two checks enforce this:
|
||||||
|
|
||||||
|
- every `encrypted_*` source file is resolved to its target, and setup fails if any
|
||||||
|
lands inside the allowlist;
|
||||||
|
- the rendered archive is scanned for credential shapes before it enters the sandbox.
|
||||||
|
|
||||||
|
Neither can infer intent from a filename, so review what you list once. A file named
|
||||||
|
`tokens.fish` that happens not to be encrypted is still a file full of tokens.
|
||||||
|
|
||||||
## Carrying your Claude configuration into the sandbox
|
## Carrying your Claude configuration into the sandbox
|
||||||
|
|
||||||
@@ -544,10 +640,8 @@ Inspect the current repository's state with `mise run ai:sbx -- status`.
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash tests/profile-mapping.test.sh
|
for test in tests/*.test.sh; do bash "$test"; done
|
||||||
bash tests/token-url.test.sh
|
shellcheck -x tasks/ai/sbx tasks/ai/workspace tests/*.sh
|
||||||
bash tests/invocation-directory.test.sh
|
|
||||||
shellcheck -x tasks/ai/sbx tests/*.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The token test checks URL encoding, that `target_name` carries the owner rather than
|
The token test checks URL encoding, that `target_name` carries the owner rather than
|
||||||
|
|||||||
+36
-14
@@ -164,9 +164,9 @@ expect ≥ 0.10. Prefer the upstream tarball or keep it on mise rather than `apt
|
|||||||
.config/nvim/ the smallest config that is pleasant on a fresh machine
|
.config/nvim/ the smallest config that is pleasant on a fresh machine
|
||||||
```
|
```
|
||||||
|
|
||||||
Keep the nvim config deliberately minimal. A plugin-manager bootstrap that fetches
|
Ship the **full** config. The network objection that would have argued for trimming it
|
||||||
from the network on first launch will hit the default-deny policy, and debugging that
|
does not survive measurement — see below — so the only real cost is a slower first
|
||||||
inside a microVM is a bad first experience.
|
launch while plugins and LSP servers download.
|
||||||
|
|
||||||
### The launcher
|
### The launcher
|
||||||
|
|
||||||
@@ -293,22 +293,44 @@ Setting a distinct prefix in the sandbox `.tmux.conf` avoids a confusing fight o
|
|||||||
**Terminal type.** `TERM` must survive into the sandbox or nvim renders badly.
|
**Terminal type.** `TERM` must survive into the sandbox or nvim renders badly.
|
||||||
`sbx exec -t` should handle it; confirm rather than assume.
|
`sbx exec -t` should handle it; confirm rather than assume.
|
||||||
|
|
||||||
**LazyVim bootstrap — measured, and not a problem.** Every host the config needs is
|
**Plugin bootstrap — measured host by host with `sbx policy check network`.**
|
||||||
already permitted by the `Balanced` policy, checked individually with
|
|
||||||
`sbx policy check network`:
|
Already permitted by `Balanced`, so nothing to declare:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
github.com codeload.github.com raw/objects.githubusercontent.com Allowed
|
github.com codeload.github.com raw/objects.githubusercontent.com
|
||||||
registry.npmjs.org pypi.org files.pythonhosted.org Allowed
|
registry.npmjs.org pypi.org files.pythonhosted.org
|
||||||
crates.io static.crates.io proxy.golang.org sum.golang.org Allowed
|
crates.io static.crates.io proxy.golang.org sum.golang.org
|
||||||
|
nodejs.org deb.debian.org archive/security.ubuntu.com
|
||||||
|
releases.hashicorp.com checkpoint-api.hashicorp.com
|
||||||
|
api.githubcopilot.com copilot-proxy.githubusercontent.com
|
||||||
|
api.anthropic.com api.openai.com
|
||||||
```
|
```
|
||||||
|
|
||||||
That covers all 93 pinned lazy.nvim plugins and all 55 mason packages (22 npm, 4 pypi,
|
That covers all 93 pinned lazy.nvim plugins, both mason registries (`mason-org` and
|
||||||
29 GitHub releases). First launch will be slow, not blocked.
|
`crashdummyy`, both `github:`), all 55 mason packages, Copilot, and codecompanion.
|
||||||
|
|
||||||
The hosts that *were* denied are the work ones — `npm.fontawesome.com`,
|
Denied, and therefore declared in `AI_SBX_NETWORK`:
|
||||||
`proget.careevolution.com`, `localstack.cloud` — now handled by `AI_SBX_NETWORK` and
|
|
||||||
by automatic allowance of any host backing a provisioned secret.
|
| Host | Needed by |
|
||||||
|
| --- | --- |
|
||||||
|
| `*.nuget.org` | roslyn, easy-dotnet, NuGet restore |
|
||||||
|
| `pkgs.dev.azure.com` | Azure-hosted NuGet feeds |
|
||||||
|
| `builds.dotnet.microsoft.com`, `dotnetcli.azureedge.net`, `dotnetbuilds.azureedge.net`, `dotnetcli.blob.core.windows.net`, `ci.dot.net` | .NET SDK downloads |
|
||||||
|
| `registry.terraform.io` | provider downloads for terragrunt |
|
||||||
|
| `mise.jdx.dev` | mise self-resolution |
|
||||||
|
| `default.exp-tas.com` | Copilot feature flags |
|
||||||
|
|
||||||
|
Confirmed reachable from inside the sandbox afterwards: `api.nuget.org` and
|
||||||
|
`registry.terraform.io` both return HTTP 200.
|
||||||
|
|
||||||
|
Wildcards match a single label: `*.nuget.org` covers `api.`, `www.`, `globalcdn.` and
|
||||||
|
the bare domain, but `*.azureedge.net` does **not** reach
|
||||||
|
`dotnetcli.blob.core.windows.net`. Prefer explicit hosts over a broad CDN wildcard —
|
||||||
|
`*.azureedge.net` would admit every Azure CDN customer, not just Microsoft's.
|
||||||
|
|
||||||
|
The work registries — `npm.fontawesome.com`, `proget.careevolution.com`,
|
||||||
|
`localstack.cloud` — are allowed automatically, since they back provisioned secrets.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
+396
-6
@@ -9,6 +9,21 @@ DEFAULT_TOKEN_DAYS="${AI_SBX_TOKEN_DAYS:-30}"
|
|||||||
DEFAULT_TEMPLATE="${AI_SBX_TEMPLATE:-}"
|
DEFAULT_TEMPLATE="${AI_SBX_TEMPLATE:-}"
|
||||||
DEFAULT_TOOLS="${AI_SBX_TOOLS:-bun}"
|
DEFAULT_TOOLS="${AI_SBX_TOOLS:-bun}"
|
||||||
DEFAULT_NETWORK="${AI_SBX_NETWORK:-}"
|
DEFAULT_NETWORK="${AI_SBX_NETWORK:-}"
|
||||||
|
DEFAULT_LAUNCH="${AI_SBX_LAUNCH:-agent}"
|
||||||
|
DEFAULT_SIGNING_KEY="${AI_SBX_SIGNING_KEY:-}"
|
||||||
|
DEFAULT_DOTFILES="${AI_SBX_DOTFILES:-}"
|
||||||
|
|
||||||
|
# Rendered dotfiles are checked for these before the archive enters the sandbox.
|
||||||
|
# chezmoi archive decrypts as it renders, so this is the last line of defence
|
||||||
|
# behind the allowlist rather than the first.
|
||||||
|
DOTFILES_CREDENTIAL_PATTERNS=(
|
||||||
|
'gh[pousr]_[A-Za-z0-9]{16,}'
|
||||||
|
'github_pat_[A-Za-z0-9_]{20,}'
|
||||||
|
'-----BEGIN [A-Z ]*PRIVATE KEY-----'
|
||||||
|
'AKIA[0-9A-Z]{16}'
|
||||||
|
'_authToken[[:space:]]*='
|
||||||
|
'aws_secret_access_key'
|
||||||
|
)
|
||||||
|
|
||||||
# VAR|host[,host...]|requirement. Provisioned for every repository when the
|
# VAR|host[,host...]|requirement. Provisioned for every repository when the
|
||||||
# variable is present in the host environment. "docker" skips the entry on a
|
# variable is present in the host environment. "docker" skips the entry on a
|
||||||
@@ -65,12 +80,14 @@ Usage:
|
|||||||
mise run ai:sbx -- token
|
mise run ai:sbx -- token
|
||||||
mise run ai:sbx -- refresh
|
mise run ai:sbx -- refresh
|
||||||
mise run ai:sbx -- config
|
mise run ai:sbx -- config
|
||||||
mise run ai:sbx -- run [-- agent arguments...]
|
mise run ai:sbx -- run [--launch agent|tmux] [-- agent arguments...]
|
||||||
mise run ai:sbx -- status
|
mise run ai:sbx -- status
|
||||||
mise run ai:sbx -- remove
|
mise run ai:sbx -- remove
|
||||||
|
|
||||||
Setup opens a pre-filled GitHub token form in your browser. Use "token" on
|
Setup opens a pre-filled GitHub token form in your browser, unless a token is
|
||||||
its own to replace an expired or revoked token later.
|
already stored for the sandbox. The secret store outlives the sandbox, so
|
||||||
|
recreating one with --replace keeps its token. Use "token" on its own to
|
||||||
|
replace an expired or revoked token.
|
||||||
|
|
||||||
Set AI_SBX_TEMPLATE in your mise config to reuse one custom image across
|
Set AI_SBX_TEMPLATE in your mise config to reuse one custom image across
|
||||||
every repository without repeating --template.
|
every repository without repeating --template.
|
||||||
@@ -91,10 +108,27 @@ AI_SBX_NETWORK lists hosts to allow through the sandbox network policy, comma
|
|||||||
or space separated. Hosts backing a provisioned secret are allowed
|
or space separated. Hosts backing a provisioned secret are allowed
|
||||||
automatically, since a credential for a denied host can never be used.
|
automatically, since a credential for a denied host can never be used.
|
||||||
|
|
||||||
|
AI_SBX_LAUNCH selects what "run" attaches to: "agent" starts the agent alone
|
||||||
|
and is the default, "tmux" attaches to a three-window workspace (agent, edit,
|
||||||
|
shell) that survives detaching. --launch overrides it for one run. Agent
|
||||||
|
arguments apply to "agent" only.
|
||||||
|
|
||||||
|
AI_SBX_DOTFILES=chezmoi renders the host's chezmoi dotfiles into the sandbox.
|
||||||
|
It requires an allowlist of target paths, one per line, in the user's config
|
||||||
|
directory as a "dotfiles" file. chezmoi decrypts as it renders, so setup
|
||||||
|
refuses to run when an encrypted file resolves inside the allowlist.
|
||||||
|
|
||||||
AI_SBX_TOOLS lists mise tools installed globally in the sandbox, defaulting
|
AI_SBX_TOOLS lists mise tools installed globally in the sandbox, defaulting
|
||||||
to bun because several Claude plugins run their hooks under it. Set it to an
|
to bun because several Claude plugins run their hooks under it. Set it to an
|
||||||
empty string to install none.
|
empty string to install none.
|
||||||
|
|
||||||
|
AI_SBX_SIGNING_KEY is the path to an SSH signing key on the host. Its private
|
||||||
|
half is copied into the sandbox, so the agent can sign as you: use a key that
|
||||||
|
signs and nothing else, and never an authentication key. Unset, the default,
|
||||||
|
leaves the sandbox unable to sign and its commits arrive unverified. The key
|
||||||
|
must not be passphrase-protected, because nothing in the sandbox can answer
|
||||||
|
the prompt. git and jj are both pointed at it.
|
||||||
|
|
||||||
Setup and run install mise in the sandbox and resolve the repository's
|
Setup and run install mise in the sandbox and resolve the repository's
|
||||||
pinned tools, so the agent runs the same versions you do. A personal
|
pinned tools, so the agent runs the same versions you do. A personal
|
||||||
mise config that must stay out of the repository goes in the per-repository
|
mise config that must stay out of the repository goes in the per-repository
|
||||||
@@ -218,6 +252,17 @@ read_token() {
|
|||||||
printf '%s' "$token"
|
printf '%s' "$token"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Only a token scoped to this sandbox counts. A global one would authenticate
|
||||||
|
# the agent too, but reaching every repository the token can reach is exactly
|
||||||
|
# what this task exists to prevent, so it is not treated as satisfying setup.
|
||||||
|
sandbox_has_github_token() {
|
||||||
|
sbx secret ls 2>/dev/null |
|
||||||
|
awk -v scope="$SANDBOX_NAME" '
|
||||||
|
$1 == scope && $2 == "service" && $3 == "github" { found = 1 }
|
||||||
|
END { exit !found }
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
install_github_token() {
|
install_github_token() {
|
||||||
local url
|
local url
|
||||||
url="$(token_url)"
|
url="$(token_url)"
|
||||||
@@ -806,8 +851,14 @@ allow_sandbox_host() {
|
|||||||
install_sandbox_network() {
|
install_sandbox_network() {
|
||||||
[[ -n "$DEFAULT_NETWORK" ]] || return 0
|
[[ -n "$DEFAULT_NETWORK" ]] || return 0
|
||||||
|
|
||||||
|
# A multi-line TOML string is a natural way to write a long list, and read
|
||||||
|
# stops at the first newline, so separators are flattened before splitting.
|
||||||
|
local normalized="${DEFAULT_NETWORK//,/ }"
|
||||||
|
normalized="${normalized//$'\n'/ }"
|
||||||
|
normalized="${normalized//$'\r'/ }"
|
||||||
|
|
||||||
local -a allow_hosts
|
local -a allow_hosts
|
||||||
read -r -a allow_hosts <<<"${DEFAULT_NETWORK//,/ }"
|
read -r -a allow_hosts <<<"$normalized"
|
||||||
|
|
||||||
((${#allow_hosts[@]})) || return 0
|
((${#allow_hosts[@]})) || return 0
|
||||||
|
|
||||||
@@ -1025,6 +1076,288 @@ EOF
|
|||||||
'
|
'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# A sandbox image ships without a locale, which leaves LC_CTYPE at POSIX. Every
|
||||||
|
# multibyte glyph then degrades to a placeholder: Nerd Font icons in the editor
|
||||||
|
# render as underscores, and bash printf emits \uXXXX escapes literally. LANG
|
||||||
|
# alone is enough, and leaves a user free to override individual categories.
|
||||||
|
install_sandbox_locale() {
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
sbx exec "$SANDBOX_NAME" bash -c '
|
||||||
|
persistent=/etc/sandbox-persistent.sh
|
||||||
|
marker="# BEGIN ai-sbx locale"
|
||||||
|
|
||||||
|
if grep -Fq "$marker" "$persistent" 2>/dev/null; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# locale -a spells these inconsistently across distributions, so probe
|
||||||
|
# each one for usability rather than matching its name.
|
||||||
|
for candidate in C.UTF-8 en_US.UTF-8; do
|
||||||
|
if LC_ALL="$candidate" locale >/dev/null 2>&1; then
|
||||||
|
chosen="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -n "${chosen:-}" ]] || exit 0
|
||||||
|
|
||||||
|
cat >>"$persistent" <<EOF
|
||||||
|
# BEGIN ai-sbx locale
|
||||||
|
export LANG=$chosen
|
||||||
|
# END ai-sbx locale
|
||||||
|
EOF
|
||||||
|
' </dev/null >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# The in-container clone inherits origin verbatim from the host, which is
|
||||||
|
# commonly an SSH URL. Nothing in the sandbox can satisfy SSH - there is no key
|
||||||
|
# and port 22 is closed - and only HTTPS carries the Authorization header the
|
||||||
|
# proxy substitutes the GitHub token into. Rewriting globally covers the clone,
|
||||||
|
# any repository the agent clones later, and every submodule, while leaving the
|
||||||
|
# host's own .git/config untouched under --direct.
|
||||||
|
install_sandbox_git_https() {
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
sbx exec "$SANDBOX_NAME" bash -c '
|
||||||
|
key="url.https://github.com/.insteadOf"
|
||||||
|
|
||||||
|
# insteadOf is multi-valued, so a plain set would replace the first
|
||||||
|
# form with the second and a repeat setup would accumulate duplicates.
|
||||||
|
git config --global --unset-all "$key" 2>/dev/null
|
||||||
|
|
||||||
|
git config --global --add "$key" "[email protected]:"
|
||||||
|
git config --global --add "$key" "ssh://[email protected]/"
|
||||||
|
' </dev/null >/dev/null 2>&1 ||
|
||||||
|
printf 'Could not rewrite GitHub SSH remotes to HTTPS in %s.\n' \
|
||||||
|
"$SANDBOX_NAME" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# The private half genuinely lands in the sandbox, which is why the key is
|
||||||
|
# opt-in and must be signing-only: an agent that can read it can sign as you.
|
||||||
|
# A signing key is separately revocable and grants no repository access, so the
|
||||||
|
# damage is forged attestation rather than reach.
|
||||||
|
install_sandbox_signing_key() {
|
||||||
|
[[ -n "$DEFAULT_SIGNING_KEY" ]] || return 0
|
||||||
|
|
||||||
|
local private="${DEFAULT_SIGNING_KEY/#\~/$HOME}"
|
||||||
|
local public="$private.pub"
|
||||||
|
|
||||||
|
[[ -f "$private" ]] ||
|
||||||
|
die "AI_SBX_SIGNING_KEY does not exist: $private"
|
||||||
|
|
||||||
|
[[ -f "$public" ]] ||
|
||||||
|
die "No public half beside $private. SSH signing needs both, and git names the signing key by its .pub."
|
||||||
|
|
||||||
|
ssh-keygen -y -P '' -f "$private" >/dev/null 2>&1 ||
|
||||||
|
die "$private is passphrase-protected. Nothing in the sandbox can answer the prompt, so every commit would fail at the moment of signing. Use a dedicated signing key with no passphrase."
|
||||||
|
|
||||||
|
local principal
|
||||||
|
principal="$(git -C "$REPO_ROOT" config user.email)" ||
|
||||||
|
die "The repository has no user.email, so signatures could not be attributed to a principal."
|
||||||
|
|
||||||
|
local sandbox_home
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
sandbox_home="$(sbx exec "$SANDBOX_NAME" bash -c 'printf %s "$HOME"')"
|
||||||
|
|
||||||
|
[[ -n "$sandbox_home" ]] ||
|
||||||
|
die "Could not determine the sandbox home directory."
|
||||||
|
|
||||||
|
local staging
|
||||||
|
staging="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$staging"' RETURN
|
||||||
|
|
||||||
|
local name
|
||||||
|
name="$(basename "$private")"
|
||||||
|
|
||||||
|
mkdir -p "$staging/.ssh"
|
||||||
|
install -m 600 "$private" "$staging/.ssh/$name"
|
||||||
|
install -m 644 "$public" "$staging/.ssh/$name.pub"
|
||||||
|
|
||||||
|
# Without a principal mapping git reports "No principal matched" for the
|
||||||
|
# signatures it just produced, so the sandbox cannot verify its own commits.
|
||||||
|
printf '%s %s\n' "$principal" "$(cat "$public")" \
|
||||||
|
>"$staging/.ssh/allowed_signers"
|
||||||
|
|
||||||
|
tar -C "$staging" -cf - .ssh |
|
||||||
|
sbx exec -i "$SANDBOX_NAME" tar -x -C "$sandbox_home" ||
|
||||||
|
die "Could not copy the signing key into $SANDBOX_NAME."
|
||||||
|
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
sbx exec "$SANDBOX_NAME" bash -c '
|
||||||
|
set -e
|
||||||
|
name="$1"
|
||||||
|
|
||||||
|
chmod 700 "$HOME/.ssh"
|
||||||
|
chmod 600 "$HOME/.ssh/$name"
|
||||||
|
chmod 644 "$HOME/.ssh/$name.pub" "$HOME/.ssh/allowed_signers"
|
||||||
|
|
||||||
|
git config --global gpg.format ssh
|
||||||
|
git config --global user.signingkey "$HOME/.ssh/$name.pub"
|
||||||
|
git config --global commit.gpgsign true
|
||||||
|
git config --global tag.gpgsign true
|
||||||
|
git config --global gpg.ssh.allowedSignersFile "$HOME/.ssh/allowed_signers"
|
||||||
|
|
||||||
|
# jj reads conf.d after config.toml, so the host key path a copied
|
||||||
|
# dotfile carries is overridden without editing a file chezmoi owns.
|
||||||
|
mkdir -p "$HOME/.config/jj/conf.d"
|
||||||
|
cat >"$HOME/.config/jj/conf.d/10-ai-sbx-signing.toml" <<EOF
|
||||||
|
[signing]
|
||||||
|
backend = "ssh"
|
||||||
|
key = "$HOME/.ssh/$name.pub"
|
||||||
|
EOF
|
||||||
|
' _ "$name" </dev/null ||
|
||||||
|
die "Could not configure commit signing in $SANDBOX_NAME."
|
||||||
|
|
||||||
|
printf 'Installed the signing key %s into %s.\n' "$name" "$SANDBOX_NAME"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_launch_mode() {
|
||||||
|
case "$1" in
|
||||||
|
agent | tmux) ;;
|
||||||
|
*)
|
||||||
|
die "Unknown launch mode: $1 (expected agent or tmux)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# The image may already carry the launcher. Its copy is built from this same
|
||||||
|
# file, so the two cannot drift, and skipping keeps a custom image authoritative
|
||||||
|
# about its own contents.
|
||||||
|
install_sandbox_workspace() {
|
||||||
|
local launcher
|
||||||
|
launcher="$(dirname "${BASH_SOURCE[0]}")/workspace"
|
||||||
|
|
||||||
|
[[ -f "$launcher" ]] ||
|
||||||
|
die "Workspace launcher is missing: $launcher"
|
||||||
|
|
||||||
|
if sbx exec "$SANDBOX_NAME" \
|
||||||
|
bash -c '[[ -x /usr/local/bin/ai-sbx-workspace ]]' \
|
||||||
|
</dev/null >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local sandbox_home
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
sandbox_home="$(sbx exec "$SANDBOX_NAME" bash -c 'printf %s "$HOME"')"
|
||||||
|
|
||||||
|
[[ -n "$sandbox_home" ]] ||
|
||||||
|
die "Could not determine the sandbox home directory."
|
||||||
|
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
sbx exec "$SANDBOX_NAME" bash -c 'mkdir -p "$HOME/.local/bin"'
|
||||||
|
|
||||||
|
sbx cp "$launcher" "$SANDBOX_NAME:$sandbox_home/.local/bin/ai-sbx-workspace"
|
||||||
|
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
sbx exec "$SANDBOX_NAME" bash -c 'chmod 755 "$HOME/.local/bin/ai-sbx-workspace"'
|
||||||
|
}
|
||||||
|
|
||||||
|
# One target path per line, relative to the home directory. Comments and blank
|
||||||
|
# lines are ignored.
|
||||||
|
read_dotfiles_allowlist() {
|
||||||
|
local file="$CONFIG_ROOT/dotfiles"
|
||||||
|
|
||||||
|
[[ -f "$file" ]] ||
|
||||||
|
die "AI_SBX_DOTFILES is set but $file does not exist. List one target path per line, for example .config/nvim"
|
||||||
|
|
||||||
|
local line
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
line="${line%%#*}"
|
||||||
|
line="$(printf '%s' "$line" | xargs)"
|
||||||
|
|
||||||
|
[[ -n "$line" ]] || continue
|
||||||
|
|
||||||
|
printf '%s\n' "$line"
|
||||||
|
done <"$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# chezmoi archive decrypts as it renders, so an encrypted file inside the
|
||||||
|
# allowlist would arrive in the sandbox as plaintext credentials - defeating the
|
||||||
|
# proxy-injected GitHub token in a single step. A denylist would rot as new
|
||||||
|
# encrypted files appear, and the failure mode is silent, so refuse instead.
|
||||||
|
assert_no_encrypted_targets() {
|
||||||
|
local source_dir
|
||||||
|
source_dir="$(chezmoi source-path)" ||
|
||||||
|
die "Could not determine the chezmoi source directory."
|
||||||
|
|
||||||
|
local encrypted target allowed
|
||||||
|
while IFS= read -r encrypted; do
|
||||||
|
[[ -n "$encrypted" ]] || continue
|
||||||
|
|
||||||
|
target="$(chezmoi target-path "$encrypted" 2>/dev/null)" || continue
|
||||||
|
target="${target#"$HOME/"}"
|
||||||
|
|
||||||
|
for allowed in "$@"; do
|
||||||
|
[[ "$target" == "$allowed" || "$target" == "$allowed"/* ]] ||
|
||||||
|
continue
|
||||||
|
|
||||||
|
die "Dotfiles allowlist entry $allowed contains the encrypted file $target, which chezmoi would render in plaintext. Narrow $CONFIG_ROOT/dotfiles."
|
||||||
|
done
|
||||||
|
done < <(find "$source_dir" -type f -name '*encrypted_*' 2>/dev/null)
|
||||||
|
}
|
||||||
|
|
||||||
|
scan_dotfiles_archive() {
|
||||||
|
local archive="$1" pattern
|
||||||
|
|
||||||
|
for pattern in "${DOTFILES_CREDENTIAL_PATTERNS[@]}"; do
|
||||||
|
grep -aEq -- "$pattern" "$archive" ||
|
||||||
|
continue
|
||||||
|
|
||||||
|
die "The rendered dotfiles archive contains something shaped like a credential (matching /$pattern/). Narrow $CONFIG_ROOT/dotfiles."
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rendered on the host, where the age identity already lives, and copied in as a
|
||||||
|
# tar. The sandbox needs no dotfiles repository, no key and no network for this.
|
||||||
|
install_sandbox_dotfiles() {
|
||||||
|
[[ -n "$DEFAULT_DOTFILES" ]] || return 0
|
||||||
|
|
||||||
|
[[ "$DEFAULT_DOTFILES" == chezmoi ]] ||
|
||||||
|
die "Unknown AI_SBX_DOTFILES value: $DEFAULT_DOTFILES (expected chezmoi)"
|
||||||
|
|
||||||
|
require_command chezmoi
|
||||||
|
|
||||||
|
local -a targets
|
||||||
|
mapfile -t targets < <(read_dotfiles_allowlist)
|
||||||
|
|
||||||
|
((${#targets[@]})) ||
|
||||||
|
die "The dotfiles allowlist $CONFIG_ROOT/dotfiles is empty."
|
||||||
|
|
||||||
|
assert_no_encrypted_targets "${targets[@]}"
|
||||||
|
|
||||||
|
local -a target_paths=()
|
||||||
|
local target
|
||||||
|
for target in "${targets[@]}"; do
|
||||||
|
target_paths+=("$HOME/$target")
|
||||||
|
done
|
||||||
|
|
||||||
|
local archive
|
||||||
|
archive="$(mktemp)"
|
||||||
|
trap 'rm -f "$archive"' RETURN
|
||||||
|
|
||||||
|
# DEV_CONTAINER=1 is required, not cosmetic: .chezmoi.toml.tmpl branches on
|
||||||
|
# it to disable git.autoCommit and git.autoPush, and without it an agent in
|
||||||
|
# the sandbox could push to the dotfiles repository.
|
||||||
|
DEV_CONTAINER=1 chezmoi archive --format tar "${target_paths[@]}" >"$archive" ||
|
||||||
|
die "chezmoi could not render the dotfiles archive."
|
||||||
|
|
||||||
|
scan_dotfiles_archive "$archive"
|
||||||
|
|
||||||
|
local sandbox_home
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
sandbox_home="$(sbx exec "$SANDBOX_NAME" bash -c 'printf %s "$HOME"')"
|
||||||
|
|
||||||
|
[[ -n "$sandbox_home" ]] ||
|
||||||
|
die "Could not determine the sandbox home directory."
|
||||||
|
|
||||||
|
sbx exec -i "$SANDBOX_NAME" tar -x -C "$sandbox_home" <"$archive" ||
|
||||||
|
die "Could not unpack the dotfiles archive in $SANDBOX_NAME."
|
||||||
|
|
||||||
|
rm -f "$archive"
|
||||||
|
trap - RETURN
|
||||||
|
|
||||||
|
printf 'Installed dotfiles into %s: %s\n' "$SANDBOX_NAME" "${targets[*]}"
|
||||||
|
}
|
||||||
|
|
||||||
create_sandbox() {
|
create_sandbox() {
|
||||||
load_config
|
load_config
|
||||||
|
|
||||||
@@ -1143,12 +1476,28 @@ setup_command() {
|
|||||||
create_sandbox
|
create_sandbox
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The secret store outlives the sandbox, so recreating one to change its
|
||||||
|
# image keeps the token. Prompting anyway would train the habit of minting
|
||||||
|
# replacement tokens and never revoking the old ones.
|
||||||
|
if sandbox_has_github_token; then
|
||||||
|
printf 'Keeping the GitHub token already stored for %s. Replace it with: mise run ai:sbx -- token\n' \
|
||||||
|
"$SANDBOX_NAME"
|
||||||
|
else
|
||||||
install_github_token
|
install_github_token
|
||||||
|
fi
|
||||||
|
|
||||||
install_sandbox_aws_files
|
install_sandbox_aws_files
|
||||||
|
|
||||||
install_sandbox_claude_config
|
install_sandbox_claude_config
|
||||||
|
|
||||||
|
install_sandbox_dotfiles
|
||||||
|
|
||||||
|
# After the dotfiles: the allowlist may carry a .gitconfig, which would
|
||||||
|
# otherwise land on top of the rewrite.
|
||||||
|
install_sandbox_git_https
|
||||||
|
|
||||||
|
install_sandbox_signing_key
|
||||||
|
|
||||||
install_sandbox_network
|
install_sandbox_network
|
||||||
|
|
||||||
install_sandbox_secrets
|
install_sandbox_secrets
|
||||||
@@ -1199,12 +1548,45 @@ config_command() {
|
|||||||
|
|
||||||
install_sandbox_claude_config
|
install_sandbox_claude_config
|
||||||
|
|
||||||
|
install_sandbox_dotfiles
|
||||||
|
|
||||||
|
install_sandbox_git_https
|
||||||
|
|
||||||
|
install_sandbox_signing_key
|
||||||
|
|
||||||
install_sandbox_network
|
install_sandbox_network
|
||||||
|
|
||||||
install_sandbox_secrets
|
install_sandbox_secrets
|
||||||
}
|
}
|
||||||
|
|
||||||
run_command() {
|
run_command() {
|
||||||
|
local launch="$DEFAULT_LAUNCH"
|
||||||
|
|
||||||
|
# Everything after -- belongs to the agent, including anything that looks
|
||||||
|
# like an option of this task.
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
--launch)
|
||||||
|
(($# >= 2)) || die "--launch requires a value"
|
||||||
|
launch="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--)
|
||||||
|
shift
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
validate_launch_mode "$launch"
|
||||||
|
|
||||||
|
if [[ "$launch" == tmux ]] && (($#)); then
|
||||||
|
die "Agent arguments are only supported with --launch agent; got: $*"
|
||||||
|
fi
|
||||||
|
|
||||||
load_config
|
load_config
|
||||||
|
|
||||||
sandbox_exists ||
|
sandbox_exists ||
|
||||||
@@ -1218,8 +1600,16 @@ run_command() {
|
|||||||
# runs every time rather than only at setup.
|
# runs every time rather than only at setup.
|
||||||
install_sandbox_mise
|
install_sandbox_mise
|
||||||
|
|
||||||
if (($#)) && [[ "$1" == "--" ]]; then
|
install_sandbox_locale
|
||||||
shift
|
|
||||||
|
if [[ "$launch" == tmux ]]; then
|
||||||
|
install_sandbox_workspace
|
||||||
|
|
||||||
|
# bash -lc is mandatory: /etc/sandbox-persistent.sh is where PATH, the
|
||||||
|
# mise shims, the AWS credentials and every secret placeholder live, and
|
||||||
|
# the tmux server inherits its environment from this shell.
|
||||||
|
exec sbx exec -it -w "$REPO_ROOT" "$SANDBOX_NAME" \
|
||||||
|
bash -lc "ai-sbx-workspace $(printf '%q' "$CONFIG_AGENT")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if (($#)); then
|
if (($#)); then
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Runs inside the sandbox as ai-sbx-workspace. The caller must start it from a
|
||||||
|
# login shell: the tmux server inherits this process's environment, so PATH, the
|
||||||
|
# mise shims, the AWS credentials and every secret placeholder reach all three
|
||||||
|
# windows through it. A non-login shell here loses the lot, and the symptom is
|
||||||
|
# "npm cannot authenticate" rather than anything that points at tmux.
|
||||||
|
|
||||||
|
SESSION=ai-sbx
|
||||||
|
|
||||||
|
# The claude invocation is observed, not assumed: sampling the process table of
|
||||||
|
# a sandbox attached with "sbx run" shows this exact command line. Getting it
|
||||||
|
# wrong would silently change the agent's permission model, so agents whose
|
||||||
|
# invocation has not been observed fall back to the bare name.
|
||||||
|
agent_command() {
|
||||||
|
case "$1" in
|
||||||
|
claude)
|
||||||
|
printf '%s' 'claude --dangerously-skip-permissions'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf '%s' "$1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local agent="${1:-claude}"
|
||||||
|
local agent_cmd
|
||||||
|
agent_cmd="$(agent_command "$agent")"
|
||||||
|
|
||||||
|
if ! command -v tmux >/dev/null 2>&1; then
|
||||||
|
printf '%s\n' \
|
||||||
|
'tmux is not installed in this sandbox; starting the agent directly.' \
|
||||||
|
'' \
|
||||||
|
'Add tmux to AI_SBX_TOOLS, or use a custom image that carries it:' \
|
||||||
|
'' \
|
||||||
|
' AI_SBX_TOOLS = "bun tmux neovim"' \
|
||||||
|
'' >&2
|
||||||
|
# Deliberate word splitting: the command comes from the mapping above.
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
exec $agent_cmd
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Attach-or-create. Detaching and re-running must land back in the same
|
||||||
|
# session with the agent's context intact, which is most of the point.
|
||||||
|
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_cmd" C-m
|
||||||
|
tmux send-keys -t "$SESSION:edit" 'nvim .' C-m
|
||||||
|
|
||||||
|
tmux select-window -t "$SESSION:agent"
|
||||||
|
exec tmux attach-session -t "$SESSION"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# shellcheck source-path=SCRIPTDIR
|
||||||
|
# shellcheck source=tasks/ai/sbx
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/../tasks/ai/sbx"
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'FAIL: %s\n' "$1" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
HOME="$work/home"
|
||||||
|
CONFIG_ROOT="$work/config"
|
||||||
|
SANDBOX_NAME=ai-test
|
||||||
|
mkdir -p "$HOME" "$CONFIG_ROOT" "$work/source/dot_config/nvim"
|
||||||
|
|
||||||
|
: >"$work/source/dot_config/nvim/encrypted_private_secrets.lua.age"
|
||||||
|
: >"$work/source/encrypted_private_dot_npmrc.age"
|
||||||
|
|
||||||
|
chezmoi_calls=0
|
||||||
|
chezmoi() {
|
||||||
|
chezmoi_calls=$((chezmoi_calls + 1))
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
source-path)
|
||||||
|
printf '%s\n' "$work/source"
|
||||||
|
;;
|
||||||
|
target-path)
|
||||||
|
case "$2" in
|
||||||
|
*nvim*) printf '%s/.config/nvim/secrets.lua\n' "$HOME" ;;
|
||||||
|
*) printf '%s/.npmrc\n' "$HOME" ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
archive)
|
||||||
|
printf 'archive\n'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
sbx() {
|
||||||
|
cat >/dev/null 2>&1 || true
|
||||||
|
printf '%s\n' "$HOME"
|
||||||
|
}
|
||||||
|
|
||||||
|
printf '%s\n' '.tmux.conf' '# a comment' '' '.gitconfig' >"$CONFIG_ROOT/dotfiles"
|
||||||
|
mapfile -t entries < <(read_dotfiles_allowlist)
|
||||||
|
[[ "${entries[*]}" == ".tmux.conf .gitconfig" ]] ||
|
||||||
|
fail "the allowlist should drop comments and blanks, got: ${entries[*]}"
|
||||||
|
|
||||||
|
if (assert_no_encrypted_targets .config/nvim) 2>/dev/null; then
|
||||||
|
fail "an allowlist entry containing an encrypted target was accepted"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (assert_no_encrypted_targets .npmrc) 2>/dev/null; then
|
||||||
|
fail "an allowlist entry that is itself an encrypted target was accepted"
|
||||||
|
fi
|
||||||
|
|
||||||
|
(assert_no_encrypted_targets .tmux.conf .gitconfig) 2>/dev/null ||
|
||||||
|
fail "an allowlist with no encrypted targets was rejected"
|
||||||
|
|
||||||
|
chezmoi_calls=0
|
||||||
|
DEFAULT_DOTFILES="" install_sandbox_dotfiles >/dev/null
|
||||||
|
((chezmoi_calls == 0)) ||
|
||||||
|
fail "AI_SBX_DOTFILES unset must not call chezmoi at all"
|
||||||
|
|
||||||
|
if (DEFAULT_DOTFILES=stow install_sandbox_dotfiles) >/dev/null 2>&1; then
|
||||||
|
fail "an unknown AI_SBX_DOTFILES value was accepted"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'token: github_pat_%s\n' "$(printf 'a%.0s' {1..30})" >"$work/archive"
|
||||||
|
if (scan_dotfiles_archive "$work/archive") 2>/dev/null; then
|
||||||
|
fail "a rendered archive holding a GitHub token was accepted"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'set -g mouse on\n' >"$work/archive"
|
||||||
|
(scan_dotfiles_archive "$work/archive") 2>/dev/null ||
|
||||||
|
fail "a clean archive was rejected"
|
||||||
|
|
||||||
|
if ((failures)); then
|
||||||
|
printf '%d assertion(s) failed\n' "$failures" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'All dotfiles assertions passed.\n'
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# shellcheck source-path=SCRIPTDIR
|
||||||
|
# shellcheck source=tasks/ai/sbx
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/../tasks/ai/sbx"
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'FAIL: %s\n' "$1" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
HOME="$work/home"
|
||||||
|
SANDBOX_NAME=ai-test
|
||||||
|
mkdir -p "$HOME"
|
||||||
|
|
||||||
|
sbx() {
|
||||||
|
[[ "$1" == exec && "$2" == "$SANDBOX_NAME" && "$3" == bash && "$4" == -c ]] ||
|
||||||
|
fail "unexpected sbx invocation: $*"
|
||||||
|
|
||||||
|
HOME="$HOME" bash -c "$5"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved() {
|
||||||
|
git -C "$work/repo" ls-remote --get-url origin
|
||||||
|
}
|
||||||
|
|
||||||
|
git init --quiet "$work/repo"
|
||||||
|
|
||||||
|
for remote in \
|
||||||
|
'[email protected]:owner/repo.git' \
|
||||||
|
'ssh://[email protected]/owner/repo.git'; do
|
||||||
|
|
||||||
|
rm -f "$HOME/.gitconfig"
|
||||||
|
|
||||||
|
git -C "$work/repo" remote remove origin 2>/dev/null || true
|
||||||
|
git -C "$work/repo" remote add origin "$remote"
|
||||||
|
|
||||||
|
[[ "$(resolved)" == "$remote" ]] ||
|
||||||
|
fail "$remote was already rewritten before the sandbox was configured"
|
||||||
|
|
||||||
|
install_sandbox_git_https
|
||||||
|
|
||||||
|
[[ "$(resolved)" == 'https://github.com/owner/repo.git' ]] ||
|
||||||
|
fail "$remote resolved to $(resolved), not an HTTPS URL"
|
||||||
|
done
|
||||||
|
|
||||||
|
install_sandbox_git_https
|
||||||
|
install_sandbox_git_https
|
||||||
|
|
||||||
|
values="$(HOME="$HOME" git config --global --get-all \
|
||||||
|
'url.https://github.com/.insteadOf' | wc -l)"
|
||||||
|
|
||||||
|
[[ "$values" -eq 2 ]] ||
|
||||||
|
fail "repeated setup left $values insteadOf values, expected 2"
|
||||||
|
|
||||||
|
git -C "$work/repo" remote set-url origin '[email protected]:owner/repo.git'
|
||||||
|
|
||||||
|
[[ "$(resolved)" == '[email protected]:owner/repo.git' ]] ||
|
||||||
|
fail "a non-GitHub remote was rewritten to $(resolved)"
|
||||||
|
|
||||||
|
((failures == 0)) ||
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
printf 'ok: GitHub SSH remotes are rewritten to HTTPS inside the sandbox\n'
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TASK="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/tasks/ai/sbx"
|
||||||
|
|
||||||
|
# shellcheck source-path=SCRIPTDIR
|
||||||
|
# shellcheck source=tasks/ai/sbx
|
||||||
|
source "$TASK"
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'FAIL: %s\n' "$1" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
SANDBOX_NAME=ai-repo-abc123
|
||||||
|
|
||||||
|
listing=""
|
||||||
|
sbx() {
|
||||||
|
printf '%s\n' "$listing"
|
||||||
|
}
|
||||||
|
|
||||||
|
with_listing() {
|
||||||
|
listing="$1"
|
||||||
|
sandbox_has_github_token
|
||||||
|
}
|
||||||
|
|
||||||
|
full_listing() {
|
||||||
|
cat <<'EOF'
|
||||||
|
SCOPE TYPE NAME SECRET
|
||||||
|
ai-repo-abc123 service github (stored)
|
||||||
|
(global) service anthropic (oauth configured)
|
||||||
|
|
||||||
|
CUSTOM SECRETS
|
||||||
|
SCOPE TARGETS ENV PLACEHOLDER SECRET
|
||||||
|
ai-repo-abc123 localstack.cloud LOCALSTACK_AUTH_TOKEN sbx-cs-0c2f39c1 ls-vOL***
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
with_listing "$(full_listing)" ||
|
||||||
|
fail "a sandbox-scoped github token was not detected"
|
||||||
|
|
||||||
|
with_listing "$(
|
||||||
|
cat <<'EOF'
|
||||||
|
SCOPE TYPE NAME SECRET
|
||||||
|
(global) service anthropic (oauth configured)
|
||||||
|
EOF
|
||||||
|
)" && fail "no github token stored, yet setup would have been skipped"
|
||||||
|
|
||||||
|
with_listing "$(
|
||||||
|
cat <<'EOF'
|
||||||
|
SCOPE TYPE NAME SECRET
|
||||||
|
(global) service github (stored)
|
||||||
|
EOF
|
||||||
|
)" && fail "a global github token must not satisfy a per-repository sandbox"
|
||||||
|
|
||||||
|
with_listing "$(
|
||||||
|
cat <<'EOF'
|
||||||
|
SCOPE TYPE NAME SECRET
|
||||||
|
ai-other-sandbox service github (stored)
|
||||||
|
EOF
|
||||||
|
)" && fail "another sandbox's github token must not count as this one's"
|
||||||
|
|
||||||
|
with_listing "$(
|
||||||
|
cat <<'EOF'
|
||||||
|
CUSTOM SECRETS
|
||||||
|
SCOPE TARGETS ENV PLACEHOLDER SECRET
|
||||||
|
ai-repo-abc123 github.com github sbx-cs-abc gh***
|
||||||
|
EOF
|
||||||
|
)" && fail "a custom secret must not be mistaken for the stored service token"
|
||||||
|
|
||||||
|
with_listing "" &&
|
||||||
|
fail "empty output should mean no token, not a stored one"
|
||||||
|
|
||||||
|
grep -q 'if sandbox_has_github_token; then' "$TASK" ||
|
||||||
|
fail "setup no longer guards install_github_token"
|
||||||
|
|
||||||
|
awk '/^token_command\(\)/, /^}/' "$TASK" | grep -q 'install_github_token' ||
|
||||||
|
fail "the token command must always prompt; it is the way to replace one"
|
||||||
|
|
||||||
|
awk '/^token_command\(\)/, /^}/' "$TASK" | grep -q 'sandbox_has_github_token' &&
|
||||||
|
fail "the token command must not skip when a token exists"
|
||||||
|
|
||||||
|
if ((failures)); then
|
||||||
|
printf '%d assertion(s) failed\n' "$failures" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'All GitHub token assertions passed.\n'
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TASK="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/tasks/ai/sbx"
|
||||||
|
|
||||||
|
# shellcheck source-path=SCRIPTDIR
|
||||||
|
# shellcheck source=tasks/ai/sbx
|
||||||
|
source "$TASK"
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'FAIL: %s\n' "$1" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$work/bin"
|
||||||
|
cat >"$work/bin/sbx" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' "$*" >>"$SBX_LOG"
|
||||||
|
EOF
|
||||||
|
chmod 755 "$work/bin/sbx"
|
||||||
|
PATH="$work/bin:$PATH"
|
||||||
|
export PATH
|
||||||
|
|
||||||
|
SANDBOX_NAME=ai-test
|
||||||
|
REPO_ROOT=/workspace/repo
|
||||||
|
CONFIG_AGENT=claude
|
||||||
|
SBX_LOG="$work/log"
|
||||||
|
export SBX_LOG
|
||||||
|
|
||||||
|
dispatch() {
|
||||||
|
: >"$SBX_LOG"
|
||||||
|
|
||||||
|
(
|
||||||
|
load_config() { :; }
|
||||||
|
sandbox_exists() { :; }
|
||||||
|
install_sandbox_aws_files() { :; }
|
||||||
|
install_sandbox_mise() { :; }
|
||||||
|
install_sandbox_workspace() { :; }
|
||||||
|
|
||||||
|
run_command "$@"
|
||||||
|
) >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
cat "$SBX_LOG"
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
unset AI_SBX_LAUNCH
|
||||||
|
# shellcheck source=tasks/ai/sbx
|
||||||
|
source "$TASK"
|
||||||
|
[[ "$DEFAULT_LAUNCH" == agent ]]
|
||||||
|
) || fail "AI_SBX_LAUNCH unset should default to agent"
|
||||||
|
|
||||||
|
(
|
||||||
|
AI_SBX_LAUNCH=tmux
|
||||||
|
export AI_SBX_LAUNCH
|
||||||
|
# shellcheck source=tasks/ai/sbx
|
||||||
|
source "$TASK"
|
||||||
|
[[ "$DEFAULT_LAUNCH" == tmux ]]
|
||||||
|
) || fail "AI_SBX_LAUNCH=tmux was not read into DEFAULT_LAUNCH"
|
||||||
|
|
||||||
|
DEFAULT_LAUNCH=agent
|
||||||
|
[[ "$(dispatch)" == *"run ai-test"* ]] ||
|
||||||
|
fail "agent mode should dispatch to sbx run: $(dispatch)"
|
||||||
|
|
||||||
|
DEFAULT_LAUNCH=tmux
|
||||||
|
tmux_dispatch="$(dispatch)"
|
||||||
|
[[ "$tmux_dispatch" == *"exec -it -w /workspace/repo ai-test"* ]] ||
|
||||||
|
fail "tmux mode should dispatch to sbx exec -it: $tmux_dispatch"
|
||||||
|
[[ "$tmux_dispatch" == *"bash -lc ai-sbx-workspace claude"* ]] ||
|
||||||
|
fail "tmux mode must use a login shell and pass the agent: $tmux_dispatch"
|
||||||
|
[[ "$tmux_dispatch" != *"run ai-test"* ]] ||
|
||||||
|
fail "tmux mode should not also call sbx run: $tmux_dispatch"
|
||||||
|
|
||||||
|
DEFAULT_LAUNCH=tmux
|
||||||
|
[[ "$(dispatch --launch agent)" == *"run ai-test"* ]] ||
|
||||||
|
fail "--launch agent should beat AI_SBX_LAUNCH=tmux"
|
||||||
|
|
||||||
|
DEFAULT_LAUNCH=agent
|
||||||
|
[[ "$(dispatch --launch tmux)" == *"exec -it"* ]] ||
|
||||||
|
fail "--launch tmux should beat AI_SBX_LAUNCH=agent"
|
||||||
|
|
||||||
|
DEFAULT_LAUNCH=agent
|
||||||
|
[[ "$(dispatch -- --resume)" == *"run ai-test -- --resume"* ]] ||
|
||||||
|
fail "agent arguments should still reach sbx run"
|
||||||
|
|
||||||
|
DEFAULT_LAUNCH=agent
|
||||||
|
[[ "$(dispatch -- --launch tmux)" == *"run ai-test -- --launch tmux"* ]] ||
|
||||||
|
fail "--launch after -- belongs to the agent, not to the task"
|
||||||
|
|
||||||
|
DEFAULT_LAUNCH=agent
|
||||||
|
locale_dispatch="$(dispatch)"
|
||||||
|
[[ "$locale_dispatch" == *"BEGIN ai-sbx locale"* ]] ||
|
||||||
|
fail "run should install a UTF-8 locale, or Nerd Font glyphs render as placeholders: $locale_dispatch"
|
||||||
|
[[ "$locale_dispatch" == *"LC_ALL=\"\$candidate\" locale"* ]] ||
|
||||||
|
fail "the locale must be probed for usability, not matched by name against locale -a"
|
||||||
|
|
||||||
|
if (validate_launch_mode bogus) 2>/dev/null; then
|
||||||
|
fail "an unknown launch mode was accepted"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (
|
||||||
|
DEFAULT_LAUNCH=agent
|
||||||
|
load_config() { :; }
|
||||||
|
sandbox_exists() { :; }
|
||||||
|
install_sandbox_aws_files() { :; }
|
||||||
|
install_sandbox_mise() { :; }
|
||||||
|
run_command --launch bogus
|
||||||
|
) >/dev/null 2>&1; then
|
||||||
|
fail "run --launch bogus should exit non-zero"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (
|
||||||
|
DEFAULT_LAUNCH=tmux
|
||||||
|
load_config() { :; }
|
||||||
|
sandbox_exists() { :; }
|
||||||
|
install_sandbox_aws_files() { :; }
|
||||||
|
install_sandbox_mise() { :; }
|
||||||
|
install_sandbox_workspace() { :; }
|
||||||
|
run_command -- --resume
|
||||||
|
) >/dev/null 2>&1; then
|
||||||
|
fail "agent arguments in tmux mode should be rejected, not dropped"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ((failures)); then
|
||||||
|
printf '%d assertion(s) failed\n' "$failures" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'All launch mode assertions passed.\n'
|
||||||
@@ -30,6 +30,14 @@ for host in a.example.com b.example.com c.example.com; do
|
|||||||
fail "install_sandbox_network skipped $host (comma and space must both split)"
|
fail "install_sandbox_network skipped $host (comma and space must both split)"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
allowed=""
|
||||||
|
DEFAULT_NETWORK="$(printf '*.nuget.org,\nregistry.terraform.io,\nmise.jdx.dev')" \
|
||||||
|
install_sandbox_network >/dev/null
|
||||||
|
for host in '*.nuget.org' registry.terraform.io mise.jdx.dev; do
|
||||||
|
[[ "$allowed" == *"$host"* ]] ||
|
||||||
|
fail "multi-line AI_SBX_NETWORK dropped $host"
|
||||||
|
done
|
||||||
|
|
||||||
allowed=""
|
allowed=""
|
||||||
DEFAULT_NETWORK="" install_sandbox_network >/dev/null
|
DEFAULT_NETWORK="" install_sandbox_network >/dev/null
|
||||||
[[ -z "${allowed// /}" ]] ||
|
[[ -z "${allowed// /}" ]] ||
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# shellcheck source-path=SCRIPTDIR
|
||||||
|
# shellcheck source=tasks/ai/sbx
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/../tasks/ai/sbx"
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'FAIL: %s\n' "$1" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
SANDBOX_NAME=ai-test
|
||||||
|
SANDBOX_HOME="$work/sandbox-home"
|
||||||
|
REPO_ROOT="$work/repo"
|
||||||
|
mkdir -p "$SANDBOX_HOME"
|
||||||
|
|
||||||
|
git init --quiet "$REPO_ROOT"
|
||||||
|
git -C "$REPO_ROOT" config user.email [email protected]
|
||||||
|
|
||||||
|
sbx() {
|
||||||
|
[[ "$1" == exec ]] ||
|
||||||
|
fail "unexpected sbx invocation: $*"
|
||||||
|
shift
|
||||||
|
|
||||||
|
if [[ "$1" == -i ]]; then
|
||||||
|
shift 2
|
||||||
|
"$@"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
shift
|
||||||
|
|
||||||
|
if [[ "$1" == bash && "$2" == -c ]]; then
|
||||||
|
local script="$3"
|
||||||
|
shift 3
|
||||||
|
HOME="$SANDBOX_HOME" bash -c "$script" "$@"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail "unexpected sbx exec command: $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
sandbox_git() {
|
||||||
|
HOME="$SANDBOX_HOME" git config --global --get "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
mode() {
|
||||||
|
stat -c '%a' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh-keygen -q -t ed25519 -N '' -C signing -f "$work/signing" </dev/null
|
||||||
|
ssh-keygen -q -t ed25519 -N 'locked' -C locked -f "$work/locked" </dev/null
|
||||||
|
|
||||||
|
DEFAULT_SIGNING_KEY=""
|
||||||
|
install_sandbox_signing_key
|
||||||
|
|
||||||
|
[[ ! -e "$SANDBOX_HOME/.ssh" ]] ||
|
||||||
|
fail "an unset AI_SBX_SIGNING_KEY still put a key in the sandbox"
|
||||||
|
|
||||||
|
DEFAULT_SIGNING_KEY="$work/absent"
|
||||||
|
(install_sandbox_signing_key) 2>/dev/null &&
|
||||||
|
fail "a missing signing key was accepted"
|
||||||
|
|
||||||
|
DEFAULT_SIGNING_KEY="$work/signing"
|
||||||
|
mv "$work/signing.pub" "$work/signing.pub.hidden"
|
||||||
|
(install_sandbox_signing_key) 2>/dev/null &&
|
||||||
|
fail "a signing key with no public half was accepted"
|
||||||
|
mv "$work/signing.pub.hidden" "$work/signing.pub"
|
||||||
|
|
||||||
|
DEFAULT_SIGNING_KEY="$work/locked"
|
||||||
|
(install_sandbox_signing_key) 2>/dev/null &&
|
||||||
|
fail "a passphrase-protected signing key was accepted"
|
||||||
|
|
||||||
|
DEFAULT_SIGNING_KEY="$work/signing"
|
||||||
|
install_sandbox_signing_key >/dev/null
|
||||||
|
|
||||||
|
[[ -f "$SANDBOX_HOME/.ssh/signing" ]] ||
|
||||||
|
fail "the private signing key was not installed"
|
||||||
|
|
||||||
|
[[ "$(mode "$SANDBOX_HOME/.ssh")" == 700 ]] ||
|
||||||
|
fail ".ssh is mode $(mode "$SANDBOX_HOME/.ssh"), expected 700"
|
||||||
|
|
||||||
|
[[ "$(mode "$SANDBOX_HOME/.ssh/signing")" == 600 ]] ||
|
||||||
|
fail "the private key is mode $(mode "$SANDBOX_HOME/.ssh/signing"), expected 600"
|
||||||
|
|
||||||
|
diff -q "$work/signing" "$SANDBOX_HOME/.ssh/signing" >/dev/null ||
|
||||||
|
fail "the installed private key does not match the host key"
|
||||||
|
|
||||||
|
[[ "$(cat "$SANDBOX_HOME/.ssh/allowed_signers")" == \
|
||||||
|
"[email protected] $(cat "$work/signing.pub")" ]] ||
|
||||||
|
fail "allowed_signers does not map the repository principal to the key"
|
||||||
|
|
||||||
|
[[ "$(sandbox_git commit.gpgsign)" == true ]] ||
|
||||||
|
fail "commit signing was not enabled in the sandbox"
|
||||||
|
|
||||||
|
[[ "$(sandbox_git tag.gpgsign)" == true ]] ||
|
||||||
|
fail "tag signing was not enabled in the sandbox"
|
||||||
|
|
||||||
|
[[ "$(sandbox_git gpg.format)" == ssh ]] ||
|
||||||
|
fail "the signing format is $(sandbox_git gpg.format), expected ssh"
|
||||||
|
|
||||||
|
[[ "$(sandbox_git user.signingkey)" == "$SANDBOX_HOME/.ssh/signing.pub" ]] ||
|
||||||
|
fail "user.signingkey points at $(sandbox_git user.signingkey)"
|
||||||
|
|
||||||
|
[[ "$(sandbox_git gpg.ssh.allowedSignersFile)" == \
|
||||||
|
"$SANDBOX_HOME/.ssh/allowed_signers" ]] ||
|
||||||
|
fail "allowedSignersFile points at $(sandbox_git gpg.ssh.allowedSignersFile)"
|
||||||
|
|
||||||
|
override="$SANDBOX_HOME/.config/jj/conf.d/10-ai-sbx-signing.toml"
|
||||||
|
|
||||||
|
grep -Fqx "key = \"$SANDBOX_HOME/.ssh/signing.pub\"" "$override" 2>/dev/null ||
|
||||||
|
fail "jj was not pointed at the key installed in the sandbox"
|
||||||
|
|
||||||
|
grep -Fqx 'backend = "ssh"' "$override" 2>/dev/null ||
|
||||||
|
fail "the jj signing backend was not set to ssh"
|
||||||
|
|
||||||
|
signed="$work/signed"
|
||||||
|
git init --quiet "$signed"
|
||||||
|
HOME="$SANDBOX_HOME" git -C "$signed" \
|
||||||
|
-c user.name=Malcolm -c user.email=[email protected] \
|
||||||
|
commit --quiet --allow-empty -m probe
|
||||||
|
|
||||||
|
status="$(HOME="$SANDBOX_HOME" git -C "$signed" log -1 --format='%G?')"
|
||||||
|
|
||||||
|
[[ "$status" == G ]] ||
|
||||||
|
fail "the sandbox produced a commit with signature status $status, expected G"
|
||||||
|
|
||||||
|
((failures == 0)) ||
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
printf 'ok: the sandbox signs and verifies its own commits\n'
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LAUNCHER="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/tasks/ai/workspace"
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'FAIL: %s\n' "$1" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$work/bin"
|
||||||
|
|
||||||
|
cat >"$work/bin/tmux" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' "$*" >>"$TMUX_LOG"
|
||||||
|
|
||||||
|
if [[ "$1" == has-session ]]; then
|
||||||
|
exit "${TMUX_HAS_SESSION:-1}"
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat >"$work/bin/claude" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' "$*" >>"$TMUX_LOG"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 755 "$work/bin/tmux" "$work/bin/claude"
|
||||||
|
|
||||||
|
TMUX_LOG="$work/log"
|
||||||
|
export TMUX_LOG
|
||||||
|
|
||||||
|
launch() {
|
||||||
|
: >"$TMUX_LOG"
|
||||||
|
PATH="$work/bin:$PATH" bash "$LAUNCHER" "$@" >/dev/null 2>&1
|
||||||
|
cat "$TMUX_LOG"
|
||||||
|
}
|
||||||
|
|
||||||
|
bash -n "$LAUNCHER" ||
|
||||||
|
fail "the launcher is not syntactically valid"
|
||||||
|
|
||||||
|
if command -v shellcheck >/dev/null 2>&1; then
|
||||||
|
shellcheck "$LAUNCHER" ||
|
||||||
|
fail "the launcher is not shellcheck clean"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log="$(launch claude)"
|
||||||
|
|
||||||
|
windows="$(grep -cE 'new-session|new-window' <<<"$log")"
|
||||||
|
[[ "$windows" == 3 ]] ||
|
||||||
|
fail "expected exactly three windows, got $windows: $log"
|
||||||
|
|
||||||
|
for window in agent edit shell; do
|
||||||
|
grep -qE "(new-session|new-window).* -n $window " <<<"$log" ||
|
||||||
|
fail "no window named $window: $log"
|
||||||
|
done
|
||||||
|
|
||||||
|
grep -qF 'send-keys -t ai-sbx:agent claude --dangerously-skip-permissions C-m' <<<"$log" ||
|
||||||
|
fail "the claude mapping must be exactly claude --dangerously-skip-permissions: $log"
|
||||||
|
|
||||||
|
grep -qF 'send-keys -t ai-sbx:edit nvim . C-m' <<<"$log" ||
|
||||||
|
fail "the edit window should open nvim: $log"
|
||||||
|
|
||||||
|
grep -qF 'select-window -t ai-sbx:agent' <<<"$log" ||
|
||||||
|
fail "the agent window should be selected: $log"
|
||||||
|
|
||||||
|
grep -qF 'attach-session -t ai-sbx' <<<"$log" ||
|
||||||
|
fail "the launcher should attach to the session: $log"
|
||||||
|
|
||||||
|
log="$(launch codex)"
|
||||||
|
grep -qF 'send-keys -t ai-sbx:agent codex C-m' <<<"$log" ||
|
||||||
|
fail "an unobserved agent should fall back to its bare name: $log"
|
||||||
|
|
||||||
|
TMUX_HAS_SESSION=0
|
||||||
|
export TMUX_HAS_SESSION
|
||||||
|
log="$(launch claude)"
|
||||||
|
if grep -qE 'new-session|new-window' <<<"$log"; then
|
||||||
|
fail "an existing session must be attached to, never rebuilt: $log"
|
||||||
|
fi
|
||||||
|
grep -qF 'attach-session -t ai-sbx' <<<"$log" ||
|
||||||
|
fail "an existing session should be attached to: $log"
|
||||||
|
unset TMUX_HAS_SESSION
|
||||||
|
|
||||||
|
mkdir -p "$work/bare"
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
printf '#!%s\nprintf %%s "$*" >>"$TMUX_LOG"\n' "$(command -v bash)" \
|
||||||
|
>"$work/bare/claude"
|
||||||
|
chmod 755 "$work/bare/claude"
|
||||||
|
|
||||||
|
: >"$TMUX_LOG"
|
||||||
|
PATH="$work/bare" "$(command -v bash)" "$LAUNCHER" claude >/dev/null 2>&1 ||
|
||||||
|
fail "the launcher should not fail when tmux is missing"
|
||||||
|
fallback="$(cat "$TMUX_LOG")"
|
||||||
|
[[ "$fallback" == '--dangerously-skip-permissions' ]] ||
|
||||||
|
fail "without tmux the launcher should exec the agent, logged: $fallback"
|
||||||
|
|
||||||
|
if ((failures)); then
|
||||||
|
printf '%d assertion(s) failed\n' "$failures" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'All workspace launcher assertions passed.\n'
|
||||||
Reference in New Issue
Block a user