diff --git a/.agents/skills/doc-sandbox/references/source-map.md b/.agents/skills/doc-sandbox/references/source-map.md index 108f7de..9e9846f 100644 --- a/.agents/skills/doc-sandbox/references/source-map.md +++ b/.agents/skills/doc-sandbox/references/source-map.md @@ -73,6 +73,8 @@ not** — grep for these in the installed script (`readlink -f "$(command -v ai- | 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` | +| 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 -- `. 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`, `` 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 ` `, 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`, `first_prompt`, `fix_prompt`, `run_validation`, `push_phase`) behind a `declare -F` diff --git a/README.md b/README.md index a9ba43a..22f46e8 100644 --- a/README.md +++ b/README.md @@ -349,13 +349,26 @@ sandbox-pi () { export AI_API_TOKEN=$(pass aipi/dev-token) ai-sandbox --allow aipi.reeseapps.com --allow github.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 `~/.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. +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 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. @@ -367,10 +380,12 @@ ai-sandbox-autophase --dry-run # the exact child and gate commands ai-sandbox-autophase # every pending task, in pipeline order ``` -Every child is started as ` --no-tty --no-interactive -- pi `: -your flags first, then the two that keep an unattended run off your terminal (no TTY to -corrupt the JSON stream, no stdin for your keystrokes to fall into), then `--`, after -which ai-sandbox reads nothing but the command. +Every child is started as ` --no-tty --no-interactive -- pi `, +with its stdin on `/dev/null`: your flags first, then the two that keep an unattended run +off your terminal (no TTY to corrupt the JSON stream, no stdin for your keystrokes to fall +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 | | |---|---| diff --git a/bin/ai-sandbox-autophase b/bin/ai-sandbox-autophase index 8e77fd1..ffd0019 100755 --- a/bin/ai-sandbox-autophase +++ b/bin/ai-sandbox-autophase @@ -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 + 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= # _AP_FN | _AP_ALIAS (absent when NAME is defined nowhere in that file) # +# +# 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() { # - 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 2>&1 shopt -s expand_aliases 2>/dev/null || true @@ -228,13 +258,22 @@ ap_probe_rc() { # 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() { # -> 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 "$@" if [[ "$AISB_AP_VALIDATE" == host ]]; then - bash .agents/validate.sh >"$1" 2>&1 + bash .agents/validate.sh >"$1" 2>&1 "$1" 2>&1 + "${AP_RUN[@]}" -- bash .agents/validate.sh >"$1" 2>&1 /dev/null | cut -d" " -f3 || echo none)" "$(id -un)" "$PWD"' 2>"$errf")" || rc=$? + # /dev/null | cut -d" " -f3 || echo none)" "$(id -un)" "$PWD"' 2>"$errf" &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 diff --git a/docs/recipes.md b/docs/recipes.md index 418be04..03906b2 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -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. ```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 () { export AI_API_TOKEN=$(pass aipi/dev-token) ai-sandbox --allow aipi.reeseapps.com --allow github.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 cd ~/Projects/foo 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 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 -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 | |---|---| @@ -320,7 +332,7 @@ A project whose tests need something the image lacks gets it from the launcher: sandbox-pi () { export AI_API_TOKEN=$(pass aipi/dev-token) 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 "$@" } ``` diff --git a/install.sh b/install.sh index c842bf0..841f67f 100755 --- a/install.sh +++ b/install.sh @@ -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 " sandbox-pi () {" 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 ' (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" diff --git a/tests/unit/10_autophase.sh b/tests/unit/10_autophase.sh index bee6c59..5a4592a 100644 --- a/tests/unit/10_autophase.sh +++ b/tests/unit/10_autophase.sh @@ -51,6 +51,9 @@ sandbox-pi() { printf 'LAUNCH argc=%s cmd=%s\n' "\${#argv0[@]}" "\${cmd[0]:-none}" local a 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" (( \${#cmd[@]} )) || return 0 "\${cmd[@]}" @@ -59,6 +62,25 @@ EOF cat > "$WORK/rc/aliases.bash" <<'EOF' alias sandbox-alias='sandbox-pi' 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' broken-sandbox() { local -a cmd=() @@ -147,6 +169,7 @@ GTRACE="$WORK/git.trace" MARKER="$WORK/token.marker" PROMPTS="$WORK/prompts.log" : > "$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" # 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 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 ppush="${T_PHASE_PUSH-}" dryout="${T_DRYOUT-}" plant="${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 + 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 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" \ "AISB_AP_LIB=$lib" "AISB_AP_RC_FILES=$rc" "AISB_AP_PI_CMD=$cmd" \ "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" \ ${gfail:+"FAKE_GIT_FAIL=$gfail"} ${ppush:+"PHASE_PUSH=$ppush"} ${dryout:+"AISB_AP_DRYOUT=$dryout"} \ ${plant:+"FAKE_PI_PLANT_HOOK=$plant"} \ - "$APBIN" "$@" 2>&1)" && RC=0 || RC=$? + "$APBIN" "$@" 2>&1 < "$stdin_src")" && RC=0 || RC=$? return 0 } 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_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() { # + 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" : > "$TRACE" 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 _bad "pi is the in-container command" "$(grep '^LAUNCH' "$TRACE" | head -3)" 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/')" [[ -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"