From 36353296aad129e81b69a6249c01253c540eecab Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 27 Aug 2026 23:01:30 -0500 Subject: [PATCH] Harden prompted Hermes session handoff Bind option-looking prompts to one-shot mode, require a successful completed usage report before resuming, replay the prompt after first-run setup, and reject Hermes runtimes that lack the session-report capability. Co-Authored-By: Codex XHigh --- bin/omarchy-agent-hermes | 16 +++++++-- bin/omarchy-install-hermes-cli | 39 ++++++++++++++++---- test/shell.d/hermes-agent-test.sh | 59 +++++++++++++++++++++++++++---- test/shell.d/hermes-cli-test.sh | 44 +++++++++++++++++++---- 4 files changed, 138 insertions(+), 20 deletions(-) diff --git a/bin/omarchy-agent-hermes b/bin/omarchy-agent-hermes index 28675cae..eb81495b 100755 --- a/bin/omarchy-agent-hermes +++ b/bin/omarchy-agent-hermes @@ -12,8 +12,20 @@ trap 'rm -f "$usage"' EXIT # TUI startup queries pass through Hermes' slash, shell, and interpolation # dispatcher. One-shot treats the prompt literally and records its session ID. -hermes --yolo --usage-file "$usage" --oneshot "$prompt" -session_id=$(jq -er '.session_id | strings | select(length > 0)' "$usage") +seed_session() { + hermes --yolo --usage-file "$usage" --oneshot="$prompt" +} + +if ! seed_session; then + if jq -e '.failed == true and (.failure | strings | startswith("No inference provider configured."))' "$usage" >/dev/null 2>&1; then + hermes setup + seed_session + else + exit 1 + fi +fi + +session_id=$(jq -er 'select(.completed == true and .failed != true) | .session_id | strings | select(length > 0)' "$usage") rm -f "$usage" trap - EXIT diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index 0c2e526e..f3a8614c 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -55,7 +55,7 @@ desktop_hermes_ready() { # And a marker left behind by an install whose venv has since gone answers # for nothing, so the command has to run, exactly as a foreign one must. - hermes_runs + hermes_prompt_ready } # Whether Hermes is really installed, not merely whether the stub exists. A @@ -88,6 +88,15 @@ hermes_runs() { timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1 } +# Prompted Omarchy sessions need Hermes' usage report to identify and validate +# the exact one-shot session before reopening it interactively. +hermes_prompt_ready() { + local help + hermes_runs && + help=$(timeout 15 "$HOME/.local/bin/hermes" --help 2>/dev/null) && + grep -qF -- '--usage-file' <<<"$help" +} + # --owns answers whether the wrapper on PATH is the one this command wrote, so # the migration and Remove Preinstalls do not each carry their own copy of the # marker and drift from it. @@ -101,12 +110,12 @@ if [[ $mode == "--check" ]]; then if desktop_owns_hermes; then if desktop_hermes_ready; then exit 0; else exit 1; fi fi - # A foreign command is ready when it runs; a broken one is not, and since it - # is not ours to replace, nothing this installer does will make it ready. + # A foreign command is ready only when it also supports prompted sessions; + # since it is not ours to replace, nothing this installer does will update it. if foreign_hermes; then - if hermes_runs; then exit 0; else exit 1; fi + if hermes_prompt_ready; then exit 0; else exit 1; fi fi - if installed; then exit 0; else exit 1; fi + if installed && hermes_prompt_ready; then exit 0; else exit 1; fi fi # Hand Hermes over to the app rather than keeping a second copy beside it. @@ -140,15 +149,29 @@ fi # The user already has a hermes of their own. Leave it be: a working one is # what the default agent will run, and a broken one is theirs to fix. if foreign_hermes; then - if hermes_runs; then + if hermes_prompt_ready; then exit 0 fi + if hermes_runs; then + echo "~/.local/bin/hermes does not support the session report Omarchy needs for prompted launches." >&2 + echo "Update it to Hermes Agent 0.18.1 or newer, then run omarchy-install-hermes-cli again." >&2 + exit 1 + fi + echo "~/.local/bin/hermes exists but is not runnable, and it was not installed by Omarchy." >&2 echo "Fix or remove it, then run omarchy-install-hermes-cli again." >&2 exit 1 fi +# An older mise environment may predate the session report. It belongs to this +# installer, so discard only that environment and let the current stub rebuild it. +if installed && ! hermes_prompt_ready; then + echo "Updating Hermes for prompted sessions..." >&2 + mise rm -g "$tool" >/dev/null 2>&1 || true + mise uninstall --all "$tool" >/dev/null 2>&1 || true +fi + mkdir -p "$HOME/.local/bin" rm -f "$HOME/.local/bin/hermes" @@ -194,4 +217,8 @@ chmod +x "$HOME/.local/bin/hermes" # hands Hermes to the GUI has to install it here rather than leave it stubbed. if [[ $mode == "--now" ]]; then "$HOME/.local/bin/hermes" --version + if ! hermes_prompt_ready; then + echo "Hermes installed without the session report Omarchy needs for prompted launches." >&2 + exit 1 + fi fi diff --git a/test/shell.d/hermes-agent-test.sh b/test/shell.d/hermes-agent-test.sh index 7472e3fc..174958d0 100755 --- a/test/shell.d/hermes-agent-test.sh +++ b/test/shell.d/hermes-agent-test.sh @@ -11,12 +11,21 @@ mock_bin="$test_tmp/bin" oneshot_log="$test_tmp/oneshot" resume_log="$test_tmp/resume" source_log="$test_tmp/source" +setup_log="$test_tmp/setup" +setup_marker="$test_tmp/setup-complete" mkdir -p "$mock_bin" cat >"$mock_bin/hermes" <<'SH' #!/bin/bash -if [[ " $* " == *" --oneshot "* ]]; then +if [[ ${1:-} == "setup" ]]; then + printf '%s\0' "$@" >"$HERMES_TEST_SETUP_LOG" + [[ ${HERMES_TEST_SETUP_FAIL:-false} == "false" ]] || exit 43 + touch "$HERMES_TEST_SETUP_MARKER" + exit +fi + +if [[ " $* " == *" --oneshot="* ]]; then printf '%s\0' "$@" >"$HERMES_TEST_ONESHOT_LOG" printf '%s' "${HERMES_SESSION_SOURCE:-}" >"$HERMES_TEST_SOURCE_LOG" @@ -28,8 +37,19 @@ if [[ " $* " == *" --oneshot "* ]]; then shift done + if [[ ${HERMES_TEST_NEEDS_SETUP:-false} == "true" && ! -e $HERMES_TEST_SETUP_MARKER ]]; then + printf '{"session_id":null,"completed":null,"failed":true,"failure":"No inference provider configured. Run hermes model."}\n' >"$usage" + exit 1 + fi + [[ ${HERMES_TEST_ONESHOT_FAIL:-false} == "false" ]] || exit 42 - [[ ${HERMES_TEST_USAGE_FAIL:-false} == "false" ]] && printf '{"session_id":"session-123"}\n' >"$usage" + if [[ ${HERMES_TEST_USAGE_FAIL:-false} == "false" ]]; then + completed=true + failed=false + [[ ${HERMES_TEST_USAGE_INCOMPLETE:-false} == "false" ]] || completed=false + [[ ${HERMES_TEST_USAGE_FAILED:-false} == "false" ]] || failed=true + printf '{"session_id":"session-123","completed":%s,"failed":%s}\n' "$completed" "$failed" >"$usage" + fi printf '%s\n' response exit fi @@ -43,18 +63,19 @@ export PATH="$mock_bin:$PATH" export HERMES_TEST_ONESHOT_LOG="$oneshot_log" export HERMES_TEST_RESUME_LOG="$resume_log" export HERMES_TEST_SOURCE_LOG="$source_log" +export HERMES_TEST_SETUP_LOG="$setup_log" +export HERMES_TEST_SETUP_MARKER="$setup_marker" sentinel="$test_tmp/hermes-seed-must-stay-literal" -prompt="!Crash /quit {!touch $sentinel}"$'\ntrailing\\' +prompt="--help !Crash /quit {!touch $sentinel}"$'\ntrailing\\' "$ROOT/bin/omarchy-agent-hermes" "$prompt" >/dev/null mapfile -d '' -t oneshot_args <"$oneshot_log" -(( ${#oneshot_args[@]} == 5 )) || fail "Hermes literal seed has five one-shot arguments" +(( ${#oneshot_args[@]} == 4 )) || fail "Hermes literal seed has four one-shot arguments" [[ ${oneshot_args[0]} == "--yolo" ]] || fail "Hermes literal seed enables yolo mode" [[ ${oneshot_args[1]} == "--usage-file" ]] || fail "Hermes literal seed requests the session report" usage_file=${oneshot_args[2]} -[[ ${oneshot_args[3]} == "--oneshot" && ${oneshot_args[4]} == "$prompt" ]] || - fail "Hermes literal seed remains one argument" +[[ ${oneshot_args[3]} == "--oneshot=$prompt" ]] || fail "Hermes literal seed binds option-looking prompts as data" [[ ! -e $usage_file ]] || fail "Hermes literal seed removes its session report" [[ ! -e $sentinel ]] || fail "Hermes literal seed never executes prompt interpolation" [[ ! -s $source_log ]] || fail "Hermes literal seed preserves native CLI session metadata" @@ -77,3 +98,29 @@ if HERMES_TEST_USAGE_FAIL=true "$ROOT/bin/omarchy-agent-hermes" missing-session fi [[ ! -s $resume_log ]] || fail "Hermes literal seed does not guess which session to resume" pass "Hermes resumes only the session recorded by the initial turn" + +for state in INCOMPLETE FAILED; do + : >"$resume_log" + if env "HERMES_TEST_USAGE_$state=true" "$ROOT/bin/omarchy-agent-hermes" "${state,,}" >/dev/null 2>&1; then + fail "Hermes literal seed rejects a reported ${state,,} initial turn" + fi + [[ ! -s $resume_log ]] || fail "Hermes literal seed does not resume a reported ${state,,} initial turn" +done +pass "Hermes resumes only completed successful initial turns" + +: >"$resume_log" +HERMES_TEST_NEEDS_SETUP=true "$ROOT/bin/omarchy-agent-hermes" setup-first >/dev/null +mapfile -d '' -t setup_args <"$setup_log" +[[ ${setup_args[*]} == "setup" ]] || fail "Hermes runs setup when no inference provider is configured" +mapfile -d '' -t resume_args <"$resume_log" +[[ ${resume_args[*]} == "chat --yolo --tui --resume session-123" ]] || + fail "Hermes replays the prompted turn after setup and resumes it" +pass "Hermes completes first-run setup before replaying the prompt" + +rm -f "$setup_marker" +: >"$resume_log" +if HERMES_TEST_NEEDS_SETUP=true HERMES_TEST_SETUP_FAIL=true "$ROOT/bin/omarchy-agent-hermes" setup-cancelled >/dev/null 2>&1; then + fail "Hermes reports a failed first-run setup" +fi +[[ ! -s $resume_log ]] || fail "Hermes does not resume when first-run setup fails" +pass "Hermes stops when first-run setup does not complete" diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 88319dc9..39435aa5 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -26,7 +26,10 @@ SH cat >"$mock_bin/mise" <<'SH' #!/bin/bash printf '%s\0' "$@" >>"$OMARCHY_TEST_MISE_LOG" -[[ $1 == "where" && ${OMARCHY_TEST_MISE_WHERE_OK:-0} == 1 ]] && exit 0 +if [[ $1 == "where" && ${OMARCHY_TEST_MISE_WHERE_OK:-0} == 1 ]]; then + printf '%s\n' "$OMARCHY_TEST_MISE_ROOT" + exit 0 +fi [[ $1 != "where" ]] SH @@ -35,6 +38,7 @@ chmod +x "$mock_bin"/* run_installer() { OMARCHY_TEST_DESKTOP_INSTALLED="$1" \ OMARCHY_TEST_MISE_WHERE_OK="${OMARCHY_TEST_MISE_WHERE_OK:-0}" \ + OMARCHY_TEST_MISE_ROOT="$test_tmp/mise" \ OMARCHY_TEST_MISE_LOG="$mise_log" \ HOME="$test_home" \ PATH="$mock_bin:$PATH" \ @@ -88,10 +92,17 @@ pass "takeover removes an unhealthy mise copy" rm -rf "$test_home/.hermes" rm -f "$test_home/.local/bin/hermes" run_installer 1 --check && fail "--check reports Hermes missing before the app installs it" -# The venv command answers --version, as the real one does: foreign wrappers -# below exec it, and the installer probes them by running exactly that. +# The venv command answers the readiness probes, as the real one does: foreign +# wrappers below exec it, and the installer runs both before trusting them. mkdir -p "$test_home/.hermes/hermes-agent/venv/bin" -printf '%s\n' "#!/bin/bash" 'echo "hermes-agent 0.0.0-test"' >"$test_home/.hermes/hermes-agent/venv/bin/hermes" +cat >"$test_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "--help" ]]; then + [[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "--usage-file PATH" +else + echo "hermes-agent 0.0.0-test" +fi +SH chmod +x "$test_home/.hermes/hermes-agent/venv/bin/hermes" run_installer 1 --check && fail "--check waits for the install to finish, not just the venv" touch "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete" @@ -123,6 +134,14 @@ run_installer 0 --now || fail "--now over a foreign hermes command returns succe fail "a foreign hermes command is left untouched" pass "a foreign hermes command is preserved and satisfies --check" +OMARCHY_TEST_HERMES_CAPABLE=0 run_installer 0 --check && + fail "--check rejects a foreign Hermes without prompted-session reports" +OMARCHY_TEST_HERMES_CAPABLE=0 run_installer 0 && + fail "installing refuses a foreign Hermes without prompted-session reports" +[[ $(cat "$test_home/.local/bin/hermes") == "$official_body" ]] || + fail "an older foreign Hermes command is left untouched" +pass "a foreign Hermes must support prompted-session reports" + # Broken foreign paths are still foreign. They cannot be used, so --check says # so and the installer refuses rather than replacing them. printf '%s\n' "$official_body" >"$test_home/.local/bin/hermes" @@ -211,6 +230,13 @@ grep -q "stale template" "$test_home/.local/bin/hermes" && fail "reinstalling re grep -q "exec env -u UV_PYTHON mise x" "$test_home/.local/bin/hermes" || fail "the refreshed stub is the current template" pass "reinstalling refreshes the Omarchy stub" +mkdir -p "$test_tmp/mise/hermes-agent/lib/python$python_pin" +: >"$mise_log" +OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 0 || fail "reinstalling replaces an older owned Hermes environment" +tr '\0' '\n' <"$mise_log" | grep -q '^rm$' || fail "an older owned Hermes environment is removed from mise config" +tr '\0' '\n' <"$mise_log" | grep -q '^uninstall$' || fail "an older owned Hermes environment is uninstalled" +pass "reinstalling replaces an older owned Hermes environment" + # install/user/mise.sh is sourced by install/user/all.sh through run_logged, # which runs it under `bash -eE` and hands its exit code back to # omarchy-provision-user's `set -euo pipefail`. Everything that finalizes a user @@ -349,8 +375,14 @@ run_ready_check() { run_ready_check && fail "--check rejects the app's wrapper when its runtime is gone" -printf '%s\n' "#!/bin/bash" 'echo "hermes-agent 0.0.0-test"' \ - >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" +cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "--help" ]]; then + echo "--usage-file PATH" +else + echo "hermes-agent 0.0.0-test" +fi +SH chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes" run_ready_check || fail "--check accepts the app's wrapper once it runs" pass "readiness runs the app's command rather than trusting its marker"