M5b spec: one instance per daemon, by a lock file

Every daemon removes a leftover socket before binding, which also lets a
second copy silently take a running one's sockets. Owner's decision:
refuse to start instead. Each daemon takes an exclusive std File lock
(stable since 1.89, checked on 1.95) on <home>/run/<role>.lock, or
<socket>.lock for inferproxy, before touching its sockets; the system
releases it however the process ends. A runbook entry, a security note
and a test per daemon.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 01:34:35 -07:00
co-authored by Claude Opus 5.5
parent 2b052b24d3
commit 67f497f482
+35 -1
View File
@@ -31,7 +31,8 @@ that has read untrusted content carries its links as code.
6. **`loopd`** neutralises tags and checks them in its self-test (section 8). 6. **`loopd`** neutralises tags and checks them in its self-test (section 8).
7. **`gatewayd`** escapes images and disarms links (section 9). 7. **`gatewayd`** escapes images and disarms links (section 9).
8. **`bxctl sanitize check`** (section 6.6). 8. **`bxctl sanitize check`** (section 6.6).
9. The runbook entries (section 12) and the links table (section 10). 9. **One instance per daemon** (section 9a).
10. The runbook entries (section 12) and the links table (section 10).
## 3. `bxcore` ## 3. `bxcore`
@@ -278,6 +279,32 @@ labels here is sound: this is a defence in depth on presentation, not a trust ga
Separately, the owner may want to review `EnableLinkPreviews` for the whole server: it applies to Separately, the owner may want to review `EnableLinkPreviews` for the whole server: it applies to
every post, including other people's. every post, including other people's.
## 9a. One instance per daemon
Every daemon removes a socket it finds at its path before binding, so a socket left by a daemon
that was killed never blocks the next start (checked 2026-09-26: `loopd`, `brokerd`, `memoryd`,
`gatewayd` and `inferproxy` all do). But the removal is unconditional: a second copy of a daemon,
started by mistake, silently takes the path from the one that is running, which keeps serving its
now-unreachable socket, and nothing says so (owner, 2026-09-26: refuse to start instead).
- Before it touches its sockets, each daemon opens a lock file, creating it with mode 0600, and
takes an exclusive lock with the standard library's `File::try_lock` (stable since Rust 1.89;
checked on 1.95: a second handle gets `TryLockError::WouldBlock` while the first holds it, and
succeeds once it is closed). The file is `<home>/run/<role>.lock` for `loopd`, `brokerd`,
`memoryd` and `gatewayd`, and `<socket>.lock` beside `inferproxy`'s `--listen` socket.
- `WouldBlock`: another instance is running. The daemon does not start, and says so:
`<role>: another <role> is running (it holds <lock file>); see docs/runbook.md#already-running`.
Any other error on the lock file also stops it, naming the error.
- With the lock held, it removes any stale socket and binds, as today. The lock is kept for the
life of the process and released by the system when the process ends, however it ends, so a
killed daemon never leaves a lock behind. The lock file itself is left in place: removing it
would reopen the race it closes.
- No new dependency and no `unsafe`: the lock is in the standard library.
Leftover sockets after a stop stay harmless and are not removed at shutdown: that would need signal
handling, which the standard library lacks. In M8, systemd's `RuntimeDirectory=` removes the
sockets' directory when a unit stops.
## 10. Links between the roles ## 10. Links between the roles
Every link as the tree stands after M5b. Paths are the defaults under the role's home; each can be Every link as the tree stands after M5b. Paths are the defaults under the role's home; each can be
@@ -317,6 +344,8 @@ The plan checks every row of this table against the code before it is handed ove
as configured; a clean session's links are still fetched by the server's previews, by the owner's as configured; a clean session's links are still fetched by the server's previews, by the owner's
choice. choice.
- `loopd`'s binary does not contain the secret loader. - `loopd`'s binary does not contain the secret loader.
- A second instance of a daemon can no longer take a running one's sockets without a word
(section 9a).
## 12. Runbook ## 12. Runbook
@@ -329,6 +358,8 @@ New entries, given in the plan as fragments to copy verbatim (lesson of M5a task
- `memory-entry-excluded`: an identity entry left out of the memory block by a refuse rule. - `memory-entry-excluded`: an identity entry left out of the memory block by a refuse rule.
- `sanitizer-rules`: the procedure of 6.5. - `sanitizer-rules`: the procedure of 6.5.
- `loopd-tags-unexpected`: the self-test found a tag that does not tokenize as recorded. - `loopd-tags-unexpected`: the self-test found a tag that does not tokenize as recorded.
- `already-running`: a daemon refused to start because another instance holds its lock: how to
find it (`pgrep -x <role>`, the lock file), and when a second copy is a mistake.
## 13. Testing ## 13. Testing
@@ -346,6 +377,9 @@ Given tests, first, as in every plan:
labels. labels.
- `gatewayd`: images escaped in every reply, URLs disarmed only when `untrusted`, with the notice. - `gatewayd`: images escaped in every reply, URLs disarmed only when `untrusted`, with the notice.
- `bxctl`: `sanitize check`'s output and exit status. - `bxctl`: `sanitize check`'s output and exit status.
- Each daemon: a second instance started against the same home (or, for `inferproxy`, the same
socket) while the first runs is refused with the sentence and pointer of 9a, and the first keeps
serving; after the first is killed, a new instance starts, removes the stale socket and serves.
**Attack tests for every trust boundary (T37)**: a fetched page ending the tool result and forging a **Attack tests for every trust boundary (T37)**: a fetched page ending the tool result and forging a
system turn reaches the model with the tags escaped; an identity write that asks to send the system turn reaches the model with the tags escaped; an identity write that asks to send the