diff --git a/Makefile b/Makefile index 6b5ff358..c0ae860d 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,11 @@ macos: macos-doctor macos-apply ./scripts/macos-bootstrap.sh osascript -e 'tell app "loginwindow" to «event aevtrrst»' +# One command for a new exe.dev VM as an @nonreagent host; see the script. +exe-box: + @test -n "$(NAME)" || { echo "usage: make exe-box NAME="; exit 1; } + ./scripts/new-exe-box.sh "$(NAME)" + macos-reset-dock: defaults write com.apple.dock persistent-apps -array killall Dock @@ -88,6 +93,7 @@ test: ./test/test_kitty.sh ./test/test_fish.sh ./test/test_macos_defaults.sh + ./test/test_exe_box.sh deploy: ./deploy.sh apply diff --git a/README.md b/README.md index 10a8da4d..109974da 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ make init-submodules make deploy ``` +# New exe.dev box + +`make exe-box NAME=` (run from the Mac, with `op` unlocked). It creates the VM or reuses one that answers ssh, hands @nonreagent's GitHub token to `gh` over ssh stdin, then clones or pulls [nonreagent/dotfiles](https://github.com/nonreagent/dotfiles) and runs its `install.sh` and `sync-plugins.sh`; design: `docs/superpowers/specs/2026-10-29-exe-box-design.md`. + # Clipboard bridge (exe.dev VMs) `make clipboard-bridge` loads `org.nonrational.clipboard-bridge`, a socket-activated launch agent on `127.0.0.1:2224` that answers image requests from the pasteboard and nothing else. `home/.ssh/config.d/exe.conf` reverse-forwards that port into every `*.exe.xyz` session and shares connections between windows; add `Include config.d/*.conf` as the first line of `~/.ssh/config` once. Inside a VM, Claude Code's ctrl+v then pastes whatever image is on the mac clipboard. The VM half is the `wl-paste` shim in [nonreagent/dotfiles](https://github.com/nonreagent/dotfiles); design and trust model: [clipboard bridge design](https://github.com/nonreagent/dotfiles/blob/main/docs/superpowers/specs/2026-09-07-clipboard-bridge-design.md). The include is global, so OpenSSH's permission check on it gates every `ssh`: if they all start failing with `Bad owner or permissions on ~/.ssh/config.d/exe.conf`, `chmod 644` the repo's copy. diff --git a/docs/superpowers/specs/2026-10-29-exe-box-design.md b/docs/superpowers/specs/2026-10-29-exe-box-design.md new file mode 100644 index 00000000..4e042421 --- /dev/null +++ b/docs/superpowers/specs/2026-10-29-exe-box-design.md @@ -0,0 +1,45 @@ +# One command for a new exe.dev box + +## `scripts/new-exe-box.sh`: one command for a new VM + +Lives in this repo because it runs from the Mac with the human's exe.dev key and 1Password session. exe.dev's `new` command offers `--setup-script` for first boot, but a first-boot run cannot be watched; the wrapper runs the provisioning itself over ssh instead, sequentially. + +``` +scripts/new-exe-box.sh # or make exe-box NAME= +``` + +1. Probe `ssh -o StrictHostKeyChecking=accept-new .exe.xyz true`. If the box answers, print `using existing .exe.xyz` and skip steps 2 and 3, so a re-run converges an existing box. +2. `ssh exe.dev new --name --json`. The box comes up bare; no `--setup-script`, so nothing runs before the wrapper can watch it. +3. Poll the same `true` probe until the box answers, within `EXE_BOX_WAIT` seconds (default 300), every `EXE_BOX_POLL` seconds (default 5). +4. Inject the token over stdin, never through `--env` or an argument, so it lands only in `gh`'s config on the VM, the same state an interactive login leaves: + + ``` + op read "op:////token" \ + | ssh .exe.xyz 'gh auth login --with-token && gh auth setup-git' + ``` + +5. Clone or update the dotfiles and install them, in one ssh call: + + ``` + ssh .exe.xyz '{ git -C ~/.dotfiles pull --ff-only || git clone ~/.dotfiles; } && ~/.dotfiles/install.sh && ~/.claude/sync-plugins.sh' + ``` + + The run is sequential and as the login user, so there is no first-boot race. A re-run pulls first and converges on the upstream of the VM checkout's current branch (not necessarily `main`); a pull that fails on an existing clone sends `git clone` into a non-empty directory, which fails and stops the chain. + The wrapper assumes the exeuntu image ships `git`, `gh` and `claude` (the exe.dev docs do not list the image's tools); B2's probe confirms it. +6. Print `ready: .exe.xyz https://.exe.xyz/`, then `optional: ssh .exe.xyz ~/bin/setup-review-watcher`. + +The script reads the token before creating anything: a bad reference costs a retry, not a half-provisioned VM. + +Configuration, all environment variables with defaults in the script: + +- `EXE_BOX_REPO_URL`: the repo to clone (default `https://github.com/nonreagent/dotfiles`). +- `EXE_BOX_GH_TOKEN_REF`: the 1Password item path (default `op://Private/nonreagent-github/token`). `op` unlocking is the only human touch. +- `EXE_BOX_WAIT` (300) and `EXE_BOX_POLL` (5): the wait loop's deadline and interval, in seconds. + +The same pipe can carry a `claude setup-token` value into the VM's environment file, which would close the expired-login failure the review watcher records. That is a separate decision: a long-lived Claude token is a larger blast radius than a fine-grained PAT scoped to nonreagent's repositories. This spec leaves it out; the wrapper is written so adding a second injected secret is one more line. + +Every ssh call passes `-o StrictHostKeyChecking=accept-new`: non-interactive ssh to the `exe.dev` lobby hangs on host-key prompts otherwise, as exe.dev's own skill file warns. + +## Tests + +`test/test_exe_box.sh` runs the script under a mktemp sandbox with stub `ssh` and `op` on `PATH`. It covers the create-wait-inject-install flow, reuse of an existing box, a bad token reference creating nothing, the token travelling on stdin only, the wait timeout, and the usage error. diff --git a/scripts/new-exe-box.sh b/scripts/new-exe-box.sh new file mode 100755 index 00000000..18c8cc9f --- /dev/null +++ b/scripts/new-exe-box.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# new-exe-box.sh : create (or reuse) an exe.dev VM and bring it up as an +# @nonreagent host in one go. Waits for ssh, hands @nonreagent's GitHub token +# from 1Password to gh over ssh stdin (never as an argument, never through +# exe.dev's --env or setup-script), then clones or pulls nonreagent/dotfiles +# and runs its install.sh and sync-plugins.sh. Runs from the Mac: it needs the +# human's exe.dev ssh key and an unlocked `op`. +# Spec: docs/superpowers/specs/2026-10-29-exe-box-design.md +set -euf -o pipefail + +name="${1:-}" +[ -n "$name" ] || { echo "usage: new-exe-box.sh " >&2; exit 2; } + +EXE_BOX_REPO_URL="${EXE_BOX_REPO_URL:-https://github.com/nonreagent/dotfiles}" +EXE_BOX_GH_TOKEN_REF="${EXE_BOX_GH_TOKEN_REF:-op://Private/nonreagent-github/token}" +EXE_BOX_WAIT="${EXE_BOX_WAIT:-300}" +EXE_BOX_POLL="${EXE_BOX_POLL:-5}" + +host="$name.exe.xyz" +# accept-new: a fresh box has an unknown host key, and a non-interactive ssh +# would otherwise hang on the prompt (exe.dev's own skill file warns of this). +ssh_opts="-o StrictHostKeyChecking=accept-new" + +# Read the token before creating anything: a bad reference should cost a +# retry, not a half-provisioned VM. Empty stdin would also send gh into its +# interactive device flow. +token="$(op read "$EXE_BOX_GH_TOKEN_REF")" +[ -n "$token" ] || { echo "error: $EXE_BOX_GH_TOKEN_REF is empty" >&2; exit 1; } + +# An existing box skips creation and the wait, so a re-run converges it. +# shellcheck disable=SC2086 +if ssh $ssh_opts -o ConnectTimeout=5 "$host" true 2>/dev/null; then + echo "using existing $host" +else + echo "creating $name" + # exe.dev reports errors (duplicate name, lobby) on stdout, so keep both + # streams and show them on failure. + # shellcheck disable=SC2086 + if ! new_out="$(ssh $ssh_opts exe.dev new --name "$name" --json 2>&1)"; then + echo "error: exe.dev new failed for $name:" >&2 + printf '%s\n' "$new_out" >&2 + exit 1 + fi + + echo "waiting for $host" + deadline=$(( $(date +%s) + EXE_BOX_WAIT )) + # shellcheck disable=SC2086 + until ssh $ssh_opts -o ConnectTimeout=5 "$host" true 2>/dev/null; do + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "error: $host did not answer within ${EXE_BOX_WAIT}s" >&2 + exit 1 + fi + sleep "$EXE_BOX_POLL" + done +fi + +echo "authenticating gh as @nonreagent" +# shellcheck disable=SC2086 +printf '%s\n' "$token" | ssh $ssh_opts "$host" 'gh auth login --with-token && gh auth setup-git' + +echo "installing dotfiles on $host" +# Pull first so a re-run converges on current main. A pull that fails on an +# existing clone falls into `git clone` on a non-empty directory, which fails +# and stops the chain. +# The URL is quoted for the remote shell. +repo_q="$(printf '%q' "$EXE_BOX_REPO_URL")" +# shellcheck disable=SC2086 +ssh $ssh_opts "$host" "{ git -C ~/.dotfiles pull --ff-only || git clone $repo_q ~/.dotfiles; } && ~/.dotfiles/install.sh && ~/.claude/sync-plugins.sh" + +echo "ready: $host https://$host/" +echo "optional: ssh $host ~/bin/setup-review-watcher" diff --git a/test/test_exe_box.sh b/test/test_exe_box.sh new file mode 100755 index 00000000..d569b17a --- /dev/null +++ b/test/test_exe_box.sh @@ -0,0 +1,175 @@ +#!/bin/bash +# Tests for scripts/new-exe-box.sh. Everything runs under a mktemp sandbox with +# stub commands on PATH, so no test touches the real $HOME, op, or exe.dev. +set -euf -o pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BASE="$(mktemp -d "${TMPDIR:-/tmp}/test-exe-box.XXXXXX")" +BASE="$(cd "$BASE" && pwd)" +trap 'rm -rf "$BASE"' EXIT + +pass=0 +fail=0 +sb_count=0 + +ok() { pass=$((pass + 1)); echo "PASS: $1"; } +bad() { fail=$((fail + 1)); echo "FAIL: $1"; } + +# Fresh sandbox: $FAKEHOME is $HOME, $SB/bin holds stubs, $SB/calls.log records +# every stub invocation as " ". +sandbox() { + sb_count=$((sb_count + 1)) + SB="$BASE/$sb_count" + FAKEHOME="$SB/home" + LOG="$SB/calls.log" + mkdir -p "$FAKEHOME" "$SB/bin" + : > "$LOG" +} + +# stub [stdout]: a command on PATH that logs its invocation, prints the +# given stdout, and exits $STUB_EXIT (default 1) when " " starts +# with any '|'-separated entry of $STUB_FAIL (e.g. STUB_FAIL='make -C /r +# macos-doctor|gh auth status'). +stub() { + cat > "$SB/bin/$1" <> "$LOG" +IFS='|' +for f in \${STUB_FAIL:-}; do + case "\$call" in "\$f"*) exit "\${STUB_EXIT:-1}" ;; esac +done +printf '%s\n' '${2:-}' +EOF + chmod +x "$SB/bin/$1" +} + + +# An ssh stub that logs each call, saves whatever arrived on stdin for the +# call that ran `gh auth login`, and fails ` true` the first +# $STUB_SSH_NOT_READY times (one counter across the existence probe and the +# wait loop). The default 0 answers at once, which takes the reuse path. +# $STUB_NEW_FAIL makes `exe.dev new` print that text on stdout and exit 1. +stub_ssh() { + cat > "$SB/bin/ssh" <> "$LOG" +case "\$*" in + *" true") + n=\$(cat "$SB/notready" 2>/dev/null || echo 0) + if [ "\$n" -lt "\${STUB_SSH_NOT_READY:-0}" ]; then echo \$((n + 1)) > "$SB/notready"; exit 255; fi + ;; + *"exe.dev new"*) + if [ -n "\${STUB_NEW_FAIL:-}" ]; then printf '%s\n' "\$STUB_NEW_FAIL"; exit 1; fi + ;; + *"gh auth login"*) cat > "$SB/token.stdin" ;; +esac +exit 0 +EOS + chmod +x "$SB/bin/ssh" +} + +exe_box() { + set +e + out="$(HOME="$FAKEHOME" PATH="$SB/bin:$PATH" EXE_BOX_POLL=0 EXE_BOX_WAIT="${EXE_BOX_WAIT:-5}" \ + EXE_BOX_REPO_URL="https://example.invalid/repo" \ + EXE_BOX_GH_TOKEN_REF="op://Vault/item/token" "$ROOT/scripts/new-exe-box.sh" "$@" 2>&1)" + status=$? + set -e +} + +INSTALL_CMD='{ git -C ~/.dotfiles pull --ff-only || git clone https://example.invalid/repo ~/.dotfiles; } && ~/.dotfiles/install.sh && ~/.claude/sync-plugins.sh' + +test_exe_box_creates_waits_injects_and_installs() { + sandbox; stub_ssh; stub op "tok-123" + STUB_SSH_NOT_READY=2 exe_box mybox + if [ "$status" -eq 0 ] \ + && grep -q '^ssh .*exe.dev new --name mybox --json$' "$LOG" \ + && [ "$(grep -c '^ssh .*mybox.exe.xyz true$' "$LOG")" -eq 3 ] \ + && grep -q '^op read op://Vault/item/token$' "$LOG" \ + && grep -q '^ssh .*mybox.exe.xyz gh auth login --with-token && gh auth setup-git$' "$LOG" \ + && grep -qxF "ssh -o StrictHostKeyChecking=accept-new mybox.exe.xyz $INSTALL_CMD" "$LOG" \ + && [ "$(tail -2 <<<"$out" | head -1)" = "ready: mybox.exe.xyz https://mybox.exe.xyz/" ] \ + && [ "$(tail -1 <<<"$out")" = "optional: ssh mybox.exe.xyz ~/bin/setup-review-watcher" ]; then + ok "new-exe-box creates the VM, waits for ssh, injects the token, installs, prints the URL" + else + bad "exe-box flow: status=$status out=$out log=$(cat "$LOG")" + fi +} + +test_exe_box_reuses_existing_box() { + sandbox; stub_ssh; stub op "tok-123" + exe_box mybox + if [ "$status" -eq 0 ] \ + && echo "$out" | grep -q 'using existing mybox.exe.xyz' \ + && ! grep -q 'exe.dev new' "$LOG" \ + && grep -qF "$INSTALL_CMD" "$LOG"; then + ok "new-exe-box reuses a box that already answers and still installs" + else + bad "reuse: status=$status out=$out log=$(cat "$LOG")" + fi +} + +test_bad_token_ref_creates_nothing() { + sandbox; stub_ssh; stub op + STUB_FAIL="op read" exe_box mybox + if [ "$status" -ne 0 ] && ! grep -q '^ssh' "$LOG"; then + ok "an unreadable token reference fails before any ssh call" + else + bad "bad token ref: status=$status out=$out log=$(cat "$LOG")" + fi +} + +test_token_goes_over_stdin_only() { + sandbox; stub_ssh; stub op "tok-123" + exe_box mybox + if [ "$(cat "$SB/token.stdin")" = "tok-123" ] && ! grep -q 'tok-123' "$LOG" && ! echo "$out" | grep -q 'tok-123'; then + ok "the token reaches ssh on stdin and appears in no argument or output" + else + bad "token: stdin=$(cat "$SB/token.stdin" 2>&1) log=$(cat "$LOG") out=$out" + fi +} + +test_exe_box_times_out_waiting() { + sandbox; stub_ssh; stub op "tok-123" + STUB_SSH_NOT_READY=1000000 EXE_BOX_WAIT=1 exe_box mybox + if [ "$status" -eq 1 ] && echo "$out" | grep -q 'did not answer within 1s' \ + && ! grep -q 'gh auth' "$LOG"; then + ok "a box that never answers ssh fails with a timeout without sending the secret" + else + bad "timeout: status=$status out=$out log=$(cat "$LOG")" + fi +} + +test_exe_box_new_failure_is_reported() { + sandbox; stub_ssh; stub op "tok-123" + STUB_SSH_NOT_READY=1000000 STUB_NEW_FAIL='{"error":"name taken"}' exe_box mybox + if [ "$status" -eq 1 ] && echo "$out" | grep -q 'name taken' && ! grep -q 'gh auth' "$LOG"; then + ok "a failed exe.dev new prints its output and stops before the secret" + else + bad "new failure: status=$status out=$out log=$(cat "$LOG")" + fi +} + +test_exe_box_requires_name() { + sandbox; stub_ssh; stub op + exe_box + if [ "$status" -ne 0 ] && echo "$out" | grep -q 'usage: new-exe-box.sh ' && ! grep -q '^ssh' "$LOG"; then + ok "new-exe-box without a name prints usage and creates nothing" + else + bad "usage: status=$status out=$out log=$(cat "$LOG")" + fi +} + +test_exe_box_creates_waits_injects_and_installs +test_exe_box_reuses_existing_box +test_bad_token_ref_creates_nothing +test_token_goes_over_stdin_only +test_exe_box_times_out_waiting +test_exe_box_new_failure_is_reported +test_exe_box_requires_name + +echo +echo "$pass passed, $fail failed" +[ "$fail" -eq 0 ]