From 889b22dd8592db83f8f4bcf9e6e9585618b56e08 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 20 Aug 2026 10:45:12 +0200 Subject: [PATCH 01/76] Keep mise upgrades from pruning versions still in use mise ships with upgrade.auto_prune on, so `mise up` deletes the old install directory as soon as a tool upgrades. Long-running processes are still executing out of that directory, and some re-exec themselves by absolute path, so running `omarchy update` or `mup` breaks them mid-session. Claude Code is the case that surfaced it. It records its own binary path at startup and dispatches its bundled ugrep and bfs through it, so once the upgrade pruned that path its grep and find wrappers fell back to the mise wrapper in ~/.local/bin. That wrapper cannot stand in for the real binary here: a shebang script never sees the argv[0] it was invoked under, because the kernel hands the interpreter the script path instead. The dispatch collapsed to plain `claude -G`, which fails. Keeping the old versions costs some disk and leaves the stale directory behind until something prunes it deliberately, which is the cheaper half of the trade. Co-Authored-By: Claude Opus 5 (1M context) --- install/user/mise.sh | 4 ++++ migrations/1787215483.sh | 3 +++ 2 files changed, 7 insertions(+) create mode 100644 migrations/1787215483.sh diff --git a/install/user/mise.sh b/install/user/mise.sh index a800393d..24f1cb7d 100644 --- a/install/user/mise.sh +++ b/install/user/mise.sh @@ -1,3 +1,7 @@ +# Upgrades must not delete the version a running process is executing from: +# mise up would prune the old install dir out from under a live session. +mise settings set upgrade.auto_prune false + omarchy-mise-install codex omarchy-mise-install claude omarchy-mise-install crush diff --git a/migrations/1787215483.sh b/migrations/1787215483.sh new file mode 100644 index 00000000..2e473ef0 --- /dev/null +++ b/migrations/1787215483.sh @@ -0,0 +1,3 @@ +echo "Stop mise upgrades from pruning versions still in use" + +mise settings set upgrade.auto_prune false From b6a8f2453fd69121498e4c7051b4de51ebe0a829 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:15:44 +0300 Subject: [PATCH 02/76] Quote mise-install arguments and refuse unusable command names The wrapper is written through an unquoted heredoc, so the package and bin names land in it as shell source. A package name carrying shell characters became code that ran every time the wrapper ran. The command name is used raw as a file name under ~/.local/bin, so a slash in it wrote and removed somewhere else entirely. Quote both values with printf %q, and refuse command names that are not plain file names before anything is removed or written. --- bin/omarchy-mise-install | 23 ++++++++- test/shell.d/mise-install-test.sh | 84 +++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 test/shell.d/mise-install-test.sh diff --git a/bin/omarchy-mise-install b/bin/omarchy-mise-install index e6dfc96c..8bec3f31 100755 --- a/bin/omarchy-mise-install +++ b/bin/omarchy-mise-install @@ -12,8 +12,27 @@ package=$1 command=${2:-$1} bin=${3:-$command} +# The command name becomes a file name under ~/.local/bin, so a slash in it +# writes the wrapper somewhere else and the rm below deletes somewhere else. A +# leading dot hides it or walks up, and a leading dash makes a name that reads +# as an option to whatever picks it up. Checked before anything is removed or +# written, and kept to those shapes so package names like npm:playwright still +# stand in for the command name. +case "$command" in + */* | .* | -* | *[[:cntrl:]]*) + echo "omarchy-mise-install: '$command' is not usable as a command name" >&2 + exit 1 + ;; +esac + mkdir -p "$HOME/.local/bin" +# The heredoc below is unquoted, so whatever these hold is written into the +# wrapper as shell source. Quote them the way omarchy-install-and-launch does, so +# a package name carrying shell characters stays one argument instead of running. +printf -v package_arg '%q' "$package" +printf -v bin_arg '%q' "$bin" + # These tools install and upgrade on first run, so mise's release cooldown would # hold a new version back for days after it ships. Exported rather than set on # the install line alone, so resolving the version to execute agrees with the @@ -22,8 +41,8 @@ rm -f "$HOME/.local/bin/$command" cat >"$HOME/.local/bin/$command" <"$stub_bin/mise" <<'SH' +#!/bin/bash + +printf 'mise' >>"$OMARCHY_MISE_TEST_LOG" +for arg in "$@"; do + printf '\t%s' "$arg" >>"$OMARCHY_MISE_TEST_LOG" +done +printf '\n' >>"$OMARCHY_MISE_TEST_LOG" +SH +chmod +x "$stub_bin/mise" + +install_wrapper() { + HOME="$home" "$ROOT/bin/omarchy-mise-install" "$@" +} + +# The ordinary case still works, and every call site in install/user/mise.sh +# passes names of this shape. +install_wrapper npm:playwright playwright >/dev/null +[[ -x $home/.local/bin/playwright ]] || + fail "a normal install writes an executable wrapper" + +log="$tmpdir/normal.log" +: >"$log" +OMARCHY_MISE_TEST_LOG="$log" PATH="$stub_bin:$PATH" "$home/.local/bin/playwright" >/dev/null +grep -Fqx $'mise\tuse\t-g\t--quiet\tnpm:playwright' "$log" || + fail "the wrapper asks mise for the package it was given" "$(cat "$log")" + +pass "a normal install writes a wrapper that names its package" + +# A package name is data. Quoted with %q it reaches mise as one argument +# instead of being read as shell source when the wrapper runs. +install_wrapper 'npm:pkg$(touch '"$tmpdir"'/PWNED)end' hostile >/dev/null + +log="$tmpdir/hostile.log" +: >"$log" +OMARCHY_MISE_TEST_LOG="$log" PATH="$stub_bin:$PATH" "$home/.local/bin/hostile" >/dev/null + +[[ -e $tmpdir/PWNED ]] && + fail "a package name with shell characters does not run when the wrapper does" \ + "wrapper: $(cat "$home/.local/bin/hostile")" + +grep -Fqx $'mise\tuse\t-g\t--quiet\tnpm:pkg$(touch '"$tmpdir"'/PWNED)end' "$log" || + fail "the package reaches mise whole" "$(cat "$log")" + +pass "a package name with shell characters reaches mise as one argument" + +# The command name is a file name under ~/.local/bin. These shapes escape it, +# hide it, or make something that reads as an option. +for name in ../escaped .hidden -dash; do + if install_wrapper somepkg "$name" >/dev/null 2>"$tmpdir/err"; then + fail "a command name of '$name' is refused" + fi + grep -Fq 'is not usable as a command name' "$tmpdir/err" || + fail "the refusal says why for '$name'" "$(cat "$tmpdir/err")" +done + +pass "command names that are not plain file names are refused" + +# The refusal has to land before the rm, which would otherwise delete the +# escaped path on its way to failing. +victim="$tmpdir/victim" +printf 'keep me\n' >"$victim" +if install_wrapper somepkg "../../../..$victim" >/dev/null 2>&1; then + fail "an escaping command name is refused" +fi +[[ -f $victim ]] || + fail "an escaping command name removes nothing outside ~/.local/bin" + +pass "an escaping command name removes nothing outside ~/.local/bin" From e090d01877ebd3bf172d26cb5c8b62b1dcd14b76 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:34:36 +0300 Subject: [PATCH 03/76] Cover the control-character branch in the mise-install guard The refusal table exercised slash, leading dot and leading dash but never a control character, so that branch of the guard could have been dropped without a test noticing. Add a newline and a tab case, labelled so the value stays out of the output. --- test/shell.d/mise-install-test.sh | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/test/shell.d/mise-install-test.sh b/test/shell.d/mise-install-test.sh index edebd05a..951db41d 100644 --- a/test/shell.d/mise-install-test.sh +++ b/test/shell.d/mise-install-test.sh @@ -60,13 +60,26 @@ grep -Fqx $'mise\tuse\t-g\t--quiet\tnpm:pkg$(touch '"$tmpdir"'/PWNED)end' "$log" pass "a package name with shell characters reaches mise as one argument" # The command name is a file name under ~/.local/bin. These shapes escape it, -# hide it, or make something that reads as an option. -for name in ../escaped .hidden -dash; do +# hide it, make something that reads as an option, or carry characters that have +# no business in a file name. Labelled so a newline in the value does not end up +# inside the test output. +refused=( + "a slash" "../escaped" + "a leading dot" ".hidden" + "a leading dash" "-dash" + "a newline" $'with\nnewline' + "a tab" $'with\ttab' +) + +for (( i = 0; i < ${#refused[@]}; i += 2 )); do + label=${refused[i]} + name=${refused[i + 1]} + if install_wrapper somepkg "$name" >/dev/null 2>"$tmpdir/err"; then - fail "a command name of '$name' is refused" + fail "a command name with $label is refused" fi grep -Fq 'is not usable as a command name' "$tmpdir/err" || - fail "the refusal says why for '$name'" "$(cat "$tmpdir/err")" + fail "the refusal says why for a command name with $label" "$(cat "$tmpdir/err")" done pass "command names that are not plain file names are refused" From a12a21c02fbc945ac1de73633494df67908ae28a Mon Sep 17 00:00:00 2001 From: Omabot Date: Wed, 26 Aug 2026 18:04:41 +0200 Subject: [PATCH 04/76] Add Hermes as a desktop app and a coding agent Hermes joins Install > AI as a desktop app, sits beside it under Remove > AI, and becomes a choice in the default-agent list. The CLI installs through omarchy-install-hermes-cli rather than a bare `mise use`, so its interpreter is pinned before mise builds it. Rebased onto quattro. Ori claimed U+E909 in #7709 while this branch was open, so the Hermes mark moves to U+E90A in the icon font, the menu entries, the font README, and the charset the menu test pins. The glyph outline itself is unchanged; it is spliced in beside Ori rather than over it. Co-Authored-By: witcheer Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SySdB3RtCA8BNv6Am246BP --- bin/omarchy-agent | 9 ++ bin/omarchy-default-agent | 24 ++++- bin/omarchy-install-ai-hermes | 26 ++++++ bin/omarchy-install-hermes-cli | 136 +++++++++++++++++++++++++++++ bin/omarchy-remove-ai-hermes | 49 +++++++++++ bin/omarchy-remove-preinstalls | 4 + default/fonts/omarchy/README.md | 1 + default/fonts/omarchy/omarchy.ttf | Bin 4668 -> 9008 bytes default/omarchy/omarchy-menu.jsonc | 3 + install/user/mise.sh | 1 + test/shell.d/hermes-cli-test.sh | 105 ++++++++++++++++++++++ test/shell.d/hermes-remove-test.sh | 77 ++++++++++++++++ test/shell.d/menu-test.sh | 5 +- 13 files changed, 434 insertions(+), 6 deletions(-) create mode 100755 bin/omarchy-install-ai-hermes create mode 100755 bin/omarchy-install-hermes-cli create mode 100755 bin/omarchy-remove-ai-hermes create mode 100755 test/shell.d/hermes-cli-test.sh create mode 100755 test/shell.d/hermes-remove-test.sh diff --git a/bin/omarchy-agent b/bin/omarchy-agent index 3c009460..426ea85a 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -86,6 +86,15 @@ codex) command=(codex --approve-for-me) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") ;; +hermes) + # Hermes has no "start interactive, seeded with this prompt" mode. --oneshot + # answers the prompt and exits, which is the closest it offers. + if [[ -n ${prompt:-} ]]; then + command=(hermes --yolo --oneshot "$prompt") + else + command=(hermes --yolo) + fi + ;; omp) command=(omp --auto-approve) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") diff --git a/bin/omarchy-default-agent b/bin/omarchy-default-agent index 1f89b765..9d575514 100755 --- a/bin/omarchy-default-agent +++ b/bin/omarchy-default-agent @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Set and launch the default coding agent -# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|agy|copilot|crush] +# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|agy|hermes|copilot|crush] # omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude installing=false @@ -33,20 +33,36 @@ codex) agent="codex"; name="Codex" ;; crush) agent="crush"; name="Crush" ;; grok) agent="grok"; name="Grok"; agent_package="npm:@xai-official/grok" ;; agy | antigravity | antigravity-cli | gemini | gemini-cli) agent="agy"; name="Antigravity"; agent_package="antigravity-cli" ;; +hermes) agent="hermes"; name="Hermes"; agent_installer="omarchy-install-hermes-cli" ;; copilot | github-copilot) agent="copilot"; name="GitHub Copilot" ;; *) - echo "Usage: omarchy-default-agent " + echo "Usage: omarchy-default-agent " exit 1 ;; esac agent_package=${agent_package:-$agent} -if [[ $installing == "false" ]] && ! mise where "$agent_package" &>/dev/null; then +# Hermes reaches mise through its own installer rather than straight from +# here: it needs its interpreter pinned, and a bare `mise use` has nowhere to +# say so. See omarchy-install-hermes-cli. +if [[ -n ${agent_installer:-} ]]; then + # Not omarchy-cmd-present: the stub is on PATH from first boot and says + # nothing about whether Hermes is installed behind it. Treating a cold stub + # as installed skips the floating terminal and runs the minute-long install + # inside the menu action instead. + agent_present() { "$agent_installer" --check; } + agent_install() { "$agent_installer" --now; } +else + agent_present() { mise where "$agent_package" &>/dev/null; } + agent_install() { mise use -g "$agent_package"; } +fi + +if [[ $installing == "false" ]] && ! agent_present; then exec omarchy-launch-floating-terminal-with-presentation omarchy-default-agent --install "$agent" fi -if ! mise use -g "$agent_package"; then +if ! agent_install; then if [[ $installing == "true" ]]; then echo "Could not install $name with mise" >&2 else diff --git a/bin/omarchy-install-ai-hermes b/bin/omarchy-install-ai-hermes new file mode 100755 index 00000000..3f6abafe --- /dev/null +++ b/bin/omarchy-install-ai-hermes @@ -0,0 +1,26 @@ +#!/bin/bash + +# omarchy:summary=Install the Hermes desktop app +# omarchy:requires-sudo=true + +set -e + +# No CLI is installed here on purpose. Hermes Desktop only runs against a +# runtime built from its own commit, so it provisions one itself under +# ~/.hermes on first launch, which takes a few minutes and shows its own +# progress. Handing it the mise CLI instead fails: PyPI trails the tags, and +# the version gap fails the app's readiness probe with a 401. +echo "Installing Hermes Desktop..." +omarchy-pkg-add hermes-desktop + +# If Hermes was already installed for the terminal, the app supersedes it: one +# machine, one Hermes. This drops that copy so the terminal, the default agent +# and the app all end up on the app's installation. +omarchy-install-hermes-cli || true + +echo "Opening Hermes Desktop..." +setsid uwsm-app -- /usr/bin/hermes-desktop >/dev/null 2>&1 & + +echo "" +echo "Hermes Desktop has been installed." +echo "Its first launch installs the Hermes runtime, which takes a few minutes." diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli new file mode 100755 index 00000000..c153013b --- /dev/null +++ b/bin/omarchy-install-hermes-cli @@ -0,0 +1,136 @@ +#!/bin/bash + +# omarchy:summary=Install the Hermes CLI as a mise-backed wrapper in ~/.local/bin +# omarchy:args=[--now] +# omarchy:examples=omarchy install hermes cli | omarchy install hermes cli --now + +# Hermes pins every one of its dependencies exactly and declares +# Requires-Python >=3.11,<3.14, so it can neither be built against Arch's +# Python nor share the python-* packages. mise builds it a private environment +# instead. +# +# It gets its own installer rather than a line in omarchy-mise-install because +# of the interpreter pin. Given no compatible interpreter to hand, uv builds +# the venv against the system Python in violation of Hermes' own bound, +# reports success, and leaves the breakage to surface later inside a +# dependency -- and omarchy-mise-install writes a fixed stub with nowhere to +# say otherwise. +# +# There is only ever one Hermes on a machine. hermes-desktop cannot run against +# this one -- it needs a runtime built from its own commit, and the version gap +# fails its readiness probe -- so it installs its own under ~/.hermes and puts +# that on PATH. When the package is present it therefore owns Hermes outright: +# this installer stands aside and removes its own copy, so the terminal, the +# default agent and the app are all the same installation. + +set -euo pipefail + +mode=${1:-} + +tool='pipx:hermes-agent[extras=all]' +python='3.13' + +# The package, not the runtime directory: it is installed before the app has +# ever run, and that is exactly when we must not start building a second copy. +desktop_owns_hermes() { + omarchy-pkg-present hermes-desktop +} + +# The venv appears at the python-deps stage, several stages before the one that +# installs the command, so its presence says nothing about being usable. The +# marker is written last, and the command is what the agent actually runs. +desktop_hermes_ready() { + [[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]] || return 1 + [[ -x $HOME/.local/bin/hermes ]] || return 1 + + # An executable of that name proves nothing about whose it is; the app's own + # points into ~/.hermes, and anything else is not the install we are asking + # about. + grep -q "$HOME/.hermes" "$HOME/.local/bin/hermes" +} + +# Whether Hermes is really installed, not merely whether the stub exists. A +# stub on its own is cold: running it installs Hermes, which takes minutes. +installed() { + [[ -d "$(mise where "$tool" 2>/dev/null)/hermes-agent/lib/python$python" ]] +} + +# --check lets callers tell a cold stub from a working one before they commit +# to a path that assumes Hermes is ready. +if [[ $mode == "--check" ]]; then + if desktop_owns_hermes; then + if desktop_hermes_ready; then exit 0; else exit 1; fi + fi + if installed; then exit 0; else exit 1; fi +fi + +# Hand Hermes over to the app rather than keeping a second copy beside it. +if desktop_owns_hermes; then + # Not gated on that copy being healthy: `mise up` can rebuild it against the + # wrong interpreter and a half-finished install answers to neither test, and + # either way it is still a second Hermes. Removing nothing is harmless. + if mise where "$tool" >/dev/null 2>&1; then + echo "Hermes Desktop provides Hermes; removing the separate CLI install..." >&2 + fi + + mise rm -g "$tool" >/dev/null 2>&1 || true + mise uninstall --all "$tool" >/dev/null 2>&1 || true + + # Our own stub has to go with it. Left in place it still answers `hermes` + # until the app's bootstrap overwrites it, and answering means building the + # second Hermes this whole arrangement exists to avoid. + if [[ -f $HOME/.local/bin/hermes ]] && grep -q omarchy-install-hermes-cli "$HOME/.local/bin/hermes"; then + rm -f "$HOME/.local/bin/hermes" + fi + + if desktop_hermes_ready; then + exit 0 + fi + + echo "Hermes Desktop is installed but has not set Hermes up yet." >&2 + echo "Launch Hermes Desktop once to finish installing it." >&2 + exit 1 +fi + +mkdir -p "$HOME/.local/bin" +rm -f "$HOME/.local/bin/hermes" + +cat >"$HOME/.local/bin/hermes" </dev/null)/hermes-agent/lib/python$python" ]]; then + echo "Installing Hermes on Python $python (this takes a minute)..." >&2 + + # mise's pipx backend shells out to uv, which a stock Omarchy does not have. + # It is fetched here rather than when this stub was written, so setting up a + # machine that never runs Hermes costs nothing. + if omarchy-cmd-missing uv && ! mise where uv >/dev/null 2>&1; then + mise use -g --quiet uv@latest || exit 1 + fi + + mise use -g --quiet --force '$tool' || exit 1 +fi + +exec mise x '$tool' -- hermes "\$@" +EOF + +chmod +x "$HOME/.local/bin/hermes" + +# The desktop app resolves a hermes on PATH by running `hermes --version` with +# a 15 second budget, then falls back to cloning its own copy when that times +# out. A first-run mise install does not fit in 15 seconds, so anything that +# hands Hermes to the GUI has to install it here rather than leave it stubbed. +if [[ $mode == "--now" ]]; then + "$HOME/.local/bin/hermes" --version +fi diff --git a/bin/omarchy-remove-ai-hermes b/bin/omarchy-remove-ai-hermes new file mode 100755 index 00000000..830ca2ad --- /dev/null +++ b/bin/omarchy-remove-ai-hermes @@ -0,0 +1,49 @@ +#!/bin/bash + +# omarchy:summary=Remove the Hermes desktop app along with the Hermes runtime it installed. +# omarchy:requires-sudo=true + +set -e + +omarchy-pkg-drop hermes-desktop + +# The app installs a Hermes of its own under ~/.hermes -- the checkout and venv, +# its own uv, its own node -- and puts its commands on PATH. None of it is any +# use once the app is gone. Not ~/.config/Hermes, which holds the gateway +# connections and their encrypted tokens, the active profile and the update +# settings. Not the rest of ~/.hermes either: +# the chats, memories and the skills Hermes wrote for itself are the user's, +# they are small, and finding them still there after a reinstall is the better +# surprise. +rm -rf \ + "$HOME/.hermes/hermes-agent" \ + "$HOME/.hermes/bootstrap-cache" \ + "$HOME/.hermes/bin" \ + "$HOME/.hermes/node" + +# Only the wrappers pointing into ~/.hermes. The app writes these at its own +# path stage, so a machine where it was installed but never launched still has +# whatever was there before, and that is not ours to delete. +for command in hermes hermes-agent hermes-acp; do + wrapper="$HOME/.local/bin/$command" + + if [[ -f $wrapper ]] && grep -q "$HOME/.hermes" "$wrapper"; then + rm -f "$wrapper" + fi +done + +# When Hermes brought its own Node it symlinked these next to its own commands, +# and they point at what we just deleted. Only the links into ~/.hermes: a +# system Node, or someone else's, lives somewhere else entirely. +for command in node npm npx; do + link="$HOME/.local/bin/$command" + + if [[ -L $link && $(readlink "$link") == "$HOME/.hermes"/* ]]; then + rm -f "$link" + fi +done + +echo "" +echo "Hermes Desktop has been removed." +echo "Your chats, memories, and skills are still in ~/.hermes," +echo "and your connections and settings in ~/.config/Hermes." diff --git a/bin/omarchy-remove-preinstalls b/bin/omarchy-remove-preinstalls index c096ca2e..8f5ed5a9 100755 --- a/bin/omarchy-remove-preinstalls +++ b/bin/omarchy-remove-preinstalls @@ -17,6 +17,10 @@ if gum confirm "Are you sure you want to remove all preinstalled web apps, TUI w ~/.local/bin/gh ~/.local/bin/opencode ~/.local/bin/playwright ~/.local/bin/playwright-cli ~/.local/bin/pi \ ~/.local/bin/omp ~/.local/bin/ori ~/.local/bin/grok ~/.local/bin/crush ~/.local/bin/ghui ~/.local/bin/hunk + # Hermes Desktop owns this path once installed, and its own CLI is not a + # preinstall to sweep away. + omarchy-pkg-present hermes-desktop || rm -f ~/.local/bin/hermes + omarchy-pkg-drop \ aether \ cliamp \ diff --git a/default/fonts/omarchy/README.md b/default/fonts/omarchy/README.md index 005fc874..11558a30 100644 --- a/default/fonts/omarchy/README.md +++ b/default/fonts/omarchy/README.md @@ -12,6 +12,7 @@ The private-use glyphs in `omarchy.ttf` are: - `U+E907` — Ollama, from - `U+E908` — T3 Code, traced from the app icon in , since upstream publishes no monochrome SVG - `U+E909` — Ori, from , OpenRouter's own mark: Ori ships no separate logo and its product page uses this one +- `U+E90A` — Hermes, traced from the Hermes app icon (), the same art the `hermes-desktop` package ships as its icon The agent marks are monochrome so the menu can render them using the active theme's foreground and selection colors. diff --git a/default/fonts/omarchy/omarchy.ttf b/default/fonts/omarchy/omarchy.ttf index fc47d0131ccb06e27d4a01e0014f5a22f393aea3..29145211c45d05ef88767dc30d750fbe84d238a2 100644 GIT binary patch delta 4770 zcmY*cTZklA8Lt1_Pu=@cRns%mnceQ0sx_O;rF*(tlb8#}Tt%aT4-t*)y1L@72}VPd zNEr|n6j4WlH}JtX@j+Nc#l#B;5=5gA1%2>A#Os3y`X&>aITL)cODa`@jEk zYM$Qx=3SpD+sRLYAh`0nHy+I1^s9UC4gz{P=5M=r^}-{6JNe=VF@HV?MEb5PAAD!~ zZ1R;LP;n4!zxDE^3-8$fTJRwDK8^44G8P=E2;+a@yLj&RH+09mG!*1umq``S!4+Op; zt{`}Ur$KOXjxL@&l%Y~DBd9+m9?D9o8_JIKKY=$BLAa=@@k-J^-A?B zb)R}by;)sUUslhnzvx%!x9O+!w@nfRt71M|E{>P0<#b|=wN1S^UM(@jwX&_Trm36y zK;VY!*i?2t?;b9Xj#*+oU+-T+fx%~SxLP)KH#GDA?T0$MmSjV`57NbazF6>x7lvzN z&16!QB|c5vveg%#8q*A4Le^QbW@T2lip8;64dDcybr=s>+0+z=6?E*GIl$mqQ}eVc zm&c3VbYkY+1h@4^o9Tr2Obw`*&#H2D2*cLEK0~fbK&_y4($vMIJeqY&6J4$_T7f<` z*-VOIQ?5OHaitSJf^ac}T3oudqIyKw*cD6%`<%Gg^StwfWA6vM}&%HlMK#>{~4XZtZ$i zE{-d#PYq~*VmMQ;+U1l5KsQer@%oE#14`E9Rpq=Rg!KXH0?1$;6NGEk1-CKmYUdX} z<8VP)voD&uBYSx?TdQhntXCYA`7!TkF{w%nj=M$1$jkL=#*8+{M+j9@^Bv~akD3OP z0`y?RI-fuXHu09}WXeJ6Y%nv}g^1!2Z{XpPf#oG{UDVB@DqD=!S2KhM4OlCnb+25P z%bw)~09kvGpU>bXlC4tXs3E~U1<;t;s$vg2 z_bV<&YkatU9b_tFnet1f^*h8o+_}Bbehv;Dq8<%+Nml%G_9@sI5Y6(@^(@BR$Zh(y~9uohQ;&nmRA(*UA{1 zM7T+t^`&gi4M*d|nXpWytg@{gCnbq08SF&;Cq2n7Gm_|guX(4s_ojo*-YBwZVk6o-eawz#jNRufO z5}{Tqib>|$04|7;Qu{24wGP7+ShNDMN@{GwqcAfPJ{!Rv;Deh~-brvzB*AhPh6;~i zM}|gAG7+{aqeHVL5u;wOtXgF@E+suEB4g7CS|3*l_DAY*ik!2Z-X!@IbPF>qM*YZA zw~sPyt#L#fp%JEXa4FF+D+CN-b5Cn1#1~sSe1J`6Tv>``JjhF{;GUvZz%AtynL6tA zbfSfnQfXnG%9M_cQb;_`FsOtoXjdw>Y(d5EHt&kSFTS!Xz>By%LXCe)X0Djn_-}4^22xv?JQDdME`PFU1I-&ud z*tAw*nx{Qy`zDJJ5iBFAtWwA^#8KE#u@Y?z5rPYR;KXBK1+}F@Rw6?1O{;fX>`uI) zH(0GHQ@tEnqLuTfPbq+d@7hYGoD(A15mqbxh7O|EQkYa|Z;AvG`EQVJ&gy6??ZyD< z;B3MCNZU(*m|`1}aahl7TTHH-?BBXIIA71sZpTu`QFMBu02zEBS@>&WuZ6~|Zh_Dy znGp$OM+h5|kin|3k}e-?heGW{x$>x1c(@BuBo*ZPC_0c0N0_gUUS<(IXww1(Pb@NR zy#YDQB^j~)AA%PyN27?YcD4L>?2x{t#Eo}t5|D40Z81T9iY7j{X(MbCvs zb7AodNMXo%fCAwkBmEx?$`!}i! zZIm5@Fjjv0anO97^{$sA#?tkpL9uDaXN(;=V{{auahNDd)4sUp850%TZJuScdwwK$ z>LznOuTrAK^@f_PA-SDZOO3ch4AM^Q0%wfAmROkbc=@HhKN6^=oGqOC5=pO+w?cSF zgN{gqCmGxjgR|X19|1kkUWQ>*#KIoRRvwgPkvK9*e_zt|FCW~#LS4rFI2!43CC6GHkZNe(5hu^H8uAmk~U zq_T8Od`Dyo@@5ZAr zy912CbJR*MDPTd{K-*`!5eLZN3sabKDWZ0`aj-Zi8hR3bxO}+k<*GkBJ9aT%siLHR*t|MLNv5$#M?;h@*M+iC(LbAlZ!mqv z=zP!?9)~e|xye~2e51*2jCVIt&f8(;21q!&wc{yGaiAJIsSJ*1o2KY@VMLz0iXyy# zc=iyd4BNq*+7KkBoFOH+7}zk9XqQ54#^b)9x*jJm$!R28qU#;!5x=$}a(Fo^hG~Z0 zjLXVFPMd~J=sxE)TjO{Dh~blRFZR&fu?^By)c07#yNylJ5F(d`XuiITkXPK}2}e52 zx-!Axk3%!}k=+3()@e-2ZB%uKHXm3b2BXGa`&;%8IB@d29?p~a#F<-f1$%+LS; delta 432 zcmZ9GJxD@P6vzMPy{Attvkamw5&K#S6sp0XscdQp93-fRSq|@|pixa$Lo^mQ=mT0x zXlW4MP(w{c5PeTUQ$ZgTtwLL!D^A_Z;oRT<{tow+22Vn3O$D3)Fh@qa!|{jVA%HrV zCz6(t{Sw=A%&UM>H*GFV9h|j40O}S{GnGymlU*ydKY)*Q^rSg(k=DrE#~eyq`S}j0 zN-7P<|1>OWz1(OL?yJtcg;ay#;{U1AMYD1eDkAM*-S3qoSTaP4aa=H zEY5?b=%b`~q!_tHKsj=|Ip-p${h~N%->W$YqSjqjbzQer&#kHe_PuwPT3KS3B9T2; zK0fY?mQkZ|0KDE^&dUD2c{RHJ-0Sp9(8P<NA9$Y2GZ zbgC?AZ;q71W=B5nry^W(0Q@g_cmux@E2|zC6-&B0Cw3w5MC2EVU`Q+mqdf8l9ky9G diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 59582db7..08f9bf4b 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -141,6 +141,7 @@ "setup.default.agent.copilot": {"icon":"","label":"Copilot","checked":"[[ \"$(omarchy-default-agent)\" == \"copilot\" ]]","action":"omarchy-default-agent copilot"}, "setup.default.agent.crush": {"icon":"󰋑","label":"Crush","checked":"[[ \"$(omarchy-default-agent)\" == \"crush\" ]]","action":"omarchy-default-agent crush"}, "setup.default.agent.grok": {"icon":"","iconFont":"omarchy","label":"Grok","checked":"[[ \"$(omarchy-default-agent)\" == \"grok\" ]]","action":"omarchy-default-agent grok"}, + "setup.default.agent.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes","checked":"[[ \"$(omarchy-default-agent)\" == \"hermes\" ]]","action":"omarchy-default-agent hermes"}, "setup.default.agent.omp": {"icon":"","iconFont":"omarchy","label":"omp","checked":"[[ \"$(omarchy-default-agent)\" == \"omp\" ]]","action":"omarchy-default-agent omp"}, "setup.default.agent.opencode": {"icon":"","iconFont":"omarchy","label":"OpenCode","checked":"[[ \"$(omarchy-default-agent)\" == \"opencode\" ]]","action":"omarchy-default-agent opencode"}, "setup.default.agent.ori": {"icon":"","iconFont":"omarchy","label":"Ori","checked":"[[ \"$(omarchy-default-agent)\" == \"ori\" ]]","action":"omarchy-default-agent ori"}, @@ -239,6 +240,7 @@ "install.ai.chatgpt": {"icon":"","iconFont":"omarchy","label":"ChatGPT Desktop","disabled":"omarchy-pkg-present openai-codex-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-chatgpt"}, "install.ai.dictation": {"icon":"","label":"Dictation","disabled":"omarchy-pkg-present voxtype-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install"}, "install.ai.grok-bot": {"icon":"","iconFont":"omarchy","label":"Grok Bot","disabled":"omarchy-pkg-present grok-bot","action":"omarchy-install-and-launch 'Grok Bot' grok-bot grok-bot"}, + "install.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","disabled":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-hermes"}, "install.ai.lm-studio": {"icon":"","iconFont":"omarchy","label":"LM Studio","disabled":"omarchy-pkg-present lmstudio-bin","action":"omarchy-install-app 'LM Studio' lmstudio-bin"}, "install.ai.ollama": {"icon":"","iconFont":"omarchy","label":"Ollama","disabled":"omarchy-cmd-present ollama","action":"if omarchy-cmd-present nvidia-smi; then ollama_pkg=ollama-cuda; elif omarchy-cmd-present rocminfo; then ollama_pkg=ollama-rocm; else ollama_pkg=ollama; fi; omarchy-install-app Ollama \"$ollama_pkg\""}, "install.ai.t3-code": {"icon":"","iconFont":"omarchy","label":"T3 Code","disabled":"omarchy-pkg-present t3code-bin","action":"omarchy-install-and-launch 'T3 Code' t3code-bin t3code"}, @@ -292,6 +294,7 @@ "remove.security.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"}, "remove.security.sshd": {"icon":"󰣀","label":"SSHD","when":"systemctl is-enabled --quiet sshd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sshd"}, "remove.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"! omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"}, + "remove.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","when":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-hermes"}, "remove.browser.chrome": {"icon":"","label":"Chrome","when":"omarchy-pkg-present google-chrome","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser chrome'"}, "remove.browser.edge": {"icon":"󰇩","label":"Edge","when":"omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser edge'"}, "remove.browser.brave": {"icon":"","label":"Brave","when":"omarchy-pkg-present brave-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser brave'"}, diff --git a/install/user/mise.sh b/install/user/mise.sh index 0c8ab99b..a944d6cb 100644 --- a/install/user/mise.sh +++ b/install/user/mise.sh @@ -13,3 +13,4 @@ omarchy-mise-install npm:@kitlangton/ghui ghui omarchy-mise-install aqua:modem-dev/hunk hunk omarchy-mise-install github:basecamp/hey-cli hey omarchy-mise-install github:OpenRouterLabs/ori-releases ori +omarchy-install-hermes-cli diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh new file mode 100755 index 00000000..881dac47 --- /dev/null +++ b/test/shell.d/hermes-cli-test.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +test_home="$test_tmp/home" +mise_log="$test_tmp/mise-log" +mkdir -p "$mock_bin" "$test_home/.local/bin" + +cat >"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]] +SH + +cat >"$mock_bin/omarchy-cmd-missing" <<'SH' +#!/bin/bash +! command -v "$1" >/dev/null 2>&1 +SH + +# `mise where` must fail so the installer sees no Hermes behind the stub. +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 +[[ $1 != "where" ]] +SH + +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_LOG="$mise_log" \ + HOME="$test_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" ${2:+"$2"} >/dev/null 2>&1 +} + +stub_marker="omarchy-install-hermes-cli" +app_stub_body='#!/bin/bash +exec /home/x/.hermes/hermes-agent/venv/bin/hermes "$@"' + +# Writing the stub must not provision anything: user setup calls this on every +# machine, including the ones that never run Hermes. +: >"$mise_log" +rm -f "$test_home/.local/bin/hermes" +run_installer 0 || fail "installer failed with no desktop installed" +[[ -x $test_home/.local/bin/hermes ]] || fail "installer writes a hermes stub when the desktop is absent" +grep -q "$stub_marker" "$test_home/.local/bin/hermes" || fail "the stub records which command wrote it" +tr '\0' ' ' <"$mise_log" | grep -q "use -g --quiet uv" && + fail "writing the stub does not install uv" +pass "writing the Hermes stub provisions nothing" + +# The desktop app owns Hermes, so our own stub must go rather than sit there +# answering `hermes` until the app's bootstrap replaces it. +printf '%s\n' "#!/bin/bash" "# $stub_marker" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 1 || true +[[ ! -e $test_home/.local/bin/hermes ]] || + fail "the desktop taking over removes the stub this command wrote" +pass "installing the desktop app removes the CLI stub" + +# ...but the app's own hermes is not ours to delete. +printf '%s\n' "$app_stub_body" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 1 || true +[[ -x $test_home/.local/bin/hermes ]] || + fail "the desktop app's own hermes command survives" +pass "the app's own hermes command is left alone" + +# A copy mise cannot vouch for is still a second Hermes. +printf '%s\n' "#!/bin/bash" "# $stub_marker" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +: >"$mise_log" +OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 1 || true +tr '\0' '\n' <"$mise_log" | grep -q "uninstall" || + fail "takeover removes a mise copy even when it is not healthy" +pass "takeover removes an unhealthy mise copy" + +# --check answers about Hermes being usable, not about the venv appearing. The +# venv exists from the python-deps stage, several stages before the command. +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" +mkdir -p "$test_home/.hermes/hermes-agent/venv/bin" +printf '%s\n' "#!/bin/bash" >"$test_home/.hermes/hermes-agent/venv/bin/hermes" +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" +printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 1 --check || fail "--check reports Hermes present once the app has finished" +pass "--check follows the app's completed install" + +# An executable called hermes that belongs to something else is not this +# install being ready. +printf '%s\n' "#!/bin/bash" "exec /usr/local/bin/somebody-elses-hermes \"\$@\"" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 1 --check && fail "--check rejects a hermes command belonging to something else" +pass "--check rejects a foreign hermes command" diff --git a/test/shell.d/hermes-remove-test.sh b/test/shell.d/hermes-remove-test.sh new file mode 100755 index 00000000..423a297e --- /dev/null +++ b/test/shell.d/hermes-remove-test.sh @@ -0,0 +1,77 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +test_home="$test_tmp/home" +mkdir -p "$mock_bin" + +cat >"$mock_bin/omarchy-pkg-drop" <<'SH' +#!/bin/bash +printf '%s\0' "$@" >>"$OMARCHY_TEST_DROP_LOG" +SH +chmod +x "$mock_bin"/* + +seed_install() { + rm -rf "$test_home" + mkdir -p "$test_home/.hermes/hermes-agent" "$test_home/.hermes/bootstrap-cache" \ + "$test_home/.hermes/bin" "$test_home/.hermes/node/bin" \ + "$test_home/.hermes/memories" "$test_home/.hermes/sessions" \ + "$test_home/.config/Hermes" "$test_home/.local/bin" + printf 'chat\n' >"$test_home/.hermes/sessions/one.json" + printf 'memory\n' >"$test_home/.hermes/memories/one.md" + printf 'soul\n' >"$test_home/.hermes/SOUL.md" + printf 'uv\n' >"$test_home/.hermes/bin/uv" + ln -sf "$test_home/.hermes/node/bin/node" "$test_home/.local/bin/node" + ln -sf "$test_home/.hermes/node/bin/npm" "$test_home/.local/bin/npm" + ln -sf /usr/bin/npx "$test_home/.local/bin/npx" + printf 'node\n' >"$test_home/.hermes/node/bin/node" +} + +remove() { + OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" HOME="$test_home" PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-remove-ai-hermes" >/dev/null 2>&1 +} + +# The app brings its own uv and its own node; both are runtime, not data. +seed_install +printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \ + >"$test_home/.local/bin/hermes" +remove || fail "remove succeeds" +[[ ! -d $test_home/.hermes/hermes-agent ]] || fail "the runtime checkout is removed" +[[ ! -d $test_home/.hermes/bin ]] || fail "the uv the app installed is removed" +[[ ! -d $test_home/.hermes/node ]] || fail "the node the app installed is removed" +pass "removal takes the whole runtime the app installed" + +[[ -d $test_home/.config/Hermes ]] || + fail "gateway connections, tokens and settings survive removal" +pass "removal keeps the app's connections and settings" + +# -L, not -e: a dangling symlink fails -e while very much still being there. +[[ ! -L $test_home/.local/bin/node ]] || fail "a node symlink into ~/.hermes is removed" +[[ ! -L $test_home/.local/bin/npm ]] || fail "an npm symlink into ~/.hermes is removed" +[[ -L $test_home/.local/bin/npx ]] || fail "an npx symlink pointing elsewhere survives" +pass "removal clears only the managed Node links it stranded" + +[[ -f $test_home/.hermes/sessions/one.json ]] || fail "chats survive removal" +[[ -f $test_home/.hermes/memories/one.md ]] || fail "memories survive removal" +[[ -f $test_home/.hermes/SOUL.md ]] || fail "SOUL.md survives removal" +pass "removal keeps what belongs to the user" + +[[ ! -e $test_home/.local/bin/hermes ]] || fail "the app's own hermes command is removed" +pass "removal takes the command the app installed" + +# Installed but never launched: the app never wrote these, so they are somebody +# else's and must survive. +seed_install +printf '%s\n' "#!/bin/bash" "exec /usr/local/bin/my-own-hermes \"\$@\"" \ + >"$test_home/.local/bin/hermes" +remove || fail "remove succeeds with a foreign hermes present" +[[ -f $test_home/.local/bin/hermes ]] || + fail "a hermes command the app did not write survives removal" +pass "removal leaves a hermes it does not own" diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index b3394678..eb5b3122 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -224,6 +224,7 @@ const expectedAgents = { claude: { icon: '󰛄', label: 'Claude' }, codex: { icon: '\ue905', iconFont: 'omarchy', label: 'Codex' }, grok: { icon: '\ue904', iconFont: 'omarchy', label: 'Grok' }, + hermes: { icon: '\ue90a', iconFont: 'omarchy', label: 'Hermes' }, copilot: { icon: '', label: 'Copilot' }, crush: { icon: '󰋑', label: 'Crush' }, } @@ -244,7 +245,7 @@ assertDeepEqual( defaultItems .filter(item => item.parent === 'setup.default.agent') .map(item => item.label), - ['Antigravity', 'Claude', 'Codex', 'Copilot', 'Crush', 'Grok', 'omp', 'OpenCode', 'Ori', 'Pi'], + ['Antigravity', 'Claude', 'Codex', 'Copilot', 'Crush', 'Grok', 'Hermes', 'omp', 'OpenCode', 'Ori', 'Pi'], 'menu sorts coding agents alphabetically' ) const expectedDefaults = { @@ -636,5 +637,5 @@ assert( JS font_charset=$(fc-query --format='%{charset}' "$ROOT/default/fonts/omarchy/omarchy.ttf") -[[ $font_charset == *"e900-e909"* ]] || fail "Omarchy icon font includes every custom menu glyph" +[[ $font_charset == *"e900-e90a"* ]] || fail "Omarchy icon font includes every custom menu glyph" pass "Omarchy icon font includes the official agent marks" From d56c1ba972b6a9b82c37aa3b5c33955ea2d3e41a Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 27 Aug 2026 01:09:45 -0500 Subject: [PATCH 05/76] Harden Hermes wrapper ownership --- bin/omarchy-install-hermes-cli | 46 +++++++++- bin/omarchy-remove-preinstalls | 9 +- migrations/1787760281.sh | 20 +++++ test/shell.d/hermes-cli-migration-test.sh | 103 ++++++++++++++++++++++ test/shell.d/hermes-cli-test.sh | 86 +++++++++++++++++- test/shell.d/preinstalls-test.sh | 40 +++++++++ 6 files changed, 295 insertions(+), 9 deletions(-) create mode 100644 migrations/1787760281.sh create mode 100755 test/shell.d/hermes-cli-migration-test.sh diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index c153013b..7922e386 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -30,6 +30,10 @@ mode=${1:-} tool='pipx:hermes-agent[extras=all]' python='3.13' +# The line that identifies the stub as this installer's; matched whole, so a +# wrapper that merely mentions the command is not mistaken for ours. +marker='# Written by omarchy-install-hermes-cli.' + # The package, not the runtime directory: it is installed before the app has # ever run, and that is exactly when we must not start building a second copy. desktop_owns_hermes() { @@ -55,12 +59,38 @@ installed() { [[ -d "$(mise where "$tool" 2>/dev/null)/hermes-agent/lib/python$python" ]] } +# The stub is the only thing this installer owns. Anything else at that path +# -- Hermes' official installer, a hand-rolled wrapper, even a dangling link +# -- was put there by the user and is never deleted or overwritten here. +# Symlinks count as foreign even when they resolve to a marked file: the stub +# is written as a regular file, so a link is someone else's arrangement. +ours() { + [[ -f $HOME/.local/bin/hermes && ! -L $HOME/.local/bin/hermes ]] && + grep -qxF "$marker" "$HOME/.local/bin/hermes" +} + +foreign_hermes() { + [[ -e $HOME/.local/bin/hermes || -L $HOME/.local/bin/hermes ]] && ! ours +} + +# A foreign path is usable when it is a command: a regular file that runs. +# A directory passes -x on search permission alone, and is no more a command +# than a dangling link is. +foreign_hermes_runs() { + [[ -f $HOME/.local/bin/hermes && -x $HOME/.local/bin/hermes ]] +} + # --check lets callers tell a cold stub from a working one before they commit # to a path that assumes Hermes is ready. 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. + if foreign_hermes; then + if foreign_hermes_runs; then exit 0; else exit 1; fi + fi if installed; then exit 0; else exit 1; fi fi @@ -79,7 +109,7 @@ if desktop_owns_hermes; then # Our own stub has to go with it. Left in place it still answers `hermes` # until the app's bootstrap overwrites it, and answering means building the # second Hermes this whole arrangement exists to avoid. - if [[ -f $HOME/.local/bin/hermes ]] && grep -q omarchy-install-hermes-cli "$HOME/.local/bin/hermes"; then + if ours; then rm -f "$HOME/.local/bin/hermes" fi @@ -92,13 +122,25 @@ if desktop_owns_hermes; then exit 1 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 foreign_hermes_runs; then + exit 0 + 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 + mkdir -p "$HOME/.local/bin" rm -f "$HOME/.local/bin/hermes" cat >"$HOME/.local/bin/hermes" <"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]] +SH + +cat >"$mock_bin/omarchy-cmd-missing" <<'SH' +#!/bin/bash +! command -v "$1" >/dev/null 2>&1 +SH + +cat >"$mock_bin/mise" <<'SH' +#!/bin/bash +[[ $1 != "where" ]] +SH + +chmod +x "$mock_bin"/* + +# The real installer is on PATH so the migration writes today's stub, not a +# copy of it. +run_migration() { + OMARCHY_TEST_DESKTOP_INSTALLED="${1:-0}" \ + HOME="$test_home" \ + PATH="$mock_bin:$ROOT/bin:$PATH" \ + bash -euo pipefail "$migration" >/dev/null 2>&1 +} + +run_migration || fail "the migration installs the wrapper on a plain install" +[[ -x $hermes ]] && grep -qxF "$marker" "$hermes" || fail "the migration writes the Omarchy wrapper" +pass "the migration installs the Hermes wrapper" + +before=$(cat "$hermes") +run_migration || fail "rerunning the migration succeeds" +[[ $(cat "$hermes") == "$before" ]] || fail "rerunning the migration leaves the same wrapper" +pass "the migration is idempotent" + +chmod -x "$hermes" +run_migration || fail "the migration repairs a non-executable Omarchy wrapper" +[[ -x $hermes ]] && grep -qxF "$marker" "$hermes" || + fail "the migration restores a non-executable Omarchy wrapper" +pass "the migration repairs a non-executable Omarchy wrapper" + +rm -f "$hermes" +touch "$test_home/.local/state/omarchy/preinstalls-removed" +run_migration || fail "the migration succeeds for users who removed the preinstalls" +[[ ! -e $hermes ]] || fail "the migration respects the preinstalls opt-out" +pass "the migration skips users who removed the preinstalls" +rm -f "$test_home/.local/state/omarchy/preinstalls-removed" + +run_migration 1 || fail "the migration succeeds when Hermes Desktop owns Hermes" +[[ ! -e $hermes ]] || fail "the migration writes nothing when Hermes Desktop owns Hermes" +pass "the migration stands aside for Hermes Desktop" + +official_body="#!/bin/bash +unset PYTHONPATH +unset PYTHONHOME +exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" +printf '%s\n' "$official_body" >"$hermes" +chmod +x "$hermes" +run_migration || fail "the migration succeeds over a foreign hermes command" +[[ $(cat "$hermes") == "$official_body" ]] || fail "the migration leaves a foreign hermes command alone" +pass "the migration preserves a foreign hermes command" + +chmod -x "$hermes" +run_migration || fail "the migration succeeds over a non-executable foreign hermes" +[[ -f $hermes && ! -x $hermes && $(cat "$hermes") == "$official_body" ]] || + fail "the migration leaves a non-executable foreign hermes alone" +pass "the migration preserves a non-executable foreign hermes" + +rm -f "$hermes" +ln -s "$test_home/nowhere/hermes" "$hermes" +run_migration || fail "the migration succeeds over a dangling hermes link" +[[ -L $hermes && $(readlink "$hermes") == "$test_home/nowhere/hermes" ]] || + fail "the migration leaves a dangling hermes link alone" +pass "the migration preserves a dangling hermes link" + +rm -f "$hermes" +mkdir "$hermes" +run_migration || fail "the migration succeeds over a directory at the hermes path" +[[ -d $hermes ]] || fail "the migration leaves a directory at the hermes path alone" +pass "the migration preserves a directory at the hermes path" + +rmdir "$hermes" +printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." >"$hermes" +chmod +x "$hermes" +run_migration || fail "the migration succeeds over a wrapper that mentions the installer" +grep -qxF "$marker" "$hermes" && fail "the migration does not rewrite a wrapper that merely mentions the installer" +pass "the migration preserves a wrapper that merely mentions the installer" diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 881dac47..d73e621f 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -41,7 +41,7 @@ run_installer() { bash "$ROOT/bin/omarchy-install-hermes-cli" ${2:+"$2"} >/dev/null 2>&1 } -stub_marker="omarchy-install-hermes-cli" +stub_marker="# Written by omarchy-install-hermes-cli." app_stub_body='#!/bin/bash exec /home/x/.hermes/hermes-agent/venv/bin/hermes "$@"' @@ -51,14 +51,14 @@ exec /home/x/.hermes/hermes-agent/venv/bin/hermes "$@"' rm -f "$test_home/.local/bin/hermes" run_installer 0 || fail "installer failed with no desktop installed" [[ -x $test_home/.local/bin/hermes ]] || fail "installer writes a hermes stub when the desktop is absent" -grep -q "$stub_marker" "$test_home/.local/bin/hermes" || fail "the stub records which command wrote it" +grep -qxF "$stub_marker" "$test_home/.local/bin/hermes" || fail "the stub records which command wrote it" tr '\0' ' ' <"$mise_log" | grep -q "use -g --quiet uv" && fail "writing the stub does not install uv" pass "writing the Hermes stub provisions nothing" # The desktop app owns Hermes, so our own stub must go rather than sit there # answering `hermes` until the app's bootstrap replaces it. -printf '%s\n' "#!/bin/bash" "# $stub_marker" >"$test_home/.local/bin/hermes" +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$test_home/.local/bin/hermes" chmod +x "$test_home/.local/bin/hermes" run_installer 1 || true [[ ! -e $test_home/.local/bin/hermes ]] || @@ -74,7 +74,7 @@ run_installer 1 || true pass "the app's own hermes command is left alone" # A copy mise cannot vouch for is still a second Hermes. -printf '%s\n' "#!/bin/bash" "# $stub_marker" >"$test_home/.local/bin/hermes" +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$test_home/.local/bin/hermes" chmod +x "$test_home/.local/bin/hermes" : >"$mise_log" OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 1 || true @@ -103,3 +103,81 @@ printf '%s\n' "#!/bin/bash" "exec /usr/local/bin/somebody-elses-hermes \"\$@\"" chmod +x "$test_home/.local/bin/hermes" run_installer 1 --check && fail "--check rejects a hermes command belonging to something else" pass "--check rejects a foreign hermes command" + +# A hermes the user installed themselves -- the official installer, a wrapper of +# their own -- is not ours to replace. --check follows whether it runs, and +# installing steps aside so the default agent uses it. +official_body="#!/bin/bash +unset PYTHONPATH +unset PYTHONHOME +exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" +printf '%s\n' "$official_body" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 0 --check || fail "--check accepts a working foreign hermes command" +run_installer 0 || fail "installing over a foreign hermes command returns success" +run_installer 0 --now || fail "--now over a foreign hermes command returns success" +[[ $(cat "$test_home/.local/bin/hermes") == "$official_body" ]] || + fail "a foreign hermes command is left untouched" +pass "a foreign hermes command is preserved and satisfies --check" + +# 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" +chmod -x "$test_home/.local/bin/hermes" +run_installer 0 --check && fail "--check rejects a non-executable foreign hermes" +run_installer 0 && fail "the installer does not succeed over a non-executable foreign hermes" +[[ -f $test_home/.local/bin/hermes && ! -x $test_home/.local/bin/hermes ]] || + fail "a non-executable foreign hermes is left untouched" +pass "a non-executable foreign hermes is preserved" + +foreign_target="$test_home/foreign/hermes" +mkdir -p "$(dirname "$foreign_target")" +printf '%s\n' "$official_body" >"$foreign_target" +chmod +x "$foreign_target" +rm -f "$test_home/.local/bin/hermes" +ln -s "$foreign_target" "$test_home/.local/bin/hermes" +run_installer 0 --check || fail "--check accepts a foreign link to a working hermes command" +run_installer 0 || fail "the installer succeeds over a foreign link to a working hermes command" +run_installer 0 --now || fail "--now succeeds over a foreign link to a working hermes command" +[[ -L $test_home/.local/bin/hermes && $(readlink "$test_home/.local/bin/hermes") == "$foreign_target" ]] || + fail "a foreign link to a working hermes command is left untouched" +pass "a foreign link to a working hermes command is preserved" + +rm -f "$test_home/.local/bin/hermes" +ln -s "$test_home/nowhere/hermes" "$test_home/.local/bin/hermes" +run_installer 0 --check && fail "--check rejects a dangling hermes link" +run_installer 0 && fail "the installer does not succeed over a dangling hermes link" +[[ -L $test_home/.local/bin/hermes && $(readlink "$test_home/.local/bin/hermes") == "$test_home/nowhere/hermes" ]] || + fail "a dangling hermes link is left untouched" +pass "a dangling hermes link is preserved" + +# A directory passes -x on search permission alone. It is still not a command. +rm -f "$test_home/.local/bin/hermes" +mkdir "$test_home/.local/bin/hermes" +run_installer 0 --check && fail "--check rejects a directory at the hermes path" +run_installer 0 && fail "the installer does not succeed over a directory at the hermes path" +[[ -d $test_home/.local/bin/hermes ]] || fail "a directory at the hermes path is left untouched" +pass "a directory at the hermes path is preserved and rejected" + +# Mentioning the installer is not the same as being written by it. +rmdir "$test_home/.local/bin/hermes" +mentions_body='#!/bin/bash +# Replaces the stub omarchy-install-hermes-cli used to write. +exec /usr/local/bin/hermes "$@"' +printf '%s\n' "$mentions_body" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 0 || fail "installing over a wrapper that mentions the installer returns success" +[[ $(cat "$test_home/.local/bin/hermes") == "$mentions_body" ]] || + fail "a wrapper that merely mentions the installer is left untouched" +pass "ownership needs the exact marker line, not a mention" + +# Our own stub is ours to rewrite, so reinstalling refreshes it to the current +# template. +rm -f "$test_home/.local/bin/hermes" +printf '%s\n' "#!/bin/bash" "$stub_marker" "# stale template" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 0 || fail "reinstalling over our own stub succeeds" +grep -qxF "$stub_marker" "$test_home/.local/bin/hermes" || fail "the refreshed stub still carries the marker" +grep -q "stale template" "$test_home/.local/bin/hermes" && fail "reinstalling rewrites our own stub" +grep -q "exec mise x" "$test_home/.local/bin/hermes" || fail "the refreshed stub is the current template" +pass "reinstalling refreshes the Omarchy stub" diff --git a/test/shell.d/preinstalls-test.sh b/test/shell.d/preinstalls-test.sh index fb4f19a2..7aba8085 100755 --- a/test/shell.d/preinstalls-test.sh +++ b/test/shell.d/preinstalls-test.sh @@ -89,3 +89,43 @@ pass "declining Remove Preinstalls changes nothing" "$ROOT/bin/omarchy-remove-preinstalls" >/dev/null [[ -f $marker ]] || fail "Remove Preinstalls records the opt-out" pass "Remove Preinstalls records the opt-out" + +# Hermes' wrapper is only a preinstall when omarchy-install-hermes-cli wrote it. +# The desktop app's command and an official install live at the same path and +# are the user's, whether or not any package says so. +hermes="$test_home/.local/bin/hermes" +mkdir -p "$(dirname "$hermes")" + +printf '%s\n' "#!/bin/bash" "# Written by omarchy-install-hermes-cli." >"$hermes" +chmod +x "$hermes" +"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null +[[ ! -e $hermes ]] || fail "Remove Preinstalls deletes the Omarchy Hermes wrapper" +pass "Remove Preinstalls deletes the Omarchy Hermes wrapper" + +printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" >"$hermes" +chmod +x "$hermes" +"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null +[[ -x $hermes ]] || fail "Remove Preinstalls keeps the desktop app's Hermes command" +pass "Remove Preinstalls keeps the desktop app's Hermes command" + +official_body="#!/bin/bash +unset PYTHONPATH +unset PYTHONHOME +exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" +printf '%s\n' "$official_body" >"$hermes" +chmod +x "$hermes" +"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null +[[ -x $hermes && $(cat "$hermes") == "$official_body" ]] || fail "Remove Preinstalls keeps an official Hermes install" +pass "Remove Preinstalls keeps an official Hermes install" + +printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." >"$hermes" +chmod +x "$hermes" +"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null +[[ -x $hermes ]] || fail "Remove Preinstalls keeps a wrapper that merely mentions the installer" +pass "Remove Preinstalls keeps a wrapper that merely mentions the installer" + +rm -f "$hermes" +ln -s "$test_home/nowhere/hermes" "$hermes" +"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null +[[ -L $hermes ]] || fail "Remove Preinstalls keeps a foreign hermes link" +pass "Remove Preinstalls keeps a foreign hermes link" From 5909210cb3ce0330f546fe0f54115efa2e69c11c Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 27 Aug 2026 01:36:26 -0500 Subject: [PATCH 06/76] Address Hermes review feedback --- bin/omarchy-install-hermes-cli | 11 +++++--- migrations/1787760281.sh | 10 +++++-- test/shell.d/hermes-cli-migration-test.sh | 30 ++++++++++++++++++++ test/shell.d/hermes-cli-test.sh | 34 +++++++++++++++++++++-- 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index 7922e386..a69d33f0 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -73,11 +73,14 @@ foreign_hermes() { [[ -e $HOME/.local/bin/hermes || -L $HOME/.local/bin/hermes ]] && ! ours } -# A foreign path is usable when it is a command: a regular file that runs. -# A directory passes -x on search permission alone, and is no more a command -# than a dangling link is. +# A foreign path is usable when it is a command that runs: a regular executable +# whose --version answers. The executable bit alone proves little -- a directory +# passes -x on search permission, and a wrapper whose interpreter or target is +# gone passes it too. The desktop app applies the same probe with the same 15 +# second budget, so what passes here is what it will use. foreign_hermes_runs() { - [[ -f $HOME/.local/bin/hermes && -x $HOME/.local/bin/hermes ]] + [[ -f $HOME/.local/bin/hermes && -x $HOME/.local/bin/hermes ]] && + timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1 } # --check lets callers tell a cold stub from a working one before they commit diff --git a/migrations/1787760281.sh b/migrations/1787760281.sh index ea0b1d68..1c47421c 100644 --- a/migrations/1787760281.sh +++ b/migrations/1787760281.sh @@ -4,8 +4,14 @@ echo "Install the Hermes CLI wrapper for existing installs" # is one of them. [[ -f $HOME/.local/state/omarchy/preinstalls-removed ]] && exit 0 -# Hermes Desktop provides its own Hermes; the installer would only stand aside. -omarchy-pkg-present hermes-desktop && exit 0 +# Hermes Desktop provides its own Hermes. The installer stands aside for it, +# removing the mise copy and the Omarchy wrapper an earlier install may have +# left beside the app. It also reports when the app has not finished setting +# Hermes up, which is the app's to finish, not this migration's to fail on. +if omarchy-pkg-present hermes-desktop; then + omarchy-install-hermes-cli || true + exit 0 +fi # Anything already answering to hermes that this installer did not write -- # an official install, a hand-rolled wrapper, even a dangling link -- belongs to diff --git a/test/shell.d/hermes-cli-migration-test.sh b/test/shell.d/hermes-cli-migration-test.sh index cfbc952c..bd18f6fb 100755 --- a/test/shell.d/hermes-cli-migration-test.sh +++ b/test/shell.d/hermes-cli-migration-test.sh @@ -24,8 +24,10 @@ cat >"$mock_bin/omarchy-cmd-missing" <<'SH' ! command -v "$1" >/dev/null 2>&1 SH +mise_log="$test_tmp/mise-log" cat >"$mock_bin/mise" <<'SH' #!/bin/bash +printf '%s\0' "$@" >>"$OMARCHY_TEST_MISE_LOG" [[ $1 != "where" ]] SH @@ -35,6 +37,7 @@ chmod +x "$mock_bin"/* # copy of it. run_migration() { OMARCHY_TEST_DESKTOP_INSTALLED="${1:-0}" \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ HOME="$test_home" \ PATH="$mock_bin:$ROOT/bin:$PATH" \ bash -euo pipefail "$migration" >/dev/null 2>&1 @@ -66,6 +69,33 @@ run_migration 1 || fail "the migration succeeds when Hermes Desktop owns Hermes" [[ ! -e $hermes ]] || fail "the migration writes nothing when Hermes Desktop owns Hermes" pass "the migration stands aside for Hermes Desktop" +# Standing aside is not the same as leaving a second Hermes behind: the wrapper +# an earlier install wrote and the mise copy it points at both go when the +# desktop app owns Hermes, even though the app has not finished setting up. +printf '%s\n' "#!/bin/bash" "$marker" >"$hermes" +chmod +x "$hermes" +: >"$mise_log" +run_migration 1 || fail "the migration succeeds when Hermes Desktop owns Hermes and the old wrapper is present" +[[ ! -e $hermes ]] || fail "the migration removes the Omarchy wrapper when Hermes Desktop owns Hermes" +mise_calls=$(tr '\0' ' ' <"$mise_log") +[[ $mise_calls == *"rm -g "* ]] || fail "the migration removes the global mise Hermes for Hermes Desktop" +[[ $mise_calls == *"uninstall --all "* ]] || fail "the migration uninstalls the mise Hermes for Hermes Desktop" +pass "the migration clears the old Omarchy Hermes for Hermes Desktop" + +# ...while anyone else's hermes stays exactly where it is, and is not run. +foreign_ran="$test_tmp/foreign-ran" +foreign_body="#!/bin/bash +touch $foreign_ran +exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" +printf '%s\n' "$foreign_body" >"$hermes" +chmod +x "$hermes" +run_migration 1 || fail "the migration succeeds over a foreign hermes when Hermes Desktop owns Hermes" +[[ -x $hermes && $(cat "$hermes") == "$foreign_body" ]] || + fail "the migration leaves a foreign hermes alone when Hermes Desktop owns Hermes" +[[ ! -e $foreign_ran ]] || fail "the migration does not run a foreign hermes" +pass "the migration preserves a foreign hermes for Hermes Desktop" +rm -f "$hermes" + official_body="#!/bin/bash unset PYTHONPATH unset PYTHONHOME diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index d73e621f..523aa871 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -87,8 +87,10 @@ 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. mkdir -p "$test_home/.hermes/hermes-agent/venv/bin" -printf '%s\n' "#!/bin/bash" >"$test_home/.hermes/hermes-agent/venv/bin/hermes" +printf '%s\n' "#!/bin/bash" 'echo "hermes-agent 0.0.0-test"' >"$test_home/.hermes/hermes-agent/venv/bin/hermes" 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" @@ -130,6 +132,32 @@ run_installer 0 && fail "the installer does not succeed over a non-executable fo fail "a non-executable foreign hermes is left untouched" pass "a non-executable foreign hermes is preserved" +# The executable bit is not enough: a wrapper whose interpreter is gone passes +# -x and still cannot run. The probe has to run it to find out, and finding +# out never touches the file. +broken_interp_body="#!$test_home/nowhere/python3 +print('hermes')" +printf '%s\n' "$broken_interp_body" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 0 --check && fail "--check rejects a foreign hermes whose interpreter is missing" +run_installer 0 && fail "the installer does not succeed over a foreign hermes whose interpreter is missing" +run_installer 0 --now && fail "--now does not succeed over a foreign hermes whose interpreter is missing" +[[ -x $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$broken_interp_body" ]] || + fail "a foreign hermes whose interpreter is missing is left untouched" +pass "a foreign hermes with a missing interpreter is preserved and rejected" + +# Likewise a wrapper that execs a target that is no longer there. +broken_target_body="#!/bin/bash +exec $test_home/nowhere/hermes \"\$@\"" +printf '%s\n' "$broken_target_body" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 0 --check && fail "--check rejects a foreign hermes whose target is missing" +run_installer 0 && fail "the installer does not succeed over a foreign hermes whose target is missing" +run_installer 0 --now && fail "--now does not succeed over a foreign hermes whose target is missing" +[[ -x $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$broken_target_body" ]] || + fail "a foreign hermes whose target is missing is left untouched" +pass "a foreign hermes with a missing target is preserved and rejected" + foreign_target="$test_home/foreign/hermes" mkdir -p "$(dirname "$foreign_target")" printf '%s\n' "$official_body" >"$foreign_target" @@ -161,9 +189,9 @@ pass "a directory at the hermes path is preserved and rejected" # Mentioning the installer is not the same as being written by it. rmdir "$test_home/.local/bin/hermes" -mentions_body='#!/bin/bash +mentions_body="#!/bin/bash # Replaces the stub omarchy-install-hermes-cli used to write. -exec /usr/local/bin/hermes "$@"' +exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" printf '%s\n' "$mentions_body" >"$test_home/.local/bin/hermes" chmod +x "$test_home/.local/bin/hermes" run_installer 0 || fail "installing over a wrapper that mentions the installer returns success" From 43d2fffaf099e1b921d768256676802fd990c565 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 10:47:53 +0200 Subject: [PATCH 07/76] Keep user setup running when Hermes cannot install install/user/mise.sh is sourced through run_logged under `bash -eE`, and its status reaches omarchy-provision-user's `set -euo pipefail`. Every other line in the file writes a mise stub and cannot fail; omarchy-install-hermes-cli can, and does whenever hermes-desktop is installed but the app has not been launched yet -- what a second user on a shared machine meets on their first login. The rest of provisioning runs after that source: refreshing applications, the default browser, the mailto handler, the first-install migration markers and the finalize-user marker. Without the marker the whole step retries and fails again at every login, and omarchy-provision-first-run calls it with `|| true`, so nothing surfaces. omarchy-install-ai-hermes and the migration already guard this call the same way. Co-Authored-By: Claude Opus 5 (1M context) --- install/user/mise.sh | 7 ++++++- test/shell.d/hermes-cli-test.sh | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/install/user/mise.sh b/install/user/mise.sh index a944d6cb..4baeffce 100644 --- a/install/user/mise.sh +++ b/install/user/mise.sh @@ -13,4 +13,9 @@ omarchy-mise-install npm:@kitlangton/ghui ghui omarchy-mise-install aqua:modem-dev/hunk hunk omarchy-mise-install github:basecamp/hey-cli hey omarchy-mise-install github:OpenRouterLabs/ori-releases ori -omarchy-install-hermes-cli +# Every line above writes a stub and cannot fail. This one can: it exits +# non-zero when Hermes Desktop owns Hermes but has not finished setting it up, +# and this leaf is sourced under `bash -eE`, so that would abort the rest of +# omarchy-provision-user -- the default browser, the mailto handler and the +# finalize-user marker all come after it. +omarchy-install-hermes-cli || true diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 523aa871..8f247184 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -209,3 +209,38 @@ grep -qxF "$stub_marker" "$test_home/.local/bin/hermes" || fail "the refreshed s grep -q "stale template" "$test_home/.local/bin/hermes" && fail "reinstalling rewrites our own stub" grep -q "exec mise x" "$test_home/.local/bin/hermes" || fail "the refreshed stub is the current template" pass "reinstalling refreshes the Omarchy stub" + +# 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 +# -- the default browser, the mailto handler, the first-install migration +# markers, the finalize-user marker -- runs after that source, so this leaf +# returning non-zero costs the user all of it. The Hermes installer is the only +# line in it that can fail, and it does exactly that whenever hermes-desktop is +# installed but the app has not been launched yet: the case a second user on a +# shared machine hits on their first login. +mise_sh_home="$test_tmp/mise-sh-home" +mkdir -p "$mise_sh_home/.local/bin" + +cat >"$mock_bin/omarchy-mise-install" <<'SH' +#!/bin/bash +exit 0 +SH +chmod +x "$mock_bin/omarchy-mise-install" + +# Desktop installed, nothing bootstrapped: omarchy-install-hermes-cli exits 1. +OMARCHY_TEST_DESKTOP_INSTALLED=1 \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$mise_sh_home" \ + PATH="$mock_bin:$ROOT/bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" >/dev/null 2>&1 && + fail "the Hermes installer exits non-zero when the desktop app has not set Hermes up" + +# Sourced exactly as run_logged does it. +OMARCHY_TEST_DESKTOP_INSTALLED=1 \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$mise_sh_home" \ + PATH="$mock_bin:$ROOT/bin:$PATH" \ + bash -eE -c 'source "$1"' bash "$ROOT/install/user/mise.sh" >/dev/null 2>&1 || + fail "user setup survives a Hermes install that cannot finish" +pass "user setup survives a Hermes install that cannot finish" From 2f918a75ada3709373c5d7b53e8cf4445b2ec789 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 10:48:06 +0200 Subject: [PATCH 08/76] Stop the Hermes interpreter pin following the agent into the user's projects The stub exports UV_PYTHON so mise builds Hermes against 3.13, which Hermes requires and Arch's Python is past. Exported, it survived the exec into Hermes itself and reached every command the agent shells out to. Hermes is a coding agent that runs commands in the user's own repositories, so a `uv venv` or `uv sync` there resolved 3.13 as well: on a project declaring requires-python >=3.14, uv warns that the interpreter contradicts it and builds the venv anyway. Dropping it at the handover keeps the pin over the install, where it belongs. mise x resolves the tool it already installed without it. Co-Authored-By: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh --- bin/omarchy-install-hermes-cli | 6 ++++- test/shell.d/hermes-cli-test.sh | 40 ++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index a69d33f0..a24f7aca 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -167,7 +167,11 @@ if ! [[ -d "\$(mise where '$tool' 2>/dev/null)/hermes-agent/lib/python$python" ] mise use -g --quiet --force '$tool' || exit 1 fi -exec mise x '$tool' -- hermes "\$@" +# The pin belongs to building Hermes, not to everything Hermes then runs. +# Exported it would reach the agent and every command it shells out to, so a +# uv in the user's own project would resolve 3.13 there too -- uv only warns +# when that contradicts the project's requires-python, and builds it anyway. +exec env -u UV_PYTHON mise x '$tool' -- hermes "\$@" EOF chmod +x "$HOME/.local/bin/hermes" diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 8f247184..bfe94b95 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -42,6 +42,7 @@ run_installer() { } stub_marker="# Written by omarchy-install-hermes-cli." +python_pin="3.13" app_stub_body='#!/bin/bash exec /home/x/.hermes/hermes-agent/venv/bin/hermes "$@"' @@ -207,7 +208,7 @@ chmod +x "$test_home/.local/bin/hermes" run_installer 0 || fail "reinstalling over our own stub succeeds" grep -qxF "$stub_marker" "$test_home/.local/bin/hermes" || fail "the refreshed stub still carries the marker" grep -q "stale template" "$test_home/.local/bin/hermes" && fail "reinstalling rewrites our own stub" -grep -q "exec mise x" "$test_home/.local/bin/hermes" || fail "the refreshed stub is the current template" +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" # install/user/mise.sh is sourced by install/user/all.sh through run_logged, @@ -244,3 +245,40 @@ OMARCHY_TEST_DESKTOP_INSTALLED=1 \ bash -eE -c 'source "$1"' bash "$ROOT/install/user/mise.sh" >/dev/null 2>&1 || fail "user setup survives a Hermes install that cannot finish" pass "user setup survives a Hermes install that cannot finish" + +# UV_PYTHON pins the interpreter Hermes is built against. Left in the +# environment it reaches Hermes itself and every command the agent shells out +# to, so a `uv` run in the user's own project resolves 3.13 there as well -- +# uv only warns that this contradicts the project's requires-python, then +# builds the venv anyway. The stub drops it before handing over. +leak_home="$test_tmp/leak-home" +leak_bin="$test_tmp/leak-bin" +leak_log="$test_tmp/leak-log" +leak_prefix="$test_tmp/leak-prefix" +mkdir -p "$leak_home/.local/bin" "$leak_bin" "$leak_prefix/hermes-agent/lib/python$python_pin" + +# A mise whose `where` satisfies the stub's probe, so the stub goes straight to +# handing over, and whose `x` records the UV_PYTHON it was handed. +cat >"$leak_bin/mise" <"$leak_log" ;; +esac +SH +chmod +x "$leak_bin/mise" + +OMARCHY_TEST_DESKTOP_INSTALLED=0 \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$leak_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" >/dev/null 2>&1 || + fail "the installer writes a stub for the leak check" + +HOME="$leak_home" PATH="$leak_bin:$mock_bin:$PATH" \ + "$leak_home/.local/bin/hermes" --version >/dev/null 2>&1 + +[[ -f $leak_log ]] || fail "the stub reaches the command it wraps" +[[ -z $(cat "$leak_log") ]] || + fail "the interpreter pin does not follow Hermes into the commands it runs" +pass "the interpreter pin does not follow Hermes into the commands it runs" From ba78e7df090ea04d25a39858cc940e4357bbdfe1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 11:44:39 +0200 Subject: [PATCH 09/76] Leave a Hermes the app never installed alone Remove > AI > Hermes deleted ~/.hermes/hermes-agent, bootstrap-cache, bin and node unconditionally, plus any wrapper on PATH pointing into ~/.hermes. The official Hermes installer uses those same paths, so a user who installed the CLI themselves, then installed the app and never launched it, lost their checkout, venv and any local changes -- while being told their chats, memories and skills were safe. The app provisions its runtime on first launch and writes .hermes-bootstrap-complete when it lands. Without that marker the app never got that far and everything under ~/.hermes predates it, so dropping the package is the whole job. Two smaller things in the same path. The wrapper test matched ~/.hermes as a pattern, and the dot made it claim a wrapper pointing at a sibling like ~/xhermes; it is a plain string now, and a symlink there is the user's arrangement rather than something to delete. And -u, so an unset HOME is an error instead of a set of rm -rf paths rooted at /. Co-Authored-By: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh --- bin/omarchy-remove-ai-hermes | 82 +++++++++++++++++------------- test/shell.d/hermes-remove-test.sh | 39 +++++++++++++- 2 files changed, 83 insertions(+), 38 deletions(-) diff --git a/bin/omarchy-remove-ai-hermes b/bin/omarchy-remove-ai-hermes index 830ca2ad..f67b1d6d 100755 --- a/bin/omarchy-remove-ai-hermes +++ b/bin/omarchy-remove-ai-hermes @@ -3,47 +3,57 @@ # omarchy:summary=Remove the Hermes desktop app along with the Hermes runtime it installed. # omarchy:requires-sudo=true -set -e +# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /. +set -euo pipefail omarchy-pkg-drop hermes-desktop -# The app installs a Hermes of its own under ~/.hermes -- the checkout and venv, -# its own uv, its own node -- and puts its commands on PATH. None of it is any -# use once the app is gone. Not ~/.config/Hermes, which holds the gateway -# connections and their encrypted tokens, the active profile and the update -# settings. Not the rest of ~/.hermes either: -# the chats, memories and the skills Hermes wrote for itself are the user's, -# they are small, and finding them still there after a reinstall is the better -# surprise. -rm -rf \ - "$HOME/.hermes/hermes-agent" \ - "$HOME/.hermes/bootstrap-cache" \ - "$HOME/.hermes/bin" \ - "$HOME/.hermes/node" +# The app writes this when the runtime it provisions under ~/.hermes has landed, +# and it is the only thing that tells that runtime apart from one the user +# installed themselves -- the paths are the same either way. Without it the app +# never got that far: a machine where it was installed but never launched still +# has whatever was there before, and none of it is ours to delete. +if [[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]]; then + # The checkout and venv, its own uv, its own node. None of it is any use once + # the app is gone. Not ~/.config/Hermes, which holds the gateway connections + # and their encrypted tokens, the active profile and the update settings. Not + # the rest of ~/.hermes either: the chats, memories and the skills Hermes + # wrote for itself are the user's, they are small, and finding them still + # there after a reinstall is the better surprise. + rm -rf \ + "$HOME/.hermes/hermes-agent" \ + "$HOME/.hermes/bootstrap-cache" \ + "$HOME/.hermes/bin" \ + "$HOME/.hermes/node" -# Only the wrappers pointing into ~/.hermes. The app writes these at its own -# path stage, so a machine where it was installed but never launched still has -# whatever was there before, and that is not ours to delete. -for command in hermes hermes-agent hermes-acp; do - wrapper="$HOME/.local/bin/$command" + # Only the wrappers pointing into ~/.hermes, matched as a plain string: the + # path carries a dot, so an unanchored pattern would also claim a wrapper + # pointing at a sibling like ~/xhermes. + for command in hermes hermes-agent hermes-acp; do + wrapper="$HOME/.local/bin/$command" - if [[ -f $wrapper ]] && grep -q "$HOME/.hermes" "$wrapper"; then - rm -f "$wrapper" - fi -done + if [[ -f $wrapper && ! -L $wrapper ]] && grep -qF "$HOME/.hermes" "$wrapper"; then + rm -f "$wrapper" + fi + done -# When Hermes brought its own Node it symlinked these next to its own commands, -# and they point at what we just deleted. Only the links into ~/.hermes: a -# system Node, or someone else's, lives somewhere else entirely. -for command in node npm npx; do - link="$HOME/.local/bin/$command" + # When Hermes brought its own Node it symlinked these next to its own commands, + # and they point at what we just deleted. Only the links into ~/.hermes: a + # system Node, or someone else's, lives somewhere else entirely. + for command in node npm npx; do + link="$HOME/.local/bin/$command" - if [[ -L $link && $(readlink "$link") == "$HOME/.hermes"/* ]]; then - rm -f "$link" - fi -done + if [[ -L $link && $(readlink "$link") == "$HOME/.hermes"/* ]]; then + rm -f "$link" + fi + done -echo "" -echo "Hermes Desktop has been removed." -echo "Your chats, memories, and skills are still in ~/.hermes," -echo "and your connections and settings in ~/.config/Hermes." + echo "" + echo "Hermes Desktop has been removed." + echo "Your chats, memories, and skills are still in ~/.hermes," + echo "and your connections and settings in ~/.config/Hermes." +else + echo "" + echo "Hermes Desktop has been removed." + echo "It never finished installing its own Hermes, so nothing in ~/.hermes was touched." +fi diff --git a/test/shell.d/hermes-remove-test.sh b/test/shell.d/hermes-remove-test.sh index 423a297e..bc80859f 100755 --- a/test/shell.d/hermes-remove-test.sh +++ b/test/shell.d/hermes-remove-test.sh @@ -31,6 +31,7 @@ seed_install() { ln -sf "$test_home/.hermes/node/bin/npm" "$test_home/.local/bin/npm" ln -sf /usr/bin/npx "$test_home/.local/bin/npx" printf 'node\n' >"$test_home/.hermes/node/bin/node" + touch "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete" } remove() { @@ -66,8 +67,8 @@ pass "removal keeps what belongs to the user" [[ ! -e $test_home/.local/bin/hermes ]] || fail "the app's own hermes command is removed" pass "removal takes the command the app installed" -# Installed but never launched: the app never wrote these, so they are somebody -# else's and must survive. +# A hermes command the app did not write survives even when the app did install +# a runtime of its own. seed_install printf '%s\n' "#!/bin/bash" "exec /usr/local/bin/my-own-hermes \"\$@\"" \ >"$test_home/.local/bin/hermes" @@ -75,3 +76,37 @@ remove || fail "remove succeeds with a foreign hermes present" [[ -f $test_home/.local/bin/hermes ]] || fail "a hermes command the app did not write survives removal" pass "removal leaves a hermes it does not own" + +# Installed but never launched. The app provisions its runtime on first launch +# and marks it complete when it lands, so without that marker everything under +# ~/.hermes predates the app -- an official install, or one built by hand -- and +# the paths are identical either way. Dropping the package is the whole job. +seed_install +rm -f "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete" +printf 'my local edit\n' >"$test_home/.hermes/hermes-agent/PATCH" +printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \ + >"$test_home/.local/bin/hermes" +remove || fail "remove succeeds when the app never finished installing Hermes" +[[ -d $test_home/.hermes/hermes-agent ]] || + fail "a Hermes runtime the app never installed survives removal" +[[ -f $test_home/.hermes/hermes-agent/PATCH ]] || + fail "local changes to a runtime the app never installed survive removal" +[[ -d $test_home/.hermes/bin && -d $test_home/.hermes/node ]] || + fail "the rest of a runtime the app never installed survives removal" +[[ -f $test_home/.local/bin/hermes ]] || + fail "the command a runtime the app never installed put on PATH survives removal" +[[ -L $test_home/.local/bin/node ]] || + fail "node links belonging to a runtime the app never installed survive removal" +pass "removal leaves a Hermes the app never installed" + +# ~/.hermes carries a dot, so a pattern rather than a plain string would also +# claim a wrapper pointing at a sibling directory that merely looks like it. +seed_install +mkdir -p "$test_home/xhermes/bin" +sibling_body="#!/bin/bash +exec $test_home/xhermes/bin/hermes \"\$@\"" +printf '%s\n' "$sibling_body" >"$test_home/.local/bin/hermes" +remove || fail "remove succeeds with a wrapper pointing at a sibling directory" +[[ -f $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$sibling_body" ]] || + fail "a wrapper pointing at ~/xhermes is not mistaken for one pointing into ~/.hermes" +pass "removal matches the runtime path as a plain string" From 12646eb5a13809d889795de859258874825c5c67 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 11:45:15 +0200 Subject: [PATCH 10/76] Run the app's Hermes before calling it ready desktop_hermes_ready decided from a marker file and a text match, while a hermes the user installed themselves had to answer --version before it counted. The marker says the app's install once landed, not that it is still there, so a runtime deleted afterwards left --check reporting success: the default agent records Hermes, skips the install terminal, and the launch fails. It now runs the command, on the same 15 second budget the app itself uses. The path match is a plain string for the same reason it is in the remover -- the dot in ~/.hermes would otherwise claim a wrapper pointing at ~/xhermes. foreign_hermes_runs never tested foreignness, only that the command runs, so it is hermes_runs now and both callers share it. Co-Authored-By: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh --- bin/omarchy-install-hermes-cli | 21 +++++++++++++-------- test/shell.d/hermes-cli-test.sh | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index a24f7aca..321ddac3 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -45,12 +45,17 @@ desktop_owns_hermes() { # marker is written last, and the command is what the agent actually runs. desktop_hermes_ready() { [[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]] || return 1 - [[ -x $HOME/.local/bin/hermes ]] || return 1 # An executable of that name proves nothing about whose it is; the app's own # points into ~/.hermes, and anything else is not the install we are asking - # about. - grep -q "$HOME/.hermes" "$HOME/.local/bin/hermes" + # about. Matched as a plain string, because the path carries a dot and an + # unanchored pattern would also claim a wrapper pointing at ~/xhermes. + [[ -f $HOME/.local/bin/hermes ]] || return 1 + grep -qF "$HOME/.hermes" "$HOME/.local/bin/hermes" || return 1 + + # 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 } # Whether Hermes is really installed, not merely whether the stub exists. A @@ -73,12 +78,12 @@ foreign_hermes() { [[ -e $HOME/.local/bin/hermes || -L $HOME/.local/bin/hermes ]] && ! ours } -# A foreign path is usable when it is a command that runs: a regular executable -# whose --version answers. The executable bit alone proves little -- a directory +# A hermes at that path is usable when it is a command that runs: a regular +# executable whose --version answers. The executable bit alone proves little -- a directory # passes -x on search permission, and a wrapper whose interpreter or target is # gone passes it too. The desktop app applies the same probe with the same 15 # second budget, so what passes here is what it will use. -foreign_hermes_runs() { +hermes_runs() { [[ -f $HOME/.local/bin/hermes && -x $HOME/.local/bin/hermes ]] && timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1 } @@ -92,7 +97,7 @@ if [[ $mode == "--check" ]]; then # 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. if foreign_hermes; then - if foreign_hermes_runs; then exit 0; else exit 1; fi + if hermes_runs; then exit 0; else exit 1; fi fi if installed; then exit 0; else exit 1; fi fi @@ -128,7 +133,7 @@ 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 foreign_hermes_runs; then + if hermes_runs; then exit 0 fi diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index bfe94b95..b701eb3c 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -282,3 +282,29 @@ HOME="$leak_home" PATH="$leak_bin:$mock_bin:$PATH" \ [[ -z $(cat "$leak_log") ]] || fail "the interpreter pin does not follow Hermes into the commands it runs" pass "the interpreter pin does not follow Hermes into the commands it runs" + +# The app's marker says its install once landed, not that it is still there. A +# wrapper whose runtime has since gone answers for nothing, so readiness runs +# the command, exactly as it does for a hermes the user installed themselves. +ready_home="$test_tmp/ready-home" +mkdir -p "$ready_home/.hermes/hermes-agent/venv/bin" "$ready_home/.local/bin" +touch "$ready_home/.hermes/hermes-agent/.hermes-bootstrap-complete" +printf '%s\n' "#!/bin/bash" "exec $ready_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \ + >"$ready_home/.local/bin/hermes" +chmod +x "$ready_home/.local/bin/hermes" + +run_ready_check() { + OMARCHY_TEST_DESKTOP_INSTALLED=1 \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$ready_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" --check >/dev/null 2>&1 +} + +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" +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" From cda02f0a8813b7c7325fe8450c82bf95bea653c2 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 11:45:23 +0200 Subject: [PATCH 11/76] Ask the installer who owns the Hermes wrapper Three files spelled out the line that marks ~/.local/bin/hermes as Omarchy's: the installer that writes it, Remove Preinstalls, and the migration. Two of them were copies, and a change to what ownership means would have left them matching a line nobody writes any more -- Remove Preinstalls quietly sweeping nothing, the migration mistaking Omarchy's own wrapper for a stranger's. omarchy-install-hermes-cli --owns answers it now, and the other two ask. The installer's own metadata was also a flag behind: --check has been there since this landed and was never listed. A test pins the marker to one file, so a second copy fails rather than drifts. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-install-hermes-cli | 9 ++++++- bin/omarchy-remove-preinstalls | 5 ++-- migrations/1787760281.sh | 9 +++---- test/shell.d/hermes-cli-test.sh | 46 ++++++++++++++++++++++++++++++++ test/shell.d/preinstalls-test.sh | 5 +++- 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index 321ddac3..0c2e526e 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Install the Hermes CLI as a mise-backed wrapper in ~/.local/bin -# omarchy:args=[--now] +# omarchy:args=[--check|--now|--owns] # omarchy:examples=omarchy install hermes cli | omarchy install hermes cli --now # Hermes pins every one of its dependencies exactly and declares @@ -88,6 +88,13 @@ hermes_runs() { timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1 } +# --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. +if [[ $mode == "--owns" ]]; then + if ours; then exit 0; else exit 1; fi +fi + # --check lets callers tell a cold stub from a working one before they commit # to a path that assumes Hermes is ready. if [[ $mode == "--check" ]]; then diff --git a/bin/omarchy-remove-preinstalls b/bin/omarchy-remove-preinstalls index 6faf2868..3dca7b22 100755 --- a/bin/omarchy-remove-preinstalls +++ b/bin/omarchy-remove-preinstalls @@ -19,8 +19,9 @@ if gum confirm "Are you sure you want to remove all preinstalled web apps, TUI w # Only the wrapper omarchy-install-hermes-cli wrote is a preinstall. Hermes # Desktop's command, an official install, or anything else at that path is - # the user's, so it is the marker that decides, not which packages are around. - if [[ -f ~/.local/bin/hermes && ! -L ~/.local/bin/hermes ]] && grep -qxF '# Written by omarchy-install-hermes-cli.' ~/.local/bin/hermes; then + # the user's, so it is the installer that decides whether the wrapper is its + # own, rather than a copy of its marker kept here. + if omarchy-install-hermes-cli --owns; then rm -f ~/.local/bin/hermes fi diff --git a/migrations/1787760281.sh b/migrations/1787760281.sh index 1c47421c..952fa9a2 100644 --- a/migrations/1787760281.sh +++ b/migrations/1787760281.sh @@ -15,12 +15,11 @@ fi # Anything already answering to hermes that this installer did not write -- # an official install, a hand-rolled wrapper, even a dangling link -- belongs to -# the user and stays exactly as it is. +# the user and stays exactly as it is. The installer is asked rather than +# matched against here, so there is one answer to who owns that wrapper. wrapper="$HOME/.local/bin/hermes" -if [[ -e $wrapper || -L $wrapper ]]; then - if [[ -L $wrapper || ! -f $wrapper ]] || ! grep -qxF '# Written by omarchy-install-hermes-cli.' "$wrapper"; then - exit 0 - fi +if [[ -e $wrapper || -L $wrapper ]] && ! omarchy-install-hermes-cli --owns; then + exit 0 fi omarchy-install-hermes-cli diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index b701eb3c..88319dc9 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -283,6 +283,52 @@ HOME="$leak_home" PATH="$leak_bin:$mock_bin:$PATH" \ fail "the interpreter pin does not follow Hermes into the commands it runs" pass "the interpreter pin does not follow Hermes into the commands it runs" +# --owns is the one answer to whether the wrapper on PATH is this installer's. +# Remove Preinstalls and the migration both ask it rather than carrying their +# own copy of the marker, so a change to what ownership means reaches them. +owns_home="$test_tmp/owns-home" +mkdir -p "$owns_home/.local/bin" + +run_owns() { + OMARCHY_TEST_DESKTOP_INSTALLED=0 \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$owns_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" --owns +} + +rm -f "$owns_home/.local/bin/hermes" +run_owns && fail "--owns says no when there is no wrapper at all" + +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$owns_home/.local/bin/hermes" +chmod +x "$owns_home/.local/bin/hermes" +run_owns || fail "--owns recognises the stub this installer wrote" + +printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." \ + >"$owns_home/.local/bin/hermes" +run_owns && fail "--owns needs the exact marker line, not a mention" + +# Quoting the marker inside a longer line is not the same as carrying it: the +# match is whole-line, so a wrapper describing what it replaced stays the +# user's. +printf '%s\n' "#!/bin/bash" "# Replaced '$stub_marker' with my own." \ + >"$owns_home/.local/bin/hermes" +run_owns && fail "--owns needs the marker to be the whole line, not part of one" + +rm -f "$owns_home/.local/bin/hermes" +ln -s "$test_home/.local/bin/hermes" "$owns_home/.local/bin/hermes" +run_owns && fail "--owns disclaims a symlink, whatever it resolves to" +rm -f "$owns_home/.local/bin/hermes" +pass "--owns answers for the wrapper this installer wrote and nothing else" + +# The marker lives in exactly one place. Every other caller asks --owns, so a +# second copy is drift waiting to happen. +marker_copies=$(grep -rl "Written by omarchy-install-hermes-cli" \ + "$ROOT/bin" "$ROOT/install" "$ROOT/migrations" 2>/dev/null | wc -l) +(( marker_copies == 1 )) || + fail "only omarchy-install-hermes-cli spells out the ownership marker" +pass "the ownership marker is written down once" + # The app's marker says its install once landed, not that it is still there. A # wrapper whose runtime has since gone answers for nothing, so readiness runs # the command, exactly as it does for a hermes the user installed themselves. diff --git a/test/shell.d/preinstalls-test.sh b/test/shell.d/preinstalls-test.sh index 7aba8085..0ca3e141 100755 --- a/test/shell.d/preinstalls-test.sh +++ b/test/shell.d/preinstalls-test.sh @@ -36,7 +36,10 @@ SH chmod +x "$mock_bin"/* -export PATH="$mock_bin:$PATH" +# $ROOT/bin after the mocks: Remove Preinstalls asks omarchy-install-hermes-cli +# whether the wrapper is Omarchy's rather than matching the marker itself, and +# that is the real command at runtime. The mocks still shadow what they name. +export PATH="$mock_bin:$ROOT/bin:$PATH" export HOME="$test_home" export OMARCHY_TEST_PKG_LOG="$pkg_log" From f70c55d81386a240702f73803b6bee63325f2852 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 11:45:33 +0200 Subject: [PATCH 12/76] Give Hermes the mark that reads at menu size U+E90A carried a trace of the Hermes app icon: a portrait whose detail collapses into a grey smudge beside the ten flat silhouettes the rest of the font is made of, which is what icon-font.md warns against when it says to pick a source whose silhouette alone reads. It is Font Awesome's staff-snake now, under CC BY 4.0 -- the mark Hermes serves as its favicon and titles its README with. The README records that, along with the licence the artwork carries, since it is the only note of where these come from. Co-Authored-By: Claude Opus 5 (1M context) --- default/fonts/omarchy/README.md | 2 +- default/fonts/omarchy/omarchy.ttf | Bin 9008 -> 5052 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/default/fonts/omarchy/README.md b/default/fonts/omarchy/README.md index 11558a30..03b8a909 100644 --- a/default/fonts/omarchy/README.md +++ b/default/fonts/omarchy/README.md @@ -12,7 +12,7 @@ The private-use glyphs in `omarchy.ttf` are: - `U+E907` — Ollama, from - `U+E908` — T3 Code, traced from the app icon in , since upstream publishes no monochrome SVG - `U+E909` — Ori, from , OpenRouter's own mark: Ori ships no separate logo and its product page uses this one -- `U+E90A` — Hermes, traced from the Hermes app icon (), the same art the `hermes-desktop` package ships as its icon +- `U+E90A` — Hermes, Font Awesome's staff-snake (CC BY 4.0) from , the mark Hermes serves as its favicon: their app icon is a portrait that reads as a smudge at menu size The agent marks are monochrome so the menu can render them using the active theme's foreground and selection colors. diff --git a/default/fonts/omarchy/omarchy.ttf b/default/fonts/omarchy/omarchy.ttf index 29145211c45d05ef88767dc30d750fbe84d238a2..1ddeb85ce255a899a563eb985e070a6f63f47258 100644 GIT binary patch delta 774 zcmZuvO=uHQ5T1Fv*T)qn++y`)fEEaN8hvwbC z0K8SwCyGQUDEbL665dyIs`IrV_|5kRj&c)-ccR&YNy!GhjnMD$~5=D?cv*_+nJQQGXdO)yFX3`hY5X>=9iEb@z^SL6+r~(DPPc z0Id~I18WSLkP=P`FJK0)(InXp>OozSCE1MIBgsU3)QXs<`F~oL1Ee&=R>X>1W`t5A znMm4O$ADiC+JsFm+Y%5JAB}Tujl1~+uC7y9mUs_K;B+PyWh@%Yq|}bgEL-hGWN7!DjL(3Yc+HPf7_vm`a80<3;jlMxuZ6Iozu4@|psjYPe zwRfSaa6{gFvom#L8-6;$I(oVwIdR7^E=dgyxfiIiu}kxE5ALP-8S1J=UaF4Wft|7R zhmePD8;#!!*|XZ=b=WBq1MubD>lvO`?=4T>{*;t_9EkPLY)_2@DT;>_&gPk89*di~ zM7ha3V0_y;17(Z5Cjq*d^0|fj6{s!l8_i;6p3i!ix0-A(I*ogP2+cN4c2n|-_AMT`OflJ zzgfL@$G7r&^tR`D7w`Y{?&LG?-F>I$u^Taev`gDm>;`z_R5p&F5UO09$Ul0=9kW1I{({${QbKaUWCwZo_yk|r~m!ze=d98hyZ=v zV|cRZ^o3u&{NcfKX(oRB^VbhvN|%A(>~wVO9Y46QRUBN$9zFO&y503&_AYt1H18kb zdC{vq@9NiBiyx$19vp4;-E;D<>QVJtynFQ}!PLPk_5+^qtKV_I6Iahgud@0$L~aq! zi(h)X-f8b)?} zwb4~smAf1dyl1*F(`mE0*q@QaYQ8$ZfC7!rd~dm^%4Vpi|343PW+h36=p3Z8>2yA) z5iN9A#;Vb%$a8$EvL>t7cD1g$A0cZj8NJlYoB4dEmt8o4y$0hRDI4o-uYitiJp~x- zRV7V}d@-B1#v?s#CU~qqN{>f$W~@QQbW-HYJs36`_6c&418M=SqpHkC`TnF?8mWAV z(Gv8L$!e5!t9<3*wUtJAAHrD|mK&@D5jYC^yE?m^l;d%$)u3$%n$N1T+=oQgt&QF; zmdmPy6~q9a@yvAh^>|F^i2rd3USWMag2DzJDat@XV>AP&EAx#Hd8XmpWI7=mIJaB? z+{*PLpU(=ck2Pq3VmMPS>&2J^KsQYZ@#>3l1xi-r75TIwg!BRG9LQiD6NGEo1h>-U zYU3B}QMjP2+Tm5%kiFQStW;Gc)(Z;CbVes?Oo|+XS+htOX}MfZh|y}ck5E-5Jz;LO zRaKZ|pa%z5`2;#}h>na$V+vAZgP6f7L=;iqt3_o+?gGQjWp>zq8^Mx7BXE+U-=Zj*6oye(*Zj+ft zlZU9s`K%^kV_B_((o4# zFfkrYCrBX;P4NGkE|d*~_ZP%J2~c>KlcV69lmh!Tt3V6Gc~t=lRUkM34yXfw70Ed& z6sqBTLto<;gefu;}MzI zgOe4l6Bo2if_P&{{*y_BX^IFg=S5R~2qhwqRDsc^S&)LNMDw9wA!E8loqxnD#do9y2kf5FdXvAz$ zkcW-?1r;OmZy!lrQFdkkM5PH&3dDX=>=lz`UP38?QdI;E;6VF6%uymxLQyLw_sDs& zLwF5ZJdKb6jgvV};yj{Jl^Brq`t-3p)rPU4E_`KTU$weD)lp2wdApNZ$CxVO*gC=f zD77|GfQK+iTZrmdw>ONe_VZYXA{lI0As8>B&PLFFo~MbDMhmSaG(=H<=$yZ^9<4iN z^s%43!R;W<6U(?1Ivt)2xUS1vEtkAC>UN^aFUrW*BKkdJEeTjIPE^ox$peasPP@0m z#N2j#c03j=>ndek)E1nb)y!mD+9ZNos|8~}z4vi>_sQLUs~4C!G6Cx!UN%fgW{gli zV@b?>#yLc#tfgd}G2h3Gr6~(JUX1Y%J>jQO2Nc;(d+2Wz9qs!nki8STkya`)Oq%U1 zx56l!I-_-9Wz@cPtAFRtP195A{QYkU703L7P-zf2k#GndzM*{wxv(vmA1FyCYc@Qh zRG^s7BgTCtWe_rvDh;?GMhNASC{)V#V_;Dd#7d!X3|oGp1$@?=JirGJA)OWAo-qNI z6W^EEh8^K+C72FyRBGkxfk2E}t-PqEUOOMPponP`2he&&MmQhHD=e_qG3T8QnAslWg1%>$HNP`csnI0B~g(B>vxsh;BvYNv! z=^_zZ)@rFpaUq0M+*p}N6>2Gwc$8sK302T8WoXEP2xZ#U{!p_eZIcy#_+>f@3~bN2 z2r(7z@EuSh^)_Q#H<1-wDyD?t!a#QoNg=)&xESI@q(%|I50kZ9&OsLg8l6E@Yp6qh zHOH`yXn-d&t)w5Pam$*vP69*(%Sb9CC2|aLDP$Y;bM}>^tLty~V(BV)NL?SGL79oZ8 zo1~zkr$S>>Ztxe7e4p|F1wwUFvoN`ge9%@K#8X-rqfNvWppc;|jI>Y|t2ROau>h{6 zq^!m7Z&VlBC^-gUto-@~(0s%=+e#5*VcS6`>zm=GHa)Ag3Ia3^9RzXQ=6AiNgKWJ{ zlZ0&@?}?4FN~}wZn6b#Vx{4Xi#ClRxajshJVl5uT))<=w1#;2$!`UER>lyLvv;MJu z61X4)$MGPm;KO9ojr3&HC7}LJ(#`E$IL->x<9gWgB|6sH^#K#THD>FLiVbvVS#RC2 zZbr;xLtiCErl*Emo0^+6YKh}teV)ykx^6TXed^}&^mMSJF5fNCt&|T$(?+01P&7P>mZk}h6Wjbo_2zLFAom-Zu%dj0L z9iZaj@h{%Dzq!_x?C{;gcU>n+04*x=Ua{^*%pYzH2LtQl)w%7ahmQw@-}ww}mckiZ z9--#ANVEmWTv1gaRE)<_b6}hdMuXJz=RDEKazXnkMZw z`}EWfh1pzS3_J(5pppU>v<hZ+ZoQ=*|K;SZHBwP3VbbXkYF zK+JF(LY54olJGLPmZU=oLnWpm$`6X6qFJ@?LxIP?;kvjmV+gvQ$lcPf$;wM!QwfCw|#3;!)MTb>?sxlb3zUD())9?Amavk8)o36Wc+;nZbr*af}O9n^B>0J)1a2zw-m; zsH;eZA0UoA#4SU1@Jp==5@X7c99(pa9|*Kd&imo8?Z&o63CzSe5H(}hTgoH)*@npB z$5Gae6ZB@hjp(GTugQdJQ(lubiU)vbx+%9q2h9!JAYBG+heiChF)mCdD17Zwk}s5Vz0ZJpuYe)Ho00AvWdGXMYp From fdb3755c7ddde199c93a16a5209a68da1803d30c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 11:45:33 +0200 Subject: [PATCH 13/76] Document Hermes in the manual The agent table lists every CLI Omarchy pre-wires, and Hermes was missing from it. Hermes Desktop earns a paragraph of its own under the graphical apps, because the one-Hermes-per-machine arrangement is something a user meets rather than reads about: the app installs its own runtime on first launch, the terminal command and the default agent then use that same one, and removing the app takes the runtime but keeps their chats, memories and skills. Co-Authored-By: Claude Opus 5 (1M context) --- manual/17-ai.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/manual/17-ai.md b/manual/17-ai.md index f5516b88..f6212146 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -14,6 +14,7 @@ Omarchy treats AI coding agents as first-class citizens, but it doesn't pick a f | `pi` | [Mario Zechner's Pi](https://github.com/badlogic/pi-mono) | | `omp` | [Oh My Pi](https://github.com/can1357/oh-my-pi) | | `ori` | [Ori](https://openrouter.ai/docs/guides/ori/harness), OpenRouter's harness | +| `hermes` | [Hermes](https://hermes-agent.nousresearch.com/), Nous Research's agent | `ori` is the odd one out: it runs the other harnesses against OpenRouter's whole model catalog, so `ori claude`, `ori codex`, or `ori opencode` start those agents on whichever model you point them at, and `ori code` is Ori's own agent. @@ -41,7 +42,9 @@ The watching is on by default. Turn it off under _Trigger > Toggle > Crash Captu ### Desktop apps -The _Install > AI_ menu also carries a couple of graphical AI apps: the ChatGPT desktop app, and Grok Bot for chatting with xAI's models. +The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT desktop app, Grok Bot for chatting with xAI's models, and Hermes Desktop. + +Hermes Desktop is the one to know about, because there is only ever one Hermes on a machine. The app only runs against a runtime built from its own commit, so it installs one of its own under `~/.hermes` on first launch, which takes a few minutes and shows its own progress. From then on that is the Hermes the terminal `hermes` command and the default agent use too, whichever order you installed them in. Removing the app under _Remove > AI_ takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself. ### Local LLMs From b609ae235578e648900f7fb9e8f26602c8fd557a Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 27 Aug 2026 21:31:17 -0500 Subject: [PATCH 14/76] Keep prompted Hermes sessions interactive Hermes oneshot deliberately exits after answering, which closes the agent terminal. Seed the TUI chat session instead, keep inherited flags after the subcommand for older Hermes parsers, and bind the query as one argument so dash-prefixed prompts remain data. Co-Authored-By: Codex XHigh --- bin/omarchy-agent | 5 ++--- test/shell.d/default-agent-test.sh | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-agent b/bin/omarchy-agent index 426ea85a..80e0f182 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -87,10 +87,9 @@ codex) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") ;; hermes) - # Hermes has no "start interactive, seeded with this prompt" mode. --oneshot - # answers the prompt and exits, which is the closest it offers. if [[ -n ${prompt:-} ]]; then - command=(hermes --yolo --oneshot "$prompt") + # Keep inherited flags after chat so older Hermes subparsers do not clear them. + command=(hermes chat --yolo --tui "--query=$prompt") else command=(hermes --yolo) fi diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index 5b4512f1..d46bbdb2 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -462,6 +462,7 @@ assert_launch claude claude --permission-mode auto -- "Review this project" assert_launch codex codex --approve-for-me -- "Review this project" assert_launch crush crush run "Review this project" assert_launch grok grok --permission-mode bypassPermissions -- "Review this project" +assert_launch hermes hermes chat --yolo --tui "--query=Review this project" assert_launch agy agy --dangerously-skip-permissions --prompt-interactive "Review this project" assert_launch copilot copilot --allow-all --interactive "Review this project" pass "agent launcher adapts initial prompts for every supported agent" @@ -474,6 +475,7 @@ assert_bypass claude claude --permission-mode auto assert_bypass codex codex --approve-for-me assert_bypass crush crush --yolo assert_bypass grok grok --permission-mode bypassPermissions +assert_bypass hermes hermes --yolo assert_bypass agy agy --dangerously-skip-permissions assert_bypass copilot copilot --allow-all pass "agent launcher skips permission prompts for every supported agent" From 750dde5ed2759816aee0b8bd27fce581fa6466c8 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 27 Aug 2026 22:37:03 -0500 Subject: [PATCH 15/76] Resume prompted Hermes sessions literally Hermes TUI startup queries execute slash, shell, interpolation, and multiline syntax before reaching the model. Run the prompt through literal one-shot mode, read its exact session ID from a private usage report, and resume that session in the TUI so arbitrary prompt text stays data while the conversation remains interactive. Co-Authored-By: Codex XHigh --- bin/omarchy-agent | 3 +- bin/omarchy-agent-hermes | 21 ++++++++ test/shell.d/default-agent-test.sh | 2 +- test/shell.d/hermes-agent-test.sh | 79 ++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 3 deletions(-) create mode 100755 bin/omarchy-agent-hermes create mode 100755 test/shell.d/hermes-agent-test.sh diff --git a/bin/omarchy-agent b/bin/omarchy-agent index 80e0f182..5436ca0b 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -88,8 +88,7 @@ codex) ;; hermes) if [[ -n ${prompt:-} ]]; then - # Keep inherited flags after chat so older Hermes subparsers do not clear them. - command=(hermes chat --yolo --tui "--query=$prompt") + command=(omarchy-agent-hermes "$prompt") else command=(hermes --yolo) fi diff --git a/bin/omarchy-agent-hermes b/bin/omarchy-agent-hermes new file mode 100755 index 00000000..b40a5e67 --- /dev/null +++ b/bin/omarchy-agent-hermes @@ -0,0 +1,21 @@ +#!/bin/bash + +# omarchy:summary=Seed Hermes literally and resume the resulting interactive session +# omarchy:args= +# omarchy:hidden=true + +set -euo pipefail + +prompt=${1:?usage: omarchy-agent-hermes } +usage=$(mktemp) +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_SESSION_SOURCE=tui hermes --yolo --usage-file "$usage" --oneshot "$prompt" +session_id=$(jq -er '.session_id | strings | select(length > 0)' "$usage") + +rm -f "$usage" +trap - EXIT + +exec hermes chat --yolo --tui --resume "$session_id" diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index d46bbdb2..81f3985c 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -462,7 +462,7 @@ assert_launch claude claude --permission-mode auto -- "Review this project" assert_launch codex codex --approve-for-me -- "Review this project" assert_launch crush crush run "Review this project" assert_launch grok grok --permission-mode bypassPermissions -- "Review this project" -assert_launch hermes hermes chat --yolo --tui "--query=Review this project" +assert_launch hermes omarchy-agent-hermes "Review this project" assert_launch agy agy --dangerously-skip-permissions --prompt-interactive "Review this project" assert_launch copilot copilot --allow-all --interactive "Review this project" pass "agent launcher adapts initial prompts for every supported agent" diff --git a/test/shell.d/hermes-agent-test.sh b/test/shell.d/hermes-agent-test.sh new file mode 100755 index 00000000..37b8de55 --- /dev/null +++ b/test/shell.d/hermes-agent-test.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +oneshot_log="$test_tmp/oneshot" +resume_log="$test_tmp/resume" +source_log="$test_tmp/source" +mkdir -p "$mock_bin" + +cat >"$mock_bin/hermes" <<'SH' +#!/bin/bash + +if [[ " $* " == *" --oneshot "* ]]; then + printf '%s\0' "$@" >"$HERMES_TEST_ONESHOT_LOG" + printf '%s' "${HERMES_SESSION_SOURCE:-}" >"$HERMES_TEST_SOURCE_LOG" + + while (( $# )); do + if [[ $1 == "--usage-file" ]]; then + usage=$2 + break + fi + shift + done + + [[ ${HERMES_TEST_ONESHOT_FAIL:-false} == "false" ]] || exit 42 + [[ ${HERMES_TEST_USAGE_FAIL:-false} == "false" ]] && printf '{"session_id":"session-123"}\n' >"$usage" + printf '%s\n' response + exit +fi + +printf '%s\0' "$@" >"$HERMES_TEST_RESUME_LOG" +SH + +chmod +x "$mock_bin/hermes" + +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" + +sentinel="$test_tmp/hermes-seed-must-stay-literal" +prompt="!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[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" +[[ ! -e $usage_file ]] || fail "Hermes literal seed removes its session report" +[[ ! -e $sentinel ]] || fail "Hermes literal seed never executes prompt interpolation" +[[ $(<"$source_log") == "tui" ]] || fail "Hermes literal seed records an interactive session" + +mapfile -d '' -t resume_args <"$resume_log" +[[ ${resume_args[*]} == "chat --yolo --tui --resume session-123" ]] || + fail "Hermes literal seed resumes the exact completed session" +pass "Hermes sends initial prompts literally and resumes their exact session" + +: >"$resume_log" +if HERMES_TEST_ONESHOT_FAIL=true "$ROOT/bin/omarchy-agent-hermes" failure >/dev/null 2>&1; then + fail "Hermes literal seed reports a failed initial turn" +fi +[[ ! -s $resume_log ]] || fail "Hermes literal seed does not resume a failed initial turn" +pass "Hermes does not resume after a failed initial turn" + +: >"$resume_log" +if HERMES_TEST_USAGE_FAIL=true "$ROOT/bin/omarchy-agent-hermes" missing-session >/dev/null 2>&1; then + fail "Hermes literal seed requires a recorded session ID" +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" From 288e387a22244a8f98a30c15ee94d048dab2ba18 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 27 Aug 2026 22:50:21 -0500 Subject: [PATCH 16/76] Preserve Hermes session workspace metadata Keep the one-shot session on Hermes' native CLI source so it records the launch directory before the exact session is resumed in the TUI. Co-Authored-By: Codex XHigh --- bin/omarchy-agent-hermes | 2 +- test/shell.d/hermes-agent-test.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-agent-hermes b/bin/omarchy-agent-hermes index b40a5e67..28675cae 100755 --- a/bin/omarchy-agent-hermes +++ b/bin/omarchy-agent-hermes @@ -12,7 +12,7 @@ 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_SESSION_SOURCE=tui hermes --yolo --usage-file "$usage" --oneshot "$prompt" +hermes --yolo --usage-file "$usage" --oneshot "$prompt" session_id=$(jq -er '.session_id | strings | select(length > 0)' "$usage") rm -f "$usage" diff --git a/test/shell.d/hermes-agent-test.sh b/test/shell.d/hermes-agent-test.sh index 37b8de55..7472e3fc 100755 --- a/test/shell.d/hermes-agent-test.sh +++ b/test/shell.d/hermes-agent-test.sh @@ -57,7 +57,7 @@ usage_file=${oneshot_args[2]} fail "Hermes literal seed remains one argument" [[ ! -e $usage_file ]] || fail "Hermes literal seed removes its session report" [[ ! -e $sentinel ]] || fail "Hermes literal seed never executes prompt interpolation" -[[ $(<"$source_log") == "tui" ]] || fail "Hermes literal seed records an interactive session" +[[ ! -s $source_log ]] || fail "Hermes literal seed preserves native CLI session metadata" mapfile -d '' -t resume_args <"$resume_log" [[ ${resume_args[*]} == "chat --yolo --tui --resume session-123" ]] || From 36353296aad129e81b69a6249c01253c540eecab Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 27 Aug 2026 23:01:30 -0500 Subject: [PATCH 17/76] 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" From 64203cc2085717d29b7a13257bad1b48a2434cbf Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 27 Aug 2026 23:07:18 -0500 Subject: [PATCH 18/76] Keep prompted Hermes sessions local Clear inherited session-source tags for Omarchy's local one-shot process so Hermes records the launch directory before the exact session is resumed in the TUI. Co-Authored-By: Codex XHigh --- bin/omarchy-agent-hermes | 1 + test/shell.d/hermes-agent-test.sh | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-agent-hermes b/bin/omarchy-agent-hermes index eb81495b..93175818 100755 --- a/bin/omarchy-agent-hermes +++ b/bin/omarchy-agent-hermes @@ -7,6 +7,7 @@ set -euo pipefail prompt=${1:?usage: omarchy-agent-hermes } +unset HERMES_SESSION_SOURCE usage=$(mktemp) trap 'rm -f "$usage"' EXIT diff --git a/test/shell.d/hermes-agent-test.sh b/test/shell.d/hermes-agent-test.sh index 174958d0..3339df24 100755 --- a/test/shell.d/hermes-agent-test.sh +++ b/test/shell.d/hermes-agent-test.sh @@ -68,7 +68,7 @@ export HERMES_TEST_SETUP_MARKER="$setup_marker" sentinel="$test_tmp/hermes-seed-must-stay-literal" prompt="--help !Crash /quit {!touch $sentinel}"$'\ntrailing\\' -"$ROOT/bin/omarchy-agent-hermes" "$prompt" >/dev/null +HERMES_SESSION_SOURCE=gateway "$ROOT/bin/omarchy-agent-hermes" "$prompt" >/dev/null mapfile -d '' -t oneshot_args <"$oneshot_log" (( ${#oneshot_args[@]} == 4 )) || fail "Hermes literal seed has four one-shot arguments" From 5284be65828112b2882179797566979f005d1ace Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Fri, 28 Aug 2026 16:32:19 -0500 Subject: [PATCH 19/76] Use Hermes native prompted sessions Hermes now keeps chat queries interactive and literal to TUI control syntax, so launch it directly and let its own session flow replace the local one-shot, usage-file, and resume bridge. Gate installation on the capability added with native interactive queries, preserve unowned mise environments, and keep the unprompted launch path unchanged. Co-Authored-By: Codex XHigh --- bin/omarchy-agent | 2 +- bin/omarchy-agent-hermes | 34 -------- bin/omarchy-install-hermes-cli | 29 ++++--- test/shell.d/default-agent-test.sh | 15 +++- test/shell.d/hermes-agent-test.sh | 126 ----------------------------- test/shell.d/hermes-cli-test.sh | 24 ++++-- 6 files changed, 47 insertions(+), 183 deletions(-) delete mode 100755 bin/omarchy-agent-hermes delete mode 100755 test/shell.d/hermes-agent-test.sh diff --git a/bin/omarchy-agent b/bin/omarchy-agent index 5436ca0b..05f1e1a1 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -88,7 +88,7 @@ codex) ;; hermes) if [[ -n ${prompt:-} ]]; then - command=(omarchy-agent-hermes "$prompt") + command=(env -u HERMES_SESSION_SOURCE hermes chat --yolo --tui "--query=$prompt") else command=(hermes --yolo) fi diff --git a/bin/omarchy-agent-hermes b/bin/omarchy-agent-hermes deleted file mode 100755 index 93175818..00000000 --- a/bin/omarchy-agent-hermes +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Seed Hermes literally and resume the resulting interactive session -# omarchy:args= -# omarchy:hidden=true - -set -euo pipefail - -prompt=${1:?usage: omarchy-agent-hermes } -unset HERMES_SESSION_SOURCE -usage=$(mktemp) -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. -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 - -exec hermes chat --yolo --tui --resume "$session_id" diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index f3a8614c..5b6a3598 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -88,13 +88,13 @@ 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. +# The chat subcommand's --oneshot opt-out arrived with native interactive -q, +# so its presence is a stable capability check without relying on a version. hermes_prompt_ready() { local help hermes_runs && - help=$(timeout 15 "$HOME/.local/bin/hermes" --help 2>/dev/null) && - grep -qF -- '--usage-file' <<<"$help" + help=$(timeout 15 "$HOME/.local/bin/hermes" chat --help 2>/dev/null) && + grep -qF -- '--oneshot' <<<"$help" } # --owns answers whether the wrapper on PATH is the one this command wrote, so @@ -154,8 +154,8 @@ if foreign_hermes; then 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 + echo "~/.local/bin/hermes does not support the interactive seeded sessions Omarchy needs." >&2 + echo "Update it to a Hermes Agent release with interactive chat queries, then run omarchy-install-hermes-cli again." >&2 exit 1 fi @@ -164,12 +164,17 @@ if foreign_hermes; then 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. +# Only the marked wrapper proves the matching mise environment is ours to replace. 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 + if ours; 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 + else + echo "A Hermes mise environment exists without an Omarchy-owned wrapper." >&2 + echo "Update or remove it explicitly, then run omarchy-install-hermes-cli again." >&2 + exit 1 + fi fi mkdir -p "$HOME/.local/bin" @@ -218,7 +223,7 @@ chmod +x "$HOME/.local/bin/hermes" 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 + echo "Hermes installed without the interactive seeded sessions Omarchy needs." >&2 exit 1 fi fi diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index 81f3985c..fbd60f31 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -431,8 +431,10 @@ assert_launched() { fail "$agent launch $description" "expected: ${expected[*]}\nactual: ${actual[*]}" for ((index = 0; index < ${#expected[@]}; index++)); do - [[ ${actual[$index]} == ${expected[$index]} ]] || - fail "$agent launch $description" "expected: ${expected[*]}\nactual: ${actual[*]}" + case ${actual[$index]} in + "${expected[$index]}") ;; + *) fail "$agent launch $description" "expected: ${expected[*]}\nactual: ${actual[*]}" ;; + esac done } @@ -462,11 +464,18 @@ assert_launch claude claude --permission-mode auto -- "Review this project" assert_launch codex codex --approve-for-me -- "Review this project" assert_launch crush crush run "Review this project" assert_launch grok grok --permission-mode bypassPermissions -- "Review this project" -assert_launch hermes omarchy-agent-hermes "Review this project" +assert_launch hermes env -u HERMES_SESSION_SOURCE hermes chat --yolo --tui "--query=Review this project" assert_launch agy agy --dangerously-skip-permissions --prompt-interactive "Review this project" assert_launch copilot copilot --allow-all --interactive "Review this project" pass "agent launcher adapts initial prompts for every supported agent" +literal_hermes_prompt=$' --help !Crash /quit {$(touch must-not-run)}\ntrailing\\ ' +printf '%s\n' "hermes" >"$agent_file" +omarchy-agent-prompt "$literal_hermes_prompt" +assert_launched hermes "binds its literal initial prompt" env -u HERMES_SESSION_SOURCE \ + hermes chat --yolo --tui "--query=$literal_hermes_prompt" +pass "Hermes receives prompted launches as one literal query argument" + assert_bypass pi pi assert_bypass omp omp --auto-approve assert_bypass opencode opencode --auto diff --git a/test/shell.d/hermes-agent-test.sh b/test/shell.d/hermes-agent-test.sh deleted file mode 100755 index 3339df24..00000000 --- a/test/shell.d/hermes-agent-test.sh +++ /dev/null @@ -1,126 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" - -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -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 [[ ${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" - - while (( $# )); do - if [[ $1 == "--usage-file" ]]; then - usage=$2 - break - fi - 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 - 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 - -printf '%s\0' "$@" >"$HERMES_TEST_RESUME_LOG" -SH - -chmod +x "$mock_bin/hermes" - -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="--help !Crash /quit {!touch $sentinel}"$'\ntrailing\\' -HERMES_SESSION_SOURCE=gateway "$ROOT/bin/omarchy-agent-hermes" "$prompt" >/dev/null - -mapfile -d '' -t oneshot_args <"$oneshot_log" -(( ${#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=$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" - -mapfile -d '' -t resume_args <"$resume_log" -[[ ${resume_args[*]} == "chat --yolo --tui --resume session-123" ]] || - fail "Hermes literal seed resumes the exact completed session" -pass "Hermes sends initial prompts literally and resumes their exact session" - -: >"$resume_log" -if HERMES_TEST_ONESHOT_FAIL=true "$ROOT/bin/omarchy-agent-hermes" failure >/dev/null 2>&1; then - fail "Hermes literal seed reports a failed initial turn" -fi -[[ ! -s $resume_log ]] || fail "Hermes literal seed does not resume a failed initial turn" -pass "Hermes does not resume after a failed initial turn" - -: >"$resume_log" -if HERMES_TEST_USAGE_FAIL=true "$ROOT/bin/omarchy-agent-hermes" missing-session >/dev/null 2>&1; then - fail "Hermes literal seed requires a recorded session ID" -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 39435aa5..83b516e9 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -97,8 +97,8 @@ run_installer 1 --check && fail "--check reports Hermes missing before the app i mkdir -p "$test_home/.hermes/hermes-agent/venv/bin" 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" +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + [[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "--oneshot" else echo "hermes-agent 0.0.0-test" fi @@ -135,12 +135,12 @@ run_installer 0 --now || fail "--now over a foreign hermes command returns succe 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" + fail "--check rejects a foreign Hermes without native prompted sessions" OMARCHY_TEST_HERMES_CAPABLE=0 run_installer 0 && - fail "installing refuses a foreign Hermes without prompted-session reports" + fail "installing refuses a foreign Hermes without native prompted sessions" [[ $(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" +pass "a foreign Hermes must support native prompted sessions" # Broken foreign paths are still foreign. They cannot be used, so --check says # so and the installer refuses rather than replacing them. @@ -237,6 +237,16 @@ tr '\0' '\n' <"$mise_log" | grep -q '^rm$' || fail "an older owned Hermes enviro tr '\0' '\n' <"$mise_log" | grep -q '^uninstall$' || fail "an older owned Hermes environment is uninstalled" pass "reinstalling replaces an older owned Hermes environment" +rm -f "$test_home/.local/bin/hermes" +: >"$mise_log" +OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 0 && + fail "installing refuses to claim an unmarked Hermes mise environment" +tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' && + fail "an unmarked Hermes mise environment is never removed" +[[ ! -e $test_home/.local/bin/hermes ]] || + fail "an unmarked Hermes mise environment is not given an Omarchy wrapper" +pass "a Hermes mise environment needs wrapper ownership before replacement" + # 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 @@ -377,8 +387,8 @@ run_ready_check && fail "--check rejects the app's wrapper when its runtime is g cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' #!/bin/bash -if [[ ${1:-} == "--help" ]]; then - echo "--usage-file PATH" +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + echo "--oneshot" else echo "hermes-agent 0.0.0-test" fi From 41a40ccc78b60c9698a0e45dc358f0e4b5ffd5a3 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Fri, 28 Aug 2026 20:03:19 -0500 Subject: [PATCH 20/76] Show captive portal status and sign-in action in network panel --- shell/plugins/panels/network/Model.js | 24 ++- shell/plugins/panels/network/Panel.qml | 141 +++++++++++++-- .../mocks/NetworkMock.qml | 31 ++++ .../network-captive-portal/mocks/qmldir | 1 + .../fixtures/network-captive-portal/shell.qml | 161 ++++++++++++++++++ test/shell.d/network-captive-portal-test.sh | 95 +++++++++++ 6 files changed, 438 insertions(+), 15 deletions(-) create mode 100644 test/shell.d/fixtures/network-captive-portal/mocks/NetworkMock.qml create mode 100644 test/shell.d/fixtures/network-captive-portal/mocks/qmldir create mode 100644 test/shell.d/fixtures/network-captive-portal/shell.qml create mode 100755 test/shell.d/network-captive-portal-test.sh diff --git a/shell/plugins/panels/network/Model.js b/shell/plugins/panels/network/Model.js index b4c84c68..5ed2c0d6 100644 --- a/shell/plugins/panels/network/Model.js +++ b/shell/plugins/panels/network/Model.js @@ -14,9 +14,25 @@ function wifiIconFor(strength) { return icons[index] } -function connectionIcon(kind, signalStrength) { - if (kind === "wifi") return wifiIconFor(signalStrength) - if (kind === "ethernet") return "󰈀" +// A known plain-HTTP endpoint lets the network redirect the browser to its +// login page. Never execute or automatically open an untrusted Location header. +var captivePortalUrl = "http://ping.archlinux.org/nm-check.txt" + +function connectivityState(kind, connectivity, states, checksEnabled) { + if (kind === "disconnected") return "none" + // Ignore stale cached results when the operator has disabled probing. + if (!checksEnabled) return "unknown" + if (connectivity === states.Portal) return "portal" + if (connectivity === states.Limited) return "limited" + if (connectivity === states.Full) return "full" + if (connectivity === states.None) return "none" + return "unknown" +} + +function connectionIcon(kind, signalStrength, connectivity) { + var restricted = connectivity === "portal" || connectivity === "limited" + if (kind === "wifi") return restricted ? "󰤩" : wifiIconFor(signalStrength) + if (kind === "ethernet") return restricted ? "󰈂" : "󰈀" return "󰤮" } @@ -352,6 +368,8 @@ if (typeof module !== "undefined") { parseNetworkStatus: parseNetworkStatus, wifiIconFor: wifiIconFor, connectionIcon: connectionIcon, + connectivityState: connectivityState, + captivePortalUrl: captivePortalUrl, formatHeaderSpeed: formatHeaderSpeed, formatHeaderFreq: formatHeaderFreq, headerDetail: headerDetail, diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index d1e41149..97729dcc 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -119,9 +119,9 @@ Panel { property bool cursorActive: false // Keyboard focus zone for the panel. j/k crosses row boundaries: - // header actions ⇄ band ⇄ DNS row ⇄ Wi-Fi networks. h/l move + // header actions ⇄ portal ⇄ band ⇄ DNS row ⇄ Wi-Fi networks. h/l move // within header actions, band pills, or DNS providers. - property string focusSection: "dns" // "header" | "band" | "dns" | "wifi" + property string focusSection: "dns" // "header" | "portal" | "band" | "dns" | "wifi" property int headerIndex: 0 readonly property bool canDisconnect: !!connectedWifiNetwork readonly property bool headerHasDisconnect: false @@ -220,6 +220,8 @@ Panel { // network target; both cards are their own plugins now. function showQr() { root.summonWifiQr(true) } function speedTest() { root.summonSpeedTest() } + function openCaptivePortal() { root.openCaptivePortal() } + function checkConnectivity() { root.checkConnectivity() } } function activateHeader() { @@ -322,11 +324,11 @@ Panel { refresh(true) selectedIndex = wifiNetworks.length > 0 ? 0 : -1 wifiActionFocused = false - focusSection = wifiNetworks.length > 0 ? "wifi" : "dns" + focusSection = hasCaptivePortal ? "portal" : (wifiNetworks.length > 0 ? "wifi" : "dns") var idx = dnsProviders.indexOf(dnsProvider) dnsIndex = idx >= 0 ? idx : 0 syncBandIndex() - cursorActive = false + cursorActive = hasCaptivePortal } else { // Drop a restart armed by this open: without it a close/reopen inside // the 100ms window reuses the running timer and re-enables the scanner @@ -450,7 +452,59 @@ Panel { Quickshell.execDetached(["bash", "-c", "printf %s " + Util.shellQuote(value) + " | wl-copy"]) } - readonly property string icon: Model.connectionIcon(kind, signalStrength) + // NetworkManager performs the HTTP probe (including unexpected page bodies, + // not just redirects). Consume its native notifications rather than running + // a second curl loop or mistaking an ordinary timeout for a captive portal. + readonly property bool connectivityChecksEnabled: networkManagerAvailable + && Networking.canCheckConnectivity && Networking.connectivityCheckEnabled + readonly property string connectivity: Model.connectivityState(kind, Networking.connectivity, { + Portal: NetworkConnectivity.Portal, Limited: NetworkConnectivity.Limited, + Full: NetworkConnectivity.Full, None: NetworkConnectivity.None + }, connectivityChecksEnabled) + readonly property bool hasCaptivePortal: connectivity === "portal" + readonly property bool restricted: hasCaptivePortal || connectivity === "limited" + readonly property string icon: Model.connectionIcon(kind, signalStrength, connectivity) + readonly property string connectionKey: kind === "wifi" && wifiDevice && connectedWifiNetwork + ? kind + ":" + wifiDevice.name + ":" + connectedWifiNetwork.name + : (kind === "ethernet" && wiredDevice ? kind + ":" + wiredDevice.name : "") + + onConnectionKeyChanged: Qt.callLater(checkConnectivity) + onConnectivityChecksEnabledChanged: Qt.callLater(checkConnectivity) + onHasCaptivePortalChanged: { + if (hasCaptivePortal && opened && passwordSsid === "") { + focusSection = "portal" + cursorActive = true + } else if (!hasCaptivePortal && focusSection === "portal") { + focusSection = headerActionCount > 0 ? "header" : "dns" + headerIndex = 0 + } + } + onRestrictedChanged: { + connectionPhraseSwap.stop() + heroMeta.opacity = 1.0 + } + + function checkConnectivity() { + if (connectivityChecksEnabled && kind !== "disconnected") Networking.checkConnectivity() + } + + function openCaptivePortal() { + if (!hasCaptivePortal) return + // Explicit user action only. argv (not a shell string), and a fixed HTTP + // URL: let the browser handle the redirect without trusting portal input. + Quickshell.execDetached(["omarchy-launch-browser", Model.captivePortalUrl]) + close() + } + + // Keep checking while login is needed, even with the panel closed in favour + // of the browser. Normal connected operation relies on NM's own schedule. + Timer { + id: connectivityPoll + interval: 10000 + repeat: true + running: root.restricted && root.connectivityChecksEnabled + onTriggered: root.checkConnectivity() + } // The share card is its own panel plugin (omarchy.wifiqr) so a replacement // design can take it over; summon() routes to whichever implementation is @@ -469,6 +523,7 @@ Panel { } function refresh(scanWifi) { + checkConnectivity() if (scanWifi === undefined) scanWifi = false if (!detailsProc.running) detailsProc.running = true if (!dnsProc.running) { @@ -901,7 +956,7 @@ Panel { Timer { id: connectionPhraseTimer interval: 2800 - running: root.opened && (root.info.type === "ethernet" || (root.info.type === "wifi" && root.canDisconnect)) + running: root.opened && !root.restricted && (root.info.type === "ethernet" || (root.info.type === "wifi" && root.canDisconnect)) repeat: true onTriggered: connectionPhraseSwap.restart() } @@ -958,6 +1013,9 @@ Panel { anchors.fill: parent bar: root.bar text: root.icon + active: root.restricted + tooltipText: root.hasCaptivePortal ? "Sign in to this network" + : (root.restricted ? "Limited internet access" : "") onPressed: function(b) { if (root.opened) root.close() @@ -1001,23 +1059,34 @@ Panel { if (dy >= 0) return } if (dy !== 0) { - // Vertical order is header ⇄ band ⇄ DNS ⇄ wifi, with the band section - // dropping out of the chain entirely when it isn't on screen. + // Hidden sections drop out of the keyboard chain entirely. if (root.focusSection === "header") { if (dy > 0) { - if (root.canSelectBand) { + if (root.hasCaptivePortal) { + root.focusSection = "portal" + } else if (root.canSelectBand) { root.focusSection = "band" root.bandAutoFocused = true } else { root.focusSection = "dns" } } + } else if (root.focusSection === "portal") { + if (dy < 0 && root.headerActionCount > 0) { + root.focusSection = "header" + root.headerIndex = 0 + } else if (dy > 0) { + root.focusSection = root.canSelectBand ? "band" : "dns" + root.bandAutoFocused = true + } } else if (root.focusSection === "band") { // Automatic on the header line, then the pills -- which collapse // away under Automatic, leaving a single row to walk. if (dy < 0) { if (!root.bandAutoFocused) { root.bandAutoFocused = true + } else if (root.hasCaptivePortal) { + root.focusSection = "portal" } else if (root.headerActionCount > 0) { root.focusSection = "header" root.headerIndex = 0 @@ -1035,6 +1104,8 @@ Panel { if (root.canSelectBand) { root.focusSection = "band" root.bandAutoFocused = !root.bandPillsVisible + } else if (root.hasCaptivePortal) { + root.focusSection = "portal" } else if (root.headerActionCount > 0) { root.focusSection = "header" root.headerIndex = 0 @@ -1063,6 +1134,7 @@ Panel { onActivateRequested: { if (root.cursorActive) { if (root.focusSection === "header") root.activateHeader() + else if (root.focusSection === "portal") root.openCaptivePortal() else if (root.focusSection === "band") root.activateBand() else if (root.focusSection === "dns") root.activateDns() else root.activateSelected() @@ -1092,7 +1164,7 @@ Panel { id: heroIcon textFormat: Text.PlainText text: root.icon - color: root.bar.foreground + color: root.restricted ? root.bar.urgent : root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.display opacity: root.networkManagerAvailable ? 1.0 : 0.5 @@ -1175,6 +1247,9 @@ Panel { width: parent.width readonly property string title: { + // The HTTP restriction does not undo association. Show the live + // SSID even before route/details polling has returned anything. + if (root.kind === "wifi" && root.connectedWifiNetwork) return root.connectedWifiNetwork.name || "Wi-Fi" if (root.info.type === "wifi") return root.info.ssid || "Wi-Fi" if (root.info.type === "ethernet") return "Ethernet" return root.info.iface || (root.kind === "disconnected" ? "Disconnected" : "No connection") @@ -1194,6 +1269,8 @@ Panel { textFormat: Text.PlainText width: parent.width text: { + if (root.hasCaptivePortal) return "SIGN-IN REQUIRED" + if (root.restricted) return "LIMITED INTERNET ACCESS" if (root.info.type === "wifi") { if (root.canDisconnect) return root.connectionPhrase.toUpperCase() if (root.kind === "disconnected") return "NOT CONNECTED" @@ -1204,7 +1281,7 @@ Panel { return "" } visible: text !== "" - color: Qt.darker(root.bar.foreground, 1.4) + color: root.restricted ? root.bar.urgent : Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption font.bold: true @@ -1215,6 +1292,43 @@ Panel { } + Column { + visible: root.hasCaptivePortal + width: parent.width + spacing: Style.space(6) + + Button { + id: portalAction + width: parent.width + text: "Open Captive Portal" + iconText: "󰏌" + foreground: root.bar.urgent + accent: root.bar.urgent + fontFamily: root.bar.fontFamily + verticalPadding: Style.space(10) + bordered: true + active: true + hasCursor: root.cursorActive && root.focusSection === "portal" + onHovered: function(on) { + if (!on) return + root.cursorActive = true + root.focusSection = "portal" + } + onClicked: root.openCaptivePortal() + } + + Text { + width: parent.width + text: "Sign in or accept this network’s terms to access the internet." + textFormat: Text.PlainText + wrapMode: Text.WordWrap + color: root.bar.foreground + opacity: 0.7 + font.family: root.bar.fontFamily + font.pixelSize: Style.font.bodySmall + } + } + // Connection details: transfer metrics first, then IP/Gateway. Column { visible: !!root.info.iface @@ -1654,6 +1768,7 @@ Panel { if (isBusy && root.actionKind === "disconnect") return "Disconnecting…" if (isBusy && root.actionKind === "forget") return "Forgetting…" if (isFailed) return root.failureReason || "Failed" + if (isConnected && root.kind === "wifi" && root.hasCaptivePortal) return "Sign-in required" if (isConnected) return "Connected" return "" } @@ -1661,6 +1776,7 @@ Panel { readonly property color statusColor: { if (isFailed) return root.bar.urgent if (isBusy) return root.bar.foreground + if (isConnected && root.kind === "wifi" && root.hasCaptivePortal) return root.bar.urgent if (isConnected) return root.bar.foreground return Qt.darker(root.bar.foreground, 1.5) } @@ -1715,7 +1831,8 @@ Panel { Text { id: networkIcon textFormat: Text.PlainText - text: row.net ? root.wifiIconFor(row.net.signal) : "" + text: row.net ? Model.connectionIcon("wifi", row.net.signal, + row.isConnected && root.kind === "wifi" ? root.connectivity : "") : "" color: row.statusColor font.family: root.bar.fontFamily font.pixelSize: Style.font.title diff --git a/test/shell.d/fixtures/network-captive-portal/mocks/NetworkMock.qml b/test/shell.d/fixtures/network-captive-portal/mocks/NetworkMock.qml new file mode 100644 index 00000000..b41c6513 --- /dev/null +++ b/test/shell.d/fixtures/network-captive-portal/mocks/NetworkMock.qml @@ -0,0 +1,31 @@ +pragma Singleton +import QtQuick +import Quickshell.Networking + +QtObject { + property int backend: NetworkBackendType.NetworkManager + property bool wifiEnabled: true + property bool canCheckConnectivity: true + property bool connectivityCheckEnabled: true + property int connectivity: NetworkConnectivity.Full + property int checks: 0 + function checkConnectivity() { checks++ } + + property var devices: ({ values: [wifi] }) + property QtObject wifi: QtObject { + property int type: DeviceType.Wifi + property string name: "test-wifi" + property bool connected: true + property bool scannerEnabled: false + property var networks: ({ values: [network] }) + } + property QtObject network: QtObject { + property string name: "Guest Wi-Fi" + property bool connected: true + property bool known: true + property bool stateChanging: false + property real signalStrength: 0.8 + property int security: WifiSecurityType.Open + signal connectionFailed(int reason) + } +} diff --git a/test/shell.d/fixtures/network-captive-portal/mocks/qmldir b/test/shell.d/fixtures/network-captive-portal/mocks/qmldir new file mode 100644 index 00000000..03b0cb27 --- /dev/null +++ b/test/shell.d/fixtures/network-captive-portal/mocks/qmldir @@ -0,0 +1 @@ +singleton NetworkMock 1.0 NetworkMock.qml diff --git a/test/shell.d/fixtures/network-captive-portal/shell.qml b/test/shell.d/fixtures/network-captive-portal/shell.qml new file mode 100644 index 00000000..24c361bc --- /dev/null +++ b/test/shell.d/fixtures/network-captive-portal/shell.qml @@ -0,0 +1,161 @@ +import QtQuick +import Quickshell +import Quickshell.Networking +import qs.Commons +import "mocks" +import "network" as Network + +ShellRoot { + id: test + property bool failed: false + function check(ok, message) { + if (!ok) { + failed = true + console.log("RESULT fail " + message) + } + } + + // Not visible in the normal test run. The optional preview maps the real + // KeyboardPanel for a screenshot, without ever altering the host network. + Item { + Network.Panel { + id: panel + bar: QtObject { + property color foreground: Color.foreground + property color barForeground: Color.foreground + property color urgent: Color.urgent + property string fontFamily: Style.font.family + property string position: "top" + property int barSize: 24 + property bool vertical: false + property bool foregroundAnimationEnabled: false + property var activePopout: null + function requestPopout(owner) { activePopout = owner } + function releasePopout(owner) { activePopout = null } + function registerClickTarget(target) {} + function unregisterClickTarget(target) {} + function hideTooltip(target) {} + function showTooltip(target, text) {} + } + } + } + + Timer { + interval: 250 + running: true + onTriggered: { + test.check(panel.kind === "wifi", "connected Wi-Fi fixture") + test.check(panel.connectivity === "full", "normal connectivity") + test.check(!panel.testButton.visible && !panel.testBarButton.active, "no false portal banner") + test.check(!panel.testPoll.running, "normal connectivity adds no polling") + test.check(NetworkMock.checks > 0, "checks at connection/startup") + var before = NetworkMock.checks + panel.checkConnectivity() + test.check(NetworkMock.checks === before + 1, "manual check delegates to NM") + NetworkMock.connectivity = NetworkConnectivity.Portal + Qt.callLater(portalChecks) + } + } + + function portalChecks() { + check(panel.hasCaptivePortal && panel.restricted, "native portal activates restricted mode") + check(panel.testButton.visible, "portal button visible") + check(panel.testButton.text === "Open Captive Portal", "prominent action label") + check(panel.icon === "󰤩" && panel.testBarButton.active, "blocked bar icon and warning color") + check(panel.testMeta.text === "SIGN-IN REQUIRED", "status replaces cheerful connection phrase") + check(panel.testTitle.text === "Guest Wi-Fi", "connected SSID survives missing route details") + check(panel.testPoll.running && panel.testPoll.interval === 10000, "restricted recheck runs while closed") + var before = NetworkMock.checks + panel.testPoll.triggered() + check(NetworkMock.checks === before + 1, "background timer rechecks through NM") + panel.testKeys.textKey("r") + check(NetworkMock.checks === before + 2, "r requests fresh connectivity") + // Exercise the existing cursor model, not a separate test-only action. + panel.cursorActive = true + panel.focusSection = "header" + panel.testKeys.moveRequested(0, 1) + check(panel.focusSection === "portal", "down from header reaches portal") + panel.testKeys.moveRequested(0, 1) + check(panel.focusSection === "dns", "down from portal skips absent band") + panel.testKeys.moveRequested(0, -1) + check(panel.focusSection === "portal", "up from DNS reaches portal") + panel.bandAvailable = ["2.4", "5"] + panel.testKeys.moveRequested(0, 1) + check(panel.focusSection === "band", "down from portal reaches available band") + panel.testKeys.moveRequested(0, -1) + check(panel.focusSection === "portal", "up from band reaches portal") + panel.testKeys.activateRequested() + NetworkMock.connectivity = NetworkConnectivity.Full + Qt.callLater(recoveryChecks) + } + + function recoveryChecks() { + check(!panel.hasCaptivePortal && !panel.restricted, "login recovery clears restriction") + check(!panel.testPoll.running, "recovery stops extra checks") + check(!panel.testButton.visible && !panel.testBarButton.active, "recovery hides button and warning color") + check(panel.focusSection === "header", "disappearing button leaves valid cursor") + check(panel.icon !== "󰤩", "signal icon returns") + // No browser launch when the portal is gone (runner asserts one launch). + panel.openCaptivePortal() + NetworkMock.connectivity = NetworkConnectivity.Limited + Qt.callLater(limitedChecks) + } + + function limitedChecks() { + check(panel.restricted && !panel.hasCaptivePortal, "outage is not mislabelled as a portal") + check(!panel.testButton.visible && panel.testMeta.text === "LIMITED INTERNET ACCESS", "limited state has no login button") + NetworkMock.connectivity = NetworkConnectivity.Portal + NetworkMock.connectivityCheckEnabled = false + Qt.callLater(disabledChecks) + } + + function disabledChecks() { + check(!panel.hasCaptivePortal && panel.connectivity === "unknown", "disabled checks ignore cached portal") + check(!panel.testPoll.running, "disabled checks stop polling") + var before = NetworkMock.checks + panel.checkConnectivity() + check(NetworkMock.checks === before, "does not enable or invoke disabled checks") + NetworkMock.connectivityCheckEnabled = true + NetworkMock.network.connected = false + NetworkMock.wifi.connected = false + Qt.callLater(disconnectedChecks) + } + + function disconnectedChecks() { + check(panel.kind === "disconnected" && !panel.hasCaptivePortal, "disconnect clears stale portal") + check(!panel.testButton.visible && panel.icon === "󰤮", "disconnected icon not portal icon") + if (failed) { Qt.quit(); return } + console.log("RESULT pass") + var preview = Quickshell.env("NETWORK_TEST_PREVIEW") + if (preview === "portal" || preview === "full") { + NetworkMock.network.connected = true + NetworkMock.wifi.connected = true + NetworkMock.connectivity = preview === "portal" ? NetworkConnectivity.Portal : NetworkConnectivity.Full + panel.open() + previewCapture.start() + previewDone.start() + } else { + // Give the detached, stubbed browser command time to append its argv. + done.start() + } + } + + // Optional fresh, panel-only captures. Rendering the card itself excludes + // the host desktop, and the network details above come only from fixtures. + // NETWORK_TEST_PREVIEW=portal (or full), NETWORK_TEST_SCREENSHOT=/tmp/new.png + Timer { + id: previewCapture + interval: 750 + onTriggered: { + var path = Quickshell.env("NETWORK_TEST_SCREENSHOT") + if (!path) return + var card = panel.testKeys.parent.parent + card.grabToImage(function(result) { + test.check(result.saveToFile(path), "save fresh preview screenshot") + Qt.quit() + }) + } + } + Timer { id: done; interval: 300; onTriggered: Qt.quit() } + Timer { id: previewDone; interval: 15000; onTriggered: Qt.quit() } +} diff --git a/test/shell.d/network-captive-portal-test.sh b/test/shell.d/network-captive-portal-test.sh new file mode 100755 index 00000000..0802eeb7 --- /dev/null +++ b/test/shell.d/network-captive-portal-test.sh @@ -0,0 +1,95 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const network = requireFromRoot('shell/plugins/panels/network/Model.js') +const states = { Unknown: 0, None: 1, Portal: 2, Limited: 3, Full: 4 } + +for (const kind of ['wifi', 'ethernet']) { + for (const [native, expected] of [ + ['Unknown', 'unknown'], ['None', 'none'], ['Portal', 'portal'], + ['Limited', 'limited'], ['Full', 'full'] + ]) { + assertEqual(network.connectivityState(kind, states[native], states, true), expected, + `${kind} maps native ${native} connectivity without confusing an outage with a portal`) + } + assertEqual(network.connectivityState(kind, 99, states, true), 'unknown', `${kind} handles unknown connectivity`) + for (const native of Object.values(states)) { + assertEqual(network.connectivityState(kind, native, states, false), 'unknown', `${kind} ignores stale results with probing disabled (${native})`) + } +} +for (const native of Object.values(states)) { + assertEqual(network.connectivityState('disconnected', native, states, true), 'none', `disconnect clears stale connectivity (${native})`) +} +for (const state of ['portal', 'limited']) { + assertEqual(network.connectionIcon('wifi', 80, state), '󰤩', `${state} uses a blocked Wi-Fi icon`) + assertEqual(network.connectionIcon('ethernet', 80, state), '󰈂', `${state} uses a blocked Ethernet icon`) + assertEqual(network.connectionIcon('disconnected', 80, state), '󰤮', `${state} does not override the disconnected icon`) +} +for (const state of ['full', 'unknown', 'none', undefined]) { + for (const signal of [-1, 0, 20, 40, 60, 80, 100]) { + assertEqual(network.connectionIcon('wifi', signal, state), network.wifiIconFor(signal), `${state} preserves Wi-Fi strength ${signal}`) + } + assertEqual(network.connectionIcon('ethernet', -1, state), '󰈀', `${state} preserves the Ethernet icon`) +} +const url = new URL(network.captivePortalUrl) +assertEqual(url.protocol, 'http:', 'browser entry point uses plain HTTP so a portal can intercept it') +assertEqual(url.hostname, 'ping.archlinux.org', 'browser entry point is fixed rather than portal-supplied') +assertEqual(url.username + url.password, '', 'browser entry point contains no credentials') +JS + +require_compositor "network captive-portal runtime test" +require_command quickshell + +stage=$(mktemp -d) +trap 'rm -rf -- "$stage"' EXIT +fixture="$SHELL_TEST_DIR/fixtures/network-captive-portal" +mkdir -p "$stage/network" "$stage/bin" "$stage/home" +ln -s "$ROOT/shell/Ui" "$stage/Ui" +ln -s "$ROOT/shell/Commons" "$stage/Commons" +cp -r "$fixture/mocks" "$stage/mocks" +cp "$fixture/shell.qml" "$stage/shell.qml" +cp "$ROOT/shell/plugins/panels/network/Model.js" "$stage/network/Model.js" +node - "$ROOT" "$stage" <<'JS' +const fs = require('fs') +const [root, stage] = process.argv.slice(2) +let source = fs.readFileSync(`${root}/shell/plugins/panels/network/Panel.qml`, 'utf8') +// Keep installed enum values and actual UI bindings. Only replace the singleton +// and expose private IDs in the disposable copy, never in production code. +source = source.replace('import Quickshell.Networking', 'import Quickshell.Networking\nimport "../mocks"') +source = source.replace(/\bNetworking\./g, 'NetworkMock.') +source = source.replace(' id: root', ` id: root + property alias testButton: portalAction + property alias testKeys: keyCatcher + property alias testMeta: heroMeta + property alias testTitle: heroSsid + property alias testPoll: connectivityPoll + property alias testBarButton: button`) +fs.writeFileSync(`${stage}/network/Panel.qml`, source) +JS +printf '#!/bin/bash\nexit 0\n' > "$stage/bin/noop" +chmod +x "$stage/bin/noop" +for command in omarchy-dns omarchy-network-band; do + ln -s noop "$stage/bin/$command" +done +# Preview uses only synthetic details, never the host's SSID or addresses. +# Normal assertions keep the details empty to exercise missing-route handling. +printf '#!/bin/bash\nif [[ -n ${NETWORK_TEST_PREVIEW:-} ]]; then\n printf "type\\twifi\\niface\\ttest-wifi\\nssid\\tGuest Wi-Fi\\nip\\t192.0.2.10\\ngateway\\t192.0.2.1\\n"\nfi\n' > "$stage/bin/omarchy-network-status" +chmod +x "$stage/bin/omarchy-network-status" +printf '#!/bin/bash\nprintf "%%s\\n" "$@" >> "$NETWORK_TEST_BROWSER_LOG"\n' > "$stage/bin/omarchy-launch-browser" +chmod +x "$stage/bin/omarchy-launch-browser" + +# All networking and external actions are mocked; the real connection and +# browser are never touched, and the fixture writes only to its scratch HOME. +output=$(HOME="$stage/home" OMARCHY_PATH="$ROOT" PATH="$stage/bin:$PATH" \ + NETWORK_TEST_BROWSER_LOG="$stage/browser.log" \ + timeout 30 quickshell -p "$stage" --no-color 2>&1) || fail "network portal fixture exits cleanly" "$output" +[[ $output == *"RESULT pass"* ]] || fail "network portal runtime assertions pass" "$output" +if rg -q 'RESULT fail|ReferenceError|TypeError|Error:|Unable to assign|Binding loop' <<< "$output"; then + fail "network portal fixture has no QML errors" "$output" +fi +[[ -f $stage/browser.log ]] || fail "portal action launches the browser" +[[ $(<"$stage/browser.log") == "http://ping.archlinux.org/nm-check.txt" ]] || fail "portal opens exactly one fixed HTTP URL" +pass "network portal, recovery, disabled checks, outage, disconnect, keyboard navigation, and browser argv work in QML" From c64e03d9c54e2c2265f5b5710ab3bc729257722a Mon Sep 17 00:00:00 2001 From: "James (SMF Works)" Date: Thu, 27 Aug 2026 11:19:05 -0400 Subject: [PATCH 21/76] Link Omarchy agent skills into Hermes skill directories Hermes was missing from the provision-user symlink list that already covers Claude, Codex, Pi, Antigravity, and ~/.agents. Add ~/.hermes/skills plus existing ~/.hermes/profiles/*/skills. Migration for current installs. --- bin/omarchy-provision-user | 10 +++++++++- docs/file-layout.md | 5 +++-- manual/17-ai.md | 2 +- migrations/1787843905.sh | 22 ++++++++++++++++++++++ test/shell.d/provision-user-test.sh | 12 ++++++++++-- 5 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 migrations/1787843905.sh diff --git a/bin/omarchy-provision-user b/bin/omarchy-provision-user index 57024b5e..3bcbf7a8 100755 --- a/bin/omarchy-provision-user +++ b/bin/omarchy-provision-user @@ -84,7 +84,7 @@ fi # Dev-aware skill symlinks. Cannot live in /etc/skel because OMARCHY_PATH may # point at a dev checkout (omarchy dev link) where the target differs. # Loops every skill directory, so shipping a new one needs no edit here. -mkdir -p ~/.agents/skills ~/.claude/skills ~/.codex/skills ~/.pi/agent/skills ~/.gemini/config/skills +mkdir -p ~/.agents/skills ~/.claude/skills ~/.codex/skills ~/.pi/agent/skills ~/.gemini/config/skills ~/.hermes/skills for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do skill=${skill%/} name=${skill##*/} @@ -93,6 +93,14 @@ for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do ln -sfn "$skill" ~/.codex/skills/"$name" ln -sfn "$skill" ~/.pi/agent/skills/"$name" ln -sfn "$skill" ~/.gemini/config/skills/"$name" + ln -sfn "$skill" ~/.hermes/skills/"$name" + if [[ -d ~/.hermes/profiles ]]; then + for profile in ~/.hermes/profiles/*/; do + [[ -d $profile ]] || continue + mkdir -p "$profile/skills" + ln -sfn "$skill" "$profile/skills/$name" + done + fi done mkdir -p ~/Downloads ~/Pictures ~/Videos ~/.config/gtk-3.0 diff --git a/docs/file-layout.md b/docs/file-layout.md index 2a9d965b..08516524 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -198,11 +198,12 @@ Runs once per user. It does **not** copy `~/.config/**`, `~/.bashrc`, `flags.lua`, or the nautilus extensions — `/etc/skel` already seeded those. It only does the things `/etc/skel` can't: -- Skill symlinks `~/.{agents,claude,codex,pi/agent}/skills/` → +- Skill symlinks `~/.{agents,claude,codex,pi/agent,hermes}/skills/` (and each `~/.hermes/profiles/*/skills/`) → `$OMARCHY_PATH/default/agents/skills/`, looping over every skill directory there (currently `omarchy` and `diagnose-crash`) so new skills need no edit. Symlinks (not copies) so `omarchy dev link` against a dev - checkout repoints them correctly. + checkout repoints them correctly. Hermes profile dirs are only linked when + they already exist — provision does not create Hermes profiles. - `xdg-user-dirs-update` (Templates/Public/Desktop folded back into `$HOME`) and `~/.config/gtk-3.0/bookmarks` (needs `$HOME` expansion). - Hyprland's package-owned default input reads `XKBLAYOUT` / `XKBVARIANT` diff --git a/manual/17-ai.md b/manual/17-ai.md index 57698f42..d9a30755 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -51,6 +51,6 @@ Omarchy recommends two ways of running local LLM models: LM Studio and Ollama. L ### The Omarchy Skill -Agent skills help AI use specific tools in a specific way, and Omarchy ships with a default skill for tailoring the system. Like tweaking your Hyprland config, adjusting the bar, or even creating a new theme from scratch. It's symlinked into the skill directories for Claude Code (`~/.claude/skills`), Codex (`~/.codex/skills`), Pi (`~/.pi/agent/skills`), Antigravity (`~/.gemini/config/skills`), and the generic `~/.agents/skills` location, so most harnesses pick it up automatically. +Agent skills help AI use specific tools in a specific way, and Omarchy ships with a default skill for tailoring the system. Like tweaking your Hyprland config, adjusting the bar, or even creating a new theme from scratch. It's symlinked into the skill directories for Claude Code (`~/.claude/skills`), Codex (`~/.codex/skills`), Pi (`~/.pi/agent/skills`), Antigravity (`~/.gemini/config/skills`), Hermes (`~/.hermes/skills` and each `~/.hermes/profiles/*/skills`), and the generic `~/.agents/skills` location, so most harnesses pick it up automatically. But you should treat this skill as experimental. Different models will use it to different effect. It's best to run in plan mode first, so you have an idea of what the agent would like to change. And then be ready to rollback changes or even invoking `omarchy reinstall configs`, if the agent makes a mess of everything. diff --git a/migrations/1787843905.sh b/migrations/1787843905.sh new file mode 100644 index 00000000..e0e58cd5 --- /dev/null +++ b/migrations/1787843905.sh @@ -0,0 +1,22 @@ +echo "Link Omarchy agent skills into Hermes skill directories" + +OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" +skills_source="$OMARCHY_PATH/default/agents/skills" + +[[ -d $skills_source ]] || exit 0 + +mkdir -p "$HOME/.hermes/skills" + +for skill in "$skills_source"/*/; do + [[ -d $skill ]] || continue + name=${skill%/} + name=${name##*/} + ln -sfn "$skills_source/$name" "$HOME/.hermes/skills/$name" + if [[ -d $HOME/.hermes/profiles ]]; then + for profile in "$HOME"/.hermes/profiles/*/; do + [[ -d $profile ]] || continue + mkdir -p "$profile/skills" + ln -sfn "$skills_source/$name" "$profile/skills/$name" + done + fi +done diff --git a/test/shell.d/provision-user-test.sh b/test/shell.d/provision-user-test.sh index b598242e..1acab3dc 100755 --- a/test/shell.d/provision-user-test.sh +++ b/test/shell.d/provision-user-test.sh @@ -8,7 +8,7 @@ test_tmp=$(mktemp -d) trap 'rm -rf "$test_tmp"' EXIT mock_bin="$test_tmp/bin" -mkdir -p "$mock_bin" "$test_tmp/home" +mkdir -p "$mock_bin" "$test_tmp/home" "$test_tmp/home/.hermes/profiles/james" for command in xdg-user-dirs-update xdg-settings xdg-mime; do printf '#!/bin/bash\nexit 0\n' >"$mock_bin/$command" @@ -30,6 +30,14 @@ for skill in omarchy diagnose-crash; do link="$test_tmp/home/.gemini/config/skills/$skill" [[ -L $link && $(readlink "$link") == "$ROOT/default/agents/skills/$skill" ]] || fail "omarchy-provision-user provisions the $skill skill for Antigravity" + + link="$test_tmp/home/.hermes/skills/$skill" + [[ -L $link && $(readlink "$link") == "$ROOT/default/agents/skills/$skill" ]] || + fail "omarchy-provision-user provisions the $skill skill for Hermes" + + link="$test_tmp/home/.hermes/profiles/james/skills/$skill" + [[ -L $link && $(readlink "$link") == "$ROOT/default/agents/skills/$skill" ]] || + fail "omarchy-provision-user provisions the $skill skill for a Hermes profile" done -pass "omarchy-provision-user provisions Antigravity skills" +pass "omarchy-provision-user provisions Antigravity and Hermes skills" From e482977f0928d9592c6bd29378f3a2a0e4f5f421 Mon Sep 17 00:00:00 2001 From: Michael Gannotti Date: Sat, 29 Aug 2026 15:31:29 -0400 Subject: [PATCH 22/76] Add a Hermes skills migration test and list Antigravity in file-layout The provision-user suite never ran the one-shot migration. Cover default-home links, a pre-existing profile, idempotency, and a missing skill source. Document ~/.gemini/config/skills and stop wrapping that bullet. --- docs/file-layout.md | 7 +- test/shell.d/hermes-skills-migration-test.sh | 75 ++++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) create mode 100755 test/shell.d/hermes-skills-migration-test.sh diff --git a/docs/file-layout.md b/docs/file-layout.md index 08516524..24c21af5 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -198,12 +198,7 @@ Runs once per user. It does **not** copy `~/.config/**`, `~/.bashrc`, `flags.lua`, or the nautilus extensions — `/etc/skel` already seeded those. It only does the things `/etc/skel` can't: -- Skill symlinks `~/.{agents,claude,codex,pi/agent,hermes}/skills/` (and each `~/.hermes/profiles/*/skills/`) → - `$OMARCHY_PATH/default/agents/skills/`, looping over every skill - directory there (currently `omarchy` and `diagnose-crash`) so new skills - need no edit. Symlinks (not copies) so `omarchy dev link` against a dev - checkout repoints them correctly. Hermes profile dirs are only linked when - they already exist — provision does not create Hermes profiles. +- Skill symlinks into `~/.agents/skills/`, `~/.claude/skills/`, `~/.codex/skills/`, `~/.pi/agent/skills/`, `~/.gemini/config/skills/` (Antigravity), `~/.hermes/skills/`, and each existing `~/.hermes/profiles/*/skills/` → `$OMARCHY_PATH/default/agents/skills/`, looping over every skill directory there (currently `omarchy` and `diagnose-crash`) so new skills need no edit. Symlinks (not copies) so `omarchy dev link` against a dev checkout repoints them correctly. Hermes profile dirs are only linked when they already exist — provision does not create Hermes profiles. - `xdg-user-dirs-update` (Templates/Public/Desktop folded back into `$HOME`) and `~/.config/gtk-3.0/bookmarks` (needs `$HOME` expansion). - Hyprland's package-owned default input reads `XKBLAYOUT` / `XKBVARIANT` diff --git a/test/shell.d/hermes-skills-migration-test.sh b/test/shell.d/hermes-skills-migration-test.sh new file mode 100755 index 00000000..f1145bd7 --- /dev/null +++ b/test/shell.d/hermes-skills-migration-test.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +migration="$ROOT/migrations/1787843905.sh" +[[ -f $migration ]] || fail "Hermes skills migration exists" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +home="$test_dir/home" + +run_migration() { + HOME="$home" OMARCHY_PATH="$ROOT" bash -euo pipefail "$migration" >/dev/null || + fail "migration exits clean" +} + +assert_link() { + local link="$1" + local skill="$2" + local description="$3" + + [[ -L $link && $(readlink "$link") == "$ROOT/default/agents/skills/$skill" ]] || + fail "$description" "$link -> $(readlink "$link" 2>/dev/null || echo missing)" +} + +# ------------------------------------------------------------------ default home, no profiles + +rm -rf "$home" +mkdir -p "$home" +run_migration + +for skill in omarchy diagnose-crash; do + assert_link "$home/.hermes/skills/$skill" "$skill" "migration links $skill into the default Hermes home" +done +[[ -e $home/.hermes/profiles ]] && fail "migration does not create Hermes profiles" +pass "migration links the default Hermes home and does not create profiles" + +run_migration +for skill in omarchy diagnose-crash; do + assert_link "$home/.hermes/skills/$skill" "$skill" "migration is idempotent on the default home for $skill" +done +pass "migration is idempotent on the default home" + +# ------------------------------------------------------------------ pre-existing profile + +rm -rf "$home" +mkdir -p "$home/.hermes/profiles/james" +run_migration + +for skill in omarchy diagnose-crash; do + assert_link "$home/.hermes/skills/$skill" "$skill" "migration links $skill into the default Hermes home when a profile exists" + assert_link "$home/.hermes/profiles/james/skills/$skill" "$skill" "migration links $skill into a pre-existing Hermes profile" +done +[[ -d $home/.hermes/profiles/james ]] || fail "migration leaves the pre-existing profile in place" +profile_count=$(find "$home/.hermes/profiles" -mindepth 1 -maxdepth 1 -type d | wc -l) +(( profile_count == 1 )) || fail "migration does not create extra profiles" "count=$profile_count" +pass "migration links a pre-existing Hermes profile and does not create extras" + +run_migration +for skill in omarchy diagnose-crash; do + assert_link "$home/.hermes/skills/$skill" "$skill" "migration is idempotent on the default home when a profile exists for $skill" + assert_link "$home/.hermes/profiles/james/skills/$skill" "$skill" "migration is idempotent on a pre-existing profile for $skill" +done +pass "migration is idempotent on a pre-existing profile" + +# ------------------------------------------------------------------ missing skill source + +rm -rf "$home" +mkdir -p "$home" "$test_dir/empty-omarchy" +HOME="$home" OMARCHY_PATH="$test_dir/empty-omarchy" bash -euo pipefail "$migration" >/dev/null || + fail "migration exits clean when the skill source is missing" +[[ -e $home/.hermes ]] && fail "migration no-ops when the skill source is missing" +pass "migration no-ops when the skill source is missing" From 7fec55e0ed9bacb560050541ba456db7dec7687e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 30 Aug 2026 10:30:34 +0200 Subject: [PATCH 23/76] Leave Hermes Desktop's HUD the transparency it draws itself The HUD is a frameless Electron window that paints its own per-pixel transparency. Under the default rules it gets a compositor border and Omarchy's window opacity on top, which turns the compact prompt into an outlined, muddy canvas. Scoped to the HUD by title, so the main Hermes window keeps the ordinary treatment. Co-authored-by: Luiz Filipe Co-Authored-By: Claude Opus 5 (1M context) --- default/hypr/apps/hermes.lua | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 default/hypr/apps/hermes.lua diff --git a/default/hypr/apps/hermes.lua b/default/hypr/apps/hermes.lua new file mode 100644 index 00000000..a7ad307f --- /dev/null +++ b/default/hypr/apps/hermes.lua @@ -0,0 +1,7 @@ +-- Hermes Desktop's frameless HUD manages its own geometry. +o.window({ class = "^Hermes$", title = "^Hermes HUD$" }, { + tag = "-default-opacity", + float = true, + border_size = 0, + opacity = "1 1", +}) From 4d017913d06f715da9d960021861cf535e4f15aa Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 1 Sep 2026 11:54:15 +0200 Subject: [PATCH 24/76] Update the tagline to Beautiful, Fun & Agentic (#9584) Matches omarchy.org, the X header, and the ISO. The README and the agent skill carry their lowercase variants. --- README.md | 2 +- bin/omarchy-provision-owner | 2 +- default/agents/skills/omarchy/SKILL.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 96838a94..5cedfc5c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Omarchy -Omarchy is a beautiful, modern & opinionated Linux distribution by DHH. +Omarchy is a beautiful, fun & agentic Linux distribution by DHH. Read more at [omarchy.org](https://omarchy.org). diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index 4c21ff49..b5321a4d 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -449,7 +449,7 @@ greeter_screen() { rows=$(stty size 2>/dev/null # Omarchy Skill -Manage [Omarchy](https://omarchy.org/) Linux systems - a beautiful, modern, opinionated Arch Linux distribution with Hyprland. +Manage [Omarchy](https://omarchy.org/) Linux systems - a beautiful, fun, agentic Arch Linux distribution with Hyprland. This skill is for end-user customization on installed systems. It is not for contributing to Omarchy source code. From 1702cf0bee025aa32eddac391c4f9ac32244cfeb Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 1 Sep 2026 07:58:30 -0600 Subject: [PATCH 25/76] Restrict third-party shell plugin capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported-by: Roger Piñol --- agents/skills/shell-dev.md | 4 +- docs/omarchy-shell.md | 12 +- manual/32-shell-plugins.md | 4 +- shell/README.md | 7 +- shell/Ui/PluginBarApi.qml | 86 +++ shell/Ui/qmldir | 1 + shell/plugins/bar/Bar.qml | 232 +++++++- shell/plugins/lock/manifest.json | 5 + shell/plugins/panels/clock/Panel.qml | 4 +- shell/plugins/panels/weather/Panel.qml | 4 +- shell/plugins/polkit/manifest.json | 5 + shell/services/AuthServiceStore.js | 34 ++ shell/services/PluginAppLibraryApi.qml | 46 ++ shell/services/PluginBarStateApi.qml | 12 + shell/services/PluginBarWidgetRegistryApi.qml | 24 + shell/services/PluginFirstPartyServiceApi.qml | 46 ++ shell/services/PluginRegistry.qml | 30 +- shell/services/PluginRegistryApi.qml | 32 + shell/services/PluginShellApi.qml | 75 +++ shell/shell.qml | 547 +++++++++++++++++- .../plugin-auth-boundary/AuthStoreOwner.qml | 12 + .../plugin-auth-boundary/AuthStoreReader.qml | 8 + .../fixtures/plugin-auth-boundary/shell.qml | 82 +++ .../fixtures/plugin-registry/shell.qml | 15 + test/shell.d/plugin-auth-boundary-test.sh | 125 ++++ 25 files changed, 1403 insertions(+), 49 deletions(-) create mode 100644 shell/Ui/PluginBarApi.qml create mode 100644 shell/services/AuthServiceStore.js create mode 100644 shell/services/PluginAppLibraryApi.qml create mode 100644 shell/services/PluginBarStateApi.qml create mode 100644 shell/services/PluginBarWidgetRegistryApi.qml create mode 100644 shell/services/PluginFirstPartyServiceApi.qml create mode 100644 shell/services/PluginRegistryApi.qml create mode 100644 shell/services/PluginShellApi.qml create mode 100644 test/shell.d/fixtures/plugin-auth-boundary/AuthStoreOwner.qml create mode 100644 test/shell.d/fixtures/plugin-auth-boundary/AuthStoreReader.qml create mode 100644 test/shell.d/fixtures/plugin-auth-boundary/shell.qml create mode 100755 test/shell.d/plugin-auth-boundary-test.sh diff --git a/agents/skills/shell-dev.md b/agents/skills/shell-dev.md index d33f21ef..59cc4e8b 100644 --- a/agents/skills/shell-dev.md +++ b/agents/skills/shell-dev.md @@ -20,9 +20,7 @@ Run `omarchy-restart-shell` after making changes to QML files. [`docs/omarchy-shell.md`](../../docs/omarchy-shell.md) and `shell/services/PluginRegistry.qml` for the current contract; fields such as `activation` are optional. -- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the - shell-injected properties `omarchyPath`, `shell`, `manifest`, and - `pluginRegistry` / `barWidgetRegistry` as appropriate. +- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the shell-injected properties `omarchyPath`, `shell`, `manifest`, and `pluginRegistry` / `barWidgetRegistry` as appropriate. First-party plugins receive the host objects. Third-party plugins receive capability-scoped facades: ordinary plugins may look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state; full-bar plugins additionally receive detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities must be stamped from trusted first-party manifests, and third-party registry views must be detached snapshots rather than shared objects. Do not expose host objects or authentication services through new third-party-facing properties. - Panel / overlay / menu plugins must expose `open(payloadJson)` and `close()` lifecycle methods for `shell summon` and `shell hide`. diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index a7c1b389..2fc0a9b3 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -41,10 +41,7 @@ Panels, overlays, and menus are loaded when summoned. Plugins can set the top-level manifest key `keepLoaded: true` to survive between summons. First-party services are loaded at startup. -Entry points are QML `Item`s. Panel, overlay, and menu entry points expose -`open(payloadJson)` and `close()` for summon/hide; on load the host injects -`omarchyPath`, `shell`, `manifest`, and the registries (`pluginRegistry` / -`barWidgetRegistry`) as properties. +Entry points are QML `Item`s. Panel, overlay, and menu entry points expose `open(payloadJson)` and `close()` for summon/hide; on load the host injects `omarchyPath`, `shell`, `manifest`, and the registries (`pluginRegistry` / `barWidgetRegistry`) as properties. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades instead: ordinary plugins may look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are kept out of the host's public service map and QML object tree, and third-party registry snapshots can be changed only locally without mutating the host registries. Full schema: [`shell/services/PluginRegistry.qml`](../shell/services/PluginRegistry.qml). @@ -81,12 +78,7 @@ one replaces the active bar, and it is therefore never offered under Disable. Bar widgets may set `barWidget.defaultSection` to `left`, `center`, or `right`; widgets that omit it default to `center`. -Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding warns you -before cloning, plugins land disabled so you can review the code before -`omarchy plugin enable`, and updates show a diff before touching anything. -Commands confirm in a terminal even when given arguments; without one they -refuse rather than guess. Add `--yes` to skip every prompt (the path for -scripts and agents). +Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding warns you before cloning, plugins land disabled so you can review the code before `omarchy plugin enable`, and updates show a diff before touching anything. Commands confirm in a terminal even when given arguments; without one they refuse rather than guess. Add `--yes` to skip every prompt (the path for scripts and agents). The scoped QML interfaces protect shell-owned credentials and cross-plugin controls; they are not an operating-system sandbox, so plugin code still has the same user-level file and process access as the shell. You can still install by hand: drop a plugin into `~/.config/omarchy/plugins//`, run `omarchy-shell shell rescanPlugins`, then diff --git a/manual/32-shell-plugins.md b/manual/32-shell-plugins.md index d54df3fb..bf7f1f2d 100644 --- a/manual/32-shell-plugins.md +++ b/manual/32-shell-plugins.md @@ -4,7 +4,7 @@ The Omarchy desktop runs as a single long-lived Quickshell process called `omarc That's not just an implementation detail. It means you can turn pieces of the desktop off, swap them out, or write your own without touching a line of Omarchy's source. -The first-party plugins ship with Omarchy and live in `$OMARCHY_PATH/shell/plugins/`. Anything you add yourself — your own experiments, or something you found on GitHub — lives in `~/.config/omarchy/plugins/`. Both are discovered the same way at startup; the only difference is where they sit on disk. +The first-party plugins ship with Omarchy and live in `$OMARCHY_PATH/shell/plugins/`. Anything you add yourself — your own experiments, or something you found on GitHub — lives in `~/.config/omarchy/plugins/`. Both are discovered the same way at startup, but built-ins receive trusted shell interfaces while third-party plugins receive a limited interface scoped to their own service and lifecycle. ## Seeing what you have @@ -37,7 +37,7 @@ A third-party plugin is just a git repo with a `manifest.json` at its root. omarchy plugin add https://github.com/acme/omarchy-weather.git --enable ``` -Before it does anything, it tells you plainly that plugins run as arbitrary, unsandboxed code inside your long-lived shell process, shows you the URL, and asks you to confirm. Take that seriously. A plugin isn't a config file — it's code that runs for as long as your session does, with everything your user account can reach. Only add repos you're willing to run, and read them before you enable them. +Before it does anything, it tells you plainly that plugins run as arbitrary, unsandboxed code inside your long-lived shell process, shows you the URL, and asks you to confirm. Take that seriously. The shell does not expose its authentication state or raw host object tree through the third-party plugin interface. A replacement bar receives additional limited capabilities so it can render built-in widgets and orchestrate the configured non-authentication UI, but it still cannot reach authentication services. This is not an operating-system sandbox: plugin code still runs for as long as your session does, with everything your user account can reach. Only add repos you're willing to run, and read them before you enable them. Then it clones the repo into a staging directory, validates the manifest, refuses the install if another plugin already claims that id, and moves it into `~/.config/omarchy/plugins//`. Without `--enable` it asks whether you want it on now, and you can say no and go read the code first. It never runs anything from the plugin, never executes an install hook, and never asks for sudo — it clones files, checks the manifest, and flips a bit over IPC. diff --git a/shell/README.md b/shell/README.md index e72d02ce..039280ff 100644 --- a/shell/README.md +++ b/shell/README.md @@ -89,6 +89,8 @@ to outlive a single summon can set `keepLoaded: true` (e.g. the image picker keeps its overlay window mounted between summons). First-party services are loaded at startup. +Entry points may declare `omarchyPath`, `shell`, `manifest`, `pluginRegistry`, and `barWidgetRegistry` properties for host injection. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades: ordinary plugins can look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are retained outside the host's public service map and QML object tree, and changing a third-party registry snapshot cannot mutate the host registry. + The full schema lives in `services/PluginRegistry.qml`. ## Installing a third-party plugin @@ -104,10 +106,7 @@ omarchy plugin update # updates every git-managed plugin omarchy plugin remove acme.weather ``` -> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding warns -> you before cloning, plugins land disabled so you can review the code before -> enabling, and updates show a diff of the changes before touching anything. -> Only add repos whose code you are willing to run. +> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding warns you before cloning, plugins land disabled so you can review the code before enabling, and updates show a diff of the changes before touching anything. The scoped QML interfaces protect shell-owned credentials and cross-plugin controls; they are not an operating-system sandbox. Only add repos whose code you are willing to run. Each command is **interactive** when run bare in a terminal (gum pickers, confirmation, a diff to review) and fully **non-interactive** when given diff --git a/shell/Ui/PluginBarApi.qml b/shell/Ui/PluginBarApi.qml new file mode 100644 index 00000000..c30021a5 --- /dev/null +++ b/shell/Ui/PluginBarApi.qml @@ -0,0 +1,86 @@ +import QtQuick + +// Bar surface exposed to an installed third-party widget. Scalar presentation +// state is mirrored by Bar.qml and operations are delegated through scoped +// callbacks, so the actual Bar (and its root-shell property) is never retained. +QtObject { + id: api + + required property string pluginId + required property string moduleName + property var shell: null + + property color foreground: "transparent" + property color barForeground: "transparent" + property color background: "transparent" + property color urgent: "transparent" + property string fontFamily: "" + property string position: "top" + property bool vertical: false + property int barSize: 0 + property bool transparent: false + property bool foregroundAnimationEnabled: true + property bool centerSectionRevealHeld: false + property bool _centerHoverRevealSuppressed: false + readonly property bool centerHoverRevealSuppressed: _centerHoverRevealSuppressed + property var activePopout: null + property var clickTargets: [] + property var layoutConfig: ({}) + readonly property var foreignPopoutMarker: ({ foreign: true }) + + property var _showTooltip: null + property var _hideTooltip: null + property var _registerClickTarget: null + property var _unregisterClickTarget: null + property var _requestPopout: null + property var _releasePopout: null + property var _switchPanelFrom: null + property var _targetBelongsToWindow: null + property var _moduleWidgets: null + property var _run: null + property var _setCenterHoverRevealSuppressed: null + + function setCenterHoverRevealSuppressed(value) { + if (_setCenterHoverRevealSuppressed) _setCenterHoverRevealSuppressed(!!value) + } + + function showTooltip(target, text) { + if (_showTooltip) _showTooltip(target, String(text || "")) + } + + function hideTooltip(target) { + if (_hideTooltip) _hideTooltip(target) + } + + function registerClickTarget(target) { + if (_registerClickTarget) _registerClickTarget(target) + } + + function unregisterClickTarget(target) { + if (_unregisterClickTarget) _unregisterClickTarget(target) + } + + function requestPopout(owner) { + if (_requestPopout) _requestPopout(owner) + } + + function releasePopout(owner) { + if (_releasePopout) _releasePopout(owner) + } + + function switchPanelFrom(owner, direction) { + return _switchPanelFrom ? _switchPanelFrom(owner, direction) : false + } + + function targetBelongsToWindow(target, window) { + return _targetBelongsToWindow ? _targetBelongsToWindow(target, window) : false + } + + function moduleWidgets(id) { + return _moduleWidgets ? _moduleWidgets(String(id || "")) : [] + } + + function run(command) { + if (_run) _run(String(command || "")) + } +} diff --git a/shell/Ui/qmldir b/shell/Ui/qmldir index b25bf5ca..5d8e6d4a 100644 --- a/shell/Ui/qmldir +++ b/shell/Ui/qmldir @@ -23,6 +23,7 @@ PanelSectionHeader 1.0 PanelSectionHeader.qml PanelSeparator 1.0 PanelSeparator.qml PanelSlider 1.0 PanelSlider.qml PanelToolTip 1.0 PanelToolTip.qml +PluginBarApi 1.0 PluginBarApi.qml PointerMoveGate 1.0 PointerMoveGate.qml ScreenMoveRemap 1.0 ScreenMoveRemap.qml PopupCard 1.0 PopupCard.qml diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index def615af..df02a2f7 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -12,20 +12,29 @@ Item { id: root // The omarchy-shell host injects omarchyPath from OMARCHY_PATH. - required property string omarchyPath + property string omarchyPath: Quickshell.env("OMARCHY_PATH") // Injected by the host shell so bar slots can resolve enabled widgets. - required property var barWidgetRegistry + property var barWidgetRegistry: fallbackBarWidgetRegistry + // Read-only registry view for third-party full bars; the built-in bar does + // not otherwise need it, but declaring it keeps clone construction atomic. + property var pluginRegistry: null // Injected by the host shell every time shell.json is reloaded. Holds the // `bar:` subtree: position, centerAnchor, layout. The host owns file IO; // the bar just renders whatever it's handed. The bar font follows the // OS-level fontconfig monospace binding — it is not stored in shell.json. - required property var barConfig + property var barConfig: ({}) // Injected by the host shell. Used for shell-wide actions such as opening // settings and persisting inline widget state. property var shell: null // Manifest for the active bar option. Present for custom bars and useful for // diagnostics; the built-in bar does not otherwise need it. property var manifest: null + QtObject { + id: fallbackBarWidgetRegistry + property var widgets: ({}) + property int revision: 0 + function metadataFor(id) { return null } + } // Mirrors the on-disk `bar-off` flag so the user can hide the bar without // killing the entire shell. Hidden panels stay mapped but park off-screen // without an exclusion zone; updated by the FileView watcher further down. @@ -100,6 +109,213 @@ Item { property var barMoveScreen: null property var clickTargets: [] property var moduleSlots: [] + property var pluginBarApis: ({}) + property var pluginObjectOwners: [] + + Component { + id: pluginBarApiComponent + PluginBarApi { } + } + + function publicLayoutConfig() { + return JSON.parse(JSON.stringify(root.layoutConfig || {})) + } + + function bindPluginBarApi(api) { + if (!api) return + api.foreground = Qt.binding(function() { return root.foreground }) + api.barForeground = Qt.binding(function() { return root.barForeground }) + api.background = Qt.binding(function() { return root.background }) + api.urgent = Qt.binding(function() { return root.urgent }) + api.fontFamily = Qt.binding(function() { return root.fontFamily }) + api.position = Qt.binding(function() { return root.position }) + api.vertical = Qt.binding(function() { return root.vertical }) + api.barSize = Qt.binding(function() { return root.barSize }) + api.transparent = Qt.binding(function() { return root.transparent }) + api.foregroundAnimationEnabled = Qt.binding(function() { return root.foregroundAnimationEnabled }) + api.centerSectionRevealHeld = Qt.binding(function() { return root.centerSectionRevealHeld }) + api._centerHoverRevealSuppressed = Qt.binding(function() { return root.centerHoverRevealSuppressed }) + root.syncPluginBarApiObjects(api) + } + + function syncPluginBarApiObjects(api) { + if (!api) return + api.activePopout = root.pluginOwnsBarObject(api.pluginId, root.activePopout) + ? root.activePopout : (root.activePopout ? api.foreignPopoutMarker : null) + api.clickTargets = root.pluginClickTargets(api.pluginId) + api.layoutConfig = root.publicLayoutConfig() + } + + function pluginObjectRecord(target) { + for (var i = 0; i < pluginObjectOwners.length; i++) { + var record = pluginObjectOwners[i] + if (record && record.target === target) return record + } + return null + } + + function markPluginObject(pluginId, target, role) { + var key = String(pluginId || "") + if (!key || !target) return false + var record = root.pluginObjectRecord(target) + if (record && record.pluginId !== key) return false + var next = [] + for (var i = 0; i < pluginObjectOwners.length; i++) { + var existing = pluginObjectOwners[i] + if (!existing || existing.target !== target) next.push(existing) + } + var updated = record || { target: target, pluginId: key, clickTarget: false, popout: false } + updated[role] = true + next.push(updated) + pluginObjectOwners = next + return true + } + + function unmarkPluginObject(pluginId, target, role) { + var key = String(pluginId || "") + var next = [] + for (var i = 0; i < pluginObjectOwners.length; i++) { + var record = pluginObjectOwners[i] + if (!record || record.target !== target || record.pluginId !== key) { + next.push(record) + continue + } + record[role] = false + if (record.clickTarget || record.popout) next.push(record) + } + pluginObjectOwners = next + } + + function pluginOwnsBarObject(pluginId, target) { + var record = target ? root.pluginObjectRecord(target) : null + return !!record && record.pluginId === String(pluginId || "") + } + + function pluginClickTargets(pluginId) { + var out = [] + for (var i = 0; i < root.clickTargets.length; i++) { + var target = root.clickTargets[i] + if (root.pluginOwnsBarObject(pluginId, target)) out.push(target) + } + return out + } + + function syncAllPluginBarApiObjects() { + for (var id in pluginBarApis) root.syncPluginBarApiObjects(pluginBarApis[id]) + } + + function registerPluginClickTarget(pluginId, target) { + if (!root.markPluginObject(pluginId, target, "clickTarget")) return + root.registerClickTarget(target) + } + + function unregisterPluginClickTarget(pluginId, target) { + if (!root.pluginOwnsBarObject(pluginId, target)) return + root.unregisterClickTarget(target) + root.unmarkPluginObject(pluginId, target, "clickTarget") + } + + function requestPluginPopout(pluginId, owner) { + if (!root.markPluginObject(pluginId, owner, "popout")) return + root.requestPopout(owner) + } + + function releasePluginPopout(pluginId, owner) { + if (!root.pluginOwnsBarObject(pluginId, owner)) return + root.releasePopout(owner) + root.unmarkPluginObject(pluginId, owner, "popout") + } + + function pluginBarApiFor(pluginId, moduleName, registered) { + var key = String(pluginId || "") + if (!key) return null + if (pluginBarApis[key]) return pluginBarApis[key] + + var pluginShell = null + if (registered && root.shell && typeof root.shell.pluginShellForId === "function") + pluginShell = root.shell.pluginShellForId(moduleName) + else if (!registered && root.shell && typeof root.shell.pluginShellForBarEntry === "function") + pluginShell = root.shell.pluginShellForBarEntry(key, moduleName) + + var api = pluginBarApiComponent.createObject(null, { + pluginId: key, + moduleName: String(moduleName || ""), + shell: pluginShell, + _showTooltip: function(target, text) { root.showTooltip(target, text) }, + _hideTooltip: function(target) { root.hideTooltip(target) }, + _registerClickTarget: function(target) { root.registerPluginClickTarget(key, target) }, + _unregisterClickTarget: function(target) { root.unregisterPluginClickTarget(key, target) }, + _requestPopout: function(owner) { root.requestPluginPopout(key, owner) }, + _releasePopout: function(owner) { root.releasePluginPopout(key, owner) }, + _switchPanelFrom: function(owner, direction) { return root.switchPanelFrom(owner, direction) }, + _targetBelongsToWindow: function(target, window) { return root.targetBelongsToWindow(target, window) }, + _moduleWidgets: function(requestedId) { + return String(requestedId || "") === String(moduleName || "") + ? root.moduleWidgets(moduleName) : [] + }, + _run: function(command) { root.run(command) }, + _setCenterHoverRevealSuppressed: function(value) { + root.centerHoverRevealSuppressed = !!value + } + }) + if (!api) return null + root.bindPluginBarApi(api) + + var next = ({}) + for (var id in pluginBarApis) next[id] = pluginBarApis[id] + next[key] = api + pluginBarApis = next + return api + } + + function pluginBarApiUsed(pluginId) { + for (var i = 0; i < moduleSlots.length; i++) { + var slot = moduleSlots[i] + if (slot && slot.pluginApiId === pluginId) return true + } + return false + } + + function releasePluginObjects(pluginId) { + var owned = pluginObjectOwners.slice() + for (var i = 0; i < owned.length; i++) { + var record = owned[i] + if (!record || record.pluginId !== pluginId) continue + if (record.clickTarget) root.unregisterClickTarget(record.target) + if (record.popout && root.activePopout === record.target) root.releasePopout(record.target) + } + pluginObjectOwners = pluginObjectOwners.filter(function(record) { + return record && record.pluginId !== pluginId + }) + } + + function prunePluginBarApis() { + var next = ({}) + for (var id in pluginBarApis) { + var api = pluginBarApis[id] + if (root.pluginBarApiUsed(id)) { + next[id] = api + continue + } + root.releasePluginObjects(id) + if (api && typeof api.destroy === "function") api.destroy() + } + pluginBarApis = next + } + + onActivePopoutChanged: syncAllPluginBarApiObjects() + onClickTargetsChanged: syncAllPluginBarApiObjects() + onLayoutConfigChanged: syncAllPluginBarApiObjects() + onModuleSlotsChanged: Qt.callLater(prunePluginBarApis) + + Component.onDestruction: { + for (var id in pluginBarApis) { + root.releasePluginObjects(id) + if (pluginBarApis[id] && typeof pluginBarApis[id].destroy === "function") + pluginBarApis[id].destroy() + } + pluginBarApis = ({}) + } function registerClickTarget(target) { if (!target || clickTargets.indexOf(target) !== -1) return @@ -599,6 +815,10 @@ Item { if (barHoverCount === 0) centerSectionRevealTimer.restart() } + function setCenterHoverRevealSuppressed(value) { + centerHoverRevealSuppressed = !!value + } + Timer { id: centerSectionRevealTimer interval: 120 @@ -1548,6 +1768,9 @@ Item { readonly property string moduleName: root.entryId(entry) readonly property var moduleSettings: root.entrySettings(entry) readonly property string customType: root.customModuleType(entry) + readonly property var registryMetadata: root.barWidgetRegistry.metadataFor(root.canonicalWidgetId(moduleName)) + readonly property bool firstParty: registryMetadata && registryMetadata.firstParty === true + readonly property string pluginApiId: registered ? root.canonicalWidgetId(moduleName) : "bar-entry:" + moduleName // Re-evaluate when the registry mutates (Component reference changes, // plugin enabled/disabled, etc.). Reading the `widgets` property creates // the binding dependency — the wrapped function call alone wouldn't. @@ -1766,7 +1989,8 @@ Item { function injectProps() { var target = activeItem if (!target) return - if ("bar" in target) target.bar = root + if ("bar" in target) target.bar = firstParty + ? root : root.pluginBarApiFor(pluginApiId, moduleName, registered) if ("moduleName" in target) target.moduleName = moduleName if ("settings" in target) target.settings = moduleSettings } diff --git a/shell/plugins/lock/manifest.json b/shell/plugins/lock/manifest.json index 87acd939..30809608 100644 --- a/shell/plugins/lock/manifest.json +++ b/shell/plugins/lock/manifest.json @@ -5,6 +5,11 @@ "version": "1.0.0", "author": "Omarchy", "description": "Quickshell session lock with separate password and fingerprint PAM flows.", + "omarchy": { + "capabilities": [ + "authentication" + ] + }, "kinds": [ "service" ], diff --git a/shell/plugins/panels/clock/Panel.qml b/shell/plugins/panels/clock/Panel.qml index be5d08a0..ec3990e2 100644 --- a/shell/plugins/panels/clock/Panel.qml +++ b/shell/plugins/panels/clock/Panel.qml @@ -119,7 +119,9 @@ Panel { // Summoning by hotkey moves no pointer, so a hover the bar was still // holding must not keep the center indicators revealed behind the panel. function setCenterHoverRevealSuppressed(value) { - if (root.bar && "centerHoverRevealSuppressed" in root.bar) + if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function") + root.bar.setCenterHoverRevealSuppressed(value) + else if (root.bar && "centerHoverRevealSuppressed" in root.bar) root.bar.centerHoverRevealSuppressed = value } diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index edb12777..d9d43b68 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -63,7 +63,9 @@ Panel { } function setCenterHoverRevealSuppressed(value) { - if (root.bar && "centerHoverRevealSuppressed" in root.bar) + if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function") + root.bar.setCenterHoverRevealSuppressed(value) + else if (root.bar && "centerHoverRevealSuppressed" in root.bar) root.bar.centerHoverRevealSuppressed = value } diff --git a/shell/plugins/polkit/manifest.json b/shell/plugins/polkit/manifest.json index 1e80b655..5d1f037e 100644 --- a/shell/plugins/polkit/manifest.json +++ b/shell/plugins/polkit/manifest.json @@ -5,6 +5,11 @@ "version": "1.0.0", "author": "Omarchy", "description": "Theme-aware authentication dialog for privileged actions.", + "omarchy": { + "capabilities": [ + "authentication" + ] + }, "kinds": [ "service" ], diff --git a/shell/services/AuthServiceStore.js b/shell/services/AuthServiceStore.js new file mode 100644 index 00000000..ffe49f33 --- /dev/null +++ b/shell/services/AuthServiceStore.js @@ -0,0 +1,34 @@ +// Intentionally not `.pragma library`: QML JavaScript imports get a private +// module instance per importing component. shell.qml's instance retains the +// authentication services; a third-party plugin importing this file receives +// a separate empty store rather than a shared path to credential-bearing QML. + +var services = ({}) + +function has(id) { + return services[String(id || "")] !== undefined +} + +function put(id, service) { + var key = String(id || "") + if (!key || !service) return + if (services[key] && services[key] !== service && typeof services[key].destroy === "function") + services[key].destroy() + services[key] = service +} + +function ids() { + return Object.keys(services) +} + +function destroy(id) { + var key = String(id || "") + var service = services[key] + if (service && typeof service.destroy === "function") service.destroy() + delete services[key] +} + +function destroyAll() { + var keys = ids() + for (var i = 0; i < keys.length; i++) destroy(keys[i]) +} diff --git a/shell/services/PluginAppLibraryApi.qml b/shell/services/PluginAppLibraryApi.qml new file mode 100644 index 00000000..00d5e8f0 --- /dev/null +++ b/shell/services/PluginAppLibraryApi.qml @@ -0,0 +1,46 @@ +import QtQuick + +// Detached application-library capability for third-party menus. Callbacks +// expose the supported app-list operations without retaining AppLibrary or its +// ShellRoot parent in the plugin-visible object graph. +QtObject { + required property string ownerPluginId + + signal appsChanged() + + property var _entryName: null + property var _entrySubtext: null + property var _sortedEntries: null + property var _iconSource: null + property var _refreshIcons: null + property var _launch: null + property var _remove: null + + function entryName(entry) { + return _entryName ? _entryName(entry) : "" + } + + function entrySubtext(entry) { + return _entrySubtext ? _entrySubtext(entry) : "" + } + + function sortedEntries(query) { + return _sortedEntries ? _sortedEntries(String(query || "")) : [] + } + + function iconSource(icon) { + return _iconSource ? _iconSource(icon) : "" + } + + function refreshIcons() { + if (_refreshIcons) _refreshIcons() + } + + function launch(desktopId, name) { + if (_launch) _launch(String(desktopId || ""), String(name || "")) + } + + function remove(desktopId, name) { + if (_remove) _remove(String(desktopId || ""), String(name || "")) + } +} diff --git a/shell/services/PluginBarStateApi.qml b/shell/services/PluginBarStateApi.qml new file mode 100644 index 00000000..db802b77 --- /dev/null +++ b/shell/services/PluginBarStateApi.qml @@ -0,0 +1,12 @@ +import QtQuick + +// Scalar-only view of the active bar for plugins that position independent +// windows. The active Bar QObject is never retained here. +QtObject { + required property string ownerPluginId + + property bool barHidden: false + property int barSize: 0 + property string fontFamily: "" + property string position: "top" +} diff --git a/shell/services/PluginBarWidgetRegistryApi.qml b/shell/services/PluginBarWidgetRegistryApi.qml new file mode 100644 index 00000000..238729f1 --- /dev/null +++ b/shell/services/PluginBarWidgetRegistryApi.qml @@ -0,0 +1,24 @@ +import QtQuick + +// Detached widget-catalogue snapshot for third-party full-bar implementations. +// Plugins can render the referenced components, but mutating this local view +// cannot replace a registration in the host registry. +QtObject { + id: api + + property var widgets: ({}) + property int revision: 0 + + function metadataFor(id) { + var entry = widgets[String(id || "")] + return entry ? entry.metadata : null + } + + function availableIds() { + return Object.keys(widgets) + } + + function has(id) { + return widgets[String(id || "")] !== undefined + } +} diff --git a/shell/services/PluginFirstPartyServiceApi.qml b/shell/services/PluginFirstPartyServiceApi.qml new file mode 100644 index 00000000..1f052629 --- /dev/null +++ b/shell/services/PluginFirstPartyServiceApi.qml @@ -0,0 +1,46 @@ +import QtQuick + +// Narrow proxy for the non-authentication first-party services used by the +// built-in bar. It intentionally has no generic property or method forwarding. +QtObject { + required property string ownerPluginId + required property string serviceId + + property bool stayAwake: false + property bool enabled: false + property bool doNotDisturb: false + property var activePlayer: null + property var sourcePlayers: [] + + property var _setIdleEnabled: null + property var _setNightlight: null + property var _setDoNotDisturb: null + property var _runAction: null + property var _playerKey: null + property var _selectPlayer: null + + function setIdleEnabled(value) { + if (serviceId === "omarchy.idle" && _setIdleEnabled) _setIdleEnabled(!!value) + } + + function setNightlight(value) { + if (serviceId === "omarchy.nightlight" && _setNightlight) _setNightlight(!!value) + } + + function setDoNotDisturb(value) { + if (serviceId === "omarchy.notifications" && _setDoNotDisturb) _setDoNotDisturb(!!value) + } + + function runAction(action, showFeedback, playerId) { + if (serviceId === "omarchy.media" && _runAction) + _runAction(String(action || ""), !!showFeedback, String(playerId || "")) + } + + function playerKey(player) { + return serviceId === "omarchy.media" && _playerKey ? _playerKey(player) : "" + } + + function selectPlayer(playerId) { + if (serviceId === "omarchy.media" && _selectPlayer) _selectPlayer(String(playerId || "")) + } +} diff --git a/shell/services/PluginRegistry.qml b/shell/services/PluginRegistry.qml index f08bf765..e24c3a22 100644 --- a/shell/services/PluginRegistry.qml +++ b/shell/services/PluginRegistry.qml @@ -20,7 +20,7 @@ QtObject { property var shellConfigProvider: null property var shellConfigMutator: null - // { pluginId: manifest } — manifests have __sourceDir and __isFirstParty stamped in. + // { pluginId: manifest } — manifests have source/trust metadata stamped in. property var installedPlugins: ({}) property int registryRevision: 0 property bool scanning: false @@ -90,6 +90,32 @@ QtObject { return manifest } + function trustedCapabilities(manifest) { + if (!manifest || !manifest.__isFirstParty) return [] + var metadata = Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null + var declared = metadata && Array.isArray(metadata.capabilities) ? metadata.capabilities : [] + var out = [] + for (var i = 0; i < declared.length; i++) { + var capability = String(declared[i] || "") + if (capability && out.indexOf(capability) === -1) out.push(capability) + } + return out + } + + function stampHostCapabilities(firstParty, thirdParty) { + for (var firstPartyId in firstParty) + firstParty[firstPartyId].__hostCapabilities = trustedCapabilities(firstParty[firstPartyId]) + + for (var thirdPartyId in thirdParty) { + var manifest = thirdParty[thirdPartyId] + var metadata = manifest && Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null + var clonedFrom = metadata ? String(metadata.clonedFrom || "") : "" + var source = clonedFrom ? firstParty[clonedFrom] : null + manifest.__hostCapabilities = source && Array.isArray(source.__hostCapabilities) + ? source.__hostCapabilities.slice() : [] + } + } + function entryPointUrl(manifest, kind) { if (!Util.isPlainObject(manifest)) return "" var ep = manifest.entryPoints ? manifest.entryPoints[kind] : null @@ -594,6 +620,8 @@ QtObject { } flush() + stampHostCapabilities(firstParty, thirdParty) + var merged = {} for (var fk in firstParty) merged[fk] = firstParty[fk] // Third-party plugins never shadow first-party ids. The whole diff --git a/shell/services/PluginRegistryApi.qml b/shell/services/PluginRegistryApi.qml new file mode 100644 index 00000000..32033c2b --- /dev/null +++ b/shell/services/PluginRegistryApi.qml @@ -0,0 +1,32 @@ +import QtQuick + +// Read-only, self-scoped registry view for an installed third-party plugin. +// The host updates manifest/enabled when it rescans; no host registry object is +// retained here, so `parent` and property traversal cannot reach ShellRoot. +QtObject { + id: api + + required property string pluginId + property var manifest: null + property bool enabled: false + property var _entryPointUrl: null + + readonly property var installedPlugins: { + var out = ({}) + if (manifest) out[pluginId] = manifest + return out + } + + function isEnabled(id) { + return String(id || "") === pluginId && enabled + } + + function resolveEnabledId(id) { + return String(id || "") === pluginId ? pluginId : "" + } + + function entryPointUrl(candidate, kind) { + if (!candidate || String(candidate.id || "") !== pluginId) return "" + return _entryPointUrl ? _entryPointUrl(String(kind || "")) : "" + } +} diff --git a/shell/services/PluginShellApi.qml b/shell/services/PluginShellApi.qml new file mode 100644 index 00000000..faa34e8d --- /dev/null +++ b/shell/services/PluginShellApi.qml @@ -0,0 +1,75 @@ +import QtQuick + +// Capability-scoped shell surface for installed third-party plugins. +// +// The callbacks are closed over one plugin id by shell.qml. A plugin can call +// them directly, but it cannot widen their scope: ordinary plugins are limited +// to their own id, and full-bar callbacks independently enforce their explicit +// non-authentication UI scope. Keeping the host shell out of this object's +// properties also prevents ordinary QML object traversal from turning the +// facade back into the root ShellRoot. +QtObject { + id: api + + required property string pluginId + + property var appLibrary: null + property var bar: null + property var barConfig: ({}) + + property var _serviceLookup: null + property var _firstPartyServiceLookup: null + property var _pluginShellLookup: null + property var _barEntryShellLookup: null + property var _summon: null + property var _hide: null + property var _toggle: null + property var _isOpen: null + property var _updateSettings: null + property var _mutateBarConfig: null + + function serviceFor(id) { + return _serviceLookup ? _serviceLookup(String(id || "")) : null + } + + // Only full-bar facades receive narrow proxies for the specific + // non-authentication services used by the built-in bar widgets. + function firstPartyServiceFor(id) { + return _firstPartyServiceLookup + ? _firstPartyServiceLookup(String(id || "")) : null + } + + function pluginShellForId(id) { + return _pluginShellLookup ? _pluginShellLookup(String(id || "")) : null + } + + function pluginShellForBarEntry(ownerId, moduleName) { + return _barEntryShellLookup + ? _barEntryShellLookup(String(ownerId || ""), String(moduleName || "")) : null + } + + function summon(id, payloadJson) { + return _summon ? _summon(String(id || ""), String(payloadJson || "")) : false + } + + function hide(id) { + return _hide ? _hide(String(id || "")) : false + } + + function toggle(id, payloadJson) { + return _toggle ? _toggle(String(id || ""), String(payloadJson || "")) : false + } + + function isPluginOpen(id) { + return _isOpen ? _isOpen(String(id || "")) : false + } + + function updateEntryInline(id, settings) { + return _updateSettings ? _updateSettings(String(id || ""), settings) : false + } + + function mutateShellConfig(mutator) { + return _mutateBarConfig && typeof mutator === "function" + ? _mutateBarConfig(mutator) : false + } +} diff --git a/shell/shell.qml b/shell/shell.qml index 71a9834f..1a762328 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -7,6 +7,7 @@ import qs.Commons import "plugins/bar" import "services" +import "services/AuthServiceStore.js" as AuthServiceStore ShellRoot { id: shell @@ -214,10 +215,10 @@ ShellRoot { function configureBar(target, manifest) { if (!target) return if ("omarchyPath" in target) target.omarchyPath = shell.omarchyPath - if ("shell" in target) target.shell = shell - if ("manifest" in target) target.manifest = manifest - if ("barWidgetRegistry" in target) target.barWidgetRegistry = shell.barWidgetRegistry - if ("pluginRegistry" in target) target.pluginRegistry = shell.pluginRegistry + if ("shell" in target) target.shell = shell.pluginShellFor(manifest) + if ("manifest" in target) target.manifest = shell.publicPluginManifest(manifest) + if ("barWidgetRegistry" in target) target.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(manifest) + if ("pluginRegistry" in target) target.pluginRegistry = shell.pluginRegistryFor(manifest) if ("barConfig" in target) target.barConfig = shell.barConfig shell.bar = target } @@ -253,8 +254,7 @@ ShellRoot { onActiveChanged: if (!active) shell.bar = null onStatusChanged: { if (status === Loader.Error) { - var detail = errorString && errorString() ? errorString() : "" - console.warn("bar option " + shell.activeBarId + " failed to load, falling back to " + shell.defaultBarId + ":", detail) + console.warn("bar option " + shell.activeBarId + " failed to load, falling back to " + shell.defaultBarId) shell.failedBarId = shell.activeBarId } } @@ -271,6 +271,462 @@ ShellRoot { } property var _services: ({}) + property var _pluginShellApis: ({}) + property var _pluginBarEntryShellApis: ({}) + property var _pluginRegistryApis: ({}) + property var _pluginBarWidgetRegistryApis: ({}) + property var _pluginAppLibraryApis: ({}) + property var _pluginBarStateApis: ({}) + property var _pluginFirstPartyServiceApis: ({}) + + Component { + id: pluginShellApiComponent + PluginShellApi { } + } + + Component { + id: pluginRegistryApiComponent + PluginRegistryApi { } + } + + Component { + id: pluginBarWidgetRegistryApiComponent + PluginBarWidgetRegistryApi { } + } + + Component { + id: pluginAppLibraryApiComponent + PluginAppLibraryApi { } + } + + Component { + id: pluginBarStateApiComponent + PluginBarStateApi { } + } + + Component { + id: pluginFirstPartyServiceApiComponent + PluginFirstPartyServiceApi { } + } + + function publicPluginManifest(manifest) { + if (!manifest) return null + if (manifest.__isFirstParty) return manifest + var copy = JSON.parse(JSON.stringify(manifest)) + delete copy.__sourceDir + delete copy.__isFirstParty + delete copy.__hostCapabilities + return copy + } + + function publicBarConfig() { + return JSON.parse(JSON.stringify(shell.barConfig || {})) + } + + function publicBarWidgetSnapshot() { + var source = shell.barWidgetRegistry.widgets || {} + var snapshot = {} + for (var id in source) { + var entry = source[id] + if (!entry) continue + snapshot[id] = { + component: entry.component, + metadata: JSON.parse(JSON.stringify(entry.metadata || {})) + } + } + return snapshot + } + + function manifestHasKind(manifest, kind) { + return !!manifest && Array.isArray(manifest.kinds) + && manifest.kinds.indexOf(kind) !== -1 + } + + function pluginHasBarCapabilities(manifest) { + return shell.manifestHasKind(manifest, "bar") + } + + function pluginOwnsTarget(pluginId, requestedId) { + var caller = String(pluginId || "") + if (!caller) return false + return shell.pluginRegistry.resolveEnabledId(String(requestedId || "")) === caller + } + + function pluginServiceFor(pluginId, requestedId) { + if (!shell.pluginOwnsTarget(pluginId, requestedId)) return null + return shell.serviceFor(shell.pluginRegistry.resolveEnabledId(requestedId)) + } + + function barEntryConfigured(pluginId) { + var location = shell.pluginRegistry.findEntryLocation(shell.shellConfig, pluginId) + return location && location.kind === "bar" + } + + function barPluginMayControl(manifest, requestedId) { + if (!shell.pluginHasBarCapabilities(manifest)) return false + var id = shell.pluginRegistry.resolveEnabledId(String(requestedId || "")) + var target = shell.pluginRegistry.installedPlugins[id] + if (!target || shell.isAuthenticationService(target)) return false + if (shell.barEntryConfigured(id)) return true + var uiKinds = ["bar-widget", "panel", "overlay", "menu"] + for (var i = 0; i < uiKinds.length; i++) + if (shell.manifestHasKind(target, uiKinds[i])) return true + return false + } + + function mutatePluginBarConfig(mutator) { + if (typeof mutator !== "function") return false + shell.mutateShellConfig(function(config) { + var scoped = { bar: JSON.parse(JSON.stringify(config.bar || {})) } + mutator(scoped) + if (Util.isPlainObject(scoped.bar)) config.bar = JSON.parse(JSON.stringify(scoped.bar)) + }) + return true + } + + function pluginAppLibraryFor(cacheKey, pluginId) { + if (_pluginAppLibraryApis[cacheKey]) return _pluginAppLibraryApis[cacheKey] + var api = pluginAppLibraryApiComponent.createObject(null, { + ownerPluginId: pluginId, + _entryName: function(entry) { return shell.appLibrary.entryName(entry) }, + _entrySubtext: function(entry) { return shell.appLibrary.entrySubtext(entry) }, + _sortedEntries: function(query) { return shell.appLibrary.sortedEntries(query) }, + _iconSource: function(icon) { return shell.appLibrary.iconSource(icon) }, + _refreshIcons: function() { shell.appLibrary.refreshIcons() }, + _launch: function(desktopId, name) { shell.appLibrary.launch(desktopId, name) }, + _remove: function(desktopId, name) { shell.appLibrary.remove(desktopId, name) } + }) + if (!api) return null + var next = ({}) + for (var id in _pluginAppLibraryApis) next[id] = _pluginAppLibraryApis[id] + next[cacheKey] = api + _pluginAppLibraryApis = next + return api + } + + function pluginBarStateFor(cacheKey, pluginId) { + if (_pluginBarStateApis[cacheKey]) return _pluginBarStateApis[cacheKey] + var api = pluginBarStateApiComponent.createObject(null, { ownerPluginId: pluginId }) + if (!api) return null + api.barHidden = Qt.binding(function() { return shell.bar ? shell.bar.barHidden === true : false }) + api.barSize = Qt.binding(function() { return shell.bar ? Math.max(0, shell.bar.barSize || 0) : 0 }) + api.fontFamily = Qt.binding(function() { return shell.bar ? String(shell.bar.fontFamily || "") : "" }) + api.position = Qt.binding(function() { return shell.bar ? String(shell.bar.position || "top") : "top" }) + var next = ({}) + for (var id in _pluginBarStateApis) next[id] = _pluginBarStateApis[id] + next[cacheKey] = api + _pluginBarStateApis = next + return api + } + + function pluginFirstPartyServiceFor(cacheKey, pluginId, requestedId) { + var id = String(requestedId || "") + var allowed = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications"] + if (allowed.indexOf(id) === -1) return null + var proxyKey = cacheKey + "::" + id + if (_pluginFirstPartyServiceApis[proxyKey]) return _pluginFirstPartyServiceApis[proxyKey] + + function service() { return shell.serviceFor(id) } + var api = pluginFirstPartyServiceApiComponent.createObject(null, { + ownerPluginId: pluginId, + serviceId: id, + _setIdleEnabled: function(value) { + var target = service() + if (target && typeof target.setIdleEnabled === "function") target.setIdleEnabled(value) + }, + _setNightlight: function(value) { + var target = service() + if (target && typeof target.setNightlight === "function") target.setNightlight(value) + }, + _setDoNotDisturb: function(value) { + var target = service() + if (target && typeof target.setDoNotDisturb === "function") target.setDoNotDisturb(value) + }, + _runAction: function(action, showFeedback, targetKey) { + var target = service() + if (target && typeof target.runAction === "function") target.runAction(action, showFeedback, targetKey) + }, + _playerKey: function(player) { + var target = service() + return target && typeof target.playerKey === "function" ? target.playerKey(player) : "" + }, + _selectPlayer: function(playerKey) { + var target = service() + if (target && typeof target.selectPlayer === "function") target.selectPlayer(playerKey) + } + }) + if (!api) return null + api.stayAwake = Qt.binding(function() { + var target = service() + return target ? target.stayAwake === true : false + }) + api.enabled = Qt.binding(function() { + var target = service() + return target ? target.enabled === true : false + }) + api.doNotDisturb = Qt.binding(function() { + var target = service() + return target ? target.doNotDisturb === true : false + }) + api.activePlayer = Qt.binding(function() { + var target = service() + return target ? target.activePlayer : null + }) + api.sourcePlayers = Qt.binding(function() { + var target = service() + return target && Array.isArray(target.sourcePlayers) ? target.sourcePlayers : [] + }) + var next = ({}) + for (var existing in _pluginFirstPartyServiceApis) next[existing] = _pluginFirstPartyServiceApis[existing] + next[proxyKey] = api + _pluginFirstPartyServiceApis = next + return api + } + + function createScopedPluginShell(manifest, cacheKey, allowOwnService, barCapabilities) { + if (_pluginShellApis[cacheKey]) return _pluginShellApis[cacheKey] + var key = String(manifest && manifest.id || "") + if (!key) return null + + // Construct the narrow service proxies before any plugin binding can call + // firstPartyServiceFor(). Creating a QObject while evaluating that binding + // makes QML re-enter the binding and report a loop on the caller's service + // property, even though the resulting proxy is otherwise acyclic. + var firstPartyServices = ({}) + if (barCapabilities) { + var serviceIds = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications"] + for (var i = 0; i < serviceIds.length; i++) { + var serviceId = serviceIds[i] + firstPartyServices[serviceId] = shell.pluginFirstPartyServiceFor(cacheKey, key, serviceId) + } + } + + var api = pluginShellApiComponent.createObject(null, { + pluginId: key, + appLibrary: shell.manifestHasKind(manifest, "menu") + ? shell.pluginAppLibraryFor(cacheKey, key) : null, + bar: shell.pluginBarStateFor(cacheKey, key), + barConfig: shell.publicBarConfig(), + _serviceLookup: function(requestedId) { + return allowOwnService ? shell.pluginServiceFor(key, requestedId) : null + }, + _firstPartyServiceLookup: function(requestedId) { + return barCapabilities ? (firstPartyServices[requestedId] || null) : null + }, + _pluginShellLookup: function(requestedId) { + return barCapabilities ? shell.scopedPluginShellForId(requestedId) : null + }, + _barEntryShellLookup: function(ownerId, moduleName) { + return barCapabilities + ? shell.pluginShellForBarEntry(cacheKey + ":" + ownerId, moduleName) : null + }, + _summon: function(requestedId, payloadJson) { + if (!shell.pluginOwnsTarget(key, requestedId) + && !shell.barPluginMayControl(manifest, requestedId)) return false + return shell.summon(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson) + }, + _hide: function(requestedId) { + if (!shell.pluginOwnsTarget(key, requestedId) + && !shell.barPluginMayControl(manifest, requestedId)) return false + return shell.hide(shell.pluginRegistry.resolveEnabledId(requestedId)) + }, + _toggle: function(requestedId, payloadJson) { + if (!shell.pluginOwnsTarget(key, requestedId) + && !shell.barPluginMayControl(manifest, requestedId)) return false + return shell.toggle(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson) + }, + _isOpen: function(requestedId) { + if (!shell.pluginOwnsTarget(key, requestedId) + && !shell.barPluginMayControl(manifest, requestedId)) return false + return shell.isPluginOpen(shell.pluginRegistry.resolveEnabledId(requestedId)) + }, + _updateSettings: function(requestedId, settings) { + if (shell.pluginOwnsTarget(key, requestedId)) return shell.updateEntryInline(key, settings) + if (barCapabilities && shell.barEntryConfigured(requestedId)) + return shell.updateEntryInline(requestedId, settings) + return false + }, + _mutateBarConfig: function(mutator) { + return barCapabilities ? shell.mutatePluginBarConfig(mutator) : false + } + }) + if (!api) return null + + var next = ({}) + for (var id in _pluginShellApis) next[id] = _pluginShellApis[id] + next[cacheKey] = api + _pluginShellApis = next + return api + } + + function scopedPluginShellForId(pluginId) { + var key = String(pluginId || "") + var manifest = shell.pluginRegistry.installedPlugins[key] + if (!manifest) return null + if (!manifest.__isFirstParty) return shell.pluginShellFor(manifest) + return shell.createScopedPluginShell(manifest, "hosted:" + key, false, false) + } + + function pluginShellForId(pluginId) { + return shell.scopedPluginShellForId(pluginId) + } + + function pluginShellForBarEntry(ownerId, moduleName) { + var owner = String(ownerId || "") + var target = String(moduleName || "") + if (!owner || !target) return null + var cacheKey = owner + "::" + target + if (_pluginBarEntryShellApis[cacheKey]) return _pluginBarEntryShellApis[cacheKey] + var api = pluginShellApiComponent.createObject(null, { + pluginId: target, + barConfig: shell.publicBarConfig(), + _updateSettings: function(requestedId, settings) { + return String(requestedId || "") === target + ? shell.updateEntryInline(target, settings) : false + } + }) + if (!api) return null + var next = ({}) + for (var id in _pluginBarEntryShellApis) next[id] = _pluginBarEntryShellApis[id] + next[cacheKey] = api + _pluginBarEntryShellApis = next + return api + } + + function pluginShellFor(manifest) { + if (!manifest || manifest.__isFirstParty) return shell + var key = String(manifest.id || "") + if (!key) return null + return shell.createScopedPluginShell(manifest, key, true, shell.pluginHasBarCapabilities(manifest)) + } + + function pluginRegistryFor(manifest) { + if (!manifest || manifest.__isFirstParty) return shell.pluginRegistry + var key = String(manifest.id || "") + if (!key) return null + if (_pluginRegistryApis[key]) return _pluginRegistryApis[key] + + var api = pluginRegistryApiComponent.createObject(null, { + pluginId: key, + manifest: shell.publicPluginManifest(manifest), + enabled: shell.pluginRegistry.isEnabled(key), + _entryPointUrl: function(kind) { + var current = shell.pluginRegistry.installedPlugins[key] + return current ? shell.pluginRegistry.entryPointUrl(current, kind) : "" + } + }) + if (!api) return null + + var next = ({}) + for (var id in _pluginRegistryApis) next[id] = _pluginRegistryApis[id] + next[key] = api + _pluginRegistryApis = next + return api + } + + function pluginBarWidgetRegistryFor(manifest) { + if (!manifest || manifest.__isFirstParty) return shell.barWidgetRegistry + var key = String(manifest.id || "") + if (!key) return null + if (_pluginBarWidgetRegistryApis[key]) return _pluginBarWidgetRegistryApis[key] + + var api = pluginBarWidgetRegistryApiComponent.createObject(null, { + widgets: shell.publicBarWidgetSnapshot(), + revision: shell.barWidgetRegistry.revision + }) + if (!api) return null + + var next = ({}) + for (var id in _pluginBarWidgetRegistryApis) next[id] = _pluginBarWidgetRegistryApis[id] + next[key] = api + _pluginBarWidgetRegistryApis = next + return api + } + + function pluginApiActive(api, plugins) { + var id = api ? String(api.pluginId || api.ownerPluginId || "") : "" + var manifest = id ? plugins[id] : null + return !!manifest && shell.pluginRegistry.isEnabled(id) + } + + function prunePluginApis() { + var plugins = shell.pluginRegistry.installedPlugins + var shellNext = ({}) + for (var shellKey in _pluginShellApis) { + var shellApi = _pluginShellApis[shellKey] + if (shell.pluginApiActive(shellApi, plugins)) shellNext[shellKey] = shellApi + else if (shellApi && typeof shellApi.destroy === "function") shellApi.destroy() + } + _pluginShellApis = shellNext + + var registryNext = ({}) + for (var registryKey in _pluginRegistryApis) { + var registryApi = _pluginRegistryApis[registryKey] + if (shell.pluginApiActive(registryApi, plugins)) registryNext[registryKey] = registryApi + else if (registryApi && typeof registryApi.destroy === "function") registryApi.destroy() + } + _pluginRegistryApis = registryNext + + var widgetNext = ({}) + for (var widgetKey in _pluginBarWidgetRegistryApis) { + var widgetApi = _pluginBarWidgetRegistryApis[widgetKey] + if (plugins[widgetKey] && shell.pluginRegistry.isEnabled(widgetKey)) widgetNext[widgetKey] = widgetApi + else if (widgetApi && typeof widgetApi.destroy === "function") widgetApi.destroy() + } + _pluginBarWidgetRegistryApis = widgetNext + + var appNext = ({}) + for (var appKey in _pluginAppLibraryApis) { + var appApi = _pluginAppLibraryApis[appKey] + if (shell.pluginApiActive(appApi, plugins)) appNext[appKey] = appApi + else if (appApi && typeof appApi.destroy === "function") appApi.destroy() + } + _pluginAppLibraryApis = appNext + + var barStateNext = ({}) + for (var barStateKey in _pluginBarStateApis) { + var barStateApi = _pluginBarStateApis[barStateKey] + if (shell.pluginApiActive(barStateApi, plugins)) barStateNext[barStateKey] = barStateApi + else if (barStateApi && typeof barStateApi.destroy === "function") barStateApi.destroy() + } + _pluginBarStateApis = barStateNext + + var serviceNext = ({}) + for (var serviceKey in _pluginFirstPartyServiceApis) { + var serviceApi = _pluginFirstPartyServiceApis[serviceKey] + if (shell.pluginApiActive(serviceApi, plugins)) serviceNext[serviceKey] = serviceApi + else if (serviceApi && typeof serviceApi.destroy === "function") serviceApi.destroy() + } + _pluginFirstPartyServiceApis = serviceNext + + var entryNext = ({}) + for (var entryKey in _pluginBarEntryShellApis) { + var entryApi = _pluginBarEntryShellApis[entryKey] + if (entryApi && shell.barEntryConfigured(entryApi.pluginId)) entryNext[entryKey] = entryApi + else if (entryApi && typeof entryApi.destroy === "function") entryApi.destroy() + } + _pluginBarEntryShellApis = entryNext + } + + function syncPluginApis() { + shell.prunePluginApis() + var plugins = shell.pluginRegistry.installedPlugins + for (var id in _pluginRegistryApis) { + var registryApi = _pluginRegistryApis[id] + var manifest = plugins[id] + registryApi.manifest = shell.publicPluginManifest(manifest) + registryApi.enabled = !!manifest && shell.pluginRegistry.isEnabled(id) + } + for (var widgetId in _pluginBarWidgetRegistryApis) { + var widgetApi = _pluginBarWidgetRegistryApis[widgetId] + widgetApi.widgets = shell.publicBarWidgetSnapshot() + widgetApi.revision = shell.barWidgetRegistry.revision + } + for (var shellKey in _pluginShellApis) + _pluginShellApis[shellKey].barConfig = shell.publicBarConfig() + for (var entryKey in _pluginBarEntryShellApis) + _pluginBarEntryShellApis[entryKey].barConfig = shell.publicBarConfig() + } function serviceFor(pluginId) { return _services[String(pluginId)] || null @@ -280,6 +736,11 @@ ShellRoot { return serviceFor(pluginId) } + function isAuthenticationService(manifest) { + return !!manifest && Array.isArray(manifest.__hostCapabilities) + && manifest.__hostCapabilities.indexOf("authentication") !== -1 + } + function ensureService(pluginId) { var key = String(pluginId) if (_services[key]) return _services[key] @@ -290,6 +751,8 @@ ShellRoot { if (!manifest.entryPoints || !manifest.entryPoints.service) return null var url = pluginRegistry.entryPointUrl(manifest, "service") if (!url) return null + var authenticationService = shell.isAuthenticationService(manifest) + if (authenticationService && AuthServiceStore.has(key)) return null var comp = Qt.createComponent(url, Component.PreferSynchronous) function finalize() { @@ -297,27 +760,37 @@ ShellRoot { console.warn("service plugin load failed for " + key + ": " + comp.errorString()) return } - var inst = comp.createObject(serviceHost) + // Authentication services and third-party services have no visual + // parent. Parenting either to serviceHost would let a plugin's object + // traversal walk between the host and credential-bearing QML. + var inst = comp.createObject(manifest.__isFirstParty && !authenticationService ? serviceHost : null) if (!inst) { console.warn("service plugin createObject returned null for", key) return } if ("omarchyPath" in inst) inst.omarchyPath = shell.omarchyPath - if ("shell" in inst) inst.shell = shell - if ("manifest" in inst) inst.manifest = manifest - if ("barWidgetRegistry" in inst) inst.barWidgetRegistry = shell.barWidgetRegistry - if ("pluginRegistry" in inst) inst.pluginRegistry = shell.pluginRegistry - var snext = ({}) - for (var sk in _services) snext[sk] = _services[sk] - snext[key] = inst - _services = snext + if ("shell" in inst) inst.shell = shell.pluginShellFor(manifest) + if ("manifest" in inst) inst.manifest = shell.publicPluginManifest(manifest) + if ("barWidgetRegistry" in inst) inst.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(manifest) + if ("pluginRegistry" in inst) inst.pluginRegistry = shell.pluginRegistryFor(manifest) + if (authenticationService) { + // Never publish lock/polkit through ShellRoot._services. The private JS + // import retains their lifetime without adding a traversable property + // or QObject parent back to the host shell. + AuthServiceStore.put(key, inst) + } else { + var snext = ({}) + for (var sk in _services) snext[sk] = _services[sk] + snext[key] = inst + _services = snext + } } if (comp.status === Component.Loading) { comp.statusChanged.connect(finalize) return null } finalize() - return _services[key] || null + return authenticationService ? null : (_services[key] || null) } function _syncServices() { @@ -329,7 +802,7 @@ ShellRoot { if (!Array.isArray(m.kinds) || m.kinds.indexOf("service") === -1) continue if (!m.entryPoints || !m.entryPoints.service) continue if (!pluginRegistry.isEnabled(id)) continue - if (_services[id]) continue + if (_services[id] || (shell.isAuthenticationService(m) && AuthServiceStore.has(id))) continue ensureService(id) } // Drop services for plugins that have been disabled or removed. @@ -343,6 +816,16 @@ ShellRoot { for (var k in _services) if (k !== existingId) next[k] = _services[k] _services = next } + // Authentication services are retained outside the root object graph, so + // reconcile their disable/remove lifecycle separately from _services. + var authenticationIds = AuthServiceStore.ids() + for (var ai = 0; ai < authenticationIds.length; ai++) { + var authenticationId = authenticationIds[ai] + var authenticationManifest = plugins[authenticationId] + if (authenticationManifest && pluginRegistry.isEnabled(authenticationId) + && shell.isAuthenticationService(authenticationManifest)) continue + AuthServiceStore.destroy(authenticationId) + } } function unloadPluginServices() { @@ -351,11 +834,28 @@ ShellRoot { if (inst && typeof inst.destroy === "function") inst.destroy() } _services = ({}) + AuthServiceStore.destroyAll() } Connections { target: shell.pluginRegistry - function onPluginsChanged() { if (!shell.pluginReloading) shell._syncServices() } + function onPluginsChanged() { + shell.syncPluginApis() + if (!shell.pluginReloading) shell._syncServices() + } + } + + Connections { + target: shell.barWidgetRegistry + function onChanged() { shell.syncPluginApis() } + } + + Connections { + target: shell.appLibrary + function onAppsChanged() { + for (var id in shell._pluginAppLibraryApis) + shell._pluginAppLibraryApis[id].appsChanged() + } } // Writes inline settings to a bar layout entry or top-level plugin entry in @@ -627,10 +1127,10 @@ ShellRoot { onLoaded: { if (!item) return if ("omarchyPath" in item) item.omarchyPath = shell.omarchyPath - if ("shell" in item) item.shell = shell - if ("manifest" in item) item.manifest = panelEntry.manifest - if ("barWidgetRegistry" in item) item.barWidgetRegistry = shell.barWidgetRegistry - if ("pluginRegistry" in item) item.pluginRegistry = shell.pluginRegistry + if ("shell" in item) item.shell = shell.pluginShellFor(panelEntry.manifest) + if ("manifest" in item) item.manifest = shell.publicPluginManifest(panelEntry.manifest) + if ("barWidgetRegistry" in item) item.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(panelEntry.manifest) + if ("pluginRegistry" in item) item.pluginRegistry = shell.pluginRegistryFor(panelEntry.manifest) // Plugins that pair a panel UI with a service entry read shared // state off `service`. Hand them the matching singleton if one was // loaded. @@ -696,7 +1196,8 @@ ShellRoot { schema: meta.schema || [], pluginId: manifest.id, sourceDir: manifest.__sourceDir || "", - source: "plugin" + source: "plugin", + firstParty: !!manifest.__isFirstParty } // A load already in flight for this URL registers itself when it diff --git a/test/shell.d/fixtures/plugin-auth-boundary/AuthStoreOwner.qml b/test/shell.d/fixtures/plugin-auth-boundary/AuthStoreOwner.qml new file mode 100644 index 00000000..e3c16364 --- /dev/null +++ b/test/shell.d/fixtures/plugin-auth-boundary/AuthStoreOwner.qml @@ -0,0 +1,12 @@ +import QtQuick +import "services/AuthServiceStore.js" as AuthServiceStore + +QtObject { + function retain(id, service) { + AuthServiceStore.put(id, service) + } + + function has(id) { + return AuthServiceStore.has(id) + } +} diff --git a/test/shell.d/fixtures/plugin-auth-boundary/AuthStoreReader.qml b/test/shell.d/fixtures/plugin-auth-boundary/AuthStoreReader.qml new file mode 100644 index 00000000..a7527589 --- /dev/null +++ b/test/shell.d/fixtures/plugin-auth-boundary/AuthStoreReader.qml @@ -0,0 +1,8 @@ +import QtQuick +import "services/AuthServiceStore.js" as AuthServiceStore + +QtObject { + function has(id) { + return AuthServiceStore.has(id) + } +} diff --git a/test/shell.d/fixtures/plugin-auth-boundary/shell.qml b/test/shell.d/fixtures/plugin-auth-boundary/shell.qml new file mode 100644 index 00000000..926b6c8f --- /dev/null +++ b/test/shell.d/fixtures/plugin-auth-boundary/shell.qml @@ -0,0 +1,82 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "services" + +ShellRoot { + id: root + + property var calls: [] + property QtObject ownService: QtObject { property string marker: "own" } + + AuthStoreOwner { id: authStoreOwner } + AuthStoreReader { id: authStoreReader } + + Component { + id: apiComponent + PluginShellApi { } + } + + FileView { + id: resultFile + path: Quickshell.env("OMARCHY_QML_TEST_RESULT") + atomicWrites: true + } + + Component.onCompleted: { + var caller = "example.safe" + authStoreOwner.retain("omarchy.lock", root.ownService) + var api = apiComponent.createObject(null, { + pluginId: caller, + _serviceLookup: function(requestedId) { + return requestedId === caller ? root.ownService : null + }, + _summon: function(requestedId) { + if (requestedId !== caller) return false + root.calls = root.calls.concat(["summon"]) + return true + }, + _hide: function(requestedId) { + if (requestedId !== caller) return false + root.calls = root.calls.concat(["hide"]) + return true + }, + _toggle: function(requestedId) { + if (requestedId !== caller) return false + root.calls = root.calls.concat(["toggle"]) + return true + }, + _isOpen: function(requestedId) { return requestedId === caller }, + _updateSettings: function(requestedId) { + if (requestedId !== caller) return false + root.calls = root.calls.concat(["settings"]) + return true + } + }) + + var own = api.serviceFor(caller) + var result = { + detached: api.parent === undefined || api.parent === null, + ownService: own && own.marker === "own", + foreignService: api.serviceFor("omarchy.lock") === null, + firstPartyService: api.firstPartyServiceFor("omarchy.polkit") === null, + ownSummon: api.summon(caller, "{}") === true, + foreignSummon: api.summon("omarchy.lock", "{}") === false, + ownHide: api.hide(caller) === true, + foreignHide: api.hide("omarchy.lock") === false, + ownToggle: api.toggle(caller, "{}") === true, + foreignToggle: api.toggle("omarchy.lock", "{}") === false, + ownOpen: api.isPluginOpen(caller) === true, + foreignOpen: api.isPluginOpen("omarchy.lock") === false, + ownSettings: api.updateEntryInline(caller, {}) === true, + foreignSettings: api.updateEntryInline("omarchy.lock", {}) === false, + authStoreOwnerRetains: authStoreOwner.has("omarchy.lock") === true, + authStoreImportIsolated: authStoreReader.has("omarchy.lock") === false, + calls: root.calls + } + result.ok = Object.keys(result).every(function(key) { + return key === "ok" || key === "calls" || result[key] === true + }) && JSON.stringify(result.calls) === JSON.stringify(["summon", "hide", "toggle", "settings"]) + resultFile.setText(JSON.stringify(result)) + } +} diff --git a/test/shell.d/fixtures/plugin-registry/shell.qml b/test/shell.d/fixtures/plugin-registry/shell.qml index e10f73ed..49e6f437 100644 --- a/test/shell.d/fixtures/plugin-registry/shell.qml +++ b/test/shell.d/fixtures/plugin-registry/shell.qml @@ -83,6 +83,9 @@ ShellRoot { scan += block("firstparty", "/first/bar", manifest("omarchy.bar", ["bar"], { bar: "Bar.qml" })) scan += block("firstparty", "/first/panels/grouped", manifest("omarchy.grouped-panel", ["panel"], { panel: "Panel.qml" })) scan += block("firstparty", "/first/hybrid", manifest("omarchy.hybrid", ["menu", "bar-widget"], { menu: "Menu.qml", barWidget: "Widget.qml" })) + var futureAuth = manifest("omarchy.future-auth", ["service"], { service: "Service.qml" }) + futureAuth.omarchy = { capabilities: ["authentication"] } + scan += block("firstparty", "/first/future-auth", futureAuth) scan += block("thirdparty", "/third/panel", manifest("third.panel", ["panel"], { panel: "Panel.qml" })) scan += block("thirdparty", "/third/widget", manifest("third.widget", ["bar-widget"], { barWidget: "Widget.qml" }, { defaultSection: "left" })) scan += block("thirdparty", "/third/center-widget", manifest("third.center-widget", ["bar-widget"], { barWidget: "Widget.qml" })) @@ -103,6 +106,12 @@ ShellRoot { localBar.omarchy = { clonedFrom: "omarchy.bar" } scan += block("thirdparty", "/third/local-bar", localBar) scan += block("thirdparty", "/third/bar", manifest("third.bar", ["bar"], { bar: "Bar.qml" })) + var localFutureAuth = manifest("local.future-auth", ["service"], { service: "Service.qml" }) + localFutureAuth.omarchy = { clonedFrom: "omarchy.future-auth" } + scan += block("thirdparty", "/third/local-future-auth", localFutureAuth) + var spoofedAuth = manifest("third.spoofed-auth", ["service"], { service: "Service.qml" }) + spoofedAuth.omarchy = { capabilities: ["authentication"] } + scan += block("thirdparty", "/third/spoofed-auth", spoofedAuth) scan += block("thirdparty", "/third/shadow", manifest("omarchy.first-widget", ["panel"], { panel: "Panel.qml" })) scan += block("thirdparty", "/third/reserved", manifest("omarchy.reserved", ["panel"], { panel: "Panel.qml" })) scan += block("thirdparty", "/third/unsafe", manifest("third.unsafe", ["panel"], { panel: "../Panel.qml" })) @@ -116,22 +125,28 @@ ShellRoot { root.assertDeepEqual(pluginIds(), [ "local.bar", "local.first-widget", + "local.future-auth", "local.grouped-panel", "local.hybrid", "local.weather", "omarchy.bar", "omarchy.first-widget", + "omarchy.future-auth", "omarchy.grouped-panel", "omarchy.hybrid", "third.bar", "third.center-widget", "third.panel", "third.right-widget", + "third.spoofed-auth", "third.widget" ], "registry merges valid first-party and third-party manifests") root.assertTrue(registry.installedPlugins["omarchy.first-widget"].__isFirstParty === true, "first-party manifests are stamped") root.assertTrue(registry.installedPlugins["third.panel"].__isFirstParty === false, "third-party manifests are stamped") + root.assertDeepEqual(registry.installedPlugins["omarchy.future-auth"].__hostCapabilities, ["authentication"], "trusted manifests stamp authentication capability") + root.assertDeepEqual(registry.installedPlugins["local.future-auth"].__hostCapabilities, ["authentication"], "clones inherit trusted host capabilities") + root.assertDeepEqual(registry.installedPlugins["third.spoofed-auth"].__hostCapabilities, [], "third-party manifests cannot self-grant host capabilities") root.assertEqual(registry.installedPlugins["omarchy.grouped-panel"].__sourceDir, "/first/panels/grouped", "grouped plugin source paths are preserved") root.assertEqual(registry.entryPointUrl(registry.installedPlugins["third.panel"], "panel"), "file:///third/panel/Panel.qml", "entryPointUrl resolves plugin-relative paths") root.assertEqual(registry.entryPointUrl(registry.installedPlugins["third.widget"], "barWidget"), "file:///third/widget/Widget.qml", "entryPointUrl resolves bar widget paths") diff --git a/test/shell.d/plugin-auth-boundary-test.sh b/test/shell.d/plugin-auth-boundary-test.sh new file mode 100755 index 00000000..ade609d4 --- /dev/null +++ b/test/shell.d/plugin-auth-boundary-test.sh @@ -0,0 +1,125 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR="" +QS_PID="" + +cleanup() { + if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then + kill "$QS_PID" 2>/dev/null || true + wait "$QS_PID" 2>/dev/null || true + fi + if [[ -n $TMPDIR && -d $TMPDIR ]]; then + rm -rf "$TMPDIR" + fi +} +trap cleanup EXIT + +shell_qml="$ROOT/shell/shell.qml" +bar_qml="$ROOT/shell/plugins/bar/Bar.qml" + +# Normalize horizontal and vertical whitespace so the wiring assertions survive +# harmless QML reflow. The runtime fixture below behaviorally covers +# PluginShellApi and AuthServiceStore; these checks remain the guard for their +# integration through shell.qml and Bar.qml, including without a compositor. +qml_matches() { + local file=$1 + local pattern=$2 + + tr '\n\r\t' ' ' < "$file" | grep -Eq "$pattern" +} + +qml_matches "$shell_qml" 'comp\.createObject\( *manifest\.__isFirstParty *&& *!authenticationService *\? *serviceHost *: *null *\)' || + fail "third-party and authentication services are detached from the host object tree" +qml_matches "$shell_qml" 'AuthServiceStore\.put\( *key, *inst *\)' || + fail "authentication services are retained outside the host service map" +pass "third-party and authentication services are detached from the host object tree" + +qml_matches "$shell_qml" 'inst\.shell *= *shell\.pluginShellFor\( *manifest *\)' || + fail "service plugins receive a scoped shell facade" +qml_matches "$shell_qml" 'item\.shell *= *shell\.pluginShellFor\( *panelEntry\.manifest *\)' || + fail "panel plugins receive a scoped shell facade" +qml_matches "$shell_qml" 'target\.shell *= *shell\.pluginShellFor\( *manifest *\)' || + fail "full-bar plugins receive a scoped shell facade" +pass "third-party entry points receive scoped shell facades" + +qml_matches "$bar_qml" 'target\.bar *= *firstParty *\? *root *: *root\.pluginBarApiFor\( *pluginApiId, *moduleName, *registered *\)' || + fail "third-party widgets receive a bar facade instead of the host bar" +qml_matches "$bar_qml" 'api\.clickTargets *= *root\.pluginClickTargets\( *api\.pluginId *\)' || + fail "third-party bar facades exclude other widgets from their object graph" +pass "third-party widgets receive a bar facade instead of the host bar" + +qml_matches "$shell_qml" 'widgets: *shell\.publicBarWidgetSnapshot\( *\)' || + fail "third-party widget registries receive detached snapshots" +qml_matches "$bar_qml" 'root\.markPluginObject\( *pluginId, *target, *"clickTarget" *\)' || + fail "third-party bar-object ownership is stamped by the host callback" +qml_matches "$bar_qml" 'root\.markPluginObject\( *pluginId, *owner, *"popout" *\)' || + fail "owner-less popouts receive trusted ownership before activation" +qml_matches "$shell_qml" 'manifest\.__hostCapabilities\.indexOf\( *"authentication" *\)' || + fail "authentication isolation follows host-stamped capabilities" +pass "registry mutation and ownership boundaries are host-controlled" + +qml_matches "$bar_qml" 'root\.moduleWidgets\( *moduleName *\)' || + fail "custom bar module widget lookups use their real module name" +qml_matches "$shell_qml" 'shell\.pluginShellForBarEntry\( *cacheKey *\+ *":" *\+ *ownerId, *moduleName *\)' || + fail "full-bar plugins receive a scoped settings facade for custom modules" +pass "custom bar modules retain settings and popout identity" + +if qml_matches "$bar_qml" 'on(Foreground|BarForeground|Background|Urgent|FontFamily|Vertical|BarSize|Transparent)Changed: *sync'; then + fail "animated scalar properties still trigger full facade resyncs" +fi +qml_matches "$bar_qml" 'api\.foreground *= *Qt\.binding\( *function\( *\) *\{ *return root\.foreground *\} *\)' || + fail "third-party bar scalar mirrors use bindings" +qml_matches "$shell_qml" 'shell\.prunePluginApis\( *\)' || + fail "disabled plugin facade caches are pruned" +pass "plugin facade synchronization is bounded" + +require_compositor "plugin authentication boundary runtime test" + +if ! command -v quickshell >/dev/null 2>&1; then + pass "quickshell not installed; skipping plugin authentication boundary runtime test" + exit 0 +fi + +require_command jq + +TMPDIR=$(mktemp -d) +result="$TMPDIR/result.json" +log="$TMPDIR/quickshell.log" +config_dir="$TMPDIR/plugin-auth-boundary" +mkdir -p "$config_dir" "$TMPDIR/home" +cp "$SHELL_TEST_DIR/fixtures/plugin-auth-boundary/"*.qml "$config_dir/" +ln -s "$ROOT/shell/services" "$config_dir/services" + +OMARCHY_QML_TEST_RESULT="$result" \ +HOME="$TMPDIR/home" \ +XDG_CONFIG_HOME="$TMPDIR/home/.config" \ +XDG_CACHE_HOME="$TMPDIR/home/.cache" \ +XDG_STATE_HOME="$TMPDIR/home/.local/state" \ + quickshell -p "$config_dir" --no-color >"$log" 2>&1 & +QS_PID=$! + +for _ in {1..80}; do + [[ -s $result ]] && break + if ! kill -0 "$QS_PID" 2>/dev/null; then + sed -n '1,220p' "$log" >&2 + fail "plugin authentication boundary fixture exited before writing result" + fi + sleep 0.1 +done + +[[ -s $result ]] || { + sed -n '1,220p' "$log" >&2 + fail "plugin authentication boundary runtime test timed out" +} + +if ! jq -e '.ok == true' "$result" >/dev/null; then + jq . "$result" >&2 + sed -n '1,220p' "$log" >&2 + fail "plugin authentication boundary runtime behavior" +fi + +pass "plugin authentication boundary runtime behavior" From f08840d6cc98b81b78290d8e6ff61567b4b21079 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 1 Sep 2026 14:25:37 -0500 Subject: [PATCH 26/76] Probe Hermes readiness by the flags omarchy-agent passes Hermes v0.20 removed chat's --oneshot flag, which hermes_prompt_ready used as its capability marker. A fully bootstrapped Hermes Desktop install then read as not ready: --check failed forever, the default agent flow looped back into the installer, and --now dead-ended with "Launch Hermes Desktop once to finish installing it" on a machine where it already had. Probe for --tui and --query instead: the flags omarchy-agent actually passes to seed an interactive session, rather than one that merely shipped alongside them. --- bin/omarchy-install-hermes-cli | 9 ++++++--- test/shell.d/hermes-cli-test.sh | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index 5b6a3598..150364c0 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -88,13 +88,16 @@ hermes_runs() { timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1 } -# The chat subcommand's --oneshot opt-out arrived with native interactive -q, -# so its presence is a stable capability check without relying on a version. +# Probed for the flags omarchy-agent actually passes -- --query to seed the +# session and --tui to keep it interactive -- rather than a flag that merely +# shipped alongside them: the previous marker, --oneshot, was dropped in Hermes +# v0.20 and left working installs stranded as "not ready". hermes_prompt_ready() { local help hermes_runs && help=$(timeout 15 "$HOME/.local/bin/hermes" chat --help 2>/dev/null) && - grep -qF -- '--oneshot' <<<"$help" + grep -qF -- '--tui' <<<"$help" && + grep -qF -- '--query' <<<"$help" } # --owns answers whether the wrapper on PATH is the one this command wrote, so diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 83b516e9..2144a795 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -98,7 +98,7 @@ mkdir -p "$test_home/.hermes/hermes-agent/venv/bin" cat >"$test_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' #!/bin/bash if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then - [[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "--oneshot" + [[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "[-q QUERY, --query QUERY] [--tui]" else echo "hermes-agent 0.0.0-test" fi @@ -388,7 +388,7 @@ run_ready_check && fail "--check rejects the app's wrapper when its runtime is g cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' #!/bin/bash if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then - echo "--oneshot" + echo "[-q QUERY, --query QUERY] [--tui]" else echo "hermes-agent 0.0.0-test" fi @@ -396,3 +396,17 @@ 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" + +# A release whose help lists only the retired --oneshot marker cannot run the +# seeded --tui --query session omarchy-agent starts, so it is not ready. +cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + echo "--oneshot" +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 a release without the flags omarchy-agent passes" +pass "a release listing only --oneshot is not prompt-ready" From f7078b9136e5241b2080a41575c85502e2b7c7b0 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 1 Sep 2026 15:53:52 -0500 Subject: [PATCH 27/76] Match Hermes flag probe at a boundary, cover the mise path Review follow-up on the readiness probe. The two greps were fixed-string substring matches, so a future release listing only --tui-theme or --query-log while dropping the bare --tui/--query omarchy-agent passes would read as ready -- the same false verdict inverted. Anchor both to a flag boundary. Add a regression case pinning that a substring-only help is rejected, and one exercising --check through a mise-installed hermes in both capability directions: the desktop and foreign cases only covered their own wrappers, and the mise path is what a machine without the app runs. Co-Authored-By: Claude --- bin/omarchy-install-hermes-cli | 9 ++++-- test/shell.d/hermes-cli-test.sh | 52 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index 150364c0..bdd0daab 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -92,12 +92,17 @@ hermes_runs() { # session and --tui to keep it interactive -- rather than a flag that merely # shipped alongside them: the previous marker, --oneshot, was dropped in Hermes # v0.20 and left working installs stranded as "not ready". +# +# Matched at a flag boundary, not as a substring: a bare grep for --tui also +# accepts a release that lists only --tui-theme while having dropped --tui +# itself, which would call an install ready that omarchy-agent cannot drive -- +# the same false verdict this check exists to prevent, merely inverted. hermes_prompt_ready() { local help hermes_runs && help=$(timeout 15 "$HOME/.local/bin/hermes" chat --help 2>/dev/null) && - grep -qF -- '--tui' <<<"$help" && - grep -qF -- '--query' <<<"$help" + grep -qE -- '--tui([^[:alnum:]-]|$)' <<<"$help" && + grep -qE -- '--query([^[:alnum:]-]|$)' <<<"$help" } # --owns answers whether the wrapper on PATH is the one this command wrote, so diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 2144a795..fa5c1db9 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -23,6 +23,11 @@ cat >"$mock_bin/omarchy-cmd-missing" <<'SH' SH # `mise where` must fail so the installer sees no Hermes behind the stub. +# +# With OMARCHY_TEST_MISE_X_HERMES=1, `mise x -- hermes ...` emulates the Hermes +# the Omarchy stub runs, so the readiness probe can be exercised through a +# mise-installed hermes and not only the foreign and desktop wrappers. Off by +# default, so `mise x` stays silent for every test that does not opt in. cat >"$mock_bin/mise" <<'SH' #!/bin/bash printf '%s\0' "$@" >>"$OMARCHY_TEST_MISE_LOG" @@ -30,6 +35,18 @@ if [[ $1 == "where" && ${OMARCHY_TEST_MISE_WHERE_OK:-0} == 1 ]]; then printf '%s\n' "$OMARCHY_TEST_MISE_ROOT" exit 0 fi +if [[ $1 == "x" && ${OMARCHY_TEST_MISE_X_HERMES:-0} == 1 ]]; then + # Args are `x -- hermes `; skip to what follows hermes. + shift + while (( $# )) && [[ $1 != "--" ]]; do shift; done + shift 2 + if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + [[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "[-q QUERY, --query QUERY] [--tui]" + else + echo "hermes-agent 0.0.0-test" + fi + exit 0 +fi [[ $1 != "where" ]] SH @@ -237,6 +254,26 @@ tr '\0' '\n' <"$mise_log" | grep -q '^rm$' || fail "an older owned Hermes enviro tr '\0' '\n' <"$mise_log" | grep -q '^uninstall$' || fail "an older owned Hermes environment is uninstalled" pass "reinstalling replaces an older owned Hermes environment" +# The mise-installed path is what a machine without the desktop app runs, and +# --check gates the default agent there too. The stub is present and its mise +# environment resolves, so readiness turns on the hermes mise runs -- exercised +# here in both directions, since the desktop and foreign cases cover only their +# own wrappers. +run_mise_check() { + OMARCHY_TEST_DESKTOP_INSTALLED=0 \ + OMARCHY_TEST_MISE_WHERE_OK=1 \ + OMARCHY_TEST_MISE_ROOT="$test_tmp/mise" \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + OMARCHY_TEST_MISE_X_HERMES=1 \ + OMARCHY_TEST_HERMES_CAPABLE="$1" \ + HOME="$test_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" --check >/dev/null 2>&1 +} +run_mise_check 1 || fail "--check accepts a mise-installed hermes that runs the seeded session" +run_mise_check 0 && fail "--check rejects a mise-installed hermes without the flags omarchy-agent passes" +pass "--check follows the mise-installed hermes it would actually run" + rm -f "$test_home/.local/bin/hermes" : >"$mise_log" OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 0 && @@ -410,3 +447,18 @@ SH chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes" run_ready_check && fail "--check accepts a release without the flags omarchy-agent passes" pass "a release listing only --oneshot is not prompt-ready" + +# A release that lists --tui-theme and --query-log but has dropped the bare +# --tui/--query omarchy-agent passes must not read as ready on the substring +# alone. The probe matches at a flag boundary for exactly this case. +cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + echo "[--tui-theme THEME] [--query-log FILE]" +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 a release whose flags only contain --tui/--query as a substring" +pass "a flag that merely contains --tui or --query is not prompt-ready" From 36d52254a7962e468ce75d637afc7bedeb090cb1 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 1 Sep 2026 22:51:03 -0500 Subject: [PATCH 28/76] Tear down the mise Hermes CLI on Remove Hermes Remove Hermes dropped the desktop package and its ~/.hermes runtime but never touched the mise CLI, on the assumption the install-time handoff had already removed it. A CLI the app never superseded -- an interrupted install, or the terminal CLI from before the app existed -- was left stranded on PATH after uninstall. Add a --remove mode to omarchy-install-hermes-cli that performs the same teardown the desktop takeover already does (mise rm -g + mise uninstall, and the marked stub), and call it from omarchy-remove-ai-hermes. The tool spec and ownership marker stay defined in one place, so the takeover and teardown paths cannot drift. Scoped to what Omarchy owns: a Hermes the user installed themselves is left alone. Co-Authored-By: Claude --- bin/omarchy-install-hermes-cli | 21 +++++++++++++++- bin/omarchy-remove-ai-hermes | 7 ++++++ test/shell.d/hermes-cli-test.sh | 39 ++++++++++++++++++++++++++++++ test/shell.d/hermes-remove-test.sh | 19 ++++++++++++++- 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index bdd0daab..7ae9410a 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Install the Hermes CLI as a mise-backed wrapper in ~/.local/bin -# omarchy:args=[--check|--now|--owns] +# omarchy:args=[--check|--now|--owns|--remove] # omarchy:examples=omarchy install hermes cli | omarchy install hermes cli --now # Hermes pins every one of its dependencies exactly and declares @@ -112,6 +112,25 @@ if [[ $mode == "--owns" ]]; then if ours; then exit 0; else exit 1; fi fi +# --remove tears down a Hermes CLI this installer put in place -- the mise tool +# it installs and the stub it marks -- so Remove Hermes clears a CLI the app +# never superseded (an interrupted install, or the terminal CLI from before the +# app existed) rather than leaving it stranded on PATH. Scoped to what is ours: +# the tool spec is this installer's, and the stub goes only when it carries the +# marker, so a Hermes the user installed themselves is untouched. Idempotent -- +# nothing installed means nothing to do -- and it is the same teardown the +# desktop takeover above performs, kept in one place so the two cannot drift. +if [[ $mode == "--remove" ]]; then + mise rm -g "$tool" >/dev/null 2>&1 || true + mise uninstall --all "$tool" >/dev/null 2>&1 || true + + if ours; then + rm -f "$HOME/.local/bin/hermes" + fi + + exit 0 +fi + # --check lets callers tell a cold stub from a working one before they commit # to a path that assumes Hermes is ready. if [[ $mode == "--check" ]]; then diff --git a/bin/omarchy-remove-ai-hermes b/bin/omarchy-remove-ai-hermes index f67b1d6d..f332dc4b 100755 --- a/bin/omarchy-remove-ai-hermes +++ b/bin/omarchy-remove-ai-hermes @@ -8,6 +8,13 @@ set -euo pipefail omarchy-pkg-drop hermes-desktop +# The mise CLI is the app's predecessor, not the app itself: Hermes Desktop takes +# it over on install and runs its own runtime instead, so a copy still here is one +# the app never superseded -- an interrupted install, or the terminal CLI from +# before the app existed. Remove Hermes clears that too, scoped by the installer +# to what Omarchy owns so a hermes the user set up themselves is left alone. +omarchy-install-hermes-cli --remove + # The app writes this when the runtime it provisions under ~/.hermes has landed, # and it is the only thing that tells that runtime apart from one the user # installed themselves -- the paths are the same either way. Without it the app diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index fa5c1db9..a70589ef 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -402,6 +402,45 @@ marker_copies=$(grep -rl "Written by omarchy-install-hermes-cli" \ fail "only omarchy-install-hermes-cli spells out the ownership marker" pass "the ownership marker is written down once" +# --remove tears down a Hermes CLI this installer owns, so Remove Hermes can +# clear one the desktop app never superseded. It turns on the same ownership as +# the rest of the file, so its cases mirror that split. +remove_home="$test_tmp/remove-home" +mkdir -p "$remove_home/.local/bin" + +run_remove() { + OMARCHY_TEST_DESKTOP_INSTALLED=0 \ + 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="$remove_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" --remove +} + +rm -f "$remove_home/.local/bin/hermes" +: >"$mise_log" +run_remove || fail "--remove succeeds when there is nothing to remove" +pass "--remove is idempotent when no Hermes CLI is present" + +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes" +chmod +x "$remove_home/.local/bin/hermes" +: >"$mise_log" +OMARCHY_TEST_MISE_WHERE_OK=1 run_remove || fail "--remove succeeds tearing down an owned CLI" +tr '\0' '\n' <"$mise_log" | grep -q '^rm$' || fail "--remove drops the mise tool from config" +tr '\0' '\n' <"$mise_log" | grep -q '^uninstall$' || fail "--remove uninstalls the mise tool" +[[ ! -e $remove_home/.local/bin/hermes ]] || fail "--remove takes the stub it owns" +pass "--remove tears down the mise CLI and the stub this installer owns" + +foreign_remove_body="#!/bin/bash +exec /usr/local/bin/my-own-hermes \"\$@\"" +printf '%s\n' "$foreign_remove_body" >"$remove_home/.local/bin/hermes" +chmod +x "$remove_home/.local/bin/hermes" +run_remove || fail "--remove succeeds with a foreign hermes present" +[[ -f $remove_home/.local/bin/hermes && $(cat "$remove_home/.local/bin/hermes") == "$foreign_remove_body" ]] || + fail "--remove leaves a hermes it does not own untouched" +pass "--remove leaves a Hermes the user installed themselves" + # The app's marker says its install once landed, not that it is still there. A # wrapper whose runtime has since gone answers for nothing, so readiness runs # the command, exactly as it does for a hermes the user installed themselves. diff --git a/test/shell.d/hermes-remove-test.sh b/test/shell.d/hermes-remove-test.sh index bc80859f..c590f0d3 100755 --- a/test/shell.d/hermes-remove-test.sh +++ b/test/shell.d/hermes-remove-test.sh @@ -15,6 +15,14 @@ cat >"$mock_bin/omarchy-pkg-drop" <<'SH' #!/bin/bash printf '%s\0' "$@" >>"$OMARCHY_TEST_DROP_LOG" SH + +# The CLI teardown is the installer's own, exercised in hermes-cli-test.sh; here +# it is mocked to a logger so this test stays about what Remove Hermes does with +# ~/.hermes, and to keep real mise out of a run with HOME pointed at a fixture. +cat >"$mock_bin/omarchy-install-hermes-cli" <<'SH' +#!/bin/bash +printf '%s\0' "$@" >>"$OMARCHY_TEST_INSTALLER_LOG" +SH chmod +x "$mock_bin"/* seed_install() { @@ -35,7 +43,10 @@ seed_install() { } remove() { - OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" HOME="$test_home" PATH="$mock_bin:$PATH" \ + : >"$test_tmp/installer-log" + OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" \ + OMARCHY_TEST_INSTALLER_LOG="$test_tmp/installer-log" \ + HOME="$test_home" PATH="$mock_bin:$PATH" \ bash "$ROOT/bin/omarchy-remove-ai-hermes" >/dev/null 2>&1 } @@ -67,6 +78,12 @@ pass "removal keeps what belongs to the user" [[ ! -e $test_home/.local/bin/hermes ]] || fail "the app's own hermes command is removed" pass "removal takes the command the app installed" +# Removal also asks the installer to tear down a mise CLI the app superseded, so +# a copy left from before the app took over does not linger once Hermes is gone. +tr '\0' '\n' <"$test_tmp/installer-log" | grep -qx -- '--remove' || + fail "removal asks the installer to tear down its own CLI" +pass "removal tears down the mise CLI through the installer" + # A hermes command the app did not write survives even when the app did install # a runtime of its own. seed_install From c462aad9eec6b1cff29027cb88fe2ec628402734 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 1 Sep 2026 23:32:35 -0500 Subject: [PATCH 29/76] Prove ownership before --remove touches mise The teardown removed the Omarchy tool spec from mise unconditionally, so removing Hermes Desktop could destroy a mise environment the user had built against the same spec while sparing their wrapper -- the very command the removal claims to preserve, broken behind its back. The whole teardown now turns on the marked stub, as replacement already does; the desktop takeover keeps its own bargain, where a second Hermes goes whoever built it and the app still provides the command after. Also close the probe over underscores -- _ continues a flag name just as - does, so --tui_mode no longer answers for --tui -- and pin the gaps review found in the tests: each flag must match on its own (either grep could be deleted before without a failure), a foreign wrapper's mise environment must survive --remove, and Remove Hermes must tear down the CLI in the interrupted-install case, not only after the app's runtime landed. Findings from an independent codex review at xhigh, each verified against the source and proven by mutation before landing. Co-Authored-By: Codex Co-Authored-By: Claude --- bin/omarchy-install-hermes-cli | 24 +++++++++-------- test/shell.d/hermes-cli-test.sh | 42 +++++++++++++++++++++++++++++- test/shell.d/hermes-remove-test.sh | 4 +++ 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index 7ae9410a..4a6923a3 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -101,8 +101,8 @@ hermes_prompt_ready() { local help hermes_runs && help=$(timeout 15 "$HOME/.local/bin/hermes" chat --help 2>/dev/null) && - grep -qE -- '--tui([^[:alnum:]-]|$)' <<<"$help" && - grep -qE -- '--query([^[:alnum:]-]|$)' <<<"$help" + grep -qE -- '--tui([^[:alnum:]_-]|$)' <<<"$help" && + grep -qE -- '--query([^[:alnum:]_-]|$)' <<<"$help" } # --owns answers whether the wrapper on PATH is the one this command wrote, so @@ -113,18 +113,20 @@ if [[ $mode == "--owns" ]]; then fi # --remove tears down a Hermes CLI this installer put in place -- the mise tool -# it installs and the stub it marks -- so Remove Hermes clears a CLI the app +# it installed and the stub it marked -- so Remove Hermes clears a CLI the app # never superseded (an interrupted install, or the terminal CLI from before the -# app existed) rather than leaving it stranded on PATH. Scoped to what is ours: -# the tool spec is this installer's, and the stub goes only when it carries the -# marker, so a Hermes the user installed themselves is untouched. Idempotent -- -# nothing installed means nothing to do -- and it is the same teardown the -# desktop takeover above performs, kept in one place so the two cannot drift. +# app existed) rather than leaving it stranded on PATH. The whole teardown +# turns on the marked stub, exactly as replacement does further down: without +# it nothing proves the mise environment is Omarchy's rather than one the user +# built against the same spec, and a user's stays theirs. The desktop takeover +# removes the environment without asking, but that is its own bargain -- a +# second Hermes has to go whoever built it, and the app still provides the +# command afterwards; here nothing would. Idempotent: nothing owned, nothing +# to do. if [[ $mode == "--remove" ]]; then - mise rm -g "$tool" >/dev/null 2>&1 || true - mise uninstall --all "$tool" >/dev/null 2>&1 || true - if ours; then + mise rm -g "$tool" >/dev/null 2>&1 || true + mise uninstall --all "$tool" >/dev/null 2>&1 || true rm -f "$HOME/.local/bin/hermes" fi diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index a70589ef..69a52f61 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -421,6 +421,10 @@ run_remove() { rm -f "$remove_home/.local/bin/hermes" : >"$mise_log" run_remove || fail "--remove succeeds when there is nothing to remove" +# No stub means no proof the mise environment -- if one even exists -- is +# Omarchy's, so nothing may reach mise at all. +tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' && + fail "--remove leaves mise alone when nothing proves ownership" pass "--remove is idempotent when no Hermes CLI is present" printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes" @@ -436,9 +440,14 @@ foreign_remove_body="#!/bin/bash exec /usr/local/bin/my-own-hermes \"\$@\"" printf '%s\n' "$foreign_remove_body" >"$remove_home/.local/bin/hermes" chmod +x "$remove_home/.local/bin/hermes" -run_remove || fail "--remove succeeds with a foreign hermes present" +: >"$mise_log" +OMARCHY_TEST_MISE_WHERE_OK=1 run_remove || fail "--remove succeeds with a foreign hermes present" [[ -f $remove_home/.local/bin/hermes && $(cat "$remove_home/.local/bin/hermes") == "$foreign_remove_body" ]] || fail "--remove leaves a hermes it does not own untouched" +# The wrapper may front a mise environment the user built against the very same +# spec; without the marker there is no telling, so the environment stays too. +tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' && + fail "--remove never removes a mise environment it cannot prove is Omarchy's" pass "--remove leaves a Hermes the user installed themselves" # The app's marker says its install once landed, not that it is still there. A @@ -501,3 +510,34 @@ SH chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes" run_ready_check && fail "--check accepts a release whose flags only contain --tui/--query as a substring" pass "a flag that merely contains --tui or --query is not prompt-ready" + +# Each flag answers for itself: a release that kept --tui but dropped --query, +# or the reverse, cannot run the seeded session either, so neither grep may +# ride on the other's match. +for kept in '--tui' '-q QUERY, --query QUERY'; do + cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + echo "[--tui_mode MODE] [--query_log FILE]" +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 flags that extend --tui/--query with an underscore" +pass "an underscore continuation is not the bare flag" diff --git a/test/shell.d/hermes-remove-test.sh b/test/shell.d/hermes-remove-test.sh index c590f0d3..6045c9a1 100755 --- a/test/shell.d/hermes-remove-test.sh +++ b/test/shell.d/hermes-remove-test.sh @@ -104,6 +104,10 @@ printf 'my local edit\n' >"$test_home/.hermes/hermes-agent/PATCH" printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \ >"$test_home/.local/bin/hermes" remove || fail "remove succeeds when the app never finished installing Hermes" +# The stranded pre-desktop CLI is exactly the interrupted-install case, so the +# teardown must be asked for here too, not only when the app's runtime landed. +tr '\0' '\n' <"$test_tmp/installer-log" | grep -qx -- '--remove' || + fail "removal tears down the CLI even when the app never finished installing" [[ -d $test_home/.hermes/hermes-agent ]] || fail "a Hermes runtime the app never installed survives removal" [[ -f $test_home/.hermes/hermes-agent/PATCH ]] || From 46cfc4ada5d9efe849d0f789178e492d98801011 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Wed, 2 Sep 2026 01:01:13 -0500 Subject: [PATCH 30/76] Judge the CLI teardown by what is left, and match flags as definitions Two review follow-ups. The probe counted any mention of --tui/--query in the help as support -- Hermes already writes "With --tui:" into --dev's description, so a release that dropped the option while keeping the prose would still read as ready. A flag now counts only when followed by a shape argparse prints after a definition: the usage bracket, the gap before same-line help text, an uppercase metavar, or the line end. Not probed by parsing a real invocation on purpose -- a release that ignores unknown arguments would turn the probe into a live session. And the teardown trusted its commands: a stub rm that failed aborted Remove Hermes under set -e before any ~/.hermes handling, while mise failures vanished into || true. --remove now attempts every step, then judges by what is left -- the marked stub still present, or mise still resolving the tool -- and Remove Hermes tolerates the failure until the runtime is handled, then carries it in its exit code. Findings from the same codex review at xhigh, verified and proven by mutation before landing. Co-Authored-By: Codex Co-Authored-By: Claude --- bin/omarchy-install-hermes-cli | 32 ++++++++++++++++++++------- bin/omarchy-remove-ai-hermes | 11 +++++++++- test/shell.d/hermes-cli-test.sh | 35 +++++++++++++++++++++++++++++- test/shell.d/hermes-remove-test.sh | 13 +++++++++++ 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index 4a6923a3..35405269 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -88,21 +88,27 @@ hermes_runs() { timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1 } +# A flag counts only when the help defines it, not whenever it is mentioned: +# what follows must be a shape argparse prints after a definition -- the usage +# line's closing bracket, the gap before same-line help text, an uppercase +# metavar, or the end of the line. Prose like "With --tui: run ..." stays +# prose, and --tui-theme or --tui_mode never answers for --tui. Not probed by +# parsing an actual invocation on purpose: a release that ignores unknown +# arguments would turn the probe into a live session. +help_defines_flag() { + grep -qE -- "$1(]|[[:space:]][[:upper:]]|[[:space:]]{2}|$)" <<<"$2" +} + # Probed for the flags omarchy-agent actually passes -- --query to seed the # session and --tui to keep it interactive -- rather than a flag that merely # shipped alongside them: the previous marker, --oneshot, was dropped in Hermes # v0.20 and left working installs stranded as "not ready". -# -# Matched at a flag boundary, not as a substring: a bare grep for --tui also -# accepts a release that lists only --tui-theme while having dropped --tui -# itself, which would call an install ready that omarchy-agent cannot drive -- -# the same false verdict this check exists to prevent, merely inverted. hermes_prompt_ready() { local help hermes_runs && help=$(timeout 15 "$HOME/.local/bin/hermes" chat --help 2>/dev/null) && - grep -qE -- '--tui([^[:alnum:]_-]|$)' <<<"$help" && - grep -qE -- '--query([^[:alnum:]_-]|$)' <<<"$help" + help_defines_flag '--tui' "$help" && + help_defines_flag '--query' "$help" } # --owns answers whether the wrapper on PATH is the one this command wrote, so @@ -127,7 +133,17 @@ if [[ $mode == "--remove" ]]; then if ours; then mise rm -g "$tool" >/dev/null 2>&1 || true mise uninstall --all "$tool" >/dev/null 2>&1 || true - rm -f "$HOME/.local/bin/hermes" + rm -f "$HOME/.local/bin/hermes" 2>/dev/null || true + + # Every step is attempted before any is judged, and judged by what is left + # rather than by what the commands claimed: the marked stub still answering + # hermes, or mise still resolving the tool, is a CLI still installed no + # matter how the removal exited. + if ours || mise where "$tool" >/dev/null 2>&1; then + echo "Could not remove the Hermes CLI Omarchy installed." >&2 + echo "Remove ~/.local/bin/hermes and the mise tool '$tool', then run omarchy-install-hermes-cli --remove again." >&2 + exit 1 + fi fi exit 0 diff --git a/bin/omarchy-remove-ai-hermes b/bin/omarchy-remove-ai-hermes index f332dc4b..1145fbd1 100755 --- a/bin/omarchy-remove-ai-hermes +++ b/bin/omarchy-remove-ai-hermes @@ -13,7 +13,10 @@ omarchy-pkg-drop hermes-desktop # the app never superseded -- an interrupted install, or the terminal CLI from # before the app existed. Remove Hermes clears that too, scoped by the installer # to what Omarchy owns so a hermes the user set up themselves is left alone. -omarchy-install-hermes-cli --remove +# Tolerated here rather than fatal, so the ~/.hermes handling below still runs; +# the failure is answered for at the end instead of being swallowed. +cli_removed=true +omarchy-install-hermes-cli --remove || cli_removed=false # The app writes this when the runtime it provisions under ~/.hermes has landed, # and it is the only thing that tells that runtime apart from one the user @@ -64,3 +67,9 @@ else echo "Hermes Desktop has been removed." echo "It never finished installing its own Hermes, so nothing in ~/.hermes was touched." fi + +# The messages above still hold -- the app and its runtime are gone -- but a CLI +# teardown that failed already said so on stderr, and that stands. +if [[ $cli_removed == "false" ]]; then + exit 1 +fi diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 69a52f61..24f8e8bc 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -430,12 +430,19 @@ pass "--remove is idempotent when no Hermes CLI is present" printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes" chmod +x "$remove_home/.local/bin/hermes" : >"$mise_log" -OMARCHY_TEST_MISE_WHERE_OK=1 run_remove || fail "--remove succeeds tearing down an owned CLI" +run_remove || fail "--remove succeeds tearing down an owned CLI" tr '\0' '\n' <"$mise_log" | grep -q '^rm$' || fail "--remove drops the mise tool from config" tr '\0' '\n' <"$mise_log" | grep -q '^uninstall$' || fail "--remove uninstalls the mise tool" [[ ! -e $remove_home/.local/bin/hermes ]] || fail "--remove takes the stub it owns" pass "--remove tears down the mise CLI and the stub this installer owns" +# When mise still resolves the tool after the teardown, the environment +# survived whatever uninstall claimed, and --remove has to say so. +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes" +chmod +x "$remove_home/.local/bin/hermes" +OMARCHY_TEST_MISE_WHERE_OK=1 run_remove && fail "--remove claims success while mise still resolves the tool" +pass "--remove fails when the mise environment survives the teardown" + foreign_remove_body="#!/bin/bash exec /usr/local/bin/my-own-hermes \"\$@\"" printf '%s\n' "$foreign_remove_body" >"$remove_home/.local/bin/hermes" @@ -450,6 +457,16 @@ tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' && fail "--remove never removes a mise environment it cannot prove is Omarchy's" pass "--remove leaves a Hermes the user installed themselves" +# Judged by what is left, not by what rm claimed: a stub that survives the +# teardown is a CLI still installed, and --remove has to say so. +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes" +chmod +x "$remove_home/.local/bin/hermes" +chmod 555 "$remove_home/.local/bin" +run_remove && fail "--remove claims success while the stub survives" +chmod 755 "$remove_home/.local/bin" +rm -f "$remove_home/.local/bin/hermes" +pass "--remove fails when the stub cannot be removed" + # The app's marker says its install once landed, not that it is still there. A # wrapper whose runtime has since gone answers for nothing, so readiness runs # the command, exactly as it does for a hermes the user installed themselves. @@ -541,3 +558,19 @@ SH chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes" run_ready_check && fail "--check accepts flags that extend --tui/--query with an underscore" pass "an underscore continuation is not the bare flag" + +# A flag mentioned in another option's help text is not that option. Hermes +# already writes "With --tui:" into --dev's description, so prose has to stay +# prose even when both names appear in it. +cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + echo " --dev With --tui: run sources via tsx" + echo " --log FILE Where --query output lands" +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 flags that appear only in option descriptions" +pass "a flag mentioned in prose is not a defined option" diff --git a/test/shell.d/hermes-remove-test.sh b/test/shell.d/hermes-remove-test.sh index 6045c9a1..d3389087 100755 --- a/test/shell.d/hermes-remove-test.sh +++ b/test/shell.d/hermes-remove-test.sh @@ -22,6 +22,7 @@ SH cat >"$mock_bin/omarchy-install-hermes-cli" <<'SH' #!/bin/bash printf '%s\0' "$@" >>"$OMARCHY_TEST_INSTALLER_LOG" +exit "${OMARCHY_TEST_INSTALLER_STATUS:-0}" SH chmod +x "$mock_bin"/* @@ -46,6 +47,7 @@ remove() { : >"$test_tmp/installer-log" OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" \ OMARCHY_TEST_INSTALLER_LOG="$test_tmp/installer-log" \ + OMARCHY_TEST_INSTALLER_STATUS="${OMARCHY_TEST_INSTALLER_STATUS:-0}" \ HOME="$test_home" PATH="$mock_bin:$PATH" \ bash "$ROOT/bin/omarchy-remove-ai-hermes" >/dev/null 2>&1 } @@ -131,3 +133,14 @@ remove || fail "remove succeeds with a wrapper pointing at a sibling directory" [[ -f $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$sibling_body" ]] || fail "a wrapper pointing at ~/xhermes is not mistaken for one pointing into ~/.hermes" pass "removal matches the runtime path as a plain string" + +# A CLI teardown that fails must not stop the runtime handling, and must not be +# papered over either: the data work still happens, and the failure reaches the +# caller's exit code. +seed_install +printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \ + >"$test_home/.local/bin/hermes" +OMARCHY_TEST_INSTALLER_STATUS=1 remove && fail "a failed CLI teardown surfaces in the exit code" +[[ ! -d $test_home/.hermes/hermes-agent ]] || + fail "a failed CLI teardown does not stop the runtime removal" +pass "a failed CLI teardown is reported after the runtime is handled" From d3d23fdddef846ebb98b52122a6ece66211c0daf Mon Sep 17 00:00:00 2001 From: Brian Armstrong Date: Wed, 2 Sep 2026 03:33:44 -0700 Subject: [PATCH 31/76] Honor keepLoaded for services during plugin hot-reload (#9485) * Honor keepLoaded for services during plugin hot-reload Plugin reload destroyed every service, including omarchy.lock, which drops the ext-session-lock client while Hyprland still holds the lock and surfaces the crashed-lockscreen fallback. * Prove keepLoaded service survival with a fixture service A fresh lock service also reports an empty lastEventAt, so comparing it across the rescan passed whether or not the instance survived. A fixture keepLoaded service whose in-memory marker is set before the rescan and read back after can only pass when the same instance is still mounted. Co-Authored-By: Claude Fable 5 * Drop kept services whose plugin no longer declares a service The _syncServices cleanup only asked whether the plugin was still installed and enabled, so a kept service whose plugin dropped its service kind or entry point kept running as a zombie until shell restart. Apply the same eligibility checks used at creation, and hand kept instances the refreshed manifest after a rescan. Co-Authored-By: Claude Fable 5 * Cover omarchy.media in keepLoaded expectations; note kept services reload on restart Co-Authored-By: Claude Fable 5 --------- Co-authored-by: David Heinemeier Hansson Co-authored-by: Claude Fable 5 --- docs/omarchy-shell.md | 4 +- shell/README.md | 8 +++- shell/plugins/README.md | 3 ++ shell/shell.qml | 31 +++++++++++-- test/shell.d/plugins-test.sh | 13 ++++++ test/shell.d/runtime-smoke-test.sh | 72 ++++++++++++++++++++++++++++++ 6 files changed, 122 insertions(+), 9 deletions(-) diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index a7c1b389..3142d991 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -37,9 +37,7 @@ wait). Only one full bar option is active at a time. The built-in `omarchy.bar` is used when `bar.id` is omitted or when a selected third-party bar cannot load. -Panels, overlays, and menus are loaded when summoned. Plugins can set the -top-level manifest key `keepLoaded: true` to survive between summons. -First-party services are loaded at startup. +Panels, overlays, and menus are loaded when summoned. Plugins can set the top-level manifest key `keepLoaded: true` to survive between summons, and to keep a service mounted across plugin hot-reload (so `omarchy.lock` is not destroyed while Hyprland still holds the session lock). First-party services are loaded at startup. Entry points are QML `Item`s. Panel, overlay, and menu entry points expose `open(payloadJson)` and `close()` for summon/hide; on load the host injects diff --git a/shell/README.md b/shell/README.md index e72d02ce..16162b79 100644 --- a/shell/README.md +++ b/shell/README.md @@ -86,8 +86,12 @@ Only one `bar` plugin is active at a time. Missing or invalid selections fall back to the built-in `omarchy.bar`, so users always have a safe path home. Panels, overlays, and menus are loaded when summoned. Plugins that need to outlive a single summon can set `keepLoaded: true` (e.g. the image -picker keeps its overlay window mounted between summons). First-party -services are loaded at startup. +picker keeps its overlay window mounted between summons). The same flag +keeps a service mounted across plugin hot-reload, so tearing down a +changed bar widget cannot destroy `omarchy.lock` while Hyprland still +holds the session lock. The kept instance is not replaced, so code +changes to a `keepLoaded` service itself only take effect on a shell +restart. First-party services are loaded at startup. The full schema lives in `services/PluginRegistry.qml`. diff --git a/shell/plugins/README.md b/shell/plugins/README.md index d4252515..66cb74e1 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -83,6 +83,9 @@ separate PAM services: `omarchy-lock-password` for password auth and, only when fingerprints are enrolled, `omarchy-lock-fingerprint` for fingerprint auth. It mirrors the previous lock screen field dimensions, colors, blurred wallpaper, placeholder, and Hyprland-driven corners. +The plugin sets `keepLoaded: true` so a plugin hot-reload (for example +an installed bar widget changing on disk) does not destroy the lock +client while Hyprland still holds the session lock. ## Polkit agent diff --git a/shell/shell.qml b/shell/shell.qml index 71a9834f..7e1cf75d 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -329,14 +329,23 @@ ShellRoot { if (!Array.isArray(m.kinds) || m.kinds.indexOf("service") === -1) continue if (!m.entryPoints || !m.entryPoints.service) continue if (!pluginRegistry.isEnabled(id)) continue - if (_services[id]) continue + if (_services[id]) { + // A kept instance outlives the rescan; hand it the fresh manifest. + var kept = _services[id] + if (kept && "manifest" in kept) kept.manifest = m + continue + } ensureService(id) } - // Drop services for plugins that have been disabled or removed. + // Drop services for plugins that have been disabled or removed, or that + // no longer declare a service entry point. for (var existingId in _services) { var stillThere = plugins[existingId] + var stillService = stillThere && Array.isArray(stillThere.kinds) + && stillThere.kinds.indexOf("service") !== -1 + && stillThere.entryPoints && stillThere.entryPoints.service var stillEnabled = stillThere && pluginRegistry.isEnabled(existingId) - if (stillThere && stillEnabled) continue + if (stillService && stillEnabled) continue var inst = _services[existingId] if (inst && typeof inst.destroy === "function") inst.destroy() var next = ({}) @@ -345,12 +354,26 @@ ShellRoot { } } + function serviceKeepLoaded(pluginId) { + var plugins = pluginRegistry && pluginRegistry.installedPlugins + var manifest = plugins ? plugins[pluginId] : null + return !!(manifest && manifest.keepLoaded === true) + } + + // keepLoaded services (lock, idle, polkit) must survive plugin hot-reload. + // Destroying omarchy.lock drops the ext-session-lock client while Hyprland + // still holds the lock, which surfaces the crashed-lockscreen fallback. function unloadPluginServices() { + var next = ({}) for (var existingId in _services) { + if (serviceKeepLoaded(existingId)) { + next[existingId] = _services[existingId] + continue + } var inst = _services[existingId] if (inst && typeof inst.destroy === "function") inst.destroy() } - _services = ({}) + _services = next } Connections { diff --git a/test/shell.d/plugins-test.sh b/test/shell.d/plugins-test.sh index 141b4323..5d634dc9 100644 --- a/test/shell.d/plugins-test.sh +++ b/test/shell.d/plugins-test.sh @@ -197,5 +197,18 @@ for (const [id, section] of Object.entries({ } check(byId['omarchy.media']?.barWidget?.defaultSection === undefined, 'omarchy.media must use the center fallback') +for (const id of ['omarchy.lock', 'omarchy.idle', 'omarchy.polkit', 'omarchy.notifications', 'omarchy.media']) { + check(byId[id]?.keepLoaded === true, `${id} must stay loaded across plugin reloads`) +} + +const shellSource = fs.readFileSync(path.join(root, 'shell/shell.qml'), 'utf8') +const unloadMatch = shellSource.match(/function unloadPluginServices\(\) \{[\s\S]*?\n \}/) +check(!!unloadMatch, 'unloadPluginServices is defined') +check(!!unloadMatch && /serviceKeepLoaded/.test(unloadMatch[0]), 'unloadPluginServices honors keepLoaded') +check( + /function _syncServices\(\) \{[\s\S]*Drop services for plugins that have been disabled/.test(shellSource), + '_syncServices still drops disabled or removed services' +) + assert(errors.length === 0, 'plugin manifests match shell registry contract', errors.join('\n')) JS diff --git a/test/shell.d/runtime-smoke-test.sh b/test/shell.d/runtime-smoke-test.sh index f9e52bfd..1b0e5fe9 100755 --- a/test/shell.d/runtime-smoke-test.sh +++ b/test/shell.d/runtime-smoke-test.sh @@ -74,6 +74,44 @@ Item { } QML +# A keepLoaded service must keep its instance (and in-memory state) across a +# plugin rescan. The marker below can only survive if the object does. +keep_service_id="acme.keep-service" +keep_service_dir="$test_home/.config/omarchy/plugins/$keep_service_id" +mkdir -p "$keep_service_dir" +cat >"$keep_service_dir/manifest.json" <"$keep_service_dir/Service.qml" <<'QML' +import QtQuick +import Quickshell.Io + +Item { + property string marker: "" + + IpcHandler { + target: "acme-keep" + + function set(value: string): string { + marker = value + return "ok" + } + + function get(): string { + return marker + } + } +} +QML + cat >"$stub_bin/omarchy-update-available" <<'SH' #!/bin/bash echo "Omarchy update available (test)" @@ -188,6 +226,15 @@ pass "shell IPC summon and hide contract works" jq -e '.hasPlayer | type == "boolean"' <<<"$(shell_ipc media status)" >/dev/null || fail_with_log "media IPC returns status JSON" jq -e '.enabled | type == "boolean"' <<<"$(shell_ipc idle status)" >/dev/null || fail_with_log "idle IPC returns status JSON" jq -e '.locked | type == "boolean"' <<<"$(shell_ipc lock status)" >/dev/null || fail_with_log "lock IPC returns status JSON" +[[ $(shell_ipc shell setPluginEnabled "$keep_service_id" true) == "ok" ]] || + fail_with_log "keepLoaded fixture service could not be enabled" +keep_marker_set="" +for _ in {1..80}; do + keep_marker_set=$(shell_ipc acme-keep set "survived" 2>/dev/null || true) + [[ $keep_marker_set == "ok" ]] && break + sleep 0.1 +done +[[ $keep_marker_set == "ok" ]] || fail_with_log "keepLoaded fixture service IPC responds" [[ $(shell_ipc image-selector ping) == "ok" ]] || fail_with_log "image selector IPC responds" [[ $(shell_ipc osd ping) == "ok" ]] || fail_with_log "OSD IPC responds" [[ $(shell_ipc osd show '{"message":"Runtime smoke","duration":0}') == "ok" ]] || fail_with_log "OSD IPC opens" @@ -215,6 +262,31 @@ shell_ipc_quiet image-selector cancel "$selector_done_file" >/dev/null rm -f "$selector_selection_file" "$selector_done_file" pass "image selector IPC survives plugin rescan" +lock_status_after=$(shell_ipc lock status) +jq -e '.locked | type == "boolean"' <<<"$lock_status_after" >/dev/null || fail_with_log "lock IPC survives plugin rescan" +lock_event_after=$(jq -r '.lastEvent // empty' <<<"$lock_status_after") +[[ $lock_event_after != lock-stranded* ]] || + fail_with_log "plugin rescan does not strand the session lock ($lock_event_after)" +# A recreated instance would answer with a fresh, empty marker. +[[ $(shell_ipc acme-keep get) == "survived" ]] || + fail_with_log "plugin rescan keeps the keepLoaded service instance mounted" +pass "keepLoaded service instance survives plugin rescan" + +# Dropping the service entry point from the manifest must drop the kept +# instance instead of leaving a zombie behind. +jq 'del(.keepLoaded) | .kinds = ["overlay"] | .entryPoints = {"overlay": "Service.qml"}' \ + "$keep_service_dir/manifest.json" >"$keep_service_dir/manifest.json.tmp" +mv "$keep_service_dir/manifest.json.tmp" "$keep_service_dir/manifest.json" +keep_gone="" +for _ in {1..80}; do + keep_gone=$(shell_ipc acme-keep get 2>/dev/null || true) + [[ $keep_gone != "survived" ]] && break + sleep 0.1 +done +[[ $keep_gone != "survived" ]] || + fail_with_log "kept service is dropped when its plugin stops declaring a service" +pass "kept service is dropped when its plugin stops declaring a service" + shell_ipc_quiet omarchy.system-update refresh >/dev/null 2>&1 || true sleep 0.8 From 21470fd1eacd8c41d26ab018923a732ae0e61528 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Wed, 2 Sep 2026 09:40:44 -0500 Subject: [PATCH 32/76] Say how to finish a failed teardown, and record the probe's real history A failed --remove told the user to run it again, but that advice could never work: rm -f has usually taken the marked stub by the time the failure is judged, and a rerun that finds nothing it owns succeeds without touching the mise environment it was asked to finish removing. Spell out the three commands that complete the job by hand instead. Also correct the story the probe comment told: chat never lost --oneshot in v0.20 -- no released Hermes defined it there. It lived at the top level until v0.21 added chat's own, so the old probe was keyed to a flag no release ever carried under chat, and every install read as not ready. Recorded straight so a future hermes-desktop bump to v0.21+, which would make the old probe pass on the desktop path alone, cannot read as the fix. Findings from omarchybot's review (Opus 5, with Codex at xhigh). Co-Authored-By: Claude Opus 5 Co-Authored-By: Claude --- bin/omarchy-install-hermes-cli | 16 +++++++++++----- test/shell.d/hermes-cli-test.sh | 4 ++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index 35405269..4f4b99d7 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -100,9 +100,10 @@ help_defines_flag() { } # Probed for the flags omarchy-agent actually passes -- --query to seed the -# session and --tui to keep it interactive -- rather than a flag that merely -# shipped alongside them: the previous marker, --oneshot, was dropped in Hermes -# v0.20 and left working installs stranded as "not ready". +# session and --tui to keep it interactive -- rather than a marker standing in +# for them: the old probe keyed on chat carrying --oneshot, which no released +# Hermes did (it lived at the top level until v0.21 added chat's own), so +# every release read as "not ready". hermes_prompt_ready() { local help hermes_runs && @@ -139,9 +140,14 @@ if [[ $mode == "--remove" ]]; then # rather than by what the commands claimed: the marked stub still answering # hermes, or mise still resolving the tool, is a CLI still installed no # matter how the removal exited. + # Not "run --remove again": once the stub is gone nothing marks the mise + # environment as ours, so a rerun would find nothing it owns and succeed + # without touching what was left. Only the full commands finish the job. if ours || mise where "$tool" >/dev/null 2>&1; then - echo "Could not remove the Hermes CLI Omarchy installed." >&2 - echo "Remove ~/.local/bin/hermes and the mise tool '$tool', then run omarchy-install-hermes-cli --remove again." >&2 + echo "Could not remove the Hermes CLI Omarchy installed. Finish by hand:" >&2 + echo " rm -f ~/.local/bin/hermes" >&2 + echo " mise rm -g '$tool'" >&2 + echo " mise uninstall --all '$tool'" >&2 exit 1 fi fi diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 24f8e8bc..1cf033ab 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -499,8 +499,8 @@ 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" -# A release whose help lists only the retired --oneshot marker cannot run the -# seeded --tui --query session omarchy-agent starts, so it is not ready. +# A release whose help lists only the old probe's --oneshot marker cannot run +# the seeded --tui --query session omarchy-agent starts, so it is not ready. cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' #!/bin/bash if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then From 094c913065315082c0cbf353a169c0245779c427 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Wed, 2 Sep 2026 11:03:03 -0600 Subject: [PATCH 33/76] Tighten replacement bar plugin boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported-by: Roger Piñol --- agents/skills/shell-dev.md | 2 +- docs/omarchy-shell.md | 6 +- manual/32-shell-plugins.md | 4 +- shell/README.md | 6 +- shell/Ui/PluginBarApi.qml | 3 +- shell/plugins/bar/Bar.qml | 10 +- shell/services/PluginRegistry.qml | 3 +- shell/services/PluginShellApi.qml | 10 +- shell/shell.qml | 38 ++++- .../fixtures/plugin-auth-boundary/shell.qml | 1 + test/shell.d/plugin-auth-boundary-test.sh | 14 ++ test/shell.d/runtime-smoke-test.sh | 133 ++++++++++++++++++ 12 files changed, 206 insertions(+), 24 deletions(-) diff --git a/agents/skills/shell-dev.md b/agents/skills/shell-dev.md index 59cc4e8b..1688c0f1 100644 --- a/agents/skills/shell-dev.md +++ b/agents/skills/shell-dev.md @@ -20,7 +20,7 @@ Run `omarchy-restart-shell` after making changes to QML files. [`docs/omarchy-shell.md`](../../docs/omarchy-shell.md) and `shell/services/PluginRegistry.qml` for the current contract; fields such as `activation` are optional. -- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the shell-injected properties `omarchyPath`, `shell`, `manifest`, and `pluginRegistry` / `barWidgetRegistry` as appropriate. First-party plugins receive the host objects. Third-party plugins receive capability-scoped facades: ordinary plugins may look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state; full-bar plugins additionally receive detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities must be stamped from trusted first-party manifests, and third-party registry views must be detached snapshots rather than shared objects. Do not expose host objects or authentication services through new third-party-facing properties. +- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the shell-injected properties `omarchyPath`, `shell`, `manifest`, and `pluginRegistry` / `barWidgetRegistry` as appropriate. First-party plugins receive the host objects. Third-party plugins receive capability-scoped facades: ordinary plugins may look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state; full-bar plugins additionally receive detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities must be stamped from trusted first-party manifests, and third-party registry views and bar configuration must be detached snapshots rather than shared objects. These facades reduce accidental authority but are not a same-process QML sandbox: a visual bar widget can walk its parent hierarchy to ordinary host objects. Authentication services must therefore remain outside both `ShellRoot._services` and the host QObject tree. Do not expose authentication services through new third-party-facing properties. - Panel / overlay / menu plugins must expose `open(payloadJson)` and `close()` lifecycle methods for `shell summon` and `shell hide`. diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index 52706d0f..cc0cc17b 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -39,7 +39,9 @@ Only one full bar option is active at a time. The built-in `omarchy.bar` is used when `bar.id` is omitted or when a selected third-party bar cannot load. Panels, overlays, and menus are loaded when summoned. Plugins can set the top-level manifest key `keepLoaded: true` to survive between summons, and to keep a service mounted across plugin hot-reload (so `omarchy.lock` is not destroyed while Hyprland still holds the session lock). First-party services are loaded at startup. -Entry points are QML `Item`s. Panel, overlay, and menu entry points expose `open(payloadJson)` and `close()` for summon/hide; on load the host injects `omarchyPath`, `shell`, `manifest`, and the registries (`pluginRegistry` / `barWidgetRegistry`) as properties. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades instead: ordinary plugins may look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are kept out of the host's public service map and QML object tree, and third-party registry snapshots can be changed only locally without mutating the host registries. +Entry points are QML `Item`s. Panel, overlay, and menu entry points expose `open(payloadJson)` and `close()` for summon/hide; on load the host injects `omarchyPath`, `shell`, `manifest`, and the registries (`pluginRegistry` / `barWidgetRegistry`) as properties. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades instead: ordinary plugins may look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are kept out of the host's public service map and QML object tree, and third-party registry/configuration snapshots can be changed only locally without mutating host state. The facades are API boundaries, not same-process QML sandboxes: a visual widget shares the host bar's scene and can walk its parent hierarchy to ordinary host objects. Sensitive state must not rely on the facade alone for isolation. + +A third-party replacement bar can render registered widget components, but widgets it hosts receive a service-less entry facade. Allowing the bar to manufacture an own-service facade for an arbitrary widget would also let it retrieve that plugin's live service object. Service-backed third-party widgets therefore retain their full integration only under the trusted built-in bar; a replacement bar may still provide their target-scoped lifecycle and settings operations. Full schema: [`shell/services/PluginRegistry.qml`](../shell/services/PluginRegistry.qml). @@ -76,7 +78,7 @@ one replaces the active bar, and it is therefore never offered under Disable. Bar widgets may set `barWidget.defaultSection` to `left`, `center`, or `right`; widgets that omit it default to `center`. -Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding warns you before cloning, plugins land disabled so you can review the code before `omarchy plugin enable`, and updates show a diff before touching anything. Commands confirm in a terminal even when given arguments; without one they refuse rather than guess. Add `--yes` to skip every prompt (the path for scripts and agents). The scoped QML interfaces protect shell-owned credentials and cross-plugin controls; they are not an operating-system sandbox, so plugin code still has the same user-level file and process access as the shell. +Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding warns you before cloning, plugins land disabled so you can review the code before `omarchy plugin enable`, and updates show a diff before touching anything. Commands confirm in a terminal even when given arguments; without one they refuse rather than guess. Add `--yes` to skip every prompt (the path for scripts and agents). The scoped interfaces remove direct access to authentication services and avoid handing generic cross-plugin service factories to replacement bars, but visual plugins can still traverse ordinary objects in their shared QML scene. Plugin code also has the same user-level file and process access as the shell. You can still install by hand: drop a plugin into `~/.config/omarchy/plugins//`, run `omarchy-shell shell rescanPlugins`, then diff --git a/manual/32-shell-plugins.md b/manual/32-shell-plugins.md index bf7f1f2d..2e349998 100644 --- a/manual/32-shell-plugins.md +++ b/manual/32-shell-plugins.md @@ -37,7 +37,9 @@ A third-party plugin is just a git repo with a `manifest.json` at its root. omarchy plugin add https://github.com/acme/omarchy-weather.git --enable ``` -Before it does anything, it tells you plainly that plugins run as arbitrary, unsandboxed code inside your long-lived shell process, shows you the URL, and asks you to confirm. Take that seriously. The shell does not expose its authentication state or raw host object tree through the third-party plugin interface. A replacement bar receives additional limited capabilities so it can render built-in widgets and orchestrate the configured non-authentication UI, but it still cannot reach authentication services. This is not an operating-system sandbox: plugin code still runs for as long as your session does, with everything your user account can reach. Only add repos you're willing to run, and read them before you enable them. +Before it does anything, it tells you plainly that plugins run as arbitrary, unsandboxed code inside your long-lived shell process, shows you the URL, and asks you to confirm. Take that seriously. The third-party plugin interface does not directly expose authentication services, and a replacement bar receives only limited capabilities for configured non-authentication UI. Visual plugins still share the shell's QML scene and can walk ordinary parent objects, while all plugin code runs with everything your user account can reach. Authentication state is protected separately by keeping those services outside the reachable host object graph. Only add repos you're willing to run, and read them before you enable them. + +A replacement bar can render installed widgets, but service-backed third-party widgets may have reduced functionality there because the bar is not allowed to request another plugin's live service object. Switch back to the built-in `omarchy.bar` if such a widget needs its companion service. Then it clones the repo into a staging directory, validates the manifest, refuses the install if another plugin already claims that id, and moves it into `~/.config/omarchy/plugins//`. Without `--enable` it asks whether you want it on now, and you can say no and go read the code first. It never runs anything from the plugin, never executes an install hook, and never asks for sudo — it clones files, checks the manifest, and flips a bit over IPC. diff --git a/shell/README.md b/shell/README.md index d5b50290..a2bc5338 100644 --- a/shell/README.md +++ b/shell/README.md @@ -93,7 +93,9 @@ holds the session lock. The kept instance is not replaced, so code changes to a `keepLoaded` service itself only take effect on a shell restart. First-party services are loaded at startup. -Entry points may declare `omarchyPath`, `shell`, `manifest`, `pluginRegistry`, and `barWidgetRegistry` properties for host injection. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades: ordinary plugins can look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are retained outside the host's public service map and QML object tree, and changing a third-party registry snapshot cannot mutate the host registry. +Entry points may declare `omarchyPath`, `shell`, `manifest`, `pluginRegistry`, and `barWidgetRegistry` properties for host injection. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades: ordinary plugins can look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are retained outside the host's public service map and QML object tree, and changing a third-party registry or configuration snapshot cannot mutate host state. Facades do not isolate visual widgets from the parent hierarchy of the shared QML scene, so sensitive state must remain outside that reachable graph. + +Widgets rendered by a third-party replacement bar receive a service-less entry facade with target-scoped lifecycle and settings operations. Their live service objects are available only when the trusted built-in bar hosts them; otherwise the replacement bar could request and retain any configured widget's service. The full schema lives in `services/PluginRegistry.qml`. @@ -110,7 +112,7 @@ omarchy plugin update # updates every git-managed plugin omarchy plugin remove acme.weather ``` -> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding warns you before cloning, plugins land disabled so you can review the code before enabling, and updates show a diff of the changes before touching anything. The scoped QML interfaces protect shell-owned credentials and cross-plugin controls; they are not an operating-system sandbox. Only add repos whose code you are willing to run. +> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding warns you before cloning, plugins land disabled so you can review the code before enabling, and updates show a diff of the changes before touching anything. The scoped QML interfaces remove direct authentication-service and generic replacement-bar service lookups, but visual plugins still share and can traverse the ordinary host scene. Only add repos whose code you are willing to run. Each command is **interactive** when run bare in a terminal (gum pickers, confirmation, a diff to review) and fully **non-interactive** when given diff --git a/shell/Ui/PluginBarApi.qml b/shell/Ui/PluginBarApi.qml index c30021a5..03c748b1 100644 --- a/shell/Ui/PluginBarApi.qml +++ b/shell/Ui/PluginBarApi.qml @@ -2,7 +2,8 @@ import QtQuick // Bar surface exposed to an installed third-party widget. Scalar presentation // state is mirrored by Bar.qml and operations are delegated through scoped -// callbacks, so the actual Bar (and its root-shell property) is never retained. +// callbacks. The facade avoids direct host-Bar injection; it cannot isolate a +// visual child from the parent hierarchy of the QML scene that renders it. QtObject { id: api diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index df02a2f7..251536c4 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -232,10 +232,16 @@ Item { if (pluginBarApis[key]) return pluginBarApis[key] var pluginShell = null - if (registered && root.shell && typeof root.shell.pluginShellForId === "function") + if (registered && root.shell && typeof root.shell.pluginShellForId === "function") { + // Only the trusted built-in bar receives ShellRoot and can request a + // service-capable facade for the widget it is instantiating. pluginShell = root.shell.pluginShellForId(moduleName) - else if (!registered && root.shell && typeof root.shell.pluginShellForBarEntry === "function") + } else if (root.shell && typeof root.shell.pluginShellForBarEntry === "function") { + // Replacement bars receive a service-less entry facade. Giving an + // untrusted bar a generic facade factory would let it retrieve another + // third-party plugin's live service object. pluginShell = root.shell.pluginShellForBarEntry(key, moduleName) + } var api = pluginBarApiComponent.createObject(null, { pluginId: key, diff --git a/shell/services/PluginRegistry.qml b/shell/services/PluginRegistry.qml index e24c3a22..e8b8ad36 100644 --- a/shell/services/PluginRegistry.qml +++ b/shell/services/PluginRegistry.qml @@ -78,8 +78,7 @@ QtObject { } } // Every entry point must be a relative path inside the plugin's source - // directory. Reject the whole manifest if anything looks like an attempt - // to escape the plugin's sandbox. + // directory. Reject the whole manifest if an entry point escapes it. for (var key in manifest.entryPoints) { if (!isSafeEntryPoint(manifest.entryPoints[key])) { console.warn("PluginRegistry: unsafe entryPoint '" + key + "'='" diff --git a/shell/services/PluginShellApi.qml b/shell/services/PluginShellApi.qml index faa34e8d..2ce7659c 100644 --- a/shell/services/PluginShellApi.qml +++ b/shell/services/PluginShellApi.qml @@ -5,9 +5,8 @@ import QtQuick // The callbacks are closed over one plugin id by shell.qml. A plugin can call // them directly, but it cannot widen their scope: ordinary plugins are limited // to their own id, and full-bar callbacks independently enforce their explicit -// non-authentication UI scope. Keeping the host shell out of this object's -// properties also prevents ordinary QML object traversal from turning the -// facade back into the root ShellRoot. +// non-authentication UI scope. This object avoids directly injecting the host +// shell, but it is not a QML sandbox: visual plugins share the host object tree. QtObject { id: api @@ -19,7 +18,6 @@ QtObject { property var _serviceLookup: null property var _firstPartyServiceLookup: null - property var _pluginShellLookup: null property var _barEntryShellLookup: null property var _summon: null property var _hide: null @@ -39,10 +37,6 @@ QtObject { ? _firstPartyServiceLookup(String(id || "")) : null } - function pluginShellForId(id) { - return _pluginShellLookup ? _pluginShellLookup(String(id || "")) : null - } - function pluginShellForBarEntry(ownerId, moduleName) { return _barEntryShellLookup ? _barEntryShellLookup(String(ownerId || ""), String(moduleName || "")) : null diff --git a/shell/shell.qml b/shell/shell.qml index 0119d97c..7e31851d 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -114,7 +114,10 @@ ShellRoot { } readonly property var barConfig: shellConfig && Util.isPlainObject(shellConfig.bar) ? shellConfig.bar : builtinShellConfig.bar - onBarConfigChanged: if (bar && "barConfig" in bar) bar.barConfig = shell.barConfig + onBarConfigChanged: { + if (bar && "barConfig" in bar) + bar.barConfig = shell.barConfigFor(shell.activeBarManifest) + } FileView { id: defaultsFile path: shell.defaultsPath @@ -219,7 +222,7 @@ ShellRoot { if ("manifest" in target) target.manifest = shell.publicPluginManifest(manifest) if ("barWidgetRegistry" in target) target.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(manifest) if ("pluginRegistry" in target) target.pluginRegistry = shell.pluginRegistryFor(manifest) - if ("barConfig" in target) target.barConfig = shell.barConfig + if ("barConfig" in target) target.barConfig = shell.barConfigFor(manifest) shell.bar = target } @@ -323,6 +326,11 @@ ShellRoot { return JSON.parse(JSON.stringify(shell.barConfig || {})) } + function barConfigFor(manifest) { + return !manifest || manifest.__isFirstParty + ? shell.barConfig : shell.publicBarConfig() + } + function publicBarWidgetSnapshot() { var source = shell.barWidgetRegistry.widgets || {} var snapshot = {} @@ -513,9 +521,6 @@ ShellRoot { _firstPartyServiceLookup: function(requestedId) { return barCapabilities ? (firstPartyServices[requestedId] || null) : null }, - _pluginShellLookup: function(requestedId) { - return barCapabilities ? shell.scopedPluginShellForId(requestedId) : null - }, _barEntryShellLookup: function(ownerId, moduleName) { return barCapabilities ? shell.pluginShellForBarEntry(cacheKey + ":" + ownerId, moduleName) : null @@ -575,11 +580,34 @@ ShellRoot { var owner = String(ownerId || "") var target = String(moduleName || "") if (!owner || !target) return null + if (!shell.barEntryConfigured(target)) return null var cacheKey = owner + "::" + target if (_pluginBarEntryShellApis[cacheKey]) return _pluginBarEntryShellApis[cacheKey] + + function owns(requestedId) { + return shell.pluginRegistry.resolveEnabledId(String(requestedId || "")) + === shell.pluginRegistry.resolveEnabledId(target) + } + var api = pluginShellApiComponent.createObject(null, { pluginId: target, barConfig: shell.publicBarConfig(), + _summon: function(requestedId, payloadJson) { + return owns(requestedId) + ? shell.summon(shell.pluginRegistry.resolveEnabledId(target), payloadJson) : false + }, + _hide: function(requestedId) { + return owns(requestedId) + ? shell.hide(shell.pluginRegistry.resolveEnabledId(target)) : false + }, + _toggle: function(requestedId, payloadJson) { + return owns(requestedId) + ? shell.toggle(shell.pluginRegistry.resolveEnabledId(target), payloadJson) : false + }, + _isOpen: function(requestedId) { + return owns(requestedId) + ? shell.isPluginOpen(shell.pluginRegistry.resolveEnabledId(target)) : false + }, _updateSettings: function(requestedId, settings) { return String(requestedId || "") === target ? shell.updateEntryInline(target, settings) : false diff --git a/test/shell.d/fixtures/plugin-auth-boundary/shell.qml b/test/shell.d/fixtures/plugin-auth-boundary/shell.qml index 9d4582d2..a4968468 100644 --- a/test/shell.d/fixtures/plugin-auth-boundary/shell.qml +++ b/test/shell.d/fixtures/plugin-auth-boundary/shell.qml @@ -78,6 +78,7 @@ ShellRoot { authStoreOwnerUpdatesManifest: root.ownService.manifest && root.ownService.manifest.version === "kept", authStoreImportIsolated: authStoreReader.has("omarchy.lock") === false, + noGenericPluginShellFactory: typeof api.pluginShellForId !== "function", calls: root.calls } result.ok = Object.keys(result).every(function(key) { diff --git a/test/shell.d/plugin-auth-boundary-test.sh b/test/shell.d/plugin-auth-boundary-test.sh index e5eb13c7..7f274e1b 100755 --- a/test/shell.d/plugin-auth-boundary-test.sh +++ b/test/shell.d/plugin-auth-boundary-test.sh @@ -20,6 +20,7 @@ trap cleanup EXIT shell_qml="$ROOT/shell/shell.qml" bar_qml="$ROOT/shell/plugins/bar/Bar.qml" +plugin_shell_api="$ROOT/shell/services/PluginShellApi.qml" # Normalize horizontal and vertical whitespace so the wiring assertions survive # harmless QML reflow. The runtime fixture below behaviorally covers @@ -50,6 +51,19 @@ qml_matches "$shell_qml" 'target\.shell *= *shell\.pluginShellFor\( *manifest *\ fail "full-bar plugins receive a scoped shell facade" pass "third-party entry points receive scoped shell facades" +if qml_matches "$plugin_shell_api" 'function +pluginShellForId\('; then + fail "replacement-bar facade exposes a generic plugin-shell factory" +fi +qml_matches "$bar_qml" 'else if *\( *root\.shell *&& *typeof root\.shell\.pluginShellForBarEntry *=== *"function" *\) *\{[^}]*pluginShell *= *root\.shell\.pluginShellForBarEntry\( *key, *moduleName *\)' || + fail "replacement bars do not fall back to a service-less entry facade" +pass "replacement bars cannot manufacture another plugin's service facade" + +qml_matches "$shell_qml" 'target\.barConfig *= *shell\.barConfigFor\( *manifest *\)' || + fail "initial replacement-bar configuration is not detached" +qml_matches "$shell_qml" 'bar\.barConfig *= *shell\.barConfigFor\( *shell\.activeBarManifest *\)' || + fail "replacement-bar configuration updates are not detached" +pass "replacement bars receive detached configuration snapshots" + qml_matches "$bar_qml" 'target\.bar *= *firstParty *\? *root *: *root\.pluginBarApiFor\( *pluginApiId, *moduleName, *registered *\)' || fail "third-party widgets receive a bar facade instead of the host bar" qml_matches "$bar_qml" 'api\.clickTargets *= *root\.pluginClickTargets\( *api\.pluginId *\)' || diff --git a/test/shell.d/runtime-smoke-test.sh b/test/shell.d/runtime-smoke-test.sh index 1b0e5fe9..d19c04ab 100755 --- a/test/shell.d/runtime-smoke-test.sh +++ b/test/shell.d/runtime-smoke-test.sh @@ -112,6 +112,85 @@ Item { } QML +# A replacement bar must not receive a generic factory for another plugin's +# live service, and its barConfig must be a detached snapshot on both initial +# injection and later host-config updates. +victim_service_id="acme.victim-service" +victim_service_dir="$test_home/.config/omarchy/plugins/$victim_service_id" +mkdir -p "$victim_service_dir" +cat >"$victim_service_dir/manifest.json" <"$victim_service_dir/Service.qml" <<'QML' +import QtQuick + +Item { + property string privateValue: "victim-secret" +} +QML + +review_bar_id="acme.review-bar" +review_bar_dir="$test_home/.config/omarchy/plugins/$review_bar_id" +mkdir -p "$review_bar_dir" +cat >"$review_bar_dir/manifest.json" <"$review_bar_dir/Bar.qml" <<'QML' +import QtQuick +import Quickshell.Io + +Item { + id: root + + property var shell: null + property var barConfig: ({}) + + IpcHandler { + target: "acme-review-bar" + + function probeVictim(): string { + var genericFactory = root.shell + && typeof root.shell.pluginShellForId === "function" + var entryFacade = root.shell + && typeof root.shell.pluginShellForBarEntry === "function" + ? root.shell.pluginShellForBarEntry("probe", "acme.victim-service") : null + var victim = entryFacade && typeof entryFacade.serviceFor === "function" + ? entryFacade.serviceFor("acme.victim-service") : null + return JSON.stringify({ + genericFactory: !!genericFactory, + entryFacade: !!entryFacade, + victimServiceReachable: !!victim + }) + } + + function snapshot(): string { + return JSON.stringify(root.barConfig || {}) + } + + function mutateSnapshot(): string { + if (root.barConfig && root.barConfig.layout + && root.barConfig.layout.left && root.barConfig.layout.left.length > 0) + root.barConfig.layout.left[0].id = "tampered.by.review-bar" + return snapshot() + } + } +} +QML + cat >"$stub_bin/omarchy-update-available" <<'SH' #!/bin/bash echo "Omarchy update available (test)" @@ -434,3 +513,57 @@ jq -e 'all(.bar.layout.right[]; (.id // .) != "omarchy.keyboard-layout")' \ <<<"$(shell_ipc shell listShellConfig)" >/dev/null || fail_with_log "bar put added a second copy of a widget already on the bar" pass "bar put leaves a widget already on the bar alone" + +# Run the replacement-bar probes last: switching bar loaders can transiently +# leave bar-aware panels without a visual host, which should not add noise to +# the default-bar assertions above. +[[ $(shell_ipc shell setPluginEnabled "$victim_service_id" true) == "ok" ]] || + fail_with_log "victim service fixture could not be enabled" +[[ $(shell_ipc shell enablePlugin "$review_bar_id" '{}') == "ok" ]] || + fail_with_log "replacement-bar fixture could not be enabled" + +review_probe="" +for _ in {1..80}; do + review_probe=$(shell_ipc acme-review-bar probeVictim 2>/dev/null || true) + if jq -e '.genericFactory == false and .entryFacade == false and .victimServiceReachable == false' \ + <<<"$review_probe" >/dev/null 2>&1; then + break + fi + if ! kill -0 "$QS_PID" 2>/dev/null; then + fail_with_log "test shell exited while loading the replacement-bar fixture" + fi + sleep 0.1 +done +jq -e '.genericFactory == false and .entryFacade == false and .victimServiceReachable == false' \ + <<<"$review_probe" >/dev/null || { + printf 'Replacement-bar service probe: %s\n' "$review_probe" >&2 + fail_with_log "replacement bar cannot recover another plugin's live service" +} + +bar_config_before=$(shell_ipc shell listShellConfig | jq -c '.bar') +shell_ipc acme-review-bar mutateSnapshot >/dev/null +bar_config_after=$(shell_ipc shell listShellConfig | jq -c '.bar') +[[ $bar_config_after == "$bar_config_before" ]] || + fail_with_log "replacement bar mutated the initially injected host configuration" + +[[ $(shell_ipc shell setBarWidget omarchy.clock format '"HH:mm:ss"' '{}') == "ok" ]] || + fail_with_log "host bar configuration could not be updated for snapshot testing" +updated_snapshot="" +for _ in {1..80}; do + updated_snapshot=$(shell_ipc acme-review-bar snapshot 2>/dev/null || true) + if jq -e 'any(.layout.center[]; (.id // .) == "omarchy.clock" and .format == "HH:mm:ss")' \ + <<<"$updated_snapshot" >/dev/null 2>&1; then + break + fi + sleep 0.1 +done +jq -e 'any(.layout.center[]; (.id // .) == "omarchy.clock" and .format == "HH:mm:ss")' \ + <<<"$updated_snapshot" >/dev/null || + fail_with_log "replacement bar did not receive the refreshed configuration snapshot" +bar_config_before=$(shell_ipc shell listShellConfig | jq -c '.bar') +shell_ipc acme-review-bar mutateSnapshot >/dev/null +bar_config_after=$(shell_ipc shell listShellConfig | jq -c '.bar') +[[ $bar_config_after == "$bar_config_before" ]] || + fail_with_log "replacement bar mutated a refreshed host configuration" + +pass "replacement-bar service and configuration boundaries hold at runtime" From 203e1639c33cea57082503a342f1fd3e632698a2 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Wed, 2 Sep 2026 12:11:15 -0600 Subject: [PATCH 34/76] Revoke stale plugin facade capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported-by: Roger Piñol --- shell/shell.qml | 111 ++++++++++++--- test/shell.d/plugin-auth-boundary-test.sh | 16 +++ test/shell.d/runtime-smoke-test.sh | 157 +++++++++++++++++++++- 3 files changed, 266 insertions(+), 18 deletions(-) diff --git a/shell/shell.qml b/shell/shell.qml index 7e31851d..d9714a9d 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -275,6 +275,7 @@ ShellRoot { property var _services: ({}) property var _pluginShellApis: ({}) + property var _pluginShellApiDescriptors: ({}) property var _pluginBarEntryShellApis: ({}) property var _pluginRegistryApis: ({}) property var _pluginBarWidgetRegistryApis: ({}) @@ -434,7 +435,9 @@ ShellRoot { var proxyKey = cacheKey + "::" + id if (_pluginFirstPartyServiceApis[proxyKey]) return _pluginFirstPartyServiceApis[proxyKey] - function service() { return shell.serviceFor(id) } + function service() { + return shell.serviceFor(shell.pluginRegistry.resolveEnabledId(id)) + } var api = pluginFirstPartyServiceApiComponent.createObject(null, { ownerPluginId: pluginId, serviceId: id, @@ -491,10 +494,67 @@ ShellRoot { return api } + function pluginShellCapabilityProfile(manifest, allowOwnService, barCapabilities) { + return [ + allowOwnService ? "own-service" : "no-own-service", + barCapabilities ? "bar" : "no-bar", + shell.manifestHasKind(manifest, "menu") ? "menu" : "no-menu" + ].join("|") + } + + function cacheWithoutKey(cache, key, destroyValue) { + var next = ({}) + for (var existing in cache) { + if (existing === key) { + var value = cache[existing] + if (destroyValue && value && typeof value.destroy === "function") value.destroy() + } else { + next[existing] = cache[existing] + } + } + return next + } + + function cacheWithoutPrefix(cache, prefix) { + var next = ({}) + for (var existing in cache) { + if (existing.indexOf(prefix) === 0) { + var value = cache[existing] + if (value && typeof value.destroy === "function") value.destroy() + } else { + next[existing] = cache[existing] + } + } + return next + } + + function revokePluginShellApi(cacheKey) { + var key = String(cacheKey || "") + if (!key) return + _pluginAppLibraryApis = shell.cacheWithoutKey(_pluginAppLibraryApis, key, true) + _pluginFirstPartyServiceApis = shell.cacheWithoutPrefix(_pluginFirstPartyServiceApis, key + "::") + _pluginBarEntryShellApis = shell.cacheWithoutPrefix(_pluginBarEntryShellApis, key + ":") + _pluginShellApis = shell.cacheWithoutKey(_pluginShellApis, key, true) + _pluginShellApiDescriptors = shell.cacheWithoutKey(_pluginShellApiDescriptors, key, false) + } + function createScopedPluginShell(manifest, cacheKey, allowOwnService, barCapabilities) { - if (_pluginShellApis[cacheKey]) return _pluginShellApis[cacheKey] var key = String(manifest && manifest.id || "") if (!key) return null + var profile = shell.pluginShellCapabilityProfile(manifest, allowOwnService, barCapabilities) + var cached = _pluginShellApis[cacheKey] + var descriptor = _pluginShellApiDescriptors[cacheKey] + if (cached && descriptor && descriptor.pluginId === key + && descriptor.profile === profile) return cached + if (cached || descriptor) shell.revokePluginShellApi(cacheKey) + + function currentManifest() { + return shell.pluginRegistry.installedPlugins[key] || null + } + + function hasCurrentBarCapabilities() { + return barCapabilities && shell.pluginHasBarCapabilities(currentManifest()) + } // Construct the narrow service proxies before any plugin binding can call // firstPartyServiceFor(). Creating a QObject while evaluating that binding @@ -519,40 +579,42 @@ ShellRoot { return allowOwnService ? shell.pluginServiceFor(key, requestedId) : null }, _firstPartyServiceLookup: function(requestedId) { - return barCapabilities ? (firstPartyServices[requestedId] || null) : null + if (allowOwnService && shell.pluginOwnsTarget(key, requestedId)) + return shell.pluginServiceFor(key, requestedId) + return hasCurrentBarCapabilities() ? (firstPartyServices[requestedId] || null) : null }, _barEntryShellLookup: function(ownerId, moduleName) { - return barCapabilities + return hasCurrentBarCapabilities() ? shell.pluginShellForBarEntry(cacheKey + ":" + ownerId, moduleName) : null }, _summon: function(requestedId, payloadJson) { if (!shell.pluginOwnsTarget(key, requestedId) - && !shell.barPluginMayControl(manifest, requestedId)) return false + && !shell.barPluginMayControl(currentManifest(), requestedId)) return false return shell.summon(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson) }, _hide: function(requestedId) { if (!shell.pluginOwnsTarget(key, requestedId) - && !shell.barPluginMayControl(manifest, requestedId)) return false + && !shell.barPluginMayControl(currentManifest(), requestedId)) return false return shell.hide(shell.pluginRegistry.resolveEnabledId(requestedId)) }, _toggle: function(requestedId, payloadJson) { if (!shell.pluginOwnsTarget(key, requestedId) - && !shell.barPluginMayControl(manifest, requestedId)) return false + && !shell.barPluginMayControl(currentManifest(), requestedId)) return false return shell.toggle(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson) }, _isOpen: function(requestedId) { if (!shell.pluginOwnsTarget(key, requestedId) - && !shell.barPluginMayControl(manifest, requestedId)) return false + && !shell.barPluginMayControl(currentManifest(), requestedId)) return false return shell.isPluginOpen(shell.pluginRegistry.resolveEnabledId(requestedId)) }, _updateSettings: function(requestedId, settings) { if (shell.pluginOwnsTarget(key, requestedId)) return shell.updateEntryInline(key, settings) - if (barCapabilities && shell.barEntryConfigured(requestedId)) + if (hasCurrentBarCapabilities() && shell.barEntryConfigured(requestedId)) return shell.updateEntryInline(requestedId, settings) return false }, _mutateBarConfig: function(mutator) { - return barCapabilities ? shell.mutatePluginBarConfig(mutator) : false + return hasCurrentBarCapabilities() ? shell.mutatePluginBarConfig(mutator) : false } }) if (!api) return null @@ -561,6 +623,15 @@ ShellRoot { for (var id in _pluginShellApis) next[id] = _pluginShellApis[id] next[cacheKey] = api _pluginShellApis = next + var descriptorNext = ({}) + for (var descriptorKey in _pluginShellApiDescriptors) + descriptorNext[descriptorKey] = _pluginShellApiDescriptors[descriptorKey] + descriptorNext[cacheKey] = { + pluginId: key, + allowOwnService: allowOwnService === true, + profile: profile + } + _pluginShellApiDescriptors = descriptorNext return api } @@ -679,13 +750,20 @@ ShellRoot { function prunePluginApis() { var plugins = shell.pluginRegistry.installedPlugins - var shellNext = ({}) - for (var shellKey in _pluginShellApis) { + var shellKeys = Object.keys(_pluginShellApis) + for (var si = 0; si < shellKeys.length; si++) { + var shellKey = shellKeys[si] var shellApi = _pluginShellApis[shellKey] - if (shell.pluginApiActive(shellApi, plugins)) shellNext[shellKey] = shellApi - else if (shellApi && typeof shellApi.destroy === "function") shellApi.destroy() + var descriptor = _pluginShellApiDescriptors[shellKey] + var manifest = descriptor ? plugins[descriptor.pluginId] : null + var barCapabilities = descriptor && descriptor.allowOwnService + && shell.pluginHasBarCapabilities(manifest) + var expectedProfile = descriptor + ? shell.pluginShellCapabilityProfile(manifest, descriptor.allowOwnService, barCapabilities) : "" + var active = descriptor && manifest && shell.pluginRegistry.isEnabled(descriptor.pluginId) + if (!active || descriptor.profile !== expectedProfile) + shell.revokePluginShellApi(shellKey) } - _pluginShellApis = shellNext var registryNext = ({}) for (var registryKey in _pluginRegistryApis) { @@ -761,7 +839,7 @@ ShellRoot { } function firstPartyServiceFor(pluginId) { - return serviceFor(pluginId) + return serviceFor(shell.pluginRegistry.resolveEnabledId(pluginId)) } function isAuthenticationService(manifest) { @@ -844,6 +922,7 @@ ShellRoot { } else { // A kept instance outlives the rescan; hand it the fresh manifest. var kept = _services[id] + if (kept && "shell" in kept) kept.shell = shell.pluginShellFor(m) if (kept && "manifest" in kept) kept.manifest = shell.publicPluginManifest(m) continue } diff --git a/test/shell.d/plugin-auth-boundary-test.sh b/test/shell.d/plugin-auth-boundary-test.sh index 7f274e1b..9859699f 100755 --- a/test/shell.d/plugin-auth-boundary-test.sh +++ b/test/shell.d/plugin-auth-boundary-test.sh @@ -95,6 +95,22 @@ qml_matches "$shell_qml" 'shell\.prunePluginApis\( *\)' || fail "disabled plugin facade caches are pruned" pass "plugin facade synchronization is bounded" +qml_matches "$shell_qml" 'descriptor\.profile *!== *expectedProfile[^}]*shell\.revokePluginShellApi\( *shellKey *\)' || + fail "manifest capability changes do not revoke cached plugin facades" +qml_matches "$shell_qml" 'shell\.barPluginMayControl\( *currentManifest\( *\), *requestedId *\)' || + fail "bar lifecycle callbacks do not validate the current manifest" +qml_matches "$shell_qml" 'return hasCurrentBarCapabilities\( *\) *\? *shell\.mutatePluginBarConfig\( *mutator *\) *: *false' || + fail "bar configuration mutation does not validate the current manifest" +pass "manifest changes revoke cached facade capabilities" + +qml_matches "$shell_qml" 'shell\.serviceFor\( *shell\.pluginRegistry\.resolveEnabledId\( *id *\) *\)' || + fail "narrow first-party service proxies do not resolve enabled clones" +qml_matches "$shell_qml" 'return serviceFor\( *shell\.pluginRegistry\.resolveEnabledId\( *pluginId *\) *\)' || + fail "trusted first-party service lookups do not resolve enabled clones" +qml_matches "$shell_qml" 'allowOwnService *&& *shell\.pluginOwnsTarget\( *key, *requestedId *\)[^}]*return shell\.pluginServiceFor\( *key, *requestedId *\)' || + fail "cloned widgets cannot use a source id to reach their own service" +pass "service facades resolve enabled clones without widening replacement-bar access" + require_compositor "plugin authentication boundary runtime test" if ! command -v quickshell >/dev/null 2>&1; then diff --git a/test/shell.d/runtime-smoke-test.sh b/test/shell.d/runtime-smoke-test.sh index d19c04ab..8c444840 100755 --- a/test/shell.d/runtime-smoke-test.sh +++ b/test/shell.d/runtime-smoke-test.sh @@ -136,6 +136,66 @@ Item { } QML +# A clone of the built-in media service exercises both supported service paths: +# its own widget receives the raw companion service under the trusted bar, while +# a replacement bar receives only the narrow media proxy resolved to the clone. +media_clone_id="acme.media-clone" +media_clone_dir="$test_home/.config/omarchy/plugins/$media_clone_id" +mkdir -p "$media_clone_dir" +cat >"$media_clone_dir/manifest.json" <"$media_clone_dir/Service.qml" <<'QML' +import QtQuick +import Quickshell.Io + +Item { + property string marker: "clone-service" + property bool enabled: true + property var activePlayer: null + property var sourcePlayers: [] + + function runAction(action, showFeedback, targetKey) {} + function playerKey(player) { return "" } + function selectPlayer(playerKey) {} + + IpcHandler { + target: "acme-media-clone-service" + function ping(): string { return marker } + } +} +QML +cat >"$media_clone_dir/BarWidget.qml" <<'QML' +import QtQuick +import Quickshell.Io + +Item { + id: root + property var bar: null + + IpcHandler { + target: "acme-media-clone-widget" + function probeOwnService(): string { + var service = root.bar && root.bar.shell + ? root.bar.shell.firstPartyServiceFor("omarchy.media") : null + return JSON.stringify({ + reachable: !!service, + marker: service ? String(service.marker || "") : "" + }) + } + } +} +QML + review_bar_id="acme.review-bar" review_bar_dir="$test_home/.config/omarchy/plugins/$review_bar_id" mkdir -p "$review_bar_dir" @@ -145,8 +205,9 @@ cat >"$review_bar_dir/manifest.json" <"$review_bar_dir/Bar.qml" <<'QML' @@ -181,6 +242,12 @@ Item { return JSON.stringify(root.barConfig || {}) } + function probeMediaProxy(): string { + var service = root.shell + ? root.shell.firstPartyServiceFor("omarchy.media") : null + return JSON.stringify({ reachable: !!service, enabled: service ? service.enabled === true : false }) + } + function mutateSnapshot(): string { if (root.barConfig && root.barConfig.layout && root.barConfig.layout.left && root.barConfig.layout.left.length > 0) @@ -190,6 +257,38 @@ Item { } } QML +cat >"$review_bar_dir/Service.qml" <<'QML' +import QtQuick +import Quickshell.Io + +Item { + id: root + property var shell: null + property var retainedShell: null + + onShellChanged: if (!retainedShell && shell) retainedShell = shell + + function mutationAllowed(candidate) { + if (!candidate) return false + try { + return typeof candidate.mutateShellConfig === "function" + && candidate.mutateShellConfig(function(config) {}) === true + } catch (e) { + return false + } + } + + IpcHandler { + target: "acme-review-capability" + function probe(): string { + return JSON.stringify({ + currentAllowed: root.mutationAllowed(root.shell), + retainedAllowed: root.mutationAllowed(root.retainedShell) + }) + } + } +} +QML cat >"$stub_bin/omarchy-update-available" <<'SH' #!/bin/bash @@ -517,6 +616,24 @@ pass "bar put leaves a widget already on the bar alone" # Run the replacement-bar probes last: switching bar loaders can transiently # leave bar-aware panels without a visual host, which should not add noise to # the default-bar assertions above. +[[ $(shell_ipc shell setPluginEnabled "$media_clone_id" true) == "ok" ]] || + fail_with_log "media clone fixture could not be enabled" +clone_widget_probe="" +for _ in {1..80}; do + clone_widget_probe=$(shell_ipc acme-media-clone-widget probeOwnService 2>/dev/null || true) + if jq -e '.reachable == true and .marker == "clone-service"' \ + <<<"$clone_widget_probe" >/dev/null 2>&1; then + break + fi + sleep 0.1 +done +jq -e '.reachable == true and .marker == "clone-service"' \ + <<<"$clone_widget_probe" >/dev/null || { + printf 'Clone own-service probe: %s\n' "$clone_widget_probe" >&2 + fail_with_log "a cloned widget resolves its source id to its own companion service" +} +pass "trusted bar gives a cloned widget its own companion service" + [[ $(shell_ipc shell setPluginEnabled "$victim_service_id" true) == "ok" ]] || fail_with_log "victim service fixture could not be enabled" [[ $(shell_ipc shell enablePlugin "$review_bar_id" '{}') == "ok" ]] || @@ -540,6 +657,12 @@ jq -e '.genericFactory == false and .entryFacade == false and .victimServiceReac fail_with_log "replacement bar cannot recover another plugin's live service" } +media_proxy_probe=$(shell_ipc acme-review-bar probeMediaProxy) +jq -e '.reachable == true and .enabled == true' <<<"$media_proxy_probe" >/dev/null || { + printf 'Replacement-bar media proxy probe: %s\n' "$media_proxy_probe" >&2 + fail_with_log "replacement-bar service proxies resolve enabled clones" +} + bar_config_before=$(shell_ipc shell listShellConfig | jq -c '.bar') shell_ipc acme-review-bar mutateSnapshot >/dev/null bar_config_after=$(shell_ipc shell listShellConfig | jq -c '.bar') @@ -567,3 +690,33 @@ bar_config_after=$(shell_ipc shell listShellConfig | jq -c '.bar') fail_with_log "replacement bar mutated a refreshed host configuration" pass "replacement-bar service and configuration boundaries hold at runtime" + +capability_before=$(shell_ipc acme-review-capability probe) +jq -e '.currentAllowed == true and .retainedAllowed == true' \ + <<<"$capability_before" >/dev/null || + fail_with_log "bar service fixture did not initially receive bar capabilities" + +# Keep the same enabled plugin ID and service instance while dropping the bar +# kind. Both the currently injected facade and a reference retained by the +# plugin must lose the old configuration capability after the manifest rescan. +jq '.kinds = ["service"] | .entryPoints = {"service": "Service.qml"}' \ + "$review_bar_dir/manifest.json" >"$review_bar_dir/manifest.json.tmp" +mv "$review_bar_dir/manifest.json.tmp" "$review_bar_dir/manifest.json" +capability_after="" +for _ in {1..80}; do + capability_after=$(shell_ipc acme-review-capability probe 2>/dev/null || true) + if jq -e '.currentAllowed == false and .retainedAllowed == false' \ + <<<"$capability_after" >/dev/null 2>&1; then + break + fi + if ! kill -0 "$QS_PID" 2>/dev/null; then + fail_with_log "test shell exited while revoking changed manifest capabilities" + fi + sleep 0.1 +done +jq -e '.currentAllowed == false and .retainedAllowed == false' \ + <<<"$capability_after" >/dev/null || { + printf 'Capability revocation probe: %s\n' "$capability_after" >&2 + fail_with_log "cached plugin facades revoke capabilities removed from the manifest" +} +pass "manifest reload revokes cached facade capabilities" From 493067741e081c3b09082da6bfd51e99ec24ef00 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 4 Sep 2026 17:59:32 +0200 Subject: [PATCH 35/76] Give foot its own touchpad scroll factor (#9793) foot only applies scrollback.multiplier to discrete wheel clicks, so precise touchpad scrolling ignores it and crawls at the group's 1.5 factor. Split foot out at 2.0 to match how the other terminals feel. Co-authored-by: Claude Fable 5 --- config/hypr/input.lua | 3 ++- default/hypr/input.lua | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/config/hypr/input.lua b/config/hypr/input.lua index d20be92a..d06e05c7 100644 --- a/config/hypr/input.lua +++ b/config/hypr/input.lua @@ -45,7 +45,8 @@ -- }) -- App-specific touchpad scroll speeds. --- o.window("(Alacritty|kitty|foot)", { scroll_touchpad = 1.5 }) +-- o.window("(Alacritty|kitty)", { scroll_touchpad = 1.5 }) +-- o.window("foot", { scroll_touchpad = 2.0 }) -- o.window("com.mitchellh.ghostty", { scroll_touchpad = 0.2 }) -- Enable touchpad gestures for changing workspaces. diff --git a/default/hypr/input.lua b/default/hypr/input.lua index bdcd384f..a87ad994 100644 --- a/default/hypr/input.lua +++ b/default/hypr/input.lua @@ -75,5 +75,7 @@ hl.config({ }) -- Scroll nicely in the terminal. -o.window("(Alacritty|kitty|foot)", { scroll_touchpad = 1.5 }) +o.window("(Alacritty|kitty)", { scroll_touchpad = 1.5 }) +-- foot only applies its scrollback multiplier to wheel clicks, not precise touchpad scrolling. +o.window("foot", { scroll_touchpad = 2.0 }) o.window("com.mitchellh.ghostty", { scroll_touchpad = 0.2 }) From 70047956fac8bdfe36aabb9dea82b4eee50e1b2c Mon Sep 17 00:00:00 2001 From: Mehmet Ince Date: Fri, 4 Sep 2026 19:19:18 +0100 Subject: [PATCH 36/76] Harden lock authentication command lookup Keep the target user's local bin on user-scoped upgrade paths while giving privileged lock and firewall helpers only root-owned search directories. Pin the lock helper's root PATH and fprintd-list executable, with regression coverage for each defense independently. Reported-by: Rooke Poole --- bin/omarchy-apply-lock | 9 +- bin/omarchy-upgrade-to-quattro | 9 +- test/shell.d/apply-lock-test.sh | 206 ++++++++++++++++++++++++ test/shell.d/upgrade-to-quattro-test.sh | 46 +++++- 4 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 test/shell.d/apply-lock-test.sh diff --git a/bin/omarchy-apply-lock b/bin/omarchy-apply-lock index 9b97c0db..5bb261cb 100755 --- a/bin/omarchy-apply-lock +++ b/bin/omarchy-apply-lock @@ -6,6 +6,12 @@ set -e +# Install and upgrade callers can start this helper as root. Ignore their PATH +# so optional commands never fall through to a user-writable directory. +if (( EUID == 0 )); then + export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin +fi + target_user=${OMARCHY_INSTALL_USER:-${SUDO_USER:-}} if [[ -z $target_user && -n ${PKEXEC_UID:-} ]]; then target_user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1) @@ -34,7 +40,8 @@ auth required pam_faillock.so authsucc account include system-local-login EOF -if omarchy-cmd-present fprintd-list && fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then +if [[ -x /usr/bin/fprintd-list ]] && + /usr/bin/fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then echo "Configuring lock screen fingerprint authentication..." as_root tee /etc/pam.d/omarchy-lock-fingerprint >/dev/null <<'EOF' #%PAM-1.0 diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 2e9bd759..a4caa7fe 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -175,7 +175,10 @@ target_home=$(getent passwd "$target_user" | cut -d: -f6) [[ -n $target_home && -d $target_home ]] || fail "Home directory for '$target_user' was not found." target_uid=$(id -u "$target_user") target_runtime_dir="/run/user/$target_uid" -package_path="/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin:$target_home/.local/bin" +# User-local commands are needed only after dropping to the target user. Never +# expose their search path to commands run through as_root. +root_path=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin +package_path="$root_path:$target_home/.local/bin" as_root() { if (( EUID == 0 )); then @@ -661,7 +664,7 @@ configure_lock_authentication() { as_root env \ OMARCHY_INSTALL_USER="$target_user" \ OMARCHY_PATH=/usr/share/omarchy \ - PATH="$package_path" \ + PATH="$root_path" \ "$apply_lock" } @@ -1287,7 +1290,7 @@ apply_firewall_defaults() { fi log "Applying Omarchy firewall defaults" - as_root env OMARCHY_PATH=/usr/share/omarchy PATH="$package_path" \ + as_root env OMARCHY_PATH=/usr/share/omarchy PATH="$root_path" \ bash -euo pipefail "$firewall_script" || warn "Could not apply firewall defaults; run 'sudo bash $firewall_script' after reboot." } diff --git a/test/shell.d/apply-lock-test.sh b/test/shell.d/apply-lock-test.sh new file mode 100644 index 00000000..5e7170cf --- /dev/null +++ b/test/shell.d/apply-lock-test.sh @@ -0,0 +1,206 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +apply_lock="$ROOT/bin/omarchy-apply-lock" + +root_path_guard=$(awk ' + /^if \(\( EUID == 0 \)\); then$/ { inside = 1 } + inside { print } + inside && /^fi$/ { exit } +' "$apply_lock") +grep -Fx ' export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin' <<<"$root_path_guard" >/dev/null || + fail "the root lock helper replaces its inherited command path" +if grep -E '(\.local/bin|target_user|target_home)' <<<"$root_path_guard" >/dev/null; then + fail "the root lock helper does not retain a user-controlled command directory" +fi +pass "the root lock helper uses only trusted command directories" + +grep -F '[[ -x /usr/bin/fprintd-list ]]' "$apply_lock" >/dev/null || + fail "the lock helper checks the trusted fprintd-list executable" +grep -F '/usr/bin/fprintd-list "$target_user"' "$apply_lock" >/dev/null || + fail "the lock helper invokes fprintd-list by its trusted absolute path" +if grep -F 'omarchy-cmd-present fprintd-list' "$apply_lock" >/dev/null || + grep -E '(^|[[:space:];&|])fprintd-list([[:space:]]|$)' "$apply_lock" >/dev/null || + grep -E 'command[[:space:]]+-v[[:space:]]+fprintd-list' "$apply_lock" >/dev/null; then + fail "the lock helper does not resolve fprintd-list through PATH" +fi +pass "the lock helper pins fprintd-list to its packaged system path" + +# Exercise the helper as real root when the suite already has it, or as root in +# an unprivileged user namespace otherwise. A hardened kernel can disable user +# namespaces, so preserve the static coverage above and skip only this probe. +root_runner=() +root_runtime_available=1 +if (( EUID != 0 )); then + if command -v unshare >/dev/null && unshare --user --map-root-user true 2>/dev/null; then + root_runner=(unshare --user --map-root-user) + else + root_runtime_available=0 + fi +fi + +if (( ! root_runtime_available )); then + pass "no unprivileged user namespace; skipping the root lock-helper lookup matrix" + exit 0 +fi + +# Retarget the two PAM files, the trusted fprintd-list binary, and the final +# shell status query in copies under this scratch directory. The production +# files and service stay untouched even when this suite itself runs as root. +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +poison_bin="$test_tmp/poison-bin" +trusted_root_bin="$test_tmp/trusted-root-bin" +trusted_fprintd="$test_tmp/trusted-fprintd-list" +password_pam="$test_tmp/omarchy-lock-password" +fingerprint_pam="$test_tmp/omarchy-lock-fingerprint" +attack_marker="$test_tmp/user-fprintd-list-ran" +trusted_uid="$test_tmp/trusted-fprintd-list.uid" +trusted_args="$test_tmp/trusted-fprintd-list.args" +attack_args="$test_tmp/user-fprintd-list.args" +patched_helper="$test_tmp/omarchy-apply-lock-patched" +absolute_only_helper="$test_tmp/omarchy-apply-lock-absolute-only" +root_path_only_helper="$test_tmp/omarchy-apply-lock-root-path-only" +unprotected_helper="$test_tmp/omarchy-apply-lock-unprotected" +target_user=omarchy-regression-user +mkdir -p "$poison_bin" "$trusted_root_bin" + +# The runtime copy pins to this isolated root path. It contains every bare +# command the exercised helper needs, but deliberately no fprintd-list. +for helper in grep rm tee; do + ln -s "/usr/bin/$helper" "$trusted_root_bin/$helper" +done + +export TEST_ATTACK_ARGS="$attack_args" +export TEST_ATTACK_MARKER="$attack_marker" +export TEST_TRUSTED_ARGS="$trusted_args" +export TEST_TRUSTED_UID="$trusted_uid" + +cat >"$trusted_fprintd" <<'EOF' +#!/bin/bash + +printf '%s\n' "$EUID" >"$TEST_TRUSTED_UID" +printf '%s\n' "$*" >"$TEST_TRUSTED_ARGS" +echo "Fingerprints are enrolled" +EOF + +cat >"$poison_bin/fprintd-list" <<'EOF' +#!/bin/bash + +printf '%s\n' "$EUID" >"$TEST_ATTACK_MARKER" +printf '%s\n' "$*" >"$TEST_ATTACK_ARGS" +echo "Fingerprints are enrolled" +EOF + +chmod +x "$trusted_fprintd" "$poison_bin/fprintd-list" + +prepare_helper() { + local destination="$1" keep_root_path="$2" use_absolute_fprintd="$3" + + awk \ + -v password_pam="$password_pam" \ + -v fingerprint_pam="$fingerprint_pam" \ + -v trusted_root_bin="$trusted_root_bin" \ + -v trusted_fprintd="$trusted_fprintd" \ + -v keep_root_path="$keep_root_path" \ + -v use_absolute_fprintd="$use_absolute_fprintd" ' + { + line = $0 + gsub("/etc/pam\\.d/omarchy-lock-password", "\"" password_pam "\"", line) + gsub("/etc/pam\\.d/omarchy-lock-fingerprint", "\"" fingerprint_pam "\"", line) + + if (line == "if (( EUID == 0 )); then" && keep_root_path == 0) { + print "if (( 0 )); then" + next + } + if (line == " export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin") { + print " export PATH=\"" trusted_root_bin "\"" + next + } + if (line == "if [[ -x /usr/bin/fprintd-list ]] &&") { + if (use_absolute_fprintd == 1) { + print "if [[ -x \"" trusted_fprintd "\" ]] &&" + } else { + print "if command -v fprintd-list >/dev/null 2>&1 &&" + } + next + } + if (line == " /usr/bin/fprintd-list \"$target_user\" 2>/dev/null | grep -qi finger; then") { + if (use_absolute_fprintd == 1) { + print " \"" trusted_fprintd "\" \"$target_user\" 2>/dev/null | grep -qi finger; then" + } else { + print " fprintd-list \"$target_user\" 2>/dev/null | grep -qi finger; then" + } + next + } + if (line == "if omarchy-shell lock status >/dev/null 2>&1; then") { + print "if false; then" + next + } + + print line + } + ' "$apply_lock" >"$destination" + chmod +x "$destination" +} + +prepare_helper "$patched_helper" 1 1 +prepare_helper "$absolute_only_helper" 0 1 +prepare_helper "$root_path_only_helper" 1 0 +prepare_helper "$unprotected_helper" 0 0 + +for helper in "$patched_helper" "$absolute_only_helper" "$root_path_only_helper" "$unprotected_helper"; do + if grep -F '/etc/pam.d/' "$helper" >/dev/null || + grep -F '/usr/bin/fprintd-list' "$helper" >/dev/null || + grep -F 'omarchy-shell lock status' "$helper" >/dev/null; then + fail "the isolated root fixture redirects every live-system lock-helper target" + fi +done + +reset_runtime_files() { + rm -f "$password_pam" "$fingerprint_pam" "$trusted_uid" "$trusted_args" "$attack_marker" "$attack_args" +} + +run_as_root() { + local helper="$1" description="$2" output + + if ! output=$(PATH="$poison_bin:/usr/bin:/bin" OMARCHY_INSTALL_USER="$target_user" \ + "${root_runner[@]}" /bin/bash "$helper" 2>&1); then + fail "$description" "$output" + fi +} + +reset_runtime_files +run_as_root "$patched_helper" "the fully hardened lock helper runs in an isolated root context" +[[ ! -e $attack_marker ]] || fail "the hardened root lock helper executes the user-planted fprintd-list" +grep -Fx '0' "$trusted_uid" >/dev/null || fail "the trusted fprintd-list probe runs with EUID 0" +grep -Fx "$target_user" "$trusted_args" >/dev/null || fail "the trusted fprintd-list probe receives the target user" +[[ -s $password_pam && -s $fingerprint_pam ]] || + fail "the isolated root lock-helper run writes both scratch PAM fixtures" +pass "the hardened root lock helper uses the trusted fingerprint probe" + +reset_runtime_files +run_as_root "$absolute_only_helper" "the absolute-path-only lock helper runs in an isolated root context" +[[ ! -e $attack_marker ]] || fail "an absolute fprintd-list path permits the user-planted command" +grep -Fx '0' "$trusted_uid" >/dev/null || fail "the absolute-path defense runs the trusted probe as root" +pass "the absolute fprintd-list path independently blocks the user-planted command" + +reset_runtime_files +run_as_root "$root_path_only_helper" "the root-PATH-only lock helper runs in an isolated root context" +[[ ! -e $attack_marker ]] || fail "the trusted root path permits the user-planted fprintd-list" +pass "the trusted root path independently blocks the user-planted command" + +# Mutation control: removing both protections must execute the planted command +# as UID 0, proving the matrix detects the original privilege-boundary failure. +reset_runtime_files +run_as_root "$unprotected_helper" "the unprotected mutation runs in an isolated root context" +grep -Fx '0' "$attack_marker" >/dev/null || + fail "the root lock-helper fixture detects a PATH-resolved fprintd-list regression" +grep -Fx "$target_user" "$attack_args" >/dev/null || + fail "the planted fprintd-list receives the target user" +[[ -s $fingerprint_pam ]] || fail "the planted fprintd-list controls the fingerprint PAM branch" +pass "the root lock-helper matrix rejects the vulnerable PATH lookup" diff --git a/test/shell.d/upgrade-to-quattro-test.sh b/test/shell.d/upgrade-to-quattro-test.sh index 0648409d..d357f513 100644 --- a/test/shell.d/upgrade-to-quattro-test.sh +++ b/test/shell.d/upgrade-to-quattro-test.sh @@ -6,6 +6,10 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" upgrade_to_quattro="$ROOT/bin/omarchy-upgrade-to-quattro" +function_body() { + awk -v name="$1" '$0 == name "() {" { inside = 1; next } inside && $0 == "}" { exit } inside' "$upgrade_to_quattro" +} + snapshot_line=$(grep -n '^create_pre_upgrade_snapshot$' "$upgrade_to_quattro" | cut -d: -f1) pacman_line=$(grep -n '^configure_pacman_channel$' "$upgrade_to_quattro" | cut -d: -f1) [[ -n $snapshot_line && -n $pacman_line ]] || fail "upgrade snapshot and first mutation calls exist" @@ -73,6 +77,44 @@ grep -F 'OMARCHY_INSTALL_USER="$target_user"' "$upgrade_to_quattro" >/dev/null grep -F '"$apply_lock"' "$upgrade_to_quattro" >/dev/null pass "Omarchy 4 upgrade configures lock screen authentication for the target user" +root_path_count=$(awk '/^root_path=/{ count++ } END { print count + 0 }' "$upgrade_to_quattro") +(( root_path_count == 1 )) || fail "Omarchy 4 upgrade defines exactly one root command path" +grep -Fx 'root_path=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade limits root command lookup to trusted system directories" +if grep -E '^root_path=.*(target_home|\.local/bin)' "$upgrade_to_quattro" >/dev/null; then + fail "Omarchy 4 upgrade does not put the target user's bin directory on the root command path" +fi +grep -Fx 'package_path="$root_path:$target_home/.local/bin"' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade retains the target user's bin directory for user commands" + +lock_authentication_body=$(function_body configure_lock_authentication) +lock_path_assignment_count=$(awk '{ count += gsub(/(^|[[:space:]])PATH=/, "") } END { print count + 0 }' <<<"$lock_authentication_body") +(( lock_path_assignment_count == 1 )) || + fail "Omarchy 4 upgrade gives the privileged lock helper exactly one command path" +grep -Fx ' PATH="$root_path" \' <<<"$lock_authentication_body" >/dev/null || + fail "Omarchy 4 upgrade gives the privileged lock helper the trusted root path" +if grep -E '(package_path|target_home|\.local/bin)' <<<"$lock_authentication_body" >/dev/null; then + fail "Omarchy 4 upgrade does not give the privileged lock helper the target user's path" +fi + +firewall_body=$(function_body apply_firewall_defaults) +firewall_path_assignment_count=$(awk '{ count += gsub(/(^|[[:space:]])PATH=/, "") } END { print count + 0 }' <<<"$firewall_body") +(( firewall_path_assignment_count == 1 )) || + fail "Omarchy 4 upgrade gives the privileged firewall helper exactly one command path" +grep -Fx ' as_root env OMARCHY_PATH=/usr/share/omarchy PATH="$root_path" \' <<<"$firewall_body" >/dev/null || + fail "Omarchy 4 upgrade gives the privileged firewall helper the trusted root path" +if grep -E '(package_path|target_home|\.local/bin)' <<<"$firewall_body" >/dev/null; then + fail "Omarchy 4 upgrade does not give the privileged firewall helper the target user's path" +fi + +user_omarchy_body=$(function_body run_as_user_omarchy) +grep -F 'PATH="$package_path"' <<<"$user_omarchy_body" >/dev/null || + fail "Omarchy 4 upgrade retains the package and user path for target-user commands" +if grep -F 'PATH="$root_path"' <<<"$user_omarchy_body" >/dev/null; then + fail "Omarchy 4 upgrade does not narrow target-user commands to the root-only path" +fi +pass "Omarchy 4 upgrade separates privileged and target-user command paths" + grep -F 'install/helpers/browser-policy.sh' "$upgrade_to_quattro" >/dev/null || fail "Omarchy 4 upgrade uses the shared browser-policy helper" grep -F 'as_root test -f "$browser_policy_helper"' "$upgrade_to_quattro" >/dev/null || @@ -100,10 +142,6 @@ pass "Omarchy 4 upgrade retires systemd-networkd for NetworkManager" # Booting with both managers enabled leaves them fighting over the Wi-Fi # adapter, so enabling NetworkManager and disabling iwd cannot be separated by # any step that might abort in between. -function_body() { - awk -v name="$1" '$0 == name "() {" { inside = 1; next } inside && $0 == "}" { exit } inside' "$upgrade_to_quattro" -} - migrations_body=$(function_body run_post_upgrade_migrations) grep -F 'fail "Omarchy migrations did not complete.' <<<"$migrations_body" >/dev/null || fail "Omarchy 4 upgrade fails when a migration cannot complete" From 8f155493805cab5e2c520b83c86b6a1aba6ebe7b Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Fri, 4 Sep 2026 21:50:41 -0500 Subject: [PATCH 37/76] Ask, default no, before Remove Hermes deletes the user's data --- bin/omarchy-remove-ai-hermes | 36 +++++++++++++---- manual/17-ai.md | 2 +- test/shell.d/hermes-remove-test.sh | 65 +++++++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 9 deletions(-) diff --git a/bin/omarchy-remove-ai-hermes b/bin/omarchy-remove-ai-hermes index 1145fbd1..924cafab 100755 --- a/bin/omarchy-remove-ai-hermes +++ b/bin/omarchy-remove-ai-hermes @@ -25,11 +25,8 @@ omarchy-install-hermes-cli --remove || cli_removed=false # has whatever was there before, and none of it is ours to delete. if [[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]]; then # The checkout and venv, its own uv, its own node. None of it is any use once - # the app is gone. Not ~/.config/Hermes, which holds the gateway connections - # and their encrypted tokens, the active profile and the update settings. Not - # the rest of ~/.hermes either: the chats, memories and the skills Hermes - # wrote for itself are the user's, they are small, and finding them still - # there after a reinstall is the better surprise. + # the app is gone, so it goes without asking; what the user made with the app + # is a different question, answered below. rm -rf \ "$HOME/.hermes/hermes-agent" \ "$HOME/.hermes/bootstrap-cache" \ @@ -58,10 +55,35 @@ if [[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]]; then fi done + # What is left is the user's: the chats, memories and skills in ~/.hermes, + # the connections and their encrypted tokens in ~/.config/Hermes. Keeping + # them stays the default -- they are small, and finding them intact after a + # reinstall is the better surprise -- but a removal meant to be complete + # should not leave credentials behind either, so the choice is put in front + # of the user, default no. Only here, behind the bootstrap marker: without + # it ~/.hermes is an install the app never owned, and offering to delete it + # would put the user's own Hermes on the chopping block. Without a terminal + # to ask in, keeping it is the answer. + data_removed=false + if [[ -t 0 ]] && command -v gum >/dev/null; then + # du answers non-zero when either directory is missing (the app makes + # ~/.config/Hermes, but nothing says it is still there), and pipefail + # would turn that into an aborted removal; the size is worth no such thing. + size=$(du -shc "$HOME/.hermes" "$HOME/.config/Hermes" 2>/dev/null | tail -1 | cut -f1 || true) + if gum confirm --default=false "Also delete your Hermes data ($size: chats, memories, skills, connections and tokens)?"; then + rm -rf "$HOME/.hermes" "$HOME/.config/Hermes" + data_removed=true + fi + fi + echo "" echo "Hermes Desktop has been removed." - echo "Your chats, memories, and skills are still in ~/.hermes," - echo "and your connections and settings in ~/.config/Hermes." + if [[ $data_removed == true ]]; then + echo "Its chats, memories, and settings in ~/.hermes and ~/.config/Hermes are gone too." + else + echo "Your chats, memories, and skills are still in ~/.hermes," + echo "and your connections and settings in ~/.config/Hermes." + fi else echo "" echo "Hermes Desktop has been removed." diff --git a/manual/17-ai.md b/manual/17-ai.md index 78b51430..7a9e7c49 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -46,7 +46,7 @@ Crashes can also be silenced one program at a time, which is what the diagnosis The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT desktop app, Grok Bot for chatting with xAI's models, and Hermes Desktop. -Hermes Desktop is the one to know about, because there is only ever one Hermes on a machine. The app only runs against a runtime built from its own commit, so it installs one of its own under `~/.hermes` on first launch, which takes a few minutes and shows its own progress. From then on that is the Hermes the terminal `hermes` command and the default agent use too, whichever order you installed them in. Removing the app under _Remove > AI_ takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself. +Hermes Desktop is the one to know about, because there is only ever one Hermes on a machine. The app only runs against a runtime built from its own commit, so it installs one of its own under `~/.hermes` on first launch, which takes a few minutes and shows its own progress. From then on that is the Hermes the terminal `hermes` command and the default agent use too, whichever order you installed them in. Removing the app under _Remove > AI_ takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself unless you tell it otherwise: it asks, defaulting to no, whether that data and your connection settings should go too. ### Local LLMs diff --git a/test/shell.d/hermes-remove-test.sh b/test/shell.d/hermes-remove-test.sh index d3389087..88554894 100755 --- a/test/shell.d/hermes-remove-test.sh +++ b/test/shell.d/hermes-remove-test.sh @@ -24,6 +24,16 @@ cat >"$mock_bin/omarchy-install-hermes-cli" <<'SH' printf '%s\0' "$@" >>"$OMARCHY_TEST_INSTALLER_LOG" exit "${OMARCHY_TEST_INSTALLER_STATUS:-0}" SH + +# The remover asks through gum whether the user's data should go too. The stub +# answers "no" unless a test says otherwise, and logs every call: a real gum +# would hang a test run, and one that answered "yes" on its own would be the +# very data loss the default-no exists to prevent. +cat >"$mock_bin/gum" <<'SH' +#!/bin/bash +printf '%s\0' "$@" >>"$OMARCHY_TEST_GUM_LOG" +exit "${OMARCHY_TEST_GUM_STATUS:-1}" +SH chmod +x "$mock_bin"/* seed_install() { @@ -43,13 +53,30 @@ seed_install() { touch "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete" } +# "$test_tmp/installer-log" + : >"$test_tmp/gum-log" OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" \ OMARCHY_TEST_INSTALLER_LOG="$test_tmp/installer-log" \ OMARCHY_TEST_INSTALLER_STATUS="${OMARCHY_TEST_INSTALLER_STATUS:-0}" \ + OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \ HOME="$test_home" PATH="$mock_bin:$PATH" \ - bash "$ROOT/bin/omarchy-remove-ai-hermes" >/dev/null 2>&1 + bash "$ROOT/bin/omarchy-remove-ai-hermes" /dev/null 2>&1 +} + +# script(1) puts the remover on a pty, which is the only way -t 0 answers true +# without a person at a real one; the stubbed gum then supplies the answer. +remove_tty() { + : >"$test_tmp/installer-log" + : >"$test_tmp/gum-log" + OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" \ + OMARCHY_TEST_INSTALLER_LOG="$test_tmp/installer-log" \ + OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \ + OMARCHY_TEST_GUM_STATUS="${OMARCHY_TEST_GUM_STATUS:-1}" \ + HOME="$test_home" PATH="$mock_bin:$PATH" \ + script -qec "bash '$ROOT/bin/omarchy-remove-ai-hermes'" /dev/null >/dev/null 2>&1 } # The app brings its own uv and its own node; both are runtime, not data. @@ -77,6 +104,12 @@ pass "removal clears only the managed Node links it stranded" [[ -f $test_home/.hermes/SOUL.md ]] || fail "SOUL.md survives removal" pass "removal keeps what belongs to the user" +# Without a terminal there is nobody to ask, so gum must not even be reached: +# a gum that answered "yes" on its own would be a data loss. +[[ ! -s $test_tmp/gum-log ]] || + fail "removal does not ask about the user's data without a terminal" +pass "removal keeps the user's data unasked when there is no terminal" + [[ ! -e $test_home/.local/bin/hermes ]] || fail "the app's own hermes command is removed" pass "removal takes the command the app installed" @@ -134,6 +167,36 @@ remove || fail "remove succeeds with a wrapper pointing at a sibling directory" fail "a wrapper pointing at ~/xhermes is not mistaken for one pointing into ~/.hermes" pass "removal matches the runtime path as a plain string" +# On a terminal the user is asked, default no: declining leaves every piece of +# data where it was. +seed_install +remove_tty || fail "remove succeeds when the data question is declined" +tr '\0' '\n' <"$test_tmp/gum-log" | grep -qx 'confirm' || + fail "removal asks about the user's data on a terminal" +[[ -f $test_home/.hermes/sessions/one.json && -d $test_home/.config/Hermes ]] || + fail "declining the question keeps the user's data" +pass "removal asks on a terminal and declining keeps the data" + +# An explicit yes is the one path that takes the data too. +seed_install +OMARCHY_TEST_GUM_STATUS=0 remove_tty || fail "remove succeeds when the data goes too" +[[ ! -e $test_home/.hermes && ! -e $test_home/.config/Hermes ]] || + fail "a yes deletes ~/.hermes and ~/.config/Hermes" +pass "removal deletes the user's data only on an explicit yes" + +# Without the bootstrap marker ~/.hermes is an install the app never owned, so +# it must not even be offered for deletion -- not to a terminal, not to a user +# who would say yes. +seed_install +rm -f "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete" +OMARCHY_TEST_GUM_STATUS=0 remove_tty || + fail "remove succeeds when the app never installed Hermes" +[[ ! -s $test_tmp/gum-log ]] || + fail "a Hermes the app never installed is not offered for deletion" +[[ -d $test_home/.hermes/hermes-agent && -d $test_home/.config/Hermes ]] || + fail "a Hermes the app never installed survives a would-be yes" +pass "removal never offers a Hermes the app did not install" + # A CLI teardown that fails must not stop the runtime handling, and must not be # papered over either: the data work still happens, and the failure reaches the # caller's exit code. From e8e92c5092c9bbbf3d7fc5240f8551fd1eeaced9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 5 Sep 2026 10:17:35 +0200 Subject: [PATCH 38/76] Register the Chromium native messaging hosts for Brave Origin (#10292) Brave Origin keeps its profile under ~/.config/BraveSoftware/Brave-Origin rather than Brave-Browser, so the Copy URL and Download Video installers never wrote their host manifests there. The extensions loaded but the shortcuts did nothing. Add the Origin profile roots and rerun both installers through a migration. Co-authored-by: Claude Fable 5.1 --- bin/omarchy-install-chromium-copy-url | 3 +++ bin/omarchy-install-chromium-ytdlp | 3 +++ migrations/1788595060.sh | 4 ++++ test/shell.d/chromium-copy-url-test.sh | 4 ++++ test/shell.d/chromium-ytdlp-test.sh | 4 ++++ 5 files changed, 18 insertions(+) create mode 100644 migrations/1788595060.sh diff --git a/bin/omarchy-install-chromium-copy-url b/bin/omarchy-install-chromium-copy-url index 4f6dae02..0ee586d6 100755 --- a/bin/omarchy-install-chromium-copy-url +++ b/bin/omarchy-install-chromium-copy-url @@ -16,6 +16,9 @@ browser_dirs=( "$HOME/.config/BraveSoftware/Brave-Browser" "$HOME/.config/BraveSoftware/Brave-Browser-Beta" "$HOME/.config/BraveSoftware/Brave-Browser-Nightly" + "$HOME/.config/BraveSoftware/Brave-Origin" + "$HOME/.config/BraveSoftware/Brave-Origin-Beta" + "$HOME/.config/BraveSoftware/Brave-Origin-Nightly" "$HOME/.config/microsoft-edge" "$HOME/.config/microsoft-edge-dev" ) diff --git a/bin/omarchy-install-chromium-ytdlp b/bin/omarchy-install-chromium-ytdlp index c700578c..63ba46d5 100755 --- a/bin/omarchy-install-chromium-ytdlp +++ b/bin/omarchy-install-chromium-ytdlp @@ -17,6 +17,9 @@ browser_dirs=( "$HOME/.config/BraveSoftware/Brave-Browser" "$HOME/.config/BraveSoftware/Brave-Browser-Beta" "$HOME/.config/BraveSoftware/Brave-Browser-Nightly" + "$HOME/.config/BraveSoftware/Brave-Origin" + "$HOME/.config/BraveSoftware/Brave-Origin-Beta" + "$HOME/.config/BraveSoftware/Brave-Origin-Nightly" "$HOME/.config/microsoft-edge" "$HOME/.config/microsoft-edge-dev" ) diff --git a/migrations/1788595060.sh b/migrations/1788595060.sh new file mode 100644 index 00000000..750da2dc --- /dev/null +++ b/migrations/1788595060.sh @@ -0,0 +1,4 @@ +echo "Register the Chromium extension native messaging hosts for Brave Origin" + +omarchy-install-chromium-copy-url +omarchy-install-chromium-ytdlp diff --git a/test/shell.d/chromium-copy-url-test.sh b/test/shell.d/chromium-copy-url-test.sh index 6dad096f..daed1a3d 100644 --- a/test/shell.d/chromium-copy-url-test.sh +++ b/test/shell.d/chromium-copy-url-test.sh @@ -71,6 +71,10 @@ jq -e --arg path "$ROOT/bin/omarchy-chromium-copy-url-host" ' ' "$native_manifest" >/dev/null || fail "copy-url native host manifest uses Omarchy host path and extension id" pass "copy-url native host installer registers the stable extension id" +[[ -f $test_home/.config/BraveSoftware/Brave-Origin/NativeMessagingHosts/com.omarchy.copy_url.json ]] || + fail "copy-url native host installer covers Brave Origin" +pass "copy-url native host installer covers Brave Origin" + # Chromium ships in the base packages, so fresh installs do not go through # omarchy-install-browser, and they mark every migration as already applied. # The user install still has to register the host itself. diff --git a/test/shell.d/chromium-ytdlp-test.sh b/test/shell.d/chromium-ytdlp-test.sh index 6cd571c4..a2872699 100755 --- a/test/shell.d/chromium-ytdlp-test.sh +++ b/test/shell.d/chromium-ytdlp-test.sh @@ -33,6 +33,10 @@ jq -e --arg path "$ROOT/bin/omarchy-chromium-ytdlp-host" ' ' "$manifest_path" >/dev/null pass "yt-dlp native host manifest uses Omarchy host path and extension id" +[[ -f $test_home/.config/BraveSoftware/Brave-Origin/NativeMessagingHosts/com.omarchy.ytdlp.json ]] || + fail "yt-dlp native host installer covers Brave Origin" +pass "yt-dlp native host installer covers Brave Origin" + parse_result=$(bash -c ' OMARCHY_PATH="$3" source "$1" From 110cb8f5b4aba7021c17ebade1850cba221d9da6 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 5 Sep 2026 16:16:23 +0200 Subject: [PATCH 39/76] Add original vi as a standard terminal editor (#10307) * Add vi as a standard terminal editor * Use the original vi package --- install/omarchy-base.packages | 1 + manual/18-development-tools.md | 2 ++ migrations/1788596255.sh | 3 +++ 3 files changed, 6 insertions(+) create mode 100644 migrations/1788596255.sh diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index c5013ba6..03af6299 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -133,6 +133,7 @@ ufw-docker unzip usage uwsm +vi whois wireless-regdb wireplumber diff --git a/manual/18-development-tools.md b/manual/18-development-tools.md index 59ae9198..f49125d9 100644 --- a/manual/18-development-tools.md +++ b/manual/18-development-tools.md @@ -4,6 +4,8 @@ Omarchy ships with [Neovim](https://neovim.io/) by default, but if you'd like something a bit more mainstream and familiar, you can run the Omarchy Menu (`Super + Space`) and see the options under _Install > Editor_. We have VSCode, Cursor, Zed, Sublime Text, Helix, Vim, and Emacs listed there. If you don't find what you're looking for, checkout _Install > Package_, and see if it isn't in an Arch package (and if not, try _Install > AUR_ to check the AUR). +The original `vi` editor is also available out of the box. Run `vi filename` to edit a file in the terminal. + Theme matching is offered for `VSCode`, `Cursor`, `VSCodium`, and `Helix`. You can set the system-wide default editor under `Setup > Defaults > Editor`. diff --git a/migrations/1788596255.sh b/migrations/1788596255.sh new file mode 100644 index 00000000..e4c7b0c4 --- /dev/null +++ b/migrations/1788596255.sh @@ -0,0 +1,3 @@ +echo "Add vi as a standard terminal editor" + +omarchy-pkg-add vi From 534567398806f36bebcb77c614e6bb8ad2828a5e Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Fri, 4 Sep 2026 16:40:12 -0500 Subject: [PATCH 40/76] Add OpenClaw to Install > AI as a web app on its own gateway OpenClaw's desktop experience on Linux is its Control UI, served by the gateway the openclaw package runs, so the Install > AI entry installs the package and a web app launcher that routes through the new omarchy-launch-openclaw: first launch hands off to OpenClaw's own onboarding wizard, later launches start the gateway when needed and open the dashboard's single-use browser handoff URL as an app window. Remove > AI tears the gateway service down through OpenClaw's own gateway uninstall (falling back to systemctl by hand), aborts rather than dropping the package under a gateway that will not stop, and keeps the user's agent in ~/.openclaw. OpenClaw also joins Setup > Defaults > Agent through the same agent_installer seam Hermes carries: its CLI is the pacman package rather than a mise tool, so omarchy-install-openclaw-cli answers --check/--now with pacman, and omarchy-agent runs `openclaw chat`, seeding prompts through --message. The menu mark is a new U+E90C glyph traced from the package's lobster favicon; E90B stays free for the Perplexity mark still in flight on its own branch. The launcher recovers the gateway through `openclaw gateway install --force` (unit not enabled: missing, or an install that died after writing it) or `openclaw gateway start` (enabled but stopped), never `openclaw dashboard --yes`: as of OpenClaw 2026.9.1 that defers to "the owning supervisor" in both cases, and once the gateway is up it copies a one-time browser pairing URL into the clipboard. The dashboard probe is bounded so an app-grid launch cannot hang without a terminal to interrupt it. Removal treats only systemd's own "inactive"/"failed" as a stopped gateway, so an unreachable user manager aborts instead of dropping the package under a live process. All of it verified against a real 2026.9.1 install. Removal also takes down the node-host unit if OpenClaw ever installed one, and asks (default no, only on a terminal) whether ~/.openclaw should go too, with its size: the chats and credentials live there next to hundreds of megabytes of plugin runtimes and cache OpenClaw downloads for itself. Onboarding goes through omarchy-openclaw-onboard rather than bare `openclaw onboard`: as of 2026.9.1 the bare command is the guided flow, which ends by running a foreground gateway and handing off to a browser tab without returning, so the install script never reached the app launch and no service was installed. The helper runs the classic wizard (--flow quickstart --install-daemon --skip-ui) as a background job that keeps the terminal as its stdin, so its prompts render and take input as upstream draws them, and stops it once the gateway answers: upstream leaves the wizard running after its outro (only the TUI branch exits, and the model sign-in holds a socket open). Every quickstart prompt precedes the service install, so that point is safe. A gateway that never comes up after this run applies setup ends the wait as a failure instead of hanging, an already-running OpenClaw is left alone rather than mistaken for this run's success, a gateway answering on the port is only this run's once its process is the unit's own MainPID (an orphan from an earlier run) is not mistaken for the service this run installs, and a signal at the helper takes the wizard down with it. --- bin/omarchy | 1 + bin/omarchy-agent | 9 + bin/omarchy-default-agent | 8 +- bin/omarchy-install-ai-openclaw | 35 ++++ bin/omarchy-install-openclaw-cli | 29 +++ bin/omarchy-launch-openclaw | 91 ++++++++ bin/omarchy-openclaw-onboard | 130 ++++++++++++ bin/omarchy-remove-ai-openclaw | 78 +++++++ default/fonts/omarchy/README.md | 1 + default/fonts/omarchy/omarchy.ttf | Bin 5052 -> 5284 bytes default/omarchy/omarchy-menu.jsonc | 3 + manual/17-ai.md | 4 +- test/shell.d/default-agent-test.sh | 65 ++++++ test/shell.d/launch-openclaw-test.sh | 189 +++++++++++++++++ test/shell.d/menu-test.sh | 5 +- test/shell.d/openclaw-onboard-test.sh | 288 ++++++++++++++++++++++++++ test/shell.d/remove-ai-test.sh | 158 ++++++++++++++ 17 files changed, 1088 insertions(+), 6 deletions(-) create mode 100755 bin/omarchy-install-ai-openclaw create mode 100755 bin/omarchy-install-openclaw-cli create mode 100755 bin/omarchy-launch-openclaw create mode 100755 bin/omarchy-openclaw-onboard create mode 100755 bin/omarchy-remove-ai-openclaw create mode 100755 test/shell.d/launch-openclaw-test.sh create mode 100755 test/shell.d/openclaw-onboard-test.sh diff --git a/bin/omarchy b/bin/omarchy index 4219109b..2bace6df 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -65,6 +65,7 @@ GROUP_DESCRIPTIONS[monitor]="Monitor status helpers" GROUP_DESCRIPTIONS[network]="Network status helpers" GROUP_DESCRIPTIONS[notification]="Notification helpers" GROUP_DESCRIPTIONS[mise]="Mise tool wrappers" +GROUP_DESCRIPTIONS[openclaw]="OpenClaw agent platform setup" GROUP_DESCRIPTIONS[osd]="On-screen display status helpers" GROUP_DESCRIPTIONS[pkg]="Package management helpers" GROUP_DESCRIPTIONS[plugin]="Omarchy shell plugin and bar widget management" diff --git a/bin/omarchy-agent b/bin/omarchy-agent index bc65ab9a..0aa0273b 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -82,6 +82,15 @@ grok) command=(grok --permission-mode bypassPermissions) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") ;; +openclaw) + # The launcher owns onboarding and the gateway dance: OpenClaw's terminal UI + # must attach to the running gateway (the embedded `openclaw chat` refuses to + # start while the gateway owns the state directory). It has no permission + # prompts to skip, and --message seeds the session while keeping it + # interactive. + command=(omarchy-launch-openclaw --tui) + [[ -n ${prompt:-} ]] && command+=(--message "$prompt") + ;; codex) command=(codex --approve-for-me) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") diff --git a/bin/omarchy-default-agent b/bin/omarchy-default-agent index 9d575514..f832b22d 100755 --- a/bin/omarchy-default-agent +++ b/bin/omarchy-default-agent @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Set and launch the default coding agent -# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|agy|hermes|copilot|crush] +# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|openclaw|agy|hermes|copilot|crush] # omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude installing=false @@ -32,11 +32,12 @@ claude | claude-code) agent="claude"; name="Claude Code" ;; codex) agent="codex"; name="Codex" ;; crush) agent="crush"; name="Crush" ;; grok) agent="grok"; name="Grok"; agent_package="npm:@xai-official/grok" ;; +openclaw) agent="openclaw"; name="OpenClaw"; agent_installer="omarchy-install-openclaw-cli" ;; agy | antigravity | antigravity-cli | gemini | gemini-cli) agent="agy"; name="Antigravity"; agent_package="antigravity-cli" ;; hermes) agent="hermes"; name="Hermes"; agent_installer="omarchy-install-hermes-cli" ;; copilot | github-copilot) agent="copilot"; name="GitHub Copilot" ;; *) - echo "Usage: omarchy-default-agent " + echo "Usage: omarchy-default-agent " exit 1 ;; esac @@ -45,7 +46,8 @@ agent_package=${agent_package:-$agent} # Hermes reaches mise through its own installer rather than straight from # here: it needs its interpreter pinned, and a bare `mise use` has nowhere to -# say so. See omarchy-install-hermes-cli. +# say so. See omarchy-install-hermes-cli. OpenClaw comes from its pacman +# package the same way; see omarchy-install-openclaw-cli. if [[ -n ${agent_installer:-} ]]; then # Not omarchy-cmd-present: the stub is on PATH from first boot and says # nothing about whether Hermes is installed behind it. Treating a cold stub diff --git a/bin/omarchy-install-ai-openclaw b/bin/omarchy-install-ai-openclaw new file mode 100755 index 00000000..cb79a9ad --- /dev/null +++ b/bin/omarchy-install-ai-openclaw @@ -0,0 +1,35 @@ +#!/bin/bash + +# omarchy:summary=Install the OpenClaw agent platform and its Control UI web app +# omarchy:requires-sudo=true + +set -e + +echo "Installing OpenClaw..." +omarchy-pkg-add openclaw + +# The desktop app is OpenClaw's Control UI: a web app served by its own +# gateway. The launcher entry goes through omarchy-launch-openclaw, which +# onboards or starts that gateway before opening the window. The icon ships +# inside the package, so nothing is fetched here. +echo "Installing the OpenClaw web app..." +omarchy-webapp-install OpenClaw "http://127.0.0.1:18789" \ + /usr/lib/node_modules/openclaw/dist/control-ui/apple-touch-icon.png \ + omarchy-launch-openclaw + +# A first install runs onboarding right here: launching the app instead would +# open a second floating terminal for the wizard, identical to this one. +# omarchy-openclaw-onboard runs the wizard the way this flow needs (terminal +# prompts, gateway as a user service, and it actually returns). A machine +# that is already onboarded goes straight to the app. +if [[ -f $HOME/.openclaw/openclaw.json ]]; then + echo "Opening OpenClaw..." + setsid uwsm-app -- gtk-launch OpenClaw >/dev/null 2>&1 & +elif omarchy-openclaw-onboard && [[ -f $HOME/.openclaw/openclaw.json ]]; then + echo "Opening OpenClaw..." + setsid uwsm-app -- gtk-launch OpenClaw >/dev/null 2>&1 & +fi + +echo "" +echo "OpenClaw has been installed." +echo "If you skipped onboarding, launching OpenClaw from the app grid resumes it." diff --git a/bin/omarchy-install-openclaw-cli b/bin/omarchy-install-openclaw-cli new file mode 100755 index 00000000..18e12317 --- /dev/null +++ b/bin/omarchy-install-openclaw-cli @@ -0,0 +1,29 @@ +#!/bin/bash + +# omarchy:summary=Ensure the OpenClaw CLI is installed for the default agent +# omarchy:args=[--check|--now] +# omarchy:requires-sudo=true + +# OpenClaw is not a mise tool: the openclaw pacman package is the one OpenClaw +# installation on the machine — the CLI, the gateway, and the Install > AI web +# app all share it, and the fast ring keeps it current. The default-agent flow +# talks to that package through the same --check/--now contract mise-backed +# agents get from mise itself. + +set -euo pipefail + +case "${1:---now}" in +--check) + omarchy-pkg-present openclaw + ;; +--now) + if ! omarchy-pkg-present openclaw; then + echo "Installing OpenClaw..." + omarchy-pkg-add openclaw + fi + ;; +*) + echo "Usage: omarchy-install-openclaw-cli [--check|--now]" >&2 + exit 1 + ;; +esac diff --git a/bin/omarchy-launch-openclaw b/bin/omarchy-launch-openclaw new file mode 100755 index 00000000..13ab6c24 --- /dev/null +++ b/bin/omarchy-launch-openclaw @@ -0,0 +1,91 @@ +#!/bin/bash + +# omarchy:summary=Open the OpenClaw Control UI (or its terminal UI with --tui), onboarding or starting the gateway first when needed. +# omarchy:args=[--tui [--message ]] + +set -euo pipefail + +tui=false +message=() +if [[ ${1:-} == "--tui" ]]; then + tui=true + shift + if [[ ${1:-} == "--message" ]]; then + message=(--message "${2:?--message needs a value}") + shift 2 + fi +fi + +# Onboarding is OpenClaw's own interactive wizard, run through +# omarchy-openclaw-onboard so it stays in the terminal, installs the gateway +# as a user service, and returns (see that script for why bare `openclaw +# onboard` does none of those as of 2026.9.1). The config check gates what +# follows because the wizard's "Skip for now" also exits 0: only inference +# that passed writes the config, and skipping must not loop back into the +# wizard. +if [[ ! -f $HOME/.openclaw/openclaw.json ]]; then + if [[ $tui == "true" ]]; then + # Already in a terminal, so the wizard runs right here. + omarchy-openclaw-onboard + [[ -f $HOME/.openclaw/openclaw.json ]] || exit 1 + else + # The wizard needs a real terminal, and chaining the relaunch means + # finishing it lands the user in the app. Single quotes so $HOME expands + # in the spawned terminal. + # shellcheck disable=SC2016 + exec omarchy-launch-floating-terminal-with-presentation \ + 'omarchy-openclaw-onboard && [[ -f $HOME/.openclaw/openclaw.json ]] && omarchy-launch-openclaw' + fi +fi + +dashboard_url() { + # browserUrl carries a single-use browser handoff; url is the shared-auth + # fallback for gateways predating the handoff flow. The timeout keeps a + # wedged CLI from hanging an app-grid launch that has no terminal to ^C. + timeout 10 openclaw dashboard --json 2>/dev/null | jq -re '.browserUrl // .url // empty' +} + +# --json never starts the gateway, so an empty answer means it is not running. +# Recovery goes through the gateway's own service commands. `dashboard --yes` +# used to be the start/install-without-prompting path, but as of 2026.9.1 it +# defers to "the owning supervisor" for both a missing and a stopped unit, +# and once the gateway is up it copies a one-time pairing URL into the +# clipboard, which nothing here needs. Enablement, not the unit file, decides: +# a unit that was written but never enabled (an install that died halfway) +# would otherwise be "started" once and stay off at every following login, +# where --force rewrites and enables it. +if ! url=$(dashboard_url); then + if systemctl --user is-enabled --quiet openclaw-gateway.service 2>/dev/null; then + timeout 60 openclaw gateway start >&2 || true + else + timeout 120 openclaw gateway install --force >&2 || true + fi + + # A first-ever service install (unit write, daemon-reload, first boot) + # takes materially longer than starting an installed unit, so the budget + # is sized for the slow case. + for _ in {1..30}; do + url=$(dashboard_url) && break + sleep 1 + done +fi + +if [[ -z ${url:-} ]]; then + echo "OpenClaw's gateway did not come up. Check it with: openclaw gateway status" >&2 + echo "If onboarding never finished, rerun it with: omarchy-openclaw-onboard" >&2 + exit 1 +fi + +if [[ $tui == "true" ]]; then + # Attach to the gateway rather than `openclaw chat`: chat is the embedded + # local runtime, which refuses to start while the gateway owns ~/.openclaw's + # state directory -- and on any machine set up through Install > AI, the + # gateway service is running whenever the desktop session is. + exec openclaw tui "${message[@]}" +fi + +# The handoff URL rides in the browser's argv, which uwsm's app daemon echoes +# into the user journal. Accepted: the token is single-use with a ten-minute +# expiry against a loopback-only gateway, and journal access already implies +# access to ~/.openclaw itself. +exec omarchy-launch-webapp "$url" diff --git a/bin/omarchy-openclaw-onboard b/bin/omarchy-openclaw-onboard new file mode 100755 index 00000000..5bac15bd --- /dev/null +++ b/bin/omarchy-openclaw-onboard @@ -0,0 +1,130 @@ +#!/bin/bash + +# omarchy:summary=Run OpenClaw's setup wizard the way Omarchy needs it: in the terminal, installing the gateway as a user service, and returning when it is done. + +# Not bare `openclaw onboard`: as of 2026.9.1 that is the guided flow, which +# ends by running a foreground gateway and handing off to a browser tab, and +# never returns. --install-daemon keeps it to the classic wizard (minimal +# prompts with --flow quickstart) that installs the gateway service, and +# --skip-ui drops its closing Control UI/TUI prompt since whoever called this +# opens one next. +# +# That classic wizard has one wrinkle: with --skip-ui it prints "Onboarding +# complete" and then never exits. Upstream only calls exit(0) when it launched +# the TUI, and the model sign-in leaves an open socket that keeps the process +# alive otherwise. So this script watches for the gateway to answer and then +# stops the wizard. That is safe because every prompt in the quickstart flow +# runs before the gateway service is installed and reachable: by the time the +# dashboard answers, only the closing notes are left. + +set -uo pipefail + +wizard=(openclaw onboard --flow quickstart --install-daemon --skip-ui) +config=$HOME/.openclaw/openclaw.json +settle_seconds=${OMARCHY_OPENCLAW_ONBOARD_SETTLE_SECONDS:-3} +# Counted from the moment the config exists, i.e. once the wizard has applied +# setup; the prompts before that take as long as the user takes. +gateway_timeout=${OMARCHY_OPENCLAW_ONBOARD_GATEWAY_TIMEOUT:-180} + +gateway_answers() { + timeout 10 openclaw dashboard --json 2>/dev/null | jq -e '.ok == true' >/dev/null 2>&1 +} + +# A gateway already answering means OpenClaw is set up, and this run must not +# read its own success off state that predates it: the wizard's repair pass +# would be stopped mid-prompt the moment the watcher looked. Nothing to do. +if [[ -f $config ]] && gateway_answers; then + echo "OpenClaw is already set up and its gateway is running." >&2 + echo "To change providers or settings, run: openclaw onboard --classic" >&2 + exit 0 +fi + +# Marks when this run began, so a config left behind by an earlier, incomplete +# setup is not mistaken for this run having applied its own: the gateway +# deadline below must not start ticking while the user is still at prompts. +started=$(mktemp) +trap 'rm -f "$started"' EXIT + +# Backgrounded so this script can watch it, but with the terminal kept as its +# stdin (bash would otherwise hand a background job /dev/null). Job control is +# off in a script, so it stays in the terminal's foreground process group and +# reads from it freely. Ctrl-C does not reach it directly, though: bash starts +# async children with SIGINT ignored when job control is off, so the INT trap +# below is what turns Ctrl-C into the wizard's exit. +"${wizard[@]}" <&0 & +wizard_pid=$! + +stop_wizard() { + kill -TERM "$wizard_pid" 2>/dev/null || true +} +# Any signal at this script, whether Ctrl-C from the terminal or a kill aimed +# at its pid alone, takes the wizard down with it rather than leaving it +# running unwatched. +trap 'stop_wizard' INT TERM HUP + +# Whether this run has applied setup: the config exists and is not older than +# the run itself. +config_applied() { + [[ -f $config && ! $started -nt $config ]] +} + +# Whether this run's gateway is up: setup applied, and the process answering +# on the gateway's port is the main process of the service the wizard +# installs. Not the dashboard alone: with no config on disk `openclaw +# dashboard --json` still probes the default loopback port, so a gateway left +# behind by something else (an earlier guided onboarding's foreground +# gateway, say) would read as this run's success while the user is still at +# the first prompt. And not the unit being active either: its Type=simple +# counts it active from the fork, before it has found the port taken by such +# an orphan, and the app would then open on the orphan rather than the +# service this run installed. +gateway_ready() { + config_applied || return 1 + local json port listener main_pid + json=$(timeout 10 openclaw dashboard --json 2>/dev/null) || return 1 + jq -e '.ok == true' <<<"$json" >/dev/null 2>&1 || return 1 + port=$(jq -r '.port // empty' <<<"$json" 2>/dev/null) + [[ -n $port ]] || return 1 + listener=$(ss -ltnpH "sport = :$port" 2>/dev/null | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1) + main_pid=$(systemctl --user show -p MainPID --value openclaw-gateway.service 2>/dev/null) + [[ -n $listener && -n $main_pid && $main_pid != 0 && $listener == "$main_pid" ]] +} + +stopped=false +timed_out=false +config_seen_at= +while kill -0 "$wizard_pid" 2>/dev/null; do + sleep 2 + if gateway_ready; then + # Let the outro finish printing, then end the process the wizard leaves + # running. + sleep "$settle_seconds" + stop_wizard + stopped=true + break + fi + config_applied || continue + : "${config_seen_at:=$SECONDS}" + if (( SECONDS - config_seen_at >= gateway_timeout )); then + # Setup was applied but the service never came up (port taken, unit + # failing, ...): the wizard would sit in its never-exiting state forever, + # and so would whoever is waiting on this script. + stop_wizard + timed_out=true + break + fi +done + +wait "$wizard_pid" +rc=$? + +if [[ $timed_out == true ]]; then + echo "OpenClaw's gateway did not come up within ${gateway_timeout}s of setup finishing." >&2 + echo "Check it with: openclaw gateway status" >&2 + exit 1 +fi +# A wizard stopped here after the gateway came up did its job. One that +# exited on its own, including a user who chose "Skip for now" (no config, +# non-zero), keeps its own exit code. +[[ $stopped == true ]] && rc=0 +exit "$rc" diff --git a/bin/omarchy-remove-ai-openclaw b/bin/omarchy-remove-ai-openclaw new file mode 100755 index 00000000..47658a20 --- /dev/null +++ b/bin/omarchy-remove-ai-openclaw @@ -0,0 +1,78 @@ +#!/bin/bash + +# omarchy:summary=Remove the OpenClaw agent platform along with its gateway service and web app. +# omarchy:requires-sudo=true + +# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /. +set -euo pipefail + +unit_dir="$HOME/.config/systemd/user" + +# Only systemd's own word counts as "stopped": a non-zero exit from is-active +# also covers an unreachable user manager, which says nothing about whether +# the process is alive. +unit_stopped() { + local state + state=$(systemctl --user is-active "$1" 2>/dev/null) || true + [[ $state == inactive || $state == failed ]] +} + +# The user services OpenClaw installs for itself: the gateway through +# onboarding, and the node host if the user ever paired this machine to +# another gateway. OpenClaw writes them to $HOME/.config regardless of +# XDG_CONFIG_HOME, so look exactly there; no unit means nothing registered. +# Upstream's own teardown knows every piece its install wrote (unit file, the +# default.target.wants enablement symlink, the reload), so prefer it while the +# binary is still installed and fall back to doing the same by hand. A service +# that will not stop aborts the removal: dropping the package would strand the +# live process on deleted code with no way to restart it cleanly. +for unit_file in "$unit_dir"/openclaw-gateway.service "$unit_dir"/openclaw-node.service; do + [[ -f $unit_file ]] || continue + unit=${unit_file##*/} + role=${unit#openclaw-} + role=${role%.service} + if openclaw "$role" uninstall >/dev/null 2>&1 || + systemctl --user disable --now "$unit" 2>/dev/null || + unit_stopped "$unit"; then + # .bak is what `gateway install --force` leaves behind when it rewrites a + # unit, so it goes with the unit. + rm -f "$unit_file" "$unit_file.bak" "$unit_dir/default.target.wants/$unit" + systemctl --user daemon-reload 2>/dev/null || true + # A unit that had been failing stays listed as "not-found failed" after + # its file is gone until its failed state is reset. + systemctl --user reset-failed "$unit" 2>/dev/null || true + else + echo "Could not stop $unit; OpenClaw was not removed." >&2 + exit 1 + fi +done + +omarchy-pkg-drop openclaw + +# The web app launcher and icon omarchy-install-ai-openclaw created. +rm -f "$HOME/.local/share/applications/OpenClaw.desktop" +rm -f "$HOME/.local/share/icons/hicolor/256x256/apps/openclaw.png" +gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true + +# ~/.openclaw stays unless asked: the chats, memories, and credentials in +# there are the user's agent, and finding them intact after a reinstall is the +# better surprise. But it also holds the plugin runtimes and caches OpenClaw +# downloads for itself, easily hundreds of megabytes, so the choice is put in +# front of the user with the size rather than left silent. Without a terminal +# to ask in, keeping it is the answer. +state_removed=false +if [[ -d $HOME/.openclaw && -t 0 ]] && command -v gum >/dev/null; then + size=$(du -sh "$HOME/.openclaw" 2>/dev/null | cut -f1) + if gum confirm --default=false "Also delete ~/.openclaw ($size: chats, memories, credentials, and downloaded plugins)?"; then + rm -rf "$HOME/.openclaw" + state_removed=true + fi +fi + +echo "" +echo "OpenClaw has been removed." +if [[ $state_removed == true ]]; then + echo "Its chats, memories, and settings in ~/.openclaw are gone too." +elif [[ -d $HOME/.openclaw ]]; then + echo "Your agent's chats, memories, and settings are still in ~/.openclaw." +fi diff --git a/default/fonts/omarchy/README.md b/default/fonts/omarchy/README.md index 03b8a909..a4ab6935 100644 --- a/default/fonts/omarchy/README.md +++ b/default/fonts/omarchy/README.md @@ -13,6 +13,7 @@ The private-use glyphs in `omarchy.ttf` are: - `U+E908` — T3 Code, traced from the app icon in , since upstream publishes no monochrome SVG - `U+E909` — Ori, from , OpenRouter's own mark: Ori ships no separate logo and its product page uses this one - `U+E90A` — Hermes, Font Awesome's staff-snake (CC BY 4.0) from , the mark Hermes serves as its favicon: their app icon is a portrait that reads as a smudge at menu size +- `U+E90C` — OpenClaw, traced from the lobster mascot the openclaw package ships as `dist/control-ui/favicon.svg`, since upstream publishes no monochrome SVG; `E90B` is left for the Perplexity mark already in flight on another branch The agent marks are monochrome so the menu can render them using the active theme's foreground and selection colors. diff --git a/default/fonts/omarchy/omarchy.ttf b/default/fonts/omarchy/omarchy.ttf index 1ddeb85ce255a899a563eb985e070a6f63f47258..fb9d22a74a6638b60b4f3a701a081a36169fe3db 100644 GIT binary patch delta 722 zcmZ8dL1+_E5S{-w*`#UOY_iEl)+jdVu4xQanl@Q#TT48M>A_-ycqr0s8jZMVg0{82 zv=uLcpw(Y$(Su4osswWJ5Y)RUh#ow6kRH?=1d$#J#jLZzg2?b@-h1(x&--uT z*%~5QK*LFFt;n|!orU7eyvs$;;`}=50i%@D{X4=v0B##~tfZzuuoiyv1K8^z1ps`bs^<&H9LNm@$O|^ zWRiUllqI+iMHp_zQ_KcbU<}9Y2o4cP*buADQ6M37t@UxNuBLB;0#*qGba>Dqhq?$Q zxC{$*sJrTJ)5QM&KCygr!xT5nhvu5eFm4A)&>3_9VUKCh&@}H_}9A{?_oLkgGt9M)8an)SgYeElE=J7~uz>}g! zDVy{BUb5peZ*tGLS=a|r{MutMf}a*{rvd+d8Vn@*j!04p%MjPs+f9ke^Fc{Urjlw$ zc$cY4Z!*y%1^j#@tfv0q6tauim^wTf8wo^wBeBt8HI`j$T+@c!K2g&|L8j;WL$Y9T zpL?hQvM9-Qj~kjcmmh5Gh@rHiIO0k)5{W8thoYoI onopK}){eimI_3RAh@YJnRZ7~}ym-9TSt{qJbB2Bu;7-l>1M@+n#{d8T delta 516 zcmZ9HPe>GD7{;G>X4YBP&Dmv^JQx|Qn=33S4U~rtL4gQGkkBd6VcpupjJwnpyC6sx z5eh24{zG_By5vEas|a=r>5m65o;;Z4pG$%uflSYAo|=#6d*0vky&vy4bG?f_&rVKa z5rB7NxbOU})uBN^^5oH??^buz+Xi_W(Aw{MjXO7&%$I=v21u@#OYYdYspKJ$snO)3^S(IF`=bv-Bi5w@v_-!I zY<~OvAo8u(E5pw>F1FYa7)tc6JR2@k?ikn}ck1 US~`QZ^qz>VKG|p0j(tV&7h2tStpET3 diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 30ee45fc..a82254c6 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -143,6 +143,7 @@ "setup.default.agent.grok": {"icon":"","iconFont":"omarchy","label":"Grok","checked":"[[ \"$(omarchy-default-agent)\" == \"grok\" ]]","action":"omarchy-default-agent grok"}, "setup.default.agent.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes","checked":"[[ \"$(omarchy-default-agent)\" == \"hermes\" ]]","action":"omarchy-default-agent hermes"}, "setup.default.agent.omp": {"icon":"","iconFont":"omarchy","label":"omp","checked":"[[ \"$(omarchy-default-agent)\" == \"omp\" ]]","action":"omarchy-default-agent omp"}, + "setup.default.agent.openclaw": {"icon":"","iconFont":"omarchy","label":"OpenClaw","checked":"[[ \"$(omarchy-default-agent)\" == \"openclaw\" ]]","action":"omarchy-default-agent openclaw"}, "setup.default.agent.opencode": {"icon":"","iconFont":"omarchy","label":"OpenCode","checked":"[[ \"$(omarchy-default-agent)\" == \"opencode\" ]]","action":"omarchy-default-agent opencode"}, "setup.default.agent.ori": {"icon":"","iconFont":"omarchy","label":"Ori","checked":"[[ \"$(omarchy-default-agent)\" == \"ori\" ]]","action":"omarchy-default-agent ori"}, "setup.default.agent.pi": {"icon":"","iconFont":"omarchy","label":"Pi","checked":"[[ \"$(omarchy-default-agent)\" == \"pi\" ]]","action":"omarchy-default-agent pi"}, @@ -243,6 +244,7 @@ "install.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","disabled":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-hermes"}, "install.ai.lm-studio": {"icon":"","iconFont":"omarchy","label":"LM Studio","disabled":"omarchy-pkg-present lmstudio-bin","action":"omarchy-install-app 'LM Studio' lmstudio-bin"}, "install.ai.ollama": {"icon":"","iconFont":"omarchy","label":"Ollama","disabled":"omarchy-cmd-present ollama","action":"if omarchy-cmd-present nvidia-smi; then ollama_pkg=ollama-cuda; elif omarchy-cmd-present rocminfo; then ollama_pkg=ollama-rocm; else ollama_pkg=ollama; fi; omarchy-install-app Ollama \"$ollama_pkg\""}, + "install.ai.openclaw": {"icon":"","iconFont":"omarchy","label":"OpenClaw","disabled":"omarchy-pkg-present openclaw","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-openclaw"}, "install.ai.t3-code": {"icon":"","iconFont":"omarchy","label":"T3 Code","disabled":"omarchy-pkg-present t3code-bin","action":"omarchy-install-and-launch 'T3 Code' t3code-bin t3code"}, "install.gaming.steam": {"icon":"","label":"Steam","disabled":"omarchy-pkg-present steam","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-steam"}, "install.gaming.retroarch": {"icon":"󰯉","label":"RetroArch","disabled":"omarchy-pkg-present retroarch","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-retroarch"}, @@ -308,6 +310,7 @@ "remove.ai.grok-bot": {"icon":"","iconFont":"omarchy","label":"Grok Bot","when":"omarchy-pkg-present grok-bot","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-grok-bot"}, "remove.ai.lm-studio": {"icon":"","iconFont":"omarchy","label":"LM Studio","when":"omarchy-pkg-present lmstudio-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-lm-studio"}, "remove.ai.ollama": {"icon":"","iconFont":"omarchy","label":"Ollama","when":"omarchy-pkg-present ollama","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-ollama"}, + "remove.ai.openclaw": {"icon":"","iconFont":"omarchy","label":"OpenClaw","when":"omarchy-pkg-present openclaw","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-openclaw"}, "remove.ai.t3-code": {"icon":"","iconFont":"omarchy","label":"T3 Code","when":"omarchy-pkg-present t3code-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-t3-code"}, "remove.gaming.steam": {"icon":"","label":"Steam","when":"omarchy-pkg-present steam","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-gaming-steam"}, "remove.gaming.retroarch": {"icon":"","label":"RetroArch","when":"omarchy-pkg-present retroarch","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-gaming-retroarch"}, diff --git a/manual/17-ai.md b/manual/17-ai.md index 78b51430..f94c3546 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -44,10 +44,12 @@ Crashes can also be silenced one program at a time, which is what the diagnosis ### Desktop apps -The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT desktop app, Grok Bot for chatting with xAI's models, and Hermes Desktop. +The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT desktop app, Grok Bot for chatting with xAI's models, Hermes Desktop, and OpenClaw. Hermes Desktop is the one to know about, because there is only ever one Hermes on a machine. The app only runs against a runtime built from its own commit, so it installs one of its own under `~/.hermes` on first launch, which takes a few minutes and shows its own progress. From then on that is the Hermes the terminal `hermes` command and the default agent use too, whichever order you installed them in. Removing the app under _Remove > AI_ takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself. +OpenClaw's desktop experience is its Control UI, which opens as a web app backed by its own local gateway. OpenClaw updates arrive through Omarchy's package updates, so skip the Control UI's own "Update Gateway" button: it would try to write into the package-managed install and fail. Removing OpenClaw under _Remove > AI_ takes the gateway service and the app with it and then asks whether `~/.openclaw` should go too, since that holds your chats and credentials alongside the plugin runtimes OpenClaw downloads for itself; the default keeps it. + ### Local LLMs Omarchy recommends two ways of running local LLM models: LM Studio and Ollama. LM Studio provides a GUI interface for finding open-weight models, installing them, and running them. It's a great way to get going easily. Ollama offers a CLI for doing so similarly. But if you're new to local models, I'd start with LM Studio. You can install either under _Install > AI_ in the Omarchy Menu. diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index f421fabb..6a2f15b2 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -542,3 +542,68 @@ fi grep -F "missing is not installed" "$test_tmp/missing-output" >/dev/null || fail "agent launcher explains when the default command is missing" pass "agent launcher reports a missing default command" + +# OpenClaw comes from its pacman package, not mise: choosing it must route +# through omarchy-install-openclaw-cli and never touch a mise environment. +cat >"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ $1 == openclaw && ${OMARCHY_TEST_OPENCLAW_INSTALLED:-false} == "true" ]] +SH +cat >"$mock_bin/omarchy-pkg-add" <<'SH' +#!/bin/bash +printf '%s\n' "pkg-add $*" >>"$OMARCHY_TEST_STUB_LOG" +SH +cat >"$mock_bin/omarchy-launch-openclaw" <<'SH' +#!/bin/bash +printf '%s\0' omarchy-launch-openclaw "$@" >"$OMARCHY_TEST_AGENT_INLINE_LOG" +SH +cat >"$mock_bin/openclaw" <<'SH' +#!/bin/bash +exit 0 +SH +chmod +x "$mock_bin/omarchy-pkg-present" "$mock_bin/omarchy-pkg-add" \ + "$mock_bin/omarchy-launch-openclaw" "$mock_bin/openclaw" + +: >"$launch_log" +: >"$terminal_log" +: >"$mise_history" +OMARCHY_TEST_OPENCLAW_INSTALLED=true omarchy-default-agent openclaw +read -r chosen <"$agent_file" +[[ $chosen == openclaw ]] || fail "choosing OpenClaw records it as the default agent" +mapfile -d '' -t launch_args <"$launch_log" +[[ ${launch_args[*]} == "--app-id=org.omarchy.agent omarchy-launch-openclaw --tui" ]] || + fail "choosing OpenClaw launches its terminal UI" +[[ ! -s $terminal_log ]] || fail "an installed OpenClaw needs no install terminal" +! grep -q 'use -g openclaw' "$mise_history" || fail "OpenClaw never installs through mise" +pass "choosing OpenClaw uses the package and launches its terminal UI" + +: >"$terminal_log" +OMARCHY_TEST_OPENCLAW_INSTALLED=false omarchy-default-agent openclaw +mapfile -d '' -t terminal_args <"$terminal_log" +[[ ${terminal_args[*]} == "omarchy-default-agent --install openclaw" ]] || + fail "a missing OpenClaw routes through the install terminal" +pass "a missing OpenClaw routes through the install terminal" + +: >"$stub_log" +: >"$inline_log" +OMARCHY_TEST_OPENCLAW_INSTALLED=false omarchy-default-agent --install openclaw >/dev/null +grep -Fx "pkg-add openclaw" "$stub_log" >/dev/null || + fail "installing OpenClaw as default agent adds its package" +mapfile -d '' -t inline_args <"$inline_log" +[[ ${inline_args[*]} == "omarchy-launch-openclaw --tui" ]] || + fail "installing OpenClaw as default agent hands over to its terminal UI" +pass "installing OpenClaw as default agent adds its package" + +: >"$launch_log" +omarchy agent prompt "Review this project" +mapfile -d '' -t launch_args <"$launch_log" +# Element-wise: the prompt must travel as one argv entry, which a space-joined +# comparison could not tell apart from a prompt split into words. +[[ ${#launch_args[@]} == 5 && + ${launch_args[0]} == "--app-id=org.omarchy.agent" && + ${launch_args[1]} == "omarchy-launch-openclaw" && + ${launch_args[2]} == "--tui" && + ${launch_args[3]} == "--message" && + ${launch_args[4]} == "Review this project" ]] || + fail "OpenClaw receives prompts through --message" "argv: ${launch_args[*]}" +pass "OpenClaw receives prompts through --message" diff --git a/test/shell.d/launch-openclaw-test.sh b/test/shell.d/launch-openclaw-test.sh new file mode 100755 index 00000000..4c443e07 --- /dev/null +++ b/test/shell.d/launch-openclaw-test.sh @@ -0,0 +1,189 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +mkdir -p "$tmp_dir/bin" "$tmp_dir/home" +export TEST_LOG="$tmp_dir/log" +export PATH="$tmp_dir/bin:$PATH" +export HOME="$tmp_dir/home" + +for stub in omarchy-launch-webapp omarchy-launch-floating-terminal-with-presentation omarchy-openclaw-onboard; do + cat >"$tmp_dir/bin/$stub" <