#!/bin/sh # Umbra provider installer — turn an idle Apple Silicon Mac into a private # inference provider. Canonical source: https://tryumbra.dev/install.sh # Supported for both first installs and updates. # Downloads the signed, notarized DMG from tryumbra.dev and verifies it. # Terms: https://tryumbra.dev/terms/ # Provider Agreement: https://tryumbra.dev/provider-agreement/ # Privacy: https://tryumbra.dev/privacy/ # Removal instructions: https://tryumbra.dev/download/#remove-heading # Changes: replaces /Applications/Umbra Provider.app and /usr/local/bin/umbra. # A legacy /opt/homebrew/bin/umbra wrapper is updated only when Umbra owns it. # It does not approve an MDM profile, change browser settings, or install a CA. set -eu DL_URL="https://tryumbra.dev/dl/umbra-macos-arm64.dmg?sha256=a0f4b757aef970594b4600445669ee82687845ce6ed474f28597570bcb78644c" DL_SHA256="a0f4b757aef970594b4600445669ee82687845ce6ed474f28597570bcb78644c" COORD_URL="https://api.tryumbra.dev" APP_DIR="${UMBRA_INSTALL_DIR:-/Applications}" APP_PATH="$APP_DIR/Umbra Provider.app" BIN_DIR="${UMBRA_BIN_DIR:-/usr/local/bin}" HOMEBREW_SHADOW="${UMBRA_HOMEBREW_SHADOW:-/opt/homebrew/bin/umbra}" [ -n "$APP_DIR" ] && [ "$APP_DIR" != "/" ] || { echo "Invalid UMBRA_INSTALL_DIR: $APP_DIR" >&2; exit 1; } [ -n "$BIN_DIR" ] && [ "$BIN_DIR" != "/" ] || { echo "Invalid UMBRA_BIN_DIR: $BIN_DIR" >&2; exit 1; } # ---- pretty output (no color if not a TTY) ------------------------------- if [ -t 1 ]; then B="$(printf '\033[1m')"; DIM="$(printf '\033[2m')"; R="$(printf '\033[0m')" GRN="$(printf '\033[32m')"; RED="$(printf '\033[31m')" else B=""; DIM=""; R=""; GRN=""; RED="" fi say() { printf '%s\n' "$*"; } ok() { printf '%s\342\234\223%s %s\n' "$GRN" "$R" "$*"; } die() { printf '%s%s%s\n' "$RED" "$*" "$R" 1>&2; exit 1; } DRY_RUN=0 while [ "$#" -gt 0 ]; do case "$1" in --dry-run) DRY_RUN=1 ;; -h|--help) say "Usage: curl -fsSL https://tryumbra.dev/install.sh | sh" say " curl -fsSL https://tryumbra.dev/install.sh | sh -s -- --dry-run" say "" say "--dry-run Download and verify the release without installing it." exit 0 ;; *) die "Unknown installer option: $1" ;; esac shift done say "" say "${B}Umbra provider${R}" say "${DIM}private inference on idle Apple Silicon${R}" say "" say "This installer downloads Umbra Provider.app from $DL_URL." say "It replaces $APP_PATH and $BIN_DIR/umbra after checksum, signature," say "Developer ID, and Gatekeeper verification. It installs no bundled apps." say "Terms and removal steps: https://tryumbra.dev/download/" say "" # ---- OS + arch gate ------------------------------------------------------ OS="$(uname -s 2>/dev/null || echo unknown)" ARCH="$(uname -m 2>/dev/null || echo unknown)" [ "$OS" = "Darwin" ] || die "Umbra runs on macOS only (this is $OS)." [ "$ARCH" = "arm64" ] || die "Umbra requires Apple Silicon, M1 or newer (this is $ARCH)." MACOS_VERSION="$(sw_vers -productVersion 2>/dev/null || true)" MACOS_MAJOR="${MACOS_VERSION%%.*}" case "$MACOS_MAJOR" in ""|*[!0-9]*) die "Could not determine the macOS version." ;; esac [ "$MACOS_MAJOR" -ge 14 ] || die "Umbra requires macOS 14 or newer (this is ${MACOS_VERSION:-unknown})." # ---- download ------------------------------------------------------------ TMP="$(mktemp -d)" MOUNT="" cleanup() { [ -z "$MOUNT" ] || hdiutil detach "$MOUNT" >/dev/null 2>&1 || true rm -rf "$TMP" } trap cleanup EXIT say "Downloading umbra..." # --retry 3 --retry-delay 2 survives transient edge/CDN blips; -C - resumes a # partial download into the fresh per-run temp file (a clean run is a no-op). if ! curl -fsSL --retry 3 --retry-delay 2 -C - "$DL_URL" -o "$TMP/umbra.dmg"; then say "" die "The signed umbra build is not published yet (Umbra is in alpha). See https://tryumbra.dev/console/setup for status, or try again soon." fi GOT="$(shasum -a 256 "$TMP/umbra.dmg" | awk '{print $1}')" [ "$GOT" = "$DL_SHA256" ] || die "Download checksum mismatch. Expected $DL_SHA256, got $GOT." ok "download checksum verified" # ---- notarization gate + mount ------------------------------------------ command -v hdiutil >/dev/null 2>&1 || die "hdiutil is required to mount the Umbra release." command -v codesign >/dev/null 2>&1 || die "codesign is required to verify the Umbra release." command -v spctl >/dev/null 2>&1 || die "spctl is required to verify the Umbra release." codesign --verify --strict --verbose=2 "$TMP/umbra.dmg" >/dev/null 2>&1 || die "Umbra disk image signature verification failed." DMG_TEAM="$(codesign -dv --verbose=4 "$TMP/umbra.dmg" 2>&1 | awk -F= '/^TeamIdentifier=/{print $2; exit}')" [ "$DMG_TEAM" = "3LHSL95J9H" ] || die "Unexpected Umbra disk-image Team ID: ${DMG_TEAM:-missing}." spctl -a -t open --context context:primary-signature "$TMP/umbra.dmg" >/dev/null 2>&1 || die "Gatekeeper rejected this Umbra release; its notarization ticket is invalid or missing." MOUNT="$TMP/mount" mkdir -p "$MOUNT" hdiutil attach -readonly -nobrowse -noautoopen -mountpoint "$MOUNT" "$TMP/umbra.dmg" >/dev/null || die "Could not mount the Umbra release." SRC_APP="$MOUNT/Umbra Provider.app" SRC_BIN="$SRC_APP/Contents/MacOS/umbra" [ -f "$SRC_BIN" ] || die "The disk image did not contain Umbra Provider.app." # ---- nested signature gate ---------------------------------------------- codesign --verify --deep --strict --verbose=2 "$SRC_APP" >/dev/null 2>&1 || die "Umbra app signature verification failed." TEAM="$(codesign -dv --verbose=4 "$SRC_BIN" 2>&1 | awk -F= '/^TeamIdentifier=/{print $2; exit}')" [ "$TEAM" = "3LHSL95J9H" ] || die "Unexpected Umbra signing Team ID: ${TEAM:-missing}." ENT_PLIST="$TMP/umbra-entitlements.plist" codesign -d --entitlements :- "$SRC_BIN" > "$ENT_PLIST" 2>/dev/null || die "Could not read Umbra entitlements." APP_ID="$(/usr/libexec/PlistBuddy -c 'Print :com.apple.application-identifier' "$ENT_PLIST" 2>/dev/null || true)" [ "$APP_ID" = "3LHSL95J9H.com.umbra.provider" ] || die "Umbra app identity entitlement is missing or invalid." ACCESS_GROUP="$(/usr/libexec/PlistBuddy -c 'Print :keychain-access-groups:0' "$ENT_PLIST" 2>/dev/null || true)" [ "$ACCESS_GROUP" = "3LHSL95J9H.com.umbra.provider" ] || die "Umbra keychain access group is missing or invalid." [ -f "$SRC_APP/Contents/embedded.provisionprofile" ] || die "Umbra provisioning profile is missing." if [ -d "$SRC_APP/Contents/Frameworks" ]; then find "$SRC_APP/Contents/Frameworks" -type f -name '*.dylib' -print | while IFS= read -r dylib; do codesign --verify --strict --verbose=2 "$dylib" >/dev/null 2>&1 || exit 1 DYLIB_TEAM="$(codesign -dv --verbose=4 "$dylib" 2>&1 | awk -F= '/^TeamIdentifier=/{print $2; exit}')" [ "$DYLIB_TEAM" = "3LHSL95J9H" ] || exit 1 done || die "Bundled library signature or Team ID verification failed." fi ok "Developer ID and notarization verified" if [ "$DRY_RUN" = "1" ]; then say "" ok "dry run complete; no files were changed" say "Would install $APP_PATH and $BIN_DIR/umbra." say "Guided setup is a separate command and still requires your browser sign-in" say "and explicit approval of the Umbra profile in macOS System Settings." exit 0 fi # ---- install bundle + PATH wrapper --------------------------------------- WRAP="$TMP/umbra-wrapper" printf '#!/bin/sh\nexport UMBRA_COORD_URL="${UMBRA_COORD_URL:-%s}"\nexec "%s/Contents/MacOS/umbra" "$@"\n' "$COORD_URL" "$APP_PATH" > "$WRAP" chmod +x "$WRAP" is_owned_umbra_wrapper() { candidate="$1" if [ -L "$candidate" ]; then link_target="$(readlink "$candidate" 2>/dev/null || true)" case "$link_target" in *"Umbra Provider.app/Contents/MacOS/umbra") return 0 ;; esac fi [ -f "$candidate" ] || return 1 [ "$(sed -n '1p' "$candidate" 2>/dev/null || true)" = "#!/bin/sh" ] || return 1 grep -Fq 'Umbra Provider.app/Contents/MacOS/umbra' "$candidate" 2>/dev/null } sync_owned_shadow_local() { shadow="$1" if [ ! -e "$shadow" ] && [ ! -L "$shadow" ]; then return 0; fi if ! is_owned_umbra_wrapper "$shadow"; then say "Leaving unrelated executable at $shadow unchanged." return 0 fi shadow_tmp="${shadow}.umbra-new.$$" rm -f "$shadow_tmp" cp "$WRAP" "$shadow_tmp" && chmod +x "$shadow_tmp" && mv -f "$shadow_tmp" "$shadow" || { rm -f "$shadow_tmp"; return 1; } } sync_owned_shadow_sudo() { shadow="$1" if [ ! -e "$shadow" ] && [ ! -L "$shadow" ]; then return 0; fi if ! is_owned_umbra_wrapper "$shadow"; then say "Leaving unrelated executable at $shadow unchanged." return 0 fi shadow_tmp="${shadow}.umbra-new.$$" $SUDO rm -f "$shadow_tmp" $SUDO cp "$WRAP" "$shadow_tmp" && $SUDO chmod +x "$shadow_tmp" && $SUDO mv -f "$shadow_tmp" "$shadow" || { $SUDO rm -f "$shadow_tmp"; return 1; } } STAGE="$APP_DIR/.Umbra Provider.app.new.$$" BACKUP="$APP_DIR/.Umbra Provider.app.old.$$" install_local() { mkdir -p "$APP_DIR" "$BIN_DIR" || return 1 rm -rf "$STAGE" "$BACKUP" || return 1 ditto "$SRC_APP" "$STAGE" || return 1 codesign --verify --deep --strict --verbose=2 "$STAGE" >/dev/null 2>&1 || { rm -rf "$STAGE"; return 1; } if [ -e "$APP_PATH" ]; then mv "$APP_PATH" "$BACKUP" || return 1; fi if mv "$STAGE" "$APP_PATH"; then rm -rf "$BACKUP" else [ ! -e "$BACKUP" ] || mv "$BACKUP" "$APP_PATH" return 1 fi cp "$WRAP" "$BIN_DIR/umbra" && chmod +x "$BIN_DIR/umbra" # Update a legacy early-PATH wrapper only after proving Umbra owns it. [ "$HOMEBREW_SHADOW" = "$BIN_DIR/umbra" ] || sync_owned_shadow_local "$HOMEBREW_SHADOW" || true } install_sudo() { # sudo hides the password by taking the controlling terminal into no-echo # mode. When this installer is run from a spawned child (e.g. `umbra # update`) rather than an interactive shell, a plain `sudo` prompt cannot # do that and would echo the password in cleartext. If the caller provides # a SUDO_ASKPASS helper, use `sudo -A` so the prompt is routed there # (hidden); otherwise plain `sudo` prompts hidden on the interactive TTY. SUDO="sudo" if [ -n "${SUDO_ASKPASS:-}" ] && [ -x "${SUDO_ASKPASS}" ]; then SUDO="sudo -A"; fi $SUDO mkdir -p "$APP_DIR" "$BIN_DIR" || return 1 $SUDO rm -rf "$STAGE" "$BACKUP" || return 1 $SUDO ditto "$SRC_APP" "$STAGE" || return 1 codesign --verify --deep --strict --verbose=2 "$STAGE" >/dev/null 2>&1 || { $SUDO rm -rf "$STAGE"; return 1; } if [ -e "$APP_PATH" ]; then $SUDO mv "$APP_PATH" "$BACKUP" || return 1; fi if $SUDO mv "$STAGE" "$APP_PATH"; then $SUDO rm -rf "$BACKUP" else [ ! -e "$BACKUP" ] || $SUDO mv "$BACKUP" "$APP_PATH" return 1 fi $SUDO cp "$WRAP" "$BIN_DIR/umbra" && $SUDO chmod +x "$BIN_DIR/umbra" [ "$HOMEBREW_SHADOW" = "$BIN_DIR/umbra" ] || sync_owned_shadow_sudo "$HOMEBREW_SHADOW" || true } if install_local 2>/dev/null; then : else say "Installing to $APP_DIR and $BIN_DIR needs admin (sudo)..." install_sudo || die "Could not install to $APP_DIR and $BIN_DIR. Set UMBRA_INSTALL_DIR/UMBRA_BIN_DIR to writable dirs and re-run." fi codesign --verify --deep --strict --verbose=2 "$APP_PATH" >/dev/null 2>&1 || die "Installed Umbra app signature changed during copy." ok "installed Umbra Provider.app to $APP_PATH" ok "installed umbra command to $BIN_DIR/umbra" "$BIN_DIR/umbra" version >/dev/null 2>&1 || die "Installed umbra did not start correctly." # Repair an existing managed host so upgrades stop launching the legacy # bare-CLI path. This operates in the invoking user launchd domain even when # the app copy required sudo. HOST_PLIST="$HOME/Library/LaunchAgents/dev.tryumbra.host.plist" if [ -f "$HOST_PLIST" ]; then # Rewrite the WHOLE array: `plutil -replace ProgramArguments.0` INSERTS at index 0 # on newer macOS instead of replacing, which shifted host-run to argv[2] and put the # service into a usage-print respawn loop (argv[1] = binary path = unknown command). if plutil -replace ProgramArguments -json "[\"$APP_PATH/Contents/MacOS/umbra\",\"host-run\"]" "$HOST_PLIST"; then DOMAIN="gui/$(id -u)" launchctl bootout "$DOMAIN" "$HOST_PLIST" >/dev/null 2>&1 || true launchctl bootstrap "$DOMAIN" "$HOST_PLIST" >/dev/null 2>&1 || die "Umbra installed, but the existing host service could not be restarted." launchctl kickstart -k "$DOMAIN/dev.tryumbra.host" >/dev/null 2>&1 || die "Umbra installed, but the host service did not start." # Clear intentional hold from stopPreservingSpec so status/doctor can # revive again after a successful update. rm -f "$HOME/.umbra/host-hold" ok "migrated and restarted the existing host service" else die "Umbra installed, but the existing host service path could not be migrated." fi fi case ":$PATH:" in *":$BIN_DIR:"*) ;; *) say "${DIM}note: $BIN_DIR is not on your PATH; add it, or run $BIN_DIR/umbra directly${R}" ;; esac say "" say "Next: ${B}umbra setup${R}" say "${DIM}umbra setup signs you in, enrolls this Mac for attestation (you approve a profile in System Settings), and starts hosting. Providers must be enrolled or the network rejects them.${R}" say "${DIM}Install changes and uninstall instructions: https://tryumbra.dev/download/${R}" say ""