From f45b4d167ab0dfeaf8a6da13cd63ccda136b755c Mon Sep 17 00:00:00 2001 From: Alec Hill Date: Sat, 8 Aug 2026 23:06:13 +0000 Subject: [PATCH 1/2] feat: give claude-box a headless exit-status contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make `claude-box -p …` compose in scripts, CI, and one-shots: - Propagate the harness's own exit code verbatim (0, 1–124, 128+ signal deaths) and map docker's container-lifecycle band to distinguishable launcher faults: 125 image-missing, 126 engine-start-failed, 127 harness-not-executable. Each fault also prints a machine-readable `claude-box: fault= detail="…"` line to stderr unconditionally. - Catch image-missing pre-run (build failure or image absent) so a runtime 125 unambiguously means engine/runtime rejection. - Compose docker -i/-t independently from the fd shapes: allocate a PTY only when both stdout and stderr are terminals (so a redirected stdout capture stays clean — no CRLF, no [entrypoint] lines), and attach stdin only when something is connected. - Stop the session container before sync_back copies out, so a supervisor's signal on a headless run can't snapshot half-flushed state. - Gate the entrypoint state dump behind CLAUDE_BOX_DEBUG (off by default) and add the CLAUDE_BOX_EXEC acceptance hook that execs raw args instead of claude, dropping the launcher's leading --dangerously-skip-permissions. - Document the whole contract in docs/runbook-headless-exit.md (and un-ignore docs/ so the runbook is tracked). Co-Authored-By: Claude Opus 4.8 --- .gitignore | 5 +- claude-box | 113 +++++++++++++-- docs/runbook-headless-exit.md | 256 ++++++++++++++++++++++++++++++++++ entrypoint.sh | 36 +++-- 4 files changed, 384 insertions(+), 26 deletions(-) create mode 100644 docs/runbook-headless-exit.md diff --git a/.gitignore b/.gitignore index 3da8ba2..bf4c1a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .DS_Store -docs/ .claude/settings.local.json + +.faff +.faffrc.local.yaml +.env.* diff --git a/claude-box b/claude-box index d5abaa3..30b139b 100755 --- a/claude-box +++ b/claude-box @@ -45,6 +45,21 @@ # git clone https://github.com/shftwst/claude-box ~/claude-box # ln -sf ~/claude-box/claude-box /usr/local/bin/claude-box # # or add ~/claude-box to PATH +# +# Exit status (so `claude-box -p …` composes in scripts, CI, and one-shots): +# 0 the harness (claude) succeeded +# 1–124 the harness's own exit code, verbatim +# 125 launcher fault — image missing +# 126 launcher fault — engine start failed +# 127 launcher fault — harness not executable +# 128+ signal deaths, verbatim +# Every launcher fault also prints `claude-box: fault= detail="…"` to +# stderr (unconditionally, even headless), so a caller testing only zero-vs- +# nonzero keeps working while one needing precision can parse fault=. 125–127 +# follow docker run's own daemon/invoke/not-found vocabulary. A redirected +# stdout carries only the harness's output — no CRLF, no [entrypoint] lines, +# nothing from stderr — because a PTY is allocated only when both stdout and +# stderr are terminals. set -euo pipefail @@ -53,6 +68,13 @@ set -euo pipefail # otherwise kill the script whenever stderr isn't a tty. log() { [[ ! -t 2 ]] || printf '[claude-box] %s\n' "$*" >&2; } +# Machine-readable launcher-fault line. UNLIKE log(), this is unconditional on +# the tty: a headless caller relies on it to disambiguate the exit code (see the +# exit-status contract in the header), so it must reach captured stderr whether +# or not stderr is a terminal. fault= is the stable field; detail="…" is +# human context. Never returns nonzero, so a bare `fault` can't trip set -e. +fault() { printf 'claude-box: fault=%s detail="%s"\n' "$1" "${2:-}" >&2; return 0; } + # Background-check the install dir against its upstream. Detached so the parent # never waits and silent on every failure mode (not a repo, no upstream, # offline, fetch fails) — those skips don't touch $UPDATE_CHECK_FILE so we @@ -184,11 +206,23 @@ elif [[ "$DOCKERFILE" -nt "$BUILT_MARKER" || "$ENTRYPOINT_SH" -nt "$BUILT_MARKER fi if [[ $needs_build -eq 1 ]]; then log "building image..." - docker build -f "$DOCKERFILE" -t "$IMAGE" "$CLAUDE_BOX_DIR" + if ! docker build -f "$DOCKERFILE" -t "$IMAGE" "$CLAUDE_BOX_DIR"; then + fault image-missing "docker build failed for image '${IMAGE}'" + exit 125 + fi mkdir -p "$(dirname "$BUILT_MARKER")" touch "$BUILT_MARKER" fi +# The image must exist before we run. If inspect still can't see it — a build +# skipped as unneeded but the image was removed out-of-band, or a build that +# produced nothing — that's an image-missing launcher fault (125), caught here +# (before the container ever launches) so runtime 125s can mean engine failure. +if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then + fault image-missing "image '${IMAGE}' is not present (build it or check the docker daemon)" + exit 125 +fi + # Persistent state dir — preserves settings, conversation history, etc. across # runs so Claude Code doesn't re-run setup on every launch. Delete to reset. STATE_DIR="${HOME}/.claude-box/state" @@ -217,6 +251,19 @@ sync_back() { [[ "${_LAUNCHED:-0}" == 1 && "${_SYNCED:-0}" == 0 ]] || return 0 _SYNCED=1 set +euo pipefail + + # Never copy out from a container that's still writing to the state volume. On + # a headless run no PTY absorbs the signal, so a supervisor's SIGINT/TERM/HUP + # can reach this launcher and fire the EXIT trap while the session container is + # still live — snapshotting half-flushed state. Stop it (with a short grace so + # the harness can flush) and wait before touching the volume. On a clean exit + # the container has already `--rm`'d itself, so `docker inspect` misses and + # this is a fast no-op — the interactive -it path routed the signal to the + # container, not here, so it too arrives with the container already gone. + if docker inspect "$CONTAINER_NAME" >/dev/null 2>&1; then + docker stop -t 2 "$CONTAINER_NAME" >/dev/null 2>&1 || true + fi + _state_proj="${STATE_DIR}/projects/${PROJECT_SLUG}" # On Colima, files written by the container may not be visible on the host due @@ -298,7 +345,10 @@ sync_back() { # pending signal trap runs the instant `docker run` returns, so sync_back must # already be defined by then — hence it lives here, above the run. During the # interactive `docker run -it` the PTY is in raw mode, so the user's in-Claude -# Ctrl-C goes to the container, not to this launcher. +# Ctrl-C goes to the container, not to this launcher. On a HEADLESS run there's +# no PTY, so a supervisor's signal lands here while the container may still be +# live; sync_back stops the container before copying out (see its guard), so the +# copy-out never races a running container regardless of the signal path. trap 'sync_back; cleanup' EXIT trap 'exit' INT TERM HUP @@ -340,8 +390,8 @@ case "$ENGINE_MODE" in ;; sysbox) if [[ "$_host_docker_caps" != *sysbox-runc* ]]; then - printf '[claude-box] --engine sysbox: host docker has no sysbox-runc runtime (install sysbox first)\n' >&2 - exit 1 + fault engine-start-failed "--engine sysbox: host docker has no sysbox-runc runtime (install sysbox first)" + exit 126 fi ENGINE=sysbox ;; @@ -520,6 +570,8 @@ FORWARD_VARS=( LINEAR_API_KEY TERM COLORFGBG + CLAUDE_BOX_DEBUG # opt-in entrypoint state dump (off by default) + CLAUDE_BOX_EXEC # acceptance hook: exec the args as a raw command, not claude "${CLAUDE_BOX_EXTRA_VARS[@]+"${CLAUDE_BOX_EXTRA_VARS[@]}"}" ) @@ -991,14 +1043,26 @@ if [[ -t 2 ]]; then printf '\e]0;claude-box: %s\a' "$(basename "$PROJECT_DIR")" >&2 fi -# Allocate a PTY (-t) whenever any of stdin/stdout/stderr is a real TTY. Claude -# Code's Ink-rendered TUI needs the container's stdout to be a PTY for color + -# layout detection; without it the welcome banner and status line render as -# white-on-white with raw ANSI artifacts. Drop -t only in fully piped/autonomous -# runs (no TTY anywhere) so docker doesn't inject CRLF translation that mangles -# stdout pipes. -TTY_FLAGS=(-i) -if [[ -t 0 || -t 1 || -t 2 ]]; then +# Compose docker's -i/-t independently from the actual fd shapes. +# +# -t (allocate a PTY): only when BOTH stdout and stderr are real terminals. +# Claude Code's Ink TUI needs a PTY on stdout for color + layout, but a PTY also +# applies CRLF translation and merges the container's stderr into stdout. So if +# stdout alone is redirected (`claude-box -p … > out.txt`), gating on any-of- +# three would still allocate a PTY and corrupt the capture with \r\n and stray +# [entrypoint] lines. Requiring both stdout and stderr to be ttys means a PTY is +# used only for genuinely interactive runs, and any redirection drops it. +# +# -i (attach stdin): only when something is actually connected to stdin — a +# terminal, a pipe, or a redirected file. Passing -i unconditionally makes a +# supervisor that launches us with stdin at /dev/null hand Claude an immediate +# EOF (interactive Claude then exits at once); omitting -i there leaves stdin +# unattached, which is correct for a no-stdin invocation. +TTY_FLAGS=() +if [[ -t 0 || -p /dev/stdin || -f /dev/stdin || -S /dev/stdin ]]; then + TTY_FLAGS+=(-i) +fi +if [[ -t 1 && -t 2 ]]; then TTY_FLAGS+=(-t) fi @@ -1007,6 +1071,7 @@ fi # Entrypoint runs as root, creates a hostuser for HOST_UID, then drops to it. log "starting container..." _LAUNCHED=1 +_rc=0 docker run --rm "${TTY_FLAGS[@]}" \ --name "$CONTAINER_NAME" \ -e "HOST_UID=$(id -u)" \ @@ -1027,7 +1092,29 @@ docker run --rm "${TTY_FLAGS[@]}" \ ${env_args[@]+"${env_args[@]}"} \ -w "${PROJECT_DIR}" \ "$IMAGE" \ - --dangerously-skip-permissions ${CLAUDE_ARGS[@]+"${CLAUDE_ARGS[@]}"} || true + --dangerously-skip-permissions ${CLAUDE_ARGS[@]+"${CLAUDE_ARGS[@]}"} || _rc=$? # The session/credential flush + resume hint run from the EXIT trap (sync_back, # defined above), so they fire on a dropped session too — not just a clean exit. +# +# Map docker run's status to the launcher's exit contract (see header). The +# harness's own codes pass through verbatim (0, 1–124 and signal deaths 128+); +# docker's container-lifecycle band becomes a distinguishable launcher fault +# with a machine-readable reason on stderr. Image-missing was already caught +# pre-run as 125, so a 125 HERE means the container failed to start with the +# image present — an engine/runtime rejection (bad posture, denied privileged / +# security-opt, missing runtime). 126/127 mean docker could not exec the harness +# (claude not executable / not found). The rare harness that itself exits in +# 125–127 is misattributed by the code alone — the fault= line, present only on +# a real launcher fault, is what actually disambiguates. +case "$_rc" in + 125) + fault engine-start-failed "docker could not create/start the container (check --engine posture and host runtime)" + _rc=126 + ;; + 126|127) + fault harness-not-executable "the container could not exec the harness (claude not found or not executable)" + _rc=127 + ;; +esac +exit "$_rc" diff --git a/docs/runbook-headless-exit.md b/docs/runbook-headless-exit.md new file mode 100644 index 0000000..ee349f7 --- /dev/null +++ b/docs/runbook-headless-exit.md @@ -0,0 +1,256 @@ +# Runbook — headless exit status & clean streams + +Verifies the launcher exits with the harness's status, emits machine-readable +fault lines, and keeps redirected streams clean. Maps 1:1 to the ticket's +acceptance criteria. + +## Prerequisites + +- A **real docker host** (Docker Desktop / OrbStack / Colima / rootless) — run + these on the host, not inside a box. +- `claude-box` from this branch on `PATH`. +- A throwaway project dir that is **not** `$HOME` (the launcher refuses `$HOME` + and its ancestors): + ```sh + mkdir -p /tmp/cbtest && cd /tmp/cbtest && git init -q + ``` +- The **first run rebuilds the image** (entrypoint.sh changed → mtime triggers a + rebuild). Warm it once and ignore its output: + ```sh + claude-box -- --version >/dev/null 2>&1 || true + ``` +- Auth (a logged-in Claude) is only needed for the one real-harness smoke in + Part 3; every other test uses the `CLAUDE_BOX_EXEC` hook and needs no auth. + +Two test hooks (both forwarded into the box by this branch): +- `CLAUDE_BOX_EXEC=1 claude-box -- ` runs `` as the in-box harness + instead of `claude`, so you can make the harness exit with any status. +- `CLAUDE_BOX_DEBUG=1` opts into the entrypoint's state dump. + +--- + +## Part 0 — Static checks (no docker) + +```sh +bash -n claude-box && bash -n entrypoint.sh && echo "parse OK" +``` + +Exit-code mapping, in isolation (this is the authoritative check for the 127 +bucket, which has no easy live trigger): + +```sh +map() { local r="$1"; case "$r" in 125) r=126;; 126|127) r=127;; esac; echo "$r"; } +for c in 0 7 2 124 125 126 127 130 137; do printf 'docker=%s -> exit=%s\n' "$c" "$(map "$c")"; done +``` +Expect: `0→0, 7→7, 2→2, 124→124, 125→126, 126→127, 127→127, 130→130, 137→137`. + +fd-based flag selection, in isolation: + +```sh +cat > /tmp/flag.sh <<'EOF' +f=(); [[ -t 0 || -p /dev/stdin || -f /dev/stdin || -S /dev/stdin ]] && f+=(-i) +[[ -t 1 && -t 2 ]] && f+=(-t); echo "flags=[${f[*]}]" +EOF +/tmp/flag.sh expect no -i" +echo x | /tmp/flag.sh ; echo " ^ pipe stdin-> expect -i" +/tmp/flag.sh /tmp/o 2>/dev/null; sed 's/^/redir stdout: /' /tmp/o; echo " ^ expect no -t" +``` + +--- + +## Part 1 — Exit status propagates (was always 0) + +```sh +cd /tmp/cbtest + +# harness success +CLAUDE_BOX_EXEC=1 claude-box -- bash -c 'exit 0' ; echo "exit=$? (expect 0)" + +# harness non-zero, verbatim (1–124) — the headline regression +CLAUDE_BOX_EXEC=1 claude-box -- bash -c 'exit 7' ; echo "exit=$? (expect 7)" +CLAUDE_BOX_EXEC=1 claude-box -- bash -c 'exit 42' ; echo "exit=$? (expect 42)" + +# signal death, verbatim (128+) +CLAUDE_BOX_EXEC=1 claude-box -- bash -c 'kill -INT $$' ; echo "exit=$? (expect 130)" +CLAUDE_BOX_EXEC=1 claude-box -- bash -c 'kill -TERM $$' ; echo "exit=$? (expect 143)" +``` + +PASS: each `exit=` matches, and **no** `claude-box: fault=` line is printed for +these (they're harness statuses, not launcher faults). + +--- + +## Part 2 — Launcher faults: distinct codes + machine-readable stderr + +### 2a. Image missing → 125 + +Break the build in a throwaway copy of the repo so the real one is untouched +(`REPO` = your claude-box checkout): + +```sh +REPO=~/src/claude-box # adjust +cp -r "$REPO" /tmp/cb-broken +printf '\nRUN exit 1\n' >> /tmp/cb-broken/Dockerfile +docker rmi -f claude-box >/dev/null 2>&1 || true + +cd /tmp/cbtest +/tmp/cb-broken/claude-box -- --version 2>err.txt; echo "exit=$? (expect 125)" +grep 'fault=image-missing' err.txt && echo "FAULT LINE OK" + +rm -rf /tmp/cb-broken # cleanup; next real run rebuilds the good image +``` + +PASS: `exit=125` and stderr carries `claude-box: fault=image-missing detail="…"`. + +### 2b. Engine start failed → 126 + +Pre-run check (host without sysbox — most dev machines): + +```sh +cd /tmp/cbtest +claude-box --engine sysbox -- --version 2>err.txt; echo "exit=$? (expect 126)" +grep 'fault=engine-start-failed' err.txt && echo "FAULT LINE OK" +``` + +Runtime create failure (works on any host — a mount docker rejects at create, +exercising the docker-125 → 126 remap): + +```sh +cd /tmp/cbtest +echo 'CLAUDE_BOX_EXTRA_MOUNTS=("/tmp:/tmp:bogusmode")' > .env.claude-box +claude-box -- --version 2>err.txt; echo "exit=$? (expect 126)" +grep 'fault=engine-start-failed' err.txt && echo "FAULT LINE OK" +rm -f .env.claude-box +``` + +PASS: `exit=126` and a `fault=engine-start-failed` line in both. + +### 2c. Harness not executable → 127 + +Authoritative check is Part 0's mapping (`docker 126/127 → 127`); docker only +returns 126/127 when it can't exec the *image entrypoint* (a corrupt image), +which has no convenient live trigger. Best-effort live probe (code may vary by +gosu build): + +```sh +cd /tmp/cbtest +CLAUDE_BOX_EXEC=1 claude-box -- /no/such/binary 2>err.txt; echo "exit=$?" +grep 'fault=harness-not-executable' err.txt && echo "FAULT LINE OK (if 127)" +``` + +### 2d. Fault line survives a fully-headless run (no fd is a terminal) + +```sh +cd /tmp/cbtest +claude-box --engine sysbox -- --version /dev/null 2>err.txt +echo "exit=$? (expect 126)" +cat err.txt +grep -q 'fault=engine-start-failed' err.txt && echo "FAULT REACHED CAPTURED STDERR" +``` + +PASS: the fault line is in `err.txt` even though stderr is a file, not a tty — +the tty-gated `log()` would have dropped it; `fault()` does not. + +--- + +## Part 3 — Streams & TTY + +### 3a. Redirected stdout is clean: no CRLF, no `[entrypoint]`, nothing from stderr + +Run from an **interactive terminal** (stdout → file, stderr → terminal): + +```sh +cd /tmp/cbtest +CLAUDE_BOX_EXEC=1 claude-box -- bash -c 'printf "hello\n"' > out.txt + +od -c out.txt | head -1 # expect: h e l l o \n (no \r) +printf 'CR count: '; grep -c $'\r' out.txt # expect 0 +printf 'entrypoint lines: '; grep -c '\[entrypoint\]' out.txt # expect 0 +printf 'line count: '; wc -l < out.txt # expect 1 +``` + +Real-harness variant (needs auth): + +```sh +claude-box -p 'reply with exactly: hello' > out2.txt +grep -c $'\r' out2.txt ; grep -c '\[entrypoint\]' out2.txt # both 0 +``` + +PASS: `out.txt` is exactly `hello\n`; no `\r`, no `[entrypoint]` lines, no stderr +content leaked into the file. + +### 3b. `-i` only when stdin is connected + +Piped stdin is forwarded (proves `-i` on for a pipe): + +```sh +printf 'PING\n' | CLAUDE_BOX_EXEC=1 claude-box -- cat # expect: PING +``` + +No-stdin interactive run does not hang (proves `-i` dropped for `/dev/null`): + +```sh +cd /tmp/cbtest +timeout 45 claude-box /dev/null 2>&1; echo "exit=$?" +``` + +PASS: the pipe case prints `PING`; the no-stdin case returns **before** 45s +(exit is *not* 124 — it resolves instead of hanging on an attached-but-empty +stdin). The exact non-124 code depends on how Claude handles no prompt. + +### 3c. Debug dump is opt-in + +```sh +cd /tmp/cbtest +CLAUDE_BOX_EXEC=1 claude-box -- true off.txt +CLAUDE_BOX_DEBUG=1 CLAUDE_BOX_EXEC=1 claude-box -- true on.txt + +printf 'default dump lines: '; grep -c '.credentials.json:' off.txt # expect 0 +printf 'debug dump lines: '; grep -c '.credentials.json:' on.txt # expect >=1 +``` + +PASS: no credential/`.claude.json` dump by default; present under +`CLAUDE_BOX_DEBUG=1`. + +--- + +## Part 4 — SIGINT to a headless launcher never copies out from a live container + +Start a long-running headless harness, signal the **launcher**, and confirm the +container was torn down (so any copy-out ran against a stopped container): + +```sh +cd /tmp/cbtest +CLAUDE_BOX_EXEC=1 claude-box -- bash -c 'sleep 60' /dev/null 2>sig.err & +launcher=$! +sleep 10 # let the container come up +docker ps --filter 'name=claude-box-' --format '{{.Names}} {{.Status}}' # should show it Up +kill -INT "$launcher" +wait "$launcher"; echo "launcher exit=$?" + +# Assertions after the launcher has exited: +docker ps --filter 'name=claude-box-' --format '{{.Names}}' # expect: (empty) — not running +docker ps -a --filter 'name=claude-box-' --format '{{.Names}}' # expect: (empty) — --rm cleaned up +``` + +PASS: after the SIGINT the launcher exits promptly and **no** `claude-box-…` +container is left running or lingering. sync_back's guard stops the container +before touching the state volume, so the copy-out cannot race a live container. + +--- + +## Results checklist + +| # | Criterion | Expected | +|---|---|---| +| 1 | harness success | exit 0, no fault line | PASS +| 1 | harness non-zero (was always 0) | exit 7 / 42, no fault line | PASS +| 1 | signal death | exit 130 / 143 | PASS +| 2a | image missing | exit 125 + `fault=image-missing` | +| 2b | engine start failed | exit 126 + `fault=engine-start-failed` | PASS +| 2c | harness not executable | mapping check `126/127→127`; live `fault=harness-not-executable` | +| 2d | fault line, fully headless | line present in captured stderr | +| 3a | redirected stdout | no `\r`, no `[entrypoint]`, no stderr leak | +| 3b | `-i` gating | pipe round-trips; no-stdin run doesn't hang (≠124) | +| 3c | debug dump | absent by default; present with `CLAUDE_BOX_DEBUG=1` | +| 4 | SIGINT headless | launcher exits; no live/lingering container | diff --git a/entrypoint.sh b/entrypoint.sh index 326b348..a5a0817 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -183,23 +183,35 @@ if [ "$(id -u)" = "0" ] && [ "${HOST_UID}" != "0" ]; then # gosu does a direct exec as the user — no shell wrapper, proper TTY + signal # inheritance for Claude Code's interactive UI. - echo "[entrypoint] HOME=${HOME}" >&2 - echo "[entrypoint] .claude contents:" >&2 - ls -la "${HOME}/.claude/" >&2 2>/dev/null || echo "(missing)" >&2 - echo "[entrypoint] .credentials.json:" >&2 - if [ -f "${HOME}/.claude/.credentials.json" ]; then - echo "present ($(wc -c < "${HOME}/.claude/.credentials.json") bytes)" >&2 - else - echo "(missing)" >&2 + # + # State dump is opt-in (CLAUDE_BOX_DEBUG=1), and stays on stderr. It's off by + # default because it's noisy and mildly sensitive: it lists ~/.claude, reports + # the credential file's byte count, and prints the first 200 bytes of + # .claude.json — none of which a headless caller wants merged into its logs. + if [ -n "${CLAUDE_BOX_DEBUG:-}" ]; then + echo "[entrypoint] HOME=${HOME}" >&2 + echo "[entrypoint] .claude contents:" >&2 + ls -la "${HOME}/.claude/" >&2 2>/dev/null || echo "(missing)" >&2 + echo "[entrypoint] .credentials.json:" >&2 + if [ -f "${HOME}/.claude/.credentials.json" ]; then + echo "present ($(wc -c < "${HOME}/.claude/.credentials.json") bytes)" >&2 + else + echo "(missing)" >&2 + fi + echo >&2 + echo "[entrypoint] .claude.json:" >&2 + head -c 200 "${HOME}/.claude.json" >&2 2>/dev/null || echo "(missing)" >&2 + echo >&2 fi - echo >&2 - echo "[entrypoint] .claude.json:" >&2 - head -c 200 "${HOME}/.claude.json" >&2 2>/dev/null || echo "(missing)" >&2 - echo >&2 # Debug/acceptance hook: CLAUDE_BOX_EXEC=1 execs the args as a raw command # instead of claude — used to run docs/cage-engine-acceptance.md in-cage # non-interactively (e.g. `CLAUDE_BOX_EXEC=1 ... bash -c 'docker info'`). if [ -n "${CLAUDE_BOX_EXEC:-}" ]; then + # The launcher always prepends claude's --dangerously-skip-permissions; drop + # a single leading one so EXEC runs the bare command that follows (this is + # what makes `CLAUDE_BOX_EXEC=1 claude-box -- bash -c 'exit 7'` exit 7, + # exercising the launcher's exit-status propagation end to end). + [ "${1:-}" = "--dangerously-skip-permissions" ] && shift echo "[entrypoint] exec (CLAUDE_BOX_EXEC): $*" >&2 exec gosu "${USERNAME}" "$@" fi From 5163c4bd0e2828aea369b14db25240f906f6dfad Mon Sep 17 00:00:00 2001 From: Alec Hill Date: Sat, 8 Aug 2026 23:06:13 +0000 Subject: [PATCH 2/2] chore: add the repo's own faff tracker config Track .faffrc.yaml (Linear tracker, agile lens, nlspec spec) and ignore the local-only .faffrc.local.yaml / .faff / .env.* alongside it. Co-Authored-By: Claude Opus 4.8 --- .faffrc.yaml | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .faffrc.yaml diff --git a/.faffrc.yaml b/.faffrc.yaml new file mode 100644 index 0000000..4b5d2f9 --- /dev/null +++ b/.faffrc.yaml @@ -0,0 +1,73 @@ +# faff — the harness's OWN repo config (Linear tracker · agile lens · nlspec spec). +# (Corrected 2026-07-01: previously carried a copied P1-link-shortener SUT / git-only header — a relic.) + +tracking: + spec_docs_path: docs/specs/ # where graft commits specs (git-only spec store is .faff/specs/) + repo: shftwst/claude-box + +slots: + methodology: faffter-dark-methodology-agile-delivery + spec: faffter-dark-nlspec + spec_review: faffter-dark-spec-review + architecture: faffter-noon-architecture + concurrency: faffter-dark-concurrency-parallel + env: faffter-noon-env-compose + review: faffter-dark-adversarial-review + # evaluator (faffter-noon-evaluate) is invoked via the holdout → prdr-coverage path, not an rc slot. + +appetite: high +automation_default: opt-in # fail-safe: a ticket is automatable ONLY when a human adds `faff-automate` in the tracker (the control-plane gesture, FAFF-19/125/218) +intake_gate: warn + +budget: + max_attempts: 40 # runaway backstop ONLY (won't bind the scoped bulk slice); NOT the terminator + tokens: 3000000000 # 3B runaway backstop (subscription covers the week; catches a true runaway) + at_ceiling: escalate # surface a needs-human signal at the ceiling — never silently drain + # run-done (dryness) + convergence.max_waves govern the real exit; budget is the backstop. + +convergence: + enabled: true # within-run convergence on by default: drain execution-discovered scope in-run + max_waves: 8 # reported runaway backstop only (dryness is the normal exit, never this) + +backends: + nvidia-glm: + provider: nvidia + model: z-ai/glm-5.2 + host: https://integrate.api.nvidia.com/v1 + api_key_env: NVIDIA_API_KEY # confirmed set in env (name only; never the key itself) + timeout: 480 + gemini-gemma: + provider: gemini + model: models/gemma-4-31b-it # /v1beta/openai lists ids models/-prefixed; the served-check is exact-match + host: https://generativelanguage.googleapis.com/v1beta/openai + api_key_env: GEMINI_API_KEY + timeout: 480 + openrouter: + provider: openai # OpenRouter is OpenAI-compatible + model: nvidia/nemotron-3-ultra-550b-a55b:free + host: https://openrouter.ai/api/v1 + api_key_env: OPENROUTER_API_KEY + timeout: 480 + gemini-gemma-paid: + provider: gemini + model: models/gemma-4-31b-it # /v1beta/openai lists ids models/-prefixed; the served-check is exact-match + host: https://generativelanguage.googleapis.com/v1beta/openai + api_key_env: GEMINI_API_KEY_FAFF_PAID + timeout: 480 + +faffter_dark: + adversarial: # ordered refs: list — index 0 first-served, no "primary" (FAFF-261/523) + deadline: 1440 + refs: + - nvidia-glm + - openrouter + - gemini-gemma + - gemini-gemma-paid + +models: + build_by_confidence: + default: claude-opus-4-8 + high: sonnet + medium: claude-opus-4-8 + prep_explore: sonnet + eval: claude-opus-4-8