daemon: endpoint loss and replacement are not handled (logout removes the runtime directory under a running daemon) #282

Closed
opened 2026-09-14 22:06:04 +09:00 by natsukium · 1 comment
Owner

Problem

A running daemon does not notice when its endpoint disappears or is replaced. The common trigger is logind: on a
host without loginctl enable-linger, the last logout removes /run/user/<uid> while a forked daemon under it
is still running. The daemon keeps its sessions (REQ-008) but nobody can dial it any more; the next felis process
resolves a fresh endpoint and spawns a second daemon, so the roster splits silently. The same applies today to any
daemon started under XDG_RUNTIME_DIR; after #275 the relay's daemon lands there too, so the exposure widens to
every SSH relay on a host with a transient logind session.

Related pre-existing hazards in the same area, found while designing #275 (design review rounds by pi, kept
here as the spec seed):

  1. Exit cleanup unlinks by pathname. Listener::drop (crates/felis-transport/src/unix.rs) removes
    daemon.sock unconditionally. After logind removed and recreated the parent directory, an old daemon exiting
    removes the replacement daemon's socket. A lock file inside the directory proves nothing across the cycle
    (it is recreated with the directory).
  2. The startup probe unlinks on any connect error. socket_is_live treats every connect error as "not live",
    so EMFILE (the startup lock can consume the last descriptor) or EACCES makes a starter unlink a live
    daemon's socket before its own bind fails. Only ENOENT / ECONNREFUSED are evidence of a stale socket.
  3. The stable SSH_AUTH_SOCK link is written by pathname. AgentLink (crates/felis-daemon/src/agent.rs)
    repoints <socket>.agent on every lease release. After a replacement, an old daemon's lease dropping removes
    or overwrites the new daemon's link; clear_stale is another ungated pathname writer. No lstat-then-rename
    check closes this (the gap is the race).
  4. Stamped shells and attached windows keep the dead path. FELIS_SOCKET in every shell of the stranded
    daemon, and the window reconnector, point into a removed directory; a bare felis or sessions spawn there
    fails or (under a user-writable custom directory) recreates the directory and revives the split.

Direction that survived review

  • Watch and drain. serve records the identity of what it bound and polls (10 s, no inotify); loss or
    replacement puts the daemon into the existing draining state (start_draining): new sessions are refused,
    the daemon exits after its last session ends. Admission shutdown is bounded by the poll interval; process
    exit stays conditional on the last session (REQ-008), so draining does not bound an unreachable daemon's
    lifetime, only its ability to accept work.
  • Identity must come from the bound listener, not from a pathname read afterward. Reviewers rejected
    capturing identity by lstat(path) after bind (a directory replaced between bind and capture records the
    replacement's inode) and also a dirfd opened before a pathname-based bind (the listener can land in the new
    directory instance while the dirfd names the old one). What was asked for: make the startup lock, probe,
    unlink, and bind relative to one directory descriptor, derive the socket identity from the bound listener,
    verify the pathname and parent identify that listener before announcing readiness, and abort or retry on
    mismatch. The parent-path comparison must follow symlinks (--socket /tmp/link/daemon.sock binds today).
  • Listener::drop never unlinks; the startup probe handles a leftover socket, with the classification
    from (2): unlink only on ENOENT / ECONNREFUSED, any other connect error fails the bind without touching
    the path.
  • Agent link per daemon instance (<socket>.<pid>.agent; REQ-912b already says "per instance"), written
    through the retained directory descriptor (symlinkat / renameat / unlinkat), detached after loss;
    clear_stale becomes a dead-pid sweep (kill(pid, 0)ESRCH; EPERM counts as live). Docs that spell
    <socket>.agent: docs/reference/ipc.md, docs/reference/terminal-identity.md,
    docs/how-to/attach-over-ssh.md, REQ-912b; CHANGELOG (the SSH_AUTH_SOCK value inside sessions changes).
  • Stale-stamp recovery, Unix only, Default / Stamped provenance only. When the socket directory of such
    an endpoint no longer exists, the client re-resolves the default ignoring FELIS_SOCKET and proceeds under
    Default provenance; explicit --socket, window retarget targets, and Windows pipe names stay exact.
    Reconnector carries provenance; redial_session returns the Reconnector it connected with and the
    reconnect event installs it (today retargeted: None leaves self.reconnector on the stale path, which
    pipe / run read via carrier.local_socket()). Open question from review: the recovery resolver must not
    hand the removed path back when XDG_RUNTIME_DIR / TMPDIR still names it, yet must pick a recreated
    /run/user/<uid> again; "skip an environment directory that does not currently exist" is a behavior change in
    ordinary resolution (ensure_dir_0700 uses create_dir_all, so a missing custom directory is created and
    used today), so the skip has to be scoped to recovery, or the compatibility change accepted and doctor's
    sibling candidates extended accordingly.
  • Docs that become false once the daemon can drain on its own: docs/reference/ipc.md ("draining is
    true between a felis daemon stop --when-empty and the daemon's exit"), docs/reference/cli.md
    (daemon status draining), docs/explanation/architecture/control-surfaces.md ("the daemon still never
    exits on its own"); a new REQ for the invariant, sourced to session-lifecycle.md.

Until then

loginctl enable-linger <user> on hosts that serve persistent daemons keeps /run/user/<uid> alive across
logouts; docs/how-to/attach-over-ssh.md says so after #275.

## Problem A running daemon does not notice when its endpoint disappears or is replaced. The common trigger is logind: on a host without `loginctl enable-linger`, the last logout removes `/run/user/<uid>` while a forked daemon under it is still running. The daemon keeps its sessions (REQ-008) but nobody can dial it any more; the next felis process resolves a fresh endpoint and spawns a second daemon, so the roster splits silently. The same applies today to any daemon started under `XDG_RUNTIME_DIR`; after #275 the relay's daemon lands there too, so the exposure widens to every SSH relay on a host with a transient logind session. Related pre-existing hazards in the same area, found while designing #275 (design review rounds by `pi`, kept here as the spec seed): 1. **Exit cleanup unlinks by pathname.** `Listener::drop` (`crates/felis-transport/src/unix.rs`) removes `daemon.sock` unconditionally. After logind removed and recreated the parent directory, an old daemon exiting removes the *replacement* daemon's socket. A lock file inside the directory proves nothing across the cycle (it is recreated with the directory). 2. **The startup probe unlinks on any connect error.** `socket_is_live` treats every connect error as "not live", so `EMFILE` (the startup lock can consume the last descriptor) or `EACCES` makes a starter unlink a live daemon's socket before its own bind fails. Only `ENOENT` / `ECONNREFUSED` are evidence of a stale socket. 3. **The stable `SSH_AUTH_SOCK` link is written by pathname.** `AgentLink` (`crates/felis-daemon/src/agent.rs`) repoints `<socket>.agent` on every lease release. After a replacement, an old daemon's lease dropping removes or overwrites the new daemon's link; `clear_stale` is another ungated pathname writer. No `lstat`-then-`rename` check closes this (the gap is the race). 4. **Stamped shells and attached windows keep the dead path.** `FELIS_SOCKET` in every shell of the stranded daemon, and the window reconnector, point into a removed directory; a bare `felis` or `sessions spawn` there fails or (under a user-writable custom directory) recreates the directory and revives the split. ## Direction that survived review - **Watch and drain.** `serve` records the identity of what it bound and polls (10 s, no inotify); loss or replacement puts the daemon into the existing draining state (`start_draining`): new sessions are refused, the daemon exits after its last session ends. Admission shutdown is bounded by the poll interval; process exit stays conditional on the last session (REQ-008), so draining does not bound an unreachable daemon's lifetime, only its ability to accept work. - **Identity must come from the bound listener, not from a pathname read afterward.** Reviewers rejected capturing identity by `lstat(path)` after bind (a directory replaced between bind and capture records the replacement's inode) and also a dirfd opened before a pathname-based `bind` (the listener can land in the new directory instance while the dirfd names the old one). What was asked for: make the startup lock, probe, unlink, and bind relative to one directory descriptor, derive the socket identity from the bound listener, verify the pathname and parent identify that listener before announcing readiness, and abort or retry on mismatch. The parent-path comparison must follow symlinks (`--socket /tmp/link/daemon.sock` binds today). - **`Listener::drop` never unlinks**; the startup probe handles a leftover socket, with the classification from (2): unlink only on `ENOENT` / `ECONNREFUSED`, any other connect error fails the bind without touching the path. - **Agent link per daemon instance** (`<socket>.<pid>.agent`; REQ-912b already says "per instance"), written through the retained directory descriptor (`symlinkat` / `renameat` / `unlinkat`), detached after loss; `clear_stale` becomes a dead-pid sweep (`kill(pid, 0)` → `ESRCH`; `EPERM` counts as live). Docs that spell `<socket>.agent`: `docs/reference/ipc.md`, `docs/reference/terminal-identity.md`, `docs/how-to/attach-over-ssh.md`, REQ-912b; CHANGELOG (the `SSH_AUTH_SOCK` value inside sessions changes). - **Stale-stamp recovery, Unix only, `Default` / `Stamped` provenance only.** When the socket *directory* of such an endpoint no longer exists, the client re-resolves the default ignoring `FELIS_SOCKET` and proceeds under `Default` provenance; explicit `--socket`, `window retarget` targets, and Windows pipe names stay exact. `Reconnector` carries provenance; `redial_session` returns the `Reconnector` it connected with and the reconnect event installs it (today `retargeted: None` leaves `self.reconnector` on the stale path, which `pipe` / `run` read via `carrier.local_socket()`). Open question from review: the recovery resolver must not hand the removed path back when `XDG_RUNTIME_DIR` / `TMPDIR` still names it, yet must pick a recreated `/run/user/<uid>` again; "skip an environment directory that does not currently exist" is a behavior change in ordinary resolution (`ensure_dir_0700` uses `create_dir_all`, so a missing custom directory is created and used today), so the skip has to be scoped to recovery, or the compatibility change accepted and `doctor`'s sibling candidates extended accordingly. - **Docs that become false** once the daemon can drain on its own: `docs/reference/ipc.md` ("`draining` is `true` between a `felis daemon stop --when-empty` and the daemon's exit"), `docs/reference/cli.md` (`daemon status` `draining`), `docs/explanation/architecture/control-surfaces.md` ("the daemon still never exits on its own"); a new REQ for the invariant, sourced to `session-lifecycle.md`. ## Until then `loginctl enable-linger <user>` on hosts that serve persistent daemons keeps `/run/user/<uid>` alive across logouts; `docs/how-to/attach-over-ssh.md` says so after #275.
Author
Owner

Plan for #282 (v1 rev 22, 2026-09-15)

Reviewed by pi (sol/luna) over 21 rounds; luna PASS on rev 20 and rev 21, sol BLOCKED on rev 21 with one finding
folded into rev 22 (the post-bind parent check) and the rest being restart orderings the user chose not to defend.
Rev 21-22 cut, by the user's decision, the mechanisms that only closed sub-second races on a multi-user host
(post-bind identity retry, descriptor-relative agent-link writes, hourly link rewrite) and the documentation of those
races as residuals. This comment supersedes the "Direction that survived review" section of the issue body.

The premise this plan changes

#275 (merged as PR #281, plan v3) put the Linux canonical endpoint under the logind runtime directory:
$XDG_RUNTIME_DIR/felis/run/user/<uid>/felis${TMPDIR:-/tmp}/felis.<uid>. Every item in #282's seed exists
because that directory has a login-session lifetime while the daemon has a last-session lifetime (REQ-008): logind
removes /run/user/<uid> at the last logout of a non-lingering uid, a forked daemon under it keeps its sessions but
nobody can dial it, the next felis process resolves a fresh endpoint and starts a second daemon, and the roster splits.
The seed's answers (watch and drain, dirfd-relative bind and identity capture, per-instance agent links, stale-stamp
recovery with provenance plumbing, an availability rule) each harden the daemon against a directory the system is
entitled to take away. Nine v2 review rounds found a hole per round in that hardening.

The mismatch is the premise, not the hardening. A daemon whose reason to exist is outliving the login that started it
must not keep its only endpoint in a directory that dies with a login. The tool felis is measured against here, tmux,
made this call fifteen years ago: /tmp/tmux-<uid>/default, derived from the uid, with TMUX_TMPDIR and -S as the
only overrides, and it ignores XDG_RUNTIME_DIR and TMPDIR for exactly this reason.

Decisions

1. The Unix endpoint is /tmp/felis.<uid>/daemon.sock, derived from the uid alone

OS Default endpoint (no --socket, no FELIS_SOCKET)
Linux /tmp/felis.<uid>/daemon.sock
macOS /tmp/felis.<uid>/daemon.sock
Windows unchanged (SID-derived pipe name)

No environment variable takes part in the default: XDG_RUNTIME_DIR, TMPDIR, and /run/user/<uid> are not
consulted. The two explicit overrides stay exactly as they are: --socket <path> (Explicit provenance) and
FELIS_SOCKET (Stamped; the daemon stamps every session with the path it serves, so a shell inside a session
targets the daemon that owns it). Resolution order stays --socketFELIS_SOCKET → default.

Why macOS uses /tmp and not launchd's per-user directory (v3's choice, dropped): confstr(3) documents that the
contents of _CS_DARWIN_USER_TEMP_DIR may be deleted after three days, _dirhelper consults
DIRHELPER_USER_DIR_SUFFIX, and confstr falls back to TMPDIR internally, so that directory is neither derived
from the uid alone nor guaranteed to live until reboot; it is what tmux avoids on macOS too (/tmp/tmux-<uid>).
On Linux the kernel's fs.protected_symlinks (default 1 on every mainstream distribution, this host included)
refuses to follow a symlink another uid plants in a sticky world-writable directory; macOS has no equivalent, which
is why the client-side uid check is unconditional on both.

Why /tmp and not the logind directory: /tmp has the lifetime the daemon needs (the boot), on every Unix felis
targets, with or without systemd, with or without pam_systemd (the Tailscale SSH case of #275), and identically for a
desktop login, an SSH login, and a relay. Logging out does not remove it; between boots only a tmp cleaner touches
it, and only stale entries and empty directories (below). A reboot ends the daemon;
whether the socket inode survives depends on the mount (tmpfs /tmp is empty after boot, a disk-backed /tmp keeps
the stale socket, whose connect is refused), and either way the next daemon binds the same path, which is what a stamp
left in a shell names. The startup sequence in decision 2 is what makes the persistent-/tmp case safe.

Why the uid alone and not "environment first, uid second" (v3): the split in #275 came from one process lacking a
variable another had. v3 kept "set wins" because it "costs nothing" for the absent case; it costs the login-session
lifetime in the common case (every desktop login exports XDG_RUNTIME_DIR=/run/user/<uid>), which is the whole of
#282. A user who wants a different location has FELIS_SOCKET / --socket, and doctor already reports a stamped or
explicit target that differs from the default.

Why not $HOME: unchanged from v3 (NFS, sun_path length, a backed-up directory).

Every socket parent is vetted before use, with one rule. The daemon creates the parent (mkdir 0700) when it
is absent and otherwise judges it from the opened, locked descriptor (fstat after flock, so what is judged
is what is used; the facts are the ones PR #281's judge_canonical_dir already checks for /run/user/<uid>): a
directory, not a symlink (O_NOFOLLOW at open), owned by the uid, access bits (mode & 0o777) exactly 0700
(setgid and sticky bits are ignored, as the judge's DirFacts already does); anything else is a hard error naming
what was found and the recovery (rm or chown by the owner, or another --socket). This is tmux's check_dir
and it closes the squat on /tmp/felis.<uid>: another uid creating it or a symlink there cannot redirect the socket.
The same rule applies to an explicit endpoint's parent: --socket <dir>/a.sock requires <dir> to be the user's
own 0700 directory (two daemons may share it), and --socket /tmp/x.sock or /run/x.sock is refused. felis
judges the parent only; the parent's ancestors are the user's contract, as ~/.ssh's are for ssh: --socket's
reference says to place the directory where no other user can rename it (not under a world-writable, non-sticky
directory), and the peer-uid check on both sides keeps a swapped parent from ever joining two uids. Today's
ensure_dir_0700 "tightens rather than refuses" and would chmod 0700 whatever parent it is given, /tmp included
when run as root; it goes, and the docs' --socket examples move into dedicated directories. The judge runs in the
daemon's bind and nowhere else.
A client neither creates nor judges any parent: it connects to the path it
resolved (default, FELIS_SOCKET, --socket, or a window retarget target) and is protected by the peer-uid check
below, not by inspecting directories; a squatted or missing parent shows up as a refused or absent dial, the
ordinary cold case, and the daemon it may spawn is the one process that judges. One helper, one call site, so
retarget's LocalEndpoint and the relay's override cannot bypass it, and a daemon today's build started at
--socket /tmp/x.sock is still reachable and stoppable from a shell it stamped.

Both sides verify the peer before application bytes. After every Unix connect and before the first byte of the
preface, the client checks the listener's uid against its own with the peer-credential helper the daemon already
uses on accept (verify_peer_uid: SO_PEERCRED on Linux, getpeereid on the Apple/BSD targets); a mismatch ends
the dial with a named error and nothing has been sent. The check lives in the shared transport connect path (felis_transport::local::connect, before the stream is
split), so every dialer inherits it: the client connector, felis-daemon relay, and the preface probe that
doctor uses (a probe of a foreign listener reports the mismatch and sends nothing). The daemon's own startup
probe (socket_is_live, a separate synchronous connect) applies the same check: a listener of another uid at the
daemon's path is neither "live" nor "absent" but a hard start error that touches nothing. REQ-106 is amended from "the peer's UID is verified on every connection" to state both
directions. This is what makes the pathname connect safe regardless of what a cleaner or another uid did to the
path in between: the relay's carrier block (its whole environment) can no longer reach a foreign listener.
This check and the judge are the two defenses this plan builds against a shared /tmp.

What the docs say about the shared /tmp, and nothing more. Another local uid can create /tmp/felis.<uid>
(or a symlink there) before this uid's first start; the daemon then refuses to start, names what it found, and the
recovery is an administrator removing the entry (/tmp is sticky, so the victim cannot) or FELIS_SOCKET /
--socket pointing at a dedicated directory. That is the whole user-facing statement; it is tmux's since
/tmp/tmux-<uid> exists. Sub-second coincidences (a cleaner emptying the directory inside the startup window, a
foreign entry arriving between the judge and the bind, an owner removing a live socket, a symlink aged under a
ten-day-idle relay) are not documented and not defended: rev 17-21 of this plan built and then dropped mechanisms
for them by the user's decision, because a reader can do nothing with them and a single-user host never sees them;
the one kept is decision 2's post-bind parent check, because its failure mode would hand sessions a foreign agent
socket and it costs five lines.

Tmp cleaners. The directory holds two felis-written entries, daemon.sock (a socket) and daemon.sock.agent
(a symlink), plus whatever earlier builds left. Every cleaner in its default configuration leaves a live socket
alone: systemd-tmpfiles skips any AF_UNIX socket present in /proc/net/unix (unix_socket_alive, commit
17b9052533, 2011; the kernel records there the pathname given to bind and never updates it, so a socket must be
bound at the name it keeps, which is why decision 2 binds at the final name and never renames a listener); macOS
periodic daily removes only regular files and empty directories; tmpwatch judges by access time by default,
with the age and the schedule supplied by the distribution's cron entry, and removes only sockets older than the
boot. A dead socket, a stale link, and then the empty directory may be aged after a daemon is gone; the
next start recreates all three. The design relies on no cleaner honoring a lock. The macOS statement is to be
verified on a Mac before the release note calls macOS supported (hand verification item, as in v3).

Known limits, stated in the docs. A process with a private /tmp (PrivateTmp= services, bwrap/flatpak
sandboxes) sees its own /tmp/felis.<uid>. Its fork spawns a daemon only it can reach; its systemd hand-off (#261)
asks the user manager, which runs in the host mount namespace, so that daemon binds the host's /tmp/felis.<uid>,
the launcher's retry fails to reach it, and the fork fallback starts a second, private one. felis is not supported
from inside such a sandbox; non-goals.md says so and names the consequence (a host-side daemon the sandbox cannot
reach, visible to felis doctor from any ordinary shell). No detection is built.

2. The startup lock is the directory; a daemon removes a socket path only inside it, after the connect classification

  • The startup lock moves from the <socket>.lock file to the socket's parent directory. The sequence, all inside
    bind:
    1. mkdir: hold UMASK_LOCK, install umask(0077), mkdir(path, 0700), restore the previous mask on every
      result (mkdir is filtered by the process umask, so an inherited 0777 would otherwise create a 0000
      directory the judge refuses); then open(O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); blocking exclusive flock;
      then judge from the fd (fstat: directory, owned by the uid, access bits 0700). Judging the descriptor
      rather than the pathname means what is judged is what is locked. ENOENT or ELOOP at the open, and a judge
      failure, are bind errors naming what was found; no retry loop (ENOENT here needs a cleaner to remove the
      directory inside this window, and the user's next start recreates it).
    2. Probe daemon.sock (rule below). A live daemon answers AddrInUse here and nothing has been written.
    3. Unlink what the rule allows at daemon.sock; bind the listener at that final name under umask(0177)
      (today's 0077 yields 0700, which is why the code still chmods afterwards; 0177 creates the socket at
      0600 and the pathname chmod goes). The name is absent only inside the lock window; a client that dials
      then sees ENOENT, spawns, and its daemon waits on the lock and finds this one live, as today.
    4. After the bind, one check: lstat(parent) device and inode equal fstat(dirfd)'s; on a mismatch drop the
      listener and fail the start naming the parent (no retry). This is five lines and it is what keeps a daemon
      from serving inside a directory another uid swapped in between the judge and the bind, where that uid could
      replace <socket>.agent with a socket of its own and feed sessions a foreign SSH_AUTH_SOCK; the peer-uid
      check covers the daemon socket, not the link.
    5. Release the lock and close the directory fd. bind returns the Listener as today; serve clears a stale
      agent link and accepts, as today.
      The probe's lstat and unlink and the bind are pathname operations inside a window whose parent was just
      judged and locked; the plan accepts them as such (a pathname that stops meaning the judged directory inside the
      window yields a failed start by step 4, never a dial between two uids, because the peer-uid check protects every
      dial). A lease released while the daemon exits (connection tasks drop with the runtime, microseconds after
      serve returns) writes the link once more; a successor's link exists only after its own first lease, an attach
      later, so nothing is built or written for that ordering. Nothing holds the lock for the daemon's
      lifetime, so there is no contention with a live daemon, no bounded wait, and the client's and relay's
      spawn-once-then-retry-connect is untouched: a waiting starter waits on another starter's window (milliseconds),
      as today, and a live daemon holds no startup lock, so step 2 reaches it. Sibling --socket daemons in one
      dedicated directory serialize their startups and nothing else. The listener is bound at the name it keeps, so
      /proc/net/unix names it and tmpfiles exempts it. StartupLock and the .lock file go.
  • Listener::drop no longer unlinks daemon.sock. A stopped daemon leaves its socket file; the next starter's probe
    removes it. This removes the last pathname write a daemon performs after bind other than the agent link, so an
    old daemon exiting can never remove a newer daemon's socket. (An owner who rms a live socket gets a second
    daemon on the path; the how-to says felis daemon stop, and nothing else is written about it.)
  • The startup probe (socket_is_live) becomes a non-following lstat first, then the REQ-009c classification: an
    absent name needs nothing; a directory or a symlink fails the start untouched (felis never removes a directory; a
    symlink at the endpoint name is the user's: --socket may name a link to a socket another daemon serves, which
    today's probe follows and refuses); a regular file or other non-socket inode is removed (today's
    stale_socket_file_is_replaced test keeps passing); a socket inode is connected to, the listener's uid checked
    (a foreign uid is a hard start error), and only ENOENT or ECONNREFUSED proves nobody is listening and
    licenses unlink + bind, while any other connect error (EMFILE, EACCES, EPERM…) fails the bind with that
    error and touches nothing (today any error unlinks). REQ-009c's text drops its "startup probe excluded" clause;
    the same helper (connect_error_is_absent) serves both.
  • What the v3 build leaves in the directory needs no special case: its <socket>.agent and <socket>.agent.new
    symlinks are what clear_stale already handles; its <socket>.lock file is harmless litter felis never reads
    (the cleaners age it; the user may remove it). Pre-release, no build-to-build cleanup is written.
  • doctor gains a stale-socket classification: today a refused connect renders through the generic
    not running (connect: …) path; the PR adds the ConnectionRefused arm and, only when a non-following lstat
    of the endpoint shows a socket inode, renders "not running (stale socket, replaced on the next start)"; a
    directory, a symlink, or a regular file at the endpoint keeps the generic refused wording plus what was found,
    because the start would not replace it (directory, symlink) or the promise would be about a different inode.

AgentLink keeps <socket>.agent as a symlink to the newest live relay's forwarded SSH_AUTH_SOCK (sshd's
/tmp/ssh-XXXX/agent.N), rewritten by stage-and-rename on every lease change, exactly as today; agent.rs does not
change and REQ-912b does not change. A cleaner may age the link under a relay idle for its age (ten days by
default); the next lease change rewrites it. Not documented, not mechanized.

Why a symlink and not a served socket (rev 5-16 of this plan, dropped by the user's decision): a socket the daemon
serves is exempt from every cleaner without a timer, but it is a new subsystem, two directional copies with agent
frame counting and a bounded drain, its own admission bound, a daemon status row and a wire minor bump. Why no
hourly rewrite and no descriptor-relative writes (rev 17-20, dropped by the user's decision): they defended against a
cleaner ageing a link under a ten-day-idle relay and against a directory replaced under a running daemon; the first
is repaired by the next attach, the second needs the owner or root in /tmp/felis.<uid>; neither is worth a task
with a lifetime of its own and a directory handle threaded through bind, serve, and AgentLink.

4. Everything else in the #282 seed is closed by decisions 1-3, not built

Out of scope, stated: two different builds of felis-daemon racing to start on one endpoint (a v3 binary taking
<socket>.lock while this build takes the directory lock) are not coordinated. The update how-to drains the old
daemon before any new window launches, and every autospawn runs the dialing process's own binary (REQ-009a), so the
race needs two builds installed and two clients of different builds dialing one cold endpoint at once; carrying the
lock file forward for that case would keep the aged-lock-file hazard for everyone.

Seed item Outcome
Endpoint watch (10 s poll) and drain on loss/replacement Not built. Nothing removes /tmp/felis.<uid> under a running daemon except the user or a reboot.
dirfd-relative lock/probe/unlink/bind, identity capture Not built. The lock is the directory fd and the judge reads that fd; the probe, unlink, and bind stay pathname operations inside the locked window (decision 2). A directory replaced under a daemon needs its owner or root in /tmp/felis.<uid>.
Per-instance agent link <socket>.<pid>.agent, symlinkat writes, dead-pid sweep Not built. Two daemons share one path only after the owner rms a live socket; one link per path, written as today, is right.
Stale-stamp recovery, provenance in Reconnector Not built. A stamp names the canonical path; after a reboot the same path is bound again.
Availability rule for environment directories Moot. No environment directory takes part in the default.
"draining is only ever set by daemon stop --when-empty", "the daemon never exits on its own" Stay true. No new REQ.
loginctl enable-linger Stays in the docs for a different reason: a daemon the systemd hand-off (#261) placed under the user manager stops with user@<uid>.service, which logind stops UserStopDelaySec (default 10 s; infinity keeps it until shutdown) after the last logout of a non-lingering uid. That is the user manager's contract, not the endpoint's; a forked daemon survives (KillUserProcesses=no). The how-to states this precisely instead of "keeps /run/user/<uid> alive".

What PR #281's code becomes

Keep: the raw-connect classification and ConnectError::Connect (REQ-009c), dial_bounded / ProbeOutcome, doctor's
"running, but…" arms and the daemon-sibling row (see below), judge_canonical_dir (retargeted at /tmp/felis.<uid>
itself), the drain how-to, empty-variable normalization is moot but harmless where it survives for FELIS_SOCKET.

Remove: the XDG_RUNTIME_DIR / /run/user/<uid> / TMPDIR chain and resolve_lazily, CanonicalDir::{Absent, Unreadable} (the judge's only outcomes are usable / unusable; a not-found after our own mkdir is an I/O error), the
relative-XDG_RUNTIME_DIR warn, SocketPathError::RuntimeDirUnreadable, the macOS confstr FFI, ensure_dir_0700
(the judge covers every parent), doctor's /tmp and $TMPDIR sibling candidates. StartupLock and the .lock
file go (the lock is the directory fd); AgentLink is unchanged (decision 3).
The relay's resolver tests (crates/felis-daemon/src/relay.rs, using CanonicalDir, EnvDirs,
SocketPath::resolve_from) are rewritten against the uid-only path.

Doctor's daemon-sibling row keeps only its T/D reasoning: a stamped target T that differs from the default D
is reported with D probed ("this shell targets T; the default endpoint D has a live daemon" or "is cold"); an
explicit --socket target keeps today's behavior and gets no sibling reasoning (endpoint_report returns before
resolving the default). The candidate list (previous builds' locations) is dropped: felis has not shipped, and a
daemon an earlier build left elsewhere is stopped from a shell inside one of its sessions, where the stamped
FELIS_SOCKET still names it (felis daemon stop --when-empty). The daemon-sibling schema token stays.

Tests that moved the default directory through XDG_RUNTIME_DIR / TMPDIR (crates/felis-cli/tests/cli_sessions.rs
spawn_default_socket_daemon, for window retarget's DefaultLocal carrier) set FELIS_SOCKET instead; if
DefaultLocal turns out not to honor the stamp, the test is rewritten to what the carrier does resolve, never a
felis-owned root variable (that is the env dependence being removed). The doc comments in felis-daemon/src/main.rs,
felis-client/src/main.rs, and the cli tests naming $XDG_RUNTIME_DIR/felis are updated.

Requirements

  • REQ-009b (rewritten): "The daemon endpoint is derived from the uid alone: /tmp/felis.<uid>/daemon.sock on Unix,
    the SID-derived pipe on Windows. No environment variable takes part; --socket and FELIS_SOCKET are the only
    overrides. The default directory is created 0700 by the daemon and refused when it exists as anything but a
    0700 directory owned by the uid (a symlink is refused). Every process of one uid, the relay included, resolves
    it the same way."
  • REQ-009c (amended): the startup probe joins the rule (drop the exclusion), and "A failure after the connect
    succeeded keeps the spawn-and-retry behavior" gains the exclusion "except a peer-identity failure (REQ-106: a uid
    mismatch or a credential query that fails), which ends the dial with that error on every path"; a dialer must not
    spawn a daemon at a path where something of another uid answers. Tests: the client autospawn and the relay with an
    injected foreign-uid listener report the mismatch and never call their spawn hook.
  • REQ-009d (new): "A daemon holds an exclusive BSD lock on its socket directory while it probes, unlinks and binds
    its socket, and at no other time; the directory is judged from the locked descriptor. Inside that window it
    unlinks, without following, a non-socket inode at its path (a directory or a symlink there fails the start
    untouched) and a socket inode only after a connect to it failed with the REQ-009c absence errors; after the bind
    it verifies that the parent path still names the locked directory and otherwise fails the start. Exit leaves the
    path in place." Source: ipc.md explanation (decision 2).
  • REQ-912b: unchanged.
  • REQ-107 (amended): "The socket directory is mode 0700 and the socket 0600, created under an explicit umask; a
    socket parent that exists as anything but a 0700 directory owned by the uid (a symlink included) is refused,
    never tightened." Source: security-model.md "Daemon IPC".
  • REQ-106 (amended): "The peer's UID is verified on every Unix connection by both sides before any application byte:
    the daemon on accept, the dialer after connect (SO_PEERCRED on Linux, getpeereid on macOS and the BSDs)."
    Source: security-model.md "Daemon IPC".
  • security-model.md "Daemon IPC" gains the shared-/tmp squat check as the reason for the judge.

Doc cascade (doc-cascade skill; grep sweep: XDG_RUNTIME_DIR, TMPDIR, /run/user, felis.<uid>, confstr,

DARWIN_USER_TEMP_DIR, linger, runtime directory, daemon-sibling, socket_is_live, on drop, --socket /,
retarget /, .sock in every how-to and reference example, alt.sock, x.sock, LOCAL_PEERCRED, getpeereid,
SO_PEERCRED)

  • docs/explanation/architecture/ipc.md: replace the "absent runtime variable resolves to the canonical location"
    section and the "runtime directory's lifetime is the known limit" paragraph with decision 1 (rationale, tmux
    precedent, tmpfiles fact with the commit cited, the sandbox limit); rejected alternatives: the logind runtime
    directory (v3, superseded: login lifetime ≠ daemon lifetime; every #282 mechanism was its price), $HOME, relay
    probing (as before), a watch-and-drain daemon (hardening against a directory the system may remove is the wrong
    layer when a directory it does not remove exists); Revisit if a target platform mounts a per-login /tmp.
    Decision 2 in the "Daemon IPC" / bind paragraph.
  • docs/explanation/architecture/session-lifecycle.md (lines ~396, ~947): the "replacement that has never heard of
    the session" and "lingers" paragraphs become the user-manager statement from decision 3; "Where an auto-spawned
    daemon lands" states that the hand-off's daemon dies with the user manager unless the uid lingers, the fork does not.
  • docs/reference/cli.md "Carrier resolution order"; docs/reference/ipc.md lines ~91-103 (the whole endpoint
    paragraph: environment-dependent resolution, runtime-directory lifetime, and the .agent sentence, rewritten to
    the uid-only /tmp endpoint; the .agent sentence stays);
    docs/reference/workspace.md directory layout (daemon.sock, daemon.sock.agent, no lock file);
    docs/reference/spec.md (REQs above); docs/reference/security-audits.md (lines ~17-27: the "Socket-dir
    creation" row names ensure_dir_0700 and the environment-derived directories, the "Socket chmod" row the
    post-bind chmod; both are replaced by the locked-directory open and judge, the bind under umask(0177), and
    the post-bind parent check).
  • docs/explanation/security-model.md "Daemon IPC": the dialer-side uid check (REQ-106) and the shared-/tmp squat statement;
    docs/reference/ipc.md connection establishment: verification by both sides;
    docs/explanation/implementation.md (lines ~408-414 name LOCAL_PEERCRED for macOS while peer.rs uses
    getpeereid; one contract, getpeereid, everywhere) and REQ-106's own text likewise.
  • docs/explanation/security-model.md "Daemon IPC": the /tmp judge and what the directory holds.
  • docs/explanation/non-goals.md line 37 (recovery "as long as the runtime directory is still there" → "until the
    host reboots"); private-/tmp sandboxes with the hand-off consequence from decision 1.
  • docs/how-to/attach-over-ssh.md: delete the macOS TMPDIR note (already obsolete), "Single daemon per user"
    becomes unconditional for processes without an override, the linger note restated per decision 3.
  • docs/how-to/update-felis.md: the "Check where your daemons are" section (the env -u FELIS_SOCKET felis doctor
    discovery sequence added by #281) is replaced by one paragraph: a daemon an earlier build started may listen
    elsewhere; stop it from a shell inside one of its sessions, where FELIS_SOCKET names it, before launching a
    window on the new endpoint; the remote section runs the same over ssh.
  • docs/reference/cli.md --socket and every doc or test example naming a socket in a shared directory
    (/tmp/x.sock): a dedicated 0700 directory owned by the user is required (grep --socket /tmp, x.sock).
  • skills/felis/SKILL.md: the endpoint rule sentence and the paragraph after it (lines ~219-230 say the endpoint
    lives under the login runtime directory and needs linger to stay reachable; linger stays only as the hand-off
    daemon's lifetime condition, per decision 4).
  • .agents/skills/felis-macos-gui-debug/SKILL.md (~186), .agents/skills/perf-trace/SKILL.md (~154),
    .agents/skills/perf-trace/scripts/samply-macos.sh (~5): they describe the parent chmod / tighten behavior;
    .agents/skills joins the grep sweep.
  • Non-doc mirrors that pass --socket with a parent the bind rule refuses: justfile smoke and smoke-headless
    (${TMPDIR:-/tmp}/felis-smoke.sock), .forgejo/workflows/pr.yml linux-smoke and .forgejo/workflows/release.yml
    (${RUNNER_TEMP:-/tmp}/$SMOKE_SOCKET_NAME, and the pkill -f pattern that names it). Each creates a per-run
    0700 directory (mktemp -d under the same base, so concurrent runs on the shared runner keep separate
    directories as the per-run name did) and passes <dir>/daemon.sock; the stop step's pattern follows. Forgejo
    Actions files: edit only those two lines and the env block, nothing else in the workflow.
  • CHANGELOG.md (unreleased): replace the #275 endpoint entry: "the daemon endpoint is /tmp/felis.<uid>/daemon.sock
    on Linux and macOS, derived from the uid alone; XDG_RUNTIME_DIR and TMPDIR no longer take part; a stopped daemon
    leaves its socket file for the next start to replace; a starter refuses to unlink a socket whose connect failed for
    any reason other than absence or refusal; an explicit --socket / FELIS_SOCKET path must lie in a dedicated
    directory owned by the uid with mode 0700, and a socket directly under a shared directory such as /tmp
    (--socket /tmp/x.sock) is refused at daemon start".

Tests

  • Resolver table: XDG_RUNTIME_DIR, TMPDIR set to absolute, relative, empty, and /run/user/<uid> present or
    absent all resolve /tmp/felis.<uid>/daemon.sock; FELIS_SOCKET and --socket win as before.
  • Judge on every parent (temp-root seam for unit tests), in the daemon's bind only, for the canonical directory
    and for a dedicated --socket directory alike; a client resolution and a retarget target neither create nor
    judge anything (test: a client dials a path whose parent is /tmp and gets the connect result): fresh → created
    0700 by the daemon only, under an inherited umask of 077 and of 0777 alike (the umask guard); existing
    0700 own dir → ok; symlink → refused; regular file → refused; foreign uid (injected facts for the fd judge) →
    refused; mode 0750 → refused and not tightened; --socket /tmp/x.sock → refused naming /tmp; error text
    names the path and the recovery.
  • Listener drop leaves the socket file; a second bind over the leftover succeeds (refused → unlink → bind); a bind
    over a live socket fails AddrInUse from the probe (unchanged); a listener of another uid at the path (injected
    credential) fails the start and the path survives; the classification is unit-tested with injected connect errors
    through connect_error_is_absent (a real mode 0000 socket yields EACCES only on some Unixes and never as root,
    so that test runs only after a preflight connect confirms EACCES) and the non-absence path leaves the file in
    place; a symlink at an explicit endpoint path pointing at a live daemon's socket fails the bind, target and link
    untouched; a directory at the endpoint path fails the bind untouched; a regular file there is unlinked (existing
    test kept); a client dialing a listener served by another uid (unit-tested through the peer helper's injected
    credential and the dial's error path) ends the dial before the preface, the relay sends no carrier block, and
    probe reports the mismatch; the socket's mode after bind is 0600 without any chmod; the parent renamed away
    and replaced between the judge and the bind (seam: a hook between the steps) fails the start with the listener
    dropped and nothing served; after bind returns,
    /proc/net/unix (Linux) names the socket at its final path; while a starter is inside its startup window,
    flock(LOCK_EX | LOCK_NB) on the directory from another fd fails EWOULDBLOCK, and after bind returns it
    succeeds (no lifetime hold); two explicit endpoints in one dedicated parent serve concurrently; no .lock file
    is created; an autospawn (client and relay) of the default endpoint ends with daemon.sock and, after the first
    lease, daemon.sock.agent as the directory's only felis-written entries (litter of earlier builds, .lock and
    .agent.new, is exempt and left to the cleaners).
  • Agent link: existing agent.rs tests kept unchanged; SSH_AUTH_SOCK in a spawned session equals
    <socket>.agent (existing test).
  • Doctor: stale socket wording only for a socket inode, generic refused wording plus what was found for a
    directory, symlink, or regular file; sibling row with T ≠ D and D live, and with D cold; no candidate list;
    explicit --socket unchanged.
  • cli_sessions.rs retarget test on FELIS_SOCKET.
  • Existing tests the change rewrites: cli_daemon.rs stop_on_an_empty_daemon_reports_stopping_and_ends_it
    (waits for the socket file to disappear; now waits for connection refused and asserts the file remains);
    cli_systemd_handoff.rs cold_socket (creates the parent with the process umask; now 0700, or lets the daemon
    create it); every other explicit-socket helper is audited for a parent created without 0700; the relay resolver
    tests; stale_socket_file_is_replaced (kept).
  • Removed with the code: the /run/user transition tests, macOS TMPDIR/confstr cells, the sibling candidate
    tests, ensure_dir_0700's tighten test.

Acceptance

  • A relay under Tailscale SSH, a desktop login, an SSH login with pam_systemd, and a felis run with
    XDG_RUNTIME_DIR unset or pointed elsewhere all dial /tmp/felis.<uid>/daemon.sock.
  • Logging out of every session on a non-lingering systemd host leaves a forked daemon reachable from the next
    login (today: unreachable, roster splits); a hand-off daemon is stopped by the user manager, and the docs say so.
  • just check and just check-windows green; macOS hand verification recorded as done or pending in the PR.

Rejected here

  • Keeping the logind directory and building the seed (watch/drain, dirfd identity, per-instance links, stamp
    recovery): five mechanisms, each reviewed to a hole, to defend a placement that no comparable tool uses.
  • ${TMPDIR:-/tmp} or a felis-owned FELIS_TMPDIR root: any variable in the default is the #275 split again; the
    explicit overrides already exist.
  • Unlink-on-drop guarded by inode identity captured after bind: correct in every scenario left, but keeps a window,
    while "never unlink after bind" has none and costs one stale file the startup probe already handles.
  • A sticky bit against tmp cleaners: it cannot be set on a symlink, and the socket needs nothing.
  • Holding an explicit endpoint's parent directory for the daemon's lifetime (rev 2-3): blocks a sibling --socket
    daemon in the same parent and switches off age cleaning for an arbitrary user directory (/tmp itself for
    --socket /tmp/x.sock); the startup-only hold of decision 2 does neither.
  • A daemon-held lifetime lock as a cleaner shield (rev 2-4): only systemd-tmpfiles honors it (tmpwatch and macOS
    periodic do not), and combined with the startup lock it made every later starter wait on a live daemon; a
    directory that holds only sockets needs no shield.
  • Serving <socket>.agent as a proxy socket (rev 5-16): exempt from every cleaner without a timer, but a new
    subsystem (two directional copies, agent frame counting, a bounded drain, its own admission bound, a daemon status row, a wire minor bump) to spare an hourly fstatat and an occasional renameat; dropped by the user's decision.
  • Post-bind identity verification with a retry from mkdir (the single check without retry is kept), descriptor-relative probe/unlink/agent-link writes
    through a SocketDir handle, and an hourly link rewrite with a serve-owned task (rev 17-20): each closed a
    sub-second coincidence on a multi-user host with a hostile local account (a cleaner emptying the directory in the
    startup window; a directory replaced under a running daemon, which in /tmp/felis.<uid> only the owner or root
    can do; a symlink aged under a ten-day-idle relay), none of them narrows the predictable-name squat that is the
    real multi-user exposure, and none matters on a single-user host; dropped by the user's decision.
  • Dropping the stable agent path altogether (REQ-912b): the tmux workaround it replaces is the kind of thing felis
    exists to make unnecessary; kept.
  • Per-instance agent link names: one live daemon per path is guaranteed by the probe except after rm of a live
    socket by its owner, which is not arbitrated.
  • Keeping <socket>.lock: a regular file every cleaner ages, which is the only way the lock's inode could be
    replaced under a starter.
# Plan for #282 (v1 rev 22, 2026-09-15) Reviewed by pi (sol/luna) over 21 rounds; luna PASS on rev 20 and rev 21, sol BLOCKED on rev 21 with one finding folded into rev 22 (the post-bind parent check) and the rest being restart orderings the user chose not to defend. Rev 21-22 cut, by the user's decision, the mechanisms that only closed sub-second races on a multi-user host (post-bind identity retry, descriptor-relative agent-link writes, hourly link rewrite) and the documentation of those races as residuals. This comment supersedes the "Direction that survived review" section of the issue body. ## The premise this plan changes #275 (merged as PR #281, plan v3) put the Linux canonical endpoint under the logind runtime directory: `$XDG_RUNTIME_DIR/felis` → `/run/user/<uid>/felis` → `${TMPDIR:-/tmp}/felis.<uid>`. Every item in #282's seed exists because that directory has a *login-session* lifetime while the daemon has a *last-session* lifetime (REQ-008): logind removes `/run/user/<uid>` at the last logout of a non-lingering uid, a forked daemon under it keeps its sessions but nobody can dial it, the next felis process resolves a fresh endpoint and starts a second daemon, and the roster splits. The seed's answers (watch and drain, dirfd-relative bind and identity capture, per-instance agent links, stale-stamp recovery with provenance plumbing, an availability rule) each harden the daemon against a directory the system is entitled to take away. Nine v2 review rounds found a hole per round in that hardening. The mismatch is the premise, not the hardening. A daemon whose reason to exist is outliving the login that started it must not keep its only endpoint in a directory that dies with a login. The tool felis is measured against here, tmux, made this call fifteen years ago: `/tmp/tmux-<uid>/default`, derived from the uid, with `TMUX_TMPDIR` and `-S` as the only overrides, and it ignores `XDG_RUNTIME_DIR` and `TMPDIR` for exactly this reason. ## Decisions ### 1. The Unix endpoint is `/tmp/felis.<uid>/daemon.sock`, derived from the uid alone | OS | Default endpoint (no `--socket`, no `FELIS_SOCKET`) | | ------- | ------------------------------------------------------------------------- | | Linux | `/tmp/felis.<uid>/daemon.sock` | | macOS | `/tmp/felis.<uid>/daemon.sock` | | Windows | unchanged (SID-derived pipe name) | No environment variable takes part in the default: `XDG_RUNTIME_DIR`, `TMPDIR`, and `/run/user/<uid>` are not consulted. The two explicit overrides stay exactly as they are: `--socket <path>` (`Explicit` provenance) and `FELIS_SOCKET` (`Stamped`; the daemon stamps every session with the path it serves, so a shell inside a session targets the daemon that owns it). Resolution order stays `--socket` → `FELIS_SOCKET` → default. Why macOS uses `/tmp` and not launchd's per-user directory (v3's choice, dropped): `confstr(3)` documents that the contents of `_CS_DARWIN_USER_TEMP_DIR` may be deleted after three days, `_dirhelper` consults `DIRHELPER_USER_DIR_SUFFIX`, and `confstr` falls back to `TMPDIR` internally, so that directory is neither derived from the uid alone nor guaranteed to live until reboot; it is what tmux avoids on macOS too (`/tmp/tmux-<uid>`). On Linux the kernel's `fs.protected_symlinks` (default `1` on every mainstream distribution, this host included) refuses to follow a symlink another uid plants in a sticky world-writable directory; macOS has no equivalent, which is why the client-side uid check is unconditional on both. Why `/tmp` and not the logind directory: `/tmp` has the lifetime the daemon needs (the boot), on every Unix felis targets, with or without systemd, with or without pam_systemd (the Tailscale SSH case of #275), and identically for a desktop login, an SSH login, and a relay. Logging out does not remove it; between boots only a tmp cleaner touches it, and only stale entries and empty directories (below). A reboot ends the daemon; whether the socket inode survives depends on the mount (tmpfs `/tmp` is empty after boot, a disk-backed `/tmp` keeps the stale socket, whose connect is refused), and either way the next daemon binds the same path, which is what a stamp left in a shell names. The startup sequence in decision 2 is what makes the persistent-`/tmp` case safe. Why the uid alone and not "environment first, uid second" (v3): the split in #275 came from one process lacking a variable another had. v3 kept "set wins" because it "costs nothing" for the absent case; it costs the login-session lifetime in the common case (every desktop login exports `XDG_RUNTIME_DIR=/run/user/<uid>`), which is the whole of #282. A user who wants a different location has `FELIS_SOCKET` / `--socket`, and doctor already reports a stamped or explicit target that differs from the default. Why not `$HOME`: unchanged from v3 (NFS, `sun_path` length, a backed-up directory). **Every socket parent is vetted before use, with one rule.** The daemon creates the parent (`mkdir 0700`) when it is absent and otherwise judges it **from the opened, locked descriptor** (`fstat` after `flock`, so what is judged is what is used; the facts are the ones PR #281's `judge_canonical_dir` already checks for `/run/user/<uid>`): a directory, not a symlink (`O_NOFOLLOW` at open), owned by the uid, access bits (`mode & 0o777`) exactly `0700` (setgid and sticky bits are ignored, as the judge's `DirFacts` already does); anything else is a hard error naming what was found and the recovery (`rm` or `chown` by the owner, or another `--socket`). This is tmux's `check_dir` and it closes the squat on `/tmp/felis.<uid>`: another uid creating it or a symlink there cannot redirect the socket. The same rule applies to an explicit endpoint's parent: `--socket <dir>/a.sock` requires `<dir>` to be the user's own `0700` directory (two daemons may share it), and `--socket /tmp/x.sock` or `/run/x.sock` is refused. felis judges the parent only; the parent's ancestors are the user's contract, as `~/.ssh`'s are for ssh: `--socket`'s reference says to place the directory where no other user can rename it (not under a world-writable, non-sticky directory), and the peer-uid check on both sides keeps a swapped parent from ever joining two uids. Today's `ensure_dir_0700` "tightens rather than refuses" and would `chmod 0700` whatever parent it is given, `/tmp` included when run as root; it goes, and the docs' `--socket` examples move into dedicated directories. **The judge runs in the daemon's `bind` and nowhere else.** A client neither creates nor judges any parent: it connects to the path it resolved (default, `FELIS_SOCKET`, `--socket`, or a `window retarget` target) and is protected by the peer-uid check below, not by inspecting directories; a squatted or missing parent shows up as a refused or absent dial, the ordinary cold case, and the daemon it may spawn is the one process that judges. One helper, one call site, so `retarget`'s `LocalEndpoint` and the relay's override cannot bypass it, and a daemon today's build started at `--socket /tmp/x.sock` is still reachable and stoppable from a shell it stamped. **Both sides verify the peer before application bytes.** After every Unix connect and before the first byte of the preface, the client checks the listener's uid against its own with the peer-credential helper the daemon already uses on accept (`verify_peer_uid`: `SO_PEERCRED` on Linux, `getpeereid` on the Apple/BSD targets); a mismatch ends the dial with a named error and nothing has been sent. The check lives in the shared transport connect path (`felis_transport::local::connect`, before the stream is split), so every dialer inherits it: the client connector, `felis-daemon relay`, and the preface `probe` that `doctor` uses (a probe of a foreign listener reports the mismatch and sends nothing). The daemon's own startup probe (`socket_is_live`, a separate synchronous connect) applies the same check: a listener of another uid at the daemon's path is neither "live" nor "absent" but a hard start error that touches nothing. REQ-106 is amended from "the peer's UID is verified on every connection" to state both directions. This is what makes the pathname connect safe regardless of what a cleaner or another uid did to the path in between: the relay's carrier block (its whole environment) can no longer reach a foreign listener. This check and the judge are the two defenses this plan builds against a shared `/tmp`. **What the docs say about the shared `/tmp`, and nothing more.** Another local uid can create `/tmp/felis.<uid>` (or a symlink there) before this uid's first start; the daemon then refuses to start, names what it found, and the recovery is an administrator removing the entry (`/tmp` is sticky, so the victim cannot) or `FELIS_SOCKET` / `--socket` pointing at a dedicated directory. That is the whole user-facing statement; it is tmux's since `/tmp/tmux-<uid>` exists. Sub-second coincidences (a cleaner emptying the directory inside the startup window, a foreign entry arriving between the judge and the bind, an owner removing a live socket, a symlink aged under a ten-day-idle relay) are not documented and not defended: rev 17-21 of this plan built and then dropped mechanisms for them by the user's decision, because a reader can do nothing with them and a single-user host never sees them; the one kept is decision 2's post-bind parent check, because its failure mode would hand sessions a foreign agent socket and it costs five lines. **Tmp cleaners.** The directory holds two felis-written entries, `daemon.sock` (a socket) and `daemon.sock.agent` (a symlink), plus whatever earlier builds left. Every cleaner in its default configuration leaves a live socket alone: systemd-tmpfiles skips any `AF_UNIX` socket present in `/proc/net/unix` (`unix_socket_alive`, commit 17b9052533, 2011; the kernel records there the pathname given to `bind` and never updates it, so a socket must be bound at the name it keeps, which is why decision 2 binds at the final name and never renames a listener); macOS `periodic daily` removes only regular files and empty directories; `tmpwatch` judges by access time by default, with the age and the schedule supplied by the distribution's cron entry, and removes only sockets older than the boot. A dead socket, a stale link, and then the empty directory may be aged after a daemon is gone; the next start recreates all three. The design relies on no cleaner honoring a lock. The macOS statement is to be verified on a Mac before the release note calls macOS supported (hand verification item, as in v3). **Known limits, stated in the docs.** A process with a private `/tmp` (`PrivateTmp=` services, bwrap/flatpak sandboxes) sees its own `/tmp/felis.<uid>`. Its fork spawns a daemon only it can reach; its systemd hand-off (#261) asks the user manager, which runs in the host mount namespace, so that daemon binds the host's `/tmp/felis.<uid>`, the launcher's retry fails to reach it, and the fork fallback starts a second, private one. `felis` is not supported from inside such a sandbox; non-goals.md says so and names the consequence (a host-side daemon the sandbox cannot reach, visible to `felis doctor` from any ordinary shell). No detection is built. ### 2. The startup lock is the directory; a daemon removes a socket path only inside it, after the connect classification - The startup lock moves from the `<socket>.lock` file to the socket's parent directory. The sequence, all inside `bind`: 1. `mkdir`: hold `UMASK_LOCK`, install `umask(0077)`, `mkdir(path, 0700)`, restore the previous mask on every result (`mkdir` is filtered by the process umask, so an inherited `0777` would otherwise create a `0000` directory the judge refuses); then `open(O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)`; blocking exclusive `flock`; then judge from the fd (`fstat`: directory, owned by the uid, access bits `0700`). Judging the descriptor rather than the pathname means what is judged is what is locked. `ENOENT` or `ELOOP` at the open, and a judge failure, are bind errors naming what was found; no retry loop (`ENOENT` here needs a cleaner to remove the directory inside this window, and the user's next start recreates it). 2. Probe `daemon.sock` (rule below). A live daemon answers `AddrInUse` here and nothing has been written. 3. Unlink what the rule allows at `daemon.sock`; bind the listener at that final name under `umask(0177)` (today's `0077` yields `0700`, which is why the code still chmods afterwards; `0177` creates the socket at `0600` and the pathname `chmod` goes). The name is absent only inside the lock window; a client that dials then sees `ENOENT`, spawns, and its daemon waits on the lock and finds this one live, as today. 4. After the bind, one check: `lstat(parent)` device and inode equal `fstat(dirfd)`'s; on a mismatch drop the listener and fail the start naming the parent (no retry). This is five lines and it is what keeps a daemon from serving inside a directory another uid swapped in between the judge and the bind, where that uid could replace `<socket>.agent` with a socket of its own and feed sessions a foreign `SSH_AUTH_SOCK`; the peer-uid check covers the daemon socket, not the link. 5. Release the lock and close the directory fd. `bind` returns the `Listener` as today; `serve` clears a stale agent link and accepts, as today. The probe's `lstat` and `unlink` and the bind are pathname operations inside a window whose parent was just judged and locked; the plan accepts them as such (a pathname that stops meaning the judged directory inside the window yields a failed start by step 4, never a dial between two uids, because the peer-uid check protects every dial). A lease released while the daemon exits (connection tasks drop with the runtime, microseconds after `serve` returns) writes the link once more; a successor's link exists only after its own first lease, an attach later, so nothing is built or written for that ordering. Nothing holds the lock for the daemon's lifetime, so there is no contention with a live daemon, no bounded wait, and the client's and relay's spawn-once-then-retry-connect is untouched: a waiting starter waits on another starter's window (milliseconds), as today, and a live daemon holds no startup lock, so step 2 reaches it. Sibling `--socket` daemons in one dedicated directory serialize their startups and nothing else. The listener is bound at the name it keeps, so `/proc/net/unix` names it and tmpfiles exempts it. `StartupLock` and the `.lock` file go. - `Listener::drop` no longer unlinks `daemon.sock`. A stopped daemon leaves its socket file; the next starter's probe removes it. This removes the last pathname write a daemon performs after bind other than the agent link, so an old daemon exiting can never remove a newer daemon's socket. (An owner who `rm`s a live socket gets a second daemon on the path; the how-to says `felis daemon stop`, and nothing else is written about it.) - The startup probe (`socket_is_live`) becomes a non-following `lstat` first, then the REQ-009c classification: an absent name needs nothing; a directory or a symlink fails the start untouched (felis never removes a directory; a symlink at the endpoint name is the user's: `--socket` may name a link to a socket another daemon serves, which today's probe follows and refuses); a regular file or other non-socket inode is removed (today's `stale_socket_file_is_replaced` test keeps passing); a socket inode is connected to, the listener's uid checked (a foreign uid is a hard start error), and only `ENOENT` or `ECONNREFUSED` proves nobody is listening and licenses unlink + bind, while any other connect error (`EMFILE`, `EACCES`, `EPERM`…) fails the bind with that error and touches nothing (today any error unlinks). REQ-009c's text drops its "startup probe excluded" clause; the same helper (`connect_error_is_absent`) serves both. - What the v3 build leaves in the directory needs no special case: its `<socket>.agent` and `<socket>.agent.new` symlinks are what `clear_stale` already handles; its `<socket>.lock` file is harmless litter felis never reads (the cleaners age it; the user may remove it). Pre-release, no build-to-build cleanup is written. - `doctor` gains a stale-socket classification: today a refused connect renders through the generic `not running (connect: …)` path; the PR adds the `ConnectionRefused` arm and, only when a non-following `lstat` of the endpoint shows a socket inode, renders "not running (stale socket, replaced on the next start)"; a directory, a symlink, or a regular file at the endpoint keeps the generic refused wording plus what was found, because the start would not replace it (directory, symlink) or the promise would be about a different inode. ### 3. The stable `SSH_AUTH_SOCK` path stays a symlink, written as today `AgentLink` keeps `<socket>.agent` as a symlink to the newest live relay's forwarded `SSH_AUTH_SOCK` (sshd's `/tmp/ssh-XXXX/agent.N`), rewritten by stage-and-rename on every lease change, exactly as today; `agent.rs` does not change and REQ-912b does not change. A cleaner may age the link under a relay idle for its age (ten days by default); the next lease change rewrites it. Not documented, not mechanized. Why a symlink and not a served socket (rev 5-16 of this plan, dropped by the user's decision): a socket the daemon serves is exempt from every cleaner without a timer, but it is a new subsystem, two directional copies with agent frame counting and a bounded drain, its own admission bound, a `daemon status` row and a wire minor bump. Why no hourly rewrite and no descriptor-relative writes (rev 17-20, dropped by the user's decision): they defended against a cleaner ageing a link under a ten-day-idle relay and against a directory replaced under a running daemon; the first is repaired by the next attach, the second needs the owner or root in `/tmp/felis.<uid>`; neither is worth a task with a lifetime of its own and a directory handle threaded through `bind`, `serve`, and `AgentLink`. ### 4. Everything else in the #282 seed is closed by decisions 1-3, not built Out of scope, stated: two *different builds* of `felis-daemon` racing to start on one endpoint (a v3 binary taking `<socket>.lock` while this build takes the directory lock) are not coordinated. The update how-to drains the old daemon before any new window launches, and every autospawn runs the dialing process's own binary (REQ-009a), so the race needs two builds installed and two clients of different builds dialing one cold endpoint at once; carrying the lock file forward for that case would keep the aged-lock-file hazard for everyone. | Seed item | Outcome | | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | Endpoint watch (10 s poll) and drain on loss/replacement | Not built. Nothing removes `/tmp/felis.<uid>` under a running daemon except the user or a reboot. | | dirfd-relative lock/probe/unlink/bind, identity capture | Not built. The lock is the directory fd and the judge reads that fd; the probe, unlink, and bind stay pathname operations inside the locked window (decision 2). A directory replaced under a daemon needs its owner or root in `/tmp/felis.<uid>`. | | Per-instance agent link `<socket>.<pid>.agent`, `symlinkat` writes, dead-pid sweep | Not built. Two daemons share one path only after the owner `rm`s a live socket; one link per path, written as today, is right. | | Stale-stamp recovery, provenance in `Reconnector` | Not built. A stamp names the canonical path; after a reboot the same path is bound again. | | Availability rule for environment directories | Moot. No environment directory takes part in the default. | | "draining is only ever set by `daemon stop --when-empty`", "the daemon never exits on its own" | Stay true. No new REQ. | | `loginctl enable-linger` | Stays in the docs for a different reason: a daemon the systemd hand-off (#261) placed under the user manager stops with `user@<uid>.service`, which logind stops `UserStopDelaySec` (default 10 s; `infinity` keeps it until shutdown) after the last logout of a non-lingering uid. That is the user manager's contract, not the endpoint's; a forked daemon survives (`KillUserProcesses=no`). The how-to states this precisely instead of "keeps `/run/user/<uid>` alive". | ## What PR #281's code becomes Keep: the raw-connect classification and `ConnectError::Connect` (REQ-009c), `dial_bounded` / `ProbeOutcome`, doctor's "running, but…" arms and the `daemon-sibling` row (see below), `judge_canonical_dir` (retargeted at `/tmp/felis.<uid>` itself), the drain how-to, empty-variable normalization is moot but harmless where it survives for `FELIS_SOCKET`. Remove: the `XDG_RUNTIME_DIR` / `/run/user/<uid>` / `TMPDIR` chain and `resolve_lazily`, `CanonicalDir::{Absent, Unreadable}` (the judge's only outcomes are usable / unusable; a not-found after our own `mkdir` is an I/O error), the relative-`XDG_RUNTIME_DIR` warn, `SocketPathError::RuntimeDirUnreadable`, the macOS `confstr` FFI, `ensure_dir_0700` (the judge covers every parent), doctor's `/tmp` and `$TMPDIR` sibling candidates. `StartupLock` and the `.lock` file go (the lock is the directory fd); `AgentLink` is unchanged (decision 3). The relay's resolver tests (`crates/felis-daemon/src/relay.rs`, using `CanonicalDir`, `EnvDirs`, `SocketPath::resolve_from`) are rewritten against the uid-only path. Doctor's `daemon-sibling` row keeps only its T/D reasoning: a stamped target `T` that differs from the default `D` is reported with `D` probed ("this shell targets `T`; the default endpoint `D` has a live daemon" or "is cold"); an explicit `--socket` target keeps today's behavior and gets no sibling reasoning (`endpoint_report` returns before resolving the default). The candidate list (previous builds' locations) is dropped: felis has not shipped, and a daemon an earlier build left elsewhere is stopped from a shell inside one of its sessions, where the stamped `FELIS_SOCKET` still names it (`felis daemon stop --when-empty`). The `daemon-sibling` schema token stays. Tests that moved the default directory through `XDG_RUNTIME_DIR` / `TMPDIR` (`crates/felis-cli/tests/cli_sessions.rs` `spawn_default_socket_daemon`, for `window retarget`'s `DefaultLocal` carrier) set `FELIS_SOCKET` instead; if `DefaultLocal` turns out not to honor the stamp, the test is rewritten to what the carrier does resolve, never a felis-owned root variable (that is the env dependence being removed). The doc comments in `felis-daemon/src/main.rs`, `felis-client/src/main.rs`, and the cli tests naming `$XDG_RUNTIME_DIR/felis` are updated. ## Requirements - REQ-009b (rewritten): "The daemon endpoint is derived from the uid alone: `/tmp/felis.<uid>/daemon.sock` on Unix, the SID-derived pipe on Windows. No environment variable takes part; `--socket` and `FELIS_SOCKET` are the only overrides. The default directory is created `0700` by the daemon and refused when it exists as anything but a `0700` directory owned by the uid (a symlink is refused). Every process of one uid, the relay included, resolves it the same way." - REQ-009c (amended): the startup probe joins the rule (drop the exclusion), and "A failure after the connect succeeded keeps the spawn-and-retry behavior" gains the exclusion "except a peer-identity failure (REQ-106: a uid mismatch or a credential query that fails), which ends the dial with that error on every path"; a dialer must not spawn a daemon at a path where something of another uid answers. Tests: the client autospawn and the relay with an injected foreign-uid listener report the mismatch and never call their spawn hook. - REQ-009d (new): "A daemon holds an exclusive BSD lock on its socket directory while it probes, unlinks and binds its socket, and at no other time; the directory is judged from the locked descriptor. Inside that window it unlinks, without following, a non-socket inode at its path (a directory or a symlink there fails the start untouched) and a socket inode only after a connect to it failed with the REQ-009c absence errors; after the bind it verifies that the parent path still names the locked directory and otherwise fails the start. Exit leaves the path in place." Source: ipc.md explanation (decision 2). - REQ-912b: unchanged. - REQ-107 (amended): "The socket directory is mode `0700` and the socket `0600`, created under an explicit `umask`; a socket parent that exists as anything but a `0700` directory owned by the uid (a symlink included) is refused, never tightened." Source: security-model.md "Daemon IPC". - REQ-106 (amended): "The peer's UID is verified on every Unix connection by both sides before any application byte: the daemon on accept, the dialer after connect (`SO_PEERCRED` on Linux, `getpeereid` on macOS and the BSDs)." Source: security-model.md "Daemon IPC". - security-model.md "Daemon IPC" gains the shared-`/tmp` squat check as the reason for the judge. ## Doc cascade (doc-cascade skill; grep sweep: `XDG_RUNTIME_DIR`, `TMPDIR`, `/run/user`, `felis.<uid>`, `confstr`, `DARWIN_USER_TEMP_DIR`, `linger`, `runtime directory`, `daemon-sibling`, `socket_is_live`, `on drop`, `--socket /`, `retarget /`, `.sock` in every how-to and reference example, `alt.sock`, `x.sock`, `LOCAL_PEERCRED`, `getpeereid`, `SO_PEERCRED`) - `docs/explanation/architecture/ipc.md`: replace the "absent runtime variable resolves to the canonical location" section and the "runtime directory's lifetime is the known limit" paragraph with decision 1 (rationale, tmux precedent, tmpfiles fact with the commit cited, the sandbox limit); rejected alternatives: the logind runtime directory (v3, superseded: login lifetime ≠ daemon lifetime; every #282 mechanism was its price), `$HOME`, relay probing (as before), a watch-and-drain daemon (hardening against a directory the system may remove is the wrong layer when a directory it does not remove exists); _Revisit if_ a target platform mounts a per-login `/tmp`. Decision 2 in the "Daemon IPC" / bind paragraph. - `docs/explanation/architecture/session-lifecycle.md` (lines ~396, ~947): the "replacement that has never heard of the session" and "lingers" paragraphs become the user-manager statement from decision 3; "Where an auto-spawned daemon lands" states that the hand-off's daemon dies with the user manager unless the uid lingers, the fork does not. - `docs/reference/cli.md` "Carrier resolution order"; `docs/reference/ipc.md` lines ~91-103 (the whole endpoint paragraph: environment-dependent resolution, runtime-directory lifetime, and the `.agent` sentence, rewritten to the uid-only `/tmp` endpoint; the `.agent` sentence stays); `docs/reference/workspace.md` directory layout (`daemon.sock`, `daemon.sock.agent`, no lock file); `docs/reference/spec.md` (REQs above); `docs/reference/security-audits.md` (lines ~17-27: the "Socket-dir creation" row names `ensure_dir_0700` and the environment-derived directories, the "Socket chmod" row the post-bind `chmod`; both are replaced by the locked-directory open and judge, the bind under `umask(0177)`, and the post-bind parent check). - `docs/explanation/security-model.md` "Daemon IPC": the dialer-side uid check (REQ-106) and the shared-`/tmp` squat statement; `docs/reference/ipc.md` connection establishment: verification by both sides; `docs/explanation/implementation.md` (lines ~408-414 name `LOCAL_PEERCRED` for macOS while `peer.rs` uses `getpeereid`; one contract, `getpeereid`, everywhere) and REQ-106's own text likewise. - `docs/explanation/security-model.md` "Daemon IPC": the `/tmp` judge and what the directory holds. - `docs/explanation/non-goals.md` line 37 (recovery "as long as the runtime directory is still there" → "until the host reboots"); private-`/tmp` sandboxes with the hand-off consequence from decision 1. - `docs/how-to/attach-over-ssh.md`: delete the macOS `TMPDIR` note (already obsolete), "Single daemon per user" becomes unconditional for processes without an override, the linger note restated per decision 3. - `docs/how-to/update-felis.md`: the "Check where your daemons are" section (the `env -u FELIS_SOCKET felis doctor` discovery sequence added by #281) is replaced by one paragraph: a daemon an earlier build started may listen elsewhere; stop it from a shell inside one of its sessions, where `FELIS_SOCKET` names it, before launching a window on the new endpoint; the remote section runs the same over `ssh`. - `docs/reference/cli.md` `--socket` and every doc or test example naming a socket in a shared directory (`/tmp/x.sock`): a dedicated `0700` directory owned by the user is required (grep `--socket /tmp`, `x.sock`). - `skills/felis/SKILL.md`: the endpoint rule sentence and the paragraph after it (lines ~219-230 say the endpoint lives under the login runtime directory and needs linger to stay reachable; linger stays only as the hand-off daemon's lifetime condition, per decision 4). - `.agents/skills/felis-macos-gui-debug/SKILL.md` (~186), `.agents/skills/perf-trace/SKILL.md` (~154), `.agents/skills/perf-trace/scripts/samply-macos.sh` (~5): they describe the parent `chmod` / tighten behavior; `.agents/skills` joins the grep sweep. - Non-doc mirrors that pass `--socket` with a parent the bind rule refuses: `justfile` `smoke` and `smoke-headless` (`${TMPDIR:-/tmp}/felis-smoke.sock`), `.forgejo/workflows/pr.yml` `linux-smoke` and `.forgejo/workflows/release.yml` (`${RUNNER_TEMP:-/tmp}/$SMOKE_SOCKET_NAME`, and the `pkill -f` pattern that names it). Each creates a per-run `0700` directory (`mktemp -d` under the same base, so concurrent runs on the shared runner keep separate directories as the per-run name did) and passes `<dir>/daemon.sock`; the stop step's pattern follows. Forgejo Actions files: edit only those two lines and the env block, nothing else in the workflow. - `CHANGELOG.md` (unreleased): replace the #275 endpoint entry: "the daemon endpoint is `/tmp/felis.<uid>/daemon.sock` on Linux and macOS, derived from the uid alone; `XDG_RUNTIME_DIR` and `TMPDIR` no longer take part; a stopped daemon leaves its socket file for the next start to replace; a starter refuses to unlink a socket whose connect failed for any reason other than absence or refusal; an explicit `--socket` / `FELIS_SOCKET` path must lie in a dedicated directory owned by the uid with mode `0700`, and a socket directly under a shared directory such as `/tmp` (`--socket /tmp/x.sock`) is refused at daemon start". ## Tests - Resolver table: `XDG_RUNTIME_DIR`, `TMPDIR` set to absolute, relative, empty, and `/run/user/<uid>` present or absent all resolve `/tmp/felis.<uid>/daemon.sock`; `FELIS_SOCKET` and `--socket` win as before. - Judge on every parent (temp-root seam for unit tests), in the daemon's `bind` only, for the canonical directory and for a dedicated `--socket` directory alike; a client resolution and a `retarget` target neither create nor judge anything (test: a client dials a path whose parent is `/tmp` and gets the connect result): fresh → created `0700` by the daemon only, under an inherited umask of `077` and of `0777` alike (the umask guard); existing `0700` own dir → ok; symlink → refused; regular file → refused; foreign uid (injected facts for the fd judge) → refused; mode `0750` → refused and not tightened; `--socket /tmp/x.sock` → refused naming `/tmp`; error text names the path and the recovery. - `Listener` drop leaves the socket file; a second bind over the leftover succeeds (refused → unlink → bind); a bind over a live socket fails `AddrInUse` from the probe (unchanged); a listener of another uid at the path (injected credential) fails the start and the path survives; the classification is unit-tested with injected connect errors through `connect_error_is_absent` (a real `mode 0000` socket yields `EACCES` only on some Unixes and never as root, so that test runs only after a preflight connect confirms `EACCES`) and the non-absence path leaves the file in place; a symlink at an explicit endpoint path pointing at a live daemon's socket fails the bind, target and link untouched; a directory at the endpoint path fails the bind untouched; a regular file there is unlinked (existing test kept); a client dialing a listener served by another uid (unit-tested through the peer helper's injected credential and the dial's error path) ends the dial before the preface, the relay sends no carrier block, and `probe` reports the mismatch; the socket's mode after bind is `0600` without any chmod; the parent renamed away and replaced between the judge and the bind (seam: a hook between the steps) fails the start with the listener dropped and nothing served; after `bind` returns, `/proc/net/unix` (Linux) names the socket at its final path; while a starter is inside its startup window, `flock(LOCK_EX | LOCK_NB)` on the directory from another fd fails `EWOULDBLOCK`, and after `bind` returns it succeeds (no lifetime hold); two explicit endpoints in one dedicated parent serve concurrently; no `.lock` file is created; an autospawn (client and relay) of the default endpoint ends with `daemon.sock` and, after the first lease, `daemon.sock.agent` as the directory's only felis-written entries (litter of earlier builds, `.lock` and `.agent.new`, is exempt and left to the cleaners). - Agent link: existing `agent.rs` tests kept unchanged; `SSH_AUTH_SOCK` in a spawned session equals `<socket>.agent` (existing test). - Doctor: stale socket wording only for a socket inode, generic refused wording plus what was found for a directory, symlink, or regular file; sibling row with `T ≠ D` and `D` live, and with `D` cold; no candidate list; explicit `--socket` unchanged. - `cli_sessions.rs` retarget test on `FELIS_SOCKET`. - Existing tests the change rewrites: `cli_daemon.rs` `stop_on_an_empty_daemon_reports_stopping_and_ends_it` (waits for the socket file to disappear; now waits for connection refused and asserts the file remains); `cli_systemd_handoff.rs` `cold_socket` (creates the parent with the process umask; now `0700`, or lets the daemon create it); every other explicit-socket helper is audited for a parent created without `0700`; the relay resolver tests; `stale_socket_file_is_replaced` (kept). - Removed with the code: the `/run/user` transition tests, macOS `TMPDIR`/confstr cells, the sibling candidate tests, `ensure_dir_0700`'s tighten test. ## Acceptance - A relay under Tailscale SSH, a desktop login, an SSH login with pam_systemd, and a `felis` run with `XDG_RUNTIME_DIR` unset or pointed elsewhere all dial `/tmp/felis.<uid>/daemon.sock`. - Logging out of every session on a non-lingering systemd host leaves a *forked* daemon reachable from the next login (today: unreachable, roster splits); a hand-off daemon is stopped by the user manager, and the docs say so. - `just check` and `just check-windows` green; macOS hand verification recorded as done or pending in the PR. ## Rejected here - Keeping the logind directory and building the seed (watch/drain, dirfd identity, per-instance links, stamp recovery): five mechanisms, each reviewed to a hole, to defend a placement that no comparable tool uses. - `${TMPDIR:-/tmp}` or a felis-owned `FELIS_TMPDIR` root: any variable in the default is the #275 split again; the explicit overrides already exist. - Unlink-on-drop guarded by inode identity captured after bind: correct in every scenario left, but keeps a window, while "never unlink after bind" has none and costs one stale file the startup probe already handles. - A sticky bit against tmp cleaners: it cannot be set on a symlink, and the socket needs nothing. - Holding an explicit endpoint's parent directory for the daemon's lifetime (rev 2-3): blocks a sibling `--socket` daemon in the same parent and switches off age cleaning for an arbitrary user directory (`/tmp` itself for `--socket /tmp/x.sock`); the startup-only hold of decision 2 does neither. - A daemon-held lifetime lock as a cleaner shield (rev 2-4): only systemd-tmpfiles honors it (`tmpwatch` and macOS `periodic` do not), and combined with the startup lock it made every later starter wait on a live daemon; a directory that holds only sockets needs no shield. - Serving `<socket>.agent` as a proxy socket (rev 5-16): exempt from every cleaner without a timer, but a new subsystem (two directional copies, agent frame counting, a bounded drain, its own admission bound, a `daemon status` row, a wire minor bump) to spare an hourly `fstatat` and an occasional `renameat`; dropped by the user's decision. - Post-bind identity verification with a retry from mkdir (the single check without retry is kept), descriptor-relative probe/unlink/agent-link writes through a `SocketDir` handle, and an hourly link rewrite with a `serve`-owned task (rev 17-20): each closed a sub-second coincidence on a multi-user host with a hostile local account (a cleaner emptying the directory in the startup window; a directory replaced under a running daemon, which in `/tmp/felis.<uid>` only the owner or root can do; a symlink aged under a ten-day-idle relay), none of them narrows the predictable-name squat that is the real multi-user exposure, and none matters on a single-user host; dropped by the user's decision. - Dropping the stable agent path altogether (REQ-912b): the tmux workaround it replaces is the kind of thing felis exists to make unnecessary; kept. - Per-instance agent link names: one live daemon per path is guaranteed by the probe except after `rm` of a live socket by its owner, which is not arbitrated. - Keeping `<socket>.lock`: a regular file every cleaner ages, which is the only way the lock's inode could be replaced under a starter.
Sign in to join this conversation.
No description provided.