transport: move the daemon endpoint to /tmp/felis.<uid> #283

Merged
natsukium merged 8 commits from issue-282 into main 2026-09-15 18:52:40 +09:00
Owner

Closes #282

What changed

  • The endpoint is derived from the uid alone: /tmp/felis.<uid>/daemon.sock on Linux and macOS.
    XDG_RUNTIME_DIR, TMPDIR and /run/user/<uid> no longer take part; --socket and FELIS_SOCKET still
    win. /tmp has boot lifetime, which a login's runtime directory does not, so a relay under Tailscale SSH, a
    desktop login and an SSH login all reach one daemon, and a forked daemon stays reachable after the last
    logout on a non-lingering systemd host. tmux derives /tmp/tmux-<uid> the same way.
  • The startup window is locked: probe, unlink and bind run under an exclusive flock on the socket
    directory, opened O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC and judged from that descriptor (owned by this
    uid, mode exactly 0700). The lock leaves no file behind and is released when bind returns, so a serving
    daemon holds nothing and a later starter never waits on it. One lstat after the bind confirms the parent is
    still the directory that was judged. The bind runs under umask 0177, so the socket is 0600 with no chmod
    window.
  • The probe lstats first and classifies: a directory or a symlink at the endpoint fails the start
    untouched, a non-socket inode is removed, and a socket is connected to (a listener of this uid means
    AddrInUse, one of another uid fails the start, and only the absence errors license the unlink).
  • Only the parent is created, and every failure to create or open it names the path: create_dir rather
    than create_dir_all, so a --socket whose grandparent is missing is reported instead of materialized, and a
    parent that cannot be opened (the foreign-owned 0700 squat) is a Parent error carrying its owner, its mode,
    and the recovery rather than a bare Permission denied.
  • Listener::drop never unlinks (REQ-009d), so a dying daemon cannot remove a newer daemon's socket. The
    cost is one stale socket file that the next start replaces, and felis doctor says so rather than inviting
    an rm; anything else at the path is named, because the next start refuses it instead.
  • Both sides verify the peer uid before any application byte (REQ-106), so a dial that lands on a foreign
    listener ends before the preface and the relay never sends its carrier block.
  • felis doctor loses the old-location candidate probes: with no environment in the derivation there is no
    sibling location to probe. The stamped-target row stays and now reports the default's state either way: a daemon
    answering it warns, a cold default is an ok row saying so.
  • Doc cascade (spec REQ-009b/c/d, REQ-106, REQ-107; ipc reference and explanation; security model; cli
    reference; session lifecycle; non-goals; the two how-tos; workspace; security audits), CHANGELOG.md, the
    shipped skill and three agent skills, and the smoke recipes in justfile and both Forgejo workflows (a
    socket directly under a shared /tmp is refused now that the parent is judged).

What the plan leaves out on purpose

No watch or drain of the socket directory, no per-instance agent link names, no doctor candidates for old
build locations, no confstr path on macOS, no <socket>.lock file, no served-socket proxy, no retry loop
around the bind (the single post-bind parent check is built), no descriptor-relative agent-link writes, no
maintenance task, no .agent length validation. agent.rs is unchanged. Decision 4 builds nothing: the
systemd hand-off keeps its behavior, and the docs state the linger condition that ends such a daemon.

One deviation to flag: the plan asked the smoke recipes to create their per-run 0700 directory with
mktemp -d. The workflow's run step and its stop step need the same path and mktemp -d cannot hand one
between them, so the directory is named felis-smoke-${{ github.run_id }} (per-run isolation as before) and
the daemon creates it 0700.

Verification

  • just check: pass, 3571 tests run: 3571 passed, 16 skipped.
  • just check-windows: pass.
  • macOS hand verification: pending (no macOS host available in this session).
Closes #282 ## What changed - **The endpoint is derived from the uid alone**: `/tmp/felis.<uid>/daemon.sock` on Linux and macOS. `XDG_RUNTIME_DIR`, `TMPDIR` and `/run/user/<uid>` no longer take part; `--socket` and `FELIS_SOCKET` still win. `/tmp` has boot lifetime, which a login's runtime directory does not, so a relay under Tailscale SSH, a desktop login and an SSH login all reach one daemon, and a forked daemon stays reachable after the last logout on a non-lingering systemd host. tmux derives `/tmp/tmux-<uid>` the same way. - **The startup window is locked**: probe, unlink and bind run under an exclusive `flock` on the socket *directory*, opened `O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC` and judged from that descriptor (owned by this uid, mode exactly `0700`). The lock leaves no file behind and is released when `bind` returns, so a serving daemon holds nothing and a later starter never waits on it. One `lstat` after the bind confirms the parent is still the directory that was judged. The bind runs under `umask 0177`, so the socket is `0600` with no chmod window. - **The probe `lstat`s first and classifies**: a directory or a symlink at the endpoint fails the start untouched, a non-socket inode is removed, and a socket is connected to (a listener of this uid means `AddrInUse`, one of another uid fails the start, and only the absence errors license the unlink). - **Only the parent is created, and every failure to create or open it names the path**: `create_dir` rather than `create_dir_all`, so a `--socket` whose grandparent is missing is reported instead of materialized, and a parent that cannot be opened (the foreign-owned `0700` squat) is a `Parent` error carrying its owner, its mode, and the recovery rather than a bare `Permission denied`. - **`Listener::drop` never unlinks** (REQ-009d), so a dying daemon cannot remove a newer daemon's socket. The cost is one stale socket file that the next start replaces, and `felis doctor` says so rather than inviting an `rm`; anything else at the path is named, because the next start refuses it instead. - **Both sides verify the peer uid before any application byte** (REQ-106), so a dial that lands on a foreign listener ends before the preface and the relay never sends its carrier block. - `felis doctor` loses the old-location candidate probes: with no environment in the derivation there is no sibling location to probe. The stamped-target row stays and now reports the default's state either way: a daemon answering it warns, a cold default is an `ok` row saying so. - Doc cascade (spec REQ-009b/c/d, REQ-106, REQ-107; ipc reference and explanation; security model; cli reference; session lifecycle; non-goals; the two how-tos; workspace; security audits), `CHANGELOG.md`, the shipped skill and three agent skills, and the smoke recipes in `justfile` and both Forgejo workflows (a socket directly under a shared `/tmp` is refused now that the parent is judged). ## What the plan leaves out on purpose No watch or drain of the socket directory, no per-instance agent link names, no doctor candidates for old build locations, no `confstr` path on macOS, no `<socket>.lock` file, no served-socket proxy, no retry loop around the bind (the single post-bind parent check is built), no descriptor-relative agent-link writes, no maintenance task, no `.agent` length validation. `agent.rs` is unchanged. Decision 4 builds nothing: the systemd hand-off keeps its behavior, and the docs state the linger condition that ends such a daemon. One deviation to flag: the plan asked the smoke recipes to create their per-run `0700` directory with `mktemp -d`. The workflow's run step and its stop step need the same path and `mktemp -d` cannot hand one between them, so the directory is named `felis-smoke-${{ github.run_id }}` (per-run isolation as before) and the daemon creates it `0700`. ## Verification - `just check`: pass, 3571 tests run: 3571 passed, 16 skipped. - `just check-windows`: pass. - macOS hand verification: pending (no macOS host available in this session).
The candidate list exists because the endpoint followed XDG_RUNTIME_DIR
and TMPDIR: a daemon started under one environment could be listening
where another environment never looks, and the doctor dialed the likely
siblings to say so. The endpoint is about to be derived from the uid
alone, which leaves no sibling to probe, since every process of this uid
then resolves the same path.

The stamped target keeps its row: FELIS_SOCKET and --socket still name a
second endpoint on purpose, and the row states what answered there.

Assisted-by: Claude Code
The endpoint was resolved from XDG_RUNTIME_DIR, then /run/user/<uid>,
then ${TMPDIR:-/tmp}/felis.<uid>. Every one of those is login-scoped: a
relay under Tailscale SSH carries no XDG_RUNTIME_DIR and lands on a
second daemon with its own session roster, and on a non-lingering
systemd host logging out of the last session takes /run/user/<uid> down
under a forked daemon that is still alive and now unreachable.

/tmp/felis.<uid> has boot lifetime and needs no lookup and no variable,
which is why tmux derives /tmp/tmux-<uid> the same way. macOS keeps a
per-boot directory of its own, but reaching it needs confstr through
FFI and it is per-session under some sandboxes, so both platforms use
the same /tmp path. A user who wants another location still has
FELIS_SOCKET and --socket.

Assisted-by: Claude Code
/tmp is shared, so between the probe that finds a stale socket and the
bind that replaces it, another starter of this uid can bind the same
name and have its listener unlinked out from under it. The startup
window now runs under an flock on the socket directory, taken on an
O_DIRECTORY|O_NOFOLLOW descriptor and judged from that same descriptor
(owned by this uid, mode exactly 0700), so the judge cannot be raced by
a swap of the path it judged. The lock leaves no file behind and is
released as soon as the bind returns: a serving daemon holds nothing,
or every later starter would wait on it instead of finding it live.

The probe lstats before it connects and classifies what it finds: a
socket whose connect is refused is stale and replaced, a socket served
by a peer of another uid fails the start with the path untouched, and
any other connect failure leaves the path alone rather than guessing.
The bind runs under umask 0177, so the socket is 0600 with no chmod
window, and one lstat after the bind confirms the parent is still the
directory that was judged. Listener::drop no longer unlinks: a dying
daemon must never remove a newer daemon's socket, and the cost is one
stale socket file that the next start replaces.

The dialing side checks the peer uid too (REQ-106), so a dial that
reaches a listener of another uid ends before any application byte and
the relay never sends the carrier block that carries its environment.

Tests that bind a socket now need a 0700 parent, which TempDir does not
give under the usual umask, and the ones that asserted a stopped daemon
unlinks its socket now assert the file survives and the connect is
refused.

Assisted-by: Claude Code
A stopped daemon leaves its socket file for the next start to replace,
so "not running (connection refused)" is the ordinary state of an idle
host rather than a symptom, and a user who reads it as debris may rm a
path felis manages. The row says the next start replaces it.

Anything else at the endpoint is refused by the next start instead of
replaced, so the row names what is there. The lstat does not follow: a
symlink at the endpoint is the user's, and reporting its target would
hide the thing that makes the start fail.

Assisted-by: Claude Code
The endpoint no longer follows the login runtime directory, so every
page that taught the XDG_RUNTIME_DIR sequence, the TMPDIR fallback, or
the lifetime that came with them would teach a resolution that no build
performs. REQ-009b is rewritten around the uid; REQ-009c takes in the
startup probe and excludes a peer-identity failure from the retry;
REQ-009d records the directory lock, the judge, and the exit that
unlinks nothing; REQ-106 has both sides check the peer uid; REQ-107
says a parent is judged and never tightened.

The ipc explanation owns the argument: why /tmp's boot lifetime is the
property being bought, why macOS gets no confstr path, what a shared
/tmp does and does not give an attacker, and the tmpfiles rule that
exempts a live socket. What the docs say about the shared /tmp is that
paragraph and nothing more: an unprivileged squatter of another uid is
refused by the judge, and no sub-second races are advertised as
residuals.

Assisted-by: Claude Code
The product-shipped skill taught the runtime-directory endpoint and the
linger requirement that came with it, and the debug skills told their
readers to chmod or inspect a parent felis now judges and never
tightens. A skill that names a path no build resolves sends its reader
to an empty directory.

Assisted-by: Claude Code
ci: give the smoke daemon a socket directory of its own
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Successful in 2m6s
darwin / detect relevant changes (pull_request) Successful in 3s
darwin / darwin docs-only no-op (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m5s
pr / nix flake check (pull_request) Successful in 31s
windows / detect relevant changes (pull_request) Successful in 3s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 12s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 53s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m41s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 51s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Failing after 12m38s
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
0ae4ec068f
A socket directly under a shared /tmp is refused at daemon start now
that the parent is judged, so the smoke recipes would fail before they
reach a window. Each run passes <base>/<per-run name>/daemon.sock and
lets the daemon create the directory 0700; the per-run name keeps
concurrent runs on the shared runner off each other's daemon, which is
what the per-run socket name did. A mktemp -d would not survive between
the run step and the stop step, which need the same path.

Assisted-by: Claude Code
natsukium force-pushed issue-282 from 0ae4ec068f
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Successful in 2m6s
darwin / detect relevant changes (pull_request) Successful in 3s
darwin / darwin docs-only no-op (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m5s
pr / nix flake check (pull_request) Successful in 31s
windows / detect relevant changes (pull_request) Successful in 3s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 12s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 53s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m41s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 51s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Failing after 12m38s
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
to 782021c267
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 3s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 53s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m6s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m52s
pr / wire schema is compatible with the base (pull_request) Successful in 8s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 57s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 3s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 12s
bench / Criterion regression gate (pull_request) Successful in 2m6s
pr / nix flake check (pull_request) Successful in 32s
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / cargo nextest (Windows) (pull_request) Has been cancelled
2026-09-15 18:23:00 +09:00
Compare
natsukium force-pushed issue-282 from 782021c267
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 3s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 53s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m6s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m52s
pr / wire schema is compatible with the base (pull_request) Successful in 8s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 57s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 3s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 12s
bench / Criterion regression gate (pull_request) Successful in 2m6s
pr / nix flake check (pull_request) Successful in 32s
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / cargo nextest (Windows) (pull_request) Has been cancelled
to 634b16de66
All checks were successful
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Successful in 2m2s
darwin / detect relevant changes (pull_request) Successful in 3s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 52s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m4s
pr / nix flake check (pull_request) Successful in 31s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m43s
pr / wire schema is compatible with the base (pull_request) Successful in 8s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 52s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 3s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 11s
windows / cargo nextest (Windows) (pull_request) Successful in 5m14s
windows / frontend smoke (Windows) (pull_request) Successful in 1m32s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
2026-09-15 18:39:09 +09:00
Compare
natsukium scheduled this pull request to auto merge when all checks succeed 2026-09-15 18:41:50 +09:00
natsukium deleted branch issue-282 2026-09-15 18:52:40 +09:00
Sign in to join this conversation.
No description provided.