diff --git a/.opencode/cost-guard.config.json b/.opencode/cost-guard.config.json new file mode 100644 index 0000000..6415115 --- /dev/null +++ b/.opencode/cost-guard.config.json @@ -0,0 +1,5 @@ +{ + "maxCostUsd": 5.0, + "warnAtPercent": 80, + "mode": "block" +} diff --git a/.sandbox/defaults.sh b/.sandbox/defaults.sh index 6b9bab5..f33dbda 100644 --- a/.sandbox/defaults.sh +++ b/.sandbox/defaults.sh @@ -1,2 +1,2 @@ -#!/usr/bin/env bash SANDBOX_COMMAND_DEFAULTS=() +SANDBOX_COMMAND=opencode diff --git a/.sandbox/profile.template.json b/.sandbox/profile.template.json index 07f4c08..5d6b58a 100644 --- a/.sandbox/profile.template.json +++ b/.sandbox/profile.template.json @@ -3,7 +3,7 @@ "extends": ["always-further/opencode"], "meta": { "name": "env", - "version": "2" + "version": "3" }, "workdir": { "access": "readwrite" @@ -24,6 +24,7 @@ }, "network": { "allow_domain": ["localhost", "github.com"], + "open_port": [12100], "open_port_range": [[11400, 11500]] }, "environment": { diff --git a/.sandbox/start.sh b/.sandbox/start.sh index 7f126a3..bf678e9 100755 --- a/.sandbox/start.sh +++ b/.sandbox/start.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +# VERSION 3 -SELF=$(basename $BASH_SOURCE) +SELF=$(basename "$BASH_SOURCE") WORKSPACE=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD") SANDBOX_DIR="$WORKSPACE/.sandbox" PROFILE_JSON="$SANDBOX_DIR/profile.json" @@ -20,7 +21,7 @@ if [[ ! -f "$PROFILE_JSON" ]]; then echo "$SELF: Copying profile.json from template (v$local_ver). Check contents and adjust to your local environment." >&2 cp "$PROFILE_TEMPLATE" "$PROFILE_JSON" else - echo "$SELF: Couldn't find neither profile.json nor profile.template.json" >&2 + echo "$SELF: Could not find either profile.json or profile.template.json" >&2 exit 1 fi else @@ -33,13 +34,22 @@ else echo -e "\n\033[33mYour '$PROFILE_JSON' (v$local_ver) is older than the template (v$tpl_ver)!\033[0m" >&2 echo -e "\033[36m (diff between local config (-) and template (+))\033[0m" >&2 echo -e "\033[36m--------------------------------------------------------\033[0m" >&2 - diff -u --color=always "$PROFILE_JSON" "$PROFILE_TEMPLATE" || true + # --color=always is GNU-only; BSD diff (macOS) rejects it and the + # error would be swallowed by `|| true`, losing the diff itself. + # Probe once and degrade to plain output where unsupported. + color_opt="" + if diff --color=always /dev/null /dev/null >/dev/null 2>&1; then + color_opt="--color=always" + fi + diff -u $color_opt "$PROFILE_JSON" "$PROFILE_TEMPLATE" || true echo -e "\033[36m--------------------------------------------------------\033[0m" >&2 echo "Please adjust your '$PROFILE_JSON' (at least the .meta.version field to $tpl_ver) or delete it to reset.\n" >&2 fi else if ! diff -q "$PROFILE_JSON" "$PROFILE_TEMPLATE" >/dev/null 2>&1; then - echo -e "\n\033[33mYour '$PROFILE_JSON' differs from the template (v$tpl_ver) but there's no 'jq' installed to check versions.[0m" >&2 + # No version here: tpl_ver is only assigned in the jq branch, and + # interpolating it would abort under set -u on this degraded path. + echo -e "\n\033[33mYour '$PROFILE_JSON' differs from the template, but there's no 'jq' installed to check versions.\033[0m" >&2 fi fi fi diff --git a/AGENTS.md b/AGENTS.md index 0c3d8d9..5e45141 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ This file defines the DNA of our collaboration. Every instruction is binding. De **Mission:** Document the progress and manage the sprint's legacy. * **Completion:** 1. Update the `PROJECT_MAP.md`. - 2. **Archiving:** Move the `PLAN.md` to `docs/plans/YYYY-MM-DD_[Feature-Name].md`. + 2. **Archiving:** Move the `PLAN.md` to `plans/YYYY-MM-DD_[Feature-Name].md`. 3. **Post-Mortem:** Add an "Expectation vs. Reality" section to the archive. ### 8. The Dreamer (Metacognitive Consolidator) diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index e69de29..0000000 diff --git a/README.md b/README.md index 23f1246..5615ce9 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ For example: ./run_harness.sh --help ``` -`run_harness.sh` launches `opencode` through [`nono`](https://nono.sh) (via `.sandbox/start.sh`), providing a sandboxed OpenCode execution. It uses the Git root as the workspace (or the current directory outside a Git repository) and loads optional defaults from `.sandbox/defaults.sh`. The sandbox profile is rendered from `.sandbox/profile.template.json`, and `nono` must be installed separately. +`run_harness.sh` launches `opencode` through [`nono`](https://nono.sh) (via `.sandbox/start.sh`), providing a sandboxed OpenCode execution. It uses the Git root as the workspace (or the current directory outside a Git repository) and loads its sandbox command and default arguments from `.sandbox/defaults.sh`. That file is generated by `nono-here.sh` during provisioning (a template-provided one is preserved untouched); without it, `run_harness.sh` exits with an error. The sandbox profile is rendered from `.sandbox/profile.template.json`, and `nono` must be installed separately. ### macOS Shortcuts diff --git a/nono/nono-here.sh b/nono/nono-here.sh new file mode 100755 index 0000000..dda95db --- /dev/null +++ b/nono/nono-here.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +set -euo pipefail +# VERSION 2 + +SELF="$(basename "$0")" + +# Documented extension point: adding a harness is a one-token edit to this +# array and nothing else. +HARNESSES=(claude opencode codex copilot pi) + +die() { + local code="$1" + shift + echo "$SELF: $*" >&2 + exit "$code" +} + +# Resolve the script's own directory, following symlinks (portable, no +# readlink -f / realpath — must work on macOS/Bash 3.2). Bounded to guard +# against symlink cycles (e.g. a -> b -> a). +resolve_script_dir() { + local src dir target hops + src="$0" + hops=0 + while [[ -L "$src" ]]; do + hops=$((hops + 1)) + if [[ "$hops" -gt 40 ]]; then + die 1 "symlink resolution exceeded 40 hops (possible cycle) for '$0'" + fi + dir="$(cd -P "$(dirname "$src")" && pwd)" + target="$(readlink "$src")" + case "$target" in + /*) src="$target" ;; + *) src="$dir/$target" ;; + esac + done + cd -P "$(dirname "$src")" && pwd +} + +# Keep this assignment bare (no `local`/`export`): with `set -e`, a failure +# inside resolve_script_dir must abort the script. Wrapping it would replace +# $? with the local/export builtin's exit status, masking the failure. +script_dir="$(resolve_script_dir)" + +# Exported so a future child process can inherit the resolved home; nothing +# consumes it yet. +export NONO_HERE_HOME="${NONO_HERE_HOME:-$script_dir}" + +workdir=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD") + +# The single exec site, shared by the fast path and the end of provisioning +# so both hand over identically (same CWD, same argument handling). +handover() { + cd "$workdir" || die 1 "cannot enter $workdir" + exec ./run_harness.sh "$@" +} + +# Fast path: an already-provisioned workspace hands straight over, silently. +# (workdir and template are echoed to stderr only when provisioning happens.) +if [[ -e "$workdir/run_harness.sh" || -L "$workdir/run_harness.sh" ]] && [[ ! -f "$workdir/run_harness.sh" ]]; then + die 9 "$workdir/run_harness.sh exists but is not a regular file" +elif [[ -f "$workdir/run_harness.sh" ]] && [[ ! -x "$workdir/run_harness.sh" ]]; then + die 2 "$workdir/run_harness.sh is not executable; run: chmod +x \"$workdir/run_harness.sh\"" +elif [[ -f "$workdir/run_harness.sh" ]] && [[ -x "$workdir/run_harness.sh" ]]; then + if [[ ! -x "$workdir/.sandbox/start.sh" ]]; then + die 3 "$workdir/.sandbox/start.sh is missing or not executable; run: chmod +x \"$workdir/.sandbox/start.sh\"" + fi + handover "$@" +fi + +# Provisioning begins here: run_harness.sh is entirely absent. Nothing on +# disk changes until a harness and template have been validated. + +if [[ -n "${NONO_HERE_HARNESS:-}" ]]; then + harness="" + for h in "${HARNESSES[@]}"; do + if [[ "$h" == "$NONO_HERE_HARNESS" ]]; then + harness="$h" + break + fi + done + if [[ -z "$harness" ]]; then + die 8 "invalid NONO_HERE_HARNESS '$NONO_HERE_HARNESS'; valid values: ${HARNESSES[*]}" + fi +elif [[ ! -t 0 ]]; then + die 4 "no TTY for interactive harness selection; set NONO_HERE_HARNESS to one of: ${HARNESSES[*]}" +else + PS3="harness> " + select harness in "${HARNESSES[@]}"; do + if [[ -n "${harness:-}" ]]; then + break + fi + echo "$SELF: invalid selection '$REPLY'; choose a number from the list" >&2 + done + if [[ -z "${harness:-}" ]]; then + die 10 "no harness selected (input closed)" + fi +fi + +# Template resolution: user overrides beat bundled templates, +# harness-specific beats default; the first existing directory wins. +template="" +for candidate in \ + "${HOME:-}/.nono-here/templates/$harness" \ + "${HOME:-}/.nono-here/templates/default" \ + "$NONO_HERE_HOME/templates/$harness" \ + "$NONO_HERE_HOME/templates/default"; do + if [[ -d "$candidate" ]]; then + template="$candidate" + break + fi +done + +if [[ -z "$template" ]]; then + die 5 "no template directory found; probed in order: +${HOME:-}/.nono-here/templates/$harness +${HOME:-}/.nono-here/templates/default +$NONO_HERE_HOME/templates/$harness +$NONO_HERE_HOME/templates/default" +fi + +echo "$SELF: workdir: $workdir" >&2 +echo "$SELF: template: $template" >&2 + +for required in run_harness.sh start.sh; do + if [[ ! -f "$template/$required" ]]; then + die 7 "template '$template' is missing required file '$required'" + fi + if [[ ! -x "$template/$required" ]]; then + die 7 "template '$template' has '$required' without the executable bit; run: chmod +x \"$template/$required\"" + fi +done + +# A .sandbox without run_harness.sh is the remnant of an interrupted +# previous run. This point is only reached with a fully validated template +# in hand, so deleting it never leaves the workspace with neither sandbox +# nor replacement. +if [[ -e "$workdir/.sandbox" ]]; then + if [[ ! -t 0 ]]; then + die 6 "$workdir/.sandbox exists but is incomplete; remove it manually and re-run: rm -r \"$workdir/.sandbox\"" + fi + + echo -e "\033[33mWarning: '$workdir/.sandbox' exists but 'run_harness.sh' is missing — the sandbox is incomplete.\033[0m" >&2 + echo -e "\033[33mIt will be re-created from template '$template'.\033[0m" >&2 + echo -e "\033[33mThis is self-healing: it is the expected result of a previous run interrupted between the copy and completion; re-creating from the template repairs it.\033[0m" >&2 + + reply="" + read -r -p "delete .sandbox and re-create from $template? [y/N] " reply || reply="" + case "$reply" in + y | Y) ;; + *) die 6 "aborted; '$workdir/.sandbox' left untouched" ;; + esac + + rm -r "$workdir/.sandbox" +fi + +# A dangling symlink named .sandbox is invisible to the `-e` test above +# (false for a broken link). Without this guard, `mkdir -p` would abort via +# set -e with a bare, unexplained `File exists`. Every other kind of +# pre-existing .sandbox was already intercepted with exit 6. +if [[ -e "$workdir/.sandbox" || -L "$workdir/.sandbox" ]] && [[ ! -d "$workdir/.sandbox" ]]; then + die 9 "$workdir/.sandbox exists but is not a directory" +fi + +mkdir -p "$workdir/.sandbox" +cp -R "$template/." "$workdir/.sandbox/" + +# defaults.sh is generated before run_harness.sh is moved into place: if +# generation fails, run_harness.sh is still absent, so the next invocation +# re-enters provisioning instead of taking the fast path against a +# workspace that is missing its defaults file. +# +# A dangling symlink named defaults.sh is invisible to `-e`, and `cat >` +# would follow it, writing outside .sandbox. Reject any non-regular path. +if [[ -e "$workdir/.sandbox/defaults.sh" || -L "$workdir/.sandbox/defaults.sh" ]] && [[ ! -f "$workdir/.sandbox/defaults.sh" ]]; then + die 9 "$workdir/.sandbox/defaults.sh exists but is not a regular file" +fi +if [[ -f "$workdir/.sandbox/defaults.sh" ]]; then + echo "$SELF: '$workdir/.sandbox/defaults.sh' already exists; preserved untouched." >&2 +else + cat >"$workdir/.sandbox/defaults.sh" </dev/null || echo "$PWD") +DEFAULTS_FILE="${DEFAULTS_FILE:-$WORKSPACE/.sandbox/defaults.sh}" + +if [[ ! -f "$DEFAULTS_FILE" ]]; then + echo "missing defaults file $DEFAULTS_FILE" + exit 1 +fi + +source "$DEFAULTS_FILE" +SANDBOX_COMMAND="${SANDBOX_COMMAND:-}" +if [[ "$SANDBOX_COMMAND" = "" ]]; then + echo "missing 'SANDBOX_COMMAND' in $DEFAULTS_FILE" + exit 2 +fi + +if [[ $# -eq 0 || "${1:-}" == -* ]]; then + set -- ${SANDBOX_COMMAND_DEFAULTS[@]+"${SANDBOX_COMMAND_DEFAULTS[@]}"} "$@" +fi + +if [[ "${1:-}" == "$SANDBOX_COMMAND" ]]; then + shift +fi + +"$WORKSPACE/.sandbox/start.sh" "$SANDBOX_COMMAND" "$@" diff --git a/nono/templates/default/start.sh b/nono/templates/default/start.sh new file mode 100755 index 0000000..89da06a --- /dev/null +++ b/nono/templates/default/start.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +SELF=$(basename "$BASH_SOURCE") +WORKSPACE=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD") +SANDBOX_DIR="$WORKSPACE/.sandbox" +PROFILE_JSON="$SANDBOX_DIR/profile.json" +PROFILE_TEMPLATE="$SANDBOX_DIR/profile.template.json" + +if ! command -v nono >/dev/null 2>&1; then + echo "$SELF: 'nono' sandbox is not installed or not in PATH." >&2 + echo "Install nono from https://nono.sh/" >&2 + exit 127 +fi + +# There's no local profile.json yet, copy from template +if [[ ! -f "$PROFILE_JSON" ]]; then + if [[ -f "$PROFILE_TEMPLATE" ]]; then + local_ver=$(jq -r '.meta.version // 1' "$PROFILE_TEMPLATE" 2>/dev/null || echo "1") + echo "$SELF: Copying profile.json from template (v$local_ver). Check contents and adjust to your local environment." >&2 + cp "$PROFILE_TEMPLATE" "$PROFILE_JSON" + else + echo "$SELF: Could not find either profile.json or profile.template.json" >&2 + exit 1 + fi +else + # Check for version mismatch via meta.version + if command -v jq >/dev/null 2>&1; then + tpl_ver=$(jq -r '.meta.version // 0' "$PROFILE_TEMPLATE" 2>/dev/null || echo "0") + local_ver=$(jq -r '.meta.version // 0' "$PROFILE_JSON" 2>/dev/null || echo "0") + + if (( local_ver < tpl_ver )); then + echo -e "\n\033[33mYour '$PROFILE_JSON' (v$local_ver) is older than the template (v$tpl_ver)!\033[0m" >&2 + echo -e "\033[36m (diff between local config (-) and template (+))\033[0m" >&2 + echo -e "\033[36m--------------------------------------------------------\033[0m" >&2 + # --color=always is GNU-only; BSD diff (macOS) rejects it and the + # error would be swallowed by `|| true`, losing the diff itself. + # Probe once and degrade to plain output where unsupported. + color_opt="" + if diff --color=always /dev/null /dev/null >/dev/null 2>&1; then + color_opt="--color=always" + fi + diff -u $color_opt "$PROFILE_JSON" "$PROFILE_TEMPLATE" || true + echo -e "\033[36m--------------------------------------------------------\033[0m" >&2 + echo "Please adjust your '$PROFILE_JSON' (at least the .meta.version field to $tpl_ver) or delete it to reset.\n" >&2 + fi + else + if ! diff -q "$PROFILE_JSON" "$PROFILE_TEMPLATE" >/dev/null 2>&1; then + # No version here: tpl_ver is only assigned in the jq branch, and + # interpolating it would abort under set -u on this degraded path. + echo -e "\n\033[33mYour '$PROFILE_JSON' differs from the template, but there's no 'jq' installed to check versions.\033[0m" >&2 + fi + fi +fi + +run_hook() { + local hook_script="$SANDBOX_DIR/hooks/$1" + if [[ -x "$hook_script" ]]; then + ( "$hook_script" ) || echo "\033[33mWarning: Hook $1 exited with non-zero status.\033[0m" >&2 + fi +} + +cd "$WORKSPACE" +run_hook before +trap 'run_hook after' EXIT + +nono wrap \ + --profile "$PROFILE_JSON" \ + --workdir "$WORKSPACE" \ + --allow-cwd \ + -- "$@" diff --git a/nono/test_nono_here.sh b/nono/test_nono_here.sh new file mode 100755 index 0000000..3682e38 --- /dev/null +++ b/nono/test_nono_here.sh @@ -0,0 +1,891 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Plain-Bash test harness for nono-here.sh (decision Q20b). No bats, no jq, +# no rsync. Every case runs in its own mktemp -d fixture with HOME and +# NONO_HERE_HOME overridden so the real $HOME is never touched and no +# artefact survives outside the fixture. +# +# Provisioning cases drive the shipped NONO_HERE_HARNESS override (Q22a) — +# the same non-interactive path real CI users get. No test-only branches +# exist in nono-here.sh or the template run_harness.sh. +# +# Exit code coverage: 2-9 are each exercised below. Exit 1 is excluded +# (unexpected-internal-only, S9). Exit 10 (Ctrl-D at the `select` prompt) +# is excluded here: it requires a real TTY and was verified manually at +# Task 3 review; nothing below fakes it with a PTY. + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NONO_HERE="$REPO_ROOT/nono-here.sh" +DEFAULT_TEMPLATE_DIR="$REPO_ROOT/templates/default" + +PASS_COUNT=0 +FAIL_COUNT=0 + +# Fixture directories are recorded in a ledger file rather than a bash +# array: new_fixture() is always invoked via command substitution +# ("fx=$(new_fixture)"), which runs in a subshell, so array mutations +# there would never be visible to the parent shell. A file write does +# survive the subshell. +FIXTURE_LEDGER="$(mktemp "${TMPDIR:-/tmp}/nono-here-test-ledger.XXXXXX")" + +cleanup() { + local d + if [[ -f "$FIXTURE_LEDGER" ]]; then + while IFS= read -r d; do + if [[ -n "$d" && -d "$d" ]]; then + rm -rf "$d" + fi + done <"$FIXTURE_LEDGER" + rm -f "$FIXTURE_LEDGER" + fi + return 0 +} +trap 'ec=$?; cleanup; exit $ec' EXIT + +# Preflight: every case below writes a fixture script, chmod +x's it and +# executes it. On a noexec ${TMPDIR:-/tmp} (some hardened systems) all of +# those would fail with misleading "not executable" messages that look like +# product bugs. Probe once and fail loud with the real cause. +_probe="$(mktemp -d "${TMPDIR:-/tmp}/nono-here-test-exec.XXXXXX")" +printf '#!/bin/sh\nexit 0\n' >"$_probe/probe.sh" +chmod +x "$_probe/probe.sh" +if ! "$_probe/probe.sh" >/dev/null 2>&1; then + echo "FATAL: fixture directory ${TMPDIR:-/tmp} is not executable (noexec mount?)." >&2 + echo "Set TMPDIR to an executable directory and re-run: TMPDIR= $0" >&2 + rm -rf "$_probe" + exit 1 +fi +rm -rf "$_probe" + +new_fixture() { + local dir + dir="$(mktemp -d "${TMPDIR:-/tmp}/nono-here-test.XXXXXX")" + echo "$dir" >>"$FIXTURE_LEDGER" + echo "$dir" +} + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + echo "PASS: $1" +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + echo "FAIL: $1 -- $2" >&2 +} + +sha() { + shasum -a 256 "$1" | awk '{print $1}' +} + +# dir_digest -> a single digest covering every regular file's path +# and content under . Unlike hashing one sentinel file, this also +# detects additions, not just mutation/deletion of a known file — that is +# what "byte-for-byte untouched" requires. +dir_digest() { + local dir="$1" f + find "$dir" -type f | sort | while IFS= read -r f; do + printf '%s %s\n' "$(sha "$f")" "${f#"$dir"/}" + done | shasum -a 256 | awk '{print $1}' +} + +# --- stub scripts ----------------------------------------------------- + +# Writes a "record argv" stub in place of run_harness.sh. Records argc on +# line 1 followed by NUL-delimited argv, to the file named by +# $RUN_HARNESS_RECORD (env, required at run time). +write_stub_run_harness() { + local path="$1" + cat >"$path" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +: "${RUN_HARNESS_RECORD:?RUN_HARNESS_RECORD not set}" +{ + printf '%s\n' "$#" + if [[ $# -gt 0 ]]; then + printf '%s\0' "$@" + fi +} >"$RUN_HARNESS_RECORD" +STUB + chmod +x "$path" +} + +# Same recording convention, for start.sh, via $START_RECORD. +write_stub_start() { + local path="$1" + cat >"$path" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +: "${START_RECORD:?START_RECORD not set}" +{ + printf '%s\n' "$#" + if [[ $# -gt 0 ]]; then + printf '%s\0' "$@" + fi +} >"$START_RECORD" +STUB + chmod +x "$path" +} + +# make_template [marker-text] +# Builds a minimally valid template: executable run_harness.sh and +# start.sh stubs, optionally a marker.txt to identify it after copy. +make_template() { + local dir="$1" marker="${2:-}" + mkdir -p "$dir" + write_stub_run_harness "$dir/run_harness.sh" + write_stub_start "$dir/start.sh" + if [[ -n "$marker" ]]; then + printf '%s\n' "$marker" >"$dir/marker.txt" + fi +} + +# read_argv -> populates array ARGV_RESULT +# Returns 1 (without aborting the whole suite) on a missing, empty or +# truncated record file, so a stub-script failure fails only the one +# case that produced it. +read_argv() { + local file="$1" n i arg + ARGV_RESULT=() + if [[ ! -s "$file" ]]; then + return 1 + fi + exec 3<"$file" || return 1 + if ! IFS= read -r -u 3 n; then + exec 3<&- + return 1 + fi + i=0 + while ((i < n)); do + if ! IFS= read -r -d '' -u 3 arg; then + exec 3<&- + return 1 + fi + ARGV_RESULT+=("$arg") + i=$((i + 1)) + done + exec 3<&- + return 0 +} + +argv_eq() { + # argv_eq expected... -- compares against ARGV_RESULT + local expected=("$@") + if [[ "${#ARGV_RESULT[@]}" -ne "${#expected[@]}" ]]; then + return 1 + fi + local i + for ((i = 0; i < ${#expected[@]}; i++)); do + [[ "${ARGV_RESULT[$i]}" == "${expected[$i]}" ]] || return 1 + done + return 0 +} + +# run_nh [args...] +# Echoes the exit code. An empty nono_home/harness/record means genuinely +# unset in the child (via `env -u`), not merely an empty string — Task 4's +# review nit and S4/S5 both require the ambient environment (which may +# already export NONO_HERE_HOME/NONO_HERE_HARNESS for the person running +# this suite) to never leak into a case that means to test the unset +# state, and Case 4 specifically requires a genuinely-unset +# NONO_HERE_HARNESS ("no override"), not an empty one (a different code +# path reaching the same exit code by coincidence). +run_nh() { + local wd="$1" home="$2" nh="$3" harness="$4" record="$5" stdin="$6" out="$7" err="$8" + shift 8 + local rc=0 + local env_args=(-u NONO_HERE_HOME -u NONO_HERE_HARNESS -u RUN_HARNESS_RECORD) + env_args+=("HOME=$home") + [[ -n "$nh" ]] && env_args+=("NONO_HERE_HOME=$nh") + [[ -n "$harness" ]] && env_args+=("NONO_HERE_HARNESS=$harness") + [[ -n "$record" ]] && env_args+=("RUN_HARNESS_RECORD=$record") + ( + cd "$wd" && + env "${env_args[@]}" "$NONO_HERE" "$@" + ) <"$stdin" >"$out" 2>"$err" || rc=$? + echo "$rc" +} + +# ================================================================== +# Case 1: fast path execs existing run_harness.sh, args forwarded +# verbatim, including one containing a space. +# ================================================================== +case01() { + local id="1: fast path forwards argv verbatim (incl. arg with space)" + local fx wd home nh record rc + fx="$(new_fixture)" + wd="$fx/work" + home="$fx/home" + nh="$fx/nonohome" + mkdir -p "$wd" "$home" "$nh" "$wd/.sandbox" + write_stub_run_harness "$wd/run_harness.sh" + write_stub_start "$wd/.sandbox/start.sh" + record="$fx/record.bin" + + rc="$(run_nh "$wd" "$home" "$nh" "" "$record" /dev/null "$fx/out" "$fx/err" one "hello world" --flag)" + + if [[ "$rc" != "0" ]]; then + fail "$id" "expected exit 0, got $rc (stderr: $(cat "$fx/err"))" + return + fi + if ! read_argv "$record"; then + fail "$id" "record file missing/short: $record" + return + fi + if argv_eq one "hello world" --flag; then + pass "$id" + else + fail "$id" "argv mismatch: ${ARGV_RESULT[*]}" + fi +} + +# ================================================================== +# Case 2: non-executable run_harness.sh -> exit 2 +# ================================================================== +case02() { + local id="2: non-executable run_harness.sh -> exit 2" + local fx wd home nh rc + fx="$(new_fixture)" + wd="$fx/work" + home="$fx/home" + nh="$fx/nonohome" + mkdir -p "$wd" "$home" "$nh" + echo "not a real script" >"$wd/run_harness.sh" + chmod -x "$wd/run_harness.sh" + + rc="$(run_nh "$wd" "$home" "$nh" "" "" /dev/null "$fx/out" "$fx/err")" + + if [[ "$rc" == "2" ]]; then + pass "$id" + else + fail "$id" "expected exit 2, got $rc" + fi +} + +# ================================================================== +# Case 3: missing/non-executable start.sh -> exit 3 (fast path) +# ================================================================== +case03() { + local fx wd home nh rc + + # sub-case: start.sh missing entirely + fx="$(new_fixture)" + wd="$fx/work" + home="$fx/home" + nh="$fx/nonohome" + mkdir -p "$wd" "$home" "$nh" "$wd/.sandbox" + write_stub_run_harness "$wd/run_harness.sh" + rc="$(run_nh "$wd" "$home" "$nh" "" "" /dev/null "$fx/out" "$fx/err")" + if [[ "$rc" == "3" ]]; then + pass "3a: start.sh missing -> exit 3" + else + fail "3a: start.sh missing -> exit 3" "got $rc" + fi + + # sub-case: start.sh present but not executable + fx="$(new_fixture)" + wd="$fx/work" + home="$fx/home" + nh="$fx/nonohome" + mkdir -p "$wd" "$home" "$nh" "$wd/.sandbox" + write_stub_run_harness "$wd/run_harness.sh" + echo "not executable" >"$wd/.sandbox/start.sh" + chmod -x "$wd/.sandbox/start.sh" + rc="$(run_nh "$wd" "$home" "$nh" "" "" /dev/null "$fx/out" "$fx/err")" + if [[ "$rc" == "3" ]]; then + pass "3b: start.sh not executable -> exit 3" + else + fail "3b: start.sh not executable -> exit 3" "got $rc" + fi +} + +# ================================================================== +# Case 4: non-TTY with no NONO_HERE_HARNESS override -> exit 4 +# ================================================================== +case04() { + local fx wd home nh rc + + # 4a: NONO_HERE_HARNESS genuinely unset (env -u), the common user state + # the plan wording ("no override") describes. run_nh scrubs it via + # `env -u` when the harness argument is empty (S4/S5). + fx="$(new_fixture)" + wd="$fx/work" + home="$fx/home" + nh="$fx/nonohome" + mkdir -p "$wd" "$home" "$nh" + rc="$(run_nh "$wd" "$home" "$nh" "" "" /dev/null "$fx/out" "$fx/err")" + if [[ "$rc" == "4" ]]; then + pass "4a: non-TTY, NONO_HERE_HARNESS genuinely unset -> exit 4" + else + fail "4a: non-TTY, NONO_HERE_HARNESS genuinely unset -> exit 4" "got $rc" + fi + + # 4b: NONO_HERE_HARNESS explicitly exported as an empty string — a + # different route (the script's [[ -n ]] check is false either way) + # that must reach the same exit 4, not fall through undetected. + fx="$(new_fixture)" + wd="$fx/work" + home="$fx/home" + nh="$fx/nonohome" + mkdir -p "$wd" "$home" "$nh" + local rc2=0 + ( + cd "$wd" && + env -u NONO_HERE_HARNESS \ + HOME="$home" \ + NONO_HERE_HOME="$nh" \ + NONO_HERE_HARNESS="" \ + "$NONO_HERE" + ) "$fx/out" 2>"$fx/err" || rc2=$? + if [[ "$rc2" == "4" ]]; then + pass "4b: non-TTY, NONO_HERE_HARNESS explicitly empty -> exit 4" + else + fail "4b: non-TTY, NONO_HERE_HARNESS explicitly empty -> exit 4" "got $rc2" + fi +} + +# ================================================================== +# Case 5: invalid NONO_HERE_HARNESS -> exit 8, no prompt, nothing written +# ================================================================== +case05() { + local id="5: invalid NONO_HERE_HARNESS -> exit 8, nothing written" + local fx wd home nh rc + fx="$(new_fixture)" + wd="$fx/work" + home="$fx/home" + nh="$fx/nonohome" + mkdir -p "$wd" "$home" "$nh" + + rc="$(run_nh "$wd" "$home" "$nh" "no-such-harness" "" /dev/null "$fx/out" "$fx/err")" + + if [[ "$rc" != "8" ]]; then + fail "$id" "expected exit 8, got $rc" + return + fi + if [[ -e "$wd/.sandbox" || -e "$wd/run_harness.sh" ]]; then + fail "$id" "unexpected filesystem writes in workdir" + return + fi + pass "$id" +} + +# ================================================================== +# Case 6: template precedence across all four positions +# ================================================================== +case06_sub() { + local label="$1" harness="$2" want_marker="$3" + shift 3 + local positions=("$@") # list of position names to actually create: A B C D + local fx wd home nh record rc pos + fx="$(new_fixture)" + wd="$fx/work" + home="$fx/home" + nh="$fx/nonohome" + mkdir -p "$wd" "$home" "$nh" + record="$fx/record.bin" + + for pos in "${positions[@]}"; do + case "$pos" in + A) make_template "$home/.nono-here/templates/$harness" A ;; + B) make_template "$home/.nono-here/templates/default" B ;; + C) make_template "$nh/templates/$harness" C ;; + D) make_template "$nh/templates/default" D ;; + esac + done + + rc="$(run_nh "$wd" "$home" "$nh" "$harness" "$record" /dev/null "$fx/out" "$fx/err")" + + if [[ "$rc" != "0" ]]; then + fail "$label" "expected exit 0, got $rc (stderr: $(cat "$fx/err"))" + return + fi + if [[ ! -f "$wd/.sandbox/marker.txt" ]]; then + fail "$label" "marker.txt missing from .sandbox" + return + fi + local got + got="$(cat "$wd/.sandbox/marker.txt")" + if [[ "$got" == "$want_marker" ]]; then + pass "$label" + else + fail "$label" "expected marker $want_marker, got $got" + fi +} + +case06() { + local harness="codex" + case06_sub "6a: HOME/.nono-here/templates/\$harness wins" "$harness" A A B C D + case06_sub "6b: HOME/.nono-here/templates/default wins (no harness-specific)" "$harness" B B C D + case06_sub "6c: NONO_HERE_HOME/templates/\$harness wins (no HOME overrides)" "$harness" C C D + case06_sub "6d: NONO_HERE_HOME/templates/default is last resort" "$harness" D D +} + +# ================================================================== +# Case 7: no template found -> exit 5, all four paths in stderr +# ================================================================== +case07() { + local id="7: no template found -> exit 5, all four paths listed" + local fx wd home nh rc err_content + fx="$(new_fixture)" + wd="$fx/work" + home="$fx/home" + nh="$fx/nonohome" + mkdir -p "$wd" "$home" "$nh" + + rc="$(run_nh "$wd" "$home" "$nh" "codex" "" /dev/null "$fx/out" "$fx/err")" + + if [[ "$rc" != "5" ]]; then + fail "$id" "expected exit 5, got $rc" + return + fi + err_content="$(cat "$fx/err")" + local expect_paths=( + "$home/.nono-here/templates/codex" + "$home/.nono-here/templates/default" + "$nh/templates/codex" + "$nh/templates/default" + ) + local p + for p in "${expect_paths[@]}"; do + if [[ "$err_content" != *"$p"* ]]; then + fail "$id" "stderr missing path: $p" + return + fi + done + pass "$id" +} + +# ================================================================== +# Case 8: malformed template -> exit 7, pre-existing .sandbox untouched +# ================================================================== +case08_sub() { + local label="$1" mangle="$2" + local fx wd home nh rc digest_before digest_after + fx="$(new_fixture)" + wd="$fx/work" + home="$fx/home" + nh="$fx/nonohome" + mkdir -p "$wd" "$home" "$nh" "$wd/.sandbox" + echo "stale-sentinel-$RANDOM" >"$wd/.sandbox/sentinel.txt" + digest_before="$(dir_digest "$wd/.sandbox")" + + make_template "$nh/templates/default" + case "$mangle" in + missing_run_harness) rm "$nh/templates/default/run_harness.sh" ;; + missing_start) rm "$nh/templates/default/start.sh" ;; + start_not_exec) chmod -x "$nh/templates/default/start.sh" ;; + esac + + rc="$(run_nh "$wd" "$home" "$nh" "codex" "" /dev/null "$fx/out" "$fx/err")" + + if [[ "$rc" != "7" ]]; then + fail "$label" "expected exit 7, got $rc (stderr: $(cat "$fx/err"))" + return + fi + digest_after="$(dir_digest "$wd/.sandbox")" + if [[ "$digest_before" != "$digest_after" ]]; then + fail "$label" ".sandbox contents changed (digest mismatch)" + return + fi + pass "$label" +} + +case08() { + case08_sub "8a: template missing run_harness.sh -> exit 7, .sandbox intact" missing_run_harness + case08_sub "8b: template missing start.sh -> exit 7, .sandbox intact" missing_start + case08_sub "8c: template start.sh not executable -> exit 7, .sandbox intact" start_not_exec +} + +# ================================================================== +# Case 9: stale .sandbox, non-TTY -> exit 6, directory untouched +# ================================================================== +case09() { + local id="9: stale .sandbox, non-TTY -> exit 6, untouched" + local fx wd home nh rc before after + fx="$(new_fixture)" + wd="$fx/work" + home="$fx/home" + nh="$fx/nonohome" + mkdir -p "$wd" "$home" "$nh" "$wd/.sandbox" + echo "stale-sentinel-$RANDOM" >"$wd/.sandbox/sentinel.txt" + make_template "$nh/templates/default" + before="$(dir_digest "$wd/.sandbox")" + + rc="$(run_nh "$wd" "$home" "$nh" "codex" "" /dev/null "$fx/out" "$fx/err")" + + if [[ "$rc" != "6" ]]; then + fail "$id" "expected exit 6, got $rc" + return + fi + after="$(dir_digest "$wd/.sandbox")" + if [[ "$before" != "$after" ]]; then + fail "$id" ".sandbox contents changed (digest mismatch)" + return + fi + pass "$id" +} + +# ================================================================== +# Case 10: stale .sandbox, 'y' piped on stdin, no TTY -> still exit 6 +# ================================================================== +case10() { + local id="10: stale .sandbox, piped 'y' without TTY -> still exit 6" + local fx wd home nh rc before after stdin_file + fx="$(new_fixture)" + wd="$fx/work" + home="$fx/home" + nh="$fx/nonohome" + mkdir -p "$wd" "$home" "$nh" "$wd/.sandbox" + echo "stale-sentinel-$RANDOM" >"$wd/.sandbox/sentinel.txt" + make_template "$nh/templates/default" + before="$(dir_digest "$wd/.sandbox")" + stdin_file="$fx/stdin" + printf 'y\n' >"$stdin_file" + + rc="$(run_nh "$wd" "$home" "$nh" "codex" "" "$stdin_file" "$fx/out" "$fx/err")" + + if [[ "$rc" != "6" ]]; then + fail "$id" "expected exit 6, got $rc" + return + fi + after="$(dir_digest "$wd/.sandbox")" + if [[ "$before" != "$after" ]]; then + fail "$id" ".sandbox was modified despite no TTY (digest mismatch)" + return + fi + pass "$id" +} + +# ================================================================== +# Case 11: generated defaults.sh content matches selected harness +# ================================================================== +case11() { + local id="11: generated defaults.sh matches selected harness" + local fx wd home nh record rc content + fx="$(new_fixture)" + wd="$fx/work" + home="$fx/home" + nh="$fx/nonohome" + mkdir -p "$wd" "$home" "$nh" + make_template "$nh/templates/default" + record="$fx/record.bin" + + rc="$(run_nh "$wd" "$home" "$nh" "opencode" "$record" /dev/null "$fx/out" "$fx/err")" + + if [[ "$rc" != "0" ]]; then + fail "$id" "expected exit 0, got $rc (stderr: $(cat "$fx/err"))" + return + fi + if [[ ! -f "$wd/.sandbox/defaults.sh" ]]; then + fail "$id" "defaults.sh not generated" + return + fi + content="$(cat "$wd/.sandbox/defaults.sh")" + if [[ "$content" == *'SANDBOX_COMMAND="opencode"'* && "$content" == *'SANDBOX_COMMAND_DEFAULTS=()'* ]]; then + pass "$id" + else + fail "$id" "unexpected content: $content" + fi +} + +# ================================================================== +# Case 12: template-provided defaults.sh preserved verbatim +# ================================================================== +case12() { + local id="12: template-provided defaults.sh preserved verbatim" + local fx wd home nh record rc before after + fx="$(new_fixture)" + wd="$fx/work" + home="$fx/home" + nh="$fx/nonohome" + mkdir -p "$wd" "$home" "$nh" + make_template "$nh/templates/default" + cat >"$nh/templates/default/defaults.sh" <<'EOF' +# custom harness-provided defaults +SANDBOX_COMMAND="pi" +SANDBOX_COMMAND_DEFAULTS=("--custom-flag") +EOF + before="$(sha "$nh/templates/default/defaults.sh")" + record="$fx/record.bin" + + rc="$(run_nh "$wd" "$home" "$nh" "pi" "$record" /dev/null "$fx/out" "$fx/err")" + + if [[ "$rc" != "0" ]]; then + fail "$id" "expected exit 0, got $rc (stderr: $(cat "$fx/err"))" + return + fi + if [[ ! -f "$wd/.sandbox/defaults.sh" ]]; then + fail "$id" "defaults.sh missing after provisioning" + return + fi + after="$(sha "$wd/.sandbox/defaults.sh")" + if [[ "$before" != "$after" ]]; then + fail "$id" "defaults.sh content changed" + return + fi + pass "$id" +} + +# ================================================================== +# Case 13: run_harness.sh argv correctness with empty/unset/populated +# SANDBOX_COMMAND_DEFAULTS, under /bin/bash and any newer bash on PATH. +# (Regression test for Task 10.) +# ================================================================== +find_bashes() { + BASHES=() + local candidate resolved already existing + + for candidate in /bin/bash "$(command -v bash)" /opt/homebrew/bin/bash /usr/local/bin/bash; do + [[ -n "$candidate" && -x "$candidate" ]] || continue + resolved="$(cd "$(dirname "$candidate")" && pwd)/$(basename "$candidate")" + already=0 + for existing in "${BASHES[@]+"${BASHES[@]}"}"; do + [[ "$existing" == "$resolved" ]] && already=1 + done + ((already)) || BASHES+=("$resolved") + done + + # S2: never silently trust that Bash 3.2 coverage was exercised. Print + # the version of every binary under test, and if none is 3.2-era, + # disclose the degraded coverage loudly instead of printing passing + # lines that prove nothing about Task 10's regression. + local b ver have_32=0 + for b in "${BASHES[@]}"; do + ver="$("$b" --version | head -1)" + echo "case13: bash under test: $b -> $ver" + # Match 3.2 explicitly: a host with only Bash 3.0/3.1 does not verify + # the targeted macOS 3.2 behavior and must still trigger the warning. + if [[ "$ver" == *"version 3.2."* ]]; then + have_32=1 + fi + done + if ((! have_32)); then + echo "WARN: case13: no Bash 3.2-era binary found on PATH or at the usual" \ + "locations (/bin/bash, /opt/homebrew/bin/bash, /usr/local/bin/bash);" \ + "Task 10's Bash-3.2 empty-array regression coverage is DEGRADED to" \ + "${BASHES[*]} only on this host. This is a disclosed environment gap," \ + "not a passing 3.2 check." >&2 + fi +} + +# copy_named_array +# Bash 3.2 has no nameref; this copies an array given only its name, +# without ever expanding "${name[@]}" on a possibly-empty array (which +# traps under set -u on Bash 3.2 — the same class of bug this suite +# regression-tests in run_harness.sh). +copy_named_array() { + local __src="$1" __dst="$2" __n __i __val + eval "__n=\${#${__src}[@]}" + eval "$__dst=()" + for ((__i = 0; __i < __n; __i++)); do + eval "__val=\"\${${__src}[$__i]}\"" + eval "$__dst+=(\"\$__val\")" + done +} + +# case13_sub