name the sandbox's working directory, and drive phases through it
Two features, one shared reason: what the sandbox calls things decides what a run tells you afterwards. The cwd is now mounted at the path it has on the host and that is the working directory, so pwd, an error the agent quotes, a commit message and the window title a harness derives from the cwd all name this project instead of reading the same as every sandbox in the session. The mount is unchanged: same directory, same rw, same :z, still the only writable one. Only the destination changed, and a destination is where a bind mount can do damage -- it hides whatever the image already had there. So mirror_block_of() refuses the paths the image or the sandbox owns (/usr, /etc, /var, /opt, /bin, /sbin, /lib*, /boot, /proc, /sys, /dev, /root, /run, /tmp, /home, /home/sandbox*) and / itself, while /home/<user>/... mirrors: the one standard hierarchy where work lives, and the one whose per-user tree never meets the container's /home/sandbox volume or the read-only .agents share inside it. /tmp is what set the shape, and it was measured rather than assumed: with --tmpfs /tmp and -v /tmp/x:/tmp/x in one run podman puts the tmpfs on top, so pwd prints the right path, writes succeed, and the project is not there. A failure that quiet earns a refusal plus a line of output instead: workdir_note in --print-policy, a comment in --dry-run, one log line on a real run. --workdir DIR still chooses the mount point; onto a refused path it is honoured (it was asked for) and warned about. /project, the one directory baked into the image, is every fallback, so a workdir always exists -- --no-cwd included. ai-sandbox-autophase runs a .agents/phases/ pipeline with the split the sandbox forces: every executor agent and the .agents/validate.sh gate inside, the commits and the pushes outside. The gate is in the writable mount, so running it on the host is running agent-editable bash on the host; the git remote is deliberately unreachable in there, because an agent that can push can ship what you are about to review. It runs your launcher, not a copy of it -- the definition is read out of your rc files and never called, which is what lets --status and --dry-run promise no token fetch, no build, no container. Host git runs with an empty hooks path and no ext:: transport, and delivery is refused if a sandboxed unit rewrote .git/config or planted a hook. Needs --no-interactive to go with --no-tty: an unattended run must not leave stdin open on your keyboard, and the two are now separate choices on both sides. Tests and tooling, most of it forced by what the two changes exposed: mirror_block_of is extracted from the script and asked about /usr and /home/sandbox, which a test cannot create (the 06_entrypoint.sh pattern), and both real branches are driven from a scratch dir under $HOME, since $TMPDIR is always the fallback -- which is why the whole suite had only ever seen /project. e2e/12 holds a real container to the same promise, including that /project, /home/sandbox and a home-volume marker survive a mirrored run. The "nothing was started" checks now compare the set of container ids before and after instead of counting every sandbox on the machine, so a session you have open is not a test failure; the pty cases fall back to python3 where util-linux's script is not installed. shellcheck covers both programs, the completion file and the fixtures, so the completion builds are word-by-word now, and a colon in the project path is refused with the reason instead of podman's "invalid option type". 558 unit assertions, 719 with e2e; lint and bash -n clean. LLM-Generated: true
This commit is contained in:
+123
-11
@@ -188,13 +188,18 @@ NETWORK
|
||||
DNS is opened up. Weaker than the gate for CDNs.
|
||||
|
||||
FILESYSTEM
|
||||
The current directory is bind-mounted at /project and is the working directory.
|
||||
Nothing else on the host is writable, and nothing else is visible except the one
|
||||
exception below, which is read-only and there to keep a harness usable.
|
||||
The rest is opt-in.
|
||||
The current directory is bind-mounted at the path it has on the host and is the
|
||||
working directory, so pwd, prompts and window titles name the project instead of
|
||||
every sandbox looking like every other. The mount itself is unchanged by that: same
|
||||
directory, same rw, nothing else visible except the one exception below, which is
|
||||
read-only and there to keep a harness usable. The rest is opt-in.
|
||||
--mount PATH[:DEST][:ro] Extra bind mount. Repeatable. Default rw, DEST=/mnt/<name>.
|
||||
--no-cwd Do not mount the current directory.
|
||||
--workdir DIR Working directory in the container. Default /project.
|
||||
--workdir DIR Mount the cwd there instead of mirroring its host path, and
|
||||
cd there. 'auto' (the default) mirrors, falling back to
|
||||
/project when the host path would bury part of the image or
|
||||
of the sandbox itself (/usr, /etc, /tmp, /home/sandbox, /).
|
||||
A DIR onto one of those is mounted as asked and warned about.
|
||||
--home-cache Persist /home/sandbox in a named volume (default).
|
||||
--ephemeral-home Discard /home/sandbox on exit.
|
||||
--read-only Read-only root filesystem (plus tmpfs /tmp).
|
||||
@@ -255,6 +260,9 @@ RUN
|
||||
--label K=V Extra label. Repeatable.
|
||||
--keep Keep the container after it exits.
|
||||
--interactive | --no-tty Attachment. A TTY is allocated when stdin+stdout are TTYs.
|
||||
--no-interactive Never attach stdin. For unattended or scripted runs: the
|
||||
command cannot read this terminal, so a stray keystroke
|
||||
cannot type into an agent that is running by itself.
|
||||
--dry-run, -n Print the exact podman invocation (secrets redacted), exit 0.
|
||||
--print-policy Print the effective policy as key=value lines, exit 0.
|
||||
--print-entrypoint Print the script that is baked into the image as the
|
||||
@@ -446,8 +454,9 @@ parse_args() {
|
||||
--keep) OPT_KEEP=1; shift ;;
|
||||
--read-only) OPT_READONLY=1; shift ;;
|
||||
--shell) OPT_SHELL=1; shift ;;
|
||||
--interactive) OPT_INTERACTIVE=1; shift ;;
|
||||
--no-tty) OPT_TTY=0; shift ;;
|
||||
--interactive) OPT_INTERACTIVE=1; shift ;;
|
||||
--no-interactive) OPT_INTERACTIVE=0; shift ;;
|
||||
--no-tty) OPT_TTY=0; shift ;;
|
||||
--dry-run|-n) OPT_DRY=1; shift ;;
|
||||
--print-policy) OPT_PRINT=1; shift ;;
|
||||
--print-entrypoint) OPT_PRINT_EP=1; shift ;;
|
||||
@@ -510,10 +519,95 @@ agents_dest_taken() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Would a mirrored copy of $1 land on top of something the image or this sandbox needs?
|
||||
#
|
||||
# A bind mount does not only name a place, it hides whatever was already there -- so the
|
||||
# question is what lives at that path inside the container, not what the project is
|
||||
# called. /home is the one standard directory people keep their work in, and a per-user
|
||||
# tree there never meets the container's own home; the rest of the hierarchy either
|
||||
# belongs to the image or is one of this sandbox's own mounts.
|
||||
#
|
||||
# /tmp is measured, not guessed: with `--tmpfs /tmp` and `-v /tmp/x:/tmp/x` in the same
|
||||
# invocation, podman (5.x, rootless, SELinux enforcing) puts the tmpfs on top, so the
|
||||
# project comes out as an empty tmpfs -- the right name, writable, and not the project.
|
||||
# The failure is silent, which is exactly the shape a refusal has to take instead.
|
||||
#
|
||||
# Prints the path that blocks mirroring, or nothing when the mirror is safe: the caller
|
||||
# needs the reason, not just the verdict, because the fallback is a surprise otherwise.
|
||||
mirror_block_of() { # $1 = absolute host path -> the container path it would bury, or empty
|
||||
local p="$1" top
|
||||
if [[ "$p" == "/" ]]; then printf '/'; return 0; fi
|
||||
top="${p#/}"; top="/${top%%/*}" # the first component, with its leading slash
|
||||
case "$top" in
|
||||
# Mirror /home/<user>/..., but never /home itself (it would bury the home mount) and
|
||||
# never the container's own home, which holds the home volume and the .agents share.
|
||||
/home)
|
||||
case "$p" in
|
||||
/home) printf '/home' ;;
|
||||
/home/sandbox|/home/sandbox/*) printf '/home/sandbox' ;;
|
||||
esac
|
||||
return 0 ;;
|
||||
# Directories the base image owns, in whole or in part.
|
||||
/bin|/boot|/dev|/etc|/lib|/lib32|/lib64|/opt|/proc|/root|/run|/sbin|/sys|/tmp|/usr|/var)
|
||||
printf '%s' "$top"; return 0 ;;
|
||||
esac
|
||||
# Everything else -- /mnt, /srv, /media, or a top-level directory the image does not
|
||||
# have at all -- is empty inside the container: mirroring creates a path, shadows
|
||||
# nothing, and the run keeps the host path it recognises.
|
||||
printf ''
|
||||
}
|
||||
|
||||
# Where the cwd is mounted and worked in -- 'auto' mirrors the host path.
|
||||
#
|
||||
# Two reasons for mirroring, one practical and one human:
|
||||
#
|
||||
# * paths the agent prints (an error, a commit message, a todo list) stay readable on
|
||||
# the host, where someone has to act on them;
|
||||
# * a harness titles its window after the last component of the working directory, so
|
||||
# a fixed /project makes every sandbox tab in the session read the same.
|
||||
#
|
||||
# The fallback is /project, the one directory baked into the image, so a run always has
|
||||
# a working directory that exists -- including --no-cwd, which mounts nothing and lands
|
||||
# there. Destination only: never the mode, the SELinux option or the rw-ness.
|
||||
resolve_workdir() { # sets POL[workdir] and POL[workdir_note]; needs POL[cwd]
|
||||
local w="${OPT_WORKDIR:-${AISB_PROJECT_DEST:-$(cfgv workdir auto)}}"
|
||||
if [[ "$w" != auto ]]; then
|
||||
[[ "$w" == /* ]] || die "--workdir: must be an absolute path or 'auto' (got '$w')"
|
||||
w="${w%/}"
|
||||
[[ -n "$w" ]] || die "--workdir: / would mount the project over the whole container"
|
||||
POL[workdir]="$w"; POL[workdir_note]=""
|
||||
# An explicit path is a choice rather than a fallback, so it is honoured -- but the same
|
||||
# hiding rule applies to it, and whoever chose it cannot be expected to know which of
|
||||
# these paths the sandbox mounts over. Say so, since the alternative is a pwd that lies.
|
||||
local hide; hide="$(mirror_block_of "$w")"
|
||||
if [[ -n "$hide" ]]; then
|
||||
local msg="--workdir $w: the sandbox owns ${hide}*,"
|
||||
msg+=" so the project mount there can be covered by the image or by one of our own"
|
||||
msg+=" mounts (/tmp is a tmpfs)"
|
||||
warn "$msg"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
# --no-cwd mounts nothing, so there is nothing to mirror: /project is the image's own
|
||||
# directory and a note about the host path would only be noise.
|
||||
if [[ "${OPT_NO_CWD:-0}" == 1 ]]; then
|
||||
POL[workdir]="/project"; POL[workdir_note]=""; return 0
|
||||
fi
|
||||
local block; block="$(mirror_block_of "${POL[cwd]}")"
|
||||
if [[ -n "$block" ]]; then
|
||||
POL[workdir]="/project"
|
||||
POL[workdir_note]="cannot mirror ${POL[cwd]}: the sandbox owns ${block}*,"
|
||||
POL[workdir_note]+=" so the project is mounted at /project instead (--workdir DIR to choose)"
|
||||
else
|
||||
POL[workdir]="${POL[cwd]}"; POL[workdir_note]=""
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_opts() {
|
||||
POL[cwd]="$PWD"
|
||||
POL[net]="${OPT_NET:-${AISB_NET:-$(cfgv net allow)}}"
|
||||
POL[harness]="${OPT_HARNESS:-${AISB_HARNESS:-$(cfgv harness pi)}}"
|
||||
POL[workdir]="${OPT_WORKDIR:-${AISB_PROJECT_DEST:-$(cfgv workdir /project)}}"
|
||||
resolve_workdir
|
||||
POL[cpus]="${OPT_CPUS:-${AISB_CPUS:-$(cfgv cpus 4)}}"
|
||||
POL[memory]="${OPT_MEMORY:-${AISB_MEMORY:-$(cfgv memory 4g)}}"
|
||||
POL[pids]="${OPT_PIDS:-${AISB_PIDS:-$(cfgv pids 512)}}"
|
||||
@@ -573,7 +667,6 @@ resolve_opts() {
|
||||
POL[unenforced]="$OPT_UNENFORCED"
|
||||
POL[keep_root]="$OPT_KEEP_ROOT"
|
||||
POL[name]="${OPT_NAME:-aisb-${SESSION:-$(session_id)}}"
|
||||
POL[cwd]="$PWD"
|
||||
|
||||
P_allow=(${CFG_allow[@]+"${CFG_allow[@]}"} ${OPT_allow[@]+"${OPT_allow[@]}"})
|
||||
P_env=(${CFG_env[@]+"${CFG_env[@]}"} ${OPT_env[@]+"${OPT_env[@]}"})
|
||||
@@ -603,6 +696,13 @@ validate_opts() {
|
||||
[[ "${POL[cpus]}" =~ ^[0-9.]+$ ]] || die "--cpus must be a number (got '${POL[cpus]}')"
|
||||
[[ "${POL[memory]}" =~ ^[0-9]+([bkmgBKMG]?)$ ]] || die "--memory must look like 512m or 4g (got '${POL[memory]}')"
|
||||
[[ -d "${POL[cwd]}" ]] || die "current directory does not exist"
|
||||
# podman splits a `-v` spec on ':', so a project path containing one cannot be handed to
|
||||
# it at all (measured: `invalid option type "/project"`). Mirroring makes that string the
|
||||
# destination as well as the source, so say it here rather than forward podman's error.
|
||||
if [[ "${OPT_NO_CWD:-0}" != 1 && "${POL[cwd]}" == *:* ]]; then
|
||||
die "the project path contains a ':', which podman cannot parse in a mount spec.
|
||||
rename the directory, or start without it: $PROG --no-cwd"
|
||||
fi
|
||||
if [[ "${POL[net]}" == full ]]; then
|
||||
warn "--net full: the container has unrestricted internet access"
|
||||
fi
|
||||
@@ -1213,7 +1313,11 @@ ENV HOME=/home/sandbox \\
|
||||
AISB=1 \\
|
||||
AISB_MANIFEST=/etc/aisb/manifest.txt
|
||||
|
||||
WORKDIR ${POL[workdir]}
|
||||
# Fixed, on purpose: where the project is mounted is a run-time decision (see
|
||||
# resolve_workdir) and podman is told with --workdir, so two projects -- or one project
|
||||
# checked out twice -- must never mean two images. /project is also what the runtime
|
||||
# falls back to, and it is created above, so a workdir always exists.
|
||||
WORKDIR /project
|
||||
ENTRYPOINT ["/usr/local/lib/aisb/entrypoint.sh"]
|
||||
CMD ["bash", "-li"]
|
||||
AISB_CF
|
||||
@@ -1248,7 +1352,8 @@ image_hash() {
|
||||
# bare tag change (24.04 -> 24.10) changes it too because the name is in the hash.
|
||||
h="${POL[base_ref]:-${POL[base_image]}}|${POL[node_version]}|${POL[pi_version]}|${AISB_PI_PACKAGE}|v${VERSION}"
|
||||
h+="|$(printf '%s ' ${P_install[@]+"${P_install[@]}"})"
|
||||
# any change to the generated image definition produces a new tag
|
||||
# any change to the generated image definition produces a new tag -- and nothing
|
||||
# per-project is in it, so the same image serves every directory on the machine
|
||||
h+="|$(entrypoint_script | sha256sum | cut -d' ' -f1)"
|
||||
h+="|$(containerfile | sha256sum | cut -d' ' -f1)"
|
||||
sha12 "$h"
|
||||
@@ -1777,6 +1882,9 @@ print_argv() {
|
||||
"$(printf '%s ' ${DIRECT4[@]+"${DIRECT4[@]}"})"
|
||||
printf '# env (%d, passed via 0600 file): %s\n' "${#ENV_NAMES[@]}" \
|
||||
"$(printf '%s ' ${ENV_NAMES[@]+"${ENV_NAMES[@]}"})"
|
||||
# A fallback is worth a line here: the invocation on its own only shows /project, not
|
||||
# that the host path was passed over. Same text as workdir_note in --print-policy.
|
||||
[[ -z "${POL[workdir_note]:-}" ]] || printf '# workdir: %s\n' "${POL[workdir_note]}"
|
||||
for a in "${ARGV[@]}"; do
|
||||
if [[ "$a" == "$ENV_FILE" ]]; then printf '<env-file:0600> '
|
||||
else printf '%s ' "$(shquote "$a")"; fi
|
||||
@@ -1803,6 +1911,7 @@ print_policy() {
|
||||
printf 'harness=%s\n' "${POL[harness]}"
|
||||
printf 'command=%s\n' "$(printf '%s ' ${COMMAND[@]+"${COMMAND[@]}"})"
|
||||
printf 'workdir=%s\n' "${POL[workdir]}"
|
||||
[[ -z "${POL[workdir_note]:-}" ]] || printf 'workdir_note=%s\n' "${POL[workdir_note]}"
|
||||
printf 'cwd=%s\n' "${POL[cwd]}"
|
||||
printf 'cwd_mounted=%s\n' "$([[ ${OPT_NO_CWD:-0} == 1 ]] && echo no || echo yes)"
|
||||
printf 'limits=cpus:%s,memory:%s,pids:%s\n' "${POL[cpus]}" "${POL[memory]}" "${POL[pids]}"
|
||||
@@ -1865,6 +1974,9 @@ cmd_run() {
|
||||
|
||||
if (( OPT_PRINT )); then print_policy; exit 0; fi
|
||||
if (( OPT_DRY )); then print_argv; exit 0; fi
|
||||
# A real run is where the fallback would otherwise be invisible: pwd is not the path
|
||||
# the user typed, and they should find out from this run rather than from the agent.
|
||||
[[ -z "${POL[workdir_note]:-}" ]] || log "${POL[workdir_note]}"
|
||||
|
||||
ensure_image sandbox > /dev/null
|
||||
# The gate only comes up once we know the sandbox image exists: a gate with no
|
||||
|
||||
Executable
+879
@@ -0,0 +1,879 @@
|
||||
#!/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 <<EOF
|
||||
$PROG $VERSION -- run the phased-execution pipeline through ai-sandbox.
|
||||
|
||||
USAGE
|
||||
$PROG every pending task, in pipeline order
|
||||
$PROG --unit 03_api/02_routes.md one task (or a phase's 00_phase.md)
|
||||
$PROG --phase 03_api one phase to completion
|
||||
$PROG --max-units 4 stop after N units (0 = no cap)
|
||||
$PROG --status what would run: launcher, pending units, git plan
|
||||
$PROG --dry-run the exact child and gate commands
|
||||
|
||||
--status and --dry-run read your rc files and nothing else: no container, no image
|
||||
build, no token fetched, no network.
|
||||
|
||||
WHAT RUNS WHERE
|
||||
in the sandbox the executor agent of every unit, and .agents/validate.sh
|
||||
on the host the phase commit, and every push -- this script's and the pipeline
|
||||
library's alike, routed through here so that nothing is delivered
|
||||
without the checks under GIT
|
||||
|
||||
The agent's repository is readable in there (status/diff/log/blame) and it is told,
|
||||
in its own prompt, that the remote is unreachable. An agent that could push could
|
||||
ship what you are about to review, so the remote stays out of the allowlist and this
|
||||
script delivers the commits: after each phase, and again before the next phase and
|
||||
at the end of the run, so nothing sits unpushed between runs.
|
||||
|
||||
LAUNCHER
|
||||
--pi-cmd NAME Command that starts a sandboxed pi. Default: \$AISB_AP_PI_CMD or
|
||||
'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'
|
||||
--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=<PATH as the rc files left it>
|
||||
# _AP_FN | _AP_ALIAS (absent when NAME is defined nowhere in that file)
|
||||
# <the definition>
|
||||
ap_probe_rc() { # <name> <file>
|
||||
local name="$1" file="$2"
|
||||
AISB_AP_P_NAME="$name" AISB_AP_P_FILE="$file" \
|
||||
timeout 15 bash -ic '
|
||||
exit() { return "${1:-0}"; }
|
||||
. "$AISB_AP_P_FILE" </dev/null >/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
|
||||
' 2>/dev/null
|
||||
}
|
||||
|
||||
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_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() { # <string> -> 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 >/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")
|
||||
}
|
||||
|
||||
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_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_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[*]:-<no rc files found>},
|
||||
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=<redacted>/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 '<launcher>' ;;
|
||||
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 <pi args> <prompt>' "$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 <url> 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.
|
||||
'
|
||||
|
||||
pi() { "${AP_RUN[@]}" -- pi "$@"; }
|
||||
|
||||
first_prompt() {
|
||||
local text
|
||||
text="$(ap_lib_first_prompt "$@")" || return 1
|
||||
printf '%s\n' "$text"
|
||||
[[ "$AISB_AP_NOTES" == 1 ]] && printf '%s\n' "$AP_SANDBOX_NOTES"
|
||||
return 0
|
||||
}
|
||||
|
||||
fix_prompt() {
|
||||
local text
|
||||
text="$(ap_lib_fix_prompt "$@")" || return 1
|
||||
printf '%s\n' "$text"
|
||||
[[ "$AISB_AP_NOTES" == 1 ]] && printf '%s\n' "$AP_SANDBOX_NOTES"
|
||||
return 0
|
||||
}
|
||||
|
||||
run_validation() { # <logfile>
|
||||
if [[ "$AISB_AP_VALIDATE" == host ]]; then
|
||||
bash .agents/validate.sh >"$1" 2>&1
|
||||
else
|
||||
"${AP_RUN[@]}" -- bash .agents/validate.sh >"$1" 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
push_phase() { ap_push_pending "phase ${1:-?}"; } # <- guarded, redacted, honest
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# git, on the host, in between the runs
|
||||
# --------------------------------------------------------------------------
|
||||
# The agent has the repository: git status/diff/log are how it sees what it changed. But
|
||||
# the COMMIT happens out here, and git is not a passive reader of a repository -- it runs
|
||||
# what the repository tells it to. So two things:
|
||||
#
|
||||
# 1. host-side git starts with the knobs that execute things turned off: no system
|
||||
# config, no pager, no prompt, no ext:: transport, and an empty hooks directory
|
||||
# instead of .git/hooks;
|
||||
# 2. the two paths that carry them (.git/config and .git/hooks/) are fingerprinted
|
||||
# before the first unit and re-checked before every delivery. If a sandboxed run
|
||||
# edited either, the run stops: a pre-commit hook written in there is the sandbox
|
||||
# executing code on the host, and refusing to commit is cheap next to that.
|
||||
# --trust-git-changes is the way through when the edit was yours.
|
||||
AP_EMPTY_HOOKS=""
|
||||
GIT_FINGERPRINT0=""
|
||||
GIT_FINGERPRINT_MSG=""
|
||||
|
||||
ap_git_env() {
|
||||
AP_EMPTY_HOOKS="$(mktemp -d "${TMPDIR:-/tmp}/aisb-ap-hooks.XXXXXX")" \
|
||||
|| ap_die "could not create the empty hooks directory that keeps .git/hooks out of this run"
|
||||
export GIT_CONFIG_NOSYSTEM=1 GIT_PAGER=cat GIT_TERMINAL_PROMPT=0
|
||||
export GIT_CONFIG_COUNT=2
|
||||
export GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0="$AP_EMPTY_HOOKS"
|
||||
export GIT_CONFIG_KEY_1=protocol.ext.allow GIT_CONFIG_VALUE_1=never
|
||||
}
|
||||
|
||||
ap_git_fingerprint() { # -> 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() { # <git output> <command> <fatal>
|
||||
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 <context> [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 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=$?
|
||||
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
|
||||
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
|
||||
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}"
|
||||
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() { # <phase>
|
||||
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
|
||||
# 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() { # <code> <signal>
|
||||
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 "$@"
|
||||
Reference in New Issue
Block a user