stop the driver hanging on a terminal and on a launcher

ai-sandbox-autophase could sit forever, printing nothing, in two places
that are both before the first unit -- which is why both looked like a
script that "does nothing".

The rc probe ran `timeout 15 bash -ic` in front of the user's terminal.
An interactive bash calls tcsetpgrp() to take that terminal, is in its
background, and is stopped for trying; a stopped process does not take
SIGTERM, so the timeout waited in front of it and the driver waited on
the command substitution behind it. It now runs in its own session
(setsid), with stdin closed, under `timeout -k`, and reads the
definition from a temp file rather than a pipe, because a grandchild an
rc file leaves behind holds a pipe's EOF open long after the shell it
came from has been reaped.

A launcher is called *with* the run, and a function ending at
`--env NAME` -- the shape everyone writes for typing by hand, including
every example this repo printed -- discards the flags and the
in-container command. The container then starts the image's default
harness with no prompt and, with --no-interactive gone, the terminal
attached: an agent waiting for keystrokes behind a pipe nobody reads.
`ap_forwards_args` checks the captured definition, --status and the
preflight name it with the one-line fix, and every child now starts with
stdin on /dev/null so a launcher cannot put the sandbox back on the
keyboard. Aliases and whole command lines need no forwarding: the shell
appends the words to those.

Tests: a launcher fixture that drops its arguments, one that leaves a
process behind, and shape assertions on ap_probe_rc.

LLM-Generated: true
This commit is contained in:
2026-10-29 21:07:39 -04:00
parent 209f167e43
commit a91744f390
8 changed files with 273 additions and 22 deletions
@@ -73,6 +73,8 @@ not** — grep for these in the installed script (`readlink -f "$(command -v ai-
| question | read | | question | read |
|---|---| |---|---|
| where does my launcher come from | `ap_resolve_launcher()`, `ap_probe_rc()` (sources an rc file in a throwaway `bash -ic` and returns the *definition*; it never calls it), `ap_rc_list()`, `AISB_AP_RC_FILES` | | where does my launcher come from | `ap_resolve_launcher()`, `ap_probe_rc()` (sources an rc file in a throwaway `bash -ic` and returns the *definition*; it never calls it), `ap_rc_list()`, `AISB_AP_RC_FILES` |
| a launcher that ignores what it was asked to run | `ap_forwards_args()` / `ap_warn_forwarding()`: a **function** must end in `"$@"`, because every child is `NAME --no-tty --no-interactive -- <command>`. Aliases and whole command lines need nothing (`ap_build_run()`). Named in `--status`, refused by `ap_preflight()` |
| "it just sits there and does nothing" | two causes, both at the very start of a run: the launcher dropped the command so the container is running an interactive harness with no prompt (`ap_forwards_args()`), or the rc probe is waiting on the terminal (`ap_probe_rc()`: `setsid`, `</dev/null`, `timeout -k 5 15`, transcript into a file, because an interactive bash takes the terminal, gets stopped, and a stopped process ignores SIGTERM). Every child starts with stdin on `/dev/null` |
| how the child command is built | `ap_build_runner()` / `ap_build_run()` — `printf %q` per argument; `--sandbox-args` defaults `--no-tty --no-interactive`; the prompt is one argument that may contain quotes, newlines and `$` | | how the child command is built | `ap_build_runner()` / `ap_build_run()` — `printf %q` per argument; `--sandbox-args` defaults `--no-tty --no-interactive`; the prompt is one argument that may contain quotes, newlines and `$` |
| what it shadows from the phase library | `ap_install_overrides()`: it first requires the library to provide `find_root next_unit phase_entries phase_next_unit unit_phase is_phase_end count_units sweep_finished_phases execute_unit unit_report notify_task commit_phase push_phase` (plus `first_prompt`/`fix_prompt`, kept as `ap_lib_*`), then shadows `pi`, `first_prompt`, `fix_prompt`, `run_validation`, `push_phase` — and nothing else. Order matters: the launcher resolves *before* `pi()` exists, or a launcher named `pi` would resolve to the shim | | what it shadows from the phase library | `ap_install_overrides()`: it first requires the library to provide `find_root next_unit phase_entries phase_next_unit unit_phase is_phase_end count_units sweep_finished_phases execute_unit unit_report notify_task commit_phase push_phase` (plus `first_prompt`/`fix_prompt`, kept as `ap_lib_*`), then shadows `pi`, `first_prompt`, `fix_prompt`, `run_validation`, `push_phase` — and nothing else. Order matters: the launcher resolves *before* `pi()` exists, or a launcher named `pi` would resolve to the shim |
| why a push was refused | `ap_git_fingerprint()`, `ap_git_guard()`, `ap_git_env()`, `ap_push_pending()`; `--trust-git-changes` is the (loud) override | | why a push was refused | `ap_git_fingerprint()`, `ap_git_guard()`, `ap_git_env()`, `ap_push_pending()`; `--trust-git-changes` is the (loud) override |
+21
View File
@@ -168,6 +168,27 @@ networking or the image, `make e2e` green too.
contain quotes, newlines and `$` at once, so nothing may build that command line by contain quotes, newlines and `$` at once, so nothing may build that command line by
concatenating raw text. The body is *never printed*: `ap_launcher_preview` names a concatenating raw text. The body is *never printed*: `ap_launcher_preview` names a
function rather than echoing it, because its body is the `pass` call. function rather than echoing it, because its body is the `pass` call.
- **The probe must not be able to touch, or wait on, the terminal.** It is
`setsid` + `</dev/null` + `timeout -k 5 15` with the transcript in a **temp file**,
and all three are load-bearing: an interactive bash started from a terminal calls
`tcsetpgrp()`, is in that terminal's background, and is **stopped** for trying — and a
stopped process does not take SIGTERM, so a plain `timeout` waited in front of it
forever and the driver waited behind it before printing anything. A pipe is the other
half: `$(...)` waits for EOF, which any grandchild the rc files leave behind (an
agent, a helper daemon) holds open after `timeout` has reaped the shell it was
watching. Only SIGKILL reaps a stopped process, hence `-k`. Read the definition into a
file and none of that can hold the run. Tests: the shape assertions plus the
backgrounding fixture in `tests/unit/10_autophase.sh`.
- **A function launcher is called *with* the run, so it must forward its arguments.**
Every child is `NAME --no-tty --no-interactive -- <in-container command>` with stdin
on `/dev/null`; a function ending at `--env NAME` — the shape everyone writes for
typing by hand — silently discards all of it, and the container then runs the image's
default harness with no prompt, which on an attached terminal is an agent waiting for
keystrokes behind a pipe nobody reads. `ap_forwards_args` checks the captured
definition (aliases and command lines need it not: the shell appends), `--status` and
the preflight name it, and every child starts `</dev/null` so the launcher cannot put
the terminal back. Keep the `"$@"` in every launcher this repo prints — `install.sh`,
`README.md`, `docs/recipes.md`.
- **The push is the delivery contract.** `ap_push_pending` runs at start, at every - **The push is the delivery contract.** `ap_push_pending` runs at start, at every
phase boundary and at end of run; start is a warning, the boundary and the end are phase boundary and at end of run; start is a warning, the boundary and the end are
fatal, because exiting 0 with the work unpushed is the silent miss this script fatal, because exiting 0 with the work unpushed is the silent miss this script
+11 -2
View File
@@ -171,8 +171,17 @@ A script cannot see an interactive shell's functions, so `sandbox-pi` (default,
`--pi-cmd`/`AISB_AP_PI_CMD`) is looked up in `~/.bashrc.d/*`, `~/.bashrc`, `--pi-cmd`/`AISB_AP_PI_CMD`) is looked up in `~/.bashrc.d/*`, `~/.bashrc`,
`~/.bash_aliases`, `~/.profile`: the rc file is sourced in a throwaway `bash -ic` `~/.bash_aliases`, `~/.profile`: the rc file is sourced in a throwaway `bash -ic`
with `exit` neutered and the *definition* returned -- never called, which is what lets with `exit` neutered and the *definition* returned -- never called, which is what lets
`--status`/`--dry-run` promise no token fetch, no image build, no container. Each child `--status`/`--dry-run` promise no token fetch, no image build, no container. That probe
then re-sources and runs it, so `pass` runs per unit rather than once a night. is a `setsid`, `</dev/null`, `timeout -k` read into a **file**: an interactive bash
started from a terminal takes that terminal, is stopped for trying, and a stopped process
does not take the SIGTERM meant for it, so a piped probe could wedge the whole script in
silence -- and any grandchild the rc files leave behind holds a pipe's EOF open long
after the shell it came from is gone. A function launcher is called *with* the run
(`NAME --no-tty --no-interactive -- pi <args> <prompt>`, stdin on `/dev/null`), so it has
to forward its arguments; one that ends at `--env NAME` drops the command, and is named
by `--status` and refused by the preflight rather than running the image default with no
prompt. Each child then re-sources and runs it, so `pass` runs per unit rather than once
a night.
Integration is by shadowing five functions of the sourced library (`pi`, Integration is by shadowing five functions of the sourced library (`pi`,
`first_prompt`, `fix_prompt`, `run_validation`, `push_phase`) behind a `declare -F` `first_prompt`, `fix_prompt`, `run_validation`, `push_phase`) behind a `declare -F`
+20 -5
View File
@@ -349,13 +349,26 @@ sandbox-pi () {
export AI_API_TOKEN=$(pass aipi/dev-token) export AI_API_TOKEN=$(pass aipi/dev-token)
ai-sandbox --allow aipi.reeseapps.com --allow github.com \ ai-sandbox --allow aipi.reeseapps.com --allow github.com \
--allow pi.dev --allow release-assets.githubusercontent.com \ --allow pi.dev --allow release-assets.githubusercontent.com \
--env AI_API_TOKEN --env AI_API_TOKEN "$@"
} }
``` ```
That last `"$@"` is the whole contract. A function launcher is called *with* the run: the
flags, then `--`, then the command to run inside, and one that ends at `--env AI_API_TOKEN`
drops all of it. It then starts the image's default harness with no prompt — and a `pi`
with no prompt on the other side of a launcher that attached your terminal sits waiting
for keystrokes while its output goes into a pipe nobody is watching. The driver names a
launcher like that in `--status` and refuses to start the pipeline; aliases and whole
command lines do not need it, because the shell appends the words to those by itself.
A script cannot see an interactive shell's functions, so the driver looks the name up in A script cannot see an interactive shell's functions, so the driver looks the name up in
`~/.bashrc.d/*`, `~/.bashrc`, `~/.bash_aliases` and `~/.profile` (or `AISB_AP_RC_FILES`) `~/.bashrc.d/*`, `~/.bashrc`, `~/.bash_aliases` and `~/.profile` (or `AISB_AP_RC_FILES`)
and takes the *definition* — probing never calls it, so a preview cannot fetch a token. and takes the *definition* — probing never calls it, so a preview cannot fetch a token.
Reading that definition is done in a throwaway session of its own, with no stdin and a
bounded read: an interactive bash started from your terminal takes your terminal, gets
stopped for it, and a stopped process ignores the timeout sent to it, so the probe used to
be able to wedge the whole script before it printed a single line.
Each child then runs exactly what your shell would have, `pass` included, once per unit Each child then runs exactly what your shell would have, `pass` included, once per unit
so an overnight run is not holding yesterday's token. An alias or a plain program works so an overnight run is not holding yesterday's token. An alias or a plain program works
too; `--pi-cmd` also accepts a whole command line. too; `--pi-cmd` also accepts a whole command line.
@@ -367,10 +380,12 @@ ai-sandbox-autophase --dry-run # the exact child and gate commands
ai-sandbox-autophase # every pending task, in pipeline order ai-sandbox-autophase # every pending task, in pipeline order
``` ```
Every child is started as `<your command> --no-tty --no-interactive -- pi <args> <prompt>`: Every child is started as `<your command> --no-tty --no-interactive -- pi <args> <prompt>`,
your flags first, then the two that keep an unattended run off your terminal (no TTY to with its stdin on `/dev/null`: your flags first, then the two that keep an unattended run
corrupt the JSON stream, no stdin for your keystrokes to fall into), then `--`, after off your terminal (no TTY to corrupt the JSON stream, no stdin for your keystrokes to fall
which ai-sandbox reads nothing but the command. into), then `--`, after which ai-sandbox reads nothing but the command. Those two are
flags, and a launcher is free to lose them, so the `/dev/null` is what actually guarantees
the second one.
| flag | | | flag | |
|---|---| |---|---|
+101 -7
View File
@@ -91,6 +91,14 @@ LAUNCHER
'sandbox-pi'. Resolved in this order: an exported function, a 'sandbox-pi'. Resolved in this order: an exported function, a
function or alias in your rc files, then PATH. A value with function or alias in your rc files, then PATH. A value with
spaces is taken verbatim: --pi-cmd 'ai-sandbox --allow api.example' spaces is taken verbatim: --pi-cmd 'ai-sandbox --allow api.example'
A FUNCTION must forward its arguments, because this is what a
run appends to it:
NAME --no-tty --no-interactive -- pi <pi args> <prompt>
so the definition has to end in ... --env AI_API_TOKEN "\$@"
One that drops them runs the image's default harness with no
prompt instead, which says nothing and waits forever. A launcher
like that is named by --status and refused by the preflight.
--rc-files LIST Colon-separated rc files to look in. --rc-files LIST Colon-separated rc files to look in.
Default: ~/.bashrc.d/*, ~/.bashrc, ~/.bash_aliases, ~/.profile Default: ~/.bashrc.d/*, ~/.bashrc, ~/.bash_aliases, ~/.profile
--sandbox-args ARGS Flags added to the launcher for every run. --sandbox-args ARGS Flags added to the launcher for every run.
@@ -215,10 +223,32 @@ ap_rc_list() {
# _AP_PATH=<PATH as the rc files left it> # _AP_PATH=<PATH as the rc files left it>
# _AP_FN | _AP_ALIAS (absent when NAME is defined nowhere in that file) # _AP_FN | _AP_ALIAS (absent when NAME is defined nowhere in that file)
# <the definition> # <the definition>
#
# Three things here exist because of one bug: an interactive bash started from a terminal
# takes the terminal. It calls tcsetpgrp(), it is in the background of that terminal, so
# the kernel stops it -- and a stopped process does not take SIGTERM, so `timeout` waited
# in front of it forever while the driver waited behind it with nothing on screen. Run
# from a script (no terminal) it never happened, which is how it survived. Hence:
#
# * setsid, so the probe has no controlling terminal to fight over at all; it is still
# interactive, which is the part the rc guards look at;
# * stdin from /dev/null, so nothing it sources can read from the run's keyboard;
# * the transcript into a FILE rather than a pipe: a command substitution waits for the
# pipe's EOF, which is held open by any grandchild the rc files leave behind (an
# agent, a daemon, an editor helper) even after timeout has reaped the shell it was
# watching. A file has no such promise -- timeout returns, we read what is there.
# * `timeout -k`, because SIGKILL is the one signal a stopped process cannot sit on.
#
# setsid is util-linux and is present wherever rootless podman is; without it the probe
# still runs, just without that protection.
AP_SETSID=()
command -v setsid > /dev/null 2>&1 && AP_SETSID=(setsid)
ap_probe_rc() { # <name> <file> ap_probe_rc() { # <name> <file>
local name="$1" file="$2" local name="$1" file="$2" tmp rc=0
tmp="$(mktemp "${TMPDIR:-/tmp}/aisb-ap-probe.XXXXXX")" || return 1
AISB_AP_P_NAME="$name" AISB_AP_P_FILE="$file" \ AISB_AP_P_NAME="$name" AISB_AP_P_FILE="$file" \
timeout 15 bash -ic ' timeout -k 5 15 ${AP_SETSID[@]+"${AP_SETSID[@]}"} bash -ic '
exit() { return "${1:-0}"; } exit() { return "${1:-0}"; }
. "$AISB_AP_P_FILE" </dev/null >/dev/null 2>&1 . "$AISB_AP_P_FILE" </dev/null >/dev/null 2>&1
shopt -s expand_aliases 2>/dev/null || true shopt -s expand_aliases 2>/dev/null || true
@@ -228,13 +258,22 @@ ap_probe_rc() { # <name> <file>
elif alias -- "$AISB_AP_P_NAME" >/dev/null 2>&1; then elif alias -- "$AISB_AP_P_NAME" >/dev/null 2>&1; then
printf "_AP_ALIAS\n"; alias -- "$AISB_AP_P_NAME" printf "_AP_ALIAS\n"; alias -- "$AISB_AP_P_NAME"
fi fi
' 2>/dev/null ' > "$tmp" 2>/dev/null < /dev/null || rc=$?
# A probe that had to be killed is worth naming: "the launcher is not defined in your rc
# files" would be the wrong thing to leave you with when the real story is that this one
# file blocked for 15s, and the file is the one thing you can act on.
if (( rc == 124 || rc == 137 )); then
ap_warn "$file took longer than 15s to read (something in it waits) -- skipped"
fi
cat "$tmp" 2>/dev/null
rm -f "$tmp"
} }
AP_KIND="" # function | alias | program | arguments AP_KIND="" # function | alias | program | arguments
AP_LAUNCHER="" # resolved name, for messages AP_LAUNCHER="" # resolved name, for messages
AP_SOURCE="" # where it was found AP_SOURCE="" # where it was found
AP_DEF="" # captured definition text, when there is one AP_DEF="" # captured definition text, when there is one
AP_ARG_FORWARD=1 # 0 when a captured function cannot pass the command line on
AP_CHILD_PATH="" # PATH as the rc files left it AP_CHILD_PATH="" # PATH as the rc files left it
AP_CHILD=() # the launcher itself; AP_RUN below is what a child is started with AP_CHILD=() # the launcher itself; AP_RUN below is what a child is started with
@@ -274,6 +313,35 @@ ap_build_runner() {
AP_CHILD=(bash -c "$(printf '%s\n' "${body[@]}")" "$PROG-launcher") AP_CHILD=(bash -c "$(printf '%s\n' "${body[@]}")" "$PROG-launcher")
} }
# A captured FUNCTION has to hand its arguments on: every run appends its own sandbox
# flags, then --, then the in-container command, and a definition that never mentions
# "$@" throws all of that away. It fails silently, which is what makes it worth checking
# for: the container then starts the image's default harness with no prompt, and with a
# terminal on the other side of the launcher that is an interactive agent waiting for
# input nobody will type, behind a pipe that swallows its output. An alias needs no
# check -- the shell appends the words to an alias by itself -- and neither does a
# program or a whole command line, for the same reason.
#
# The test is on the definition text, never on a call: a probe must not fetch a token.
# Anything matching $@ / $* / ${1 / $1 counts as forwarding; a false pass is a
# preflight failure with a diagnosis, a false alarm is only ever a warning.
ap_forwards_args() { # <definition text> -> 0 when it can pass the command line on
printf '%s' "$1" | grep -qE '\$[@*]|[$][{@#0-9]|\$[1-9]'
}
ap_check_forwarding() {
AP_ARG_FORWARD=1
[[ "$AP_KIND" == function ]] || return 0
ap_forwards_args "$AP_DEF" || AP_ARG_FORWARD=0
}
# Said once per run, wherever the launcher was reported. Never prints the definition:
# the body of a launcher is the thing that fetches the token.
ap_warn_forwarding() {
(( AP_ARG_FORWARD )) && return 0
ap_warn "$AP_LAUNCHER does not forward its arguments, so the run flags and the in-container command this script appends to it are dropped -- it runs the image's default harness with no prompt instead, which waits for a terminal nothing sends. End the function with \"\$@\" (see --help)."
}
ap_resolve_launcher() { ap_resolve_launcher() {
local name="$AISB_AP_PI_CMD" f out kind path def local name="$AISB_AP_PI_CMD" f out kind path def
AP_CMD_WORDS=() AP_CMD_WORDS=()
@@ -297,6 +365,7 @@ ap_resolve_launcher() {
if declare -F -- "$name" >/dev/null 2>&1; then if declare -F -- "$name" >/dev/null 2>&1; then
AP_KIND=function; AP_LAUNCHER="$name"; AP_SOURCE="the calling shell" AP_KIND=function; AP_LAUNCHER="$name"; AP_SOURCE="the calling shell"
AP_DEF="$(declare -f -- "$name")" AP_DEF="$(declare -f -- "$name")"
ap_check_forwarding
ap_build_runner ap_build_runner
return 0 return 0
fi fi
@@ -311,6 +380,7 @@ ap_resolve_launcher() {
def="$(printf '%s\n' "$out" | tail -n +3)" def="$(printf '%s\n' "$out" | tail -n +3)"
if [[ "$kind" == "_AP_FN" ]]; then AP_KIND="function"; else AP_KIND="alias"; fi if [[ "$kind" == "_AP_FN" ]]; then AP_KIND="function"; else AP_KIND="alias"; fi
AP_LAUNCHER="$name"; AP_SOURCE="$f"; AP_DEF="$def"; AP_CHILD_PATH="$path" AP_LAUNCHER="$name"; AP_SOURCE="$f"; AP_DEF="$def"; AP_CHILD_PATH="$path"
ap_check_forwarding
ap_build_runner ap_build_runner
return 0 return 0
fi fi
@@ -454,7 +524,12 @@ writable mount and your home is a sandbox home, not the host one.
Never edit the gate to make it pass. Never edit the gate to make it pass.
' '
pi() { "${AP_RUN[@]}" -- pi "$@"; } # Every child starts with its stdin closed. `--no-interactive` in the sandbox flags is
# what normally keeps podman from attaching the terminal, but a launcher is the user's
# code and can drop that flag -- and ai-sandbox attaches stdin when it has a terminal,
# which is how a run ends up waiting for keystrokes behind a pipe that shows nothing.
# /dev/null is the only honest stdin for an unattended run.
pi() { "${AP_RUN[@]}" -- pi "$@" </dev/null; }
first_prompt() { first_prompt() {
local text local text
@@ -474,9 +549,9 @@ writable mount and your home is a sandbox home, not the host one.
run_validation() { # <logfile> run_validation() { # <logfile>
if [[ "$AISB_AP_VALIDATE" == host ]]; then if [[ "$AISB_AP_VALIDATE" == host ]]; then
bash .agents/validate.sh >"$1" 2>&1 bash .agents/validate.sh >"$1" 2>&1 </dev/null
else else
"${AP_RUN[@]}" -- bash .agents/validate.sh >"$1" 2>&1 "${AP_RUN[@]}" -- bash .agents/validate.sh >"$1" 2>&1 </dev/null
fi fi
} }
@@ -625,11 +700,23 @@ ap_preflight() {
local out errf rc=0 local out errf rc=0
ap_log "preflight: one sandboxed shell, to prove the launcher, the image and the policy" ap_log "preflight: one sandboxed shell, to prove the launcher, the image and the policy"
errf="$(mktemp)" errf="$(mktemp)"
out="$("${AP_RUN[@]}" -- bash -c 'printf "aisb-ok aisb=%s git=%s user=%s cwd=%s\n" "${AISB:-none}" "$(git --version 2>/dev/null | cut -d" " -f3 || echo none)" "$(id -un)" "$PWD"' 2>"$errf")" || rc=$? # </dev/null for the same reason as pi() below: this call has the terminal on its stdin
# and the answer in a pipe, so anything that reads stdin inside the container would sit
# there forever with nothing on screen to explain it.
out="$("${AP_RUN[@]}" -- bash -c 'printf "aisb-ok aisb=%s git=%s user=%s cwd=%s\n" "${AISB:-none}" "$(git --version 2>/dev/null | cut -d" " -f3 || echo none)" "$(id -un)" "$PWD"' 2>"$errf" </dev/null)" || rc=$?
if (( rc != 0 )) || [[ "$out" != aisb-ok* ]]; then if (( rc != 0 )) || [[ "$out" != aisb-ok* ]]; then
printf '%s: preflight failed (exit %s): the launcher does not give a working sandbox.\n' "$PROG" "$rc" >&2 printf '%s: preflight failed (exit %s): the launcher does not give a working sandbox.\n' "$PROG" "$rc" >&2
printf ' command: %s\n' "$(ap_child_preview)" >&2 printf ' command: %s\n' "$(ap_child_preview)" >&2
tail -n 12 "$errf" | sed 's/^/ | /' >&2 tail -n 12 "$errf" | sed 's/^/ | /' >&2
if (( AP_ARG_FORWARD == 0 )); then
printf ' %s is a function that ignores its arguments, so the flags above and everything after -- were dropped\n' "$AP_LAUNCHER" >&2
printf ' and the container ran the image default harness with no prompt instead of the shell asked for. Forward them:\n' >&2
printf ' %s() { ai-sandbox --allow ... --env NAME "$@"; }\n' "$AP_LAUNCHER" >&2
elif [[ -z "${out//[[:space:]]/}" ]]; then
printf ' the sandbox started and answered nothing at all: a launcher that runs no command, or one\n' >&2
printf ' that prints its answer somewhere other than stdout, fails exactly like this. It has to run\n' >&2
printf ' follows the -- it was given (a function needs "$@" at the end; see --help).\n' >&2
fi
rm -f "$errf" rm -f "$errf"
ap_die "Fix the launcher or the sandbox before running the pipeline: every unit would fail this way. (--no-preflight to skip the check.)" ap_die "Fix the launcher or the sandbox before running the pipeline: every unit would fail this way. (--no-preflight to skip the check.)"
fi fi
@@ -656,6 +743,10 @@ ap_report_config() {
# --pi-cmd may carry a literal --env value, and nothing that prints repeats a value. # --pi-cmd may carry a literal --env value, and nothing that prints repeats a value.
printf 'launcher : %s\n' "$(printf '%s' "$AISB_AP_PI_CMD" | ap_redact)" printf 'launcher : %s\n' "$(printf '%s' "$AISB_AP_PI_CMD" | ap_redact)"
printf ' = %s, from %s\n' "$AP_KIND" "${AP_SOURCE:-nowhere}" printf ' = %s, from %s\n' "$AP_KIND" "${AP_SOURCE:-nowhere}"
if (( AP_ARG_FORWARD == 0 )); then
printf ' ! %s drops the arguments appended to it; the child command cannot reach the\n' "$AP_LAUNCHER"
printf ' sandbox, so this run would fail the preflight. End the function with "$@".\n'
fi
printf 'child : %s\n' "$(ap_child_preview)" printf 'child : %s\n' "$(ap_child_preview)"
if [[ "$AISB_AP_VALIDATE" == host ]]; then if [[ "$AISB_AP_VALIDATE" == host ]]; then
printf 'gate : .agents/validate.sh, on the host (see --validate-in)\n' printf 'gate : .agents/validate.sh, on the host (see --validate-in)\n'
@@ -839,6 +930,9 @@ main() {
ap_resolve_launcher ap_resolve_launcher
ap_build_run ap_build_run
ap_install_overrides # after the resolve: see the note on ap_install_overrides ap_install_overrides # after the resolve: see the note on ap_install_overrides
# Previews report it in their config block; a real run has no config block, so it hears
# it here instead -- before the first container, not after an hour of nothing.
[[ "$AP_MODE" == status || "$AP_MODE" == dryrun ]] || ap_warn_forwarding
# Preview modes stop here: no image build, no container, no state written, no # Preview modes stop here: no image build, no container, no state written, no
# network -- and no sweep of finished phase directories either, which writes. # network -- and no sweep of finished phase directories either, which writes.
local root local root
+16 -4
View File
@@ -243,15 +243,23 @@ The nightly case: `.agents/phases/todo/` full of task files, nobody watching, an
agent that must not be able to reach the git remote or touch the host. agent that must not be able to reach the git remote or touch the host.
```bash ```bash
# ~/.bashrc.d/ai-sandbox.bash -- the launcher, the way you already write it # ~/.bashrc.d/ai-sandbox.bash -- the launcher, the way you already write it, plus the
# "$@" that lets a run hand it the command to sandbox
sandbox-pi () { sandbox-pi () {
export AI_API_TOKEN=$(pass aipi/dev-token) export AI_API_TOKEN=$(pass aipi/dev-token)
ai-sandbox --allow aipi.reeseapps.com --allow github.com \ ai-sandbox --allow aipi.reeseapps.com --allow github.com \
--allow pi.dev --allow release-assets.githubusercontent.com \ --allow pi.dev --allow release-assets.githubusercontent.com \
--env AI_API_TOKEN --env AI_API_TOKEN "$@"
} }
``` ```
A function written for typing by hand usually stops at `--env AI_API_TOKEN`, and that is
the one thing the driver cannot work with silently: everything after the function name is
the run, so dropping it starts the image's default harness with no prompt. With a terminal
behind it that is an interactive agent waiting for input, its output going into a pipe the
driver is reading — a run that appears to do nothing at all. `--status` names a launcher
that takes no arguments and the preflight refuses it; the fix is the `"$@"` above.
```bash ```bash
cd ~/Projects/foo cd ~/Projects/foo
ai-sandbox-autophase --status # what it resolved, what is pending, the git plan ai-sandbox-autophase --status # what it resolved, what is pending, the git plan
@@ -270,7 +278,11 @@ sandbox-pi --no-tty --no-interactive -- pi --no-approve --session-dir .agents/ph
`pass` runs again per unit, so a run that starts at 23:00 is not holding a token `pass` runs again per unit, so a run that starts at 23:00 is not holding a token
fetched at 09:00. Reading the definition is all the probe does: `--status` and fetched at 09:00. Reading the definition is all the probe does: `--status` and
`--dry-run` never call the launcher, fetch a token, build an image or start a `--dry-run` never call the launcher, fetch a token, build an image or start a
container. container. It reads it in a session of its own with no stdin — an interactive bash
started from a terminal tries to take that terminal, gets stopped for trying, and a
stopped process does not take the timeout meant for it. Every child is started with its
stdin on `/dev/null` for the same reason in the other direction: nothing inside the
sandbox can be waiting for your keyboard.
| you want | flag | | you want | flag |
|---|---| |---|---|
@@ -320,7 +332,7 @@ A project whose tests need something the image lacks gets it from the launcher:
sandbox-pi () { sandbox-pi () {
export AI_API_TOKEN=$(pass aipi/dev-token) export AI_API_TOKEN=$(pass aipi/dev-token)
ai-sandbox --allow aipi.reeseapps.com --allow pypi.org --allow files.pythonhosted.org \ ai-sandbox --allow aipi.reeseapps.com --allow pypi.org --allow files.pythonhosted.org \
--install 'python3-pip ripgrep' --env AI_API_TOKEN --install 'python3-pip ripgrep' --env AI_API_TOKEN "$@"
} }
``` ```
+3 -1
View File
@@ -76,6 +76,8 @@ echo "(that is what ai-sandbox-autophase looks for, since a script cannot see a
echo "function it did not define):" echo "function it did not define):"
echo " sandbox-pi () {" echo " sandbox-pi () {"
echo " export AI_API_TOKEN=\$(pass aipi/token)" echo " export AI_API_TOKEN=\$(pass aipi/token)"
echo " ai-sandbox --allow api.example.com --env AI_API_TOKEN" echo ' ai-sandbox --allow api.example.com --env AI_API_TOKEN "$@"'
echo " }" echo " }"
echo ' (the "$@" is not decoration: a run appends its flags, then --, then the command'
echo " to sandbox, and a function that drops them runs the image default with no prompt)"
echo "then: cd your/project && ai-sandbox-autophase --status" echo "then: cd your/project && ai-sandbox-autophase --status"
+99 -3
View File
@@ -51,6 +51,9 @@ sandbox-pi() {
printf 'LAUNCH argc=%s cmd=%s\n' "\${#argv0[@]}" "\${cmd[0]:-none}" printf 'LAUNCH argc=%s cmd=%s\n' "\${#argv0[@]}" "\${cmd[0]:-none}"
local a local a
for a in "\${argv0[@]}"; do printf 'A:%s\n' "\$a"; done for a in "\${argv0[@]}"; do printf 'A:%s\n' "\$a"; done
# what this child was started with on stdin: the driver closes it, so an agent inside
# the sandbox cannot end up reading the terminal of an unattended run
printf 'STDIN:%s\n' "\$(readlink /proc/self/fd/0)"
} >> "\$trace" } >> "\$trace"
(( \${#cmd[@]} )) || return 0 (( \${#cmd[@]} )) || return 0
"\${cmd[@]}" "\${cmd[@]}"
@@ -59,6 +62,25 @@ EOF
cat > "$WORK/rc/aliases.bash" <<'EOF' cat > "$WORK/rc/aliases.bash" <<'EOF'
alias sandbox-alias='sandbox-pi' alias sandbox-alias='sandbox-pi'
EOF EOF
# The launcher exactly as it is often written for interactive use -- a fixed policy, and
# nothing after it. It works when *you* type its name, and drops the whole in-container
# command when something appends one, which is every run the driver makes. Kept as a
# fixture so the driver is checked to name it instead of hanging on it.
cat > "$WORK/rc/stubby.bash" <<'EOF'
stubby-pi() {
export AI_API_TOKEN='CANARY-STUBBY-4f2c81'
ai-sandbox --allow api.example.com --env AI_API_TOKEN
}
EOF
# An rc file that leaves a grandchild behind, the way one that starts an agent or a
# helper daemon does. The definition probe must not wait for it: reading a definition is
# not a conversation with the file's process tree.
cat > "$WORK/rc/daemon.bash" <<'EOF'
daemon-pi() {
sleep 10 &
ai-sandbox "$@"
}
EOF
cat > "$WORK/rc/broken.bash" <<'EOF' cat > "$WORK/rc/broken.bash" <<'EOF'
broken-sandbox() { broken-sandbox() {
local -a cmd=() local -a cmd=()
@@ -147,6 +169,7 @@ GTRACE="$WORK/git.trace"
MARKER="$WORK/token.marker" MARKER="$WORK/token.marker"
PROMPTS="$WORK/prompts.log" PROMPTS="$WORK/prompts.log"
: > "$TRACE"; : > "$GTRACE"; : > "$MARKER"; : > "$PROMPTS" : > "$TRACE"; : > "$GTRACE"; : > "$MARKER"; : > "$PROMPTS"
printf 'the driver stdin, which no child is allowed to see\n' > "$WORK/stdin.marker"
DEF_RC="$WORK/rc/sandbox.bash:$WORK/rc/aliases.bash:$WORK/rc/00-nothing.bash" DEF_RC="$WORK/rc/sandbox.bash:$WORK/rc/aliases.bash:$WORK/rc/00-nothing.bash"
# A prefix assignment in front of a shell function sticks around for the rest of the # A prefix assignment in front of a shell function sticks around for the rest of the
@@ -156,8 +179,11 @@ ap_run() { # [args...]
local cmd="${T_CMD-sandbox-pi}" rc="${T_RC-$DEF_RC}" lib="${T_LIB-$LIB_FIX}" local cmd="${T_CMD-sandbox-pi}" rc="${T_RC-$DEF_RC}" lib="${T_LIB-$LIB_FIX}"
local validate="${T_VALIDATE-sandbox}" sargs="${T_SANDBOX_ARGS---no-tty --no-interactive}" local validate="${T_VALIDATE-sandbox}" sargs="${T_SANDBOX_ARGS---no-tty --no-interactive}"
local push="${T_PUSH-1}" pre="${T_PREFLIGHT-1}" notes="${T_NOTES-1}" gfail="${T_GITFAIL-}" local push="${T_PUSH-1}" pre="${T_PREFLIGHT-1}" notes="${T_NOTES-1}" gfail="${T_GITFAIL-}"
local ppush="${T_PHASE_PUSH-}" dryout="${T_DRYOUT-}" plant="${T_PLANT-}" local ppush="${T_PHASE_PUSH-}" dryout="${T_DRYOUT-}" plant="${T_PLANT-}" stdin_src="${T_STDIN-$WORK/stdin.marker}"
unset T_CMD T_RC T_LIB T_VALIDATE T_SANDBOX_ARGS T_PUSH T_PREFLIGHT T_NOTES T_GITFAIL T_PHASE_PUSH T_DRYOUT T_PLANT unset T_CMD T_RC T_LIB T_VALIDATE T_SANDBOX_ARGS T_PUSH T_PREFLIGHT T_NOTES T_GITFAIL T_PHASE_PUSH T_DRYOUT T_PLANT T_STDIN
# stdin is a file with a known name, never the test runner's own: the launcher records
# what it was started with, and that is how "a child never inherits the terminal" is
# checked. A driver that forwards stdin would show up as this path in the trace.
OUT="$(env "PATH=$WORK/bin:$PATH" \ OUT="$(env "PATH=$WORK/bin:$PATH" \
"AISB_AP_LIB=$lib" "AISB_AP_RC_FILES=$rc" "AISB_AP_PI_CMD=$cmd" \ "AISB_AP_LIB=$lib" "AISB_AP_RC_FILES=$rc" "AISB_AP_PI_CMD=$cmd" \
"AISB_AP_VALIDATE=$validate" "AISB_AP_SANDBOX_ARGS=$sargs" \ "AISB_AP_VALIDATE=$validate" "AISB_AP_SANDBOX_ARGS=$sargs" \
@@ -166,7 +192,7 @@ ap_run() { # [args...]
"FAKE_PI_PROMPTS=$PROMPTS" "REAL_GIT=$REAL_GIT" "PHASE_NOTIFY=0" \ "FAKE_PI_PROMPTS=$PROMPTS" "REAL_GIT=$REAL_GIT" "PHASE_NOTIFY=0" \
${gfail:+"FAKE_GIT_FAIL=$gfail"} ${ppush:+"PHASE_PUSH=$ppush"} ${dryout:+"AISB_AP_DRYOUT=$dryout"} \ ${gfail:+"FAKE_GIT_FAIL=$gfail"} ${ppush:+"PHASE_PUSH=$ppush"} ${dryout:+"AISB_AP_DRYOUT=$dryout"} \
${plant:+"FAKE_PI_PLANT_HOOK=$plant"} \ ${plant:+"FAKE_PI_PLANT_HOOK=$plant"} \
"$APBIN" "$@" 2>&1)" && RC=0 || RC=$? "$APBIN" "$@" 2>&1 < "$stdin_src")" && RC=0 || RC=$?
return 0 return 0
} }
gt() { grep -c "^$1" "$GTRACE" 2>/dev/null || true; } # count of git calls by verb gt() { grep -c "^$1" "$GTRACE" 2>/dev/null || true; } # count of git calls by verb
@@ -210,6 +236,67 @@ T_CMD='' ap_run --status
assert_rc 1 "an explicitly empty launcher is an error, not a silent fallback" assert_rc 1 "an explicitly empty launcher is an error, not a silent fallback"
assert_contains "no sandbox launcher" "and says so" assert_contains "no sandbox launcher" "and says so"
# Extract one function from the driver itself, so a test asks the real code a question
# instead of restating its answer (the way aisb_fn() does for bin/ai-sandbox).
ap_fn() { # <name>
awk -v fn="$1" '$0 ~ "^" fn "\\(\\) \\{" {f=1} f {print} f && /^\}$/ {exit}' "$APBIN"
}
t_name "a launcher that drops its arguments is named, and never hangs a run"
# The shape every interactive sandbox-pi takes: a fixed policy and no "$@". Appended to,
# it runs the image's default harness with no prompt, so an unattended run of the real
# thing sat in front of an agent waiting for keystrokes, behind a pipe that showed
# nothing. The driver has to say so, in the preview and before the first unit.
: > "$TRACE"
T_CMD=stubby-pi T_RC="$WORK/rc/stubby.bash" ap_run --status
assert_rc 0 "--status with a launcher that takes no arguments"
assert_contains "drops the arguments appended to it" "the preview says so"
assert_contains "End the function with" "and says how to fix it"
assert_absent "CANARY-STUBBY" "without repeating what the function exports"
assert_absent "export AI_API_TOKEN" "and without printing its body"
T_CMD=stubby-pi T_RC="$WORK/rc/stubby.bash" ap_run --max-units 1
assert_rc 1 "a run with it is refused, not left waiting"
assert_contains "preflight failed" "at the preflight, before any unit"
assert_contains "ignores its arguments" "with the reason named"
assert_contains 'ai-sandbox --allow ... --env NAME "$@"' "and the fix written out"
if grep -q 'cmd=pi' "$TRACE"; then _bad "no unit runs"; else _ok "no unit runs"; fi
# the fixture launcher in the tests forwards "\$@", and must not be nagged about it
ap_run --status
assert_absent "drops the arguments" "a launcher that forwards them is not flagged"
t_name "reading the rc files cannot be stopped, and cannot be held open"
# An interactive bash started from a terminal takes that terminal: it calls tcsetpgrp(),
# gets SIGTTOU because it is in the background, and stops -- and a stopped process does
# not take SIGTERM, so the probe waited on it forever with the driver waiting behind it.
# Everything a run does starts in this function, previews included, so the whole script
# looked wedged with nothing printed. The three defences are asserted here by shape;
# stopping a process is not something a test can arrange without a terminal of its own.
probe_src="$(ap_fn ap_probe_rc)"
for want in 'AP_SETSID' '< /dev/null' 'timeout -k 5 15' 'mktemp' '> "$tmp"'; do
if printf '%s' "$probe_src" | grep -qF -- "$want"; then
_ok "the probe is bounded, file-backed and off the terminal ($want)"
else
_bad "the probe is bounded, file-backed and off the terminal" "missing: $want"
fi
done
if grep -qF 'AP_SETSID=(setsid)' "$APBIN"; then
_ok "and its own session, so it has no controlling terminal to be stopped by"
else
_bad "and its own session, so it has no controlling terminal to be stopped by" "no AP_SETSID=(setsid) in $APBIN"
fi
# and the consequence a user would feel: an rc file that leaves a process behind must not
# be able to hold the read open. sleep 10 in the fixture is the stand-in for the agent or
# helper daemon a real rc file starts; if the probe reads a pipe instead of a file, this
# test does not fail fast -- it fails 10 seconds later, which is the whole point.
: > "$TRACE"
start=$SECONDS
T_CMD=daemon-pi T_RC="$WORK/rc/daemon.bash" ap_run --status
elapsed=$(( SECONDS - start ))
assert_rc 0 "--status with an rc file that backgrounds a process"
assert_contains "function, from $WORK/rc/daemon.bash" "resolved all the same"
(( elapsed < 8 )) && _ok "without waiting for what it left behind (${elapsed}s)" \
|| _bad "without waiting for what it left behind" "took ${elapsed}s"
t_name "a child is the launcher, then --, then the harness command" t_name "a child is the launcher, then --, then the harness command"
: > "$TRACE" : > "$TRACE"
PROJ_A="$WORK/pa"; mkproj "$PROJ_A" 1 >/dev/null PROJ_A="$WORK/pa"; mkproj "$PROJ_A" 1 >/dev/null
@@ -232,6 +319,15 @@ if grep -q '^LAUNCH argc=[0-9]* cmd=pi$' "$TRACE"; then
else else
_bad "pi is the in-container command" "$(grep '^LAUNCH' "$TRACE" | head -3)" _bad "pi is the in-container command" "$(grep '^LAUNCH' "$TRACE" | head -3)"
fi fi
# Every child starts with stdin on /dev/null, whatever this test's own stdin is: the
# flag that normally keeps podman off the terminal (--no-interactive) is a flag, and a
# launcher is free to drop it.
if grep -q '^STDIN:/dev/null$' "$TRACE"; then
_ok "the child's stdin is /dev/null, not the caller's"
else
_bad "the child's stdin is /dev/null, not the caller's" "$(grep '^STDIN' "$TRACE" | head -3)"
fi
if grep -qF "stdin.marker" "$TRACE"; then _bad "the run's own stdin never reaches a child"; else _ok "the run's own stdin never reaches a child"; fi
argc="$(grep '^LAUNCH argc=.* cmd=pi$' "$TRACE" | head -1 | sed 's/.*argc=\([0-9]*\).*/\1/')" argc="$(grep '^LAUNCH argc=.* cmd=pi$' "$TRACE" | head -1 | sed 's/.*argc=\([0-9]*\).*/\1/')"
[[ -n "$argc" && "$argc" -ge 8 ]] && _ok "the whole harness command line reached the launcher ($argc arguments)" \ [[ -n "$argc" && "$argc" -ge 8 ]] && _ok "the whole harness command line reached the launcher ($argc arguments)" \
|| _bad "the whole harness command line reached the launcher" "argc=$argc" || _bad "the whole harness command line reached the launcher" "argc=$argc"