#!/bin/sh set -e # Everything is in main(), called on the last line: `curl ... | sh` runs # nothing until the whole script has arrived, so a download cut short can't # run half of it. main() { BINARY="sidekar" DOMAIN="sidekar.dev" # Options (curl -fsSL https://sidekar.dev/install.sh | sh -s -- ): # --global install to /usr/local/bin instead of ~/.local/bin # --no-daemon don't have systemd/OpenRC/launchd keep the daemon running # --headless a headless machine: the daemon starts at boot, without # a login (already the default with no display, or over SSH), # and this machine is connected to your sidekar account # (`sidekar device login --no-browser`) unless it already is. # As root on Linux, also installs passt (apt-get or dnf), # whose pasta gives each session its own network # --linger, --no-linger # Linux, per-user daemon: start it at boot without a login # (`loginctl enable-linger`) or not. Default: on when headless. # --user as root: install for that user; their daemon is a system unit # (systemd, or OpenRC where systemd isn't running) that runs as them # --name the device name (`--device ` from your other # machines); implies logging in # --login, --no-login # log this machine in to your sidekar account or not # (default: only with --headless, --name or a token) # --enroll-token , --enroll-token-file # log in with a one-time enrollment token (made on # sidekar.dev/devices or with `sidekar device enroll-token`): # no browser, no approval, for cloud-init. The token can also # come in SIDEKAR_ENROLL_TOKEN, which keeps it out of `ps`: # curl -fsSL https://sidekar.dev/install.sh | SIDEKAR_ENROLL_TOKEN=... sh -s -- --headless # (start the line with a space to keep it out of shell # history; --enroll-token shows in `ps`, prefer these) # --env-from-kv # after logging in, put these kv values into the daemon's # environment (`sidekar daemon env set-from-kv`), e.g. # CLAUDE_CODE_OAUTH_TOKEN,openai:OPENAI_API_KEY. Never printed. # --agents # also install Claude Code and/or Codex (npm; Node.js LTS is # installed first when node is missing or older than 18) # --trust =[,...] # after logging in, let control this machine, once # its key on sidekar.dev has that fingerprint (what `sidekar # device key` prints there). `sidekar device provision` on # that device prints the whole line. # --remote-engines # what remote control may start here (config remote_engines) # --git-token-from-kv # git takes its github.com token from kv KEY, read each time # and never stored (`sidekar git-credential setup`) # As root (a fresh cloud VM logs you in as root), the daemon is a system unit # that runs as root (/etc/systemd/system/sidekar.service, or an OpenRC service # /etc/init.d/sidekar where systemd isn't running) and starts at boot; # `sidekar daemon --help` says what that means for security. A Linux host with # none of systemd, OpenRC, runit, s6 or inittab still finishes `--headless` # (the binary is installed and the login still runs) and says to start the # daemon from a boot script: `sidekar daemon install --hook `, or # `--init runit|s6|inittab`. # # The release's signature is always verified, with minisign when it is # installed, else with openssl (minisign's format is Ed25519 over a BLAKE2b-512 # hash, which OpenSSL 1.1.1 and later can check: every current Debian and # Ubuntu has it), else, as root, after installing minisign with the package # manager (apt-get, dnf, yum, apk, zypper). With none of these it refuses. GLOBAL="" NO_DAEMON="" TARGET_USER="" DAEMON_ARGS="" LINGER_SEEN="" HEADLESS="" DEVICE_NAME="" LOGIN_MODE="" ENROLL_TOKEN="${SIDEKAR_ENROLL_TOKEN:-}" ENV_FROM_KV="" AGENTS="" TRUST="" REMOTE_ENGINES="" GIT_TOKEN_KEY="" need_value() { if [ "$2" -lt 2 ] || [ -z "$3" ]; then echo "$1 needs a value" >&2; exit 1; fi } while [ $# -gt 0 ]; do case "$1" in --global) GLOBAL=1 ;; --no-daemon) NO_DAEMON=1 ;; --headless) HEADLESS=1; DAEMON_ARGS="$DAEMON_ARGS --headless" ;; --linger|--no-linger) if [ -n "$LINGER_SEEN" ] && [ "$LINGER_SEEN" != "$1" ]; then echo "--linger and --no-linger contradict each other" >&2; exit 1 fi LINGER_SEEN="$1"; DAEMON_ARGS="$DAEMON_ARGS $1" ;; --user) if [ $# -lt 2 ] || [ -z "$2" ]; then echo "--user needs a user name" >&2; exit 1; fi TARGET_USER="$2"; shift ;; --user=*) TARGET_USER="${1#--user=}" if [ -z "$TARGET_USER" ]; then echo "--user needs a user name" >&2; exit 1; fi ;; --name) need_value --name $# "${2:-}"; DEVICE_NAME="$2"; shift ;; --name=*) DEVICE_NAME="${1#--name=}"; need_value --name 2 "$DEVICE_NAME" ;; --login|--no-login) if [ -n "$LOGIN_MODE" ] && [ "$LOGIN_MODE" != "$1" ]; then echo "--login and --no-login contradict each other" >&2; exit 1 fi LOGIN_MODE="$1" ;; --enroll-token) need_value --enroll-token $# "${2:-}"; ENROLL_TOKEN="$2"; shift ;; --enroll-token=*) ENROLL_TOKEN="${1#--enroll-token=}"; need_value --enroll-token 2 "$ENROLL_TOKEN" ;; --enroll-token-file|--enroll-token-file=*) if [ "$1" = --enroll-token-file ]; then need_value "$1" $# "${2:-}"; f="$2"; shift; else f="${1#--enroll-token-file=}"; fi if [ ! -r "$f" ]; then echo "--enroll-token-file: can't read $f" >&2; exit 1; fi ENROLL_TOKEN="$(tr -d ' \t\r\n' < "$f")" if [ -z "$ENROLL_TOKEN" ]; then echo "--enroll-token-file: $f is empty" >&2; exit 1; fi ;; --env-from-kv) need_value --env-from-kv $# "${2:-}"; ENV_FROM_KV="$ENV_FROM_KV,$2"; shift ;; --env-from-kv=*) ENV_FROM_KV="$ENV_FROM_KV,${1#--env-from-kv=}" ;; --trust) need_value --trust $# "${2:-}"; TRUST="$TRUST,$2"; shift ;; --trust=*) TRUST="$TRUST,${1#--trust=}" ;; --remote-engines) need_value --remote-engines $# "${2:-}"; REMOTE_ENGINES="$2"; shift ;; --remote-engines=*) REMOTE_ENGINES="${1#--remote-engines=}"; need_value --remote-engines 2 "$REMOTE_ENGINES" ;; --git-token-from-kv) need_value --git-token-from-kv $# "${2:-}"; GIT_TOKEN_KEY="$2"; shift ;; --git-token-from-kv=*) GIT_TOKEN_KEY="${1#--git-token-from-kv=}"; need_value --git-token-from-kv 2 "$GIT_TOKEN_KEY" ;; --agents) need_value --agents $# "${2:-}"; AGENTS="$AGENTS,$2"; shift ;; --agents=*) AGENTS="$AGENTS,${1#--agents=}" ;; *skenroll_*) echo "Unknown option (not shown: it carries an enrollment token)" >&2; exit 1 ;; *) echo "Unknown option: $1 (use --global, --no-daemon, --headless, --linger, --no-linger, --user , --name , --login, --no-login, --enroll-token , --enroll-token-file , --env-from-kv , --agents , --trust =, --remote-engines , --git-token-from-kv )" >&2; exit 1 ;; esac shift done # Only the variable install.sh hands the binary (below), and only to `device login`. unset SIDEKAR_ENROLL_TOKEN case "$DEVICE_NAME" in '') ;; *[!a-zA-Z0-9-]*|-*|*-) echo "--name: $DEVICE_NAME: use 1-63 letters, digits and hyphens, starting and ending with a letter or digit" >&2; exit 1 ;; *) if [ "${#DEVICE_NAME}" -gt 63 ]; then echo "--name: $DEVICE_NAME is longer than 63 characters" >&2; exit 1; fi DEVICE_NAME="$(printf '%s' "$DEVICE_NAME" | tr 'A-Z' 'a-z')" ;; esac AGENT_LIST="" for a in $(printf '%s' "$AGENTS" | tr ',' ' '); do case "$a" in claude|claude-code) AGENT_LIST="$AGENT_LIST claude" ;; codex) AGENT_LIST="$AGENT_LIST codex" ;; *) echo "--agents: unknown agent $a (claude, codex)" >&2; exit 1 ;; esac done for t in $(printf '%s' "$TRUST" | tr ',' ' '); do case "$t" in *=SHA256:?*) ;; *) echo "--trust: $t: use =SHA256: (\`sidekar device key\` on that device)" >&2; exit 1 ;; esac case "${t%%=*}" in ''|-*|*[!A-Za-z0-9._-]*) echo "--trust: ${t%%=*} is not a device name or id" >&2; exit 1 ;; esac case "${t#*=SHA256:}" in *[!A-Za-z0-9+/=]*) echo "--trust: ${t#*=} is not a key fingerprint" >&2; exit 1 ;; esac done case "$REMOTE_ENGINES" in *[!A-Za-z0-9,_-]*) echo "--remote-engines: $REMOTE_ENGINES: agent names separated by commas" >&2; exit 1 ;; esac case "$GIT_TOKEN_KEY" in -*|*[!A-Za-z0-9_./@:-]*) echo "--git-token-from-kv: $GIT_TOKEN_KEY is not a kv key name" >&2; exit 1 ;; esac for k in $(printf '%s' "$ENV_FROM_KV" | tr ',' ' '); do case "${k#*:}" in ''|[0-9]*|*[!A-Za-z0-9_]*) echo "--env-from-kv: ${k#*:} is not an environment variable name" >&2; exit 1 ;; esac done # Log in: asked for, or a headless machine, a name or a token says so. DO_LOGIN="" if [ "$LOGIN_MODE" = --no-login ]; then if [ -n "$ENROLL_TOKEN" ]; then echo "--no-login with an enrollment token: pick one" >&2; exit 1; fi if [ -n "$DEVICE_NAME" ]; then echo "--no-login with --name: naming the device needs a login" >&2; exit 1; fi if [ -n "$ENV_FROM_KV" ]; then echo "--no-login with --env-from-kv: reading kv needs a login" >&2; exit 1; fi if [ -n "$GIT_TOKEN_KEY" ]; then echo "--no-login with --git-token-from-kv: reading kv needs a login" >&2; exit 1; fi if [ -n "$TRUST" ]; then echo "--no-login with --trust: a device's key comes from sidekar.dev, which needs a login" >&2; exit 1; fi elif [ -n "$LOGIN_MODE$HEADLESS$DEVICE_NAME$ENROLL_TOKEN" ]; then DO_LOGIN=1 fi if [ -n "$ENV_FROM_KV$GIT_TOKEN_KEY$TRUST" ] && [ -z "$DO_LOGIN" ]; then DO_LOGIN=1 fi # cloud-init and some service runners start scripts without HOME (or with # HOME=/): use this account's own. if [ -z "${HOME:-}" ] || [ "$HOME" = / ]; then HOME="$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f6)" if [ -z "$HOME" ] && [ "$(id -u)" = 0 ]; then HOME=/root; fi if [ -z "$HOME" ]; then echo "HOME is not set" >&2; exit 1; fi export HOME fi # Who owns a path (GNU stat, else BSD stat). owner_uid() { stat -c %u "$1" 2>/dev/null || stat -f %u "$1" 2>/dev/null; } # This HOME is this account's: not root with `sudo` keeping a user's HOME. own_home() { [ "$(owner_uid "$HOME")" = "$(id -u)" ]; } TARGET_HOME="" if [ -n "$TARGET_USER" ]; then if [ "$(uname -s)" != Linux ]; then echo "--user is for Linux (a system unit); on macOS run install.sh as that user" >&2; exit 1 fi if [ "$(id -u)" != 0 ]; then echo "--user needs root: curl -fsSL https://${DOMAIN}/install.sh | sudo sh -s -- --user $TARGET_USER" >&2; exit 1 fi TARGET_HOME="$(getent passwd "$TARGET_USER" 2>/dev/null | cut -d: -f6)" if [ -z "$TARGET_HOME" ]; then echo "No user named $TARGET_USER" >&2; exit 1; fi fi # Default: user-local install (the --user's, with --user). Use --global for /usr/local/bin. if [ -n "$GLOBAL" ] || [ "$INSTALL_DIR" = "/usr/local/bin" ]; then INSTALL_DIR="/usr/local/bin" elif [ -n "$INSTALL_DIR" ]; then : # custom INSTALL_DIR from env elif [ -n "$TARGET_USER" ]; then INSTALL_DIR="$TARGET_HOME/.local/bin" else INSTALL_DIR="$HOME/.local/bin" fi # Detect OS and architecture OS="$(uname -s)" ARCH="$(uname -m)" case "$OS" in Darwin) PLATFORM="darwin" ;; Linux) PLATFORM="linux" ;; *) echo "Unsupported OS: $OS"; exit 1 ;; esac case "$ARCH" in arm64|aarch64) ARCH_NAME="arm64" ;; x86_64|amd64) ARCH_NAME="x64" ;; *) echo "Unsupported architecture: $ARCH"; exit 1 ;; esac # Alpine and other musl systems: the gnu release won't even start there (no # /lib64/ld-linux-x86-64.so.2). `/etc/alpine-release` or a musl dynamic loader # under /lib(64) says so outright; otherwise an `ldd` that names musl does. LIBC_SUFFIX="" if [ "$PLATFORM" = linux ]; then is_musl="" if [ -e /etc/alpine-release ]; then is_musl=1 else for f in /lib/ld-musl-*.so.1 /lib64/ld-musl-*.so.1; do [ -e "$f" ] && is_musl=1 done if [ -z "$is_musl" ] && command -v ldd >/dev/null 2>&1 \ && ldd --version 2>&1 | grep -qi musl; then is_musl=1 fi fi [ -n "$is_musl" ] && LIBC_SUFFIX="-musl" fi ASSET="${BINARY}-${PLATFORM}-${ARCH_NAME}${LIBC_SUFFIX}" # Get latest version from sidekar.dev API if [ -z "$VERSION" ]; then VERSION="$(curl -fsSL "https://${DOMAIN}/v1/version?current=0.0.0" | grep '"latest"' | cut -d'"' -f4)" if [ -n "$VERSION" ]; then VERSION="v${VERSION}" fi fi if [ -z "$VERSION" ]; then echo "Failed to determine latest version" exit 1 fi URL="https://${DOMAIN}/download/${VERSION}/${ASSET}.tar.gz" SIG_URL="${URL}.minisig" PUBKEY="RWRbW42KimMWVFdiSOnjFE3ZqQ3qqQ45SOySRmomdZIp3Bb9l3ZUrE33" echo "Installing ${BINARY} ${VERSION} (${PLATFORM}/${ARCH_NAME})..." TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT curl -fsSL "$URL" -o "$TMPDIR/${ASSET}.tar.gz" curl -fsSL "$SIG_URL" -o "$TMPDIR/${ASSET}.tar.gz.minisig" # Verify the signature. This must never be silently skipped: if we can't # verify, we refuse to install rather than run unverified code. # Hex of stdin's bytes; bytes [skip, skip+count) of a file. hexof() { od -An -v -tx1 | tr -d ' \n'; } slice() { dd if="$1" bs=1 skip="$2" count="$3" 2>/dev/null; } # Whether this openssl can check a minisign signature: BLAKE2b-512 and raw # Ed25519 verification (OpenSSL 1.1.1+; not LibreSSL, macOS's openssl). openssl_can_verify() { command -v openssl >/dev/null 2>&1 || return 1 printf '' | openssl dgst -blake2b512 -binary >/dev/null 2>&1 || return 1 openssl pkeyutl -help 2>&1 | grep -q -- '-rawin' || return 1 } # Releases are signed prehashed ("ED": Ed25519 over the file's BLAKE2b-512). # A legacy "Ed" signature (over the file itself) is refused on every path: # the global signature doesn't cover those two bytes, so a release's "ED" # signature relabelled "Ed" would "verify" a 64-byte file that is the # release's hash. base64 of "ED" + anything starts "RU"; of "Ed", "RW". sig_is_prehashed() { case "$(sed -n 2p "$1")" in RU*) return 0 ;; *) return 1 ;; esac } # One raw Ed25519 check with openssl: DER-KEY MSG SIG. Both the exit status # and openssl's own verdict, so a build that exits 0 on failure can't pass. ed25519_verify() { openssl pkeyutl -verify -pubin -keyform DER -inkey "$1" -rawin \ -in "$2" -sigfile "$3" 2>/dev/null | grep -q '^Signature Verified Successfully' } # minisign -V with openssl: FILE SIGFILE PUBKEY WORKDIR. Checks what minisign # -V -H checks: the key id, the Ed25519 signature over the file's BLAKE2b-512 # hash ("ED" only), and the global signature over that signature and the # trusted comment. verify_minisig_openssl() { vf="$1"; vs="$2"; vw="$4" mkdir -p "$vw" printf '%s' "$3" | openssl base64 -d -A > "$vw/pub" 2>/dev/null || return 1 [ "$(wc -c < "$vw/pub" | tr -d ' ')" = 42 ] || return 1 [ "$(slice "$vw/pub" 0 2 | hexof)" = 4564 ] || return 1 sed -n 2p "$vs" | tr -d '\r' | openssl base64 -d -A > "$vw/sig" 2>/dev/null || return 1 [ "$(wc -c < "$vw/sig" | tr -d ' ')" = 74 ] || return 1 vtc="$(sed -n 3p "$vs" | tr -d '\r')" case "$vtc" in "trusted comment: "*) ;; *) return 1 ;; esac sed -n 4p "$vs" | tr -d '\r' | openssl base64 -d -A > "$vw/gsig" 2>/dev/null || return 1 [ "$(wc -c < "$vw/gsig" | tr -d ' ')" = 64 ] || return 1 [ "$(slice "$vw/sig" 2 8 | hexof)" = "$(slice "$vw/pub" 2 8 | hexof)" ] || return 1 # The public key as DER (SubjectPublicKeyInfo, id-Ed25519). printf '\060\052\060\005\006\003\053\145\160\003\041\000' > "$vw/pk.der" slice "$vw/pub" 10 32 >> "$vw/pk.der" slice "$vw/sig" 10 64 > "$vw/sig.bin" [ "$(slice "$vw/sig" 0 2 | hexof)" = 4544 ] || return 1 openssl dgst -blake2b512 -binary "$vf" > "$vw/msg" || return 1 [ "$(wc -c < "$vw/msg" | tr -d ' ')" = 64 ] || return 1 ed25519_verify "$vw/pk.der" "$vw/msg" "$vw/sig.bin" || return 1 cp "$vw/sig.bin" "$vw/gmsg" printf '%s' "${vtc#trusted comment: }" >> "$vw/gmsg" ed25519_verify "$vw/pk.der" "$vw/gmsg" "$vw/gsig" || return 1 } # As root: minisign from the distribution's packages. install_minisign() { log="$TMPDIR/minisign-install.log" if command -v apt-get >/dev/null 2>&1; then echo "Installing minisign with apt-get, to verify the release..." # A fresh VM's first boot can hold the dpkg lock (unattended-upgrades): # wait for it rather than fail. { DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300 install -y -q minisign \ || { DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300 update -q \ && DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300 install -y -q minisign; }; } > "$log" 2>&1 || true elif command -v dnf >/dev/null 2>&1; then echo "Installing minisign with dnf, to verify the release..." dnf install -y -q minisign > "$log" 2>&1 || true elif command -v yum >/dev/null 2>&1; then echo "Installing minisign with yum, to verify the release..." yum install -y -q minisign > "$log" 2>&1 || true elif command -v apk >/dev/null 2>&1; then echo "Installing minisign with apk, to verify the release..." apk add --no-cache -q minisign > "$log" 2>&1 || true elif command -v zypper >/dev/null 2>&1; then echo "Installing minisign with zypper, to verify the release..." zypper --non-interactive -q install minisign > "$log" 2>&1 || true else return 1 fi if command -v minisign >/dev/null 2>&1; then return 0; fi echo "Could not install minisign:" >&2 tail -n 5 "$log" >&2 2>/dev/null || true return 1 } minisign_verify() { if sig_is_prehashed "$TMPDIR/${ASSET}.tar.gz.minisig" && minisign -Vm "$TMPDIR/${ASSET}.tar.gz" -P "$PUBKEY" >/dev/null 2>&1; then echo "Signature verified (minisign)." else echo "ERROR: Signature verification failed. Aborting." >&2 exit 1 fi } if command -v minisign >/dev/null 2>&1; then minisign_verify elif openssl_can_verify; then if verify_minisig_openssl "$TMPDIR/${ASSET}.tar.gz" "$TMPDIR/${ASSET}.tar.gz.minisig" "$PUBKEY" "$TMPDIR/verify"; then echo "Signature verified (openssl)." else echo "ERROR: Signature verification failed. Aborting." >&2 exit 1 fi elif [ "$(id -u)" = 0 ] && install_minisign; then minisign_verify else echo "ERROR: can't verify the release signature: no minisign, and no openssl that can check one." >&2 echo " Install minisign and re-run this script:" >&2 case "$PLATFORM" in darwin) echo " brew install minisign" >&2 ;; linux) echo " apt install minisign (Debian/Ubuntu; use your distro's package manager otherwise)" >&2 ;; esac echo " https://jedisct1.github.io/minisign/" >&2 echo " (As root, this script installs it itself with apt-get, dnf, yum, apk or zypper.)" >&2 echo "Refusing to install ${BINARY} without verifying its signature." >&2 exit 1 fi # --no-same-owner: as root, tar would keep the release builder's uid (1001) # on the binary, and whoever has that uid here could replace what root runs. tar --no-same-owner -xzf "$TMPDIR/${ASSET}.tar.gz" -C "$TMPDIR" mkdir -p "$INSTALL_DIR" if [ -w "$INSTALL_DIR" ]; then mv "$TMPDIR/${ASSET}" "${INSTALL_DIR}/${BINARY}" else sudo mv "$TMPDIR/${ASSET}" "${INSTALL_DIR}/${BINARY}" fi chmod +x "${INSTALL_DIR}/${BINARY}" # --user: what was made in their HOME is theirs, so their daemon can update it. if [ -n "$TARGET_USER" ]; then case "$INSTALL_DIR" in "$TARGET_HOME"/*) for p in "$TARGET_HOME/.local" "$INSTALL_DIR" "${INSTALL_DIR}/${BINARY}"; do if [ -e "$p" ] && [ "$(owner_uid "$p")" = 0 ]; then chown "$TARGET_USER:" "$p"; fi done ;; esac fi echo "Installed ${BINARY} to ${INSTALL_DIR}/${BINARY}" # Say so plainly when the release can't run here (an older glibc than it was # built against, say), instead of failing in the steps below. if ! "${INSTALL_DIR}/${BINARY}" --version >/dev/null 2>"$TMPDIR/run.err"; then echo "ERROR: ${INSTALL_DIR}/${BINARY} does not run on this system:" >&2 sed 's/^/ /' "$TMPDIR/run.err" >&2 if grep -q GLIBC "$TMPDIR/run.err"; then echo " This release needs a newer glibc than this system has ($(ldd --version 2>&1 | head -n 1))." >&2 echo " Use a newer distribution (Debian 13, Ubuntu 24.04 or later)." >&2 fi exit 1 fi # --headless as root on Linux: pasta (the passt package), so each session of # the root daemon gets a network of its own (session_network=auto runs a # session on the host's network without it). Best effort: without it the # install still completes, with a warning. have_pasta() { command -v pasta >/dev/null 2>&1 && return 0 for p in /usr/bin/pasta /usr/local/bin/pasta /usr/sbin/pasta /bin/pasta; do [ -x "$p" ] && return 0 done return 1 } install_passt() { log="$TMPDIR/passt-install.log" if command -v apt-get >/dev/null 2>&1; then echo "Installing passt with apt-get, for each session's own network..." { DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300 install -y -q passt \ || { DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300 update -q \ && DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300 install -y -q passt; }; } > "$log" 2>&1 || true elif command -v dnf >/dev/null 2>&1; then echo "Installing passt with dnf, for each session's own network..." dnf install -y -q passt > "$log" 2>&1 || true else echo "NOTE: no apt-get or dnf: install passt yourself, or sessions share this machine's network." >&2 return 0 fi if have_pasta; then return 0; fi echo "WARNING: could not install passt; sessions share this machine's network until it is installed:" >&2 tail -n 5 "$log" >&2 2>/dev/null || true } if [ -n "$HEADLESS" ] && [ "$PLATFORM" = linux ] && [ "$(id -u)" = 0 ] && [ -z "$TARGET_USER" ] \ && ! have_pasta; then install_passt fi # A daemon that is running keeps the binary it started from until it # restarts: restart this HOME's onto the new one now. (Daemons from this # release on also notice a new binary and restart themselves within a # minute; older ones don't.) # Not for a HOME this account doesn't own (`curl ... | sudo sh`, where sudo # may keep the user's HOME): that would stop the user's daemon and start a # root one in their ~/.sidekar. Nor with --user (their daemon notices). if [ -n "$TARGET_USER" ] || ! own_home; then : elif "${INSTALL_DIR}/${BINARY}" daemon status 2>/dev/null | grep -q '^Daemon running'; then if "${INSTALL_DIR}/${BINARY}" daemon restart >/dev/null 2>&1; then echo "Restarted the ${BINARY} daemon onto the new binary." else echo "WARNING: could not restart the ${BINARY} daemon; run: ${BINARY} daemon restart" >&2 fi fi # Clean up old binaries if present for dir in /usr/local/bin "$HOME/.local/bin"; do for old_bin in sidekar-mcp sidekar-rs webact webact-mcp webact-rs; do if [ -x "$dir/$old_bin" ]; then if [ -w "$dir" ]; then rm -f "$dir/$old_bin" echo "Removed old $dir/$old_bin (now use: ${BINARY})" fi fi done done # Auto-add install dir to PATH in shell rc if needed (not root's rc for # another user's install) if [ -n "$TARGET_USER" ]; then PATH_CHECK="$INSTALL_DIR"; else PATH_CHECK=""; fi case ":$PATH:${PATH_CHECK:+:$PATH_CHECK:}" in *":${INSTALL_DIR}:"*) ;; *) PATH_LINE="export PATH=\"${INSTALL_DIR}:\$PATH\"" if [ -f "$HOME/.zshrc" ]; then RC_FILE="$HOME/.zshrc" elif [ -f "$HOME/.bashrc" ]; then RC_FILE="$HOME/.bashrc" elif [ -f "$HOME/.bash_profile" ]; then RC_FILE="$HOME/.bash_profile" else RC_FILE="" fi if [ -n "$RC_FILE" ]; then if ! grep -q "${INSTALL_DIR}" "$RC_FILE" 2>/dev/null; then echo "" >> "$RC_FILE" echo "# Added by ${BINARY} installer" >> "$RC_FILE" echo "$PATH_LINE" >> "$RC_FILE" echo "Added ${INSTALL_DIR} to PATH in ${RC_FILE}" fi else echo "WARNING: ${INSTALL_DIR} is not in your PATH. Add it with:" echo " $PATH_LINE" fi export PATH="${INSTALL_DIR}:$PATH" ;; esac BIN="${INSTALL_DIR}/${BINARY}" # Run the new binary as the account it is for: --user's (as them, with their # HOME), else this one. stdin is never the rest of this script (curl | sh). as_owner() { if [ -n "$TARGET_USER" ]; then runuser -u "$TARGET_USER" -- env HOME="$TARGET_HOME" "$@" < /dev/null else "$@" < /dev/null fi } if [ -n "$TARGET_USER" ] && ! command -v runuser >/dev/null 2>&1; then RUNUSER_MISSING=1 else RUNUSER_MISSING="" fi # Connect this machine to the sidekar account first, so the daemon starts # logged in and its relay link connects at once. LOGIN_FAILED="" LOGGED_IN="" if [ -n "$DO_LOGIN" ]; then if [ -z "$TARGET_USER" ] && [ "$(id -u)" = 0 ] && ! own_home; then echo "NOTE: HOME ($HOME) is not root's: not logging in here. Use --user ." >&2 elif [ -n "$RUNUSER_MISSING" ]; then echo "NOTE: no runuser: run '${BINARY} device login --no-browser' as $TARGET_USER." >&2 elif ! "$BIN" help device 2>/dev/null | grep -q -- '--no-browser'; then echo "NOTE: this ${BINARY} release can't log in without a browser; run '${BINARY} device login' yourself." >&2 else set -- device login --if-needed --no-browser if [ -n "$DEVICE_NAME" ]; then set -- "$@" --name "$DEVICE_NAME"; fi if [ -n "$ENROLL_TOKEN" ]; then echo "Logging in with the enrollment token..."; fi # The token goes to the binary in its environment only: never argv, never # printed. The binary takes it out of its own environment at start. if [ -n "$ENROLL_TOKEN" ]; then export SIDEKAR_ENROLL_TOKEN="$ENROLL_TOKEN"; fi if as_owner "$BIN" "$@"; then LOGGED_IN=1 else LOGIN_FAILED=1 echo "WARNING: this machine is not logged in to sidekar. Retry: ${BINARY} device login --no-browser${DEVICE_NAME:+ --name $DEVICE_NAME}" >&2 fi unset SIDEKAR_ENROLL_TOKEN fi fi ENROLL_TOKEN="" # Skill files, and the daemon under systemd or launchd, so it is always up # and back after a reboot: a user unit or LaunchAgent for this user, a # system unit as root or for --user. # A Linux host with none of these has nothing for `daemon install` to write. # `--headless` still finishes: skills and login, and a pointer at --hook. # SIDEKAR_INIT_ROOT prefixes the probe paths (the tests); unset, they are # the real ones. init_path() { printf '%s%s' "${SIDEKAR_INIT_ROOT:-}" "$1"; } have_boot_supervisor() { case "$(uname -s)" in Darwin) return 0 ;; esac if [ -d "$(init_path /run/systemd/system)" ]; then return 0; fi if [ -d "$(init_path /run/openrc)" ]; then return 0; fi if [ -d "$(init_path /etc/service)" ] || [ -d "$(init_path /var/service)" ] \ || [ -d "$(init_path /service)" ]; then return 0; fi if [ -d "$(init_path /run/service)" ] || [ -d "$(init_path /etc/s6/service)" ] \ || [ -d "$(init_path /var/run/s6/service)" ]; then return 0; fi if [ -f "$(init_path /etc/inittab)" ]; then return 0; fi return 1 } DAEMON_SET_UP="" NO_SUPERVISOR="" # A release from before the supervised daemon takes none of these options. if ! "$BIN" help install 2>/dev/null | grep -q -- '--no-daemon'; then if [ -n "$TARGET_USER" ]; then echo "NOTE: this ${BINARY} release can't set up ${TARGET_USER}'s daemon; run '${BINARY} install' as ${TARGET_USER}." >&2 else "$BIN" install fi elif [ -n "$TARGET_USER" ]; then # Skill files in their HOME, as them; the unit as root. if [ -z "$RUNUSER_MISSING" ]; then runuser -u "$TARGET_USER" -- env HOME="$TARGET_HOME" "$BIN" install --no-daemon \ || echo "WARNING: could not install the skill files for $TARGET_USER" >&2 else echo "WARNING: no runuser: run '${BINARY} install --no-daemon' as $TARGET_USER for the skill files" >&2 fi if [ -z "$NO_DAEMON" ]; then if [ -n "$HEADLESS" ] && ! have_boot_supervisor; then NO_SUPERVISOR=1 elif "$BIN" daemon install --user "$TARGET_USER"; then DAEMON_SET_UP=1; else echo "WARNING: the daemon is not supervised; it starts on demand. Retry: ${BINARY} daemon install --user $TARGET_USER" >&2 fi fi elif [ -n "$NO_DAEMON" ]; then "$BIN" install --no-daemon elif [ "$(id -u)" = 0 ] && ! own_home; then # Root in a user's HOME: no root daemon in their ~/.sidekar. echo "NOTE: HOME ($HOME) is not root's: not setting up a daemon. For that user's, use --user ." >&2 "$BIN" install --no-daemon elif [ -n "$HEADLESS" ] && ! have_boot_supervisor; then "$BIN" install --no-daemon NO_SUPERVISOR=1 else # shellcheck disable=SC2086 "$BIN" install $DAEMON_ARGS DAEMON_SET_UP=1 fi if [ -n "$NO_SUPERVISOR" ]; then echo "No systemd, OpenRC, runit, s6 or inittab here, so the daemon was not set to start at boot." echo " ${BINARY} daemon install --hook " echo " ${BINARY} daemon install --init runit|s6|inittab" fi # Agent credentials from kv into the daemon's environment (never printed). ENV_FAILED="" if [ -n "$ENV_FROM_KV" ] && [ -n "$LOGGED_IN" ]; then for k in $(printf '%s' "$ENV_FROM_KV" | tr ',' ' '); do key="${k%%:*}"; var="${k#*:}" as_owner "$BIN" daemon env set-from-kv "$key" --as "$var" || ENV_FAILED=1 done elif [ -n "$ENV_FROM_KV" ]; then echo "WARNING: not logged in, so --env-from-kv was skipped." >&2 ENV_FAILED=1 fi # --agents: Claude Code and Codex from npm, into this install's prefix # (~/.local, or /usr/local with --global), so the daemon's PATH has them. case "$INSTALL_DIR" in */bin) AGENT_PREFIX="${INSTALL_DIR%/bin}" ;; *) AGENT_PREFIX="${TARGET_HOME:-$HOME}/.local" ;; esac # With --user and a prefix in their HOME, everything that reads or writes it # runs as them: root never runs a node or npm they could have put there, nor # chowns paths they control (a symlinked ~/.local would hand them /usr/local). AGENTS_AS="" if [ -n "$TARGET_USER" ]; then case "$AGENT_PREFIX" in "$TARGET_HOME"/*) AGENTS_AS="$TARGET_USER" ;; esac fi in_prefix() { if [ -n "$AGENTS_AS" ]; then runuser -u "$AGENTS_AS" -- env HOME="$TARGET_HOME" "$@"; else "$@"; fi } node_ok() { v="$(in_prefix env PATH="$AGENT_PREFIX/bin:$PATH" node -v 2>/dev/null < /dev/null | sed 's/^v//' | cut -d. -f1)" [ -n "$v" ] && [ "$v" -ge 18 ] 2>/dev/null } sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1 else openssl dgst -sha256 "$1" | sed 's/.*= *//'; fi } # Node.js LTS from nodejs.org into $AGENT_PREFIX/lib/sidekar-node, linked into # $AGENT_PREFIX/bin. Checked against nodejs.org's SHASUMS256.txt, fetched over # HTTPS only (redirects too): the trust is nodejs.org's TLS, as for npm's # registry below; the checksum catches a corrupt or truncated download. install_node() { NODE_BASE="${SIDEKAR_NODE_DIST:-https://nodejs.org/dist/latest-v22.x}" echo "Installing Node.js (LTS) from nodejs.org for the agents..." curl --proto =https --proto-redir =https -fsSL "$NODE_BASE/SHASUMS256.txt" -o "$TMPDIR/SHASUMS256.txt" || return 1 line="$(grep " node-v[0-9.]*-${PLATFORM}-${ARCH_NAME}\.tar\.gz\$" "$TMPDIR/SHASUMS256.txt" | head -n 1)" [ -n "$line" ] || { echo "no Node.js build for ${PLATFORM}-${ARCH_NAME}" >&2; return 1; } want="${line%% *}"; file="${line##* }" curl --proto =https --proto-redir =https -fsSL "$NODE_BASE/$file" -o "$TMPDIR/$file" || return 1 if [ "$(sha256_of "$TMPDIR/$file")" != "$want" ]; then echo "ERROR: $file does not match nodejs.org's SHASUMS256.txt" >&2; return 1 fi # Unpacked by whoever owns the prefix, from the checked file on stdin. # shellcheck disable=SC2016 in_prefix sh -c 'rm -rf "$1/lib/sidekar-node" && mkdir -p "$1/lib/sidekar-node" "$1/bin" \ && tar --no-same-owner -xzf - -C "$1/lib/sidekar-node" --strip-components=1 \ && for b in node npm npx; do ln -sf "$1/lib/sidekar-node/bin/$b" "$1/bin/$b" || exit 1; done' \ sh "$AGENT_PREFIX" < "$TMPDIR/$file" || return 1 } AGENTS_FAILED="" if [ -n "$AGENT_LIST" ]; then if [ -n "$AGENTS_AS" ] && [ -n "$RUNUSER_MISSING" ]; then echo "WARNING: no runuser: can't install the agents as $TARGET_USER" >&2; AGENTS_FAILED=1 elif ! node_ok; then install_node || AGENTS_FAILED=1; fi if [ -z "$AGENTS_FAILED" ]; then pkgs="" for a in $AGENT_LIST; do case "$a" in claude) pkgs="$pkgs @anthropic-ai/claude-code" ;; codex) pkgs="$pkgs @openai/codex" ;; esac done echo "Installing${pkgs} with npm into $AGENT_PREFIX..." # shellcheck disable=SC2086 if ! in_prefix env PATH="$AGENT_PREFIX/bin:$PATH" npm install -g --prefix "$AGENT_PREFIX" --no-fund --no-audit $pkgs < /dev/null; then AGENTS_FAILED=1 fi fi if [ -n "$AGENTS_FAILED" ]; then echo "WARNING: the agents were not installed. Retry: npm install -g --prefix $AGENT_PREFIX${pkgs:- @anthropic-ai/claude-code @openai/codex}" >&2 elif [ -n "$DAEMON_SET_UP" ]; then # The daemon's PATH (daemon.env) is re-read from this shell's. if [ -n "$TARGET_USER" ]; then "$BIN" daemon install --user "$TARGET_USER" >/dev/null 2>&1 || true else # shellcheck disable=SC2086 PATH="$AGENT_PREFIX/bin:$PATH" "$BIN" daemon install $DAEMON_ARGS >/dev/null 2>&1 || true fi fi # The skill files went in before the agents existed, so they skipped them: # again now, with the agents' prefix on PATH (#328). if [ -z "$AGENTS_FAILED" ] && "$BIN" help install 2>/dev/null | grep -q -- '--no-daemon'; then if [ -n "$TARGET_USER" ]; then [ -n "$RUNUSER_MISSING" ] || runuser -u "$TARGET_USER" -- env HOME="$TARGET_HOME" \ PATH="$AGENT_PREFIX/bin:$PATH" "$BIN" install --no-daemon >/dev/null 2>&1 \ || echo "WARNING: could not install the skill files for the agents; run '${BINARY} install --no-daemon' as $TARGET_USER" >&2 else PATH="$AGENT_PREFIX/bin:$PATH" "$BIN" install --no-daemon >/dev/null 2>&1 \ || echo "WARNING: could not install the skill files for the agents; run '${BINARY} install --no-daemon'" >&2 fi fi fi # What remote control and git need from this account (#328). SETUP_FAILED="" TRUSTED="" if [ -n "$TRUST$REMOTE_ENGINES$GIT_TOKEN_KEY" ] && [ -z "$LOGGED_IN" ]; then echo "WARNING: not logged in, so --trust, --remote-engines and --git-token-from-kv were skipped." >&2 SETUP_FAILED=1 elif [ -n "$TRUST$REMOTE_ENGINES$GIT_TOKEN_KEY" ]; then for t in $(printf '%s' "$TRUST" | tr ',' ' '); do if as_owner "$BIN" device trust "${t%%=*}" --fingerprint "${t#*=}"; then TRUSTED="$TRUSTED ${t%%=*}"; else echo "WARNING: could not trust ${t%%=*}. Retry: ${BINARY} device trust ${t%%=*} --fingerprint ${t#*=}" >&2 SETUP_FAILED=1 fi done if [ -n "$REMOTE_ENGINES" ]; then as_owner "$BIN" config set remote_engines "$REMOTE_ENGINES" >/dev/null || { echo "WARNING: could not set remote_engines. Retry: ${BINARY} config set remote_engines $REMOTE_ENGINES" >&2 SETUP_FAILED=1 } fi if [ -n "$GIT_TOKEN_KEY" ]; then if ! "$BIN" help git-credential 2>/dev/null | grep -q 'git-credential setup'; then echo "WARNING: this ${BINARY} release has no git-credential; --git-token-from-kv was skipped" >&2 SETUP_FAILED=1 elif ! command -v git >/dev/null 2>&1; then echo "WARNING: git is not installed; --git-token-from-kv was skipped (install git, then: ${BINARY} git-credential setup --kv $GIT_TOKEN_KEY)" >&2 SETUP_FAILED=1 elif ! as_owner "$BIN" git-credential setup --kv "$GIT_TOKEN_KEY"; then echo "WARNING: git was not set up. Retry: ${BINARY} git-credential setup --kv $GIT_TOKEN_KEY" >&2 SETUP_FAILED=1 fi fi fi # Summary: what is installed and running, and what to do next. if [ -z "$RUNUSER_MISSING" ] && { [ -n "$TARGET_USER" ] || [ "$(id -u)" != 0 ] || own_home; } \ && "$BIN" help device 2>/dev/null | grep -q -- '--wait-relay'; then echo "" echo "sidekar is installed:" if [ -n "$LOGGED_IN" ] && [ -n "$DAEMON_SET_UP" ]; then wait_relay=45; else wait_relay=0; fi as_owner "$BIN" device status --wait-relay "$wait_relay" 2>&1 | sed 's/^/ /' || true shown="${DEVICE_NAME:-$(hostname 2>/dev/null || echo this-machine)}" echo "" if [ -z "$LOGGED_IN" ]; then echo "Next, log this machine in (it shows up in device lists only then):" echo " ${BINARY} device login --no-browser --name ${shown}" echo "Then, from another machine logged in to the same account:" else echo "Next, from another machine logged in to the same account:" fi echo " ${BINARY} device list # shows ${shown}" echo " ${BINARY} device key # prints that machine's key fingerprint" if [ -n "$TRUSTED" ]; then echo "Trusted here for remote control:${TRUSTED}" echo "From there:" else echo "Trust that key here (remote control takes commands only from trusted keys):" echo " ${BINARY} device trust --fingerprint SHA256:..." echo "Then, from that machine:" fi echo " ${BINARY} session new claude --device ${shown}" if [ -z "$ENV_FROM_KV" ] || [ -n "$ENV_FAILED" ]; then echo "Agent credentials here (values are never printed):" echo " on your laptop: claude setup-token, then ${BINARY} kv set CLAUDE_CODE_OAUTH_TOKEN - (paste the token, then Ctrl-D)" echo " on this machine: ${BINARY} daemon env set-from-kv CLAUDE_CODE_OAUTH_TOKEN" fi fi if [ -n "$LOGIN_FAILED$ENV_FAILED$AGENTS_FAILED$SETUP_FAILED" ]; then exit 1 fi exit 0 } main "$@"