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:
@@ -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).
|
||||
7. **`gatewayd`** escapes images and disarms links (section 9).
|
||||
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`
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
choice.
|
||||
- `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
|
||||
|
||||
@@ -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.
|
||||
- `sanitizer-rules`: the procedure of 6.5.
|
||||
- `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
|
||||
|
||||
@@ -346,6 +377,9 @@ Given tests, first, as in every plan:
|
||||
labels.
|
||||
- `gatewayd`: images escaped in every reply, URLs disarmed only when `untrusted`, with the notice.
|
||||
- `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
|
||||
system turn reaches the model with the tags escaped; an identity write that asks to send the
|
||||
|
||||
Reference in New Issue
Block a user