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:
+101
-7
@@ -91,6 +91,14 @@ LAUNCHER
|
||||
'sandbox-pi'. Resolved in this order: an exported function, a
|
||||
function or alias in your rc files, then PATH. A value with
|
||||
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.
|
||||
Default: ~/.bashrc.d/*, ~/.bashrc, ~/.bash_aliases, ~/.profile
|
||||
--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_FN | _AP_ALIAS (absent when NAME is defined nowhere in that file)
|
||||
# <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>
|
||||
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" \
|
||||
timeout 15 bash -ic '
|
||||
timeout -k 5 15 ${AP_SETSID[@]+"${AP_SETSID[@]}"} bash -ic '
|
||||
exit() { return "${1:-0}"; }
|
||||
. "$AISB_AP_P_FILE" </dev/null >/dev/null 2>&1
|
||||
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
|
||||
printf "_AP_ALIAS\n"; alias -- "$AISB_AP_P_NAME"
|
||||
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_LAUNCHER="" # resolved name, for messages
|
||||
AP_SOURCE="" # where it was found
|
||||
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=() # 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")
|
||||
}
|
||||
|
||||
# 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() {
|
||||
local name="$AISB_AP_PI_CMD" f out kind path def
|
||||
AP_CMD_WORDS=()
|
||||
@@ -297,6 +365,7 @@ ap_resolve_launcher() {
|
||||
if declare -F -- "$name" >/dev/null 2>&1; then
|
||||
AP_KIND=function; AP_LAUNCHER="$name"; AP_SOURCE="the calling shell"
|
||||
AP_DEF="$(declare -f -- "$name")"
|
||||
ap_check_forwarding
|
||||
ap_build_runner
|
||||
return 0
|
||||
fi
|
||||
@@ -311,6 +380,7 @@ ap_resolve_launcher() {
|
||||
def="$(printf '%s\n' "$out" | tail -n +3)"
|
||||
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_check_forwarding
|
||||
ap_build_runner
|
||||
return 0
|
||||
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.
|
||||
'
|
||||
|
||||
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() {
|
||||
local text
|
||||
@@ -474,9 +549,9 @@ writable mount and your home is a sandbox home, not the host one.
|
||||
|
||||
run_validation() { # <logfile>
|
||||
if [[ "$AISB_AP_VALIDATE" == host ]]; then
|
||||
bash .agents/validate.sh >"$1" 2>&1
|
||||
bash .agents/validate.sh >"$1" 2>&1 </dev/null
|
||||
else
|
||||
"${AP_RUN[@]}" -- bash .agents/validate.sh >"$1" 2>&1
|
||||
"${AP_RUN[@]}" -- bash .agents/validate.sh >"$1" 2>&1 </dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -625,11 +700,23 @@ ap_preflight() {
|
||||
local out errf rc=0
|
||||
ap_log "preflight: one sandboxed shell, to prove the launcher, the image and the policy"
|
||||
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
|
||||
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
|
||||
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"
|
||||
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
|
||||
@@ -656,6 +743,10 @@ ap_report_config() {
|
||||
# --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 ' = %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)"
|
||||
if [[ "$AISB_AP_VALIDATE" == host ]]; then
|
||||
printf 'gate : .agents/validate.sh, on the host (see --validate-in)\n'
|
||||
@@ -839,6 +930,9 @@ main() {
|
||||
ap_resolve_launcher
|
||||
ap_build_run
|
||||
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
|
||||
# network -- and no sweep of finished phase directories either, which writes.
|
||||
local root
|
||||
|
||||
Reference in New Issue
Block a user