diff --git a/install.sh b/install.sh index 0ec7d7b..7dc168c 100755 --- a/install.sh +++ b/install.sh @@ -9,6 +9,13 @@ set -e # Install: curl -fsSL https://pilotprotocol.network/install.sh | sh # Pin a version: curl -fsSL https://pilotprotocol.network/install.sh | sh -s -- --version v1.13.6 # Beta channel: curl -fsSL https://pilotprotocol.network/install.sh | sh -s -- --channel beta +# UDP blocked / curl -fsSL https://pilotprotocol.network/install.sh | sh -s -- --transport compat +# HTTPS proxy: (with a release that has transport "auto" nothing extra is +# needed: auto picks TLS/WSS over TCP 443, through +# $HTTPS_PROXY when set, where UDP does not work. Releases +# before auto stay on UDP unless given --transport compat, and +# use no proxy: the installer prints what to do instead. +# Proxy credentials that rotate: see PILOT_PROXY_CMD below) # Managed node: export PILOT_ENROLLMENT_TOKEN # enter it without putting it in shell history # sh install.sh --managed-url https://management.pilotprotocol.network # Uninstall: curl -fsSL https://pilotprotocol.network/install.sh | sh -s uninstall @@ -21,6 +28,16 @@ set -e # never silently falls back to an unverified source build. # --yes / -y Skip the older-version confirmation prompt. # --no-warn Suppress the older-version warning entirely. +# --transport auto (the default), udp or compat. udp and compat are +# saved as "transport" in ~/.pilot/config.json; auto is +# never saved (it is what `pilotctl daemon start` and the +# service units use when nothing is saved, and a daemon +# that predates auto would refuse it after a downgrade). +# auto: UDP when the beacon answers over UDP, else compat. +# compat: TLS/WSS over TCP 443 only, through +# $HTTPS_PROXY/$ALL_PROXY when set (CONNECT by hostname) — +# for UDP-blocked hosts and agent sandboxes whose only way +# out is an HTTPS proxy. # --managed-url # Install the checksum-pinned core managed runtime, claim # a one-time hosted identity, and start signed reporting. @@ -35,17 +52,38 @@ set -e # non-interactive/headless installs (no TTY prompt). # If omitted headless, the daemon auto-synthesizes a # @nodes.pilotprotocol.network identity. +# PILOT_TRANSPORT=compat Same as --transport compat. +# PILOT_PROXY_CMD= Saved as "proxy_cmd": a command printing the +# current proxy URL, for proxies that rotate their +# credentials. In a Linux container/VM without +# systemd whose HTTPS_PROXY carries credentials +# (hosted agent sandboxes such as Meta Muse), the +# installer saves one that reads a fresh shell's +# $https_proxy when none is set. The installer +# also uses it to retry a failed download. +# PILOT_ALLOW_ROOT=1 Install as root on a host with systemd/launchd, +# or under sudo/doas (not needed where the agent +# itself is root in a container/VM without systemd). # PILOT_MANAGEMENT_URL=https://management.example # Same as --managed-url. Requires the one-time # PILOT_ENROLLMENT_TOKEN on first adoption. # +# Proxies: every download is a curl HTTPS request, so HTTPS_PROXY / https_proxy / +# ALL_PROXY / NO_PROXY are honored (curl asks the proxy to CONNECT by hostname — +# no local DNS lookup of the target); a PILOT_PROXY http(s):// URL is used when +# none of those is set. Nothing here needs UDP, a non-443 port, or a direct +# connection to the registry/beacon. Proxy credentials are never printed or +# written to disk. Steps that need root, sudo, systemd or launchd are skipped +# with a message, never fatal. +# # WHAT THIS SCRIPT DOES (read before piping to sh): # 1. Detects OS/arch (Linux/Darwin × amd64/arm64) # 2. Resolves the latest release tag from github.com/pilot-protocol/pilotprotocol/releases # 3. Downloads the release tarball + checksums.txt from that release # 4. *** Verifies SHA-256 of the tarball against checksums.txt AND the signed # manifest (aborts on mismatch OR if it cannot verify — never extracts -# an unverified archive) *** +# an unverified archive; the manifest hashes the release it describes, +# so a --version pin or beta tag is checked against checksums.txt) *** # 5. Extracts binaries to ~/.pilot/bin (per-user, NOT system-wide) # 6. Adds ~/.pilot/bin to PATH in your shell profiles (~/.profile, ~/.bashrc, # ~/.zshenv, ~/.zshrc, ~/.bash_profile when it already exists) @@ -73,7 +111,9 @@ set -e # Network 9 directory and for receiving identifier-based deliveries. # # WHAT THIS SCRIPT DOES NOT DO: -# - Run as root (refuses if invoked as root; see check at line ~25) +# - Run as root on a host with systemd or launchd, or under sudo (refuses; +# see the root check below). A Linux container/VM without systemd, where +# the agent itself is root, installs into root's own $HOME/.pilot. # - Send any personal data anywhere (the install script only fetches the # release tarball from GitHub; the daemon registers its public key + a # synthetic or user-supplied email with the rendezvous server, nothing else) @@ -96,8 +136,13 @@ set -e # error. REPO="pilot-protocol/pilotprotocol" -REGISTRY="${PILOT_REGISTRY:-34.71.57.205:9000}" -BEACON="${PILOT_BEACON:-34.71.57.205:9001}" +# Production defaults — the same raw-TCP/UDP endpoints compiled into +# pilot-daemon. Compat mode must not pin them explicitly (see NET_FLAGS). +DEFAULT_REGISTRY="34.71.57.205:9000" +DEFAULT_BEACON="34.71.57.205:9001" +COMPAT_REGISTRY="registry.pilotprotocol.network:443" +REGISTRY="${PILOT_REGISTRY:-$DEFAULT_REGISTRY}" +BEACON="${PILOT_BEACON:-$DEFAULT_BEACON}" PILOT_DIR="$HOME/.pilot" BIN_DIR="$PILOT_DIR/bin" MANAGED_CONTROL_PATH="$PILOT_DIR/managed/enterprise-control.json" @@ -146,6 +191,7 @@ PILOT_NO_WARN=0 PILOT_MANAGED_NO_START=0 PILOT_MANAGEMENT_URL="${PILOT_MANAGEMENT_URL:-}" PILOT_POSITIONAL="" +PILOT_REQUESTED_TRANSPORT="" while [ $# -gt 0 ]; do case "$1" in @@ -159,6 +205,11 @@ while [ $# -gt 0 ]; do PILOT_REQUESTED_CHANNEL="$2"; shift 2 ;; --channel=*) PILOT_REQUESTED_CHANNEL="${1#--channel=}"; shift ;; + --transport) + if [ $# -lt 2 ]; then echo "Error: --transport requires a value" >&2; exit 2; fi + PILOT_REQUESTED_TRANSPORT="$2"; shift 2 ;; + --transport=*) + PILOT_REQUESTED_TRANSPORT="${1#--transport=}"; shift ;; --yes|-y) PILOT_YES=1; shift ;; --no-warn) @@ -171,7 +222,9 @@ while [ $# -gt 0 ]; do --no-start) PILOT_MANAGED_NO_START=1; shift ;; -h|--help) - sed -n '4,32p' "$0" 2>/dev/null || echo "See https://pilotprotocol.network/install.sh" + # The usage header: from line 4 up to "WHAT THIS SCRIPT DOES". + awk 'NR >= 4 { if (/^# WHAT THIS SCRIPT DOES/) exit; print }' "$0" 2>/dev/null \ + || echo "See https://pilotprotocol.network/install.sh" exit 0 ;; --) shift @@ -264,17 +317,63 @@ if [ -n "$PILOT_REQUESTED_CHANNEL" ] \ exit 2 fi +# --transport beats the PILOT_TRANSPORT env var. Empty means "not requested on +# this run": a re-run keeps whatever transport config.json already has. +TRANSPORT="$(printf '%s' "${PILOT_REQUESTED_TRANSPORT:-${PILOT_TRANSPORT:-}}" | tr '[:upper:]' '[:lower:]')" +case "$TRANSPORT" in + ""|udp|compat|auto) ;; + *) + echo "Error: --transport must be 'udp', 'compat' or 'auto' (got: $TRANSPORT)" >&2 + exit 2 ;; +esac + # Restore positional args so the existing uninstall handler still uses $1. # shellcheck disable=SC2086 # intentional word-split on PILOT_POSITIONAL set -- $PILOT_POSITIONAL -# Refuse to run as root — daemon must run as the invoking user so identity.json -# and received files land under that user's home, not /root. +# Refuse to run as root on a regular host — the daemon must run as the +# invoking user so identity.json and received files land under that user's +# home, not /root. A Linux container or VM without systemd (CI runners, +# hosted agent sandboxes such as Meta Muse, where the agent IS root) has no +# other user to install for and no system service to protect, so root is +# allowed there — but only when root is who runs it. Root reached through +# sudo/doas/pkexec is a regular user's install (`curl ... | sudo sh` on WSL, +# OpenRC/runit hosts, dev containers): it would land in /root/.pilot (0700, +# with the /usr/local/bin links pointing into it) or, with sudo -E, in a +# root-owned ~/.pilot, and that user could run neither. Refused everywhere. +SANDBOX_HOST=false +if [ "$(uname -s)" = "Linux" ] && [ ! -d /run/systemd/system ]; then + SANDBOX_HOST=true +fi +ELEVATED_FROM="" +if [ -n "${SUDO_UID:-}" ]; then + if [ "$SUDO_UID" != "0" ]; then + ELEVATED_FROM="sudo for ${SUDO_USER:-uid $SUDO_UID}" + fi +elif [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then + ELEVATED_FROM="sudo for $SUDO_USER" +elif [ -n "${DOAS_USER:-}" ] && [ "$DOAS_USER" != "root" ]; then + ELEVATED_FROM="doas for $DOAS_USER" +elif [ -n "${PKEXEC_UID:-}" ] && [ "$PKEXEC_UID" != "0" ]; then + ELEVATED_FROM="pkexec for uid $PKEXEC_UID" +fi if [ "${1:-}" != "uninstall" ] && [ "$(id -u)" = "0" ] && [ -z "${PILOT_ALLOW_ROOT:-}" ]; then - echo "Error: refusing to install as root." - echo " Run as a regular user; the installer uses sudo only when needed." - echo " Set PILOT_ALLOW_ROOT=1 to override (not recommended)." - exit 1 + if [ -n "$ELEVATED_FROM" ]; then + echo "Error: refusing to install as root: this runs under ${ELEVATED_FROM}." + echo " The node would be installed for root, into ${HOME}/.pilot, and that" + echo " user could not use it. Run the installer as that user, without sudo or doas:" + echo " curl -fsSL https://pilotprotocol.network/install.sh | sh" + echo " It uses sudo itself only where needed (never with a password prompt)." + echo " Set PILOT_ALLOW_ROOT=1 to install for root anyway (not recommended)." + exit 1 + elif [ "$SANDBOX_HOST" = true ]; then + echo "Note: installing as root (no systemd: container/VM sandbox) into ${HOME}/.pilot" + else + echo "Error: refusing to install as root." + echo " Run as a regular user; the installer uses sudo only when needed." + echo " Set PILOT_ALLOW_ROOT=1 to override (not recommended)." + exit 1 + fi fi # A managed identity is per node, but the CLI links, service label and daemon @@ -318,13 +417,145 @@ if [ "$PILOT_MANAGED_MODE" = "1" ] && [ ! -e "$MANAGED_CONTROL_PATH" ] \ _pilot_collision=""; _pilot_target=""; _pilot_service=""; _pilot_os="" fi +# The transport already saved in config.json, if any ("udp", "compat", +# "auto"). A re-run without --transport keeps it, so regenerated service +# units stay consistent with it. +CONFIG_TRANSPORT="" +if [ -f "$PILOT_DIR/config.json" ]; then + CONFIG_TRANSPORT=$(sed -n 's/.*"transport"[[:space:]]*:[[:space:]]*"\([A-Za-z]*\)".*/\1/p' "$PILOT_DIR/config.json" 2>/dev/null | head -n 1 | tr '[:upper:]' '[:lower:]') +fi +# Without any choice, new installs get auto (settled below, once the +# installed daemon is known to support it). +EFFECTIVE_TRANSPORT="${TRANSPORT:-${CONFIG_TRANSPORT:-auto}}" + +# --- Egress proxy --- +# +# Every download below is a curl HTTPS request, and curl honors HTTPS_PROXY / +# https_proxy / ALL_PROXY / NO_PROXY on its own, asking the proxy to CONNECT +# by hostname (no local DNS lookup of the target — which matters where local +# DNS for pilotprotocol.network is poisoned). PILOT_PROXY_URL is only used in +# messages, and only ever printed redacted: the userinfo of an +# authenticating proxy is a credential. Nothing in this script writes a proxy +# URL to disk. +# +# ENV_PROXY_CREDS: the proxy environment itself carries credentials — read +# before PILOT_PROXY is copied into it below for this run's downloads. Only +# then can the sandbox proxy command (SANDBOX_PROXY_CMD), which prints what a +# fresh shell's $https_proxy / $HTTPS_PROXY hold, print them: credentials +# that arrive in PILOT_PROXY never reach a fresh shell. +ENV_PROXY_CREDS=false +case "${https_proxy:-}${HTTPS_PROXY:-}" in + *@*) ENV_PROXY_CREDS=true ;; +esac + +# PILOT_PROXY is the daemon's own proxy setting; an http(s):// URL there +# carries this run's downloads too when the environment names no proxy +# (exported to this process and its children only). +if [ -z "${https_proxy:-}${HTTPS_PROXY:-}${all_proxy:-}${ALL_PROXY:-}" ]; then + case "${PILOT_PROXY:-}" in + http://*|https://*|HTTP://*|HTTPS://*) + https_proxy="$PILOT_PROXY" + HTTPS_PROXY="$PILOT_PROXY" + export https_proxy HTTPS_PROXY ;; + esac +fi +# The order curl uses for an https:// URL. +PILOT_PROXY_URL="${https_proxy:-${HTTPS_PROXY:-${all_proxy:-${ALL_PROXY:-}}}}" + +# Rotating proxy credentials. Hosted agent sandboxes (Meta Muse) put the proxy +# credentials in HTTPS_PROXY and replace them every few minutes; a process +# keeps the ones it started with, and the proxy answers its next CONNECT with +# 407. A fresh shell sees the current ones. PROXY_REFRESH_CMD prints the +# current proxy URL: $PILOT_PROXY_CMD, else — in a Linux container/VM without +# systemd whose HTTPS_PROXY or https_proxy carries credentials (ENV_PROXY_CREDS) +# and no explicit PILOT_PROXY is set — what a fresh bash has: whichever of +# $https_proxy and $HTTPS_PROXY carries credentials ($https_proxy when both do, the variable Meta Muse's guidance reads), else +# ${HTTPS_PROXY:-$https_proxy}, so a URL with credentials is never traded for +# one without (pilotctl uses the same command). It is saved as the daemon's +# proxy_cmd further down, and pcurl uses it here to retry a download once +# after the credentials rotated mid-install. +# shellcheck disable=SC2016 # literal: the fresh bash expands it, not this shell +SANDBOX_PROXY_CMD='bash -c '\''case $https_proxy in *@*) printf %s "$https_proxy";; *) printf %s "${HTTPS_PROXY:-$https_proxy}";; esac'\''' +# An explicit PILOT_PROXY is left alone, as pilotctl leaves it: pilot-daemon +# runs a proxy command in place of the URL it would use, so the sandbox +# command would replace that URL with the environment's proxy. +PROXY_REFRESH_CMD="${PILOT_PROXY_CMD:-}" +if [ -z "$PROXY_REFRESH_CMD" ] && [ "$SANDBOX_HOST" = true ] \ + && [ "$ENV_PROXY_CREDS" = true ] && command -v bash >/dev/null 2>&1; then + case "${PILOT_PROXY:-}" in + ""|auto|AUTO|Auto) PROXY_REFRESH_CMD="$SANDBOX_PROXY_CMD" ;; + esac +fi + +# proxy_refresh — run PROXY_REFRESH_CMD (at most 10s where `timeout` exists) +# and, when it prints an http(s):// URL different from the current one, use +# that for the rest of this run. The URL is never printed; only this process's +# environment changes. Returns 0 when the proxy URL changed. +proxy_refresh() { + [ -n "$PROXY_REFRESH_CMD" ] || return 1 + if command -v timeout >/dev/null 2>&1; then + _pr_url=$(timeout 10 sh -c "$PROXY_REFRESH_CMD" 2>/dev/null /dev/null &2 + echo " Check that it accepts CONNECT to pilotprotocol.network:443, github.com:443" >&2 + echo " and *.githubusercontent.com:443, and that its credentials are right." >&2 + else + echo " Note: check outbound HTTPS to pilotprotocol.network and github.com. If this host" >&2 + echo " can only reach the internet through a proxy, export HTTPS_PROXY and re-run." >&2 + fi +} + # --- Manifest + version helpers --- # fetch_manifest writes the manifest JSON to $1 and returns 0 on success. # Soft-fails (returns 1) so callers fall back to the GitHub-redirect path # when the manifest host is unreachable. fetch_manifest() { - curl -fsSL --max-time 10 "$MANIFEST_URL" -o "$1" 2>/dev/null + pcurl -fsSL --max-time 10 "$MANIFEST_URL" -o "$1" 2>/dev/null } # manifest_field "" "" extracts a string field. Supports nested @@ -354,16 +585,36 @@ manifest_field() { # independent integrity anchor (served from pilotprotocol.network) alongside the # release's checksums.txt (served from GitHub). manifest_platform_sha256() { - _mp_plat="$1"; _mp_file="$2" + manifest_platform_field "$1" sha256 "$2" +} + +# manifest_platform_field "-" "" "" extracts one string +# field (sha256, url) of that platform's object; empty when absent. +manifest_platform_field() { + _mp_plat="$1"; _mp_key="$2"; _mp_file="$3" # The authority is free to emit compact JSON. A line-range parser sees all # platform objects on that one line and a greedy replacement can therefore # return the final platform's hash. Collapse whitespace deliberately, then # constrain the match to this platform's first closing brace. tr -d '\r\n' < "$_mp_file" \ - | sed -n -E "s/.*\"${_mp_plat}\"[[:space:]]*:[[:space:]]*\\{[^}]*\"sha256\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"[^}]*\\}.*/\\1/p" \ + | sed -n -E "s/.*\"${_mp_plat}\"[[:space:]]*:[[:space:]]*\\{[^}]*\"${_mp_key}\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"[^}]*\\}.*/\\1/p" \ | head -1 } +# manifest_describes_tag "" "-" "" — whether the +# manifest's per-platform entry is the archive of . The manifest carries +# hashes for one release only: the platform url names it +# (…/releases/download//…), and a manifest without urls (the managed +# runtime's) describes its latest_stable. Any other tag — a --version pin, the +# beta channel — has no second anchor, so its hash must not be compared. +manifest_describes_tag() { + [ "$1" = "$(manifest_field "latest_stable" "$3")" ] && return 0 + case "$(manifest_platform_field "$2" url "$3")" in + */download/"$1"/*) return 0 ;; + esac + return 1 +} + # version_compare a b emits -1 / 0 / 1 for ab. # Honors semver: a prerelease tag ("X.Y.Z-rcN") is LOWER than the same base # without it ("X.Y.Z"). Plain `sort -V` gets this backwards on hyphenated @@ -516,8 +767,19 @@ echo " Pilot Protocol" echo " The network stack for AI agents." echo "" echo " Platform: ${OS}/${ARCH}" -echo " Registry: ${REGISTRY}" -echo " Beacon: ${BEACON}" +# auto is not announced here: whether the release being installed has it is +# known only after the download (releases before it run udp). The summary at +# the end states the transport the installed daemon will actually use. +case "$EFFECTIVE_TRANSPORT" in + compat) + echo " Transport: compat (TLS + WSS over TCP 443 only)" ;; + udp) + echo " Registry: ${REGISTRY}" + echo " Beacon: ${BEACON}" ;; +esac +if [ -n "$PILOT_PROXY_URL" ]; then + echo " Proxy: $(redact_proxy "$PILOT_PROXY_URL") (from environment)" +fi echo "" # --- Resolve email --- @@ -660,7 +922,7 @@ elif [ "${PILOT_RC:-}" = "1" ]; then elif [ "$HAVE_MANIFEST" = "1" ]; then TAG=$(manifest_field "latest_stable" "$MANIFEST_FILE") else - TAG=$(curl -fsSI "/${REPO}/releases/latest/download/${ARCHIVE}" 2>/dev/null \ + TAG=$(pcurl -fsSI "/${REPO}/releases/latest/download/${ARCHIVE}" 2>/dev/null \ | grep -i '^location:' \ | sed -n 's|.*/releases/download/\([^/]*\)/.*|\1|p' \ | tr -d '\r' | head -1) @@ -693,11 +955,13 @@ if [ -z "$TAG" ]; then if [ -n "$PILOT_REQUESTED_CHANNEL" ]; then echo "Error: channel '$PILOT_REQUESTED_CHANNEL' resolved to no release (manifest reachable: $HAVE_MANIFEST)." >&2 echo " Refusing to fall back to an unverified source build for an explicit channel request." >&2 + [ "$HAVE_MANIFEST" = "1" ] || net_hint exit 1 fi if [ "${PILOT_RC:-}" = "1" ]; then echo "Error: the beta/prerelease channel resolved to no release." >&2 echo " Refusing to fall back to an unverified source build for an explicit channel request." >&2 + [ "$HAVE_MANIFEST" = "1" ] || net_hint exit 1 fi fi @@ -732,7 +996,7 @@ if [ -n "$TAG" ]; then URL="/${REPO}/releases/download/${TAG}/${ARCHIVE}" CHECKSUMS_URL="/${REPO}/releases/download/${TAG}/checksums.txt" echo "Downloading ${TAG}..." - if curl -fsSL "$URL" -o "$TMPDIR/$ARCHIVE" 2>/dev/null; then + if pcurl -fsSL "$URL" -o "$TMPDIR/$ARCHIVE" 2>/dev/null; then # --- Verify SHA-256 (fail closed) --- # This block NEVER extracts an archive it could not verify. Two # independent anchors are used: @@ -746,11 +1010,14 @@ if [ -n "$TAG" ]; then # archive line, or the absence of shasum/sha256sum silently extracted # the archive UNVERIFIED.) EXPECTED_CKS="" - if curl -fsSL "$CHECKSUMS_URL" -o "$TMPDIR/checksums.txt" 2>/dev/null; then + if pcurl -fsSL "$CHECKSUMS_URL" -o "$TMPDIR/checksums.txt" 2>/dev/null; then EXPECTED_CKS=$(grep " ${ARCHIVE}\$" "$TMPDIR/checksums.txt" | awk '{print $1}') fi + # The manifest hashes only the release it describes (see + # manifest_describes_tag): for any other tag it is no anchor at all. EXPECTED_MAN="" - if [ "$HAVE_MANIFEST" = "1" ]; then + if [ "$HAVE_MANIFEST" = "1" ] \ + && manifest_describes_tag "$TAG" "${OS}-${ARCH}" "$MANIFEST_FILE"; then EXPECTED_MAN=$(manifest_platform_sha256 "${OS}-${ARCH}" "$MANIFEST_FILE") fi @@ -812,11 +1079,13 @@ if [ -n "$TAG" ]; then # Archive download failed. Only the automatic default path may fall # back to a source build; an explicit request already hard-failed # above, so reaching here means no version/channel was pinned. + echo " Could not download ${URL}" >&2 TAG="" fi fi if [ -z "$TAG" ]; then + net_hint echo "No release available. Building from source..." if ! command -v go >/dev/null 2>&1; then echo "Error: Go is required to build from source." @@ -1062,6 +1331,113 @@ if [ "$LINK_OK" = true ]; then echo " pilotctl now resolves in non-interactive shells (bash -c, cron, CI, agents)" fi +# --- What the installed binaries support --- +# +# pilot_config_set merges one key into config.json through pilotctl (atomic +# write, 0600, every other key kept) instead of rewriting the file, so a +# hand-edited config survives a re-run. PILOT_HOME is blanked so the write +# lands in THIS install's $HOME/.pilot, which is also the file pilot-daemon +# auto-loads. An empty value removes the key (used only with a pilotctl whose +# daemon supports transport auto, which clears keys that way). +pilot_config_set() { + PILOT_HOME='' "$BIN_DIR/pilotctl" config --set "$1" >/dev/null 2>&1 +} + +# The probes are local (no network). +DAEMON_HAS_TRANSPORT=false +DAEMON_HAS_PROXY=false +DAEMON_HAS_AUTO=false +_daemon_help=$("$BIN_DIR/pilot-daemon" -help 2>&1 || true) +if printf '%s\n' "$_daemon_help" | grep -qE '^[[:space:]]+-transport([[:space:]]|$)'; then + DAEMON_HAS_TRANSPORT=true + # -transport=auto: its usage line names 'auto'. + if printf '%s\n' "$_daemon_help" | sed -n '/^[[:space:]]*-transport/,/^[[:space:]]*-[a-z]/p' | grep -q "'auto'"; then + DAEMON_HAS_AUTO=true + fi +fi +if printf '%s\n' "$_daemon_help" | grep -qE '^[[:space:]]+-proxy([[:space:]]|$)'; then + DAEMON_HAS_PROXY=true +fi +DAEMON_HAS_PROXY_CMD=false +if printf '%s\n' "$_daemon_help" | grep -qE '^[[:space:]]+-proxy-cmd([[:space:]]|$)'; then + DAEMON_HAS_PROXY_CMD=true +fi + +# --- Transport: auto, udp or compat --- +# +# auto is never saved in config.json. It is already the default wherever +# this install starts the daemon — `pilotctl daemon start` asks a daemon +# that supports it for auto, and the service units below set +# PILOT_TRANSPORT_DEFAULT=auto — while a pilot-daemon that predates auto +# (reinstalled with --version, or `pilotctl update --pin`) refuses to start +# with "transport":"auto" in config.json. udp and compat are saved. +TRANSPORT_TO_SAVE="" +TRANSPORT_CLEAR=false +case "$TRANSPORT" in + udp|compat) + TRANSPORT_TO_SAVE="$TRANSPORT" ;; + auto) + if [ "$DAEMON_HAS_AUTO" = true ]; then + if [ -n "$CONFIG_TRANSPORT" ]; then TRANSPORT_CLEAR=true; fi + else + # Nothing is saved or removed: this release has no auto to go + # back to, and what it runs is what config.json already says. + case "$CONFIG_TRANSPORT" in + compat|udp) + echo " Note: this pilot-daemon (${TAG:-source}) predates -transport=auto; it keeps the" + echo " transport saved in config.json (${CONFIG_TRANSPORT}). Switch with --transport udp or" + echo " --transport compat." ;; + *) + echo " Note: this pilot-daemon (${TAG:-source}) predates -transport=auto; it keeps its default (udp)." ;; + esac + fi ;; +esac +if [ "$CONFIG_TRANSPORT" = "auto" ] && [ "$DAEMON_HAS_AUTO" != true ] && [ -z "$TRANSPORT_TO_SAVE" ]; then + # Downgrade: this daemon would exit with "invalid -transport auto". + TRANSPORT_TO_SAVE="udp" + echo " Note: this pilot-daemon (${TAG:-source}) predates -transport=auto, which config.json" + echo " selects; switching it to udp (the daemon's default) so the daemon still starts." +fi + +# What the daemon will run: the saved transport, else auto where the +# daemon supports it, else its default (udp). +if [ -n "$TRANSPORT_TO_SAVE" ]; then + EFFECTIVE_TRANSPORT="$TRANSPORT_TO_SAVE" +elif [ "$TRANSPORT_CLEAR" != true ] && [ -n "$CONFIG_TRANSPORT" ] && [ "$CONFIG_TRANSPORT" != "auto" ]; then + EFFECTIVE_TRANSPORT="$CONFIG_TRANSPORT" +elif [ "$DAEMON_HAS_AUTO" = true ]; then + EFFECTIVE_TRANSPORT="auto" +else + EFFECTIVE_TRANSPORT="udp" +fi + +# pilotctl releases before `daemon start --transport` pass config.json's +# registry (else the raw-TCP default) to the daemon verbatim, and a daemon +# given the raw-TCP registry explicitly stays on it even in compat mode. +# Probed only for compat, and only with a daemon that has -transport (v1.11+): +# every pilotctl since v1.10 prints help for `daemon start --help` instead of +# starting a daemon. +PILOTCTL_HAS_TRANSPORT=false +if [ "$EFFECTIVE_TRANSPORT" = "compat" ] && [ "$DAEMON_HAS_TRANSPORT" = true ] \ + && "$BIN_DIR/pilotctl" daemon start --help 2>&1 | grep -q -- '--transport'; then + PILOTCTL_HAS_TRANSPORT=true +fi + +# The stock raw-TCP registry (34.71.57.205:9000) and UDP beacon are left out +# of what this installer writes when the node runs compat, or auto behind a +# proxy: the daemon then applies the endpoints that fit the transport it +# runs (registry.pilotprotocol.network:443 over TLS in compat mode or through +# a proxy), and an address that a 443-only network or HTTPS proxy never +# carries is not pinned anywhere. Custom PILOT_REGISTRY / PILOT_BEACON values +# are always kept. +STOCK_ENDPOINTS=true +if [ "$DAEMON_HAS_TRANSPORT" = true ]; then + case "$EFFECTIVE_TRANSPORT" in + compat) STOCK_ENDPOINTS=false ;; + auto) if [ -n "$PILOT_PROXY_URL" ]; then STOCK_ENDPOINTS=false; fi ;; + esac +fi + # --- Fresh install: write config --- # # config.json is written ONLY when there isn't one already. A re-run must never @@ -1079,13 +1455,34 @@ fi # erased by this write, and the erase happened before the first skills pass # further below. Guarding on the file itself makes the documented opt-outs # reachable at install time instead of only after the fact. Defaults are -# unchanged — a host with no config still gets the standard one. +# unchanged — a host with no config still gets the standard one (without the +# stock endpoints in compat mode or behind a proxy, see STOCK_ENDPOINTS). if [ "$UPDATING" != true ] && [ ! -f "$PILOT_DIR/config.json" ]; then + CONF_REGISTRY="$REGISTRY" + CONF_BEACON="$BEACON" + if [ "$STOCK_ENDPOINTS" != true ]; then + if [ "$REGISTRY" = "$DEFAULT_REGISTRY" ]; then + CONF_REGISTRY="" + # A pilotctl that predates --transport would pass the raw + # default instead: name the compat TLS registry explicitly. + if [ "$EFFECTIVE_TRANSPORT" = "compat" ] && [ "$PILOTCTL_HAS_TRANSPORT" != true ]; then + CONF_REGISTRY="$COMPAT_REGISTRY" + fi + fi + if [ "$BEACON" = "$DEFAULT_BEACON" ]; then CONF_BEACON=""; fi + fi + CONF_NET="" + if [ -n "$CONF_REGISTRY" ]; then + CONF_NET="${CONF_NET} \"registry\": \"${CONF_REGISTRY}\", +" + fi + if [ -n "$CONF_BEACON" ]; then + CONF_NET="${CONF_NET} \"beacon\": \"${CONF_BEACON}\", +" + fi cat > "$PILOT_DIR/config.json" </dev/null; then + PROXY_CMD_TO_SAVE="$SANDBOX_PROXY_CMD" +fi +if [ -n "$PROXY_CMD_TO_SAVE" ]; then + if ! pilot_config_set "proxy_cmd=$PROXY_CMD_TO_SAVE"; then + echo " Note: could not save proxy_cmd in ${PILOT_DIR}/config.json" + elif [ "$DAEMON_HAS_PROXY_CMD" = true ]; then + echo "Proxy credentials: re-read by the daemon via proxy_cmd (${PILOT_DIR}/config.json stores the command, not the credentials)" + else + echo "Proxy credentials: proxy_cmd saved in ${PILOT_DIR}/config.json (the command, not the credentials)." + if [ "$DAEMON_HAS_PROXY" = true ]; then + echo " This pilot-daemon (${TAG:-source}) predates -proxy-cmd and ignores it until upgraded;" + echo " until then, if the proxy rotates its credentials, restart the daemon from a fresh shell." + else + echo " This pilot-daemon (${TAG:-source}) predates -proxy-cmd and ignores it until upgraded." + fi + fi +fi +PROXY_CMD_SAVED=false +if grep -q '"proxy_cmd"' "$PILOT_DIR/config.json" 2>/dev/null; then + PROXY_CMD_SAVED=true +fi + +# --- Registry for the transport --- +# +# No "proxy" key is written: the daemon's default, auto, already uses +# $HTTPS_PROXY / $ALL_PROXY where it needs a proxy, and a saved "auto" would +# only get in the way of a proxy passed later with --proxy or $PILOT_PROXY. +CONFIG_REGISTRY=$(sed -n 's/.*"registry"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PILOT_DIR/config.json" 2>/dev/null | head -n 1) +if [ "$EFFECTIVE_TRANSPORT" = "compat" ]; then + if [ "$DAEMON_HAS_TRANSPORT" != true ]; then + echo "" + echo " WARNING: this pilot-daemon (${TAG:-source}) predates compat mode (-transport)." + echo " It will keep using UDP. Re-run without --version to get the latest release." + fi + + # A pilotctl that predates --transport passes config.json's registry, + # else the raw-TCP default, to the daemon verbatim: point it at the + # compat TLS registry — only when the file holds the stock default or + # no registry at all. + if [ "$DAEMON_HAS_TRANSPORT" = true ] && [ "$PILOTCTL_HAS_TRANSPORT" != true ] \ + && { [ "$CONFIG_REGISTRY" = "$DEFAULT_REGISTRY" ] || [ -z "$CONFIG_REGISTRY" ]; }; then + if pilot_config_set "registry=${COMPAT_REGISTRY}"; then + echo " Registry set to ${COMPAT_REGISTRY} for compat mode (this pilotctl" + echo " always passes config.json's registry to the daemon). Switching back to" + echo " UDP later: re-run this installer with --transport udp" + fi + fi +elif [ "$CONFIG_REGISTRY" = "$COMPAT_REGISTRY" ]; then + # Leaving compat after an install that pointed the registry at the + # compat TLS host: a udp daemon needs the raw-TCP registry back. + if pilot_config_set "registry=${DEFAULT_REGISTRY}"; then + echo " Registry restored to ${DEFAULT_REGISTRY} for transport ${EFFECTIVE_TRANSPORT}" + fi +fi + +# A proxy in the environment that this daemon cannot use: say so, and where +# the proxy is the only way out, point at the recipe that works with it. +# Such a daemon dials the registry and beacon directly, around the proxy. +# PROXY_ONLY: the proxy is known to be the way out — $PILOT_PROXY_CMD is set +# (rotating credentials), or a credential-bearing proxy in a Linux +# container/VM without systemd (a hosted agent sandbox such as Meta Muse). +# There every "start the daemon" instruction below is replaced by the +# recipe; elsewhere it is kept, with the condition spelled out. +SANDBOX_RECIPE_URL="https://pilotprotocol.network/learn/install-pilot-skills-in-meta-muse" +# The recipe needs Linux (root and `unshare -m` for its SNI router): on any +# other OS the way forward is a release whose pilot-daemon has -proxy. +if [ "$OS" = "linux" ]; then + PROXY_REMEDY="the pilot-sandbox recipe (step 3): ${SANDBOX_RECIPE_URL}" +else + PROXY_REMEDY="re-run this installer once a Pilot release whose pilot-daemon -h lists -proxy is out" +fi +PROXY_UNSUPPORTED=false +PROXY_ONLY=false +if [ -n "$PILOT_PROXY_URL" ] && [ "$DAEMON_HAS_PROXY" != true ]; then + PROXY_UNSUPPORTED=true + if [ -n "${PILOT_PROXY_CMD:-}" ]; then + PROXY_ONLY=true + elif [ "$SANDBOX_HOST" = true ]; then + case "${https_proxy:-}${HTTPS_PROXY:-}${PILOT_PROXY_URL}" in + *@*) PROXY_ONLY=true ;; + esac + fi + echo "" + if [ "$PROXY_ONLY" = true ]; then + echo " WARNING: this host goes out through a proxy ($(redact_proxy "$PILOT_PROXY_URL")), and this" + echo " pilot-daemon (${TAG:-source}) cannot use one: \`pilotctl daemon start\` would dial" + echo " the registry directly, around the proxy, and never come online." + echo " Do not start it here. What brings the node online: ${PROXY_REMEDY}" + else + echo " WARNING: a proxy is set ($(redact_proxy "$PILOT_PROXY_URL")), but this pilot-daemon" + echo " (${TAG:-source}) cannot use one: it dials the registry directly. If the" + echo " proxy is this host's only way out (UDP blocked, e.g. an agent sandbox)," + echo " the daemon will not come online with this release. What does then:" + echo " ${PROXY_REMEDY}" + fi +fi + +# UDP_ONLY_HINT: the daemon runs udp only because this release predates auto +# (nothing chose udp), so on a UDP-blocked host it will not fall back to TCP +# 443 by itself. Said in the summary, with the re-run that selects compat +# (which also points an older pilotctl at the TLS registry). Not where the +# proxy warning above already applies: compat would not use the proxy either. +UDP_ONLY_HINT=false +if [ "$EFFECTIVE_TRANSPORT" = "udp" ] && [ "$DAEMON_HAS_AUTO" != true ] \ + && [ "$DAEMON_HAS_TRANSPORT" = true ] && [ "$PROXY_UNSUPPORTED" != true ] \ + && [ "$TRANSPORT" != "udp" ] && [ "$CONFIG_TRANSPORT" != "udp" ]; then + UDP_ONLY_HINT=true +fi + +# start_hint PREFIX COMMAND [NAME [STOP]] — print how to start the daemon: +# COMMAND, except where this daemon cannot use the proxy (the WARNING above). +# On a proxy-only host (PROXY_ONLY) COMMAND is not printed as something to +# run: only that NAME (default: COMMAND) must not be run there, and +# PROXY_REMEDY. STOP (restart hints) is printed first there: the recipe starts +# a daemon but never stops one, and two daemons must not share an identity. +start_hint() { + if [ "$PROXY_ONLY" = true ]; then + echo "${1}Do not run \`${3:-$2}\` on this host (see the WARNING above)." + if [ -n "${4:-}" ]; then + echo "${1}Stop the running daemon first: ${4}" + echo "${1}then bring it back with ${PROXY_REMEDY}" + else + echo "${1}What brings the node online: ${PROXY_REMEDY}" + fi + elif [ "$PROXY_UNSUPPORTED" = true ]; then + echo "${1}${2}" + echo "${1}(if the proxy is this host's only way out, it will not come online with" + echo "${1} this release: see the WARNING above)" + else + echo "${1}${2}" + fi +} + +# Network flags for the service units. The transport itself comes from +# config.json, which the daemon reads, so `pilotctl config --set transport=` +# applies to the service too. Where the stock endpoints are left out (compat, +# or auto behind a proxy; see STOCK_ENDPOINTS) the daemon's built-in defaults +# apply — the same raw-TCP/UDP endpoints for udp, the TLS registry for +# compat (an older daemon given -registry explicitly stays pinned to a port +# no 443-only network or HTTPS proxy will carry); a custom PILOT_REGISTRY / +# PILOT_BEACON is kept. +if [ "$STOCK_ENDPOINTS" != true ]; then + NET_FLAGS="" + if [ "$REGISTRY" != "$DEFAULT_REGISTRY" ]; then NET_FLAGS="$NET_FLAGS -registry $REGISTRY"; fi + if [ "$BEACON" != "$DEFAULT_BEACON" ]; then NET_FLAGS="$NET_FLAGS -beacon $BEACON"; fi + NET_FLAGS="${NET_FLAGS# }" +else + NET_FLAGS="-registry $REGISTRY -beacon $BEACON" +fi + +# The service units ask for transport auto through PILOT_TRANSPORT_DEFAULT: +# it applies only when neither -transport, $PILOT_TRANSPORT nor config.json +# chooses, and a daemon that predates auto ignores it (a -transport auto +# flag would stop it from starting after a downgrade). +UNIT_ENV="" +PLIST_ENV="" +if [ "$DAEMON_HAS_AUTO" = true ]; then + UNIT_ENV=" +Environment=PILOT_TRANSPORT_DEFAULT=auto" + PLIST_ENV=" EnvironmentVariables + + PILOT_TRANSPORT_DEFAULT + auto + +" +fi + +# service_proxy_note UNIT — a service manager starts the daemon with its own +# environment, not this shell's, so an HTTPS_PROXY exported here never +# reaches it. config.json (0600, read by the daemon itself) does. +# This installer never writes the URL itself: with credentials in it, where +# to store them is the operator's call. +service_proxy_note() { + if [ "$EFFECTIVE_TRANSPORT" != "udp" ] && [ -n "$PILOT_PROXY_URL" ] \ + && ! grep -q '"proxy"[[:space:]]*:[[:space:]]*"http' "$PILOT_DIR/config.json" 2>/dev/null; then + echo " Note: $1 does not inherit this shell's HTTPS_PROXY. For the service to use" + case "$PILOT_PROXY_URL" in + *@*) + echo " the proxy, save it in config.json (0600; this stores its credentials):" + echo " pilotctl config --set proxy=''" + echo " or save a command that prints it: pilotctl config --set proxy_cmd=''" ;; + *) + echo " the proxy, save it in config.json:" + echo " pilotctl config --set proxy='${PILOT_PROXY_URL}'" ;; + esac + fi +} + # Enable background auto-updates by default (opt-out). The install output and # the systemd/launchd units below promise the updater keeps binaries current; # the pilot-updater treats a MISSING control file as "disabled", so without @@ -1168,10 +1779,9 @@ Wants=network-online.target [Service] Type=simple -User=$(whoami) +User=$(whoami)${UNIT_ENV} ExecStart=${BIN_DIR}/pilot-daemon \\ - -registry ${REGISTRY} \\ - -beacon ${BEACON} \\ + ${NET_FLAGS} \\ -listen :4000 \\ -socket /tmp/pilot.sock \\ -identity ${PILOT_DIR}/identity.json \\ @@ -1219,6 +1829,7 @@ USVC if [ "$PILOT_MANAGED_MODE" != "1" ] && [ -f "$BIN_DIR/pilot-updater" ]; then echo " Service: pilot-updater.service (auto-updates)" fi + service_proxy_note "pilot-daemon.service" # Auto-enable + start the updater so future releases land without # operator action. The unit file alone is not enough — without this, @@ -1262,19 +1873,41 @@ USVC elif [ "$PILOT_MANAGED_MODE" != "1" ]; then case " $RESTART_SYSTEMD " in *" pilot-daemon "*) ;; - *) echo " Start daemon: sudo systemctl enable --now pilot-daemon" ;; + *) + if [ "$PROXY_UNSUPPORTED" = true ]; then + echo " Start daemon:" + start_hint " " "sudo systemctl enable --now pilot-daemon" + else + echo " Start daemon: sudo systemctl enable --now pilot-daemon" + fi ;; esac fi else echo " Skipped systemd setup (run as root or with passwordless sudo to enable)" + if [ "$PILOT_MANAGED_MODE" != "1" ]; then + if [ "$PROXY_UNSUPPORTED" = true ]; then + echo " Start the daemon without a service manager:" + start_hint " " "pilotctl daemon start" + else + echo " Start the daemon without a service manager: pilotctl daemon start" + fi + fi fi elif [ "$OS" = "linux" ]; then - # systemd is not the init system here (container / WSL / CI runner). - # There is no service to install — tell the agent the portable start path - # instead of silently leaving it with no daemon. + # systemd is not the init system here (container / WSL / CI runner / + # hosted agent sandbox). There is no service to install — tell the agent + # the portable start path instead of silently leaving it with no daemon. if [ "$PILOT_MANAGED_MODE" != "1" ]; then - echo "No systemd detected (container / WSL / CI) — start the daemon manually:" - echo " pilotctl daemon start" + if [ "$PROXY_ONLY" = true ]; then + echo "No systemd detected (container / WSL / CI / sandbox):" + else + echo "No systemd detected (container / WSL / CI / sandbox) — start the daemon manually:" + fi + start_hint " " "pilotctl daemon start" + if [ "$PROXY_UNSUPPORTED" != true ] && [ "$EFFECTIVE_TRANSPORT" != "udp" ]; then + echo " (transport=${EFFECTIVE_TRANSPORT}; start it from a shell that has HTTPS_PROXY" + echo " set if this host reaches the internet only through a proxy)" + fi fi fi @@ -1306,6 +1939,13 @@ if [ "$OS" = "darwin" ]; then # empty value passes a blank argv element to the daemon; omitting # it lets the daemon do the documented thing instead — fall back to # ~/.pilot/account.json, then synthesise a fingerprint identity. + # One per word of NET_FLAGS (host:port / flag names only — + # validate_safe already rejected anything with spaces or markup). + PLIST_NET_ARGS="" + for _a in $NET_FLAGS; do + PLIST_NET_ARGS="${PLIST_NET_ARGS} ${_a} +" + done EXTRA_ARGS="" if [ -n "$EMAIL" ]; then EXTRA_ARGS="${EXTRA_ARGS} -email @@ -1331,11 +1971,7 @@ if [ "$OS" = "darwin" ]; then ProgramArguments ${BIN_DIR}/pilot-daemon - -registry - ${REGISTRY} - -beacon - ${BEACON} - -listen +${PLIST_NET_ARGS} -listen :4000 -socket /tmp/pilot.sock @@ -1343,7 +1979,7 @@ if [ "$OS" = "darwin" ]; then ${PILOT_DIR}/identity.json -encrypt ${EXTRA_ARGS} - RunAtLoad +${PLIST_ENV} RunAtLoad KeepAlive @@ -1396,6 +2032,7 @@ UPLIST if [ "$PILOT_MANAGED_MODE" != "1" ] && [ -f "$BIN_DIR/pilot-updater" ]; then echo " Service: network.pilotprotocol.pilot-updater (auto-updates)" fi + service_proxy_note "the launchd agent" # Auto-load the updater LaunchAgent so future releases land without # operator action. Without this, install.sh writes the plist but leaves @@ -1433,7 +2070,12 @@ UPLIST case " $RESTART_LAUNCHD " in *" network.pilotprotocol.pilot-daemon "*) ;; *) - echo " Start daemon: launchctl load -w $PLIST" + if [ "$PROXY_UNSUPPORTED" = true ]; then + echo " Start daemon:" + start_hint " " "launchctl load -w $PLIST" "launchctl load -w $PLIST" + else + echo " Start daemon: launchctl load -w $PLIST" + fi echo " Stop daemon: launchctl unload $PLIST" ;; esac @@ -1560,6 +2202,36 @@ if [ "$PILOT_MANAGED_MODE" = "1" ]; then exit 0 fi +# transport_line — the transport the installed daemon will run, stated once +# the binaries are known (the banner at the top does not guess), plus what to +# do on a UDP-blocked host when this release will not fall back by itself. +transport_line() { + case "$EFFECTIVE_TRANSPORT" in + compat) + if [ "$DAEMON_HAS_TRANSPORT" != true ]; then + echo " Transport: udp (compat is saved, but this pilot-daemon, ${TAG:-source}," + echo " predates it; see the WARNING above)" + return 0 + fi + _tl_registry="$COMPAT_REGISTRY" + if [ "$REGISTRY" != "$DEFAULT_REGISTRY" ]; then _tl_registry="$REGISTRY"; fi + echo " Transport: compat (registry ${_tl_registry} over TLS, beacon over WSS)" ;; + auto) + echo " Transport: auto (UDP when it works, else compat over TCP 443)" ;; + *) + if [ "$DAEMON_HAS_AUTO" != true ]; then + echo " Transport: udp (this pilot-daemon, ${TAG:-source}, predates auto and never" + echo " falls back to TCP 443 by itself)" + else + echo " Transport: udp" + fi ;; + esac + if [ "$UDP_ONLY_HINT" = true ]; then + echo " UDP blocked on this host? Use TLS + WSS over TCP 443 instead:" + echo " curl -fsSL https://pilotprotocol.network/install.sh | sh -s -- --transport compat" + fi +} + # --- Upgrade: short summary, skip the first-run onboarding text --- # # Everything above this point (binary swap, unit/plist regeneration, service @@ -1573,11 +2245,12 @@ if [ "$UPDATING" = true ]; then echo " pilotctl ${BIN_DIR}/pilotctl" [ -f "$BIN_DIR/pilot-gateway" ] && echo " pilot-gateway ${BIN_DIR}/pilot-gateway" [ -f "$BIN_DIR/pilot-updater" ] && echo " pilot-updater ${BIN_DIR}/pilot-updater" + transport_line echo "" if [ -z "$RESTART_SYSTEMD" ] && [ -z "$RESTART_LAUNCHD" ]; then echo "No managed service was running. If you run the daemon yourself," echo "restart it to pick up the new version:" - echo " pilotctl daemon stop && pilotctl daemon start" + start_hint " " "pilotctl daemon stop && pilotctl daemon start" "pilotctl daemon start" "pilotctl daemon stop" echo "" fi exit 0 @@ -1591,11 +2264,34 @@ echo " pilotctl ${BIN_DIR}/pilotctl" [ -f "$BIN_DIR/pilot-updater" ] && echo " pilot-updater ${BIN_DIR}/pilot-updater (auto-updates in background)" echo "" echo "Config: ${PILOT_DIR}/config.json" -echo " Registry: ${REGISTRY}" -echo " Beacon: ${BEACON}" +transport_line +case "$EFFECTIVE_TRANSPORT" in + compat) ;; + auto) + if [ "$STOCK_ENDPOINTS" = true ]; then + echo " Registry: ${REGISTRY}" + echo " Beacon: ${BEACON}" + else + echo " Registry: picked by the daemon for its transport (${COMPAT_REGISTRY} over TLS via the proxy)" + fi ;; + *) + echo " Registry: ${REGISTRY}" + echo " Beacon: ${BEACON}" ;; +esac +if [ "$EFFECTIVE_TRANSPORT" != "udp" ] && [ -n "$PILOT_PROXY_URL" ] && [ "$DAEMON_HAS_PROXY" = true ]; then + echo " Proxy: auto -> $(redact_proxy "$PILOT_PROXY_URL") (from environment)" + if [ "$PROXY_CMD_SAVED" = true ] && [ "$DAEMON_HAS_PROXY_CMD" = true ]; then + echo " credentials re-read by the daemon (proxy_cmd): rotation needs no restart" + fi +fi echo " Socket: /tmp/pilot.sock" echo " Identity: ${PILOT_DIR}/identity.json" -echo " Email: ${EMAIL}" +if [ -n "$EMAIL" ]; then + echo " Email: ${EMAIL}" +else + echo " Email: not set (the daemon uses @nodes.pilotprotocol.network;" + echo " set a real one any time: pilotctl set-email )" +fi echo "" echo "============================================" echo " GET STARTED" @@ -1603,21 +2299,35 @@ echo "" echo " 0) Put pilotctl on your PATH and bring the node online." echo " ------------------------------------------------------------------" echo " export PATH=\"${BIN_DIR}:\$PATH\" # only needed in THIS shell, before you open a new one" +# The start command itself only where this daemon can come online with it +# (see PROXY_UNSUPPORTED / PROXY_ONLY): a proxy-only host gets the recipe. +if [ "$PROXY_ONLY" = true ]; then + echo " # Do NOT run \`pilotctl daemon start\` on this host: this pilot-daemon (${TAG:-source})" + echo " # cannot use the proxy and would dial the registry directly. What brings the" + echo " # node online (then check it here):" + echo " # ${PROXY_REMEDY}" +elif [ "$PROXY_UNSUPPORTED" = true ]; then + echo " # This pilot-daemon (${TAG:-source}) cannot use the proxy (see the WARNING above). If the" + echo " # proxy is this host's only way out, skip the next line; what works then:" + echo " # ${PROXY_REMEDY}" + echo " pilotctl daemon start --hostname my-agent # blocks until registered; email already saved" +else + echo " pilotctl daemon start --hostname my-agent # blocks until registered; email already saved" +fi cat <<'PILOT_GET_STARTED' - pilotctl daemon start --hostname my-agent # blocks until registered; email already saved pilotctl daemon status # confirm it's running pilotctl info # node ID, address, peer count, uptime - Reading any reply is always the same two-step idiom — send with - --wait, then print the newest reply. No jq, no extra dependency: + Ask with --wait: the reply comes back in the send's own output. If + it fails or times out there is no reply to read — do not substitute + the newest inbox message, which may answer an older question: ------------------------------------------------------------------ pilotctl send-message --data '' --wait - pilotctl inbox --latest # full body of the newest reply + pilotctl --json send-message --data '' --wait # reply in .data.reply - # Other ways to read the inbox: + # Every reply is also kept in the inbox: pilotctl inbox # 10 newest, one-line previews pilotctl inbox --from pilot-mom --limit 3 - pilotctl --json inbox --latest # machine-readable envelope 1) MOM (pilot-mom) — your default for anything LIVE or fresh. @@ -1625,7 +2335,6 @@ cat <<'PILOT_GET_STARTED' agents + filters and returns a validated, ready-to-run plan. ------------------------------------------------------------------ pilotctl send-message pilot-mom --data 'current weather and air quality for Berlin' --wait - pilotctl inbox --latest # More examples — hand it the whole sentence, don't hunt for agents yourself: pilotctl send-message pilot-mom --data 'summarize this season F1 drivers' --wait @@ -1640,16 +2349,13 @@ cat <<'PILOT_GET_STARTED' ------------------------------------------------------------------ # Browse the whole live catalogue: pilotctl send-message list-agents --data '/data' --wait - pilotctl inbox --latest # Search by keyword: pilotctl send-message list-agents --data '/data {"search":"bitcoin","limit":10}' --wait - pilotctl inbox --latest # Learn a specialist's query schema, then query it with filters: pilotctl send-message --data '/help' --wait pilotctl send-message --data '/data {"":""}' --wait - pilotctl inbox --latest @@ -1663,8 +2369,10 @@ cat <<'PILOT_GET_STARTED' # See an app's full details (methods, source, permissions, pricing): pilotctl appstore view io.pilot.sqlite - # Install it (daemon auto-spawns it; re-run `list` if state != ready): - pilotctl appstore install io.pilot.sqlite --force + # Install it once (daemon auto-spawns it; re-run `list` if state != ready). + # Do not add --force routinely: it reinstalls over the app and can + # delete its saved state (keys). + pilotctl appstore install io.pilot.sqlite pilotctl appstore list # ALWAYS call .help first — lists every method, its params, @@ -1675,13 +2383,13 @@ cat <<'PILOT_GET_STARTED' pilotctl appstore call io.pilot.sqlite sqlite.query '{"sql":"select 1"}' # A few concrete capability examples (install first, then call): - pilotctl appstore install io.pilot.smol --force + pilotctl appstore install io.pilot.smol pilotctl appstore call io.pilot.smol smol.push '{"image":"alpine","net":true}' - pilotctl appstore install io.pilot.bowmark --force + pilotctl appstore install io.pilot.bowmark pilotctl appstore call io.pilot.bowmark bowmark.ask '{"site":"amazon.com","task":"search for a product"}' - pilotctl appstore install io.pilot.orthogonal --force + pilotctl appstore install io.pilot.orthogonal pilotctl appstore call io.pilot.orthogonal orthogonal.search '{"prompt":"work email for a person given name + company"}' Cost: most apps run locally and are free. A few (orthogonal, sixtyfour, @@ -1700,8 +2408,9 @@ cat <<'PILOT_GET_STARTED' pilotctl send-message --data '' # talk, once trust is mutual pilotctl send-file /path/to/file.tar.gz # exchange artifacts - Full operator manual & task→agent/app maps: - ~/.claude/skills/pilotctl/SKILL.md + Full operator manual & task→agent/app maps: the pilotctl skill + (`pilotctl skills` lists where it is installed, e.g. + ~/.claude/skills/pilotctl/SKILL.md). ============================================ PILOT_GET_STARTED echo "" @@ -1721,6 +2430,9 @@ echo " The daemon scans every 15 minutes and injects the Pilot Protocol" echo " skill into installed agent tools. Triggering a first pass right now" echo " so your agents know about Pilot before the daemon is even started:" echo "" +# pilotctl fetches the skills through this process's proxy settings: hand it +# the current credentials if they rotated since the downloads. +proxy_refresh || true if "${BIN_DIR}/pilotctl" skills check 2>&1 | sed 's/^/ /'; then : else diff --git a/tests/proxy-transport-install.sh b/tests/proxy-transport-install.sh new file mode 100644 index 0000000..bb9311d --- /dev/null +++ b/tests/proxy-transport-install.sh @@ -0,0 +1,638 @@ +#!/bin/sh +# Contract test for the transport / proxy / sandbox handling in install.sh: +# - root is allowed in a Linux container/VM without systemd (hosted agent +# sandboxes such as Meta Muse run the agent as root), refused elsewhere; +# - --transport udp|compat is saved in config.json, auto never is, and +# --transport auto removes a saved transport; +# - proxy_cmd is saved in such a sandbox when HTTPS_PROXY carries +# credentials (the daemon re-reads rotating proxy credentials with it), +# also for a daemon that predates -proxy-cmd (it ignores the key until +# upgraded); +# - a download that fails because the proxy credentials rotated mid-install +# is retried once with the credentials a fresh shell sees; +# - PILOT_PROXY carries the downloads when no *_PROXY variable is set; +# - no raw-IP registry/beacon is written for compat, or auto behind a proxy +# (an older pilotctl in compat mode gets the TLS registry by name); +# - a saved transport=auto is rewritten to udp for a daemon that predates it; +# - proxy credentials never reach the installer output or ~/.pilot; +# - root reached through sudo/doas is refused, also without systemd; +# - a daemon that cannot use the proxy is never followed by an instruction +# to run `pilotctl daemon start` where the proxy is the way out, and a +# release without auto is not announced as auto (with the compat re-run +# for UDP-blocked hosts); +# - the sandbox proxy_cmd is saved only for credentials a fresh shell sees +# (not for PILOT_PROXY's), and never over an explicit PILOT_PROXY; +# - restart advice on a proxy-only host stops the running daemon before the +# recipe starts one; macOS is never sent to the Linux-only recipe, and its +# LaunchAgent start line carries the same condition as the others; +# - --transport auto with a daemon that predates auto says what stays saved; +# - --version / --channel beta install a tag the manifest does not describe +# (checksums.txt is its anchor), while the manifest hash still has to +# agree for the tag it describes. +# +# Like tests/managed-install.sh it installs a fixture release through a fake +# curl, so it needs no network. It never uses sudo (a fake sudo fails), and the +# root cases fake `id -u`; run it in a disposable container to also exercise +# the /usr/local/bin links as real root. +# The per-run environment is set in subshells on purpose (see run_install). +# shellcheck disable=SC2030,SC2031 +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) +WORK=$(mktemp -d "${TMPDIR:-/tmp}/pilot-proxy-install-test.XXXXXX") +FIXTURE="$WORK/fixture" +FAKEBIN="$WORK/fakebin" +mkdir -p "$FIXTURE/new/archive" "$FIXTURE/old/archive" "$FAKEBIN" + +fail() { + echo "FAIL: $*" >&2 + [ -n "${LOG:-}" ] && [ -f "$LOG" ] && sed 's/^/ | /' "$LOG" >&2 + exit 1 +} + +# --- Fixture binaries --------------------------------------------------------- + +# pilot-daemon: only -help matters to the installer. The "new" daemon has +# -transport (with 'auto'), -proxy and -proxy-cmd; the "old" one predates auto, +# -proxy and -proxy-cmd (v1.13.x). +cat > "$FIXTURE/new/archive/daemon" <<'SH' +#!/bin/sh +cat <<'HELP' +Usage of pilot-daemon: + -proxy string + Outbound proxy: 'auto', 'off' or an http(s):// URL. Env: PILOT_PROXY. + -proxy-cmd string + Command printing the current proxy URL. Env: PILOT_PROXY_CMD. + -registry string + registry server address + -transport string + Tunnel transport: 'udp', 'compat' or 'auto' +HELP +exit 0 +SH +cat > "$FIXTURE/old/archive/daemon" <<'SH' +#!/bin/sh +cat <<'HELP' +Usage of pilot-daemon: + -registry string + registry server address + -transport string + Tunnel transport: 'udp' (default) or 'compat' (TCP/443) +HELP +exit 0 +SH +# pilotctl: `config --set key=value` edits $HOME/.pilot/config.json the way the +# real one does (an empty value removes the key); everything else succeeds. +cat > "$FIXTURE/new/archive/pilotctl" <<'SH' +#!/bin/sh +case "$*" in + "config --set "*) + kv="${3:-}" + python3 - "$HOME/.pilot/config.json" "$kv" <<'PY' +import json, os, sys +path, kv = sys.argv[1], sys.argv[2] +k, _, v = kv.partition("=") +try: + with open(path) as f: + cfg = json.load(f) +except FileNotFoundError: + cfg = {} +if v == "": + cfg.pop(k, None) +else: + cfg[k] = v +with open(path, "w") as f: + json.dump(cfg, f, indent=2) +PY + ;; + "daemon start --help") + echo " --transport " + ;; + version) + echo v9.9.9 + ;; + *) + exit 0 + ;; +esac +SH +# The "old" pilotctl predates `daemon start --transport` (v1.13.x). +sed 's/--transport /--registry /' "$FIXTURE/new/archive/pilotctl" > "$FIXTURE/old/archive/pilotctl" +# An "ancient" daemon predates -transport altogether. +mkdir -p "$FIXTURE/ancient/archive" +cp "$FIXTURE/old/archive/pilotctl" "$FIXTURE/ancient/archive/pilotctl" +cat > "$FIXTURE/ancient/archive/daemon" <<'SH' +#!/bin/sh +cat <<'HELP' +Usage of pilot-daemon: + -registry string + registry server address +HELP +exit 0 +SH +chmod 755 "$FIXTURE"/new/archive/* "$FIXTURE"/old/archive/* "$FIXTURE"/ancient/archive/* + +make_release() { # make_release [ [ []]] + COPYFILE_DISABLE=1 tar -czf "$1/pilot-linux-amd64.tar.gz" -C "$1/archive" . + cp "$1/pilot-linux-amd64.tar.gz" "$1/pilot-darwin-amd64.tar.gz" # same fixture binaries + _sha=$(shasum -a 256 "$1/pilot-linux-amd64.tar.gz" | awk '{print $1}') + printf '%s %s\n%s %s\n' "$_sha" pilot-linux-amd64.tar.gz "$_sha" pilot-darwin-amd64.tar.gz > "$1/checksums.txt" + _url="" + if [ -n "${5:-}" ]; then + _url="\"url\": \"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/pilot-protocol/pilotprotocol/releases/download/$5/pilot-linux-amd64.tar.gz\", " + fi + cat > "$1/stable-manifest.json" < "$FAKEBIN/uname" <<'SH' +#!/bin/sh +case "${1:-}" in + -m) echo x86_64 ;; + *) echo "${PILOT_TEST_UNAME:-Linux}" ;; +esac +SH +cat > "$FAKEBIN/curl" <<'SH' +#!/bin/sh +: "${PILOT_TEST_RELEASE:?}" +url="" +output="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) output="$2"; shift 2 ;; + --max-time|-w) shift 2 ;; + http://*|https://*) url="$1"; shift ;; + *) shift ;; + esac +done +[ -n "$output" ] || exit 89 +# PILOT_TEST_EXPECT_PROXY: the proxy this download must go through. +if [ -n "${PILOT_TEST_EXPECT_PROXY:-}" ] && [ "${https_proxy:-}" != "$PILOT_TEST_EXPECT_PROXY" ]; then + echo "download not through the expected proxy" >&2; exit 5 +fi +# PILOT_TEST_CREDS: file holding the proxy password that is valid right now. +# A request with any other one gets curl's 407 failure (exit 56). After the +# manifest is served the password rotates once. +if [ -n "${PILOT_TEST_CREDS:-}" ]; then + _pw=${https_proxy#*://*:}; _pw=${_pw%%@*} + if [ "$_pw" != "$(cat "$PILOT_TEST_CREDS")" ]; then + echo 407 >> "$PILOT_TEST_CREDS.rejected" + echo "curl: (56) CONNECT tunnel failed, response 407" >&2; exit 56 + fi + case "$url" in + */.well-known/latest.json) + if [ ! -e "$PILOT_TEST_CREDS.rotated" ]; then + : > "$PILOT_TEST_CREDS.rotated" + printf 'rotated-%s\n' "$(cat "$PILOT_TEST_CREDS")" > "$PILOT_TEST_CREDS.new" + mv "$PILOT_TEST_CREDS.new" "$PILOT_TEST_CREDS" + fi ;; + esac +fi +case "$url" in + */.well-known/latest.json) src="$PILOT_TEST_RELEASE/stable-manifest.json" ;; + */pilot-linux-amd64.tar.gz) src="$PILOT_TEST_RELEASE/pilot-linux-amd64.tar.gz" ;; + */pilot-darwin-amd64.tar.gz) src="$PILOT_TEST_RELEASE/pilot-darwin-amd64.tar.gz" ;; + */checksums.txt) src="$PILOT_TEST_RELEASE/checksums.txt" ;; + *) echo "unexpected curl URL: $url" >&2; exit 88 ;; +esac +cp "$src" "$output" +SH +# Never escalate on the machine running the test. +printf '#!/bin/sh\nexit 1\n' > "$FAKEBIN/sudo" +# launchd (PILOT_TEST_UNAME=Darwin cases): nothing is loaded, nothing fails. +printf '#!/bin/sh\nexit 0\n' > "$FAKEBIN/launchctl" +# `id -u` answers $PILOT_TEST_UID when set. +REAL_ID=$(command -v id) +cat > "$FAKEBIN/id" < [installer args...] — the caller's +# environment passes through. Callers that set variables for one run do it in +# a subshell: under bash's POSIX mode (macOS sh) an assignment prefixing a +# function call outlives the call. +run_install() { + _home="$1"; _rel="$2"; LOG="$3"; shift 3 + mkdir -p "$_home" + PATH="$FAKEBIN:$PATH" HOME="$_home" PILOT_TEST_RELEASE="$_rel" \ + PILOT_EMAIL=ci@example.com \ + "${PILOT_TEST_SH:-sh}" "$ROOT/install.sh" "$@" > "$LOG" 2>&1 — the log never tells anyone to run the daemon start +# command (a line that starts with it, i.e. an instruction to run it). +no_start_command() { + if grep -E '^[[:space:]]*(pilotctl daemon (stop && pilotctl daemon )?start|sudo systemctl enable --now pilot-daemon)' "$1" >/dev/null; then + fail "$2: the output still tells the agent to start the daemon directly" + fi +} + +cfg_get() { # cfg_get — prints the value, "" when absent + python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' \ + "$1/.pilot/config.json" "$2" +} + +SECRET="s3cretPW" +PROXY="http://muse:${SECRET}@egress.test:3128" +# shellcheck disable=SC2016 # the literal command the installer saves +SANDBOX_CMD='bash -c '\''case $https_proxy in *@*) printf %s "$https_proxy";; *) printf %s "${HTTPS_PROXY:-$https_proxy}";; esac'\''' +unset HTTPS_PROXY https_proxy ALL_PROXY all_proxy PILOT_TRANSPORT PILOT_PROXY_CMD PILOT_ALLOW_ROOT \ + PILOT_PROXY SUDO_USER SUDO_UID SUDO_GID SUDO_COMMAND DOAS_USER PKEXEC_UID 2>/dev/null || true + +# 1. --transport is validated. +if run_install "$WORK/h-bad" "$FIXTURE/new" "$WORK/bad.log" --transport quic; then + fail "--transport quic was accepted" +fi +grep -F "must be 'udp', 'compat' or 'auto'" "$WORK/bad.log" >/dev/null || fail "no --transport error" + +# 2. Default install: auto is not saved; no proxy -> no proxy_cmd. +run_install "$WORK/h-default" "$FIXTURE/new" "$WORK/default.log" || fail "default install" +[ -z "$(cfg_get "$WORK/h-default" transport)" ] || fail "default install saved a transport" +[ -z "$(cfg_get "$WORK/h-default" proxy_cmd)" ] || fail "default install saved proxy_cmd" +grep -F "Transport: auto" "$WORK/default.log" >/dev/null || fail "default install did not report transport auto" +grep -F "Verified SHA-256 (checksums.txt + manifest)" "$WORK/default.log" >/dev/null \ + || fail "latest_stable install was not checked against both anchors" +if grep -F -- "--transport compat" "$WORK/default.log" >/dev/null; then + fail "a daemon with auto got the UDP-blocked compat hint" +fi + +# 2b. A release without auto (v1.13.x) is not announced as auto: the summary +# says udp, and how to get compat where UDP is blocked. +run_install "$WORK/h-default-old" "$FIXTURE/old" "$WORK/default-old.log" || fail "default install (old daemon)" +if grep -iE 'Transport: +auto' "$WORK/default-old.log" >/dev/null; then + fail "a release without auto was announced as auto" +fi +grep -F "Transport: udp (this pilot-daemon, v9.9.9, predates auto" "$WORK/default-old.log" >/dev/null \ + || fail "old daemon: transport udp not stated" +grep -F "install.sh | sh -s -- --transport compat" "$WORK/default-old.log" >/dev/null \ + || fail "old daemon: no compat hint for UDP-blocked hosts" +grep -E '^[[:space:]]*pilotctl daemon start --hostname' "$WORK/default-old.log" >/dev/null \ + || fail "a host without a proxy lost the daemon start instruction" +# ...but not when udp was chosen. +run_install "$WORK/h-udp-old" "$FIXTURE/old" "$WORK/udp-old.log" --transport udp || fail "--transport udp (old daemon)" +if grep -F -- "--transport compat" "$WORK/udp-old.log" >/dev/null; then + fail "--transport udp got the compat hint" +fi + +# 3. --transport compat is saved and survives a re-run without --transport; +# --transport auto then removes it. +run_install "$WORK/h-compat" "$FIXTURE/new" "$WORK/compat.log" --transport compat || fail "compat install" +[ "$(cfg_get "$WORK/h-compat" transport)" = compat ] || fail "--transport compat not saved" +run_install "$WORK/h-compat" "$FIXTURE/new" "$WORK/compat2.log" || fail "compat re-run" +[ "$(cfg_get "$WORK/h-compat" transport)" = compat ] || fail "re-run dropped transport=compat" +run_install "$WORK/h-compat" "$FIXTURE/new" "$WORK/compat3.log" --transport auto || fail "auto re-run" +[ -z "$(cfg_get "$WORK/h-compat" transport)" ] || fail "--transport auto did not remove the saved transport" + +# 3b. --transport auto with a daemon that predates auto: nothing to go back +# to, so the saved compat stays and the output says so (it used to say +# the daemon "keeps its default (udp)" and then report compat). +run_install "$WORK/h-compat-old" "$FIXTURE/old" "$WORK/compat-old.log" --transport compat || fail "old daemon compat install" +run_install "$WORK/h-compat-old" "$FIXTURE/old" "$WORK/compat-old2.log" --transport auto || fail "old daemon auto re-run" +[ "$(cfg_get "$WORK/h-compat-old" transport)" = compat ] || fail "old daemon: --transport auto changed the saved transport" +grep -F "transport saved in config.json (compat)" "$WORK/compat-old2.log" >/dev/null \ + || fail "old daemon: --transport auto does not say the saved compat stays" +if grep -F "keeps its default (udp)" "$WORK/compat-old2.log" >/dev/null; then + fail "old daemon: --transport auto claims udp while compat stays saved" +fi +grep -F "Transport: compat" "$WORK/compat-old2.log" >/dev/null || fail "old daemon: summary does not report the saved compat" + +# 4. Sandbox (Linux without systemd) with a credential-bearing HTTPS_PROXY: +# proxy_cmd is saved and the credentials never reach the output. +if [ ! -d /run/systemd/system ]; then + LOG="$WORK/sandbox.log" + (export HTTPS_PROXY="$PROXY" https_proxy="$PROXY" + run_install "$WORK/h-sandbox" "$FIXTURE/new" "$LOG") || fail "sandbox install" + [ "$(cfg_get "$WORK/h-sandbox" proxy_cmd)" = "$SANDBOX_CMD" ] || fail "proxy_cmd not saved in a sandbox: '$(cfg_get "$WORK/h-sandbox" proxy_cmd)'" + grep -F 'http://***@egress.test:3128' "$LOG" >/dev/null || fail "proxy not shown redacted" + # A daemon that uses the proxy is started the ordinary way. + grep -E '^[[:space:]]*pilotctl daemon start --hostname' "$LOG" >/dev/null \ + || fail "a daemon that can use the proxy lost the daemon start instruction" + if grep -F "pilot-sandbox recipe" "$LOG" >/dev/null; then + fail "a daemon that can use the proxy was sent to the sandbox recipe" + fi + if grep -F "$SECRET" "$LOG" "$WORK/h-sandbox/.pilot/config.json" >/dev/null; then + fail "proxy credentials leaked" + fi + + # An explicit PILOT_PROXY_CMD wins; an existing proxy_cmd is never replaced. + LOG="$WORK/sandbox2.log" + (export PILOT_PROXY_CMD='cat /run/proxy-url' HTTPS_PROXY="$PROXY" + run_install "$WORK/h-sandbox2" "$FIXTURE/new" "$LOG") || fail "PILOT_PROXY_CMD install" + [ "$(cfg_get "$WORK/h-sandbox2" proxy_cmd)" = 'cat /run/proxy-url' ] || fail "PILOT_PROXY_CMD not saved" + LOG="$WORK/sandbox3.log" + (export HTTPS_PROXY="$PROXY" + run_install "$WORK/h-sandbox2" "$FIXTURE/new" "$LOG") || fail "sandbox re-run" + [ "$(cfg_get "$WORK/h-sandbox2" proxy_cmd)" = 'cat /run/proxy-url' ] || fail "re-run replaced proxy_cmd" + + # A proxy without credentials has nothing to rotate. + LOG="$WORK/nocreds.log" + (export HTTPS_PROXY=http://egress.test:3128 + run_install "$WORK/h-nocreds" "$FIXTURE/new" "$LOG") || fail "no-creds install" + [ -z "$(cfg_get "$WORK/h-nocreds" proxy_cmd)" ] || fail "proxy_cmd saved for a proxy without credentials" + + # A daemon without -proxy-cmd (v1.13.x): proxy_cmd is still saved (the + # daemon ignores it until upgraded), with a note, and the proxy it cannot + # use is called out with the recipe that works. + LOG="$WORK/oldcmd.log" + (export HTTPS_PROXY="$PROXY" + run_install "$WORK/h-oldcmd" "$FIXTURE/old" "$LOG") || fail "old daemon sandbox install" + [ "$(cfg_get "$WORK/h-oldcmd" proxy_cmd)" = "$SANDBOX_CMD" ] || fail "proxy_cmd not saved for a daemon without -proxy-cmd" + grep -F "predates -proxy-cmd" "$LOG" >/dev/null || fail "no -proxy-cmd note for an old daemon" + grep -F "cannot use one" "$LOG" >/dev/null || fail "no warning about a proxy the old daemon cannot use" + grep -F "https://pilotprotocol.network/learn/install-pilot-skills-in-meta-muse" "$LOG" >/dev/null \ + || fail "the old-daemon proxy warning does not point at the sandbox recipe" + # The proxy is the way out (its credentials rotate): nothing tells the + # agent to run `pilotctl daemon start`, which would dial the registry + # around the proxy; GET STARTED points at the recipe instead. + no_start_command "$LOG" "old daemon in a proxy-only sandbox" + # shellcheck disable=SC2016 # literal backquotes + grep -F 'Do NOT run `pilotctl daemon start` on this host' "$LOG" >/dev/null \ + || fail "GET STARTED does not warn against pilotctl daemon start" + [ "$(grep -c -F "https://pilotprotocol.network/learn/install-pilot-skills-in-meta-muse" "$LOG")" -ge 3 ] \ + || fail "the warning, the no-systemd hint and GET STARTED do not all point at the recipe" + if grep -iE 'Transport: +auto' "$LOG" >/dev/null; then fail "old daemon announced as auto (sandbox)"; fi + if grep -F -- "--transport compat" "$LOG" >/dev/null; then + fail "the compat hint was shown where compat cannot use the proxy either" + fi + if grep -F "restart the daemon from a fresh shell" "$LOG" >/dev/null; then + fail "a daemon that cannot use a proxy was told to refresh proxy credentials" + fi + # A re-run (update) does not tell it to restart the daemon directly either. + LOG="$WORK/oldcmd-rerun.log" + (export HTTPS_PROXY="$PROXY" + run_install "$WORK/h-oldcmd" "$FIXTURE/old" "$LOG") || fail "old daemon sandbox re-run" + no_start_command "$LOG" "old daemon sandbox re-run" + grep -F "https://pilotprotocol.network/learn/install-pilot-skills-in-meta-muse" "$LOG" >/dev/null \ + || fail "the re-run does not point at the recipe" + # The recipe starts a daemon but never stops one: the restart advice + # must stop the running one first, or following it runs two daemons + # with one identity. + grep -F "Stop the running daemon first: pilotctl daemon stop" "$LOG" >/dev/null \ + || fail "the re-run advice dropped \`pilotctl daemon stop\` before the recipe" + LOG="$WORK/oldcmd.log" + # --transport compat on the same host: same story. + LOG="$WORK/oldcmd-compat.log" + (export HTTPS_PROXY="$PROXY" + run_install "$WORK/h-oldcmd-compat" "$FIXTURE/old" "$LOG" --transport compat) || fail "old daemon sandbox compat install" + no_start_command "$LOG" "old daemon, compat, proxy-only sandbox" + LOG="$WORK/oldcmd.log" + + # A proxy without credentials may not be the only way out: the start + # command stays, with the condition and the recipe next to it. + LOG="$WORK/old-nocreds.log" + (export HTTPS_PROXY=http://egress.test:3128 + run_install "$WORK/h-old-nocreds" "$FIXTURE/old" "$LOG") || fail "old daemon, proxy without credentials" + grep -F "skip the next line; what works then:" "$LOG" >/dev/null \ + || fail "no condition next to the start command (proxy without credentials)" + grep -E '^[[:space:]]*pilotctl daemon start --hostname' "$LOG" >/dev/null \ + || fail "the start command was dropped for a proxy that may not be the only way out" + LOG="$WORK/oldcmd.log" + + # auto is not supported, so the daemon runs udp: the stock endpoints stay. + [ "$(cfg_get "$WORK/h-oldcmd" registry)" = "34.71.57.205:9000" ] || fail "udp install lost the raw registry" + if grep -F "$SECRET" "$LOG" >/dev/null || grep -rF "$SECRET" "$WORK/h-oldcmd/.pilot" >/dev/null; then + fail "proxy credentials leaked (old daemon)" + fi + + # Credentials that rotate while the installer runs: the download that + # gets the 407 is retried once with what a fresh bash sees (BASH_ENV + # stands in for the sandbox's mechanism), and nothing is printed. + if command -v bash >/dev/null 2>&1; then + CREDS="$WORK/creds" + printf 'gen1pw\n' > "$CREDS" + cat > "$WORK/bash_env.sh" </dev/null || fail "rotated install did not verify the archive" + if grep -F -e gen1pw -e rotated-gen1pw "$LOG" >/dev/null \ + || grep -rF -e gen1pw "$WORK/h-rotate/.pilot" >/dev/null; then + fail "proxy credentials leaked (rotation)" + fi + else + echo "skip: rotation case needs bash" + fi + + # 4b. Compat / auto behind a proxy: no raw-IP registry or beacon in + # config.json (the daemon picks the TLS registry itself), and no + # credentials anywhere under ~/.pilot. + LOG="$WORK/auto-proxy.log" + (export HTTPS_PROXY="$PROXY" https_proxy="$PROXY" + run_install "$WORK/h-auto-proxy" "$FIXTURE/new" "$LOG") || fail "auto install behind a proxy" + if grep -F "34.71.57.205" "$WORK/h-auto-proxy/.pilot/config.json" >/dev/null; then + fail "auto install behind a proxy wrote a raw-IP endpoint" + fi + [ -z "$(cfg_get "$WORK/h-auto-proxy" transport)" ] || fail "auto install saved a transport" + if grep -F "$SECRET" "$LOG" >/dev/null || grep -rF "$SECRET" "$WORK/h-auto-proxy/.pilot" >/dev/null; then + fail "proxy credentials leaked (auto behind a proxy)" + fi + + LOG="$WORK/compat-proxy.log" + (export HTTPS_PROXY="$PROXY" https_proxy="$PROXY" + run_install "$WORK/h-compat-proxy" "$FIXTURE/new" "$LOG" --transport compat) || fail "compat install behind a proxy" + if grep -F "34.71.57.205" "$WORK/h-compat-proxy/.pilot/config.json" >/dev/null; then + fail "compat install wrote a raw-IP endpoint" + fi + [ "$(cfg_get "$WORK/h-compat-proxy" transport)" = compat ] || fail "compat not saved (proxy)" + + # 4c. PILOT_PROXY carries the downloads when no *_PROXY is set. + LOG="$WORK/pilot-proxy.log" + (export PILOT_PROXY=http://relay.test:3128 PILOT_TEST_EXPECT_PROXY=http://relay.test:3128 + run_install "$WORK/h-pilot-proxy" "$FIXTURE/new" "$LOG") || fail "downloads did not use PILOT_PROXY" + + # 4d. Credentials that come from PILOT_PROXY never reach a fresh shell, + # so the sandbox proxy command (which prints a fresh shell's + # $https_proxy / $HTTPS_PROXY) is not saved and rotation is not + # claimed; nor does it replace an explicit PILOT_PROXY next to a + # credential-bearing HTTPS_PROXY (the daemon runs a proxy command in + # place of the URL it would use). + LOG="$WORK/pilot-proxy-creds.log" + (export PILOT_PROXY="$PROXY" + run_install "$WORK/h-pilot-proxy-creds" "$FIXTURE/new" "$LOG") || fail "PILOT_PROXY with credentials" + [ -z "$(cfg_get "$WORK/h-pilot-proxy-creds" proxy_cmd)" ] || fail "sandbox proxy_cmd saved for credentials that come from PILOT_PROXY" + if grep -F -e "re-read by the daemon" -e "rotation needs no restart" "$LOG" >/dev/null; then + fail "rotation claimed for credentials that come from PILOT_PROXY" + fi + if grep -F "$SECRET" "$LOG" >/dev/null || grep -rF "$SECRET" "$WORK/h-pilot-proxy-creds/.pilot" >/dev/null; then + fail "proxy credentials leaked (PILOT_PROXY)" + fi + LOG="$WORK/pilot-proxy-explicit.log" + (export PILOT_PROXY=http://relay.test:3128 HTTPS_PROXY="$PROXY" + run_install "$WORK/h-pilot-proxy-explicit" "$FIXTURE/new" "$LOG") || fail "explicit PILOT_PROXY next to HTTPS_PROXY" + [ -z "$(cfg_get "$WORK/h-pilot-proxy-explicit" proxy_cmd)" ] || fail "sandbox proxy_cmd saved over an explicit PILOT_PROXY" + + # 5. Root: allowed in a Linux container/VM without systemd. + LOG="$WORK/root.log" + (export PILOT_TEST_UID=0 HTTPS_PROXY="$PROXY" + run_install "$WORK/h-root" "$FIXTURE/new" "$LOG") || fail "root install in a sandbox was refused" + grep -F "installing as root (no systemd" "$LOG" >/dev/null || fail "no root note" + [ -x "$WORK/h-root/.pilot/bin/pilotctl" ] || fail "root install did not install pilotctl" + [ "$(cfg_get "$WORK/h-root" proxy_cmd)" = "$SANDBOX_CMD" ] || fail "root sandbox install did not save proxy_cmd" + + # sudo run by root itself (SUDO_UID=0) is still root's own install. + LOG="$WORK/root-sudo-root.log" + (export PILOT_TEST_UID=0 SUDO_USER=root SUDO_UID=0 + run_install "$WORK/h-root-sudo-root" "$FIXTURE/new" "$LOG") || fail "root via sudo from root was refused" + # PILOT_ALLOW_ROOT=1 still overrides the sudo refusal. + LOG="$WORK/root-sudo-allow.log" + (export PILOT_TEST_UID=0 SUDO_USER=agent SUDO_UID=1000 PILOT_ALLOW_ROOT=1 + run_install "$WORK/h-root-sudo-allow" "$FIXTURE/new" "$LOG") || fail "PILOT_ALLOW_ROOT=1 did not override the sudo refusal" +else + # 5b. Root on a host with systemd is still refused (PILOT_ALLOW_ROOT=1 overrides). + LOG="$WORK/root.log" + if (export PILOT_TEST_UID=0; run_install "$WORK/h-root" "$FIXTURE/new" "$LOG"); then + fail "root install on a systemd host was accepted" + fi + grep -F "refusing to install as root" "$LOG" >/dev/null || fail "no root refusal" + [ ! -e "$WORK/h-root/.pilot" ] || fail "refused root install left ~/.pilot behind" + echo "skip: sandbox cases need a host without /run/systemd/system" +fi + +# 6. Downgrade: a saved transport=auto is rewritten to udp for a daemon that +# predates auto (it would refuse to start). +mkdir -p "$WORK/h-down/.pilot" +printf '{\n "registry": "34.71.57.205:9000",\n "transport": "auto"\n}\n' > "$WORK/h-down/.pilot/config.json" +run_install "$WORK/h-down" "$FIXTURE/old" "$WORK/down.log" || fail "downgrade install" +[ "$(cfg_get "$WORK/h-down" transport)" = udp ] || fail "transport=auto not rewritten to udp for an old daemon" + +# 7. Leaving compat restores the raw-TCP registry an older compat install saved. +mkdir -p "$WORK/h-back/.pilot" +printf '{\n "registry": "registry.pilotprotocol.network:443",\n "transport": "compat"\n}\n' > "$WORK/h-back/.pilot/config.json" +run_install "$WORK/h-back" "$FIXTURE/new" "$WORK/back.log" --transport udp || fail "switch-back install" +[ "$(cfg_get "$WORK/h-back" transport)" = udp ] || fail "--transport udp not saved" +[ "$(cfg_get "$WORK/h-back" registry)" = "34.71.57.205:9000" ] || fail "raw registry not restored" + +# 8. Without a proxy the stock endpoints are written as before (auto or udp). +[ "$(cfg_get "$WORK/h-default" registry)" = "34.71.57.205:9000" ] || fail "default install lost the raw registry" +[ "$(cfg_get "$WORK/h-default" beacon)" = "34.71.57.205:9001" ] || fail "default install lost the raw beacon" + +# 9. compat with a pilotctl that predates --transport (it passes config.json's +# registry to the daemon verbatim): the TLS registry is written by name. +run_install "$WORK/h-oldctl" "$FIXTURE/old" "$WORK/oldctl.log" --transport compat || fail "old pilotctl compat install" +[ "$(cfg_get "$WORK/h-oldctl" registry)" = "registry.pilotprotocol.network:443" ] \ + || fail "old pilotctl compat install: registry '$(cfg_get "$WORK/h-oldctl" registry)'" +if grep -F "34.71.57.205" "$WORK/h-oldctl/.pilot/config.json" >/dev/null; then + fail "old pilotctl compat install wrote a raw-IP endpoint" +fi + +# 9b. compat with a daemon that predates -transport: the summary does not +# claim compat. +run_install "$WORK/h-ancient" "$FIXTURE/ancient" "$WORK/ancient.log" --transport compat || fail "ancient daemon compat install" +grep -F "predates compat mode" "$WORK/ancient.log" >/dev/null || fail "ancient daemon: no compat warning" +grep -F "Transport: udp (compat is saved, but this pilot-daemon" "$WORK/ancient.log" >/dev/null \ + || fail "ancient daemon: the summary claims compat" + +# 10. Root on macOS is refused like on any host with a service manager. +LOG="$WORK/root-mac.log" +if (export PILOT_TEST_UID=0 PILOT_TEST_UNAME=Darwin; run_install "$WORK/h-root-mac" "$FIXTURE/new" "$LOG"); then + fail "root install on macOS was accepted" +fi +grep -F "refusing to install as root" "$LOG" >/dev/null || fail "no root refusal on macOS" + +# 10a. macOS with a proxy this release cannot use: the LaunchAgent start +# line carries the same condition as every other start line, and +# nothing points at the Linux-root-only sandbox recipe. +LOG="$WORK/mac-proxy.log" +(export PILOT_TEST_UNAME=Darwin HTTPS_PROXY="$PROXY" + run_install "$WORK/h-mac-proxy" "$FIXTURE/old" "$LOG") || fail "macOS install behind a proxy (old daemon)" +grep -F "cannot use one" "$LOG" >/dev/null || fail "macOS: no warning about a proxy the old daemon cannot use" +if grep -F "pilot-sandbox recipe (step 3)" "$LOG" >/dev/null; then + fail "macOS was sent to the Linux-only pilot-sandbox recipe" +fi +if grep -E '^[[:space:]]*Start daemon: launchctl load' "$LOG" >/dev/null; then + fail "macOS: the LaunchAgent start line has no condition next to it" +fi +grep -F "it will not come online with" "$LOG" >/dev/null || fail "macOS: no condition next to the LaunchAgent start line" +grep -F "lists -proxy" "$LOG" >/dev/null || fail "macOS: no way forward named" +LOG="$WORK/mac-proxy-cmd.log" +(export PILOT_TEST_UNAME=Darwin HTTPS_PROXY="$PROXY" PILOT_PROXY_CMD='cat /run/proxy-url' + run_install "$WORK/h-mac-proxy-cmd" "$FIXTURE/old" "$LOG") || fail "macOS install, PILOT_PROXY_CMD (old daemon)" +if grep -E '^[[:space:]]*(launchctl load|Start daemon: launchctl load)' "$LOG" >/dev/null; then + fail "macOS proxy-only: still told to load the LaunchAgent" +fi +# shellcheck disable=SC2016 # literal backquotes +grep -F 'Do not run `launchctl load -w' "$LOG" >/dev/null || fail "macOS proxy-only: no warning against loading the LaunchAgent" +if grep -F "pilot-sandbox recipe (step 3)" "$LOG" >/dev/null; then + fail "macOS proxy-only was sent to the Linux-only pilot-sandbox recipe" +fi +if grep -F "$SECRET" "$LOG" >/dev/null; then fail "proxy credentials leaked (macOS)"; fi + +# 10b. Root through sudo/doas for a regular user is refused, with or without +# systemd: that user could not use a node installed for root. +for _elev in "SUDO_USER=agent SUDO_UID=1000" "SUDO_USER=agent" "DOAS_USER=agent"; do + LOG="$WORK/root-elev.log" + rm -rf "$WORK/h-root-elev" + # shellcheck disable=SC2086,SC2163 # intentional split into NAME=value words + if (export PILOT_TEST_UID=0 $_elev; run_install "$WORK/h-root-elev" "$FIXTURE/new" "$LOG"); then + fail "root install under '$_elev' was accepted" + fi + grep -F "refusing to install as root: this runs under" "$LOG" >/dev/null || fail "no sudo refusal ($_elev)" + grep -F "for agent" "$LOG" >/dev/null || fail "the sudo refusal does not name the user ($_elev)" + [ ! -e "$WORK/h-root-elev/.pilot" ] || fail "refused sudo install left ~/.pilot behind ($_elev)" +done + +# 10c. --version / --channel beta: a tag the manifest does not describe is +# checked against checksums.txt alone (previously: "integrity anchors +# disagree" for every tag but latest_stable). +for _mf in pinned pinned-nourl; do + run_install "$WORK/h-pin-$_mf" "$FIXTURE/$_mf" "$WORK/pin-$_mf.log" --version v9.9.8 --yes \ + || fail "--version v9.9.8 ($_mf manifest) was refused" + grep -F "Verified SHA-256 (checksums.txt)" "$WORK/pin-$_mf.log" >/dev/null \ + || fail "--version v9.9.8 ($_mf manifest): not verified against checksums.txt" + [ "$(cat "$WORK/h-pin-$_mf/.pilot/bin/.pilot-version")" = v9.9.8 ] || fail "--version v9.9.8 ($_mf): wrong version file" + run_install "$WORK/h-beta-$_mf" "$FIXTURE/$_mf" "$WORK/beta-$_mf.log" --channel beta \ + || fail "--channel beta ($_mf manifest) was refused" + grep -F "Downloading v9.9.10-rc.1" "$WORK/beta-$_mf.log" >/dev/null || fail "--channel beta did not resolve the beta tag" + # The tag the manifest describes still needs both anchors to agree. + if run_install "$WORK/h-latest-$_mf" "$FIXTURE/$_mf" "$WORK/latest-$_mf.log" --version v9.9.9; then + fail "a manifest hash that disagrees was ignored for latest_stable ($_mf)" + fi + grep -F "integrity anchors disagree" "$WORK/latest-$_mf.log" >/dev/null || fail "no anchor mismatch error ($_mf)" +done +# A manifest whose platform url names the pinned tag is an anchor for it. +if run_install "$WORK/h-pin-named" "$FIXTURE/pinned-named" "$WORK/pin-named.log" --version v9.9.8 --yes; then + fail "a manifest hash for the pinned tag that disagrees was ignored" +fi +grep -F "integrity anchors disagree" "$WORK/pin-named.log" >/dev/null || fail "no anchor mismatch error (url names the tag)" + +# 11. --help prints the whole usage header and nothing past it. +"${PILOT_TEST_SH:-sh}" "$ROOT/install.sh" --help > "$WORK/help.log" 2>&1 || fail "--help failed" +grep -F -- "--transport " "$WORK/help.log" >/dev/null || fail "--help lacks --transport" +grep -F "with a message, never fatal." "$WORK/help.log" >/dev/null || fail "--help cut the header short" +if grep -F "WHAT THIS SCRIPT DOES" "$WORK/help.log" >/dev/null; then fail "--help printed past the usage header"; fi + +if [ -n "${PILOT_TEST_KEEP:-}" ]; then echo "logs kept in $WORK"; else rm -rf "$WORK"; fi +echo "proxy/transport installer contract: ok"