#!/bin/sh # AgentPanes optional notifications, user-scoped installation. No sudo. set -eu umask 077 VERSION=0.1.4 # Dedicated R2 custom domain; release objects must be verified before publishing this script. DOWNLOAD_BASE=https://downloads.agentpanes.com/notifications relay_url= subscription= pair_code= update=false dry_run=false usage() { echo 'AgentPanes optional notification helper (user-scoped, no sudo).' echo 'First install: use the pairing command in Machine → Notifications.' echo 'Update: rerun this script with --update. Existing pairings/consent are preserved.' echo 'Inspect: --dry-run prints paths without downloading, pairing, or writing.' echo 'No shell profile, SSH settings, agent config, inbound ports, or login policy changes.' echo 'Updates are manual. Installation starts only our own background user service.' } while [ "$#" -gt 0 ]; do case "$1" in --help|-h) usage; exit 0 ;; --update) update=true; shift ;; --dry-run) dry_run=true; shift ;; --relay) relay_url=${2:?Missing relay}; shift 2 ;; --subscription) subscription=${2:?Missing subscription}; shift 2 ;; --pair-code) pair_code=${2:?Missing one-time pairing code}; shift 2 ;; *) echo 'Use the complete installation command from AgentPanes.' >&2; exit 2 ;; esac done if [ "$update" = true ]; then [ -z "$relay_url$subscription$pair_code" ] || { echo 'Use --update without pairing arguments.' >&2; exit 2; } elif [ "$dry_run" = false ]; then [ -n "$relay_url" ] && [ -n "$subscription" ] && [ -n "$pair_code" ] || { echo 'Pairing details are missing. Generate a command in AgentPanes.' >&2; exit 2; } fi case "${HOME:-}" in /|''|*[![:print:]]*) echo 'A valid user home directory is required.' >&2; exit 1 ;; /*) ;; *) exit 1 ;; esac case "$HOME" in *'%'*|*'"'*|*'\'*) echo 'This home path is not supported by the service installer.' >&2; exit 1 ;; esac [ "$(id -u)" != 0 ] || { echo 'Run as the intended SSH user, without sudo or root.' >&2; exit 1; } [ "$(cd "$HOME" && pwd -P)" = "$HOME" ] || { echo 'Use a physical, non-symlink home path.' >&2; exit 1; } install_root="$HOME/.local/share/agentpanes-notify" settings_root="$HOME/.config/agentpanes-notify" case "$(uname -s)" in Linux) service_file="$HOME/.config/systemd/user/agentpanes-notify.service" ;; Darwin) service_file="$HOME/Library/LaunchAgents/com.agentpanes.notify.plist" ;; *) echo 'Unsupported service manager.' >&2; exit 1 ;; esac # Refuse symlink components (including dangling links) before any writes. # Atomic rename below also avoids truncating an existing hard-linked file. safe_path() { checked=$1 [ ! -L "$checked" ] && { [ ! -e "$checked" ] || [ -f "$checked" ]; } || { echo "Refusing non-regular or symlink file: $checked" >&2; exit 1; } checked=${checked%/*} while [ "$checked" != "$HOME" ]; do [ ! -L "$checked" ] && { [ ! -e "$checked" ] || [ -d "$checked" ]; } || { echo "Refusing non-directory or symlink path: $checked" >&2; exit 1; } checked=${checked%/*} done } for path in "$service_file" "$install_root/.owner" "$install_root/agentpanes-notify" "$install_root/uninstall.sh" "$install_root/THIRD-PARTY-NOTICES.txt" "$settings_root/config.json" "$settings_root/state.json" "$settings_root/run.lock"; do safe_path "$path" done owned=false if [ -f "$install_root/.owner" ]; then [ "$(head -n 1 "$install_root/.owner")" = agentpanes-notify-user-install-v1 ] || { echo 'Unrecognized installation marker. Nothing changed.' >&2; exit 1; } owned=true else for path in "$service_file" "$install_root/agentpanes-notify" "$install_root/uninstall.sh" "$install_root/THIRD-PARTY-NOTICES.txt" "$settings_root/config.json" "$settings_root/state.json" "$settings_root/run.lock"; do [ ! -e "$path" ] || { echo "Refusing to replace an unmarked existing file: $path" >&2; exit 1; } done fi if [ "$dry_run" = true ]; then usage printf 'Release: %s\nFiles: %s\nPairing/state: %s\nUser service: %s\n' "$VERSION" "$install_root" "$settings_root" "$service_file" exit 0 fi if [ "$update" = true ]; then [ "$owned" = true ] && [ -f "$settings_root/config.json" ] && [ -x "$install_root/agentpanes-notify" ] || { echo 'No managed, paired installation found. Use the command from the app first.' >&2; exit 1; } fi case "$(uname -s):$(uname -m)" in Linux:x86_64) platform=x86_64-unknown-linux-musl ;; Linux:aarch64|Linux:arm64) platform=aarch64-unknown-linux-musl ;; Darwin:arm64) platform=aarch64-apple-darwin ;; Darwin:x86_64) platform=x86_64-apple-darwin ;; *) echo 'This machine architecture is not supported yet.' >&2; exit 1 ;; esac command -v curl >/dev/null || { echo 'curl is required.' >&2; exit 1; } # Check the actual user service domain before downloading or consuming a # single-use pairing. Never enable linger or change login policy silently. if [ "$(uname -s)" = Linux ]; then command -v systemctl >/dev/null && systemctl --user show-environment >/dev/null 2>&1 || { echo 'A running systemd user session is required. Nothing was paired or installed. Log in as the intended SSH user and retry.' >&2; exit 1; } else user_id=$(id -u) launchctl print "gui/$user_id" >/dev/null 2>&1 || { echo 'A macOS desktop login is required for this user LaunchAgent. Nothing was paired or installed. Log in to the Mac desktop as this user and retry.' >&2; exit 1; } fi mkdir -p "$install_root" chmod 700 "$install_root" mkdir "$install_root/.install-lock" 2>/dev/null || { echo 'Another install/removal may be running. Nothing changed.' >&2; exit 1; } download_tmp= service_tmp= backup_tmp= # `replaced` becomes true once the live helper binary has been swapped; `committed` once a known-good # state is reached (fresh start OR a successful rollback); `handled` once an explicit failure branch # has decided the outcome (so the cleanup safety net does not roll back twice). replaced=false committed=false handled=false cleanup() { if [ -n "$download_tmp" ]; then rm -f "$download_tmp/agentpanes-notify" "$download_tmp/uninstall.sh" "$download_tmp/THIRD-PARTY-NOTICES.txt" "$download_tmp/checksum" "$download_tmp/owner" rmdir "$download_tmp" 2>/dev/null || true fi [ -z "$service_tmp" ] || rm -f "$service_tmp" # A replaced-but-uncommitted helper (an unexpected error or a signal that bypassed the explicit # handlers) must never be left live without recovery: try to roll back here, and keep the backup as # evidence if that fails. Only allowlisted backup files are ever removed — never a recursive delete. if [ "$replaced" = true ] && [ "$committed" != true ] && [ "$handled" != true ]; then if restore_previous_helper; then committed=true; fi fi if [ -n "$backup_tmp" ] && { [ "$committed" = true ] || [ "$replaced" = false ]; }; then rm -f "$backup_tmp/agentpanes-notify" "$backup_tmp/service" rmdir "$backup_tmp" 2>/dev/null || true fi rmdir "$install_root/.install-lock" 2>/dev/null || true } trap cleanup EXIT trap 'exit 130' HUP INT TERM # Actual running PID of our named user service, or nonzero exit when it is not running. A # `launchctl print` (or a loaded unit) can exist for a job that is crash-looping or never started, # so success is judged by a real PID, not mere load state. service_pid() { if [ "$(uname -s)" = Linux ]; then sp=$(systemctl --user show -p MainPID --value agentpanes-notify.service 2>/dev/null) || return 1 else # Capture the full output AND its exit status BEFORE parsing: a failing `launchctl print` # (manager error / job not loaded) must fail closed, never be masked by a downstream pipe. sp_out=$(launchctl print "gui/$(id -u)/com.agentpanes.notify" 2>/dev/null) || return 1 sp=$(printf '%s\n' "$sp_out" | sed -n 's/^[[:space:]]*pid = \([0-9][0-9]*\).*/\1/p' | head -n 1) fi case "${sp:-}" in ''|0|*[!0-9]*) return 1 ;; *) printf '%s\n' "$sp" ;; esac } # Require the named service to reach a STABLE running PID: a nonzero PID that is unchanged across a # short interval (a loaded-but-crashing/immediately-exiting job never holds one PID). Bounded, and # uses only the service manager's own PID field — no new runtime hooks or heartbeat parsing. wait_service_healthy() { wsh_prev=; wsh_n=0 while [ "$wsh_n" -lt 15 ]; do if wsh_cur=$(service_pid); then if [ -n "$wsh_prev" ] && [ "$wsh_prev" = "$wsh_cur" ]; then return 0; fi wsh_prev=$wsh_cur else wsh_prev= fi wsh_n=$((wsh_n + 1)) sleep 1 done return 1 } # The macOS user-service label. mac_target=com.agentpanes.notify # Bounded STOP of our macOS user service. `launchctl bootout` only REQUESTS an asynchronous unload: # it returns 0 while the job keeps unloading, and an immediate re-bootstrap then fails with an I/O # error (CLI status 5 / internal 37) while the previous instance lingers. So poll — never a fixed # sleep, since the unload delay is variable — until launchd reports the NAMED job absent. A missing # job is only trusted as "unloaded" when the gui DOMAIN itself is still reachable; if the domain # vanishes mid-poll (e.g., logout) we fail closed rather than mistake it for a completed unload. # Fails closed if it never unloads. macOS only. stop_macos_service() { sms_domain="gui/$(id -u)" sms_target="$sms_domain/$mac_target" launchctl bootout "$sms_target" >/dev/null 2>&1 || true sms_n=0 while [ "$sms_n" -lt 100 ]; do if launchctl print "$sms_target" >/dev/null 2>&1; then : # still loaded — keep waiting for the async unload elif launchctl print "$sms_domain" >/dev/null 2>&1; then return 0 # job absent AND the gui domain is healthy => fully unloaded else return 1 # the gui domain itself is unavailable => fail closed, never a false "unloaded" fi sms_n=$((sms_n + 1)); sleep 0.1 done return 1 } # Bounded START of our macOS user service: the gui domain must be reachable, then bootstrap with a # bounded retry (the domain can still briefly reject a bootstrap right after an unload settles, even # once the job is absent). Retries any failure (the surfaced CLI status is unreliable), not just a # specific code. Fails closed. macOS only. `service_file` is the plist written by the caller. start_macos_service() { smt_domain="gui/$(id -u)" launchctl print "$smt_domain" >/dev/null 2>&1 || return 1 smt_n=0 while [ "$smt_n" -lt 5 ]; do if launchctl bootstrap "$smt_domain" "$service_file" >/dev/null 2>&1; then return 0; fi smt_n=$((smt_n + 1)); sleep 0.2 done return 1 } # Restore the operation-owned backup of the previous helper (and its service definition, if it was # changed) and start ONLY our named user service. Returns success solely when the previous helper is # back AND running. The restore is atomic — staged in the target directory then renamed over the live # path, so a running binary is never truncated (no ETXTBSY) and unrelated hard links are preserved — # and the backup itself is kept intact so a failed restore still leaves recovery evidence. restore_previous_helper() { [ "$update" = true ] && [ -n "$backup_tmp" ] && [ -f "$backup_tmp/agentpanes-notify" ] || return 1 rb=$(mktemp "$install_root/restore.XXXXXX") || return 1 cp -p "$backup_tmp/agentpanes-notify" "$rb" && mv -f "$rb" "$install_root/agentpanes-notify" || { rm -f "$rb"; return 1; } if [ -f "$backup_tmp/service" ]; then rs=$(mktemp "$(dirname "$service_file")/.agentpanes-restore.XXXXXX") || return 1 cp -p "$backup_tmp/service" "$rs" && mv -f "$rs" "$service_file" || { rm -f "$rs"; return 1; } fi if [ "$(uname -s)" = Linux ]; then # A failed reload would leave the manager on the NEW unit definition; treat it as a rollback # failure (retain the backup) rather than falsely reporting the previous service as restored. systemctl --user daemon-reload >/dev/null 2>&1 || return 1 systemctl --user restart agentpanes-notify.service >/dev/null 2>&1 || return 1 systemctl --user is-active --quiet agentpanes-notify.service || return 1 wait_service_healthy else # Same asynchronous-bootout sequencing on the rollback path: unload the (failed) new instance # fully before bootstrapping the restored previous helper. stop_macos_service || return 1 start_macos_service || return 1 wait_service_healthy fi } # Write our named user service and bring ONLY it up. EVERY mutating operation is failure-handled with # `|| return 1` so a nonzero result routes to rollback instead of aborting the script under `set -e`. # Returns 0 only when the service is fully up; 2 means no user service manager is available. install_and_start_service() { if [ "$(uname -s)" = Linux ]; then command -v systemctl >/dev/null || return 2 service_dir="$HOME/.config/systemd/user" mkdir -p "$service_dir" || return 1 # Only our named user service is created/replaced. service_tmp=$(mktemp "$service_dir/.agentpanes-notify.XXXXXX") || return 1 cat > "$service_tmp" </dev/null && [ "$(loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null || true)" != yes ]; then echo 'This user session may stop after logout. Always-on operation requires an administrator to enable user lingering; the installer has not changed that policy.' fi return 0 else # launchd requires XML escaping even for legitimate home paths. xml_home=$(printf '%s' "$HOME" | sed 's/\&/\&/g; s//\>/g; s/"/\"/g') || return 1 service_dir="$HOME/Library/LaunchAgents" mkdir -p "$service_dir" || return 1 service_tmp=$(mktemp "$service_dir/.agentpanes-notify.XXXXXX") || return 1 cat > "$service_tmp" < Labelcom.agentpanes.notify ProgramArguments$xml_home/.local/share/agentpanes-notify/agentpanes-notifyrun EnvironmentVariablesPATH$xml_home/.local/bin:$xml_home/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/binHOME$xml_home RunAtLoadKeepAliveSuccessfulExit ThrottleInterval15 EOF mv -f "$service_tmp" "$service_file" || return 1 service_tmp= # `launchctl bootout` is asynchronous: fully stop any previous instance (poll until unloaded) # BEFORE bootstrapping, or the immediate bootstrap fails with an I/O error while it lingers. stop_macos_service || return 1 start_macos_service || return 1 # `launchctl print` succeeds for a loaded-but-crashing job; require a real, stable running PID. wait_service_healthy || return 1 return 0 fi } # Strict semantic version: EXACTLY three numeric components, each 1-9 digits (bounded so POSIX # integer comparison below can never overflow). Mirrors the mobile `parseVersion`. valid_version() { vv=$1 case "$vv" in *[!0-9.]*|*..*|.*|*.) return 1 ;; esac vv_major=${vv%%.*}; vv_rest=${vv#*.} case "$vv_rest" in *.*) ;; *) return 1 ;; esac # need a second dot vv_minor=${vv_rest%%.*}; vv_patch=${vv_rest#*.} case "$vv_patch" in *.*) return 1 ;; esac # but not a third for vv_c in "$vv_major" "$vv_minor" "$vv_patch"; do case "$vv_c" in ''|*[!0-9]*) return 1 ;; esac [ "${#vv_c}" -le 9 ] || return 1 done return 0 } # `version_ge A B` succeeds when A >= B, comparing the three numeric components. Both inputs MUST # have passed `valid_version` (so each component is a bounded integer). Mirrors the mobile compare # (numeric, so 0.1.10 > 0.1.9). version_ge() { vg_a=$1; vg_b=$2 for vg_i in 1 2 3; do vg_ah=${vg_a%%.*}; vg_bh=${vg_b%%.*} case "$vg_a" in *.*) vg_a=${vg_a#*.} ;; esac case "$vg_b" in *.*) vg_b=${vg_b#*.} ;; esac if [ "$vg_ah" -gt "$vg_bh" ]; then return 0; fi if [ "$vg_ah" -lt "$vg_bh" ]; then return 1; fi done return 0 } # No-downgrade / idempotence guard for --update, UNDER the install lock and BEFORE any download, # mutation, or service restart: read the CURRENTLY installed helper's version and refuse to replace # or restart it when it is already at or newer than this release. A helper that EXITS NONZERO, or # whose version is unreadable/unparseable, fails closed (never replace a helper we cannot compare). # Pairings/consent/state are never touched here — the app can then report a truthful "already up to # date". Exit 21 is the distinct no-op sentinel. Old app builds run --update only via the verified- # opens path; if that path is ever taken against an equal/newer helper that still lacks apr-v1, the # old wrapper reports a truthful generic failure and the helper is preserved (documented, not relied # upon as impossible). if [ "$update" = true ]; then if ! installed_out=$("$install_root/agentpanes-notify" --version 2>/dev/null); then echo 'The installed helper did not report its version. Nothing changed.' >&2; exit 1 fi installed_line=$(printf '%s\n' "$installed_out" | head -n 1) case "$installed_line" in 'agentpanes-notify '*) installed_version=${installed_line#agentpanes-notify } ;; *) echo 'Could not read the installed helper version. Nothing changed.' >&2; exit 1 ;; esac valid_version "$installed_version" || { echo 'Could not read the installed helper version. Nothing changed.' >&2; exit 1; } if version_ge "$installed_version" "$VERSION"; then echo "The installed helper $installed_version is already up to date (release $VERSION). Nothing changed." exit 21 fi fi download_tmp=$(mktemp -d "$install_root/download.XXXXXX") for artifact in agentpanes-notify uninstall.sh THIRD-PARTY-NOTICES.txt; do # Per-artifact download budget: the binary and the (multi-MB) third-party notices need a large size # ceiling; only the small uninstaller is tightly bounded. All fit the app's overall update budget # (see notifications/RELEASING.md). artifact_time=120; artifact_size=30000000 case "$artifact" in agentpanes-notify) base="$DOWNLOAD_BASE/v$VERSION/$platform" ;; uninstall.sh) base="$DOWNLOAD_BASE/v$VERSION"; artifact_time=30; artifact_size=1000000 ;; *) base="$DOWNLOAD_BASE/v$VERSION"; artifact_time=60 ;; esac curl --fail --silent --show-error --proto '=https' --tlsv1.2 --max-time "$artifact_time" --max-filesize "$artifact_size" \ "$base/$artifact" -o "$download_tmp/$artifact" curl --fail --silent --show-error --proto '=https' --tlsv1.2 --max-time 30 --max-filesize 256 \ "$base/$artifact.sha256" -o "$download_tmp/checksum" expected=$(awk 'NR==1 {print $1}' "$download_tmp/checksum") case "$expected" in *[!0-9a-f]*|'') echo 'Invalid release checksum.' >&2; exit 1 ;; esac [ "${#expected}" = 64 ] || { echo 'Invalid release checksum.' >&2; exit 1; } if command -v sha256sum >/dev/null; then actual=$(sha256sum "$download_tmp/$artifact" | awk '{print $1}') else actual=$(shasum -a 256 "$download_tmp/$artifact" | awk '{print $1}') fi [ "$actual" = "$expected" ] || { echo 'Download checksum did not match. Nothing was installed.' >&2; exit 1; } chmod 700 "$download_tmp/$artifact" done chmod 600 "$download_tmp/THIRD-PARTY-NOTICES.txt" # Verify executable architecture/version before replacing an existing helper. [ "$("$download_tmp/agentpanes-notify" --version)" = "agentpanes-notify $VERSION" ] || { echo 'Unexpected helper version.' >&2; exit 1; } printf '%s\n' agentpanes-notify-user-install-v1 > "$download_tmp/owner" mv -f "$download_tmp/owner" "$install_root/.owner" if [ "$update" = false ]; then "$download_tmp/agentpanes-notify" pair "$relay_url" "$subscription" "$pair_code" fi unset pair_code if [ "$update" = true ]; then # Stage the current, known-good helper (and its service definition) in an operation-owned backup # dir before any replacement, so a failed service start can be rolled back atomically. backup_tmp=$(mktemp -d "$install_root/backup.XXXXXX") || { echo 'Could not stage a safe update backup. Nothing changed.' >&2; exit 1; } cp -p "$install_root/agentpanes-notify" "$backup_tmp/agentpanes-notify" || { echo 'Could not stage the current helper for safe update. Nothing changed.' >&2; exit 1; } [ ! -f "$service_file" ] || cp -p "$service_file" "$backup_tmp/service" || { echo 'Could not stage the current service definition. Nothing changed.' >&2; exit 1; } fi mv -f "$download_tmp/agentpanes-notify" "$install_root/agentpanes-notify" replaced=true mv -f "$download_tmp/uninstall.sh" "$install_root/uninstall.sh" mv -f "$download_tmp/THIRD-PARTY-NOTICES.txt" "$install_root/THIRD-PARTY-NOTICES.txt" if install_and_start_service; then committed=true; handled=true else rc=$? handled=true if [ "$rc" = 2 ]; then echo 'Paired, but a user service manager is unavailable. Run ~/.local/share/agentpanes-notify/agentpanes-notify run manually.' >&2; exit 1 fi # A failed start AFTER the binary was replaced: roll back to the staged previous helper. Report the # restoration ONLY when a rollback actually succeeded (distinct exit 20 sentinel); otherwise keep the # backup as recovery evidence and report a truthful generic failure without claiming preservation. if restore_previous_helper; then committed=true echo 'The updated helper did not start, so your previous working helper was restored and is running. No pairing changed.' >&2; exit 20 elif [ "$update" = true ]; then echo "The updated helper did not start and could not be rolled back automatically. The previous helper is kept in $backup_tmp for recovery. No pairing changed." >&2; exit 1 else echo 'The helper did not start. Check the user service, then retry setup. Pairing files were retained for retry.' >&2; exit 1 fi fi echo 'AgentPanes notifications installed for this user. Return to the app to verify the heartbeat.' echo 'Disable delivery per phone in the app; uninstall instructions are in Machine → Notifications.'