From e18b5aaf36994139976c97c8df0da34425f512d1 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 01/11] 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. (cherry picked from commit 0a65b45ab145c5bc134b6846e72e12dbf459de53) --- 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 8c2a59d99c1..499cdf14cfa 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 4fda5b3a63a..3b2f5e17e40 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 00000000000..2955ad27b67 --- /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 9a54c4141ec3a9a9285c9d60c1e4eb61848cffd0 Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Sat, 5 Sep 2026 17:57:11 -0400 Subject: [PATCH 02/11] Erase old password hashes during factory reset (cherry picked from commit a75924aca4ad20275557332956fd2d8665160eab) --- bin/omarchy-system-factory-reset | 39 ++++-- test/shell.d/factory-reset-accounts-test.sh | 146 ++++++++++++++++++++ 2 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 test/shell.d/factory-reset-accounts-test.sh diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index 51eca40110a..f9e57d95f47 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -297,19 +297,34 @@ rebuild_next_boot() { umount "$next$esp_mount" } +# Both the staged system and the retained baseline must lose the old hashes. +scrub_factory_accounts() { + local root="$1" user users + + users=$(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$root/etc/passwd") || return 1 + for user in $users; do + userdel --root "$root" "$user" 2>>"$LOG_FILE" || return 1 + rm -rf "${root:?}/home/$user" || return 1 + done + + # passwd --lock preserves the hash. Replace it, then remove the backups + # that userdel and usermod leave behind. + usermod --root "$root" --password '!' root >>"$LOG_FILE" 2>&1 || return 1 + rm -f "$root/etc/"{shadow-,gshadow-,passwd-,group-} +} + # Remove the seller's account material and machine identity from the retained # @factory baseline so it can neither be mounted for recovery nor restore the # seller's account on a future reset. Idempotent (a scrubbed baseline has no # uid>=1000 accounts left to remove). sanitize_factory_baseline() { - local factory="$1" user + local factory="$1" btrfs property set -ts "$factory" ro false - for user in $(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$factory/etc/passwd"); do - userdel --root "$factory" "$user" 2>>"$LOG_FILE" || true - rm -rf "${factory:?}/home/$user" - done - passwd --root "$factory" --lock root >>"$LOG_FILE" 2>&1 || true + if ! scrub_factory_accounts "$factory"; then + btrfs property set -ts "$factory" ro true + fail "could not remove account credentials from the factory baseline (see $LOG_FILE)" + fi rm -f "$factory"/etc/ssh/ssh_host_* rm -f "$factory"/etc/NetworkManager/system-connections/* rm -rf "$factory"/var/lib/NetworkManager/* "$factory/var/lib/tailscale" "$factory/var/lib/iwd" @@ -337,17 +352,13 @@ stage_full_reset() { rm -rf "$next"/var/lib/NetworkManager/* "$next/var/lib/tailscale" "$next/var/lib/iwd" rm -f "$next/var/lib/sddm/state.conf" "$next/etc/sddm.conf.d/autologin.conf" - # A factory snapshot from a normal (normal) install contains the original + # A factory snapshot from a normal install contains the original # user account; first-boot setup must start from none. A leftover account # would keep its password hash and group memberships (including wheel), so # failure here has to abort the reset, not be shrugged off. - local user - for user in $(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$next/etc/passwd"); do - log "Removing user $user from the factory system" - userdel --root "$next" "$user" 2>>"$LOG_FILE" || - fail "could not remove user $user from the factory system (see $LOG_FILE)" - done - passwd --root "$next" --lock root >>"$LOG_FILE" 2>&1 || true + log "Removing account credentials from the factory system" + scrub_factory_accounts "$next" || + fail "could not remove account credentials from the factory system (see $LOG_FILE)" # @factory itself survives the wipe as the baseline for future resets. If it # came from a normal install it still holds the seller's account and diff --git a/test/shell.d/factory-reset-accounts-test.sh b/test/shell.d/factory-reset-accounts-test.sh new file mode 100644 index 00000000000..7f6f4a3101a --- /dev/null +++ b/test/shell.d/factory-reset-accounts-test.sh @@ -0,0 +1,146 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +if (( EUID != 0 )); then + if unshare --user --map-root-user true 2>/dev/null; then + exec unshare --user --map-root-user bash "$0" + fi + pass "no unprivileged user namespace; skipping factory account cleanup" + exit 0 +fi + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +# Load the production functions without self-elevation or the reset entrypoint. +awk ' + /^[a-z_]+\(\) \{/ { copying = 1 } + copying { print } + /^}/ { copying = 0 } +' "$ROOT/bin/omarchy-system-factory-reset" >"$test_tmp/functions" + +cat >"$test_tmp/reset" <<'SH' +#!/bin/bash +set -euo pipefail +source "$1/functions" +TOP_MNT="$2" +NEXT_NAME=@omarchy-reset-next +PROVISIONING_DIR=/var/lib/omarchy/provisioning +LOG_FILE="$TOP_MNT/reset.log" + +log() { printf '%s\n' "$1" >>"$LOG_FILE"; } +fail() { log "$1"; exit 1; } + +# Account tools are real. Only snapshots, boot rebuilding, and system services +# are replaced: all writes stay inside this test's disposable directory. +btrfs() { + if [[ $1 == "subvolume" && $2 == "snapshot" ]]; then + mkdir -p "$4" + cp -a "$3/." "$4/" + elif [[ $1 == "property" ]]; then + printf '%s\n' "$6" >"$4/read-only" + else + return 1 + fi +} +systemd-id128() { printf '%032d\n' 1; } +install_provisioning_units() { :; } +encrypted_install() { return 1; } +rebuild_next_boot() { touch "$TOP_MNT/rebuilt"; } +sync() { :; } + +userdel() { + [[ ${FAIL_COMMAND:-} == "userdel" && $2 == "$FAIL_ROOT" ]] && return 42 + command userdel "$@" +} +usermod() { + [[ ${FAIL_COMMAND:-} == "usermod" && $2 == "$FAIL_ROOT" ]] && return 42 + command usermod "$@" +} +rm() { + [[ ${FAIL_COMMAND:-} == "rm" && $* == *"$FAIL_ROOT/etc/shadow-"* ]] && return 42 + command rm "$@" +} + +stage_full_reset +SH + +make_fixture() { + local top="$1" root_hash="${2:-original-root-hash}" + local factory="$top/@factory" + mkdir -p "$top/@" "$factory/etc" "$factory/home/seller" \ + "$factory/usr/bin" "$factory/usr/share/omarchy/install/provisioning" \ + "$factory/var/lib/omarchy/provisioning/packages" + touch "$top/@/old-system" "$factory/home/seller/private-file" \ + "$factory/usr/share/omarchy/install/provisioning/omarchy-provision-owner.service" \ + "$factory/var/lib/omarchy/provisioning/packages/node-v0.tar.gz" + printf '#!/bin/bash\n' >"$factory/usr/bin/omarchy-provision-owner" + chmod +x "$factory/usr/bin/omarchy-provision-owner" + printf 'true\n' >"$factory/read-only" + cat >"$factory/etc/passwd" <<'EOF' +root:x:0:0:root:/root:/bin/bash +daemon:x:1:1:daemon:/:/usr/bin/nologin +seller:x:1000:1000:Seller:/home/seller:/bin/bash +EOF + printf 'root:%s:20000:0:99999:7:::\ndaemon:*:20000:0:99999:7:::\nseller:original-user-hash:20000:0:99999:7:::\n' \ + "$root_hash" >"$factory/etc/shadow" + printf 'root:x:0:\ndaemon:x:1:\nseller:x:1000:\nwheel:x:998:seller\n' >"$factory/etc/group" + printf 'root:!::\ndaemon:!::\nseller:!::\nwheel:!::seller\n' >"$factory/etc/gshadow" + printf 'USERGROUPS_ENAB yes\n' >"$factory/etc/login.defs" + chmod 600 "$factory/etc/"{shadow,gshadow} + for file in passwd shadow group gshadow; do + cp "$factory/etc/$file" "$factory/etc/$file-" + done +} + +assert_scrubbed() { + local root="$1" file + [[ $(awk -F: '$1 == "root" { print $2 }' "$root/etc/shadow") == "!" ]] || + fail "reset erases the root hash while keeping the account locked" + ! grep -q 'original-.*-hash\|seller' "$root/etc/"{passwd,shadow,group,gshadow} || + fail "reset removes seller account credentials and group membership" + [[ ! -e $root/home/seller ]] || fail "reset removes the seller's baseline home" + grep -q '^daemon:\*:' "$root/etc/shadow" || fail "reset preserves service accounts" + [[ $(stat -c '%a' "$root/etc/shadow") == "600" ]] || fail "shadow stays private" + for file in passwd shadow group gshadow; do + [[ ! -e $root/etc/$file- ]] || fail "reset removes the $file backup" + done +} + +for scenario in normal locked; do + top="$test_tmp/$scenario" + if [[ $scenario == "locked" ]]; then + make_fixture "$top" '!' + else + make_fixture "$top" + fi + bash "$test_tmp/reset" "$test_tmp" "$top" || fail "$scenario reset stages successfully" + assert_scrubbed "$top/@factory" + assert_scrubbed "$top/@" + [[ $(cat "$top/@factory/read-only") == "true" ]] || fail "baseline returns to read-only" + [[ -f $top/@/var/lib/omarchy/provisioning/pending && -f $top/rebuilt ]] || + fail "reset reaches provisioning after cleanup" + + bash "$test_tmp/reset" "$test_tmp" "$top" || fail "$scenario reset can be repeated" + assert_scrubbed "$top/@factory" + assert_scrubbed "$top/@" + pass "$scenario reset scrubs both roots, preserves service accounts, and can be repeated" +done + +for target in @omarchy-reset-next @factory; do + for command in userdel usermod rm; do + top="$test_tmp/fail-$target-$command" + make_fixture "$top" + if FAIL_COMMAND="$command" FAIL_ROOT="$top/$target" bash "$test_tmp/reset" "$test_tmp" "$top"; then + fail "reset accepted failed $command in $target" + fi + [[ -f $top/@/old-system && ! -e $top/rebuilt ]] || + fail "failed cleanup must not activate or rebuild the reset system" + [[ $(cat "$top/@factory/read-only") == "true" ]] || + fail "failed cleanup must leave the baseline read-only" + pass "failed $command in $target aborts reset before activation" + done +done From a6f02d11a3f24e684307a12371a32e8b96f248f3 Mon Sep 17 00:00:00 2001 From: omarchybot Date: Sun, 6 Sep 2026 05:37:10 -0700 Subject: [PATCH 03/11] Remove the subuid and subgid backups during the factory scrub userdel rewrites /etc/subuid and /etc/subgid, and like every shadow-utils database write it leaves the previous contents behind in a dash-suffixed backup. The scrub removed four of the six backups those tools produce, so the retained @factory baseline still named the previous owner in /etc/subuid- and /etc/subgid- along with their subordinate ID range. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit a00be8fa16c941749886a80d91413e9bbe3c09e4) --- bin/omarchy-system-factory-reset | 2 +- test/shell.d/factory-reset-accounts-test.sh | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index f9e57d95f47..43348e5a4d0 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -310,7 +310,7 @@ scrub_factory_accounts() { # passwd --lock preserves the hash. Replace it, then remove the backups # that userdel and usermod leave behind. usermod --root "$root" --password '!' root >>"$LOG_FILE" 2>&1 || return 1 - rm -f "$root/etc/"{shadow-,gshadow-,passwd-,group-} + rm -f "$root/etc/"{shadow-,gshadow-,passwd-,group-,subuid-,subgid-} } # Remove the seller's account material and machine identity from the retained diff --git a/test/shell.d/factory-reset-accounts-test.sh b/test/shell.d/factory-reset-accounts-test.sh index 7f6f4a3101a..ed2bb69757d 100644 --- a/test/shell.d/factory-reset-accounts-test.sh +++ b/test/shell.d/factory-reset-accounts-test.sh @@ -90,8 +90,10 @@ EOF printf 'root:x:0:\ndaemon:x:1:\nseller:x:1000:\nwheel:x:998:seller\n' >"$factory/etc/group" printf 'root:!::\ndaemon:!::\nseller:!::\nwheel:!::seller\n' >"$factory/etc/gshadow" printf 'USERGROUPS_ENAB yes\n' >"$factory/etc/login.defs" + printf 'seller:100000:65536\n' >"$factory/etc/subuid" + printf 'seller:100000:65536\n' >"$factory/etc/subgid" chmod 600 "$factory/etc/"{shadow,gshadow} - for file in passwd shadow group gshadow; do + for file in passwd shadow group gshadow subuid subgid; do cp "$factory/etc/$file" "$factory/etc/$file-" done } @@ -105,7 +107,7 @@ assert_scrubbed() { [[ ! -e $root/home/seller ]] || fail "reset removes the seller's baseline home" grep -q '^daemon:\*:' "$root/etc/shadow" || fail "reset preserves service accounts" [[ $(stat -c '%a' "$root/etc/shadow") == "600" ]] || fail "shadow stays private" - for file in passwd shadow group gshadow; do + for file in passwd shadow group gshadow subuid subgid; do [[ ! -e $root/etc/$file- ]] || fail "reset removes the $file backup" done } From c84ebdaaa142f8092bb35738e10e9dc9ec7b037c Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:59:36 +0300 Subject: [PATCH 04/11] Refuse path-like names in omarchy-hook-install too The runner already rejects a slash, a bare . or .. The installer still joined the type into hooks/.d before mkdir/cp, so a name nothing can run could still land on disk. (cherry picked from commit e522a18ef0d31b5024a5a92697215abe7b57042e) --- bin/omarchy-hook-install | 11 +++++ test/shell.d/hook-state-name-guard-test.sh | 49 ++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/bin/omarchy-hook-install b/bin/omarchy-hook-install index 7c53648259b..6e954bd158b 100755 --- a/bin/omarchy-hook-install +++ b/bin/omarchy-hook-install @@ -15,6 +15,17 @@ fi HOOK_TYPE=$1 HOOK_FILE=$2 + +# Hook types are the same labels omarchy-hook runs (post-update, theme-set). +# The type becomes a directory under the hooks directory. A slash would turn +# it into directory levels. A bare `.` or `..` is a name the runner already +# refuses, so installing under it would write a hook nothing can run. Refuse +# those rather than mkdir/cp into them. Dots inside a name (a..b) are fine. +if [[ -z $HOOK_TYPE || $HOOK_TYPE == */* || $HOOK_TYPE == "." || $HOOK_TYPE == ".." ]]; then + echo "Invalid hook name: $HOOK_TYPE" >&2 + exit 2 +fi + HOOK_DIR="$HOME/.config/omarchy/hooks/$HOOK_TYPE.d" HOOK_NAME=$(basename "$HOOK_FILE") HOOK_PATH="$HOOK_DIR/$HOOK_NAME" diff --git a/test/shell.d/hook-state-name-guard-test.sh b/test/shell.d/hook-state-name-guard-test.sh index 2955ad27b67..0157202b4d1 100644 --- a/test/shell.d/hook-state-name-guard-test.sh +++ b/test/shell.d/hook-state-name-guard-test.sh @@ -63,6 +63,55 @@ HOME="$fake_home" "$ROOT/bin/omarchy-hook" "sub/dir" >/dev/null 2>&1 || status=$ fail "omarchy hook refuses a hook name with a slash" "exit: $status" pass "omarchy hook refuses a hook name with a slash" +# --- omarchy-hook-install ------------------------------------------------------ + +# The installer joins the type into ~/.config/omarchy/hooks/.d before +# mkdir/cp. The runner already refuses a slashed type; install must too, or a +# name the runner will not run still lands on disk. + +source_hook="$work_dir/source-hook" +cat >"$source_hook" <<'SH' +#!/bin/bash +true +SH + +HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" post-update "$source_hook" >/dev/null +[[ -f $fake_home/.config/omarchy/hooks/post-update.d/source-hook ]] || + fail "omarchy hook install still installs a named hook" +pass "omarchy hook install still installs a named hook" + +HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" a..b "$source_hook" >/dev/null +[[ -f $fake_home/.config/omarchy/hooks/a..b.d/source-hook ]] || + fail "omarchy hook install accepts a hook name with dots in the middle" +pass "omarchy hook install accepts a hook name with dots in the middle" + +for name in . ..; do + status=0 + HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" "$name" "$source_hook" >/dev/null 2>&1 || status=$? + (( status == 2 )) || + fail "omarchy hook install refuses a hook name of $name" "exit: $status" + [[ ! -e $fake_home/.config/omarchy/hooks/${name}.d ]] || + fail "omarchy hook install creates no directory for a hook name of $name" + pass "omarchy hook install refuses a hook name of $name" +done + +# hooks/../../evil.d is ~/.config/evil.d. The guard must fire before mkdir. +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" "../../evil" "$source_hook" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy hook install refuses a hook name with a dot-dot" "exit: $status" +[[ ! -e $fake_home/.config/evil.d ]] || + fail "omarchy hook install creates nothing outside the hooks directory" +pass "omarchy hook install refuses a hook name with a dot-dot" + +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" "sub/dir" "$source_hook" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy hook install refuses a hook name with a slash" "exit: $status" +[[ ! -e $fake_home/.config/omarchy/hooks/sub ]] || + fail "omarchy hook install creates no nested directory from a slashed name" +pass "omarchy hook install refuses a hook name with a slash" + # --- omarchy-state ------------------------------------------------------------- state_dir="$fake_home/.local/state/omarchy" From a4ec93498ac533fae5fd1e6b72095732df032886 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 12:11:00 +0200 Subject: [PATCH 05/11] Loosen the offline Node pin so mise up tracks new releases Offline installs unpack the bundled tarball and pin Node to its exact version, since latest can't be resolved without network. But nothing ever loosened that pin, so Node stayed frozen at the ISO's version and mup skipped it forever, while online installs tracked latest. Rewrite the pin to latest right after registering the bundled version: mise resolves latest to the installed version while offline (verified with no network and an empty cache), and the first mise up with network picks up new releases just like an online install. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017LseZ1jcLaFBnndRnW4yb5 (cherry picked from commit 5db4a40195eee1c8c45ba318d0c5ce307db08bd3) --- install/user/mise-work.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/install/user/mise-work.sh b/install/user/mise-work.sh index 9ce5249fef0..75ca0916c43 100644 --- a/install/user/mise-work.sh +++ b/install/user/mise-work.sh @@ -30,6 +30,11 @@ if [[ -n $NODE_PACKAGE_DIR ]]; then mkdir -p "$NODE_INSTALL_DIR" tar -xzf "$NODE_TARBALL" --strip-components=1 -C "$NODE_INSTALL_DIR" mise use -g node@"$NODE_VERSION" + + # That pinned the exact bundled version, which would exempt Node from + # mise up forever. Loosen it to latest, like an online install gets: + # mise resolves latest to the installed version while offline. + mise config set tools.node latest --file "$HOME/.config/mise/config.toml" fi else mise use -g node@latest From 1ce5592866545232456bcbee283cf4db748b483b Mon Sep 17 00:00:00 2001 From: nunomaduro Date: Wed, 16 Sep 2026 16:56:47 +0100 Subject: [PATCH 06/11] feat: improves php and laravel installation (cherry picked from commit 181ad4b4d399feb7f797fa941150d3c65866a496) --- bin/omarchy-install-dev-env | 36 +++++------------------------- bin/omarchy-remove-dev-env | 7 +++++- default/omarchy/omarchy-menu.jsonc | 8 +++---- 3 files changed, 15 insertions(+), 36 deletions(-) diff --git a/bin/omarchy-install-dev-env b/bin/omarchy-install-dev-env index 263fd755637..5dcd819af24 100755 --- a/bin/omarchy-install-dev-env +++ b/bin/omarchy-install-dev-env @@ -12,37 +12,11 @@ if [[ -z $1 ]]; then fi install_php() { - omarchy-pkg-add php composer php-sqlite xdebug + mise tool-alias set php github:nunomaduro/static-php-builds + mise use --global php@latest - # Install Path for Composer - if [[ :$PATH: != *:$HOME/.config/composer/vendor/bin:* ]]; then - echo 'export PATH="$HOME/.config/composer/vendor/bin:$PATH"' >>"$HOME/.bashrc" - source "$HOME/.bashrc" - echo "Added Composer global bin directory to PATH." - else - echo "Composer global bin directory already in PATH." - fi - - # Enable some extensions - local php_ini_path="/etc/php/php.ini" - local extensions_to_enable=( - "bcmath" - "intl" - "iconv" - "openssl" - "pdo_sqlite" - "pdo_mysql" - ) - - # Enable Xdebug - sudo sed -i \ - -e 's/^;zend_extension=xdebug.so/zend_extension=xdebug.so/' \ - -e 's/^;xdebug.mode=debug/xdebug.mode=debug/' \ - /etc/php/conf.d/xdebug.ini - - for ext in "${extensions_to_enable[@]}"; do - sudo sed -i "s/^;extension=${ext}/extension=${ext}/" "$php_ini_path" - done + # Composer's global binaries go to ~/.local/bin, which is already on PATH + mise x php -- composer global config bin-dir "$HOME/.local/bin" } install_node() { @@ -84,7 +58,7 @@ laravel) echo -e "Installing PHP and Laravel...\n" install_php install_node - composer global require laravel/installer + mise x php -- composer global require laravel/installer echo -e "\nYou can now run: laravel new myproject" ;; symfony) diff --git a/bin/omarchy-remove-dev-env b/bin/omarchy-remove-dev-env index a24e7f9e294..b586ed09292 100755 --- a/bin/omarchy-remove-dev-env +++ b/bin/omarchy-remove-dev-env @@ -10,6 +10,11 @@ if [[ -z $1 ]]; then fi remove_php() { + mise uninstall php --all + mise rm -g php + mise tool-alias unset php + + # PHP from before it moved to mise omarchy-pkg-drop php composer php-sqlite xdebug } @@ -46,7 +51,7 @@ php) ;; laravel) echo -e "Removing Laravel...\n" - composer global remove laravel/installer 2>/dev/null || true + mise x php -- composer global remove laravel/installer 2>/dev/null || true ;; symfony) echo -e "Removing Symfony CLI...\n" diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 3323dd44bd1..56314b5b2a2 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -278,8 +278,8 @@ "install.development.javascript.node": {"icon":"","label":"Node.js","disabled":"[[ -d $HOME/.local/share/mise/installs/node ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env node'"}, "install.development.javascript.bun": {"icon":"","label":"Bun","disabled":"[[ -d $HOME/.local/share/mise/installs/bun ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env bun'"}, "install.development.javascript.deno": {"icon":"","label":"Deno","disabled":"[[ -d $HOME/.local/share/mise/installs/deno ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env deno'"}, - "install.development.php.php": {"icon":"","label":"PHP","disabled":"omarchy-pkg-present php","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env php'"}, - "install.development.php.laravel": {"icon":"","label":"Laravel","disabled":"[[ -x $HOME/.config/composer/vendor/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env laravel'"}, + "install.development.php.php": {"icon":"","label":"PHP","disabled":"[[ -d $HOME/.local/share/mise/installs/php ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env php'"}, + "install.development.php.laravel": {"icon":"","label":"Laravel","disabled":"[[ -x $HOME/.local/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env laravel'"}, "install.development.php.symfony": {"icon":"","label":"Symfony","disabled":"omarchy-pkg-present symfony-cli","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env symfony'"}, "install.development.elixir.elixir": {"icon":"","label":"Elixir","disabled":"[[ -d $HOME/.local/share/mise/installs/elixir ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env elixir'"}, "install.development.elixir.phoenix": {"icon":"","label":"Phoenix","disabled":"compgen -G \"$HOME/.mix/archives/phx_new*\"","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env phoenix'"}, @@ -345,8 +345,8 @@ "remove.development.javascript.node": {"icon":"","label":"Node.js","when":"[[ -d $HOME/.local/share/mise/installs/node ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env node'"}, "remove.development.javascript.bun": {"icon":"","label":"Bun","when":"[[ -d $HOME/.local/share/mise/installs/bun ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env bun'"}, "remove.development.javascript.deno": {"icon":"","label":"Deno","when":"[[ -d $HOME/.local/share/mise/installs/deno ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env deno'"}, - "remove.development.php.php": {"icon":"","label":"PHP","when":"omarchy-pkg-present php","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env php'"}, - "remove.development.php.laravel": {"icon":"","label":"Laravel","when":"[[ -x $HOME/.config/composer/vendor/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env laravel'"}, + "remove.development.php.php": {"icon":"","label":"PHP","when":"[[ -d $HOME/.local/share/mise/installs/php ]] || omarchy-pkg-present php","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env php'"}, + "remove.development.php.laravel": {"icon":"","label":"Laravel","when":"[[ -x $HOME/.local/bin/laravel || -x $HOME/.config/composer/vendor/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env laravel'"}, "remove.development.php.symfony": {"icon":"","label":"Symfony","when":"omarchy-pkg-present symfony-cli","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env symfony'"}, "remove.development.elixir.elixir": {"icon":"","label":"Elixir","when":"[[ -d $HOME/.local/share/mise/installs/elixir ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env elixir'"}, "remove.development.elixir.phoenix": {"icon":"","label":"Phoenix","when":"[[ -d $HOME/.local/share/mise/installs/elixir ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env phoenix'"}, From d97b2b612de82e9af6ce5e33de71cd856c278a03 Mon Sep 17 00:00:00 2001 From: nunomaduro Date: Wed, 16 Sep 2026 17:44:30 +0100 Subject: [PATCH 07/11] chore: removes useless comment (cherry picked from commit 456af5cc8899b1a67219f9bd7489746441f78daf) --- bin/omarchy-install-dev-env | 1 - 1 file changed, 1 deletion(-) diff --git a/bin/omarchy-install-dev-env b/bin/omarchy-install-dev-env index 5dcd819af24..03577198d6f 100755 --- a/bin/omarchy-install-dev-env +++ b/bin/omarchy-install-dev-env @@ -15,7 +15,6 @@ install_php() { mise tool-alias set php github:nunomaduro/static-php-builds mise use --global php@latest - # Composer's global binaries go to ~/.local/bin, which is already on PATH mise x php -- composer global config bin-dir "$HOME/.local/bin" } From fcf9e663245c76606c332bb09efc3f8312137d4a Mon Sep 17 00:00:00 2001 From: nunomaduro Date: Wed, 16 Sep 2026 20:35:34 +0100 Subject: [PATCH 08/11] fix: removes the laravel binary even when php is already gone Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 486e1cab9b98c9064522741d7bc878f6c93c9497) --- bin/omarchy-remove-dev-env | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bin/omarchy-remove-dev-env b/bin/omarchy-remove-dev-env index b586ed09292..46bc8459d15 100755 --- a/bin/omarchy-remove-dev-env +++ b/bin/omarchy-remove-dev-env @@ -52,6 +52,9 @@ php) laravel) echo -e "Removing Laravel...\n" mise x php -- composer global remove laravel/installer 2>/dev/null || true + + # Composer cannot run once PHP is gone, so drop the installer binary directly (new and old bin-dir) + rm -f "$HOME/.local/bin/laravel" "$HOME/.config/composer/vendor/bin/laravel" ;; symfony) echo -e "Removing Symfony CLI...\n" From fbbbb32c0589ed9c597aa509b75dd5d4ad8b047d Mon Sep 17 00:00:00 2001 From: nunomaduro Date: Wed, 16 Sep 2026 20:46:40 +0100 Subject: [PATCH 09/11] chore: removes comment (cherry picked from commit 85da80dd7c280e99ef333bb2804fe4a7a6c91c7e) --- bin/omarchy-remove-dev-env | 1 - 1 file changed, 1 deletion(-) diff --git a/bin/omarchy-remove-dev-env b/bin/omarchy-remove-dev-env index 46bc8459d15..1be245b79a7 100755 --- a/bin/omarchy-remove-dev-env +++ b/bin/omarchy-remove-dev-env @@ -53,7 +53,6 @@ laravel) echo -e "Removing Laravel...\n" mise x php -- composer global remove laravel/installer 2>/dev/null || true - # Composer cannot run once PHP is gone, so drop the installer binary directly (new and old bin-dir) rm -f "$HOME/.local/bin/laravel" "$HOME/.config/composer/vendor/bin/laravel" ;; symfony) From 75e8fd1ae8d6a514f898e854a923dcb21556bbe2 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 18 Sep 2026 14:10:14 +0200 Subject: [PATCH 10/11] Detach 1Password from installer terminal (cherry picked from commit 5f34ce4581f7cd65b9555d821a61f58e6314821a) --- bin/omarchy-install-service-1password | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/omarchy-install-service-1password b/bin/omarchy-install-service-1password index f59cab9257c..fc7238a6dd8 100755 --- a/bin/omarchy-install-service-1password +++ b/bin/omarchy-install-service-1password @@ -28,7 +28,7 @@ echo "Installing 1Password extension for Chromium..." install_chromium_extension echo "Opening 1Password..." -uwsm-app -- 1password >/dev/null 2>&1 & +setsid uwsm-app -- 1password >/dev/null 2>&1 & echo "" echo "1Password has been installed. Restart Chromium to load the browser extension." From f049e1ff4732807a36ceb64ddcde98522df55e85 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 18 Sep 2026 15:34:01 +0200 Subject: [PATCH 11/11] Add omarchy up alias (cherry picked from commit d174d4aa279ea7393d4fad4a80fed147866106b9) --- bin/omarchy-update | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/omarchy-update b/bin/omarchy-update index dc7f9f6a17d..f89b5810446 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -1,6 +1,7 @@ #!/bin/bash # omarchy:summary=Update Omarchy and system packages +# omarchy:alias=omarchy up # omarchy:args=[-y] # omarchy:examples=omarchy update | omarchy update -y # omarchy:requires-sudo=true