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:
@@ -265,9 +265,9 @@ provider "aws" {
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `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, 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 |
|
||||||
| `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 +294,67 @@ 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 |
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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 +605,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
|
||||||
|
|||||||
@@ -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.
|
||||||
+212
-3
@@ -9,6 +9,20 @@ 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_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,7 +79,7 @@ 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
|
||||||
|
|
||||||
@@ -91,6 +105,16 @@ 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.
|
||||||
@@ -1031,6 +1055,154 @@ EOF
|
|||||||
'
|
'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
@@ -1155,6 +1327,8 @@ setup_command() {
|
|||||||
|
|
||||||
install_sandbox_claude_config
|
install_sandbox_claude_config
|
||||||
|
|
||||||
|
install_sandbox_dotfiles
|
||||||
|
|
||||||
install_sandbox_network
|
install_sandbox_network
|
||||||
|
|
||||||
install_sandbox_secrets
|
install_sandbox_secrets
|
||||||
@@ -1205,12 +1379,41 @@ config_command() {
|
|||||||
|
|
||||||
install_sandbox_claude_config
|
install_sandbox_claude_config
|
||||||
|
|
||||||
|
install_sandbox_dotfiles
|
||||||
|
|
||||||
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 ||
|
||||||
@@ -1224,8 +1427,14 @@ 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
|
if [[ "$launch" == tmux ]]; then
|
||||||
shift
|
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,122 @@
|
|||||||
|
#!/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"
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ "$DEFAULT_LAUNCH" == agent ]] ||
|
||||||
|
fail "AI_SBX_LAUNCH unset should default to agent, got: $DEFAULT_LAUNCH"
|
||||||
|
|
||||||
|
(
|
||||||
|
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"
|
||||||
|
|
||||||
|
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'
|
||||||
@@ -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