#!/bin/sh # Crawdad Installer # Usage: curl -fsSL https://getcrawdad.dev/install.sh | sh set -e # VERSION is the pinned fallback. At runtime install.sh resolves the CURRENT # release from the gateway release manifest - the same source fleet-install.sh # and the sidecar auto-updater use - so a fresh install always tracks the signed # manifest and can never go stale from a missed install-script upload. If the # gateway is unreachable (offline / down), it falls back to this pin so a fresh # install still works. Override with CRAWDAD_VERSION for image-baking / tests. VERSION="1.8.2" resolve_version() { if [ -n "${CRAWDAD_VERSION:-}" ]; then VERSION="$CRAWDAD_VERSION" return fi MANIFEST_URL="https://crawdad-production.up.railway.app/api/v1/releases/latest" RESOLVED=$(curl -fsSL --connect-timeout 5 "$MANIFEST_URL" 2>/dev/null \ | grep -o '"version":"[^"]*"' | head -1 | sed 's/"version":"//;s/"//g') if [ -n "$RESOLVED" ]; then VERSION="$RESOLVED" fi # else: gateway unreachable - keep the pinned VERSION fallback above. # (No echo here: install.sh's --silent mode emits one machine-readable line; # the "Crawdad v${VERSION}" banner below already surfaces the chosen version.) } resolve_version INSTALL_NAME="crawdad-sidecar" # WO42 Part E - install location is overridable for MSP/RMM image-baking and for # hermetic testing (default: /usr/local/bin). Paired with CRAWDAD_SKIP_SERVICE=1 # (install the binary only; no launchd/systemd/health probe) for golden images # whose service starts on first boot. BIN_DIR="${CRAWDAD_BIN_DIR:-/usr/local/bin}" INSTALL_PATH="${BIN_DIR}/${INSTALL_NAME}" # User-facing command. The binary installs under the service name # (crawdad-sidecar) - that is what the launchd/systemd unit runs and what # the auto-updater swaps in place. But the binary's own help and every doc # invoke `crawdad`, so we also expose a `crawdad` symlink -> crawdad-sidecar. CRAWDAD_CMD="crawdad" CRAWDAD_LINK="${BIN_DIR}/${CRAWDAD_CMD}" TMP="/tmp/crawdad-download-$$" PLIST_DIR="$HOME/Library/LaunchAgents" PLIST="$PLIST_DIR/com.crawdad.sidecar.plist" # ── Platform detection ── OS=$(uname -s | tr '[:upper:]' '[:lower:]') ARCH=$(uname -m) # ── Per-platform data directory ── # Must match crawdad-sidecar/src/device.rs::data_dir(). The sidecar # refuses to start on a group- or world-accessible directory (defense- # in-depth for the zero-knowledge claim), so we create with 0700. # v0.7/v0.8 stored data under /etc/crawdad with 0777 permissions - the # sidecar auto-migrates from that path on first v0.9 startup. This # installer never touches /etc/crawdad again. if [ "$OS" = "darwin" ]; then CRAWDAD_SIDECAR_DATA_DIR="$HOME/Library/Application Support/crawdad" else CRAWDAD_SIDECAR_DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/crawdad" fi # ── Install root for large artifacts (ORT runtime, ML model) ── # Kept separate from the sidecar data dir so a `crawdad uninstall` that # wipes user data doesn't re-download ~570 MiB of dylib + model. CRAWDAD_INSTALL_ROOT="${XDG_DATA_HOME:-$HOME/.local/share}/crawdad" # ARTIFACTS_VERSION names the LOCAL model staging dir only # (models/v-custom, created below). It does NOT drive a download: the # sidecar binary fetches the ML model AND the libonnxruntime dylib itself, # from URLs baked into crawdad-sidecar/src/model_manager.rs - MODEL_URL # (currently the retrained, instruction-benign-FP-fixed v0.9.4 model) and # current_platform_ort (the v0.9.2 ORT dylib). This value is kept in # lockstep with model_manager.rs MODEL_VERSION so the on-disk dir name # reflects the model actually in use; scripts/bump-version.sh syncs it on # every release. Bumping the sidecar VERSION above is what delivers a new # model to a fresh install (the newer binary embeds the newer MODEL_URL). ARTIFACTS_VERSION="0.9.4" CRAWDAD_MODEL_DIR="$CRAWDAD_INSTALL_ROOT/models/v${ARTIFACTS_VERSION}-custom" CRAWDAD_MODEL_CURRENT="$CRAWDAD_INSTALL_ROOT/models/current" CRAWDAD_ORT_DIR="$CRAWDAD_INSTALL_ROOT/libonnxruntime" # ── WO42 Part E: headless install for MSP / RMM ── # Flags (curl -fsSL … | sh -s -- --silent --key= [--posture=

] [--yes]), # with env-var fallbacks so `irm|iex`-style pipes work too. `--silent` is # machine-readable + prompt-free; it implies `--yes` ONLY when a `--key` is present # (A3 non-interactive rc rule below). Deterministic exit codes: 0 ok; 1 platform/ # download/checksum; 2 binary won't run; 3 health timeout; 4 `crawdad setup` failed. SILENT="${CRAWDAD_SILENT:-}" ASSUME_YES="${CRAWDAD_YES:-}" LICENSE_KEY="${CRAWDAD_KEY:-}" POSTURE="${CRAWDAD_POSTURE:-}" while [ $# -gt 0 ]; do case "$1" in --silent) SILENT=1 ;; --yes|-y) ASSUME_YES=1 ;; --key) shift; LICENSE_KEY="$1" ;; --key=*) LICENSE_KEY="${1#--key=}" ;; --license) shift; LICENSE_KEY="$1" ;; --license=*) LICENSE_KEY="${1#--license=}" ;; --posture) shift; POSTURE="$1" ;; --posture=*) POSTURE="${1#--posture=}" ;; *) echo " ⚠ Unknown option ignored: $1" ;; esac shift done # A3 / E1: silent implies --yes ONLY when a key is present. if [ -n "$SILENT" ] && [ -n "$LICENSE_KEY" ]; then ASSUME_YES=1; fi # Machine-readable one-line summary - emitted only in --silent mode, on EVERY # exit path (success or failure), via an EXIT trap so RMM/CI can grep one line. SETUP_RESULT="skipped" ENROLL_RESULT="none" SHELL_RESULT="none" SUMMARY_DONE=0 emit_summary() { code="${1:-$?}" [ "$SUMMARY_DONE" = "1" ] && return 0 SUMMARY_DONE=1 [ -z "$SILENT" ] && return 0 if [ "$code" = "0" ]; then _st="ok"; else _st="error"; fi printf 'crawdad-install: result=%s version=%s binary=%s setup=%s posture=%s enrollment=%s shell=%s exit=%s\n' \ "$_st" "$VERSION" "$INSTALL_PATH" "$SETUP_RESULT" "${POSTURE:-default}" "$ENROLL_RESULT" "$SHELL_RESULT" "$code" } trap 'emit_summary $?' EXIT echo "" echo " Crawdad v${VERSION}" echo "" case "$OS-$ARCH" in darwin-arm64) BINARY="crawdad-macos-arm64" ;; darwin-x86_64) BINARY="crawdad-macos-x86_64" ;; linux-x86_64) BINARY="crawdad-linux-x86_64" ;; linux-aarch64) BINARY="crawdad-linux-arm64" ;; linux-arm64) BINARY="crawdad-linux-arm64" ;; *) echo " ✗ Unsupported platform: $OS-$ARCH" echo " Crawdad supports macOS (ARM64, Intel) and Linux (x86_64, ARM64)." echo " Contact contact@getcrawdad.dev for help." exit 1 ;; esac echo " Platform: $OS/$ARCH" # ── macOS Intel: disclose pattern-only detection ── if [ "$OS" = "darwin" ] && [ "$ARCH" = "x86_64" ]; then echo "" echo " Note: macOS Intel installs run with pattern-based detection only." echo " ML detection requires Apple Silicon; pattern detection still" echo " covers a substantial share of common attacks. ML on macOS Intel" echo " is tracked for a future release." echo "" fi # ── Stop any running sidecar + reclaim root-owned artifacts ── # (WO42 Part E: skipped under CRAWDAD_SKIP_SERVICE - service management, incl. # `sudo launchctl`, must not run during a binary-only image bake or hermetic test.) # # WO-HOTFIX-UPDATER: an older install may have run the sidecar AS ROOT (a system # LaunchDaemon, or `sudo crawdad run`), leaving root-owned artifacts that make the # per-user LaunchAgent unstartable - launchd cannot open a root-owned # StandardOutPath (posix_spawn EACCES → EX_CONFIG, the "spawn failure"), and the # sidecar panics on a root-owned data dir (sessions.db). Stop every shape, remove # the anomalous root daemon, and reclaim ownership of the user's data/log dirs # BEFORE installing the new per-user LaunchAgent. if [ "$OS" = "darwin" ] && [ -z "${CRAWDAD_SKIP_SERVICE:-}" ]; then UID_NUM=$(id -u) # 1. Stop the per-user LaunchAgent (current + old label). launchctl bootout "gui/${UID_NUM}/com.crawdad.sidecar" 2>/dev/null || true launchctl unload "$PLIST" 2>/dev/null || true # may not exist on fresh install launchctl unload "$HOME/Library/LaunchAgents/dev.getcrawdad.sidecar.plist" 2>/dev/null || true # old plist name # 2. Stop + remove any system (root) daemon: the anomalous com.crawdad.sidecar # (never shipped by THIS installer - a manual/dogfood artifact that runs the # sidecar as root and re-poisons the user files) and the .pkg/`crawdad setup` # dev.getcrawdad.sidecar daemon. sudo launchctl bootout system/com.crawdad.sidecar 2>/dev/null || true sudo launchctl bootout system/dev.getcrawdad.sidecar 2>/dev/null || true sudo launchctl unload /Library/LaunchDaemons/dev.getcrawdad.sidecar.plist 2>/dev/null || true # old daemon plist if [ -f /Library/LaunchDaemons/com.crawdad.sidecar.plist ]; then sudo launchctl unload /Library/LaunchDaemons/com.crawdad.sidecar.plist 2>/dev/null || true sudo rm -f /Library/LaunchDaemons/com.crawdad.sidecar.plist echo " Removed stale root sidecar daemon (com.crawdad.sidecar → ran as root, poisoned user files)" fi # 3. Reclaim ownership of user data/log dirs if a prior root run left them # root-owned (the ownership check keeps this a no-op - and prompt-free - on a # clean install where the dirs are user-owned or absent). for _d in "$CRAWDAD_SIDECAR_DATA_DIR" "$CRAWDAD_INSTALL_ROOT" "$HOME/Library/Logs/crawdad"; do if [ -e "$_d" ] && [ "$(stat -f '%u' "$_d" 2>/dev/null)" != "$UID_NUM" ]; then echo " Reclaiming ownership of $_d (root-owned from a prior run)" sudo chown -R "${UID_NUM}:$(id -g)" "$_d" 2>/dev/null || true fi done # 4. Remove stale shared /tmp logs from an older LaunchDaemon (root-owned → # blocks the user agent's posix_spawn). The new agent logs under # ~/Library/Logs/crawdad, never shared /tmp. for _f in /tmp/crawdad.log /tmp/crawdad.err; do if [ -e "$_f" ] && [ "$(stat -f '%u' "$_f" 2>/dev/null)" != "$UID_NUM" ]; then sudo rm -f "$_f" 2>/dev/null || true fi done fi # ── Download binary ── if [ -n "${CRAWDAD_BINARY_PATH:-}" ]; then echo " Using pre-built binary from $CRAWDAD_BINARY_PATH" cp "$CRAWDAD_BINARY_PATH" "$TMP" else echo " Downloading ${BINARY}..." DOWNLOAD_URL="https://getcrawdad.dev/download/v${VERSION}/${BINARY}" if ! curl -fsSL "$DOWNLOAD_URL" -o "$TMP" 2>/dev/null; then rm -f "$TMP" echo " ✗ Download failed." echo " Tried: $DOWNLOAD_URL" echo " Contact contact@getcrawdad.dev for help." exit 1 fi FILE_SIZE=$(wc -c < "$TMP" | tr -d ' ') if [ "$FILE_SIZE" -lt 1000000 ]; then rm -f "$TMP" echo " ✗ Download failed - file too small (${FILE_SIZE} bytes). Binary may not be available for this platform." exit 1 fi # ── Verify download integrity ── SHA_URL="https://getcrawdad.dev/download/v${VERSION}/${BINARY}.sha256" EXPECTED_SHA=$(curl -fsSL "$SHA_URL" 2>/dev/null | awk '{print $1}') if [ -z "$EXPECTED_SHA" ]; then rm -f "$TMP" echo " ✗ Could not fetch SHA-256 checksum from $SHA_URL" exit 1 fi ACTUAL_SHA=$(shasum -a 256 "$TMP" 2>/dev/null | awk '{print $1}') if [ -z "$ACTUAL_SHA" ]; then ACTUAL_SHA=$(sha256sum "$TMP" 2>/dev/null | awk '{print $1}') fi if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then rm -f "$TMP" echo " ✗ SHA-256 verification failed" echo " Expected: $EXPECTED_SHA" echo " Got: $ACTUAL_SHA" echo " The downloaded binary does not match the published checksum." echo " Contact contact@getcrawdad.dev for help." exit 1 fi echo " ✓ SHA-256 verified" fi # ── Install binary ── (WO42 Part E: honor BIN_DIR, not a hardcoded path) chmod +x "$TMP" [ -d "$BIN_DIR" ] || mkdir -p "$BIN_DIR" 2>/dev/null || true if [ -w "$BIN_DIR" ]; then mv "$TMP" "$INSTALL_PATH" elif [ -f "$INSTALL_PATH" ] && [ -w "$INSTALL_PATH" ]; then cp "$TMP" "$INSTALL_PATH" rm -f "$TMP" else echo " Installing to $BIN_DIR (requires admin password)" sudo mv "$TMP" "$INSTALL_PATH" fi if [ ! -x "$INSTALL_PATH" ]; then echo " ✗ Installation failed - binary not at $INSTALL_PATH" exit 1 fi echo " ✓ Binary installed" # ── Create the user-facing `crawdad` command ── # `crawdad` -> crawdad-sidecar. The binary dispatches on its subcommand # (argv[1]), not on argv[0], so both names invoke the same tool. This also # reconciles the old layout where the binary itself was named `crawdad`: # a pre-existing /usr/local/bin/crawdad (real file or stale symlink) is # replaced by a symlink to the current crawdad-sidecar. if [ -w "$BIN_DIR" ]; then ln -sf "$INSTALL_PATH" "$CRAWDAD_LINK" else sudo ln -sf "$INSTALL_PATH" "$CRAWDAD_LINK" fi if [ -L "$CRAWDAD_LINK" ] || [ -x "$CRAWDAD_LINK" ]; then echo " ✓ 'crawdad' command created ($CRAWDAD_LINK -> $INSTALL_NAME)" else echo " ⚠ Could not create the 'crawdad' command - use 'crawdad-sidecar' instead" fi # ── macOS arm64: install SE helper for model encryption ── # WO42 Part E: skipped under CRAWDAD_SKIP_SERVICE (needs sudo + network; a golden # image installs it on first real boot). if [ "$OS" = "darwin" ] && [ "$ARCH" = "arm64" ] && [ -z "${CRAWDAD_SKIP_SERVICE:-}" ]; then SE_HELPER="crawdad-se-helper-macos-arm64" SE_TMP=$(mktemp) SE_INSTALL="/usr/local/bin/crawdad-se-helper" echo " Downloading ${SE_HELPER} (Secure Enclave helper)..." SE_URL="https://getcrawdad.dev/download/v${VERSION}/${SE_HELPER}" if curl -fsSL "$SE_URL" -o "$SE_TMP" 2>/dev/null; then chmod +x "$SE_TMP" if [ -w /usr/local/bin ]; then mv "$SE_TMP" "$SE_INSTALL" else sudo mv "$SE_TMP" "$SE_INSTALL" fi echo " ✓ SE helper installed" else rm -f "$SE_TMP" echo " ⚠ SE helper download failed (model encryption will be unavailable)" fi fi # ── Runtime viability: --version smoke test ── # Catches platform-compatibility failures (wrong arch, missing glibc, # missing dylib) immediately on the install host - the only place we # can guarantee per-target runtime verification for cross-compiled # builds. See memory: crawdad_binary_runtime_testing.md. TROUBLESHOOTING_URL="https://getcrawdad.dev/docs/install-troubleshooting" if ! VERSION_OUTPUT=$("$INSTALL_PATH" --version 2>&1); then echo " ✗ Binary failed to execute on this platform." echo " Ran: $INSTALL_PATH --version" echo " Error: $VERSION_OUTPUT" echo " Platform: $OS/$ARCH" echo "" echo " This usually means the downloaded binary is incompatible with" echo " your OS/architecture, or a required system library is missing." echo " Troubleshooting: $TROUBLESHOOTING_URL" exit 2 fi echo " ✓ Binary runs ($VERSION_OUTPUT)" # ── Service install + health (WO42 Part E: skipped by CRAWDAD_SKIP_SERVICE) ── # For golden-image / RMM baking, set CRAWDAD_SKIP_SERVICE=1 to install the binary # only and let the image's own boot bring the service up. `crawdad setup` and the # shell config below still run. if [ -z "${CRAWDAD_SKIP_SERVICE:-}" ]; then # ── Pre-create artifact + data directories ── # On macOS these run as the invoking user (no sudo mismatch). # On Linux this is deferred to the Linux block below, which resolves # the real user via SUDO_USER/getent and creates dirs with correct # ownership - running mkdir here as root would leave parent dirs # (e.g. ~/.local, ~/.local/share) owned by root, breaking a # purge → reinstall cycle. if [ "$OS" = "darwin" ]; then mkdir -p "$CRAWDAD_ORT_DIR" "$CRAWDAD_MODEL_DIR" mkdir -p "$CRAWDAD_SIDECAR_DATA_DIR" chmod 700 "$CRAWDAD_SIDECAR_DATA_DIR" fi # ── macOS: create launchd user agent ── if [ "$OS" = "darwin" ]; then mkdir -p "$PLIST_DIR" # Per-user log dir (NOT shared /tmp). A root-owned /tmp/crawdad.log left by a # prior root-context run made the user LaunchAgent unstartable - launchd could # not open the StandardOutPath, so posix_spawn failed EACCES (0xd) → EX_CONFIG. # ~/Library/Logs is per-user, so the collision cannot recur. WO-HOTFIX-UPDATER. LOG_DIR="$HOME/Library/Logs/crawdad" # WO-181-FRONTDOOR: remember what this install CREATED (not what it edited), so uninstall.sh # --purge can remove exactly those and never a file the user already had. CREATED_LEDGER="$CRAWDAD_SIDECAR_DATA_DIR/install_created.txt" mkdir -p "$CRAWDAD_SIDECAR_DATA_DIR" 2>/dev/null; chmod 700 "$CRAWDAD_SIDECAR_DATA_DIR" 2>/dev/null [ -d "$LOG_DIR" ] || echo "dir $LOG_DIR" >> "$CREATED_LEDGER" mkdir -p "$LOG_DIR" cat > "$PLIST" << EOF Label com.crawdad.sidecar ProgramArguments ${INSTALL_PATH} run RunAtLoad KeepAlive SuccessfulExit ThrottleInterval 60 StandardOutPath ${LOG_DIR}/sidecar.log StandardErrorPath ${LOG_DIR}/sidecar.err EnvironmentVariables CRAWDAD_ML_MODEL_DIR ${CRAWDAD_MODEL_CURRENT} CRAWDAD_WORKSPACE_ENABLED 1 EOF # Register the LaunchAgent race-safely. `launchctl bootout` (run earlier) is # ASYNC - bootstrapping while the old job is still tearing down fails with # "Bootstrap failed: 5: Input/output error" and can leave the agent unloaded # ("Could not find service"). Prefer the modern `bootstrap`; on the teardown race, # bootout + WAIT for the label to disappear, then retry (bounded); fall back to the # legacy loader; then kickstart to ensure it is actually running. GUI_DOMAIN="gui/${UID_NUM}" SVC_TARGET="gui/${UID_NUM}/com.crawdad.sidecar" REGISTERED="" i=1 while [ "$i" -le 4 ]; do if launchctl bootstrap "$GUI_DOMAIN" "$PLIST" 2>/dev/null; then REGISTERED=1; break fi # Already loaded under this label → done. if launchctl print "$SVC_TARGET" >/dev/null 2>&1; then REGISTERED=1; break fi # Clear any half-registered record and wait for teardown to finish before retry. launchctl bootout "$SVC_TARGET" 2>/dev/null || true j=1 while [ "$j" -le 20 ]; do launchctl print "$SVC_TARGET" >/dev/null 2>&1 || break sleep 0.25 j=$((j + 1)) done i=$((i + 1)) done # Last-ditch: the legacy loader registers some edge-state jobs bootstrap won't. if [ -z "$REGISTERED" ]; then launchctl load -w "$PLIST" 2>/dev/null && REGISTERED=1 fi # Ensure it is running (RunAtLoad usually covers this). launchctl kickstart "$SVC_TARGET" 2>/dev/null || true if launchctl print "$SVC_TARGET" >/dev/null 2>&1; then echo " ✓ Service registered (starts on login)" else echo " ⚠ Failed to register LaunchAgent - start manually: launchctl bootstrap $GUI_DOMAIN $PLIST" fi # ── WO-181-MENUBAR: menu-bar app (launch at login) ── # Installs the menu-bar app when its bundle is available beside this installer # (built by crawdad-menubar/build.sh). Per-user LaunchAgent, no sudo. Skipped # (non-breaking) when the bundle is not present. MB_LABEL="com.crawdad.menubar" MB_PLIST="$PLIST_DIR/$MB_LABEL.plist" MB_DIR="$HOME/.local/lib/crawdad" MB_APP="" for cand in "$(dirname "$0")/crawdad-menubar/crawdad-menubar.app" \ "$(dirname "$0")/crawdad-menubar.app" "${CRAWDAD_MENUBAR_APP:-}"; do if [ -n "$cand" ] && [ -d "$cand" ]; then MB_APP="$cand"; break; fi done # Never install a menu-bar app whose popover has nothing to show (no bundled panel). if [ -n "$MB_APP" ] && [ ! -s "$MB_APP/Contents/Resources/panel/index.html" ]; then echo " Skipping the menu-bar app: $MB_APP has no panel (Contents/Resources/panel). Rebuild it with crawdad-menubar/build.sh." MB_APP="" fi if [ -n "$MB_APP" ]; then echo " Installing menu-bar app..." mkdir -p "$MB_DIR"; rm -rf "$MB_DIR/crawdad-menubar.app" cp -R "$MB_APP" "$MB_DIR/crawdad-menubar.app" cat > "$MB_PLIST" < Label$MB_LABEL ProgramArguments$MB_DIR/crawdad-menubar.app/Contents/MacOS/crawdad-menubar RunAtLoad KeepAliveCrashedSuccessfulExit ProcessTypeInteractive LimitLoadToSessionTypeAqua MBEOF launchctl bootout "gui/${UID_NUM}/$MB_LABEL" 2>/dev/null || true launchctl bootstrap "$GUI_DOMAIN" "$MB_PLIST" 2>/dev/null || launchctl load -w "$MB_PLIST" 2>/dev/null || true launchctl kickstart "gui/${UID_NUM}/$MB_LABEL" 2>/dev/null || true echo " ✓ Menu-bar app installed (starts on login)" fi # ── Install privileged update helper ── # The sidecar runs as the user (LaunchAgent) but the binary lives in # /usr/local/bin (root-owned). A small privileged helper performs the # swap so updates apply unattended without a password prompt. HELPER_BINARY="crawdad-update-helper-macos-${ARCH}" HELPER_URL="https://getcrawdad.dev/download/v${VERSION}/${HELPER_BINARY}" HELPER_INSTALL="/usr/local/libexec/crawdad-update-helper" HELPER_PLIST="/Library/LaunchDaemons/dev.getcrawdad.update-helper.plist" HELPER_TMP="/tmp/crawdad-helper-download-$$" echo " Installing privileged update helper..." if curl -fsSL "$HELPER_URL" -o "$HELPER_TMP" 2>/dev/null && [ -s "$HELPER_TMP" ]; then HELPER_SHA_URL="https://getcrawdad.dev/download/v${VERSION}/${HELPER_BINARY}.sha256" HELPER_EXPECTED_SHA=$(curl -fsSL "$HELPER_SHA_URL" 2>/dev/null | awk '{print $1}') if [ -z "$HELPER_EXPECTED_SHA" ]; then rm -f "$HELPER_TMP" echo " ✗ Could not fetch SHA-256 checksum for update helper" echo " ⚠ Update helper not installed (updates require manual sudo)" else HELPER_ACTUAL_SHA=$(shasum -a 256 "$HELPER_TMP" 2>/dev/null | awk '{print $1}') if [ -z "$HELPER_ACTUAL_SHA" ]; then HELPER_ACTUAL_SHA=$(sha256sum "$HELPER_TMP" 2>/dev/null | awk '{print $1}') fi if [ "$HELPER_ACTUAL_SHA" != "$HELPER_EXPECTED_SHA" ]; then rm -f "$HELPER_TMP" echo " ✗ SHA-256 verification failed for update helper" echo " Expected: $HELPER_EXPECTED_SHA" echo " Got: $HELPER_ACTUAL_SHA" echo " ⚠ Update helper not installed (updates require manual sudo)" else echo " ✓ SHA-256 verified: ${HELPER_ACTUAL_SHA:0:16}..." sudo mkdir -p /usr/local/libexec /var/log/crawdad sudo mv "$HELPER_TMP" "$HELPER_INSTALL" sudo chmod 755 "$HELPER_INSTALL" sudo chown root:wheel "$HELPER_INSTALL" # Install LaunchDaemon plist sudo tee "$HELPER_PLIST" > /dev/null << 'HELPERPLIST' Label dev.getcrawdad.update-helper ProgramArguments /usr/local/libexec/crawdad-update-helper RunAtLoad KeepAlive UserName root StandardOutPath /var/log/crawdad/update-helper.log StandardErrorPath /var/log/crawdad/update-helper.log ThrottleInterval 5 HELPERPLIST sudo launchctl bootout system/dev.getcrawdad.update-helper 2>/dev/null || true # may not exist on fresh install if ! sudo launchctl bootstrap system "$HELPER_PLIST" 2>/dev/null && \ ! sudo launchctl load -w "$HELPER_PLIST" 2>/dev/null; then echo " ⚠ Failed to load update helper LaunchDaemon - auto-updates will require sudo" echo " Try: sudo launchctl bootstrap system $HELPER_PLIST" else echo " ✓ Update helper installed (auto-updates will apply without sudo)" fi fi fi else rm -f "$HELPER_TMP" echo " ⚠ Update helper not available for this version (updates require manual sudo)" fi # ── Install workspace launcher ── # crawdad-workspace provides sandboxed VM workspaces for agents. The # sidecar locates it beside its own binary (workspace.rs::launcher_path). WS_BINARY="crawdad-workspace-macos-${ARCH}" WS_URL="https://getcrawdad.dev/download/v${VERSION}/${WS_BINARY}" WS_INSTALL="/usr/local/bin/crawdad-workspace" WS_TMP="/tmp/crawdad-workspace-download-$$" echo " Installing workspace launcher..." if curl -fsSL "$WS_URL" -o "$WS_TMP" 2>/dev/null && [ -s "$WS_TMP" ]; then WS_SHA_URL="https://getcrawdad.dev/download/v${VERSION}/${WS_BINARY}.sha256" WS_EXPECTED_SHA=$(curl -fsSL "$WS_SHA_URL" 2>/dev/null | awk '{print $1}') if [ -z "$WS_EXPECTED_SHA" ]; then rm -f "$WS_TMP" echo " ✗ Could not fetch SHA-256 checksum for workspace launcher" echo " ⚠ Workspace launcher not installed (sandboxed workspaces unavailable)" else WS_ACTUAL_SHA=$(shasum -a 256 "$WS_TMP" 2>/dev/null | awk '{print $1}') if [ -z "$WS_ACTUAL_SHA" ]; then WS_ACTUAL_SHA=$(sha256sum "$WS_TMP" 2>/dev/null | awk '{print $1}') fi if [ "$WS_ACTUAL_SHA" != "$WS_EXPECTED_SHA" ]; then rm -f "$WS_TMP" echo " ✗ SHA-256 verification failed for workspace launcher" echo " Expected: $WS_EXPECTED_SHA" echo " Got: $WS_ACTUAL_SHA" echo " ⚠ Workspace launcher not installed (sandboxed workspaces unavailable)" else echo " ✓ SHA-256 verified: ${WS_ACTUAL_SHA:0:16}..." if [ -w /usr/local/bin ]; then mv "$WS_TMP" "$WS_INSTALL" else sudo mv "$WS_TMP" "$WS_INSTALL" fi chmod +x "$WS_INSTALL" echo " ✓ Workspace launcher installed" fi fi else rm -f "$WS_TMP" echo " ⚠ Workspace launcher not available for this version (sandboxed workspaces unavailable)" fi # ── Install workspace host-stack (macOS) ── # The host-stack binary runs under sandbox-exec alongside the VM launcher. # Built from spikes/w0-workspace/host-stack/, must be a real binary (not symlink). HS_BINARY="crawdad-workspace-host-stack-macos-${ARCH}" HS_URL="https://getcrawdad.dev/download/v${VERSION}/${HS_BINARY}" HS_INSTALL="/usr/local/bin/crawdad-workspace-host-stack" HS_TMP="/tmp/crawdad-workspace-host-stack-download-$$" echo " Installing workspace host-stack..." if curl -fsSL "$HS_URL" -o "$HS_TMP" 2>/dev/null && [ -s "$HS_TMP" ]; then HS_SHA_URL="https://getcrawdad.dev/download/v${VERSION}/${HS_BINARY}.sha256" HS_EXPECTED_SHA=$(curl -fsSL "$HS_SHA_URL" 2>/dev/null | awk '{print $1}') if [ -z "$HS_EXPECTED_SHA" ]; then rm -f "$HS_TMP" echo " ⚠ Workspace host-stack not installed (SHA-256 unavailable)" else HS_ACTUAL_SHA=$(shasum -a 256 "$HS_TMP" 2>/dev/null | awk '{print $1}') if [ -z "$HS_ACTUAL_SHA" ]; then HS_ACTUAL_SHA=$(sha256sum "$HS_TMP" 2>/dev/null | awk '{print $1}') fi if [ "$HS_ACTUAL_SHA" != "$HS_EXPECTED_SHA" ]; then rm -f "$HS_TMP" echo " ✗ SHA-256 verification failed for workspace host-stack" echo " ⚠ Workspace host-stack not installed" else echo " ✓ SHA-256 verified: ${HS_ACTUAL_SHA:0:16}..." if [ -w /usr/local/bin ]; then mv "$HS_TMP" "$HS_INSTALL" else sudo mv "$HS_TMP" "$HS_INSTALL" fi chmod +x "$HS_INSTALL" echo " ✓ Workspace host-stack installed" fi fi else rm -f "$HS_TMP" echo " ⚠ Workspace host-stack not available for this version" fi # ── Install workspace sandbox profile (macOS) ── # The sandbox profile restricts the host-stack to only the permissions it needs. # Source: spikes/w0-workspace/host-stack/sandbox/host-stack.sb SB_URL="https://getcrawdad.dev/download/v${VERSION}/workspace-host-stack.sb" SB_INSTALL="/usr/local/bin/workspace-host-stack.sb" SB_TMP="/tmp/workspace-host-stack-sb-download-$$" echo " Installing workspace sandbox profile..." if curl -fsSL "$SB_URL" -o "$SB_TMP" 2>/dev/null && [ -s "$SB_TMP" ]; then SB_SHA_URL="https://getcrawdad.dev/download/v${VERSION}/workspace-host-stack.sb.sha256" SB_EXPECTED_SHA=$(curl -fsSL "$SB_SHA_URL" 2>/dev/null | awk '{print $1}') if [ -z "$SB_EXPECTED_SHA" ]; then rm -f "$SB_TMP" echo " ⚠ Sandbox profile not installed (SHA-256 unavailable)" else SB_ACTUAL_SHA=$(shasum -a 256 "$SB_TMP" 2>/dev/null | awk '{print $1}') if [ -z "$SB_ACTUAL_SHA" ]; then SB_ACTUAL_SHA=$(sha256sum "$SB_TMP" 2>/dev/null | awk '{print $1}') fi if [ "$SB_ACTUAL_SHA" != "$SB_EXPECTED_SHA" ]; then rm -f "$SB_TMP" echo " ✗ SHA-256 verification failed for sandbox profile" echo " ⚠ Sandbox profile not installed" else echo " ✓ SHA-256 verified: ${SB_ACTUAL_SHA:0:16}..." if [ -w /usr/local/bin ]; then mv "$SB_TMP" "$SB_INSTALL" else sudo mv "$SB_TMP" "$SB_INSTALL" fi echo " ✓ Sandbox profile installed" fi fi else rm -f "$SB_TMP" echo " ⚠ Sandbox profile not available for this version" fi # ── Install biometric helper (macOS) ── # Touch ID / Secure Enclave approval for workspace promotions. # Source: crawdad-biometric-helper/ (Swift, links LocalAuthentication.framework) BIO_BINARY="crawdad-biometric-helper-macos-${ARCH}" BIO_URL="https://getcrawdad.dev/download/v${VERSION}/${BIO_BINARY}" BIO_INSTALL="/usr/local/bin/crawdad-biometric-helper" BIO_TMP="/tmp/crawdad-biometric-helper-download-$$" echo " Installing biometric helper..." if curl -fsSL "$BIO_URL" -o "$BIO_TMP" 2>/dev/null && [ -s "$BIO_TMP" ]; then BIO_SHA_URL="https://getcrawdad.dev/download/v${VERSION}/${BIO_BINARY}.sha256" BIO_EXPECTED_SHA=$(curl -fsSL "$BIO_SHA_URL" 2>/dev/null | awk '{print $1}') if [ -z "$BIO_EXPECTED_SHA" ]; then rm -f "$BIO_TMP" echo " ⚠ Biometric helper not installed (SHA-256 unavailable)" else BIO_ACTUAL_SHA=$(shasum -a 256 "$BIO_TMP" 2>/dev/null | awk '{print $1}') if [ -z "$BIO_ACTUAL_SHA" ]; then BIO_ACTUAL_SHA=$(sha256sum "$BIO_TMP" 2>/dev/null | awk '{print $1}') fi if [ "$BIO_ACTUAL_SHA" != "$BIO_EXPECTED_SHA" ]; then rm -f "$BIO_TMP" echo " ✗ SHA-256 verification failed for biometric helper" echo " ⚠ Biometric helper not installed" else echo " ✓ SHA-256 verified: ${BIO_ACTUAL_SHA:0:16}..." if [ -w /usr/local/bin ]; then mv "$BIO_TMP" "$BIO_INSTALL" else sudo mv "$BIO_TMP" "$BIO_INSTALL" fi chmod +x "$BIO_INSTALL" echo " ✓ Biometric helper installed (Touch ID approval enabled)" fi fi else rm -f "$BIO_TMP" echo " ⚠ Biometric helper not available for this version" fi fi # ── Linux: create systemd service ── if [ "$OS" = "linux" ]; then : ${USER:=$(id -un)} RUN_USER="${SUDO_USER:-$USER}" CRAWDAD_SIDECAR_DATA_DIR="/var/lib/crawdad" CRAWDAD_INSTALL_ROOT="/var/lib/crawdad" CRAWDAD_MODEL_DIR="${CRAWDAD_INSTALL_ROOT}/models/v${ARTIFACTS_VERSION}-custom" CRAWDAD_MODEL_CURRENT="${CRAWDAD_INSTALL_ROOT}/models/current" CRAWDAD_ORT_DIR="${CRAWDAD_INSTALL_ROOT}/libonnxruntime" sudo mkdir -p "$CRAWDAD_SIDECAR_DATA_DIR" "$CRAWDAD_ORT_DIR" "$CRAWDAD_MODEL_DIR" "$CRAWDAD_MODEL_CURRENT" sudo chmod 700 "$CRAWDAD_SIDECAR_DATA_DIR" # Migrate data from prior user-level installs OLD_DATA_DIR="" if [ -n "$RUN_USER" ]; then RUN_HOME=$(getent passwd "$RUN_USER" 2>/dev/null | cut -d: -f6) if [ -n "$RUN_HOME" ] && [ -d "${RUN_HOME}/.local/share/crawdad" ] && [ ! -f "${CRAWDAD_SIDECAR_DATA_DIR}/.migrated" ]; then OLD_DATA_DIR="${RUN_HOME}/.local/share/crawdad" echo " Migrating data from ${OLD_DATA_DIR} → ${CRAWDAD_SIDECAR_DATA_DIR}..." if ! sudo cp -a "${OLD_DATA_DIR}/." "${CRAWDAD_SIDECAR_DATA_DIR}/" 2>/dev/null; then echo " ⚠ Data migration failed - old data preserved at ${OLD_DATA_DIR}" echo " Copy manually: sudo cp -a ${OLD_DATA_DIR}/. ${CRAWDAD_SIDECAR_DATA_DIR}/" fi sudo chown -R root:root "${CRAWDAD_SIDECAR_DATA_DIR}" sudo chmod 700 "${CRAWDAD_SIDECAR_DATA_DIR}" sudo touch "${CRAWDAD_SIDECAR_DATA_DIR}/.migrated" echo " ✓ Data migrated (old data preserved at ${OLD_DATA_DIR})" fi fi if ! command -v systemctl >/dev/null 2>&1; then echo "" echo " ⚠ systemd not available on this distribution" echo " Crawdad's auto-start service requires systemd." echo " Binary is installed at $INSTALL_PATH and ready to run manually:" echo " CRAWDAD_DATA_DIR=${CRAWDAD_SIDECAR_DATA_DIR} $INSTALL_PATH run &" echo "" exit 0 fi sudo mkdir -p /var/log/crawdad # Stop old service if it exists (handles rename from crawdad → crawdad-sidecar) sudo systemctl stop crawdad 2>/dev/null || true # old service name, may not exist sudo systemctl disable crawdad 2>/dev/null || true # old service name, may not exist sudo rm -f /etc/systemd/system/crawdad.service sudo tee /etc/systemd/system/crawdad-sidecar.service > /dev/null << UNIT [Unit] Description=Crawdad Documentation=https://getcrawdad.dev After=network.target [Service] Type=simple ExecStart=${INSTALL_PATH} run Restart=on-failure RestartSec=5 StandardOutput=append:/var/log/crawdad/sidecar.log StandardError=append:/var/log/crawdad/sidecar.err Environment=CRAWDAD_DATA_DIR=${CRAWDAD_SIDECAR_DATA_DIR} Environment=CRAWDAD_ML_MODEL_DIR=${CRAWDAD_MODEL_CURRENT} # WO2 follow-up: /run/crawdad holds the tray shell's peer-authenticated socket # (crawdad-tray connects here; access is gated by SO_PEERCRED + a logind seat check, # so the admin token never leaves root). systemd creates and cleans it up. RuntimeDirectory=crawdad RuntimeDirectoryMode=0755 # Security hardening NoNewPrivileges=true ProtectSystem=strict ReadWritePaths=${CRAWDAD_SIDECAR_DATA_DIR} /var/log/crawdad /tmp PrivateTmp=true [Install] WantedBy=multi-user.target UNIT sudo systemctl daemon-reload if ! sudo systemctl enable crawdad-sidecar.service 2>/dev/null; then echo " ⚠ Failed to enable systemd service" echo " Try: sudo systemctl enable crawdad-sidecar.service" fi if ! sudo systemctl restart crawdad-sidecar.service 2>/dev/null; then echo " ⚠ Failed to start systemd service" echo " Try: sudo systemctl start crawdad-sidecar.service" echo " Logs: sudo journalctl -u crawdad-sidecar -e" else echo " ✓ Systemd service installed and started (runs as root)" fi echo " Data: ${CRAWDAD_SIDECAR_DATA_DIR}" echo " Logs: sudo journalctl -u crawdad-sidecar -f" # No workspace launcher on Linux: crawdad-workspace is built for macOS only (RC gap 14). The # sidecar runs without it, and sandboxed workspaces are not offered on Linux in this release. # ── WO-181-LINUX-SHELL: the tray shell (autostart at login) ── # The Linux peer of the macOS menu-bar app: a StatusNotifierItem tray that hosts # the shared panel, posts desktop notifications, and runs the polkit step-up. It # lives in the person's graphical session, so it installs under the invoking # user's HOME via an XDG autostart entry - never as the root service. Best-effort: # a headless server install (no desktop session, or missing GTK bindings) simply # skips it. See crawdad-tray/README.md. TRAY_SRC="" for cand in "$(dirname "$0")/crawdad-tray" "${CRAWDAD_TRAY_SRC:-}"; do if [ -n "$cand" ] && [ -f "$cand/install/install-tray.sh" ]; then TRAY_SRC="$cand"; break; fi done # The polkit action a weakening step-up authenticates against. Installed # unconditionally (not only with the tray) because EVERY weakening path on the # root service - the dashboard, the loopback API, and the tray socket - prompts # the logged-in seat user through this action (os_verify.rs); without it the root # service cannot prove a person is present. if [ -n "$TRAY_SRC" ] && [ -f "$TRAY_SRC/install/dev.crawdad.policy" ] && [ -d /usr/share/polkit-1/actions ]; then sudo cp "$TRAY_SRC/install/dev.crawdad.policy" /usr/share/polkit-1/actions/dev.crawdad.policy 2>/dev/null \ && echo " ✓ Installed the Crawdad polkit action (weakening step-up)" fi if [ -n "$TRAY_SRC" ] && [ -n "$RUN_USER" ] && [ "$RUN_USER" != "root" ]; then echo " Installing the tray shell for ${RUN_USER} (autostart at login)..." if [ "$(id -un)" = "$RUN_USER" ]; then CRAWDAD_TRAY_NO_START=1 "$TRAY_SRC/install/install-tray.sh" 2>&1 | sed 's/^/ /' || \ echo " ⚠ Tray shell not installed (see crawdad-tray/README.md)" else sudo -u "$RUN_USER" -H env CRAWDAD_TRAY_NO_START=1 \ "$TRAY_SRC/install/install-tray.sh" 2>&1 | sed 's/^/ /' || \ echo " ⚠ Tray shell not installed (see crawdad-tray/README.md)" fi fi fi # ── Health check ── # Canonical /v1/health probe on the sidecar's own port (7749). # 7749 is the sidecar port (main.rs). 7750 is the dashboard port. # First launch may take 30-40s due to macOS Keychain migration # (CDHash ACL re-sealing). Budget 60s total with progress feedback. HEALTH_URL="http://127.0.0.1:7749/v1/health" echo " Waiting for Crawdad to start..." HEALTHY=0 HEALTH_BODY="" HEALTH_WAIT=60 i=0 while [ "$i" -lt "$HEALTH_WAIT" ]; do if HEALTH_BODY=$(curl -fsS "$HEALTH_URL" 2>&1); then HEALTHY=1 break fi i=$((i + 1)) if [ "$i" -eq 15 ]; then echo " (still starting - first launch may take up to 60s for Keychain setup)" fi sleep 1 done if [ "$HEALTHY" -ne 1 ]; then echo " ✗ Crawdad did not respond within ${HEALTH_WAIT} seconds." echo " Probed: $HEALTH_URL" echo " Last error: $HEALTH_BODY" if [ "$OS" = "darwin" ]; then echo " Logs: cat \"$HOME/Library/Logs/crawdad/sidecar.err\"" else echo " Logs: sudo journalctl -u crawdad-sidecar --no-pager -n 50" fi echo " Troubleshooting: $TROUBLESHOOTING_URL" exit 3 fi echo " ✓ Crawdad /v1/health OK" fi # ── end CRAWDAD_SKIP_SERVICE guard (service install + health) ── # ── Device registration + posture (WO42 Part E: crawdad setup --license) ── # install.sh owns the service, so setup does registration + posture only # (--no-service): no double service install, and the throwaway-HOME tests never # spawn a server. Registration flows through the air-gap egress gate (Part C). if [ -n "$LICENSE_KEY" ]; then echo "" echo " ── Device setup ──" set -- setup --license "$LICENSE_KEY" --no-service [ -n "$POSTURE" ] && set -- "$@" --posture "$POSTURE" [ -n "$ASSUME_YES" ] && set -- "$@" --yes [ -n "$SILENT" ] && set -- "$@" --silent if "$INSTALL_PATH" "$@"; then SETUP_RESULT="ok" else SETUP_RESULT="failed" echo " ✗ crawdad setup failed" emit_summary 4 exit 4 fi fi # ── Silent fleet enrollment (RMM / MDM push) ── # Set ENROLLMENT_KEY + CONSOLE_URL (+ optionally CA_FINGERPRINT) to # auto-enroll after install without user interaction. # Example: # curl -fsSL https://getcrawdad.dev/install.sh | \ # ENROLLMENT_KEY=ek_... CONSOLE_URL=https://fleet.example.com:9000 \ # CA_FINGERPRINT=sha256:abc123... sh if [ -n "${ENROLLMENT_KEY:-}" ] && [ -n "${CONSOLE_URL:-}" ]; then echo "" echo " ── Fleet enrollment ──" ENROLL_CMD="$INSTALL_PATH enroll --key $ENROLLMENT_KEY --console-url $CONSOLE_URL" if [ -n "${CA_FINGERPRINT:-}" ]; then ENROLL_CMD="$ENROLL_CMD --ca-fingerprint $CA_FINGERPRINT" fi echo " Enrolling in fleet at $CONSOLE_URL ..." if eval "$ENROLL_CMD"; then ENROLL_RESULT="ok" echo " ✓ Fleet enrollment succeeded" else ENROLL_RESULT="failed" echo " ✗ Fleet enrollment failed (exit $?). Device installed but not enrolled." echo " Re-try manually: crawdad enroll --key YOUR_KEY --console-url $CONSOLE_URL" fi fi # ── Configure shell (WO42 Part A3 rc-edit gating) ── # Route the Anthropic SDK/CLI through the local sidecar proxy. The design partner # reported install.sh editing his shell rc WITHOUT asking. A3: in a non-interactive # context (curl | sh, no TTY) the rc is edited ONLY with explicit consent (--yes, # CRAWDAD_YES, or silent+key); otherwise we PRINT the manual export line instead of # editing silently. An interactive terminal run still proceeds (the explicit A2 # confirmation prompt is Part A). Re-runs stay idempotent via the fence guard. # # POLITE PERSISTENCE: we NEVER shadow a base URL the user set themselves. The guard # below distinguishes (1) already-crawdad (our fence, or a localhost:7748 line) → # idempotent skip; (2) the user's OWN ANTHROPIC_BASE_URL → respect it, warn, and do # NOT append an override after it; (3) absent → append the fenced block. This # mirrors connect_persist.rs::var_status so the installer and the dashboard agree. EXPORT_LINE='export ANTHROPIC_BASE_URL=http://localhost:7748' SHELL_NAME=$(basename "$SHELL" 2>/dev/null || echo "unknown") RC_FILE="" case "$SHELL_NAME" in zsh) RC_FILE="$HOME/.zshrc" ;; bash) RC_FILE="$HOME/.bashrc" ;; esac # May we modify the rc? Explicit consent, or an interactive terminal. RC_ALLOWED="" if [ -n "$ASSUME_YES" ]; then RC_ALLOWED=1 elif [ -t 0 ] && [ -z "$SILENT" ]; then RC_ALLOWED=1 fi if [ -n "$RC_FILE" ] && grep -qE '# >>> crawdad >>>|ANTHROPIC_BASE_URL=.*(localhost|127\.0\.0\.1):7748' "$RC_FILE" 2>/dev/null; then # (1) Crawdad's fence, or a base URL already pointing at the local proxy. SHELL_RESULT="already" echo " ✓ Shell already configured ($RC_FILE)" elif [ -n "$RC_FILE" ] && grep -q 'ANTHROPIC_BASE_URL' "$RC_FILE" 2>/dev/null; then # (2) The user set their OWN ANTHROPIC_BASE_URL. Respect it - never shadow it. SHELL_RESULT="conflict" CURRENT_URL=$(grep 'ANTHROPIC_BASE_URL' "$RC_FILE" 2>/dev/null | grep -v '^[[:space:]]*#' | tail -1 | sed 's/^[[:space:]]*//') echo " ⚠ Your shell already sets ANTHROPIC_BASE_URL - Crawdad will NOT override it:" [ -n "$CURRENT_URL" ] && echo " $CURRENT_URL" echo " Your agents keep using your setting; Crawdad isn't auto-routing them yet." echo " To route them through Crawdad, change that line to:" echo " $EXPORT_LINE" echo " (or turn on auto-routing later from the dashboard → Protection Mode)." elif [ -n "$RC_FILE" ] && [ -n "$RC_ALLOWED" ]; then # (3) Absent - append the consented fenced block. If the rc file itself did not exist, note # that it is ours to remove again (uninstall.sh reads install_created.txt). if [ ! -e "$RC_FILE" ] && [ -n "${CREATED_LEDGER:-}" ]; then echo "file $RC_FILE" >> "$CREATED_LEDGER" 2>/dev/null; fi printf '\n# >>> crawdad >>>\n%s\n# <<< crawdad <<<\n' "$EXPORT_LINE" >> "$RC_FILE" SHELL_RESULT="edited" echo " ✓ Shell configured ($RC_FILE)" echo " Run: source $RC_FILE (or open a new terminal)" elif [ "$SHELL_NAME" = "fish" ] && [ -n "$RC_ALLOWED" ]; then if fish -c "set -Ux ANTHROPIC_BASE_URL http://localhost:7748" 2>/dev/null; then SHELL_RESULT="edited" echo " ✓ Shell configured (fish universal)" fi else # No consent (non-interactive without --yes) or unknown shell → print, don't edit. SHELL_RESULT="printed" echo " To route your agents through Crawdad, add this to your shell profile:" echo " $EXPORT_LINE" if [ -z "$RC_ALLOWED" ] && [ -n "$RC_FILE" ]; then echo " (not written automatically - re-run with --yes, or set CRAWDAD_YES=1)" fi fi # ── Open dashboard (skipped when headless/silent - WO42 Part E) ── if [ -z "${CRAWDAD_SKIP_SERVICE:-}" ] && [ -z "$SILENT" ]; then if [ "$OS" = "darwin" ]; then open "http://localhost:7750" 2>/dev/null || true # best-effort, headless OK elif command -v xdg-open > /dev/null 2>&1; then xdg-open "http://localhost:7750" 2>/dev/null || true # best-effort, headless OK fi fi # ── Check for PATH shadowing (e.g. PyPI crawdad package in conda) ── # We install /usr/local/bin/crawdad (a symlink to crawdad-sidecar). Warn # only if a DIFFERENT `crawdad` resolves ahead of ours on PATH. SHADOW=$(command -v crawdad 2>/dev/null || true) # probe only, absence is fine if [ -n "$SHADOW" ]; then SHADOW_TARGET=$(readlink "$SHADOW" 2>/dev/null || echo "$SHADOW") if [ "$SHADOW" != "$CRAWDAD_LINK" ] && \ [ "$SHADOW" != "$INSTALL_PATH" ] && \ [ "$SHADOW_TARGET" != "$INSTALL_PATH" ]; then echo "" echo " ⚠ WARNING: another 'crawdad' is ahead in your PATH:" echo " $SHADOW" echo " This shadows the Crawdad command at $CRAWDAD_LINK -> $INSTALL_PATH" echo " (Common cause: PyPI 'crawdad' package installed in a conda env)" echo " Fix: pip uninstall crawdad OR put /usr/local/bin first in PATH" echo "" fi fi # ── Register the browser-extension native-messaging host (Chrome + Edge) ── # WO-ENGINE-01 Phase 2. The Crawdad browser extension speaks ONLY to this sidecar # in `native-host` mode. Registration is PER-USER (under the user's browser # profile), self-contained, idempotent, and NON-FATAL - on any failure the # extension just fails safe. Enterprise force-install + a standalone/uninstall # script live in browser-extension/deploy/. Skipped during a hermetic image bake. if [ "${CRAWDAD_SKIP_SERVICE:-}" != "1" ]; then # Chrome Web Store item id (single source of truth: browser-extension/EXTENSION_ID.txt). CRAWDAD_EXT_ID="dfneaghjhaglnhfbolhnpbcdkicnkjba" # Retired self-hosted id, kept in allowed_origins during migration. Remove after 1.8.0. CRAWDAD_LEGACY_EXT_ID="mfihjjjjgkakbjhgmfhmmgfnjhainneg" # Resolve the invoking (non-root) user + home so per-user browser paths and the # launcher land where the browser (running as that user) can read them. if [ "$OS" = "linux" ] && [ "$(id -u)" = "0" ] && [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then NMH_USER="$SUDO_USER" NMH_HOME="$(getent passwd "$SUDO_USER" 2>/dev/null | cut -d: -f6)" else NMH_USER="$(id -un)" NMH_HOME="$HOME" fi if [ -n "$NMH_HOME" ] && [ -d "$NMH_HOME" ]; then # The whole Chromium family (Chrome, Edge, Chromium, Brave, Vivaldi, Opera, # + Arc on macOS). One deterministic extension id serves them all. Per-browser # support caveats: browser-extension/deploy/BROWSER-SUPPORT-MATRIX.md. if [ "$OS" = "darwin" ]; then NMH_DATA="$NMH_HOME/Library/Application Support/crawdad" NMH_DIRS="$NMH_HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts $NMH_HOME/Library/Application Support/Microsoft Edge/NativeMessagingHosts $NMH_HOME/Library/Application Support/Chromium/NativeMessagingHosts $NMH_HOME/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts $NMH_HOME/Library/Application Support/Vivaldi/NativeMessagingHosts $NMH_HOME/Library/Application Support/com.operasoftware.Opera/NativeMessagingHosts $NMH_HOME/Library/Application Support/Arc/User Data/NativeMessagingHosts" else NMH_DATA="$NMH_HOME/.local/share/crawdad" NMH_DIRS="$NMH_HOME/.config/google-chrome/NativeMessagingHosts $NMH_HOME/.config/microsoft-edge/NativeMessagingHosts $NMH_HOME/.config/chromium/NativeMessagingHosts $NMH_HOME/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts $NMH_HOME/.config/vivaldi/NativeMessagingHosts $NMH_HOME/.config/opera/NativeMessagingHosts" fi NMH_LAUNCHER="$NMH_DATA/crawdad-native-host" if mkdir -p "$NMH_DATA" 2>/dev/null; then printf '#!/bin/sh\nexec "%s" native-host\n' "$INSTALL_PATH" > "$NMH_LAUNCHER" 2>/dev/null || true chmod 0755 "$NMH_LAUNCHER" 2>/dev/null || true echo "$NMH_DIRS" | while IFS= read -r _d; do [ -n "$_d" ] || continue mkdir -p "$_d" 2>/dev/null || continue printf '{"name":"com.crawdad.native","description":"Crawdad on-device inspection host","path":"%s","type":"stdio","allowed_origins":["chrome-extension://%s/","chrome-extension://%s/"]}\n' \ "$NMH_LAUNCHER" "$CRAWDAD_EXT_ID" "$CRAWDAD_LEGACY_EXT_ID" > "$_d/com.crawdad.native.json" 2>/dev/null || true done # Firefox: separate manifest (allowed_extensions, Gecko id) + Mozilla dir. if [ "$OS" = "darwin" ]; then NMH_FF_DIR="$NMH_HOME/Library/Application Support/Mozilla/NativeMessagingHosts" else NMH_FF_DIR="$NMH_HOME/.mozilla/native-messaging-hosts" fi if mkdir -p "$NMH_FF_DIR" 2>/dev/null; then printf '{"name":"com.crawdad.native","description":"Crawdad on-device inspection host","path":"%s","type":"stdio","allowed_extensions":["%s"]}\n' \ "$NMH_LAUNCHER" "crawdad@getcrawdad.dev" > "$NMH_FF_DIR/com.crawdad.native.json" 2>/dev/null || true fi # If we wrote as root (Linux + sudo), hand the artifacts back to the user. # Iterate the exact dirs we wrote (paths like BraveSoftware/Brave-Browser # don't fit a single-name loop), so every browser's manifest is user-owned. if [ "$OS" = "linux" ] && [ "$(id -u)" = "0" ] && [ "$NMH_USER" != "root" ]; then chown "$NMH_USER" "$NMH_LAUNCHER" 2>/dev/null || true echo "$NMH_DIRS" | while IFS= read -r _d; do [ -n "$_d" ] || continue chown -R "$NMH_USER" "$_d" 2>/dev/null || true done chown -R "$NMH_USER" "$NMH_FF_DIR" 2>/dev/null || true fi echo " ✓ Browser-extension native host registered (Chromium family + Firefox) for $NMH_USER" fi fi fi # ── Agent hooks (WO-ENGINE-03 Phase 4): Claude Code + GitHub Copilot PreToolUse ── # Writes the user-level hook config so each tool asks the local sidecar for a # decision before every tool call. Merge-safe + idempotent (see `crawdad hook`), # non-fatal, and skippable with CRAWDAD_SKIP_HOOKS=1 (golden-image / RMM baking). if [ -z "${CRAWDAD_SKIP_HOOKS:-}" ] && [ "$OS" != "windows" ]; then HOOK_HOME="$HOME" HOOK_USER="$USER" if [ "$OS" = "linux" ] && [ "$(id -u)" = "0" ] && [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then HOOK_USER="$SUDO_USER" HOOK_HOME="$(getent passwd "$SUDO_USER" 2>/dev/null | cut -d: -f6)" fi if [ -n "$HOOK_HOME" ] && "$CRAWDAD_LINK" hook install --home "$HOOK_HOME" >/dev/null 2>&1; then if [ "$OS" = "linux" ] && [ "$(id -u)" = "0" ] && [ "$HOOK_USER" != "root" ]; then chown -R "$HOOK_USER" "$HOOK_HOME/.claude" "$HOOK_HOME/.copilot" 2>/dev/null || true fi echo " ✓ Agent hooks registered (Claude Code + GitHub Copilot PreToolUse) for $HOOK_USER" fi fi # ── Done ── echo "" echo " ✓ Crawdad installed and protecting your agents" echo "" echo " Dashboard: http://localhost:7750" echo " Pattern detection is active immediately." echo " Enhanced detection downloads in the background; ml=active typically" echo " within 2 minutes of installation." echo "" echo " ---" echo "" echo " Crawdad has no marketing budget and no growth team. It spreads by" echo " word of mouth, from people who use it. If it earns your trust and" echo " you know someone building with agents who should see it, point them" echo " to it - that's worth more than anything I could buy." echo " - Andrew, founder" echo ""