#!/usr/bin/env bash # OrgSync's AI employee installer (D-180). It prepares the computer an AI employee works on: # # curl -fsSL https://orgsync.co/ai-employee/install.sh | bash -s -- --cadence 15 --weekday 5 # # In order: Hermes Agent at the commit the employee kit was tested with (its installer checked against the SHA-256 below), the # model account (in Hermes's own setup), Hermes's gateway, OrgSync's employee kit (checked against its SHA-256 below and # reinstalled from that checked archive on every run), the `orgsync-employee` command and the kit's setup. Then, unless # --no-pair, it asks for the 8-digit pairing code from OrgSync and pairs (D-191); the person types the end of the fingerprint # it prints in OrgSync. It never signs in to OrgSync, uses no sudo itself, and never stores or prints a key, a token or the # code. Running it again is safe: a computer that is already paired says so and stops. # # The web build fills in the five values below (apps/web/kit/ai-employee-kit.ts). Hermes facts come from Hermes's source at # that commit; each is cited where it is used. if [ -z "${BASH_VERSION:-}" ]; then echo "Run this script with bash: curl -fsSL
| bash -s -- --cadence 15 --weekday 5" >&2 exit 2 fi set -u KIT_VERSION="0.5.0" KIT_SHA256="796deb50cbb1d4b71d014c261c99ddd0cc37069e4a03313c79cc9bd77ea0e1c8" HERMES_VERSION="0.22.5" HERMES_COMMIT="bf0203a794a07aac3ff764972ab9087baa4e4f4e" HERMES_INSTALLER_SHA256="99cb3691d55019c6973a7acb775a915648666cc24e483ca827a1cdbb3531a674" DEFAULT_URL="https://orgsync.co" PROFILE="orgsync-employee" # Hermes's own installer as of the pinned commit, so the installer and what it installs come from the same tree. It runs only # if its SHA-256 is the one OrgSync measured, so a change on GitHub's side stops here. What it downloads in turn is Hermes's. HERMES_INSTALLER_URL="https://raw.githubusercontent.com/NousResearch/hermes-agent/$HERMES_COMMIT/scripts/install.sh" # Tests shorten the wait for another Hermes update and point the terminal at a file; nothing else sets these. UPDATE_WAIT_SECONDS="${ORGSYNC_INSTALL_UPDATE_WAIT_SECONDS:-1200}" POLL_SECONDS="${ORGSYNC_INSTALL_POLL_SECONDS:-5}" TTY="${ORGSYNC_INSTALL_TTY:-/dev/tty}" # ------------------------------------------------------------------------------------------------------------- output say() { printf '%s\n' "$*"; } step() { printf '\n==> %s\n' "$*"; } # Every stop ends with its short code and one next step. stop() { # code, what happened, the next step printf '\nStopped: %s\n' "$2" >&2 printf 'Error: %s\n' "$1" >&2 printf 'Next step: %s\n' "$3" >&2 exit 1 } usage_error() { # code, what is wrong printf '%s\n' "$2" >&2 printf 'Error: %s\n' "$1" >&2 printf 'Next step: %s\n' "Copy the command from the Add AI employee pop-up in OrgSync again, or run it with --help." >&2 exit 2 } usage() { cat </dev/null; } check_computer() { case "$KIT_SHA256$KIT_VERSION$HERMES_COMMIT$HERMES_VERSION$HERMES_INSTALLER_SHA256" in *@*) stop installer_not_built "This copy of the script has no kit checksum: it wasn't built by OrgSync's web build." \ "Copy the command from the Add AI employee pop-up in OrgSync, which downloads the built script." ;; esac [ "$(id -u)" != 0 ] || stop root_refused "This script doesn't run as root, and doesn't need sudo." \ "Sign in as the user the AI employee works as (create one if needed, for example with adduser), then run the command again." case "${HOME:-}" in /*) ;; *) stop home_missing "HOME isn't set to a folder." "Sign in as the AI employee's own user, then run the command again." ;; esac OS="$(uname -s)" case "$OS/$(uname -m)" in Darwin/arm64|Linux/x86_64|Linux/aarch64|Linux/arm64) ;; *) stop platform_unsupported "This computer is $(uname -s) on $(uname -m). OrgSync's kit supports an Apple Silicon Mac, or Linux on x86_64 or arm64." \ "Use a Mac with Apple Silicon, or a Linux server or virtual machine." ;; esac local tool for tool in curl tar ps; do command -v "$tool" >/dev/null 2>&1 || stop "${tool}_missing" "This computer has no $tool command." "Install $tool with the system's package manager, then run the command again." done command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1 \ || stop checksum_tool_missing "This computer has neither sha256sum nor shasum to check the kit with." "Install coreutils (sha256sum), then run the command again." if has_terminal; then INPUT="$TTY"; fi } # ---------------------------------------------------------------------------------------------------------- downloads fetch() { # url, file local proto="=https" case "$1" in http://127.0.0.1*|http://localhost*|http://\[::1\]*) proto="=http" ;; esac curl -fsSL --proto "$proto" --proto-redir "$proto" --tlsv1.2 --retry 2 --connect-timeout 20 -o "$2" "$1" /dev/null 2>&1; then sha256sum "$1" | cut -d ' ' -f 1; else shasum -a 256 "$1" | cut -d ' ' -f 1; fi } # A value in single quotes for a shell script. sh_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } # ------------------------------------------------------------------------------------- another Hermes update, if any # Hermes refuses to change an install while another update holds it, saying "Another Hermes update is already running" # (hermes_cli/update_lock.py describe_holder). An update holds it while the marker /.hermes-update-in-progress names # a live process, or while a process holds the kernel lock on the checkout's hermes-update.lock. These probes follow # Hermes's own shell reader, scripts/desktop-update/marker.sh, and change nothing. proc_ct() { # pid: its creation time in Unix seconds, or nothing (marker.sh proc_ct) local pid="$1" stat rest start btime hz lstart secs if [ -r "/proc/$pid/stat" ]; then stat="$(cat "/proc/$pid/stat" 2>/dev/null)" || return 0 rest="${stat##*) }" start="$(printf '%s\n' "$rest" | awk '{print $20}')" btime="$(awk '/^btime /{print $2}' /proc/stat 2>/dev/null)" hz="$(getconf CLK_TCK 2>/dev/null)" [ -n "$start" ] && [ -n "$btime" ] && [ -n "$hz" ] || return 0 awk -v b="$btime" -v s="$start" -v h="$hz" 'BEGIN{printf "%.3f\n", b + s / h}' elif [ "$OS" = Darwin ]; then lstart="$(TZ=UTC0 LC_ALL=C ps -o lstart= -p "$pid" 2>/dev/null | sed 's/^ *//;s/ *$//')" [ -n "$lstart" ] || return 0 secs="$(TZ=UTC0 LC_ALL=C date -j -f '%a %b %e %T %Y' "$lstart" +%s 2>/dev/null)" || return 0 [ -n "$secs" ] && printf '%s.000\n' "$secs" fi } pid_alive() { # a running process, not a zombie local state case "$1" in ''|*[!0-9]*) return 1 ;; esac state="$(ps -o stat= -p "$1" 2>/dev/null | tr -d '[:space:]')" [ -n "$state" ] && [ "${state#Z}" = "$state" ] } owner_live() { # pid, recorded creation time, marker age: the same process still runs (marker.sh marker_identity) local have pid_alive "$1" || return 1 [ -z "$2" ] || have="$(proc_ct "$1")" if [ -z "$2" ] || [ -z "${have:-}" ]; then [ "$3" -le 1200 ]; return; fi awk -v a="$2" -v b="$have" 'BEGIN{d=a-b; if (d<0) d=-d; exit !(d<=2.0)}' } update_marker_live() { local marker="$HERMES_ROOT/.hermes-update-in-progress" pid started ct delegate dct age mtime [ -e "$marker" ] || return 1 if [ ! -s "$marker" ]; then # a claim being written is briefly empty (contract A3) mtime="$(stat -c %Y "$marker" 2>/dev/null || stat -f %m "$marker" 2>/dev/null)" || return 1 [ -n "$mtime" ] && [ $(( $(date +%s) - mtime )) -lt 5 ] return fi pid="$(sed -n '1p' "$marker" 2>/dev/null | tr -d '[:space:]')" started="$(sed -n '2p' "$marker" 2>/dev/null | tr -d '[:space:]')" ct="$(sed -n '3s/^[[:space:]]*ct:\([0-9][0-9.]*\)[[:space:]]*$/\1/p' "$marker" 2>/dev/null)" delegate="$(sed -n 's/^[[:space:]]*delegate:\([0-9][0-9]*\) ct:\([0-9][0-9.]*\)[[:space:]]*$/\1 \2/p' "$marker" 2>/dev/null | head -n 1)" case "$started" in ''|*[!0-9]*) age=0 ;; *) [ "${#started}" -le 12 ] && age=$(( $(date +%s) - 10#$started )) || age=0 ;; esac owner_live "$pid" "$ct" "$age" && return 0 if [ -n "$delegate" ]; then dct="${delegate#* }"; owner_live "${delegate%% *}" "$dct" "$age" && return 0; fi return 1 } checkout_lock_held() { # one non-blocking try at Hermes's checkout lock, given straight back (marker.sh checkout_lock_held) local lock="$HERMES_AGENT/.hermes-update.lock" status [ -d "$HERMES_AGENT/.git" ] && lock="$HERMES_AGENT/.git/hermes-update.lock" [ -f "$lock" ] || return 1 if command -v perl >/dev/null 2>&1; then perl -MFcntl=:flock -e 'open(my $f, "<", $ARGV[0]) or exit($!{ENOENT} ? 3 : 2); flock($f, LOCK_EX | LOCK_NB) or exit 1; flock($f, LOCK_UN); exit 0' "$lock" status=$? elif command -v python3 >/dev/null 2>&1; then python3 -I -c 'import fcntl, os, sys try: fd = os.open(sys.argv[1], os.O_RDONLY) except FileNotFoundError: sys.exit(3) except OSError: sys.exit(2) try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: sys.exit(1) fcntl.flock(fd, fcntl.LOCK_UN)' "$lock" status=$? else return 1 # no tool to probe with: the marker alone decides fi [ "$status" -ne 0 ] && [ "$status" -ne 3 ] } update_running() { update_marker_live || checkout_lock_held; } wait_for_update() { update_running || return 0 local started=$SECONDS last=$SECONDS say "Hermes is busy with another update. Waiting for it to finish (up to $((UPDATE_WAIT_SECONDS / 60)) minutes)…" while update_running; do [ $((SECONDS - started)) -lt "$UPDATE_WAIT_SECONDS" ] || stop hermes_update_still_running \ "Another Hermes update was still running after $((UPDATE_WAIT_SECONDS / 60)) minutes." \ "Let it finish (\`hermes logs\` shows it), or close the Hermes app or dashboard that started it, then run this command again." sleep "$POLL_SECONDS" if [ $((SECONDS - last)) -ge 30 ]; then say " Still waiting for Hermes's other update ($(( (SECONDS - started) / 60 )) min so far)…"; last=$SECONDS; fi done say "Hermes's other update has finished." } install_log_size() { [ ! -f "$HERMES_ROOT/logs/install.log" ] || wc -c <"$HERMES_ROOT/logs/install.log" | tr -d '[:space:]'; } # Hermes's installer writes a refusal to its log in a terminal (hermes_cli/source_completion.py: "an update is still running"). install_log_names_update() { local log="$HERMES_ROOT/logs/install.log" [ -f "$log" ] || return 1 tail -c "+$((LOG_MARK + 1))" "$log" 2>/dev/null | grep -qiE 'another hermes update is already running|an update is still running' } # Runs a Hermes step after any other update ends. If it fails while, or because, another update holds the install, it waits # that update out and runs the step once more. Exit 2 is Hermes's "another update owns this install" (UPDATE_EXIT_CONCURRENT). hermes_step() { local status wait_for_update LOG_MARK="$(install_log_size)"; LOG_MARK="${LOG_MARK:-0}" "$@" status=$? [ "$status" -ne 0 ] || return 0 if [ "$status" -eq 2 ] || update_running || install_log_names_update; then say "Hermes said another update was running. Waiting for it, then trying once more…" wait_for_update "$@" status=$? fi return "$status" } # -------------------------------------------------------------------------------------------------------------- Hermes # The installed version, as the kit reads it: baseVersion in install-stamp.json (hermes_cli/version_info.py). hermes_version() { local version version="$(tr -d '\n' <"$HERMES_AGENT/install-stamp.json" 2>/dev/null \ | grep -o '"baseVersion"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n 1 | sed 's/.*"\([^"]*\)"$/\1/')" [[ "$version" =~ ^[0-9A-Za-z.+-]{1,40}$ ]] && printf '%s' "$version" } # The install's own launcher first: the installer publishes it there and links it into ~/.local/bin. find_hermes() { local candidate for candidate in "$HERMES_AGENT/.hermes/bin/hermes" "$BIN_DIR/hermes" "$(command -v hermes 2>/dev/null)" "$HERMES_AGENT/venv/bin/hermes"; do if [ -n "$candidate" ] && [ -x "$candidate" ]; then HERMES_BIN="$candidate"; return 0; fi done return 1 } run_hermes_installer() { bash "$WORK/hermes-install.sh" --commit "$HERMES_COMMIT" <"$INPUT"; } step_hermes() { local version if [ -d "$HERMES_AGENT" ]; then step "Checking Hermes Agent…" wait_for_update version="$(hermes_version)" if [ "$version" = "$HERMES_VERSION" ]; then say "Hermes Agent $HERMES_VERSION is already installed." elif [ "$ALLOW_OTHER" = 1 ]; then say "Using the Hermes Agent already here (${version:-version unknown}), as --allow-other-hermes asks. OrgSync's kit is tested with $HERMES_VERSION." else stop hermes_version_not_pinned "Hermes Agent ${version:-of an unknown version} is installed here, but OrgSync's kit is tested with $HERMES_VERSION." \ "Remove it with \`hermes uninstall\` (it keeps your settings) and run this command again, or add --allow-other-hermes to keep it." fi else step "Installing Hermes Agent ${HERMES_VERSION}…" fetch "$HERMES_INSTALLER_URL" "$WORK/hermes-install.sh" || stop hermes_download_failed "Hermes's installer couldn't be downloaded." \ "Check this computer's internet connection, then run this command again." [ "$(sha256_of "$WORK/hermes-install.sh")" = "$HERMES_INSTALLER_SHA256" ] || stop hermes_installer_mismatch \ "Hermes's installer downloaded from GitHub doesn't match the SHA-256 OrgSync checked for Hermes Agent $HERMES_VERSION, so it wasn't run." \ "Run this command again. If it says this again, tell OrgSync support." say "Hermes's own installer runs now. When its setup asks for a model account, choose your company's: the key goes to Hermes, never to OrgSync." hermes_step run_hermes_installer || stop hermes_install_failed "Hermes's installer stopped; its message is above." \ "Fix what it says, then run this command again." version="$(hermes_version)" [ "$version" = "$HERMES_VERSION" ] || stop hermes_install_unexpected "Hermes's installer finished, but Hermes Agent ${version:-of an unknown version} is installed, not $HERMES_VERSION." \ "Run this command again. If it says this again, tell OrgSync support." say "Hermes Agent $HERMES_VERSION is installed." fi find_hermes || stop hermes_not_found "Hermes Agent is installed, but its hermes command isn't." "Open a new terminal, then run this command again." } # ------------------------------------------------------------------------------------------------------------- model # Whether a Hermes home has a model provider, by Hermes's own test (hermes_cli/main.py _has_any_provider_configured, which # reads that home's config.yaml, .env and auth.json). It runs with a bare environment, as the gateway's schedule does, and # reports only yes or no: 0 yes, 1 no, 2 unknown. MODEL_PROBE='import sys sys.path.insert(0, sys.argv[1]) try: from hermes_cli.main import _has_any_provider_configured except Exception: sys.exit(2) try: sys.exit(0 if _has_any_provider_configured() else 1) except Exception: sys.exit(2)' model_configured() { # Hermes home [ -x "$HERMES_PY" ] || return 2 env -i HOME="$HOME" PATH="$PATH" USER="${USER:-}" LOGNAME="${LOGNAME:-}" LANG="${LANG:-C.UTF-8}" HERMES_HOME="$1" \ "$HERMES_PY" -I -c "$MODEL_PROBE" "$HERMES_AGENT" /dev/null 2>&1 case $? in 0) return 0 ;; 1) return 1 ;; *) return 2 ;; esac } # The AI employee works in its own Hermes profile, and Hermes keeps each profile's keys apart (agent/secret_scope.py). So the # model account must be in that profile. When Hermes's main settings already have one, from Hermes's own setup a moment ago, # the profile starts as a copy of them (`profile create --clone-from default` copies config.yaml and .env, without messaging # channels: hermes_cli/profiles.py create_profile), and nothing is asked twice. step_model() { local state step "Checking the model account for $PROFILE, the Hermes profile the AI employee works in…" if [ ! -d "$PROFILE_HOME" ]; then if model_configured "$HERMES_ROOT"; then say "Creating the profile $PROFILE from Hermes's main settings, so it uses the model account chosen there." hermes_step "$HERMES_BIN" profile create "$PROFILE" --clone-from default --no-alias &1)"; status=$? [ -z "$text" ] || printf '%s\n' "$text" | sed 's/^/ /' if [ "$status" -ne 0 ] || printf '%s' "$text" | grep -qi 'not running'; then say "The gateway isn't running yet, so $PROFILE's schedule won't run. Start it with: hermes gateway start" fi # A user service on Linux stops at logout unless linger is on (hermes_cli/gateway.py get_systemd_linger_status). Turning it # on needs sudo, so the command is shown, never run. if [ "$OS" = Linux ] && command -v loginctl >/dev/null 2>&1; then user="$(id -un)" linger="$(loginctl show-user "$user" --property=Linger --value /dev/null | tr '[:upper:]' '[:lower:]')" case "$linger" in yes|true|1) say "Linger is on, so the gateway keeps running after you log out." ;; *) say "So $PROFILE keeps working after you log out, turn on linger once. It needs sudo:" say " sudo loginctl enable-linger $user" ;; esac fi } # --------------------------------------------------------------------------------------------------------------- kit # Every run downloads the kit, checks its SHA-256 and puts that checked copy in place, even when the same version is here: the # AI employee runs as this user and could have changed the files, so nothing already on disk is trusted (D-191 review). step_kit() { local actual again=0 [ ! -e "$KIT_HOME" ] || again=1 step "Installing OrgSync's employee kit ${KIT_VERSION}…" fetch "$URL/ai-employee/orgsync-employee-$KIT_VERSION.tar.gz" "$WORK/kit.tar.gz" \ || stop kit_download_failed "The kit couldn't be downloaded from $URL." "Check this computer's internet connection, then run this command again." actual="$(sha256_of "$WORK/kit.tar.gz")" [ "$actual" = "$KIT_SHA256" ] || stop kit_checksum_mismatch "The kit downloaded from $URL doesn't match the SHA-256 this script expects, so it wasn't installed." \ "Copy the command from OrgSync again and run it. If it says this again, tell OrgSync support." mkdir -p "$WORK/kit" && tar -xzf "$WORK/kit.tar.gz" -C "$WORK/kit" /dev/null)" != "$KIT_VERSION" ]; then ln -sfn "$KIT_VERSION" "$KIT_ROOT/current" || stop kit_install_failed "The kit's current link couldn't be written." "Check that your home folder is writable, then run this command again." fi } # ------------------------------------------------------------------------------------------------------------ command step_command() { local content rc="~/.profile" case "${SHELL:-}" in */zsh) rc="~/.zshrc" ;; */bash) rc="~/.bashrc" ;; esac step "Writing the orgsync-employee command…" content="#!/bin/sh # Written by OrgSync's AI employee installer: the employee kit, run with Hermes's own Python. exec $(sh_quote "$HERMES_PY") -I $(sh_quote "$KIT_ROOT/current/orgsync_employee.py") \"\$@\"" if [ -f "$COMMAND" ] && [ "$(cat "$COMMAND")" = "$content" ] && [ -x "$COMMAND" ]; then say "It's already at $COMMAND." else mkdir -p "$BIN_DIR" && printf '%s\n' "$content" >"$COMMAND.tmp" && chmod 755 "$COMMAND.tmp" && mv -f "$COMMAND.tmp" "$COMMAND" \ || stop command_write_failed "The command couldn't be written to $COMMAND." "Check that your home folder is writable, then run this command again." say "Written to $COMMAND." fi case ":$PATH:" in *":$BIN_DIR:"*|*":$BIN_DIR/:"*) ;; *) say "~/.local/bin isn't on this terminal's PATH yet. New terminals usually have it; in this one, use the full path: $COMMAND" say "To add it for new terminals yourself: echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> $rc" ;; esac } # --------------------------------------------------------------------------------------------------------- kit setup step_setup() { local args=(--hermes "$HERMES_BIN" setup --url "$URL" --cadence-minutes "$CADENCE" --check-in-weekday "$WEEKDAY") [ "$DEDICATED" = 0 ] || args+=(--dedicated-machine-mode) [ "$ALLOW_OTHER" = 0 ] || args+=(--allow-other-hermes) step "Setting up the kit in Hermes: OrgSync's tools, its skills and $PROFILE's schedule…" hermes_step "$COMMAND" "${args[@]}" /dev/null 2>&1; then say "This computer is already paired with its AI employee, so there's nothing to pair." say "To pair it again, for example after Replace credential in OrgSync, run: $(pair_command)" return 0 fi has_terminal || stop terminal_required "Pairing asks for the code from OrgSync in a terminal, which this session doesn't have." \ "Run the command in a terminal (over SSH, connect with ssh -t), or add --no-pair and run \`orgsync-employee pair\` later." say "In OrgSync, click Create … and get the pairing code. It's 8 digits and works once, for 15 minutes." while :; do printf 'Enter the 8-digit pairing code from OrgSync: ' IFS= read -r code <"$TTY" || { printf '\n'; stop pairing_code_required "No code was entered." "Run \`$(pair_command)\` and enter the code from OrgSync."; } code="$(printf '%s' "$code" | tr -d '[:space:]-')" [[ "$code" =~ ^[0-9]{8}$ ]] && break tries=$((tries + 1)) [ "$tries" -lt 3 ] || stop pairing_code_format "That wasn't an 8-digit code three times." "Copy the code from OrgSync again, then run: $(pair_command)" say "That isn't 8 digits. A pairing code looks like 4829 1037." done ORGSYNC_PAIRING_CODE="$code" "$COMMAND" pair