daemon: endpoint loss and replacement are not handled (logout removes the runtime directory under a running daemon) #282
Labels
No labels
priority/P0
priority/P1
priority/P2
release/v0.1.0
status/blocked
status/planned
type/bug
type/design
type/test-gap
type/tracker
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#282
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 itis 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 toevery 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, kepthere as the spec seed):
Listener::drop(crates/felis-transport/src/unix.rs) removesdaemon.sockunconditionally. After logind removed and recreated the parent directory, an old daemon exitingremoves the replacement daemon's socket. A lock file inside the directory proves nothing across the cycle
(it is recreated with the directory).
socket_is_livetreats every connect error as "not live",so
EMFILE(the startup lock can consume the last descriptor) orEACCESmakes a starter unlink a livedaemon's socket before its own bind fails. Only
ENOENT/ECONNREFUSEDare evidence of a stale socket.SSH_AUTH_SOCKlink is written by pathname.AgentLink(crates/felis-daemon/src/agent.rs)repoints
<socket>.agenton every lease release. After a replacement, an old daemon's lease dropping removesor overwrites the new daemon's link;
clear_staleis another ungated pathname writer. Nolstat-then-renamecheck closes this (the gap is the race).
FELIS_SOCKETin every shell of the strandeddaemon, and the window reconnector, point into a removed directory; a bare
felisorsessions spawntherefails or (under a user-writable custom directory) recreates the directory and revives the split.
Direction that survived review
serverecords the identity of what it bound and polls (10 s, no inotify); loss orreplacement 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.
capturing identity by
lstat(path)after bind (a directory replaced between bind and capture records thereplacement's inode) and also a dirfd opened before a pathname-based
bind(the listener can land in the newdirectory 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.sockbinds today).Listener::dropnever unlinks; the startup probe handles a leftover socket, with the classificationfrom (2): unlink only on
ENOENT/ECONNREFUSED, any other connect error fails the bind without touchingthe path.
<socket>.<pid>.agent; REQ-912b already says "per instance"), writtenthrough the retained directory descriptor (
symlinkat/renameat/unlinkat), detached after loss;clear_stalebecomes a dead-pid sweep (kill(pid, 0)→ESRCH;EPERMcounts 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 (theSSH_AUTH_SOCKvalue inside sessions changes).Default/Stampedprovenance only. When the socket directory of suchan endpoint no longer exists, the client re-resolves the default ignoring
FELIS_SOCKETand proceeds underDefaultprovenance; explicit--socket,window retargettargets, and Windows pipe names stay exact.Reconnectorcarries provenance;redial_sessionreturns theReconnectorit connected with and thereconnect event installs it (today
retargeted: Noneleavesself.reconnectoron the stale path, whichpipe/runread viacarrier.local_socket()). Open question from review: the recovery resolver must nothand the removed path back when
XDG_RUNTIME_DIR/TMPDIRstill names it, yet must pick a recreated/run/user/<uid>again; "skip an environment directory that does not currently exist" is a behavior change inordinary resolution (
ensure_dir_0700usescreate_dir_all, so a missing custom directory is created andused today), so the skip has to be scoped to recovery, or the compatibility change accepted and
doctor'ssibling candidates extended accordingly.
docs/reference/ipc.md("drainingistruebetween afelis daemon stop --when-emptyand the daemon's exit"),docs/reference/cli.md(
daemon statusdraining),docs/explanation/architecture/control-surfaces.md("the daemon still neverexits 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 acrosslogouts;
docs/how-to/attach-over-ssh.mdsays so after #275.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 existsbecause 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 butnobody 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, withTMUX_TMPDIRand-Sas theonly overrides, and it ignores
XDG_RUNTIME_DIRandTMPDIRfor exactly this reason.Decisions
1. The Unix endpoint is
/tmp/felis.<uid>/daemon.sock, derived from the uid alone--socket, noFELIS_SOCKET)/tmp/felis.<uid>/daemon.sock/tmp/felis.<uid>/daemon.sockNo environment variable takes part in the default:
XDG_RUNTIME_DIR,TMPDIR, and/run/user/<uid>are notconsulted. The two explicit overrides stay exactly as they are:
--socket <path>(Explicitprovenance) andFELIS_SOCKET(Stamped; the daemon stamps every session with the path it serves, so a shell inside a sessiontargets the daemon that owns it). Resolution order stays
--socket→FELIS_SOCKET→ default.Why macOS uses
/tmpand not launchd's per-user directory (v3's choice, dropped):confstr(3)documents that thecontents of
_CS_DARWIN_USER_TEMP_DIRmay be deleted after three days,_dirhelperconsultsDIRHELPER_USER_DIR_SUFFIX, andconfstrfalls back toTMPDIRinternally, so that directory is neither derivedfrom 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(default1on 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
/tmpand not the logind directory:/tmphas the lifetime the daemon needs (the boot), on every Unix felistargets, 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
/tmpis empty after boot, a disk-backed/tmpkeepsthe 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-
/tmpcase 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 orexplicit target that differs from the default.
Why not
$HOME: unchanged from v3 (NFS,sun_pathlength, a backed-up directory).Every socket parent is vetted before use, with one rule. The daemon creates the parent (
mkdir 0700) when itis absent and otherwise judges it from the opened, locked descriptor (
fstatafterflock, so what is judgedis what is used; the facts are the ones PR #281's
judge_canonical_diralready checks for/run/user/<uid>): adirectory, not a symlink (
O_NOFOLLOWat open), owned by the uid, access bits (mode & 0o777) exactly0700(setgid and sticky bits are ignored, as the judge's
DirFactsalready does); anything else is a hard error namingwhat was found and the recovery (
rmorchownby the owner, or another--socket). This is tmux'scheck_dirand 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.sockrequires<dir>to be the user'sown
0700directory (two daemons may share it), and--socket /tmp/x.sockor/run/x.sockis refused. felisjudges the parent only; the parent's ancestors are the user's contract, as
~/.ssh's are for ssh:--socket'sreference 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 wouldchmod 0700whatever parent it is given,/tmpincludedwhen run as root; it goes, and the docs'
--socketexamples move into dedicated directories. The judge runs in thedaemon's
bindand nowhere else. A client neither creates nor judges any parent: it connects to the path itresolved (default,
FELIS_SOCKET,--socket, or awindow retargettarget) and is protected by the peer-uid checkbelow, 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'sLocalEndpointand the relay's override cannot bypass it, and a daemon today's build started at--socket /tmp/x.sockis 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_PEERCREDon Linux,getpeereidon the Apple/BSD targets); a mismatch endsthe 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 issplit), so every dialer inherits it: the client connector,
felis-daemon relay, and the prefaceprobethatdoctoruses (a probe of a foreign listener reports the mismatch and sends nothing). The daemon's own startupprobe (
socket_is_live, a separate synchronous connect) applies the same check: a listener of another uid at thedaemon'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 (
/tmpis sticky, so the victim cannot) orFELIS_SOCKET/--socketpointing 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, aforeign 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) anddaemon.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_UNIXsocket present in/proc/net/unix(unix_socket_alive, commit17b9052533, 2011; the kernel records there the pathname given to
bindand never updates it, so a socket must bebound at the name it keeps, which is why decision 2 binds at the final name and never renames a listener); macOS
periodic dailyremoves only regular files and empty directories;tmpwatchjudges 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/flatpaksandboxes) 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.
felisis not supportedfrom inside such a sandbox; non-goals.md says so and names the consequence (a host-side daemon the sandbox cannot
reach, visible to
felis doctorfrom 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
<socket>.lockfile to the socket's parent directory. The sequence, all insidebind:mkdir: holdUMASK_LOCK, installumask(0077),mkdir(path, 0700), restore the previous mask on everyresult (
mkdiris filtered by the process umask, so an inherited0777would otherwise create a0000directory the judge refuses); then
open(O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); blocking exclusiveflock;then judge from the fd (
fstat: directory, owned by the uid, access bits0700). Judging the descriptorrather than the pathname means what is judged is what is locked.
ENOENTorELOOPat the open, and a judgefailure, are bind errors naming what was found; no retry loop (
ENOENThere needs a cleaner to remove thedirectory inside this window, and the user's next start recreates it).
daemon.sock(rule below). A live daemon answersAddrInUsehere and nothing has been written.daemon.sock; bind the listener at that final name underumask(0177)(today's
0077yields0700, which is why the code still chmods afterwards;0177creates the socket at0600and the pathnamechmodgoes). The name is absent only inside the lock window; a client that dialsthen sees
ENOENT, spawns, and its daemon waits on the lock and finds this one live, as today.lstat(parent)device and inode equalfstat(dirfd)'s; on a mismatch drop thelistener 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>.agentwith a socket of its own and feed sessions a foreignSSH_AUTH_SOCK; the peer-uidcheck covers the daemon socket, not the link.
bindreturns theListeneras today;serveclears a staleagent link and accepts, as today.
The probe's
lstatandunlinkand the bind are pathname operations inside a window whose parent was justjudged 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
servereturns) writes the link once more; a successor's link exists only after its own first lease, an attachlater, 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
--socketdaemons in onededicated directory serialize their startups and nothing else. The listener is bound at the name it keeps, so
/proc/net/unixnames it and tmpfiles exempts it.StartupLockand the.lockfile go.Listener::dropno longer unlinksdaemon.sock. A stopped daemon leaves its socket file; the next starter's proberemoves 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 seconddaemon on the path; the how-to says
felis daemon stop, and nothing else is written about it.)socket_is_live) becomes a non-followinglstatfirst, then the REQ-009c classification: anabsent 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:
--socketmay name a link to a socket another daemon serves, whichtoday's probe follows and refuses); a regular file or other non-socket inode is removed (today's
stale_socket_file_is_replacedtest keeps passing); a socket inode is connected to, the listener's uid checked(a foreign uid is a hard start error), and only
ENOENTorECONNREFUSEDproves nobody is listening andlicenses unlink + bind, while any other connect error (
EMFILE,EACCES,EPERM…) fails the bind with thaterror 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.<socket>.agentand<socket>.agent.newsymlinks are what
clear_stalealready handles; its<socket>.lockfile is harmless litter felis never reads(the cleaners age it; the user may remove it). Pre-release, no build-to-build cleanup is written.
doctorgains a stale-socket classification: today a refused connect renders through the genericnot running (connect: …)path; the PR adds theConnectionRefusedarm and, only when a non-followinglstatof 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_SOCKpath stays a symlink, written as todayAgentLinkkeeps<socket>.agentas a symlink to the newest live relay's forwardedSSH_AUTH_SOCK(sshd's/tmp/ssh-XXXX/agent.N), rewritten by stage-and-rename on every lease change, exactly as today;agent.rsdoes notchange 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 statusrow and a wire minor bump. Why nohourly 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 taskwith a lifetime of its own and a directory handle threaded through
bind,serve, andAgentLink.4. Everything else in the #282 seed is closed by decisions 1-3, not built
Out of scope, stated: two different builds of
felis-daemonracing to start on one endpoint (a v3 binary taking<socket>.lockwhile this build takes the directory lock) are not coordinated. The update how-to drains the olddaemon 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.
/tmp/felis.<uid>under a running daemon except the user or a reboot./tmp/felis.<uid>.<socket>.<pid>.agent,symlinkatwrites, dead-pid sweeprms a live socket; one link per path, written as today, is right.Reconnectordaemon stop --when-empty", "the daemon never exits on its own"loginctl enable-lingeruser@<uid>.service, which logind stopsUserStopDelaySec(default 10 s;infinitykeeps 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-siblingrow (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>/TMPDIRchain andresolve_lazily,CanonicalDir::{Absent, Unreadable}(the judge's only outcomes are usable / unusable; a not-found after our ownmkdiris an I/O error), therelative-
XDG_RUNTIME_DIRwarn,SocketPathError::RuntimeDirUnreadable, the macOSconfstrFFI,ensure_dir_0700(the judge covers every parent), doctor's
/tmpand$TMPDIRsibling candidates.StartupLockand the.lockfile go (the lock is the directory fd);
AgentLinkis unchanged (decision 3).The relay's resolver tests (
crates/felis-daemon/src/relay.rs, usingCanonicalDir,EnvDirs,SocketPath::resolve_from) are rewritten against the uid-only path.Doctor's
daemon-siblingrow keeps only its T/D reasoning: a stamped targetTthat differs from the defaultDis reported with
Dprobed ("this shell targetsT; the default endpointDhas a live daemon" or "is cold"); anexplicit
--sockettarget keeps today's behavior and gets no sibling reasoning (endpoint_reportreturns beforeresolving 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_SOCKETstill names it (felis daemon stop --when-empty). Thedaemon-siblingschema token stays.Tests that moved the default directory through
XDG_RUNTIME_DIR/TMPDIR(crates/felis-cli/tests/cli_sessions.rsspawn_default_socket_daemon, forwindow retarget'sDefaultLocalcarrier) setFELIS_SOCKETinstead; ifDefaultLocalturns out not to honor the stamp, the test is rewritten to what the carrier does resolve, never afelis-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/felisare updated.Requirements
/tmp/felis.<uid>/daemon.sockon Unix,the SID-derived pipe on Windows. No environment variable takes part;
--socketandFELIS_SOCKETare the onlyoverrides. The default directory is created
0700by the daemon and refused when it exists as anything but a0700directory owned by the uid (a symlink is refused). Every process of one uid, the relay included, resolvesit the same way."
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.
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).
0700and the socket0600, created under an explicitumask; asocket parent that exists as anything but a
0700directory owned by the uid (a symlink included) is refused,never tightened." Source: security-model.md "Daemon IPC".
the daemon on accept, the dialer after connect (
SO_PEERCREDon Linux,getpeereidon macOS and the BSDs)."Source: security-model.md "Daemon IPC".
/tmpsquat 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 /,.sockin 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, relayprobing (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 ofthe 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.mdlines ~91-103 (the whole endpointparagraph: environment-dependent resolution, runtime-directory lifetime, and the
.agentsentence, rewritten tothe uid-only
/tmpendpoint; the.agentsentence stays);docs/reference/workspace.mddirectory 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-dircreation" row names
ensure_dir_0700and the environment-derived directories, the "Socket chmod" row thepost-bind
chmod; both are replaced by the locked-directory open and judge, the bind underumask(0177), andthe post-bind parent check).
docs/explanation/security-model.md"Daemon IPC": the dialer-side uid check (REQ-106) and the shared-/tmpsquat statement;docs/reference/ipc.mdconnection establishment: verification by both sides;docs/explanation/implementation.md(lines ~408-414 nameLOCAL_PEERCREDfor macOS whilepeer.rsusesgetpeereid; one contract,getpeereid, everywhere) and REQ-106's own text likewise.docs/explanation/security-model.md"Daemon IPC": the/tmpjudge and what the directory holds.docs/explanation/non-goals.mdline 37 (recovery "as long as the runtime directory is still there" → "until thehost reboots"); private-
/tmpsandboxes with the hand-off consequence from decision 1.docs/how-to/attach-over-ssh.md: delete the macOSTMPDIRnote (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 (theenv -u FELIS_SOCKET felis doctordiscovery 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_SOCKETnames it, before launching awindow on the new endpoint; the remote section runs the same over
ssh.docs/reference/cli.md--socketand every doc or test example naming a socket in a shared directory(
/tmp/x.sock): a dedicated0700directory 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 endpointlives 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 parentchmod/ tighten behavior;.agents/skillsjoins the grep sweep.--socketwith a parent the bind rule refuses:justfilesmokeandsmoke-headless(
${TMPDIR:-/tmp}/felis-smoke.sock),.forgejo/workflows/pr.ymllinux-smokeand.forgejo/workflows/release.yml(
${RUNNER_TEMP:-/tmp}/$SMOKE_SOCKET_NAME, and thepkill -fpattern that names it). Each creates a per-run0700directory (mktemp -dunder the same base, so concurrent runs on the shared runner keep separatedirectories as the per-run name did) and passes
<dir>/daemon.sock; the stop step's pattern follows. ForgejoActions 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.sockon Linux and macOS, derived from the uid alone;
XDG_RUNTIME_DIRandTMPDIRno longer take part; a stopped daemonleaves 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_SOCKETpath must lie in a dedicateddirectory 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
XDG_RUNTIME_DIR,TMPDIRset to absolute, relative, empty, and/run/user/<uid>present orabsent all resolve
/tmp/felis.<uid>/daemon.sock;FELIS_SOCKETand--socketwin as before.bindonly, for the canonical directoryand for a dedicated
--socketdirectory alike; a client resolution and aretargettarget neither create norjudge anything (test: a client dials a path whose parent is
/tmpand gets the connect result): fresh → created0700by the daemon only, under an inherited umask of077and of0777alike (the umask guard); existing0700own 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 textnames the path and the recovery.
Listenerdrop leaves the socket file; a second bind over the leftover succeeds (refused → unlink → bind); a bindover a live socket fails
AddrInUsefrom the probe (unchanged); a listener of another uid at the path (injectedcredential) fails the start and the path survives; the classification is unit-tested with injected connect errors
through
connect_error_is_absent(a realmode 0000socket yieldsEACCESonly 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 inplace; 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
probereports the mismatch; the socket's mode after bind is0600without any chmod; the parent renamed awayand replaced between the judge and the bind (seam: a hook between the steps) fails the start with the listener
dropped and nothing served; after
bindreturns,/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 failsEWOULDBLOCK, and afterbindreturns itsucceeds (no lifetime hold); two explicit endpoints in one dedicated parent serve concurrently; no
.lockfileis created; an autospawn (client and relay) of the default endpoint ends with
daemon.sockand, after the firstlease,
daemon.sock.agentas the directory's only felis-written entries (litter of earlier builds,.lockand.agent.new, is exempt and left to the cleaners).agent.rstests kept unchanged;SSH_AUTH_SOCKin a spawned session equals<socket>.agent(existing test).directory, symlink, or regular file; sibling row with
T ≠ DandDlive, and withDcold; no candidate list;explicit
--socketunchanged.cli_sessions.rsretarget test onFELIS_SOCKET.cli_daemon.rsstop_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.rscold_socket(creates the parent with the process umask; now0700, or lets the daemoncreate it); every other explicit-socket helper is audited for a parent created without
0700; the relay resolvertests;
stale_socket_file_is_replaced(kept)./run/usertransition tests, macOSTMPDIR/confstr cells, the sibling candidatetests,
ensure_dir_0700's tighten test.Acceptance
felisrun withXDG_RUNTIME_DIRunset or pointed elsewhere all dial/tmp/felis.<uid>/daemon.sock.login (today: unreachable, roster splits); a hand-off daemon is stopped by the user manager, and the docs say so.
just checkandjust check-windowsgreen; macOS hand verification recorded as done or pending in the PR.Rejected here
recovery): five mechanisms, each reviewed to a hole, to defend a placement that no comparable tool uses.
${TMPDIR:-/tmp}or a felis-ownedFELIS_TMPDIRroot: any variable in the default is the #275 split again; theexplicit overrides already exist.
while "never unlink after bind" has none and costs one stale file the startup probe already handles.
--socketdaemon in the same parent and switches off age cleaning for an arbitrary user directory (
/tmpitself for--socket /tmp/x.sock); the startup-only hold of decision 2 does neither.tmpwatchand macOSperiodicdo not), and combined with the startup lock it made every later starter wait on a live daemon; adirectory that holds only sockets needs no shield.
<socket>.agentas a proxy socket (rev 5-16): exempt from every cleaner without a timer, but a newsubsystem (two directional copies, agent frame counting, a bounded drain, its own admission bound, a
daemon statusrow, a wire minor bump) to spare an hourlyfstatatand an occasionalrenameat; dropped by the user's decision.through a
SocketDirhandle, and an hourly link rewrite with aserve-owned task (rev 17-20): each closed asub-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 rootcan 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.
exists to make unnecessary; kept.
rmof a livesocket by its owner, which is not arbitrated.
<socket>.lock: a regular file every cleaner ages, which is the only way the lock's inode could bereplaced under a starter.