From 08f44f69d64745ae7dfba30260346974c42fc33d Mon Sep 17 00:00:00 2001 From: konpyl Date: Wed, 29 Jul 2026 23:16:35 +0400 Subject: [PATCH 1/2] fix(scripts): find Bun when Herdr invokes an action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `herdr plugin action invoke update` fails with "bun not found on PATH". Herdr spawns plugin actions with a minimal environment: no login shell, so nothing has sourced the line Bun's installer adds to your profile, and ~/.bun/bin is not on PATH. collie-ctl.sh resolved Bun with `command -v bun` alone, so every action needing it died. What makes this easy to miss is how it fails rather than that it fails. The systemd unit is written with an ABSOLUTE ExecStart, so the running bridge is unaffected — only plugin-invoked actions break. And `update` pulls BEFORE it builds, so a failed run still fast-forwards the checkout: you end up with new code on disk, the OLD web/dist still being served, and both the manifest and /api/config reporting the new version. A half-succeeded deploy is indistinguishable from a working one unless you read the plugin log. It failed this way four times on my deployment before I noticed. Bun is now resolved from PATH first, then $BUN_INSTALL, ~/.bun/bin, ~/.local/bin, /usr/local/bin, /opt/homebrew/bin and /usr/bin, and its directory is prepended to PATH rather than only remembered in $BUN — this script shells out to a bare `bun` for the Tailscale ownership probe, and `bun run build` spawns children that expect to find it themselves. An empty result stays non-fatal, so the existing "bun not found" messages still report and exit. Verified in a reproduction of the failing environment (`env -i` with PATH=/usr/bin:/bin), and end to end on a real deployment: the same action that had been failing now completes, because `update` re-execs the just-pulled script and so repairs itself on the first run. Co-Authored-By: Claude Opus 5 --- scripts/collie-ctl.sh | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/scripts/collie-ctl.sh b/scripts/collie-ctl.sh index 4946164..543b4dd 100755 --- a/scripts/collie-ctl.sh +++ b/scripts/collie-ctl.sh @@ -44,7 +44,46 @@ SERVE_MODE="${COLLIE_SERVE_MODE:-https}" # Records the ONE `tailscale serve` root mount Collie published, so teardown can prove the mapping # it is about to remove is still the one it created. Format: `:||`. TAILSCALE_HANDLER_FILE="${CONFIG_DIR}/tailscale-managed-handler" -BUN="$(command -v bun || true)" +# Find Bun on PATH, then in the usual install locations. +# +# Herdr spawns plugin actions with a minimal environment — no login shell, so nothing has sourced the +# line `bun` puts in your profile and `~/.bun/bin` is simply absent from PATH. `update` therefore +# pulled the new commit and then failed its build, leaving the checkout AHEAD of the web/dist being +# served while every version string reported the new release. The systemd unit is written with an +# absolute ExecStart, so the running service kept working and the breakage was visible only in the +# plugin log — which is how it went unnoticed across four separate invocations. +# +# An empty result is still fine: callers already report "bun not found" and exit. +resolve_bun() { + local candidate + if candidate="$(command -v bun 2>/dev/null)"; then + printf '%s' "$candidate" + return 0 + fi + for candidate in \ + "${BUN_INSTALL:-${HOME}/.bun}/bin/bun" \ + "${HOME}/.bun/bin/bun" \ + "${HOME}/.local/bin/bun" \ + /usr/local/bin/bun \ + /opt/homebrew/bin/bun \ + /usr/bin/bun; do + if [ -x "$candidate" ]; then + printf '%s' "$candidate" + return 0 + fi + done + return 0 +} +BUN="$(resolve_bun)" +# Put it on PATH too, not just in $BUN: this script shells out to a bare `bun` (the Tailscale +# ownership probe), and `bun run build` spawns children that expect to find it themselves. +if [ -n "$BUN" ]; then + BUN_DIR="$(dirname "$BUN")" + case ":${PATH}:" in + *":${BUN_DIR}:"*) ;; + *) PATH="${BUN_DIR}:${PATH}"; export PATH ;; + esac +fi WEB_DIST="${PLUGIN_ROOT}/web/dist/index.html" have_systemd() { command -v systemctl >/dev/null && systemctl --user show-environment >/dev/null 2>&1; } From 4841e377207ca8ff34dc5ac456ea9000f6b42fee Mon Sep 17 00:00:00 2001 From: Altan Sarisin Date: Wed, 29 Jul 2026 23:57:57 +0200 Subject: [PATCH 2/2] fix(scripts): only an absolute Bun path may reach PATH, and pin it in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes on top of the Bun resolution fix. `command -v` reports a shell function or alias as a bare word, and the plugin .env is sourced before we resolve — so a `bun()` left in there resolved to `bun`, whose dirname is `.`. Prepending that handed every later `git` / `systemctl` / `tailscale` a cwd-relative lookup. Restrict the prepend to absolute paths; an empty or bare result now falls through to the existing guards unchanged. The control script's resolution had no coverage at all — the lifecycle suite stubs `BUN=/bin/true` everywhere — which is why a missing Bun could fail four deploys unnoticed. Three tests: resolution reaching into $HOME when PATH holds no bun (and $BUN_INSTALL outranking it), the directory reaching children on PATH, the CWD never doing so, and the empty case still reporting and exiting non-zero. They fail against the unpatched script with the original `error: bun not found on PATH`. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/collie-ctl.sh | 20 ++++++--- scripts/collie-ctl.test.sh | 92 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/scripts/collie-ctl.sh b/scripts/collie-ctl.sh index 543b4dd..0cd3674 100755 --- a/scripts/collie-ctl.sh +++ b/scripts/collie-ctl.sh @@ -77,13 +77,19 @@ resolve_bun() { BUN="$(resolve_bun)" # Put it on PATH too, not just in $BUN: this script shells out to a bare `bun` (the Tailscale # ownership probe), and `bun run build` spawns children that expect to find it themselves. -if [ -n "$BUN" ]; then - BUN_DIR="$(dirname "$BUN")" - case ":${PATH}:" in - *":${BUN_DIR}:"*) ;; - *) PATH="${BUN_DIR}:${PATH}"; export PATH ;; - esac -fi +# +# ABSOLUTE paths only. `command -v` reports a shell function or alias as a bare word, and the plugin +# .env is sourced above us — so a `bun()` defined there would resolve to `bun`, whose dirname is `.`, +# and we'd prepend the CWD to the PATH used for every later `git` / `systemctl` / `tailscale`. +case "$BUN" in + /*) + BUN_DIR="$(dirname "$BUN")" + case ":${PATH}:" in + *":${BUN_DIR}:"*) ;; + *) PATH="${BUN_DIR}:${PATH}"; export PATH ;; + esac + ;; +esac WEB_DIST="${PLUGIN_ROOT}/web/dist/index.html" have_systemd() { command -v systemctl >/dev/null && systemctl --user show-environment >/dev/null 2>&1; } diff --git a/scripts/collie-ctl.test.sh b/scripts/collie-ctl.test.sh index 4986657..be779e7 100755 --- a/scripts/collie-ctl.test.sh +++ b/scripts/collie-ctl.test.sh @@ -321,10 +321,102 @@ EOF assert_contains "$(cat "${CASE_DIR}/start.out")" 'BANNER' } +# A bun that reports only how it was found: its own path, and the PATH it inherited. +install_fake_bun() { + local target="$1" calls="$2" + mkdir -p "$(dirname "$target")" + cat > "$target" < "$calls" +exit 0 +EOF + chmod +x "$target" +} + +# Herdr spawns plugin actions with a minimal environment — no login shell, so ~/.bun/bin is simply +# absent from PATH and resolving with \`command -v bun\` alone found nothing. Because \`update\` pulls +# BEFORE it builds, that left the checkout ahead of the web/dist still being served while every +# version string reported the new release. Pin both halves of the fix: which Bun gets chosen, and +# that its directory reaches child processes on PATH (the Tailscale ownership probe, and the children +# `bun run build` spawns, look up a bare `bun` themselves). +test_bun_resolution() { + setup_case bun-resolution + ln -s "$(command -v dirname)" "${BIN_DIR}/dirname" + local calls="${CASE_DIR}/calls" + + install_fake_bun "${HOME_DIR}/.bun/bin/bun" "$calls" + # PATH holds no bun at all — this IS the Herdr-action environment. Resolution has to reach into + # $HOME, and the fixture wins over any real bun in /usr/bin because ~/.bun/bin is tried first. + # $BUN_INSTALL is scrubbed: a developer running these tests from a shell where Bun's installer + # exported it would otherwise resolve their REAL bun and the fixture would never be consulted. + env -u BUN_INSTALL HOME="$HOME_DIR" HERDR_PLUGIN_CONFIG_DIR="$CONFIG_DIR" PATH="$BIN_DIR" \ + /bin/bash "$CTL" push-test + assert_eq "$(cut -d'|' -f1 "$calls")" "${HOME_DIR}/.bun/bin/bun" + case "$(cut -d'|' -f2- "$calls")" in + "${HOME_DIR}/.bun/bin:"*) ;; + *) fail "resolved Bun's directory never reached children on PATH" ;; + esac + + # $BUN_INSTALL is the operator's explicit choice, so it outranks the default ~/.bun. + install_fake_bun "${CASE_DIR}/alt/bin/bun" "$calls" + HOME="$HOME_DIR" HERDR_PLUGIN_CONFIG_DIR="$CONFIG_DIR" PATH="$BIN_DIR" \ + BUN_INSTALL="${CASE_DIR}/alt" /bin/bash "$CTL" push-test + assert_eq "$(cut -d'|' -f1 "$calls")" "${CASE_DIR}/alt/bin/bun" +} + +# `command -v` reports a function or alias as a BARE word, and the plugin .env is sourced before we +# resolve — so a `bun()` defined there yields dirname `.`, and prepending that would hand every later +# `git` / `systemctl` / `tailscale` a cwd-relative lookup. Only absolute paths reach PATH. +test_non_absolute_bun_never_reaches_path() { + setup_case bun-not-absolute + local harness="${CASE_DIR}/harness.sh" + cat > "$harness" < "${CASE_DIR}/path.out" 2>&1 || + fail "sourcing the script with a bun function failed" + case "$(cat "${CASE_DIR}/path.out")" in + *"PATH=.:"*|*":.:"*) fail "a non-absolute Bun put the CWD on PATH" ;; + esac +} + +# An empty resolution must still be reported and exit non-zero — that message is all an operator on a +# host genuinely without Bun gets, and it's what stops a build from half-finishing. +test_missing_bun_still_reports() { + setup_case bun-missing + local harness="${CASE_DIR}/harness.sh" + cat > "$harness" < "${CASE_DIR}/build.out" 2>&1 + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "cmd_build with no Bun reported success" + assert_contains "$(cat "${CASE_DIR}/build.out")" 'bun not found' +} + test_tailscale_cutovers_and_collisions test_missing_tailscale_cli test_state_delete_failures test_adopts_preexisting_collie_mount test_serve_failure_does_not_abort_start +test_bun_resolution +test_non_absolute_bun_never_reaches_path +test_missing_bun_still_reports echo "collie-ctl lifecycle tests: passed"