Files
hermes-agent/scripts/install.sh
ethernet 92686159d1 fix(pm): integrate audited runtime and lifecycle repairs
Prepare dependency generations before selecting them. Keep shipped tool
bytes separate from writable additions, and store facts beside their entries.
Validate proposed plugin sets before config publication. Restore the previous
config if the facts write fails.

Consolidate duplicate updater, backup, setup, and voice helpers. Repair
launcher selection, dependency consumers, download ownership, update feeds,
and native Windows process and file handling.

Verification: 206 changed/prior-failing Python files reported 4630 passed,
one failed, and 330 skipped. Fix the remaining Hindsight fixture boundary.
The final targeted rerun reported 234 passed and two skipped. The store
review regression batch reported 83 passed and one skipped. Desktop
TypeScript checks, 56 selected Electron tests, 24 release tests, and the
removed-import/compatibility guards passed.

This is an integration checkpoint, not full audit acceptance. The complete
Python suite has not run on this fixed tree. Crash-atomic plugin publication,
generation cleanup, receipt correlation, and packaged lifecycle acceptance
remain open in docs/pm-audit-status.md.
2026-09-05 22:36:48 -04:00

451 lines
17 KiB
Bash
Executable File

#!/usr/bin/env bash
# Hermes Agent bootstrap: git checkout + venv + hermes command on PATH.
# Heavy dependencies (tool binaries, browsers, node) are pm's job after
# this: `hermes pm install`. Stage protocol kept for Hermes-Setup:
# --manifest print the stage list as JSON
# --stage NAME [--json] run one stage
# --non-interactive skip stages that need input
# --include-desktop add the desktop build stage
set -u
# Prevent uv from discovering config files (uv.toml, pyproject.toml) from the
# wrong user's home directory when running under sudo -u <user>. See #21269.
# pm's own venv sync re-isolates (pm/packages.py::uv_env), so this bootstrap
# hygiene can't break the locked sync the way it used to before pm owned it.
export UV_NO_CONFIG=1
REPO_URL="${HERMES_REPO_URL:-https://github.com/NousResearch/hermes-agent.git}"
BRANCH="main"
INSTALL_COMMIT=""
INSTALL_DIR="${HERMES_INSTALL_DIR:-$HOME/.hermes/hermes-agent}"
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
STAGE=""
WANT_MANIFEST=false
JSON=false
NON_INTERACTIVE=false
INCLUDE_DESKTOP=false
while [ $# -gt 0 ]; do
case "$1" in
--branch|-Branch) BRANCH="$2"; shift 2 ;;
--commit|-Commit) INSTALL_COMMIT="$2"; shift 2 ;;
--dir) INSTALL_DIR="$2"; shift 2 ;;
--manifest|-Manifest) WANT_MANIFEST=true; shift ;;
--stage|-Stage) STAGE="$2"; shift 2 ;;
--json|-Json) JSON=true; shift ;;
--non-interactive|-NonInteractive) NON_INTERACTIVE=true; shift ;;
--skip-setup|--skip-browser) NON_INTERACTIVE=true; shift ;;
--include-desktop|-IncludeDesktop) INCLUDE_DESKTOP=true; shift ;;
-h|--help)
echo "Usage: install.sh [--branch NAME] [--commit SHA] [--dir PATH]"
echo " [--manifest] [--stage NAME] [--json]"
echo " [--non-interactive] [--include-desktop]"
exit 0 ;;
*) echo "unknown option: $1" >&2; exit 1 ;;
esac
done
log() { printf "\033[1;34m[hermes]\033[0m %s\n" "$1"; }
fail() { printf "\033[1;31m[hermes]\033[0m %s\n" "$1" >&2; exit 1; }
# --- BEGIN GENERATED: bootstrap pins (scripts/gen-bootstrap-pins.py) ---
# Derived from pm/lock.json. DO NOT EDIT BY HAND:
# run scripts/gen-bootstrap-pins.py after a pin bump.
UV_PIN_VERSION="0.12.3"
# Sets UV_PIN_URL + UV_PIN_SHA256 for a <os>-<arch> target key.
uv_bootstrap_pin() {
case "$1" in
linux-x64)
UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-x86_64-unknown-linux-gnu.tar.gz"
UV_PIN_SHA256="600cf9a742aca00d292673b16b5acffaa7b8c269a364ad0c2e79498dcb1fe101"
;;
linux-arm64)
UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-aarch64-unknown-linux-gnu.tar.gz"
UV_PIN_SHA256="bb66cb52e7b1823aed1183630d8d8e5c958840d584a4c55ec10a4cfc168dcca2"
;;
darwin-x64)
UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-x86_64-apple-darwin.tar.gz"
UV_PIN_SHA256="4c9f52262a14da336e4a42ed24992d12d0c956acde87619e4611d321dffa602b"
;;
darwin-arm64)
UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-aarch64-apple-darwin.tar.gz"
UV_PIN_SHA256="546f7f8a6c70ff13a3a9d2bc958db3427298cebf3e0cb756f9177133b7068843"
;;
*)
UV_PIN_URL=""
UV_PIN_SHA256=""
return 1
;;
esac
}
# --- END GENERATED: bootstrap pins ---
uv_bootstrap_target() {
# Map this host to a pm/lock.json target key (<os>-<arch>).
local _arch
case "$(uname -m)" in
arm64|aarch64) _arch="arm64" ;;
x86_64|amd64) _arch="x64" ;;
*) return 1 ;;
esac
case "$(uname -s)" in
Linux) echo "linux-$_arch" ;;
Darwin) echo "darwin-$_arch" ;;
*) return 1 ;;
esac
}
# Provision uv for this host from the pinned pm/lock.json artifact. Stages
# the EXACT artifact pm itself uses into the same store slot
# (~/.hermes/tools/uv-<version>-<target>/), sha256-verified, so the byte
# authority is pm/lock.json - no astral-latest, no curl|sh.
UV_CMD=""
ensure_uv() {
[ -n "$UV_CMD" ] && return 0
if command -v uv >/dev/null 2>&1; then
# Developer shortcut: an existing uv on PATH is fine to use; this
# branch fetches nothing.
UV_CMD="uv"
return 0
fi
local _target
if ! _target="$(uv_bootstrap_target)"; then
fail "no pinned uv build for this platform ($(uname -s) $(uname -m)); install uv manually: https://docs.astral.sh/uv/"
fi
if ! uv_bootstrap_pin "$_target"; then
fail "no pinned uv artifact for $_target; install uv manually: https://docs.astral.sh/uv/"
fi
local _store="${HERMES_RUNTIME_DIR:-$HOME/.hermes/tools}"
local _entry="$_store/uv-$UV_PIN_VERSION-$_target"
UV_CMD="$_entry/uv"
if [ ! -x "$UV_CMD" ]; then
log "staging pinned uv $UV_PIN_VERSION ($_target) into the pm store"
local _tmp
_tmp="$(mktemp -d 2>/dev/null || echo "/tmp/hermes-uv-bootstrap.$$")"
mkdir -p "$_tmp"
if ! curl -LsSf "$UV_PIN_URL" -o "$_tmp/uv.tar.gz"; then
rm -rf "$_tmp"
fail "failed to download pinned uv from $UV_PIN_URL"
fi
local _digest
if command -v sha256sum >/dev/null 2>&1; then
_digest="$(sha256sum "$_tmp/uv.tar.gz" | cut -d' ' -f1)"
else
_digest="$(shasum -a 256 "$_tmp/uv.tar.gz" | cut -d' ' -f1)"
fi
if [ "$_digest" != "$UV_PIN_SHA256" ]; then
rm -rf "$_tmp"
fail "uv download digest mismatch (expected $UV_PIN_SHA256, got $_digest)"
fi
if ! tar -xzf "$_tmp/uv.tar.gz" -C "$_tmp"; then
rm -rf "$_tmp"
fail "failed to extract pinned uv archive"
fi
local _unpacked
_unpacked="$(find "$_tmp" -mindepth 1 -maxdepth 2 -name uv -type f | head -n1)"
if [ -z "$_unpacked" ]; then
rm -rf "$_tmp"
fail "uv binary not found in the downloaded archive"
fi
mkdir -p "$_entry"
mv "$_unpacked" "$UV_CMD"
[ -f "$(dirname "$_unpacked")/uvx" ] && mv "$(dirname "$_unpacked")/uvx" "$_entry/uvx"
chmod +x "$UV_CMD"
chmod +x "$_entry/uvx" 2>/dev/null || true
rm -rf "$_tmp"
fi
# Make the staged (or found) uv available to bare `uv` invocations.
export PATH="$(dirname "$UV_CMD"):$PATH"
if ! "$UV_CMD" --version >/dev/null 2>&1; then
fail "pinned uv staged but does not run on this host"
fi
log "uv ready ($("$UV_CMD" --version 2>/dev/null))"
}
check_platform() {
case "$(uname -s 2>/dev/null)" in
Linux*) : ;;
Darwin*) : ;;
*) fail "unsupported platform: $(uname -s). On Windows use install.ps1." ;;
esac
}
json_frame() {
# $1 ok, $2 stage, $3 skipped, $4 reason
if [ -n "${4:-}" ]; then
printf '{"ok":%s,"stage":"%s","skipped":%s,"reason":"%s"}\n' "$1" "$2" "$3" "$4"
else
printf '{"ok":%s,"stage":"%s","skipped":%s}\n' "$1" "$2" "$3"
fi
}
# The single authoritative stage list: emit_manifest prints it AND the
# no-flag ladder runs it, so --include-desktop affects the real run
# exactly as the manifest advertises.
stage_names() {
printf '%s\n' prerequisites repository venv python-deps node-deps path config setup gateway
[ "$INCLUDE_DESKTOP" = true ] && printf '%s\n' desktop
printf '%s\n' complete
}
# "$1" stage name -> its manifest record fields (title|category|needs_user_input).
stage_record() {
case "$1" in
prerequisites) echo "System prerequisites|runtime|false" ;;
repository) echo "Download Hermes Agent|runtime|false" ;;
venv) echo "Create Python environment|runtime|false" ;;
python-deps) echo "Install Python dependencies|runtime|false" ;;
node-deps) echo "Install tool dependencies|runtime|false" ;;
path) echo "Install hermes command|runtime|false" ;;
config) echo "Prepare config and skills|configuration|false" ;;
setup) echo "Configure API keys and settings|configuration|true" ;;
gateway) echo "Configure gateway service|configuration|true" ;;
desktop) echo "Build desktop app|runtime|false" ;;
complete) echo "Finish install|runtime|false" ;;
esac
}
emit_manifest() {
printf '%s' '{"protocol_version":1,"stages":['
_sep=""
for _s in $(stage_names); do
IFS='|' read -r _title _category _needs <<< "$(stage_record "$_s")"
printf '%s{"name":"%s","title":"%s","category":"%s","needs_user_input":%s}' \
"$_sep" "$_s" "$_title" "$_category" "$_needs"
_sep=","
done
printf '%s\n' ']}'
}
stage_prerequisites() {
command -v git >/dev/null 2>&1 || fail "git is required. Install it with your system package manager."
command -v curl >/dev/null 2>&1 || fail "curl is required. Install it with your system package manager."
log "prerequisites ok (git, curl)"
}
stage_repository() {
if [ -d "$INSTALL_DIR/.git" ]; then
log "updating $INSTALL_DIR"
git -C "$INSTALL_DIR" fetch origin "$BRANCH" || fail "git fetch failed"
git -C "$INSTALL_DIR" checkout "$BRANCH" || fail "git checkout failed"
git -C "$INSTALL_DIR" pull --ff-only origin "$BRANCH" || log "not fast-forwardable; keeping local state"
else
log "cloning $REPO_URL ($BRANCH) into $INSTALL_DIR"
mkdir -p "$(dirname "$INSTALL_DIR")"
local staged attempt cloned=false
staged="$(mktemp -d "$(dirname "$INSTALL_DIR")/.hermes-clone-XXXXXX")" || fail "cannot stage clone"
for attempt in 1 2 3; do
if git clone --branch "$BRANCH" "$REPO_URL" "$staged/tree"; then
cloned=true
break
fi
rm -rf "$staged/tree"
[ "$attempt" = 3 ] || sleep "$((attempt * 5))"
done
if [ "$cloned" = false ]; then
log "direct clone failed; trying deferred blob download"
if git clone --depth 1 --single-branch --filter=blob:none --no-checkout \
--branch "$BRANCH" "$REPO_URL" "$staged/tree"; then
for attempt in 1 2; do
if git -C "$staged/tree" reset --hard HEAD; then
cloned=true
break
fi
[ "$attempt" = 2 ] || sleep 5
done
fi
fi
if [ "$cloned" = false ]; then
rm -rf "$staged"
fail "git clone failed; no checkout published"
fi
if ! mv "$staged/tree" "$INSTALL_DIR"; then
rm -rf "$staged"
fail "cannot publish cloned checkout"
fi
rmdir "$staged"
fi
if [ -n "$INSTALL_COMMIT" ]; then
git -C "$INSTALL_DIR" checkout "$INSTALL_COMMIT" || fail "could not pin commit $INSTALL_COMMIT"
fi
}
stage_venv() {
ensure_uv
log "creating venv"
(cd "$INSTALL_DIR" && "$UV_CMD" venv --allow-existing venv) || fail "uv venv failed"
}
# Delegate the whole python+venv+tools install to pm: stage the pinned uv,
# let uv run pm.cli, and pm provisions the interpreter, the venv (default
# extras = [all], so it matches what `hermes update` force-syncs), and the
# tool store — all hash-verified against pm/lock.json + uv.lock. install.sh
# no longer runs `uv sync` directly; pm is the single install authority
# (the run_locked_uv_sync contract moved into pm/packages.py::uv_env).
bootstrap_pm() {
ensure_uv
local _py
_py="$(awk '/^ "python": \{/ { in_py = 1 }
in_py && /^ "version":/ { gsub(/.*: "|"$|",$/, ""); print; exit }' \
"$INSTALL_DIR/pm/lock.json" | cut -d+ -f1 | cut -d. -f1,2)"
[ -n "$_py" ] || _py="3.11"
log "delegating python + venv + tools to pm (hash-verified via uv.lock)"
(cd "$INSTALL_DIR" && "$UV_CMD" run --no-project --python "$_py" python -m pm.cli install) \
|| fail "pm install failed"
}
stage_python_deps() {
bootstrap_pm
}
stage_node_deps() {
# Tool binaries, node, browsers: pm packages, installed on demand or
# via `hermes pm install`. Nothing to do at bootstrap time.
log "tool dependencies are managed by pm (hermes pm install)"
}
stage_path() {
local link_dir="$HOME/.local/bin"
mkdir -p "$link_dir"
rm -f "$link_dir/hermes"
# Boot wrapper: exec the pm STORE python with PYTHONPATH=repo:venv-
# site-packages (repo first). Never boots through venv/bin/python —
# the venv is only a uv sync target and pyvenv.cfg is inert dead
# config (pm work item 3). Store root + python entry mirror
# pm/paths.py / pm facts.json; resolved at BOOT so the wrapper picks
# up `hermes pm install` whenever it materializes the store.
cat > "$link_dir/hermes" <<WRAPPER
#!/usr/bin/env bash
# Hermes boot wrapper: store python + PYTHONPATH (never the venv python).
set -u
repo="\${HERMES_INSTALL_DIR:-$INSTALL_DIR}"
unset PYTHONHOME
runtime="\${HERMES_RUNTIME_DIR:-}"
if [ -z "\$runtime" ] && [ -f "\$repo/install-stamp.json" ]; then
runtime="\$(sed -n 's/.*"runtimeDir"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "\$repo/install-stamp.json" | head -n 1)"
fi
[ -n "\$runtime" ] || runtime="\$HOME/.hermes/tools"
store_py=""
if [ -f "\$runtime/facts.json" ]; then
entry="\$(sed -n 's/.*"entry"[[:space:]]*:[[:space:]]*"\(python-[^"]*\)".*/\1/p' "\$runtime/facts.json" | head -n 1)"
if [ -n "\$entry" ] && [ -x "\$runtime/\$entry/bin/python3" ]; then
store_py="\$runtime/\$entry/bin/python3"
fi
fi
if [ -z "\$store_py" ]; then
for d in "\$runtime"/python-*/bin/python3; do
[ -x "\$d" ] && store_py="\$d"
done
fi
if [ -z "\$store_py" ]; then
echo "hermes: no pm store interpreter under \$runtime - run 'hermes pm install' first" >&2
exit 1
fi
site=""
for d in "\$repo"/venv/lib/python3.*/site-packages "\$repo"/.venv/lib/python3.*/site-packages; do
[ -d "\$d" ] && site="\$d"
done
export PYTHONPATH="\$repo\${site:+:\$site}"
exec "\$store_py" "\$repo/hermes" "\$@"
WRAPPER
chmod +x "$link_dir/hermes"
case ":$PATH:" in
*":$link_dir:"*) : ;;
*) log "add $link_dir to your PATH to use the hermes command" ;;
esac
log "hermes command installed at $link_dir/hermes"
}
stage_config() {
mkdir -p "$HERMES_HOME"/cron "$HERMES_HOME"/sessions "$HERMES_HOME"/logs \
"$HERMES_HOME"/pairing "$HERMES_HOME"/hooks "$HERMES_HOME"/image_cache \
"$HERMES_HOME"/audio_cache "$HERMES_HOME"/memories "$HERMES_HOME"/skills
if [ ! -f "$HERMES_HOME/.env" ]; then
cp "$INSTALL_DIR/.env.example" "$HERMES_HOME/.env" 2>/dev/null || touch "$HERMES_HOME/.env"
fi
chmod 600 "$HERMES_HOME/.env"
if [ ! -f "$HERMES_HOME/config.yaml" ] && [ -f "$INSTALL_DIR/cli-config.yaml.example" ]; then
cp "$INSTALL_DIR/cli-config.yaml.example" "$HERMES_HOME/config.yaml"
fi
log "config prepared in $HERMES_HOME"
}
stage_setup() {
if [ "$NON_INTERACTIVE" = true ]; then return 0; fi
"$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/hermes" setup || true
}
stage_gateway() {
if [ "$NON_INTERACTIVE" = true ]; then return 0; fi
"$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/hermes" gateway install || true
}
stage_desktop() {
# `hermes desktop --build-only` is the current authority (same path as
# `hermes gui` / the update flow); no installer-local node/electron code.
"$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/hermes" desktop --build-only || fail "desktop build failed"
}
stage_complete() {
local commit
commit="$INSTALL_COMMIT"
[ -n "$commit" ] || commit=$(git -C "$INSTALL_DIR" rev-parse HEAD 2>/dev/null) || commit=""
if [ -n "$commit" ]; then
printf '{\n "schemaVersion": 1,\n "pinnedCommit": "%s",\n "pinnedBranch": "%s",\n "completedAt": "%s"\n}\n' \
"$commit" "$BRANCH" "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" > "$INSTALL_DIR/.hermes-bootstrap-complete.tmp"
mv -f "$INSTALL_DIR/.hermes-bootstrap-complete.tmp" "$INSTALL_DIR/.hermes-bootstrap-complete"
fi
log "install complete. Run: hermes"
}
run_stage() {
case "$1" in
prerequisites) stage_prerequisites ;;
repository) stage_repository ;;
venv) stage_venv ;;
python-deps) stage_python_deps ;;
node-deps) stage_node_deps ;;
path) stage_path ;;
config) stage_config ;;
setup) stage_setup ;;
gateway) stage_gateway ;;
desktop) stage_desktop ;;
complete) stage_complete ;;
*) echo "unknown stage: $1" >&2; exit 2 ;;
esac
}
# Main. Guarded so the script can be SOURCED for its functions (the
# installer-test harness sources it with --manifest, which must define
# the functions and stop before main).
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
if [ "$WANT_MANIFEST" = true ]; then
emit_manifest
exit 0
fi
check_platform
if [ -n "$STAGE" ]; then
if [ "$NON_INTERACTIVE" = true ] && { [ "$STAGE" = setup ] || [ "$STAGE" = gateway ]; }; then
[ "$JSON" = true ] && json_frame true "$STAGE" true "needs user input"
exit 0
fi
if run_stage "$STAGE"; then
[ "$JSON" = true ] && json_frame true "$STAGE" false
exit 0
else
rc=$?
[ "$JSON" = true ] && json_frame false "$STAGE" false "stage failed"
exit "$rc"
fi
fi
# No --stage: run the whole ladder — the same authoritative list the
# manifest prints, so --include-desktop inserts desktop here too.
for s in $(stage_names); do
run_stage "$s"
done
fi