#!/usr/bin/env bash # ai-sandbox-autophase -- run the .agents/phases/ pipeline with the agent inside # ai-sandbox and the git history outside it. # # Who runs where is the whole design: # # * the executor agents run INSIDE the sandbox, so the code they write, the tests # they run and the dependencies they download cannot touch the host; # * .agents/validate.sh runs INSIDE it too, because that file sits in the writable # mount -- a gate executed on the host is the sandboxed agent executing arbitrary # bash on the host the moment it edits its own jailer; # * the commit and the push run on the HOST, between unit runs, by this script. # The egress allowlist does not contain the git remote and adding it would let an # agent push what you are about to review. `git status`, `git diff`, `git log` and # `git blame` all work in there -- the repository is in the mount and git is in the # image -- only the remote is out of reach. So the pipeline cannot expect the agent # to deliver anything to the remote, and this script does that delivery instead: # one atomic commit per completed phase (the library's, scoped to the phase's own # work), pushed as soon as it exists, re-pushed between phase runs if a previous # run left something behind. # # The sandbox launcher is YOUR command, not a copy of it. Default: the `sandbox-pi` # shell function people keep in ~/.bashrc.d / ~/.bashrc, which knows the allowlist and # how to fetch the token. A non-interactive script never loads an interactive rc, so # the command is looked up in those rc files (function, alias or executable) and every # child then runs exactly what your own shell would have run -- the `pass` call # included, once per unit so an overnight run is not holding a day-old token. # # Usage: ai-sandbox-autophase [options] (see --help) set -uo pipefail VERSION="1.0.0" PROG="ai-sandbox-autophase" # -------------------------------------------------------------------------- # options: defaults -> environment -> command line # -------------------------------------------------------------------------- AISB_AP_PI_CMD="${AISB_AP_PI_CMD-sandbox-pi}" # function, alias or program AISB_AP_RC_FILES="${AISB_AP_RC_FILES:-}" # colon list; default: your rc files AISB_AP_LIB="${AISB_AP_LIB:-}" # phased-execution lib.sh AISB_AP_SKILL_DIR="${AISB_AP_SKILL_DIR:-}" # ... or its skill directory AISB_AP_VALIDATE="${AISB_AP_VALIDATE:-sandbox}" # sandbox | host AISB_AP_PUSH="${AISB_AP_PUSH:-1}" # push between phase runs AISB_AP_PREFLIGHT="${AISB_AP_PREFLIGHT:-1}" # prove the chain before spending tokens # No colon on purpose: an explicitly empty AISB_AP_SANDBOX_ARGS means "no extra flags", # which is how a launcher that is not ai-sandbox (or a test) opts out of them. AISB_AP_SANDBOX_ARGS="${AISB_AP_SANDBOX_ARGS---no-tty --no-interactive}" AISB_AP_NOTES="${AISB_AP_NOTES:-1}" # append the sandbox rules to the prompts AISB_AP_TRUST_GIT="${AISB_AP_TRUST_GIT:-0}" # allow a run that rewrote .git config/hooks AP_MODE="pipeline" # pipeline | unit | phase | status | dryrun AP_UNIT="" AP_PHASE="" AP_MAX_UNITS=0 ap_ts() { date '+%Y-%m-%d %H:%M:%S'; } ap_log() { printf '[%s] %s\n' "$(ap_ts)" "$*"; } ap_warn() { printf '[%s] ⚠ %s\n' "$(ap_ts)" "$*" >&2; } ap_die() { printf '[%s] ✗ ERROR: %s\n' "$(ap_ts)" "$*" >&2; exit 1; } ap_need() { [[ -n "${2:-}" ]] || ap_die "$1 needs a value (see --help)"; } usage() { cat < 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. Default: --no-tty --no-interactive -- nothing of an unattended run is attached to this terminal: no TTY to corrupt the JSON stream, and your keystrokes cannot reach the agent. --lib PATH phased-execution lib.sh. Default: \$AISB_AP_LIB, then \$AISB_AP_SKILL_DIR/scripts/lib.sh, then ~/.agents/skills/phased-execution/scripts/lib.sh, .agents/skills/..., ~/.pi/agent/skills/... GATE --validate-in WHERE sandbox (default) or host. The gate file lives in the writable mount, so choosing host runs agent-editable bash outside the sandbox, and says so loudly. --no-preflight Skip the throwaway container that proves launcher, image, entrypoint and egress policy work. On by default, so a broken sandbox fails in seconds instead of after an hour of attempts. GIT --no-push Leave commits local. PHASE_PUSH=0 in the environment means the same thing here and is honoured: nothing goes out either way. --trust-git-changes Deliver even though the sandbox edited .git/config or .git/hooks/. Default: refuse: a repository RUNS those, so a hook written inside the sandbox is code running on the host. Host git runs with an empty hooks path, no pager, no system config and no ext:: transport with or without this. A failed push stops the run and prints the hand-fix command. The commit itself is local and safe, and the between-phase push of the next run takes it out once the remote answers. Prompts: --no-notes drops the block that tells the agent it is inside a sandbox and that the remote is unreachable (on by default -- without it an agent will spend attempts trying to push). Everything else is the pipeline's own environment: MAX_FIX_ATTEMPTS, PHASE_MODEL, PHASE_THINKING, PI_TRUST, FRESH_FIX, QUIET, PHASE_NOTIFY, PHASE_COMMIT, PHASE_STAGE_DENY. See the phased-execution skill. EXIT STATUS 0 everything pending was delivered (or there was nothing to do) 1 a unit failed after all attempts, a commit/push did not complete, or delivery was refused because the sandbox changed .git/config or .git/hooks/ 2 bad usage 130/143 interrupted; the unit stays in todo/ and its session is resumed next run EOF } while (($#)); do case "$1" in --pi-cmd) ap_need "$1" "${2:-}"; AISB_AP_PI_CMD="$2"; shift 2 ;; --pi-cmd=*) AISB_AP_PI_CMD="${1#*=}"; shift ;; --rc-files) ap_need "$1" "${2:-}"; AISB_AP_RC_FILES="$2"; shift 2 ;; --rc-files=*) AISB_AP_RC_FILES="${1#*=}"; shift ;; --lib) ap_need "$1" "${2:-}"; AISB_AP_LIB="$2"; shift 2 ;; --lib=*) AISB_AP_LIB="${1#*=}"; shift ;; --skill-dir) ap_need "$1" "${2:-}"; AISB_AP_SKILL_DIR="$2"; shift 2 ;; --skill-dir=*) AISB_AP_SKILL_DIR="${1#*=}"; shift ;; --sandbox-args) ap_need "$1" "${2:-}"; AISB_AP_SANDBOX_ARGS="$2"; shift 2 ;; --sandbox-args=*) AISB_AP_SANDBOX_ARGS="${1#*=}"; shift ;; --validate-in) ap_need "$1" "${2:-}"; AISB_AP_VALIDATE="$2"; shift 2 ;; --validate-in=*) AISB_AP_VALIDATE="${1#*=}"; shift ;; --no-push) AISB_AP_PUSH=0; shift ;; --trust-git-changes) AISB_AP_TRUST_GIT=1; shift ;; --no-preflight) AISB_AP_PREFLIGHT=0; shift ;; --no-notes) AISB_AP_NOTES=0; shift ;; --unit) ap_need "$1" "${2:-}"; AP_MODE=unit; AP_UNIT="$2"; shift 2 ;; --unit=*) AP_MODE=unit; AP_UNIT="${1#*=}"; shift ;; --phase) ap_need "$1" "${2:-}"; AP_MODE=phase; AP_PHASE="$2"; shift 2 ;; --phase=*) AP_MODE=phase; AP_PHASE="${1#*=}"; shift ;; --max-units) ap_need "$1" "${2:-}"; AP_MAX_UNITS="$2"; shift 2 ;; --max-units=*) AP_MAX_UNITS="${1#*=}"; shift ;; --status) AP_MODE=status; shift ;; --dry-run|-n) AP_MODE=dryrun; shift ;; -h|--help) usage; exit 0 ;; -V|--version) printf '%s %s\n' "$PROG" "$VERSION"; exit 0 ;; *) printf '%s: unknown option: %s (see --help)\n' "$PROG" "$1" >&2; exit 2 ;; esac done case "$AISB_AP_VALIDATE" in sandbox) ;; host) # Said once, up front, whatever mode this is: .agents/validate.sh is inside the # mount the agent can write, so this choice runs agent-editable bash outside the # sandbox. It is a legitimate choice (a project whose toolchain only exists on the # host) and it is never a quiet one. ap_warn "the validation gate will run on the HOST: .agents/validate.sh is writable by the sandboxed agent, so this runs agent-editable bash outside the sandbox" ;; *) ap_die "--validate-in must be 'sandbox' or 'host' (got '$AISB_AP_VALIDATE')" ;; esac [[ "$AP_MAX_UNITS" =~ ^[0-9]+$ ]] || ap_die "--max-units must be a number" # -------------------------------------------------------------------------- # the launcher: run the command the user's own shell would run # -------------------------------------------------------------------------- # rc files to look in, in the order a login shell reads them. Only existence is # checked here; ap_probe_rc is what sources them, and only to read a definition. AP_RC_LIST=() ap_rc_list() { local -a out=() local f if [[ -n "$AISB_AP_RC_FILES" ]]; then local IFS=':' for f in $AISB_AP_RC_FILES; do [[ -n "$f" ]] && out+=("$f"); done else for f in "$HOME"/.bashrc.d/* "$HOME/.bashrc" "$HOME/.bash_aliases" "$HOME/.profile"; do [[ -f "$f" && -r "$f" ]] && out+=("$f") done fi AP_RC_LIST=("${out[@]+"${out[@]}"}") } # Source ONE rc file in a throwaway interactive bash and print what it defines for # NAME -- the definition text only, never a call: probing must not fetch a token or # start a container. Interactive because a ~/.bashrc that opens with `case $- in *i*)` # defines nothing for a non-interactive shell. `exit` is neutered first, because plenty # of rc files exit when they dislike the conditions and must not take the probe with them. # # Output, in this order, so it parses without ambiguity: # _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" tmp rc=0 tmp="$(mktemp "${TMPDIR:-/tmp}/aisb-ap-probe.XXXXXX")" || return 1 AISB_AP_P_NAME="$name" AISB_AP_P_FILE="$file" \ 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 printf "_AP_PATH=%s\n" "$PATH" if declare -F -- "$AISB_AP_P_NAME" >/dev/null 2>&1; then printf "_AP_FN\n"; declare -f -- "$AISB_AP_P_NAME" elif alias -- "$AISB_AP_P_NAME" >/dev/null 2>&1; then printf "_AP_ALIAS\n"; alias -- "$AISB_AP_P_NAME" fi ' > "$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 # Two word lists live in globals: AP_WORDS is the sandbox flags of every run, AP_CMD_WORDS # the words of a --pi-cmd that named a whole command line. Do not reuse one for the other. AP_WORDS=() AP_CMD_WORDS=() ap_words() { # -> AP_WORDS AP_WORDS=() read -r -a AP_WORDS <<< "$1" } # A captured function/alias becomes a small launcher script: neuter `exit`, source the # rc files (the command may lean on other functions or aliases from them), define what # we captured, then call it. Every argument is re-quoted with printf %q, because the # prompt is one argument that can contain quotes, newlines and dollar signs at once. ap_build_runner() { local -a body=() local f [[ -n "$AP_CHILD_PATH" ]] && body+=("PATH=$(printf '%q' "$AP_CHILD_PATH")" "export PATH") body+=('exit() { return "${1:-0}"; }') body+=('shopt -s expand_aliases 2>/dev/null || true') for f in ${AP_RC_LIST[@]+"${AP_RC_LIST[@]}"}; do body+=(". $(printf '%q' "$f") /dev/null 2>&1 || true") done # An rc file that turned on errexit must not kill the launcher halfway through the # run: the child's own exit code is the only status that matters here. body+=('set +e') body+=("$AP_DEF") body+=('_ap_run_named() {') body+=(' local n="$1"; shift') body+=(' local -a q=(); local a') body+=(' for a in "$@"; do q+=("$(printf "%q" "$a")"); done') body+=(' eval "$n ${q[*]}"') body+=('}') body+=("_ap_run_named $AP_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() { # -> 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=() [[ -n "$name" ]] || ap_die "no sandbox launcher: \$AISB_AP_PI_CMD and --pi-cmd are both empty" # An explicit command line wins: the user said exactly what to run. if [[ "$name" == *[[:space:]]* ]]; then read -r -a AP_CMD_WORDS <<< "$name" ((${#AP_CMD_WORDS[@]})) || ap_die "--pi-cmd has no command in it" AP_KIND=arguments AP_LAUNCHER="${AP_CMD_WORDS[0]}" AP_SOURCE="--pi-cmd" AP_CHILD=("${AP_CMD_WORDS[@]}") return 0 fi [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] \ || ap_die "launcher '$name' is not a command name; pass a whole command line instead (see --pi-cmd)" # 1. a function the caller exported 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 # 2. a function or an alias inside the rc files ap_rc_list for f in ${AP_RC_LIST[@]+"${AP_RC_LIST[@]}"}; do out="$(ap_probe_rc "$name" "$f")" path="$(printf '%s\n' "$out" | sed -n '1s/^_AP_PATH=//p')" kind="$(printf '%s\n' "$out" | sed -n '2p')" if [[ "$kind" == "_AP_FN" || "$kind" == "_AP_ALIAS" ]]; then 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 done # 3. a program on PATH -- recorded as the absolute path, because a bare word would be # resolved as a shell name first, and `pi` is one of the names this script shadows. if command -v -- "$name" >/dev/null 2>&1; then AP_KIND=program; AP_LAUNCHER="$(command -v -- "$name")"; AP_SOURCE="$AP_LAUNCHER" AP_CHILD=("$AP_LAUNCHER") return 0 fi ap_die "cannot resolve the sandbox launcher '$name': not an exported function, not a function or alias in: ${AP_RC_LIST[*]:-}, not a program on PATH. Point --rc-files at the file that defines it, or name the command outright: --pi-cmd 'ai-sandbox --allow api.example.com --env API_KEY'" } # The full argv prefix of a child run: the launcher, this run's own sandbox flags, then # -- so that everything after it is the in-container command verbatim. ai-sandbox stops # reading its own options at --, which is what lets a launcher function with a fixed # policy carry an arbitrary harness command line. AP_RUN=() ap_build_run() { AP_WORDS=() read -r -a AP_WORDS <<< "$AISB_AP_SANDBOX_ARGS" AP_RUN=("${AP_CHILD[@]}" ${AP_WORDS[@]+"${AP_WORDS[@]}"}) } # Anything printed that could carry a value is redacted first: a --pi-cmd that sets a # literal value, and a remote URL with a credential in it, which is what a git error # quotes back verbatim. Values never appear in output, only their names. ap_redact() { sed -E -e 's#([A-Za-z][A-Za-z0-9+.-]*://[^/:@ ]+:)[^@[:space:]]+@#\1***@#g' \ -e 's/(--[a-z-]*env[= ]+[A-Za-z_][A-Za-z0-9_]*)=[^[:space:]]+/\1=/g' } # The launcher part of a preview. A captured function or alias is NOT printed: its body # is the thing that fetches the token, and a preview must not echo it -- naming it is # the useful and the safe thing to print. A command line is printed with any literal # value redacted. ap_launcher_preview() { case "$AP_KIND" in function|alias) printf '<%s>' "$AP_LAUNCHER" ;; program) printf '%s' "$AP_LAUNCHER" ;; arguments) printf '%s' "$(printf '%s ' ${AP_CMD_WORDS[@]+"${AP_CMD_WORDS[@]}"} | ap_redact)" ;; *) printf '' ;; esac } ap_child_preview() { local a out="" out="$(ap_launcher_preview) " for a in ${AP_WORDS[@]+"${AP_WORDS[@]}"}; do out+="$(printf '%q' "$a") "; done printf '%s-- pi ' "$out" } # -------------------------------------------------------------------------- # the pipeline library, and the four things that change when the agent is remote # -------------------------------------------------------------------------- ap_find_lib() { local -a c=() [[ -n "$AISB_AP_LIB" ]] && c+=("$AISB_AP_LIB") [[ -n "$AISB_AP_SKILL_DIR" ]] && c+=("$AISB_AP_SKILL_DIR/scripts/lib.sh") c+=("$HOME/.agents/skills/phased-execution/scripts/lib.sh" ".agents/skills/phased-execution/scripts/lib.sh" "$HOME/.pi/agent/skills/phased-execution/scripts/lib.sh") local f for f in "${c[@]}"; do [[ -f "$f" && -r "$f" ]] && { printf '%s\n' "$f"; return 0; } done return 1 } AP_LIB="$(ap_find_lib)" || ap_die "the phased-execution library (lib.sh) was not found. Looked in: \$AISB_AP_LIB, \$AISB_AP_SKILL_DIR/scripts/lib.sh, ~/.agents/skills/phased-execution/scripts/lib.sh, .agents/skills/..., ~/.pi/agent/skills/... Install the phased-execution skill, or point --lib at its scripts/lib.sh." # shellcheck source=/dev/null source "$AP_LIB" # The library owns the pipeline. This script shadows exactly four of its functions -- # the ones that assume the agent is a local process -- and nothing else, so unit order, # validation, retries, provider-outage backoff, report recovery, the scoped phase # commit and the notifications all stay the library's. # # pi() the library calls `pi` once per child attempt; this turns that # call into a container. The child contract is unchanged: stdout is # still the JSON stream progress.mjs reads, and the exit code is # still pi's own, because ai-sandbox propagates the in-container # command's status and its own chatter goes to stderr. # first_prompt() the agent also has to know what it cannot do in there. # fix_prompt() # run_validation() the gate runs where the agent runs. # This happens after the launcher is resolved, and that order is not cosmetic: one of # the names installed here is `pi`, and a launcher called `pi` has to resolve to the # user's command rather than to the shim that wraps it. ap_install_overrides() { # Every push goes through ap_push_pending, including the library's own: it commits at # the phase's commit point and then calls push_phase, so shadowing that function is # what puts the fingerprint guard, the credential redaction and the error contract in # front of all of it. Requiring it is deliberate -- if the library ever stops calling # it, this script must find out here rather than quietly pushing around the guard. for _ap_fn in 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; do declare -F -- "$_ap_fn" >/dev/null 2>&1 \ || ap_die "$AP_LIB does not provide $_ap_fn(): it is not the phased-execution library, or it has changed shape. Refusing to run a half-wired pipeline." done for _ap_fn in first_prompt fix_prompt; do declare -F -- "$_ap_fn" >/dev/null 2>&1 \ || ap_die "$AP_LIB does not provide $_ap_fn(): refusing to run a pipeline whose prompts it cannot build." # the library's version, kept under its own name so the wrappers below can extend it eval "ap_lib_$_ap_fn ()$(declare -f -- "$_ap_fn" | tail -n +2)" done unset _ap_fn AP_SANDBOX_NOTES=' ## Sandbox rules for this run (ai-sandbox) You are running inside a rootless podman container. This directory is the only writable mount and your home is a sandbox home, not the host one. * git works locally: git status, git diff, git log and git blame are available and the repository is the real one. The git REMOTE is unreachable from here, and that is policy rather than a fault to fix: git push, git fetch, git pull, git ls-remote and git clone cannot work, so never attempt them and never report them as a failure of the task. The harness commits and pushes on the host when the phase completes; a task is delivered by leaving the work in the tree and green. * Egress is limited to an allowlist you cannot change. A connection that is refused or times out to anything outside it is the policy working: say so in your report rather than routing around it. * No root, no package manager, no system services. Use the toolchain in the image (node, python3, make, gcc, git, jq) and the project own vendored dependencies. * .agents/validate.sh, .agents/phases/ and .agents/reports/ belong to the harness. Never edit the gate to make it pass. ' # 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 "$1" 2>&1 hash of the repo's local config and hook files, or nothing local gd gd="$(git rev-parse --git-dir 2>/dev/null)" || return 0 [[ -n "$gd" ]] || return 0 { git config --local --list 2>/dev/null find "$gd/hooks" -type f -printf '%p %s\n' 2>/dev/null | sort find "$gd/hooks" -type f -exec sha1sum {} + 2>/dev/null | sort } | sha1sum | cut -d' ' -f1 } # 0 when nothing dangerous moved; 1 with the reason when it did. ap_git_guard() { [[ -n "$GIT_FINGERPRINT0" ]] || return 0 [[ "$AISB_AP_TRUST_GIT" == 1 ]] && return 0 local now now="$(ap_git_fingerprint)" [[ -z "$now" || "$now" == "$GIT_FINGERPRINT0" ]] && return 0 GIT_FINGERPRINT_MSG="$(git rev-parse --git-dir 2>/dev/null)" return 1 } ap_git_guard_fail() { printf '✗ ERROR: refusing to deliver (commit/push) -- the sandbox changed this repository'\''s own git configuration.\n' >&2 printf ' A repository runs .git/config and .git/hooks/, it does not merely record them: a hook\n' >&2 printf ' written inside the sandbox would execute on the host the moment this script committed.\n' >&2 printf ' look at it by hand: git config --local --list; ls -l "%s/hooks"\n' "${GIT_FINGERPRINT_MSG:-.git}" >&2 printf ' if the change was yours: re-run with --trust-git-changes\n' >&2 } ap_push_fail() { # local out="$1" cmd="$2" fatal="$3" printf '✗ ERROR: %s\n' "the push FAILED. The commit is local and safe; the remote does not have it." >&2 printf '%s\n' "$out" | ap_redact | sed 's/^/ /' >&2 printf ' finish by hand: %s\n' "$cmd" >&2 printf ' nothing is lost: the next run pushes it again once the remote answers.\n' >&2 return "$fatal" } # ap_push_pending [fatal] -- idempotent: pushes what the remote is missing. ap_push_pending() { local context="$1" fatal="${2:-1}" if ! ap_git_guard; then ap_git_guard_fail return "$fatal" fi if [[ "$AISB_AP_PUSH" != 1 ]]; then echo " (push disabled by --no-push: commits stay local)" return 0 fi if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo " (not a git repository -- nothing to push)" return 0 fi local remote branch ahead out remote="$(git remote 2>/dev/null | head -n1)" if [[ -z "$remote" ]]; then echo " (no git remote configured -- commits stay local)" return 0 fi branch="$(git symbolic-ref --short -q HEAD || true)" if [[ -z "$branch" ]]; then printf '✗ ERROR: %s -- detached HEAD, nothing to push.\n' "$context" >&2 printf ' the commits are local; check out a branch and push them.\n' >&2 return "$fatal" fi if git rev-parse --abbrev-ref --disambiguate '@{u}' >/dev/null 2>&1; then ahead="$(git rev-list --count '@{u}..HEAD' 2>/dev/null || echo 0)" if [[ "$ahead" == "0" ]]; then echo " (up to date with $remote/$branch)" return 0 fi ap_log "pushing $ahead unpushed commit(s) on $branch ($context)" if ! out="$(git push 2>&1)"; then ap_push_fail "$out" "git push" "$fatal" return $? fi else ap_log "pushing $branch to $remote (no upstream set yet)" if ! out="$(git push -u "$remote" "$branch" 2>&1)"; then ap_push_fail "$out" "git push -u $remote $branch" "$fatal" return $? fi fi echo " (pushed: $(git log -1 --oneline))" return 0 } # What the host intends to do with git. Printed by --status and --dry-run, neither of # which contacts the remote: 'unpushed' is computed from local refs only. ap_git_plan() { if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then printf 'git: not a repository -- the pipeline leaves its work in the tree\n' return 0 fi local branch remote ahead="n/a" branch="$(git symbolic-ref --short -q HEAD || echo '(detached)')" remote="$(git remote 2>/dev/null | head -n1)" if [[ -n "$remote" ]] && git rev-parse --abbrev-ref --disambiguate '@{u}' >/dev/null 2>&1; then ahead="$(git rev-list --count '@{u}..HEAD' 2>/dev/null || echo '?')" fi # The remote's NAME is resolved by name, never printed as a URL: one with a # credential in it would land in the log and the session output. printf 'git: branch=%s remote=%s unpushed=%s\n' "$branch" \ "$(if [[ -n "$remote" ]]; then git remote get-url "$remote" 2>/dev/null | ap_redact; else printf 'none'; fi)" "$ahead" printf 'git: commit and push happen on the host, between phase runs; the agent cannot reach the remote and is not given it\n' } # -------------------------------------------------------------------------- # preflight: one throwaway container, before any agent tokens are spent # -------------------------------------------------------------------------- ap_preflight() { 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 | 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 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" 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 } # -------------------------------------------------------------------------- # modes # -------------------------------------------------------------------------- ap_gate_preview() { local a out="" out="$(ap_launcher_preview) " for a in ${AP_WORDS[@]+"${AP_WORDS[@]}"}; do out+="$(printf '%q' "$a") "; done printf '%s-- bash .agents/validate.sh' "$out" } ap_report_config() { # The launcher line is printed through the same redaction as everything else: a # --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' else printf 'gate : .agents/validate.sh, in the sandbox\n' fi printf 'pipeline : %s\n' "$AP_LIB" printf 'attempts : MAX_FIX_ATTEMPTS=%s, PI_TRUST=%s, FRESH_FIX=%s, QUIET=%s, PHASE_COMMIT=%s\n' \ "${MAX_FIX_ATTEMPTS:-3}" "${PI_TRUST:-0}" "${FRESH_FIX:-0}" "${QUIET:-0}" "${PHASE_COMMIT:-1}" } # The pending units of one phase, in the library's execution order: the task files # sorted, then 00_phase.md as the final pass. next_unit() reports the first pending unit # rather than consuming it, so a listing cannot be built from it -- it has to walk the # same shape the scheduler does. --status compares the total against the library's own # count_units(), which is what notices if that shape ever changes. ap_phase_units() { # local p="$1" f t if [[ -d "$PHASE_TODO/$p" ]]; then for f in "$PHASE_TODO/$p"/[0-9]*.md; do # globs, so a name cannot be mis-split [[ -f "$f" ]] || continue t="${f##*/}"; [[ "$t" == "00_phase.md" ]] && continue printf '%s/%s\n' "$p" "$t" done [[ -f "$PHASE_TODO/$p/00_phase.md" ]] && printf '%s/00_phase.md\n' "$p" elif [[ -f "$PHASE_TODO/$p" ]]; then printf '%s\n' "$p" fi return 0 } ap_list_units() { local p u n=0 for p in $(phase_entries); do while IFS= read -r u; do [[ -n "$u" ]] || continue printf ' %s\n' "$u" n=$((n + 1)) (( n >= 10000 )) && break # a listing is never worth an unbounded loop done < <(ap_phase_units "$p") done [[ "$n" == 0 ]] && printf ' (none -- the pipeline is complete)\n' local libn libn="$(count_units 2>/dev/null || echo '?')" if [[ "$libn" != "?" && "$libn" != "$n" ]]; then ap_warn "the library counts $libn pending unit(s) but this listing found $n -- its directory shape has changed; trust the library" fi return 0 } ap_preview() { ap_report_config echo echo "one container per unit attempt; the prompt travels as one argument:" printf ' %s\n' "$(ap_child_preview)" echo echo "the gate:" printf ' %s\n' "$(ap_gate_preview)" echo echo "pending units:" ap_list_units echo ap_git_plan echo echo "Nothing was started: --dry-run builds no image, starts no container, fetches no" echo "token and contacts no remote. The launcher was resolved by reading rc files only." } ensure_git_env() { # Two switches mean the same thing here, so they are kept in step in both directions: # PHASE_PUSH=0 in the environment means "do not deliver", and --no-push means the # library must not push behind our back either. [[ "${PHASE_PUSH:-1}" == 1 ]] || AISB_AP_PUSH=0 [[ "$AISB_AP_PUSH" == 1 ]] || export PHASE_PUSH=0 } ap_run_pipeline() { local unit phase last_phase="" last_unit="" n=0 rc=0 local -a delivered=() sweep_finished_phases ensure_git_env command -v node >/dev/null 2>&1 || ap_die "node is required on the host to render task progress" [[ "$AISB_AP_PREFLIGHT" == 1 ]] && ap_preflight # Anything an earlier run failed to push goes out before new work. A failure here is # a warning: the pipeline can still make progress locally and every later push takes # the same commits up. ap_log "checking for commits a previous run left unpushed" ap_push_pending "start of run" 0 || ap_warn "some commits are still local -- continuing, and each phase push retries them" while unit="$(next_unit)"; do [[ -n "$unit" ]] || break # next_unit() reports the first pending unit; the progress that removes it is # execute_unit(). A unit that comes back twice has not been delivered, and looping # on it would run the pipeline in place forever -- stop and say so. if [[ -n "$unit" && "$unit" == "$last_unit" ]]; then echo "✗ ERROR: $unit was reported pending again after completing -- nothing moved it to $PHASE_DONE/. Stopping rather than repeating it forever." >&2 return 1 fi last_unit="$unit" phase="$(unit_phase "$unit")" if [[ -n "$last_phase" && "$phase" != "$last_phase" ]]; then # In between phase runs: deliver the phase that just committed. if ! ap_push_pending "between $last_phase and $phase"; then echo "✗ ERROR: pipeline stopped -- the phase commit is not on the remote (see above)" >&2 return 1 fi fi rc=0 execute_unit "$unit" || rc=$? if (( rc != 0 )); then # execute_unit printed the detail: task failure, or commit/push failure with the # hand-fix command. echo "✗ ERROR: pipeline stopped -- $unit did not complete (see the error output above)" >&2 echo " re-run this script to continue where it stopped" >&2 return 1 fi delivered+=("$unit") last_phase="$phase" n=$((n + 1)) if is_phase_end "$unit"; then notify_task "$unit" phase; else notify_task "$unit" task; fi if (( AP_MAX_UNITS > 0 && n >= AP_MAX_UNITS )); then ap_log "--max-units $AP_MAX_UNITS reached; $(count_units) unit(s) still pending" break fi done # Delivery is the point of the run, so a remote that will not answer makes the run a # failure: exiting 0 with the work committed but unpushed is the silent miss this # script exists to prevent. The commits are safe either way and the next run retries. if ! ap_push_pending "end of run"; then echo "✗ ERROR: the run delivered the work but could not push it (see above)" >&2 return 1 fi echo ap_log "✓ pipeline done -- ${#delivered[@]} unit(s) delivered this run: ${delivered[*]:-none}" echo " remaining in $PHASE_TODO/: $(count_units) task(s)" return 0 } # One unit, or one phase to completion: the library's own entry points, with the # between-run delivery around them. ap_run_selected() { local unit phase="$AP_PHASE" rc=0 n=0 sweep_finished_phases ensure_git_env command -v node >/dev/null 2>&1 || ap_die "node is required on the host to render task progress" [[ "$AISB_AP_PREFLIGHT" == 1 ]] && ap_preflight if [[ "$AP_MODE" == unit ]]; then unit="$AP_UNIT" [[ "$unit" == *.md ]] || unit="$unit.md" [[ -f "$PHASE_TODO/$unit" ]] || ap_die "$unit not found in $PHASE_TODO/" execute_unit "$unit" || rc=$? if (( rc == 0 )); then if is_phase_end "$unit"; then notify_task "$unit" phase; else notify_task "$unit" task; fi fi ap_push_pending "after $unit" || return 1 return "$rc" fi [[ -d "$PHASE_TODO/$phase" || -f "$PHASE_TODO/$phase.md" ]] \ || ap_die "phase $phase not found in $PHASE_TODO/" while unit="$(phase_next_unit "$phase")"; do [[ -n "$unit" ]] || break rc=0 execute_unit "$unit" || rc=$? if (( rc != 0 )); then echo "✗ ERROR: phase $phase did not complete (see the error output above)" >&2 return 1 fi n=$((n + 1)) if is_phase_end "$unit"; then notify_task "$unit" phase; else notify_task "$unit" task; fi done ap_push_pending "after phase $phase" || return 1 ap_log "✓ phase $phase complete ($n unit(s))" } 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 root="$(find_root)" || root="" [[ -n "$root" ]] || ap_die "no .agents/phases/todo found in this or parent directories (run it inside a phased project)" cd "$root" || ap_die "cannot enter $root" if [[ "$AP_MODE" != status && "$AP_MODE" != dryrun ]]; then ap_git_env # see the note above GIT_FINGERPRINT0="$(ap_git_fingerprint)" fi case "$AP_MODE" in status) ap_status ;; dryrun) ap_preview ;; unit|phase) ap_run_selected ;; *) ap_run_pipeline ;; esac } ap_status() { ap_report_config echo echo "pending units:" ap_list_units echo ap_git_plan } ap_interrupt() { # echo printf '[%s] ✗ ERROR: interrupted (%s) -- the unit stays in the todo/ directory; re-run to continue where it stopped\n' "$(ap_ts)" "$2" >&2 exit "$1" } trap 'ap_interrupt 130 SIGINT' INT trap 'ap_interrupt 143 SIGTERM' TERM # the empty hooks directory exists only to keep .git/hooks out of our own git calls trap '[ -z "$AP_EMPTY_HOOKS" ] || rm -rf "$AP_EMPTY_HOOKS"' EXIT main "$@"