From 3f927eb35c28fdc50970a1970560b5acd648aba2 Mon Sep 17 00:00:00 2001 From: Martin Kuckert Date: Wed, 2 Sep 2026 21:48:52 +0200 Subject: [PATCH 01/25] Makes run_harness more generic by moving SANDBOX_COMMAND to defaults file --- .sandbox/defaults.sh | 2 +- .sandbox/start.sh | 1 + run_harness.sh | 15 +++++++++++---- 3 files changed, 13 insertions(+), 5 deletions(-) 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/start.sh b/.sandbox/start.sh index 7f126a3..e2c0ffb 100755 --- a/.sandbox/start.sh +++ b/.sandbox/start.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euo pipefail +# VERSION 2 SELF=$(basename $BASH_SOURCE) WORKSPACE=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD") diff --git a/run_harness.sh b/run_harness.sh index a9438d8..79eddc2 100755 --- a/run_harness.sh +++ b/run_harness.sh @@ -1,13 +1,20 @@ #!/usr/bin/env bash set -euo pipefail +# VERSION 2 WORKSPACE=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD") +DEFAULTS_FILE="${DEFAULTS_FILE:-$WORKSPACE/.sandbox/defaults.sh}" -SANDBOX_COMMAND="${SANDBOX_COMMAND:-opencode}" -DEFAULTS_FILE="${CLOUD_DEFAULTS_FILE:-$WORKSPACE/.sandbox/defaults.sh}" +if [[ ! -f "$DEFAULTS_FILE" ]]; then + echo "missing defaults file $DEFAULTS_FILE" + exit 1 +fi -if [[ -f "$DEFAULTS_FILE" ]]; then - source "$DEFAULTS_FILE" +source "$DEFAULTS_FILE" +SANDBOX_COMMAND="${SANDBOX_COMMAND:-}" +if [[ "$SANDBOX_COMMAND" = "" ]]; then + echo "missing 'SANDBOX_COMMAND' in $DEFAULTS_FILE" + exit 2 fi SANDBOX_COMMAND_DEFAULTS=("${SANDBOX_COMMAND_DEFAULTS[@]:-}") From 441a15c0748ca23a4ba53cdad970dca7eca5e299 Mon Sep 17 00:00:00 2001 From: Martin Kuckert Date: Wed, 2 Sep 2026 21:53:59 +0200 Subject: [PATCH 02/25] Prepares nono-here template --- nono/nono-here.sh | 3 + nono/templates/default/.gitignore | 1 + nono/templates/default/hooks/after-template | 2 + nono/templates/default/hooks/before-template | 2 + nono/templates/default/profile.template.json | 35 +++++++++++ nono/templates/default/run_harness.sh | 30 ++++++++++ nono/templates/default/start.sh | 62 ++++++++++++++++++++ 7 files changed, 135 insertions(+) create mode 100755 nono/nono-here.sh create mode 100644 nono/templates/default/.gitignore create mode 100755 nono/templates/default/hooks/after-template create mode 100755 nono/templates/default/hooks/before-template create mode 100644 nono/templates/default/profile.template.json create mode 100755 nono/templates/default/run_harness.sh create mode 100755 nono/templates/default/start.sh diff --git a/nono/nono-here.sh b/nono/nono-here.sh new file mode 100755 index 0000000..2f95c3e --- /dev/null +++ b/nono/nono-here.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +# VERSION 2 diff --git a/nono/templates/default/.gitignore b/nono/templates/default/.gitignore new file mode 100644 index 0000000..ea000fc --- /dev/null +++ b/nono/templates/default/.gitignore @@ -0,0 +1 @@ +profile.json diff --git a/nono/templates/default/hooks/after-template b/nono/templates/default/hooks/after-template new file mode 100755 index 0000000..6e87746 --- /dev/null +++ b/nono/templates/default/hooks/after-template @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +set -euo pipefail diff --git a/nono/templates/default/hooks/before-template b/nono/templates/default/hooks/before-template new file mode 100755 index 0000000..6e87746 --- /dev/null +++ b/nono/templates/default/hooks/before-template @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +set -euo pipefail diff --git a/nono/templates/default/profile.template.json b/nono/templates/default/profile.template.json new file mode 100644 index 0000000..ddf694a --- /dev/null +++ b/nono/templates/default/profile.template.json @@ -0,0 +1,35 @@ +{ + "extends": ["default"], + "meta": { + "name": "NAME", + "version": "1" + }, + "workdir": { + "access": "readwrite" + }, + "filesystem": { + "allow": [], + "deny": [], + "read_file": ["~/.gitconfig", "~/.gitignore"], + "read": ["~/.CFUserTextEncoding"] + }, + "network": { + "allow_domain": [] + }, + "environment": { + "allow_vars": [ + "HOME", + "SHELL", + "TERM", + "PATH", + "LSCOLORS", + "LS_COLORS", + "LC_ALL", + "LANG", + "PWD", + "USER", + "EDITOR", + "DO_NOT_TRACK" + ] + } +} diff --git a/nono/templates/default/run_harness.sh b/nono/templates/default/run_harness.sh new file mode 100755 index 0000000..635cd0c --- /dev/null +++ b/nono/templates/default/run_harness.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# VERSION 2 +set -euo pipefail + +WORKSPACE=$(git rev-parse --show-toplevel 2>/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 + +SANDBOX_COMMAND_DEFAULTS=("${SANDBOX_COMMAND_DEFAULTS[@]:-}") + +if [[ $# -eq 0 || "$1" == -* ]]; then + set -- "${SANDBOX_COMMAND_DEFAULTS[@]}" "$@" +fi + +if [[ "$1" == "$SANDBOX_COMMAND" ]]; then + shift +fi + +.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..7f126a3 --- /dev/null +++ b/nono/templates/default/start.sh @@ -0,0 +1,62 @@ +#!/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: Couldn't find neither profile.json nor 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 + diff -u --color=always "$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 + 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 \ + -- "$@" From 2c32e4dec1e562a417534f9afd07127108a123b6 Mon Sep 17 00:00:00 2001 From: Martin Kuckert Date: Fri, 4 Sep 2026 21:32:49 +0200 Subject: [PATCH 03/25] chore: Adds plan for new nono-here script --- PLAN.md | 443 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..a77268b --- /dev/null +++ b/PLAN.md @@ -0,0 +1,443 @@ +# Plan: nono-here.sh — zero-config sandbox bootstrap for AI agent harnesses + +## Objective + +`nono/nono-here.sh` becomes a self-contained, relocatable entry point that can be invoked from +any directory. It resolves the workspace, and either (a) hands straight over to an already +provisioned `run_harness.sh`, or (b) interactively provisions `.sandbox/` from a template for a +chosen harness and then hands over. One command takes a cold repository to a running, +sandboxed agent. + +Secondary objective: fix a latent `set -u` argv bug in `templates/default/run_harness.sh`. + +## Requirements & Decisions + +- **Frameworks:** Plain Bash (`#!/usr/bin/env bash`, `set -euo pipefail`), consistent with all + existing scripts under `nono/`. External runtime dependency `nono` is checked by + `.sandbox/start.sh`, not by `nono-here.sh`. +- **Chosen Libraries:** None. No `rsync`, no `bats`, no `jq` added. Tests are plain Bash + (decision Q20b) to preserve the repo's dependency-averse style. +- **Error Handling Strategy:** Fail Loud, Never Fake. Every abort writes a `$SELF`-prefixed + message to stderr with a distinct exit code and, where a fix exists, names it (`chmod +x …`). + No silent defaults, no silent fallbacks, no destructive `-f`. The single interactive + destructive action (removing a stale `.sandbox`) requires explicit `y` confirmation. + +### Settled design decisions (interrogation record) + +| # | Decision | +|---|---| +| Q1/Q11/Q18 | `.sandbox` exists but `run_harness.sh` missing ⇒ warn, prompt `[y/N]`. On `y`: `rm -r` (**never** `-f`) the old `.sandbox`, then fresh copy. No backup directory. | +| Q2/Q12 | Executable bit is required on both `run_harness.sh` and `.sandbox/start.sh`; verified on the shortcut path *and* after a fresh copy. | +| Q3 | All args forwarded verbatim: `exec "$workdir/run_harness.sh" "$@"`. | +| Q4 | After provisioning, exec `run_harness.sh` immediately. | +| Q5 | Harness chosen via Bash `select`. Non-TTY stdin ⇒ abort (harnesses need a TTY anyway). | +| Q6/Q22 | `NONO_HERE_HARNESS` env var overrides the menu. Validated against the closed list; unknown value ⇒ abort. Reinstated after plan review: without it the entire provisioning path is untestable by construction, and routing around that with a test-only hook would mean shipped behaviour is never the tested behaviour. No positional arg or flag (both collide with Q3 arg forwarding). | +| Q7 | Closed harness list, held in one easily extended array at the top of the script. | +| Q8 | A template-provided `.sandbox/defaults.sh` is preserved; the stub is generated only when absent. | +| Q9 | No template found ⇒ abort, listing all four probed paths in order. | +| Q10 | `.sandbox/` and `run_harness.sh` are intended to be committed; the script never touches the workspace `.gitignore`. | +| Q13 | `cp -R "$template/." "$sandbox/"` — dotfile- and mode-preserving, portable. | +| Q14 | Generate `SANDBOX_COMMAND_DEFAULTS=()` **and** fix the `set -u` empty-array handling in the template's `run_harness.sh` — at the *use* site, not by re-assigning the array (see Task 10). Bump its `# VERSION`. | +| Q15 | Strict order: workdir → shortcut check → prompt → **template resolution + validation** → stale-`.sandbox` prompt → `rm -r` → copy → move → defaults → exec. Nothing is ever deleted before a valid replacement template has been located and validated. | +| Q16 | `NONO_HERE_HOME` defaults to the script's own resolved directory (symlinks followed), not a hardcoded `~/env/nono`. | +| Q17 | Workdir resolution reuses the existing idiom as-is; submodule/bare-repo quirks accepted. Resolved workdir is printed before acting. | +| Q19 | An existing workspace `run_harness.sh` is never moved or overwritten. Three paths only: executable ⇒ exec; non-executable ⇒ abort; absent ⇒ move template copy in. | +| Q21 | `# VERSION 2` marker retained in `nono-here.sh`. | + +### Exit code map + +| Code | Meaning | +|------|---------| +| 0 | Success (or `exec` handover) | +| 1 | Unexpected internal error — an uncaught `set -e` failure, or a `cd "$workdir"` that fails in `handover()` (R2-5). Not a user-facing contract. | +| 2 | Workspace `run_harness.sh` exists but is not executable (user-fixable: `chmod +x`) | +| 3 | Workspace `.sandbox/start.sh` missing or not executable (user-fixable: `chmod +x`) | +| 4 | Harness selection impossible: stdin is not a TTY and `NONO_HERE_HARNESS` is unset | +| 5 | No template directory found (all four candidates probed) | +| 6 | Stale `.sandbox` not replaced: user declined, or the run is non-interactive | +| 7 | Template is malformed: missing `run_harness.sh`/`start.sh`, or they lack `+x` | +| 8 | `NONO_HERE_HARNESS` set to a value outside the closed harness list | +| 9 | Workspace `run_harness.sh` path exists but is not a regular file (directory, dangling symlink, socket) | +| 10 | Harness selection aborted by the user (EOF/Ctrl-D at the `select` prompt) | + +Codes 2/3 denote a *workspace* defect the user can fix in place; code 7 denotes a *template* +defect (including missing exec bits detected after the copy in Task 7), so the two causes the +reviewer flagged as overloaded are now distinct. + + +## Implementation Steps + +> Status Markers: [ ] Open, [/] In Progress, [x] Completed (set after accepted review only!) + +- [ ] **Task 1: Script skeleton, self-location and workdir resolution** + - **Description:** Flesh out `nono/nono-here.sh` keeping `#!/usr/bin/env bash`, + `set -euo pipefail` and `# VERSION 2`. Add `SELF="$(basename "$0")"` and a + `die ` helper writing to stderr. Resolve the script's own directory by + following symlinks in a portable loop (no GNU `readlink -f`, no `realpath` — macOS + compatibility): iterate `while [[ -L $src ]]` resolving relative targets against their + parent. Set `NONO_HERE_HOME="${NONO_HERE_HOME:-$script_dir}"`. Resolve + `workdir=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")`. Echo it to stderr + **only when provisioning is about to occur** — never on the fast path, which is the common + case and must add no noise to the harness's own output (N13). + Note (N16): this is the `git rev-parse` idiom's third occurrence in the repo. Accepted + deliberately — the three scripts must stay independently executable, and a shared library + for one line would be over-engineering. Recorded so it is not re-flagged at code review. + - **Review Criteria:** `NONO_HERE_HOME` correctly derived when the script is invoked via a + symlink from another directory; an explicitly exported `NONO_HERE_HOME` wins; workdir is + the git root inside a repo and `$PWD` outside one; the fast path emits nothing on success. + +- [ ] **Task 2: Fast path — hand over to an existing `run_harness.sh`** + - **Description:** Test the workspace path in this order, so no case falls through + unhandled (B4): + 1. `[[ -e $workdir/run_harness.sh || -L $workdir/run_harness.sh ]]` but **not** + `[[ -f … ]]` ⇒ `die 9`. This covers a directory named `run_harness.sh`, a dangling + symlink and other non-regular files. Without this, Task 7's `mv` would move the + template file *into* such a directory — a silent, wrong success. + 2. `-f` but not `-x` ⇒ `die 2`, naming `chmod +x "$workdir/run_harness.sh"`. + 3. `-f` and `-x` ⇒ require `$workdir/.sandbox/start.sh` to exist and be executable, else + `die 3`. Then `handover "$@"` (Task 9). + 4. Nothing at that path ⇒ fall through to Task 3. This is the only branch that continues. + No prompting occurs on this path. + - **Review Criteria:** All four branches are reachable and tested; a directory named + `run_harness.sh` exits 9 and is never written into; args (including flags such as + `--resume` and args containing spaces) arrive verbatim; both exec-bit checks abort with + the documented codes and actionable messages. + +- [ ] **Task 3: Harness selection** + - **Description:** Declare `HARNESSES=(claude opencode codex copilot pi)` as a single + top-of-file array (the documented extension point). Resolution order: + 1. If `NONO_HERE_HARNESS` is set and non-empty, validate it against `HARNESSES`; on a + match use it and skip the menu, otherwise `die 8` printing the offending value and the + valid list. Never fall back to the menu on an invalid override — a typo'd value must + fail, not silently prompt. + 2. Else, if `[[ ! -t 0 ]]`, `die 4` listing the valid values and naming + `NONO_HERE_HARNESS` as the non-interactive route. + 3. Else present a Bash `select harness in "${HARNESSES[@]}"` menu with `PS3="harness> "`. + On invalid input the *result variable* `$harness` is empty while `$REPLY` holds the raw + input (S6 — the earlier description had this inverted); so: re-prompt with a warning + whenever `$harness` is empty, and `break` only once it is non-empty. `select` exits its + loop on EOF (Ctrl-D) leaving `$harness` unset — detect that after the loop and + `die 10`, never default. + - **Review Criteria:** Adding a harness is a one-token array edit with no other change; + a valid `NONO_HERE_HARNESS` produces a fully non-interactive run; an invalid one exits 8 + without prompting; invalid menu input re-prompts; Ctrl-D exits 10 and non-TTY-without- + override exits 4; nothing is written to or deleted from disk before a valid selection + exists. + +- [ ] **Task 4: Template resolution and validation** + - **Description:** Probe, in order and stopping at the first existing directory: + `$HOME/.nono-here/templates/$harness`, `$HOME/.nono-here/templates/default`, + `$NONO_HERE_HOME/templates/$harness`, `$NONO_HERE_HOME/templates/default`. If none exist, + `die 5` printing all four candidate paths in probe order, one per line. Echo the selected + template path to stderr. Validate that the template contains `run_harness.sh` and `start.sh` **and + that both are executable**; if not, `die 7` naming the template path and the offending + file. The exec-bit assertion must live *here*, not after the copy: a template with dropped + mode bits (the zip/checkout scenario) would otherwise pass validation, Task 5 would delete + the user's `.sandbox`, and the run would then abort — leaving neither a sandbox nor a + harness (R2-1). + - **Review Criteria:** Precedence honoured exactly, including the user-override-before- + bundled ordering; the not-found message is self-diagnosing (all four paths visible); both + the presence *and* the permission checks fire before any destructive or write operation — + verified by a test that makes a template's `start.sh` non-executable and asserts a + pre-existing `.sandbox` survives. + + +- [ ] **Task 5: Stale `.sandbox` handling** + - **Description:** Reached only when `run_harness.sh` is absent **and** a valid template has + already been resolved (Task 4) — so the deletion below can never leave the user with + neither a sandbox nor a replacement. If `$workdir/.sandbox` exists, warn (yellow, stderr, + matching `start.sh`'s ANSI style) that the sandbox is incomplete, name the template that + will replace it, and prompt + `delete .sandbox and re-create from