#!/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