Backport of the FIDO2 authfile fix (PR #7904 by @mdisec, merged to quattro as
23dab9ec) onto the v4-0-1 release branch.
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, no race and no second account: anything running as that
uid can append its own credential and satisfy the machine's sudo prompt without
knowing the password.
The setup now creates a unique staging file as root beside the final authfile
and pipes pamu2fcfg into it, so root never reopens a caller-owned pathname,
rejects failed or empty enrollment output, publishes with an atomic mv -Tf,
cleans the exact staging file after every failure, refuses non-regular authfile
states, and installs root:root 0644. A migration repairs machines set up by
earlier versions by replacing the inode rather than chowning in place: a process
that already holds a writable descriptor on the legacy user-owned mapping keeps
it, so the repair has to leave that inode behind where PAM no longer reads it.
Clean cherry-pick: all six files are byte-identical to quattro, so merging
v4-0-1 into quattro resolves without a conflict. The migration's timestamp
(1787494718) is older than others already on this branch, which is harmless:
omarchy-migrate marks each migration by filename rather than tracking a
watermark, so this one runs on every machine that has not run this exact file.
test/shell passes: 195 files, including the three this adds -- the setup's
staging and failure paths, the migration's repair and unrepairable states, and
the removal's symlink handling. test/cli passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the input-device name fix (PR #8129 by @acrogenesis, merged to
quattro as 9285b19d) onto the v4-0-1 release branch.
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. XF86TouchpadToggle is bound with
locked = true, so a malicious USB name reached Lua execution from the lock
screen as the logged-in user, and a persisted disable made it run on every
start. Publicly reported by Jorrit Jongma / Chainfire.
The disable is no longer executable Lua anywhere. The device name is stored as
plain-text data in a *-disabled-name sidecar and read back by a packaged module,
default/hypr/disabled-input-device.lua, on every reload; the live hyprctl eval
Lua-quotes the name and rejects control characters outright. The reload loader
excludes the two legacy filenames, so a leftover generated *-disabled.lua on a
not-yet-migrated install can never be sourced as code again, and a migration
recovers the device name from it and deletes it, sanitizing installs that ran
the vulnerable version. All four monitor scripts validate an output name against
a plain-connector-name pattern before writing it as Lua, closing the same latent
pattern in the siblings, and paths.lua treats a set-but-empty XDG_STATE_HOME as
unset to match the bash side.
Clean cherry-pick: all fourteen files are byte-identical to quattro, so merging
v4-0-1 into quattro resolves without a conflict. This branch ships no leftover
*-disabled.lua template of its own -- the tracked "disabled" files are the same
two quattro has -- so the migration is the only path that has to sanitize
anything here.
test/shell passes: 192 files, including the three this adds. The toggle suite's
public-PoC case passes here, as do the monitor scripts' accept/reject cases and
the XDG path cases. test/cli passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the DNS PATH pin (PR #8172 by @mdisec, merged to quattro as
4637735a) onto the v4-0-1 release branch.
omarchy dev link prepends a user-writable checkout's bin/ to sudo's secure_path
so privileged Omarchy commands resolve to the development versions, and that
reaches the subprocesses they launch too. This branch carries the same
passwordless grant -- etc/sudoers.d/omarchy-dns lets wheel run
/usr/bin/omarchy-dns Cloudflare, Google and DHCP without a password -- so the
packaged script ran as root while resolving bare helpers (dirname, install,
tee, rm, nmcli, systemctl, awk) through the caller's secure_path. Write access
to a dev checkout became arbitrary root execution, with no password prompt in
the way.
Pin PATH to trusted system directories once EUID is 0. The restriction lands
only after elevation, so the unprivileged wrapper phase keeps the caller's PATH
and can still find sudo or pkexec; every helper the privileged half uses is a
system utility, so it needs nothing from the checkout.
Clean cherry-pick: both files are byte-identical to quattro, so merging v4-0-1
into quattro resolves without a conflict. Verified by mutation: with the pin
removed, test/shell.d/dns-sudoers-test.sh fails at the poisoned-helper case;
restored, all four of its cases pass. test/shell (189 files) and test/cli pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the Taildrop toast fix (PR #7953, merged to quattro as 7e469f96)
onto the v4-0-1 release branch.
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 mechanism is already on this branch: omarchy-notification-send parses
options that follow the two positionals, and NotificationLogic.js gives a
critical popup duration 0, which never expires.
One conflict, in test/shell.d/notification-send-test.sh. #7953 added coverage
for the trailing-option path in the notify-send form it had then; #7926, which
merged after it on quattro and is already backported here, rewrote that file for
the Notify D-Bus form and carried the same coverage across. This branch
therefore already asserts what #7953 added -- an urgency and a glyph after the
description reach the call, and the urgency is set once -- so the branch's
version stands and the resolved file is unchanged.
Verified by mutation: with -u critical removed, test/shell.d/tailscale-receive-
test.sh fails at once; restored, it passes, including the new assertion that
every announcement waits to be answered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the shared URL check (PR #8174, merged to quattro as 68ab12f7) onto
the v4-0-1 release branch, on top of the #8067 backport it follows.
omarchy-theme-install and omarchy-plugin-add both 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. The rule now lives in omarchy-git-url-check and
both callers ask it: two copies of a security check drift, and the second copy
arrived four months after the first only because someone went looking for it.
That rule was also enforcing half of what it described. <helper>::<address> is
one of two shapes git resolves a remote helper from -- it also runs
git-remote-<scheme> for <scheme>://<address> whenever the scheme is not one it
connects itself, so ext::sh -c id and ext://sh -c id reach the same helper while
only the first was refused. Nothing exploitable follows on a stock system:
protocol.ext.allow defaults to never, and the :// spelling hands git-remote-ext
a command name it cannot exec. But a third-party helper installed on PATH is
reachable through the scheme form alone, and a guard is worth more when it
enforces the rule it states.
The :// shape cannot be refused the way :: is, because it is also how every
legitimate URL arrives, so the scheme is checked against the transports git
still connects itself: ssh git git+ssh ssh+git http https ftp ftps file.
git+ssh and ssh+git are on that list because they are spelled like a helper and
read as plain ssh; leaving them off would refuse a URL that clones today. ext
and fd are off it deliberately. A single colon is always scp-style ssh and a
bare path is always a path, so neither needs constraining.
The check fails closed: the callers read a non-zero status as a refusal, so a
missing omarchy-git-url-check refuses the URL rather than waving it through.
Clean cherry-pick on top of the #8067 backport: every file is byte-identical to
quattro, so merging v4-0-1 into quattro resolves without a conflict. test/shell
passes: 189 files, including the one this adds. test/cli passes, so the new
command's metadata is well-formed. Exercised the check here: https, scp-style,
ssh, git+ssh and scp-style IPv6 all pass, while ext:: ext:// fd:: fd:// and a
leading-dash form are refused, as is an uppercase EXT:// spelling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the sudoless Docker follow-up (PR #8098, merged to quattro as
06a3dbca) onto the v4-0-1 release branch, on top of the #8056 and #8080
backports it follows.
Group membership only takes effect on a fresh session, and in practice a logout
or newgrp is not enough -- only a reboot reliably applies it. The setup and
remove commands now flag the reboot and offer to do it right away with a gum
confirm, the same shape as the GPU toggle, and their notices say "after a
reboot" instead of pointing at logout or newgrp. The existing-user migration
reuses the removal command inside omarchy update, so it passes
OMARCHY_DEFER_REBOOT to skip the prompt there and lets omarchy-update-restart
handle the reboot once the whole update has finished.
Setup > Security showed Sudoless Docker under both Setup and Remove, and the
guards tested the running session's groups, which do not change until the
reboot: 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 to both questions that differ in that
window -- by default whether this session can reach the socket, which is what
decides if a command must elevate, and with --configured whether the account is
set up for it, which is what the menu and the toggles need. It succeeds when
sudo is needed, so the Setup entry appears while sudoless Docker is off and
Remove once it is on. lazydocker and the Windows VM keep prompting until the
reboot lands.
Clean cherry-pick on top of the earlier backports: every file is byte-identical
to quattro except default/omarchy/omarchy-menu.jsonc, which merged into this
branch's menu and whose two Sudoless Docker lines match quattro exactly.
test/shell passes: 188 files, including the two this adds. test/cli passes, so
the new command's metadata is well-formed. Exercised the helper here: with no
socket it reports sudo is needed, and --configured answers from the account's
groups.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the plugin-add URL guard (PR #8067 by @bastidotnet, merged to
quattro as 30471bf3) onto the v4-0-1 release branch.
omarchy-plugin-add cloned a user-supplied git URL without the transport-helper
guard omarchy-theme-install already applies. That guard arrived with #7884,
which is on this branch, but it never touched plugin-add -- so the sibling
command still leaned entirely on git's own protocol.ext.allow=never to keep a
URL like ext::sh -c <cmd> from running a command at clone time.
Stock systems are unaffected: Omarchy sets no protocol.* override, so the git
default holds and there is no live exploit here. This is defense in depth --
it closes the gap #7884 left in the sibling path and drops a silent dependency
on a default the project does not control. Reject ext::/fd:: and leading-dash
forms; https, ssh, scp-style and token-auth URLs still clone, including an
scp-style IPv6 host, which carries :: of its own.
Clean cherry-pick: both files are byte-identical to quattro, so merging v4-0-1
into quattro resolves without a conflict. The guard reads the same as the one
already in bin/omarchy-theme-install on this branch. test/shell passes: 186
files, with the plugin-add suite's new cases all running here -- including the
pty-driven prompt case, which is the only path that reaches the guard's
leading-dash arm.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the reboot flag (PR #8080, merged to quattro as 1565919c) onto the
v4-0-1 release branch, on top of the #8056 backport it follows.
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.
Clean cherry-pick on top of the #8056 backport: all three files are
byte-identical to quattro, so merging v4-0-1 into quattro resolves without a
conflict. omarchy-state and omarchy-update-restart are unchanged on this branch
and read the same ~/.local/state/omarchy/reboot-required marker. test/shell
passes: 186 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the docker group removal (PR #8056, merged to quattro as b5ded31e)
onto the v4-0-1 release branch.
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 is not
an escalation -- the owner is already a wheel user -- but it hands any code
running as the user, a rogue plugin or a poisoned dependency, a silent,
headless, passwordless path to root that sudo's password prompt would otherwise
gate.
Stop granting the group by default. The daemon still runs, the Docker TUI and
the Windows VM reach it through a polkit prompt, and the plain docker CLI runs
under sudo. Sudoless Docker becomes a warned opt-in under Setup > Security, and
no automatic path re-grants it: install and first-boot provisioning never record
or apply the group, and the Quattro upgrade no longer adds it. A migration takes
existing installs out of the group, reusing omarchy-remove-security-sudoless-
docker so the change and its notice have one source of truth.
The Windows VM keeps needing the root daemon for a privileged container, so it
runs without the group without becoming a new way in: the compose moves to a
root-owned directory written only by an elevated, input-validated writer, volume
paths are rebuilt from $HOME on migration rather than trusted from the
user-writable legacy file, the privileged sub-action is checked against an
allowlist before dispatch, pkexec elevates a verified root-owned command path,
mount sources are refused when they are or resolve through a symlink, and the
guest password moves to a private 0600 per-user file instead of a
world-readable compose. Existing installs auto-migrate the VM without a
redownload.
Two files had diverged from quattro and were resolved by hand:
bin/omarchy-windows-vm -- v4-0-1 still carries the "Starting Windows VM" toast
that #7585 dropped on quattro, and the new start path has no user-side status
check to hang it on: after this change the user cannot inspect the container
without privilege, which is the whole point. Took quattro's version. #7585's
reason holds here too -- the shell shows its own "Launching Windows…" OSD until
the RDP window appears (shell/services/AppLibrary.qml) -- and the failure
notification stays. The file is now byte-identical to quattro.
manual/28-windows-vm.md -- took the new paragraph on the root-owned compose,
without the neighbouring OEM-key paragraph, which documents omarchy windows key,
a command quattro has and this branch does not.
test/shell passes here: 186 files, including the three this adds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the synaptic touchpad quirk fix (PR #7236, merged to quattro as
20400bad) onto the v4-0-1 release branch, so 4.0.1 installs stop dying on it.
install/hardware/fix-synaptic-touchpad.sh calls modprobe against the running
kernel. On 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 the two
never match: modprobe exits 1 with "Module psmouse not found in directory
/lib/modules/<live kernel>". 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.
Ask modprobe itself, with -qn, whether it can resolve psmouse for the running
kernel before asking it to load one, and warn instead of failing when it
declines for any other reason. The chroot mismatch becomes an unresolvable
module, skipped in silence. An optional touchpad improvement must not 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 -- it only ever aborted installs. Persisting
it would mean 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". That is a
hardware-behaviour change on a wide class of machines, left as a separate
decision on quattro.
Clean cherry-pick: both files are byte-identical to #7236, so merging v4-0-1
into quattro resolves without a conflict. test/shell.d/synaptic-touchpad-test.sh
passes here, as does the rest of test/shell (183 files).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the notification click-command hardening (PR #7926, merged to
quattro as 43bfe9b9) onto the v4-0-1 release branch. Click actions are argv
vectors run without a shell, omarchy-notification-send calls the Notify D-Bus
method directly via busctl instead of notify-send, and --exec takes the command
as rest-of-line words. Excludes docs/notifications.md, which does not exist on
v4-0-1.
* Stop an installed theme from shipping code
`omarchy theme install <url>` 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/<file>.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 `<helper>::<address>` 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 <codex@openai.com>
* 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 <codex@openai.com>
* 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 <codex@openai.com>
* 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 <codex@openai.com>
(cherry picked from commit ef6d9e6605)
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
* 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) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
* 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) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
* 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) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Omabot <omabot@omarchy.org>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
(cherry picked from commit b71c60fe30)
* 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 <noreply@openai.com>
* 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 <noreply@openai.com>
---------
Co-authored-by: Codex XHigh <noreply@openai.com>
(cherry picked from commit 13a969e1ab)
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 <noreply@anthropic.com>
(cherry picked from commit b63616422f)
grant_covers re-implemented etc/sudoers.d/omarchy-dns in bash -- one of
the three providers, and %wheel -- but never asked whether the rule was
installed. It ships in the etc/ tree that omarchy-settings copies, so
every machine still on an older settings package answers yes to a grant
it does not have. require_root then execs into sudo with no way back,
and the panel's one-click toggle dies on a password prompt it has no
terminal to show.
Ask sudo instead. `sudo -l` alone reports whether a command is
permitted, which the blanket %wheel rule answers yes to for everything,
but the long listing prints the matched entry's tags -- !authenticate is
the grant and nothing else. It runs nothing, and under -n it prompts for
nothing, so a machine without the rule falls through to polkit and gets
a prompt on screen.
The provider list and the wheel check go away with it; sudo owns that
policy now, and it stays right if the rule is ever edited or removed.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 1e70cca144)
* Switch DNS providers without a password prompt
The network panel and the menu run omarchy-dns from a process with no
terminal, so require_root reached for pkexec and put a polkit password
prompt in front of what is meant to be a one-click toggle.
Grant %wheel passwordless sudo for the three stock providers and take
that path whenever the grant covers the invocation. Custom stays out of
the grant: it points the machine at servers the caller supplies, and it
already runs in a terminal that can ask.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Pick the elevation path without asking sudo
The `sudo -n -l` probe answered the wrong question. It reports whether a
command is permitted, not whether it is passwordless, and the %wheel rule
every Omarchy install ships permits everything -- `sudo -n -l /usr/bin/rm
-rf /tmp/x` exits 0. So the probe passed for Custom too, and the exec
below it ran `sudo -n`, which fails outright with no terminal and no way
back to pkexec.
Decide from what the sudoers rule actually says instead: sudo when there
is a terminal to type into, or when the resolved path and the provider
are both ones the rule names. Everything else keeps going through polkit.
Pin a root-owned PATH once elevated, too. `omarchy dev link` puts a
user-writable checkout ahead of sudo's secure_path for every command, so
a passwordless grant on a script that resolves nmcli, tee, and install
through PATH would otherwise hand root to whoever can write there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Keep users outside %wheel on the polkit path
The rule grants %wheel, so path and provider alone do not mean sudo will
take it. A user outside the group was sent to sudo anyway, and with no
terminal to answer the prompt that is a dead end -- polkit at least
offers to authenticate as somebody else.
Two holes in the test alongside it: it accepted any file containing the
expected rule, so a second, argument-free line would have widened the
grant unnoticed, and run as root it would have sailed past the stubs and
rewritten the host's own DNS config.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Elevate the system install, whatever copy was invoked
The rule names /usr/bin/omarchy-dns, so a dev-linked checkout handed sudo
a path nothing could match and fell back to a polkit prompt. Re-exec the
packaged path instead: the privileged half is the system install
everywhere, the grant matches everywhere, and the path comparison and the
PATH pinning that existed to work around the checkout both go away.
Dev-linked checkouts run their own unprivileged half and the installed
one as root, which is the trade for not carrying a second code path.
---------
Co-authored-by: Omabot <david@hey.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 3765e8010b)
omarchy-plugin-clone only takes the source id as the first argument, but the
menu passed --edit ahead of it, so the id fell through to the unknown-option
branch and every clone from Setup > Plugins failed.
Closes#6913
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit ff4e92e63b)
Bar widgets propagate their composed press-and-hold down to the center gesture
area without handing over the grab, so the gesture area started a bar move and
then received neither a release nor a cancel to end it. The move ghost stayed on
screen for the rest of the session. Ignore the gesture unless we hold the press.
Closes#6881
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit c0a20e2e43)
mise use -g announces the resolved tool on stdout, so every wrapped command
prepended a "tools:" line to its own output. That corrupts anything speaking a
protocol over stdout, such as codex app-server. Pass --quiet, which keeps errors
on stderr and preserves the exit status.
The obsolete-wrapper check in the agent migration matched the generated command
verbatim, so loosen it to match the package instead of the flags.
Closes#6908
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 1c3da94906)
* Launch claude and codex agents with auto-review instead of full bypass
Claude's auto permission mode and codex's --approve-for-me both run
unattended without prompting, but keep automatic review (and codex's
workspace-write sandbox) instead of skipping approval entirely. Grok
stays on bypassPermissions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Match the bash aliases to the agent launcher's auto-review modes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit dd9dee417f)
* Restore preinstalls from the menu, and drop the Omacom apps with them
Remove Preinstalls missed omacut, omacalc, and omawrite, so the three Omacom
apps survived an opt-out that was supposed to clear the desk.
Opting out was also one-way. Install > Preinstalls now puts everything back:
the shipped .desktop launchers and mise stubs via omarchy-refresh-applications,
the dropped packages via pacman, and the opt-out marker deleted so the
preinstalled keybindings return on reload. The two menu entries guard on the
marker, so exactly one of them is ever visible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Trim the preinstall lists to what quattro actually ships
Remove Preinstalls was still dropping typora, spotify, 1password, 1password-cli,
signal-desktop, opencode, claude-code, and github-cli. None of those are in
omarchy-base.packages anymore: typora gave way to omawrite, the services moved
to on-demand menu installs, and the agent CLIs are mise-managed. Removing them
took out apps the user had deliberately installed, and restoring them would have
put back what we no longer ship.
Both lists are now the same twelve packages, all of them in omarchy-base.packages.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Keep the opt-out marker when a restore fails
omarchy-pkg-add exits non-zero when pacman cannot install a package, but the
restore ran straight past it, cleared the marker, and reloaded Hyprland. That
reported success and brought back keybindings for apps that never arrived. The
marker now falls last, behind a check on the transaction.
The new test also pins the two lists to each other and to omarchy-base.packages,
which is the drift that let retired packages linger in the removal list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Pacman answers its own conflict question with No under --noconfirm, so one
retired package can stop every update after it. Which package to drop is a
decision rather than a cleanup, so run the upgrade again with pacman asking
when there is a terminal to answer on, and report instead when -y promised
not to ask.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The migration asked a user to close every running browser before repairing
the Copy URL shortcut, but a browser only ever rewrites its own Preferences
on exit. Waiting on all browsers deadlocks `omarchy update` for anyone whose
main browser is effectively never closed: the pending ghosts commonly sit in
a stale profile nobody has open, yet the migration blocks on the always-open
browser until the prompt is declined, failing the whole update.
A running Chromium-family browser holds a SingletonLock (and socket) inside
its user-data-dir, so whether the profile being repaired is open is
mechanical. Gate on that instead of on the sheer presence of a browser
process — the repair proceeds where the affected profile is closed, and
browsers attached to other profiles no longer hold the update hostage.
The gate stays conservative while an affected profile actually is open, and
the existing post-repair verification still catches a browser that starts
mid-repair and restores stale Preferences on exit.
The migration test now simulates an open profile with its SingletonLock
instead of a pgrep stub; every prior scenario still passes.
LocalSend registers an Ayatana item with no ItemIsMenu and no Activate
handler, so its primary click is a silent no-op and the menu offers only
Open and Quit. Share > Receive already opens it, so drop the item the way
Dropbox's is dropped when its dedicated widget owns the surface.
Closes#6838
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
gum draws its confirm UI on stderr, so the migration's `2>/dev/null` threw
away the whole prompt while gum still held the terminal in raw mode reading
keys. With a browser open, an update stopped after "Running migration
(1786643346)" on an unpainted screen with no way to tell it was waiting for
an answer.
Nothing else in the repo suppresses gum's stderr; the redirect only ever hid
gum's own error in the no-terminal case, where the migration already explains
itself on stderr before deferring.
Fixes#6841
Claude-Session: https://claude.ai/code/session_01So4yKzTDdJJp7orjnF8i1J
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Rebind ghost Copy URL shortcut registrations to the pinned id
Chromium never hands a suggested shortcut to one extension while
another — even a long-gone one — still holds the registration. Profiles
that first loaded Copy URL before its id was pinned registered
Alt+Shift+L under an id derived from the extension's load path at the
time, so the pinned extension never receives the shortcut and the
keypress does nothing (#6816).
The quattro upgrade tried to repair this against one hardcoded
path-derived id, which only ever matched a single home directory. The
historical ids are unknowable in general — they hash long-gone absolute
paths through whatever symlinks existed then — but the registration
itself names the command, so a migration now rebinds any copy-url
command that points away from the pinned id, unless that id belongs to
an extension that is actually installed or the pinned extension already
holds a binding of its own.
Browsers rewrite Preferences on exit, which reverts any repair made
while one runs, so the migration asks for this user's browser windows to
be closed first — failing and staying pending when there is no terminal
to ask in or the prompt is declined. The backup a repair leaves behind
marks it as attempted but unverified: until a browser-free run confirms
the registration stayed repaired, the migration keeps itself pending
rather than trusting a disk state an open browser may still overwrite.
The upgrade-time repair is dropped: the upgrade already runs migrations,
so the migration is the single implementation.
Fixes#6816
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Pin the WhatsApp Slim extension id
Keyless unpacked extensions get path-derived ids, which go stale if the
load path or packaging ever changes — the same class of bug that broke
the Copy URL shortcut for pre-package installs. Pin the id with a
manifest key like the other bundled extensions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Crash capture stays on by default, but Trigger > Toggle > Crash Capture (or
`omarchy toggle crash-capture`) now turns the watcher off. The toggle writes the
usual flag file and stops the unit for this session; the unit checks the same
flag with ConditionPathExists, so the choice survives a logout without the unit
having to be disabled.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The blank timer was gated on `authenticating`, which is
`authenticatingPassword || fingerprintAuthenticating`. The fingerprint
PAM sits armed for the entire lock waiting for a finger, so on any
machine with a reader enrolled the gate is true from lock until unlock:
the timer is stopped when the lock begins and never re-armed, and the
display stays lit indefinitely.
Gate on `authenticatingPassword` instead. A password check in flight
still holds the display up, and the passive fingerprint wait no longer
does.
Claude-Session: https://claude.ai/code/session_01EDpyC9793TKZBS2jXUNECG
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Fix notification focus for agent terminals
* Restrict notification title fallback to agents
* Simplify the focus fallback to a lazy two-tier query
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Keep AppLibrary scans out of login shells
A login shell sources the user's profile, and tools like mise touch
~/.local/share on activation. Quickshell's desktop-entry watcher monitors
that tree, so every scan retriggered another scan: a self-sustaining loop
that pinned ~20% of a core at idle. The scans only use bash builtins,
find, and sort, so they don't need the login environment.
Fixes#6806
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Pin the AppLibrary scans to non-login shells in tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Persist notification images so history keeps avatars
Persisted popup and history entries stored image/appIcon as URLs into
resources that die with the live notification: Chromium-family senders
(every Omarchy web app, WhatsApp included) pass avatars as files in a
scoped /tmp dir deleted when the notification closes, and raw image-data
hints surface as in-process image:// URLs that die with the server
object. Replaying history then found dead references and hid the icon.
Copy file-backed images into the notification state dir when persisting,
keyed by the entry's file stem, and reference the copies from the JSON.
Blank dead image:// URLs so the card falls back to the app icon. The
copies die with their JSON: superseded-popup deletes, history trims and
clears remove them, and a startup sweep collects copies orphaned by a
restart killing a queued job mid-write.
Hold DND-silenced notifications open until their history write has run,
since untracking tells the sender to delete its avatar file, and carry
replayed on-screen rows over via their persisted copies, since the
replay dismisses their live notifications first.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Coalesce silenced updates and bound image copies through temp files
A replaces_id update lands on a held DND notification without a second
onNotification, so releasing after the first write could persist a stale
snapshot. Re-snapshot when the write completes and write again until the
content is stable, reusing the original file identity.
The image copy reopened the sender-controlled path after checking it, so
a file growing or becoming a FIFO mid-copy defeated the size bound. Read
through head -c under a timeout into a temp file, validate its size, and
rename it into place; the startup sweep clears temp files a killed job
leaves behind.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* perf(agents): cut codex usage collector memory with SQL filter and cache
The codex collector scanned every row of opencode.db (1.7 GB, 55k+ rows)
with Python-side json.loads, peaking around 716 MB of RSS on every run
-- including the panel's refreshLimits() call, which passed --limits-only
that the collector silently ignored.
Filter rows in SQL (LIKE gates + json_valid + json_extract authority,
mirroring the old Python filter semantics) so giant blobs are never
parsed, and cache the local stats scan in XDG_CACHE_HOME following the
claude collector's pattern (atomic writes, flock, schemaVersion).
--force rescans, --limits-only and normal mode reuse a fresh cache and
fall back to a full scan when it is missing, stale, or corrupt.
Measured: cold scan 716 MB -> 158 MB peak; warm --limits-only ~85 MB
and ~1.4 s. Output record schema and values are unchanged for the same
data (parity verified against the old filter, including malformed rows).
* Scope the codex scan cache's 15-minute reuse to --limits-only
A no-flag run is the widget's periodic refresh, and refreshIntervalSec is
configurable down to 30 seconds; holding every mode to a 15-minute cache
meant stats could lag far behind the interval the user asked for. Mirror
the claude collector: normal runs reuse a scan for ~20 seconds purely to
dedup concurrent collectors, and only --limits-only, which promises just
fresh limits, may reuse a scan for up to 15 minutes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Invalidate the codex scan cache across day boundaries
The cached stats embed date-dependent fields (todayPrompts,
todayTotalTokens, recentDays), but only the file's age was checked, so a
cache written at 23:58 served yesterday's numbers as "today" for up to
15 minutes past midnight. Stamp the envelope with the scan's local date
and treat any other date as a miss.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Reject codex scan caches with a future mtime
A cache whose mtime is ahead of the clock has a negative age, which the
freshness check accepted forever: setting the clock backwards froze the
stats until real time caught up with the file. Require a non-negative
age before trusting the cache.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Never cache an interrupted opencode scan
A transient lock, schema migration, or corrupted database aborts the
opencode scan mid-flight; the partial numbers still serve the current
run, but persisting them let a single bad read suppress opencode usage
for every cache reader until expiry. The claude collector already skips
its opencode cache write on a database error; do the same here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Make the json_valid guard order explicit in the opencode query
The query relied on json_valid(data) evaluating before json_extract(),
but SQLite does not promise that AND terms run left to right; a
reordered plan would let json_extract raise on a malformed row and
silently truncate the scan. Wrap each json_extract in a CASE so the
guard is structural rather than positional.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Drop a claude-collector comment that is false for codex
"These caches were world-readable before" was copied from the claude
collector; codex had no caches before this one existed. Explain the
chmod on its own terms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: markbusking <marcosbustos.dev@gmail.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
omarchy-hw-hybrid-gpu gates the Hybrid GPU menu entry, and it queried
supergfxctl unbounded — a wedged supergfxd stalled menu rendering
forever. Bound the query with the same TERM-then-KILL escalation the
toggle uses, and treat a daemon that cannot answer like a machine
without supergfxctl: fall back to counting GPUs rather than hiding
hardware that is really there. An ordinary supergfxctl failure still
hides the entry.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Only offer video capture devices as webcams
V4L2 exposes raw processing and output-only nodes beside usable cameras. The webcam picker and automatic recorder selection treated the first /dev/video node as a camera, so IPU6 laptops opened a black overlay instead of their loopback capture device.\n\nShare one device lister across detection, selection, and recording, and keep only groups whose first video node advertises Video Capture in Device Caps. Cover raw IPU nodes, ordinary capture devices, and capture-less systems.
* Fall through to a later capture-capable node in a webcam group
A group whose first video node is not capture-capable vanished entirely,
even when a later node in the same group could capture. Probe each node
until one qualifies, still emitting at most one device per group. Also
exit zero explicitly: a trailing filtered device used to leak the failed
capability check as the script's exit status.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Bound hybrid GPU mode queries
* Test blocked hybrid GPU queries
* Give the blocked-client test headroom over its 12s of kill cycles
The third case spends ~12s of real TERM/KILL escalation against its own
15s watchdog, which can tip to a spurious 124 on a loaded machine. Also
drop the TEST_LOG plumbing no stub ever wrote.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The interval floor was applied with max(), so the zero that --force
picked could never win: max(0, 15) is 15. Forcing a refresh within
fifteen seconds of the last probe silently served the cache instead,
though --force documents itself as ignoring them.
The window exists to absorb a panel opened and shut repeatedly, which
arrives as --limits-only. --force is a person pressing refresh, and it
should outrank a window meant for flicks.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Stop closed network panels from leaving Wi-Fi scanning enabled
refresh() defaults scanWifi to false and its no-scan branch enabled the
scanner unconditionally. Five paths reach it with no panel on screen —
Component.onCompleted, clearNetworkAction(), failNetworkAction(), the
band-change actionProc exit, and the 30s actionTimeout — so the scanner
stayed on and Quickshell kept re-arming RequestScan behind a closed panel.
scanRestart had the mirror gap: it enabled the scanner 100ms after
refresh(true) without re-checking that the panel was still open.
Every sweep takes the radio off the operating channel, so this degraded
the link it was scanning from: one sweep every 17s, gateway RTT rising
from ~2ms to repeated 150ms+ spikes on an otherwise idle connection.
Gate the scanner block on the panel being open, cancel a pending restart
on close and re-check the panel when it fires, and track the WifiDevice
this instance enabled so close, device replacement and destruction
release the right object. Destruction matters on its own: a bar reload
with the panel open would otherwise die with opened still true and never
write scannerEnabled = false.
* Cover the scanner ownership helper's own invariants
The previous assertion only pinned that no write bypasses
setScannerEnabled(); it said nothing about what the helper does. Dropping
either the opened gate or the release-before-adopt from the helper still
passed, while a closed instance could reclaim scanning and a device swap
could leave the previous interface scanning.
Run the helper's actual JavaScript against stand-in devices instead,
following the extract-and-eval pattern the agents panel tests already use.
Removing either invariant now fails its own assertion.
Only the Claude Code CLI can refresh the OAuth token it saves; the
collector just reads it. A machine left alone long enough finds the
token lapsed, and that branch returned an empty limits list with no
status text at all, so the panel hid its whole limits section and
explained nothing.
Say what is wrong, and fall back to the cached limits already on disk
rather than discarding them. Cached windows are kept only until they
reset: a percentage from a window that has rolled over describes a
period that is over, and pinning a stale 78% on an allowance that is
now untouched would be worse than showing nothing. The probe-failure
path gets the same filtering for the same reason.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Drop the kms hook when the proprietary NVIDIA driver handles early KMS
install/hardware/nvidia.sh early-loads nvidia_drm (modeset=1) for early KMS,
but HOOKS still carried the kms hook, so autodetect pulled nouveau and
~100 MB of its GSP firmware into every initramfs for a driver that never
runs. On a Limine UKI setup that meant a 256 MB image where ~144 MB is
normal, doubled again by the fallback history on /boot.
Filter kms out of HOOKS when nvidia_drm is in MODULES (nvidia.conf sorts
before this drop-in) and every PCI display controller is NVIDIA. Hybrid
systems keep kms so the iGPU retains early KMS at the LUKS prompt.
Verified on an RTX 4090 (nvidia-open-dkms 610.57.04): UKI shrinks
256,183,296 -> 144,066,048 bytes, nouveau and its firmware gone, the
nvidia-utils GSP blobs and all four nvidia modules retained, Plymouth
still owns the LUKS prompt via nvidia_drm.
Fixes#6790
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Address review: quote literals, harden PCI detection, add shell tests
Quote fixed string literals in the [[ ]] comparisons per AGENTS.md, and
read the PCI tree through OMARCHY_PCI_DEVICES_PATH, the same seam
bin/omarchy-hw-nvidia already uses.
Require a positively identified NVIDIA display controller before dropping
kms: an empty or unreadable PCI tree previously counted as "no non-NVIDIA
GPU" and would have dropped the hook. Unexpected trees now keep kms.
Cover the conditional in test/shell.d/nvidia-kms-hook-test.sh: nvidia-only,
hybrid, no nvidia_drm, MODULES unset under set -u, audio-function-only,
empty tree, and a device directory missing its sysfs attributes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Treat unreadable PCI devices as inconclusive and make the test hermetic
A device whose class/vendor attributes cannot be read could be another
GPU, so skipping it let a readable NVIDIA GPU beside it drop kms without
having verified the whole tree. Count it as a non-NVIDIA sighting so kms
stays, and cover the mixed case in the test.
The test also sourced the host's /etc/vconsole.conf under set -u, where a
valid KEYMAP-only file makes the XKBLAYOUT expansion fail in the subshell
and ties the result to the machine running it. Predefine XKBLAYOUT and
FILES before sourcing the config.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Rebuild existing initramfses once the kms hook no longer applies
The settings package deploys the new omarchy_hooks.conf conditional, but
nothing rebuilds the initramfs when only a mkinitcpio drop-in changes, so
existing NVIDIA-only installs would carry nouveau's ~100 MB of GSP
firmware until their next kernel update. Following the precedent of
1784476564, add a migration that rebuilds via limine-mkinitcpio — once
per machine, and only where evaluating the installed drop-ins shows the
conditional actually dropped kms, so hybrid machines, non-NVIDIA
machines, and user-edited configs are left alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Document the mid-sourcing MODULES caveat in the kms conditional
A later-sorting drop-in that resets MODULES outright (as
surface_device_modules.conf does) would strip nvidia_drm after kms was
already dropped. Every machine Omarchy writes such a file for is hybrid
Intel and keeps kms through the PCI scan, but that is worth stating so
the invariant is not broken by accident.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>