Compare commits

..
12 Commits
Author SHA1 Message Date
OpenAI Coding Assistant 2311330dbf feat: add Tor Browser to workstation applications 2026-09-06 12:45:42 -05:00
OpenAI Coding Assistant 35ca88d517 feat: add project-composable DeepSeek Harness microVMs
Share the live project cwd, DSH home and skills read-write while running guest root behind rootless QEMU and Bubblewrap. Reuse project toolchains, expose configurable SSH-forwarded web access, and launch the latest official DSH.

Include the project template, operating guide, offline boot and mount tests, and shell checks.
2026-09-06 12:45:36 -05:00
OpenAI Coding Assistant 221e0b33cc chore: follow rolling NixOS and Home Manager inputs 2026-09-06 12:45:28 -05:00
OpenAI Coding Assistant 83c29a083b feat: enable persistent local Tor client and network helpers 2026-09-06 11:51:16 -05:00
NixOS Updater 14e7e19eec chore: update NixOS package inputs 2026-09-06 00:34:41 -05:00
OpenAI Coding Assistant 000a177607 fix: authenticate only the actual NixOS activation command
A blanket sudo -v requests a password under verifypw=all even when the command itself is NOPASSWD. Keep unprivileged builds and retain the lock through activation without the unnecessary credential preflight.
2026-09-05 23:55:51 -05:00
OpenAI Coding Assistant 4893fcfec0 refactor: share workstation setup and boot-staged updates
Use dev-owned checkouts and one update policy on both hosts. Keep only hardware and deployment identity in host modules, use the same SDDM/UWSM workstation module in the VM, and install a host-configured manual switch command with lock regression tests.
2026-09-05 23:52:51 -05:00
OpenAI Coding Assistant 209f4d8bda feat: add one-command laptop configuration sync 2026-09-05 23:36:28 -05:00
OpenAI Coding Assistant f509079c33 fix: slightly reduce laptop display scaling 2026-09-05 23:31:18 -05:00
OpenAI Coding Assistant 527d989a01 feat: complete and visually audit the Hyprland workstation
Refresh system/tool pins, install official Element Nightly, expand development tools, and finish desktop workflows with a shared charcoal/gold design. Retain host-specific updates and NixOS recovery generations.
2026-09-05 23:22:27 -05:00
OpenAI Coding Assistant 35ac95651d feat: keep pi and nix features current 2026-09-05 22:18:49 -05:00
OpenAI Coding Assistant ad39155705 feat: add recovery-safe physical host and VPN tools
Keep kbot, Plasma/SDDM, NetworkManager, systemd-boot and the installed storage while adding dev/Hyprland as a separate physical host target. Keep EC2 integration and its updater isolated.

Include WireGuard/OpenVPN clients, NetworkManager OpenVPN integration and network diagnostics; add recovery checks and activation instructions.
2026-09-05 22:02:50 -05:00
45 changed files with 3940 additions and 435 deletions
+7 -1
View File
@@ -3,13 +3,19 @@
/result-* /result-*
/.direnv/ /.direnv/
# NixOS-generated files when the checkout lives at /etc/nix.
/nix.conf
/registry.json
# Local secrets must never be imported into Nix or committed. # Local secrets must never be imported into Nix or committed.
/.env /.env
/.env.* /.env.*
!/.env.example !/.env.example
/secrets/ /secrets/
# Editor temporary files. # Python bytecode and editor temporary files.
__pycache__/
*.pyc
*.swp *.swp
*.swo *.swo
*~ *~
+308
View File
@@ -0,0 +1,308 @@
# DeepSeek Harness project VMs
Official **`deepseek-ai/deepseek-harness` (`dsh`)**, not OpenCode. A reusable NixOS
module + flake function + shell launcher. No custom Python control plane, host
service, sudo launcher, or host rebuild is needed.
## Start a project
As your normal user, on **x86_64 Linux** with accessible `/dev/kvm` and enabled
unprivileged user namespaces (for Bubblewrap):
```sh
mkdir -p ~/projects/example && cd ~/projects/example
nix flake init -t path:/etc/nix#agent
# Edit project.nix (tools/env) and flake.nix (RAM/CPU/network).
git init
git add flake.nix project.nix README.md .gitignore
nix flake lock
git add flake.lock
nix develop # ordinary host project shell
nix run .#agent # boots VM; waits and prints a private browser URL
```
Run commands **from the project directory**, including in the second terminal:
```sh
nix run .#agent -- url # print the current token-bearing browser login URL
nix run .#agent -- ssh # root shell, starting in the SAME project cwd
nix run .#agent -- ssh 'id; pwd; rg --version'
nix run .#agent -- ssh 'journalctl -u agent -b --no-pager'
nix run .#agent -- stop # graceful poweroff; Ctrl-C in the launcher also stops it
```
Open the printed URL. In a fresh DSH profile, **Choose workspace → add/select the
project's original absolute path**, which exists in the guest. Then
**Settings → Models** to configure DeepSeek, if you haven't already. Existing
credentials/settings are reused. No API key is required merely to boot the UI.
No model inference runs locally: the VM's RAM/CPUs are for tools/builds, not model weights.
The template input `path:/etc/nix/agent-vm` is a local bootstrap. For collaboration
or guest-side evaluation of the flake, replace it with your accessible Git remote,
e.g. `git+https://git.cyber.ayyalasomayajula.net/marsultor/nixconfig.git?dir=agent-vm&ref=main`
**after these files have actually been committed and published there**. No push is
performed by this setup. Input changes require `nix flake update agent-vm`.
## Exactly what is shared
All of these mounts are **read-write**, as requested:
| Host source, resolved when you launch | Guest path | Includes |
| --- | --- | --- |
| Current working directory, not the flake's store copy | Same absolute cwd; `/workspace` is also an alias | Live source, uncommitted work, `.git`, project `.dsh/skills` and `.agents/skills` |
| `$DSH_HOME`, otherwise `~/.dsh` | `/root/.dsh` | `.credentials.yaml`, `settings.yaml`, `.env`, profiles, plugins, skills, sessions and other DSH state |
| `${DSH_AGENTS_HOME:-~/.agents}/skills` | `/root/.agents/skills` | Shared cross-agent skills only, not the rest of `~/.agents` |
**Upstream's standard directory is `~/.dsh`, not `~/.config/dsh`.** If you have
chosen an XDG-style location, use the upstream override, consistently for all commands:
```sh
export DSH_HOME="$HOME/.config/dsh"
nix run .#agent
```
Missing directories are created. Existing DSH home must be user-owned and private:
`chmod 700 ~/.dsh` (or your actual `DSH_HOME`); existing credentials must be mode
600 as DSH requires. The launcher never reads the credentials into Nix, copies
them to the store, or replaces your settings/skills with generated content.
Root inside the guest is **not host root**. QEMU/9p writes as the launching host
user, so newly created project files belong to you. The whole DSH directory is
mounted instead of individual credential files so atomic rename-based saves work.
DSH retains each project's real cwd identity rather than confusing every project's
sessions with a single `/workspace` identity. Do not run host DSH and several
VMs concurrently against the same mutable profile: package-fallback links and
profile changes can race. Separate `DSH_HOME` values provide independent profiles.
The runner retains a private per-cwd directory under
`${XDG_STATE_HOME:-~/.local/state}/agent-vm/`: dedicated SSH keys, console log,
control socket, and a 4 GiB sparse npm-cache disk. Guest root, guest Nix writes,
processes and other unshared state are otherwise ephemeral. This is **not** a
push/pull/snapshot workflow; edits immediately affect the mounted host files.
## Resources and Nix composition
The template uses one ordinary Nix attrset:
```nix
project = import ./project.nix { inherit pkgs; };
devShells.${system}.default = pkgs.mkShell {
inherit (project) packages env;
};
agent = agent-vm.lib.mkAgentVM {
inherit system project;
modules = [ {
microvm.mem = 8192; # MiB
microvm.vcpu = 6;
agentVM.packages = [ pkgs.strace ]; # additional guest-only tool
} ];
};
```
The function imports the microvm.nix and agent NixOS modules and merges your
modules with them. Normal `imports`, `mkDefault`, `mkForce`, and list merging work.
`apps.${system}.agent = agent.app` exposes `nix run .#agent`; `agent.nixos` exposes
the NixOS configuration for inspection. RAM/CPU/network changes apply on **stop
and restart**, not live. vCPUs are not a host CPU-time quota, and QEMU has some
memory overhead beyond guest RAM.
The standard guest toolkit includes Git/LFS, ripgrep, fd, Python, Node/npm/pnpm,
jq/yq, curl/wget, common archive tools, Make, pkg-config, ShellCheck, and ordinary
Unix inspection tools. Project versions/tools belong in `project.nix`.
This deliberately shares **packages and non-secret environment variables**, not
an arbitrary shell's internals. Existing `shellHook`, `inputsFrom`, cross-compilation
setup hooks, library search paths and background dev services are not magically
converted into NixOS configuration. Factor tools/env into the shared attrset;
configure required guest services through `modules`. Do not put credentials in
`project.env`. Build outputs/package definitions can use the same `pkgs` input.
## Networking: bind address versus VM interface
DSH intentionally rejects `dsh web --host 0.0.0.0`. We don't patch around its
browser protections: it listens on **guest `127.0.0.1:3080`**, and the shell launcher
publishes an **SSH local forward** on your chosen **host** IPv4 address/port.
DSH's random launch-token → signed-cookie authentication and Host/Origin checks
remain in use. Only SSH is forwarded by QEMU, always on host loopback.
```nix
agentVM.network = {
hostAddress = "127.0.0.1"; # default: this computer only
sshPort = 2222;
webPort = 3080;
};
```
- **One LAN/VPN interface:** set `hostAddress = "192.168.1.20"` (an IP actually
assigned to this host). Its browser authority is automatically trusted.
- **All IPv4 interfaces:** set `hostAddress = "0.0.0.0"` and
`trustedHosts = [ "192.168.1.20:3080" "laptop.example:3080" ];`.
Use a real address, not `0.0.0.0`, in your browser. The printed local URL can
have its host replaced with one of those authorities. This includes public
interfaces too; it is not shorthand for “LAN only.”
- Multiple project VMs need distinct host SSH/Web ports.
- Binding a host IP controls the **incoming listener**, not outgoing routing or
which NIC reaches DeepSeek. Outgoing traffic follows host routes/VPN policy.
- No host firewall is changed. For LAN access, explicitly allow only the Web port
on the intended host interface in your firewall. Do not open the SSH forward.
**Use a VPN or a TLS reverse proxy for off-host access.** The forward is encrypted
between host and guest, but browser → host remains HTTP. A token/cookie on an
untrusted network can be stolen. Static assets and third-party plugin routes
may not share DSH's RPC authentication. Never expose this developer-preview
service directly to the public Internet.
### Optional dedicated TAP interface
Default user networking needs no administrator setup. If you need your own guest
IP/interface rather than NAT, add:
```nix
agentVM.network = {
mode = "tap";
tapName = "agent0";
mac = "02:00:00:00:00:10";
guestAddress = "192.168.77.2";
prefixLength = 24;
gateway = "192.168.77.1";
dns = [ "YOUR_REACHABLE_DNS_IP" ];
hostAddress = "127.0.0.1"; # Web publication is still independently configurable.
};
```
An administrator must first provision `agent0`, owned by the launching user,
with `multi_queue` when using multiple vCPUs, and arrange the host's address,
routing/NAT/DNS/firewall. For example, the interface creation portion is
`sudo ip tuntap add agent0 mode tap user "$USER" multi_queue`. That command alone
**does not** establish routing. See microvm.nix's linked routed-network docs for
persistent NixOS setup. Do not substitute a physical Wi-Fi/Ethernet interface for
`tapName`, or run QEMU as root. TAP joins a less isolated network; the host's
loopback publication default does not hide guest SSH from other routed peers.
## What “latest” means
- **Nix:** moving `nixos-unstable` (latest tested rolling channel), microvm.nix's
default branch, and `inputs.*.follows` to keep the project's package set coherent.
`nix flake update` refreshes the resolved snapshots; restart to use them.
`flake.lock` is retained because that is how flakes work, not a manually selected
old package version. The helper's own lock and each consuming project's lock
are independent.
- **DSH:** `npm exec --yes --package=@deepseek-ai/dsh@latest -- dsh ...` runs
**inside the VM**. Each launch resolves the current npm `latest` dist-tag,
using a persistent download cache. Network/registry availability is required;
failed installs are visible in `journalctl -u agent`. There is no fixed DSH
version, handwritten dependency hash, host `npm -g`, or claim of reproducibility
for this mutable part. `latest` can itself be a release candidate. A two-line
Node shim supplies `--expose-internals`, required by the current Cordis HMR
dependency but missing from the published CLI's shebang.
- **Plugins:** explicitly opt in, then update with DSH/pnpm. No unreviewed community
plugins are automatically installed or upgraded by this repository.
- **Workstation:** the root `/etc/nix` flake now also follows `nixos-unstable` and
Home Manager `master`; CLI tools still use Nixpkgs `master`. Its existing daily
updater refreshes those inputs and stages the tested result for next boot.
`stateVersion = "26.05"` remains a compatibility setting, **not a package pin**.
The existing separate Neovim-dotfile revision and manually packaged Element
binary are unchanged; their documented update boundaries still apply.
## Plugins worth considering
DSH is still a developer preview. Popularity is not a security audit or a promise
of compatibility with tomorrow's `latest`. These are actual DSH plugins, not
OpenCode plugins relabeled as DeepSeek plugins. GitHub stars checked 2026-09-06:
| Plugin | Why consider it | Approx. repository stars |
| --- | --- | --- |
| [DSH better sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) | File explorer/editor, Git and terminal alongside the conversation | 3.4k |
| [dsh-market](https://github.com/dsh-market/dsh-market) | Discover/manage plugins in the DSH settings UI | 3.3k |
| [dsh-agent-teams](https://github.com/NanmiCoder/dsh-agent-teams) | Multi-agent delegation; add only when you need it, as it can multiply API cost | 1.4k |
| [dsh-context](https://github.com/bowenliang123/dsh-context) | Inspect context/token use and manage context | 1.3k |
Start with **dsh-context**, then optionally the sidebar. A marketplace and a
multi-agent orchestrator are not necessary to make the core harness useful.
Review their source first; native-addon build prerequisites vary. Example:
```sh
nix run .#agent -- ssh
# Now INSIDE the VM, with your real DSH home mounted RW:
dsh plugin --profile web add dsh-context@latest
# Optional, separately reviewed:
dsh plugin --profile web add dshmarket@latest
systemctl restart agent
exit
nix run .#agent -- url # restart creates a fresh browser launch token
```
Use `dsh plugin --profile web update --latest` when you explicitly want to update
installed plugins. Review pnpm's build-script approval requests rather than
blanket-approving everything. The built-in MCP bridge can also connect to popular
projects such as [Context7](https://github.com/upstash/context7) and
[Playwright MCP](https://github.com/microsoft/playwright-mcp); those are separate
MCP services, not evidence that a particular DSH adapter is widely deployed.
Playwright needs its browsers/dependencies inside the guest. No browser sessions,
external MCP credentials or arbitrary host skill symlink targets are imported.
## Sandbox boundary and limitations
- Trust the project's flake and launcher before `nix run`: an arbitrary flake app
is host code and can choose not to launch this sandbox at all.
- Separate KVM guest kernel; agent is root only there. Rootless QEMU is additionally
confined by Bubblewrap: restricted filesystem, private process/IPC/UTS/user
namespaces, dropped capabilities, no inherited host environment.
- No host home mount, Nix daemon/socket, SSH-agent forwarding, Docker socket,
desktop session sockets, or full host Nix-store share **inside the guest**.
QEMU itself needs read-only access to `/nix/store` to run its host binaries.
- Writable **cwd + DSH home + shared skills are intentional holes in the boundary**.
A bad agent/plugin can delete those files, steal tokens, corrupt Git metadata,
and plant malicious skills/plugins for future host runs. Back them up and use
scoped/revocable API keys. Never execute the shared plugin state on the host
without trusting changes made in the VM.
- Symlinks outside the shares do not grant those extra host directories. This
also means external Git-worktree metadata, symlinked skills, local path flake
inputs and host-built `node_modules` may not work. Use self-contained checkouts
and Linux-compatible dependencies, not wider home mounts to make errors vanish.
- Outgoing networking is **not filtered**: API access, exfiltration, host/LAN
services and cloud metadata can be reachable. This is filesystem/process
isolation, not an egress-security appliance. Use a separate filtered network
or machine for hostile code and don't give it real shared credentials.
- 9p favors a small rootless setup over maximum filesystem throughput. Remote
host edits may require a DSH refresh/restart for watchers to notice them.
- Latest code, kernels, QEMU and mounts can contain vulnerabilities. This is
defense in depth, not a claim of an audited or escape-proof sandbox.
## Checks and sources
```sh
nix flake check path:/etc/nix/agent-vm --no-build
nix build path:/etc/nix/agent-vm#checks.x86_64-linux.config \
path:/etc/nix/agent-vm#checks.x86_64-linux.shell --no-link
# Actual offline microVM boot/mount/SSH test, with a fake harness (no API calls):
nix build path:/etc/nix/agent-vm#checks.x86_64-linux.boot --no-link
```
The boot test requires KVM and nested user namespaces in the Nix build sandbox.
A real `dsh@latest` Web startup additionally requires network access and is not
silently replaced by the fake harness in normal usage.
Validated here: template/host/helper flake evaluation, ShellCheck, the real
microVM offline boot/mount/ownership/isolation test, and a separate live official
DSH Web launch with disposable config. The live check returned **401** without
a cookie, **200** after the token exchange, and **403** for an untrusted Host;
DSH stayed running without restarts. No real API credentials, model requests,
community plugins, TAP network provisioning, or host activation were involved.
Research used the **new official wiki**, plus upstream sources:
- [NixOS wiki: Flakes](https://wiki.nixos.org/wiki/Flakes) — lockfiles, pure inputs,
dev shells, apps, and Git-tracked source.
- [NixOS wiki: Virtualization](https://wiki.nixos.org/wiki/Virtualization) — points
to [microvm.nix](https://microvm-nix.github.io/microvm.nix/declaring.html).
- [microvm interfaces](https://microvm-nix.github.io/microvm.nix/interfaces.html),
[shares](https://microvm-nix.github.io/microvm.nix/shares.html), and
[routed networking](https://microvm-nix.github.io/microvm.nix/routed-network.html).
- [DSH CLI reference](https://github.com/deepseek-ai/deepseek-harness/blob/master/apps/cli/reference/README.md),
[Web guide](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/index.md),
[skill paths](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/skill/skill-filesystem/README.md),
[browser auth](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/connection/README.md),
[safety notice](https://github.com/deepseek-ai/deepseek-harness/blob/master/SAFETY.md).
- [Awesome DSH plugins](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
plus the individual repositories above; no third-party plugin was installed.
+69 -53
View File
@@ -1,76 +1,92 @@
# Desktop research and proposed baseline # Workstation design and visual audit
Researched **2026-09-05 UTC**. **This is a proposal, not an installed desktop.** Hyprland and Firefox are selected; the supporting applications below are recommendations for review. No desktop, authentication, driver or input-lock changes have been activated. Reviewed 2026-09-06 UTC on the physical laptop and against the pinned upstream sources. JaKooLit is a **functional reference, not the visual template**. The goal is an informative, polished development workstation—not a sparse desktop and not a collection of theme selectors.
The comparison uses the current official NixOS Wiki, upstream documentation/release notes, project screenshots, and the actual package/module sources locked by this repository: ## Design
- Nixpkgs: `d57af924f160a5084293c71c2043f058bd1cdb60`. - Dark **One Ring** wallpaper, charcoal surfaces, parchment text and restrained old-gold accents. Terminal ANSI colors remain distinct; Neovim's theme and configuration are unchanged.
- Home Manager: `65258d5c65a250189fde2e35f490d15e064c4c62`. - One continuous top bar: launcher/workspaces/help and active-window context on the left, date/time in the center, media, CPU/RAM/temperature, notification count, privacy and laptop status on the right. Details and actions are available without filling the bar with permanent buttons.
- Package versions below are **available in that pin**, not necessarily current upstream releases. Screenshots illustrate upstream styling, not a locally tested session or a promise that every pictured feature exists in our pin. - The bar's quick-settings panel owns audio, microphone, brightness, network/VPN, Bluetooth, power profiles and idle inhibition. Its **Actions** and **Health** buttons open the action palette and real system/update diagnostics.
- SwayNC owns notification history, actions, DND and media—not a duplicate hardware settings dashboard.
- Anyrun is the application/calculator launcher; Fuzzel provides consistent searchable action, clipboard, window and help pickers. Help is read from live described bindings and never executes a selected shortcut.
- Flat NixOS modules, Home Manager files, native Hyprland Lua, standard systemd services and small packaged helpers. No copied dotfile framework, downloaded login scripts, dynamic theme generators or additional recovery framework.
## Recommended small desktop The laptop is **Intel Lunar Lake / Arc 130V140V**, using `xe`, with a **1920×1200 internal panel now at 133⅓%** (1440×900 logical pixels; reduced from the original 150% audit at the user's request). The CPU temperature source is configured on this host, not guessed for every machine. No NVIDIA configuration, driver replacement, DNS change or storage migration is part of this work.
| Role | Recommendation | Pinned version | Rationale / trade-off | ## What the audit repaired
| --- | --- | --- | --- |
| Compositor/session | Hyprland + UWSM | 0.55.4 / 0.26.4 | Selected compositor; native NixOS session integration, with one owner for systemd startup/shutdown. |
| Browser | Firefox | 154.0.1 | Selected browser; keep its sandbox and ordinary browser configuration. Update freshness needs attention below. |
| Terminal | **Alacritty** (Rust) | 0.17.0 | Focused terminal with native Wayland support. No built-in tabs/splits; Hyprland already arranges windows. Ghostty 1.3.1 (Zig) is the richer alternative if terminal tabs, splits or image protocols are wanted. |
| Bar/quick settings | **ashell** (Rust/Iced) | 0.8.0 | Rounded, ready-made bar with workspaces, tray, audio controls and settings; avoids building a shell from widgets. Ironbar 0.19.0 (Rust/GTK4) is preferable for more bespoke panels/CSS. |
| Launcher | **Anyrun** (Rust/GTK4) | 25.12.0 | Application search with optional calculator/symbol plugins; enable only useful plugins, not indexing/network integrations by default. Fuzzel 1.14.1 (C) is the simpler alternative with a direct launch-prefix option. |
| Lock/idle | **hyprlock + hypridle** (C++) | 0.9.5 / 0.1.7 | Hyprland-native integration, PAM, session-lock protocol and lock-completion-aware sleep inhibition. Security and recovery matter more than implementation language here. |
| Notifications | **mako** (C) | 1.11.0 | Small, styleable notification daemon. SwayNotificationCenter 0.12.6 (Vala) adds a full notification drawer if that is wanted later. |
| Privilege prompts | **hyprpolkitagent** | 0.1.3 | Existing Home Manager session-bound integration. Soteria 0.3.1 is a genuine Rust alternative, but has session-registration considerations and a newer restart-related fix upstream. |
| Audio/screensharing | PipeWire + WirePlumber; Hyprland and GTK portals | — | Audio, screen capture and file-picker plumbing, not optional decoration. Use native NixOS modules. |
| Clipboard/screenshots | wl-clipboard; grim + slurp; **Satty** (Rust) for annotation | 2.3.0 / 1.5.0 / 1.5.0 / 0.20.1 | Normal copy/paste and area capture; annotation runs on demand. No persistent clipboard history by default. |
Use a small font/icon set, restrained dark colors, modest rounding and short animations. Do not add a theme framework, custom shell framework, downloaded startup scripts, weather accounts or a second bar. This does not change the Neovim theme or any Neovim source. 1. **Session ownership:** the original SDDM selection started plain Hyprland without `graphical-session.target`; the configured bar, wallpaper, locker and polkit services consequently did not start. SDDM now offers only **Hyprland (uwsm-managed)**. Plasma and the `kbot` account are removed as requested; `/home/kbot` remains intact.
2. **Physical updates:** the laptop previously excluded the updater, whose script selected the EC2 host. Host selection is now explicit. Both hosts now share dev-owned checkouts and the same updater, staging checked generations for the next boot without logging out the user or rebooting.
3. **Shared styling:** native CSS and Lua are rendered from `colors.nix`, rather than keeping several unrelated palettes.
4. **Launcher geometry:** upstream gives the row, boxes, image and labels the same `.match` class. Applying padding to that class multiplied row height. Padding now applies only to `row.match`; application descriptions are hidden, results are bounded, and calculator results remain readable. A session-owned Anyrun daemon supports calculator clipboard output.
5. **Actual visual sizing:** screenshots led to a shorter/wider action picker, shorter help, smaller notification drawer, consistent borders, and removal of the thick upstream notification-focus background. Bar information was restored after an overly sparse iteration; information density is intentional.
6. **Duplicate/broken controls:** an unconfigured SwayNC backlight widget was present but uninitialized. Hardware controls now live in ashell's working native panel, not in both panels.
7. **Clipboard/lock behavior:** history is session-local; a clipboard write cannot race past the lock wipe, and a failed wipe cannot prevent the screen from locking. OSD notifications are transient and replace only other OSD messages, not screenshot/error notifications.
8. **Toolkit and apps:** the expanded toolset is declarative. Element is the official pinned Nightly, with its matching Electron/native modules and libsecret storage. Thunar, archives, image/video viewers and MIME defaults complement Yazi.
**Optional wallpaper tool:** [awww](https://codeberg.org/LGFae/awww), Rust, pinned 0.12.1, if image switching/transitions are wanted. The old `swww` GitHub repository is archived and explicitly redirects to this renamed project; Nixpkgs warns about the old attribute. A solid background does not need another daemon. ## Useful workflows
Visual references: [ashell gallery](https://github.com/MalpenZibo/ashell#-screenshots), [Ironbar examples](https://github.com/JakeStanger/ironbar), [Anyrun](https://github.com/anyrun-org/anyrun), [Sherlock](https://github.com/Skxxtz/sherlock), [Veila](https://github.com/naurissteins/Veila). The reviewed ashell design uses compact rounded groups and popovers; Ironbar's minimal example is a flatter, denser strip. Both are legitimate aesthetic choices. The authoritative full list is **Super-H** or **Super-Shift-K**, also available through the bar's `?` button.
## Rust screen lockers: real candidates, not a blanket dismissal | Capability | Entry point / implementation |
| --- | --- |
| Apps and calculator | **Super-D**, bar launcher; Anyrun applications/Rink |
| Files | **Super-E** Thunar; **Super-Ctrl-E** Yazi in Kitty |
| Window overview | **Super-A**, **Super-Ctrl-S**; workspace-labelled picker, validated addresses |
| Window operations | Super-arrows focus; Ctrl modifier moves, Alt swaps, Shift resizes; Super-G groups; Super-Ctrl-Tab changes group tab |
| Floating/fullscreen | **Super-Space** floating; **Super-Shift-F** fullscreen; **Super-Ctrl-F** maximize |
| Workspaces | Super-1…0; Shift moves and follows, Ctrl moves silently; Super-Tab cycles |
| Scratch/drop-down terminal | **Super-U** scratchpad; **Super-Shift-U** move to it; **Super-Shift-Enter** persistent drop-down terminal |
| Actions / quick settings | **Super-Shift-E** action palette; right side of bar opens hardware controls |
| Notifications / DND | **Super-Shift-N** history; **Super-Ctrl-N** DND; bell/count in bar |
| Clipboard | **Super-Alt-V**; text/images, delete, clear and pause/resume from Actions |
| Screenshots | Print menu; Super-Print output; Super-Shift-Print region; Alt-Print window; Ctrl variants delay 5/10 seconds; Super-Shift-S annotation |
| Recording | **Super-Alt-R**; region/output, no audio by default, explicit desktop-audio option; **REC** bar control stops recording |
| Capture feedback/privacy | Screenshot copy/save notification; recording owns a notification inhibitor without overwriting DND preferences; bar privacy indicators |
| Media/OSD | MPRIS bar module and media keys; volume/mic/brightness/keyboard-backlight keys have replacing feedback |
| Night light | **Super-N**; 4200 K from 21:00, identity from 07:00; manual toggle |
| Laptop controls | Power profile, airplane mode and temporary display scaling in Actions; Super-Alt-T touchpad; low/critical battery alerts |
| Emoji / color / search | Super-Alt-E emoji; color picker in Actions; Super-S URL-encoded web search |
| Session | Ctrl-Alt-L lock; Ctrl-Alt-P power menu; destructive menu actions require confirmation |
| Health | Bar settings → Health, Actions → System/update health, or `desktop health`; real units, journal and running/booted/selected generation |
- **Veila** is an attractive, standalone Rust option using `ext-session-lock-v1`, with a packaged build in this Nixpkgs. However, the pin has **0.4.0** and upstream **0.4.4** explicitly lists password-memory handling, daemon-authorized unlock, and fail-closed unlock-handoff fixes. Do **not** select the older package merely to keep the desktop Rust-based. Its daemon and PAM setup must also be declared. [0.4.4 release notes](https://github.com/naurissteins/Veila/releases/tag/0.4.4). Clipboard data lives in `$XDG_RUNTIME_DIR/workstation`, with a 200-item limit and private permissions. It is cleared on lock and service/session exit. Sources which label sensitive clipboard content are excluded; **universal password detection is not promised**. Pause history when appropriate.
- **Cthulock** is Rust/Slint with a configurable UI and the same session-lock protocol. Latest observed release: 0.1.2, 2025-08-31. Its upstream Nix integration is an additional flake rather than a package/module already present in this pin. It is a possible experiment, not the lowest-maintenance baseline. [Project](https://github.com/FriederHannenheim/cthulock).
- **veiland** is a newer Rust locker with process-isolated GPU plugins, including upstream reports of NVIDIA suspend/hotplug testing. That is upstream evidence, not our testing. Its extensible scene/plugin architecture is more than this minimal desktop needs. [Project](https://github.com/sylflo/veiland).
- **hyprlock** remains the recommendation, not a claim of audited or bug-free software. Its newer 0.9.6 release also contains PAM, rendering and output-handling fixes; review it when refreshing the pin. [Release notes](https://github.com/hyprwm/hyprlock/releases/tag/v0.9.6).
The important property is a compositor-enforced lock, not a fullscreen password window. The [Wayland session-lock protocol](https://wayland.app/protocols/ext-session-lock-v1) requires the session to stay locked if the locker dies after acquiring the lock. It does not prevent authentication bugs in a client, guarantee successful initial locking, or protect against every compositor/GPU failure. Screenshots go to the XDG Pictures directory under `Screenshots`; recordings go to XDG Videos under `Recordings`. Region cancellation produces no empty capture. Recording has no microphone option enabled by default; the audio choice explicitly captures the output's monitor source. Recording suppression does not claim to detect every browser/portal screenshare.
**Login is separate:** ReGreet and tuigreet are Rust greeters for greetd, not screen lockers. ReGreet is the graphical option; tuigreet is the smaller terminal option. A display manager remains a separate choice, not a silent addition to the EC2 host. COSMIC's greeter is not a drop-in Hyprland locker. ## Visual and interaction checks
## Compatibility details that affect implementation Native screenshots are kept locally under `~/.cache/desktop-audit/resume/`, **not committed**. They include the real 150% bar/wallpaper, application search, calculator, help, actions, notification drawer, quick settings and Element Nightly startup. Earlier iterations are retained for comparison; filenames alone are not proof that a check passed.
1. **Use the new Hyprland configuration format.** The [current NixOS Wiki](https://wiki.nixos.org/wiki/Hyprland) explicitly flags its older examples as outdated: Hyprland 0.55 introduced Lua configuration. Our locked Home Manager defaults to `configType = "lua"` for `home.stateVersion = "26.05"`. Write a small native `hyprland.lua`, using the [0.55.4 example/API](https://github.com/hyprwm/Hyprland/blob/v0.55.4/example/hyprland.lua), rather than copying old `bind = ...` examples into Lua settings. Do not change stateVersion to work around this. The native audit caught real issues that configuration evaluation did not: nested launcher padding, overly tall pickers, the notification focus slab and an uninitialized duplicate brightness control. The quick-settings screenshot confirms that the actual panel contains the audio/mic/brightness sliders, network/Bluetooth, idle inhibition, power profile, Actions and Health controls.
2. **Only one session manager.** NixOS recommends `programs.hyprland.withUWSM = true`; disable Home Manager's separate Hyprland systemd integration. Upstream also warns UWSM adds its own quirks. Bind bar/agent services to the graphical session and launch applications through the session manager. [Session documentation](https://wiki.hypr.land/Useful-Utilities/Systemd-start/).
3. **ashell needs small but important overrides.** Its pinned default logout command is `loginctl kill-user $(whoami)`, which is broader than logging out of the desktop and could terminate other sessions. Use `uwsm stop`. The pinned Home Manager service has startup ordering but no `PartOf`; explicitly tie its lifetime to `graphical-session.target`. Version 0.8 does not advertise the notification manager shown in the current 0.10 README, so do not assume it replaces mako. [Pinned settings](https://github.com/MalpenZibo/ashell/blob/0.8.0/website/versioned_docs/version-0.8.0/configuration/modules/settings.md).
4. **Rust does not eliminate NVIDIA rendering issues.** ashell 0.8 documents startup freezes with the Vulkan backend and an application-scoped `WGPU_BACKEND=gl` workaround. Anyrun documents a driver-dependent GTK close/hang problem and `GSK_RENDERER=ngl`. Test the actual target first; apply a needed workaround only to that application on that target. Do not export these globally to integrated-graphics machines. [ashell troubleshooting](https://github.com/MalpenZibo/ashell/blob/0.8.0/website/versioned_docs/version-0.8.0/configuration/troubleshooting.md), [Anyrun warning](https://github.com/anyrun-org/anyrun#anyrun).
5. **Anyrun's provider is already packaged correctly.** Since 25.12 it needs `anyrun-provider`; our Nixpkgs wrapper supplies it and the plugin search path. Do not add a manual Cargo install or another flake. The applications plugin searches desktop entries, and its version-specific preprocessing hook must be used when arranging UWSM application launching.
6. **Do not install the wrong Sherlock.** The Rust launcher is `pkgs.sherlock-launcher` (0.1.14-3), and `programs.sherlock` in Home Manager correctly selects it. `pkgs.sherlock` (0.16.0) is an unrelated Python social-account search tool. Sherlock has a polished widget-style UI, but Anyrun/Fuzzel are a more focused starting point.
7. **Portals and idle services already have system integration.** The pinned NixOS Hyprland module adds both Hyprland and GTK portals; the latter supplies a file picker. Do not install competing portal stacks or add sleep/kill/restart hacks. The NixOS hyprlock module creates its PAM service and enables the system-provided hypridle user unit; Home Manager can supply idle configuration with `package = null` instead of creating another unit. A valid lock configuration is required: installing a locker is not enough.
8. **Wait for locking, not an arbitrary delay.** The pinned hypridle supports `general.inhibit_sleep = 3`, waiting for Hyprland's lock notification before releasing its sleep inhibitor, subject to logind's inhibitor timeout. It does not support the newer conditional-timeout options in current upstream documentation. Configure idle lock/display-off without silently adding automatic suspend to a development host. [hypridle documentation](https://wiki.hypr.land/Hypr-Ecosystem/hypridle/).
## RTX 4090 versus integrated graphics `desktop-test.py` exercises the disposable graphical VM using the same SDDM/UWSM workstation module as the laptop: session ownership, fonts, real PipeWire nodes, launcher geometry at 100%/150%, clipboard picker, recording container and no-audio default, notification ownership/inhibition, described help, and real wrong/correct-password PAM locking. Test credentials never reach the host. `desktop-actions-test.py` covers cancellation, untrusted input, byte-preserving clipboard behavior, lock failure handling, display timeout restoration and recording-inhibitor cleanup.
Keep the shared desktop separate from a deliberately imported `nvidia.nix`. Do not put NVIDIA options/environment variables into every machine's common module, invent PRIME bus IDs, or reuse EC2's boot/storage configuration on a workstation. **Completed validation (2026-09-06):** flake evaluation, Nix formatting, generated Hyprland configuration, physical/AWS/shared-policy assertions, 125 CLI executable smoke checks, 14 desktop-action tests, 23 updater regressions, 22 manual-switch regressions (including lock retention through sudo), both host system builds, and the full SDDM/UWSM graphical/PAM/clipboard/recording VM check all passed on the refreshed inputs. Native clipboard, recording and rootless Podman checks passed too. The 150% six-result launcher and help screenshots were inspected visually; the transparent launcher click-catcher's IPC dimensions are not mistaken for the visible palette bounds.
- **Intel/AMD-only targets:** start with the standard kernel/Mesa graphics stack. Device-generation-specific video decoding or firmware adjustments require actual hardware identification. **Account/hardware boundaries:**
- **RTX 4090 target:** NVIDIA's open kernel modules support Ada/RTX 4090. Use the NixOS driver module with `hardware.nvidia.open = true`, modesetting and power-management support. The userspace driver remains proprietary and needs a scoped unfree allowance. `services.xserver.videoDrivers = [ "nvidia" ]` selects the driver even for Wayland; this does not require enabling the X server.
- **Pinned driver:** stable and production both resolve to **595.71.05**. With open modules, the pinned NixOS module selects the new **kernel suspend notifier** path. Enabling power management sets the appropriate module parameters without the legacy `nvidia-suspend`/`nvidia-resume` services. Do not unconditionally paste older service recipes on top.
- **Suspend storage:** NVIDIA recommends enough temporary backing storage for total VRAM plus about 5%; a 24-GiB 4090 needs roughly 25.2 GiB in the conservative worst case. Check the target filesystem and `/tmp` policy before promising reliable suspend. Do not force early KMS or hibernation configuration without checking the machine.
- **Firefox video decoding is a separate question.** The NVIDIA VA-API bridge's documented setup disables Firefox's RDD sandbox. Do not silently adopt that security trade-off for hardware video decoding. Native Wayland rendering and hardware video decoding are not the same feature.
Sources: [official NixOS NVIDIA page](https://wiki.nixos.org/wiki/NVIDIA), [Hyprland NVIDIA guidance](https://wiki.hypr.land/Nvidia/), [NVIDIA supported GPUs](https://github.com/NVIDIA/open-gpu-kernel-modules#compatible-gpus), [595.71.05 power-management documentation](https://download.nvidia.com/XFree86/Linux-x86_64/595.71.05/README/powermanagement.html), [NVIDIA VA-API bridge](https://github.com/elFarto/nvidia-vaapi-driver#firefox). - Element Nightly launches with libsecret enforced. The live audit reached the encryption warning because no unlocked/configured Secret Service vault was available. The insecure fallback was **not selected**. Open/configure KeePassXC's Secret Service group before signing in; account/vault setup remains the user's responsibility.
- Bluetooth pairing, real suspend/resume, external-monitor hotplug and interactive browser portal sharing require their respective hardware/account interaction. Package and VM tests are not substitutes.
- Temporary display scaling preserves output mode, position and rotation and reverts on timeout/cancel. Specific dock/mirror profiles are not invented without attached displays.
## Before implementation/activation ## Wallpaper provenance
- **Review an input refresh.** Mozilla's [release metadata](https://product-details.mozilla.org/1.0/firefox_versions.json) reports **155.0.1**, versus the pin's **154.0.1**. Reproducible does not mean current. Review the stable Nixpkgs update, browser/locker fixes, evaluation and build separately; no input was updated during this research. Do not move to development snapshots merely for cosmetic features. [Wallhaven 01e5v4](https://wallhaven.cc/w/01e5v4), a dark One Ring inscription, **1920×1200**. Wallhaven lists uploader **ulairi88**, not a verified original artist, and provides no redistribution license. No artist attribution or open license is invented.
- **Establish local authentication.** `dev` currently has a locked Unix password. SSH authorization and passwordless sudo do not give a graphical greeter, locker or polkit agent a usable password. Choose and declare an appropriate credential/secret mechanism before enabling a usable local login/lock workflow. No invented password, plaintext secret in the Nix store, empty-password workaround or silent autologin.
- **Keep the implementation flat:** shared desktop integration, native `hyprland.lua`, and opt-in NVIDIA settings, with each real machine retaining its own hardware/boot module. No deep host/profile framework and no Neovim changes.
## What was validated `wallpaper.nix` fetches immutable bytes:
Non-activating evaluations of candidate module compositions passed NixOS and Home Manager assertions for both Mesa-default and opt-in NVIDIA settings. They verified Lua defaults, UWSM ownership, both automatically supplied portals, PAM/idle integration without duplicate idle units, bar lifecycle/logout settings, unchanged Neovim package/source, and the NVIDIA 595 kernel-notifier parameters. These were evaluation fixtures using the existing host as a base, **not bootable physical-host definitions or desktop builds**. ```text
https://w.wallhaven.cc/full/01/wallhaven-01e5v4.jpg
sha256-3jkKzJ0q4MTlHygwUs3SuSiUIjUjkiTqSaM+q8EL/oc=
```
The EC2 host exposes only a simple framebuffer DRM device, with no render node. No actual Hyprland session, hardware acceleration, screen sharing, password unlock, hotplug or suspend/resume was tested. Those checks must be run on the target machines, including wrong/correct-password behavior, locker-crash behavior, monitor changes while locked, and repeated suspend/resume with recovery access retained. No wallpaper service is contacted at login. `wallpaper.svg` remains the original locally authored alternative. Existing generations retain the fetched image if the source later disappears.
## Reference, not imitation
Reviewed [JaKooLit/Hyprland-Dots](https://github.com/JaKooLit/Hyprland-Dots), its [keybindings](https://github.com/JaKooLit/Hyprland-Dots/blob/main/config/hypr/configs/Keybinds.conf), [scripts](https://github.com/JaKooLit/Hyprland-Dots/tree/main/config/hypr/scripts), Waybar modules and SwayNC configuration, plus the announced successor [LinuxBeginnings/Hyprland-Dots](https://github.com/LinuxBeginnings/Hyprland-Dots). Familiar general shortcuts are retained without copying the installers, mutable `.conf` edits, presentation style or duplicate ownership.
**Excluded deliberately:** animation/theme/bar-layout selectors, online radio/weather/location services, live wallpaper effects, opacity/layout preset collections, speculative GPU/game-mode tuning and broad process-killing refresh scripts. Alt-Tab/window search supplies a useful overview without a second desktop shell for thumbnails.
Implementation references: [Hyprland 0.55 Lua example](https://github.com/hyprwm/Hyprland/blob/v0.55.4/example/hyprland.lua), installed Lua API stubs, [ashell](https://github.com/MalpenZibo/ashell), [Anyrun](https://github.com/anyrun-org/anyrun), [Fuzzel](https://codeberg.org/dnkl/fuzzel), [SwayNC](https://github.com/ErikReider/SwayNotificationCenter), [cliphist](https://github.com/sentriz/cliphist), [UWSM](https://github.com/Vladimir-csp/uwsm), and the locked package/module sources.
+190 -63
View File
@@ -1,102 +1,229 @@
# Development host # Development workstation
Flat, explicit NixOS modules, with locked inputs. Required machine setup belongs in these configs—not a list of manual installs or dotfile copies. Flat, explicit NixOS modules with locked inputs. Required setup belongs here—not in manual package installs, copied dotfiles or an extra framework.
**Select the host, not the login name:** `nixosConfigurations.nixos` is this physical UEFI/NVMe laptop; `nixosConfigurations.dev` is EC2 only. Both use the `dev` account. Never activate the EC2 closure on the laptop.
## Layout
| File | Owns | | File | Owns |
| --- | --- | | --- | --- |
| `flake.nix`, `flake.lock` | Host entry point and exact Nixpkgs/Home Manager/dotfile revisions and content hashes | | `flake.nix`, `flake.lock` | Host entry points, exact Nixpkgs/Home Manager/tool/dotfile revisions and hashes |
| `configuration.nix` | EC2 base, platform, Nix features, compatibility version, imports | | `common.nix` | Shared environment, locale/timezone, Nix features, update policy and compatibility version |
| `users.nix` | `dev`, SSH/sudo, Home Manager integration, workspace/repo ownership | | `physical.nix`, `hardware-configuration.nix` | Laptop boot/storage, panel scale, CPU sensor and checkout/target identity |
| `dev-authorized-keys` | Public SSH keys for `dev`—never private keys | | `configuration.nix` | AWS boot/storage/network/recovery integration and checkout/target identity |
| `tools.nix`, `colors.nix` | Zsh, Kitty, Pi, Starship, fzf, Yazi, btop, Git policy and common CLI tools; shared readable palette | | `users.nix` | `dev`, SSH authorization, sudo, Home Manager and workspace ownership |
| `desktop.nix`, `hyprland.lua`, `anyrun.css`, `wallpaper.svg` | Hyprland/UWSM, bar, launcher, original wallpaper, notifications, lock/idle, PipeWire and desktop styling | | `tools.nix`, `network.nix` | Development toolkit, terminal/shell, rootless Podman, VPN/proxy clients, local Tor service and network diagnostics |
| `apps.nix` | Firefox ESR, KeePassXC, Thunderbird, Steam, Element, Slack and Zathura | | `colors.nix`, `wallpaper.nix`, `wallpaper.svg` | Shared One Ring palette, hash-pinned wallpaper and original fallback artwork |
| `workstation.nix`, `nvidia.nix` | Separately selected physical-workstation/greeter and NVIDIA integration; not enabled on EC2 | | `desktop.nix`, `hyprland.lua`, `anyrun.css`, `swaync.css`, `desktop-help.py`, `desktop-actions.py` | Session/bar, launchers, described help, capture, clipboard, notifications, lock/idle and styling |
| `updates.nix`, `update-system.sh` | Daily stable-input updates in an isolated Git worktree; validated commits, no forced reboot or GC | | `apps.nix`, `element-nightly.nix` | Firefox ESR, Tor Browser, KeePassXC, Thunderbird, Steam, pinned Element Nightly, Slack, file/media viewers and MIME defaults |
| `desktop-test.nix`, `desktop-test.py`, `audit-desktop.sh`, `update-test.py` | Disposable graphical audit and updater failure/concurrency tests | | `neovim.nix`, `neovim-test.lua` | Unmodified upstream editor deployment and opt-in native runtime audit |
| `network.nix` | systemd-resolved and network/WireGuard diagnostics; leaves interface management with the host | | `updates.nix`, `update-system.sh`, `update-test.py` | Shared dev-owned checkouts, daily boot-staged updates and failure/concurrency regression tests |
| `neovim.nix`, `neovim-test.lua` | Editor, unchanged upstream dotfile deployment and opt-in native runtime audit | | `switch-system.sh`, `switch-test.py` | Same installed manual apply/preview command on both hosts, with host identity supplied by Nix |
| [DESKTOP.md](DESKTOP.md) | Historical component research; executable configuration is in the files above | | `physical-test.nix`, `tools-test.nix` | Physical/AWS safety, shared-policy assertions and bounded offline tool/help smoke tests |
| `desktop-test.nix`, `desktop-test.py`, `audit-desktop.sh` | Disposable graphical/PAM/audio/scaling audit |
| `workstation.nix`, `nvidia.nix` | Shared local hardware/SDDM integration for laptop and VM; separate opt-in NVIDIA support |
| [DESKTOP.md](DESKTOP.md) | Live audit, wallpaper provenance, JaKooLit comparison and explicit feature-completion plan |
| `agent-vm/`, `templates/agent/`, [AGENT-VM.md](AGENT-VM.md) | Reusable rootless DSH microVM, shared project toolchain, live RW cwd/config/credentials/skills and access/networking guide |
## Account ## Account and session
- Daily user: `dev`, UID 1001, home `/home/dev`, workspace `~/projects`. - Daily account: **dev**, UID 1001, `/home/dev`, workspace `~/projects`, Zsh.
- Passwordless sudo is scoped to `dev`; existing SSM-user administration and root SSH recovery remain intact. - `dev` has **full passwordless sudo**, not a rebuild-only privilege. Nix daemon trusted-user access is not separately granted.
- `dev` is authorized by the public key in this repo. No private key or password is embedded. - The physical host uses the locally established `dev` password; mutable users preserve it. No plaintext password, private key or password hash is embedded in this repo/store.
- `/etc/nixos` is writable by `dev` through a native tmpfiles ownership rule, which does not follow store symlinks. - At the user's request, **Plasma and the previously managed `kbot` account are removed**. This configuration does not delete `/home/kbot`; retained files and old generations are not erased. Home Manager only manages `dev`.
- Nix daemon access stays untrusted for ordinary use. Sudo is a separate, explicit administrative capability. - SDDM exposes **Hyprland (uwsm-managed)** only. Plain Hyprland is deliberately hidden because it does not start the session-bound bar, wallpaper, idle and polkit services. UWSM is the single session owner.
- The declared daily shell is Zsh, with completion, suggestions, highlighting, Starship, fzf (`Ctrl-R`, `Ctrl-T`, `Alt-C`), and zoxide (`z`, `zi`). Root/SSM shells are not changed. - Both checkouts are **dev-owned**, including Git metadata: `/etc/nix` on the laptop and `/etc/nixos` on EC2. Existing paths are retained to avoid moving deployed repositories. NixOS maintains ownership without following symlinks into the Nix store; the laptop's original `/etc/nixos` files stay untouched.
- Kitty uses an opaque dark background, 13pt JetBrains Mono and generous padding. `Ctrl-Shift-+` / `Ctrl-Shift--` zoom its font; `Ctrl-Shift-Backspace` resets it. `y` opens Yazi with shell-directory integration. - On EC2, AWS boot/storage/networking and SSM/SSH recovery remain intact; no physical greeter, Wi-Fi/Bluetooth or battery services are added. User settings, locale/timezone, packages and update policy come from the same modules. The graphical VM now uses the same SDDM/UWSM integration as the laptop.
- Desktop keys: `Super-Enter` Kitty, `Super-Space` launcher, `Super-E` Yazi, `Super-B` Firefox, `Super-P` KeePassXC, `Super-Escape` lock, `Super-Q` close, `Super-1…0` workspaces, `Print` screenshot/annotation. Launch the **Hyprland (uwsm-managed)** session. - The SSH key in `dev-authorized-keys` is public. Account/cloud/browser/VPN credentials and vaults remain user-controlled.
- KeePassXC preferences are seeded once and remain writable. Open/create your own vault and pair its browser extension; no vault, account or VPN credentials are embedded. The physical-workstation module requires a securely provisioned `hashedPasswordFile`; EC2 gets no greeter or autologin.
- Element uses libsecret for encrypted local storage through KeePassXC. Before using it, create/open your own vault and select a dedicated application-token group in **Database Settings → Secret Service Integration**. Keep that vault unlocked when requested. Do not select Element's weaker/no-encryption fallback. Vault setup and application authentication are intentionally user-controlled.
- `wg` and `wg-quick` are installed without any tunnels, peers, keys or added firewall ports. `resolvectl` is backed by resolved; DHCP remains under the existing host network manager.
Enter from an administrator session with `sudo -iu dev`. ### Everyday controls
Git author identity and remote destination are deliberately unset. `user.useConfigOnly` is now a Home Manager setting in `tools.nix`, not a required manual `git config` step. | Key / command | Action |
| --- | --- |
| `Super-H`, bar `?` | Search described shortcuts from the live compositor; selecting a row does not execute it |
| `Super-Enter` | Kitty |
| `Super-D` | Anyrun applications/calculator |
| `Super-E`, `Super-Ctrl-E`, `y` | Thunar / Yazi; `y` includes shell-directory integration |
| `Super-B`, `Super-Alt-P` | Firefox / KeePassXC |
| `Ctrl-Alt-L`, `Super-Escape` | Lock |
| `Super-Q`, `Super-Shift-F`, `Super-Space` | Close / fullscreen / floating |
| `Super-1…0`, `Super-Shift-1…0` | Focus workspace / move and follow; Ctrl moves silently |
| `Super-U`, `Super-Shift-U` | Scratch workspace / move window to it |
| `Super-Shift-Enter` | Drop-down terminal |
| `Super-Shift-E`, `Ctrl-Alt-P` | Actions / confirmed session-power menu |
| `Super-Shift-N`, `Super-Ctrl-N` | Notification history / DND |
| `Super-Alt-V` | Clipboard history |
| `Print`, `Super-Shift-S` | Screenshot menu / region annotation |
| `Super-Alt-R` | Start/stop recording; red REC indicator when active |
| `Super-N`, `Super-Alt-E` | Night light / emoji |
| `Super-A`, `Alt-Tab` | Window search / cycle windows |
| `Ctrl-R`, `Ctrl-T`, `Alt-C` in Zsh | fzf history / files / directories |
| `z`, `zi` | zoxide directory navigation |
| `direnv allow` | Explicitly approve a project's `.envrc`; no automatic trust whitelist |
| `tmux`, `zellij`, `lazygit` | Persistent terminal workspaces and Git UI; not auto-started or forced |
Kitty uses an opaque charcoal background, 12pt JetBrains Mono and compact padding. Font zoom remains `Ctrl-Shift-+` / `Ctrl-Shift--`; reset with `Ctrl-Shift-Backspace`. Semantic ANSI colors remain distinguishable from the gold UI accent. Neovim's own theme is unchanged.
The **informative top bar** includes workspaces/window context, clock, CPU/RAM/host temperature, media, notification count, privacy and laptop status. Click its right-hand status area for audio, microphone, brightness, network/Bluetooth, idle inhibition and power-profile controls. **Actions** and **Health** open the searchable action palette and real system/update diagnostics. Notification history is a separate compact drawer, not another settings dashboard.
See [DESKTOP.md](DESKTOP.md) for the screenshot-led audit, functional coverage, wallpaper provenance and remaining hardware/account checks. JaKooLit informed the general shortcuts and workflow coverage—not the visual design.
## Tool coverage
`tools.nix` is organized by purpose, not a single unexplained package dump:
- **Native/debug:** GCC, Make, pkg-config, CMake, Ninja, Meson, ccache, clang tools, GDB, LLDB, Valgrind, Heaptrack, rr, ELF utilities and bpftrace.
- **Rust:** rustc/Cargo/rustfmt/Clippy/rust-analyzer, nextest, cargo-audit/deny/expand/edit.
- **Go:** Go, gopls, Delve, golangci-lint.
- **Python:** Python, uv, Ruff, Pyright; existing Lua/LuaRocks prerequisites stay for Neovim.
- **JS/TS:** Node, pnpm, TypeScript, Biome, Bun, Deno.
- **Additional ecosystems:** OpenJDK 25, Maven, Gradle 9, Kotlin, .NET SDK 10, Ruby/Bundler, PHP/Composer, Zig/ZLS and Elixir/Erlang.
- **Project/CI:** just, watchexec, hyperfine, tokei, ShellCheck, shfmt, yamllint, actionlint, pre-commit, dprint, StyLua, Taplo, Marksman, markdownlint, SQLFluff, Hadolint, ast-grep and ripgrep-all.
- **Source control:** Git/LFS, gh, glab, LazyGit, delta, difftastic, Jujutsu, git-absorb/filter-repo. No guessed Git identity or account authentication.
- **Nix:** direnv/nix-direnv, nix-output-monitor, nvd, nix-tree/diff, nixd, statix, deadnix, nixpkgs-review and the repo formatter.
- **Containers/cloud:** rootless Podman, Compose, Buildah, Skopeo, Dive, AWS CLI, kubectl, Helm, k9s, kubectx, Stern, Kustomize, OpenTofu, Ansible. No Docker daemon, docker-group privilege, deployed infrastructure or automatic image downloads.
- **Data/API:** SQLite, DuckDB, pgcli/litecli, Redis tools, Miller, csvlens, jq/yq/jless, xh, grpcurl, websocat, Protobuf/Buf, Hurl and oha. Installing these does not start database servers or load-test an endpoint.
- **Security/backup:** age, sops, GnuPG, Gitleaks, Trivy, Cosign, Syft, Grype, step, mkcert, restic, rclone, rsync, Mosh and SSHFS. No keys, trusted CA, backup destination, schedules or scan targets are created.
- **Diagnostics:** btop, procs, lnav, sysstat, iotop, dust/duf/ncdu, strace/lsof, NVMe/SMART/USB/PCI/sensor tools.
- **Media/documents:** FFmpeg, ImageMagick, ExifTool, MediaInfo, Poppler utilities, Pandoc, yt-dlp, Chafa, Asciinema, VHS and archive/compression tools.
- **Networking (`network.nix`):** WireGuard/OpenVPN, NetworkManager VPN integration on the laptop, mtr, iperf3, nmap (including ncat/nping), tcpdump/tshark, doggo/dig, ldns/drill, fping, iftop/bandwhich, traceroute, whois, ethtool, netcat, socat, torsocks and proxychains-ng. Tor runs as a local client service (below). No tunnels, peers, credentials, extra capture privileges or opened firewall ports.
These are system-owned executables from Nix, not unmanaged `npm -g`, `pip install --user` or `cargo install` bootstraps. Project dependencies may still be downloaded by their ordinary package managers. `nix develop` / `.envrc` remain appropriate for project-specific versions; this is not a promise that every language project uses the same global toolchain.
### Per-project DeepSeek Harness
[AGENT-VM.md](AGENT-VM.md) documents the project template and `nix run .#agent`.
The official `dsh@latest` runs as root in a rootless microVM, with the current
project and your standard DSH home/shared skills mounted **read-write**. Its
host Web listener defaults to localhost; RAM, vCPUs, IPs and optional TAP
networking are configured through Nix modules. No host service is activated.
### Local Tor client
On both hosts, `network.nix` installs Tor from the **system pin** and enables `tor.service` at boot. It runs as the dedicated `tor` user with the NixOS module's sandbox and private persistent state in `/var/lib/tor`. Systemd restarts an exited daemon after five seconds without a retry limit; an explicit `systemctl stop tor` still stops it normally. A running process does not guarantee network connectivity—check for `Bootstrapped 100%` in the journal.
The SOCKS listener is **`127.0.0.1:9050` only**, with destination and SOCKS-auth isolation. There is no relay/exit, onion service, control listener, transparent proxy, system DNS change or global proxy environment. Applications must opt in; installing Tor does **not** anonymize the whole machine. Prefer native SOCKS5 support with proxy-side hostname resolution (`socks5h`), for example:
```sh
systemctl status tor.service
journalctl -u tor.service -b --no-pager
curl --fail --show-error --max-time 60 --proxy socks5h://127.0.0.1:9050 \
https://check.torproject.org/api/ip
# For compatible dynamically linked applications, explicitly wrap one command:
torsocks curl --fail --show-error --max-time 60 https://check.torproject.org/api/ip
```
`torsocks` and `proxychains4` are opt-in wrappers, not sandboxes: static binaries and applications that bypass their hooks are not reliably covered. Tor carries TCP, not arbitrary UDP/ICMP; do not assume tools such as fping, raw-packet nmap or traceroute run through it. Keep ordinary DNS lookups out of workflows that require Tor-side resolution. **Tor Browser is installed by `apps.nix`**: launch `tor-browser` or select **Tor Browser** in the application launcher after applying the configuration. It retains its separate profile and upstream privacy defaults; Firefox remains the default browser. Tor Browser is not simply a generic browser pointed at the system SOCKS port, and it is not auto-started.
## Build and apply ## Build and apply
As `dev`, from `/etc/nixos`: ### One-command sync on either host
Save your work, then run the installed command from any directory:
```sh ```sh
nixfmt --check ./*.nix switch-system # explicitly apply now and make it the boot default
nix flake check --no-build --no-update-lock-file switch-system dry-activate # build and preview changes without applying
nix build .#checks.x86_64-linux.updates .#checks.x86_64-linux.desktop-config --no-link switch-system boot # stage for the next boot instead
nix build .#nixosConfigurations.dev.config.system.build.toplevel --no-update-lock-file
``` ```
New source files must be added to Git for flakes to see them. Keep `flake.lock` in version control. A build does **not** activate changes. Nix supplies the correct checkout/target to the **same script** on each host. Builds run as `dev`; only activation uses sudo. It applies the exact built output, including `dev`'s Home Manager configuration, shares the automatic updater's lock, stops on build failure, keeps recovery generations and never reboots. It does **not** pull Git or update `flake.lock`: “latest” means the current checkout. Add new source files to Git for flakes to include them. Open a new terminal afterward for shell environment changes; some desktop changes require a fresh login. `--help` lists the modes, including temporary `test` activation.
Review and activate exactly the built closure: Direct `/etc/nix/switch-system.sh` execution still defaults to the laptop. When running the source script on EC2 before the packaged command is installed, explicitly set `NIXOS_CONFIG_REPO=/etc/nixos NIXOS_UPDATE_HOST=dev` and choose `boot`. Automatic updates, unlike an explicit manual switch, **always stage for the next boot on both hosts**.
### Detailed validation and activation
On the **physical laptop**, from an administrator shell:
```sh ```sh
built=$(readlink -f result) cd /etc/nix
nixfmt --check ./*.nix
nix flake check --no-build --no-update-lock-file
nix build .#checks.x86_64-linux.updates \
.#checks.x86_64-linux.desktop-config \
.#checks.x86_64-linux.physical-config \
.#checks.x86_64-linux.tools .#checks.x86_64-linux.desktop-actions \
.#checks.x86_64-linux.switch-system --no-update-lock-file --no-link
nix build .#nixosConfigurations.nixos.config.system.build.toplevel \
--no-update-lock-file --out-link result-nixos
built=$(readlink -f result-nixos)
sudo "$built/sw/bin/nixos-rebuild" dry-activate --no-reexec --store-path "$built" sudo "$built/sw/bin/nixos-rebuild" dry-activate --no-reexec --store-path "$built"
```
New source files must be added to Git for flakes to include them. A build or dry activation does not activate the result. Review the dry activation; removing Plasma and `kbot` is intentional, removing `dev`, NetworkManager or the installed mounts is not.
**Save work before activating.** A desktop/display-manager change can end a graphical session. Use `Ctrl-Alt-F3`, log in as `dev`, and retain that console:
```sh
built=$(readlink -f /etc/nix/result-nixos)
sudo "$built/sw/bin/nixos-rebuild" test --no-reexec --store-path "$built"
# After testing login/session, persist exactly that closure:
sudo "$built/sw/bin/nixos-rebuild" switch --no-reexec --store-path "$built" sudo "$built/sw/bin/nixos-rebuild" switch --no-reexec --store-path "$built"
``` ```
Use the rebuild tool from that closure with `--no-reexec`: otherwise the bootstrap tool can try to rebuild itself through the old channel even when `--store-path` is supplied. Capturing `built` also keeps review and activation on the same immutable result. `test` is a real activation, not a dry run. For a non-disruptive deployment that takes effect on next boot, use `boot` instead of `test`/`switch`. Both hosts' daily updaters use that policy.
For initial deployment on a compatible NixOS EC2 base where flakes are not enabled yet, check out this repo and run the build as an existing administrator with the temporary CLI flag: On **EC2 only**, use `/etc/nixos` and `.#nixosConfigurations.dev.config.system.build.toplevel`. Run the same evaluation/checks and activate the exact output with its own `nixos-rebuild --no-reexec --store-path`. Never select a target merely because it matches your username.
### Recovery
Keep existing generations. Inspect what is actually selected and booted:
```sh ```sh
nix --extra-experimental-features 'nix-command flakes' build \ readlink -f /run/current-system /run/booted-system /nix/var/nix/profiles/system
.#nixosConfigurations.dev.config.system.build.toplevel --no-update-lock-file sudo nix-env --profile /nix/var/nix/profiles/system --list-generations
``` ```
Then review/apply the resulting closure as above. The configuration creates `dev`, sets permissions and deploys its files. **No separate Neovim clone, copy, useradd, chown or global Git-config recipe is required.** To restore the booted closure temporarily from the retained console:
This host build does not use a mutable channel. NixOS's native flake integration pins the `nixpkgs` registry entry and login-shell `<nixpkgs>` lookup to the system input; the global `nix-path` setting keeps the same pin when `NIX_PATH` is unset. Old root channel profiles are retained for recovery, not used as build inputs. Dev-environment templates/composition remain deferred; there is no flake framework here. ```sh
previous=$(readlink -f /run/booted-system)
sudo "$previous/sw/bin/nixos-rebuild" test --no-reexec --store-path "$previous"
```
## Neovim: import, do not rewrite If boot fails, choose a known-good systemd-boot generation (hold Space at startup). Do not blindly select “generation 1”: generation numbers are machine/history-specific. System rollback does not restore mutable user data, Git changes, application databases or backups.
The input is [the existing neovim-dots repository](https://git.cyber.ayyalasomayajula.net/marsultor/neovim-dots), initially pinned to `380eb86778a7c53a0f1c18e84f14037456155347`. ## Automatic freshness, precisely
Home Manager deploys its files under `~/.config/nvim`, with the **Lua, AstroNvim, Lazy, Mason, plugins and keymaps unchanged**. `programs.neovim.configure` stays empty so Neovim discovers `init.lua` normally. No Nixvim or custom Lua loader. - **Every day**, `nixos-update.timer` runs with up to one hour of jitter and catches missed runs. It advances `nixpkgs`, `home-manager`, and `nixpkgs-latest`; the Neovim source remains fixed.
- The system/desktop now follow rolling **`nixos-unstable`** (the latest tested channel) and Home Manager **`master`**, rather than a fixed release branch. Most standalone CLI tools use **Nixpkgs master** for faster updates. They are imported separately, **not** overlaid onto the desktop's GCC/Python/libraries. Flake locks record each resolved snapshot; input updates, not deleting locks, advance Nix-owned packages.
- **Same policy on both hosts:** run as `dev`, build/check/record and **stage for next boot**. There is no automatic live switch, logout or reboot. Only checkout path and explicit flake target differ: laptop `/etc/nix#nixos`, EC2 `/etc/nixos#dev`. Installed versions change when that generation is booted, or explicitly switched by the user.
- Both skip dirty/detached repositories, serialize runs with manual switching, use an isolated worktree, check for edits again after dry activation and commit only the tested lock. On staging failure, the updater restores the previously selected **boot generation**, including a generation that was already staged but not running. A failed rollback is reported as failure; recovery generations are retained.
- **“Current” means newest successfully checked/built versions packaged in those branch heads**, not a guarantee of every upstream release immediately. Master can contain breakage and uncached builds; failure retains the previous working generation. Upstream Pi, Mason/plugin downloads, browser add-ons, firmware and project dependencies are separate update boundaries. No runtime self-updater is bolted on to override Nix-owned executables.
- The updater does not fetch/merge repository code from origin. Configuration code is reviewed separately. A dirty working tree intentionally prevents automatic input changes until work is committed/stashed.
- `tools.nix` supplies GCC/Make, pkg-config, Python, Node, Lua 5.1/LuaRocks and `nix-ld` for the existing plugin builds and Mason's upstream Linux executables. These are runtime prerequisites, not a replacement plugin manager or project-template framework. Inspect or trigger:
- Configuration files are linked from the pinned source and managed by Nix. Change the upstream repo and its input revision rather than editing generated links.
- Lazy's `lazy-lock.json` must remain writable. The config seeds a copy at `~/.local/state/nvim/locks/<dotfile-revision>.json` and links to it. A new dotfile revision gets its own original lock; repeated activation preserves runtime changes to an existing lock.
- A declared migration preserves the earlier manual checkout intact at `~/projects/neovim-dots-before-nix`. It refuses to overwrite an existing backup. On a clean home this migration does nothing.
**Reproducibility boundary:** Nix locks the host inputs and dotfile source, and reproduces their deployment. The existing Lua still bootstraps Lazy and manages plugin/Mason downloads at runtime. The supplied Lazy lock records plugin revisions, but it is writable and Mason's tool versions are not pinned by this Nix config. This is not a claim that every runtime download/cache is a Nix-reproducible build. Changing that policy requires a separate agreement; do not silently replace the user's plugin managers. ```sh
systemctl list-timers nixos-update.timer
journalctl -u nixos-update
sudo systemctl start nixos-update.service
sudo less /var/cache/nixos-update/last-success
```
## Validation and commits Manual refresh: `nix flake update nixpkgs home-manager nixpkgs-latest`, review `flake.lock`, then check/build. `system.stateVersion` / `home.stateVersion` remain `26.05`: they govern compatibility, not package freshness. Channels are disabled; the Nixpkgs registry and `<nixpkgs>` lookup follow the locked system input.
The initial deployment was tested with an empty disposable home: all upstream files were reproduced byte-for-byte, the Lazy lock remained writable across repeated activation, and the migration preserved local data and refused to overwrite an existing backup. The real plugin bootstrap is a separate runtime test, not covered by these file-deployment checks. As `dev`, run `nvim --headless ~/.config/nvim/init.lua -c 'luafile /etc/nixos/neovim-test.lua'` for a bounded runtime check of Lazy, the configured Mason tools (including executable startup), and nine parsers. This uses the existing writable plugin/Mason cache and may download dependencies; it does not edit the managed Lua or save buffers. ## Editor and application data
Run the graphical test separately with `./audit-desktop.sh`. An optional accessible render node, for example `./audit-desktop.sh /dev/dri/renderD128`, moves rendering out of QEMU's CPU emulation without touching the host display. Screenshots and logs go to `~/.cache/desktop-audit/run.*`. The test uses an isolated VM, test-only credentials and an emulated sound card with a silent backend. It checks session services, fonts, 100%/150% scaling, real PAM lock/unlock, speaker volume, microphone mute and audio controls. Hardware audio, NVIDIA, suspend and mixed-monitor behavior still need the target workstation. `nix flake check` without `--no-build` also runs the software-rendered VM and can be very slow without KVM. Neovim imports [the existing neovim-dots source](https://git.cyber.ayyalasomayajula.net/marsultor/neovim-dots), pinned to `380eb86778a7c53a0f1c18e84f14037456155347`. Lua, AstroNvim, Lazy, Mason, plugins and keymaps are unchanged. No Nixvim, replacement loader or plugin-manager migration.
Make focused changes, format/evaluate them, and commit regularly. Build and activate a reviewed commit rather than accumulating uncommitted setup. `system.configurationRevision` records the source revision in the system generation. Do not put human identity guesses in Git settings or push to an unapproved remote. Home Manager links configuration files under `~/.config/nvim`. Lazy's writable lock lives under `~/.local/state/nvim/locks/<revision>.json`, seeded once per dotfile revision. An earlier manual checkout is preserved at `~/projects/neovim-dots-before-nix`; activation refuses to overwrite an existing backup. The host input is reproducible; Lazy/Mason's mutable runtime downloads are not claimed to be fully Nix-reproducible.
## Updates and safety As `dev`, the separate runtime audit may download dependencies but does not modify managed Lua or save buffers:
- `nixos-update.timer` checks daily with up to one hour of jitter. Only the stable Nixpkgs/Home Manager branches advance; Neovim's source stays fixed. The updater skips a dirty repository, builds in a detached worktree, records a tested commit and applies it without rebooting. It attempts rollback if activation fails and retains recovery generations. Inspect `journalctl -u nixos-update` or trigger it with `sudo systemctl start nixos-update`. ```sh
- For a manual input refresh: `nix flake update nixpkgs home-manager`, review `flake.lock`, then check/build. The exact resolved revisions and hashes remain committed. nvim --headless ~/.config/nvim/init.lua -c 'luafile /etc/nix/neovim-test.lua'
- Keep the EC2 module, sandboxing, signature verification and recovery access intact. ```
- `system.stateVersion` and `home.stateVersion` are both `26.05`; these preserve compatibility, not package versions.
- `test` activates changes too; it is not a dry run. Keep the original system generation. KeePassXC preferences are seeded once and stay writable. Create/open your own vault, pair the browser extension and choose a dedicated **Secret Service Integration** group for application tokens. Element Nightly is forced to libsecret storage; **do not select its unencrypted fallback**. The native audit confirmed that Nightly starts, but login needs an unlocked/configured vault. No vault, account or VPN credentials are embedded.
- Rollbacks do not restore mutable user/application data, lockfile updates, backups, or this Git working tree.
- No private keys, plaintext secrets, build outputs or agent notes in this repo. `element-nightly.nix` packages the official Element Nightly **2026090401** Debian artifact by URL and SHA-256, preserving its matched Electron/native modules. It uses Chromium's user-namespace sandbox rather than the unusable setuid helper; `--no-sandbox` is not used. Its Nightly profile is separate from stable Element; existing account data is not copied or deleted. The launcher is **Element-Nightly**, command `element-desktop-nightly`; `element-desktop` is a compatibility alias.
**Nightly update boundary:** this upstream binary is not packaged by the pinned Nixpkgs inputs. Its URL/version/hash must be refreshed in `element-nightly.nix` from the official `packages.element.io` package index, then rebuilt. The daily flake-input updater does not silently mutate this source file. This intentionally keeps the setup simple and reproducible rather than adding another downloader/self-updater.
## Validation and contributions
`./audit-desktop.sh` runs the disposable graphical VM; `./audit-desktop.sh /dev/dri/renderD128` optionally uses an accessible render node. Logs/screenshots go to `~/.cache/desktop-audit/run.*`. The VM tests session services, fonts, audio, scaling and real PAM with **test-only** credentials. The native live screenshot audit covers this laptop; neither substitutes for real hardware suspend, hotplug or browser portal tests.
Plain `nix flake check` also builds/runs the VM and may be slow without KVM. Use `--no-build` for evaluation, then select bounded checks explicitly. Keep changes focused, format/evaluate/test them, and commit reviewed configuration. Do not push to an unapproved remote, invent a human Git identity, commit secrets, screenshots of personal windows, build outputs or agent scratch notes.
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Offline test of the real microvm.nix runner, mounts, SSH and host-side sandbox.
set -euo pipefail
launcher=$1
tmp=$(mktemp -d)
export HOME=$tmp/home DSH_HOME=$tmp/dsh DSH_AGENTS_HOME=$tmp/agents XDG_STATE_HOME=$tmp/state
mkdir -p "$HOME" "$tmp/project" "$DSH_HOME/skills" "$DSH_AGENTS_HOME/skills"
chmod 700 "$DSH_HOME"
printf 'not shared\n' > "$HOME/host-only-secret"
ln -s "$HOME/host-only-secret" "$tmp/project/escape"
cd "$tmp/project"
"$launcher" run > "$tmp/launcher.log" 2>&1 &
pid=$!
cleanup() {
status=$?
if (( status )); then
grep -h . "$tmp/launcher.log" "$XDG_STATE_HOME"/agent-vm/*/console.log | tail -80 || true
fi
"$launcher" stop >/dev/null 2>&1 || true
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
# Never delete real project/config data; everything here is a test fixture.
rm -rf "$tmp"
return "$status"
}
trap cleanup EXIT
ready=false
for ((i=0; i<120; i++)); do
if "$launcher" ssh true 2>/dev/null; then ready=true; break; fi
if ! kill -0 "$pid" 2>/dev/null; then break; fi
sleep 2
done
if ! $ready; then
grep -h . "$tmp/launcher.log" "$XDG_STATE_HOME"/agent-vm/*/console.log || true
exit 1
fi
[[ $("$launcher" ssh 'id -u') == 0 ]]
[[ $("$launcher" ssh pwd) == "$tmp/project" ]]
[[ $("$launcher" ssh nproc) == 4 ]]
[[ $("$launcher" ssh 'printenv AGENT_PROJECT_TEST') == shared ]]
[[ $("$launcher" ssh hello) == 'Hello, world!' ]]
"$launcher" ssh 'test ! -e /workspace/escape; test ! -e /run/host; test -d /nix/.rw-store'
"$launcher" ssh 'printf edited > /workspace/changed; printf config > /root/.dsh/config-test; printf creds > /root/.dsh/credentials-test; printf skill > /root/.dsh/skills/test.md; printf shared > /root/.agents/skills/test.md'
[[ $(< changed) == edited && $(< "$DSH_HOME/config-test") == config ]]
[[ $(< "$DSH_HOME/credentials-test") == creds && $(< "$DSH_HOME/skills/test.md") == skill ]]
[[ $(< "$DSH_AGENTS_HOME/skills/test.md") == shared ]]
[[ $(stat -c %u changed) == "$(id -u)" ]]
"$launcher" ssh 'command -v rg python3 git; findmnt /workspace; findmnt /root/.dsh'
# A second start must fail without disrupting the existing VM.
if "$launcher" run >/dev/null 2>&1; then echo 'Duplicate launch succeeded' >&2; exit 1; fi
"$launcher" stop
wait "$pid"
trap - EXIT
rm -rf "$tmp"
echo 'PASS: microVM boot, root SSH, shared toolchain, RW cwd/config/creds/skills, host ownership, symlink isolation, duplicate lock, shutdown'
+65
View File
@@ -0,0 +1,65 @@
{
"nodes": {
"microvm": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"spectrum": "spectrum"
},
"locked": {
"lastModified": 1788636433,
"narHash": "sha256-iCLUJO5V2ZlEAlYxlkCZ5YLkyQrpkyXOMUTkPXjsdPk=",
"owner": "microvm-nix",
"repo": "microvm.nix",
"rev": "804cbac7a462aa0fa8bb60c3d2fc4ead0a62060f",
"type": "github"
},
"original": {
"owner": "microvm-nix",
"repo": "microvm.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1788614874,
"narHash": "sha256-7QYjT2vHLuX9Z1pdxHXDKCbh1CR3D/2rywB9Tx0MPRg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c043004d1c6985732bcc1cbc5a9c9aecbbb4e0f0",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"microvm": "microvm",
"nixpkgs": "nixpkgs"
}
},
"spectrum": {
"flake": false,
"locked": {
"lastModified": 1785761586,
"narHash": "sha256-MWOMVqJJERwjQGgRIv8d6rlEBqbLM3VZWxHkNKIIZNw=",
"ref": "refs/heads/main",
"rev": "a7762d6f54b40560dd5255ce902e6e6a5d980fe9",
"revCount": 1416,
"type": "git",
"url": "https://spectrum-os.org/git/spectrum"
},
"original": {
"type": "git",
"url": "https://spectrum-os.org/git/spectrum"
}
}
},
"root": "root",
"version": 7
}
+39
View File
@@ -0,0 +1,39 @@
{
description = "Project-composable, rootless microVMs for a DeepSeek coding agent";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
microvm = {
url = "github:microvm-nix/microvm.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
inputs@{
self,
nixpkgs,
microvm,
...
}:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
example = self.lib.mkAgentVM {
inherit system;
project = {
packages = [ pkgs.hello ];
env.AGENT_PROJECT_TEST = "shared";
};
};
in
{
lib.mkAgentVM = import ./lib.nix { inherit nixpkgs microvm; };
nixosModules.agent = ./module.nix;
nixosConfigurations.agent = example.nixos;
packages.${system}.default = example.package;
apps.${system}.default = example.app;
formatter.${system} = pkgs.nixfmt;
checks.${system} = import ./tests.nix { inherit inputs pkgs example; };
};
}
+121
View File
@@ -0,0 +1,121 @@
# Included by writeShellApplication: bash and PATH are supplied by Nix.
set -euo pipefail
if [[ ${1:-} == --help ]]; then
echo 'Usage: nix run .#agent -- [run | ssh [command ...] | url | stop]'
echo 'Workspace = cwd. RW config/credentials/skills = DSH_HOME (default ~/.dsh)'
echo 'Also shares DSH_AGENTS_HOME/skills (default ~/.agents/skills). RAM/CPU/network: flake.'
exit 0
fi
[[ $EUID != 0 ]] || { echo 'Run as your normal host user, not sudo/root.' >&2; exit 1; }
umask 077
project=$(pwd -P)
dsh=$(realpath -m "${DSH_HOME:-$HOME/.dsh}")
skills=$(realpath -m "${DSH_AGENTS_HOME:-$HOME/.agents}/skills")
state=$(realpath -m "${XDG_STATE_HOME:-$HOME/.local/state}/agent-vm/$(printf %s "$project" | sha256sum | cut -c1-16)")
for path in "$project" "$dsh" "$skills"; do
case "$path" in /|/home|/etc|/nix|/nix/*|/proc|/proc/*|/sys|/sys/*|/dev|/dev/*|/run|/run/*|"$HOME"|*$'\n'*) echo "Refusing broad/system share: $path" >&2; exit 1;; esac
[[ $state != "$path" && $state != "$path/"* ]] || { echo 'State must be outside shared directories.' >&2; exit 1; }
done
# Prevent a broad workspace/config mount from accidentally including other mounts.
disjoint() {
[[ $1 != "$2" && $1 != "$2/"* && $2 != "$1/"* ]] || { echo 'Writable shares must not overlap.' >&2; exit 1; }
}
disjoint "$project" "$dsh"; disjoint "$project" "$skills"; disjoint "$dsh" "$skills"
ssh_cmd=(ssh -F /dev/null -i "$state/client-key" -p "$AGENT_SSH_PORT"
-o IdentitiesOnly=yes -o IdentityAgent=none -o ForwardAgent=no -o BatchMode=yes
-o StrictHostKeyChecking=yes -o HostKeyAlias=agent-vm -o ConnectTimeout=3
-o "UserKnownHostsFile=$state/known_hosts" -o GlobalKnownHostsFile=/dev/null)
remote="root@$AGENT_SSH_HOST"
url() {
local found address=$AGENT_WEB_BIND
[[ $address != 0.0.0.0 ]] || address=127.0.0.1
found=$("${ssh_cmd[@]}" "$remote" 'journalctl -u agent -b -o cat --no-pager' |
grep -oE 'http://127\.0\.0\.1:3080/\?token=[a-zA-Z0-9_%.-]+' | tail -1) || return 1
[[ -n $found ]] || return 1
printf '%s\n' "${found/http:\/\/127.0.0.1:3080/http:\/\/$address:$AGENT_WEB_PORT}"
}
# Expand cwd inside the guest, not on the host.
# shellcheck disable=SC2016
case ${1:-run} in
ssh) shift; if (( $# )); then exec "${ssh_cmd[@]}" "$remote" 'cd -- "$(cat /run/agent-vm/workdir)" || exit; '"$*"; else exec "${ssh_cmd[@]}" -t "$remote" 'cd -- "$(cat /run/agent-vm/workdir)" || exit; exec bash -l'; fi;;
url) url || { echo "DSH not ready; inspect: nix run .#agent -- ssh 'journalctl -u agent -b'" >&2; exit 1; }; exit;;
stop) cd "$state"; exec "$AGENT_RUNNER/microvm-shutdown";;
run) [[ $# -le 1 ]] || { echo 'Unexpected run arguments; use --help.' >&2; exit 1; };;
*) echo 'Unknown command; use --help.' >&2; exit 1;;
esac
[[ -r /dev/kvm && -w /dev/kvm ]] || { echo 'Need read/write access to /dev/kvm.' >&2; exit 1; }
mkdir -p "$state" "$dsh" "$skills"
for dir in "$state" "$dsh"; do
[[ $(stat -c %u "$dir") == "$(id -u)" && $(stat -c %a "$dir") == 700 ]] || {
echo "Make this directory private and user-owned first: $dir (chmod 700)" >&2; exit 1;
}
done
exec 9>"$state/run.lock"
flock -n 9 || { echo 'This project VM is already running.' >&2; exit 1; }
for key in client-key ssh-host-key; do
[[ -f $state/$key ]] || ssh-keygen -q -t ed25519 -N '' -C agent-vm -f "$state/$key"
done
printf '%s\n' "$project" > "$state/workdir"
cp "$state/client-key.pub" "$state/ssh-authorized-key"
printf 'agent-vm %s\n' "$(cut -d' ' -f1,2 "$state/ssh-host-key.pub")" > "$state/known_hosts"
printf 'RW workspace: %s -> /workspace\nRW DSH home: %s\nRW shared skills: %s\nConsole log: %s/console.log\n' "$project" "$dsh" "$skills" "$state"
if [[ $AGENT_WEB_BIND != 127.0.0.1 ]]; then
echo 'WARNING: off-host Web access is plaintext HTTP. Use a VPN/TLS; never expose directly to the Internet.' >&2
fi
vm_pid=''
tunnel_pid=''
cleanup() {
trap - EXIT INT TERM
if [[ -n $vm_pid ]] && kill -0 "$vm_pid" 2>/dev/null; then
(cd "$state"; timeout 30 "$AGENT_RUNNER/microvm-shutdown") >/dev/null 2>&1 || true
kill "$vm_pid" 2>/dev/null || true
wait "$vm_pid" 2>/dev/null || true
fi
if [[ -n $tunnel_pid ]]; then kill "$tunnel_pid" 2>/dev/null || true; wait "$tunnel_pid" 2>/dev/null || true; fi
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
# Host-side defense in depth around QEMU. No host home/session sockets or other
# processes; only the three explicit shares and private VM control state are RW.
# Network is intentionally inherited for API access (not an egress firewall).
devices=()
[[ $AGENT_NETWORK != tap ]] || devices=(--dev-bind /dev/net/tun /dev/net/tun)
bwrap "${devices[@]}" --die-with-parent --new-session --unshare-user --unshare-pid --unshare-ipc \
--unshare-uts --unshare-cgroup-try --cap-drop ALL --clearenv \
--setenv HOME /tmp --setenv PATH /no-host-path --setenv LANG C.UTF-8 \
--ro-bind /nix/store /nix/store --proc /proc --dev /dev --dev-bind /dev/kvm /dev/kvm \
--tmpfs /tmp --bind "$state" /state --bind "$project" /workspace \
--bind "$dsh" /dsh-home --bind "$skills" /skills \
--ro-bind-try /etc/resolv.conf /etc/resolv.conf --ro-bind-try /etc/hosts /etc/hosts \
--chdir /state "$AGENT_RUNNER/microvm-run" >"$state/console.log" 2>&1 &
vm_pid=$!
ready=false
for ((i=0; i<90; i++)); do
kill -0 "$vm_pid" 2>/dev/null || { echo "VM exited; see $state/console.log" >&2; exit 1; }
if "${ssh_cmd[@]}" "$remote" true 2>/dev/null; then ready=true; break; fi
sleep 1
done
$ready || { echo "SSH boot timeout; see $state/console.log" >&2; exit 1; }
# DSH deliberately refuses --host 0.0.0.0. Keep its own authenticated browser
# endpoint on guest loopback and publish an SSH forward on the chosen host IP.
"${ssh_cmd[@]}" -N -g -o ExitOnForwardFailure=yes -o ServerAliveInterval=10 \
-o ServerAliveCountMax=3 -L "$AGENT_WEB_BIND:$AGENT_WEB_PORT:127.0.0.1:3080" \
"$remote" >>"$state/console.log" 2>&1 &
tunnel_pid=$!
echo "Booted. DSH resolves npm @latest on startup; first launch may take a few minutes."
echo 'Use another terminal: nix run .#agent -- url (or: ssh / stop)'
printed=false
while kill -0 "$vm_pid" 2>/dev/null; do
# Normal guest poweroff can close SSH slightly before QEMU exits.
if ! kill -0 "$tunnel_pid" 2>/dev/null; then
timeout 30 tail --pid="$vm_pid" -f /dev/null || true
if kill -0 "$vm_pid" 2>/dev/null; then echo "Web tunnel exited; see $state/console.log" >&2; exit 1; fi
break
fi
if ! $printed; then
if login_url=$(url 2>/dev/null); then printf 'Private login URL: %s\n' "$login_url"; printed=true; fi
fi
sleep 2
done
wait "$vm_pid"
+54
View File
@@ -0,0 +1,54 @@
{ nixpkgs, microvm }:
{
system,
project,
modules ? [ ],
}:
let
pkgs = nixpkgs.legacyPackages.${system};
inherit (pkgs) lib;
nixos = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
microvm.nixosModules.microvm
./module.nix
{
agentVM.packages = project.packages or [ ];
environment.variables = project.env or { };
}
]
++ modules;
};
net = nixos.config.agentVM.network;
package = pkgs.writeShellApplication {
name = "agent-vm";
runtimeInputs = with pkgs; [
coreutils
util-linux
openssh
bubblewrap
gnugrep
gnused
];
runtimeEnv = {
AGENT_RUNNER = "${nixos.config.microvm.declaredRunner}/bin";
AGENT_NETWORK = net.mode;
AGENT_WEB_BIND = net.hostAddress;
AGENT_WEB_PORT = toString net.webPort;
AGENT_SSH_HOST = if net.mode == "user" then "127.0.0.1" else net.guestAddress;
AGENT_SSH_PORT = toString (if net.mode == "user" then net.sshPort else 22);
};
text = builtins.readFile ./launch.sh;
};
in
assert lib.assertMsg (
system == "x86_64-linux"
) "agent-vm currently supports x86_64-linux hosts/guests";
{
inherit nixos package;
app = {
type = "app";
program = lib.getExe package;
meta.description = "Run DSH with this project's toolchain and live workspace";
};
}
+376
View File
@@ -0,0 +1,376 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib) mkOption types;
cfg = config.agentVM;
net = cfg.network;
ipv4 = types.strMatching "[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+";
dshNode = pkgs.writeShellScript "dsh-node" ''
# Cordis HMR requires this Node flag; npm's published dsh shebang omits it.
exec node --expose-internals "$(command -v dsh)" "$@"
'';
dshLatest = pkgs.writeShellApplication {
name = "dsh";
runtimeInputs = [
pkgs.nodejs
pkgs.pnpm
];
text = ''
export npm_config_cache=/var/cache/dsh/npm
# Explicitly rolling upstream, not a pretend-reproducible Nix derivation.
exec npm exec --yes --package=@deepseek-ai/dsh@latest -- ${dshNode} "$@"
'';
};
guestLaunch = pkgs.writeShellScript "dsh-project" ''
cd -- "$(cat /run/agent-vm/workdir)"
exec "$@"
'';
share = source: mountPoint: tag: {
inherit source mountPoint tag;
proto = "9p";
securityModel = "none"; # QEMU writes as its unprivileged host uid, not guest root.
readOnly = false;
};
in
{
options.agentVM = {
packages = mkOption {
type = types.listOf types.package;
default = [ ];
description = "The same project package list used by the development shell.";
};
package = mkOption {
type = types.package;
default = dshLatest;
description = "Official DSH launcher; resolves the npm latest tag inside the guest at launch.";
};
network = {
mode = mkOption {
type = types.enum [
"user"
"tap"
];
default = "user";
description = "Rootless QEMU NAT, or an administrator-prepared TAP interface.";
};
hostAddress = mkOption {
type = ipv4;
default = "127.0.0.1";
description = "Host IPv4 bind address for the SSH-forwarded Web UI.";
};
sshPort = mkOption {
type = types.port;
default = 2222;
description = "Host SSH port in user mode; SSH is always bound to host loopback.";
};
webPort = mkOption {
type = types.port;
default = 3080;
description = "Host Web UI port; the guest DSH listener stays on 127.0.0.1:3080.";
};
trustedHosts = mkOption {
type = types.listOf (types.strMatching "[a-zA-Z0-9.:-]+");
default = [ ];
description = "Additional exact browser authorities for DSH's Host/Origin protection. Required for wildcard publication.";
};
tapName = mkOption {
type = types.strMatching "[a-zA-Z0-9_-]{1,15}";
default = "agent0";
description = "Pre-created host TAP interface, not a physical NIC.";
};
mac = mkOption {
type = types.strMatching "[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}";
default = "02:00:00:00:00:01";
description = "Unique locally administered guest MAC; change for each TAP guest.";
};
guestAddress = mkOption {
type = types.nullOr ipv4;
default = null;
description = "Static guest IPv4 address in TAP mode.";
};
prefixLength = mkOption {
type = types.ints.between 1 32;
default = 24;
description = "Guest IPv4 prefix length in TAP mode.";
};
gateway = mkOption {
type = types.nullOr ipv4;
default = null;
description = "Guest default router in TAP mode; routing/NAT is configured separately.";
};
dns = mkOption {
type = types.listOf types.str;
default = [ ];
description = "DNS servers in TAP mode. User mode uses QEMU DHCP/DNS.";
};
};
};
config = {
assertions = [
{
assertion =
net.mode != "tap" || (net.guestAddress != null && net.gateway != null && net.dns != [ ]);
message = "agentVM TAP mode requires network.guestAddress, gateway and dns.";
}
{
assertion = net.webPort >= 1024 && net.sshPort >= 1024 && net.webPort != net.sshPort;
message = "Rootless Web/SSH listeners need distinct unprivileged ports (>=1024).";
}
{
assertion = net.hostAddress != "0.0.0.0" || net.trustedHosts != [ ];
message = "When publishing on 0.0.0.0, list the actual browser IP:port/hostname:port in network.trustedHosts.";
}
];
networking.hostName = lib.mkDefault "project-agent";
system.stateVersion = "26.05";
microvm = {
hypervisor = "qemu";
mem = lib.mkDefault 4096;
vcpu = lib.mkDefault 4;
socket = "control.sock";
storeOnDisk = true;
# Ephemeral guest-only Nix writes; never share the host store or daemon.
writableStoreOverlay = "/nix/.rw-store";
volumes = [
{
image = "cache.img";
mountPoint = "/var/cache/dsh";
size = 4096;
}
];
# Paths are in the launcher's restricted mount namespace, not Nix paths.
# No credential/project contents enter the Nix store.
shares = [
(share "/workspace" "/workspace" "project")
(share "/dsh-home" "/root/.dsh" "dsh-home")
(share "/skills" "/root/.agents/skills" "agent-skills")
];
interfaces = [
{
type = net.mode;
id = if net.mode == "user" then "agentnet" else net.tapName;
inherit (net) mac;
}
];
forwardPorts = lib.optionals (net.mode == "user") [
{
from = "host";
host.address = "127.0.0.1";
host.port = net.sshPort;
guest.port = 22;
}
];
# Firmware credentials carry only dedicated VM SSH keys, not DSH secrets.
# Relative runtime filenames avoid embedding user paths in derivations.
qemu.extraArgs =
lib.concatMap
(name: [
"-fw_cfg"
"name=opt/io.systemd.credentials/${name},file=${name}"
])
[
"ssh-authorized-key"
"ssh-host-key"
"workdir"
];
};
fileSystems."/workspace".options = [
"nodev"
"nosuid"
"cache=none"
];
fileSystems."/root/.dsh".options = [
"nodev"
"nosuid"
"cache=none"
];
fileSystems."/root/.agents/skills".options = [
"nodev"
"nosuid"
"cache=none"
];
networking.useDHCP = false;
systemd.network.enable = true;
systemd.network.networks."20-agent" = {
matchConfig.MACAddress = net.mac;
networkConfig =
if net.mode == "user" then
{ DHCP = "ipv4"; }
else
{
Address = [ "${net.guestAddress}/${toString net.prefixLength}" ];
Gateway = net.gateway;
DNS = net.dns;
};
};
networking.firewall.allowedTCPPorts = [ 22 ]; # Web stays on guest loopback.
nix.settings.experimental-features = [
"nix-command"
"flakes"
];
nix.settings.auto-optimise-store = false;
nix.channel.enable = false;
users.users.root.hashedPassword = "!";
services.openssh = {
enable = true;
hostKeys = [
{
path = "/run/agent-vm/ssh-host-key";
type = "ed25519";
}
];
authorizedKeysFiles = lib.mkForce [ "/run/agent-vm/ssh-authorized-key" ];
settings = {
PermitRootLogin = "prohibit-password";
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
AllowAgentForwarding = false;
X11Forwarding = false;
AllowTcpForwarding = "local";
};
};
systemd.services.agent-vm-credentials = {
before = [
"sshd.service"
"sshd-keygen.service"
"agent.service"
];
requiredBy = [
"sshd.service"
"sshd-keygen.service"
"agent.service"
];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
RuntimeDirectory = "agent-vm";
RuntimeDirectoryMode = "0700";
ImportCredential = [
"ssh-authorized-key"
"ssh-host-key"
"workdir"
];
};
script = ''
for name in ssh-authorized-key ssh-host-key workdir; do
install -m 600 "$CREDENTIALS_DIRECTORY/$name" "/run/agent-vm/$name"
done
'';
};
environment.variables = {
DSH_HOME = "/root/.dsh";
DSH_AGENTS_HOME = "/root/.agents";
DSH_TELEMETRY_DISABLED = "1";
};
programs.git.config.safe.directory = "/workspace"; # 9p files retain host ownership.
programs.nix-ld.enable = true; # Upstream npm native executables, guest only.
environment.systemPackages = [
cfg.package
]
++ cfg.packages
++ (with pkgs; [
bashInteractive
coreutils
findutils
gnugrep
gnused
gawk
diffutils
git
git-lfs
openssh
ripgrep
fd
jq
yq-go
tree
file
less
python3
nodejs
pnpm
curl
wget
cacert
unzip
zip
gnutar
gzip
xz
zstd
procps
util-linux
which
patch
gnumake
pkg-config
shellcheck
bubblewrap
]);
systemd.services.agent = {
description = "Official DeepSeek Harness (root inside the guest)";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
unitConfig.RequiresMountsFor = [
"/workspace"
"/root/.dsh"
"/root/.agents/skills"
"/var/cache/dsh"
];
path = [ "/run/current-system/sw" ];
environment = config.environment.variables // {
HOME = "/root";
};
# Preserve the real cwd path in DSH's workspace/session identity. Mapping
# every project to /workspace alone would conflate their shared sessions.
preStart = ''
workdir=$(cat /run/agent-vm/workdir)
mkdir -p -- "$workdir"
mountpoint -q -- "$workdir" || mount --bind /workspace "$workdir"
git config --global --replace-all safe.directory "$workdir"
'';
serviceConfig = {
User = "root";
WorkingDirectory = "/workspace";
ExecStart = lib.escapeShellArgs (
[
"${guestLaunch}"
"${cfg.package}/bin/dsh"
"web"
"--no-open"
"--host"
"127.0.0.1"
"--port"
"3080"
]
++
lib.concatMap
(host: [
"--trusted-host"
host
])
(
net.trustedHosts
++ lib.optional (
!builtins.elem net.hostAddress [
"127.0.0.1"
"0.0.0.0"
]
) "${net.hostAddress}:${toString net.webPort}"
)
);
Restart = "on-failure";
RestartSec = 3;
UMask = "0077";
};
};
};
}
+92
View File
@@ -0,0 +1,92 @@
{
inputs,
pkgs,
example,
}:
let
c = example.nixos.config;
testVM = inputs.self.lib.mkAgentVM {
system = pkgs.stdenv.hostPlatform.system;
project = {
packages = [ pkgs.hello ];
env.AGENT_PROJECT_TEST = "shared";
};
modules = [
{
# Offline infrastructure test. A real DSH startup is tested separately;
# @latest needs the network and is intentionally outside Nix reproducibility.
agentVM.package = pkgs.writeShellScriptBin "dsh" ''
echo 'http://127.0.0.1:3080/?token=offline-test'
exec ${pkgs.coreutils}/bin/sleep infinity
'';
}
];
};
tap = inputs.self.lib.mkAgentVM {
system = pkgs.stdenv.hostPlatform.system;
project.packages = [ ];
modules = [
{
microvm.mem = 8192;
microvm.vcpu = 6;
agentVM.network = {
mode = "tap";
tapName = "agent-test";
guestAddress = "192.168.77.2";
gateway = "192.168.77.1";
dns = [ "192.168.77.1" ];
};
}
];
};
in
{
config =
assert c.microvm.mem == 4096;
assert c.microvm.vcpu == 4;
assert builtins.length c.microvm.shares == 3;
assert builtins.all (s: !s.readOnly && s.securityModel == "none") c.microvm.shares;
assert c.microvm.storeOnDisk;
assert c.systemd.services.agent.serviceConfig.User == "root";
assert c.systemd.services.agent.serviceConfig.WorkingDirectory == "/workspace";
assert c.services.openssh.settings.PasswordAuthentication == false;
assert c.services.openssh.settings.AllowAgentForwarding == false;
assert builtins.length c.microvm.forwardPorts == 1;
assert (builtins.head c.microvm.forwardPorts).host.address == "127.0.0.1";
assert builtins.elem pkgs.hello c.environment.systemPackages;
assert c.environment.variables.AGENT_PROJECT_TEST == "shared";
assert tap.nixos.config.microvm.mem == 8192;
assert tap.nixos.config.microvm.vcpu == 6;
assert tap.nixos.config.microvm.forwardPorts == [ ];
assert (builtins.head tap.nixos.config.microvm.interfaces).id == "agent-test";
pkgs.runCommand "agent-vm-config-check" { } ''touch "$out"'';
shell =
pkgs.runCommand "agent-vm-shell-check"
{
nativeBuildInputs = [
pkgs.shellcheck
pkgs.bash
];
}
''
shellcheck -s bash ${./launch.sh} ${./boot-test.sh}
bash -n ${./launch.sh}
touch "$out"
'';
boot =
pkgs.runCommand "agent-vm-boot-check"
{
requiredSystemFeatures = [ "kvm" ];
nativeBuildInputs = [
pkgs.bash
pkgs.coreutils
pkgs.gnugrep
];
}
''
bash ${./boot-test.sh} ${testVM.package}/bin/agent-vm
touch "$out"
'';
}
+17 -14
View File
@@ -1,24 +1,27 @@
* { font-family: Inter, sans-serif; font-size: 17px; } * { font-family: Inter, sans-serif; font-size: 16px; }
window { background: transparent; } window { background: transparent; }
box.main { box.main {
padding: 14px; padding: 12px;
margin: 18px; margin: 12px;
border-radius: 16px; border-radius: 6px;
border: 2px solid #78a9ff; border: 2px solid @accent@;
background: #161616; background: @background@;
box-shadow: 0 8px 18px rgba(0, 0, 0, .35); box-shadow: 0 8px 18px rgba(0, 0, 0, .35);
} }
text { text {
color: #f2f4f8; color: @text@;
background: #262626; background: @surface@;
min-height: 38px; min-height: 38px;
padding: 10px 14px; padding: 10px 14px;
border-radius: 9px; border-radius: 4px;
} }
.matches, list.plugin { background: transparent; } .matches, list.plugin { background: transparent; }
box.plugin:first-child { margin-top: 10px; } box.plugin:first-child { margin-top: 10px; }
.match { padding: 9px; border-radius: 9px; background: transparent; } /* The row, boxes, icon AND labels all share .match upstream. Padding that
.match:selected { background: #2e3f5f; } class multiplies row height until the launcher extends off the screen. */
label.match { color: #f2f4f8; } .match { background: transparent; }
label.match.description { font-size: 14px; color: #a2a9b0; } row.match { padding: 7px 10px; border-radius: 4px; }
label.plugin.info { color: #a2a9b0; } row.match:selected { background: @selection@; }
label.match { color: @text@; }
label.match.description { font-size: 14px; color: @muted@; }
label.plugin.info { color: @muted@; }
+54 -14
View File
@@ -7,16 +7,7 @@
let let
c = import ./colors.nix; c = import ./colors.nix;
# Select secure storage without rebuilding the cached Electron application. elementNightly = import ./element-nightly.nix { inherit pkgs; };
elementWithKeyring = pkgs.symlinkJoin {
name = "element-desktop-with-keyring-${pkgs.element-desktop.version}";
paths = [ pkgs.element-desktop ];
nativeBuildInputs = [ pkgs.makeWrapper ];
postBuild = ''
wrapProgram "$out/bin/element-desktop" --add-flags "--password-store=gnome-libsecret"
'';
inherit (pkgs.element-desktop) meta;
};
browserAddon = pkgs.fetchurl { browserAddon = pkgs.fetchurl {
name = "keepassxc-browser-1.10.3.xpi"; name = "keepassxc-browser-1.10.3.xpi";
url = "https://addons.mozilla.org/firefox/downloads/file/4831838/keepassxc_browser-1.10.3.xpi"; url = "https://addons.mozilla.org/firefox/downloads/file/4831838/keepassxc_browser-1.10.3.xpi";
@@ -58,6 +49,13 @@ in
"nvidia-persistenced" "nvidia-persistenced"
] ]
); );
programs.thunar = {
enable = true;
plugins = [
pkgs.thunar-archive-plugin
pkgs.thunar-volman
];
};
programs.steam = { programs.steam = {
enable = true; enable = true;
remotePlay.openFirewall = false; remotePlay.openFirewall = false;
@@ -66,11 +64,31 @@ in
}; };
programs.firefox = { programs.firefox = {
enable = true; enable = true;
# Stable's ESR is security-current (153.2); regular 155.0 is one patch behind. # Use the security-supported ESR line from the system pin.
package = pkgs.firefox-esr; package = pkgs.firefox-esr;
nativeMessagingHosts.packages = [ pkgs.keepassxc ]; nativeMessagingHosts.packages = [ pkgs.keepassxc ];
policies = { policies = {
DisableTelemetry = true; DisableTelemetry = true;
DontCheckDefaultBrowser = true;
NoDefaultBookmarks = true;
Preferences = {
"browser.compactmode.show" = {
Value = true;
Status = "default";
};
"browser.uidensity" = {
Value = 1;
Status = "default";
};
"browser.newtabpage.activity-stream.showSponsored" = {
Value = false;
Status = "locked";
};
"browser.newtabpage.activity-stream.showSponsoredTopSites" = {
Value = false;
Status = "locked";
};
};
OfferToSaveLogins = false; OfferToSaveLogins = false;
ExtensionSettings."keepassxc-browser@keepassxc.org" = { ExtensionSettings."keepassxc-browser@keepassxc.org" = {
installation_mode = "normal_installed"; installation_mode = "normal_installed";
@@ -82,12 +100,24 @@ in
home-manager.users.dev = { home-manager.users.dev = {
home.packages = with pkgs; [ home.packages = with pkgs; [
# Keep Tor Browser's separate profile and upstream privacy defaults.
# This is not Firefox pointed at the system Tor SOCKS port.
tor-browser
thunderbird thunderbird
file-roller
imv
# Electron does not reliably detect a keyring under Hyprland. Use the # Electron does not reliably detect a keyring under Hyprland. Use the
# KeePassXC Secret Service explicitly; never fall back to basic_text. # KeePassXC Secret Service explicitly; never fall back to basic_text.
elementWithKeyring elementNightly
slack slack
]; ];
programs.mpv = {
enable = true;
config = {
hwdec = "auto-safe";
keep-open = true;
};
};
programs.keepassxc = { programs.keepassxc = {
enable = true; enable = true;
autostart = true; autostart = true;
@@ -113,7 +143,7 @@ in
inputbar-fg = c.text; inputbar-fg = c.text;
completion-bg = c.background; completion-bg = c.background;
completion-fg = c.text; completion-fg = c.text;
completion-highlight-bg = "#354562"; completion-highlight-bg = c.selection;
completion-highlight-fg = c.text; completion-highlight-fg = c.text;
notification-bg = c.surface; notification-bg = c.surface;
notification-fg = c.text; notification-fg = c.text;
@@ -127,11 +157,21 @@ in
enable = true; enable = true;
defaultApplications = { defaultApplications = {
"application/pdf" = [ "org.pwmt.zathura.desktop" ]; "application/pdf" = [ "org.pwmt.zathura.desktop" ];
"inode/directory" = [ "thunar.desktop" ];
"application/zip" = [ "org.gnome.FileRoller.desktop" ];
"image/png" = [ "imv.desktop" ];
"image/jpeg" = [ "imv.desktop" ];
"image/webp" = [ "imv.desktop" ];
"video/mp4" = [ "mpv.desktop" ];
"video/x-matroska" = [ "mpv.desktop" ];
"audio/mpeg" = [ "mpv.desktop" ];
"text/html" = [ "firefox-esr.desktop" ]; "text/html" = [ "firefox-esr.desktop" ];
"x-scheme-handler/http" = [ "firefox-esr.desktop" ]; "x-scheme-handler/http" = [ "firefox-esr.desktop" ];
"x-scheme-handler/https" = [ "firefox-esr.desktop" ]; "x-scheme-handler/https" = [ "firefox-esr.desktop" ];
"x-scheme-handler/mailto" = [ "thunderbird.desktop" ]; "x-scheme-handler/mailto" = [ "thunderbird.desktop" ];
"x-scheme-handler/matrix" = [ "element-desktop.desktop" ]; "x-scheme-handler/matrix" = [ "element-desktop-nightly.desktop" ];
"x-scheme-handler/element" = [ "element-desktop-nightly.desktop" ];
"x-scheme-handler/io.element.desktop" = [ "element-desktop-nightly.desktop" ];
}; };
}; };
}; };
+17 -14
View File
@@ -1,16 +1,19 @@
# Small shared palette, not a theme framework. Hyprland's native Lua uses the # One Ring: charcoal, old gold, parchment and muted woodland accents.
# same accent/border colors explicitly; wallpaper.svg is original source art. # Semantic UI colors are separate from ANSI terminal colors. Consumers substitute
# these values into native Lua/CSS; there is no runtime theme generator.
{ {
background = "#161616"; background = "#121311";
surface = "#262626"; surface = "#1e201b";
raised = "#393939"; raised = "#2c3027";
border = "#525252"; border = "#4a5142";
text = "#f2f4f8"; selection = "#3a3d2e";
muted = "#a2a9b0"; text = "#e9e4d5";
blue = "#78a9ff"; muted = "#adb2a0";
cyan = "#3ddbd9"; accent = "#c6a664";
purple = "#be95ff"; blue = "#8faeb5";
green = "#42be65"; cyan = "#92b8a0";
yellow = "#f1c21b"; purple = "#b5a5be";
red = "#ff6b7a"; green = "#a3b878";
yellow = "#d8bb78";
red = "#df8b78";
} }
+39
View File
@@ -0,0 +1,39 @@
# Shared user environment and update policy; only machine integration differs.
{ config, inputs, ... }:
{
imports = [
./users.nix
./tools.nix
./network.nix
./desktop.nix
./apps.nix
./neovim.nix
./updates.nix
];
time.timeZone = "America/Chicago";
i18n.defaultLocale = "en_US.UTF-8";
services.xserver.xkb = {
layout = "us";
variant = "";
};
nixpkgs.hostPlatform = "x86_64-linux";
# tools.nix/network.nix select current standalone CLIs without overriding the
# system package set (or mixing unstable libraries into the desktop stack).
nix = {
channel.enable = false;
# Keep non-login environments on the same pin when NIX_PATH is unset.
settings.nix-path = config.nix.nixPath;
settings.experimental-features = [
"nix-command"
"flakes"
];
};
system.configurationRevision = inputs.self.rev or inputs.self.dirtyRev or null;
# Initial data/default compatibility, not the desired package release.
system.stateVersion = "26.05";
}
+7 -29
View File
@@ -1,37 +1,15 @@
{ # EC2 only. Physical machines must use the separate nixos host target.
config, { modulesPath, ... }:
inputs,
modulesPath,
...
}:
{ {
imports = [ imports = [
# Keep the image's EC2 boot, storage, metadata, SSH and SSM integration. # Keep the image's EC2 boot, storage, metadata, SSH and SSM integration.
"${modulesPath}/virtualisation/amazon-image.nix" "${modulesPath}/virtualisation/amazon-image.nix"
./users.nix ./common.nix
./tools.nix
./network.nix
./desktop.nix
./apps.nix
./updates.nix
./neovim.nix
]; ];
nixpkgs.hostPlatform = "x86_64-linux"; # Keep the existing deployment path; ownership/update policy is shared.
systemd.services.nixos-update.environment = {
nix = { NIXOS_CONFIG_REPO = "/etc/nixos";
channel.enable = false; NIXOS_UPDATE_HOST = "dev";
# Keep non-login environments on the same pin when NIX_PATH is unset.
settings.nix-path = config.nix.nixPath;
settings.experimental-features = [
"nix-command"
"flakes"
];
}; };
system.configurationRevision = inputs.self.rev or inputs.self.dirtyRev or null;
# Initial data/default compatibility, not the desired package release.
system.stateVersion = "26.05";
} }
+127
View File
@@ -0,0 +1,127 @@
"""Offline safety/behavior tests for desktop actions: no host desktop or power calls."""
import importlib.util
import os
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location("actions", sys.argv.pop(1))
actions = importlib.util.module_from_spec(spec)
spec.loader.exec_module(actions)
class ActionsTest(unittest.TestCase):
def test_picker_cancel_and_untrusted_output(self):
for code, text in [(1, "0"), (0, "$(touch /oops)"), (0, "-1"), (0, "9")]:
with patch.object(actions, "run", return_value=subprocess.CompletedProcess([], code, text)):
self.assertIsNone(actions.pick("Test", ["only item"]))
def test_picker_uses_index_and_sanitizes_labels(self):
with patch.object(actions, "run", return_value=subprocess.CompletedProcess([], 0, "0\n")) as run:
self.assertEqual(actions.pick("Test", ["title\nsecond\x00row"]), 0)
self.assertEqual(run.call_args.kwargs["input"], "title second row")
self.assertIn("--index", run.call_args.args)
def test_address_validation(self):
self.assertEqual(actions.address("0xabc123"), "address:0xabc123")
for value in ["", "0xABC; os.execute('bad')", "123", '"}']:
with self.assertRaises(ValueError):
actions.address(value)
def test_no_power_action_on_cancel(self):
with patch.object(actions, "pick", return_value=None), patch.object(actions, "run") as run:
actions.power()
run.assert_not_called()
with patch.object(actions, "pick", return_value=4), patch.object(actions, "confirm", return_value=False), patch.object(actions, "run") as run:
actions.power()
run.assert_not_called()
def test_screenshot_cancellation(self):
with patch.object(actions, "capture_geometry", return_value=None), patch.object(actions, "run") as run:
actions.screenshot()
run.assert_not_called()
def test_sensitive_and_locked_clipboard_not_stored(self):
with tempfile.TemporaryDirectory() as directory:
with patch.dict(os.environ, {"XDG_RUNTIME_DIR": directory, "CLIPBOARD_STATE": "sensitive"}), patch.object(actions, "run") as run:
actions.clipboard("store")
run.assert_not_called()
with patch.dict(os.environ, {"XDG_RUNTIME_DIR": directory, "CLIPBOARD_STATE": "data"}), patch.object(actions, "run") as run:
(actions.runtime() / "locked").touch()
actions.clipboard("store")
run.assert_not_called()
def test_clear_removes_database_not_just_entries(self):
with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"XDG_RUNTIME_DIR": directory}):
database = actions.runtime() / "clipboard.db"
database.write_bytes(b"old history pages")
actions.clipboard("clear")
self.assertFalse(database.exists())
def test_clipboard_roundtrip_preserves_bytes(self):
data = b" leading\ntrailing \x00\xff"
with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"XDG_RUNTIME_DIR": directory}):
(actions.runtime() / "clipboard.db").touch()
calls = []
def fake(*args, **kwargs):
calls.append((args, kwargs))
value = "4\tpreview\n" if args == ("cliphist", "list") else data
return subprocess.CompletedProcess(args, 0, value)
with patch.object(actions, "run", side_effect=fake), patch.object(actions, "pick", return_value=0):
actions.clipboard("pick")
self.assertEqual(calls[-1][0], ("wl-copy",))
self.assertEqual(calls[-1][1]["input"], data)
def test_web_search_is_url_encoded_not_shell(self):
result = subprocess.CompletedProcess([], 0, "hello; $(bad) & stuff")
with patch.object(actions, "run", return_value=result) as run:
actions.web_search()
self.assertEqual(run.call_args.args[:4], ("uwsm", "app", "--", "xdg-open"))
self.assertIn("hello%3B+%24%28bad%29+%26+stuff", run.call_args.args[4])
def test_lua_strings_do_not_use_json_control_escapes(self):
self.assertEqual(actions.lua('a"\n\x00'), '"\\097\\034\\010\\000"')
def test_lock_still_runs_when_clipboard_wipe_fails(self):
with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"XDG_RUNTIME_DIR": directory}):
with patch.object(actions, "clipboard", side_effect=subprocess.CalledProcessError(1, "cliphist")), patch.object(actions, "run", return_value=subprocess.CompletedProcess([], 0)) as run:
actions.lock_start()
run.assert_called_once_with("hyprlock", check=False)
self.assertFalse((actions.runtime() / "locked").exists())
def test_record_inhibitor_is_removed_on_failure(self):
calls = []
def fake(*args, **kwargs):
calls.append(args)
if args[0] == "wf-recorder":
raise subprocess.CalledProcessError(1, args)
return subprocess.CompletedProcess(args, 0)
with patch.object(actions.signal, "signal"), patch.object(actions, "run", side_effect=fake):
with self.assertRaises(subprocess.CalledProcessError):
actions.record_run(["-f", "test.mkv"])
self.assertEqual(calls[-1], ("swaync-client", "-Ir", "workstation-recording", "-sw"))
def test_only_osd_notifications_are_transient(self):
with patch.object(actions, "run") as run:
actions.notify("Saved", "Screenshot")
self.assertNotIn("int:transient:1", run.call_args.args)
actions.notify("Volume", "50%", 50)
self.assertIn("int:transient:1", run.call_args.args)
def test_display_timeout_restores_scale(self):
monitor = '[{"name":"eDP-1","width":1920,"height":1200,"scale":1.5}]'
calls = []
def fake(*args, **kwargs):
calls.append(args)
if args[0] == "fuzzel":
raise subprocess.TimeoutExpired(args, 15)
return subprocess.CompletedProcess(args, 0, "ok")
with patch.object(actions, "output", return_value=monitor), patch.object(actions, "pick", side_effect=[0, 0]), patch.object(actions, "run", side_effect=fake):
actions.displays()
self.assertIn("scale=1}", calls[0][-1])
self.assertIn("scale=1.5}", calls[-1][-1])
unittest.main()
+520
View File
@@ -0,0 +1,520 @@
"""Small desktop actions, not a shell framework. No selected text is executed.
Control state is session-local; explicit captures use XDG media directories.
Dependencies are supplied by desktop.nix.
"""
import datetime
import fcntl
import json
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess as sp
import sys
import time
from urllib.parse import quote_plus
def run(*args, check=True, **kwargs):
return sp.run(list(args), check=check, **kwargs)
def output(*args):
return run(*args, capture_output=True, text=True).stdout.strip()
def notify(title, body="", value=None):
args = ["notify-send", "-a", "Workstation"]
if value is not None:
# OSD replaces only OSD, never a saved screenshot or action failure.
args += ["-h", "string:x-canonical-private-synchronous:workstation-osd",
"-h", "int:transient:1", "-h", f"int:value:{max(0, min(100, int(value)))}", "-t", "1500"]
run(*args, title, body, check=False)
def pick(prompt, choices):
"""Return a validated index; Escape/custom unmatched input never acts."""
if not choices:
notify(prompt, "Nothing available")
return None
labels = [re.sub(r"[\x00-\x1f\x7f]", " ", str(x)) for x in choices]
result = run("fuzzel", "--dmenu", "--index", "--prompt", prompt + " ",
input="\n".join(labels), text=True, capture_output=True, check=False)
if result.returncode != 0:
return None
try:
index = int(result.stdout.strip())
except ValueError:
return None
return index if 0 <= index < len(choices) else None
def confirm(action):
return pick(action + "?", ["Cancel", action]) == 1
def runtime():
path = Path(os.environ["XDG_RUNTIME_DIR"]) / "workstation"
path.mkdir(mode=0o700, exist_ok=True)
return path
def clients():
return json.loads(output("hyprctl", "-j", "clients"))
def dispatch(expression):
response = output("hyprctl", "dispatch", expression)
if response not in ("", "ok"):
raise RuntimeError(response)
def lua(value):
if isinstance(value, str):
# Lua does not support JSON's \\uXXXX escapes. Fixed-width decimal byte
# escapes also keep newlines, quotes and arbitrary device names inert.
return '"' + ''.join(f'\\{byte:03d}' for byte in value.encode()) + '"'
return json.dumps(value, allow_nan=False)
def address(value):
if not re.fullmatch(r"0x[0-9a-fA-F]+", value):
raise ValueError("Invalid compositor window address")
return "address:" + value
def window_picker():
windows = sorted(clients(), key=lambda c: (c["workspace"]["id"], c.get("focusHistoryID", 0)))
labels = [f'{c["workspace"]["name"]} · {c["class"]}{c["title"]}' for c in windows]
index = pick("Windows", labels)
if index is not None:
dispatch("hl.dsp.focus({window=" + lua(address(windows[index]["address"])) + "})")
def scratch():
existing = any(c["class"] == "dropterminal" for c in clients())
dispatch('hl.dsp.workspace.toggle_special("terminal")')
if not existing:
run("uwsm", "app", "--", "kitty", "--class", "dropterminal")
def clipboard(action):
# Lock checks and database writes together: an in-flight store cannot put
# a clipboard item back AFTER the lock-screen wipe has completed.
if action not in {"store", "clear"}:
_clipboard(action)
return
with (runtime() / "clipboard.lock").open("w") as handle:
fcntl.flock(handle, fcntl.LOCK_EX)
_clipboard(action)
def _clipboard(action):
root = runtime()
env = dict(os.environ, CLIPHIST_DB_PATH=str(root / "clipboard.db"), CLIPHIST_MAX_ITEMS="200")
database = root / "clipboard.db"
if action == "store":
if os.environ.get("CLIPBOARD_STATE") in ("sensitive", "nil", "clear"):
return
if (root / "clipboard-paused").exists() or (root / "locked").exists():
return
run("cliphist", "store", env=env)
elif action == "clear":
# cliphist wipe leaves its database file (and free pages) behind.
# Stores are serialized above, so remove the session database itself.
database.unlink(missing_ok=True)
elif action == "pause":
marker = root / "clipboard-paused"
if marker.exists():
marker.unlink()
notify("Clipboard history resumed")
else:
marker.touch(mode=0o600)
notify("Clipboard history paused")
elif action in ("pick", "delete"):
if not database.exists():
notify("Clipboard history", "No items in this session")
return
rows = run("cliphist", "list", env=env, capture_output=True, text=True).stdout.splitlines()
index = pick("Clipboard" if action == "pick" else "Delete clipboard item", rows)
if index is None:
return
row = rows[index] + "\n"
if action == "delete":
run("cliphist", "delete", env=env, input=row, text=True)
else:
data = run("cliphist", "decode", env=env, input=row.encode(), capture_output=True).stdout
run("wl-copy", input=data)
def lock_start():
root = runtime()
with (root / "locker.lock").open("w") as handle:
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return
(root / "locked").touch(mode=0o600)
try:
clipboard("clear")
except (OSError, sp.SubprocessError) as error:
# Clipboard trouble must NEVER prevent the screen from locking.
print(f"Clipboard wipe failed before lock: {error}", file=sys.stderr)
result = run("hyprlock", check=False)
# A crash must not resume clipboard collection on a still-locked session.
if result.returncode == 0:
(root / "locked").unlink(missing_ok=True)
else:
raise RuntimeError("Locker failed; clipboard history remains paused")
def power():
choices = ["Lock", "Suspend", "Log out", "Reboot", "Shut down"]
index = pick("Session", choices)
if index is None:
return
if index == 0:
run("loginctl", "lock-session")
elif confirm(choices[index]):
if index == 2:
run("uwsm", "stop")
else:
run("systemctl", {1: "suspend", 3: "reboot", 4: "poweroff"}[index])
def capture_geometry(mode):
if mode == "area":
result = run("slurp", capture_output=True, text=True, check=False)
if result.returncode != 0 or not result.stdout.strip():
return None
return ["-g", result.stdout.strip()]
if mode == "window":
window = json.loads(output("hyprctl", "-j", "activewindow"))
if not window.get("address"):
return None
x, y = map(int, window["at"])
w, h = map(int, window["size"])
if w <= 0 or h <= 0:
return None
return ["-g", f"{x},{y} {w}x{h}"]
if mode == "output":
monitors = json.loads(output("hyprctl", "-j", "monitors"))
return ["-o", next(m["name"] for m in monitors if m["focused"])]
if mode == "all":
return []
raise ValueError("Unknown capture mode")
def destination(kind, extension):
# User's XDG directory setting, with a conventional fallback.
key = "PICTURES" if kind == "Screenshots" else "VIDEOS"
base = Path(output("xdg-user-dir", key))
folder = base / kind
folder.mkdir(mode=0o700, parents=True, exist_ok=True)
stamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S_%f")
return folder / (stamp + extension)
def screenshot(mode="area", annotate=False, delay=0, copy_only=False):
if delay:
notify("Screenshot", f"Capturing in {delay} seconds")
time.sleep(delay)
geometry = capture_geometry(mode)
if geometry is None:
return
image = run("grim", *geometry, "-", capture_output=True).stdout
if annotate:
target = destination("Screenshots", ".png")
run("satty", "--filename", "-", "--copy-command", "wl-copy",
"--output-filename", str(target), input=image)
else:
run("wl-copy", "--type", "image/png", input=image)
if not copy_only:
target = destination("Screenshots", ".png")
target.write_bytes(image)
notify("Screenshot saved and copied", str(target))
else:
notify("Screenshot copied")
def screenshot_menu():
actions = [("Region · annotate", "area", True, 0, False),
("Region · copy only", "area", False, 0, True),
("Active window", "window", False, 0, False),
("Current display", "output", False, 0, False),
("All displays", "all", False, 0, False),
("Current display · 5 second delay", "output", False, 5, False),
("Current display · 10 second delay", "output", False, 10, False)]
index = pick("Screenshot", [a[0] for a in actions])
if index is not None:
screenshot(*actions[index][1:])
def recording():
return run("systemctl", "--user", "is-active", "--quiet", "desktop-recording.service", check=False).returncode == 0
def record():
root = runtime()
# Serialize two rapid keypresses so they cannot start competing recorders.
with (root / "record.lock").open("w") as handle:
fcntl.flock(handle, fcntl.LOCK_EX)
if recording():
run("systemctl", "--user", "stop", "desktop-recording.service")
notify("Recording saved", (root / "recording-file").read_text() if (root / "recording-file").exists() else "")
return
index = pick("Record", ["Region · no audio", "Display · no audio", "Display · desktop audio"])
if index is None:
return
geometry = capture_geometry("area" if index == 0 else "output")
if geometry is None:
return
args = list(geometry)
if index == 2:
# Explicit monitor source: never silently record the microphone.
sink = output("pactl", "get-default-sink")
args += ["--audio=" + sink + ".monitor"]
target = destination("Recordings", ".mkv")
run("systemd-run", "--user", "--collect", "--unit=desktop-recording",
"--property=KillSignal=SIGINT", "--property=TimeoutStopSec=20",
"--property=PartOf=graphical-session.target", "--property=UMask=0077",
"--setenv=PATH=" + os.environ["PATH"], "--",
shutil.which("desktop"), "record-run", *args, "-f", str(target))
(root / "recording-file").write_text(str(target))
# The REC bar indicator is the start feedback. A popup here can race
# the recorder's inhibitor and end up embedded in the captured video.
def record_run(args):
# A separate inhibitor does not overwrite the user's DND preference. The
# unit sends SIGINT to both processes; let wf-recorder finish its container.
signal.signal(signal.SIGINT, lambda *_: None)
run("swaync-client", "-Ia", "workstation-recording", "-sw")
try:
run("wf-recorder", *args)
finally:
run("swaync-client", "-Ir", "workstation-recording", "-sw", check=False)
def osd(kind, change):
if kind in ("volume", "microphone"):
device = "@DEFAULT_AUDIO_SOURCE@" if kind == "microphone" else "@DEFAULT_AUDIO_SINK@"
if change == "mute":
run("wpctl", "set-mute", device, "toggle")
else:
run("wpctl", "set-volume", "-l", "1", device, "5%+" if change == "up" else "5%-")
value = output("wpctl", "get-volume", device)
percent = round(float(value.split()[1]) * 100)
notify("Microphone" if kind == "microphone" else "Volume", "Muted" if "MUTED" in value else f"{percent}%", percent)
else:
selector = ["-c", "leds", "-d", "*kbd_backlight*"] if kind == "keyboard" else ["-c", "backlight"]
run("brightnessctl", *selector, "--min-value=1", "set", "+5%" if change == "up" else "5%-")
value = output("brightnessctl", *selector, "-m")
percent = int(value.split(",")[3].rstrip("%"))
notify("Keyboard backlight" if kind == "keyboard" else "Brightness", f"{percent}%", percent)
def night():
current = output("hyprctl", "hyprsunset", "temperature")
temperature = int(current)
run("hyprctl", "hyprsunset", "identity" if temperature < 6000 else "temperature", *([] if temperature < 6000 else ["4200"]))
notify("Night light", "Off" if temperature < 6000 else "4200 K")
def displays():
monitors = json.loads(output("hyprctl", "-j", "monitors"))
choices = [f'{m["name"]} · {m["width"]}×{m["height"]} · {m["scale"]:g}×' for m in monitors]
index = pick("Display", choices)
if index is None:
return
m = monitors[index]
actions = ["Scale 100%", "Scale 125%", "Scale 150%", "Scale 175%", "Scale 200%", "Restore declared configuration"]
action = pick(m["name"], actions)
if action is None:
return
if action == 5:
run("hyprctl", "reload")
return
scale = [1, 1.25, 1.5, 1.75, 2][action]
def set_scale(value):
# Scaling must not silently change refresh rate, dock position or rotation.
mode = f'{m["width"]}x{m["height"]}@{m.get("refreshRate", 60)}'
position = f'{m.get("x", 0)}x{m.get("y", 0)}'
run("hyprctl", "eval", 'hl.monitor({output=' + lua(m["name"]) + ',mode=' + lua(mode)
+ ',position=' + lua(position) + ',transform=' + str(m.get("transform", 0))
+ ',scale=' + str(value) + '})')
set_scale(scale)
# An unattended or invisible confirmation must revert, not strand the user.
try:
result = run("fuzzel", "--dmenu", "--index", "--prompt", "Keep display scale? ",
input="Revert\nKeep\n", text=True, capture_output=True, check=False, timeout=15)
keep = result.returncode == 0 and result.stdout.strip() == "1"
except sp.TimeoutExpired:
keep = False
if not keep:
set_scale(m["scale"])
def web_search():
result = run("fuzzel", "--dmenu", "--prompt", "Search web ", input="", text=True, capture_output=True, check=False)
if result.returncode == 0 and result.stdout.strip():
run("uwsm", "app", "--", "xdg-open", "https://duckduckgo.com/?q=" + quote_plus(result.stdout.strip()))
def emoji():
entries = []
for line in Path(os.environ["DESKTOP_EMOJI_FILE"]).read_text().splitlines():
if "; fully-qualified" not in line:
continue
code, detail = line.split("#", 1)
character = "".join(chr(int(c, 16)) for c in code.split(";", 1)[0].split())
description = re.sub(r"^\S+\s+E[\d.]+\s+", "", detail.strip())
entries.append((character, character + " " + description))
index = pick("Emoji", [entry[1] for entry in entries])
if index is not None:
run("wl-copy", input=entries[index][0].encode())
notify("Emoji copied")
def touchpad():
devices = json.loads(output("hyprctl", "-j", "devices"))
pads = [device["name"] for device in devices["mice"] if re.search("touchpad|trackpad", device["name"], re.I)]
if not pads:
notify("Touchpad", "No touchpad detected")
return
marker = runtime() / "touchpad-disabled"
enabled = marker.exists()
for name in pads:
run("hyprctl", "eval", "hl.device({name=" + lua(name) + ",enabled=" + lua(enabled) + "})")
if enabled:
marker.unlink()
else:
marker.touch(mode=0o600)
notify("Touchpad", "Enabled" if enabled else "Disabled")
def airplane():
disabled = output("nmcli", "radio", "wifi") == "disabled"
if not disabled and not confirm("Disable wireless radios"):
return
run("nmcli", "radio", "wifi", "on" if disabled else "off")
run("rfkill", "unblock" if disabled else "block", "bluetooth")
notify("Airplane mode", "Off" if disabled else "On")
def battery():
last = None
while True:
for path in Path("/sys/class/power_supply").glob("*"):
if (path / "type").read_text().strip() != "Battery" or not (path / "capacity").exists():
continue
capacity = int((path / "capacity").read_text())
discharging = (path / "status").read_text().strip() == "Discharging"
level = "critical" if capacity <= 10 else "low" if capacity <= 20 else None
if discharging and level and level != last:
run("notify-send", "-u", "critical", "Battery " + level, f"{capacity}% remaining — connect power")
last = level if discharging else None
time.sleep(60)
def health():
print("WORKSTATION HEALTH\n")
for label, path in [("Running", "/run/current-system"), ("Booted", "/run/booted-system"),
("Selected for boot", "/nix/var/nix/profiles/system")]:
print(f"{label}: {Path(path).resolve()}")
if Path("/run/booted-system").resolve() != Path("/nix/var/nix/profiles/system").resolve():
print("\nA different generation is selected for the next boot. No automatic reboot.")
run("systemctl", "show", "nixos-update.service", "-p", "ActiveState", "-p", "Result", "-p", "ExecMainStatus")
run("systemctl", "list-timers", "nixos-update.timer", "--no-pager")
run("systemctl", "--failed", "--no-pager")
run("systemctl", "--user", "--failed", "--no-pager")
print("\nRecent update journal (not a package-availability count):", flush=True)
run("journalctl", "-u", "nixos-update.service", "-n", "30", "--no-pager", check=False)
def status():
while True:
active = recording()
print(json.dumps({"text": "REC" if active else "", "alt": "recording" if active else "idle"}), flush=True)
time.sleep(2)
def menu():
entries = [
("Applications / calculator", ["anyrun"]),
("Help · all shortcuts", ["desktop-help"]),
("Windows · overview", ["desktop", "windows"]),
("Notifications", ["desktop", "notifications"]),
("Do not disturb · toggle", ["desktop", "dnd"]),
("Clipboard history", ["desktop", "clipboard"]),
("Clipboard · delete an item", ["desktop", "clipboard-delete"]),
("Clipboard · clear", ["desktop", "clipboard-clear"]),
("Clipboard · pause/resume", ["desktop", "clipboard-pause"]),
("Screenshot", ["desktop", "screenshot-menu"]),
("Recording · start/stop", ["desktop", "record"]),
("Color picker · copy HEX", ["hyprpicker", "--autocopy"]),
("Emoji · copy", ["desktop", "emoji"]),
("Search web", ["desktop", "search"]),
("Audio · outputs and per-app volume", ["pavucontrol"]),
("Microphone · input devices", ["pavucontrol", "-t", "4"]),
("Network / VPN connections", ["nm-connection-editor"]),
("Bluetooth devices", ["blueman-manager"]),
("Airplane mode · toggle", ["desktop", "airplane"]),
("Touchpad · toggle", ["desktop", "touchpad"]),
("Night light · toggle", ["desktop", "night"]),
("Display scale · temporary", ["desktop", "displays"]),
("Power profile", ["desktop", "power-profile"]),
("File manager", ["thunar"]),
("System monitor", ["kitty", "-e", "btop"]),
("System / update health", ["kitty", "--hold", "-e", "desktop", "health"]),
("Session / power", ["desktop", "power"]),
]
index = pick("Workstation", [entry[0] for entry in entries])
if index is not None:
run("uwsm", "app", "--", *entries[index][1])
def main():
command = sys.argv[1] if len(sys.argv) > 1 else "menu"
if command in {"clipboard", "clipboard-store", "clipboard-clear", "clipboard-delete", "clipboard-pause"}:
clipboard({"clipboard": "pick"}.get(command, command.removeprefix("clipboard-")))
elif command == "screenshot":
screenshot(sys.argv[2] if len(sys.argv) > 2 else "area", annotate="--annotate" in sys.argv,
delay=10 if "--delay10" in sys.argv else 5 if "--delay5" in sys.argv else 0)
elif command == "record-run":
record_run(sys.argv[2:])
elif command == "health":
health()
elif command == "osd":
osd(*sys.argv[2:4])
elif command == "notifications":
run("swaync-client", "-t", "-sw")
elif command == "dnd":
run("swaync-client", "-d", "-sw")
elif command == "power-profile":
profiles = output("powerprofilesctl", "list").splitlines()
names = [p.strip().strip("* ").rstrip(":") for p in profiles if p.rstrip().endswith(":") and p.strip().strip("* ").rstrip(":") in ("balanced", "power-saver", "performance")]
index = pick("Power profile", names)
if index is not None:
run("powerprofilesctl", "set", names[index])
elif command in {"menu", "windows", "scratch", "power", "screenshot-menu", "record", "night", "displays", "search", "lock-start", "battery", "status", "emoji", "touchpad", "airplane"}:
{"menu": menu, "windows": window_picker, "scratch": scratch, "power": power,
"screenshot-menu": screenshot_menu, "record": record, "night": night,
"displays": displays, "search": web_search, "lock-start": lock_start,
"battery": battery, "status": status, "emoji": emoji,
"touchpad": touchpad, "airplane": airplane}[command]()
else:
raise ValueError("Unknown desktop action: " + command)
if __name__ == "__main__":
try:
os.umask(0o077)
main()
except (OSError, ValueError, KeyError, RuntimeError, sp.SubprocessError) as error:
print(f"desktop: {error}", file=sys.stderr)
if len(sys.argv) < 2 or sys.argv[1] not in ("clipboard-store", "status"):
notify("Desktop action failed", str(error))
sys.exit(1)
+47
View File
@@ -0,0 +1,47 @@
"""Read-only shortcut search from Hyprland's running binding registry.
Descriptions come from native Lua bindings, not a second hand-maintained cheat
sheet. Choosing a row does NOT execute its command (in particular power/close).
"""
import json
import subprocess
import sys
def rows(bindings):
modifiers = [(64, "Super"), (4, "Ctrl"), (8, "Alt"), (1, "Shift")]
result = []
for binding in bindings:
description = binding.get("description", "")
if not description:
continue
keys = [name for bit, name in modifiers if binding.get("modmask", 0) & bit]
keys.append(binding.get("key") or f"code:{binding.get('keycode', 0)}")
submap = binding.get("submap")
context = f" [{submap}]" if submap else ""
result.append(f"{description}{context} {' + '.join(keys)}")
return sorted(set(result), key=str.casefold)
def main():
try:
data = json.loads(subprocess.check_output(["hyprctl", "-j", "binds"], text=True))
entries = rows(data)
if "--print" in sys.argv:
print("\n".join(entries))
return
if not entries:
raise ValueError("No described shortcuts; reload the managed Hyprland configuration.")
result = subprocess.run(
["fuzzel", "--dmenu", "--prompt", "Shortcuts ", "--width", "68", "--lines", "12"],
input="\n".join(entries), text=True, stdout=subprocess.DEVNULL,
)
if result.returncode not in (0, 1):
raise RuntimeError(f"Shortcut picker exited with {result.returncode}")
except (OSError, ValueError, subprocess.CalledProcessError, RuntimeError) as error:
print(f"desktop-help: {error}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+10 -9
View File
@@ -15,15 +15,15 @@ pkgs.testers.runNixOSTest {
nodes.machine = { pkgs, ... }: { nodes.machine = { pkgs, ... }: {
imports = [ imports = [
inputs.home-manager.nixosModules.home-manager inputs.home-manager.nixosModules.home-manager
./users.nix ./common.nix
./tools.nix
./network.nix
./neovim.nix
./desktop.nix
./apps.nix
./workstation.nix ./workstation.nix
]; ];
system.stateVersion = "26.05"; # Exercise the shared host policy, but never run automatic updates in a VM.
systemd.services.nixos-update.environment = {
NIXOS_CONFIG_REPO = "/etc/nix";
NIXOS_UPDATE_HOST = "nixos";
};
systemd.timers.nixos-update.enable = false;
boot.blacklistedKernelModules = [ "floppy" ]; boot.blacklistedKernelModules = [ "floppy" ];
virtualisation = { virtualisation = {
memorySize = 6144; memorySize = 6144;
@@ -58,9 +58,10 @@ pkgs.testers.runNixOSTest {
mkpasswd --method=sha-512 --salt=nixostest desktop-test > "$out" mkpasswd --method=sha-512 --salt=nixostest desktop-test > "$out"
'' ''
); );
services.greetd.settings.initial_session = { # The same SDDM/UWSM session as the laptop; autologin is test-only.
services.displayManager.autoLogin = {
enable = true;
user = "dev"; user = "dev";
command = "${pkgs.uwsm}/bin/uwsm start -e -D Hyprland hyprland.desktop";
}; };
}; };
testScript = builtins.readFile ./desktop-test.py; testScript = builtins.readFile ./desktop-test.py;
+66 -8
View File
@@ -35,7 +35,8 @@ def screenshot(name):
def launch(name, command): def launch(name, command):
user("systemd-run --quiet --user --collect --unit=audit-" + name + " " + command) # Match UWSM's application lifetime: clipboard owners can outlive main PID.
user("systemd-run --quiet --user --collect --property=ExitType=cgroup --unit=audit-" + name + " " + command)
machine.start() machine.start()
@@ -45,8 +46,7 @@ try:
"runuser -u dev -- env XDG_RUNTIME_DIR=/run/user/1001 " "runuser -u dev -- env XDG_RUNTIME_DIR=/run/user/1001 "
"systemctl --user is-active graphical-session.target", timeout=180 "systemctl --user is-active graphical-session.target", timeout=180
) )
# Mako is D-Bus activated on the first notification, not eagerly started. for unit in ["ashell", "awww", "hypridle", "hyprpolkitagent", "pipewire", "wireplumber", "swaync", "hyprsunset", "desktop-clipboard", "anyrun"]:
for unit in ["ashell", "awww", "hypridle", "hyprpolkitagent", "pipewire", "wireplumber"]:
machine.wait_until_succeeds( machine.wait_until_succeeds(
"runuser -u dev -- env XDG_RUNTIME_DIR=/run/user/1001 " "runuser -u dev -- env XDG_RUNTIME_DIR=/run/user/1001 "
"systemctl --user is-active " + unit + ".service", timeout=60 "systemctl --user is-active " + unit + ".service", timeout=60
@@ -72,13 +72,15 @@ assert "0.42" in session("wpctl get-volume @DEFAULT_AUDIO_SINK@")
session("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ 1") session("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ 1")
assert "MUTED" in session("wpctl get-volume @DEFAULT_AUDIO_SOURCE@") assert "MUTED" in session("wpctl get-volume @DEFAULT_AUDIO_SOURCE@")
session("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ 0") session("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ 0")
machine.succeed("systemctl is-active systemd-resolved") machine.succeed("systemctl is-active NetworkManager display-manager")
machine.succeed("test $(stat -c %U /etc/nix) = dev")
machine.succeed("test $(stat -c %U /var/cache/nixos-update) = dev")
launch("terminal", "kitty --title 'Workspace ready' sh -c " + shlex.quote( launch("terminal", "kitty --title 'Workspace ready' sh -c " + shlex.quote(
"printf '\\n WORKSPACE READY\\n\\n'; " "printf '\\n WORKSPACE READY\\n\\n'; "
"zsh --version; kitty --version; git --version; printf 'Pi: '; command -v pi; " "zsh --version; kitty --version; git --version; printf 'Pi: '; command -v pi; "
"printf '\\n Ctrl-R history | Ctrl-T files\\n'; " "printf '\\n Ctrl-R history | Ctrl-T files\\n'; "
"printf ' Alt-C directories | Super-Space launcher\\n'; " "printf ' Alt-C directories | Super-D launcher\\n'; "
"printf ' Super-Enter terminal | Super-F fullscreen\\n\\n'; exec zsh -i" "printf ' Super-Enter terminal | Super-F fullscreen\\n\\n'; exec zsh -i"
)) ))
launch("monitor", "kitty --title 'System monitor' -e btop") launch("monitor", "kitty --title 'System monitor' -e btop")
@@ -88,13 +90,46 @@ screenshot("desktop-100")
launch("launcher", "anyrun") launch("launcher", "anyrun")
wait_layer("anyrun") wait_layer("anyrun")
machine.sleep(5) machine.sleep(2)
screenshot("launcher-empty-100")
machine.send_chars("kitty", delay=0.1) machine.send_chars("kitty", delay=0.1)
machine.sleep(5) machine.sleep(5)
screenshot("launcher-100") screenshot("launcher-100")
machine.send_key("esc") machine.send_key("esc")
# Exercise clipboard through the actual picker, not only mocked byte handling.
session("desktop clipboard-clear")
launch("clipboard-source", "sh -c " + shlex.quote("printf 'clipboard audit marker' | wl-copy --foreground"))
machine.wait_until_succeeds(as_user("test -s /run/user/1001/workstation/clipboard.db"), timeout=30)
launch("clipboard", "desktop clipboard")
wait_layer("launcher")
machine.send_key("ret")
machine.sleep(1)
assert session("wl-paste --no-newline") == "clipboard audit marker"
# Record only this disposable desktop; no host screen/audio is captured.
launch("record", "desktop record")
wait_layer("launcher")
machine.send_key("down") # current output, no audio
machine.send_key("ret")
machine.wait_until_succeeds(as_user("systemctl --user is-active desktop-recording.service"))
machine.sleep(3)
assert session("swaync-client -I -sw").strip() == "true"
screenshot("recording-indicator-100")
session("desktop record")
assert session("swaync-client -I -sw").strip() == "false"
video = session('cat "$XDG_RUNTIME_DIR/workstation/recording-file"').strip()
probe = json.loads(session("ffprobe -v error -show_streams -of json " + shlex.quote(video)))
assert any(stream["codec_type"] == "video" for stream in probe["streams"])
assert not any(stream["codec_type"] == "audio" for stream in probe["streams"])
session("notify-send 'Desktop ready' 'Readable text, working audio and native Wayland services.'") session("notify-send 'Desktop ready' 'Readable text, working audio and native Wayland services.'")
assert '"mako"' in user("busctl --user call org.freedesktop.Notifications /org/freedesktop/Notifications org.freedesktop.Notifications GetServerInformation") assert "sway" in user("busctl --user call org.freedesktop.Notifications /org/freedesktop/Notifications org.freedesktop.Notifications GetServerInformation").lower()
assert "Clipboard" in session("desktop-help --print")
session("swaync-client -t -sw")
machine.sleep(1)
screenshot("notification-center-100")
session("swaync-client -t -sw")
machine.sleep(1) machine.sleep(1)
screenshot("notification-100") screenshot("notification-100")
@@ -105,6 +140,26 @@ session("hyprctl eval " + shlex.quote(
)) ))
machine.sleep(4) machine.sleep(4)
assert json.loads(session("hyprctl -j monitors"))[0]["scale"] == 1.5 assert json.loads(session("hyprctl -j monitors"))[0]["scale"] == 1.5
launch("launcher150", "anyrun")
wait_layer("anyrun")
machine.sleep(2)
screenshot("launcher-empty-150")
# closeOnClick uses a transparent full-screen catcher; its IPC dimensions are
# not the visible palette's bounds. Audit the rendered six-result screenshot.
machine.send_chars("e")
machine.sleep(1)
screenshot("launcher-results-150")
session("anyrun close")
launch("help150", "desktop-help")
wait_layer("launcher")
machine.sleep(1)
screenshot("help-150")
machine.send_key("esc")
launch("actions150", "desktop menu")
wait_layer("launcher")
machine.sleep(1)
screenshot("actions-150")
machine.send_key("esc")
# At 150%, a half-screen btop is below its 80-column minimum: use Super-F. # At 150%, a half-screen btop is below its 80-column minimum: use Super-F.
# Closing the launcher can restore focus to either terminal; select btop explicitly. # Closing the launcher can restore focus to either terminal; select btop explicitly.
monitor = next(c for c in json.loads(session("hyprctl -j clients")) if c["initialTitle"] == "System monitor") monitor = next(c for c in json.loads(session("hyprctl -j clients")) if c["initialTitle"] == "System monitor")
@@ -127,6 +182,7 @@ machine.succeed("pgrep -u dev hyprlock")
machine.send_chars("desktop-test") machine.send_chars("desktop-test")
machine.send_key("ret") machine.send_key("ret")
machine.wait_until_fails("pgrep -u dev hyprlock", timeout=30) machine.wait_until_fails("pgrep -u dev hyprlock", timeout=30)
assert user('test ! -e /run/user/1001/workstation/locked; test ! -e /run/user/1001/workstation/clipboard.db') == ""
# Restore scale before inspecting settings and ordinary application windows. # Restore scale before inspecting settings and ordinary application windows.
session("hyprctl eval " + shlex.quote( session("hyprctl eval " + shlex.quote(
@@ -142,7 +198,9 @@ screenshot("audio-controls")
# Preferences must not be a read-only Home Manager symlink. # Preferences must not be a read-only Home Manager symlink.
user("test -w ~/.config/keepassxc/keepassxc.ini && test ! -L ~/.config/keepassxc/keepassxc.ini") user("test -w ~/.config/keepassxc/keepassxc.ini && test ! -L ~/.config/keepassxc/keepassxc.ini")
user("grep -q 'UpdateBinaryPath=false' ~/.config/keepassxc/keepassxc.ini") user("grep -q 'UpdateBinaryPath=false' ~/.config/keepassxc/keepassxc.ini")
user("grep -q -- '--password-store=gnome-libsecret' \"$(command -v element-desktop)\"") user("grep -q -- '--password-store=gnome-libsecret' \"$(command -v element-desktop-nightly)\"")
user("test -f /etc/profiles/per-user/dev/share/applications/element-desktop-nightly.desktop")
user("test ! -e /etc/profiles/per-user/dev/share/applications/element-desktop.desktop")
user("grep -Eq 'fade_on_empty *= *false' ~/.config/hypr/hyprlock.conf") user("grep -Eq 'fade_on_empty *= *false' ~/.config/hypr/hyprlock.conf")
assert "libapplications.so" in user("cat ~/.config/anyrun/config.ron") assert "libapplications.so" in user("cat ~/.config/anyrun/config.ron")
session("hyprctl clients") session("hyprctl clients")
+262 -69
View File
@@ -7,9 +7,22 @@
let let
c = import ./colors.nix; c = import ./colors.nix;
wallpaper = pkgs.runCommand "quiet-orbit.png" { nativeBuildInputs = [ pkgs.resvg ]; } '' renderColors =
resvg ${./wallpaper.svg} "$out" text:
''; builtins.replaceStrings (map (name: "@${name}@") (
builtins.attrNames c
)) (builtins.attrValues c) text;
rgb = color: "rgb(${lib.removePrefix "#" color})";
wallpaper = import ./wallpaper.nix { inherit pkgs; };
help = pkgs.writeShellApplication {
name = "desktop-help";
runtimeInputs = [
pkgs.python3
pkgs.hyprland
pkgs.fuzzel
];
text = ''exec python ${./desktop-help.py} "$@"'';
};
wallpaperInit = pkgs.writeShellApplication { wallpaperInit = pkgs.writeShellApplication {
name = "initialize-wallpaper"; name = "initialize-wallpaper";
runtimeInputs = [ runtimeInputs = [
@@ -28,23 +41,42 @@ let
exit 1 exit 1
''; '';
}; };
screenshot = pkgs.writeShellApplication { actions = pkgs.writeShellApplication {
name = "desktop-screenshot"; name = "desktop";
runtimeInputs = with pkgs; [ runtimeInputs = with pkgs; [
python3
hyprland
uwsm
systemd
fuzzel
libnotify
cliphist
wl-clipboard
grim grim
slurp slurp
satty satty
wl-clipboard wf-recorder
coreutils wireplumber
pulseaudio
brightnessctl
power-profiles-daemon
xdg-user-dirs
xdg-utils
swaynotificationcenter
hyprlock
networkmanager
util-linux
]; ];
text = '' text = ''
geometry=$(slurp) || exit 0 export DESKTOP_EMOJI_FILE=${pkgs.unicode-emoji.emoji-test}/share/unicode/emoji/emoji-test.txt
[ -n "$geometry" ] || exit 0 exec python ${./desktop-actions.py} "$@"
mkdir -p "$HOME/Pictures/Screenshots"
grim -g "$geometry" - | satty --filename - --copy-command wl-copy \
--output-filename "$HOME/Pictures/Screenshots/$(date +%Y-%m-%d_%H-%M-%S).png"
''; '';
}; };
screenshot = pkgs.writeShellApplication {
name = "desktop-screenshot";
runtimeInputs = [ actions ];
text = ''exec desktop screenshot area --annotate "$@"'';
};
launcherExec = pkgs.writeShellScript "anyrun-uwsm" '' launcherExec = pkgs.writeShellScript "anyrun-uwsm" ''
kind=$1 kind=$1
shift shift
@@ -93,6 +125,10 @@ in
slurp slurp
satty satty
screenshot screenshot
actions
help
hyprpicker
wf-recorder
pavucontrol pavucontrol
playerctl playerctl
brightnessctl brightnessctl
@@ -123,6 +159,7 @@ in
}; };
xdg.autostart.enable = true; xdg.autostart.enable = true;
home.pointerCursor = { home.pointerCursor = {
enable = true;
package = pkgs.bibata-cursors; package = pkgs.bibata-cursors;
name = "Bibata-Modern-Ice"; name = "Bibata-Modern-Ice";
size = 24; size = 24;
@@ -155,7 +192,8 @@ in
dconf.settings."org/gnome/desktop/interface" = { dconf.settings."org/gnome/desktop/interface" = {
color-scheme = "prefer-dark"; color-scheme = "prefer-dark";
font-name = "Inter 11"; font-name = "Inter 11";
monospace-font-name = "JetBrainsMono Nerd Font 13"; monospace-font-name = "JetBrainsMono Nerd Font 12";
accent-color = "yellow";
}; };
wayland.windowManager.hyprland = { wayland.windowManager.hyprland = {
@@ -164,7 +202,7 @@ in
portalPackage = null; portalPackage = null;
systemd.enable = false; # UWSM owns the session and environment. systemd.enable = false; # UWSM owns the session and environment.
configType = "lua"; configType = "lua";
extraConfig = builtins.readFile ./hyprland.lua; extraConfig = renderColors (builtins.readFile ./hyprland.lua);
}; };
services.hyprpolkitagent.enable = true; services.hyprpolkitagent.enable = true;
services.awww.enable = true; services.awww.enable = true;
@@ -180,21 +218,25 @@ in
settings = { settings = {
log_level = "warn"; log_level = "warn";
position = "Top"; position = "Top";
layer = "Top";
modules = { modules = {
left = [ left = [
[ [
"appLauncher" "appLauncher"
"Workspaces" "Workspaces"
"desktopHelp"
] ]
"WindowTitle"
]; ];
center = [ "Tempo" ]; center = [ "Tempo" ];
right = [ right = [
"recording"
"MediaPlayer"
"SystemInfo" "SystemInfo"
[ "notifications"
"Tray" "Tray"
"Privacy" "Privacy"
"Settings" "Settings"
]
]; ];
}; };
CustomModule = [ CustomModule = [
@@ -203,22 +245,74 @@ in
icon = "󰍜"; icon = "󰍜";
command = "uwsm app -- anyrun"; command = "uwsm app -- anyrun";
} }
{
name = "desktopHelp";
icon = "󰋖";
command = "uwsm app -- desktop-help";
}
{
name = "notifications";
icon = "󰂚";
command = "uwsm app -- desktop notifications";
listen_cmd = "swaync-client -swb";
icons."dnd.*" = "󰂛";
alert = "notification";
}
{
name = "recording";
command = "uwsm app -- desktop record";
listen_cmd = "desktop status";
alert = "recording";
}
]; ];
tempo.clock_format = "%a %d %b %H:%M"; enable_esc_key = true;
workspaces = { media_player.max_title_length = 18;
visibility_mode = "MonitorSpecific"; window_title = {
enable_workspace_filling = true; mode = "Title";
truncate_title_after_length = 26;
}; };
system_info = { system_info = {
indicators = [ indicators = [
"Cpu" "Cpu"
"Memory" "Memory"
"Temperature"
]; ];
interval = 5; interval = 5;
}; };
tempo.clock_format = "%a %d %b %H:%M";
workspaces = {
visibility_mode = "MonitorSpecific";
enable_workspace_filling = false;
workspace_names = [
"01"
"02"
"03"
"04"
"05"
"06"
"07"
"08"
"09"
"10"
];
};
settings = { settings = {
lock_cmd = "loginctl lock-session"; lock_cmd = "loginctl lock-session";
logout_cmd = "uwsm stop"; logout_cmd = "uwsm app -- desktop power";
shutdown_cmd = "uwsm app -- desktop power";
reboot_cmd = "uwsm app -- desktop power";
CustomButton = [
{
name = "Actions";
icon = "󰒓";
command = "uwsm app -- desktop menu";
}
{
name = "Health";
icon = "󰄦";
command = "uwsm app -- kitty --hold -e desktop health";
}
];
audio_sinks_more_cmd = "uwsm app -- pavucontrol -t 3"; audio_sinks_more_cmd = "uwsm app -- pavucontrol -t 3";
audio_sources_more_cmd = "uwsm app -- pavucontrol -t 4"; audio_sources_more_cmd = "uwsm app -- pavucontrol -t 4";
wifi_more_cmd = "uwsm app -- nm-connection-editor"; wifi_more_cmd = "uwsm app -- nm-connection-editor";
@@ -227,26 +321,24 @@ in
indicators = [ indicators = [
"IdleInhibitor" "IdleInhibitor"
"Audio" "Audio"
"Microphone"
] ]
++ lib.optionals networkManager [ ++ lib.optionals networkManager [
"Network" "Network"
"Vpn" "Vpn"
] ]
++ lib.optionals bluetooth [ "Bluetooth" ] ++ lib.optionals bluetooth [ "Bluetooth" ]
++ lib.optionals power [ ++ lib.optionals power [ "Battery" ];
"PowerProfile"
"Battery"
"Brightness"
];
audio_indicator_format = "IconAndPercentage"; audio_indicator_format = "IconAndPercentage";
}; };
appearance = { appearance = {
font_name = "Inter"; font_name = "Inter";
scale_factor = 1.15; scale_factor = 1.15;
style = "Islands"; style = "Solid";
opacity = 1.0; opacity = 1.0;
primary_color = c.blue; primary_color = {
base = c.accent;
text = c.background;
};
text_color = c.text; text_color = c.text;
success_color = c.green; success_color = c.green;
danger_color = c.red; danger_color = c.red;
@@ -256,11 +348,7 @@ in
strong = c.raised; strong = c.raised;
}; };
secondary_color.base = c.surface; secondary_color.base = c.surface;
workspace_colors = [ workspace_colors = [ c.accent ];
c.blue
c.purple
c.cyan
];
}; };
}; };
}; };
@@ -273,45 +361,149 @@ in
"${pkgs.anyrun}/lib/libapplications.so" "${pkgs.anyrun}/lib/libapplications.so"
"${pkgs.anyrun}/lib/librink.so" "${pkgs.anyrun}/lib/librink.so"
]; ];
width.fraction = 0.42; width.fraction = 0.5;
y.fraction = 0.2; y.fraction = 0.12;
hidePluginInfo = true; hidePluginInfo = true;
closeOnClick = true; closeOnClick = true;
maxEntries = 8; showResultsImmediately = true;
maxEntries = 6;
}; };
extraCss = builtins.readFile ./anyrun.css; extraCss = renderColors (builtins.readFile ./anyrun.css);
extraConfigFiles."applications.ron".text = '' extraConfigFiles."applications.ron".text = ''
( (
desktop_actions: false, desktop_actions: false,
max_entries: 8, hide_description: true,
max_entries: 6,
terminal: Some((command: "uwsm", args: "app -- kitty -e {}")), terminal: Some((command: "uwsm", args: "app -- kitty -e {}")),
preprocess_exec_script: Some("${launcherExec}"), preprocess_exec_script: Some("${launcherExec}"),
) )
''; '';
}; };
services.mako = { # This pinned launcher needs a daemon for calculator copy-to-clipboard.
systemd.user.services.anyrun = {
Unit = {
Description = "Application launcher and calculator";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
ConditionEnvironment = "WAYLAND_DISPLAY";
};
Service = {
ExecStart = "${pkgs.anyrun}/bin/anyrun daemon";
Restart = "on-failure";
};
Install.WantedBy = [ "graphical-session.target" ];
};
# Fuzzel is the shared dmenu-style picker for actions/help; Anyrun remains
# the app launcher/calculator, not a second competing autostarted shell.
programs.fuzzel = {
enable = true; enable = true;
settings = { settings = {
font = "Inter 11"; main = {
width = 360; font = "Inter:size=11";
height = 160; terminal = "kitty";
margin = "12"; "launch-prefix" = "uwsm app --";
padding = "14"; "line-height" = 22;
border-size = 2; width = 46;
border-radius = 12; lines = 10;
background-color = c.background; "horizontal-pad" = 18;
text-color = c.text; "vertical-pad" = 14;
border-color = c.border; };
default-timeout = 6000; colors = {
max-visible = 4; background = "${lib.removePrefix "#" c.background}ff";
max-icon-size = 48; text = "${lib.removePrefix "#" c.text}ff";
"urgency=critical" = { match = "${lib.removePrefix "#" c.accent}ff";
border-color = c.red; selection = "${lib.removePrefix "#" c.selection}ff";
default-timeout = 0; "selection-text" = "${lib.removePrefix "#" c.text}ff";
"selection-match" = "${lib.removePrefix "#" c.accent}ff";
border = "${lib.removePrefix "#" c.border}ff";
prompt = "${lib.removePrefix "#" c.muted}ff";
};
border = {
width = 2;
radius = 6;
}; };
}; };
}; };
services.swaync = {
enable = true;
style = renderColors (builtins.readFile ./swaync.css);
settings = {
positionX = "right";
positionY = "top";
control-center-width = 340;
control-center-height = 500;
control-center-margin-top = 8;
control-center-margin-right = 8;
notification-window-width = 360;
fit-to-screen = false;
timeout = 6;
timeout-critical = 0;
keyboard-shortcuts = true;
hide-on-action = true;
widgets = [
"title"
"dnd"
"mpris"
"notifications"
];
widget-config = {
title = {
text = "Notifications";
clear-all-button = true;
button-text = "Clear";
};
dnd.text = "Do not disturb";
mpris = {
image-size = 64;
image-radius = 4;
};
};
};
};
services.hyprsunset = {
enable = true;
settings.profile = [
{
time = "07:00";
identity = true;
}
{
time = "21:00";
temperature = 4200;
}
];
};
systemd.user.services.desktop-clipboard = {
Unit = {
Description = "Session-local clipboard history";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
ConditionEnvironment = "WAYLAND_DISPLAY";
};
Service = {
ExecStart = "${pkgs.wl-clipboard}/bin/wl-paste --watch ${actions}/bin/desktop clipboard-store";
ExecStopPost = "${actions}/bin/desktop clipboard-clear";
Restart = "on-failure";
UMask = "0077";
};
Install.WantedBy = [ "graphical-session.target" ];
};
systemd.user.services.desktop-battery = lib.mkIf power {
Unit = {
Description = "Low/critical battery notifications";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
ConditionPathExistsGlob = "/sys/class/power_supply/BAT*";
};
Service = {
ExecStart = "${actions}/bin/desktop battery";
Restart = "on-failure";
};
Install.WantedBy = [ "graphical-session.target" ];
};
programs.hyprlock = { programs.hyprlock = {
enable = true; enable = true;
package = null; package = null;
@@ -331,9 +523,9 @@ in
{ {
monitor = ""; monitor = "";
text = "cmd[update:1000] date +'%H:%M'"; text = "cmd[update:1000] date +'%H:%M'";
font_size = 84; font_size = 64;
font_family = "Inter"; font_family = "Inter";
color = "rgb(f2f4f8)"; color = rgb c.text;
position = "0,100"; position = "0,100";
halign = "center"; halign = "center";
valign = "center"; valign = "center";
@@ -343,7 +535,7 @@ in
text = "$USER"; text = "$USER";
font_size = 18; font_size = 18;
font_family = "Inter"; font_family = "Inter";
color = "rgb(a2a9b0)"; color = rgb c.muted;
position = "0,0"; position = "0,0";
halign = "center"; halign = "center";
valign = "center"; valign = "center";
@@ -360,12 +552,13 @@ in
dots_center = true; dots_center = true;
fade_on_empty = false; fade_on_empty = false;
font_family = "Inter"; font_family = "Inter";
inner_color = "rgb(262626)"; rounding = 6;
outer_color = "rgb(78a9ff)"; inner_color = rgb c.surface;
font_color = "rgb(f2f4f8)"; outer_color = rgb c.accent;
check_color = "rgb(3ddbd9)"; font_color = rgb c.text;
fail_color = "rgb(ff6b7a)"; check_color = rgb c.green;
capslock_color = "rgb(f1c21b)"; fail_color = rgb c.red;
capslock_color = rgb c.yellow;
placeholder_text = "<i>Password</i>"; placeholder_text = "<i>Password</i>";
fail_text = "<i>Try again ($ATTEMPTS)</i>"; fail_text = "<i>Try again ($ATTEMPTS)</i>";
} }
@@ -377,7 +570,7 @@ in
package = null; # NixOS owns the service and PAM integration. package = null; # NixOS owns the service and PAM integration.
settings = { settings = {
general = { general = {
lock_cmd = "pidof hyprlock || hyprlock"; lock_cmd = "${actions}/bin/desktop lock-start";
before_sleep_cmd = "loginctl lock-session"; before_sleep_cmd = "loginctl lock-session";
after_sleep_cmd = "hyprctl dispatch 'hl.dsp.dpms({ action = \"enable\" })'"; after_sleep_cmd = "hyprctl dispatch 'hl.dsp.dpms({ action = \"enable\" })'";
inhibit_sleep = 3; inhibit_sleep = 3;
+83
View File
@@ -0,0 +1,83 @@
# Official Nightly binary, pinned like the other external application sources.
# Keep its matching Electron/native modules together; do not disable the sandbox.
{ pkgs }:
pkgs.stdenv.mkDerivation {
pname = "element-nightly";
version = "2026090401";
src = pkgs.fetchurl {
url = "https://packages.element.io/debian/pool/main/e/element-nightly/element-nightly_2026090401_amd64.deb";
hash = "sha256-lLIkH6MtJWhGgohDFBmdOPEI/YdLTAJ9mkgr+TgSPLc=";
};
nativeBuildInputs = with pkgs; [
dpkg
autoPatchelfHook
wrapGAppsHook3
makeWrapper
];
buildInputs = with pkgs; [
alsa-lib
at-spi2-atk
at-spi2-core
cairo
cups
dbus
expat
glib
gtk3
libgbm
libdrm
libxkbcommon
libx11
libxcb
libxcomposite
libxdamage
libxext
libxfixes
libxrandr
libxrender
libxtst
libxscrnsaver
libxshmfence
nss
nspr
pango
libsecret
stdenv.cc.cc
];
runtimeDependencies = with pkgs; [
libnotify
libGL
libsecret
libpulseaudio
systemd
];
dontUnpack = true;
dontBuild = true;
dontWrapGApps = true;
installPhase = ''
runHook preInstall
dpkg --fsys-tarfile "$src" | tar --extract --no-same-owner
mkdir -p "$out/lib" "$out/bin"
mv opt/Element-Nightly "$out/lib/element-nightly"
mv usr/share "$out/share"
substituteInPlace "$out/share/applications/element-desktop-nightly.desktop" \
--replace-fail /opt/Element-Nightly/element-desktop-nightly "$out/bin/element-desktop-nightly"
runHook postInstall
'';
preFixup = ''
makeWrapper "$out/lib/element-nightly/element-desktop-nightly" "$out/bin/element-desktop-nightly" \
"''${gappsWrapperArgs[@]}" \
--suffix PATH : ${pkgs.lib.makeBinPath [ pkgs.xdg-utils ]} \
--add-flags "--password-store=gnome-libsecret --disable-setuid-sandbox" \
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform=wayland}}"
# Compatibility command only, not a duplicate launcher or a second install.
ln -s element-desktop-nightly "$out/bin/element-desktop"
'';
meta = {
description = "Element Nightly, the upstream development build of the Matrix client";
homepage = "https://element.io/download";
license = pkgs.lib.licenses.agpl3Plus;
platforms = [ "x86_64-linux" ];
mainProgram = "element-desktop-nightly";
};
}
Generated
+26 -9
View File
@@ -7,16 +7,16 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1787377438, "lastModified": 1788651960,
"narHash": "sha256-Sxu1NLTD/Ern6hFGLlZmtKCSct3YQXZI/lls8RE1XeM=", "narHash": "sha256-v9wJd32eZ2bvhBzVOd7TIjLQd011P7nwOhjKtWlci5I=",
"owner": "nix-community", "owner": "nix-community",
"repo": "home-manager", "repo": "home-manager",
"rev": "65258d5c65a250189fde2e35f490d15e064c4c62", "rev": "2c0350c759688177331b8f5242311fae8877bdb3",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nix-community", "owner": "nix-community",
"ref": "release-26.05", "ref": "master",
"repo": "home-manager", "repo": "home-manager",
"type": "github" "type": "github"
} }
@@ -41,16 +41,32 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1788405554, "lastModified": 1788614874,
"narHash": "sha256-r2f1oUwixlgq9zOdYLqJLfS/lWBT60/IITjhTKI59JU=", "narHash": "sha256-7QYjT2vHLuX9Z1pdxHXDKCbh1CR3D/2rywB9Tx0MPRg=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "a5cc6f2c37bf518436dc8d1c288ccd0c43c2f4c4", "rev": "c043004d1c6985732bcc1cbc5a9c9aecbbb4e0f0",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "NixOS",
"ref": "nixos-26.05", "ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-latest": {
"locked": {
"lastModified": 1788714682,
"narHash": "sha256-2HwAxLzDRJgLN7rsnU54alC898C1YLrHX5paHAiOFpk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3e6ba0d1cf48c2f84922fd44b104b1ebaba6ec6b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "master",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
@@ -59,7 +75,8 @@
"inputs": { "inputs": {
"home-manager": "home-manager", "home-manager": "home-manager",
"neovim-dots": "neovim-dots", "neovim-dots": "neovim-dots",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs",
"nixpkgs-latest": "nixpkgs-latest"
} }
} }
}, },
+63 -10
View File
@@ -2,14 +2,18 @@
description = "Development host"; description = "Development host";
inputs = { inputs = {
# Release branches may advance; flake.lock records every exact snapshot. # Rolling, latest-tested NixOS; flake.lock records the resolved snapshot.
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
home-manager = { home-manager = {
url = "github:nix-community/home-manager/release-26.05"; url = "github:nix-community/home-manager/master";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
# Fast-moving developer tools can advance ahead of the tested channel.
# flake.lock still records the exact resolved snapshot.
nixpkgs-latest.url = "github:NixOS/nixpkgs/master";
neovim-dots = { neovim-dots = {
url = "git+https://git.cyber.ayyalasomayajula.net/marsultor/neovim-dots?ref=main&rev=380eb86778a7c53a0f1c18e84f14037456155347"; url = "git+https://git.cyber.ayyalasomayajula.net/marsultor/neovim-dots?ref=main&rev=380eb86778a7c53a0f1c18e84f14037456155347";
flake = false; flake = false;
@@ -18,16 +22,32 @@
outputs = outputs =
inputs@{ nixpkgs, home-manager, ... }: inputs@{ nixpkgs, home-manager, ... }:
let
mkHost =
module:
nixpkgs.lib.nixosSystem {
specialArgs = { inherit inputs; };
modules = [
module
home-manager.nixosModules.home-manager
];
};
in
{ {
nixosConfigurations.dev = nixpkgs.lib.nixosSystem { nixosConfigurations = {
specialArgs = { inherit inputs; }; dev = mkHost ./configuration.nix; # EC2 integration
modules = [ nixos = mkHost ./physical.nix; # This laptop's boot/storage/hardware
./configuration.nix };
home-manager.nixosModules.home-manager
]; templates.agent = {
path = ./templates/agent;
description = "Project toolchain + live-workspace DeepSeek Harness microVM";
}; };
formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt; formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt;
packages.x86_64-linux.element-nightly = import ./element-nightly.nix {
pkgs = nixpkgs.legacyPackages.x86_64-linux;
};
nixosModules = { nixosModules = {
desktop = ./desktop.nix; desktop = ./desktop.nix;
@@ -43,12 +63,45 @@
in in
{ {
desktop = import ./desktop-test.nix { inherit inputs pkgs; }; desktop = import ./desktop-test.nix { inherit inputs pkgs; };
tools = import ./tools-test.nix {
inherit pkgs;
config = inputs.self.nixosConfigurations.nixos.config;
};
physical-config = import ./physical-test.nix {
inherit pkgs;
config = inputs.self.nixosConfigurations.nixos.config;
ec2Config = inputs.self.nixosConfigurations.dev.config;
};
switch-system =
pkgs.runCommand "switch-system-check"
{
nativeBuildInputs = with pkgs; [
python3
bash
coreutils
util-linux
shellcheck
];
}
''
shellcheck ${./switch-system.sh}
python ${./switch-test.py} ${./switch-system.sh}
touch "$out"
'';
desktop-actions =
pkgs.runCommand "desktop-actions-check" { nativeBuildInputs = [ pkgs.python3 ]; }
''
python ${./desktop-actions-test.py} ${./desktop-actions.py}
touch "$out"
'';
desktop-config = desktop-config =
pkgs.runCommand "hyprland-config-check" { nativeBuildInputs = [ pkgs.hyprland ]; } pkgs.runCommand "hyprland-config-check" { nativeBuildInputs = [ pkgs.hyprland ]; }
'' ''
export HOME="$TMPDIR/home" XDG_RUNTIME_DIR="$TMPDIR/runtime" export HOME="$TMPDIR/home" XDG_RUNTIME_DIR="$TMPDIR/runtime"
mkdir -m 700 -p "$HOME" "$XDG_RUNTIME_DIR" mkdir -m 700 -p "$HOME" "$XDG_RUNTIME_DIR"
Hyprland --verify-config -c ${./hyprland.lua} Hyprland --verify-config -c ${
inputs.self.nixosConfigurations.nixos.config.home-manager.users.dev.xdg.configFile."hypr/hyprland.lua".source
}
touch "$out" touch "$out"
''; '';
updates = updates =
+60
View File
@@ -0,0 +1,60 @@
# Do not modify this file! It was generated by nixos-generate-config
# and may be overwritten by future invocations. Please make changes
# to /etc/nixos/configuration.nix instead.
{
config,
lib,
pkgs,
modulesPath,
...
}:
{
imports = [
(modulesPath + "/installer/scan/not-detected.nix")
];
boot.initrd.availableKernelModules = [
"xhci_pci"
"thunderbolt"
"nvme"
"usb_storage"
"sd_mod"
"sdhci_pci"
];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
fileSystems."/" = {
device = "/dev/disk/by-uuid/8a16015f-d6f8-4f74-8558-6261b9112216";
fsType = "btrfs";
};
fileSystems."/home" = {
device = "/dev/disk/by-uuid/8a16015f-d6f8-4f74-8558-6261b9112216";
fsType = "btrfs";
options = [ "subvol=home" ];
};
fileSystems."/nix" = {
device = "/dev/disk/by-uuid/8a16015f-d6f8-4f74-8558-6261b9112216";
fsType = "btrfs";
options = [ "subvol=nix" ];
};
fileSystems."/boot" = {
device = "/dev/disk/by-uuid/DEC5-51CB";
fsType = "vfat";
options = [
"fmask=0077"
"dmask=0077"
];
};
swapDevices = [ ];
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.intel.npu.enable = true;
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
+89 -37
View File
@@ -1,18 +1,19 @@
-- Native Hyprland 0.55 configuration, deployed by Home Manager. -- Native Hyprland 0.55 configuration, deployed by Home Manager.
-- @color@ tokens are rendered from colors.nix at build time.
-- Services belong to UWSM/systemd, never a second exec-once process tree. -- Services belong to UWSM/systemd, never a second exec-once process tree.
local function rgb(hex) return "rgb(" .. hex:sub(2) .. ")" end
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" }) hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })
hl.config({ hl.config({
general = { general = {
gaps_in = 6, gaps_out = 12, border_size = 2, gaps_in = 4, gaps_out = 8, border_size = 2,
layout = "dwindle", resize_on_border = true, allow_tearing = false, layout = "dwindle", resize_on_border = true, allow_tearing = false,
col = { col = {
active_border = { colors = { "rgb(78a9ff)", "rgb(be95ff)" }, angle = 45 }, active_border = { colors = { rgb("@accent@"), rgb("@green@") }, angle = 45 },
inactive_border = "rgb(393939)", inactive_border = rgb("@border@"),
}, },
}, },
decoration = { decoration = {
rounding = 12, rounding = 6,
active_opacity = 1.0, inactive_opacity = 1.0, active_opacity = 1.0, inactive_opacity = 1.0,
shadow = { enabled = true, range = 16, render_power = 3, color = 0x55000000 }, shadow = { enabled = true, range = 16, render_power = 3, color = 0x55000000 },
blur = { enabled = false }, blur = { enabled = false },
@@ -38,47 +39,98 @@ hl.animation({ leaf = "workspaces", enabled = true, speed = 2.5, bezier = "settl
hl.animation({ leaf = "layers", enabled = true, speed = 2, bezier = "settle", style = "fade" }) hl.animation({ leaf = "layers", enabled = true, speed = 2, bezier = "settle", style = "fade" })
hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" }) hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
-- Every binding is discoverable through hyprctl -j binds / desktop-help.
local function bind(keys, action, description, options)
options = options or {}
options.description = description
hl.bind(keys, action, options)
end
local function app(command) return hl.dsp.exec_cmd("uwsm app -- " .. command) end local function app(command) return hl.dsp.exec_cmd("uwsm app -- " .. command) end
hl.bind("SUPER + Return", app("kitty")) bind("SUPER + Return", app("kitty"), "Apps · Terminal")
hl.bind("SUPER + Space", app("anyrun")) bind("SUPER + D", app("anyrun"), "Apps · Search applications / calculator")
hl.bind("SUPER + E", app("kitty --class files -e yazi")) bind("SUPER + A", app("desktop windows"), "Windows · Overview / search")
hl.bind("SUPER + B", app("firefox-esr")) bind("SUPER + CTRL + S", app("desktop windows"), "Windows · Search all windows")
hl.bind("SUPER + P", app("keepassxc")) bind("SUPER + E", app("thunar"), "Apps · Graphical file manager")
hl.bind("SUPER + Escape", hl.dsp.exec_cmd("loginctl lock-session")) bind("SUPER + CTRL + E", app("kitty --class files -e yazi"), "Apps · Yazi terminal file manager")
hl.bind("SUPER + Q", hl.dsp.window.close()) bind("SUPER + B", app("firefox-esr"), "Apps · Browser")
hl.bind("SUPER + V", hl.dsp.window.float({ action = "toggle" })) bind("SUPER + ALT + P", app("keepassxc"), "Apps · Password vault")
hl.bind("SUPER + F", hl.dsp.window.fullscreen()) bind("SUPER + P", hl.dsp.window.pseudo(), "Windows · Toggle pseudotiling")
hl.bind("SUPER + J", hl.dsp.layout("togglesplit")) bind("SUPER + H", app("desktop-help"), "Help · Search all keyboard shortcuts")
hl.bind("SUPER + N", hl.dsp.exec_cmd("makoctl dismiss")) bind("SUPER + SHIFT + K", app("desktop-help"), "Help · Search all keyboard shortcuts")
hl.bind("SUPER + SHIFT + N", hl.dsp.exec_cmd("makoctl restore")) bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), "Session · Lock screen")
hl.bind("Print", hl.dsp.exec_cmd("desktop-screenshot")) bind("SUPER + Escape", hl.dsp.exec_cmd("loginctl lock-session"), "Session · Lock screen (alias)")
bind("CTRL + ALT + P", app("desktop power"), "Session · Power / logout menu")
bind("SUPER + SHIFT + E", app("desktop menu"), "Desktop · Quick settings / actions")
bind("SUPER + ALT + V", app("desktop clipboard"), "Clipboard · History")
bind("SUPER + ALT + E", app("desktop emoji"), "Clipboard · Emoji picker")
bind("SUPER + ALT + C", app("anyrun"), "Apps · Calculator (enter an expression)")
bind("SUPER + S", app("desktop search"), "Apps · Search web")
bind("SUPER + SHIFT + Return", app("desktop scratch"), "Windows · Drop-down terminal")
bind("SUPER + ALT + R", app("desktop record"), "Capture · Start / stop recording")
bind("SUPER + ALT + T", app("desktop touchpad"), "Input · Toggle touchpad")
bind("SUPER + Q", hl.dsp.window.close(), "Windows · Close active window")
bind("SUPER + Space", hl.dsp.window.float({ action = "toggle" }), "Windows · Toggle floating")
bind("SUPER + F", hl.dsp.window.fullscreen(), "Windows · Toggle fullscreen (alias)")
bind("SUPER + SHIFT + F", hl.dsp.window.fullscreen(), "Windows · Toggle fullscreen")
bind("SUPER + CTRL + F", hl.dsp.window.fullscreen({mode = "maximized"}), "Windows · Toggle maximize")
bind("SUPER + SHIFT + I", hl.dsp.layout("togglesplit"), "Windows · Toggle split direction")
bind("SUPER + N", app("desktop night"), "Display · Toggle night light")
bind("SUPER + SHIFT + N", app("desktop notifications"), "Notifications · Open history / controls")
bind("SUPER + CTRL + N", app("desktop dnd"), "Notifications · Toggle do not disturb")
bind("ALT + Tab", hl.dsp.window.cycle_next(), "Windows · Cycle forward")
bind("ALT + SHIFT + Tab", hl.dsp.window.cycle_next({next = false}), "Windows · Cycle backward")
bind("SUPER + G", hl.dsp.group.toggle(), "Windows · Toggle tabbed group")
bind("SUPER + CTRL + Tab", hl.dsp.group.next(), "Windows · Next group tab")
bind("Print", app("desktop screenshot-menu"), "Capture · Screenshot menu")
bind("SUPER + Print", app("desktop screenshot output"), "Capture · Current display")
bind("SUPER + SHIFT + Print", app("desktop screenshot area"), "Capture · Region")
bind("SUPER + CTRL + Print", app("desktop screenshot output --delay5"), "Capture · Display after 5 seconds")
bind("SUPER + CTRL + SHIFT + Print", app("desktop screenshot output --delay10"), "Capture · Display after 10 seconds")
bind("ALT + Print", app("desktop screenshot window"), "Capture · Active window")
bind("SUPER + SHIFT + S", app("desktop-screenshot"), "Capture · Region and annotation")
for _, direction in ipairs({ "left", "right", "up", "down" }) do for _, direction in ipairs({ "left", "right", "up", "down" }) do
hl.bind("SUPER + " .. direction, hl.dsp.focus({ direction = direction })) bind("SUPER + " .. direction, hl.dsp.focus({ direction = direction }), "Windows · Focus " .. direction)
hl.bind("SUPER + SHIFT + " .. direction, hl.dsp.window.move({ direction = direction })) bind("SUPER + CTRL + " .. direction, hl.dsp.window.move({ direction = direction }), "Windows · Move " .. direction)
bind("SUPER + ALT + " .. direction, hl.dsp.window.swap({ direction = direction }), "Windows · Swap " .. direction)
local dx = direction == "left" and -50 or direction == "right" and 50 or 0
local dy = direction == "up" and -50 or direction == "down" and 50 or 0
bind("SUPER + SHIFT + " .. direction, hl.dsp.window.resize({ x = dx, y = dy, relative = true }), "Windows · Resize " .. direction, {repeating = true})
end end
for i = 1, 10 do for i = 1, 10 do
local key = i % 10 local key = i % 10
hl.bind("SUPER + " .. key, hl.dsp.focus({ workspace = i })) bind("SUPER + " .. key, hl.dsp.focus({ workspace = i }), "Workspaces · Focus " .. i)
hl.bind("SUPER + SHIFT + " .. key, hl.dsp.window.move({ workspace = i })) bind("SUPER + SHIFT + " .. key, hl.dsp.window.move({ workspace = i, follow = true }), "Workspaces · Move and follow to " .. i)
bind("SUPER + CTRL + " .. key, hl.dsp.window.move({ workspace = i, follow = false }), "Workspaces · Move silently to " .. i)
end end
hl.bind("SUPER + S", hl.dsp.workspace.toggle_special("scratch")) bind("SUPER + U", hl.dsp.workspace.toggle_special("scratch"), "Workspaces · Toggle scratchpad")
hl.bind("SUPER + SHIFT + S", hl.dsp.window.move({ workspace = "special:scratch" })) bind("SUPER + SHIFT + U", hl.dsp.window.move({ workspace = "special:scratch" }), "Workspaces · Move window to scratchpad")
hl.bind("SUPER + mouse_down", hl.dsp.focus({ workspace = "e+1" })) bind("SUPER + Tab", hl.dsp.focus({workspace = "m+1"}), "Workspaces · Next on this monitor")
hl.bind("SUPER + mouse_up", hl.dsp.focus({ workspace = "e-1" })) bind("SUPER + SHIFT + Tab", hl.dsp.focus({workspace = "m-1"}), "Workspaces · Previous on this monitor")
hl.bind("SUPER + mouse:272", hl.dsp.window.drag(), { mouse = true }) for i, direction in ipairs({"left", "right", "up", "down"}) do
hl.bind("SUPER + mouse:273", hl.dsp.window.resize(), { mouse = true }) bind("SUPER + CTRL + F" .. (8 + i), hl.dsp.workspace.move({monitor = direction}), "Monitors · Move workspace " .. direction)
end
bind("SUPER + mouse_down", hl.dsp.focus({ workspace = "e+1" }), "Workspaces · Next occupied workspace")
bind("SUPER + mouse_up", hl.dsp.focus({ workspace = "e-1" }), "Workspaces · Previous occupied workspace")
bind("SUPER + mouse:272", hl.dsp.window.drag(), "Windows · Drag with left mouse", { mouse = true })
bind("SUPER + mouse:273", hl.dsp.window.resize(), "Windows · Resize with right mouse", { mouse = true })
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+"), { locked = true, repeating = true }) bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("desktop osd volume up"), "Media · Volume up", { locked = true, repeating = true })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { locked = true, repeating = true }) bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("desktop osd volume down"), "Media · Volume down", { locked = true, repeating = true })
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true }) bind("XF86AudioMute", hl.dsp.exec_cmd("desktop osd volume mute"), "Media · Toggle speaker mute", { locked = true })
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true }) bind("XF86AudioMicMute", hl.dsp.exec_cmd("desktop osd microphone mute"), "Media · Toggle microphone mute", { locked = true })
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl set +5%"), { locked = true, repeating = true }) bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("desktop osd brightness up"), "Display · Brightness up", { locked = true, repeating = true })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl --min-value=1 set 5%-"), { locked = true, repeating = true }) bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("desktop osd brightness down"), "Display · Brightness down", { locked = true, repeating = true })
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true }) bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), "Media · Play / pause", { locked = true })
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true }) bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), "Media · Next track", { locked = true })
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true }) bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), "Media · Previous track", { locked = true })
bind("XF86KbdBrightnessUp", hl.dsp.exec_cmd("desktop osd keyboard up"), "Input · Keyboard backlight up", {locked = true, repeating = true})
bind("XF86KbdBrightnessDown", hl.dsp.exec_cmd("desktop osd keyboard down"), "Input · Keyboard backlight down", {locked = true, repeating = true})
bind("XF86RFKill", app("desktop airplane"), "Network · Toggle airplane mode")
bind("XF86Sleep", hl.dsp.exec_cmd("systemctl suspend"), "Session · Suspend")
hl.window_rule({name = "dropterminal", match = {class = "^dropterminal$"}, workspace = "special:terminal", float = true, center = true})
hl.window_rule({ name = "ignore-maximize", match = { class = ".*" }, suppress_event = "maximize" }) hl.window_rule({ name = "ignore-maximize", match = { class = ".*" }, suppress_event = "maximize" })
hl.window_rule({ hl.window_rule({
name = "fix-xwayland-drag", match = { class = "^$", title = "^$", xwayland = true, float = true, fullscreen = false, pin = false }, name = "fix-xwayland-drag", match = { class = "^$", title = "^$", xwayland = true, float = true, fullscreen = false, pin = false },
+63 -9
View File
@@ -1,10 +1,21 @@
{ pkgs, ... }:
{ {
# Keep each host's existing interface/DHCP owner. On EC2 this is dhcpcd; config,
# NixOS wires its resolvconf hook to resolved's compatibility interface. inputs,
lib,
pkgs,
...
}:
let
latest = import inputs.nixpkgs-latest {
inherit (pkgs.stdenv.hostPlatform) system;
config = pkgs.config;
};
in
{
# NetworkManager owns local DNS on workstations. With dhcpcd (EC2),
# NixOS wires resolvconf to resolved. Preserve both hosts' existing behavior.
services.resolved = { services.resolved = {
enable = true; enable = lib.mkDefault (!config.networking.networkmanager.enable);
settings.Resolve = { settings.Resolve = {
LLMNR = false; LLMNR = false;
MulticastDNS = false; MulticastDNS = false;
@@ -12,14 +23,57 @@
}; };
}; };
programs.mtr.enable = true; # Local, opt-in SOCKS client only. Keep the daemon on the system package pin.
environment.systemPackages = with pkgs; [ # No relay/exit, control listener, transparent proxy or host DNS changes.
wireguard-tools services.tor = {
enable = true;
openFirewall = false;
relay.enable = false;
client = {
enable = true;
socksListenAddress = {
addr = "127.0.0.1";
port = 9050;
IsolateDestAddr = true;
IsolateSOCKSAuth = true;
};
};
settings.ClientOnly = true;
};
# The NixOS module enables tor.service at boot and supplies its sandbox/user.
# Retry even after a clean daemon exit; never exhaust systemd's start limit.
systemd.services.tor = {
unitConfig.StartLimitIntervalSec = 0;
serviceConfig = {
Restart = lib.mkForce "always";
RestartSec = "5s";
};
};
programs.mtr = {
enable = true;
package = latest.mtr;
};
environment.systemPackages = with latest; [
wireguard-tools # wg and wg-quick; no interfaces or credentials are configured.
openvpn
iperf3
nmap
traceroute
whois
dnsutils dnsutils
tcpdump tcpdump
ethtool ethtool
netcat-openbsd netcat-openbsd
socat socat
fping
ldns # drill and DNS/DNSSEC inspection utilities.
torsocks
proxychains-ng # Opt-in wrappers; no global proxy environment is set.
doggo
iftop
bandwhich
wireshark-cli # tshark; no capture group/capabilities or daemon.
]; ];
# wg/wg-quick are available, but no invented peers, keys, routes or ports. # No VPN services, peers, keys, routes or opened firewall ports.
} }
+244
View File
@@ -0,0 +1,244 @@
# Physical safety, shared host policy and built files; no activation/connections.
{
config,
ec2Config,
pkgs,
}:
let
inherit (pkgs) lib;
dev = config.users.users.dev;
btrfsDevice = "/dev/disk/by-uuid/8a16015f-d6f8-4f74-8558-6261b9112216";
mounts = {
"/" = {
device = btrfsDevice;
fsType = "btrfs";
};
"/home" = {
device = btrfsDevice;
fsType = "btrfs";
options = [ "subvol=home" ];
};
"/nix" = {
device = btrfsDevice;
fsType = "btrfs";
options = [ "subvol=nix" ];
};
"/boot" = {
device = "/dev/disk/by-uuid/DEC5-51CB";
fsType = "vfat";
options = [
"fmask=0077"
"dmask=0077"
];
};
};
tests = [
{
assertion = config.boot.loader.systemd-boot.enable && !config.boot.loader.grub.enable;
message = "The physical host must use systemd-boot, never EC2's GRUB disk.";
}
{
assertion = lib.all (
mount:
let
actual = config.fileSystems.${mount};
expected = mounts.${mount};
in
actual.device == expected.device
&& actual.fsType == expected.fsType
&& lib.all (option: builtins.elem option actual.options) (expected.options or [ ])
) (builtins.attrNames mounts);
message = "Preserve the installed root/home/nix/EFI filesystems and Btrfs subvolumes.";
}
{
assertion =
config.users.mutableUsers
&& !(config.users.users ? kbot)
&& dev.password == null
&& dev.hashedPassword == null
&& dev.hashedPasswordFile == null;
message = "Retire kbot without overwriting dev's locally established password.";
}
{
assertion =
dev.isNormalUser
&& dev.uid == 1001
&& dev.home == "/home/dev"
&& builtins.elem "wheel" dev.extraGroups
&& builtins.elem "networkmanager" dev.extraGroups
&& builtins.attrNames config.home-manager.users == [ "dev" ];
message = "dev is the sole managed daily account, with local administration/network access.";
}
{
assertion =
config.services.displayManager.sddm.enable
&& !config.services.desktopManager.plasma6.enable
&& config.programs.hyprland.enable
&& config.programs.hyprland.withUWSM
&& !config.services.greetd.enable
&& !config.services.displayManager.autoLogin.enable
&& config.services.displayManager.defaultSession == "hyprland-uwsm"
&& !(builtins.elem "hyprland" config.services.displayManager.sessionData.sessionNames);
message = "Offer only the managed Hyprland desktop in SDDM, without Plasma or autologin.";
}
{
assertion =
config.networking.networkmanager.enable
&& !config.services.resolved.enable
&& builtins.elem (lib.getName pkgs.networkmanager-openvpn) (
map lib.getName config.networking.networkmanager.plugins
);
message = "Keep NetworkManager/DNS and provide its OpenVPN integration.";
}
{
assertion =
config.systemd.services.nixos-update.environment.NIXOS_UPDATE_HOST == "nixos"
&& config.systemd.services.nixos-update.environment.NIXOS_CONFIG_REPO == "/etc/nix"
&& config.systemd.services.nixos-update.serviceConfig.User == "dev"
&& builtins.elem "Z /etc/nix - dev users -" config.systemd.tmpfiles.rules
&& config.systemd.timers.nixos-update.timerConfig.Persistent
&& !(config.systemd.services ? amazon-ssm-agent)
&& !(builtins.elem "Z /etc/nixos - dev users -" config.systemd.tmpfiles.rules);
message = "The laptop must keep its own checkout/target, not EC2's deployment settings.";
}
{
assertion =
lib.all
(
c:
let
update = c.systemd.services.nixos-update;
in
update.serviceConfig.User == "dev"
&& update.serviceConfig.Group == "users"
&& update.serviceConfig.ExecStart == config.systemd.services.nixos-update.serviceConfig.ExecStart
&& c.systemd.timers.nixos-update.timerConfig == config.systemd.timers.nixos-update.timerConfig
&& !(update.environment ? NIXOS_UPDATE_MODE)
&& builtins.elem "Z ${update.environment.NIXOS_CONFIG_REPO} - dev users -" c.systemd.tmpfiles.rules
&& builtins.elem "Z /var/cache/nixos-update - dev users -" c.systemd.tmpfiles.rules
&& c.users.mutableUsers
&& c.time.timeZone == "America/Chicago"
&& c.i18n.defaultLocale == "en_US.UTF-8"
)
[
config
ec2Config
];
message = "Both hosts must share the dev-owned checkout/updater and user preferences; no per-host activation policy.";
}
{
assertion =
ec2Config.systemd.services.nixos-update.environment.NIXOS_UPDATE_HOST == "dev"
&& ec2Config.systemd.services.nixos-update.environment.NIXOS_CONFIG_REPO == "/etc/nixos"
&& ec2Config.boot.loader.grub.enable
&& ec2Config.boot.loader.grub.device == "/dev/xvda"
&& !ec2Config.boot.loader.systemd-boot.enable
&& ec2Config.fileSystems."/".device == "/dev/disk/by-label/nixos"
&& ec2Config.fileSystems."/".fsType == "ext4"
&& ec2Config.services.openssh.enable
&& ec2Config.services.amazon-ssm-agent.enable
&& ec2Config.networking.dhcpcd.enable
&& ec2Config.services.resolved.enable
&& !ec2Config.networking.networkmanager.enable
&& !ec2Config.services.displayManager.sddm.enable;
message = "EC2 must retain its AWS boot, disks, networking and remote recovery, not laptop hardware services.";
}
{
assertion =
lib.all
(package: builtins.elem (lib.getName package) (map lib.getName config.environment.systemPackages))
(
with pkgs;
[
wireguard-tools
openvpn
iperf3
nmap
traceroute
whois
dnsutils
tcpdump
ethtool
netcat-openbsd
socat
fping
ldns
torsocks
proxychains-ng
tor
]
)
&& config.programs.mtr.enable;
message = "The VPN/proxy clients and network diagnostics must remain installed.";
}
{
assertion =
lib.all
(
c:
let
tor = c.services.tor;
unit = c.systemd.services.tor;
in
tor.enable
&& tor.client.enable
&& !tor.relay.enable
&& !tor.openFirewall
&& tor.settings.ClientOnly
&& tor.settings.ORPort == [ ]
&& tor.settings.DirPort == [ ]
&& tor.settings.ExitPolicy == [ "reject *:*" ]
&& tor.relay.onionServices == { }
&& !tor.controlSocket.enable
&& tor.settings.ControlPort == [ ]
&& !tor.client.dns.enable
&& !tor.client.transparentProxy.enable
&& tor.settings.DNSPort == [ ]
&& tor.settings.TransPort == [ ]
&& builtins.length tor.settings.SOCKSPort == 1
&& lib.all (
listener:
listener.addr == "127.0.0.1"
&& listener.port == 9050
&& listener.IsolateDestAddr
&& listener.IsolateSOCKSAuth
) tor.settings.SOCKSPort
&& builtins.elem "multi-user.target" unit.wantedBy
&& unit.serviceConfig.Restart == "always"
&& unit.serviceConfig.RestartSec == "5s"
&& unit.unitConfig.StartLimitIntervalSec == 0
&& unit.serviceConfig.User == "tor"
&& unit.serviceConfig.NoNewPrivileges
)
[
config
ec2Config
];
message = "Both hosts need a boot-enabled, restarting, loopback-only Tor client, without relay, control, DNS or transparent-proxy listeners.";
}
{
assertion =
config.networking.wireguard.interfaces == { }
&& config.networking.wg-quick.interfaces == { }
&& config.services.openvpn.servers == { };
message = "Installing VPN tools must not invent tunnels, peers or credentials.";
}
];
in
assert lib.all (test: lib.assertMsg test.assertion test.message) tests;
pkgs.runCommand "physical-config-check" { } ''
sessions=${config.services.displayManager.sessionData.desktops}/share
test ! -e "$sessions/wayland-sessions/plasma.desktop"
test -f "$sessions/wayland-sessions/hyprland-uwsm.desktop"
test ! -e "$sessions/wayland-sessions/hyprland.desktop"
for tool in wg wg-quick openvpn iperf3 nmap traceroute whois mtr dig tcpdump ethtool nc socat \
fping drill torsocks proxychains4 tor nm-connection-editor; do
test -x "${config.system.path}/bin/$tool"
done
# Validate the exact generated torrc offline without touching live Tor state.
mkdir -m 700 "$TMPDIR/tor"
${config.services.tor.package}/bin/tor --verify-config \
-f ${builtins.head config.systemd.services.tor.restartTriggers} \
--DataDirectory "$TMPDIR/tor"
touch "$out"
''
+28
View File
@@ -0,0 +1,28 @@
# Laptop-only boot/storage, hardware measurements and checkout identity.
# The original /etc/nixos files and /home/kbot remain untouched.
{ lib, ... }:
{
imports = [
./hardware-configuration.nix
./common.nix
./workstation.nix
];
boot.loader = {
systemd-boot.enable = true;
efi.canTouchEfiVariables = true;
};
networking.hostName = "nixos";
# 1440x900 logical pixels; leave other displays on automatic scaling.
home-manager.users.dev.wayland.windowManager.hyprland.extraConfig = lib.mkAfter ''
hl.monitor({ output = "eDP-1", mode = "preferred", position = "auto", scale = 4 / 3 })
'';
home-manager.users.dev.programs.ashell.settings.system_info.temperature.sensor =
"coretemp Package id 0";
systemd.services.nixos-update.environment = {
NIXOS_CONFIG_REPO = "/etc/nix";
NIXOS_UPDATE_HOST = "nixos";
};
}
+31
View File
@@ -0,0 +1,31 @@
* { font-family: Inter, sans-serif; font-size: 14px; color: @text@; }
.control-center {
background: @background@;
border: 1px solid @border@;
border-radius: 6px;
padding: 10px;
}
.notification {
background: @surface@;
border: 1px solid @border@;
border-radius: 6px;
margin: 6px;
}
.notification.critical { border-color: @red@; }
/* Keep keyboard focus visible without the upstream thick gray outer slab. */
.notification-row:focus, .notification-group:focus { background: transparent; }
.notification-row:focus .notification, .notification-group:focus .notification { border-color: @accent@; }
.notification-row .notification-background .notification { box-shadow: none; }
.notification-content { padding: 12px; }
.summary { font-weight: 600; }
.time, .body { color: @muted@; }
button { background: @raised@; border-radius: 4px; border: none; padding: 6px 10px; }
button:hover { background: @selection@; }
button:checked, switch:checked { background: @accent@; color: @background@; }
.widget-title { margin: 8px; }
.widget-title > label { font-size: 18px; font-weight: 600; }
.widget-dnd, .widget-volume, .widget-backlight, .widget-mpris { margin: 8px; }
.widget-mpris-player { background: @surface@; border-radius: 6px; padding: 8px; }
trough { background: @raised@; border-radius: 4px; }
highlight, progress { background: @accent@; border-radius: 4px; }
.close-button { background: @raised@; color: @text@; }
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# The installed command gets its host/path from updates.nix on both machines.
# Direct execution from this checkout defaults to the laptop, never the login name.
set -euo pipefail
repo=${NIXOS_CONFIG_REPO:-/etc/nix}
host=${NIXOS_UPDATE_HOST:-nixos}
usage() {
printf 'Usage: %s [switch|dry-activate|boot|test]\n' "$0"
printf 'Build %s#%s using flake.lock.\n' "$repo" "$host"
printf '%s\n' \
'Default: switch now and save for boot (an explicit manual action).' \
'dry-activate previews changes; boot stages them; test applies temporarily.' \
'Does not pull Git, update package pins, delete generations, or reboot.'
}
if [[ $# -eq 1 && ($1 == --help || $1 == -h) ]]; then
usage
exit 0
fi
if (($# > 1)); then
usage >&2
exit 2
fi
action=${1:-switch}
case "$action" in
switch | dry-activate | boot | test) ;;
*)
usage >&2
exit 2
;;
esac
case "$host" in
nixos | dev) ;;
*)
printf 'Unsupported host: %s\n' "$host" >&2
exit 2
;;
esac
# Keep builds/Git access under the checkout owner, including when invoked via sudo.
# Only activating the built system needs root.
if ((EUID == 0)); then
exec runuser -u dev -- env NIXOS_CONFIG_REPO="$repo" NIXOS_UPDATE_HOST="$host" \
"$(readlink -f -- "${BASH_SOURCE[0]}")" "$@"
fi
# Share the automatic updater's lock. Directory ownership is managed by NixOS.
state=${CACHE_DIRECTORY:-/var/cache/nixos-update}
mkdir -p "$state"
exec 9>"$state/lock"
flock 9
cd "$repo"
printf 'Building %s#%s (%s).\n' "$repo" "$host" "$action"
built=$(nix build ".#nixosConfigurations.$host.config.system.build.toplevel" \
--no-update-lock-file --no-link --print-out-paths)
# Keep this shell alive holding the lock: sudo closes inherited descriptors.
sudo "$built/sw/bin/nixos-rebuild" "$action" --no-reexec --store-path "$built"
+79
View File
@@ -0,0 +1,79 @@
"""Test manual switching for both hosts without Nix builds or host activation."""
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
script = Path(sys.argv[1]).resolve()
if os.geteuid() == 0:
raise SystemExit("Run this check as an unprivileged user (or through nix flake check).")
mock = r'''import fcntl, json, os, pathlib, sys
name = pathlib.Path(sys.argv[0]).name
args = sys.argv[1:]
with open(os.environ["CALLS"], "a") as f:
f.write(json.dumps([name, *args]) + "\n")
if name in ("nix", "sudo"):
# Model sudo's descriptor cleanup: the parent shell must retain the lock.
try: os.close(9)
except OSError: pass
with open(pathlib.Path(os.environ["CACHE_DIRECTORY"]) / "lock", "a") as lock:
try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError: pass
else: raise AssertionError("Updater lock was released before activation completed")
if name == "nix":
assert pathlib.Path.cwd() == pathlib.Path(os.environ["NIXOS_CONFIG_REPO"])
assert args == ["build", f'.#nixosConfigurations.{os.environ["NIXOS_UPDATE_HOST"]}.config.system.build.toplevel', "--no-update-lock-file", "--no-link", "--print-out-paths"]
if os.environ["SCENARIO"] == "build-failure": sys.exit(42)
print(os.environ["BUILT"])
elif name == "sudo":
# No sudo -v: its verifypw=all policy can demand a password even when
# the actual command is NOPASSWD (the user also has a wheel rule).
assert args[0] == os.environ["BUILT"] + "/sw/bin/nixos-rebuild"
assert args[2:] == ["--no-reexec", "--store-path", os.environ["BUILT"]]
if os.environ["SCENARIO"] == "sudo-failure": sys.exit(43)
if os.environ["SCENARIO"] == "activation-failure": sys.exit(44)
else:
raise AssertionError(name)
'''
with tempfile.TemporaryDirectory(prefix="switch-test-") as directory:
root = Path(directory)
repo = root / "repo with spaces"
binaries = root / "bin"
calls = root / "calls"
repo.mkdir()
binaries.mkdir()
for name in ["nix", "sudo"]:
executable = binaries / name
executable.write_text("#!" + sys.executable + "\n" + mock)
executable.chmod(0o755)
env = dict(os.environ, PATH=str(binaries) + os.pathsep + os.environ["PATH"],
NIXOS_CONFIG_REPO=str(repo), CACHE_DIRECTORY=str(root / "cache"),
CALLS=str(calls), BUILT=str(root / "built system"))
cases = [([], "switch", "success", 0), (["dry-activate"], "dry-activate", "success", 0),
(["boot"], "boot", "success", 0), (["test"], "test", "success", 0),
(["--help"], None, "help", 0), (["invalid"], None, "invalid", 2),
(["switch", "extra"], None, "invalid", 2),
([], None, "build-failure", 42), ([], "switch", "sudo-failure", 43),
([], "switch", "activation-failure", 44), ([], None, "invalid-host", 2)]
for host in ["nixos", "dev"]:
for args, action, scenario, expected_code in cases:
calls.unlink(missing_ok=True)
result = subprocess.run(
["bash", str(script), *args], cwd=root,
env=env | {"NIXOS_UPDATE_HOST": "unknown" if scenario == "invalid-host" else host,
"SCENARIO": scenario}, text=True, capture_output=True,
)
assert result.returncode == expected_code, (host, scenario, result.stdout, result.stderr)
log = [json.loads(line) for line in calls.read_text().splitlines()] if calls.exists() else []
if action:
assert [call[0] for call in log] == ["nix", "sudo"], log
assert log[-1][2] == action, log
elif scenario == "build-failure":
assert [call[0] for call in log] == ["nix"], log
else:
assert not log, log
print("PASS", host, args or ["default"], scenario)
+6
View File
@@ -0,0 +1,6 @@
result
result-*
.env
.env.*
*.qcow2
*.img
+27
View File
@@ -0,0 +1,27 @@
# Project + DeepSeek Harness microVM
`project.nix` is the shared toolchain for `nix develop` and the guest.
`flake.nix` sets RAM, vCPUs and networking. Run from the project directory:
```sh
nix run .#agent # boots with cwd mounted read-write at its original path
# Another terminal in the same directory:
nix run .#agent -- url # private browser login URL
nix run .#agent -- ssh # root shell, starting in the same project cwd
nix run .#agent -- stop
```
`$DSH_HOME` (default `~/.dsh`) and `${DSH_AGENTS_HOME:-~/.agents}/skills` are
also mounted **read-write**. No other home directories or host sockets are shared.
The first run creates missing DSH/skills directories. Existing DSH home must be
private (`chmod 700 ~/.dsh`). Credentials, settings, profiles and skills are live
shared files, not copied into the Nix store. Select the project's original
absolute path in the DSH UI; `/workspace` is also an alias.
DSH resolves `@deepseek-ai/dsh@latest` inside the VM on startup. Nix packages
follow the rolling Nixpkgs input: `nix flake update`, then restart the VM.
See `/etc/nix/AGENT-VM.md` for the full guide and security boundaries. The reusable
input lives at `/etc/nix/agent-vm`; replace the local input with your Git remote
when sharing this project. Keep backups: the agent can modify/delete the mounted
project and its shared DSH configuration/credentials/skills.
+44
View File
@@ -0,0 +1,44 @@
{
description = "Project toolchain + DeepSeek Harness microVM";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# Local bootstrap. For teammates/CI use your Git remote's moving branch:
# git+https://YOUR-REMOTE/nixconfig.git?dir=agent-vm&ref=main
agent-vm.url = "path:/etc/nix/agent-vm";
agent-vm.inputs.nixpkgs.follows = "nixpkgs";
};
outputs =
{ nixpkgs, agent-vm, ... }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
project = import ./project.nix { inherit pkgs; };
agent = agent-vm.lib.mkAgentVM {
inherit system project;
modules = [
{
microvm.mem = 4096; # MiB
microvm.vcpu = 4;
agentVM.network = {
hostAddress = "127.0.0.1"; # Or a host LAN/VPN IPv4 address, or 0.0.0.0.
sshPort = 2222; # Always host localhost in user-network mode.
webPort = 3080;
# For 0.0.0.0, list the actual browser authorities, not a wildcard:
# trustedHosts = [ "192.168.1.20:3080" ];
};
}
];
};
in
{
devShells.${system}.default = pkgs.mkShell {
inherit (project) packages env;
};
packages.${system}.agent = agent.package;
apps.${system}.agent = agent.app;
nixosConfigurations.agent = agent.nixos;
formatter.${system} = pkgs.nixfmt;
};
}
+18
View File
@@ -0,0 +1,18 @@
{ pkgs }:
{
# Single source of truth: both pkgs.mkShell and the VM consume this attrset.
# Replace/add your project's pinned toolchain here, not in two separate lists.
packages = with pkgs; [
python3
uv
ruff
nodejs
pnpm
git
ripgrep
];
# Shared non-secret variables. Do not put API tokens in a Nix expression.
env = {
UV_PYTHON_DOWNLOADS = "never";
};
}
+71
View File
@@ -0,0 +1,71 @@
# Bounded, offline smoke tests. No user caches, credentials, servers or downloads.
{ config, pkgs }:
assert builtins.elem pkgs.tor-browser config.home-manager.users.dev.home.packages;
pkgs.runCommand "workstation-tools-check"
{
nativeBuildInputs = [ pkgs.python3 ];
}
''
export HOME="$TMPDIR/home"
mkdir -p "$HOME"
export PATH=${config.system.path}/bin:$PATH
for tool in pi git git-lfs gh glab cmake ninja gdb \
cargo rustc rustfmt cargo-clippy cargo-nextest go gopls dlv uv ruff pyright \
node pnpm tsc biome bun deno shellcheck shfmt just hyperfine watchexec \
nom nvd nix-tree nix-diff statix deadnix nixd \
jq yq jless mlr csvlens sqlite3 duckdb pgcli \
xh grpcurl websocat aws skopeo buildah podman podman-compose dive \
kubectl helm k9s kubectx stern kustomize tofu ansible \
age sops gpg gitleaks trivy restic rclone dust duf ncdu procs lnav \
ffmpeg magick mediainfo exiftool pdftotext pandoc yt-dlp chafa desktop-help \
valgrind heaptrack rr eu-readelf bpftrace java javac mvn gradle kotlin \
dotnet ruby bundle php composer zig zls elixir erl protoc buf \
dprint stylua taplo marksman markdownlint-cli2 sqlfluff hadolint ast-grep rga \
hurl oha step mkcert cosign syft grype mosh sshfs asciinema vhs switch-system; do
command -v "$tool" >/dev/null
done
# Inspect only: don't start a graphical browser or make Tor connections.
test -x ${pkgs.tor-browser}/bin/tor-browser
test -x ${config.home-manager.users.dev.programs.lazygit.package}/bin/lazygit
test -x ${config.home-manager.users.dev.programs.tmux.package}/bin/tmux
switch-system --help > "$TMPDIR/switch-help"
grep -Fq '/etc/nix#nixos' "$TMPDIR/switch-help"
pi --version
uv --version
ruff --version
cargo --version
rustc --version
go version
node --version
java -version
dotnet --version
zig version
ruby --version
php --version | head -1
socat -V
# fping opens ICMP sockets even for -v; physical-config checks its binary.
drill -v
tor --version
torsocks --version
printf 'socat-offline-check\n' | socat -u STDIN STDOUT | grep -qx socat-offline-check
torsocks curl --version
proxychains4 -q curl --version
printf 'select 42;\n' | sqlite3 | grep -qx 42
python - <<'PY'
import importlib.util
spec = importlib.util.spec_from_file_location("desktop_help", "${./desktop-help.py}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
rows = module.rows([
{"modmask": 65, "key": "H", "description": "Help"},
{"modmask": 0, "key": "Print", "description": "Capture"},
{"modmask": 64, "key": "Q", "description": "Close; $(touch /not-executed)"},
{"modmask": 0, "key": "", "keycode": 20, "description": "Code", "submap": "resize"},
{"key": "undocumented"},
])
assert "Help Super + Shift + H" in rows
assert "Code [resize] code:20" in rows
assert len(rows) == 4
PY
touch "$out"
''
+251 -26
View File
@@ -1,39 +1,209 @@
{ pkgs, ... }: { inputs, pkgs, ... }:
let let
c = import ./colors.nix; c = import ./colors.nix;
# Standalone tools from the fast-moving pin, NOT an overlay of the system's
# Python/GCC/libraries. Desktop, drivers and NixOS services remain coherent.
latest = import inputs.nixpkgs-latest {
inherit (pkgs.stdenv.hostPlatform) system;
config = pkgs.config;
};
in in
{ {
programs.git.enable = true; programs.git = {
enable = true;
package = latest.git;
lfs.enable = true;
lfs.package = latest.git-lfs;
};
programs.zsh.enable = true; programs.zsh.enable = true;
# Native module supplies rootless mappings/networking. No Docker daemon,
# docker-group access, public API socket, containers or images on activation.
virtualisation.podman.enable = true;
# Mason's upstream Linux executables expect a conventional dynamic loader. # Mason's upstream Linux executables expect a conventional dynamic loader.
programs.nix-ld.enable = true; programs.nix-ld.enable = true;
environment.systemPackages = with pkgs; [ environment.systemPackages = [
nixfmt pkgs.nixfmt
]
++ (with latest; [
pi-coding-agent pi-coding-agent
# Runtime build prerequisites for the unchanged Lazy/Mason plugin workflow.
# Native builds and the unchanged Lazy/Mason runtime prerequisites.
gcc gcc
gnumake gnumake
pkg-config pkg-config
cmake
ninja
meson
ccache
python3 python3
nodejs nodejs
lua5_1 lua5_1
luajitPackages.luarocks luajitPackages.luarocks
clang-tools
gdb
lldb
valgrind
heaptrack
rr
elfutils
bpftrace
# Language toolchains, testing and dependency/security audits.
rustc
cargo
rustfmt
clippy
rust-analyzer
cargo-nextest
cargo-audit
cargo-deny
cargo-expand
cargo-edit
go
gopls
delve
golangci-lint
uv
ruff
pyright
pnpm
typescript
biome
bun
deno
# JVM, .NET, Ruby/PHP, Zig and BEAM: project versions still belong in devShells.
jdk25
maven
gradle_9
kotlin
dotnet-sdk_10
ruby
bundler
php
phpPackages.composer
zig
zls
beamPackages.elixir
beamPackages.erlang
protobuf
buf
shellcheck
shfmt
just
hyperfine
watchexec
tokei
yamllint
actionlint
pre-commit
dprint
stylua
taplo
marksman
markdownlint-cli2
sqlfluff
hadolint
ast-grep
ripgrep-all
# Version control: no invented identity, login or credentials.
gh
glab
git-absorb
git-filter-repo
difftastic
jujutsu
# Nix introspection and development; never replace the system Nix daemon.
nix-output-monitor
nvd
nix-tree
nix-diff
statix
deadnix
nixd
nixpkgs-review
# Shell, structured data, file navigation and documentation.
ripgrep ripgrep
fd fd
eza eza
jq jq
yq-go yq-go
jless
sd
tree
file
hexyl
parallel
moreutils
tealdeer
zellij
miller
csvlens
sqlite
duckdb
pgcli
litecli
redis
# HTTP/API clients. Packet-level tools belong in network.nix.
curl curl
wget wget
file xh
tree grpcurl
websocat
hurl
oha
step-cli
mkcert # Installed only: no CA is created or trusted automatically.
# Cloud, containers and orchestration: clients only, no live infrastructure.
awscli2
skopeo
buildah
podman-compose
dive
kubectl
kubernetes-helm
k9s
kubectx
stern
kustomize
opentofu
ansible
# Encryption, secret scanning, backup and transfer. No automatic jobs or keys.
age
sops
gnupg
gitleaks
trivy
cosign
syft
grype
restic
rclone
rsync
mosh
sshfs
openssl
unzip unzip
zip zip
p7zip p7zip
rsync zstd
openssl lz4
# Logs, storage, process and hardware diagnosis. No extra privileges granted.
dust
duf
ncdu
procs
sysstat
iotop
lnav
lsof lsof
strace strace
psmisc psmisc
@@ -44,7 +214,19 @@ in
lm_sensors lm_sensors
man-pages man-pages
man-pages-posix man-pages-posix
];
# Media/document tooling and rich Yazi previews.
ffmpeg
imagemagick
mediainfo
exiftool
poppler-utils
pandoc
yt-dlp
chafa
asciinema
vhs
]);
fonts.packages = [ pkgs.nerd-fonts.jetbrains-mono ]; fonts.packages = [ pkgs.nerd-fonts.jetbrains-mono ];
fonts.fontconfig.defaultFonts.monospace = [ "JetBrainsMono Nerd Font" ]; fonts.fontconfig.defaultFonts.monospace = [ "JetBrainsMono Nerd Font" ];
@@ -56,6 +238,43 @@ in
settings.user.useConfigOnly = true; settings.user.useConfigOnly = true;
}; };
programs.delta = {
enable = true;
package = latest.delta;
enableGitIntegration = true;
options = {
navigate = true;
line-numbers = true;
};
};
programs.lazygit = {
enable = true;
package = latest.lazygit;
settings.gui = {
nerdFontsVersion = "3";
showRandomTip = false;
};
};
programs.direnv = {
enable = true;
package = latest.direnv;
enableZshIntegration = true;
nix-direnv = {
enable = true;
package = latest.nix-direnv;
};
# Deliberately no whitelist: each project's .envrc needs `direnv allow`.
};
programs.tmux = {
enable = true;
package = latest.tmux;
terminal = "tmux-256color";
mouse = true;
keyMode = "vi";
historyLimit = 50000;
escapeTime = 10;
};
programs.zsh = { programs.zsh = {
enable = true; enable = true;
enableCompletion = true; enableCompletion = true;
@@ -85,15 +304,16 @@ in
programs.fzf = { programs.fzf = {
enable = true; enable = true;
enableZshIntegration = true; enableZshIntegration = true;
package = latest.fzf;
defaultCommand = "fd --type f --hidden --exclude .git"; defaultCommand = "fd --type f --hidden --exclude .git";
fileWidgetCommand = "fd --type f --hidden --exclude .git"; fileWidget.command = "fd --type f --hidden --exclude .git";
changeDirWidgetCommand = "fd --type d --hidden --exclude .git"; changeDirWidget.command = "fd --type d --hidden --exclude .git";
defaultOptions = [ defaultOptions = [
"--height=45%" "--height=45%"
"--layout=reverse" "--layout=reverse"
"--border=rounded" "--border=rounded"
]; ];
fileWidgetOptions = [ "--preview 'bat --color=always --line-range=:200 -- {}'" ]; fileWidget.options = [ "--preview 'bat --color=always --line-range=:200 -- {}'" ];
colors = { colors = {
bg = c.background; bg = c.background;
fg = c.text; fg = c.text;
@@ -102,7 +322,7 @@ in
hl = c.cyan; hl = c.cyan;
"hl+" = c.cyan; "hl+" = c.cyan;
border = c.border; border = c.border;
prompt = c.blue; prompt = c.accent;
pointer = c.purple; pointer = c.purple;
marker = c.green; marker = c.green;
info = c.muted; info = c.muted;
@@ -111,11 +331,12 @@ in
programs.starship = { programs.starship = {
enable = true; enable = true;
package = latest.starship;
settings = { settings = {
add_newline = true; add_newline = true;
format = "$username$hostname$directory$git_branch$git_status$nix_shell$cmd_duration\n$character"; format = "$username$hostname$directory$git_branch$git_status$nix_shell$cmd_duration\n$character";
directory = { directory = {
style = "bold ${c.blue}"; style = "bold ${c.accent}";
truncation_length = 4; truncation_length = 4;
truncation_symbol = "/"; truncation_symbol = "/";
read_only = " [read-only]"; read_only = " [read-only]";
@@ -142,14 +363,17 @@ in
programs.zoxide = { programs.zoxide = {
enable = true; enable = true;
package = latest.zoxide;
enableZshIntegration = true; enableZshIntegration = true;
}; };
programs.bat = { programs.bat = {
enable = true; enable = true;
package = latest.bat;
config.theme = "base16"; config.theme = "base16";
}; };
programs.btop = { programs.btop = {
enable = true; enable = true;
package = latest.btop;
settings = { settings = {
theme_background = false; theme_background = false;
rounded_corners = true; rounded_corners = true;
@@ -158,6 +382,7 @@ in
}; };
programs.yazi = { programs.yazi = {
enable = true; enable = true;
package = latest.yazi;
enableZshIntegration = true; enableZshIntegration = true;
settings.mgr = { settings.mgr = {
show_hidden = true; show_hidden = true;
@@ -170,10 +395,10 @@ in
enable = true; enable = true;
font = { font = {
name = "JetBrainsMono Nerd Font"; name = "JetBrainsMono Nerd Font";
size = 13; size = 12;
}; };
settings = { settings = {
window_padding_width = 14; window_padding_width = 10;
background_opacity = "1.0"; background_opacity = "1.0";
hide_window_decorations = true; hide_window_decorations = true;
scrollback_lines = 20000; scrollback_lines = 20000;
@@ -185,9 +410,9 @@ in
background = c.background; background = c.background;
cursor = c.cyan; cursor = c.cyan;
selection_foreground = c.text; selection_foreground = c.text;
selection_background = "#354562"; selection_background = c.selection;
url_color = c.blue; url_color = c.blue;
active_border_color = c.blue; active_border_color = c.accent;
inactive_border_color = c.border; inactive_border_color = c.border;
color0 = c.surface; color0 = c.surface;
color1 = c.red; color1 = c.red;
@@ -196,14 +421,14 @@ in
color4 = c.blue; color4 = c.blue;
color5 = c.purple; color5 = c.purple;
color6 = c.cyan; color6 = c.cyan;
color7 = "#dde1e6"; color7 = c.text;
color8 = c.muted; color8 = c.muted;
color9 = "#ff99a0"; color9 = "#eda692";
color10 = "#6fdc8c"; color10 = "#b8c992";
color11 = "#f7d75c"; color11 = "#e6ca91";
color12 = "#a6c8ff"; color12 = "#a7c2c8";
color13 = "#d4bbff"; color13 = "#c9b9d1";
color14 = "#82e9de"; color14 = "#adcbb7";
color15 = c.text; color15 = c.text;
}; };
}; };
+36 -15
View File
@@ -1,5 +1,11 @@
# Run as dev; privileged activation uses the already declared scoped sudo rule. # Both hosts stage for the next boot. Only the checkout and flake target differ;
repo=${NIXOS_CONFIG_REPO:-/etc/nixos} # target identity is explicit, never inferred from the login name.
repo=${NIXOS_CONFIG_REPO:?Set NIXOS_CONFIG_REPO}
host=${NIXOS_UPDATE_HOST:?Set NIXOS_UPDATE_HOST}
case "$host" in
dev|nixos) ;;
*) echo "Refusing unsupported update target: $host" >&2; exit 2 ;;
esac
state=${CACHE_DIRECTORY:-/var/cache/nixos-update} state=${CACHE_DIRECTORY:-/var/cache/nixos-update}
mkdir -p "$state" mkdir -p "$state"
exec 9>"$state/lock" exec 9>"$state/lock"
@@ -11,7 +17,10 @@ if [ -n "$(git status --porcelain)" ]; then
exit 0 exit 0
fi fi
baseline=$(git rev-parse HEAD) baseline=$(git rev-parse HEAD)
branch=$(git symbolic-ref HEAD) if ! branch=$(git symbolic-ref -q HEAD); then
echo 'Skipping automatic update: checkout is detached.'
exit 0
fi
work=$(mktemp -d "$state/work.XXXXXXXX") work=$(mktemp -d "$state/work.XXXXXXXX")
cleanup() { cleanup() {
git -C "$repo" worktree remove --force "$work" >/dev/null 2>&1 || true git -C "$repo" worktree remove --force "$work" >/dev/null 2>&1 || true
@@ -21,40 +30,52 @@ trap cleanup EXIT
git worktree add --detach "$work" "$baseline" git worktree add --detach "$work" "$baseline"
cd "$work" cd "$work"
# Only these stable release inputs advance. Neovim's source stays pinned. # Advance package inputs to newest resolving branch heads. Neovim stays pinned.
nix flake update nixpkgs home-manager nix flake update nixpkgs home-manager nixpkgs-latest
if git diff --quiet -- flake.lock; then if git diff --quiet -- flake.lock; then
echo 'Stable inputs are already current.' echo 'Package inputs are already current.'
exit 0 exit 0
fi fi
nix flake check --no-build --no-update-lock-file nix flake check --no-build --no-update-lock-file
nix build .#checks.x86_64-linux.updates .#checks.x86_64-linux.desktop-config \ nix build .#checks.x86_64-linux.updates .#checks.x86_64-linux.desktop-config \
.#checks.x86_64-linux.physical-config .#checks.x86_64-linux.tools \
.#checks.x86_64-linux.desktop-actions .#checks.x86_64-linux.switch-system \
--no-update-lock-file --no-link --no-update-lock-file --no-link
git add flake.lock git add flake.lock
git -c user.name='NixOS Updater' -c user.email='nixos-updater@localhost' \ git -c user.name='NixOS Updater' -c user.email='nixos-updater@localhost' \
commit -m 'chore: update stable NixOS and Home Manager inputs' commit -m 'chore: update NixOS package inputs'
built=$(nix build .#nixosConfigurations.dev.config.system.build.toplevel \ built=$(nix build ".#nixosConfigurations.$host.config.system.build.toplevel" \
--no-update-lock-file --no-link --print-out-paths) --no-update-lock-file --no-link --print-out-paths)
candidate=$(git rev-parse HEAD) candidate=$(git rev-parse HEAD)
# Never overwrite work started while the candidate was building. # Never overwrite work started while the candidate was building.
cd "$repo" cd "$repo"
if [ "$(git rev-parse HEAD)" != "$baseline" ] || \ unchanged() {
[ "$(git symbolic-ref HEAD)" != "$branch" ] || \ [ "$(git rev-parse HEAD)" = "$baseline" ] &&
[ -n "$(git status --porcelain)" ]; then [ "$(git symbolic-ref -q HEAD)" = "$branch" ] &&
[ -z "$(git status --porcelain)" ]
}
if ! unchanged; then
echo 'Configuration changed during the build; leaving it untouched.' echo 'Configuration changed during the build; leaving it untouched.'
exit 0 exit 0
fi fi
previous=$(readlink -f /run/current-system) # Preserve an already staged generation on failure, not just the running one.
previous=$(readlink -f /nix/var/nix/profiles/system)
sudo "$built/sw/bin/nixos-rebuild" dry-activate --no-reexec --store-path "$built" sudo "$built/sw/bin/nixos-rebuild" dry-activate --no-reexec --store-path "$built"
if ! unchanged; then
echo 'Configuration changed during dry activation; leaving it untouched.'
exit 0
fi
git merge --ff-only "$candidate" git merge --ff-only "$candidate"
if ! sudo "$built/sw/bin/nixos-rebuild" switch --no-reexec --store-path "$built"; then if ! sudo "$built/sw/bin/nixos-rebuild" boot --no-reexec --store-path "$built"; then
echo 'Activation failed; restoring the previous system. See the journal.' >&2 echo 'Staging failed; restoring the previous boot generation. See the journal.' >&2
sudo "$previous/sw/bin/nixos-rebuild" switch --no-reexec --store-path "$previous" sudo "$previous/sw/bin/nixos-rebuild" boot --no-reexec --store-path "$previous"
if [ "$(git rev-parse HEAD)" = "$candidate" ] && [ -z "$(git status --porcelain)" ]; then if [ "$(git rev-parse HEAD)" = "$candidate" ] && [ -z "$(git status --porcelain)" ]; then
git -c user.name='NixOS Updater' -c user.email='nixos-updater@localhost' \ git -c user.name='NixOS Updater' -c user.email='nixos-updater@localhost' \
revert --no-edit "$candidate" revert --no-edit "$candidate"
fi fi
exit 1 exit 1
fi fi
printf '%s %s %s\n' "$(date -Is)" "$candidate" "$built" > "$state/last-success"
echo "Updated $host (staged for next boot): $built"
# No forced reboot or garbage collection: recovery generations are retained. # No forced reboot or garbage collection: recovery generations are retained.
+34 -16
View File
@@ -17,12 +17,15 @@ with open(os.environ["CALLS"], "a") as f:
f.write(json.dumps([name, *args]) + "\n") f.write(json.dumps([name, *args]) + "\n")
if name == "nix": if name == "nix":
if args[:2] == ["flake", "update"]: if args[:2] == ["flake", "update"]:
assert args[2:] == ["nixpkgs", "home-manager"] assert args[2:] == ["nixpkgs", "home-manager", "nixpkgs-latest"]
if scenario != "unchanged": if scenario != "unchanged":
pathlib.Path("flake.lock").write_text('{"revision":2}\n') pathlib.Path("flake.lock").write_text('{"revision":2}\n')
elif args[:2] == ["flake", "check"]: elif args[:2] == ["flake", "check"]:
if scenario == "evaluation-failure": sys.exit(42) if scenario == "evaluation-failure": sys.exit(42)
elif args[0] == "build": elif args[0] == "build":
targets = [arg for arg in args if "nixosConfigurations." in arg]
if targets:
assert targets == [f'.#nixosConfigurations.{os.environ["NIXOS_UPDATE_HOST"]}.config.system.build.toplevel']
if scenario == "build-failure": sys.exit(43) if scenario == "build-failure": sys.exit(43)
if scenario == "concurrent-edit": if scenario == "concurrent-edit":
(pathlib.Path(os.environ["NIXOS_CONFIG_REPO"]) / "notes").write_text("user work\n") (pathlib.Path(os.environ["NIXOS_CONFIG_REPO"]) / "notes").write_text("user work\n")
@@ -32,17 +35,19 @@ if name == "nix":
elif name == "sudo": elif name == "sudo":
assert "--no-reexec" in args and "--store-path" in args assert "--no-reexec" in args and "--store-path" in args
if scenario == "dry-activation-failure" and args[1] == "dry-activate": sys.exit(44) if scenario == "dry-activation-failure" and args[1] == "dry-activate": sys.exit(44)
if scenario == "activation-failure" and args[1] == "switch" and args[-1] == os.environ["BUILT"]: if scenario == "concurrent-dry-edit" and args[1] == "dry-activate":
sys.exit(45) (pathlib.Path(os.environ["NIXOS_CONFIG_REPO"]) / "notes").write_text("user work\n")
if scenario in ("activation-failure", "rollback-failure") and args[1] == "boot":
if args[-1] == os.environ["BUILT"] or scenario == "rollback-failure": sys.exit(45)
elif name == "readlink": elif name == "readlink":
assert args == ["-f", "/run/current-system"] assert args == ["-f", "/nix/var/nix/profiles/system"]
print(os.environ["PREVIOUS"]) print(os.environ["PREVIOUS"])
else: else:
raise AssertionError(name) raise AssertionError(name)
''' '''
def run_case(scenario): def run_case(scenario, host="dev"):
with tempfile.TemporaryDirectory(prefix="update-test-") as directory: with tempfile.TemporaryDirectory(prefix="update-test-") as directory:
root = Path(directory) root = Path(directory)
repo = root / "repo with spaces" repo = root / "repo with spaces"
@@ -52,7 +57,8 @@ def run_case(scenario):
mocks.mkdir() mocks.mkdir()
calls_path = root / "calls.jsonl" calls_path = root / "calls.jsonl"
env = dict(os.environ, HOME=str(root / "home"), CACHE_DIRECTORY=str(cache), env = dict(os.environ, HOME=str(root / "home"), CACHE_DIRECTORY=str(cache),
NIXOS_CONFIG_REPO=str(repo), SCENARIO=scenario, NIXOS_CONFIG_REPO=str(repo), NIXOS_UPDATE_HOST=host,
SCENARIO=scenario,
CALLS=str(calls_path), BUILT=str(root / "candidate-system"), CALLS=str(calls_path), BUILT=str(root / "candidate-system"),
PREVIOUS=str(root / "previous-system"), PREVIOUS=str(root / "previous-system"),
GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null") GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null")
@@ -77,37 +83,49 @@ def run_case(scenario):
baseline = git("rev-parse", "HEAD") baseline = git("rev-parse", "HEAD")
if scenario == "dirty": if scenario == "dirty":
(repo / "notes").write_text("user work\n") (repo / "notes").write_text("user work\n")
if scenario == "detached":
git("checkout", "--detach", "--quiet")
result = subprocess.run( result = subprocess.run(
[shutil.which("bash"), "-euo", "pipefail", str(script)], [shutil.which("bash"), "-euo", "pipefail", str(script)],
env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
) )
calls = [json.loads(line) for line in calls_path.read_text().splitlines()] if calls_path.exists() else [] calls = [json.loads(line) for line in calls_path.read_text().splitlines()] if calls_path.exists() else []
sudo = [call for call in calls if call[0] == "sudo"] sudo = [call for call in calls if call[0] == "sudo"]
failed = scenario.endswith("failure") failed = scenario.endswith("failure") or scenario == "invalid-host"
assert (result.returncode != 0) == failed, (scenario, result.stdout) assert (result.returncode != 0) == failed, (scenario, result.stdout)
assert all(call[2] in ("dry-activate", "boot") for call in sudo), calls
if scenario == "success": if scenario == "success":
assert json.loads((repo / "flake.lock").read_text())["revision"] == 2 assert json.loads((repo / "flake.lock").read_text())["revision"] == 2
assert git("log", "-1", "--format=%an <%ae>") == "NixOS Updater <nixos-updater@localhost>" assert git("log", "-1", "--format=%an <%ae>") == "NixOS Updater <nixos-updater@localhost>"
assert [call[2] for call in sudo] == ["dry-activate", "switch"] assert [call[2] for call in sudo] == ["dry-activate", "boot"]
assert (cache / "last-success").is_file()
assert git("status", "--porcelain") == "" assert git("status", "--porcelain") == ""
elif scenario == "activation-failure": elif scenario == "activation-failure":
assert json.loads((repo / "flake.lock").read_text())["revision"] == 1 assert json.loads((repo / "flake.lock").read_text())["revision"] == 1
assert [call[2] for call in sudo] == ["dry-activate", "switch", "switch"] assert [call[2] for call in sudo] == ["dry-activate", "boot", "boot"]
assert sudo[-1][-1] != env["BUILT"] assert sudo[-1][-1] != env["BUILT"]
assert git("log", "-1", "--format=%s").startswith("Revert") assert git("log", "-1", "--format=%s").startswith("Revert")
elif scenario == "rollback-failure":
# Keep the candidate commit for recovery, never claim rollback worked.
assert json.loads((repo / "flake.lock").read_text())["revision"] == 2
assert [call[2] for call in sudo] == ["dry-activate", "boot", "boot"]
assert not (cache / "last-success").exists()
else: else:
assert git("rev-parse", "HEAD") == baseline, (scenario, result.stdout) assert git("rev-parse", "HEAD") == baseline, (scenario, result.stdout)
assert json.loads((repo / "flake.lock").read_text())["revision"] == 1 assert json.loads((repo / "flake.lock").read_text())["revision"] == 1
assert not sudo or scenario == "dry-activation-failure" assert not sudo or scenario in ("dry-activation-failure", "concurrent-dry-edit")
assert (repo / "unchanged-editor-input").read_text().strip() == "380eb86778a7c53a0f1c18e84f14037456155347" assert (repo / "unchanged-editor-input").read_text().strip() == "380eb86778a7c53a0f1c18e84f14037456155347"
assert len(git("worktree", "list", "--porcelain").split("worktree ")) == 2 assert len(git("worktree", "list", "--porcelain").split("worktree ")) == 2
if scenario in ["dirty", "concurrent-edit"]: if scenario in ["dirty", "concurrent-edit", "concurrent-dry-edit"]:
assert (repo / "notes").read_text() == "user work\n" assert (repo / "notes").read_text() == "user work\n"
if scenario == "dirty": if scenario in ("dirty", "detached", "invalid-host"):
assert not calls assert not calls
print("PASS", scenario) print("PASS", host, "boot", scenario)
for scenario in ["dirty", "unchanged", "evaluation-failure", "build-failure", "concurrent-edit", for host in ["dev", "nixos"]:
"dry-activation-failure", "activation-failure", "success"]: for scenario in ["dirty", "detached", "unchanged", "evaluation-failure", "build-failure",
run_case(scenario) "concurrent-edit", "concurrent-dry-edit", "dry-activation-failure",
"activation-failure", "rollback-failure", "success"]:
run_case(scenario, host)
run_case("invalid-host", "not-a-host")
+39 -11
View File
@@ -1,21 +1,48 @@
{ pkgs, ... }: {
config,
lib,
pkgs,
...
}:
let let
env = config.systemd.services.nixos-update.environment;
repo = env.NIXOS_CONFIG_REPO;
runtimeInputs = with pkgs; [
nix
git
coreutils
util-linux
];
updater = pkgs.writeShellApplication { updater = pkgs.writeShellApplication {
name = "update-system"; name = "update-system";
runtimeInputs = with pkgs; [ inherit runtimeInputs;
nix
git
coreutils
util-linux
];
text = builtins.readFile ./update-system.sh; text = builtins.readFile ./update-system.sh;
}; };
switcher = pkgs.writeShellApplication {
name = "switch-system";
inherit runtimeInputs;
text = ''
export NIXOS_CONFIG_REPO=${lib.escapeShellArg repo}
export NIXOS_UPDATE_HOST=${lib.escapeShellArg env.NIXOS_UPDATE_HOST}
${builtins.readFile ./switch-system.sh}
'';
};
in in
{ {
environment.systemPackages = [ updater ]; environment.systemPackages = [
systemd.tmpfiles.rules = [ "d /var/cache/nixos-update 0700 dev users -" ]; updater
switcher
];
# Z does not follow symlinks: in /etc/nix, Nix-owned configuration links and
# result links never cause ownership changes in /etc/static or /nix/store.
systemd.tmpfiles.rules = [
"d ${repo} 0755 dev users -"
"Z ${repo} - dev users -"
"d /var/cache/nixos-update 0700 dev users -"
"Z /var/cache/nixos-update - dev users -"
];
systemd.services.nixos-update = { systemd.services.nixos-update = {
description = "Build, record and apply stable NixOS updates without disturbing local work"; description = "Build and stage NixOS/tool updates for the next boot";
wants = [ "network-online.target" ]; wants = [ "network-online.target" ];
after = [ "network-online.target" ]; after = [ "network-online.target" ];
path = [ "/run/wrappers" ]; path = [ "/run/wrappers" ];
@@ -23,8 +50,9 @@ in
Type = "oneshot"; Type = "oneshot";
User = "dev"; User = "dev";
Group = "users"; Group = "users";
WorkingDirectory = "/etc/nixos"; WorkingDirectory = repo;
CacheDirectory = "nixos-update"; CacheDirectory = "nixos-update";
CacheDirectoryMode = "0700";
UMask = "0077"; UMask = "0077";
Nice = 10; Nice = 10;
IOSchedulingClass = "idle"; IOSchedulingClass = "idle";
+2 -2
View File
@@ -1,6 +1,8 @@
{ pkgs, ... }: { pkgs, ... }:
{ {
# Preserve locally provisioned passwords on every host; never invent one.
users.mutableUsers = true;
users.users.dev = { users.users.dev = {
isNormalUser = true; isNormalUser = true;
uid = 1001; uid = 1001;
@@ -32,7 +34,5 @@
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /home/dev/.config 0755 dev users -" "d /home/dev/.config 0755 dev users -"
"d /home/dev/projects 0755 dev users -" "d /home/dev/projects 0755 dev users -"
# Keep the working repo editable by dev. Z does not follow store symlinks.
"Z /etc/nixos - dev users -"
]; ];
} }
+8
View File
@@ -0,0 +1,8 @@
# The image is fetched reproducibly, not downloaded by a login/startup script.
# Source/attribution details and the original fallback are documented in DESKTOP.md.
{ pkgs }:
pkgs.fetchurl {
name = "one-ring-dark-1920x1200.jpg";
url = "https://w.wallhaven.cc/full/01/wallhaven-01e5v4.jpg";
hash = "sha256-3jkKzJ0q4MTlHygwUs3SuSiUIjUjkiTqSaM+q8EL/oc=";
}
+36 -26
View File
@@ -1,47 +1,57 @@
# Opt-in physical workstation integration. Never imported by the EC2 host. # Local hardware/session integration. Shared by the laptop and graphical VM,
# not the headless EC2 host; all application/user settings live in common.nix.
{ {
config, config,
lib, lib,
pkgs, pkgs,
... ...
}: }:
let
managedHyprlandSession =
pkgs.runCommand "hyprland-managed-session"
{
passthru.providedSessions = [ "hyprland-uwsm" ];
}
''
mkdir -p "$out/share/wayland-sessions"
ln -s ${config.programs.hyprland.package}/share/wayland-sessions/hyprland-uwsm.desktop \
"$out/share/wayland-sessions/hyprland-uwsm.desktop"
'';
in
{ {
networking.networkmanager.enable = true; networking.networkmanager = {
enable = true;
plugins = [ pkgs.networkmanager-openvpn ];
};
networking.dhcpcd.enable = false; networking.dhcpcd.enable = false;
users.users.dev.extraGroups = [ "networkmanager" ]; users.users.dev.extraGroups = [ "networkmanager" ];
services.xserver.enable = true;
services.displayManager = {
sddm.enable = true;
defaultSession = "hyprland-uwsm";
# Plain Hyprland bypasses the UWSM-owned bar/idle/polkit services.
sessionPackages = lib.mkForce [ managedHyprlandSession ];
};
services.printing.enable = true;
services.udisks2.enable = true;
services.gvfs.enable = true;
services.fwupd.enable = true;
home-manager.users.dev.services.udiskie.enable = true;
environment.systemPackages = [ pkgs.networkmanagerapplet ];
hardware.bluetooth = { hardware.bluetooth = {
enable = true; enable = true;
powerOnBoot = false; powerOnBoot = false;
}; };
services.blueman.enable = true; services.blueman.enable = true;
# Keep Blueman's manager/mechanism, not a second tray applet next to ashell. services.upower.enable = true;
services.power-profiles-daemon.enable = true;
# Retain Blueman's manager without a second tray applet next to ashell.
home-manager.users.dev.xdg.configFile."autostart/blueman.desktop".text = '' home-manager.users.dev.xdg.configFile."autostart/blueman.desktop".text = ''
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=Blueman Name=Blueman
Hidden=true Hidden=true
''; '';
services.upower.enable = true;
services.power-profiles-daemon.enable = true;
services.udisks2.enable = true;
home-manager.users.dev.services.udiskie.enable = true;
environment.systemPackages = [ pkgs.networkmanagerapplet ];
programs.regreet = {
enable = true;
theme.name = "Adwaita-dark";
font = {
package = pkgs.inter;
name = "Inter";
size = 13;
};
settings.GTK.application_prefer_dark_theme = true;
};
# No autologin. The physical host must supply a secure credential file.
assertions = [
{
assertion = config.users.users.dev.hashedPasswordFile != null;
message = "workstation.nix requires users.users.dev.hashedPasswordFile (provision outside the Nix store); no production password is invented.";
}
];
} }