2 Commits
Author SHA1 Message Date
mroberts b5dfae0696 Keep the stored GitHub token when setup runs again
The secret store outlives the sandbox, so recreating one to change its image
or add a profile does not lose the token. Prompting for a replacement anyway
made --replace impractical and trained the habit of minting tokens that are
never revoked.

Only a token scoped to this sandbox counts. A global one would authenticate
the agent too, but reaching every repository it can reach is what this task
exists to prevent. The token command still always prompts; it is the way to
replace an expired or revoked token.
2026-08-03 16:09:44 -05:00
mroberts 53db7df812 Give the sandbox a UTF-8 locale
A sandbox image ships without a locale, leaving LC_CTYPE at POSIX. Every
multibyte glyph then degrades to a placeholder, so Nerd Font icons in the
editor render as underscores and bash printf emits \uXXXX escapes literally.

The locale is probed for usability rather than matched by name, because
locale -a spells C.UTF-8 as C.utf8 on glibc and a name match would silently
find nothing. LANG alone is set, leaving individual categories overridable.
2026-08-03 15:26:41 -05:00
4 changed files with 164 additions and 7 deletions
+2 -2
View File
@@ -263,8 +263,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 |
+59 -3
View File
@@ -83,8 +83,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.
@@ -242,6 +244,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 +1068,39 @@ 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
}
validate_launch_mode() { validate_launch_mode() {
case "$1" in case "$1" in
agent | tmux) ;; agent | tmux) ;;
@@ -1321,7 +1367,15 @@ setup_command() {
create_sandbox create_sandbox
fi fi
install_github_token # 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
fi
install_sandbox_aws_files install_sandbox_aws_files
@@ -1427,6 +1481,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
+90
View File
@@ -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'
+13 -2
View File
@@ -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