Compare commits

...

24 Commits

Author SHA1 Message Date
90f2c27c2c DISABLE KMSCON
Some checks failed
Build and Deploy / mreow (push) Successful in 7m39s
Build and Deploy / yarn (push) Successful in 1m5s
Build and Deploy / muffin (push) Failing after 36s
THIS is what caused issues with greetd, nothing kernel related
2026-04-25 19:20:24 -04:00
450b77140b pi: apply omp patches via prePatch (bun2nix.hook overrides patchPhase)
`bun2nix.hook` (used by upstream omp's package.nix) sets

  patchPhase = bunPatchPhase

at the end of its setup-hook unless `dontUseBunPatch` is already set.
`bunPatchPhase` only runs `patchShebangs` plus a HOME mktemp; it never
iterates over `$patches`. The standard nixpkgs `patches` attribute
therefore went into the derivation env but was silently ignored at
build time, leaving the deployed omp binary unpatched.

Switch to applying the two patches via `prePatch` (which `bunPatchPhase`
does call). Verified with strings(1) over the rebuilt binary that both
patch hunks land:

  /wrong_api_format|...|invalid tool parameters/  (patch 0001)
  stubsReasoningContent ... thinkingFormat == "openrouter"  (patch 0002)
2026-04-25 19:20:08 -04:00
318373c09c pi: patch omp to require reasoning_content for OpenRouter reasoning models
DeepSeek V4 Pro (and similar reasoning models reached via OpenRouter) reject
multi-turn requests in thinking mode with:

  400 The `reasoning_content` in the thinking mode must be passed back
  to the API.

omp's existing kimi placeholder injection (`requiresReasoningContentForToolCalls`)
covered this requirement only for `thinkingFormat == "openai"`. OpenRouter
sets `thinkingFormat == "openrouter"`, so the gate never fired even though
the underlying providers behind OpenRouter (DeepSeek, Kimi, etc.) all enforce
the same invariant.

This patch:

1. Extends `requiresReasoningContentForToolCalls` detection: any
   reasoning-capable model fronted by OpenRouter now sets the flag.
2. Extends the placeholder gate in `convertMessages` to accept
   `thinkingFormat == "openrouter"` alongside `"openai"`.

Cross-provider continuations are the dominant trigger: a conversation
warmed up by Anthropic Claude (whose reasoning is redacted/encrypted on
the wire) followed by a switch to DeepSeek V4 Pro via OpenRouter. omp
cannot synthesize plaintext `reasoning_content` from Anthropic's
encrypted blocks, so the placeholder satisfies DeepSeek's validator
without fabricating a reasoning trace. Real captured reasoning, when
present, short-circuits the placeholder via `hasReasoningField` and
survives intact.

Side benefit: also closes a latent gap where Kimi-via-OpenRouter
(`thinkingFormat == "openrouter"`) had the compat flag set but the
placeholder gate silently rejected it.

Applies cleanly on top of patch 0001.
2026-04-25 19:20:05 -04:00
d55743a9e7 revert: roll back flake.lock pre-update (niri 8ed0da4 black-screens on amdgpu) 2026-04-25 16:21:28 -04:00
8ab4924948 omp: add patch that fixes deepseek 2026-04-25 15:38:39 -04:00
8bd148dc96 update
All checks were successful
Build and Deploy / mreow (push) Successful in 12m7s
Build and Deploy / yarn (push) Successful in 1m36s
Build and Deploy / muffin (push) Successful in 1m11s
2026-04-25 15:20:34 -04:00
2ab1c855ec Revert "muffin: test, move to 7.0"
All checks were successful
Build and Deploy / mreow (push) Successful in 1m45s
Build and Deploy / yarn (push) Successful in 47s
Build and Deploy / muffin (push) Successful in 1m31s
This reverts commit f67ec5bde6.
2026-04-25 10:50:00 -04:00
f67ec5bde6 muffin: test, move to 7.0
Some checks failed
Build and Deploy / mreow (push) Successful in 1h43m17s
Build and Deploy / yarn (push) Successful in 22m1s
Build and Deploy / muffin (push) Failing after 33s
2026-04-25 02:12:21 -04:00
112b85f3fb update
Some checks failed
Build and Deploy / yarn (push) Has been cancelled
Build and Deploy / muffin (push) Has been cancelled
Build and Deploy / mreow (push) Has been cancelled
2026-04-25 01:45:47 -04:00
86cf624027 Revert "muffin: test, move to 6.18"
All checks were successful
Build and Deploy / mreow (push) Successful in 50s
Build and Deploy / yarn (push) Successful in 44s
Build and Deploy / muffin (push) Successful in 1m2s
This reverts commit 1df3a303f5.
2026-04-24 14:21:40 -04:00
1df3a303f5 muffin: test, move to 6.18
All checks were successful
Build and Deploy / mreow (push) Successful in 1m15s
Build and Deploy / yarn (push) Successful in 43s
Build and Deploy / muffin (push) Successful in 1m29s
2026-04-24 14:08:26 -04:00
07a5276e40 patiodeck: fix disko partition order (fixed-size before 100%) 2026-04-24 01:47:25 -04:00
f3d21f16fb desktop-jovian: unify steam/jovian config across yarn + patiodeck
- modules/desktop-jovian.nix: shared Jovian deck-mode config (unfree
  predicate, jovian.steam, sddm, gamescope override, imports
  desktop-steam-update.nix)
- home/progs/steam-shortcuts.nix: declarative non-Steam shortcuts
  (Prism Launcher); add new entries here for all Jovian hosts
- hosts/yarn/default.nix: reduced to host-specific config only
- hosts/patiodeck/default.nix: same
2026-04-23 22:42:25 -04:00
5b2a1a652a patiodeck: add prism launcher to steam shortcuts 2026-04-23 22:34:58 -04:00
665793668d patiodeck: add steam deck LCD host 2026-04-23 22:34:47 -04:00
5ccd84c77e yarn: fix steamos-update exit code — 7 means no update, not 0
Some checks failed
Build and Deploy / mreow (push) Successful in 1m48s
Build and Deploy / yarn (push) Successful in 4m39s
Build and Deploy / muffin (push) Failing after 31s
Steam interprets exit 0 from 'steamos-update check' as 'update applied
successfully' and shows a persistent 'update available' notification.
The SteamOS convention is exit 7 = no update available.
2026-04-23 20:47:33 -04:00
7721c9d3a2 ssh: remove desktop key
Some checks failed
Build and Deploy / mreow (push) Successful in 1m58s
Build and Deploy / yarn (push) Successful in 47s
Build and Deploy / muffin (push) Failing after 30s
2026-04-23 20:23:37 -04:00
b41a547589 yarn: persist root fish history
Some checks failed
Build and Deploy / mreow (push) Successful in 46s
Build and Deploy / yarn (push) Successful in 51s
Build and Deploy / muffin (push) Failing after 28s
2026-04-23 20:17:02 -04:00
d122842995 secrets: update yarn TPM recipient after tmpfs wipe
Some checks failed
Build and Deploy / mreow (push) Successful in 2m8s
Build and Deploy / yarn (push) Successful in 48s
Build and Deploy / muffin (push) Failing after 29s
2026-04-23 19:56:54 -04:00
d65d991118 secrets: add mreow + yarn TPM recipients, re-encrypt desktop secrets
Some checks failed
Build and Deploy / mreow (push) Successful in 2m56s
Build and Deploy / yarn (push) Successful in 1m49s
Build and Deploy / muffin (push) Failing after 31s
2026-04-23 19:45:57 -04:00
06ccc337c1 secrets: proper agenix for desktop hosts via TPM identity
- modules/desktop-age-secrets.nix: agenix + rage wrapped with age-plugin-tpm,
  TPM identity primary, admin SSH key fallback for recovery/pre-bootstrap
- modules/desktop-lanzaboote-agenix.nix: extract secureboot.tar at activation
- modules/desktop-networkmanager.nix: revert to simple import of git-crypt file
- modules/server-age-secrets.nix: renamed from age-secrets.nix
- modules/desktop-common.nix: wire netrc + password-hash to agenix paths
- hosts/yarn/impermanence.nix: persist /var/lib/agenix across tmpfs wipes
- secrets/secrets.nix: recipient declarations (admin + tpm + muffin USB)
- secrets/desktop/*.age: secureboot.tar, nix-cache-netrc, password-hash
- scripts/bootstrap-desktop-tpm.sh: generate TPM identity + print recipient
2026-04-23 19:24:34 -04:00
a3f7a19cc2 update
All checks were successful
Build and Deploy / mreow (push) Successful in 3m39s
Build and Deploy / yarn (push) Successful in 1m3s
Build and Deploy / muffin (push) Successful in 2m26s
2026-04-23 14:23:17 -04:00
e019f2d4fb secrets overhaul: use tpm for laptop (need to migrate desktop later) 2026-04-23 14:22:37 -04:00
22282691e7 grafana: add minecraft server stats 2026-04-23 01:17:10 -04:00
39 changed files with 1242 additions and 259 deletions

View File

@@ -36,10 +36,11 @@ lib/
overlays.nix # jellyfin-exporter, igpu-exporter, reflac, ensureZfsMounts overlays.nix # jellyfin-exporter, igpu-exporter, reflac, ensureZfsMounts
patches/nixpkgs/ # applied to nixpkgs-stable for muffin builds patches/nixpkgs/ # applied to nixpkgs-stable for muffin builds
secrets/ 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) home/ # git-crypt: per-user HM secrets (api keys, steam id)
server/ # agenix *.age + git-crypt *.nix/*.tar/livekit_keys server/ # agenix *.age + git-crypt *.nix/*.tar/livekit_keys (muffin)
usb-secrets/ # USB-resident agenix identity key (git-crypt inside the repo) 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`. **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` | | `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` | | `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` | | `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). 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 ## 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). - **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. - **agenix** decrypts `*.age` into `/run/agenix/` at activation on every host:
- **USB identity**: `/mnt/usb-secrets/usb-secrets-key` on muffin; the age identity path is wired in `modules/usb-secrets.nix`. - **muffin**: identity is `/mnt/usb-secrets/usb-secrets-key` (ssh-ed25519 on a physical USB). Wired in `modules/usb-secrets.nix`.
- **Encrypting a new agenix secret** uses the SSH public key directly with `age -R`: - **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 ```sh
age -R <(ssh-keygen -y -f secrets/usb-secrets/usb-secrets-key) \ age -R <(ssh-keygen -y -f secrets/usb-secrets/usb-secrets-key) \
-o secrets/server/<name>.age \ -o secrets/server/<name>.age \
/path/to/plaintext /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. - **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. - Never read or commit plaintext secrets. Never log secret values.
@@ -210,7 +215,7 @@ Prior art: the 3-path `{kernel,initrd,kernel-modules}` diff is lifted from nixpk
- **Privilege escalation**: `doas` everywhere; `sudo` is disabled on every host. - **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`). - **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`). - **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. - **Disks**: disko.
- **Binary cache**: muffin runs harmonia; desktops consume it at `https://nix-cache.sigkill.computer`. - **Binary cache**: muffin runs harmonia; desktops consume it at `https://nix-cache.sigkill.computer`.

132
flake.lock generated
View File

@@ -92,16 +92,16 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1776182890, "lastModified": 1776192490,
"narHash": "sha256-+/VOe8XGq5klpU+I19D+3TcaR7o+Cwbq67KNF7mcFak=", "narHash": "sha256-5gYQNEs0/vDkHhg63aHS5g0IwG/8HNvU1Vr00cElofk=",
"owner": "Mic92", "owner": "nix-community",
"repo": "bun2nix", "repo": "bun2nix",
"rev": "648d293c51e981aec9cb07ba4268bc19e7a8c575", "rev": "6ef9f144616eedea90b364bb408ef2e1de7b310a",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "Mic92", "owner": "nix-community",
"ref": "catalog-support", "ref": "staging-2.1.0",
"repo": "bun2nix", "repo": "bun2nix",
"type": "github" "type": "github"
} }
@@ -109,11 +109,11 @@
"cachyos-kernel": { "cachyos-kernel": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1776608760, "lastModified": 1776881435,
"narHash": "sha256-ehDv8bF7k/2Kf4b8CCoSm51U/MOoFuLsRXqe5wZ57sE=", "narHash": "sha256-j8AobLjMzeKJugseObrVC4O5k7/aZCWoft2sCS3jWYs=",
"owner": "CachyOS", "owner": "CachyOS",
"repo": "linux-cachyos", "repo": "linux-cachyos",
"rev": "7e06e29005853bbaaa3b1c1067f915d6e0db728a", "rev": "1c61dfd1c3ad7762faa0db8b06c6af6c59cc4340",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -125,11 +125,11 @@
"cachyos-kernel-patches": { "cachyos-kernel-patches": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1776792814, "lastModified": 1777002108,
"narHash": "sha256-39dlIhz9KxUNQFxGpE9SvCviaOWAivdW0XJM8RnPNmg=", "narHash": "sha256-PIZCIf6xUTOUqLFbEGH0mSwu2O/YfeAmYlgdAbP4dhs=",
"owner": "CachyOS", "owner": "CachyOS",
"repo": "kernel-patches", "repo": "kernel-patches",
"rev": "d7d558d0b2e239e27b40bcf1af6fe12e323aa391", "rev": "46476ae2538db486462aef8a9de37d19030cdaf2",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -222,11 +222,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1776849698, "lastModified": 1777083982,
"narHash": "sha256-t2I9ZhBuAcaLV1Z65aVd/5BmDFGvyzLY5kpiSedx2uY=", "narHash": "sha256-O44P8qcFEv0PYQd+9vFAgCu/e9RclHIAyAmRDJ8qR5s=",
"owner": "nix-community", "owner": "nix-community",
"repo": "emacs-overlay", "repo": "emacs-overlay",
"rev": "87dff52c245cba0c5103cf89b964e508ed9bb720", "rev": "42711d50137a45b8065c3e329946e2d4525235d0",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -266,11 +266,11 @@
}, },
"locked": { "locked": {
"dir": "pkgs/firefox-addons", "dir": "pkgs/firefox-addons",
"lastModified": 1776830588, "lastModified": 1777089773,
"narHash": "sha256-1X4L6+F7DgYTUDah+PDs7IYJiQrb7MwYfateq2fBxGY=", "narHash": "sha256-ZIlNuebeWTncyl7mcV9VbceSLAaZki+UeXLPQG959xI=",
"owner": "rycee", "owner": "rycee",
"repo": "nur-expressions", "repo": "nur-expressions",
"rev": "f3db83bc13aee22474fab41fa838e50a691dfbc5", "rev": "402ba229617a12d918c2a887a4c83a9a24f9a36c",
"type": "gitlab" "type": "gitlab"
}, },
"original": { "original": {
@@ -484,11 +484,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1776891022, "lastModified": 1777086106,
"narHash": "sha256-vEe2f4NEhMvaNDpM1pla4hteaIIGQyAMKUfIBPLasr0=", "narHash": "sha256-hlNpIN18pw3xo34Lsrp6vAMUPn0aB/zFBqL0QXI1Pmk=",
"owner": "nix-community", "owner": "nix-community",
"repo": "home-manager", "repo": "home-manager",
"rev": "508daf831ab8d1b143d908239c39a7d8d39561b2", "rev": "5826802354a74af18540aef0b01bc1320f82cc17",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -564,11 +564,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1776874528, "lastModified": 1776962372,
"narHash": "sha256-X4Y2vMbVBuyUQzbZnl72BzpZMYUsWdE78JuDg2ySDxE=", "narHash": "sha256-Y2imW4kyIhupx8myNSeNCzDbEx2X+h+AmhNjWXA/7Yw=",
"owner": "Jovian-Experiments", "owner": "Jovian-Experiments",
"repo": "Jovian-NixOS", "repo": "Jovian-NixOS",
"rev": "4c8ccc482a3665fb4a3b2cadbbe7772fb7cc2629", "rev": "ee3a1184a978e311194a2d3d352c5e6aba67a4b5",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -631,11 +631,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1776862155, "lastModified": 1777066729,
"narHash": "sha256-EDvbwsGNE/N5ul+9ul1dJP3Ouf72+Ub2C0UMbDWcxyQ=", "narHash": "sha256-f+a+ikbq0VS6RQFf+A6EuVnsWYn2RR3ggRJNkzZgMto=",
"owner": "TheTom", "owner": "TheTom",
"repo": "llama-cpp-turboquant", "repo": "llama-cpp-turboquant",
"rev": "9e3fb40e8bc0f873ad4d3d8329b17dacff28e4ca", "rev": "11a241d0db78a68e0a5b99fe6f36de6683100f6a",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -657,11 +657,11 @@
"treefmt-nix": "treefmt-nix" "treefmt-nix": "treefmt-nix"
}, },
"locked": { "locked": {
"lastModified": 1776883427, "lastModified": 1777093284,
"narHash": "sha256-prHCm++hniRcoqzvWTEFyAiLKT6m+EUVCRaDLrsuEgM=", "narHash": "sha256-tBvsFPJy0/2gocc6QGYFXJF44TvJ8PC726NsdTpFJ44=",
"owner": "numtide", "owner": "numtide",
"repo": "llm-agents.nix", "repo": "llm-agents.nix",
"rev": "6fd26c9cb50d9549f3791b3d35e4f72f97677103", "rev": "6b4673fddbbe1f2656b3fa8d2a32666570aafbfa",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -704,11 +704,11 @@
"xwayland-satellite-unstable": "xwayland-satellite-unstable" "xwayland-satellite-unstable": "xwayland-satellite-unstable"
}, },
"locked": { "locked": {
"lastModified": 1776879043, "lastModified": 1777068473,
"narHash": "sha256-M9RjuowtoqQbFRdQAm2P6GjFwgHjRcnWYcB7ChSjDms=", "narHash": "sha256-atEzEdMgJMRPm/yxOiBvOSEcjSUgU20ieXYQeDfxhTo=",
"owner": "sodiboo", "owner": "sodiboo",
"repo": "niri-flake", "repo": "niri-flake",
"rev": "535ebbe038039215a5d1c6c0c67f833409a5be96", "rev": "d543523b5cd4c1f10e41ad8801c49808198b9ca5",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -737,11 +737,11 @@
"niri-unstable": { "niri-unstable": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1776853441, "lastModified": 1777045529,
"narHash": "sha256-mSxfoEs7DiDhMCBzprI/1K7UXzMISuGq0b7T06LVJXE=", "narHash": "sha256-EeAwmrvONsovL2qPwKGXF2xGhbo7MySesY3fW2pNLpM=",
"owner": "YaLTeR", "owner": "YaLTeR",
"repo": "niri", "repo": "niri",
"rev": "74d2b18603366b98ec9045ecf4a632422f472365", "rev": "9438f59e2b9d8deb6fcec5922f8aca18162b673c",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -761,11 +761,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1776796985, "lastModified": 1777054238,
"narHash": "sha256-cNFg3H09sBZl1v9ds6PDHfLCUTDJbefGMSv+WxFs+9c=", "narHash": "sha256-qaqHPZO3oQJiIZgD6sp5HKwvYAVyMtHVJiXVwPSEkx0=",
"owner": "xddxdd", "owner": "xddxdd",
"repo": "nix-cachyos-kernel", "repo": "nix-cachyos-kernel",
"rev": "ac5956bbceb022998fc1dd0001322f10ef1e6dda", "rev": "acb94409639d6d6d64bea140f939ac34938560b1",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -787,11 +787,11 @@
"systems": "systems_6" "systems": "systems_6"
}, },
"locked": { "locked": {
"lastModified": 1776851701, "lastModified": 1776938345,
"narHash": "sha256-tdtOcU2Hz/eLqAhkzUcEocgX0WpjKSbl2SkVjOZGZw0=", "narHash": "sha256-3/BFiytDNoIXMUQHcJLoxa7JK0Q1/49M0ffOR9pbzvw=",
"owner": "marienz", "owner": "marienz",
"repo": "nix-doom-emacs-unstraightened", "repo": "nix-doom-emacs-unstraightened",
"rev": "7ac65a49eec5e3f87d27396e645eddbf9dc626de", "rev": "eb25c754986165e509ad2ab8c6b6729f4a861f0c",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -846,11 +846,11 @@
"systems": "systems_7" "systems": "systems_7"
}, },
"locked": { "locked": {
"lastModified": 1776828595, "lastModified": 1777001712,
"narHash": "sha256-LkFpFnPTK6H0gwyfYezN3kEKHVxjSdPp/tBnrQRFP3E=", "narHash": "sha256-9JX9msZU1NvHzjKM24PRorP76Ge8GBy6LAkJKA21mlY=",
"owner": "Infinidoge", "owner": "Infinidoge",
"repo": "nix-minecraft", "repo": "nix-minecraft",
"rev": "28f0f2369655a5910e810c35c698dfaa9ccec692", "rev": "394d3bfd943458baf29e4798bc9b256d824a3bb9",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -861,11 +861,11 @@
}, },
"nixos-hardware": { "nixos-hardware": {
"locked": { "locked": {
"lastModified": 1776830795, "lastModified": 1776983936,
"narHash": "sha256-PAfvLwuHc1VOvsLcpk6+HDKgMEibvZjCNvbM1BJOA7o=", "narHash": "sha256-ZOQyNqSvJ8UdrrqU1p7vaFcdL53idK+LOM8oRWEWh6o=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixos-hardware", "repo": "nixos-hardware",
"rev": "72674a6b5599e844c045ae7449ba91f803d44ebc", "rev": "2096f3f411ce46e88a79ae4eafcfc9df8ed41c61",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -877,11 +877,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1776548001, "lastModified": 1776877367,
"narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=", "narHash": "sha256-EHq1/OX139R1RvBzOJ0aMRT3xnWyqtHBRUBuO1gFzjI=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc", "rev": "0726a0ecb6d4e08f6adced58726b95db924cef57",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -991,11 +991,11 @@
"noctalia-qs": "noctalia-qs" "noctalia-qs": "noctalia-qs"
}, },
"locked": { "locked": {
"lastModified": 1776888984, "lastModified": 1777079905,
"narHash": "sha256-Up2F/eoMuPUsZnPVYdH5TMHe1TBP2Ue1QuWd0vWZoxY=", "narHash": "sha256-TvYEXwkZnRFQRuFyyqTNSfPnU2tMdhtiBOXSk2AWLJA=",
"owner": "noctalia-dev", "owner": "noctalia-dev",
"repo": "noctalia-shell", "repo": "noctalia-shell",
"rev": "2c1808f9f8937fc0b82c54af513f7620fec56d71", "rev": "a50c92167c8d438000270f7eca36f6eea74f388e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -1133,11 +1133,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1776827647, "lastModified": 1777086717,
"narHash": "sha256-sYixYhp5V8jCajO8TRorE4fzs7IkL4MZdfLTKgkPQBk=", "narHash": "sha256-vEl3cGHRxEFdVNuP9PbrhAWnmU98aPOLGy9/1JXzSuM=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "40e6ccc06e1245a4837cbbd6bdda64e21cc67379", "rev": "3be56bd430bfd65d3c468a50626c3a601c7dee03",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -1190,11 +1190,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1776653059, "lastModified": 1777000965,
"narHash": "sha256-K3tWnUj6FXaK95sBUajedutJrFVrOzYhvrQwQjJ0FbU=", "narHash": "sha256-xcrhVgfI13s1WH4hg5MLL83zAp6/htfF8Pjw4RPiKM8=",
"owner": "nix-community", "owner": "nix-community",
"repo": "srvos", "repo": "srvos",
"rev": "4968d2a44c84edfc9a38a2494cc7f85ad2c7122b", "rev": "7ae6f096b2ffbd25d17da8a4d0fe299a164c4eac",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -1356,11 +1356,11 @@
"trackerlist": { "trackerlist": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1776809383, "lastModified": 1777068584,
"narHash": "sha256-r4V5l+Yk3jxVfZNQk2Ddu8Vlyshd9FWcnGGFyaL4UCw=", "narHash": "sha256-UZr6mQfauhIUo8n3SDYnBWeq11xs5lTAoc9onh2MHBc=",
"owner": "ngosang", "owner": "ngosang",
"repo": "trackerslist", "repo": "trackerslist",
"rev": "37d5c0552c25abf50f05cc6b377345e65a588dc2", "rev": "747c048c604c8d12b9d20cfccea4800a32382a66",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -1524,11 +1524,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1776844129, "lastModified": 1777084302,
"narHash": "sha256-DaYSEBVzTvUhTuoVe70NHphoq5JKUHqUhlNlN5XnTuU=", "narHash": "sha256-qHE5XpgtRedzND5xzaqzbSOw4amse0aA4/BaVI4ONcU=",
"owner": "0xc000022070", "owner": "0xc000022070",
"repo": "zen-browser-flake", "repo": "zen-browser-flake",
"rev": "90706e6ab801e4fb7bc53343db67583631936192", "rev": "f6bab88f8566ddc13fb5e5500bd6c720b61d5321",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -376,6 +376,7 @@
nixosConfigurations = { nixosConfigurations = {
mreow = mkDesktopHost "mreow"; mreow = mkDesktopHost "mreow";
yarn = mkDesktopHost "yarn"; yarn = mkDesktopHost "yarn";
patiodeck = mkDesktopHost "patiodeck";
muffin = muffinHost; muffin = muffinHost;
}; };

View File

@@ -37,8 +37,21 @@ let
in in
{ {
home.packages = [ 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: { (inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.omp.overrideAttrs (old: {
patches = (old.patches or [ ]) ++ [ ]; prePatch =
(old.prePatch or "")
+ ''
# 0001 retry without strict tools when DeepSeek (via OpenRouter) rejects strict-mode
# `anyOf` nullable unions with `Invalid tool parameters schema : field \`anyOf\`:
# missing field \`type\``.
patch -p1 < ${../../patches/omp/0001-openai-completions-retry-without-strict-on-deepseek-openrouter.patch}
# 0002 require `reasoning_content` for OpenRouter reasoning models so DeepSeek V4 Pro
# et al. accept follow-up requests in thinking mode.
patch -p1 < ${../../patches/omp/0002-openai-completions-stub-reasoning-content-for-openrouter.patch}
'';
})) }))
]; ];

View 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" ];
}
];
};
}

View File

@@ -19,7 +19,7 @@
../../modules/zfs.nix ../../modules/zfs.nix
../../modules/server-impermanence.nix ../../modules/server-impermanence.nix
../../modules/usb-secrets.nix ../../modules/usb-secrets.nix
../../modules/age-secrets.nix ../../modules/server-age-secrets.nix
../../modules/server-lanzaboote-agenix.nix ../../modules/server-lanzaboote-agenix.nix
../../modules/no-rgb.nix ../../modules/no-rgb.nix
../../modules/server-security.nix ../../modules/server-security.nix

View File

@@ -196,6 +196,10 @@ rec {
port = 9563; port = 9563;
proto = "tcp"; proto = "tcp";
}; };
minecraft_exporter = {
port = 9567;
proto = "tcp";
};
prometheus_zfs = { prometheus_zfs = {
port = 9134; port = 9134;
proto = "tcp"; proto = "tcp";

View 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
View 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
View File

@@ -0,0 +1,8 @@
{ ... }:
{
imports = [
../../home/profiles/gui.nix
../../home/profiles/desktop.nix
../../home/progs/steam-shortcuts.nix
];
}

View 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"
];
}

View File

@@ -1,5 +1,4 @@
{ {
config,
pkgs, pkgs,
lib, lib,
username, username,
@@ -10,13 +9,13 @@
{ {
imports = [ imports = [
../../modules/desktop-common.nix ../../modules/desktop-common.nix
../../modules/desktop-jovian.nix
../../modules/no-rgb.nix ../../modules/no-rgb.nix
./disk.nix ./disk.nix
./impermanence.nix ./impermanence.nix
./vr.nix ./vr.nix
inputs.impermanence.nixosModules.impermanence inputs.impermanence.nixosModules.impermanence
inputs.jovian-nixos.nixosModules.default
]; ];
fileSystems."/media/games" = { fileSystems."/media/games" = {
@@ -83,145 +82,6 @@
systemd.services.lactd.serviceConfig.ExecStartPre = "${lib.getExe pkgs.bash} -c \"sleep 3s\""; systemd.services.lactd.serviceConfig.ExecStartPre = "${lib.getExe pkgs.bash} -c \"sleep 3s\"";
# root-level service that applies a pending update. Triggered by # yarn is not a Steam Deck
# steamos-update (via systemctl start) when the user accepts an update. jovian.devices.steamdeck.enable = false;
# 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;
} }

View File

@@ -1,15 +1,12 @@
{ {
pkgs, pkgs,
inputs,
lib,
config,
... ...
}: }:
{ {
imports = [ imports = [
../../home/profiles/gui.nix ../../home/profiles/gui.nix
../../home/profiles/desktop.nix ../../home/profiles/desktop.nix
inputs.json2steamshortcut.homeModules.default ../../home/progs/steam-shortcuts.nix
]; ];
home.packages = with pkgs; [ home.packages = with pkgs; [
@@ -27,20 +24,4 @@
obs-pipewire-audio-capture 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" ];
}
];
};
} }

View File

@@ -12,6 +12,14 @@
"/var/lib/systemd/coredump" "/var/lib/systemd/coredump"
"/var/lib/nixos" "/var/lib/nixos"
"/var/lib/systemd/timers" "/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 = [ files = [
@@ -21,6 +29,12 @@
"/etc/ssh/ssh_host_rsa_key.pub" "/etc/ssh/ssh_host_rsa_key.pub"
"/etc/machine-id" "/etc/machine-id"
]; ];
users.root = {
files = [
".local/share/fish/fish_history"
];
};
}; };
# Bind mount entire home directory from persistent storage # Bind mount entire home directory from persistent storage

View File

@@ -75,4 +75,19 @@ final: prev: {
''; '';
meta.mainProgram = "igpu-exporter"; 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";
};
} }

View File

@@ -58,8 +58,6 @@
]; ];
}; };
services.kmscon.enable = true;
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
doas-sudo-shim doas-sudo-shim
]; ];

View 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";
};
};
}

View File

@@ -17,9 +17,10 @@
./desktop-vm.nix ./desktop-vm.nix
./desktop-steam.nix ./desktop-steam.nix
./desktop-networkmanager.nix ./desktop-networkmanager.nix
./desktop-age-secrets.nix
./desktop-lanzaboote-agenix.nix
inputs.disko.nixosModules.disko inputs.disko.nixosModules.disko
inputs.lanzaboote.nixosModules.lanzaboote
inputs.nixos-hardware.nixosModules.common-cpu-amd-pstate inputs.nixos-hardware.nixosModules.common-cpu-amd-pstate
inputs.nixos-hardware.nixosModules.common-cpu-amd-zenpower inputs.nixos-hardware.nixosModules.common-cpu-amd-zenpower
@@ -50,16 +51,6 @@
mkdir -p /nix/var/nix/profiles/per-user/root/channels 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 = [ ]; swapDevices = [ ];
@@ -71,7 +62,7 @@
trusted-public-keys = [ trusted-public-keys = [
site_config.binary_cache.public_key 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 # cachyos kernel overlay
@@ -896,8 +887,7 @@
"camera" "camera"
"adbusers" "adbusers"
]; ];
# TODO! this is really bad :( I should really figure out how to do proper secrets management hashedPasswordFile = config.age.secrets.password-hash.path;
hashedPasswordFile = "${../secrets/desktop/password-hash}";
}; };
services.gvfs.enable = true; services.gvfs.enable = true;

View 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;
}

View 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}
)
'';
};
};
}

View File

@@ -1,4 +1,4 @@
{ hostname, site_config, ... }: { hostname, ... }:
{ {
# speed up boot times (by about three seconds) # speed up boot times (by about three seconds)
systemd.services.NetworkManager-wait-online.enable = false; systemd.services.NetworkManager-wait-online.enable = false;
@@ -9,7 +9,10 @@
networkmanager = { networkmanager = {
enable = true; enable = true;
appendNameservers = site_config.dns_servers; appendNameservers = [
"1.1.1.1"
"9.9.9.9"
];
wifi = { wifi = {
scanRandMacAddress = true; scanRandMacAddress = true;

View 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;
}
});
'';
}

View File

@@ -0,0 +1,126 @@
Subject: [PATCH] fix(openai-completions): retry without strict tools for DeepSeek-via-OpenRouter anyOf rejections
The retry-on-strict-tool-error path in openai-completions failed to recover when
DeepSeek (and similar backends fronted by OpenRouter) reject strict-mode tool
schemas with errors of the form:
Invalid tool parameters schema : field `anyOf`: missing field `type`
Two reasons:
1. Retry only triggered in "all_strict" mode. OpenRouter defaults to "mixed"
(per-tool strict), so the early return prevented retry.
2. The error-message regex required "strict" near "tool". DeepSeek's message
never mentions "strict".
Fix:
- Allow retry whenever any tool was sent with strict (i.e. mode != "none").
- Recognize "Invalid tool parameters" in the regex.
Includes a regression test reproducing the exact DeepSeek error body via
OpenRouter mixed-strict mode.
Applies cleanly against v14.2.1.
---
diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts
index e58189607..3c20631c1 100644
--- a/packages/ai/src/providers/openai-completions.ts
+++ b/packages/ai/src/providers/openai-completions.ts
@@ -1245,7 +1245,10 @@ function shouldRetryWithoutStrictTools(
toolStrictMode: AppliedToolStrictMode,
tools: Tool[] | undefined,
): boolean {
- if (!tools || tools.length === 0 || toolStrictMode !== "all_strict") {
+ // Retry whenever any tool was sent with `strict: true`. OpenRouter routes to underlying
+ // providers (e.g. DeepSeek) whose schema validators reject the strict-mode `anyOf` shape
+ // even when omp emitted strict per-tool ("mixed"), not just provider-wide ("all_strict").
+ if (!tools || tools.length === 0 || toolStrictMode === "none") {
return false;
}
const status = extractHttpStatusFromError(error) ?? capturedErrorResponse?.status;
@@ -1255,7 +1258,14 @@ function shouldRetryWithoutStrictTools(
const messageParts = [error instanceof Error ? error.message : undefined, capturedErrorResponse?.bodyText]
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.join("\n");
- return /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool/i.test(messageParts);
+ // Patterns:
+ // - `wrong_api_format`, `mixed values for 'strict'`: OpenAI rejecting mixed strict flags.
+ // - `tool ... strict` / `strict ... tool`: generic strict-tool complaints.
+ // - `Invalid tool parameters schema`: DeepSeek (via OpenRouter) rejecting strict-mode
+ // nullable unions because their validator demands `type` alongside `anyOf`.
+ return /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool|invalid tool parameters/i.test(
+ messageParts,
+ );
}
function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): {
diff --git a/packages/ai/test/openai-tool-strict-mode.test.ts b/packages/ai/test/openai-tool-strict-mode.test.ts
index 2bf17e6d8..24d5a09d5 100644
--- a/packages/ai/test/openai-tool-strict-mode.test.ts
+++ b/packages/ai/test/openai-tool-strict-mode.test.ts
@@ -231,6 +231,64 @@ describe("OpenAI tool strict mode", () => {
expect(result.content).toContainEqual({ type: "text", text: "Hello" });
expect(strictFlags).toEqual([[true], [false]]);
});
+ it("retries with non-strict tool schemas when OpenRouter backend rejects strict anyOf nullable unions", async () => {
+ // Reproduces deepseek/deepseek-v4-pro via OpenRouter rejecting the strict-mode schema with:
+ // 400 Provider returned error
+ // {"error":{"message":"Invalid tool parameters schema : field `anyOf`: missing field `type`",...}}
+ // OpenRouter is in mixed-strict mode by default (per-tool strict), so the original retry condition
+ // (only "all_strict") prevented recovery. The retry now triggers whenever any tool sent strict=true.
+ const model = getBundledModel("openrouter", "deepseek/deepseek-v3.2") as Model<"openai-completions">;
+ const strictFlags: boolean[][] = [];
+ global.fetch = Object.assign(
+ async (_input: string | URL | Request, init?: RequestInit): Promise<Response> => {
+ const bodyText = typeof init?.body === "string" ? init.body : "";
+ const payload = JSON.parse(bodyText) as {
+ tools?: Array<{ function?: { strict?: boolean } }>;
+ };
+ strictFlags.push((payload.tools ?? []).map(tool => tool.function?.strict === true));
+ if (strictFlags.length === 1) {
+ return new Response(
+ JSON.stringify({
+ error: {
+ message: "Invalid tool parameters schema : field `anyOf`: missing field `type`",
+ type: "invalid_request_error",
+ param: null,
+ code: "invalid_request_error",
+ },
+ }),
+ {
+ status: 400,
+ headers: { "content-type": "application/json" },
+ },
+ );
+ }
+ return createSseResponse([
+ {
+ id: "chatcmpl-or",
+ object: "chat.completion.chunk",
+ created: 0,
+ model: model.id,
+ choices: [{ index: 0, delta: { content: "Hello" } }],
+ },
+ {
+ id: "chatcmpl-or",
+ object: "chat.completion.chunk",
+ created: 0,
+ model: model.id,
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
+ },
+ "[DONE]",
+ ]);
+ },
+ { preconnect: originalFetch.preconnect },
+ );
+
+ const result = await streamOpenAICompletions(model, testContext, { apiKey: "test-key" }).result();
+ expect(result.stopReason).toBe("stop");
+ expect(result.content).toContainEqual({ type: "text", text: "Hello" });
+ expect(strictFlags).toEqual([[true], [false]]);
+ });
+
it("sends strict=true for openai-responses tool schemas on OpenAI", async () => {
const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">;

View File

@@ -0,0 +1,233 @@
Subject: [PATCH] fix(openai-completions): require `reasoning_content` for OpenRouter reasoning models
DeepSeek V4 Pro (and similar reasoning models reached via OpenRouter) reject
multi-turn requests in thinking mode with:
400 The `reasoning_content` in the thinking mode must be passed back to
the API.
omp's existing kimi placeholder injection (`requiresReasoningContentForToolCalls`)
covered this requirement only for `thinkingFormat === "openai"`. OpenRouter
sets `thinkingFormat === "openrouter"`, so the gate never fired even though
the underlying providers behind OpenRouter (DeepSeek, Kimi, etc.) all enforce
the same invariant.
This patch:
1. Extends `requiresReasoningContentForToolCalls` detection: any
reasoning-capable model fronted by OpenRouter now sets the flag.
2. Extends the placeholder gate in `convertMessages` to accept
`thinkingFormat === "openrouter"` alongside `"openai"`.
Cross-provider continuations are the dominant trigger: a conversation warmed
up by Anthropic Claude (whose reasoning is redacted/encrypted on the wire)
followed by a switch to DeepSeek V4 Pro via OpenRouter. omp cannot
synthesize plaintext `reasoning_content` from Anthropic's encrypted blocks,
so the placeholder satisfies DeepSeek's validator without fabricating a
reasoning trace. Real captured reasoning, when present, short-circuits the
placeholder via `hasReasoningField` and survives intact.
Side benefit: also closes a latent gap where Kimi-via-OpenRouter
(`thinkingFormat === "openrouter"`) had the compat flag set but the
placeholder gate silently rejected it.
Regression tests cover:
- compat flag detection on OpenRouter reasoning models
- opt-out for non-reasoning OpenRouter models
- cross-provider redacted-thinking placeholder
- Kimi-via-OpenRouter placeholder firing
- real reasoning preserved over the placeholder
Applies cleanly on top of patch 0001.
---
diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts
--- a/packages/ai/src/providers/openai-completions-compat.ts
+++ b/packages/ai/src/providers/openai-completions-compat.ts
@@ -105,7 +105,14 @@
? "qwen"
: "openai",
reasoningContentField: "reasoning_content",
- requiresReasoningContentForToolCalls: isKimiModel,
+ // 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.
+ requiresReasoningContentForToolCalls:
+ isKimiModel || ((provider === "openrouter" || baseUrl.includes("openrouter.ai")) && 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
--- a/packages/ai/src/providers/openai-completions.ts
+++ b/packages/ai/src/providers/openai-completions.ts
@@ -1059,12 +1059,21 @@
(assistantMsg as any).reasoning_content !== undefined ||
(assistantMsg as any).reasoning !== undefined ||
(assistantMsg as any).reasoning_text !== undefined;
- if (
- toolCalls.length > 0 &&
+ // 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
+ // 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
+ // the `thinkingSignature` echo path and short-circuits via `hasReasoningField`.
+ // `thinkingFormat` is gated to formats that consume the field (openai/openrouter chat
+ // completions); formats with their own conventions (zai, qwen) are excluded.
+ const stubsReasoningContent =
compat.requiresReasoningContentForToolCalls &&
- compat.thinkingFormat === "openai" &&
- !hasReasoningField
- ) {
+ (compat.thinkingFormat === "openai" || compat.thinkingFormat === "openrouter");
+ if (toolCalls.length > 0 && stubsReasoningContent && !hasReasoningField) {
const reasoningField = compat.reasoningContentField ?? "reasoning_content";
(assistantMsg as any)[reasoningField] = ".";
}
diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts
--- a/packages/ai/test/openai-completions-compat.test.ts
+++ b/packages/ai/test/openai-completions-compat.test.ts
@@ -367,4 +367,137 @@
const compat = detectCompat(model);
expect(compat.requiresReasoningContentForToolCalls).toBe(true);
});
+
+ it("requires reasoning_content for tool calls on reasoning-capable models via OpenRouter", () => {
+ const model: Model<"openai-completions"> = {
+ ...(getBundledModel("openrouter", "deepseek/deepseek-v3.2") as Model<"openai-completions">),
+ reasoning: true,
+ };
+ const compat = detectCompat(model);
+ expect(compat.thinkingFormat).toBe("openrouter");
+ expect(compat.requiresReasoningContentForToolCalls).toBe(true);
+ });
+
+ it("does not require reasoning_content for non-reasoning OpenRouter models", () => {
+ const model: Model<"openai-completions"> = {
+ ...(getBundledModel("openrouter", "deepseek/deepseek-v3.2") as Model<"openai-completions">),
+ reasoning: false,
+ };
+ const compat = detectCompat(model);
+ expect(compat.requiresReasoningContentForToolCalls).toBe(false);
+ });
+
+ it("injects reasoning_content placeholder for OpenRouter reasoning models lacking captured reasoning", () => {
+ // Reproduces the failing path from real usage: a conversation generated under Anthropic Claude (whose
+ // reasoning is redacted/encrypted) is continued with deepseek/deepseek-v4-pro via OpenRouter. The
+ // prior assistant turns persist as ThinkingContent blocks with empty `thinking` text plus an opaque
+ // Anthropic signature cookie. omp cannot translate that into DeepSeek's plain-text `reasoning_content`,
+ // so the empty thinking block is filtered out and the placeholder fires — satisfying DeepSeek's
+ // thinking-mode validator without fabricating a reasoning trace.
+ const model: Model<"openai-completions"> = {
+ ...(getBundledModel("openrouter", "deepseek/deepseek-v3.2") as Model<"openai-completions">),
+ reasoning: true,
+ };
+ const compat = detectCompat(model);
+ const toolCallMessage: AssistantMessage = {
+ role: "assistant",
+ content: [
+ // Anthropic-style redacted thinking block: empty text plus opaque signature.
+ // `thinking.trim().length === 0` filters this out before the signature echo can fire.
+ { type: "thinking", thinking: "", thinkingSignature: "Ep4CClkIDRgCKkDOpaqueAnthropicCookie" },
+ { type: "toolCall", id: "call_anth_to_ds", 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 kimi-k2-5 via OpenRouter (closes the kimi-via-openrouter gap)", () => {
+ // Before this fix, `requiresReasoningContentForToolCalls` was true for Kimi via OpenRouter but the
+ // stub gate only fired when `thinkingFormat === "openai"`. OpenRouter sets thinkingFormat="openrouter",
+ // so the stub silently never fired and Kimi-via-OpenRouter conversations 400'd the same way.
+ const model: Model<"openai-completions"> = {
+ ...getBundledModel("openai", "gpt-4o-mini"),
+ api: "openai-completions",
+ provider: "openrouter",
+ baseUrl: "https://openrouter.ai/api/v1",
+ id: "moonshotai/kimi-k2-5",
+ reasoning: true,
+ };
+ const compat = detectCompat(model);
+ const toolCallMessage: AssistantMessage = {
+ role: "assistant",
+ content: [
+ { type: "toolCall", id: "call_kimi_or", 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("preserves real captured reasoning over the placeholder when the assistant has non-empty thinking", () => {
+ // Sanity check: the placeholder must not overwrite real reasoning. When the prior assistant turn was
+ // generated by the same provider and surfaces plaintext reasoning, the existing thinkingSignature
+ // echo path sets `reasoning_content` first, and `hasReasoningField` short-circuits the stub.
+ const model: Model<"openai-completions"> = {
+ ...(getBundledModel("openrouter", "deepseek/deepseek-v3.2") as Model<"openai-completions">),
+ reasoning: true,
+ };
+ const compat = detectCompat(model);
+ const toolCallMessage: AssistantMessage = {
+ role: "assistant",
+ content: [
+ { type: "thinking", thinking: "Step 1: read the file. Step 2: search.", thinkingSignature: "reasoning_content" },
+ { type: "toolCall", id: "call_real", 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("Step 1: read the file. Step 2: search.");
+ });
+
});

View 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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
secrets/secrets.nix Normal file

Binary file not shown.

View File

@@ -687,6 +687,188 @@ let
overrides = [ ]; 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 in

View File

@@ -10,6 +10,9 @@ let
jellyfinExporterPort = service_configs.ports.private.jellyfin_exporter.port; jellyfinExporterPort = service_configs.ports.private.jellyfin_exporter.port;
qbitExporterPort = service_configs.ports.private.qbittorrent_exporter.port; qbitExporterPort = service_configs.ports.private.qbittorrent_exporter.port;
igpuExporterPort = service_configs.ports.private.igpu_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 in
{ {
# -- Jellyfin Prometheus Exporter -- # -- Jellyfin Prometheus Exporter --
@@ -109,4 +112,45 @@ in
REFRESH_PERIOD_MS = "30000"; 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";
};
};
} }

View File

@@ -95,6 +95,12 @@ in
{ targets = [ "127.0.0.1:${toString service_configs.ports.private.igpu_exporter.port}" ]; } { 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"; job_name = "zfs";
static_configs = [ static_configs = [

View File

@@ -27,7 +27,6 @@
users.users.${username}.openssh.authorizedKeys.keys = [ users.users.${username}.openssh.authorizedKeys.keys = [
site_config.ssh_keys.laptop site_config.ssh_keys.laptop
site_config.ssh_keys.desktop
]; ];
# used for deploying configs to server # used for deploying configs to server

View File

@@ -57,7 +57,6 @@ rec {
# hosts/yarn/default.nix. Rotating a key means changing it here, nowhere else. # hosts/yarn/default.nix. Rotating a key means changing it here, nowhere else.
ssh_keys = { ssh_keys = {
laptop = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO4jL6gYOunUlUtPvGdML0cpbKSsPNqQ1jit4E7U1RyH"; laptop = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO4jL6gYOunUlUtPvGdML0cpbKSsPNqQ1jit4E7U1RyH";
desktop = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBJjT5QZ3zRDb+V6Em20EYpSEgPW5e/U+06uQGJdraxi";
ci_deploy = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC5ZYN6idL/w/mUIfPOH1i+Q/SQXuzAMQUEuWpipx1Pc ci-deploy@muffin"; ci_deploy = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC5ZYN6idL/w/mUIfPOH1i+Q/SQXuzAMQUEuWpipx1Pc ci-deploy@muffin";
}; };
} }