Keep the sandbox's own chatter off the stream the command owns

stdout inside the container belongs to the command that ran. Two readers take it as
data: ai-sandbox-autophase's preflight reads its answer from that stream, and
progress.mjs reads the child pi's JSON event stream from it. Five notices in the
pi-seeding block went there instead of stderr, and the steady state (host file
unchanged, so cur = prev) prints one on *every* pi-bridged run -- the driver refused a
working sandbox on every project. They all go to stderr now, like log() and fail().

The preflight takes the aisb-ok marker from wherever it appears instead of insisting it
come first: a banner from an older image or from the user's own launcher is warned about
and quoted back, because that stream is parsed as data in every unit. It still refuses on
a non-zero exit, on silence, and on a launcher that runs no command -- the failures that
would repeat in every unit -- and quotes the foreign stdout instead of a bare exit code.

The same block, second contract: the thinking level is policy, so a home the sandbox
creates gets it on the *first* seed, not one run later via the merge.

tests/unit/06_entrypoint.sh runs the seeding block with the two streams split and asserts
stdout empty in every state (cold, steady, edited inside, unparseable, level changed), and
greps the entrypoint for any [aisb] line without >&2. tests/unit/10_autophase.sh adds the
noisy and the yapping launcher fixtures.

LLM-Generated: true
This commit is contained in:
2026-10-30 22:53:57 -04:00
parent 8b70967907
commit 90d98dcc9e
5 changed files with 171 additions and 11 deletions
+28 -6
View File
@@ -1001,6 +1001,13 @@ command -v setpriv >/dev/null || fail "setpriv is missing from the image; rebuil
# the entrypoint has no CAP_DAC_OVERRIDE, so root could not write there anyway. It is
# also the honest identity -- these are the user's own files.
#
# Stdout belongs to the command that was asked for. Everything the sandbox says about
# itself goes to stderr, because two readers take that stream as data: ai-sandbox-autophase's
# preflight reads its own answer from it, and progress.mjs reads the child pi's JSON event
# stream from it. A notice on stdout is not cosmetic -- it is a line the reader has to
# explain. tests/unit/06_entrypoint.sh runs this block with stdout split off and fails if it
# is not empty, and checks every `[aisb]` line in the entrypoint for the redirect.
#
# The two files are managed differently, on purpose.
#
# models.json is a catalog, so it is managed whole: replaced when it is missing or still
@@ -1052,12 +1059,12 @@ if [[ -d "$POLICY_DIR/pi" ]]; then
echo "[aisb] pi config: cannot read $f -- its mode or SELinux label blocks this container, and nothing inside can repair it (remove it from the host, or use --ephemeral-home)" >&2
elif [ -z "$cur" ]; then
install -m 600 "$src" "$tgt" && printf %s "$want" > "$mark"
echo "[aisb] pi config: seeded $f"
echo "[aisb] pi config: seeded $f" >&2
elif [ "$cur" = "$prev" ]; then
install -m 600 "$src" "$tgt" && printf %s "$want" > "$mark"
echo "[aisb] pi config: refreshed $f from the host config"
echo "[aisb] pi config: refreshed $f from the host config" >&2
else
echo "[aisb] pi config: left $f alone (changed inside the sandbox; delete it to take the host version again)"
echo "[aisb] pi config: left $f alone (changed inside the sandbox; delete it to take the host version again)" >&2
fi
fi
@@ -1067,8 +1074,23 @@ if [[ -d "$POLICY_DIR/pi" ]]; then
tgt="$dst/$f"; wrote="$dst/.aisb-$f.wrote"; tmp="$dst/.aisb-$f.tmp"
rm -f "$dst/.aisb-$f.seeded" # the whole-file marker is .wrote now
if [ ! -e "$tgt" ]; then
install -m 600 "$src" "$tgt" && cp "$tgt" "$wrote"
echo "[aisb] pi config: seeded $f"
# The level is policy, so it goes in on the very first seed too. Copying the host
# file alone left a fresh sandbox home running one unit at the default of pi itself,
# with the merge on the next run quietly correcting it -- policy that waits a run is
# not policy.
if [ -n "$lvl" ]; then
first="$(jq --arg lvl "$lvl" .defaultThinkingLevel=\$lvl "$src" 2>/dev/null)"
else
first="$(jq . "$src" 2>/dev/null)"
fi
if [ -n "$first" ] && printf "%s\n" "$first" > "$tmp" && install -m 600 "$tmp" "$tgt"; then
:
else
install -m 600 "$src" "$tgt" # not JSON we could rewrite, so take it as it is
fi
rm -f "$tmp"
cp "$tgt" "$wrote"
echo "[aisb] pi config: seeded $f" >&2
elif [ ! -r "$tgt" ]; then
echo "[aisb] pi config: cannot read $f -- its mode or SELinux label blocks this container, and nothing inside can repair it (remove it from the host, or use --ephemeral-home)" >&2
else
@@ -1093,7 +1115,7 @@ if [[ -d "$POLICY_DIR/pi" ]]; then
if [ -n "$merged" ] && printf "%s\n" "$merged" > "$tmp" \
&& install -m 600 "$tmp" "$tgt" && rm -f "$tmp" && cp "$tgt" "$wrote"; then
if [ -n "$lvl" ] && [ "$had" != "$lvl" ]; then
echo "[aisb] pi config: thinking -> $lvl (policy: --thinking, or host)"
echo "[aisb] pi config: thinking -> $lvl (policy: --thinking, or host)" >&2
fi
fi
fi
+22 -4
View File
@@ -697,14 +697,23 @@ ap_git_plan() {
# preflight: one throwaway container, before any agent tokens are spent
# --------------------------------------------------------------------------
ap_preflight() {
local out errf rc=0
local out got="" line errf rc=0
ap_log "preflight: one sandboxed shell, to prove the launcher, the image and the policy"
errf="$(mktemp)"
# </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
# The answer is the marker line, wherever it turned up. stdout inside the container belongs
# to the command the driver asked for and the sandbox's own notices belong on stderr; but an
# image built before that was settled, or a launcher that echoes, can still put a line in
# front. Name that loudly -- the same stream is read as data by progress.mjs in every unit
# -- but do not refuse a sandbox that answered for it. The refusal is for a non-zero exit,
# for silence, and for a launcher that runs nothing.
while IFS= read -r line; do
[[ "$line" == aisb-ok* ]] && { got="$line"; break; }
done <<< "$out"
if (( rc != 0 )) || [[ -z "$got" ]]; then
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
tail -n 12 "$errf" | sed 's/^/ | /' >&2
@@ -716,13 +725,22 @@ ap_preflight() {
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
else
printf ' stdout carried no aisb-ok answer at all; this is what came back instead:\n' >&2
printf '%s\n' "$out" | awk -v max=6 '{ if (++n <= max) printf " > %s\n", $0 }' >&2
fi
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.)"
fi
rm -f "$errf"
echo " $out"
if [[ "$out" == *"git=none"* ]]; then
printf ' %s\n' "$got"
if [[ "$out" != "$got" ]]; then
ap_warn "the sandbox put lines of its own on stdout before answering. That stream is data: the agent"
ap_warn "stream is parsed from it, and so is this check. Notices belong on stderr -- rebuild the"
ap_warn "image if they read [aisb], or move any echo in your launcher there. Continuing: it answered."
printf '%s\n' "$out" | awk -v max=4 '!/^aisb-ok/ { if (++n <= max) printf " > %s\n", $0 }' >&2
fi
if [[ "$got" == *"git=none"* ]]; then
ap_warn "no git inside the sandbox -- the agent cannot read its own diff. Add it to the image (--install git) or expect blind fixer runs."
fi
return 0