Field report (2026-08-24, Linux): the tray showed "Open web console (not responding)" next to a tooltip reading "idle", and the console would not load in a browser either. No other streaming host on the box.
What was actually wrong
The host has kept two identities since the identity split (crate::identity):
pair
what it is
who pins it
native-cert.pem / native-key.pem
ECDSA P-256, real SANs (hostname, localhost, 127.0.0.1, ::1)
native QUIC plane, mgmt API, native clients, the tray
cert.pem / key.pem
legacy RSA, CN=punktfunk, no SAN at all
Moonlight (byte-stable, pairing hashes bind its signature)
The web console never followed the split. Every launcher — scripts/punktfunk-web.service, the NixOS module, the Windows service supervisor, web-run.cmd, the Steam Deck installer — still names the legacy pair, and none of them can choose: systemd Environment= has no "this file, else that one".
So the console served a cert with no SAN, which costs twice:
Browsers reject a CN-only cert outright (ERR_CERT_COMMON_NAME_INVALID / SSL_ERROR_BAD_CERT_DOMAIN) — the console the operator was told to open does not load.
The tray's liveness probe reused the agent pinned to the mgmt identity — the native cert — so rustls refused the handshake and a perfectly healthy console got labelled "not responding".
The reporter's "idle" tooltip is the proof of (2): the same agent reached the mgmt API fine on the very same tick. Anything that fetched the summary successfully and then called the console dead was a pin mismatch, nothing else. This reproduces on every identity-split host, i.e. every fresh install since the split.
The fix
web/nitro-entry/tls-paths.mjs (new) — the entry is the one place every launcher routes through, so the choice is made there: prefer the native sibling pair when both files are present and usable, as a pair or not at all (a native cert with the legacy key completes no handshake with anyone). Both halves must resolve to the same directory. A host that never took the split has no native pair on disk and falls through unchanged, as does an operator-supplied cert under any other name.
Deliberately not built on node:path: that resolves per-runtime, so a POSIX CI runner reads C:\ProgramData\punktfunk\cert.pem as one long filename and never swaps — and Windows, where windows/service.rs hands us exactly that shape, is the platform CI can never exercise. A suffix test gives the same answer everywhere (and leaves the prefix verbatim, where join(dirname(p), …) would normalise /a/b/../cert.pem into a different directory the moment b is a symlink).
The check is a non-empty stat, not mere existence: pf_paths::write_secret_file is create+truncate+write rather than temp+rename, so a console starting mid-write could otherwise adopt a 0-byte cert and leave Bun.serve throwing on every restart — and not every launcher retries forever (the Steam Deck unit is Restart=on-failure under the default rate limit, i.e. permanently dead).
This also hands the bundled bun the smaller secret: on a default build key.pem is the Moonlight pairing signing key; native-key.pem is only a TLS key.
crates/punktfunk-tray/src/status.rs — the console probe loses its pin rather than gaining a second one. It is a different server and there is no rule that it presents the mgmt certificate; an operator fronting the console with their own LAN-CA cert would have hit this just as squarely. The probe sends no credentials, reads no body, and decides only presentation, so there is nothing for a pin to protect. (On Windows it was never pinned to begin with — punktfunk_config_dir returns None off Linux, so load_pin was already None.)
crates/punktfunk-host/src/gamestream/mod.rs — serve now resolves the native identity before minting the legacy one. Two things fall out:
whenever that call writes a native pair, it does so before cert.pem appears — closing a first-run window where the console (which waits on cert.pem) could start between the two writes and serve the SAN-less cert for the rest of that boot;
it fixes a latent fault: with cert.pem missing but native clients paired, the old order let load_or_create mint a brand-newcert.pem that load_or_adopt then adopted while logging that it was preserving their pins — stranding them silently. Reading the dir first routes that case to the branch written for it.
The rest is comment accuracy across the launchers (they keep naming the legacy pair on purpose — the fallback lives on the other side of the handoff).
Verification
End-to-end against the built server, configured exactly as the shipped unit does (PUNKTFUNK_UI_TLS_CERT=…/cert.pem):
both pairs on disk → serves id-ecPublicKey / NIST P-256 with DNS:localhost, IP Address:127.0.0.1✅
native pair removed → serves rsaEncryption, Subject: CN=punktfunk, no SAN — legacy hosts unchanged ✅
native-cert.pem truncated to 0 bytes → falls back to the legacy pair, console still serving (with a bare existence check this was a permanent restart loop) ✅
18/18 web tests, biome and rustfmt clean, and all 8 CI checks green — including ci / rust, which carries cargo clippy --workspace -- -D warningsand the native-only --no-default-features --features pyrowave gate, so both feature configurations compile and lint.
Reviewed
An adversarial review pass found no blockers. Its one real finding — the resolver could return a mismatched pair from two different directories, the exact invariant the module claimed to hold — is fixed in 1e2b956d, along with the Windows/normalisation/zero-byte hardening above and two comments of mine that overclaimed.
Known follow-up, not taken here: the console resolves its pair once at start and the unit has no PartOf=/BindsTo=, so the documented migration in identity.rs ("unpair ALL native clients, restart the host, re-pair") mints native-*.pem while the running console keeps serving the legacy cert until something restarts it. Adding PartOf= would bounce the console on every host restart, which is a trade worth making deliberately rather than as a side effect of this fix.
Note for the reporter
Worth confirming their console is actually enabled — the .deb postinst only prints the command, it cannot run it for a user:
systemctl --user status punktfunk-web
systemctl --user enable --now punktfunk-web
If it is running, this PR is their bug: the tray label was a false alarm, and the browser was refusing a SAN-less cert.
Field report (2026-08-24, Linux): the tray showed **"Open web console (not responding)"** next to a tooltip reading **"idle"**, and the console would not load in a browser either. No other streaming host on the box.
## What was actually wrong
The host has kept **two** identities since the identity split (`crate::identity`):
| pair | what it is | who pins it |
|---|---|---|
| `native-cert.pem` / `native-key.pem` | ECDSA P-256, real SANs (hostname, `localhost`, `127.0.0.1`, `::1`) | native QUIC plane, mgmt API, native clients, **the tray** |
| `cert.pem` / `key.pem` | legacy RSA, `CN=punktfunk`, **no SAN at all** | Moonlight (byte-stable, pairing hashes bind its signature) |
**The web console never followed the split.** Every launcher — `scripts/punktfunk-web.service`, the NixOS module, the Windows service supervisor, `web-run.cmd`, the Steam Deck installer — still names the *legacy* pair, and none of them **can** choose: systemd `Environment=` has no "this file, else that one".
So the console served a cert with no SAN, which costs twice:
1. **Browsers reject a CN-only cert outright** (`ERR_CERT_COMMON_NAME_INVALID` / `SSL_ERROR_BAD_CERT_DOMAIN`) — the console the operator was told to open does not load.
2. **The tray's liveness probe reused the agent pinned to the *mgmt* identity** — the native cert — so rustls refused the handshake and a perfectly healthy console got labelled "not responding".
The reporter's "idle" tooltip is the proof of (2): the *same* agent reached the mgmt API fine on the very same tick. Anything that fetched the summary successfully and then called the console dead was a pin mismatch, nothing else. This reproduces on **every identity-split host**, i.e. every fresh install since the split.
## The fix
**`web/nitro-entry/tls-paths.mjs`** (new) — the entry is the one place every launcher routes through, so the choice is made there: prefer the native sibling pair when both files are present and usable, as a **pair or not at all** (a native cert with the legacy key completes no handshake with anyone). Both halves must resolve to the same directory. A host that never took the split has no native pair on disk and falls through unchanged, as does an operator-supplied cert under any other name.
Deliberately **not** built on `node:path`: that resolves per-*runtime*, so a POSIX CI runner reads `C:\ProgramData\punktfunk\cert.pem` as one long filename and never swaps — and Windows, where `windows/service.rs` hands us exactly that shape, is the platform CI can never exercise. A suffix test gives the same answer everywhere (and leaves the prefix verbatim, where `join(dirname(p), …)` would normalise `/a/b/../cert.pem` into a different directory the moment `b` is a symlink).
The check is a **non-empty stat**, not mere existence: `pf_paths::write_secret_file` is create+truncate+write rather than temp+rename, so a console starting mid-write could otherwise adopt a 0-byte cert and leave `Bun.serve` throwing on every restart — and not every launcher retries forever (the Steam Deck unit is `Restart=on-failure` under the default rate limit, i.e. permanently dead).
This also hands the bundled bun the *smaller* secret: on a default build `key.pem` is the Moonlight pairing **signing** key; `native-key.pem` is only a TLS key.
**`crates/punktfunk-tray/src/status.rs`** — the console probe loses its pin rather than gaining a second one. It is a different server and there is no rule that it presents the mgmt certificate; an operator fronting the console with their own LAN-CA cert would have hit this just as squarely. The probe sends no credentials, reads no body, and decides only presentation, so there is nothing for a pin to protect. (On Windows it was never pinned to begin with — `punktfunk_config_dir` returns `None` off Linux, so `load_pin` was already `None`.)
**`crates/punktfunk-host/src/gamestream/mod.rs`** — `serve` now resolves the native identity **before** minting the legacy one. Two things fall out:
- whenever that call *writes* a native pair, it does so before `cert.pem` appears — closing a first-run window where the console (which waits on `cert.pem`) could start between the two writes and serve the SAN-less cert for the rest of that boot;
- it fixes a latent fault: with `cert.pem` missing but native clients paired, the old order let `load_or_create` mint a **brand-new** `cert.pem` that `load_or_adopt` then adopted *while logging that it was preserving their pins* — stranding them silently. Reading the dir first routes that case to the branch written for it.
The rest is comment accuracy across the launchers (they keep naming the legacy pair on purpose — the fallback lives on the other side of the handoff).
## Verification
End-to-end against the **built** server, configured exactly as the shipped unit does (`PUNKTFUNK_UI_TLS_CERT=…/cert.pem`):
- both pairs on disk → serves `id-ecPublicKey` / NIST P-256 with `DNS:localhost, IP Address:127.0.0.1` ✅
- native pair removed → serves `rsaEncryption`, `Subject: CN=punktfunk`, no SAN — legacy hosts unchanged ✅
- `native-cert.pem` truncated to 0 bytes → falls back to the legacy pair, console still serving (with a bare existence check this was a permanent restart loop) ✅
**18/18** web tests, biome and rustfmt clean, and all 8 CI checks green — including `ci / rust`, which carries `cargo clippy --workspace -- -D warnings` **and** the native-only `--no-default-features --features pyrowave` gate, so both feature configurations compile and lint.
## Reviewed
An adversarial review pass found **no blockers**. Its one real finding — the resolver could return a mismatched pair from two different directories, the exact invariant the module claimed to hold — is fixed in `1e2b956d`, along with the Windows/normalisation/zero-byte hardening above and two comments of mine that overclaimed.
**Known follow-up, not taken here:** the console resolves its pair once at start and the unit has no `PartOf=`/`BindsTo=`, so the documented migration in `identity.rs` ("unpair ALL native clients, restart the host, re-pair") mints `native-*.pem` while the running console keeps serving the legacy cert until something restarts it. Adding `PartOf=` would bounce the console on every host restart, which is a trade worth making deliberately rather than as a side effect of this fix.
## Note for the reporter
Worth confirming their console is actually enabled — the `.deb` postinst only *prints* the command, it cannot run it for a user:
```
systemctl --user status punktfunk-web
systemctl --user enable --now punktfunk-web
```
If it is running, this PR is their bug: the tray label was a false alarm, and the browser was refusing a SAN-less cert.
A Linux operator saw "Open web console (not responding)" in the tray next to a
tooltip reading "idle", and the console would not load in a browser either.
The host has kept two identities since the identity split (crate::identity):
native-cert.pem/native-key.pem (P-256, real SANs — what the native QUIC plane,
the mgmt API and every native client pin) and the legacy cert.pem/key.pem (RSA,
CN=punktfunk, NO SAN, kept byte-stable for Moonlight). The web console never
followed the split. Every launcher — the systemd unit, the NixOS module, the
Windows service supervisor, web-run.cmd, the Steam Deck installer — still names
the LEGACY pair, and none of them CAN choose: `Environment=` has no "this file,
else that one". So the console served a certificate with no SAN at all, which
costs twice over:
* browsers reject a CN-only cert outright (ERR_CERT_COMMON_NAME_INVALID), so
the console the operator was told to open does not load;
* the tray's loopback liveness probe reused the agent PINNED to the mgmt
identity — the native cert — so rustls refused the handshake and a perfectly
healthy console was labelled "not responding". The "idle" tooltip beside it
is the proof: the same agent reached mgmt fine on the very same tick.
The entry is the one place every launcher routes through, so the choice is made
there: prefer the native sibling pair when both files exist, as a PAIR or not at
all (a native cert with the legacy key completes no handshake with anyone). A
host that never took the split has no native pair on disk and falls through
unchanged, as does an operator-supplied cert under any other name. This also
hands the bundled bun the smaller secret: on a default build key.pem is the
Moonlight pairing SIGNING key, native-key.pem is only a TLS key.
The tray's console probe loses its pin rather than gaining a second one. It is a
different server and there is no rule that it presents the mgmt certificate — an
operator fronting the console with their own LAN-CA cert would have hit this just
as squarely. The probe sends no credentials, reads no body, and decides only a
menu label, so there is nothing for a pin to protect.
`serve` now resolves the native identity BEFORE minting the legacy one. That
closes a first-run window where the console (which waits on cert.pem) could start
between the two writes and serve the SAN-less cert for the rest of the boot, and
it fixes a second latent fault: with cert.pem missing but native clients paired,
the old order let load_or_create mint a brand-new cert.pem that load_or_adopt
then adopted while logging that it was preserving their pins.
Verified against the built server: configured exactly as the shipped unit does
(PUNKTFUNK_UI_TLS_CERT=.../cert.pem), it now serves the P-256 cert with
DNS:localhost/IP:127.0.0.1; with the native pair removed it serves the RSA cert
as before. 14/14 web tests pass, biome and rustfmt clean.
Review of the parent commit. One real defect, the rest accuracy.
The resolver could hand back a MISMATCHED pair, which is the one invariant its
own header promised it never would: `nativeCert` came from `dirname(cert)` and
`nativeKey` from `dirname(key)`, two independent directories, so
("/a/cert.pem", "/b/key.pem") resolved to /a/native-cert.pem + /b/native-key.pem
— two unrelated files presented as a pair. No shipped launcher splits them, but
the guard is one comparison and it is the whole point of the module.
Rewritten off a suffix test instead of `node:path`, which fixes two more things
in passing. `node:path` resolves per-RUNTIME, so a POSIX CI runner reads
`C:\ProgramData\punktfunk\cert.pem` as one long filename and never swaps — and
Windows, where windows/service.rs hands us exactly that, is the platform the CI
job can never exercise. The suffix test gives the same answer everywhere and is
now covered by a win32 case. It also leaves the prefix VERBATIM, where
`join(dirname(p), …)` normalised /a/b/../cert.pem into a different directory the
moment `b` was a symlink.
Existence is no longer enough: `pf_paths::write_secret_file` is
create+truncate+write rather than temp+rename, so a console starting mid-write
could adopt a 0-byte cert and leave `Bun.serve` throwing on every restart. Not
every launcher retries forever — the Steam Deck unit is `Restart=on-failure`
under the default rate limit, i.e. permanently dead. The check is now a
non-empty stat, mirroring the host's own `!c.trim().is_empty()`. Verified: with
native-cert.pem truncated to 0 bytes the console starts and serves the legacy
pair.
Two comments of mine overclaimed and are corrected rather than left to mislead:
* serve() said "cert.pem existing implies the native pair does too". False on
an upgraded host whose native clients pinned the legacy cert — load_or_adopt
returns it and writes no native files at all. The ordering claim that IS
true is narrower: whenever that call writes a native pair, it does so before
cert.pem appears.
* the tray said the console entry "always opens the same URL regardless of the
probe". True of the menu entry, but win.rs gates the tray-icon single-click
on console_up. Also notes that the Windows probe was never pinned to begin
with (punktfunk_config_dir is None off Linux), so that half is a no-op.
Rest is doc drift the parent commit annotated in two launchers but not the other
four: web.env.example, README, web-run.cmd, and the ci.yml comment that still
said the web test step was "Scoped to server/".
18/18 web tests (was 14), biome and rustfmt clean, and the runtime check re-run
against a fresh build: both pairs -> P-256 with SANs; 0-byte native cert ->
legacy RSA, console still serving.
enricobuehler
marked the pull request as ready for review 2026-08-24 20:54:32 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Field report (2026-08-24, Linux): the tray showed "Open web console (not responding)" next to a tooltip reading "idle", and the console would not load in a browser either. No other streaming host on the box.
What was actually wrong
The host has kept two identities since the identity split (
crate::identity):native-cert.pem/native-key.pemlocalhost,127.0.0.1,::1)cert.pem/key.pemCN=punktfunk, no SAN at allThe web console never followed the split. Every launcher —
scripts/punktfunk-web.service, the NixOS module, the Windows service supervisor,web-run.cmd, the Steam Deck installer — still names the legacy pair, and none of them can choose: systemdEnvironment=has no "this file, else that one".So the console served a cert with no SAN, which costs twice:
ERR_CERT_COMMON_NAME_INVALID/SSL_ERROR_BAD_CERT_DOMAIN) — the console the operator was told to open does not load.The reporter's "idle" tooltip is the proof of (2): the same agent reached the mgmt API fine on the very same tick. Anything that fetched the summary successfully and then called the console dead was a pin mismatch, nothing else. This reproduces on every identity-split host, i.e. every fresh install since the split.
The fix
web/nitro-entry/tls-paths.mjs(new) — the entry is the one place every launcher routes through, so the choice is made there: prefer the native sibling pair when both files are present and usable, as a pair or not at all (a native cert with the legacy key completes no handshake with anyone). Both halves must resolve to the same directory. A host that never took the split has no native pair on disk and falls through unchanged, as does an operator-supplied cert under any other name.Deliberately not built on
node:path: that resolves per-runtime, so a POSIX CI runner readsC:\ProgramData\punktfunk\cert.pemas one long filename and never swaps — and Windows, wherewindows/service.rshands us exactly that shape, is the platform CI can never exercise. A suffix test gives the same answer everywhere (and leaves the prefix verbatim, wherejoin(dirname(p), …)would normalise/a/b/../cert.peminto a different directory the momentbis a symlink).The check is a non-empty stat, not mere existence:
pf_paths::write_secret_fileis create+truncate+write rather than temp+rename, so a console starting mid-write could otherwise adopt a 0-byte cert and leaveBun.servethrowing on every restart — and not every launcher retries forever (the Steam Deck unit isRestart=on-failureunder the default rate limit, i.e. permanently dead).This also hands the bundled bun the smaller secret: on a default build
key.pemis the Moonlight pairing signing key;native-key.pemis only a TLS key.crates/punktfunk-tray/src/status.rs— the console probe loses its pin rather than gaining a second one. It is a different server and there is no rule that it presents the mgmt certificate; an operator fronting the console with their own LAN-CA cert would have hit this just as squarely. The probe sends no credentials, reads no body, and decides only presentation, so there is nothing for a pin to protect. (On Windows it was never pinned to begin with —punktfunk_config_dirreturnsNoneoff Linux, soload_pinwas alreadyNone.)crates/punktfunk-host/src/gamestream/mod.rs—servenow resolves the native identity before minting the legacy one. Two things fall out:cert.pemappears — closing a first-run window where the console (which waits oncert.pem) could start between the two writes and serve the SAN-less cert for the rest of that boot;cert.pemmissing but native clients paired, the old order letload_or_createmint a brand-newcert.pemthatload_or_adoptthen adopted while logging that it was preserving their pins — stranding them silently. Reading the dir first routes that case to the branch written for it.The rest is comment accuracy across the launchers (they keep naming the legacy pair on purpose — the fallback lives on the other side of the handoff).
Verification
End-to-end against the built server, configured exactly as the shipped unit does (
PUNKTFUNK_UI_TLS_CERT=…/cert.pem):id-ecPublicKey/ NIST P-256 withDNS:localhost, IP Address:127.0.0.1✅rsaEncryption,Subject: CN=punktfunk, no SAN — legacy hosts unchanged ✅native-cert.pemtruncated to 0 bytes → falls back to the legacy pair, console still serving (with a bare existence check this was a permanent restart loop) ✅18/18 web tests, biome and rustfmt clean, and all 8 CI checks green — including
ci / rust, which carriescargo clippy --workspace -- -D warningsand the native-only--no-default-features --features pyrowavegate, so both feature configurations compile and lint.Reviewed
An adversarial review pass found no blockers. Its one real finding — the resolver could return a mismatched pair from two different directories, the exact invariant the module claimed to hold — is fixed in
1e2b956d, along with the Windows/normalisation/zero-byte hardening above and two comments of mine that overclaimed.Known follow-up, not taken here: the console resolves its pair once at start and the unit has no
PartOf=/BindsTo=, so the documented migration inidentity.rs("unpair ALL native clients, restart the host, re-pair") mintsnative-*.pemwhile the running console keeps serving the legacy cert until something restarts it. AddingPartOf=would bounce the console on every host restart, which is a trade worth making deliberately rather than as a side effect of this fix.Note for the reporter
Worth confirming their console is actually enabled — the
.debpostinst only prints the command, it cannot run it for a user:If it is running, this PR is their bug: the tray label was a false alarm, and the browser was refusing a SAN-less cert.
A Linux operator saw "Open web console (not responding)" in the tray next to a tooltip reading "idle", and the console would not load in a browser either. The host has kept two identities since the identity split (crate::identity): native-cert.pem/native-key.pem (P-256, real SANs — what the native QUIC plane, the mgmt API and every native client pin) and the legacy cert.pem/key.pem (RSA, CN=punktfunk, NO SAN, kept byte-stable for Moonlight). The web console never followed the split. Every launcher — the systemd unit, the NixOS module, the Windows service supervisor, web-run.cmd, the Steam Deck installer — still names the LEGACY pair, and none of them CAN choose: `Environment=` has no "this file, else that one". So the console served a certificate with no SAN at all, which costs twice over: * browsers reject a CN-only cert outright (ERR_CERT_COMMON_NAME_INVALID), so the console the operator was told to open does not load; * the tray's loopback liveness probe reused the agent PINNED to the mgmt identity — the native cert — so rustls refused the handshake and a perfectly healthy console was labelled "not responding". The "idle" tooltip beside it is the proof: the same agent reached mgmt fine on the very same tick. The entry is the one place every launcher routes through, so the choice is made there: prefer the native sibling pair when both files exist, as a PAIR or not at all (a native cert with the legacy key completes no handshake with anyone). A host that never took the split has no native pair on disk and falls through unchanged, as does an operator-supplied cert under any other name. This also hands the bundled bun the smaller secret: on a default build key.pem is the Moonlight pairing SIGNING key, native-key.pem is only a TLS key. The tray's console probe loses its pin rather than gaining a second one. It is a different server and there is no rule that it presents the mgmt certificate — an operator fronting the console with their own LAN-CA cert would have hit this just as squarely. The probe sends no credentials, reads no body, and decides only a menu label, so there is nothing for a pin to protect. `serve` now resolves the native identity BEFORE minting the legacy one. That closes a first-run window where the console (which waits on cert.pem) could start between the two writes and serve the SAN-less cert for the rest of the boot, and it fixes a second latent fault: with cert.pem missing but native clients paired, the old order let load_or_create mint a brand-new cert.pem that load_or_adopt then adopted while logging that it was preserving their pins. Verified against the built server: configured exactly as the shipped unit does (PUNKTFUNK_UI_TLS_CERT=.../cert.pem), it now serves the P-256 cert with DNS:localhost/IP:127.0.0.1; with the native pair removed it serves the RSA cert as before. 14/14 web tests pass, biome and rustfmt clean.Review of the parent commit. One real defect, the rest accuracy. The resolver could hand back a MISMATCHED pair, which is the one invariant its own header promised it never would: `nativeCert` came from `dirname(cert)` and `nativeKey` from `dirname(key)`, two independent directories, so ("/a/cert.pem", "/b/key.pem") resolved to /a/native-cert.pem + /b/native-key.pem — two unrelated files presented as a pair. No shipped launcher splits them, but the guard is one comparison and it is the whole point of the module. Rewritten off a suffix test instead of `node:path`, which fixes two more things in passing. `node:path` resolves per-RUNTIME, so a POSIX CI runner reads `C:\ProgramData\punktfunk\cert.pem` as one long filename and never swaps — and Windows, where windows/service.rs hands us exactly that, is the platform the CI job can never exercise. The suffix test gives the same answer everywhere and is now covered by a win32 case. It also leaves the prefix VERBATIM, where `join(dirname(p), …)` normalised /a/b/../cert.pem into a different directory the moment `b` was a symlink. Existence is no longer enough: `pf_paths::write_secret_file` is create+truncate+write rather than temp+rename, so a console starting mid-write could adopt a 0-byte cert and leave `Bun.serve` throwing on every restart. Not every launcher retries forever — the Steam Deck unit is `Restart=on-failure` under the default rate limit, i.e. permanently dead. The check is now a non-empty stat, mirroring the host's own `!c.trim().is_empty()`. Verified: with native-cert.pem truncated to 0 bytes the console starts and serves the legacy pair. Two comments of mine overclaimed and are corrected rather than left to mislead: * serve() said "cert.pem existing implies the native pair does too". False on an upgraded host whose native clients pinned the legacy cert — load_or_adopt returns it and writes no native files at all. The ordering claim that IS true is narrower: whenever that call writes a native pair, it does so before cert.pem appears. * the tray said the console entry "always opens the same URL regardless of the probe". True of the menu entry, but win.rs gates the tray-icon single-click on console_up. Also notes that the Windows probe was never pinned to begin with (punktfunk_config_dir is None off Linux), so that half is a no-op. Rest is doc drift the parent commit annotated in two launchers but not the other four: web.env.example, README, web-run.cmd, and the ci.yml comment that still said the web test step was "Scoped to server/". 18/18 web tests (was 14), biome and rustfmt clean, and the runtime check re-run against a fresh build: both pairs -> P-256 with SANs; 0-byte native cert -> legacy RSA, console still serving.