Compare commits
4
Commits
v1.11.0
...
16ba06cc6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16ba06cc6d | ||
|
|
e0f6113320 | ||
|
|
b5dfae0696 | ||
|
|
53db7df812 |
@@ -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,8 +270,8 @@ 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 [--launch MODE] [-- args...]` | Refresh AWS credentials and the repository's mise tools, then attach to the agent or to a tmux workspace |
|
| `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, plugins and dotfiles 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 |
|
||||||
@@ -296,6 +303,7 @@ provider "aws" {
|
|||||||
| `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_LAUNCH` | `agent` | `run --launch agent\|tmux` |
|
||||||
| `AI_SBX_DOTFILES` | unset | `chezmoi` renders the host's dotfiles into the sandbox |
|
| `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
|
## The tmux workspace
|
||||||
|
|
||||||
@@ -328,6 +336,33 @@ AI_SBX_TOOLS = "bun tmux neovim"
|
|||||||
|
|
||||||
Without `tmux` the launcher says so and starts the agent directly.
|
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
|
## Carrying your dotfiles into the sandbox
|
||||||
|
|
||||||
`AI_SBX_DOTFILES=chezmoi` renders your chezmoi target state **on the host** — where the
|
`AI_SBX_DOTFILES=chezmoi` renders your chezmoi target state **on the host** — where the
|
||||||
|
|||||||
+177
-2
@@ -10,6 +10,7 @@ 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_LAUNCH="${AI_SBX_LAUNCH:-agent}"
|
||||||
|
DEFAULT_SIGNING_KEY="${AI_SBX_SIGNING_KEY:-}"
|
||||||
DEFAULT_DOTFILES="${AI_SBX_DOTFILES:-}"
|
DEFAULT_DOTFILES="${AI_SBX_DOTFILES:-}"
|
||||||
|
|
||||||
# Rendered dotfiles are checked for these before the archive enters the sandbox.
|
# Rendered dotfiles are checked for these before the archive enters the sandbox.
|
||||||
@@ -83,8 +84,10 @@ Usage:
|
|||||||
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.
|
||||||
@@ -119,6 +122,13 @@ 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
|
||||||
@@ -242,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)"
|
||||||
@@ -1055,6 +1076,140 @@ 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() {
|
validate_launch_mode() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
agent | tmux) ;;
|
agent | tmux) ;;
|
||||||
@@ -1321,7 +1476,15 @@ 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
|
||||||
|
|
||||||
@@ -1329,6 +1492,12 @@ setup_command() {
|
|||||||
|
|
||||||
install_sandbox_dotfiles
|
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
|
||||||
@@ -1381,6 +1550,10 @@ config_command() {
|
|||||||
|
|
||||||
install_sandbox_dotfiles
|
install_sandbox_dotfiles
|
||||||
|
|
||||||
|
install_sandbox_git_https
|
||||||
|
|
||||||
|
install_sandbox_signing_key
|
||||||
|
|
||||||
install_sandbox_network
|
install_sandbox_network
|
||||||
|
|
||||||
install_sandbox_secrets
|
install_sandbox_secrets
|
||||||
@@ -1427,6 +1600,8 @@ run_command() {
|
|||||||
# runs every time rather than only at setup.
|
# runs every time rather than only at setup.
|
||||||
install_sandbox_mise
|
install_sandbox_mise
|
||||||
|
|
||||||
|
install_sandbox_locale
|
||||||
|
|
||||||
if [[ "$launch" == tmux ]]; then
|
if [[ "$launch" == tmux ]]; then
|
||||||
install_sandbox_workspace
|
install_sandbox_workspace
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -47,8 +47,12 @@ dispatch() {
|
|||||||
cat "$SBX_LOG"
|
cat "$SBX_LOG"
|
||||||
}
|
}
|
||||||
|
|
||||||
[[ "$DEFAULT_LAUNCH" == agent ]] ||
|
(
|
||||||
fail "AI_SBX_LAUNCH unset should default to agent, got: $DEFAULT_LAUNCH"
|
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
|
AI_SBX_LAUNCH=tmux
|
||||||
@@ -87,6 +91,13 @@ DEFAULT_LAUNCH=agent
|
|||||||
[[ "$(dispatch -- --launch tmux)" == *"run ai-test -- --launch tmux"* ]] ||
|
[[ "$(dispatch -- --launch tmux)" == *"run ai-test -- --launch tmux"* ]] ||
|
||||||
fail "--launch after -- belongs to the agent, not to the task"
|
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
|
if (validate_launch_mode bogus) 2>/dev/null; then
|
||||||
fail "an unknown launch mode was accepted"
|
fail "an unknown launch mode was accepted"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -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'
|
||||||
Reference in New Issue
Block a user