Files
ai-sandbox/contrib/ai-sandbox.bash
T
ducoterra b273bfef09 name the sandbox's working directory, and drive phases through it
Two features, one shared reason: what the sandbox calls things decides
what a run tells you afterwards.

The cwd is now mounted at the path it has on the host and that is the
working directory, so pwd, an error the agent quotes, a commit message and
the window title a harness derives from the cwd all name this project
instead of reading the same as every sandbox in the session. The mount is
unchanged: same directory, same rw, same :z, still the only writable one.

Only the destination changed, and a destination is where a bind mount can
do damage -- it hides whatever the image already had there. So
mirror_block_of() refuses the paths the image or the sandbox owns (/usr,
/etc, /var, /opt, /bin, /sbin, /lib*, /boot, /proc, /sys, /dev, /root,
/run, /tmp, /home, /home/sandbox*) and / itself, while /home/<user>/...
mirrors: the one standard hierarchy where work lives, and the one whose
per-user tree never meets the container's /home/sandbox volume or the
read-only .agents share inside it.

/tmp is what set the shape, and it was measured rather than assumed: with
--tmpfs /tmp and -v /tmp/x:/tmp/x in one run podman puts the tmpfs on top,
so pwd prints the right path, writes succeed, and the project is not there.
A failure that quiet earns a refusal plus a line of output instead:
workdir_note in --print-policy, a comment in --dry-run, one log line on a
real run. --workdir DIR still chooses the mount point; onto a refused path
it is honoured (it was asked for) and warned about. /project, the one
directory baked into the image, is every fallback, so a workdir always
exists -- --no-cwd included.

ai-sandbox-autophase runs a .agents/phases/ pipeline with the split the
sandbox forces: every executor agent and the .agents/validate.sh gate
inside, the commits and the pushes outside. The gate is in the writable
mount, so running it on the host is running agent-editable bash on the
host; the git remote is deliberately unreachable in there, because an agent
that can push can ship what you are about to review. It runs your launcher,
not a copy of it -- the definition is read out of your rc files and never
called, which is what lets --status and --dry-run promise no token fetch,
no build, no container. Host git runs with an empty hooks path and no ext::
transport, and delivery is refused if a sandboxed unit rewrote .git/config
or planted a hook. Needs --no-interactive to go with --no-tty: an
unattended run must not leave stdin open on your keyboard, and the two are
now separate choices on both sides.

Tests and tooling, most of it forced by what the two changes exposed:
mirror_block_of is extracted from the script and asked about /usr and
/home/sandbox, which a test cannot create (the 06_entrypoint.sh pattern),
and both real branches are driven from a scratch dir under $HOME, since
$TMPDIR is always the fallback -- which is why the whole suite had only
ever seen /project. e2e/12 holds a real container to the same promise,
including that /project, /home/sandbox and a home-volume marker survive a
mirrored run. The "nothing was started" checks now compare the set of
container ids before and after instead of counting every sandbox on the
machine, so a session you have open is not a test failure; the pty cases
fall back to python3 where util-linux's script is not installed.
shellcheck covers both programs, the completion file and the fixtures, so
the completion builds are word-by-word now, and a colon in the project path
is refused with the reason instead of podman's "invalid option type".

558 unit assertions, 719 with e2e; lint and bash -n clean.

LLM-Generated: true
2026-10-28 10:06:31 -04:00

123 lines
6.2 KiB
Bash
Executable File

# bash completion for ai-sandbox.
#
# Install (either):
# mkdir -p ~/.local/share/bash-completion/completions
# cp contrib/ai-sandbox.bash ~/.local/share/bash-completion/completions/ai-sandbox
# (it also registers ai-sandbox-autophase, which shares this file)
# or source it from ~/.bashrc -- put the `source` line *after* the alias, since
# bash needs `complete` registered under the alias name (`alias pi=ai-sandbox`).
#
# The flag list mirrors parse_args() in bin/ai-sandbox -- keep them in sync.
# compgen prints one candidate per line; reading them instead of dropping them through an
# unquoted $(...) keeps a candidate that contains a space (a file name, a phase directory)
# in one piece. Process substitution, not a pipe: COMPREPLY has to survive the call.
_aisb_fill() { local w; while IFS= read -r w; do COMPREPLY+=("$w"); done; }
_ai_sandbox_completions() {
local cur prev word first="" sub="" i
if type _get_comp_words_by_ref >/dev/null 2>&1; then
_get_comp_words_by_ref -n : cur prev
else
cur="${COMP_WORDS[COMP_CWORD]}"; prev="${COMP_WORDS[COMP_CWORD-1]:-}"
fi
local subs="build update doctor gate ps clean inspect version help"
# flags that consume the next word, so their values are not mistaken for
# positional (harness) arguments.
local value_flags=" --net --allow --allow-file --proxy --mount --workdir --agents-dir
--env --env-file --base-image --install --harness --pi-token-env --cpus --memory
--pids --tmpfs-size --name --label "
# Walk the words before the cursor to find the subcommand, skipping option values.
local skip=0
for ((i = 1; i < COMP_CWORD; i++)); do
word="${COMP_WORDS[i]}"
if (( skip )); then skip=0; continue; fi
if [[ "$word" == --* && "$value_flags" == *" $word "* ]]; then skip=1; continue; fi
[[ "$word" == -* ]] && continue
[[ -z "$first" ]] && first="$word"
done
[[ " $subs " == *" $first "* ]] && sub="$first"
case "$prev" in # values of the flag just typed
--net) COMPREPLY=(); _aisb_fill < <(compgen -W "none allow full" -- "$cur"); return ;;
--harness) COMPREPLY=(); _aisb_fill < <(compgen -W "pi codex claude none" -- "$cur"); return ;;
--env) COMPREPLY=(); _aisb_fill < <(compgen -A export -- "$cur"); return ;;
--allow) # hosts already in the allow files; no DNS, no network
local hosts
hosts="$(sed 's/#.*//' "$HOME/.config/ai-sandbox/allow" ./.ai-sandbox/allow 2>/dev/null | tr ',[:space:]' '\n\n')"
COMPREPLY=(); _aisb_fill < <(compgen -W "$hosts" -- "$cur")
(( ${#COMPREPLY[@]} )) || COMPREPLY=( "$cur" ) # don't fall back to filenames
return ;;
--proxy) COMPREPLY=( "$cur" ); return ;;
--allow-file|--env-file) COMPREPLY=(); _aisb_fill < <(compgen -f -- "$cur"); return ;;
--workdir) COMPREPLY=(); _aisb_fill < <(compgen -W "auto" -- "$cur") # 'auto' mirrors the host path
(( ${#COMPREPLY[@]} )) || _aisb_fill < <(compgen -d -- "$cur"); return ;;
--mount|--agents-dir) COMPREPLY=(); _aisb_fill < <(compgen -d -- "$cur"); return ;;
esac
# A non-flag word before the cursor that is not a subcommand means the rest goes
# to the harness: ai-sandbox stops parsing there, so offer nothing, not sandbox flags.
[[ -n "$first" && -z "$sub" ]] && return 1
[[ -z "$first" && "$cur" != -* ]] && { COMPREPLY=(); _aisb_fill < <(compgen -W "$subs" -- "$cur"); return; }
# subcommand-specific flags (each subcommand dies on anything else);
# `inspect` takes the full policy flag set, so it falls through to the list below.
case "$sub" in
build) COMPREPLY=(); _aisb_fill < <(compgen -W "--rebuild -f --refresh-base -B --gate" -- "$cur"); return ;;
update) COMPREPLY=(); _aisb_fill < <(compgen -W "--check --no-gate" -- "$cur"); return ;;
doctor) COMPREPLY=(); _aisb_fill < <(compgen -W "--deep -d" -- "$cur"); return ;;
clean) COMPREPLY=(); _aisb_fill < <(compgen -W "--all -a --volumes -V" -- "$cur"); return ;;
gate) COMPREPLY=(); _aisb_fill < <(compgen -W "up down status logs" -- "$cur"); return ;;
ps|version) return 1 ;;
esac
COMPREPLY=()
_aisb_fill < <(compgen -W \
"--net --allow --allow-file --proxy --no-gate --mount --no-cwd --workdir --home-cache --ephemeral-home --read-only --agents --no-agents --agents-dir --env --env-file --base-image --install --harness --pi-token-env --no-pi-token-env --no-pi-config --cpus --memory --pids --tmpfs-size --shell --interactive --no-interactive --no-tty --keep --name --label --no-build --dry-run --print-policy --print-entrypoint --print-containerfile --allow-unenforced --insecure-keep-root --strict-args -h --help -V" \
-- "$cur")
}
# ai-sandbox-autophase: the pipeline driver. Its own flag list, mirroring its parser.
_ai_sandbox_autophase_completions() {
local cur
if type _get_comp_words_by_ref >/dev/null 2>&1; then
_get_comp_words_by_ref -n : cur
else
cur="${COMP_WORDS[COMP_CWORD]}"
fi
case "$cur" in
--validate-in=*) COMPREPLY=(); _aisb_fill < <(compgen -W "sandbox host" -- "${cur#*=}"); return ;;
esac
COMPREPLY=()
_aisb_fill < <(compgen -W \
"--pi-cmd --rc-files --lib --skill-dir --sandbox-args --validate-in --no-push --no-preflight --no-notes --trust-git-changes --unit --phase --max-units --status --dry-run -n -h --help -V" \
-- "$cur")
# --unit and --phase name pending work: offer the phase directories first, then the
# ordinary file completion the caller would otherwise have got.
local todo="${PWD}/.agents/phases/todo"
case "${COMP_WORDS[COMP_CWORD-1]}" in
--unit|--phase)
if [[ -d "$todo" ]]; then
local d
for d in "$todo"/"$cur"*; do
[[ -e "$d" ]] && COMPREPLY+=("${d#"$todo"/}") # named as the driver wants them
done
_aisb_fill < <(compgen -f -- "$cur")
fi ;;
esac
}
complete -o default -F _ai_sandbox_completions ai-sandbox 2>/dev/null || true
complete -o default -F _ai_sandbox_autophase_completions ai-sandbox-autophase 2>/dev/null || true
if [[ $- == *i* ]]; then # also complete the harness alias
for _aisb in $(compgen -A alias 2>/dev/null); do
if alias "$_aisb" 2>/dev/null | grep -q 'ai-sandbox'; then
complete -o default -F _ai_sandbox_completions "$_aisb" 2>/dev/null || true
fi
done
unset _aisb
fi