Compare commits
40 Commits
fdd5c5fba0
...
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
|
|||
|
0a8b863e4b
|
|||
|
0901f5edf0
|
|||
|
a1924849d6
|
36
AGENTS.md
36
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.
|
||||
|
||||
@@ -191,11 +196,26 @@ lib.mkIf config.services.<service>.enable {
|
||||
|
||||
Existing registrations live in `services/jellyfin/jellyfin-deploy-guard.nix` (REST `/Sessions` via curl+jq) and `services/minecraft-deploy-guard.nix` (Server List Ping via `mcstatus`). Prefer soft-fail on unreachable — a service that's already down has no users to disrupt.
|
||||
|
||||
## Deploy finalize (muffin)
|
||||
|
||||
`modules/server-deploy-finalize.nix` solves the self-deploy problem: the gitea-actions runner driving CI deploys lives on muffin itself, so a direct `switch-to-configuration switch` restarts the runner mid-activation, killing the SSH session, the CI job, and deploy-rs's magic-rollback handshake. The failure mode is visible as "deploy appears to fail even though the new config landed" (or worse, a rollback storm).
|
||||
|
||||
The fix is a two-phase activation wired into `deploy.nodes.muffin.profiles.system.path` in `flake.nix`:
|
||||
|
||||
1. `switch-to-configuration boot` — bootloader-only, no service restarts. The runner, SSH session, and magic-rollback survive.
|
||||
2. `deploy-finalize` — schedules a detached `systemd-run --on-active=N` transient unit (default 60s). The unit is owned by pid1, so it survives the eventual runner restart. If `/run/booted-system/{kernel,initrd,kernel-modules}` differs from the new profile's, the unit runs `systemctl reboot`; otherwise it runs `switch-to-configuration switch`.
|
||||
|
||||
That is, reboot is dynamically gated on kernel/initrd/kernel-modules change. The 60s delay is tuned so the CI job (or manual `./deploy.sh muffin`) has time to emit status/notification steps before the runner is recycled.
|
||||
|
||||
Back-to-back deploys supersede each other: each invocation cancels any still-pending `deploy-finalize-*.timer` before scheduling its own. `deploy-finalize --dry-run` prints the decision without scheduling anything — useful when debugging.
|
||||
|
||||
Prior art: the 3-path `{kernel,initrd,kernel-modules}` diff is lifted from nixpkgs's `system.autoUpgrade` module (the `allowReboot = true` branch) and was packaged the same way in [obsidiansystems/obelisk#957](https://github.com/obsidiansystems/obelisk/pull/957). nixpkgs#185030 tracks lifting it into `switch-to-configuration` proper but has been stale since 2025-07. The self-deploy `systemd-run` detachment is the proposed fix from [deploy-rs#153](https://github.com/serokell/deploy-rs/issues/153), also unmerged upstream.
|
||||
|
||||
## Technical details
|
||||
|
||||
- **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": {
|
||||
|
||||
23
flake.nix
23
flake.nix
@@ -376,6 +376,7 @@
|
||||
nixosConfigurations = {
|
||||
mreow = mkDesktopHost "mreow";
|
||||
yarn = mkDesktopHost "yarn";
|
||||
patiodeck = mkDesktopHost "patiodeck";
|
||||
muffin = muffinHost;
|
||||
};
|
||||
|
||||
@@ -415,7 +416,27 @@
|
||||
# want to avoid when the deploy is supposed to be a no-op blocked by
|
||||
# the guard. Blocking before the deploy-rs invocation is the only
|
||||
# clean way to leave the running system untouched.
|
||||
path = deploy-rs.lib.${system}.activate.nixos self.nixosConfigurations.muffin;
|
||||
#
|
||||
# Activation uses `switch-to-configuration boot` + a detached finalize
|
||||
# (modules/server-deploy-finalize.nix) rather than the default
|
||||
# `switch`. The gitea-actions runner driving CI deploys lives on
|
||||
# muffin itself; a direct `switch` restarts gitea-runner-muffin mid-
|
||||
# activation, killing the SSH session, the CI job, and deploy-rs's
|
||||
# magic-rollback handshake. `boot` only touches the bootloader — no
|
||||
# service restarts — and deploy-finalize schedules a pid1-owned
|
||||
# transient unit that runs the real `switch` (or `systemctl reboot`
|
||||
# when kernel/initrd/kernel-modules changed) ~60s later, surviving
|
||||
# runner restart because it's decoupled from the SSH session.
|
||||
path =
|
||||
deploy-rs.lib.${system}.activate.custom self.nixosConfigurations.muffin.config.system.build.toplevel
|
||||
''
|
||||
# matches activate.nixos's workaround for NixOS/nixpkgs#73404
|
||||
cd /tmp
|
||||
|
||||
$PROFILE/bin/switch-to-configuration boot
|
||||
|
||||
${nixpkgs-stable.lib.getExe self.nixosConfigurations.muffin.config.system.build.deployFinalize}
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
'';
|
||||
}))
|
||||
];
|
||||
|
||||
@@ -69,6 +74,9 @@ in
|
||||
## Nix
|
||||
For using `nix build` append `-L` to get better visibility into the logs.
|
||||
If you get an error that a file can't be found, always try to `git add` the file before trying other troubleshooting steps.
|
||||
|
||||
## Implementation
|
||||
When sketching out an implementation of something, always look for tools that already exist in the space first before implementing something custom. This is also the case when it comes to submodules and sections of code, I don't want you to implement things in-house when it isn't needed.
|
||||
'';
|
||||
|
||||
home.file.".omp/agent/skills/android-ui/SKILL.md".text = ''
|
||||
|
||||
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,13 +19,14 @@
|
||||
../../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
|
||||
../../modules/ntfy-alerts.nix
|
||||
../../modules/server-power.nix
|
||||
../../modules/server-deploy-guard.nix
|
||||
../../modules/server-deploy-finalize.nix
|
||||
|
||||
../../services/postgresql.nix
|
||||
../../services/jellyfin
|
||||
@@ -99,6 +100,13 @@
|
||||
|
||||
services.deployGuard.enable = true;
|
||||
|
||||
# Detached deploy finalize: see modules/server-deploy-finalize.nix. deploy-rs
|
||||
# activates in `boot` mode and invokes deploy-finalize to schedule the real
|
||||
# `switch` (or reboot, when kernel/initrd/kernel-modules changed) 60s later
|
||||
# as a pid1-owned transient unit. Prevents the self-hosted gitea runner from
|
||||
# being restarted mid-CI-deploy.
|
||||
services.deployFinalize.enable = true;
|
||||
|
||||
# Disable serial getty on ttyS0 to prevent dmesg warnings
|
||||
systemd.services."serial-getty@ttyS0".enable = false;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
'';
|
||||
}
|
||||
187
modules/server-deploy-finalize.nix
Normal file
187
modules/server-deploy-finalize.nix
Normal file
@@ -0,0 +1,187 @@
|
||||
# Deferred deploy finalize for deploy-rs-driven hosts.
|
||||
#
|
||||
# When deploy-rs activates via `switch-to-configuration switch` and the gitea-
|
||||
# actions runner driving the deploy lives on the same host, the runner unit
|
||||
# gets restarted mid-activation — its definition changes between builds. That
|
||||
# restart kills the SSH session, the CI job, and deploy-rs's magic-rollback
|
||||
# handshake, so CI reports failure even when the deploy itself completed.
|
||||
# This is deploy-rs#153, open since 2022.
|
||||
#
|
||||
# This module breaks the dependency: activation does `switch-to-configuration
|
||||
# boot` (bootloader only, no service restarts), then invokes deploy-finalize
|
||||
# which schedules a detached systemd transient unit that fires `delay` seconds
|
||||
# later with the real `switch` (or `systemctl reboot` when the kernel, initrd,
|
||||
# or kernel-modules changed since boot). The transient unit is owned by pid1,
|
||||
# so it survives the runner's eventual restart — by which time the CI job has
|
||||
# finished reporting.
|
||||
#
|
||||
# Prior art (reboot-or-switch logic, not the self-deploy detachment):
|
||||
# - nixpkgs `system.autoUpgrade` (allowReboot = true branch) is the canonical
|
||||
# source of the 3-path {initrd,kernel,kernel-modules} comparison.
|
||||
# - obsidiansystems/obelisk#957 merged the same snippet into `ob deploy` for
|
||||
# push-based remote deploys — but doesn't need detachment since its deployer
|
||||
# lives on a different machine from the target.
|
||||
# - nixpkgs#185030 tracks lifting this into switch-to-configuration proper.
|
||||
# Stale since 2025-07; until it lands, every downstream reimplements it.
|
||||
#
|
||||
# Bootstrap note: the activation snippet resolves deploy-finalize via
|
||||
# lib.getExe (store path), not via `/run/current-system/sw/bin` — `boot` mode
|
||||
# does not update `/run/current-system`, so the old binary would be resolved.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.deployFinalize;
|
||||
|
||||
finalize = pkgs.writeShellApplication {
|
||||
name = "deploy-finalize";
|
||||
runtimeInputs = [
|
||||
pkgs.coreutils
|
||||
pkgs.systemd
|
||||
];
|
||||
text = ''
|
||||
delay=${toString cfg.delay}
|
||||
profile=/nix/var/nix/profiles/system
|
||||
dry_run=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: deploy-finalize [--dry-run] [--delay N] [--profile PATH]
|
||||
|
||||
Compares /run/booted-system against PATH (default /nix/var/nix/profiles/system)
|
||||
and schedules either \`systemctl reboot\` (kernel or initrd changed) or
|
||||
\`switch-to-configuration switch\` (services only) via a detached systemd-run
|
||||
timer firing N seconds later.
|
||||
|
||||
Options:
|
||||
--dry-run Print the decision and would-be command without scheduling.
|
||||
--delay N Override the delay in seconds. Default: ${toString cfg.delay}.
|
||||
--profile PATH Override the profile path used for comparison.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) dry_run=1; shift ;;
|
||||
--delay) delay="$2"; shift 2 ;;
|
||||
--profile) profile="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*)
|
||||
echo "deploy-finalize: unknown option $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Comparing {kernel,initrd,kernel-modules} matches nixpkgs's canonical
|
||||
# `system.autoUpgrade` allowReboot logic. -e (not -f) so a dangling
|
||||
# symlink counts as missing: on a real NixOS profile all three exist,
|
||||
# but defensive: if a profile has bad symlinks we refuse to schedule
|
||||
# rather than scheduling against ghost paths.
|
||||
booted_kernel="$(readlink -e /run/booted-system/kernel 2>/dev/null || true)"
|
||||
booted_initrd="$(readlink -e /run/booted-system/initrd 2>/dev/null || true)"
|
||||
booted_modules="$(readlink -e /run/booted-system/kernel-modules 2>/dev/null || true)"
|
||||
new_kernel="$(readlink -e "$profile/kernel" 2>/dev/null || true)"
|
||||
new_initrd="$(readlink -e "$profile/initrd" 2>/dev/null || true)"
|
||||
new_modules="$(readlink -e "$profile/kernel-modules" 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$new_kernel" || -z "$new_initrd" || -z "$new_modules" ]]; then
|
||||
echo "deploy-finalize: refusing to schedule — $profile is missing kernel, initrd, or kernel-modules" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
changed=()
|
||||
if [[ -z "$booted_kernel" || -z "$booted_initrd" || -z "$booted_modules" ]]; then
|
||||
# Unreachable on a booted NixOS, but fail closed on reboot.
|
||||
changed+=("/run/booted-system incomplete")
|
||||
fi
|
||||
[[ "$booted_kernel" != "$new_kernel" ]] && changed+=("kernel")
|
||||
[[ "$booted_initrd" != "$new_initrd" ]] && changed+=("initrd")
|
||||
[[ "$booted_modules" != "$new_modules" ]] && changed+=("kernel-modules")
|
||||
|
||||
reboot_needed=0
|
||||
reason=""
|
||||
if [[ ''${#changed[@]} -gt 0 ]]; then
|
||||
reboot_needed=1
|
||||
# Join with commas so the reason reads as e.g. `kernel,initrd changed`.
|
||||
reason="$(IFS=, ; echo "''${changed[*]}") changed"
|
||||
fi
|
||||
|
||||
if [[ "$reboot_needed" == 1 ]]; then
|
||||
action=reboot
|
||||
cmd="systemctl reboot"
|
||||
else
|
||||
action=switch
|
||||
reason="services only"
|
||||
cmd="$profile/bin/switch-to-configuration switch"
|
||||
fi
|
||||
|
||||
# Nanosecond suffix so back-to-back deploys don't collide on unit names.
|
||||
unit="deploy-finalize-$(date +%s%N)"
|
||||
|
||||
printf 'deploy-finalize: booted_kernel=%s\n' "$booted_kernel"
|
||||
printf 'deploy-finalize: new_kernel=%s\n' "$new_kernel"
|
||||
printf 'deploy-finalize: booted_initrd=%s\n' "$booted_initrd"
|
||||
printf 'deploy-finalize: new_initrd=%s\n' "$new_initrd"
|
||||
printf 'deploy-finalize: booted_kernel-modules=%s\n' "$booted_modules"
|
||||
printf 'deploy-finalize: new_kernel-modules=%s\n' "$new_modules"
|
||||
printf 'deploy-finalize: action=%s reason=%s delay=%ss unit=%s\n' \
|
||||
"$action" "$reason" "$delay" "$unit"
|
||||
|
||||
if [[ "$dry_run" == 1 ]]; then
|
||||
printf 'deploy-finalize: dry-run — not scheduling\n'
|
||||
printf 'deploy-finalize: would run: %s\n' "$cmd"
|
||||
printf 'deploy-finalize: would schedule: systemd-run --collect --unit=%s --on-active=%s\n' \
|
||||
"$unit" "$delay"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Cancel any still-pending finalize timers from an earlier deploy so this
|
||||
# invocation is authoritative. Without this a stale timer could fire with
|
||||
# the old profile's action (reboot/switch) against the new profile and
|
||||
# briefly run new userspace under the old kernel.
|
||||
systemctl stop 'deploy-finalize-*.timer' 2>/dev/null || true
|
||||
|
||||
# --on-active arms a transient timer owned by pid1. systemd-run returns
|
||||
# once the timer is armed; the SSH session that called us can exit and
|
||||
# the gitea-runner can be restarted (by the switch the timer fires)
|
||||
# without affecting whether the finalize runs.
|
||||
systemd-run \
|
||||
--collect \
|
||||
--unit="$unit" \
|
||||
--description="Finalize NixOS deploy ($action after boot-mode activation)" \
|
||||
--on-active="$delay" \
|
||||
/bin/sh -c "$cmd"
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
options.services.deployFinalize = {
|
||||
enable = lib.mkEnableOption "deferred deploy finalize (switch or reboot) after boot-mode activation";
|
||||
|
||||
delay = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = 60;
|
||||
description = ''
|
||||
Seconds between the deploy-rs activation completing and the scheduled
|
||||
finalize firing. Tuned so the CI job (or manual SSH session) has time
|
||||
to complete status reporting before the runner is restarted by the
|
||||
eventual switch-to-configuration.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
environment.systemPackages = [ finalize ];
|
||||
|
||||
# Exposed for the deploy-rs activation snippet to reference by /nix/store
|
||||
# path via lib.getExe — `boot` mode does not update /run/current-system,
|
||||
# so reading through /run/current-system/sw/bin would resolve to the OLD
|
||||
# binary on a new-feature rollout or immediately after a rollback.
|
||||
system.build.deployFinalize = finalize;
|
||||
};
|
||||
}
|
||||
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.
@@ -50,14 +50,14 @@
|
||||
};
|
||||
|
||||
# Hide repo Actions/workflow details from anonymous visitors. Gitea's own
|
||||
# REQUIRE_SIGNIN_VIEW=expensive mode does not cover /{user}/{repo}/actions,
|
||||
# so we gate the path at Caddy: forward_auth probes Gitea's /api/v1/user
|
||||
# with the incoming request's Cookie/Authorization headers. A logged-in
|
||||
# session answers 200 and the original request falls through to the
|
||||
# reverse_proxy from mkCaddyReverseProxy; a 401 is turned into a redirect
|
||||
# to the login page so the browser shows the login form instead of the
|
||||
# workflow list. Workflow status badges stay public so README links keep
|
||||
# rendering.
|
||||
# REQUIRE_SIGNIN_VIEW=expensive does not cover /{user}/{repo}/actions, and
|
||||
# the API auth chain (routers/api/v1/api.go buildAuthGroup) deliberately
|
||||
# omits `auth_service.Session`, so an /api/v1/user probe would 401 even
|
||||
# for logged-in browser sessions. We gate at Caddy instead: forward_auth
|
||||
# probes a lightweight *web-UI* endpoint that does accept session cookies,
|
||||
# and Gitea's own reqSignIn middleware answers 303 to /user/login for
|
||||
# anonymous callers which we rewrite to preserve the original URL.
|
||||
# Workflow status badges stay public so README links keep rendering.
|
||||
services.caddy.virtualHosts.${service_configs.gitea.domain}.extraConfig = ''
|
||||
@repoActionsNotBadge {
|
||||
path_regexp ^/[^/]+/[^/]+/actions(/.*)?$
|
||||
@@ -65,9 +65,9 @@
|
||||
}
|
||||
handle @repoActionsNotBadge {
|
||||
forward_auth :${toString service_configs.ports.private.gitea.port} {
|
||||
uri /api/v1/user
|
||||
uri /user/stopwatches
|
||||
|
||||
@unauthorized status 401
|
||||
@unauthorized status 302 303
|
||||
handle_response @unauthorized {
|
||||
redir * /user/login?redirect_to={uri} 302
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
|
||||
196
tests/deploy-finalize.nix
Normal file
196
tests/deploy-finalize.nix
Normal file
@@ -0,0 +1,196 @@
|
||||
# Test for modules/server-deploy-finalize.nix.
|
||||
#
|
||||
# Covers the decision and scheduling logic with fabricated profile directories,
|
||||
# since spawning a second booted NixOS toplevel to diff kernels is too heavy for
|
||||
# a runNixOSTest. We rely on the shellcheck pass baked into writeShellApplication
|
||||
# to catch syntax regressions in the script itself.
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "deploy-finalize";
|
||||
|
||||
node.specialArgs = {
|
||||
inherit inputs lib;
|
||||
username = "testuser";
|
||||
};
|
||||
|
||||
nodes.machine =
|
||||
{ ... }:
|
||||
{
|
||||
imports = [
|
||||
../modules/server-deploy-finalize.nix
|
||||
];
|
||||
|
||||
services.deployFinalize = {
|
||||
enable = true;
|
||||
# Shorter default in the test to make expected-substring assertions
|
||||
# stable and reinforce that the option is wired through.
|
||||
delay = 15;
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
start_all()
|
||||
machine.wait_for_unit("multi-user.target")
|
||||
|
||||
# Test fixtures: fabricated profile trees whose kernel/initrd/kernel-modules
|
||||
# symlinks are under test control. `readlink -e` requires the targets to
|
||||
# exist, so we point at real files in /tmp rather than non-existent paths.
|
||||
machine.succeed(
|
||||
"mkdir -p /tmp/profile-same /tmp/profile-changed-kernel "
|
||||
"/tmp/profile-changed-initrd /tmp/profile-changed-modules "
|
||||
"/tmp/profile-missing /tmp/fake-targets"
|
||||
)
|
||||
machine.succeed(
|
||||
"touch /tmp/fake-targets/alt-kernel /tmp/fake-targets/alt-initrd "
|
||||
"/tmp/fake-targets/alt-modules"
|
||||
)
|
||||
|
||||
booted_kernel = machine.succeed("readlink -e /run/booted-system/kernel").strip()
|
||||
booted_initrd = machine.succeed("readlink -e /run/booted-system/initrd").strip()
|
||||
booted_modules = machine.succeed("readlink -e /run/booted-system/kernel-modules").strip()
|
||||
|
||||
def link_profile(path, kernel, initrd, modules):
|
||||
machine.succeed(f"ln -sf {kernel} {path}/kernel")
|
||||
machine.succeed(f"ln -sf {initrd} {path}/initrd")
|
||||
machine.succeed(f"ln -sf {modules} {path}/kernel-modules")
|
||||
|
||||
# profile-same: matches booted exactly → should choose `switch`.
|
||||
link_profile("/tmp/profile-same", booted_kernel, booted_initrd, booted_modules)
|
||||
machine.succeed("mkdir -p /tmp/profile-same/bin")
|
||||
machine.succeed(
|
||||
"ln -sf /run/current-system/bin/switch-to-configuration "
|
||||
"/tmp/profile-same/bin/switch-to-configuration"
|
||||
)
|
||||
|
||||
# profile-changed-kernel: kernel differs only → should choose `reboot`.
|
||||
link_profile(
|
||||
"/tmp/profile-changed-kernel",
|
||||
"/tmp/fake-targets/alt-kernel",
|
||||
booted_initrd,
|
||||
booted_modules,
|
||||
)
|
||||
|
||||
# profile-changed-initrd: initrd differs only → should choose `reboot`.
|
||||
link_profile(
|
||||
"/tmp/profile-changed-initrd",
|
||||
booted_kernel,
|
||||
"/tmp/fake-targets/alt-initrd",
|
||||
booted_modules,
|
||||
)
|
||||
|
||||
# profile-changed-modules: kernel-modules differs only → should choose `reboot`.
|
||||
# Catches the obelisk PR / nixpkgs auto-upgrade case where modules rebuild
|
||||
# against the same kernel but ABI-incompatible.
|
||||
link_profile(
|
||||
"/tmp/profile-changed-modules",
|
||||
booted_kernel,
|
||||
booted_initrd,
|
||||
"/tmp/fake-targets/alt-modules",
|
||||
)
|
||||
|
||||
# profile-missing: no kernel/initrd/kernel-modules → should fail closed.
|
||||
|
||||
with subtest("dry-run against identical profile selects switch"):
|
||||
rc, out = machine.execute(
|
||||
"deploy-finalize --dry-run --profile /tmp/profile-same 2>&1"
|
||||
)
|
||||
assert rc == 0, f"rc={rc}\n{out}"
|
||||
assert "action=switch" in out, out
|
||||
assert "services only" in out, out
|
||||
assert "dry-run — not scheduling" in out, out
|
||||
assert "would run: /tmp/profile-same/bin/switch-to-configuration switch" in out, out
|
||||
assert "would schedule: systemd-run" in out, out
|
||||
|
||||
with subtest("dry-run against changed-kernel profile selects reboot"):
|
||||
rc, out = machine.execute(
|
||||
"deploy-finalize --dry-run --profile /tmp/profile-changed-kernel 2>&1"
|
||||
)
|
||||
assert rc == 0, f"rc={rc}\n{out}"
|
||||
assert "action=reboot" in out, out
|
||||
assert "reason=kernel changed" in out, out
|
||||
assert "systemctl reboot" in out, out
|
||||
|
||||
with subtest("dry-run against changed-initrd profile selects reboot"):
|
||||
rc, out = machine.execute(
|
||||
"deploy-finalize --dry-run --profile /tmp/profile-changed-initrd 2>&1"
|
||||
)
|
||||
assert rc == 0, f"rc={rc}\n{out}"
|
||||
assert "action=reboot" in out, out
|
||||
assert "reason=initrd changed" in out, out
|
||||
|
||||
with subtest("dry-run against changed-modules profile selects reboot"):
|
||||
rc, out = machine.execute(
|
||||
"deploy-finalize --dry-run --profile /tmp/profile-changed-modules 2>&1"
|
||||
)
|
||||
assert rc == 0, f"rc={rc}\n{out}"
|
||||
assert "action=reboot" in out, out
|
||||
assert "reason=kernel-modules changed" in out, out
|
||||
|
||||
with subtest("dry-run against empty profile fails closed with rc=1"):
|
||||
rc, out = machine.execute(
|
||||
"deploy-finalize --dry-run --profile /tmp/profile-missing 2>&1"
|
||||
)
|
||||
assert rc == 1, f"rc={rc}\n{out}"
|
||||
assert "missing kernel, initrd, or kernel-modules" in out, out
|
||||
|
||||
with subtest("--delay override is reflected in output"):
|
||||
rc, out = machine.execute(
|
||||
"deploy-finalize --dry-run --delay 7 --profile /tmp/profile-same 2>&1"
|
||||
)
|
||||
assert rc == 0, f"rc={rc}\n{out}"
|
||||
assert "delay=7s" in out, out
|
||||
|
||||
with subtest("configured default delay from module option is used"):
|
||||
rc, out = machine.execute(
|
||||
"deploy-finalize --dry-run --profile /tmp/profile-same 2>&1"
|
||||
)
|
||||
assert rc == 0, f"rc={rc}\n{out}"
|
||||
# module option delay=15 in nodes.machine above.
|
||||
assert "delay=15s" in out, out
|
||||
|
||||
with subtest("unknown option rejected with rc=2"):
|
||||
rc, out = machine.execute("deploy-finalize --bogus 2>&1")
|
||||
assert rc == 2, f"rc={rc}\n{out}"
|
||||
assert "unknown option --bogus" in out, out
|
||||
|
||||
with subtest("non-dry run arms a transient systemd timer"):
|
||||
# Long delay so the timer doesn't fire during the test. We stop it
|
||||
# explicitly afterwards.
|
||||
rc, out = machine.execute(
|
||||
"deploy-finalize --delay 3600 --profile /tmp/profile-same 2>&1"
|
||||
)
|
||||
assert rc == 0, f"scheduling rc={rc}\n{out}"
|
||||
# Confirm exactly one transient timer is active.
|
||||
timers = machine.succeed(
|
||||
"systemctl list-units --type=timer --no-legend 'deploy-finalize-*.timer' "
|
||||
"--state=waiting | awk 'NF{print $1}'"
|
||||
).strip().splitlines()
|
||||
assert len(timers) == 1, f"expected exactly one pending timer, got {timers}"
|
||||
assert timers[0].startswith("deploy-finalize-"), timers
|
||||
|
||||
with subtest("back-to-back scheduling cancels the previous timer"):
|
||||
# The previous subtest left one timer armed. Schedule again; the old
|
||||
# one should be stopped before the new unit name is created.
|
||||
machine.succeed("sleep 1") # ensure a distinct unit-name timestamp
|
||||
rc, out = machine.execute(
|
||||
"deploy-finalize --delay 3600 --profile /tmp/profile-same 2>&1"
|
||||
)
|
||||
assert rc == 0, f"second-schedule rc={rc}\n{out}"
|
||||
timers = machine.succeed(
|
||||
"systemctl list-units --type=timer --no-legend 'deploy-finalize-*.timer' "
|
||||
"--state=waiting | awk 'NF{print $1}'"
|
||||
).strip().splitlines()
|
||||
assert len(timers) == 1, f"expected only the new timer, got {timers}"
|
||||
|
||||
# Clean up so the test's shutdown path is quiet.
|
||||
machine.succeed(
|
||||
"systemctl stop 'deploy-finalize-*.timer' 'deploy-finalize-*.service' "
|
||||
"2>/dev/null || true"
|
||||
)
|
||||
'';
|
||||
}
|
||||
@@ -67,10 +67,15 @@ pkgs.testers.runNixOSTest {
|
||||
# `service_configs.gitea.domain` string, which here is the full URL
|
||||
# `http://server`. Override to valid bare values so Gitea doesn't
|
||||
# get a malformed ROOT_URL like `https://http://server`.
|
||||
services.gitea.settings.server = {
|
||||
services.gitea.settings = {
|
||||
server = {
|
||||
DOMAIN = lib.mkForce "server";
|
||||
ROOT_URL = lib.mkForce "http://server/";
|
||||
};
|
||||
# Tests talk HTTP, so drop the Secure flag — otherwise curl's cookie
|
||||
# jar holds the session cookie but never sends it back.
|
||||
session.COOKIE_SECURE = lib.mkForce false;
|
||||
};
|
||||
services.caddy = {
|
||||
enable = true;
|
||||
# No DNS / ACME in the VM test network — serve plain HTTP.
|
||||
@@ -103,6 +108,8 @@ pkgs.testers.runNixOSTest {
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
import re
|
||||
|
||||
start_all()
|
||||
server.wait_for_unit("postgresql.service")
|
||||
server.wait_for_unit("gitea.service")
|
||||
@@ -110,7 +117,6 @@ pkgs.testers.runNixOSTest {
|
||||
server.wait_for_open_port(3000)
|
||||
server.wait_for_open_port(80)
|
||||
|
||||
# Admin user — used to exercise the authenticated path via Basic Auth.
|
||||
server.succeed(
|
||||
"su -l gitea -s /bin/sh -c '${pkgs.gitea}/bin/gitea admin user create "
|
||||
"--username testuser --password testpassword "
|
||||
@@ -118,13 +124,44 @@ pkgs.testers.runNixOSTest {
|
||||
"--work-path /var/lib/gitea'"
|
||||
)
|
||||
|
||||
def curl(args):
|
||||
def curl(args, cookies=None):
|
||||
cookie_args = f"-b {cookies} " if cookies else ""
|
||||
cmd = (
|
||||
"curl -4 -s -o /dev/null "
|
||||
"-w '%{http_code}|%{redirect_url}' " + args
|
||||
f"-w '%{{http_code}}|%{{redirect_url}}' {cookie_args}{args}"
|
||||
)
|
||||
return client.succeed(cmd).strip()
|
||||
|
||||
def login():
|
||||
# Gitea's POST /user/login requires a _csrf token and expects the
|
||||
# matching session cookie already set. Fetch the login form first
|
||||
# to harvest both, then submit credentials with the same cookie jar.
|
||||
client.succeed("rm -f /tmp/cookies.txt")
|
||||
html = client.succeed(
|
||||
"curl -4 -s -c /tmp/cookies.txt http://server/user/login"
|
||||
)
|
||||
match = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
|
||||
assert match, f"CSRF token not found in login form: {html[:500]!r}"
|
||||
csrf = match.group(1)
|
||||
# -L so we follow the post-login redirect; the session cookie is
|
||||
# rewritten by Gitea on successful login to carry uid.
|
||||
client.succeed(
|
||||
"curl -4 -s -L -o /dev/null "
|
||||
"-b /tmp/cookies.txt -c /tmp/cookies.txt "
|
||||
f"--data-urlencode '_csrf={csrf}' "
|
||||
"--data-urlencode 'user_name=testuser' "
|
||||
"--data-urlencode 'password=testpassword' "
|
||||
"http://server/user/login"
|
||||
)
|
||||
# Sanity-check the session by hitting the gated probe directly —
|
||||
# the post-login cookie jar MUST drive /user/stopwatches to 200.
|
||||
probe = client.succeed(
|
||||
"curl -4 -s -o /dev/null -w '%{http_code}' "
|
||||
"-b /tmp/cookies.txt http://server/user/stopwatches"
|
||||
).strip()
|
||||
assert probe == "200", f"session auth probe expected 200, got {probe!r}"
|
||||
return "/tmp/cookies.txt"
|
||||
|
||||
with subtest("Anonymous /{user}/{repo}/actions redirects to login"):
|
||||
result = curl("http://server/foo/bar/actions")
|
||||
code, _, redir = result.partition("|")
|
||||
@@ -132,6 +169,9 @@ pkgs.testers.runNixOSTest {
|
||||
assert code == "302", f"expected 302, got {code!r} (full: {result!r})"
|
||||
assert "/user/login" in redir, f"expected login redirect, got {redir!r}"
|
||||
assert "redirect_to=" in redir, f"expected redirect_to param, got {redir!r}"
|
||||
assert "/foo/bar/actions" in redir, (
|
||||
f"expected original URL preserved in redirect_to, got {redir!r}"
|
||||
)
|
||||
|
||||
with subtest("Anonymous deep /actions paths also redirect"):
|
||||
for path in ["/foo/bar/actions/", "/foo/bar/actions/runs/1", "/foo/bar/actions/workflows/build.yaml"]:
|
||||
@@ -142,8 +182,6 @@ pkgs.testers.runNixOSTest {
|
||||
assert "/user/login" in redir, f"{path}: expected login redirect, got {redir!r}"
|
||||
|
||||
with subtest("Anonymous workflow badge stays public"):
|
||||
# Repo doesn't exist → Gitea answers 404, but Caddy does NOT intercept
|
||||
# with a login redirect so README badges keep rendering.
|
||||
result = curl("http://server/foo/bar/actions/workflows/ci.yaml/badge.svg")
|
||||
code, _, redir = result.partition("|")
|
||||
print(f"anon badge -> {result!r}")
|
||||
@@ -151,17 +189,18 @@ pkgs.testers.runNixOSTest {
|
||||
f"badge path should not redirect to login, got {result!r}"
|
||||
)
|
||||
|
||||
with subtest("Authenticated /{user}/{repo}/actions does not redirect to login"):
|
||||
cookies = login()
|
||||
|
||||
with subtest("Session-authenticated /{user}/{repo}/actions reaches Gitea"):
|
||||
result = curl(
|
||||
"-u testuser:testpassword "
|
||||
"http://server/testuser/nonexistent/actions"
|
||||
"http://server/testuser/nonexistent/actions", cookies=cookies
|
||||
)
|
||||
code, _, redir = result.partition("|")
|
||||
print(f"auth /testuser/nonexistent/actions -> {result!r}")
|
||||
# Gitea will 404 the missing repo — the key assertion is that the
|
||||
# Caddy gate let the request through instead of redirecting to login.
|
||||
# Gitea returns 404 for the missing repo — the key assertion is that
|
||||
# Caddy's gate forwarded the request instead of redirecting to login.
|
||||
assert not (code == "302" and "/user/login" in redir), (
|
||||
f"authenticated actions request was intercepted by login gate: {result!r}"
|
||||
f"session-authed actions request was intercepted by login gate: {result!r}"
|
||||
)
|
||||
|
||||
with subtest("Anonymous /explore/repos is served without gating"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,6 +13,7 @@ in
|
||||
minecraftTest = handleTest ./minecraft.nix;
|
||||
jellyfinQbittorrentMonitorTest = handleTest ./jellyfin-qbittorrent-monitor.nix;
|
||||
deployGuardTest = handleTest ./deploy-guard.nix;
|
||||
deployFinalizeTest = handleTest ./deploy-finalize.nix;
|
||||
filePermsTest = handleTest ./file-perms.nix;
|
||||
|
||||
# fail2ban tests
|
||||
|
||||
Reference in New Issue
Block a user