Merge pull request #335 from spencerbull/restore-hermes-desktop-package
Restore prebuilt Hermes with native in-app updates
This commit is contained in:
7 files changed
+216
-635
No files matched your search
@@ -16,9 +16,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Test Hermes installation and launch
|
||||
run: python3 test/hermes-desktop-test.py
|
||||
|
||||
# An Arch container for vercmp: version ordering has to be decided by
|
||||
# the same comparator pacman uses on users' machines.
|
||||
- name: Run self-tests
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# Maintainer: David Heinemeier Hansson <david@hey.com>
|
||||
|
||||
# Build a prebuilt release; Omarchy seeds it into the user's Hermes checkout
|
||||
# so the upstream updater can rebuild and relaunch it in place.
|
||||
|
||||
pkgname=hermes-desktop
|
||||
pkgver=2026.8.31
|
||||
pkgrel=1
|
||||
pkgdesc='Installer and launcher for the self-updating Hermes desktop'
|
||||
pkgrel=2
|
||||
pkgdesc='Native desktop shell for Hermes Agent'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/NousResearch/hermes-agent'
|
||||
license=('MIT')
|
||||
|
||||
# Hermes builds its desktop under the user's runtime so both the app and CLI
|
||||
# can update it. The compiler and Electron libraries must survive installation.
|
||||
depends=(
|
||||
'alsa-lib'
|
||||
'at-spi2-core'
|
||||
'bash'
|
||||
'cairo'
|
||||
'curl'
|
||||
'dbus'
|
||||
'expat'
|
||||
'curl'
|
||||
'gcc'
|
||||
'gdk-pixbuf2'
|
||||
'git'
|
||||
@@ -48,39 +48,74 @@ depends=(
|
||||
'python'
|
||||
'systemd-libs'
|
||||
'util-linux'
|
||||
'xdg-terminal-exec'
|
||||
'xdg-utils'
|
||||
)
|
||||
|
||||
optdepends=('omarchy: sets up the user installation for in-app updates')
|
||||
|
||||
# The build runs the repo's own npm workspace install, which fetches Electron
|
||||
# and rebuilds node-pty against it.
|
||||
makedepends=('imagemagick')
|
||||
|
||||
# Snapshot the bootstrap installer, including upstream's Linux sandbox fixes.
|
||||
# Only installation uses it; updates belong to the native runtime on main.
|
||||
_commit=245e48008fa814b3251f50755eb656bd9fb86cb1
|
||||
source=("install-${_commit}.sh::https://raw.githubusercontent.com/NousResearch/hermes-agent/${_commit}/scripts/install.sh"
|
||||
"LICENSE-${_commit}::https://raw.githubusercontent.com/NousResearch/hermes-agent/${_commit}/LICENSE"
|
||||
# Electron bundles prebuilt binaries that stripping corrupts.
|
||||
options=('!strip' '!debug')
|
||||
|
||||
# The tag's commit. apps/desktop/scripts/write-build-stamp.mjs pins the app's
|
||||
# first-launch bootstrap to a Hermes commit, and resolves it from $GITHUB_SHA
|
||||
# before falling back to `git rev-parse`. That fallback is wrong here: makepkg
|
||||
# builds inside this repository, so git ascends out of srcdir and stamps the
|
||||
# app with an omarchy-pkgs commit that means nothing upstream.
|
||||
_commit=29112bef099274229cadff79cdff7bf7b99c4b77
|
||||
|
||||
_srcdir="hermes-agent-${pkgver}"
|
||||
source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz"
|
||||
'hermes-desktop.sh'
|
||||
'hermes-desktop.desktop'
|
||||
'hermes-desktop.png')
|
||||
sha256sums=('5854b15670b51a8daae8f59ddfa917062de9f74be261eb73b4b8d719710f8968'
|
||||
'821556e6336796450ab852d375117b48a4887e71d255794fd6318d99982a5ab6'
|
||||
'27040084e691421dc1748e7646ae7ee79d5777e8ee1e1ec7581a68432d4a29c4'
|
||||
'c37d4cfa4801eccbd769fddaebb2115d54faa49a5b7bc0f305f563e3cefd9bd8'
|
||||
'd60d164e24fdcf6532133b8ea43c77a201e4b9e9dbc396187b58d51d8590ef52')
|
||||
'hermes-desktop.png'
|
||||
'runtime.patch'
|
||||
'runtime-test.py')
|
||||
sha256sums=('78fb3ff707ec1d17044b875ecac8bef28aa39d44242824f6871ca40afe7bf217'
|
||||
'7324b1d5e7db16c6169dd9e685f4093925c4a8c0568cb7ce416ac734f3336606'
|
||||
'3ef685bfcf366776b025d26c37d32854d8d4aa2023b2bd07c8e08b001ef1e8c4'
|
||||
'd60d164e24fdcf6532133b8ea43c77a201e4b9e9dbc396187b58d51d8590ef52'
|
||||
'03b67e26c234c797a6b1d4c9f34a57502dba37f462540e47ce6c88f6ea79302a'
|
||||
'69bc57d04610567eca35679328668619fcd3f129ed099bf5e2a1f24d1f197de9')
|
||||
|
||||
build() {
|
||||
cd "${srcdir}/${_srcdir}"
|
||||
|
||||
export GITHUB_SHA="${_commit}"
|
||||
export GITHUB_REF_NAME="main"
|
||||
|
||||
# The desktop workspace resolves against the repo root, so the install has to
|
||||
# happen there rather than in apps/desktop.
|
||||
npm ci
|
||||
|
||||
cd apps/desktop
|
||||
npm run pack
|
||||
}
|
||||
|
||||
check() {
|
||||
python "${srcdir}/runtime-test.py" "${srcdir}/${_srcdir}" "${srcdir}/runtime.patch" "${srcdir}/hermes-desktop.sh"
|
||||
}
|
||||
|
||||
package() {
|
||||
install -Dm644 "${srcdir}/install-${_commit}.sh" \
|
||||
"${pkgdir}/usr/share/${pkgname}/install.sh"
|
||||
install -Dm644 "${srcdir}/LICENSE-${_commit}" \
|
||||
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
cd "${srcdir}/${_srcdir}/apps/desktop/release/linux-unpacked"
|
||||
|
||||
install -dm755 "${pkgdir}/opt/${pkgname}"
|
||||
cp -a . "${pkgdir}/opt/${pkgname}/"
|
||||
|
||||
install -Dm755 "${srcdir}/hermes-desktop.sh" "${pkgdir}/usr/bin/${pkgname}"
|
||||
|
||||
# Native registration uses hermes.desktop but omits URI handling. Keep the
|
||||
# original handler ID hidden, and let the native entry replace the visible one.
|
||||
install -Dm644 "${srcdir}/${_srcdir}/scripts/install.sh" \
|
||||
"${pkgdir}/usr/share/${pkgname}/install.sh"
|
||||
# Let the release's first updater relaunch with the user-namespace sandbox.
|
||||
install -Dm644 "${srcdir}/runtime.patch" "${pkgdir}/usr/share/${pkgname}/runtime.patch"
|
||||
|
||||
|
||||
install -Dm644 "${srcdir}/hermes-desktop.desktop" \
|
||||
"${pkgdir}/usr/share/applications/hermes-desktop.desktop"
|
||||
printf 'NoDisplay=true\n' >>"${pkgdir}/usr/share/applications/hermes-desktop.desktop"
|
||||
sed '/^MimeType=/d' "${srcdir}/hermes-desktop.desktop" >"${pkgdir}/usr/share/applications/hermes.desktop"
|
||||
"${pkgdir}/usr/share/applications/${pkgname}.desktop"
|
||||
|
||||
install -Dm644 "${srcdir}/hermes-desktop.png" \
|
||||
"${pkgdir}/usr/share/icons/hicolor/1024x1024/apps/${pkgname}.png"
|
||||
local size
|
||||
@@ -89,4 +124,17 @@ package() {
|
||||
install -Dm644 "${srcdir}/icon-${size}.png" \
|
||||
"${pkgdir}/usr/share/icons/hicolor/${size}x${size}/apps/${pkgname}.png"
|
||||
done
|
||||
|
||||
install -Dm644 "${srcdir}/${_srcdir}/LICENSE" \
|
||||
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
install -Dm644 "${pkgdir}/opt/${pkgname}/LICENSE.electron.txt" \
|
||||
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE.electron.txt"
|
||||
|
||||
# Chromium's setuid sandbox is only needed where unprivileged user namespaces
|
||||
# are unavailable; where they work, setuid root is the worse of the two.
|
||||
if ! { [[ -L /proc/self/ns/user ]] && unshare --user true; }; then
|
||||
chmod 4755 "${pkgdir}/opt/${pkgname}/chrome-sandbox"
|
||||
else
|
||||
chmod 0755 "${pkgdir}/opt/${pkgname}/chrome-sandbox"
|
||||
fi
|
||||
}
|
||||
@@ -4,7 +4,7 @@ Type=Application
|
||||
Name=Hermes
|
||||
GenericName=AI Agent
|
||||
Comment=The self-improving AI agent that grows with you
|
||||
Exec=/usr/bin/hermes-desktop %U
|
||||
Exec=hermes-desktop %U
|
||||
Icon=hermes-desktop
|
||||
Terminal=false
|
||||
Categories=Development;
|
||||
|
||||
@@ -1,266 +1,55 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
die() {
|
||||
echo "Hermes: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
# Use the runtime prepared by Omarchy rather than a separate CLI on PATH.
|
||||
export HERMES_DESKTOP_IGNORE_EXISTING=1
|
||||
|
||||
HERMES_HOME=$(realpath -ms -- "${HERMES_HOME:-$HOME/.hermes}")
|
||||
# A profile session shares its parent home's installation and launchers.
|
||||
home_parent=${HERMES_HOME%/*}
|
||||
if [[ ${home_parent##*/} == "profiles" ]]; then
|
||||
HERMES_HOME=${home_parent%/*}
|
||||
fi
|
||||
[[ $HERMES_HOME == /* && $HERMES_HOME != "/" ]] || die "Use a Hermes data directory other than /."
|
||||
export HERMES_HOME
|
||||
root="$HERMES_HOME/hermes-agent"
|
||||
marker="$root/.omarchy-hermes-desktop"
|
||||
installer=/usr/share/hermes-desktop/install.sh
|
||||
cli=("$root/venv/bin/python" "$root/hermes")
|
||||
|
||||
desktop_executable() {
|
||||
local executable
|
||||
for executable in "$root/apps/desktop/release/linux-unpacked/"{Hermes,hermes}; do
|
||||
if [[ -f $executable && -x $executable ]]; then
|
||||
printf '%s\n' "$executable"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
native_wrapper() {
|
||||
local wrapper=$1 entry=$2 suffix=${3:-}
|
||||
[[ -f $wrapper && ! -L $wrapper ]] && cmp -s "$wrapper" <(
|
||||
printf '#!/usr/bin/env bash\nunset PYTHONPATH\nunset PYTHONHOME\nexec "%s/venv/bin/python" "%s/%s"%s "$@"\n' "$root" "$root" "$entry" "$suffix"
|
||||
)
|
||||
}
|
||||
|
||||
runtime_ready() {
|
||||
[[ -f $root/.hermes-bootstrap-complete && -x ${cli[0]} ]] &&
|
||||
[[ -x $HOME/.local/bin/hermes ]] &&
|
||||
native_wrapper "$HOME/.local/bin/hermes" hermes &&
|
||||
desktop_executable >/dev/null &&
|
||||
timeout 15 env -u PYTHONPATH -u PYTHONHOME "${cli[@]}" --version >/dev/null 2>&1
|
||||
}
|
||||
|
||||
ready() {
|
||||
if [[ -f $marker ]]; then
|
||||
grep -qxF ready "$marker" || return 1
|
||||
fi
|
||||
runtime_ready
|
||||
}
|
||||
|
||||
restore_launchers() {
|
||||
local command wrapper entry
|
||||
for command in hermes hermes-agent hermes-acp; do
|
||||
[[ -f $install_backup/$command ]] || continue
|
||||
wrapper="$HOME/.local/bin/$command"
|
||||
entry=hermes
|
||||
[[ $command == "hermes-agent" ]] && entry=run_agent.py
|
||||
if [[ ! -e $wrapper && ! -L $wrapper ]] || native_wrapper "$wrapper" "$entry" ||
|
||||
{ [[ -f $wrapper && ! -L $wrapper ]] && grep -qxF '# Written by omarchy-install-hermes-cli.' "$wrapper"; } ||
|
||||
{ [[ $command == "hermes-acp" ]] && native_wrapper "$wrapper" hermes ' acp'; }; then
|
||||
cp -p "$install_backup/$command" "$wrapper"
|
||||
else
|
||||
echo "Keeping changed $wrapper; its original is in $install_backup." >&2
|
||||
return
|
||||
fi
|
||||
done
|
||||
rm -rf "$install_backup"
|
||||
}
|
||||
|
||||
stage() {
|
||||
bash "$installer" --dir "${2:-$root}" --branch main --non-interactive --stage "$1"
|
||||
}
|
||||
|
||||
install_desktop() {
|
||||
(( EUID != 0 )) || die "Run this installer as your desktop user, without sudo."
|
||||
mkdir -p "$HERMES_HOME"
|
||||
exec 9>"$HERMES_HOME/.omarchy-hermes-desktop.lock"
|
||||
flock -n 9 || die "Another Hermes installation is already running."
|
||||
if ready; then
|
||||
exec 9>&-
|
||||
return
|
||||
fi
|
||||
|
||||
# Upstream replaces these launchers. Admit its exact shims or Omarchy's
|
||||
# marked predecessor before starting work, never a user's custom command.
|
||||
local command wrapper entry
|
||||
for command in hermes hermes-agent hermes-acp; do
|
||||
wrapper="$HOME/.local/bin/$command"
|
||||
[[ -e $wrapper || -L $wrapper ]] || continue
|
||||
entry=hermes
|
||||
[[ $command == "hermes-agent" ]] && entry=run_agent.py
|
||||
if native_wrapper "$wrapper" "$entry"; then continue; fi
|
||||
if [[ $command == "hermes-acp" ]] && native_wrapper "$wrapper" hermes ' acp'; then continue; fi
|
||||
if [[ $command == "hermes" && -f $wrapper && ! -L $wrapper ]] &&
|
||||
grep -qxF '# Written by omarchy-install-hermes-cli.' "$wrapper"; then continue; fi
|
||||
die "Keeping the existing $wrapper. Move or update that installation before installing Hermes Desktop."
|
||||
done
|
||||
|
||||
# The native prerequisite stage may install Node's three user symlinks.
|
||||
for command in node npm npx; do
|
||||
wrapper="$HOME/.local/bin/$command"
|
||||
if [[ -e $wrapper || -L $wrapper ]] &&
|
||||
[[ ! -L $wrapper || $(readlink "$wrapper") != "$HERMES_HOME/node/bin/$command" ]]; then
|
||||
die "Keeping the existing $wrapper. Move it before installing Hermes Desktop."
|
||||
fi
|
||||
done
|
||||
|
||||
local legacy=false
|
||||
if [[ -e $root || -L $root ]]; then
|
||||
[[ -d $root/.git && ! -L $root && ! -L $root/.git ]] || die "Keeping $root; it is not a managed checkout."
|
||||
if [[ -f $marker ]] && grep -qxF updating "$marker"; then
|
||||
legacy=true
|
||||
elif [[ ! -f $marker ]]; then
|
||||
python -I - "$root/.hermes-bootstrap-complete" <<'PY' || die "Keeping the existing checkout; install its desktop with 'hermes desktop'."
|
||||
import json, sys
|
||||
try:
|
||||
stamp = json.load(open(sys.argv[1]))
|
||||
assert stamp.get('desktopVersion') and stamp.get('pinnedCommit') in {
|
||||
'e624e9fde561e1add9388384012b295fde669ade',
|
||||
'29112bef099274229cadff79cdff7bf7b99c4b77',
|
||||
}
|
||||
except (OSError, ValueError, AssertionError, AttributeError):
|
||||
sys.exit(1)
|
||||
PY
|
||||
legacy=true
|
||||
fi
|
||||
[[ -z $(git -C "$root" status --porcelain) ]] || die "Keeping local changes in $root. Save them before installing the desktop."
|
||||
case "$(git -C "$root" remote get-url origin)" in
|
||||
https://github.com/NousResearch/hermes-agent.git | git@github.com:NousResearch/hermes-agent.git) ;;
|
||||
*) die "Keeping the checkout's custom origin. Install its desktop with 'hermes desktop'." ;;
|
||||
esac
|
||||
if [[ $legacy == true ]]; then
|
||||
case "$(git -C "$root" rev-parse HEAD)" in
|
||||
e624e9fde561e1add9388384012b295fde669ade | 29112bef099274229cadff79cdff7bf7b99c4b77) ;;
|
||||
*)
|
||||
if [[ $(git -C "$root" symbolic-ref --short -q HEAD) != "main" ]] ||
|
||||
! git -C "$root" merge-base --is-ancestor HEAD refs/remotes/origin/main; then
|
||||
die "The legacy checkout has moved. Run 'hermes update', then 'hermes desktop'."
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
install_backup=$(mktemp -d "$HERMES_HOME/.omarchy-hermes-launchers.XXXXXX")
|
||||
for command in hermes hermes-agent hermes-acp; do
|
||||
wrapper="$HOME/.local/bin/$command"
|
||||
if [[ -f $wrapper ]]; then cp -p "$wrapper" "$install_backup/$command"; fi
|
||||
done
|
||||
trap restore_launchers EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
local mise_predecessor=false
|
||||
if [[ -f $install_backup/hermes ]] && grep -qxF '# Written by omarchy-install-hermes-cli.' "$install_backup/hermes"; then
|
||||
mise_predecessor=true
|
||||
fi
|
||||
|
||||
if [[ $legacy == true ]]; then
|
||||
if [[ $mise_predecessor == true ]]; then
|
||||
printf '%s\n' 'pipx:hermes-agent[extras=all]' >"$root/.git/omarchy-mise-predecessor"
|
||||
fi
|
||||
if ! grep -qxF '/.omarchy-hermes-desktop' "$root/.git/info/exclude"; then
|
||||
printf '/.omarchy-hermes-desktop\n' >>"$root/.git/info/exclude"
|
||||
fi
|
||||
printf 'updating\n' >"$marker"
|
||||
local refspec='+refs/heads/main:refs/remotes/origin/main'
|
||||
if ! git -C "$root" config --get-all remote.origin.fetch | grep -qxF "$refspec"; then
|
||||
git -C "$root" config --add remote.origin.fetch "$refspec"
|
||||
fi
|
||||
git -C "$root" fetch origin "$refspec"
|
||||
env -u PYTHONPATH -u PYTHONHOME "${cli[@]}" update --yes --branch main
|
||||
elif [[ ! -e $root ]]; then
|
||||
stage prerequisites
|
||||
# Publish only a complete clone with its ownership marker. An interrupted
|
||||
# clone stays aside for inspection and cannot block the next installation.
|
||||
local repository_dir
|
||||
repository_dir=$(mktemp -d "$HERMES_HOME/.omarchy-hermes-repository.XXXXXX")
|
||||
echo "Preparing the Hermes checkout in $repository_dir"
|
||||
stage repository "$repository_dir/hermes-agent"
|
||||
printf '/.omarchy-hermes-desktop\n' >>"$repository_dir/hermes-agent/.git/info/exclude"
|
||||
printf 'pending\n' >"$repository_dir/hermes-agent/.omarchy-hermes-desktop"
|
||||
mv -T --no-clobber "$repository_dir/hermes-agent" "$root"
|
||||
[[ ! -e $repository_dir/hermes-agent ]] || die "Keeping the checkout that appeared at $root during installation."
|
||||
rmdir "$repository_dir"
|
||||
fi
|
||||
|
||||
if [[ $mise_predecessor == true ]]; then
|
||||
printf '%s\n' 'pipx:hermes-agent[extras=all]' >"$root/.git/omarchy-mise-predecessor"
|
||||
fi
|
||||
|
||||
if [[ $legacy == false ]]; then
|
||||
for command in venv python-deps node-deps config; do stage "$command"; done
|
||||
fi
|
||||
# The native builder also handles updates and supports the namespace
|
||||
# sandbox. The shell installer's desktop stage still requires a sudo chown.
|
||||
# The build registers a desktop entry, too. Keep it private until the new
|
||||
# CLI is published so a different hermes on PATH cannot become its target.
|
||||
env -u PYTHONPATH -u PYTHONHOME XDG_DATA_HOME="$install_backup/desktop" "${cli[@]}" desktop --build-only
|
||||
|
||||
# Build first: a failed download leaves the old terminal CLI usable.
|
||||
stage complete
|
||||
stage path
|
||||
runtime_ready || die "The CLI or desktop is not ready. See the installer output above."
|
||||
env -u PYTHONPATH -u PYTHONHOME PATH="$HOME/.local/bin:$PATH" "${cli[@]}" desktop --skip-build --build-only
|
||||
if ! grep -qxF '/.omarchy-hermes-desktop' "$root/.git/info/exclude"; then
|
||||
printf '/.omarchy-hermes-desktop\n' >>"$root/.git/info/exclude"
|
||||
fi
|
||||
printf 'ready\n' >"$marker"
|
||||
trap - EXIT INT TERM
|
||||
rm -rf "$install_backup"
|
||||
exec 9>&-
|
||||
if [[ -f $root/.git/omarchy-mise-predecessor ]] && command -v omarchy-install-hermes-cli >/dev/null 2>&1; then
|
||||
omarchy-install-hermes-cli || echo 'Run omarchy-install-hermes-cli again to finish the old CLI cleanup.' >&2
|
||||
fi
|
||||
echo "Hermes is ready. Update it in the app or with 'hermes update'."
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--check) ready; exit ;;
|
||||
--install) install_desktop; exit ;;
|
||||
--setup)
|
||||
shift
|
||||
if /usr/bin/hermes-desktop --install; then
|
||||
setsid --fork /usr/bin/hermes-desktop "$@" >/dev/null 2>&1
|
||||
exit
|
||||
else
|
||||
status=$?
|
||||
echo "Hermes installation failed. Retry with: hermes-desktop --install" >&2
|
||||
if [[ -t 0 ]]; then read -r -p 'Press Enter to close.'; fi
|
||||
exit "$status"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! ready; then
|
||||
if [[ -t 0 && -t 1 ]]; then
|
||||
install_desktop
|
||||
else
|
||||
exec xdg-terminal-exec /usr/bin/hermes-desktop --setup "$@"
|
||||
fi
|
||||
fi
|
||||
|
||||
export HERMES_DESKTOP_HERMES_ROOT="$root"
|
||||
# Chromium cannot reliably infer the Secret Service password-store backend
|
||||
# from a Hyprland session, even when GNOME Keyring is already providing it.
|
||||
# Keep an explicit user choice (such as KWallet), otherwise select the
|
||||
# libsecret backend that this package depends on.
|
||||
export HERMES_DESKTOP_PASSWORD_STORE="${HERMES_DESKTOP_PASSWORD_STORE:-gnome-libsecret}"
|
||||
# Upstream desktop registration resolves its launcher through PATH.
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
unset ELECTRON_RUN_AS_NODE PYTHONPATH PYTHONHOME
|
||||
|
||||
flags=()
|
||||
if [[ -n ${WAYLAND_DISPLAY:-} || ${XDG_SESSION_TYPE:-} == "wayland" ]]; then
|
||||
flags=(--ozone-platform=wayland)
|
||||
# Reconcile every launch rather than trusting whatever installed us. A plain
|
||||
# `pacman -S hermes-desktop`, or an install interrupted partway, leaves any
|
||||
# Hermes the terminal agent had built still sitting there, and by then the
|
||||
# menu entry that would have tidied it up is disabled because we are present.
|
||||
if command -v omarchy-install-hermes-cli >/dev/null 2>&1; then
|
||||
omarchy-install-hermes-cli >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Chromium's own Ozone detection falls back to XWayland often enough to matter,
|
||||
# and the result is a blurry window on every scaled display. Ask for Wayland
|
||||
# directly, unless the user has already picked a platform themselves.
|
||||
platform_flags=()
|
||||
if [[ -n "${WAYLAND_DISPLAY:-}" || ${XDG_SESSION_TYPE:-} == wayland ]]; then
|
||||
platform_flags=(--ozone-platform=wayland)
|
||||
|
||||
for flag in "$@"; do
|
||||
case "$flag" in --ozone-platform=* | --ozone-platform-hint=*) flags=() ;; esac
|
||||
case "$flag" in
|
||||
--ozone-platform=* | --ozone-platform-hint=*) platform_flags=() ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
# Disable only the unusable setuid helper; Chromium keeps its namespace sandbox.
|
||||
if unshare --user --map-root-user true 2>/dev/null; then
|
||||
flags+=(--disable-setuid-sandbox)
|
||||
hermes_home=$(realpath -ms -- "${HERMES_HOME:-$HOME/.hermes}")
|
||||
parent=${hermes_home%/*}
|
||||
if [[ ${parent##*/} == [Pp][Rr][Oo][Ff][Ii][Ll][Ee][Ss] ]]; then
|
||||
hermes_home=${parent%/*}
|
||||
hermes_home=${hermes_home:-/}
|
||||
fi
|
||||
export HERMES_HOME="$hermes_home"
|
||||
runtime="$hermes_home/hermes-agent"
|
||||
native="$runtime/apps/desktop/release/linux-unpacked/Hermes"
|
||||
|
||||
if [[ -x $native && -x $runtime/venv/bin/hermes ]]; then
|
||||
# Use the namespace sandbox without asking the CLI to make a user-writable
|
||||
# helper setuid-root. Keep the same launch path for menu entries and URLs.
|
||||
if ! timeout 5 unshare --user --map-root-user true 2>/dev/null; then
|
||||
echo "Hermes Desktop requires working unprivileged user namespaces for its sandbox." >&2
|
||||
exit 1
|
||||
fi
|
||||
exec "$native" --disable-setuid-sandbox "${platform_flags[@]}" "$@"
|
||||
else
|
||||
exec /opt/hermes-desktop/Hermes "${platform_flags[@]}" "$@"
|
||||
fi
|
||||
exec "$(desktop_executable)" "${flags[@]}" "$@"
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Check native launch and the release updater without running Hermes or sudo."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
source, patch_file, launcher = map(Path, sys.argv[1:])
|
||||
with tempfile.TemporaryDirectory(prefix="hermes-runtime-check-") as temporary:
|
||||
root = Path(temporary)
|
||||
destination = root / "scripts/desktop-update/posix.sh"
|
||||
destination.parent.mkdir(parents=True)
|
||||
shutil.copyfile(source / "scripts/desktop-update/posix.sh", destination)
|
||||
subprocess.run(["git", "apply", str(patch_file.resolve())], cwd=root, check=True)
|
||||
|
||||
home = root / "home with spaces"
|
||||
runtime = home / ".hermes/hermes-agent"
|
||||
native = runtime / "apps/desktop/release/linux-unpacked"
|
||||
native.mkdir(parents=True)
|
||||
executable = native / "Hermes"
|
||||
executable.write_text(f"#!{sys.executable}\n" + '''import json, os, sys
|
||||
from pathlib import Path
|
||||
Path(os.environ["TEST_OUTPUT"]).write_text(json.dumps({
|
||||
"args": sys.argv[1:], "home": os.environ["HERMES_HOME"],
|
||||
"store": os.environ["HERMES_DESKTOP_PASSWORD_STORE"],
|
||||
"gpu": os.environ.get("HERMES_DESKTOP_DISABLE_GPU"),
|
||||
}))
|
||||
''')
|
||||
executable.chmod(0o755)
|
||||
sandbox = native / "chrome-sandbox"
|
||||
sandbox.write_text("fixture")
|
||||
sandbox.chmod(0o755)
|
||||
|
||||
mock_bin = root / "bin"
|
||||
mock_bin.mkdir()
|
||||
unshare = mock_bin / "unshare"
|
||||
unshare.write_text('#!/bin/bash\nexit "${TEST_NAMESPACE_RESULT:-0}"\n')
|
||||
unshare.chmod(0o755)
|
||||
forbidden = '#!/bin/bash\ntouch "$TEST_FORBIDDEN"\nexit 99\n'
|
||||
cli = runtime / "venv/bin/hermes"
|
||||
cli.parent.mkdir(parents=True)
|
||||
cli.write_text(forbidden)
|
||||
cli.chmod(0o755)
|
||||
for command in ("sudo", "omarchy-install-hermes-cli"):
|
||||
target = mock_bin / command
|
||||
target.write_text(forbidden if command == "sudo" else '#!/bin/bash\nexit 0\n')
|
||||
target.chmod(0o755)
|
||||
|
||||
output = root / "launch.json"
|
||||
forbidden_output = root / "forbidden"
|
||||
env = {"HOME": str(home), "PATH": f"{mock_bin}:/usr/bin:/bin",
|
||||
"TEST_OUTPUT": str(output), "TEST_FORBIDDEN": str(forbidden_output)}
|
||||
launch = ["bash", str(launcher.resolve())]
|
||||
for args, overrides, expected_args, expected_store in (
|
||||
([], {"WAYLAND_DISPLAY": "wayland-1"}, ["--ozone-platform=wayland"], "gnome-libsecret"),
|
||||
(["--ozone-platform=x11", "hermes://open?text=a%20b"],
|
||||
{"WAYLAND_DISPLAY": "wayland-1", "HERMES_DESKTOP_PASSWORD_STORE": "kwallet6"},
|
||||
["--ozone-platform=x11", "hermes://open?text=a%20b"], "kwallet6"),
|
||||
([], {"HERMES_HOME": str(home / ".hermes/profiles/work"), "HERMES_DESKTOP_DISABLE_GPU": "1"},
|
||||
[], "gnome-libsecret"),
|
||||
):
|
||||
subprocess.run(launch + args, env={**env, **overrides}, check=True)
|
||||
result = json.loads(output.read_text())
|
||||
assert result["args"] == ["--disable-setuid-sandbox", *expected_args], result
|
||||
assert result["home"] == str(home / ".hermes"), result
|
||||
assert result["store"] == expected_store, result
|
||||
assert result["gpu"] == overrides.get("HERMES_DESKTOP_DISABLE_GPU"), result
|
||||
assert not forbidden_output.exists(), "launcher invoked CLI or sudo"
|
||||
output.unlink()
|
||||
for code in ("1", "127"):
|
||||
for args in ([], ["hermes://open"]):
|
||||
result = subprocess.run(launch + args, env={**env, "TEST_NAMESPACE_RESULT": code},
|
||||
capture_output=True, text=True)
|
||||
assert result.returncode != 0 and "user namespaces" in result.stderr, result
|
||||
assert not output.exists() and not forbidden_output.exists()
|
||||
|
||||
gate = ["bash", str(destination), "--self-test-gate", "--install-root", str(runtime),
|
||||
"--relaunch-target", str(executable)]
|
||||
assert subprocess.check_output(gate, env=env, text=True).strip() == "relaunch"
|
||||
assert subprocess.check_output(gate, env={**env, "TEST_NAMESPACE_RESULT": "1"}, text=True).startswith("manual:")
|
||||
gate[-1] = "/opt/hermes-desktop/Hermes"
|
||||
assert subprocess.check_output(gate, env=env, text=True).startswith("skew:")
|
||||
print("PASS: direct native launches fail closed; release updater accepts the namespace sandbox")
|
||||
@@ -0,0 +1,11 @@
|
||||
--- a/scripts/desktop-update/posix.sh
|
||||
+++ b/scripts/desktop-update/posix.sh
|
||||
@@ -317,6 +317,8 @@
|
||||
if [ ! -e "$sb" ]; then GATE=relaunch; return; fi
|
||||
if [ -u "$sb" ] && [ "$(stat -c %u "$sb" 2>/dev/null)" = "0" ]; then GATE=relaunch; return; fi
|
||||
|
||||
+ if unshare --user --map-root-user true 2>/dev/null; then GATE=relaunch; return; fi
|
||||
+
|
||||
case "${ELECTRON_DISABLE_SANDBOX:-}" in 1|true|TRUE|True) GATE=relaunch; return ;; esac
|
||||
[ "$SANDBOX_FALLBACK" -eq 1 ] && { GATE=relaunch; return; }
|
||||
for arg in ${RELAUNCH_ARGS[@]+"${RELAUNCH_ARGS[@]}"}; do
|
||||
@@ -1,349 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the packaged entry point with a disposable HOME and installer."""
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import signal
|
||||
import time
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1] / 'pkgbuilds/hermes-desktop/hermes-desktop.sh'
|
||||
INSTALLER = r'''#!/bin/bash
|
||||
set -eu
|
||||
while (( $# )); do
|
||||
case $1 in --dir) root=$2; shift;; --stage) stage=$2; shift;; esac
|
||||
shift
|
||||
done
|
||||
printf '%s\n' "$stage" >> "$TEST_LOG"
|
||||
[[ ${FAIL_STAGE:-} != "$stage" ]] || exit 42
|
||||
case $stage in
|
||||
repository)
|
||||
mkdir -p "$root/venv/bin"
|
||||
git init -q "$root"
|
||||
[[ ${FAIL_PARTIAL_CLONE:-} != 1 ]] || exit 44
|
||||
git -C "$root" remote add origin https://github.com/NousResearch/hermes-agent.git
|
||||
touch "$root/hermes"
|
||||
cp "$TEST_PYTHON" "$root/venv/bin/python"
|
||||
git -C "$root" add hermes venv
|
||||
git -C "$root" -c user.email=test@example.com -c user.name=Test commit -qm initial
|
||||
if [[ ${RACE_CLONE:-} == 1 ]]; then
|
||||
mkdir -p "$HOME/.hermes/hermes-agent"
|
||||
printf 'foreign checkout\n' > "$HOME/.hermes/hermes-agent/keep"
|
||||
fi
|
||||
;;
|
||||
complete) touch "$root/.hermes-bootstrap-complete";;
|
||||
path)
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
for command in hermes hermes-agent hermes-acp; do
|
||||
entry=hermes; suffix=''
|
||||
[[ $command != hermes-agent ]] || entry=run_agent.py
|
||||
[[ $command != hermes-acp ]] || suffix=' acp'
|
||||
printf '#!/usr/bin/env bash\nunset PYTHONPATH\nunset PYTHONHOME\nexec "%s/venv/bin/python" "%s/%s"%s "$@"\n' "$root" "$root" "$entry" "$suffix" > "$HOME/.local/bin/$command"
|
||||
chmod +x "$HOME/.local/bin/$command"
|
||||
done
|
||||
[[ ${FAIL_AFTER_PATH:-} != 1 ]] || exit 43
|
||||
;;
|
||||
esac
|
||||
'''
|
||||
|
||||
class LauncherTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix='hermes-package-')
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.base = Path(self.temp.name)
|
||||
self.home = self.base / 'home with spaces'
|
||||
self.home.mkdir()
|
||||
self.root = self.home / '.hermes/hermes-agent'
|
||||
self.bin = self.base / 'bin'
|
||||
self.bin.mkdir()
|
||||
self.log = self.base / 'stages'
|
||||
self.output = self.base / 'launch.json'
|
||||
self.env = dict(os.environ, HOME=str(self.home), HERMES_HOME=str(self.home / '.hermes'),
|
||||
XDG_DATA_HOME=str(self.home / '.local/share'),
|
||||
XDG_CONFIG_HOME=str(self.home / '.config'), XDG_CACHE_HOME=str(self.home / '.cache'),
|
||||
PATH=f'{self.bin}:/usr/bin:/bin', TEST_LOG=str(self.log),
|
||||
TEST_GUI=str(self.base / 'gui'), TEST_PYTHON=str(self.base / 'python'),
|
||||
TEST_OUTPUT=str(self.output), GIT_CONFIG_NOSYSTEM='1',
|
||||
GIT_CONFIG_GLOBAL='/dev/null')
|
||||
for k in ['FAIL_STAGE', 'FAIL_AFTER_PATH', 'PYTHONHOME', 'PYTHONPATH']:
|
||||
self.env.pop(k, None)
|
||||
self.write(self.base / 'installer', INSTALLER)
|
||||
self.write(self.base / 'python', '''#!/bin/bash
|
||||
set -eu
|
||||
if [[ ${2:-} == desktop ]]; then
|
||||
root=$(dirname "$1")
|
||||
if [[ ${3:-} == --skip-build ]]; then
|
||||
[[ ${4:-} == --build-only && -x $root/apps/desktop/release/linux-unpacked/Hermes ]]
|
||||
[[ ${FAIL_FINAL_REGISTER:-} != 1 ]] || exit 45
|
||||
else
|
||||
[[ ${3:-} == --build-only ]]
|
||||
printf 'desktop\\n' >> "$TEST_LOG"
|
||||
[[ ${FAIL_STAGE:-} != desktop ]] || exit 42
|
||||
mkdir -p "$root/apps/desktop/release/linux-unpacked"
|
||||
cp "$TEST_GUI" "$root/apps/desktop/release/linux-unpacked/Hermes"
|
||||
printf '/apps/\\n/.hermes-bootstrap-complete\\n' >> "$root/.git/info/exclude"
|
||||
fi
|
||||
entry_dir="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
|
||||
mkdir -p "$entry_dir"
|
||||
printf 'Exec=%s desktop\\n' "$(command -v hermes || printf '%s/venv/bin/python -m hermes_cli.main' "$root")" > "$entry_dir/hermes.desktop"
|
||||
else
|
||||
[[ ${FAIL_READY:-} != 1 ]]
|
||||
fi
|
||||
''')
|
||||
self.write(self.base / 'gui', '''#!/usr/bin/env python3
|
||||
import json,os,sys,time
|
||||
json.dump({'pid':os.getpid(),'args':sys.argv[1:],'root':os.environ['HERMES_DESKTOP_HERMES_ROOT'],'home':os.environ['HERMES_HOME'],'node':os.environ.get('ELECTRON_RUN_AS_NODE'),'password':os.environ['HERMES_DESKTOP_PASSWORD_STORE']},open(os.environ['TEST_OUTPUT'],'w'))
|
||||
if os.environ.get('TEST_GUI_WAIT'): time.sleep(30)
|
||||
''')
|
||||
self.write(self.bin / 'unshare', '#!/bin/bash\nexit 0\n')
|
||||
self.write(self.bin / 'xdg-terminal-exec', '#!/bin/bash\nprintf "%s\\n" "$@" > "$TEST_OUTPUT"\n')
|
||||
source = SOURCE.read_text().replace('/usr/share/hermes-desktop/install.sh', str(self.base / 'installer'))
|
||||
source = source.replace('/usr/bin/hermes-desktop', str(self.base / 'launcher'))
|
||||
self.write(self.base / 'launcher', source)
|
||||
|
||||
def write(self, path, text):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
path.chmod(0o755)
|
||||
|
||||
def run_launcher(self, *args, ok=True, **env):
|
||||
result = subprocess.run(['bash', str(self.base / 'launcher'), *args], env=self.env | env,
|
||||
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
self.assertEqual(result.returncode == 0, ok, result.stdout)
|
||||
return result
|
||||
|
||||
def install(self):
|
||||
self.run_launcher('--install')
|
||||
self.run_launcher('--check')
|
||||
|
||||
def test_fresh_install_publishes_cli_after_desktop(self):
|
||||
self.install()
|
||||
stages = self.log.read_text().splitlines()
|
||||
self.assertLess(stages.index('desktop'), stages.index('path'))
|
||||
self.assertEqual((self.root / '.omarchy-hermes-desktop').read_text(), 'ready\n')
|
||||
self.assertEqual(subprocess.check_output(['git','-C',str(self.root),'status','--porcelain'],env=self.env),b'')
|
||||
|
||||
def test_profile_setup_installs_in_machine_home(self):
|
||||
profile = self.home / '.hermes/profiles/coder'
|
||||
self.write(profile / 'config.yaml', 'profile data\n')
|
||||
self.env['HERMES_HOME'] = str(profile) + '/./'
|
||||
self.install()
|
||||
self.assertEqual((self.root / '.omarchy-hermes-desktop').read_text(), 'ready\n')
|
||||
self.assertEqual(list(profile.iterdir()), [profile / 'config.yaml'])
|
||||
self.assertEqual((profile / 'config.yaml').read_text(), 'profile data\n')
|
||||
|
||||
def test_profile_warm_check_install_and_launch_share_machine_runtime(self):
|
||||
self.install()
|
||||
before = self.log.read_bytes()
|
||||
self.env['HERMES_HOME'] = str(self.home / '.hermes/profiles/coder')
|
||||
self.run_launcher('--check')
|
||||
self.run_launcher('--install')
|
||||
self.run_launcher('hermes://profile')
|
||||
observed = json.loads(self.output.read_text())
|
||||
self.assertEqual(observed['root'], str(self.root))
|
||||
self.assertEqual(observed['home'], str(self.root.parent))
|
||||
self.assertEqual(self.log.read_bytes(), before)
|
||||
|
||||
def test_custom_home_profile_uses_its_own_parent_root(self):
|
||||
custom_home = self.base / 'custom data'
|
||||
self.root = custom_home / 'hermes-agent'
|
||||
self.env['HERMES_HOME'] = str(custom_home / 'profiles/coder')
|
||||
self.install()
|
||||
self.run_launcher()
|
||||
observed = json.loads(self.output.read_text())
|
||||
self.assertEqual(observed['root'], str(self.root))
|
||||
self.assertEqual(observed['home'], str(custom_home))
|
||||
self.assertFalse((self.home / '.hermes').exists())
|
||||
self.assertFalse((custom_home / 'profiles').exists())
|
||||
|
||||
def test_custom_home_with_profiles_ancestor_is_preserved(self):
|
||||
custom_home = self.base / 'profiles/team/custom data'
|
||||
self.root = custom_home / 'hermes-agent'
|
||||
self.env['HERMES_HOME'] = str(custom_home)
|
||||
self.install()
|
||||
self.run_launcher()
|
||||
observed = json.loads(self.output.read_text())
|
||||
self.assertEqual(observed['root'], str(self.root))
|
||||
self.assertEqual(observed['home'], str(custom_home))
|
||||
|
||||
def test_profile_resolution_preserves_symlink_home_spelling(self):
|
||||
target = self.base / 'physical data'
|
||||
target.mkdir()
|
||||
alias = self.base / 'data alias'
|
||||
alias.symlink_to(target, target_is_directory=True)
|
||||
self.root = alias / 'hermes-agent'
|
||||
self.env['HERMES_HOME'] = str(alias / 'profiles/coder')
|
||||
self.install()
|
||||
self.run_launcher()
|
||||
observed = json.loads(self.output.read_text())
|
||||
self.assertEqual(observed['root'], str(self.root))
|
||||
self.assertEqual(observed['home'], str(alias))
|
||||
self.assertTrue(alias.is_symlink())
|
||||
|
||||
def test_filesystem_root_home_is_rejected_before_mutation(self):
|
||||
self.write(self.bin / 'mkdir', '#!/bin/bash\nprintf called >> "$TEST_LOG"\nexit 42\n')
|
||||
for home in ['/', '/profiles/coder', '/profiles/coder/./']:
|
||||
with self.subTest(home=home):
|
||||
result = self.run_launcher('--install', ok=False, HERMES_HOME=home)
|
||||
self.assertIn('Use a Hermes data directory other than /.', result.stdout)
|
||||
self.assertFalse(self.log.exists())
|
||||
|
||||
def test_interrupted_clone_stays_aside_and_retry_succeeds(self):
|
||||
self.run_launcher('--install', ok=False, FAIL_PARTIAL_CLONE='1')
|
||||
self.assertFalse(self.root.exists())
|
||||
partials=list((self.home/'.hermes').glob('.omarchy-hermes-repository.*/hermes-agent/.git'))
|
||||
self.assertEqual(len(partials),1)
|
||||
self.install()
|
||||
self.assertTrue(partials[0].is_dir())
|
||||
|
||||
def test_clone_publication_preserves_concurrent_checkout(self):
|
||||
self.run_launcher('--install', ok=False, RACE_CLONE='1')
|
||||
self.assertEqual((self.root/'keep').read_text(),'foreign checkout\n')
|
||||
self.assertFalse((self.root/'.omarchy-hermes-desktop').exists())
|
||||
self.assertFalse((self.home/'.local/bin/hermes').exists())
|
||||
|
||||
def test_desktop_registration_uses_published_cli(self):
|
||||
self.write(self.bin/'hermes', '#!/bin/bash\necho foreign\n')
|
||||
self.install()
|
||||
entry=self.home/'.local/share/applications/hermes.desktop'
|
||||
self.assertEqual(entry.read_text(),f'Exec={self.home}/.local/bin/hermes desktop\n')
|
||||
|
||||
def test_failed_registration_preserves_previous_launcher(self):
|
||||
wrapper=self.home/'.local/bin/hermes'
|
||||
self.write(wrapper,'#!/bin/bash\n# Written by omarchy-install-hermes-cli.\necho old\n')
|
||||
entry=self.home/'.local/share/applications/hermes.desktop'
|
||||
self.write(entry,'previous desktop entry\n')
|
||||
before=wrapper.read_bytes()
|
||||
self.run_launcher('--install',ok=False,FAIL_FINAL_REGISTER='1')
|
||||
self.assertEqual(wrapper.read_bytes(),before)
|
||||
self.assertEqual(entry.read_text(),'previous desktop entry\n')
|
||||
self.run_launcher('--check',ok=False)
|
||||
self.install()
|
||||
|
||||
def test_warm_launch_and_package_reinstall_leave_runtime_untouched(self):
|
||||
self.install()
|
||||
before = self.log.read_bytes()
|
||||
self.run_launcher('--install')
|
||||
self.run_launcher('hermes://test?a=b', 'two words', WAYLAND_DISPLAY='wayland-1', ELECTRON_RUN_AS_NODE='1')
|
||||
observed = json.loads(self.output.read_text())
|
||||
self.assertEqual(observed['args'], ['--ozone-platform=wayland','--disable-setuid-sandbox','hermes://test?a=b','two words'])
|
||||
self.assertEqual(observed['root'],str(self.root))
|
||||
self.assertIsNone(observed['node'])
|
||||
self.assertEqual(observed['password'],'gnome-libsecret')
|
||||
self.assertEqual(before,self.log.read_bytes())
|
||||
|
||||
def test_explicit_platform_and_password_are_preserved(self):
|
||||
self.install()
|
||||
self.run_launcher('--ozone-platform=x11', WAYLAND_DISPLAY='wayland-1', HERMES_DESKTOP_PASSWORD_STORE='kwallet6')
|
||||
result=json.loads(self.output.read_text())
|
||||
self.assertNotIn('--ozone-platform=wayland',result['args'])
|
||||
self.assertEqual(result['password'],'kwallet6')
|
||||
|
||||
def test_foreign_command_and_symlink_are_preserved(self):
|
||||
wrapper=self.home/'.local/bin/hermes'
|
||||
self.write(wrapper,'#!/bin/bash\necho custom\n')
|
||||
before=wrapper.read_bytes()
|
||||
self.run_launcher('--install',ok=False)
|
||||
self.assertEqual(before,wrapper.read_bytes())
|
||||
self.assertFalse(self.log.exists())
|
||||
wrapper.unlink()
|
||||
wrapper.symlink_to(self.base/'missing')
|
||||
self.run_launcher('--install',ok=False)
|
||||
self.assertTrue(wrapper.is_symlink())
|
||||
|
||||
def test_customized_native_wrapper_is_preserved(self):
|
||||
self.install()
|
||||
wrapper=self.home/'.local/bin/hermes'
|
||||
wrapper.write_text(wrapper.read_text().replace('unset PYTHONPATH','export CUSTOM=yes\nunset PYTHONPATH'))
|
||||
before=wrapper.read_bytes()
|
||||
self.run_launcher('--install',ok=False)
|
||||
self.assertEqual(wrapper.read_bytes(),before)
|
||||
|
||||
def test_failed_build_preserves_old_cli_and_retry_succeeds(self):
|
||||
wrapper=self.home/'.local/bin/hermes'
|
||||
self.write(wrapper,'#!/bin/bash\n# Written by omarchy-install-hermes-cli.\necho old\n')
|
||||
before=wrapper.read_bytes()
|
||||
self.run_launcher('--install',ok=False,FAIL_STAGE='desktop')
|
||||
self.assertEqual(wrapper.read_bytes(),before)
|
||||
self.run_launcher('--check',ok=False)
|
||||
self.install()
|
||||
self.assertNotEqual(wrapper.read_bytes(),before)
|
||||
|
||||
def test_late_path_failure_restores_old_cli(self):
|
||||
wrapper=self.home/'.local/bin/hermes'
|
||||
self.write(wrapper,'#!/bin/bash\n# Written by omarchy-install-hermes-cli.\necho old\n')
|
||||
before=wrapper.read_bytes()
|
||||
self.run_launcher('--install',ok=False,FAIL_AFTER_PATH='1')
|
||||
self.assertEqual(wrapper.read_bytes(),before)
|
||||
self.run_launcher('--check',ok=False)
|
||||
self.install()
|
||||
|
||||
def test_dirty_pending_checkout_is_preserved(self):
|
||||
self.run_launcher('--install',ok=False,FAIL_STAGE='desktop')
|
||||
(self.root/'hermes').write_text('user changes')
|
||||
before=self.log.read_bytes()
|
||||
self.run_launcher('--install',ok=False)
|
||||
self.assertEqual((self.root/'hermes').read_text(),'user changes')
|
||||
self.assertEqual(self.log.read_bytes(),before)
|
||||
|
||||
def test_cold_graphical_launch_opens_visible_setup(self):
|
||||
self.run_launcher('hermes://open')
|
||||
self.assertEqual(self.output.read_text().splitlines(),[str(self.base/'launcher'),'--setup','hermes://open'])
|
||||
self.assertFalse(self.log.exists())
|
||||
|
||||
def test_unexecutable_native_cli_is_repaired(self):
|
||||
self.install()
|
||||
wrapper=self.home/'.local/bin/hermes'
|
||||
wrapper.chmod(0o644)
|
||||
self.run_launcher('--check',ok=False)
|
||||
self.install()
|
||||
self.assertTrue(os.access(wrapper,os.X_OK))
|
||||
|
||||
def test_old_cli_ownership_survives_package_only_setup(self):
|
||||
wrapper=self.home/'.local/bin/hermes'
|
||||
self.write(wrapper,'#!/bin/bash\n# Written by omarchy-install-hermes-cli.\necho old\n')
|
||||
self.install()
|
||||
receipt=self.root/'.git/omarchy-mise-predecessor'
|
||||
self.assertEqual(receipt.read_text(),'pipx:hermes-agent[extras=all]\n')
|
||||
|
||||
def test_graphical_setup_detaches_and_releases_install_lock(self):
|
||||
started=time.monotonic()
|
||||
self.run_launcher('--setup', 'hermes://open', TEST_GUI_WAIT='1')
|
||||
self.assertLess(time.monotonic()-started,5)
|
||||
for _ in range(100):
|
||||
if self.output.exists(): break
|
||||
time.sleep(.02)
|
||||
observed=json.loads(self.output.read_text())
|
||||
self.addCleanup(lambda: os.kill(observed['pid'],signal.SIGTERM))
|
||||
self.assertIn('hermes://open',observed['args'])
|
||||
self.run_launcher('--install')
|
||||
lock=self.home/'.hermes/.omarchy-hermes-desktop.lock'
|
||||
self.assertEqual(subprocess.run(['flock','--nonblock',str(lock),'true']).returncode,0)
|
||||
|
||||
def test_cold_terminal_launch_releases_lock_before_exec(self):
|
||||
master,slave=pty.openpty()
|
||||
process=subprocess.Popen(['bash',str(self.base/'launcher')],stdin=slave,stdout=slave,stderr=slave,env=self.env | {'TEST_GUI_WAIT':'1'})
|
||||
os.close(slave)
|
||||
def cleanup():
|
||||
if process.poll() is None: process.terminate()
|
||||
process.wait(timeout=5)
|
||||
os.close(master)
|
||||
self.addCleanup(cleanup)
|
||||
for _ in range(100):
|
||||
if self.output.exists(): break
|
||||
time.sleep(.02)
|
||||
self.assertTrue(self.output.exists())
|
||||
self.assertIsNone(process.poll())
|
||||
lock=self.home/'.hermes/.omarchy-hermes-desktop.lock'
|
||||
self.assertEqual(subprocess.run(['flock','--nonblock',str(lock),'true']).returncode,0)
|
||||
|
||||
def test_pending_marker_overrides_old_completion(self):
|
||||
self.install()
|
||||
(self.root/'.omarchy-hermes-desktop').write_text('pending\n')
|
||||
self.run_launcher('--check',ok=False)
|
||||
|
||||
if __name__=='__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user