Compare commits
37 Commits
0a8b863e4b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
bbdc478e84
|
|||
|
675fc7f805
|
|||
|
141754ca39
|
|||
|
4b173ef164
|
|||
|
3201b5726e
|
|||
|
3c7bdc0c42
|
|||
|
2ebb7fc90d
|
|||
|
72320e2332
|
|||
|
b5a94520fe
|
|||
|
9ee3547d5d
|
|||
|
ce288ccdb0
|
|||
|
da87f82a66
|
|||
|
90f2c27c2c
|
|||
|
450b77140b
|
|||
|
318373c09c
|
|||
| d55743a9e7 | |||
|
8ab4924948
|
|||
|
8bd148dc96
|
|||
|
2ab1c855ec
|
|||
|
f67ec5bde6
|
|||
|
112b85f3fb
|
|||
|
86cf624027
|
|||
|
1df3a303f5
|
|||
| 07a5276e40 | |||
| f3d21f16fb | |||
|
5b2a1a652a
|
|||
|
665793668d
|
|||
| 5ccd84c77e | |||
| 7721c9d3a2 | |||
| b41a547589 | |||
| d122842995 | |||
| d65d991118 | |||
| 06ccc337c1 | |||
|
a3f7a19cc2
|
|||
|
e019f2d4fb
|
|||
|
22282691e7
|
|||
|
bc3652c782
|
21
AGENTS.md
21
AGENTS.md
@@ -36,10 +36,11 @@ lib/
|
||||
overlays.nix # jellyfin-exporter, igpu-exporter, reflac, ensureZfsMounts
|
||||
patches/nixpkgs/ # applied to nixpkgs-stable for muffin builds
|
||||
secrets/
|
||||
desktop/ # git-crypt: mreow + yarn share these (wifi, nix-cache-netrc, secureboot.tar, password-hash, disk-password)
|
||||
secrets.nix # agenix recipients (who can decrypt each .age)
|
||||
desktop/ # agenix *.age (mreow + yarn) + disk-password (install-time only, git-crypt)
|
||||
home/ # git-crypt: per-user HM secrets (api keys, steam id)
|
||||
server/ # agenix *.age + git-crypt *.nix/*.tar/livekit_keys
|
||||
usb-secrets/ # USB-resident agenix identity key (git-crypt inside the repo)
|
||||
server/ # agenix *.age + git-crypt *.nix/*.tar/livekit_keys (muffin)
|
||||
usb-secrets/ # USB-resident agenix identity for muffin (git-crypt inside the repo)
|
||||
```
|
||||
|
||||
**Never read or write files under `secrets/`.** They are encrypted at rest (git-crypt for plaintext, agenix for `.age`). The git-crypt key is delivered to `muffin` at runtime as `/run/agenix/git-crypt-key-nixos.age`.
|
||||
@@ -89,7 +90,7 @@ If Nix complains about a missing file, `git add` it first — flakes only see tr
|
||||
| `common-` | imported by ALL hosts | `common-doas.nix`, `common-nix.nix`, `common-shell-fish.nix` |
|
||||
| `desktop-` | imported by mreow + yarn only | `desktop-common.nix`, `desktop-steam.nix`, `desktop-networkmanager.nix` |
|
||||
| `server-` | imported by muffin only | `server-security.nix`, `server-power.nix`, `server-impermanence.nix`, `server-lanzaboote-agenix.nix` |
|
||||
| *(none)* | host-specific filename-scoped; see file contents | `age-secrets.nix`, `zfs.nix`, `no-rgb.nix` (yarn + muffin) |
|
||||
| *(none)* | host-specific filename-scoped; see file contents | `zfs.nix`, `no-rgb.nix` (yarn + muffin) |
|
||||
|
||||
New modules: pick the narrowest prefix that's true, then add the import explicitly in the host's `default.nix` (there is no auto-discovery).
|
||||
|
||||
@@ -117,14 +118,18 @@ New modules: pick the narrowest prefix that's true, then add the import explicit
|
||||
## Secrets
|
||||
|
||||
- **git-crypt** covers `secrets/**` per the root `.gitattributes`. Initialized with a single symmetric key checked into `secrets/server/git-crypt-key-nixos.age` (agenix-encrypted to the USB SSH identity).
|
||||
- **agenix** decrypts `secrets/server/*.age` at activation into `/run/agenix/` on muffin.
|
||||
- **USB identity**: `/mnt/usb-secrets/usb-secrets-key` on muffin; the age identity path is wired in `modules/usb-secrets.nix`.
|
||||
- **Encrypting a new agenix secret** uses the SSH public key directly with `age -R`:
|
||||
- **agenix** decrypts `*.age` into `/run/agenix/` at activation on every host:
|
||||
- **muffin**: identity is `/mnt/usb-secrets/usb-secrets-key` (ssh-ed25519 on a physical USB). Wired in `modules/usb-secrets.nix`.
|
||||
- **mreow + yarn**: identity is `/var/lib/agenix/tpm-identity` (an `age-plugin-tpm` handle sealed by the host's TPM 2.0). Wired in `modules/desktop-age-secrets.nix`; yarn persists `/var/lib/agenix` through impermanence.
|
||||
- **Recipients** are declared in `secrets/secrets.nix`. Desktop secrets are encrypted to the admin SSH key + each host's TPM recipient; server secrets stay encrypted to the muffin USB key.
|
||||
- **Bootstrap a new desktop**: run `doas scripts/bootstrap-desktop-tpm.sh` on the host. It generates a TPM-sealed identity at `/var/lib/agenix/tpm-identity` and prints an `age1tpm1…` recipient. Append it to the `tpm` list in `secrets/secrets.nix`, run `agenix -r` to re-encrypt, commit, `./deploy.sh switch`.
|
||||
- **Encrypting a new server secret** uses the SSH public key directly with `age -R`:
|
||||
```sh
|
||||
age -R <(ssh-keygen -y -f secrets/usb-secrets/usb-secrets-key) \
|
||||
-o secrets/server/<name>.age \
|
||||
/path/to/plaintext
|
||||
```
|
||||
For desktop secrets, prefer `agenix -e secrets/desktop/<name>.age` from a shell with `age-plugin-tpm` on PATH — it reads `secrets/secrets.nix` and encrypts to every recipient listed there.
|
||||
- **DO NOT use `ssh-to-age`**. It produces `X25519` recipient stanzas, which the SSH private key on muffin cannot decrypt (it only decrypts `ssh-ed25519` stanzas produced by `age -R` against the SSH pubkey). Mismatched stanzas show up as `age: error: no identity matched any of the recipients` at deploy time.
|
||||
- Never read or commit plaintext secrets. Never log secret values.
|
||||
|
||||
@@ -210,7 +215,7 @@ Prior art: the 3-path `{kernel,initrd,kernel-modules}` diff is lifted from nixpk
|
||||
|
||||
- **Privilege escalation**: `doas` everywhere; `sudo` is disabled on every host.
|
||||
- **Shell**: fish. `bash` login shells re-exec into fish via `programs.bash.interactiveShellInit` (see `modules/common-shell-fish.nix`).
|
||||
- **Secure boot**: lanzaboote. Desktops extract keys from `secrets/desktop/secureboot.tar`; muffin extracts from an agenix-decrypted tar (see `modules/server-lanzaboote-agenix.nix`).
|
||||
- **Secure boot**: lanzaboote. Every host extracts keys from an agenix-decrypted tar at activation — desktops via `modules/desktop-lanzaboote-agenix.nix`, muffin via `modules/server-lanzaboote-agenix.nix`.
|
||||
- **Impermanence**: muffin is tmpfs-root with `/persistent` surviving reboots (`modules/server-impermanence.nix`); yarn binds `/home/primary` from `/persistent` (`hosts/yarn/impermanence.nix`).
|
||||
- **Disks**: disko.
|
||||
- **Binary cache**: muffin runs harmonia; desktops consume it at `https://nix-cache.sigkill.computer`.
|
||||
|
||||
@@ -12,11 +12,11 @@ Browser: Firefox 🦊 (actually [Zen Browser](https://github.com/zen-browser/des
|
||||
|
||||
Text Editor: [Doom Emacs](https://github.com/doomemacs/doomemacs)
|
||||
|
||||
Terminal: [alacritty](https://github.com/alacritty/alacritty)
|
||||
Terminal: [ghostty](https://ghostty.org/)
|
||||
|
||||
Shell: [fish](https://fishshell.com/) with the [pure](https://github.com/pure-fish/pure) prompt
|
||||
|
||||
WM: [niri](https://github.com/YaLTeR/niri) (KDE on my desktop)
|
||||
WM: [niri](https://github.com/YaLTeR/niri)
|
||||
|
||||
### Background
|
||||
- Got my background from [here](https://old.reddit.com/r/celestegame/comments/11dtgwg/all_most_of_the_backgrounds_in_celeste_edited/) and used the command `magick input.png -filter Point -resize 2880x1920! output.png` to upscale it bilinearly
|
||||
|
||||
150
flake.lock
generated
150
flake.lock
generated
@@ -92,16 +92,16 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776182890,
|
||||
"narHash": "sha256-+/VOe8XGq5klpU+I19D+3TcaR7o+Cwbq67KNF7mcFak=",
|
||||
"owner": "Mic92",
|
||||
"lastModified": 1776192490,
|
||||
"narHash": "sha256-5gYQNEs0/vDkHhg63aHS5g0IwG/8HNvU1Vr00cElofk=",
|
||||
"owner": "nix-community",
|
||||
"repo": "bun2nix",
|
||||
"rev": "648d293c51e981aec9cb07ba4268bc19e7a8c575",
|
||||
"rev": "6ef9f144616eedea90b364bb408ef2e1de7b310a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "Mic92",
|
||||
"ref": "catalog-support",
|
||||
"owner": "nix-community",
|
||||
"ref": "staging-2.1.0",
|
||||
"repo": "bun2nix",
|
||||
"type": "github"
|
||||
}
|
||||
@@ -109,11 +109,11 @@
|
||||
"cachyos-kernel": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1776608760,
|
||||
"narHash": "sha256-ehDv8bF7k/2Kf4b8CCoSm51U/MOoFuLsRXqe5wZ57sE=",
|
||||
"lastModified": 1776881435,
|
||||
"narHash": "sha256-j8AobLjMzeKJugseObrVC4O5k7/aZCWoft2sCS3jWYs=",
|
||||
"owner": "CachyOS",
|
||||
"repo": "linux-cachyos",
|
||||
"rev": "7e06e29005853bbaaa3b1c1067f915d6e0db728a",
|
||||
"rev": "1c61dfd1c3ad7762faa0db8b06c6af6c59cc4340",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -125,11 +125,11 @@
|
||||
"cachyos-kernel-patches": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1776792814,
|
||||
"narHash": "sha256-39dlIhz9KxUNQFxGpE9SvCviaOWAivdW0XJM8RnPNmg=",
|
||||
"lastModified": 1777002108,
|
||||
"narHash": "sha256-PIZCIf6xUTOUqLFbEGH0mSwu2O/YfeAmYlgdAbP4dhs=",
|
||||
"owner": "CachyOS",
|
||||
"repo": "kernel-patches",
|
||||
"rev": "d7d558d0b2e239e27b40bcf1af6fe12e323aa391",
|
||||
"rev": "46476ae2538db486462aef8a9de37d19030cdaf2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -222,11 +222,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776849698,
|
||||
"narHash": "sha256-t2I9ZhBuAcaLV1Z65aVd/5BmDFGvyzLY5kpiSedx2uY=",
|
||||
"lastModified": 1777257791,
|
||||
"narHash": "sha256-KE3+aTLGTIp8OZEI4lq1kvp30lmh3KA8Ru84UocbXyE=",
|
||||
"owner": "nix-community",
|
||||
"repo": "emacs-overlay",
|
||||
"rev": "87dff52c245cba0c5103cf89b964e508ed9bb720",
|
||||
"rev": "b1f88788b2f0e31cfa42e9dffbc5e9de218369de",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -266,11 +266,11 @@
|
||||
},
|
||||
"locked": {
|
||||
"dir": "pkgs/firefox-addons",
|
||||
"lastModified": 1776830588,
|
||||
"narHash": "sha256-1X4L6+F7DgYTUDah+PDs7IYJiQrb7MwYfateq2fBxGY=",
|
||||
"lastModified": 1777262571,
|
||||
"narHash": "sha256-ni1Cz9BChOXO6C0H4cRAq6bJRQIUV40Yet306ZOEEHs=",
|
||||
"owner": "rycee",
|
||||
"repo": "nur-expressions",
|
||||
"rev": "f3db83bc13aee22474fab41fa838e50a691dfbc5",
|
||||
"rev": "0827fcbe30e591e79b0554ecc5be9c79ba71a86b",
|
||||
"type": "gitlab"
|
||||
},
|
||||
"original": {
|
||||
@@ -484,11 +484,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776891022,
|
||||
"narHash": "sha256-vEe2f4NEhMvaNDpM1pla4hteaIIGQyAMKUfIBPLasr0=",
|
||||
"lastModified": 1777258755,
|
||||
"narHash": "sha256-EC07KwADRE2LdIk7vEDyAaD3I0ZUq24T9jQF9L0iEPk=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "508daf831ab8d1b143d908239c39a7d8d39561b2",
|
||||
"rev": "7f8bbc93d63401e41368d6ddc46a4f631610fa90",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -564,11 +564,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776874528,
|
||||
"narHash": "sha256-X4Y2vMbVBuyUQzbZnl72BzpZMYUsWdE78JuDg2ySDxE=",
|
||||
"lastModified": 1777132364,
|
||||
"narHash": "sha256-qK6A0xRDAgLf8DUHpDWpVL6NcWi4IhoVClcov+GjLP0=",
|
||||
"owner": "Jovian-Experiments",
|
||||
"repo": "Jovian-NixOS",
|
||||
"rev": "4c8ccc482a3665fb4a3b2cadbbe7772fb7cc2629",
|
||||
"rev": "7ae8615cc307c282555b025f88e0c8d7c185bcbf",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -631,11 +631,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776862155,
|
||||
"narHash": "sha256-EDvbwsGNE/N5ul+9ul1dJP3Ouf72+Ub2C0UMbDWcxyQ=",
|
||||
"lastModified": 1777066729,
|
||||
"narHash": "sha256-f+a+ikbq0VS6RQFf+A6EuVnsWYn2RR3ggRJNkzZgMto=",
|
||||
"owner": "TheTom",
|
||||
"repo": "llama-cpp-turboquant",
|
||||
"rev": "9e3fb40e8bc0f873ad4d3d8329b17dacff28e4ca",
|
||||
"rev": "11a241d0db78a68e0a5b99fe6f36de6683100f6a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -657,11 +657,11 @@
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776883427,
|
||||
"narHash": "sha256-prHCm++hniRcoqzvWTEFyAiLKT6m+EUVCRaDLrsuEgM=",
|
||||
"lastModified": 1777266861,
|
||||
"narHash": "sha256-cdSr2nIz4I+ysG1gAZxbKQo+f79vCCKfQCdiRYnyPec=",
|
||||
"owner": "numtide",
|
||||
"repo": "llm-agents.nix",
|
||||
"rev": "6fd26c9cb50d9549f3791b3d35e4f72f97677103",
|
||||
"rev": "c8f7c7882804510f2b807021cac0a69c1aeb4829",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -704,11 +704,11 @@
|
||||
"xwayland-satellite-unstable": "xwayland-satellite-unstable"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776879043,
|
||||
"narHash": "sha256-M9RjuowtoqQbFRdQAm2P6GjFwgHjRcnWYcB7ChSjDms=",
|
||||
"lastModified": 1777240421,
|
||||
"narHash": "sha256-ooPmu+8tqOGh4kozPW4rJC7Y7WM/FHtEY3OK1PoNW7g=",
|
||||
"owner": "sodiboo",
|
||||
"repo": "niri-flake",
|
||||
"rev": "535ebbe038039215a5d1c6c0c67f833409a5be96",
|
||||
"rev": "2bb22af2985e5f3cfd051b3d977ebfbf81126280",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -737,11 +737,11 @@
|
||||
"niri-unstable": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1776853441,
|
||||
"narHash": "sha256-mSxfoEs7DiDhMCBzprI/1K7UXzMISuGq0b7T06LVJXE=",
|
||||
"lastModified": 1777237919,
|
||||
"narHash": "sha256-bZHBzo4EuW/xLzXnnMKsIMdZYqgY2O0mIMdplwDHB8Y=",
|
||||
"owner": "YaLTeR",
|
||||
"repo": "niri",
|
||||
"rev": "74d2b18603366b98ec9045ecf4a632422f472365",
|
||||
"rev": "a85b922919815c32a3ae34e0838830fe522d6a1c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -761,11 +761,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776796985,
|
||||
"narHash": "sha256-cNFg3H09sBZl1v9ds6PDHfLCUTDJbefGMSv+WxFs+9c=",
|
||||
"lastModified": 1777227006,
|
||||
"narHash": "sha256-A7GcOXjfo2xmZ3ERgN0j6GcqaVzqIf5zpYQcdfDaMr0=",
|
||||
"owner": "xddxdd",
|
||||
"repo": "nix-cachyos-kernel",
|
||||
"rev": "ac5956bbceb022998fc1dd0001322f10ef1e6dda",
|
||||
"rev": "0f7e2bea4088227a80502557f6c0e3b74949d6b5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -787,11 +787,11 @@
|
||||
"systems": "systems_6"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776851701,
|
||||
"narHash": "sha256-tdtOcU2Hz/eLqAhkzUcEocgX0WpjKSbl2SkVjOZGZw0=",
|
||||
"lastModified": 1776938345,
|
||||
"narHash": "sha256-3/BFiytDNoIXMUQHcJLoxa7JK0Q1/49M0ffOR9pbzvw=",
|
||||
"owner": "marienz",
|
||||
"repo": "nix-doom-emacs-unstraightened",
|
||||
"rev": "7ac65a49eec5e3f87d27396e645eddbf9dc626de",
|
||||
"rev": "eb25c754986165e509ad2ab8c6b6729f4a861f0c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -802,11 +802,11 @@
|
||||
},
|
||||
"nix-flatpak": {
|
||||
"locked": {
|
||||
"lastModified": 1776625032,
|
||||
"narHash": "sha256-edvwHiFhgOiwywt6/Iwe+sSn6ybhU3WZGnIoiGcKjfQ=",
|
||||
"lastModified": 1777229239,
|
||||
"narHash": "sha256-OwSaWqlBdKn8QIa7BrPtJmlrr46U7AuwMc/toDKuMZw=",
|
||||
"owner": "gmodena",
|
||||
"repo": "nix-flatpak",
|
||||
"rev": "479e19f1decb390aa5b75cae13ddf87d763c74cc",
|
||||
"rev": "3f1d78b63b6af353c0685b8a7411c04d980426e4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -846,11 +846,11 @@
|
||||
"systems": "systems_7"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776828595,
|
||||
"narHash": "sha256-LkFpFnPTK6H0gwyfYezN3kEKHVxjSdPp/tBnrQRFP3E=",
|
||||
"lastModified": 1777001712,
|
||||
"narHash": "sha256-9JX9msZU1NvHzjKM24PRorP76Ge8GBy6LAkJKA21mlY=",
|
||||
"owner": "Infinidoge",
|
||||
"repo": "nix-minecraft",
|
||||
"rev": "28f0f2369655a5910e810c35c698dfaa9ccec692",
|
||||
"rev": "394d3bfd943458baf29e4798bc9b256d824a3bb9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -861,11 +861,11 @@
|
||||
},
|
||||
"nixos-hardware": {
|
||||
"locked": {
|
||||
"lastModified": 1776830795,
|
||||
"narHash": "sha256-PAfvLwuHc1VOvsLcpk6+HDKgMEibvZjCNvbM1BJOA7o=",
|
||||
"lastModified": 1776983936,
|
||||
"narHash": "sha256-ZOQyNqSvJ8UdrrqU1p7vaFcdL53idK+LOM8oRWEWh6o=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixos-hardware",
|
||||
"rev": "72674a6b5599e844c045ae7449ba91f803d44ebc",
|
||||
"rev": "2096f3f411ce46e88a79ae4eafcfc9df8ed41c61",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -877,11 +877,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776548001,
|
||||
"narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=",
|
||||
"lastModified": 1776877367,
|
||||
"narHash": "sha256-EHq1/OX139R1RvBzOJ0aMRT3xnWyqtHBRUBuO1gFzjI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc",
|
||||
"rev": "0726a0ecb6d4e08f6adced58726b95db924cef57",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -937,11 +937,11 @@
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1776734388,
|
||||
"narHash": "sha256-vl3dkhlE5gzsItuHoEMVe+DlonsK+0836LIRDnm6MXQ=",
|
||||
"lastModified": 1777077449,
|
||||
"narHash": "sha256-AIiMJiqvGrN4HyLEbKAoCSRRYn0rnlW5VbKNIMIYqm4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "10e7ad5bbcb421fe07e3a4ad53a634b0cd57ffac",
|
||||
"rev": "a4bf06618f0b5ee50f14ed8f0da77d34ecc19160",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -991,11 +991,11 @@
|
||||
"noctalia-qs": "noctalia-qs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776888984,
|
||||
"narHash": "sha256-Up2F/eoMuPUsZnPVYdH5TMHe1TBP2Ue1QuWd0vWZoxY=",
|
||||
"lastModified": 1777253304,
|
||||
"narHash": "sha256-XqSHEKEW5pSAx9MoMo8mKPgkjoy4FEhZ4x0a6hGYrSI=",
|
||||
"owner": "noctalia-dev",
|
||||
"repo": "noctalia-shell",
|
||||
"rev": "2c1808f9f8937fc0b82c54af513f7620fec56d71",
|
||||
"rev": "6773c4750a12c9e9af9c4ce2365e083f1d0d0ad8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -1014,11 +1014,11 @@
|
||||
"treefmt-nix": "treefmt-nix_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776585574,
|
||||
"narHash": "sha256-j35EWhKoGhKrfcXcAOpoRVgXEPQt41Eukji/h59cnjk=",
|
||||
"lastModified": 1777167795,
|
||||
"narHash": "sha256-VHdtmxVX7oF2+FxYQQPARQmtaHw23FoTBiTaH6ucOEg=",
|
||||
"owner": "noctalia-dev",
|
||||
"repo": "noctalia-qs",
|
||||
"rev": "75d180c28a9ab4470e980f3d6f706ad6c5213add",
|
||||
"rev": "697db4c14e27d841956ff76887fc312443e6fb17",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -1133,11 +1133,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776827647,
|
||||
"narHash": "sha256-sYixYhp5V8jCajO8TRorE4fzs7IkL4MZdfLTKgkPQBk=",
|
||||
"lastModified": 1777259803,
|
||||
"narHash": "sha256-fIb/EoVu/1U0qVrE6qZCJ2WCfprRpywNIAVzKEACIQc=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "40e6ccc06e1245a4837cbbd6bdda64e21cc67379",
|
||||
"rev": "a6cb2224d975e16b5e67de688c6ad306f7203425",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -1190,11 +1190,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776653059,
|
||||
"narHash": "sha256-K3tWnUj6FXaK95sBUajedutJrFVrOzYhvrQwQjJ0FbU=",
|
||||
"lastModified": 1777000965,
|
||||
"narHash": "sha256-xcrhVgfI13s1WH4hg5MLL83zAp6/htfF8Pjw4RPiKM8=",
|
||||
"owner": "nix-community",
|
||||
"repo": "srvos",
|
||||
"rev": "4968d2a44c84edfc9a38a2494cc7f85ad2c7122b",
|
||||
"rev": "7ae6f096b2ffbd25d17da8a4d0fe299a164c4eac",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -1356,11 +1356,11 @@
|
||||
"trackerlist": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1776809383,
|
||||
"narHash": "sha256-r4V5l+Yk3jxVfZNQk2Ddu8Vlyshd9FWcnGGFyaL4UCw=",
|
||||
"lastModified": 1777241384,
|
||||
"narHash": "sha256-mzqjBOMvL8951W4qt5VA31rQB+TiOYDRyMXTQ7ScSUY=",
|
||||
"owner": "ngosang",
|
||||
"repo": "trackerslist",
|
||||
"rev": "37d5c0552c25abf50f05cc6b377345e65a588dc2",
|
||||
"rev": "50a204edfeb4f5f904a28e20b650966241203edb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -1524,11 +1524,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776844129,
|
||||
"narHash": "sha256-DaYSEBVzTvUhTuoVe70NHphoq5JKUHqUhlNlN5XnTuU=",
|
||||
"lastModified": 1777218171,
|
||||
"narHash": "sha256-+JGU5Cw6Zm3XVl3xBCkbY7/lTxfLQpjuuhF0IB4dJ8k=",
|
||||
"owner": "0xc000022070",
|
||||
"repo": "zen-browser-flake",
|
||||
"rev": "90706e6ab801e4fb7bc53343db67583631936192",
|
||||
"rev": "8a8e30610393c7f1a766a119dea37bf82d0ebcf6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
nixosConfigurations = {
|
||||
mreow = mkDesktopHost "mreow";
|
||||
yarn = mkDesktopHost "yarn";
|
||||
patiodeck = mkDesktopHost "patiodeck";
|
||||
muffin = muffinHost;
|
||||
};
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
{
|
||||
imports = [
|
||||
./no-gui.nix
|
||||
# ../progs/ghostty.nix
|
||||
../progs/alacritty.nix
|
||||
../progs/ghostty.nix
|
||||
../progs/emacs.nix
|
||||
# ../progs/trezor.nix # - broken
|
||||
../progs/flatpak.nix
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
home.sessionVariables = {
|
||||
TERMINAL = "alacritty";
|
||||
};
|
||||
|
||||
programs.alacritty = {
|
||||
enable = true;
|
||||
package = pkgs.alacritty;
|
||||
settings = {
|
||||
# some programs can't handle alacritty
|
||||
env.TERM = "xterm-256color";
|
||||
|
||||
window = {
|
||||
# using a window manager, no decorations needed
|
||||
decorations = "none";
|
||||
|
||||
# semi-transparent
|
||||
opacity = 0.90;
|
||||
|
||||
# padding between the content of the terminal and the edge
|
||||
padding = {
|
||||
x = 10;
|
||||
y = 10;
|
||||
};
|
||||
|
||||
dimensions = {
|
||||
columns = 80;
|
||||
lines = 40;
|
||||
};
|
||||
};
|
||||
|
||||
scrolling = {
|
||||
history = 1000;
|
||||
multiplier = 3;
|
||||
};
|
||||
|
||||
font =
|
||||
let
|
||||
baseFont = {
|
||||
family = "JetBrains Mono Nerd Font";
|
||||
style = "Regular";
|
||||
};
|
||||
in
|
||||
{
|
||||
size = 12;
|
||||
|
||||
normal = baseFont;
|
||||
|
||||
bold = baseFont // {
|
||||
style = "Bold";
|
||||
};
|
||||
|
||||
italic = baseFont // {
|
||||
style = "Italic";
|
||||
};
|
||||
|
||||
offset.y = 0;
|
||||
glyph_offset.y = 0;
|
||||
};
|
||||
|
||||
# color scheme
|
||||
colors =
|
||||
let
|
||||
normal = {
|
||||
black = "0x1b1e28";
|
||||
red = "0xd0679d";
|
||||
green = "0x5de4c7";
|
||||
yellow = "0xfffac2";
|
||||
blue = "#435c89";
|
||||
magenta = "0xfcc5e9";
|
||||
cyan = "0xadd7ff";
|
||||
white = "0xffffff";
|
||||
};
|
||||
|
||||
bright = {
|
||||
black = "0xa6accd";
|
||||
red = normal.red;
|
||||
green = normal.green;
|
||||
yellow = normal.yellow;
|
||||
blue = normal.cyan;
|
||||
magenta = "0xfae4fc";
|
||||
cyan = "0x89ddff";
|
||||
white = normal.white;
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit normal bright;
|
||||
primary = {
|
||||
background = "0x131621";
|
||||
foreground = bright.black;
|
||||
};
|
||||
|
||||
cursor = {
|
||||
text = "CellBackground";
|
||||
cursor = "CellForeground";
|
||||
};
|
||||
|
||||
search =
|
||||
let
|
||||
foreground = normal.black;
|
||||
background = normal.cyan;
|
||||
in
|
||||
{
|
||||
matches = {
|
||||
inherit foreground background;
|
||||
};
|
||||
|
||||
focused_match = {
|
||||
inherit foreground background;
|
||||
};
|
||||
};
|
||||
|
||||
selection = {
|
||||
text = "CellForeground";
|
||||
background = "0x303340";
|
||||
};
|
||||
|
||||
vi_mode_cursor = {
|
||||
text = "CellBackground";
|
||||
cursor = "CellForeground";
|
||||
};
|
||||
};
|
||||
|
||||
cursor = {
|
||||
style = "Underline";
|
||||
vi_mode_style = "Underline";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,71 @@
|
||||
{ pkgs, ... }:
|
||||
{ ... }:
|
||||
{
|
||||
# https://mynixos.com/home-manager/option/programs.ghostty
|
||||
programs.ghostty = {
|
||||
enable = true;
|
||||
enableFishIntegration = true;
|
||||
|
||||
# custom palette ported verbatim from the previous alacritty config
|
||||
# (poimandres-ish). lives in ~/.config/ghostty/themes/poimandres and is
|
||||
# selected by `theme = "poimandres"` below.
|
||||
themes.poimandres = {
|
||||
palette = [
|
||||
"0=#1b1e28"
|
||||
"1=#d0679d"
|
||||
"2=#5de4c7"
|
||||
"3=#fffac2"
|
||||
"4=#435c89"
|
||||
"5=#fcc5e9"
|
||||
"6=#add7ff"
|
||||
"7=#ffffff"
|
||||
"8=#a6accd"
|
||||
"9=#d0679d"
|
||||
"10=#5de4c7"
|
||||
"11=#fffac2"
|
||||
"12=#add7ff"
|
||||
"13=#fae4fc"
|
||||
"14=#89ddff"
|
||||
"15=#ffffff"
|
||||
];
|
||||
background = "131621";
|
||||
foreground = "a6accd";
|
||||
cursor-color = "a6accd";
|
||||
cursor-text = "131621";
|
||||
selection-background = "303340";
|
||||
selection-foreground = "a6accd";
|
||||
};
|
||||
|
||||
settings = {
|
||||
theme = "Adventure";
|
||||
background-opacity = 0.7;
|
||||
theme = "poimandres";
|
||||
|
||||
# font
|
||||
font-family = "JetBrainsMono Nerd Font";
|
||||
font-size = 12;
|
||||
|
||||
# window
|
||||
window-decoration = false;
|
||||
window-padding-x = 10;
|
||||
window-padding-y = 10;
|
||||
window-width = 80;
|
||||
window-height = 40;
|
||||
|
||||
# semi-transparent background
|
||||
background-opacity = 0.90;
|
||||
|
||||
# cursor
|
||||
cursor-style = "underline";
|
||||
|
||||
# always open new windows at $HOME instead of inheriting whatever cwd the
|
||||
# currently-focused ghostty window has. with gtk-single-instance, the
|
||||
# focused-window inherit rule otherwise sticks the daemon's first cwd to
|
||||
# every subsequent niri Mod+T launch.
|
||||
window-inherit-working-directory = false;
|
||||
working-directory = "home";
|
||||
|
||||
# keep one daemon alive so subsequent launches (e.g. niri Mod+T) are
|
||||
# instant instead of paying GTK + wgpu init each time. relies on the
|
||||
# dbus-activated systemd user service that the HM module wires up.
|
||||
gtk-single-instance = true;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
};
|
||||
wallpaper = {
|
||||
enabled = true;
|
||||
skipStartupTransition = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,8 +37,13 @@ let
|
||||
in
|
||||
{
|
||||
home.packages = [
|
||||
# `bun2nix.hook` sets `patchPhase = bunPatchPhase`, which only runs `patchShebangs` and
|
||||
# silently ignores the standard `patches` attribute. Apply patches via `prePatch` instead
|
||||
# so they actually take effect. Tracking: nothing upstream yet.
|
||||
(inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.omp.overrideAttrs (old: {
|
||||
patches = (old.patches or [ ]) ++ [ ];
|
||||
prePatch = (old.prePatch or "") + ''
|
||||
patch -p1 < ${../../patches/omp/0001-fix-reasoning_content.patch}
|
||||
'';
|
||||
}))
|
||||
];
|
||||
|
||||
|
||||
29
home/progs/steam-shortcuts.nix
Normal file
29
home/progs/steam-shortcuts.nix
Normal file
@@ -0,0 +1,29 @@
|
||||
# Declarative non-Steam game shortcuts for the Steam library.
|
||||
# Add entries to the `shortcuts` list to have them appear in Steam's UI.
|
||||
{
|
||||
pkgs,
|
||||
inputs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
inputs.json2steamshortcut.homeModules.default
|
||||
];
|
||||
|
||||
services.steam-shortcuts = {
|
||||
enable = true;
|
||||
overwriteExisting = true;
|
||||
steamUserId = lib.strings.toInt (
|
||||
lib.strings.trim (builtins.readFile ../../secrets/home/steam-user-id)
|
||||
);
|
||||
shortcuts = [
|
||||
{
|
||||
AppName = "Prism Launcher";
|
||||
Exe = "${pkgs.prismlauncher}/bin/prismlauncher";
|
||||
Icon = "${pkgs.prismlauncher}/share/icons/hicolor/scalable/apps/org.prismlauncher.PrismLauncher.svg";
|
||||
Tags = [ "Game" ];
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
../../modules/zfs.nix
|
||||
../../modules/server-impermanence.nix
|
||||
../../modules/usb-secrets.nix
|
||||
../../modules/age-secrets.nix
|
||||
../../modules/server-age-secrets.nix
|
||||
../../modules/server-lanzaboote-agenix.nix
|
||||
../../modules/no-rgb.nix
|
||||
../../modules/server-security.nix
|
||||
|
||||
@@ -196,6 +196,10 @@ rec {
|
||||
port = 9563;
|
||||
proto = "tcp";
|
||||
};
|
||||
minecraft_exporter = {
|
||||
port = 9567;
|
||||
proto = "tcp";
|
||||
};
|
||||
prometheus_zfs = {
|
||||
port = 9134;
|
||||
proto = "tcp";
|
||||
|
||||
38
hosts/patiodeck/default.nix
Normal file
38
hosts/patiodeck/default.nix
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
username,
|
||||
inputs,
|
||||
site_config,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
../../modules/desktop-common.nix
|
||||
../../modules/desktop-jovian.nix
|
||||
./disk.nix
|
||||
./impermanence.nix
|
||||
|
||||
inputs.impermanence.nixosModules.impermanence
|
||||
];
|
||||
|
||||
networking.hostId = "a1b2c3d4";
|
||||
|
||||
# SSH for remote management from laptop
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
ports = [ 22 ];
|
||||
settings = {
|
||||
PasswordAuthentication = false;
|
||||
PermitRootLogin = "yes";
|
||||
};
|
||||
};
|
||||
|
||||
users.users.${username}.openssh.authorizedKeys.keys = [
|
||||
site_config.ssh_keys.laptop
|
||||
];
|
||||
|
||||
users.users.root.openssh.authorizedKeys.keys = [
|
||||
site_config.ssh_keys.laptop
|
||||
];
|
||||
|
||||
jovian.devices.steamdeck.enable = true;
|
||||
}
|
||||
52
hosts/patiodeck/disk.nix
Normal file
52
hosts/patiodeck/disk.nix
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
disko.devices = {
|
||||
disk = {
|
||||
main = {
|
||||
type = "disk";
|
||||
content = {
|
||||
type = "gpt";
|
||||
partitions = {
|
||||
ESP = {
|
||||
type = "EF00";
|
||||
size = "500M";
|
||||
content = {
|
||||
type = "filesystem";
|
||||
format = "vfat";
|
||||
mountpoint = "/boot";
|
||||
};
|
||||
};
|
||||
nix = {
|
||||
size = "200G";
|
||||
content = {
|
||||
type = "filesystem";
|
||||
format = "f2fs";
|
||||
mountpoint = "/nix";
|
||||
};
|
||||
};
|
||||
persistent = {
|
||||
size = "100%";
|
||||
content = {
|
||||
type = "filesystem";
|
||||
format = "f2fs";
|
||||
mountpoint = "/persistent";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
nodev = {
|
||||
"/" = {
|
||||
fsType = "tmpfs";
|
||||
mountOptions = [
|
||||
"defaults"
|
||||
"size=2G"
|
||||
"mode=755"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
fileSystems."/persistent".neededForBoot = true;
|
||||
fileSystems."/nix".neededForBoot = true;
|
||||
}
|
||||
8
hosts/patiodeck/home.nix
Normal file
8
hosts/patiodeck/home.nix
Normal file
@@ -0,0 +1,8 @@
|
||||
{ ... }:
|
||||
{
|
||||
imports = [
|
||||
../../home/profiles/gui.nix
|
||||
../../home/profiles/desktop.nix
|
||||
../../home/progs/steam-shortcuts.nix
|
||||
];
|
||||
}
|
||||
48
hosts/patiodeck/impermanence.nix
Normal file
48
hosts/patiodeck/impermanence.nix
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
username,
|
||||
...
|
||||
}:
|
||||
{
|
||||
environment.persistence."/persistent" = {
|
||||
hideMounts = true;
|
||||
directories = [
|
||||
"/var/log"
|
||||
"/var/lib/systemd/coredump"
|
||||
"/var/lib/nixos"
|
||||
"/var/lib/systemd/timers"
|
||||
# agenix identity sealed by the TPM
|
||||
{
|
||||
directory = "/var/lib/agenix";
|
||||
mode = "0700";
|
||||
user = "root";
|
||||
group = "root";
|
||||
}
|
||||
];
|
||||
|
||||
files = [
|
||||
"/etc/ssh/ssh_host_ed25519_key"
|
||||
"/etc/ssh/ssh_host_ed25519_key.pub"
|
||||
"/etc/ssh/ssh_host_rsa_key"
|
||||
"/etc/ssh/ssh_host_rsa_key.pub"
|
||||
"/etc/machine-id"
|
||||
];
|
||||
|
||||
users.root = {
|
||||
files = [
|
||||
".local/share/fish/fish_history"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
# bind mount home directory from persistent storage
|
||||
fileSystems."/home/${username}" = {
|
||||
device = "/persistent/home/${username}";
|
||||
fsType = "none";
|
||||
options = [ "bind" ];
|
||||
neededForBoot = true;
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /etc 755 root"
|
||||
];
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
lib,
|
||||
username,
|
||||
@@ -10,13 +9,13 @@
|
||||
{
|
||||
imports = [
|
||||
../../modules/desktop-common.nix
|
||||
../../modules/desktop-jovian.nix
|
||||
../../modules/no-rgb.nix
|
||||
./disk.nix
|
||||
./impermanence.nix
|
||||
./vr.nix
|
||||
|
||||
inputs.impermanence.nixosModules.impermanence
|
||||
inputs.jovian-nixos.nixosModules.default
|
||||
];
|
||||
|
||||
fileSystems."/media/games" = {
|
||||
@@ -83,145 +82,6 @@
|
||||
|
||||
systemd.services.lactd.serviceConfig.ExecStartPre = "${lib.getExe pkgs.bash} -c \"sleep 3s\"";
|
||||
|
||||
# root-level service that applies a pending update. Triggered by
|
||||
# steamos-update (via systemctl start) when the user accepts an update.
|
||||
# Runs as root so it can write the system profile and boot entry.
|
||||
systemd.services.pull-update-apply = {
|
||||
description = "Apply pending NixOS update pulled from binary cache";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = pkgs.writeShellScript "pull-update-apply" ''
|
||||
set -uo pipefail
|
||||
export PATH=${
|
||||
pkgs.lib.makeBinPath [
|
||||
pkgs.curl
|
||||
pkgs.coreutils
|
||||
pkgs.nix
|
||||
]
|
||||
}
|
||||
|
||||
STORE_PATH=$(curl -sf --max-time 30 "${site_config.binary_cache.url}/deploy/yarn" || true)
|
||||
if [ -z "$STORE_PATH" ]; then
|
||||
echo "server unreachable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT=$(readlink -f /nix/var/nix/profiles/system)
|
||||
if [ "$CURRENT" = "$STORE_PATH" ]; then
|
||||
echo "already up to date: $STORE_PATH"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "applying $STORE_PATH (was $CURRENT)"
|
||||
nix-store -r --add-root /nix/var/nix/gcroots/pull-update-apply-latest --indirect "$STORE_PATH" \
|
||||
|| { echo "fetch failed"; exit 1; }
|
||||
nix-env -p /nix/var/nix/profiles/system --set "$STORE_PATH" \
|
||||
|| { echo "profile set failed"; exit 1; }
|
||||
"$STORE_PATH/bin/switch-to-configuration" boot \
|
||||
|| { echo "boot entry failed"; exit 1; }
|
||||
echo "update applied; reboot required"
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# Allow primary user to start pull-update-apply.service without a password
|
||||
security.polkit.extraConfig = ''
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||
action.lookup("unit") == "pull-update-apply.service" &&
|
||||
subject.user == "${username}") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
'';
|
||||
|
||||
nixpkgs.config.allowUnfreePredicate =
|
||||
pkg:
|
||||
builtins.elem (lib.getName pkg) [
|
||||
"steamdeck-hw-theme"
|
||||
"steam-jupiter-unwrapped"
|
||||
"steam"
|
||||
"steam-original"
|
||||
"steam-unwrapped"
|
||||
"steam-run"
|
||||
];
|
||||
|
||||
# Override jovian-stubs to disable steamos-update kernel check
|
||||
# This prevents Steam from requesting reboots for "system updates"
|
||||
# Steam client updates will still work normally
|
||||
nixpkgs.overlays = [
|
||||
(
|
||||
final: prev:
|
||||
let
|
||||
deploy-url = "${site_config.binary_cache.url}/deploy/yarn";
|
||||
|
||||
steamos-update-script = final.writeShellScript "steamos-update" ''
|
||||
export PATH=${
|
||||
final.lib.makeBinPath [
|
||||
final.curl
|
||||
final.coreutils
|
||||
final.systemd
|
||||
]
|
||||
}
|
||||
|
||||
STORE_PATH=$(curl -sf --max-time 30 "${deploy-url}" || true)
|
||||
|
||||
if [ -z "$STORE_PATH" ]; then
|
||||
>&2 echo "[steamos-update] server unreachable"
|
||||
exit 7
|
||||
fi
|
||||
|
||||
CURRENT=$(readlink -f /nix/var/nix/profiles/system)
|
||||
if [ "$CURRENT" = "$STORE_PATH" ]; then
|
||||
>&2 echo "[steamos-update] no update available"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# check-only mode: just report that an update exists
|
||||
if [ "''${1:-}" = "check" ] || [ "''${1:-}" = "--check-only" ]; then
|
||||
>&2 echo "[steamos-update] update available"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# apply: trigger the root-running systemd service to install the update
|
||||
>&2 echo "[steamos-update] applying update..."
|
||||
if systemctl start --wait pull-update-apply.service; then
|
||||
>&2 echo "[steamos-update] update installed, reboot to apply"
|
||||
exit 0
|
||||
else
|
||||
>&2 echo "[steamos-update] apply failed; see 'journalctl -u pull-update-apply'"
|
||||
exit 1
|
||||
fi
|
||||
'';
|
||||
in
|
||||
{
|
||||
# Only replace holo-update (and its steamos-update alias) with our
|
||||
# binary-cache pull script. All other stubs (pkexec, sudo,
|
||||
# holo-reboot, holo-select-branch, …) come from upstream unchanged.
|
||||
jovian-stubs = prev.jovian-stubs.overrideAttrs (old: {
|
||||
buildCommand = (old.buildCommand or "") + ''
|
||||
install -D -m 755 ${steamos-update-script} $out/bin/holo-update
|
||||
install -D -m 755 ${steamos-update-script} $out/bin/steamos-update
|
||||
'';
|
||||
});
|
||||
}
|
||||
)
|
||||
];
|
||||
|
||||
jovian = {
|
||||
devices.steamdeck.enable = false;
|
||||
steam = {
|
||||
enable = true;
|
||||
autoStart = true;
|
||||
desktopSession = "niri";
|
||||
user = username;
|
||||
};
|
||||
};
|
||||
|
||||
# Jovian-NixOS requires sddm
|
||||
# https://github.com/Jovian-Experiments/Jovian-NixOS/commit/52f140c07493f8bb6cd0773c7e1afe3e1fd1d1fa
|
||||
services.displayManager.sddm.wayland.enable = true;
|
||||
|
||||
# Disable gamescope from common.nix to avoid conflict with jovian-nixos
|
||||
programs.gamescope.enable = lib.mkForce false;
|
||||
# yarn is not a Steam Deck
|
||||
jovian.devices.steamdeck.enable = false;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
{
|
||||
pkgs,
|
||||
inputs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
../../home/profiles/gui.nix
|
||||
../../home/profiles/desktop.nix
|
||||
inputs.json2steamshortcut.homeModules.default
|
||||
../../home/progs/steam-shortcuts.nix
|
||||
];
|
||||
|
||||
home.packages = with pkgs; [
|
||||
@@ -27,20 +24,4 @@
|
||||
obs-pipewire-audio-capture
|
||||
];
|
||||
};
|
||||
|
||||
services.steam-shortcuts = {
|
||||
enable = true;
|
||||
overwriteExisting = true;
|
||||
steamUserId = lib.strings.toInt (
|
||||
lib.strings.trim (builtins.readFile ../../secrets/home/steam-user-id)
|
||||
);
|
||||
shortcuts = [
|
||||
{
|
||||
AppName = "Prism Launcher";
|
||||
Exe = "${pkgs.prismlauncher}/bin/prismlauncher";
|
||||
Icon = "${pkgs.prismlauncher}/share/icons/hicolor/scalable/apps/org.prismlauncher.PrismLauncher.svg";
|
||||
Tags = [ "Game" ];
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
"/var/lib/systemd/coredump"
|
||||
"/var/lib/nixos"
|
||||
"/var/lib/systemd/timers"
|
||||
# agenix identity sealed by the TPM. Must survive the tmpfs root
|
||||
# wipe so decryption at activation finds the right handle.
|
||||
{
|
||||
directory = "/var/lib/agenix";
|
||||
mode = "0700";
|
||||
user = "root";
|
||||
group = "root";
|
||||
}
|
||||
];
|
||||
|
||||
files = [
|
||||
@@ -21,6 +29,12 @@
|
||||
"/etc/ssh/ssh_host_rsa_key.pub"
|
||||
"/etc/machine-id"
|
||||
];
|
||||
|
||||
users.root = {
|
||||
files = [
|
||||
".local/share/fish/fish_history"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
# Bind mount entire home directory from persistent storage
|
||||
|
||||
@@ -75,4 +75,19 @@ final: prev: {
|
||||
'';
|
||||
meta.mainProgram = "igpu-exporter";
|
||||
};
|
||||
|
||||
mc-monitor = prev.buildGoModule rec {
|
||||
pname = "mc-monitor";
|
||||
version = "0.16.1";
|
||||
src = prev.fetchFromGitHub {
|
||||
owner = "itzg";
|
||||
repo = "mc-monitor";
|
||||
rev = version;
|
||||
hash = "sha256-/94+Z9FTFOzQHynHiJuaGFiidkOxmM0g/FIpHn+xvJM=";
|
||||
};
|
||||
vendorHash = "sha256-qq7rIpvGRi3AMnBbi8uAhiPcfSF4McIuqozdtxB5CeQ=";
|
||||
# upstream tests probe live Minecraft servers
|
||||
doCheck = false;
|
||||
meta.mainProgram = "mc-monitor";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,8 +58,6 @@
|
||||
];
|
||||
};
|
||||
|
||||
services.kmscon.enable = true;
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
doas-sudo-shim
|
||||
];
|
||||
|
||||
70
modules/desktop-age-secrets.nix
Normal file
70
modules/desktop-age-secrets.nix
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
pkgs,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
# Wrap rage so age-plugin-tpm is on PATH at activation time.
|
||||
# Both mreow and yarn use age1tpm1… recipients (legacy P-256 encoding),
|
||||
# which age-plugin-tpm handles under its own name.
|
||||
rageWithTpm = pkgs.writeShellScriptBin "rage" ''
|
||||
export PATH="${pkgs.age-plugin-tpm}/bin:$PATH"
|
||||
exec ${pkgs.rage}/bin/rage "$@"
|
||||
'';
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
inputs.agenix.nixosModules.default
|
||||
];
|
||||
|
||||
# Expose the plugin + agenix CLI for interactive edits (`agenix -e …`).
|
||||
environment.systemPackages = [
|
||||
inputs.agenix.packages.${pkgs.system}.default
|
||||
pkgs.age-plugin-tpm
|
||||
];
|
||||
|
||||
age.ageBin = "${rageWithTpm}/bin/rage";
|
||||
|
||||
# Primary identity: TPM-sealed key, generated by scripts/bootstrap-desktop-tpm.sh.
|
||||
# Fallback identity: admin SSH key. age tries paths in order, so if the TPM
|
||||
# is wiped or the board is replaced the SSH key keeps secrets accessible until
|
||||
# the TPM is re-bootstrapped. Both are encrypted recipients on every .age file.
|
||||
age.identityPaths = [
|
||||
"/var/lib/agenix/tpm-identity"
|
||||
"/home/primary/.ssh/id_ed25519"
|
||||
];
|
||||
|
||||
# Ensure the identity directory exists before agenix activation so a fresh
|
||||
# bootstrap doesn't race the directory creation.
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/agenix 0700 root root -"
|
||||
];
|
||||
|
||||
age.secrets = {
|
||||
# Secureboot PKI bundle (db/KEK/PK keys + certs) consumed by lanzaboote
|
||||
# via desktop-lanzaboote-agenix.nix at activation time.
|
||||
secureboot-tar = {
|
||||
file = ../secrets/desktop/secureboot.tar.age;
|
||||
mode = "0400";
|
||||
owner = "root";
|
||||
group = "root";
|
||||
};
|
||||
|
||||
# netrc for the private nix binary cache.
|
||||
nix-cache-netrc = {
|
||||
file = ../secrets/desktop/nix-cache-netrc.age;
|
||||
mode = "0400";
|
||||
owner = "root";
|
||||
group = "root";
|
||||
};
|
||||
|
||||
# yescrypt hash for the primary user.
|
||||
password-hash = {
|
||||
file = ../secrets/desktop/password-hash.age;
|
||||
mode = "0400";
|
||||
owner = "root";
|
||||
group = "root";
|
||||
};
|
||||
|
||||
};
|
||||
}
|
||||
@@ -17,9 +17,10 @@
|
||||
./desktop-vm.nix
|
||||
./desktop-steam.nix
|
||||
./desktop-networkmanager.nix
|
||||
./desktop-age-secrets.nix
|
||||
./desktop-lanzaboote-agenix.nix
|
||||
|
||||
inputs.disko.nixosModules.disko
|
||||
inputs.lanzaboote.nixosModules.lanzaboote
|
||||
|
||||
inputs.nixos-hardware.nixosModules.common-cpu-amd-pstate
|
||||
inputs.nixos-hardware.nixosModules.common-cpu-amd-zenpower
|
||||
@@ -50,16 +51,6 @@
|
||||
mkdir -p /nix/var/nix/profiles/per-user/root/channels
|
||||
'';
|
||||
|
||||
# extract all my secureboot keys
|
||||
# TODO! proper secrets management
|
||||
"secureboot-keys".text = ''
|
||||
#!/usr/bin/env sh
|
||||
rm -fr ${config.boot.lanzaboote.pkiBundle} || true
|
||||
mkdir -p ${config.boot.lanzaboote.pkiBundle}
|
||||
${lib.getExe pkgs.gnutar} xf ${../secrets/desktop/secureboot.tar} -C ${config.boot.lanzaboote.pkiBundle}
|
||||
chown -R root:wheel ${config.boot.lanzaboote.pkiBundle}
|
||||
chmod -R 500 ${config.boot.lanzaboote.pkiBundle}
|
||||
'';
|
||||
};
|
||||
|
||||
swapDevices = [ ];
|
||||
@@ -71,7 +62,7 @@
|
||||
trusted-public-keys = [
|
||||
site_config.binary_cache.public_key
|
||||
];
|
||||
netrc-file = "${../secrets/desktop/nix-cache-netrc}";
|
||||
netrc-file = config.age.secrets.nix-cache-netrc.path;
|
||||
};
|
||||
|
||||
# cachyos kernel overlay
|
||||
@@ -182,9 +173,14 @@
|
||||
DRM_HISI_HIBMC = lib.mkForce no;
|
||||
DRM_APPLETBDRM = lib.mkForce no;
|
||||
|
||||
# intel gpu
|
||||
DRM_I915 = lib.mkForce no;
|
||||
DRM_XE = lib.mkForce no;
|
||||
# legacy AMD IP blocks. hosts are Navi 32 RDNA3 dGPU (7800 XT, yarn,
|
||||
# 2023, gfx1101, DCN 3.2) and Krackan Point RDNA 3.5 iGPU (mreow,
|
||||
# 2024, gfx1150, DCN 3.5). everything below pre-dates those by a
|
||||
# decade. upstream only exposes per-generation toggles for SI and
|
||||
# CIK — no switch for VI/Polaris/Vega/Navi1x, those stay in amdgpu.
|
||||
DRM_AMDGPU_SI = lib.mkForce no; # Southern Islands / GCN 1 (2012): HD 7950/7970, R9 280/280X, R7 260X
|
||||
DRM_AMDGPU_CIK = lib.mkForce no; # Sea Islands / GCN 2 (2013): R9 290/290X/390, Kaveri APUs (A10-7850K), Steam Machine Bonaire
|
||||
DRM_AMD_SECURE_DISPLAY = lib.mkForce no; # HDCP region-CRC debugfs helper, needs custom DMCU firmware
|
||||
|
||||
# early-boot framebuffer chain: drop every alternative to amdgpu so
|
||||
# the console never transitions simpledrm -> dummy -> amdgpu (visible
|
||||
@@ -286,6 +282,486 @@
|
||||
XZ_DEC_ARM64 = lib.mkForce no;
|
||||
XZ_DEC_SPARC = lib.mkForce no;
|
||||
XZ_DEC_RISCV = lib.mkForce no;
|
||||
|
||||
# ==== no hardware for any of these on either host ====
|
||||
|
||||
# laptop vendor platform drivers (only FRAMEWORK_LAPTOP is used)
|
||||
ACER_WMI = lib.mkForce no;
|
||||
ACER_WIRELESS = lib.mkForce no;
|
||||
ACERHDF = lib.mkForce no;
|
||||
APPLE_GMUX = lib.mkForce no;
|
||||
ASUS_LAPTOP = lib.mkForce no;
|
||||
ASUS_WMI = lib.mkForce no;
|
||||
ASUS_NB_WMI = lib.mkForce no;
|
||||
ASUS_ARMOURY = lib.mkForce no;
|
||||
ASUS_TF103C_DOCK = lib.mkForce no;
|
||||
ASUS_WIRELESS = lib.mkForce no;
|
||||
COMPAL_LAPTOP = lib.mkForce no;
|
||||
DELL_LAPTOP = lib.mkForce no;
|
||||
DELL_RBTN = lib.mkForce no;
|
||||
DELL_PC = lib.mkForce no;
|
||||
DELL_SMBIOS = lib.mkForce no;
|
||||
DELL_SMO8800 = lib.mkForce no;
|
||||
DELL_UART_BACKLIGHT = lib.mkForce no;
|
||||
DELL_WMI = lib.mkForce no;
|
||||
DELL_WMI_AIO = lib.mkForce no;
|
||||
DELL_WMI_DDV = lib.mkForce no;
|
||||
DELL_WMI_DESCRIPTOR = lib.mkForce no;
|
||||
DELL_WMI_LED = lib.mkForce no;
|
||||
DELL_WMI_SYSMAN = lib.mkForce no;
|
||||
EEEPC_LAPTOP = lib.mkForce no;
|
||||
EEEPC_WMI = lib.mkForce no;
|
||||
FUJITSU_LAPTOP = lib.mkForce no;
|
||||
FUJITSU_ES = lib.mkForce no;
|
||||
FUJITSU_TABLET = lib.mkForce no;
|
||||
HUAWEI_WMI = lib.mkForce no;
|
||||
IBM_ASM = lib.mkForce no;
|
||||
IBM_RTL = lib.mkForce no;
|
||||
IDEAPAD_LAPTOP = lib.mkForce no;
|
||||
LG_LAPTOP = lib.mkForce no;
|
||||
MSI_LAPTOP = lib.mkForce no;
|
||||
MSI_WMI = lib.mkForce no;
|
||||
MSI_EC = lib.mkForce no;
|
||||
PANASONIC_LAPTOP = lib.mkForce no;
|
||||
SONY_LAPTOP = lib.mkForce no;
|
||||
SAMSUNG_LAPTOP = lib.mkForce no;
|
||||
TOPSTAR_LAPTOP = lib.mkForce no;
|
||||
THINKPAD_ACPI = lib.mkForce no;
|
||||
THINKPAD_LMI = lib.mkForce no;
|
||||
LENOVO_SE10_WDT = lib.mkForce no;
|
||||
LENOVO_SE30_WDT = lib.mkForce no;
|
||||
LENOVO_WMI_HOTKEY_UTILITIES = lib.mkForce no;
|
||||
LENOVO_WMI_CAMERA = lib.mkForce no;
|
||||
LENOVO_YMC = lib.mkForce no;
|
||||
LENOVO_WMI_CAPDATA = lib.mkForce no;
|
||||
LENOVO_WMI_EVENTS = lib.mkForce no;
|
||||
LENOVO_WMI_HELPERS = lib.mkForce no;
|
||||
LENOVO_WMI_GAMEZONE = lib.mkForce no;
|
||||
LENOVO_WMI_TUNING = lib.mkForce no;
|
||||
YOGABOOK = lib.mkForce no;
|
||||
YT2_1380 = lib.mkForce no;
|
||||
XIAOMI_WMI = lib.mkForce no;
|
||||
BARCO_P50_GPIO = lib.mkForce no;
|
||||
PC_ENGINES_APU = lib.mkForce no;
|
||||
SILICOM_PLATFORM = lib.mkForce no;
|
||||
SIEMENS_SIMATIC_IPC_WDT = lib.mkForce no;
|
||||
SYSTEM76_ACPI = lib.mkForce no;
|
||||
INSPUR_PLATFORM_PROFILE = lib.mkForce no;
|
||||
NVIDIA_WMI_EC_BACKLIGHT = lib.mkForce no;
|
||||
|
||||
# legacy filesystems (hosts use vfat/f2fs/tmpfs/fuse; exfat/ntfs3 kept for externals)
|
||||
JFS_FS = lib.mkForce no;
|
||||
GFS2_FS = lib.mkForce no;
|
||||
OCFS2_FS = lib.mkForce no;
|
||||
NILFS2_FS = lib.mkForce no;
|
||||
AFFS_FS = lib.mkForce no;
|
||||
HFS_FS = lib.mkForce no;
|
||||
HFSPLUS_FS = lib.mkForce no;
|
||||
BEFS_FS = lib.mkForce no;
|
||||
JFFS2_FS = lib.mkForce no;
|
||||
UBIFS_FS = lib.mkForce no;
|
||||
MINIX_FS = lib.mkForce no;
|
||||
OMFS_FS = lib.mkForce no;
|
||||
ROMFS_FS = lib.mkForce no;
|
||||
UFS_FS = lib.mkForce no;
|
||||
EROFS_FS = lib.mkForce no;
|
||||
ORANGEFS_FS = lib.mkForce no;
|
||||
CODA_FS = lib.mkForce no;
|
||||
AFS_FS = lib.mkForce no;
|
||||
CEPH_FS = lib.mkForce no;
|
||||
ZONEFS_FS = lib.mkForce no;
|
||||
BCACHE = lib.mkForce no;
|
||||
BCACHEFS_FS = lib.mkForce no;
|
||||
ECRYPT_FS = lib.mkForce no;
|
||||
NFSD = lib.mkForce no;
|
||||
|
||||
# legacy partition tables (only GPT+MBR in use)
|
||||
AIX_PARTITION = lib.mkForce no;
|
||||
MAC_PARTITION = lib.mkForce no;
|
||||
LDM_PARTITION = lib.mkForce no;
|
||||
KARMA_PARTITION = lib.mkForce no;
|
||||
MINIX_SUBPARTITION = lib.mkForce no;
|
||||
SOLARIS_X86_PARTITION = lib.mkForce no;
|
||||
BSD_DISKLABEL = lib.mkForce no;
|
||||
UNIXWARE_DISKLABEL = lib.mkForce no;
|
||||
SYSV68_PARTITION = lib.mkForce no;
|
||||
ULTRIX_PARTITION = lib.mkForce no;
|
||||
OSF_PARTITION = lib.mkForce no;
|
||||
SGI_PARTITION = lib.mkForce no;
|
||||
SUN_PARTITION = lib.mkForce no;
|
||||
ATARI_PARTITION = lib.mkForce no;
|
||||
AMIGA_PARTITION = lib.mkForce no;
|
||||
ACORN_PARTITION = lib.mkForce no;
|
||||
|
||||
# legacy net protocols (nothing uses SCTP/RDS/TIPC/SMC or GRE tunnels)
|
||||
IP_SCTP = lib.mkForce no;
|
||||
RDS = lib.mkForce no;
|
||||
TIPC = lib.mkForce no;
|
||||
SMC = lib.mkForce no;
|
||||
NET_IPIP = lib.mkForce no;
|
||||
NET_IPGRE = lib.mkForce no;
|
||||
NET_IPGRE_DEMUX = lib.mkForce no;
|
||||
NET_IPVTI = lib.mkForce no;
|
||||
|
||||
# legacy PCI sound cards (kept: SND_HDA_* for AMD HDA, SND_SOC_SOF_AMD for ACP)
|
||||
SND_ALI5451 = lib.mkForce no;
|
||||
SND_ATIIXP = lib.mkForce no;
|
||||
SND_ATIIXP_MODEM = lib.mkForce no;
|
||||
SND_AU8810 = lib.mkForce no;
|
||||
SND_AU8820 = lib.mkForce no;
|
||||
SND_AU8830 = lib.mkForce no;
|
||||
SND_AW2 = lib.mkForce no;
|
||||
SND_AZT3328 = lib.mkForce no;
|
||||
SND_BT87X = lib.mkForce no;
|
||||
SND_CA0106 = lib.mkForce no;
|
||||
SND_CMIPCI = lib.mkForce no;
|
||||
SND_OXYGEN = lib.mkForce no;
|
||||
SND_CS46XX = lib.mkForce no;
|
||||
SND_CTXFI = lib.mkForce no;
|
||||
SND_DARLA20 = lib.mkForce no;
|
||||
SND_GINA20 = lib.mkForce no;
|
||||
SND_LAYLA20 = lib.mkForce no;
|
||||
SND_DARLA24 = lib.mkForce no;
|
||||
SND_GINA24 = lib.mkForce no;
|
||||
SND_LAYLA24 = lib.mkForce no;
|
||||
SND_MONA = lib.mkForce no;
|
||||
SND_MIA = lib.mkForce no;
|
||||
SND_ECHO3G = lib.mkForce no;
|
||||
SND_INDIGO = lib.mkForce no;
|
||||
SND_INDIGOIO = lib.mkForce no;
|
||||
SND_INDIGODJ = lib.mkForce no;
|
||||
SND_INDIGOIOX = lib.mkForce no;
|
||||
SND_INDIGODJX = lib.mkForce no;
|
||||
SND_EMU10K1 = lib.mkForce no;
|
||||
SND_EMU10K1X = lib.mkForce no;
|
||||
SND_ENS1370 = lib.mkForce no;
|
||||
SND_ENS1371 = lib.mkForce no;
|
||||
SND_ES1938 = lib.mkForce no;
|
||||
SND_ES1968 = lib.mkForce no;
|
||||
SND_FM801 = lib.mkForce no;
|
||||
SND_HDSP = lib.mkForce no;
|
||||
SND_HDSPM = lib.mkForce no;
|
||||
SND_ICE1712 = lib.mkForce no;
|
||||
SND_ICE1724 = lib.mkForce no;
|
||||
SND_INTEL8X0 = lib.mkForce no;
|
||||
SND_INTEL8X0M = lib.mkForce no;
|
||||
SND_KORG1212 = lib.mkForce no;
|
||||
SND_LOLA = lib.mkForce no;
|
||||
SND_LX6464ES = lib.mkForce no;
|
||||
SND_MAESTRO3 = lib.mkForce no;
|
||||
SND_MIXART = lib.mkForce no;
|
||||
SND_MPU401 = lib.mkForce no;
|
||||
SND_MTS64 = lib.mkForce no;
|
||||
SND_NM256 = lib.mkForce no;
|
||||
SND_PCXHR = lib.mkForce no;
|
||||
SND_PORTMAN2X4 = lib.mkForce no;
|
||||
SND_RIPTIDE = lib.mkForce no;
|
||||
SND_RME32 = lib.mkForce no;
|
||||
SND_RME96 = lib.mkForce no;
|
||||
SND_RME9652 = lib.mkForce no;
|
||||
SND_SE6X = lib.mkForce no;
|
||||
SND_TRIDENT = lib.mkForce no;
|
||||
SND_VIA82XX = lib.mkForce no;
|
||||
SND_VIRTUOSO = lib.mkForce no;
|
||||
SND_VX222 = lib.mkForce no;
|
||||
SND_YMFPCI = lib.mkForce no;
|
||||
|
||||
# legacy HDA codecs (kept: REALTEK for ALC269 on Framework + HDMI for amdhdmi)
|
||||
SND_HDA_CODEC_ANALOG = lib.mkForce no;
|
||||
SND_HDA_CODEC_SIGMATEL = lib.mkForce no;
|
||||
SND_HDA_CODEC_VIA = lib.mkForce no;
|
||||
SND_HDA_CODEC_CONEXANT = lib.mkForce no;
|
||||
SND_HDA_CODEC_CA0110 = lib.mkForce no;
|
||||
SND_HDA_CODEC_CA0132 = lib.mkForce no;
|
||||
SND_HDA_CODEC_SI3054 = lib.mkForce no;
|
||||
SND_HDA_CODEC_CIRRUS = lib.mkForce no;
|
||||
SND_HDA_CODEC_CS420X = lib.mkForce no;
|
||||
SND_HDA_CODEC_CS421X = lib.mkForce no;
|
||||
SND_HDA_CODEC_CS8409 = lib.mkForce no;
|
||||
|
||||
# OSS compat (deprecated)
|
||||
SOUND_OSS_CORE = lib.mkForce no;
|
||||
|
||||
# legacy USB HCDs (Zen APUs only have xHCI)
|
||||
USB_OHCI_HCD = lib.mkForce no;
|
||||
USB_UHCI_HCD = lib.mkForce no;
|
||||
USB_C67X00_HCD = lib.mkForce no;
|
||||
USB_OXU210HP_HCD = lib.mkForce no;
|
||||
USB_ISP116X_HCD = lib.mkForce no;
|
||||
USB_ISP1760 = lib.mkForce no;
|
||||
USB_MAX3421_HCD = lib.mkForce no;
|
||||
USB_SL811_HCD = lib.mkForce no;
|
||||
USB_R8A66597 = lib.mkForce no;
|
||||
USB_XEN_HCD = lib.mkForce no;
|
||||
|
||||
# USB gadget + exotic device drivers
|
||||
USB_GADGET = lib.mkForce no;
|
||||
USB_MICROTEK = lib.mkForce no;
|
||||
USB_USS720 = lib.mkForce no;
|
||||
USB_EMI26 = lib.mkForce no;
|
||||
USB_EMI62 = lib.mkForce no;
|
||||
USB_ADUTUX = lib.mkForce no;
|
||||
USB_SEVSEG = lib.mkForce no;
|
||||
USB_LEGOTOWER = lib.mkForce no;
|
||||
USB_CYPRESS_CY7C63 = lib.mkForce no;
|
||||
USB_CYTHERM = lib.mkForce no;
|
||||
USB_IDMOUSE = lib.mkForce no;
|
||||
USB_APPLEDISPLAY = lib.mkForce no;
|
||||
USB_TRANCEVIBRATOR = lib.mkForce no;
|
||||
USB_CHAOSKEY = lib.mkForce no;
|
||||
USB_TEST = lib.mkForce no;
|
||||
|
||||
# USB mass-storage sub-drivers for legacy flash/camera readers
|
||||
USB_STORAGE_REALTEK = lib.mkForce no;
|
||||
USB_STORAGE_DATAFAB = lib.mkForce no;
|
||||
USB_STORAGE_FREECOM = lib.mkForce no;
|
||||
USB_STORAGE_ISD200 = lib.mkForce no;
|
||||
USB_STORAGE_USBAT = lib.mkForce no;
|
||||
USB_STORAGE_SDDR09 = lib.mkForce no;
|
||||
USB_STORAGE_SDDR55 = lib.mkForce no;
|
||||
USB_STORAGE_JUMPSHOT = lib.mkForce no;
|
||||
USB_STORAGE_ALAUDA = lib.mkForce no;
|
||||
USB_STORAGE_ONETOUCH = lib.mkForce no;
|
||||
USB_STORAGE_KARMA = lib.mkForce no;
|
||||
USB_STORAGE_CYPRESS_ATACB = lib.mkForce no;
|
||||
USB_STORAGE_ENE_UB6250 = lib.mkForce no;
|
||||
|
||||
# wlan vendors (kept: MEDIATEK/INTEL/REALTEK/BROADCOM for mreow+yarn)
|
||||
WLAN_VENDOR_ADMTEK = lib.mkForce no;
|
||||
WLAN_VENDOR_ATMEL = lib.mkForce no;
|
||||
WLAN_VENDOR_CISCO = lib.mkForce no;
|
||||
WLAN_VENDOR_INTERSIL = lib.mkForce no;
|
||||
WLAN_VENDOR_MARVELL = lib.mkForce no;
|
||||
WLAN_VENDOR_MICROCHIP = lib.mkForce no;
|
||||
WLAN_VENDOR_PURELIFI = lib.mkForce no;
|
||||
WLAN_VENDOR_QUANTENNA = lib.mkForce no;
|
||||
WLAN_VENDOR_RALINK = lib.mkForce no;
|
||||
WLAN_VENDOR_RSI = lib.mkForce no;
|
||||
WLAN_VENDOR_SILABS = lib.mkForce no;
|
||||
WLAN_VENDOR_ST = lib.mkForce no;
|
||||
WLAN_VENDOR_TI = lib.mkForce no;
|
||||
WLAN_VENDOR_ZYDAS = lib.mkForce no;
|
||||
|
||||
# ethernet vendors (kept: AMD/INTEL/REALTEK/AQUANTIA/ATHEROS)
|
||||
NET_VENDOR_3COM = lib.mkForce no;
|
||||
NET_VENDOR_ADAPTEC = lib.mkForce no;
|
||||
NET_VENDOR_AGERE = lib.mkForce no;
|
||||
NET_VENDOR_ALACRITECH = lib.mkForce no;
|
||||
NET_VENDOR_ALTEON = lib.mkForce no;
|
||||
NET_VENDOR_AMAZON = lib.mkForce no;
|
||||
NET_VENDOR_ARC = lib.mkForce no;
|
||||
NET_VENDOR_BROADCOM = lib.mkForce no;
|
||||
NET_VENDOR_BROCADE = lib.mkForce no;
|
||||
NET_VENDOR_CADENCE = lib.mkForce no;
|
||||
NET_VENDOR_CAVIUM = lib.mkForce no;
|
||||
NET_VENDOR_CHELSIO = lib.mkForce no;
|
||||
NET_VENDOR_CISCO = lib.mkForce no;
|
||||
NET_VENDOR_CORTINA = lib.mkForce no;
|
||||
NET_VENDOR_DAVICOM = lib.mkForce no;
|
||||
NET_VENDOR_DEC = lib.mkForce no;
|
||||
NET_VENDOR_DLINK = lib.mkForce no;
|
||||
NET_VENDOR_EMULEX = lib.mkForce no;
|
||||
NET_VENDOR_ENGLEDER = lib.mkForce no;
|
||||
NET_VENDOR_EZCHIP = lib.mkForce no;
|
||||
NET_VENDOR_FUJITSU = lib.mkForce no;
|
||||
NET_VENDOR_FUNGIBLE = lib.mkForce no;
|
||||
NET_VENDOR_GOOGLE = lib.mkForce no;
|
||||
NET_VENDOR_HISILICON = lib.mkForce no;
|
||||
NET_VENDOR_HUAWEI = lib.mkForce no;
|
||||
NET_VENDOR_I825XX = lib.mkForce no;
|
||||
NET_VENDOR_ADI = lib.mkForce no;
|
||||
NET_VENDOR_LITEX = lib.mkForce no;
|
||||
NET_VENDOR_MARVELL = lib.mkForce no;
|
||||
NET_VENDOR_META = lib.mkForce no;
|
||||
NET_VENDOR_MICREL = lib.mkForce no;
|
||||
NET_VENDOR_MICROCHIP = lib.mkForce no;
|
||||
NET_VENDOR_MICROSEMI = lib.mkForce no;
|
||||
NET_VENDOR_MICROSOFT = lib.mkForce no;
|
||||
NET_VENDOR_MUCSE = lib.mkForce no;
|
||||
NET_VENDOR_MYRI = lib.mkForce no;
|
||||
NET_VENDOR_NI = lib.mkForce no;
|
||||
NET_VENDOR_NATSEMI = lib.mkForce no;
|
||||
NET_VENDOR_NETRONOME = lib.mkForce no;
|
||||
NET_VENDOR_8390 = lib.mkForce no;
|
||||
NET_VENDOR_NVIDIA = lib.mkForce no;
|
||||
NET_VENDOR_OKI = lib.mkForce no;
|
||||
NET_VENDOR_PACKET_ENGINES = lib.mkForce no;
|
||||
NET_VENDOR_PENSANDO = lib.mkForce no;
|
||||
NET_VENDOR_QLOGIC = lib.mkForce no;
|
||||
NET_VENDOR_QUALCOMM = lib.mkForce no;
|
||||
NET_VENDOR_RDC = lib.mkForce no;
|
||||
NET_VENDOR_RENESAS = lib.mkForce no;
|
||||
NET_VENDOR_ROCKER = lib.mkForce no;
|
||||
NET_VENDOR_SAMSUNG = lib.mkForce no;
|
||||
NET_VENDOR_SEEQ = lib.mkForce no;
|
||||
NET_VENDOR_SILAN = lib.mkForce no;
|
||||
NET_VENDOR_SIS = lib.mkForce no;
|
||||
NET_VENDOR_SOLARFLARE = lib.mkForce no;
|
||||
NET_VENDOR_SMSC = lib.mkForce no;
|
||||
NET_VENDOR_SOCIONEXT = lib.mkForce no;
|
||||
NET_VENDOR_STMICRO = lib.mkForce no;
|
||||
NET_VENDOR_SUN = lib.mkForce no;
|
||||
NET_VENDOR_SYNOPSYS = lib.mkForce no;
|
||||
NET_VENDOR_TEHUTI = lib.mkForce no;
|
||||
NET_VENDOR_TI = lib.mkForce no;
|
||||
NET_VENDOR_VERTEXCOM = lib.mkForce no;
|
||||
NET_VENDOR_VIA = lib.mkForce no;
|
||||
NET_VENDOR_WANGXUN = lib.mkForce no;
|
||||
NET_VENDOR_WIZNET = lib.mkForce no;
|
||||
NET_VENDOR_XILINX = lib.mkForce no;
|
||||
NET_VENDOR_XIRCOM = lib.mkForce no;
|
||||
|
||||
# watchdogs (kept: SP5100_TCO for AMD chipset, WDAT_WDT for ACPI)
|
||||
ACQUIRE_WDT = lib.mkForce no;
|
||||
ADVANTECH_WDT = lib.mkForce no;
|
||||
ADVANTECH_EC_WDT = lib.mkForce no;
|
||||
ALIM1535_WDT = lib.mkForce no;
|
||||
ALIM7101_WDT = lib.mkForce no;
|
||||
CGBC_WDT = lib.mkForce no;
|
||||
EBC_C384_WDT = lib.mkForce no;
|
||||
EXAR_WDT = lib.mkForce no;
|
||||
F71808E_WDT = lib.mkForce no;
|
||||
EUROTECH_WDT = lib.mkForce no;
|
||||
IB700_WDT = lib.mkForce no;
|
||||
WAFER_WDT = lib.mkForce no;
|
||||
I6300ESB_WDT = lib.mkForce no;
|
||||
IE6XX_WDT = lib.mkForce no;
|
||||
ITCO_WDT = lib.mkForce no;
|
||||
IT8712F_WDT = lib.mkForce no;
|
||||
IT87_WDT = lib.mkForce no;
|
||||
HP_WATCHDOG = lib.mkForce no;
|
||||
HPWDT_NMI_DECODE = lib.mkForce no;
|
||||
KEMPLD_WDT = lib.mkForce no;
|
||||
MLX_WDT = lib.mkForce no;
|
||||
NI903X_WDT = lib.mkForce no;
|
||||
NIC7018_WDT = lib.mkForce no;
|
||||
SMSC37B787_WDT = lib.mkForce no;
|
||||
TQMX86_WDT = lib.mkForce no;
|
||||
VIA_WDT = lib.mkForce no;
|
||||
W83627HF_WDT = lib.mkForce no;
|
||||
W83877F_WDT = lib.mkForce no;
|
||||
W83977F_WDT = lib.mkForce no;
|
||||
MACHZ_WDT = lib.mkForce no;
|
||||
SBC_EPX_C3_WATCHDOG = lib.mkForce no;
|
||||
MEN_A21_WDT = lib.mkForce no;
|
||||
DW_WATCHDOG = lib.mkForce no;
|
||||
SOFT_WATCHDOG = lib.mkForce no;
|
||||
XILINX_WATCHDOG = lib.mkForce no;
|
||||
|
||||
# misc dead weight
|
||||
BLK_DEV_DRBD = lib.mkForce no;
|
||||
GREYBUS = lib.mkForce no;
|
||||
SOUNDWIRE_QCOM = lib.mkForce no;
|
||||
SOUNDWIRE_INTEL = lib.mkForce no;
|
||||
MEDIA_RADIO_SUPPORT = lib.mkForce no;
|
||||
|
||||
# net queue disciplines not used on desktop (kept: htb/prio/fifo/fq/fq_codel/cake/bpf/ingress/netem/tbf/mqprio for basic shaping + testing)
|
||||
NET_SCH_CBS = lib.mkForce no;
|
||||
NET_SCH_CHOKE = lib.mkForce no;
|
||||
NET_SCH_CODEL = lib.mkForce no;
|
||||
NET_SCH_DRR = lib.mkForce no;
|
||||
NET_SCH_DUALPI2 = lib.mkForce no;
|
||||
NET_SCH_ETF = lib.mkForce no;
|
||||
NET_SCH_ETS = lib.mkForce no;
|
||||
NET_SCH_FQ_PIE = lib.mkForce no;
|
||||
NET_SCH_GRED = lib.mkForce no;
|
||||
NET_SCH_HFSC = lib.mkForce no;
|
||||
NET_SCH_HHF = lib.mkForce no;
|
||||
NET_SCH_MULTIQ = lib.mkForce no;
|
||||
NET_SCH_PIE = lib.mkForce no;
|
||||
NET_SCH_PLUG = lib.mkForce no;
|
||||
NET_SCH_QFQ = lib.mkForce no;
|
||||
NET_SCH_RED = lib.mkForce no;
|
||||
NET_SCH_SFB = lib.mkForce no;
|
||||
NET_SCH_SFQ = lib.mkForce no;
|
||||
NET_SCH_SKBPRIO = lib.mkForce no;
|
||||
NET_SCH_TAPRIO = lib.mkForce no;
|
||||
NET_SCH_TEQL = lib.mkForce no;
|
||||
|
||||
# battery charger PMIC drivers — all mobile/embedded SoCs, none of these
|
||||
# exist on x86 laptops/desktops (which use ACPI battery + USB-PD via ucsi).
|
||||
# CROS_* are Chromebook-specific; Framework has CrOS EC but not CrOS charging.
|
||||
CHARGER_88PM860X = lib.mkForce no;
|
||||
CHARGER_ADP5061 = lib.mkForce no;
|
||||
CHARGER_AXP20X = lib.mkForce no;
|
||||
CHARGER_BD71828 = lib.mkForce no;
|
||||
CHARGER_BD99954 = lib.mkForce no;
|
||||
CHARGER_BQ2415X = lib.mkForce no;
|
||||
CHARGER_BQ24190 = lib.mkForce no;
|
||||
CHARGER_BQ24257 = lib.mkForce no;
|
||||
CHARGER_BQ24735 = lib.mkForce no;
|
||||
CHARGER_BQ2515X = lib.mkForce no;
|
||||
CHARGER_BQ256XX = lib.mkForce no;
|
||||
CHARGER_BQ257XX = lib.mkForce no;
|
||||
CHARGER_BQ25890 = lib.mkForce no;
|
||||
CHARGER_BQ25980 = lib.mkForce no;
|
||||
CHARGER_CROS_CONTROL = lib.mkForce no;
|
||||
CHARGER_CROS_PCHG = lib.mkForce no;
|
||||
CHARGER_CROS_USBPD = lib.mkForce no;
|
||||
CHARGER_DA9150 = lib.mkForce no;
|
||||
CHARGER_DETECTOR_MAX14656 = lib.mkForce no;
|
||||
CHARGER_GPIO = lib.mkForce no;
|
||||
CHARGER_ISP1704 = lib.mkForce no;
|
||||
CHARGER_LP8727 = lib.mkForce no;
|
||||
CHARGER_LP8788 = lib.mkForce no;
|
||||
CHARGER_LT3651 = lib.mkForce no;
|
||||
CHARGER_LTC4162L = lib.mkForce no;
|
||||
CHARGER_MANAGER = lib.mkForce no;
|
||||
CHARGER_MAX14577 = lib.mkForce no;
|
||||
CHARGER_MAX77650 = lib.mkForce no;
|
||||
CHARGER_MAX77693 = lib.mkForce no;
|
||||
CHARGER_MAX77705 = lib.mkForce no;
|
||||
CHARGER_MAX77976 = lib.mkForce no;
|
||||
CHARGER_MAX8903 = lib.mkForce no;
|
||||
CHARGER_MAX8971 = lib.mkForce no;
|
||||
CHARGER_MAX8997 = lib.mkForce no;
|
||||
CHARGER_MAX8998 = lib.mkForce no;
|
||||
CHARGER_MP2629 = lib.mkForce no;
|
||||
CHARGER_MT6360 = lib.mkForce no;
|
||||
CHARGER_MT6370 = lib.mkForce no;
|
||||
CHARGER_PF1550 = lib.mkForce no;
|
||||
CHARGER_RK817 = lib.mkForce no;
|
||||
CHARGER_RT5033 = lib.mkForce no;
|
||||
CHARGER_RT9455 = lib.mkForce no;
|
||||
CHARGER_RT9467 = lib.mkForce no;
|
||||
CHARGER_RT9471 = lib.mkForce no;
|
||||
CHARGER_RT9756 = lib.mkForce no;
|
||||
CHARGER_SBS = lib.mkForce no;
|
||||
CHARGER_SMB347 = lib.mkForce no;
|
||||
CHARGER_TPS65090 = lib.mkForce no;
|
||||
CHARGER_TPS65217 = lib.mkForce no;
|
||||
CHARGER_TWL4030 = lib.mkForce no;
|
||||
CHARGER_TWL6030 = lib.mkForce no;
|
||||
CHARGER_UCS1002 = lib.mkForce no;
|
||||
CHARGER_WILCO = lib.mkForce no;
|
||||
|
||||
# enterprise storage stack (kept: DM_CRYPT for LUKS, DM_SNAPSHOT/INTEGRITY/VERITY, MD_RAID0/1/10/456 in case)
|
||||
DM_MULTIPATH = lib.mkForce no;
|
||||
DM_MULTIPATH_QL = lib.mkForce no;
|
||||
DM_MULTIPATH_ST = lib.mkForce no;
|
||||
DM_MULTIPATH_HST = lib.mkForce no;
|
||||
DM_MULTIPATH_IOA = lib.mkForce no;
|
||||
DM_VDO = lib.mkForce no;
|
||||
DM_PCACHE = lib.mkForce no;
|
||||
DM_ZONED = lib.mkForce no;
|
||||
DM_LOG_USERSPACE = lib.mkForce no;
|
||||
DM_EBS = lib.mkForce no;
|
||||
DM_ERA = lib.mkForce no;
|
||||
DM_DUST = lib.mkForce no;
|
||||
DM_DELAY = lib.mkForce no;
|
||||
DM_FLAKEY = lib.mkForce no;
|
||||
DM_SWITCH = lib.mkForce no;
|
||||
DM_LOG_WRITES = lib.mkForce no;
|
||||
DM_CLONE = lib.mkForce no;
|
||||
DM_UNSTRIPED = lib.mkForce no;
|
||||
DM_CACHE = lib.mkForce no;
|
||||
DM_WRITECACHE = lib.mkForce no;
|
||||
DM_THIN_PROVISIONING = lib.mkForce no;
|
||||
MD_CLUSTER = lib.mkForce no;
|
||||
MD_LINEAR = lib.mkForce no;
|
||||
SCSI_DH_RDAC = lib.mkForce no;
|
||||
SCSI_DH_HP_SW = lib.mkForce no;
|
||||
SCSI_ENCLOSURE = lib.mkForce no;
|
||||
};
|
||||
}
|
||||
];
|
||||
@@ -411,8 +887,7 @@
|
||||
"camera"
|
||||
"adbusers"
|
||||
];
|
||||
# TODO! this is really bad :( I should really figure out how to do proper secrets management
|
||||
hashedPasswordFile = "${../secrets/desktop/password-hash}";
|
||||
hashedPasswordFile = config.age.secrets.password-hash.path;
|
||||
};
|
||||
|
||||
services.gvfs.enable = true;
|
||||
|
||||
40
modules/desktop-jovian.nix
Normal file
40
modules/desktop-jovian.nix
Normal file
@@ -0,0 +1,40 @@
|
||||
# Jovian-NixOS deck-mode configuration shared by all hosts running Steam
|
||||
# in gamescope (yarn, patiodeck). Host-specific settings (like
|
||||
# jovian.devices.steamdeck.enable) stay in the host's default.nix.
|
||||
{
|
||||
lib,
|
||||
username,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
./desktop-steam-update.nix
|
||||
inputs.jovian-nixos.nixosModules.default
|
||||
];
|
||||
|
||||
nixpkgs.config.allowUnfreePredicate =
|
||||
pkg:
|
||||
builtins.elem (lib.getName pkg) [
|
||||
"steamdeck-hw-theme"
|
||||
"steam-jupiter-unwrapped"
|
||||
"steam"
|
||||
"steam-original"
|
||||
"steam-unwrapped"
|
||||
"steam-run"
|
||||
];
|
||||
|
||||
jovian.steam = {
|
||||
enable = true;
|
||||
autoStart = true;
|
||||
desktopSession = "niri";
|
||||
user = username;
|
||||
};
|
||||
|
||||
# jovian overrides the display manager; sddm is required
|
||||
services.displayManager.sddm.wayland.enable = true;
|
||||
|
||||
# desktop-common.nix enables programs.gamescope which conflicts with
|
||||
# jovian's own gamescope wrapper
|
||||
programs.gamescope.enable = lib.mkForce false;
|
||||
}
|
||||
49
modules/desktop-lanzaboote-agenix.nix
Normal file
49
modules/desktop-lanzaboote-agenix.nix
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
inputs.lanzaboote.nixosModules.lanzaboote
|
||||
];
|
||||
|
||||
boot = {
|
||||
loader.systemd-boot.enable = lib.mkForce false;
|
||||
|
||||
lanzaboote = {
|
||||
enable = true;
|
||||
# sbctl expects the bundle at /var/lib/sbctl; muffin uses /etc/secureboot
|
||||
# because it is wiped on every activation there (impermanence) — desktops
|
||||
# extract to the historical sbctl path so existing tooling keeps working.
|
||||
pkiBundle = "/var/lib/sbctl";
|
||||
};
|
||||
};
|
||||
|
||||
system.activationScripts = {
|
||||
# Extract the secureboot PKI bundle from the agenix-decrypted tar. Mirrors
|
||||
# modules/server-lanzaboote-agenix.nix; skip when keys are already present
|
||||
# (e.g., disko-install staged them via --extra-files).
|
||||
"secureboot-keys" = {
|
||||
deps = [ "agenix" ];
|
||||
text = ''
|
||||
#!/bin/sh
|
||||
(
|
||||
umask 077
|
||||
if [[ -d ${config.boot.lanzaboote.pkiBundle} && -f ${config.boot.lanzaboote.pkiBundle}/db.key ]]; then
|
||||
echo "secureboot keys already present, skipping extraction"
|
||||
else
|
||||
echo "extracting secureboot keys from agenix"
|
||||
rm -fr ${config.boot.lanzaboote.pkiBundle} || true
|
||||
install -d -o root -g wheel -m 0500 ${config.boot.lanzaboote.pkiBundle}
|
||||
${pkgs.gnutar}/bin/tar xf ${config.age.secrets.secureboot-tar.path} -C ${config.boot.lanzaboote.pkiBundle}
|
||||
fi
|
||||
chown -R root:wheel ${config.boot.lanzaboote.pkiBundle}
|
||||
chmod -R 500 ${config.boot.lanzaboote.pkiBundle}
|
||||
)
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
{ hostname, site_config, ... }:
|
||||
{ hostname, ... }:
|
||||
{
|
||||
# speed up boot times (by about three seconds)
|
||||
systemd.services.NetworkManager-wait-online.enable = false;
|
||||
@@ -9,7 +9,10 @@
|
||||
networkmanager = {
|
||||
enable = true;
|
||||
|
||||
appendNameservers = site_config.dns_servers;
|
||||
appendNameservers = [
|
||||
"1.1.1.1"
|
||||
"9.9.9.9"
|
||||
];
|
||||
|
||||
wifi = {
|
||||
scanRandMacAddress = true;
|
||||
|
||||
122
modules/desktop-steam-update.nix
Normal file
122
modules/desktop-steam-update.nix
Normal file
@@ -0,0 +1,122 @@
|
||||
# Binary-cache update mechanism for Jovian-NixOS desktops.
|
||||
#
|
||||
# Replaces the upstream holo-update/steamos-update stubs with a script that
|
||||
# checks the private binary cache for a newer system closure, and provides a
|
||||
# root-level systemd service to apply it. Steam's deck UI calls
|
||||
# `steamos-update check` periodically; exit 7 = no update, exit 0 = update
|
||||
# applied or available.
|
||||
#
|
||||
# The deploy endpoint is ${binary_cache_url}/deploy/${hostname} — a plain
|
||||
# text file containing the /nix/store path of the latest closure, published
|
||||
# by CI after a successful build.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
hostname,
|
||||
username,
|
||||
site_config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
deploy-url = "${site_config.binary_cache.url}/deploy/${hostname}";
|
||||
|
||||
steamos-update-script = pkgs.writeShellScript "steamos-update" ''
|
||||
export PATH=${
|
||||
lib.makeBinPath [
|
||||
pkgs.curl
|
||||
pkgs.coreutils
|
||||
pkgs.systemd
|
||||
]
|
||||
}
|
||||
|
||||
STORE_PATH=$(curl -sf --max-time 30 "${deploy-url}" || true)
|
||||
|
||||
if [ -z "$STORE_PATH" ]; then
|
||||
>&2 echo "[steamos-update] server unreachable"
|
||||
exit 7
|
||||
fi
|
||||
|
||||
CURRENT=$(readlink -f /nix/var/nix/profiles/system)
|
||||
if [ "$CURRENT" = "$STORE_PATH" ]; then
|
||||
>&2 echo "[steamos-update] no update available"
|
||||
exit 7
|
||||
fi
|
||||
|
||||
# check-only mode: just report that an update exists
|
||||
if [ "''${1:-}" = "check" ] || [ "''${1:-}" = "--check-only" ]; then
|
||||
>&2 echo "[steamos-update] update available"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# apply: trigger the root-running systemd service to install the update
|
||||
>&2 echo "[steamos-update] applying update..."
|
||||
if systemctl start --wait pull-update-apply.service; then
|
||||
>&2 echo "[steamos-update] update installed, reboot to apply"
|
||||
exit 0
|
||||
else
|
||||
>&2 echo "[steamos-update] apply failed; see 'journalctl -u pull-update-apply'"
|
||||
exit 1
|
||||
fi
|
||||
'';
|
||||
in
|
||||
{
|
||||
nixpkgs.overlays = [
|
||||
(_final: prev: {
|
||||
jovian-stubs = prev.jovian-stubs.overrideAttrs (old: {
|
||||
buildCommand = (old.buildCommand or "") + ''
|
||||
install -D -m 755 ${steamos-update-script} $out/bin/holo-update
|
||||
install -D -m 755 ${steamos-update-script} $out/bin/steamos-update
|
||||
'';
|
||||
});
|
||||
})
|
||||
];
|
||||
|
||||
systemd.services.pull-update-apply = {
|
||||
description = "Apply pending NixOS update pulled from binary cache";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = pkgs.writeShellScript "pull-update-apply" ''
|
||||
set -uo pipefail
|
||||
export PATH=${
|
||||
lib.makeBinPath [
|
||||
pkgs.curl
|
||||
pkgs.coreutils
|
||||
pkgs.nix
|
||||
]
|
||||
}
|
||||
|
||||
STORE_PATH=$(curl -sf --max-time 30 "${deploy-url}" || true)
|
||||
if [ -z "$STORE_PATH" ]; then
|
||||
echo "server unreachable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT=$(readlink -f /nix/var/nix/profiles/system)
|
||||
if [ "$CURRENT" = "$STORE_PATH" ]; then
|
||||
echo "already up to date: $STORE_PATH"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "applying $STORE_PATH (was $CURRENT)"
|
||||
nix-store -r --add-root /nix/var/nix/gcroots/pull-update-apply-latest --indirect "$STORE_PATH" \
|
||||
|| { echo "fetch failed"; exit 1; }
|
||||
nix-env -p /nix/var/nix/profiles/system --set "$STORE_PATH" \
|
||||
|| { echo "profile set failed"; exit 1; }
|
||||
"$STORE_PATH/bin/switch-to-configuration" boot \
|
||||
|| { echo "boot entry failed"; exit 1; }
|
||||
echo "update applied; reboot required"
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# allow the primary user to trigger pull-update-apply without a password
|
||||
security.polkit.extraConfig = ''
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||
action.lookup("unit") == "pull-update-apply.service" &&
|
||||
subject.user == "${username}") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
'';
|
||||
}
|
||||
804
patches/omp/0001-fix-reasoning_content.patch
Normal file
804
patches/omp/0001-fix-reasoning_content.patch
Normal file
@@ -0,0 +1,804 @@
|
||||
From e145b627cffb6907e6bde348f1318f48acba3801 Mon Sep 17 00:00:00 2001
|
||||
From: sonhyrd <son.hong.do@hyrd.ai>
|
||||
Date: Mon, 27 Apr 2026 00:00:18 +0700
|
||||
Subject: [PATCH 1/5] fix(ai/providers): cover opencode-go reasoning tool-call
|
||||
history
|
||||
|
||||
---
|
||||
.../providers/openai-completions-compat.ts | 12 +++--
|
||||
.../ai/src/providers/openai-completions.ts | 4 +-
|
||||
.../ai/test/openai-completions-compat.test.ts | 51 +++++++++++++++----
|
||||
3 files changed, 49 insertions(+), 18 deletions(-)
|
||||
|
||||
diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts
|
||||
index 69f4811c8..c777f312b 100644
|
||||
--- a/packages/ai/src/providers/openai-completions-compat.ts
|
||||
+++ b/packages/ai/src/providers/openai-completions-compat.ts
|
||||
@@ -107,12 +107,14 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
|
||||
reasoningContentField: "reasoning_content",
|
||||
// Backends that 400 follow-up requests when prior assistant tool-call turns lack `reasoning_content`:
|
||||
// - Kimi: documented invariant on its native API and via OpenCode-Go.
|
||||
- // - Any reasoning-capable model reached through OpenRouter: DeepSeek V4 Pro and similar enforce
|
||||
- // this server-side whenever the request is in thinking mode. We can't translate Anthropic's
|
||||
- // redacted/encrypted reasoning into DeepSeek's plaintext form, so cross-provider continuations
|
||||
- // rely on a placeholder — see `convertMessages` for the placeholder injection.
|
||||
+ // - Reasoning-capable models reached through OpenRouter or OpenCode-Go: DeepSeek V4 Pro and
|
||||
+ // similar enforce this server-side whenever the request is in thinking mode.
|
||||
+ // We can't translate Anthropic's redacted/encrypted reasoning into DeepSeek's plaintext form, so
|
||||
+ // cross-provider continuations rely on a placeholder — see `convertMessages` for injection rules.
|
||||
requiresReasoningContentForToolCalls:
|
||||
- isKimiModel || ((provider === "openrouter" || baseUrl.includes("openrouter.ai")) && Boolean(model.reasoning)),
|
||||
+ isKimiModel ||
|
||||
+ ((provider === "openrouter" || baseUrl.includes("openrouter.ai") || provider === "opencode-go" ||
|
||||
+ baseUrl.includes("opencode.ai/zen/go")) && Boolean(model.reasoning)),
|
||||
requiresAssistantContentForToolCalls: isKimiModel,
|
||||
openRouterRouting: undefined,
|
||||
vercelGatewayRouting: undefined,
|
||||
diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts
|
||||
index 3785af106..70f2e3b63 100644
|
||||
--- a/packages/ai/src/providers/openai-completions.ts
|
||||
+++ b/packages/ai/src/providers/openai-completions.ts
|
||||
@@ -1213,8 +1213,8 @@ export function convertMessages(
|
||||
// Inject a `reasoning_content` placeholder on assistant tool-call turns when the backend
|
||||
// rejects history without it. The compat flag captures the rule:
|
||||
// - Kimi (native or via OpenCode-Go): chat completion endpoint demands the field.
|
||||
- // - Reasoning models reached through OpenRouter (e.g. DeepSeek V4 Pro): the underlying
|
||||
- // provider's thinking-mode validator demands it on every prior assistant turn. omp
|
||||
+ // - Reasoning models reached through OpenRouter or OpenCode-Go (e.g. DeepSeek V4 Pro):
|
||||
+ // the upstream thinking-mode validator demands it on every prior assistant turn. omp
|
||||
// cannot synthesize real reasoning when the conversation was warmed up by another
|
||||
// provider whose reasoning is redacted/encrypted (Anthropic) or simply absent, so we
|
||||
// emit a placeholder. Real captured reasoning, when present, is preserved earlier via
|
||||
diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts
|
||||
index 6fc3ca9af..6d60ba5e4 100644
|
||||
--- a/packages/ai/test/openai-completions-compat.test.ts
|
||||
+++ b/packages/ai/test/openai-completions-compat.test.ts
|
||||
@@ -283,23 +283,59 @@ describe("openai-completions compatibility", () => {
|
||||
});
|
||||
|
||||
describe("kimi model detection via detectCompat", () => {
|
||||
- function kimiOpenCodeModel(id: string): Model<"openai-completions"> {
|
||||
+ function openCodeGoModel(id: string, reasoning = true): Model<"openai-completions"> {
|
||||
return {
|
||||
...getBundledModel("openai", "gpt-4o-mini"),
|
||||
api: "openai-completions",
|
||||
provider: "opencode-go",
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
id,
|
||||
- reasoning: true,
|
||||
+ reasoning,
|
||||
};
|
||||
}
|
||||
|
||||
+ function kimiOpenCodeModel(id: string): Model<"openai-completions"> {
|
||||
+ return openCodeGoModel(id, true);
|
||||
+ }
|
||||
+
|
||||
it("requires reasoning_content for tool calls on kimi-k2.5 (opencode-go)", () => {
|
||||
const compat = detectCompat(kimiOpenCodeModel("kimi-k2.5"));
|
||||
expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
expect(compat.requiresAssistantContentForToolCalls).toBe(true);
|
||||
});
|
||||
|
||||
+ it("requires reasoning_content for tool calls on reasoning DeepSeek models via opencode-go", () => {
|
||||
+ const compat = detectCompat(openCodeGoModel("deepseek-v4-pro", true));
|
||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
+ expect(compat.requiresAssistantContentForToolCalls).toBe(false);
|
||||
+ });
|
||||
+
|
||||
+ it("injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via opencode-go", () => {
|
||||
+ const model = openCodeGoModel("deepseek-v4-pro", true);
|
||||
+ const compat = detectCompat(model);
|
||||
+ const toolCallMessage: AssistantMessage = {
|
||||
+ role: "assistant",
|
||||
+ content: [{ type: "toolCall", id: "call_ds_go", name: "web_search", arguments: { query: "hi" } }],
|
||||
+ api: model.api,
|
||||
+ provider: model.provider,
|
||||
+ model: model.id,
|
||||
+ usage: {
|
||||
+ input: 0,
|
||||
+ output: 0,
|
||||
+ cacheRead: 0,
|
||||
+ cacheWrite: 0,
|
||||
+ totalTokens: 0,
|
||||
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
+ },
|
||||
+ stopReason: "toolUse",
|
||||
+ timestamp: Date.now(),
|
||||
+ };
|
||||
+ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
||||
+ const assistant = messages.find(m => m.role === "assistant");
|
||||
+ expect(assistant).toBeDefined();
|
||||
+ expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
||||
+ });
|
||||
+
|
||||
it("injects reasoning_content placeholder when assistant with tool calls has no reasoning field", () => {
|
||||
const model = kimiOpenCodeModel("kimi-k2.5");
|
||||
const compat = detectCompat(model);
|
||||
@@ -338,15 +374,8 @@ describe("kimi model detection via detectCompat", () => {
|
||||
expect((reasoningContent as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
- it("does not inject reasoning_content when model is not kimi", () => {
|
||||
- const model: Model<"openai-completions"> = {
|
||||
- ...getBundledModel("openai", "gpt-4o-mini"),
|
||||
- api: "openai-completions",
|
||||
- provider: "opencode-go",
|
||||
- baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
- id: "some-other-model",
|
||||
- };
|
||||
- const compat = detectCompat(model);
|
||||
+ it("does not require reasoning_content when opencode-go model is not reasoning-capable", () => {
|
||||
+ const compat = detectCompat(openCodeGoModel("some-other-model", false));
|
||||
expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
From 70eda0132d7ff48314cbf2dc9560339f0a765d9e Mon Sep 17 00:00:00 2001
|
||||
From: sonhyrd <son.hong.do@hyrd.ai>
|
||||
Date: Mon, 27 Apr 2026 00:08:04 +0700
|
||||
Subject: [PATCH 2/5] fix(ai/providers): generalize opencode reasoning_content
|
||||
gating
|
||||
|
||||
---
|
||||
.../providers/openai-completions-compat.ts | 14 +-
|
||||
.../ai/src/providers/openai-completions.ts | 4 +-
|
||||
.../ai/test/openai-completions-compat.test.ts | 160 ++++++++----------
|
||||
3 files changed, 82 insertions(+), 96 deletions(-)
|
||||
|
||||
diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts
|
||||
index c777f312b..b4825a31c 100644
|
||||
--- a/packages/ai/src/providers/openai-completions-compat.ts
|
||||
+++ b/packages/ai/src/providers/openai-completions-compat.ts
|
||||
@@ -54,6 +54,8 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
|
||||
const isKimiModel = model.id.includes("moonshotai/kimi") || /^kimi[-.]/i.test(model.id);
|
||||
const isAlibaba = provider === "alibaba-coding-plan" || baseUrl.includes("dashscope");
|
||||
const isQwen = model.id.toLowerCase().includes("qwen");
|
||||
+ const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai");
|
||||
+ const isOpenCode = provider === "opencode-zen" || provider === "opencode-go" || baseUrl.includes("opencode.ai/zen");
|
||||
|
||||
const isNonStandard =
|
||||
isCerebras ||
|
||||
@@ -99,22 +101,20 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
|
||||
requiresMistralToolIds: isMistral,
|
||||
thinkingFormat: isZai
|
||||
? "zai"
|
||||
- : provider === "openrouter" || baseUrl.includes("openrouter.ai")
|
||||
+ : isOpenRouter
|
||||
? "openrouter"
|
||||
: isAlibaba || isQwen
|
||||
? "qwen"
|
||||
: "openai",
|
||||
reasoningContentField: "reasoning_content",
|
||||
// Backends that 400 follow-up requests when prior assistant tool-call turns lack `reasoning_content`:
|
||||
- // - Kimi: documented invariant on its native API and via OpenCode-Go.
|
||||
- // - Reasoning-capable models reached through OpenRouter or OpenCode-Go: DeepSeek V4 Pro and
|
||||
- // similar enforce this server-side whenever the request is in thinking mode.
|
||||
+ // - Kimi: documented invariant on its native API and via OpenCode.
|
||||
+ // - Reasoning-capable models reached through OpenRouter or OpenCode (Zen/Go): DeepSeek V4 Pro,
|
||||
+ // Kimi, and similar models can enforce this server-side whenever the request is in thinking mode.
|
||||
// We can't translate Anthropic's redacted/encrypted reasoning into DeepSeek's plaintext form, so
|
||||
// cross-provider continuations rely on a placeholder — see `convertMessages` for injection rules.
|
||||
requiresReasoningContentForToolCalls:
|
||||
- isKimiModel ||
|
||||
- ((provider === "openrouter" || baseUrl.includes("openrouter.ai") || provider === "opencode-go" ||
|
||||
- baseUrl.includes("opencode.ai/zen/go")) && Boolean(model.reasoning)),
|
||||
+ isKimiModel || ((isOpenRouter || isOpenCode) && Boolean(model.reasoning)),
|
||||
requiresAssistantContentForToolCalls: isKimiModel,
|
||||
openRouterRouting: undefined,
|
||||
vercelGatewayRouting: undefined,
|
||||
diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts
|
||||
index 70f2e3b63..e25aeffb3 100644
|
||||
--- a/packages/ai/src/providers/openai-completions.ts
|
||||
+++ b/packages/ai/src/providers/openai-completions.ts
|
||||
@@ -1212,8 +1212,8 @@ export function convertMessages(
|
||||
(assistantMsg as any).reasoning_text !== undefined;
|
||||
// Inject a `reasoning_content` placeholder on assistant tool-call turns when the backend
|
||||
// rejects history without it. The compat flag captures the rule:
|
||||
- // - Kimi (native or via OpenCode-Go): chat completion endpoint demands the field.
|
||||
- // - Reasoning models reached through OpenRouter or OpenCode-Go (e.g. DeepSeek V4 Pro):
|
||||
+ // - Kimi (native or via OpenCode Zen/Go): chat completion endpoint demands the field.
|
||||
+ // - Reasoning models reached through OpenRouter or OpenCode Zen/Go (e.g. DeepSeek V4 Pro):
|
||||
// the upstream thinking-mode validator demands it on every prior assistant turn. omp
|
||||
// cannot synthesize real reasoning when the conversation was warmed up by another
|
||||
// provider whose reasoning is redacted/encrypted (Anthropic) or simply absent, so we
|
||||
diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts
|
||||
index 6d60ba5e4..c743dd246 100644
|
||||
--- a/packages/ai/test/openai-completions-compat.test.ts
|
||||
+++ b/packages/ai/test/openai-completions-compat.test.ts
|
||||
@@ -282,105 +282,91 @@ describe("openai-completions compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
-describe("kimi model detection via detectCompat", () => {
|
||||
- function openCodeGoModel(id: string, reasoning = true): Model<"openai-completions"> {
|
||||
+describe("opencode reasoning-content compatibility via detectCompat", () => {
|
||||
+ type OpenCodeProvider = "opencode-go" | "opencode-zen";
|
||||
+
|
||||
+ function openCodeModel(provider: OpenCodeProvider, id: string, reasoning = true): Model<"openai-completions"> {
|
||||
+ const baseUrl = provider === "opencode-go" ? "https://opencode.ai/zen/go/v1" : "https://opencode.ai/zen/v1";
|
||||
return {
|
||||
...getBundledModel("openai", "gpt-4o-mini"),
|
||||
api: "openai-completions",
|
||||
- provider: "opencode-go",
|
||||
- baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
+ provider,
|
||||
+ baseUrl,
|
||||
id,
|
||||
reasoning,
|
||||
};
|
||||
}
|
||||
|
||||
- function kimiOpenCodeModel(id: string): Model<"openai-completions"> {
|
||||
- return openCodeGoModel(id, true);
|
||||
- }
|
||||
-
|
||||
- it("requires reasoning_content for tool calls on kimi-k2.5 (opencode-go)", () => {
|
||||
- const compat = detectCompat(kimiOpenCodeModel("kimi-k2.5"));
|
||||
- expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
- expect(compat.requiresAssistantContentForToolCalls).toBe(true);
|
||||
- });
|
||||
-
|
||||
- it("requires reasoning_content for tool calls on reasoning DeepSeek models via opencode-go", () => {
|
||||
- const compat = detectCompat(openCodeGoModel("deepseek-v4-pro", true));
|
||||
- expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
- expect(compat.requiresAssistantContentForToolCalls).toBe(false);
|
||||
- });
|
||||
-
|
||||
- it("injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via opencode-go", () => {
|
||||
- const model = openCodeGoModel("deepseek-v4-pro", true);
|
||||
- const compat = detectCompat(model);
|
||||
- const toolCallMessage: AssistantMessage = {
|
||||
- role: "assistant",
|
||||
- content: [{ type: "toolCall", id: "call_ds_go", name: "web_search", arguments: { query: "hi" } }],
|
||||
- api: model.api,
|
||||
- provider: model.provider,
|
||||
- model: model.id,
|
||||
- usage: {
|
||||
- input: 0,
|
||||
- output: 0,
|
||||
- cacheRead: 0,
|
||||
- cacheWrite: 0,
|
||||
- totalTokens: 0,
|
||||
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
- },
|
||||
- stopReason: "toolUse",
|
||||
- timestamp: Date.now(),
|
||||
+ it.each(["opencode-go", "opencode-zen"] as const)(
|
||||
+ "requires reasoning_content for tool calls on kimi-k2.5 via %s",
|
||||
+ provider => {
|
||||
+ const compat = detectCompat(openCodeModel(provider, "kimi-k2.5", true));
|
||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
+ expect(compat.requiresAssistantContentForToolCalls).toBe(true);
|
||||
+ },
|
||||
+ );
|
||||
+
|
||||
+ it.each(["opencode-go", "opencode-zen"] as const)(
|
||||
+ "requires reasoning_content for tool calls on reasoning DeepSeek models via %s",
|
||||
+ provider => {
|
||||
+ const compat = detectCompat(openCodeModel(provider, "deepseek-v4-pro", true));
|
||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
+ expect(compat.requiresAssistantContentForToolCalls).toBe(false);
|
||||
+ },
|
||||
+ );
|
||||
+
|
||||
+ it("requires reasoning_content when custom openai provider targets opencode zen baseUrl", () => {
|
||||
+ const model: Model<"openai-completions"> = {
|
||||
+ ...getBundledModel("openai", "gpt-4o-mini"),
|
||||
+ api: "openai-completions",
|
||||
+ provider: "openai",
|
||||
+ baseUrl: "https://opencode.ai/zen/v1",
|
||||
+ id: "deepseek-v4-pro",
|
||||
+ reasoning: true,
|
||||
};
|
||||
- const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
||||
- const assistant = messages.find(m => m.role === "assistant");
|
||||
- expect(assistant).toBeDefined();
|
||||
- expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
||||
- });
|
||||
-
|
||||
- it("injects reasoning_content placeholder when assistant with tool calls has no reasoning field", () => {
|
||||
- const model = kimiOpenCodeModel("kimi-k2.5");
|
||||
const compat = detectCompat(model);
|
||||
- const toolCallMessage: AssistantMessage = {
|
||||
- role: "assistant",
|
||||
- content: [
|
||||
- // Thinking returned as plain text (as kimi-k2.5 on opencode-go does)
|
||||
- { type: "text", text: "Let me research this." },
|
||||
- {
|
||||
- type: "toolCall",
|
||||
- id: "call_abc123",
|
||||
- name: "web_search",
|
||||
- arguments: { query: "beads gastownhall" },
|
||||
- },
|
||||
- ],
|
||||
- api: model.api,
|
||||
- provider: model.provider,
|
||||
- model: model.id,
|
||||
- usage: {
|
||||
- input: 0,
|
||||
- output: 0,
|
||||
- cacheRead: 0,
|
||||
- cacheWrite: 0,
|
||||
- totalTokens: 0,
|
||||
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
- },
|
||||
- stopReason: "toolUse",
|
||||
- timestamp: Date.now(),
|
||||
- };
|
||||
- const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
||||
- const assistant = messages.find(m => m.role === "assistant");
|
||||
- expect(assistant).toBeDefined();
|
||||
- const reasoningContent = Reflect.get(assistant as object, "reasoning_content");
|
||||
- expect(reasoningContent).toBeDefined();
|
||||
- expect(typeof reasoningContent).toBe("string");
|
||||
- expect((reasoningContent as string).length).toBeGreaterThan(0);
|
||||
- });
|
||||
-
|
||||
- it("does not require reasoning_content when opencode-go model is not reasoning-capable", () => {
|
||||
- const compat = detectCompat(openCodeGoModel("some-other-model", false));
|
||||
- expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
});
|
||||
|
||||
- it.each(["kimi-k2.5", "kimi-k1.5", "kimi-k2-5"])("matches kimi model id: %s", id => {
|
||||
- const compat = detectCompat(kimiOpenCodeModel(id));
|
||||
+ it.each(["opencode-go", "opencode-zen"] as const)(
|
||||
+ "injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via %s",
|
||||
+ provider => {
|
||||
+ const model = openCodeModel(provider, "deepseek-v4-pro", true);
|
||||
+ const compat = detectCompat(model);
|
||||
+ const toolCallMessage: AssistantMessage = {
|
||||
+ role: "assistant",
|
||||
+ content: [{ type: "toolCall", id: `call_ds_${provider}`, name: "web_search", arguments: { query: "hi" } }],
|
||||
+ api: model.api,
|
||||
+ provider: model.provider,
|
||||
+ model: model.id,
|
||||
+ usage: {
|
||||
+ input: 0,
|
||||
+ output: 0,
|
||||
+ cacheRead: 0,
|
||||
+ cacheWrite: 0,
|
||||
+ totalTokens: 0,
|
||||
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
+ },
|
||||
+ stopReason: "toolUse",
|
||||
+ timestamp: Date.now(),
|
||||
+ };
|
||||
+ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
||||
+ const assistant = messages.find(m => m.role === "assistant");
|
||||
+ expect(assistant).toBeDefined();
|
||||
+ expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
||||
+ },
|
||||
+ );
|
||||
+
|
||||
+ it.each(["opencode-go", "opencode-zen"] as const)(
|
||||
+ "does not require reasoning_content when %s model is not reasoning-capable",
|
||||
+ provider => {
|
||||
+ const compat = detectCompat(openCodeModel(provider, "some-other-model", false));
|
||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
||||
+ },
|
||||
+ );
|
||||
+
|
||||
+ it.each(["kimi-k2.5", "kimi-k1.5", "kimi-k2-5"])("matches kimi model id pattern via opencode-zen: %s", id => {
|
||||
+ const compat = detectCompat(openCodeModel("opencode-zen", id, true));
|
||||
expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
From 76c1fe9ee083836ecca43900fefc458c8cf4c4fb Mon Sep 17 00:00:00 2001
|
||||
From: sonhyrd <son.hong.do@hyrd.ai>
|
||||
Date: Mon, 27 Apr 2026 00:14:27 +0700
|
||||
Subject: [PATCH 3/5] test(ai): restore non-kimi coverage while adding
|
||||
opencode-zen cases
|
||||
|
||||
---
|
||||
.../ai/test/openai-completions-compat.test.ts | 215 +++++++++++++-----
|
||||
1 file changed, 154 insertions(+), 61 deletions(-)
|
||||
|
||||
diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts
|
||||
index c743dd246..8b8cef393 100644
|
||||
--- a/packages/ai/test/openai-completions-compat.test.ts
|
||||
+++ b/packages/ai/test/openai-completions-compat.test.ts
|
||||
@@ -282,38 +282,56 @@ describe("openai-completions compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
-describe("opencode reasoning-content compatibility via detectCompat", () => {
|
||||
- type OpenCodeProvider = "opencode-go" | "opencode-zen";
|
||||
+describe("kimi model detection via detectCompat", () => {
|
||||
+ function openCodeGoModel(id: string, reasoning = true): Model<"openai-completions"> {
|
||||
+ return {
|
||||
+ ...getBundledModel("openai", "gpt-4o-mini"),
|
||||
+ api: "openai-completions",
|
||||
+ provider: "opencode-go",
|
||||
+ baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
+ id,
|
||||
+ reasoning,
|
||||
+ };
|
||||
+ }
|
||||
|
||||
- function openCodeModel(provider: OpenCodeProvider, id: string, reasoning = true): Model<"openai-completions"> {
|
||||
- const baseUrl = provider === "opencode-go" ? "https://opencode.ai/zen/go/v1" : "https://opencode.ai/zen/v1";
|
||||
+ function openCodeZenModel(id: string, reasoning = true): Model<"openai-completions"> {
|
||||
return {
|
||||
...getBundledModel("openai", "gpt-4o-mini"),
|
||||
api: "openai-completions",
|
||||
- provider,
|
||||
- baseUrl,
|
||||
+ provider: "opencode-zen",
|
||||
+ baseUrl: "https://opencode.ai/zen/v1",
|
||||
id,
|
||||
reasoning,
|
||||
};
|
||||
}
|
||||
|
||||
- it.each(["opencode-go", "opencode-zen"] as const)(
|
||||
- "requires reasoning_content for tool calls on kimi-k2.5 via %s",
|
||||
- provider => {
|
||||
- const compat = detectCompat(openCodeModel(provider, "kimi-k2.5", true));
|
||||
- expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
- expect(compat.requiresAssistantContentForToolCalls).toBe(true);
|
||||
- },
|
||||
- );
|
||||
-
|
||||
- it.each(["opencode-go", "opencode-zen"] as const)(
|
||||
- "requires reasoning_content for tool calls on reasoning DeepSeek models via %s",
|
||||
- provider => {
|
||||
- const compat = detectCompat(openCodeModel(provider, "deepseek-v4-pro", true));
|
||||
- expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
- expect(compat.requiresAssistantContentForToolCalls).toBe(false);
|
||||
- },
|
||||
- );
|
||||
+ function kimiOpenCodeModel(id: string): Model<"openai-completions"> {
|
||||
+ return openCodeGoModel(id, true);
|
||||
+ }
|
||||
+
|
||||
+ it("requires reasoning_content for tool calls on kimi-k2.5 (opencode-go)", () => {
|
||||
+ const compat = detectCompat(kimiOpenCodeModel("kimi-k2.5"));
|
||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
+ expect(compat.requiresAssistantContentForToolCalls).toBe(true);
|
||||
+ });
|
||||
+
|
||||
+ it("requires reasoning_content for tool calls on kimi-k2.5 (opencode-zen)", () => {
|
||||
+ const compat = detectCompat(openCodeZenModel("kimi-k2.5", true));
|
||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
+ expect(compat.requiresAssistantContentForToolCalls).toBe(true);
|
||||
+ });
|
||||
+
|
||||
+ it("requires reasoning_content for tool calls on reasoning DeepSeek models via opencode-go", () => {
|
||||
+ const compat = detectCompat(openCodeGoModel("deepseek-v4-pro", true));
|
||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
+ expect(compat.requiresAssistantContentForToolCalls).toBe(false);
|
||||
+ });
|
||||
+
|
||||
+ it("requires reasoning_content for tool calls on reasoning DeepSeek models via opencode-zen", () => {
|
||||
+ const compat = detectCompat(openCodeZenModel("deepseek-v4-pro", true));
|
||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
+ expect(compat.requiresAssistantContentForToolCalls).toBe(false);
|
||||
+ });
|
||||
|
||||
it("requires reasoning_content when custom openai provider targets opencode zen baseUrl", () => {
|
||||
const model: Model<"openai-completions"> = {
|
||||
@@ -328,45 +346,120 @@ describe("opencode reasoning-content compatibility via detectCompat", () => {
|
||||
expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
});
|
||||
|
||||
- it.each(["opencode-go", "opencode-zen"] as const)(
|
||||
- "injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via %s",
|
||||
- provider => {
|
||||
- const model = openCodeModel(provider, "deepseek-v4-pro", true);
|
||||
- const compat = detectCompat(model);
|
||||
- const toolCallMessage: AssistantMessage = {
|
||||
- role: "assistant",
|
||||
- content: [{ type: "toolCall", id: `call_ds_${provider}`, name: "web_search", arguments: { query: "hi" } }],
|
||||
- api: model.api,
|
||||
- provider: model.provider,
|
||||
- model: model.id,
|
||||
- usage: {
|
||||
- input: 0,
|
||||
- output: 0,
|
||||
- cacheRead: 0,
|
||||
- cacheWrite: 0,
|
||||
- totalTokens: 0,
|
||||
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
+ it("injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via opencode-go", () => {
|
||||
+ const model = openCodeGoModel("deepseek-v4-pro", true);
|
||||
+ const compat = detectCompat(model);
|
||||
+ const toolCallMessage: AssistantMessage = {
|
||||
+ role: "assistant",
|
||||
+ content: [{ type: "toolCall", id: "call_ds_go", name: "web_search", arguments: { query: "hi" } }],
|
||||
+ api: model.api,
|
||||
+ provider: model.provider,
|
||||
+ model: model.id,
|
||||
+ usage: {
|
||||
+ input: 0,
|
||||
+ output: 0,
|
||||
+ cacheRead: 0,
|
||||
+ cacheWrite: 0,
|
||||
+ totalTokens: 0,
|
||||
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
+ },
|
||||
+ stopReason: "toolUse",
|
||||
+ timestamp: Date.now(),
|
||||
+ };
|
||||
+ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
||||
+ const assistant = messages.find(m => m.role === "assistant");
|
||||
+ expect(assistant).toBeDefined();
|
||||
+ expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
||||
+ });
|
||||
+
|
||||
+ it("injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via opencode-zen", () => {
|
||||
+ const model = openCodeZenModel("deepseek-v4-pro", true);
|
||||
+ const compat = detectCompat(model);
|
||||
+ const toolCallMessage: AssistantMessage = {
|
||||
+ role: "assistant",
|
||||
+ content: [{ type: "toolCall", id: "call_ds_zen", name: "web_search", arguments: { query: "hi" } }],
|
||||
+ api: model.api,
|
||||
+ provider: model.provider,
|
||||
+ model: model.id,
|
||||
+ usage: {
|
||||
+ input: 0,
|
||||
+ output: 0,
|
||||
+ cacheRead: 0,
|
||||
+ cacheWrite: 0,
|
||||
+ totalTokens: 0,
|
||||
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
+ },
|
||||
+ stopReason: "toolUse",
|
||||
+ timestamp: Date.now(),
|
||||
+ };
|
||||
+ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
||||
+ const assistant = messages.find(m => m.role === "assistant");
|
||||
+ expect(assistant).toBeDefined();
|
||||
+ expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
||||
+ });
|
||||
+
|
||||
+ it("injects reasoning_content placeholder when assistant with tool calls has no reasoning field", () => {
|
||||
+ const model = kimiOpenCodeModel("kimi-k2.5");
|
||||
+ const compat = detectCompat(model);
|
||||
+ const toolCallMessage: AssistantMessage = {
|
||||
+ role: "assistant",
|
||||
+ content: [
|
||||
+ // Thinking returned as plain text (as kimi-k2.5 on opencode-go does)
|
||||
+ { type: "text", text: "Let me research this." },
|
||||
+ {
|
||||
+ type: "toolCall",
|
||||
+ id: "call_abc123",
|
||||
+ name: "web_search",
|
||||
+ arguments: { query: "beads gastownhall" },
|
||||
},
|
||||
- stopReason: "toolUse",
|
||||
- timestamp: Date.now(),
|
||||
- };
|
||||
- const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
||||
- const assistant = messages.find(m => m.role === "assistant");
|
||||
- expect(assistant).toBeDefined();
|
||||
- expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
||||
- },
|
||||
- );
|
||||
-
|
||||
- it.each(["opencode-go", "opencode-zen"] as const)(
|
||||
- "does not require reasoning_content when %s model is not reasoning-capable",
|
||||
- provider => {
|
||||
- const compat = detectCompat(openCodeModel(provider, "some-other-model", false));
|
||||
- expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
||||
- },
|
||||
- );
|
||||
-
|
||||
- it.each(["kimi-k2.5", "kimi-k1.5", "kimi-k2-5"])("matches kimi model id pattern via opencode-zen: %s", id => {
|
||||
- const compat = detectCompat(openCodeModel("opencode-zen", id, true));
|
||||
+ ],
|
||||
+ api: model.api,
|
||||
+ provider: model.provider,
|
||||
+ model: model.id,
|
||||
+ usage: {
|
||||
+ input: 0,
|
||||
+ output: 0,
|
||||
+ cacheRead: 0,
|
||||
+ cacheWrite: 0,
|
||||
+ totalTokens: 0,
|
||||
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
+ },
|
||||
+ stopReason: "toolUse",
|
||||
+ timestamp: Date.now(),
|
||||
+ };
|
||||
+ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat);
|
||||
+ const assistant = messages.find(m => m.role === "assistant");
|
||||
+ expect(assistant).toBeDefined();
|
||||
+ const reasoningContent = Reflect.get(assistant as object, "reasoning_content");
|
||||
+ expect(reasoningContent).toBeDefined();
|
||||
+ expect(typeof reasoningContent).toBe("string");
|
||||
+ expect((reasoningContent as string).length).toBeGreaterThan(0);
|
||||
+ });
|
||||
+
|
||||
+ it("does not inject reasoning_content when model is not kimi", () => {
|
||||
+ const model: Model<"openai-completions"> = {
|
||||
+ ...getBundledModel("openai", "gpt-4o-mini"),
|
||||
+ api: "openai-completions",
|
||||
+ provider: "opencode-go",
|
||||
+ baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
+ id: "some-other-model",
|
||||
+ };
|
||||
+ const compat = detectCompat(model);
|
||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
||||
+ });
|
||||
+
|
||||
+ it("does not require reasoning_content when opencode-go model is not reasoning-capable", () => {
|
||||
+ const compat = detectCompat(openCodeGoModel("some-other-model", false));
|
||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
||||
+ });
|
||||
+
|
||||
+ it("does not require reasoning_content when opencode-zen model is not reasoning-capable", () => {
|
||||
+ const compat = detectCompat(openCodeZenModel("some-other-model", false));
|
||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
||||
+ });
|
||||
+
|
||||
+ it.each(["kimi-k2.5", "kimi-k1.5", "kimi-k2-5"])("matches kimi model id: %s", id => {
|
||||
+ const compat = detectCompat(kimiOpenCodeModel(id));
|
||||
expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
From 9c7a8958c682b16990504500551827320508087d Mon Sep 17 00:00:00 2001
|
||||
From: sonhyrd <son.hong.do@hyrd.ai>
|
||||
Date: Mon, 27 Apr 2026 00:29:48 +0700
|
||||
Subject: [PATCH 4/5] fix(ai/providers): gate reasoning_content stubs on
|
||||
deepseek models
|
||||
|
||||
---
|
||||
.../providers/openai-completions-compat.ts | 7 ++--
|
||||
.../ai/src/providers/openai-completions.ts | 4 +--
|
||||
.../ai/test/openai-completions-compat.test.ts | 36 +++++++++++++++++++
|
||||
3 files changed, 42 insertions(+), 5 deletions(-)
|
||||
|
||||
diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts
|
||||
index b4825a31c..bba1cef70 100644
|
||||
--- a/packages/ai/src/providers/openai-completions-compat.ts
|
||||
+++ b/packages/ai/src/providers/openai-completions-compat.ts
|
||||
@@ -54,6 +54,7 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
|
||||
const isKimiModel = model.id.includes("moonshotai/kimi") || /^kimi[-.]/i.test(model.id);
|
||||
const isAlibaba = provider === "alibaba-coding-plan" || baseUrl.includes("dashscope");
|
||||
const isQwen = model.id.toLowerCase().includes("qwen");
|
||||
+ const isDeepSeekModel = model.id.toLowerCase().includes("deepseek");
|
||||
const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai");
|
||||
const isOpenCode = provider === "opencode-zen" || provider === "opencode-go" || baseUrl.includes("opencode.ai/zen");
|
||||
|
||||
@@ -109,12 +110,12 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
|
||||
reasoningContentField: "reasoning_content",
|
||||
// Backends that 400 follow-up requests when prior assistant tool-call turns lack `reasoning_content`:
|
||||
// - Kimi: documented invariant on its native API and via OpenCode.
|
||||
- // - Reasoning-capable models reached through OpenRouter or OpenCode (Zen/Go): DeepSeek V4 Pro,
|
||||
- // Kimi, and similar models can enforce this server-side whenever the request is in thinking mode.
|
||||
+ // - DeepSeek reasoning models reached through OpenRouter or OpenCode (Zen/Go): enforced when
|
||||
+ // thinking mode is enabled on those model families.
|
||||
// We can't translate Anthropic's redacted/encrypted reasoning into DeepSeek's plaintext form, so
|
||||
// cross-provider continuations rely on a placeholder — see `convertMessages` for injection rules.
|
||||
requiresReasoningContentForToolCalls:
|
||||
- isKimiModel || ((isOpenRouter || isOpenCode) && Boolean(model.reasoning)),
|
||||
+ isKimiModel || (isDeepSeekModel && (isOpenRouter || isOpenCode) && Boolean(model.reasoning)),
|
||||
requiresAssistantContentForToolCalls: isKimiModel,
|
||||
openRouterRouting: undefined,
|
||||
vercelGatewayRouting: undefined,
|
||||
diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts
|
||||
index e25aeffb3..89a997a0f 100644
|
||||
--- a/packages/ai/src/providers/openai-completions.ts
|
||||
+++ b/packages/ai/src/providers/openai-completions.ts
|
||||
@@ -1213,8 +1213,8 @@ export function convertMessages(
|
||||
// Inject a `reasoning_content` placeholder on assistant tool-call turns when the backend
|
||||
// rejects history without it. The compat flag captures the rule:
|
||||
// - Kimi (native or via OpenCode Zen/Go): chat completion endpoint demands the field.
|
||||
- // - Reasoning models reached through OpenRouter or OpenCode Zen/Go (e.g. DeepSeek V4 Pro):
|
||||
- // the upstream thinking-mode validator demands it on every prior assistant turn. omp
|
||||
+ // - DeepSeek reasoning models reached through OpenRouter or OpenCode Zen/Go: the upstream
|
||||
+ // thinking-mode validator demands it on every prior assistant turn. omp
|
||||
// cannot synthesize real reasoning when the conversation was warmed up by another
|
||||
// provider whose reasoning is redacted/encrypted (Anthropic) or simply absent, so we
|
||||
// emit a placeholder. Real captured reasoning, when present, is preserved earlier via
|
||||
diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts
|
||||
index 8b8cef393..c083c2151 100644
|
||||
--- a/packages/ai/test/openai-completions-compat.test.ts
|
||||
+++ b/packages/ai/test/openai-completions-compat.test.ts
|
||||
@@ -333,6 +333,29 @@ describe("kimi model detection via detectCompat", () => {
|
||||
expect(compat.requiresAssistantContentForToolCalls).toBe(false);
|
||||
});
|
||||
|
||||
+ it("does not require reasoning_content for non-DeepSeek reasoning models via opencode-go", () => {
|
||||
+ const compat = detectCompat(openCodeGoModel("glm-5", true));
|
||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
||||
+ });
|
||||
+
|
||||
+ it("does not require reasoning_content for non-DeepSeek reasoning models via opencode-zen", () => {
|
||||
+ const compat = detectCompat(openCodeZenModel("glm-5", true));
|
||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
||||
+ });
|
||||
+
|
||||
+ it("does not require reasoning_content when custom openai provider targets opencode zen baseUrl with non-DeepSeek model", () => {
|
||||
+ const model: Model<"openai-completions"> = {
|
||||
+ ...getBundledModel("openai", "gpt-4o-mini"),
|
||||
+ api: "openai-completions",
|
||||
+ provider: "openai",
|
||||
+ baseUrl: "https://opencode.ai/zen/v1",
|
||||
+ id: "glm-5",
|
||||
+ reasoning: true,
|
||||
+ };
|
||||
+ const compat = detectCompat(model);
|
||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
||||
+ });
|
||||
+
|
||||
it("requires reasoning_content when custom openai provider targets opencode zen baseUrl", () => {
|
||||
const model: Model<"openai-completions"> = {
|
||||
...getBundledModel("openai", "gpt-4o-mini"),
|
||||
@@ -453,6 +476,19 @@ describe("kimi model detection via detectCompat", () => {
|
||||
expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
||||
});
|
||||
|
||||
+ it("does not require reasoning_content for non-DeepSeek reasoning models via openrouter", () => {
|
||||
+ const model: Model<"openai-completions"> = {
|
||||
+ ...getBundledModel("openai", "gpt-4o-mini"),
|
||||
+ api: "openai-completions",
|
||||
+ provider: "openrouter",
|
||||
+ baseUrl: "https://openrouter.ai/api/v1",
|
||||
+ id: "openai/gpt-4.1-mini",
|
||||
+ reasoning: true,
|
||||
+ };
|
||||
+ const compat = detectCompat(model);
|
||||
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
||||
+ });
|
||||
+
|
||||
it("does not require reasoning_content when opencode-zen model is not reasoning-capable", () => {
|
||||
const compat = detectCompat(openCodeZenModel("some-other-model", false));
|
||||
expect(compat.requiresReasoningContentForToolCalls).toBe(false);
|
||||
|
||||
From 53a03286cf658bb4aeab67dad3246b7ba80cf244 Mon Sep 17 00:00:00 2001
|
||||
From: sonhyrd <son.hong.do@hyrd.ai>
|
||||
Date: Mon, 27 Apr 2026 00:52:22 +0700
|
||||
Subject: [PATCH 5/5] fix(ai/providers): set content when reasoning placeholder
|
||||
is injected
|
||||
|
||||
---
|
||||
packages/ai/src/providers/openai-completions.ts | 3 ++-
|
||||
packages/ai/test/openai-completions-compat.test.ts | 2 ++
|
||||
2 files changed, 4 insertions(+), 1 deletion(-)
|
||||
|
||||
diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts
|
||||
index 89a997a0f..b490e254e 100644
|
||||
--- a/packages/ai/src/providers/openai-completions.ts
|
||||
+++ b/packages/ai/src/providers/openai-completions.ts
|
||||
@@ -1206,7 +1206,7 @@ export function convertMessages(
|
||||
}
|
||||
|
||||
const toolCalls = msg.content.filter(b => b.type === "toolCall") as ToolCall[];
|
||||
- const hasReasoningField =
|
||||
+ let hasReasoningField =
|
||||
(assistantMsg as any).reasoning_content !== undefined ||
|
||||
(assistantMsg as any).reasoning !== undefined ||
|
||||
(assistantMsg as any).reasoning_text !== undefined;
|
||||
@@ -1227,6 +1227,7 @@ export function convertMessages(
|
||||
if (toolCalls.length > 0 && stubsReasoningContent && !hasReasoningField) {
|
||||
const reasoningField = compat.reasoningContentField ?? "reasoning_content";
|
||||
(assistantMsg as any)[reasoningField] = ".";
|
||||
+ hasReasoningField = true;
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
assistantMsg.tool_calls = toolCalls.map((tc, toolCallIndex) => {
|
||||
diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts
|
||||
index c083c2151..8efae899a 100644
|
||||
--- a/packages/ai/test/openai-completions-compat.test.ts
|
||||
+++ b/packages/ai/test/openai-completions-compat.test.ts
|
||||
@@ -393,6 +393,7 @@ describe("kimi model detection via detectCompat", () => {
|
||||
const assistant = messages.find(m => m.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
||||
+ expect(Reflect.get(assistant as object, "content")).toBe("");
|
||||
});
|
||||
|
||||
it("injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via opencode-zen", () => {
|
||||
@@ -419,6 +420,7 @@ describe("kimi model detection via detectCompat", () => {
|
||||
const assistant = messages.find(m => m.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
||||
+ expect(Reflect.get(assistant as object, "content")).toBe("");
|
||||
});
|
||||
|
||||
it("injects reasoning_content placeholder when assistant with tool calls has no reasoning field", () => {
|
||||
54
scripts/bootstrap-desktop-tpm.sh
Executable file
54
scripts/bootstrap-desktop-tpm.sh
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bootstrap the age-plugin-tpm identity for a desktop host (mreow / yarn).
|
||||
#
|
||||
# Produces a TPM-sealed age identity at /var/lib/agenix/tpm-identity and
|
||||
# prints the recipient string to add to secrets/secrets.nix.
|
||||
#
|
||||
# Usage:
|
||||
# doas scripts/bootstrap-desktop-tpm.sh
|
||||
#
|
||||
# After running:
|
||||
# 1. Append the printed recipient to the `tpm` list in secrets/secrets.nix.
|
||||
# 2. Re-encrypt: nix-shell -p age-plugin-tpm rage --run \
|
||||
# 'agenix -r -i ~/.ssh/id_ed25519'
|
||||
# 3. Commit + ./deploy.sh switch.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "this script must run as root (access to /dev/tpmrm0 + /var/lib/agenix)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
host=$(hostname -s)
|
||||
id_file=/var/lib/agenix/tpm-identity
|
||||
|
||||
install -d -m 0700 -o root -g root /var/lib/agenix
|
||||
|
||||
if [[ -f "$id_file" ]]; then
|
||||
echo "existing identity found at $id_file — preserving"
|
||||
else
|
||||
echo "generating TPM-sealed age identity..."
|
||||
nix-shell -p age-plugin-tpm --run "age-plugin-tpm --generate -o $id_file"
|
||||
chmod 0400 "$id_file"
|
||||
chown root:root "$id_file"
|
||||
fi
|
||||
|
||||
# Read the recipient directly from the identity file header — no TPM
|
||||
# round-trip needed, no nix run, no set -e hazards.
|
||||
recipient=$(grep '^# Recipient:' "$id_file" | awk '{print $3}')
|
||||
if [[ -z "$recipient" ]]; then
|
||||
echo "failed to read recipient from $id_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
recipient for $host:
|
||||
"$recipient $host"
|
||||
|
||||
next steps (run on a workstation with git-crypt unlocked):
|
||||
1. edit secrets/secrets.nix and add the line above to the \`tpm\` list.
|
||||
2. re-encrypt: nix-shell -p age-plugin-tpm rage --run 'agenix -r -i ~/.ssh/id_ed25519'
|
||||
3. git commit + ./deploy.sh switch
|
||||
EOF
|
||||
Binary file not shown.
BIN
secrets/desktop/nix-cache-netrc.age
Normal file
BIN
secrets/desktop/nix-cache-netrc.age
Normal file
Binary file not shown.
Binary file not shown.
BIN
secrets/desktop/password-hash.age
Normal file
BIN
secrets/desktop/password-hash.age
Normal file
Binary file not shown.
Binary file not shown.
BIN
secrets/desktop/secureboot.tar.age
Normal file
BIN
secrets/desktop/secureboot.tar.age
Normal file
Binary file not shown.
Binary file not shown.
BIN
secrets/secrets.nix
Normal file
BIN
secrets/secrets.nix
Normal file
Binary file not shown.
@@ -687,6 +687,188 @@ let
|
||||
overrides = [ ];
|
||||
};
|
||||
}
|
||||
|
||||
# -- Row 6: Minecraft --
|
||||
{
|
||||
id = 14;
|
||||
type = "stat";
|
||||
title = "Minecraft Players";
|
||||
gridPos = {
|
||||
h = 8;
|
||||
w = 6;
|
||||
x = 0;
|
||||
y = 40;
|
||||
};
|
||||
datasource = promDs;
|
||||
targets = [
|
||||
{
|
||||
datasource = promDs;
|
||||
expr = "sum(minecraft_status_players_online_count) or vector(0)";
|
||||
refId = "A";
|
||||
}
|
||||
];
|
||||
fieldConfig = {
|
||||
defaults = {
|
||||
thresholds = {
|
||||
mode = "absolute";
|
||||
steps = [
|
||||
{
|
||||
color = "green";
|
||||
value = null;
|
||||
}
|
||||
{
|
||||
color = "yellow";
|
||||
value = 3;
|
||||
}
|
||||
{
|
||||
color = "red";
|
||||
value = 6;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
overrides = [ ];
|
||||
};
|
||||
options = {
|
||||
reduceOptions = {
|
||||
calcs = [ "lastNotNull" ];
|
||||
fields = "";
|
||||
values = false;
|
||||
};
|
||||
colorMode = "value";
|
||||
graphMode = "area";
|
||||
};
|
||||
}
|
||||
{
|
||||
id = 15;
|
||||
type = "stat";
|
||||
title = "Minecraft Server";
|
||||
gridPos = {
|
||||
h = 8;
|
||||
w = 6;
|
||||
x = 6;
|
||||
y = 40;
|
||||
};
|
||||
datasource = promDs;
|
||||
targets = [
|
||||
{
|
||||
datasource = promDs;
|
||||
expr = "max(minecraft_status_healthy) or vector(0)";
|
||||
refId = "A";
|
||||
}
|
||||
];
|
||||
fieldConfig = {
|
||||
defaults = {
|
||||
mappings = [
|
||||
{
|
||||
type = "value";
|
||||
options = {
|
||||
"0" = {
|
||||
text = "Offline";
|
||||
color = "red";
|
||||
index = 0;
|
||||
};
|
||||
"1" = {
|
||||
text = "Online";
|
||||
color = "green";
|
||||
index = 1;
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
thresholds = {
|
||||
mode = "absolute";
|
||||
steps = [
|
||||
{
|
||||
color = "red";
|
||||
value = null;
|
||||
}
|
||||
{
|
||||
color = "green";
|
||||
value = 1;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
overrides = [ ];
|
||||
};
|
||||
options = {
|
||||
reduceOptions = {
|
||||
calcs = [ "lastNotNull" ];
|
||||
fields = "";
|
||||
values = false;
|
||||
};
|
||||
colorMode = "value";
|
||||
graphMode = "none";
|
||||
};
|
||||
}
|
||||
{
|
||||
id = 16;
|
||||
type = "timeseries";
|
||||
title = "Minecraft Player Activity";
|
||||
gridPos = {
|
||||
h = 8;
|
||||
w = 12;
|
||||
x = 12;
|
||||
y = 40;
|
||||
};
|
||||
datasource = promDs;
|
||||
targets = [
|
||||
{
|
||||
datasource = promDs;
|
||||
expr = "sum(minecraft_status_players_online_count) or vector(0)";
|
||||
legendFormat = "Online players";
|
||||
refId = "A";
|
||||
}
|
||||
{
|
||||
datasource = promDs;
|
||||
expr = "max(minecraft_status_players_max_count) or vector(0)";
|
||||
legendFormat = "Max players";
|
||||
refId = "B";
|
||||
}
|
||||
];
|
||||
fieldConfig = {
|
||||
defaults = {
|
||||
unit = "short";
|
||||
min = 0;
|
||||
decimals = 0;
|
||||
color.mode = "palette-classic";
|
||||
custom = {
|
||||
lineWidth = 2;
|
||||
fillOpacity = 15;
|
||||
spanNulls = true;
|
||||
};
|
||||
};
|
||||
overrides = [
|
||||
{
|
||||
matcher = {
|
||||
id = "byFrameRefID";
|
||||
options = "B";
|
||||
};
|
||||
properties = [
|
||||
{
|
||||
id = "custom.lineStyle";
|
||||
value = {
|
||||
fill = "dash";
|
||||
dash = [
|
||||
8
|
||||
4
|
||||
];
|
||||
};
|
||||
}
|
||||
{
|
||||
id = "custom.fillOpacity";
|
||||
value = 0;
|
||||
}
|
||||
{
|
||||
id = "custom.lineWidth";
|
||||
value = 1;
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
in
|
||||
|
||||
@@ -10,6 +10,9 @@ let
|
||||
jellyfinExporterPort = service_configs.ports.private.jellyfin_exporter.port;
|
||||
qbitExporterPort = service_configs.ports.private.qbittorrent_exporter.port;
|
||||
igpuExporterPort = service_configs.ports.private.igpu_exporter.port;
|
||||
minecraftExporterPort = service_configs.ports.private.minecraft_exporter.port;
|
||||
minecraftServerName = service_configs.minecraft.server_name;
|
||||
minecraftServerPort = service_configs.ports.public.minecraft.port;
|
||||
in
|
||||
{
|
||||
# -- Jellyfin Prometheus Exporter --
|
||||
@@ -109,4 +112,45 @@ in
|
||||
REFRESH_PERIOD_MS = "30000";
|
||||
};
|
||||
};
|
||||
|
||||
# -- Minecraft Prometheus Exporter --
|
||||
# itzg/mc-monitor queries the local server via SLP on each scrape and exposes
|
||||
# minecraft_status_{healthy,response_time_seconds,players_online_count,players_max_count}.
|
||||
# mc-monitor binds to 0.0.0.0 (no listen-address flag); the firewall keeps
|
||||
# 9567 internal and IPAddressAllow pins the socket to loopback as defense-in-depth.
|
||||
systemd.services.minecraft-exporter =
|
||||
lib.mkIf (config.services.grafana.enable && config.services.minecraft-servers.enable)
|
||||
{
|
||||
description = "Prometheus exporter for Minecraft (mc-monitor SLP)";
|
||||
after = [
|
||||
"network.target"
|
||||
"minecraft-server-${minecraftServerName}.service"
|
||||
];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
ExecStart = "${lib.getExe pkgs.mc-monitor} export-for-prometheus";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
DynamicUser = true;
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
MemoryDenyWriteExecute = true;
|
||||
RestrictAddressFamilies = [
|
||||
"AF_INET"
|
||||
"AF_INET6"
|
||||
];
|
||||
IPAddressAllow = [
|
||||
"127.0.0.0/8"
|
||||
"::1/128"
|
||||
];
|
||||
IPAddressDeny = "any";
|
||||
};
|
||||
environment = {
|
||||
EXPORT_SERVERS = "127.0.0.1:${toString minecraftServerPort}";
|
||||
EXPORT_PORT = toString minecraftExporterPort;
|
||||
TIMEOUT = "5s";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,6 +95,12 @@ in
|
||||
{ targets = [ "127.0.0.1:${toString service_configs.ports.private.igpu_exporter.port}" ]; }
|
||||
];
|
||||
}
|
||||
{
|
||||
job_name = "minecraft";
|
||||
static_configs = [
|
||||
{ targets = [ "127.0.0.1:${toString service_configs.ports.private.minecraft_exporter.port}" ]; }
|
||||
];
|
||||
}
|
||||
{
|
||||
job_name = "zfs";
|
||||
static_configs = [
|
||||
|
||||
@@ -38,6 +38,7 @@ class JellyfinQBittorrentMonitor:
|
||||
stream_bitrate_headroom=1.1,
|
||||
webhook_port=0,
|
||||
webhook_bind="127.0.0.1",
|
||||
gateway_ip=None,
|
||||
):
|
||||
self.jellyfin_url = jellyfin_url
|
||||
self.qbittorrent_url = qbittorrent_url
|
||||
@@ -77,6 +78,15 @@ class JellyfinQBittorrentMonitor:
|
||||
ipaddress.ip_network("fe80::/10"), # IPv6 link-local
|
||||
]
|
||||
|
||||
# Hairpin marker. When a LAN client reaches Jellyfin via the public
|
||||
# hostname, the router NAT-loopbacks the packet and SNATs the source
|
||||
# to itself — the session arrives looking local but still costs WAN
|
||||
# bandwidth. Sessions whose source equals the gateway must therefore
|
||||
# NOT be skipped. None disables the check (pre-hairpin-aware behavior).
|
||||
if gateway_ip is None:
|
||||
gateway_ip = self._discover_default_gateway()
|
||||
self.gateway_ip = gateway_ip
|
||||
|
||||
def is_local_ip(self, ip_address: str) -> bool:
|
||||
"""Check if an IP address is from a local network"""
|
||||
try:
|
||||
@@ -86,6 +96,39 @@ class JellyfinQBittorrentMonitor:
|
||||
logger.warning(f"Invalid IP address format: {ip_address}")
|
||||
return True # Treat invalid IPs as local for safety
|
||||
|
||||
def _discover_default_gateway(self) -> str | None:
|
||||
"""Read the IPv4 default gateway from /proc/net/route, or None."""
|
||||
try:
|
||||
with open("/proc/net/route") as f:
|
||||
next(f) # skip header
|
||||
for line in f:
|
||||
fields = line.split()
|
||||
if len(fields) < 8 or fields[1] != "00000000":
|
||||
continue
|
||||
flags = int(fields[3], 16)
|
||||
if not flags & 0x2: # RTF_GATEWAY
|
||||
continue
|
||||
gw_bytes = bytes.fromhex(fields[2])[::-1] # little-endian
|
||||
if len(gw_bytes) != 4:
|
||||
continue
|
||||
return ".".join(str(b) for b in gw_bytes)
|
||||
except (OSError, ValueError) as e:
|
||||
logger.warning(f"Could not autodetect default gateway: {e}")
|
||||
return None
|
||||
|
||||
def is_skippable(self, ip_address: str) -> bool:
|
||||
"""True iff this source IP can be ignored when deciding to throttle.
|
||||
|
||||
Truly LAN-direct sessions are skippable (no WAN cost). Hairpin-NAT'd
|
||||
LAN sessions arrive with the LAN gateway as their source — those still
|
||||
cost WAN bandwidth and must NOT be skipped.
|
||||
"""
|
||||
if not self.is_local_ip(ip_address):
|
||||
return False
|
||||
if self.gateway_ip and ip_address == self.gateway_ip:
|
||||
return False
|
||||
return True
|
||||
|
||||
def signal_handler(self, signum, frame):
|
||||
logger.info("Received shutdown signal, cleaning up...")
|
||||
self.running = False
|
||||
@@ -164,7 +207,7 @@ class JellyfinQBittorrentMonitor:
|
||||
if (
|
||||
"NowPlayingItem" in session
|
||||
and not session.get("PlayState", {}).get("IsPaused", True)
|
||||
and not self.is_local_ip(session.get("RemoteEndPoint", ""))
|
||||
and not self.is_skippable(session.get("RemoteEndPoint", ""))
|
||||
):
|
||||
item = session["NowPlayingItem"]
|
||||
item_type = item.get("Type", "").lower()
|
||||
@@ -354,6 +397,9 @@ class JellyfinQBittorrentMonitor:
|
||||
logger.info(f"Default stream bitrate: {self.default_stream_bitrate} bps")
|
||||
logger.info(f"Minimum torrent speed: {self.min_torrent_speed} KB/s")
|
||||
logger.info(f"Stream bitrate headroom: {self.stream_bitrate_headroom}x")
|
||||
logger.info(
|
||||
f"LAN gateway (hairpin marker): {self.gateway_ip or 'none / autodetect failed'}"
|
||||
)
|
||||
if self.webhook_port:
|
||||
logger.info(f"Webhook receiver: {self.webhook_bind}:{self.webhook_port}")
|
||||
|
||||
@@ -484,6 +530,7 @@ if __name__ == "__main__":
|
||||
stream_bitrate_headroom = float(os.getenv("STREAM_BITRATE_HEADROOM", "1.1"))
|
||||
webhook_port = int(os.getenv("WEBHOOK_PORT", "0"))
|
||||
webhook_bind = os.getenv("WEBHOOK_BIND", "127.0.0.1")
|
||||
gateway_ip = os.getenv("LAN_GATEWAY_IP") or None
|
||||
|
||||
monitor = JellyfinQBittorrentMonitor(
|
||||
jellyfin_url=jellyfin_url,
|
||||
@@ -499,6 +546,7 @@ if __name__ == "__main__":
|
||||
stream_bitrate_headroom=stream_bitrate_headroom,
|
||||
webhook_port=webhook_port,
|
||||
webhook_bind=webhook_bind,
|
||||
gateway_ip=gateway_ip,
|
||||
)
|
||||
|
||||
monitor.run()
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
|
||||
users.users.${username}.openssh.authorizedKeys.keys = [
|
||||
site_config.ssh_keys.laptop
|
||||
site_config.ssh_keys.desktop
|
||||
];
|
||||
|
||||
# used for deploying configs to server
|
||||
|
||||
@@ -57,7 +57,6 @@ rec {
|
||||
# hosts/yarn/default.nix. Rotating a key means changing it here, nowhere else.
|
||||
ssh_keys = {
|
||||
laptop = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO4jL6gYOunUlUtPvGdML0cpbKSsPNqQ1jit4E7U1RyH";
|
||||
desktop = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBJjT5QZ3zRDb+V6Em20EYpSEgPW5e/U+06uQGJdraxi";
|
||||
ci_deploy = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC5ZYN6idL/w/mUIfPOH1i+Q/SQXuzAMQUEuWpipx1Pc ci-deploy@muffin";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -428,6 +428,73 @@ pkgs.testers.runNixOSTest {
|
||||
local_playback["PositionTicks"] = 50000000
|
||||
server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing/Stopped' -d '{json.dumps(local_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{local_auth}, Token={local_token}'")
|
||||
|
||||
with subtest("Hairpin'd LAN session (source IP = configured gateway) DOES throttle"):
|
||||
# Simulates a LAN client reaching Jellyfin via the public hostname:
|
||||
# the router SNATs the source to itself, so Jellyfin sees the gateway
|
||||
# IP and IsInLocalNetwork=True even though WAN bandwidth is in play.
|
||||
# We use 127.0.0.1 as the "gateway" in this VM because the localhost
|
||||
# curl below produces source 127.0.0.1 from Jellyfin's view.
|
||||
server.succeed("systemctl stop monitor-test || true")
|
||||
time.sleep(1)
|
||||
server.succeed(f"""
|
||||
systemd-run --unit=monitor-hairpin \
|
||||
--setenv=JELLYFIN_URL=http://localhost:8096 \
|
||||
--setenv=JELLYFIN_API_KEY={token} \
|
||||
--setenv=QBITTORRENT_URL=http://localhost:8080 \
|
||||
--setenv=CHECK_INTERVAL=1 \
|
||||
--setenv=STREAMING_START_DELAY=1 \
|
||||
--setenv=STREAMING_STOP_DELAY=1 \
|
||||
--setenv=TOTAL_BANDWIDTH_BUDGET=50000000 \
|
||||
--setenv=SERVICE_BUFFER=2000000 \
|
||||
--setenv=DEFAULT_STREAM_BITRATE=10000000 \
|
||||
--setenv=MIN_TORRENT_SPEED=100 \
|
||||
--setenv=LAN_GATEWAY_IP=127.0.0.1 \
|
||||
{python} {monitor}
|
||||
""")
|
||||
time.sleep(2)
|
||||
assert not is_throttled(), "Should start unthrottled (no streams yet)"
|
||||
|
||||
hairpin_auth = 'MediaBrowser Client="Hairpin Client", DeviceId="hairpin-2222", Device="HairpinDevice", Version="1.0"'
|
||||
hairpin_auth_result = json.loads(server.succeed(
|
||||
f"curl -sf -X POST 'http://localhost:8096/Users/AuthenticateByName' -d '@${jfLib.payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{hairpin_auth}'"
|
||||
))
|
||||
hairpin_token = hairpin_auth_result["AccessToken"]
|
||||
|
||||
hairpin_playback = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-hairpin",
|
||||
"CanSeek": True,
|
||||
"IsPaused": False,
|
||||
}
|
||||
server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing' -d '{json.dumps(hairpin_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{hairpin_auth}, Token={hairpin_token}'")
|
||||
time.sleep(3)
|
||||
assert is_throttled(), "Hairpin'd session (source=gateway) should throttle even though source is RFC1918"
|
||||
|
||||
# Cleanup: stop the playback and the override-monitor, restore the normal one.
|
||||
hairpin_playback["PositionTicks"] = 50000000
|
||||
server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing/Stopped' -d '{json.dumps(hairpin_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{hairpin_auth}, Token={hairpin_token}'")
|
||||
time.sleep(2)
|
||||
assert not is_throttled(), "Should unthrottle after hairpin'd playback stops"
|
||||
|
||||
server.succeed("systemctl stop monitor-hairpin || true")
|
||||
time.sleep(1)
|
||||
server.succeed(f"""
|
||||
systemd-run --unit=monitor-test \
|
||||
--setenv=JELLYFIN_URL=http://localhost:8096 \
|
||||
--setenv=JELLYFIN_API_KEY={token} \
|
||||
--setenv=QBITTORRENT_URL=http://localhost:8080 \
|
||||
--setenv=CHECK_INTERVAL=1 \
|
||||
--setenv=STREAMING_START_DELAY=1 \
|
||||
--setenv=STREAMING_STOP_DELAY=1 \
|
||||
--setenv=TOTAL_BANDWIDTH_BUDGET=50000000 \
|
||||
--setenv=SERVICE_BUFFER=2000000 \
|
||||
--setenv=DEFAULT_STREAM_BITRATE=10000000 \
|
||||
--setenv=MIN_TORRENT_SPEED=100 \
|
||||
{python} {monitor}
|
||||
""")
|
||||
time.sleep(2)
|
||||
|
||||
# === WEBHOOK TESTS ===
|
||||
#
|
||||
# Configure the Jellyfin Webhook plugin to target the monitor, then verify
|
||||
@@ -589,7 +656,7 @@ pkgs.testers.runNixOSTest {
|
||||
server.succeed("systemctl restart jellyfin.service")
|
||||
server.wait_for_unit("jellyfin.service")
|
||||
server.wait_for_open_port(8096)
|
||||
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60)
|
||||
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=180)
|
||||
|
||||
# During Jellyfin restart, monitor can't reach Jellyfin
|
||||
# After restart, sessions are cleared - monitor should eventually unthrottle
|
||||
@@ -645,7 +712,7 @@ pkgs.testers.runNixOSTest {
|
||||
server.succeed("systemctl start jellyfin.service")
|
||||
server.wait_for_unit("jellyfin.service")
|
||||
server.wait_for_open_port(8096)
|
||||
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60)
|
||||
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=180)
|
||||
|
||||
# After Jellyfin comes back, sessions are gone - should unthrottle
|
||||
time.sleep(3)
|
||||
|
||||
Reference in New Issue
Block a user