#!/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. # On a first install it starts guided setup; macOS still requires the owner to # approve the MDM profile. It does not change browser settings or install a CA. set -eu DL_URL='https://umbra-web.pages.dev/dl/umbra-macos-arm64-v0.4.1-alpha.26.dmg?sha256=8428e60b99fc83dfa710c2c8ad9477654ed1a0301b52f71bb94cd951d257481f' DL_SHA256='8428e60b99fc83dfa710c2c8ad9477654ed1a0301b52f71bb94cd951d257481f' 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 RUN_SETUP=1 while [ "$#" -gt 0 ]; do case "$1" in --dry-run) DRY_RUN=1 ;; --no-setup) RUN_SETUP=0 ;; -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 " curl -fsSL https://tryumbra.dev/install.sh | sh -s -- --no-setup" say "" say "--dry-run Download and verify the release without installing it." say "--no-setup Install only; do not start guided setup afterward." 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 "On a first install, guided setup then opens sign-in and asks you to" say "approve the attestation profile before you choose a model." 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." APNS_ENV="$(/usr/libexec/PlistBuddy -c 'Print :com.apple.developer.aps-environment' "$ENT_PLIST" 2>/dev/null || true)" [ "$APNS_ENV" = "production" ] || die "Umbra production APNs entitlement 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 "A first install would then start guided setup. Browser sign-in and" say "explicit profile approval in macOS System Settings are still required." exit 0 fi # Setup is first-install behavior, never an update side effect. A prior login # config means this Mac has already entered the guided flow, even if hosting is # intentionally stopped or its model is being changed. NEEDS_SETUP=1 [ ! -f "$HOME/.umbra/config.json" ] || NEEDS_SETUP=0 # ---- 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)" [ "$link_target" = "$APP_PATH/Contents/MacOS/umbra" ] && return 0 return 1 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.$$" BIN_BACKUP="$BIN_DIR/.umbra.old.$$" APP_SWAPPED=0 BIN_SWAPPED=0 INSTALL_MODE="" rollback_local() { if [ "$BIN_SWAPPED" = "1" ]; then rm -f "$BIN_DIR/umbra" if [ -e "$BIN_BACKUP" ] || [ -L "$BIN_BACKUP" ]; then mv "$BIN_BACKUP" "$BIN_DIR/umbra" || return 1; fi fi if [ "$APP_SWAPPED" = "1" ]; then rm -rf "$APP_PATH" if [ -e "$BACKUP" ]; then mv "$BACKUP" "$APP_PATH" || return 1; fi fi rm -rf "$STAGE" APP_SWAPPED=0; BIN_SWAPPED=0 } rollback_sudo() { if [ "$BIN_SWAPPED" = "1" ]; then $SUDO rm -f "$BIN_DIR/umbra" if [ -e "$BIN_BACKUP" ] || [ -L "$BIN_BACKUP" ]; then $SUDO mv "$BIN_BACKUP" "$BIN_DIR/umbra" || return 1; fi fi if [ "$APP_SWAPPED" = "1" ]; then $SUDO rm -rf "$APP_PATH" if [ -e "$BACKUP" ]; then $SUDO mv "$BACKUP" "$APP_PATH" || return 1; fi fi $SUDO rm -rf "$STAGE" APP_SWAPPED=0; BIN_SWAPPED=0 } install_local() { mkdir -p "$APP_DIR" "$BIN_DIR" || return 1 rm -rf "$STAGE" "$BACKUP" "$BIN_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 [ ! -e "$BACKUP" ] || mv "$BACKUP" "$APP_PATH" return 1 fi APP_SWAPPED=1 if [ -e "$BIN_DIR/umbra" ] || [ -L "$BIN_DIR/umbra" ]; then mv "$BIN_DIR/umbra" "$BIN_BACKUP" || { rollback_local; return 1; } fi BIN_SWAPPED=1 cp "$WRAP" "$BIN_DIR/umbra" && chmod +x "$BIN_DIR/umbra" || { rollback_local; return 1; } INSTALL_MODE="local" } 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` or a coding agent) rather than an interactive shell, a plain # `sudo` prompt has no TTY. Route that case through a native hidden macOS # dialog; the password is returned directly to sudo and never logged, stored # in argv, or written to disk. SUDO="sudo" if [ -n "${SUDO_ASKPASS:-}" ] && [ -x "${SUDO_ASKPASS}" ]; then SUDO="sudo -A" elif ! ( : /dev/tty ) 2>/dev/null; then ASKPASS="$TMP/umbra-askpass" cat > "$ASKPASS" <<'UMBRA_ASKPASS' #!/bin/sh pw="$(osascript -e 'display dialog "Umbra needs administrator access to install to /Applications and /usr/local/bin." with title "Umbra Installer" default answer "" with hidden answer with icon caution' -e 'text returned of result' 2>/dev/null)" || exit 1 printf '%s' "$pw" UMBRA_ASKPASS chmod 700 "$ASKPASS" || return 1 export SUDO_ASKPASS="$ASKPASS" SUDO="sudo -A" fi $SUDO -v || return 1 $SUDO mkdir -p "$APP_DIR" "$BIN_DIR" || return 1 $SUDO rm -rf "$STAGE" "$BACKUP" "$BIN_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 [ ! -e "$BACKUP" ] || $SUDO mv "$BACKUP" "$APP_PATH" return 1 fi APP_SWAPPED=1 if [ -e "$BIN_DIR/umbra" ] || [ -L "$BIN_DIR/umbra" ]; then $SUDO mv "$BIN_DIR/umbra" "$BIN_BACKUP" || { rollback_sudo; return 1; } fi BIN_SWAPPED=1 $SUDO cp "$WRAP" "$BIN_DIR/umbra" && $SUDO chmod +x "$BIN_DIR/umbra" || { rollback_sudo; return 1; } INSTALL_MODE="sudo" } 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 # Keep both backups until the installed app AND PATH wrapper launch. A bad # copy or non-runnable binary is rolled back before this installer exits. install_smoke() { codesign --verify --deep --strict --verbose=2 "$APP_PATH" >/dev/null 2>&1 || return 1 "$APP_PATH/Contents/MacOS/umbra" version >/dev/null 2>&1 || return 1 "$BIN_DIR/umbra" version >/dev/null 2>&1 || return 1 } if ! install_smoke; then if [ "$INSTALL_MODE" = "sudo" ]; then rollback_sudo || die "New Umbra failed its launch check and automatic rollback also failed." else rollback_local || die "New Umbra failed its launch check and automatic rollback also failed." fi die "Installed Umbra failed its signature or launch check; the previous version was restored." fi if [ "$INSTALL_MODE" = "sudo" ]; then $SUDO rm -rf "$BACKUP" "$BIN_BACKUP" [ "$HOMEBREW_SHADOW" = "$BIN_DIR/umbra" ] || sync_owned_shadow_sudo "$HOMEBREW_SHADOW" || true else rm -rf "$BACKUP" "$BIN_BACKUP" # Update a legacy early-PATH wrapper only after proving Umbra owns it. [ "$HOMEBREW_SHADOW" = "$BIN_DIR/umbra" ] || sync_owned_shadow_local "$HOMEBREW_SHADOW" || true fi APP_SWAPPED=0; BIN_SWAPPED=0 ok "installed Umbra Provider.app to $APP_PATH" ok "installed umbra command to $BIN_DIR/umbra" # 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 an update hold only after the installer restarted the service. 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.${R}" say "${DIM}Use $BIN_DIR/umbra directly, or add this line to ~/.zprofile:${R}" say " export PATH="$BIN_DIR:\$PATH"" ;; esac say "" CLI="$BIN_DIR/umbra" CLI_HINT="$CLI" RESOLVED_UMBRA="$(command -v umbra 2>/dev/null || true)" [ "$RESOLVED_UMBRA" != "$CLI" ] || CLI_HINT="umbra" if [ "$RUN_SETUP" = "1" ] && [ "$NEEDS_SETUP" = "1" ]; then say "${B}Starting guided setup${R}" say "${DIM}Your browser will open for sign-in. macOS will then ask you to review" say "and approve the Umbra attestation profile before model selection.${R}" say "" # `curl | sh` owns stdin, so attach the interactive CLI to the controlling # terminal explicitly. Never launch a half-interactive setup from CI or an # agent shell without a TTY. if ( : /dev/tty ) 2>/dev/null; then if "$CLI" setup /dev/tty 2>&1; then say "" if [ -f "$HOME/.umbra/host.json" ] && [ -f "$HOME/Library/LaunchAgents/dev.tryumbra.host.plist" ]; then ok "guided setup finished; managed hosting is configured" say "Confirm coordinator admission with: $CLI_HINT status" else say "Guided setup paused before a model was hosted." say "Continue with: $CLI setup" exit 1 fi else say "" say "Guided setup stopped before hosting started." say "The verified app is installed. Continue with: $CLI setup" exit 1 fi else say "Guided setup was not started because this shell has no interactive terminal." say "Open Terminal on this Mac and run:" say " $CLI setup" fi elif [ "$RUN_SETUP" = "0" ]; then say "Install complete. Guided setup was skipped by --no-setup." say "Start it later with: $CLI setup" else say "Updated the existing Umbra installation." say "Check it with: $CLI_HINT status" fi say "${DIM}Install changes and uninstall instructions: https://tryumbra.dev/download/${R}" say ""