daemon: a relay without XDG_RUNTIME_DIR autospawns a second daemon and splits the roster #275

Closed
opened 2026-09-13 17:35:29 +09:00 by natsukium · 2 comments
Owner

felis-daemon relay resolves its socket in its own environment (crates/felis-daemon/src/main.rs:104
felis_transport::socket::default_socket_path). On Linux SocketPath::resolve
(crates/felis-transport/src/socket.rs:28-37) takes $XDG_RUNTIME_DIR/felis when the runtime dir is
there and otherwise falls back to ${TMPDIR:-/tmp}/felis.<uid>. An SSH server that does not run
pam_systemd hands the relay an environment with no XDG_RUNTIME_DIR, so the relay resolves the
fallback path, finds it cold, and autospawns a second daemon there
(crates/felis-daemon/src/relay.rs:72-87). The retargeted window lands on that daemon with an empty
roster while every existing session stays on the first one, and both keep running.

Observed

NixOS host reached over Tailscale SSH, 2026-09-13. Two daemons of the same build:

1341483  Sep 12 20:54  felis-daemon serve --socket /run/user/1000/felis/daemon.sock   13 sessions
2569722  Sep 13 17:24  felis-daemon serve --socket /tmp/felis.1000/daemon.sock         2 sessions

The second one was spawned by a relay under Tailscale SSH:

tailscaled be-child ssh --login-shell=/run/current-system/sw/bin/fish --uid=1000 --has-tty=false --cmd=felis-daemon relay
  └ fish -c felis-daemon relay
      └ felis-daemon relay

/proc/<pid>/environ is the proof: the relay and the daemon it spawned carry no XDG_RUNTIME_DIR,
while the first daemon carries XDG_RUNTIME_DIR=/run/user/1000. An OpenSSH login to the same host
in the same minute does get /run/user/1000 from pam_systemd, so the discriminator is the SSH
server, not the host.

Why the recorded triage does not cover it

The triage on #41 already corrected the issue body's Linux claim and confirmed this exact path, but
concluded it "rarely manifests because pam_systemd sets XDG_RUNTIME_DIR for SSH logins on systemd
hosts", and its round-3 amendment withdrew the proposed /run/user/<uid> probe because the Linux
endpoint rule is frozen at v0.1.0 (an upgraded client must not miss a running daemon). Tailscale SSH
falsifies the "rarely" premise: it is an ordinary way to reach a host, it never creates the
pam_systemd session environment, and nothing in felis reports what happened. The freeze conclusion
still stands — this issue asks for detection and an escape hatch, not a different default.

Options

  • A. Make the fallback audible. The relay's stderr rides the SSH channel back to the caller's
    terminal, which relay.rs:18-27 already uses for the --no-spawn diagnosis. When
    XDG_RUNTIME_DIR is unset and the relay is about to autospawn on the ${TMPDIR:-/tmp} fallback,
    say so and name the path. Cheapest, no contract change, and it turns a silent split into a visible
    one.
  • B. An escape hatch for the remote endpoint. There is none today: client-side --socket is
    local-only and mutually exclusive with --host, and the relay deliberately ignores
    FELIS_SOCKET (crates/felis-client-core/src/local_socket.rs:1-4: a daemon that reads the stamp
    makes an autospawned child bind an address already in use). felis-daemon relay already takes
    --socket (main.rs:55-58), so the missing piece is a client-side way to append it to the remote
    argv — a flag paired with --host. CLI plus carrier surface, so extend-ipc governs it.
  • C. Let doctor see the sibling. The Linux half of #41's option C: when the resolved endpoint
    differs from ${TMPDIR:-/tmp}/felis.<uid>/daemon.sock, connect-probe the other path and report
    "a second daemon is serving <path>" as a warn row. Read-only, never unlink a live socket
    (docs/explanation/security-model.md "Daemon IPC").
  • D. Document the remote-side fix. docs/how-to/attach-over-ssh.md carries the macOS TMPDIR
    recipe; the Linux counterpart is exporting XDG_RUNTIME_DIR for non-login shells on the remote
    host, which is what a Tailscale SSH user needs today.

Reproduce

  1. Enable Tailscale SSH on a Linux host that already runs a felis daemon under
    $XDG_RUNTIME_DIR/felis.
  2. From another machine, felis window retarget --to-host <tailscale-destination>.
  3. On the host: ps -eo args | grep felis-daemon shows two serve processes on different sockets,
    and felis --socket /tmp/felis.<uid>/daemon.sock sessions list shows the new window's session
    alone.

Acceptance

  • A relay that lands on the fallback path says so where the user can see it, and doctor on the
    host reports the second daemon instead of showing only the resolved one.
  • The Linux default endpoint is unchanged (#12 freeze boundary).

Related: #41 (the macOS twin of this split, TMPDIR instead of XDG_RUNTIME_DIR).

`felis-daemon relay` resolves its socket in its own environment (`crates/felis-daemon/src/main.rs:104` → `felis_transport::socket::default_socket_path`). On Linux `SocketPath::resolve` (`crates/felis-transport/src/socket.rs:28-37`) takes `$XDG_RUNTIME_DIR/felis` when the runtime dir is there and otherwise falls back to `${TMPDIR:-/tmp}/felis.<uid>`. An SSH server that does not run pam_systemd hands the relay an environment with no `XDG_RUNTIME_DIR`, so the relay resolves the fallback path, finds it cold, and autospawns a second daemon there (`crates/felis-daemon/src/relay.rs:72-87`). The retargeted window lands on that daemon with an empty roster while every existing session stays on the first one, and both keep running. ## Observed NixOS host reached over Tailscale SSH, 2026-09-13. Two daemons of the same build: ``` 1341483 Sep 12 20:54 felis-daemon serve --socket /run/user/1000/felis/daemon.sock 13 sessions 2569722 Sep 13 17:24 felis-daemon serve --socket /tmp/felis.1000/daemon.sock 2 sessions ``` The second one was spawned by a relay under Tailscale SSH: ``` tailscaled be-child ssh --login-shell=/run/current-system/sw/bin/fish --uid=1000 --has-tty=false --cmd=felis-daemon relay └ fish -c felis-daemon relay └ felis-daemon relay ``` `/proc/<pid>/environ` is the proof: the relay and the daemon it spawned carry no `XDG_RUNTIME_DIR`, while the first daemon carries `XDG_RUNTIME_DIR=/run/user/1000`. An OpenSSH login to the same host in the same minute does get `/run/user/1000` from pam_systemd, so the discriminator is the SSH server, not the host. ## Why the recorded triage does not cover it The triage on #41 already corrected the issue body's Linux claim and confirmed this exact path, but concluded it "rarely manifests because pam_systemd sets `XDG_RUNTIME_DIR` for SSH logins on systemd hosts", and its round-3 amendment withdrew the proposed `/run/user/<uid>` probe because the Linux endpoint rule is frozen at v0.1.0 (an upgraded client must not miss a running daemon). Tailscale SSH falsifies the "rarely" premise: it is an ordinary way to reach a host, it never creates the pam_systemd session environment, and nothing in felis reports what happened. The freeze conclusion still stands — this issue asks for detection and an escape hatch, not a different default. ## Options - **A. Make the fallback audible.** The relay's stderr rides the SSH channel back to the caller's terminal, which `relay.rs:18-27` already uses for the `--no-spawn` diagnosis. When `XDG_RUNTIME_DIR` is unset and the relay is about to autospawn on the `${TMPDIR:-/tmp}` fallback, say so and name the path. Cheapest, no contract change, and it turns a silent split into a visible one. - **B. An escape hatch for the remote endpoint.** There is none today: client-side `--socket` is local-only and mutually exclusive with `--host`, and the relay deliberately ignores `FELIS_SOCKET` (`crates/felis-client-core/src/local_socket.rs:1-4`: a daemon that reads the stamp makes an autospawned child bind an address already in use). `felis-daemon relay` already takes `--socket` (`main.rs:55-58`), so the missing piece is a client-side way to append it to the remote argv — a flag paired with `--host`. CLI plus carrier surface, so `extend-ipc` governs it. - **C. Let `doctor` see the sibling.** The Linux half of #41's option C: when the resolved endpoint differs from `${TMPDIR:-/tmp}/felis.<uid>/daemon.sock`, connect-probe the other path and report "a second daemon is serving `<path>`" as a `warn` row. Read-only, never unlink a live socket (`docs/explanation/security-model.md` "Daemon IPC"). - **D. Document the remote-side fix.** `docs/how-to/attach-over-ssh.md` carries the macOS `TMPDIR` recipe; the Linux counterpart is exporting `XDG_RUNTIME_DIR` for non-login shells on the remote host, which is what a Tailscale SSH user needs today. ## Reproduce 1. Enable Tailscale SSH on a Linux host that already runs a felis daemon under `$XDG_RUNTIME_DIR/felis`. 2. From another machine, `felis window retarget --to-host <tailscale-destination>`. 3. On the host: `ps -eo args | grep felis-daemon` shows two `serve` processes on different sockets, and `felis --socket /tmp/felis.<uid>/daemon.sock sessions list` shows the new window's session alone. ## Acceptance - A relay that lands on the fallback path says so where the user can see it, and `doctor` on the host reports the second daemon instead of showing only the resolved one. - The Linux default endpoint is unchanged (#12 freeze boundary). Related: #41 (the macOS twin of this split, `TMPDIR` instead of `XDG_RUNTIME_DIR`).
Author
Owner

Plan (2026-09-14, rev 12)

Source-grounded against main at 3664c6cb, reviewed through twelve rounds of two independent reviewers (pi sol and luna) until both passed with no findings.

Issue: #275 (body reproduced at the end).
Related: #41 (macOS TMPDIR twin; its triage plan and round-3 amendment froze the Linux default
endpoint rule at v0.1.0 and withdrew a /run/user/<uid> probe in the default rule).

Decision

Ship E + C + A + D in one PR. Reject B.

Shared vocabulary

Endpoint topology (felis-transport::socket, pure functions of uid, no environment)

Per OS, the two canonical places a per-user daemon socket can be:

OS login manager's location (LM) environment-free fallback (FB)
Linux /run/user/<uid>/felis/daemon.sock tmpdir_fallback(uid, None) = /tmp/felis.<uid>/daemon.sock
macOS <confstr(_CS_DARWIN_USER_TEMP_DIR)>/felis.<uid>/daemon.sock tmpdir_fallback(uid, None) = /tmp/felis.<uid>/daemon.sock
Windows none none

LM on macOS needs one libc confstr call in felis-transport, beside the existing audited
getpeereid site, with a // SAFETY: comment (the shape #41's triage plan already sanctioned).
Windows has no topology: the SID-derived pipe name is environment-independent, so nothing below
applies there.

The resolver (SocketPath::resolve, the frozen default rule) is untouched except for one
normalization: an empty TMPDIR (and, on Linux, an empty XDG_RUNTIME_DIR, which
directories already treats as unset) is treated as unset instead of failing as "not an
absolute path". Today TMPDIR= makes resolution error out before anything can run; nothing
could have been listening at a path that never resolved, so no running daemon is missed.
Test: TMPDIR= resolves like unset on both platforms.

Given a resolved default R, exactly one of three holds (path comparison is lexical, on the
paths the resolver produced):

  • R == LM: the normal login-session shell.
  • R == FB: the environment lacks the login manager's variable (Tailscale SSH on Linux, any SSH
    login on macOS).
  • R is neither: the user set XDG_RUNTIME_DIR or TMPDIR to a custom directory. This is an
    explicit opt-out: no probing anywhere, no doctor row; the environment is authoritative,
    exactly as today. Documented as such.

The probe (felis-transport::preface, shared by relay and doctor)

probe(path, deadline) -> Absent | Indeterminate { err } | ConnectedUnverified { why } | Live { detail },
one connection, run to a terminal outcome under one deadline (2 s, a constant):

  1. connect. ENOENT / ECONNREFUSED -> Absent. Any other error (EACCES, EMFILE,
    ENFILE, ...) -> Indeterminate.
  2. write ClientPreface::CURRENT; read the daemon preface. DaemonPreface::Refuse or
    DaemonPreface::Unknown (a valid FLIS preface with a status this build does not know is
    normatively a refusal from a live, newer daemon; docs/reference/ipc.md preface status table)
    -> Live { detail: the status and words }. Accept naming a major this build did not offer
    -> Live { detail: major mismatch } (what the primary doctor row already reports as a running
    daemon). Accept on an offered major -> step 3.
  3. send an Observer Hello; read the reply. Welcome -> Live. ConnMsg::Refused of any
    reason (AtCapacity included: a full daemon is a live daemon) -> Live { detail: reason }.
  4. Anywhere after a successful connect: timeout, EOF, reset, invalid preface bytes, invalid frame
    -> ConnectedUnverified. This includes a full daemon whose refusal slots are all taken and
    which accepts and drops without a preface (serve.rs refuse_over_capacity), a listener that
    sends Accept and then goes silent, and any non-felis process on the path.

Only Absent means "nothing is there". Indeterminate and ConnectedUnverified mean "something
may be there and could not be verified"; no caller may treat them as cold. The probe never
unlinks or binds (security-model "Binding never steals a live daemon's socket"). The connection is
closed after the terminal outcome; a daemon sees an Observer that left before attaching, which
the pool already tolerates.

Security: a client-side connect does not verify the server's uid; the trust in LM is the same
trust the resolver already places in $XDG_RUNTIME_DIR / launchd's TMPDIR (the login
manager's 0700 per-user directory), and the probe adds no symlink check the default path does not
have either. Stated in security-model.md "Daemon IPC".

E (primary): the relay selects its endpoint before failing or spawning

Applies to every remote dial, spawning and --no-spawn alike: window launches, sessions spawn, read/drive verbs, doctor --host, version --host, notifications.

felis-daemon relay receives a relay-only endpoint type from main.rs, which stops collapsing
None / Some(path) into one PathBuf: Explicit(PathBuf) for --socket, Default(R)
otherwise.

  1. Explicit(path): exact single endpoint, today's behavior unchanged (bare connect; on failure,
    --no-spawn fails, else spawn on path).
  2. Default(R): bare connect to R (today's connect; the client handshakes end-to-end through
    the pump, so the frozen path's behavior is unchanged when a daemon is there). Classify the
    connect result with the probe's step-1 rule: connected -> bridge; Absent -> step 3;
    Indeterminate -> exit with a diagnostic naming R and the error, no probe, no spawn. (This
    tightens today's relay, which spawns on any connect error; EACCES on the resolved path is not
    evidence of a cold socket.)
  3. If R == FB (and only then; R == LM has no sibling to prefer, and "neither" is the opt-out):
    probe(LM). Live -> step 4. Absent -> step 5. Indeterminate / ConnectedUnverified ->
    exit with a diagnostic naming LM and the outcome ("something listens at but did not
    answer as a felis daemon; not starting a second daemon"), no spawn, for spawning and
    --no-spawn dials alike.
  4. Bridge to LM: open a fresh connection to LM and pump it exactly as the R path is pumped
    today (bare connect, byte-transparent, unbounded). Log at info which endpoint won and why.
    The probe proved that a felis daemon answered at LM at selection time; the fresh connection
    is not re-verified, and no relay-side gate is attempted: a partial gate (first byte, first
    frame) cannot bound a peer that stops after a valid Accept, and a full one would make the
    relay parse the client's handshake, which relay.rs forbids by design (byte-transparent). A
    client-side handshake deadline is likewise rejected: the client's preface read sits behind
    SSH's own password and host-key prompts, and a felis timer there would fire under a prompt
    (explanation/architecture/ipc.md, the "no deadline on the ssh child" rule). What remains is
    the exposure the frozen R path already has: a same-user process that takes over the socket
    path in the interval between probe and connect, and then does not speak felis, hangs the
    client until the user closes it. This is stated as a documented boundary beside the
    probe-to-spawn race, not claimed closed.
  5. R and LM both Absent (or R == LM, no sibling): --no-spawn -> today's "no daemon on
    this host" error; otherwise spawn on R as today, plus the A warning.

Freeze argument: the frozen rule still resolves R first, and a live daemon on R is always
taken (step 2 bridges before any probe). E adds a candidate only when R is cold, which is
exactly the case that today produces a split (spawn) or a false "no daemon" (--no-spawn). An
upgraded client never misses a daemon it would have found before. Connect only, never bind or
unlink, so #41 round-3 ("must probe the legacy path and never bind over a live socket") holds.
The macOS LM probe is connect-only and leaves the macOS default where it is, so it complements
#41 rather than pre-empting it; a comment on #41 records that.

Cross-platform shape (non-goals.md "Cross-platform constraints"): the intent is uniform, "a
process whose environment lacks the login manager's per-user variable still finds the per-user
daemon"; only the mechanism (LM) differs per OS, and Windows needs none. Recorded as the fifth
platform shim in non-goals.md. macOS has no CI runtime: the topology function has
cfg(target_os = "macos") unit tests and the end-to-end path is hand-verified
(felis-macos-gui-debug) and recorded in the PR.

Residual races, stated rather than hidden. First, the probe-to-connect swap above (a hang the
frozen path shares). Second, between probe(LM) == Absent and the daemon spawned on
R binding, another process can start a daemon on LM (a GUI or OpenSSH login autospawning at
the same moment). serve's startup flock is per socket path and does not coordinate two
endpoints; E adds no cross-endpoint lock. The guarantee is "a relay never spawns beside a daemon
it can observe", not "two daemons can never coexist"; the outcome of the race is the pre-existing
split, now detectable by C. Recorded in the decision record with a Revisit-if trigger (the race is
observed in practice, or a cross-endpoint lock becomes cheap).

--help for relay --socket no longer says "same default as serve"; it says that without
--socket the relay also probes the login manager's location when the environment resolved the
fallback.

Tests (spawn seam injected; the test binary is never exec'd; LM/FB injected as paths):

  • topology: LM/FB per OS; classification of R into LM / FB / neither for: variable
    unset, empty, equal to LM's dir, custom; cfg(target_os = "macos") for the confstr cases.
  • (a) R cold, LM live: bridges to LM, spawn seam not called.
  • (b) same with --no-spawn: bridges, no error.
  • (c) Explicit(R) with LM live: no probe; --no-spawn fails NotFound.
  • (d) both Absent, spawn allowed: spawn seam called with R.
  • (e) both live: bridges to R; LM never dialed; spawn seam not called (the freeze invariant).
  • (f) silent accepting listener on LM, R cold: no spawn, exit with the diagnostic, no hang.
  • (g) listener answering Accept then silent: same as (f).
  • (h) fake daemon answering Refused AtCapacity on LM: selected as live.
  • (i) daemon on LM with connection admission and refusal slots both zero
    (ConnectionAdmission::with_refusal_slots(0, 0): accept-and-drop; admission zero alone still
    answers Refused AtCapacity): no spawn, diagnostic.
  • (j) LM connect fails with EACCES (unreadable dir): no spawn, diagnostic names LM.
  • (k) fake daemon answering DaemonPreface::Unknown on LM: selected as live.
  • (l) R connect fails with EACCES, LM cold: no probe, no spawn, diagnostic names R.
  • (m) R == LM (Linux with XDG_RUNTIME_DIR), R cold: no probe, spawn on R as today.
  • (n) R neither (custom variable), R cold, LM live: no probe, spawn on R (opt-out).

C: doctor reports the other canonical endpoint

Provenance first. Reconnector carries only Carrier::Local(path), so --socket, a non-empty
FELIS_SOCKET stamp (every shell inside a felis session has one: felis doctor typed in a
session deliberately targets that daemon), and the platform default are indistinguishable there,
and path equality is not a substitute (an explicit or stamped path can equal the default).
resolve_local_socket_from therefore returns the provenance (Explicit | Stamped | Default)
alongside the path, conn::resolve keeps it, and doctor::run runs the sibling check only for
Default on a local carrier. Under --host, --socket, or a stamped FELIS_SOCKET there is no
sibling row (skipped, not "absent"). Tests: all four sources, including an explicit and a stamped
path equal to the computed default.

Selection: with R the default, the other endpoint S is FB when R == LM, LM when
R == FB, and none (no row) when R is neither (the opt-out). Windows: no row ever. Alias: if
canonicalize(R) == canonicalize(S) (a symlinked directory), no row: the two names are one
socket.

Primary row: today daemon_check folds every non-specialized ConnectError into "not running",
which would let a silent or malformed listener on R read as cold while the same peer on S
reads as "unverified". Two separate changes to the primary row, with different scopes:

(1) Dial behavior: the deadline and the connect-error partition (Absent / Indeterminate /
ConnectedUnverified) apply only for Default provenance on the local Unix carrier.
Explicit --socket, stamped FELIS_SOCKET, SSH (--host), and Windows targets keep today's
unbounded dial and today's failure renderings; tests pin the boundary (an explicit --socket to
a silent listener is not subject to the deadline).

(2) Rendering of completed handshakes: the two Live outcomes that today fall into the "not
running" catch-all get their own arms on every provenance and carrier, because they are
answers from a daemon, not dial failures. A test on a non-default target (explicit --socket to
a fake daemon answering Refused) pins that the arms apply there too.

With the probe deadline, the default-local partition renders: Absent -> warn "not
running" (today's rendering and status, pinned by crates/felis-cli/tests/cli_doctor.rs, kept);
Indeterminate -> warn "could not dial : "; ConnectedUnverified -> warn "something
listens at but did not answer as a felis daemon"; the existing Live renderings (running,
major skew, not-felis-negotiation fail) are unchanged. The new rendering arms of (2):
DaemonPreface::Unknown ->
warn "running, but answered preface status () this build does not know: upgrade
this build"; ConnMsg::Refused { reason } for every reason -> warn "running, but refused this
connection: " (AtCapacity, Role, and any later reason render through the same
arm). Tests on a default-local target: unknown preface status and each Refused reason render
"running, but ...", never "not running". A silent listener on a local R no
longer hangs doctor. The SSH carrier (doctor --host) keeps today's unbounded dial and
renderings: its preface read sits behind SSH's prompts, and a deadline there would fire under a
password or host-key prompt (the same rule that rejects a client-side handshake deadline in E).
Over SSH the relay's diagnostics (indeterminate, unverified) are stderr-only, printed beside the
row; the client sees the relay close its pipe and the row renders "not running" as today. That is
the honest reading of what the client can observe, and the plan adds no machine-readable remote
probe (extend-ipc scope, not needed for this issue's acceptance, which is local doctor on the
host). Tests: silent listener on a local R, EACCES on a local R, the existing cold-socket
test unchanged.

Sibling row: probe(S) with the shared helper. Live -> a warn row with token daemon-sibling; every
other variant -> no row, rendered as such without renaming (Indeterminate and
ConnectedUnverified are "no row", never "absent"). Wording states only what was observed:
nothing on the wire identifies a daemon instance (DaemonStatus carries version and protocol,
no pid or endpoint), so doctor cannot prove two answering paths are two daemons and must not
say so or recommend stopping one.

  • primary live, S live: "a felis daemon also answers at ; if it is a different daemon, its
    sessions are not visible from . Inspect with felis --socket <S> sessions list." Live
    detail (major skew, capacity) appended when present.
  • primary cold, S live: "no daemon at ; a felis daemon answers at ". Factual; no claim
    about why.

Read-only; never unlink. The operator recipe (confirm with sessions list on both, retire with
felis --socket <path> daemon stop --when-empty) lives in the how-to (D), which the row does not
repeat.

Machine output: daemon-sibling extends the stable check token vocabulary, so CheckObject's
doc comment in cli_output.rs lists it, just schema regenerates
crates/felis-cli/schemas/felis-cli-v1.schema.json, and the regenerated file is committed (the
schema-freshness test fails otherwise). docs/reference/cli.md "Doctor" lists the token.

Tests: both-live, primary-only (no row), sibling-only, alias (symlinked dir, no row),
refuse-major, unknown-status, accept-and-drop (ConnectionAdmission::with_refusal_slots(0, 0):
ConnectedUnverified, no row) and Refused AtCapacity (admission zero with refusal slots: row
with detail), silent listener, accept-then-silent, indeterminate connect
error (no row), --socket set, FELIS_SOCKET stamped, R neither (no row), Linux R == LM ->
probes FB, Linux R == FB -> probes LM, macOS R == LM -> probes FB
(cfg(target_os = "macos")), silent listener does not hang doctor.

A: make the fallback spawn audible where it can be

In step 5 of E, when spawning on R == FB: warn! "the environment resolved the fallback
endpoint ( is unset, empty, or names the fallback directory); spawning a daemon there. If
this host runs a login manager, export for non-login shells so remote and local felis share
one daemon" with <var> the OS's variable (XDG_RUNTIME_DIR on Linux, TMPDIR on macOS).
Eligibility is endpoint-based, so an explicit TMPDIR=/tmp on macOS is R == FB and is probed
and warned like an unset one; the text makes no claim about how the variable got there. The
opt-out case (R neither) gets an info line naming the variable and the path, no warning: the
environment was set on purpose. Topology tests include TMPDIR=/tmp explicitly set.

Honest scope: relay stderr is inherited by the ssh child (felis-transport/src/stdio.rs), so
it reaches the terminal for CLI --host dials only. For a GUI-initiated retarget the felis-client's
stderr is not a terminal and the tracing subscriber does not capture the child's stderr, so
nothing is shown or logged there. A is a diagnostic, not the mechanism that satisfies the
acceptance; E (prevention) and C (detection) are.

D: document the remote-side situation

docs/how-to/attach-over-ssh.md "Operational notes":

  • Replace the macOS TMPDIR note: current builds find the GUI daemon automatically (the relay
    probes the login manager's location when the SSH login lacks the variable); the export recipe
    stays as the remedy for older builds on the remote host or when the relay's stderr diagnostic
    reports that the login manager's endpoint could not be verified.
  • Linux counterpart: an SSH server that does not run pam_systemd (Tailscale SSH is the known one)
    hands the relay no XDG_RUNTIME_DIR; current builds probe /run/user/<uid>/felis
    automatically when the environment resolves the fallback /tmp/felis.<uid> (no
    XDG_RUNTIME_DIR, and TMPDIR unset, empty, or /tmp); a custom TMPDIR or
    XDG_RUNTIME_DIR is an opt-out and is used as given. On a host where /run/user/<uid> exists (systemd-logind created it, 0700, owned
    by you), exporting XDG_RUNTIME_DIR=/run/user/$(id -u) for non-login shells is still worth
    doing because the relay's environment is carried into the sessions it creates (carrier block),
    so the missing variable otherwise reaches the user's shells. Not presented as a non-systemd
    remedy: a host without that directory has nothing to export and the fallback path is correct
    there.
  • The operator recipe doctor points at: confirm which endpoint holds which sessions with
    felis --socket <path> sessions list on both, then retire the unwanted one with
    felis --socket <path> daemon stop --when-empty.
  • The "Single daemon per user" note gets a pointer to the above.

B: remote-side socket flag paired with --host -> rejected

Permanent CLI + carrier surface (extend-ipc) for a hatch the user must remember on every
invocation. E removes the common case; the custom-variable opt-out and D cover the rest.
Recorded as a rejected alternative in the carrier decision record (explanation/architecture/
ipc.md), not in non-goals.md (it does not pass the permanent-rejection gate; revisit if a
concrete host layout neither E nor the opt-out handles is reported).

Doc cascade

  • docs/reference/ipc.md "Cross-host carrier: SSH stdio": the relay's endpoint selection
    (explicit --socket; else R; else, when R == FB, LM; the probe's outcome classes and
    what each leads to; the fresh, unverified, byte-transparent connection the relay then pumps and
    the accepted probe-to-connect exposure). Normative facts only.
  • docs/explanation/architecture/ipc.md cross-host carrier section: the decision record inline
    (why probe-only and relay-only, why not the default rule (freeze, #41 round 3), why no reverse
    probe in the relay, why the custom-variable case is an opt-out, the residual race; rejected:
    remote --socket flag (B), changing the default, a cross-endpoint lock; Revisit if: the race is
    observed, a non-systemd Linux runtime layout shows up, or #41 moves the macOS endpoint).
  • docs/explanation/architecture/control-surfaces.md "A session verb never spawns a daemon
    implicitly": "a cold remote socket fails like a cold local one" becomes: "no daemon" is
    reported only after every eligible endpoint (R, and LM when R == FB) is Absent; an
    Indeterminate endpoint or an unverified LM fails the dial without spawning, with the path in
    the diagnostic; both linked to the normative rule in reference/ipc.md.
  • docs/reference/workspace.md runtime-dir bullet: "Two processes whose environments disagree
    here resolve different sockets and therefore different daemons" gets the relay/doctor exception,
    the Linux SSH case beside the macOS one, and the empty-variable normalization.
  • docs/reference/spec.md: new REQ (next free id near REQ-009a) for the relay's endpoint
    selection, sourced to docs/reference/ipc.md "Cross-host carrier: SSH stdio" (the normative
    lane), with the explanation ipc.md section as the rationale link; REQ-009a wording unchanged (a
    cold daemon still spawns on the dialing process's own resolved path).
  • docs/explanation/non-goals.md "Cross-platform constraints": the fifth platform shim, "the
    per-user daemon endpoint when the login manager's variable is missing", with the per-OS LM
    and the Windows no-op; "None of the four" becomes "None of the five".
  • docs/reference/cli.md: "Carrier resolution order" paragraph gains the relay sentence and the
    empty-variable normalization; the doctor section documents the daemon-sibling row and
    token; the auto-spawn matrix notes that relay --no-spawn probes LM before reporting "no
    daemon".
  • docs/explanation/security-model.md "Daemon IPC": one bullet stating the trust the LM probe
    relies on; nothing about scope.
  • docs/reference/security-audits.md (socket location claim, ~line 19-21): sockets live at the
    resolved default ($XDG_RUNTIME_DIR/felis, the macOS confstr dir, or the /tmp/felis.<uid>
    fallback), each a 0700 per-user directory; the relay/doctor probe reads the other canonical
    location under the same ownership assumption.
  • docs/how-to/attach-over-ssh.md: D above.
  • skills/felis/SKILL.md remote-verb matrix (line ~212-214): read/drive verbs exit 2 only when
    neither R nor, when eligible, LM has a daemon; and the "outside this skill" list
    (~line 241-244) that excludes doctor gains the one exception an agent needs: when a remote
    or local roster looks empty, felis doctor --format json on the host and its daemon-sibling
    row tell whether a second endpoint holds the sessions.
  • CHANGELOG.md: relay endpoint selection (user-affecting), the tightened indeterminate-error
    handling, empty TMPDIR normalization, doctor row, how-to note.
  • felis-daemon --help text for relay --socket.
  • Issue #41: a comment noting the macOS connect-only LM probe landed here and that #41's
    default move remains its own decision.
  • Grep sweep for XDG_RUNTIME_DIR, TMPDIR, felis.<uid>, no daemon on this host,
    --no-spawn, daemon-sibling.

Acceptance

  • A relay under an SSH server without pam_systemd (Linux) or any SSH login (macOS) bridges to the
    daemon at LM when one is running there, for spawning and --no-spawn dials alike, and spawns
    nothing.
  • With R and LM both Absent, it spawns (or fails under --no-spawn) on R as before, and
    the spawn warns with the path on the relay's stderr.
  • A connect error other than ENOENT / ECONNREFUSED on R or LM, or a listener on LM that
    does not complete the felis handshake at probe time, ends the dial with a diagnostic naming the
    path and never spawns or hangs. A peer swapped in after the probe has the frozen path's
    existing exposure, documented, not claimed closed.
  • --socket on the relay and on the client stays an exact single endpoint; a custom
    XDG_RUNTIME_DIR / TMPDIR is an opt-out (no probe, no row).
  • The doctor deadline and connect-error partition apply to the local Unix carrier's default
    endpoint; doctor --host, --socket, stamped, and Windows targets keep today's dial and
    dial-failure renderings, with the relay's diagnostic on stderr over SSH. The "running, but ..."
    arms for an unknown preface status and for Refused apply on every target.
  • felis doctor with daemons at both R and S reports S as a warn row with the both-live
    wording; with R cold and S live it reports S without claiming two daemons; with --socket,
    --host, a stamped FELIS_SOCKET, or R neither it emits no sibling row; an alias, a silent
    listener, an accept-and-drop daemon, or an indeterminate connect error yields no row and no
    hang; a major-skewed or Refused AtCapacity daemon on S is reported with its detail; the
    primary row reports an unverified or undialable R as warn, never as "not running".
  • The same relay selection and doctor row exist on macOS via the confstr LM (unit-tested under
    cfg(target_os = "macos"), hand-verified end to end, recorded in the PR); on Windows the
    relay, topology, sibling row, deadline, and dial behavior are unchanged, and only the two new
    rendering arms apply.
  • The default endpoint rule for serve and local clients is unchanged except that an empty
    TMPDIR resolves like an unset one (#12 freeze boundary).
  • The residual probe-to-spawn race and the probe-to-connect swap exposure are documented in the
    decision record, not claimed closed.
  • just schema is clean after the daemon-sibling token is added; just check passes; the new
    tests exercise the spawn seam, not the test binary.
## Plan (2026-09-14, rev 12) Source-grounded against `main` at 3664c6cb, reviewed through twelve rounds of two independent reviewers (`pi` sol and luna) until both passed with no findings. Issue: https://git.natsukium.com/natsukium/felis/issues/275 (body reproduced at the end). Related: #41 (macOS TMPDIR twin; its triage plan and round-3 amendment froze the Linux default endpoint rule at v0.1.0 and withdrew a `/run/user/<uid>` probe *in the default rule*). ## Decision Ship **E + C + A + D** in one PR. Reject **B**. ## Shared vocabulary ### Endpoint topology (felis-transport::socket, pure functions of uid, no environment) Per OS, the two canonical places a per-user daemon socket can be: | OS | login manager's location (`LM`) | environment-free fallback (`FB`) | | ------- | -------------------------------------------------------- | ------------------------------------------------------------- | | Linux | `/run/user/<uid>/felis/daemon.sock` | `tmpdir_fallback(uid, None)` = `/tmp/felis.<uid>/daemon.sock` | | macOS | `<confstr(_CS_DARWIN_USER_TEMP_DIR)>/felis.<uid>/daemon.sock` | `tmpdir_fallback(uid, None)` = `/tmp/felis.<uid>/daemon.sock` | | Windows | none | none | `LM` on macOS needs one libc `confstr` call in `felis-transport`, beside the existing audited `getpeereid` site, with a `// SAFETY:` comment (the shape #41's triage plan already sanctioned). Windows has no topology: the SID-derived pipe name is environment-independent, so nothing below applies there. The resolver (`SocketPath::resolve`, the frozen default rule) is untouched except for one normalization: an empty `TMPDIR` (and, on Linux, an empty `XDG_RUNTIME_DIR`, which `directories` already treats as unset) is treated as unset instead of failing as "not an absolute path". Today `TMPDIR=` makes resolution error out before anything can run; nothing could have been listening at a path that never resolved, so no running daemon is missed. Test: `TMPDIR=` resolves like unset on both platforms. Given a resolved default `R`, exactly one of three holds (path comparison is lexical, on the paths the resolver produced): - `R == LM`: the normal login-session shell. - `R == FB`: the environment lacks the login manager's variable (Tailscale SSH on Linux, any SSH login on macOS). - `R` is neither: the user set `XDG_RUNTIME_DIR` or `TMPDIR` to a custom directory. This is an **explicit opt-out**: no probing anywhere, no doctor row; the environment is authoritative, exactly as today. Documented as such. ### The probe (felis-transport::preface, shared by relay and doctor) `probe(path, deadline) -> Absent | Indeterminate { err } | ConnectedUnverified { why } | Live { detail }`, one connection, run to a terminal outcome under one deadline (2 s, a constant): 1. connect. `ENOENT` / `ECONNREFUSED` -> `Absent`. Any other error (`EACCES`, `EMFILE`, `ENFILE`, ...) -> `Indeterminate`. 2. write `ClientPreface::CURRENT`; read the daemon preface. `DaemonPreface::Refuse` or `DaemonPreface::Unknown` (a valid `FLIS` preface with a status this build does not know is normatively a refusal from a live, newer daemon; `docs/reference/ipc.md` preface status table) -> `Live { detail: the status and words }`. `Accept` naming a major this build did not offer -> `Live { detail: major mismatch }` (what the primary doctor row already reports as a running daemon). `Accept` on an offered major -> step 3. 3. send an Observer `Hello`; read the reply. `Welcome` -> `Live`. `ConnMsg::Refused` of any reason (`AtCapacity` included: a full daemon is a live daemon) -> `Live { detail: reason }`. 4. Anywhere after a successful connect: timeout, EOF, reset, invalid preface bytes, invalid frame -> `ConnectedUnverified`. This includes a full daemon whose refusal slots are all taken and which accepts and drops without a preface (`serve.rs` `refuse_over_capacity`), a listener that sends `Accept` and then goes silent, and any non-felis process on the path. Only `Absent` means "nothing is there". `Indeterminate` and `ConnectedUnverified` mean "something may be there and could not be verified"; no caller may treat them as cold. The probe never unlinks or binds (security-model "Binding never steals a live daemon's socket"). The connection is closed after the terminal outcome; a daemon sees an Observer that left before attaching, which the pool already tolerates. Security: a client-side connect does not verify the server's uid; the trust in `LM` is the same trust the resolver already places in `$XDG_RUNTIME_DIR` / launchd's `TMPDIR` (the login manager's 0700 per-user directory), and the probe adds no symlink check the default path does not have either. Stated in security-model.md "Daemon IPC". ## E (primary): the relay selects its endpoint before failing or spawning Applies to every remote dial, spawning and `--no-spawn` alike: window launches, `sessions spawn`, read/drive verbs, `doctor --host`, `version --host`, notifications. `felis-daemon relay` receives a relay-only endpoint type from `main.rs`, which stops collapsing `None` / `Some(path)` into one `PathBuf`: `Explicit(PathBuf)` for `--socket`, `Default(R)` otherwise. 1. `Explicit(path)`: exact single endpoint, today's behavior unchanged (bare connect; on failure, `--no-spawn` fails, else spawn on `path`). 2. `Default(R)`: bare connect to `R` (today's connect; the client handshakes end-to-end through the pump, so the frozen path's behavior is unchanged when a daemon is there). Classify the connect result with the probe's step-1 rule: connected -> bridge; `Absent` -> step 3; `Indeterminate` -> exit with a diagnostic naming `R` and the error, no probe, no spawn. (This tightens today's relay, which spawns on any connect error; `EACCES` on the resolved path is not evidence of a cold socket.) 3. If `R == FB` (and only then; `R == LM` has no sibling to prefer, and "neither" is the opt-out): `probe(LM)`. `Live` -> step 4. `Absent` -> step 5. `Indeterminate` / `ConnectedUnverified` -> exit with a diagnostic naming `LM` and the outcome ("something listens at <LM> but did not answer as a felis daemon; not starting a second daemon"), no spawn, for spawning and `--no-spawn` dials alike. 4. Bridge to `LM`: open a fresh connection to `LM` and pump it exactly as the `R` path is pumped today (bare connect, byte-transparent, unbounded). Log at info which endpoint won and why. The probe proved that a felis daemon answered at `LM` at selection time; the fresh connection is not re-verified, and no relay-side gate is attempted: a partial gate (first byte, first frame) cannot bound a peer that stops after a valid `Accept`, and a full one would make the relay parse the client's handshake, which `relay.rs` forbids by design (byte-transparent). A client-side handshake deadline is likewise rejected: the client's preface read sits behind SSH's own password and host-key prompts, and a felis timer there would fire under a prompt (explanation/architecture/ipc.md, the "no deadline on the ssh child" rule). What remains is the exposure the frozen `R` path already has: a same-user process that takes over the socket path in the interval between probe and connect, and then does not speak felis, hangs the client until the user closes it. This is stated as a documented boundary beside the probe-to-spawn race, not claimed closed. 5. `R` and `LM` both `Absent` (or `R == LM`, no sibling): `--no-spawn` -> today's "no daemon on this host" error; otherwise spawn on `R` as today, plus the A warning. Freeze argument: the frozen rule still resolves `R` first, and a live daemon on `R` is always taken (step 2 bridges before any probe). E adds a candidate only when `R` is cold, which is exactly the case that today produces a split (spawn) or a false "no daemon" (`--no-spawn`). An upgraded client never misses a daemon it would have found before. Connect only, never bind or unlink, so #41 round-3 ("must probe the legacy path and never bind over a live socket") holds. The macOS `LM` probe is connect-only and leaves the macOS default where it is, so it complements #41 rather than pre-empting it; a comment on #41 records that. Cross-platform shape (non-goals.md "Cross-platform constraints"): the intent is uniform, "a process whose environment lacks the login manager's per-user variable still finds the per-user daemon"; only the mechanism (`LM`) differs per OS, and Windows needs none. Recorded as the fifth platform shim in non-goals.md. macOS has no CI runtime: the topology function has `cfg(target_os = "macos")` unit tests and the end-to-end path is hand-verified (felis-macos-gui-debug) and recorded in the PR. Residual races, stated rather than hidden. First, the probe-to-connect swap above (a hang the frozen path shares). Second, between `probe(LM) == Absent` and the daemon spawned on `R` binding, another process can start a daemon on `LM` (a GUI or OpenSSH login autospawning at the same moment). `serve`'s startup `flock` is per socket path and does not coordinate two endpoints; E adds no cross-endpoint lock. The guarantee is "a relay never spawns beside a daemon it can observe", not "two daemons can never coexist"; the outcome of the race is the pre-existing split, now detectable by C. Recorded in the decision record with a Revisit-if trigger (the race is observed in practice, or a cross-endpoint lock becomes cheap). `--help` for `relay --socket` no longer says "same default as serve"; it says that without `--socket` the relay also probes the login manager's location when the environment resolved the fallback. Tests (spawn seam injected; the test binary is never exec'd; `LM`/`FB` injected as paths): - topology: `LM`/`FB` per OS; classification of `R` into `LM` / `FB` / neither for: variable unset, empty, equal to `LM`'s dir, custom; `cfg(target_os = "macos")` for the confstr cases. - (a) `R` cold, `LM` live: bridges to `LM`, spawn seam not called. - (b) same with `--no-spawn`: bridges, no error. - (c) `Explicit(R)` with `LM` live: no probe; `--no-spawn` fails NotFound. - (d) both `Absent`, spawn allowed: spawn seam called with `R`. - (e) both live: bridges to `R`; `LM` never dialed; spawn seam not called (the freeze invariant). - (f) silent accepting listener on `LM`, `R` cold: no spawn, exit with the diagnostic, no hang. - (g) listener answering `Accept` then silent: same as (f). - (h) fake daemon answering `Refused AtCapacity` on `LM`: selected as live. - (i) daemon on `LM` with connection admission and refusal slots both zero (`ConnectionAdmission::with_refusal_slots(0, 0)`: accept-and-drop; admission zero alone still answers `Refused AtCapacity`): no spawn, diagnostic. - (j) `LM` connect fails with `EACCES` (unreadable dir): no spawn, diagnostic names `LM`. - (k) fake daemon answering `DaemonPreface::Unknown` on `LM`: selected as live. - (l) `R` connect fails with `EACCES`, `LM` cold: no probe, no spawn, diagnostic names `R`. - (m) `R == LM` (Linux with `XDG_RUNTIME_DIR`), `R` cold: no probe, spawn on `R` as today. - (n) `R` neither (custom variable), `R` cold, `LM` live: no probe, spawn on `R` (opt-out). ## C: `doctor` reports the other canonical endpoint Provenance first. `Reconnector` carries only `Carrier::Local(path)`, so `--socket`, a non-empty `FELIS_SOCKET` stamp (every shell inside a felis session has one: `felis doctor` typed in a session deliberately targets that daemon), and the platform default are indistinguishable there, and path equality is not a substitute (an explicit or stamped path can equal the default). `resolve_local_socket_from` therefore returns the provenance (`Explicit | Stamped | Default`) alongside the path, `conn::resolve` keeps it, and `doctor::run` runs the sibling check only for `Default` on a local carrier. Under `--host`, `--socket`, or a stamped `FELIS_SOCKET` there is no sibling row (skipped, not "absent"). Tests: all four sources, including an explicit and a stamped path equal to the computed default. Selection: with `R` the default, the other endpoint `S` is `FB` when `R == LM`, `LM` when `R == FB`, and none (no row) when `R` is neither (the opt-out). Windows: no row ever. Alias: if `canonicalize(R) == canonicalize(S)` (a symlinked directory), no row: the two names are one socket. Primary row: today `daemon_check` folds every non-specialized `ConnectError` into "not running", which would let a silent or malformed listener on `R` read as cold while the same peer on `S` reads as "unverified". Two separate changes to the primary row, with different scopes: (1) **Dial behavior**: the deadline and the connect-error partition (`Absent` / `Indeterminate` / `ConnectedUnverified`) apply **only for `Default` provenance on the local Unix carrier**. Explicit `--socket`, stamped `FELIS_SOCKET`, SSH (`--host`), and Windows targets keep today's unbounded dial and today's failure renderings; tests pin the boundary (an explicit `--socket` to a silent listener is not subject to the deadline). (2) **Rendering of completed handshakes**: the two `Live` outcomes that today fall into the "not running" catch-all get their own arms on **every provenance and carrier**, because they are answers from a daemon, not dial failures. A test on a non-default target (explicit `--socket` to a fake daemon answering `Refused`) pins that the arms apply there too. With the probe deadline, the default-local partition renders: `Absent` -> `warn` "not running" (today's rendering and status, pinned by `crates/felis-cli/tests/cli_doctor.rs`, kept); `Indeterminate` -> `warn` "could not dial <R>: <err>"; `ConnectedUnverified` -> `warn` "something listens at <R> but did not answer as a felis daemon"; the existing `Live` renderings (running, major skew, not-felis-negotiation `fail`) are unchanged. The new rendering arms of (2): `DaemonPreface::Unknown` -> `warn` "running, but answered preface status <n> (<words>) this build does not know: upgrade this build"; `ConnMsg::Refused { reason }` for every reason -> `warn` "running, but refused this connection: <reason>" (`AtCapacity`, `Role`, and any later reason render through the same arm). Tests on a default-local target: unknown preface status and each `Refused` reason render "running, but ...", never "not running". A silent listener on a local `R` no longer hangs `doctor`. The SSH carrier (`doctor --host`) keeps today's unbounded dial and renderings: its preface read sits behind SSH's prompts, and a deadline there would fire under a password or host-key prompt (the same rule that rejects a client-side handshake deadline in E). Over SSH the relay's diagnostics (indeterminate, unverified) are stderr-only, printed beside the row; the client sees the relay close its pipe and the row renders "not running" as today. That is the honest reading of what the client can observe, and the plan adds no machine-readable remote probe (extend-ipc scope, not needed for this issue's acceptance, which is local `doctor` on the host). Tests: silent listener on a local `R`, `EACCES` on a local `R`, the existing cold-socket test unchanged. Sibling row: `probe(S)` with the shared helper. `Live` -> a `warn` row with token `daemon-sibling`; every other variant -> no row, rendered as such without renaming (`Indeterminate` and `ConnectedUnverified` are "no row", never "absent"). Wording states only what was observed: nothing on the wire identifies a daemon *instance* (`DaemonStatus` carries version and protocol, no pid or endpoint), so `doctor` cannot prove two answering paths are two daemons and must not say so or recommend stopping one. - primary live, `S` live: "a felis daemon also answers at <S>; if it is a different daemon, its sessions are not visible from <R>. Inspect with `felis --socket <S> sessions list`." `Live` detail (major skew, capacity) appended when present. - primary cold, `S` live: "no daemon at <R>; a felis daemon answers at <S>". Factual; no claim about why. Read-only; never unlink. The operator recipe (confirm with `sessions list` on both, retire with `felis --socket <path> daemon stop --when-empty`) lives in the how-to (D), which the row does not repeat. Machine output: `daemon-sibling` extends the stable `check` token vocabulary, so `CheckObject`'s doc comment in `cli_output.rs` lists it, `just schema` regenerates `crates/felis-cli/schemas/felis-cli-v1.schema.json`, and the regenerated file is committed (the schema-freshness test fails otherwise). `docs/reference/cli.md` "Doctor" lists the token. Tests: both-live, primary-only (no row), sibling-only, alias (symlinked dir, no row), refuse-major, unknown-status, accept-and-drop (`ConnectionAdmission::with_refusal_slots(0, 0)`: `ConnectedUnverified`, no row) and `Refused AtCapacity` (admission zero with refusal slots: row with detail), silent listener, accept-then-silent, indeterminate connect error (no row), `--socket` set, `FELIS_SOCKET` stamped, `R` neither (no row), Linux `R == LM` -> probes `FB`, Linux `R == FB` -> probes `LM`, macOS `R == LM` -> probes `FB` (`cfg(target_os = "macos")`), silent listener does not hang `doctor`. ## A: make the fallback spawn audible where it can be In step 5 of E, when spawning on `R == FB`: `warn!` "the environment resolved the fallback endpoint <R> (<var> is unset, empty, or names the fallback directory); spawning a daemon there. If this host runs a login manager, export <var> for non-login shells so remote and local felis share one daemon" with `<var>` the OS's variable (`XDG_RUNTIME_DIR` on Linux, `TMPDIR` on macOS). Eligibility is endpoint-based, so an explicit `TMPDIR=/tmp` on macOS is `R == FB` and is probed and warned like an unset one; the text makes no claim about how the variable got there. The opt-out case (`R` neither) gets an info line naming the variable and the path, no warning: the environment was set on purpose. Topology tests include `TMPDIR=/tmp` explicitly set. Honest scope: relay stderr is inherited by the `ssh` child (`felis-transport/src/stdio.rs`), so it reaches the terminal for CLI `--host` dials only. For a GUI-initiated retarget the felis-client's stderr is not a terminal and the tracing subscriber does not capture the child's stderr, so nothing is shown or logged there. A is a diagnostic, not the mechanism that satisfies the acceptance; E (prevention) and C (detection) are. ## D: document the remote-side situation `docs/how-to/attach-over-ssh.md` "Operational notes": - Replace the macOS `TMPDIR` note: current builds find the GUI daemon automatically (the relay probes the login manager's location when the SSH login lacks the variable); the export recipe stays as the remedy for older builds on the remote host or when the relay's stderr diagnostic reports that the login manager's endpoint could not be verified. - Linux counterpart: an SSH server that does not run pam_systemd (Tailscale SSH is the known one) hands the relay no `XDG_RUNTIME_DIR`; current builds probe `/run/user/<uid>/felis` automatically when the environment resolves the fallback `/tmp/felis.<uid>` (no `XDG_RUNTIME_DIR`, and `TMPDIR` unset, empty, or `/tmp`); a custom `TMPDIR` or `XDG_RUNTIME_DIR` is an opt-out and is used as given. On a host where `/run/user/<uid>` exists (systemd-logind created it, 0700, owned by you), exporting `XDG_RUNTIME_DIR=/run/user/$(id -u)` for non-login shells is still worth doing because the relay's environment is carried into the sessions it creates (carrier block), so the missing variable otherwise reaches the user's shells. Not presented as a non-systemd remedy: a host without that directory has nothing to export and the fallback path is correct there. - The operator recipe `doctor` points at: confirm which endpoint holds which sessions with `felis --socket <path> sessions list` on both, then retire the unwanted one with `felis --socket <path> daemon stop --when-empty`. - The "Single daemon per user" note gets a pointer to the above. ## B: remote-side socket flag paired with `--host` -> rejected Permanent CLI + carrier surface (extend-ipc) for a hatch the user must remember on every invocation. E removes the common case; the custom-variable opt-out and D cover the rest. Recorded as a rejected alternative in the carrier decision record (explanation/architecture/ ipc.md), not in non-goals.md (it does not pass the permanent-rejection gate; revisit if a concrete host layout neither E nor the opt-out handles is reported). ## Doc cascade - `docs/reference/ipc.md` "Cross-host carrier: SSH stdio": the relay's endpoint selection (explicit `--socket`; else `R`; else, when `R == FB`, `LM`; the probe's outcome classes and what each leads to; the fresh, unverified, byte-transparent connection the relay then pumps and the accepted probe-to-connect exposure). Normative facts only. - `docs/explanation/architecture/ipc.md` cross-host carrier section: the decision record inline (why probe-only and relay-only, why not the default rule (freeze, #41 round 3), why no reverse probe in the relay, why the custom-variable case is an opt-out, the residual race; rejected: remote `--socket` flag (B), changing the default, a cross-endpoint lock; Revisit if: the race is observed, a non-systemd Linux runtime layout shows up, or #41 moves the macOS endpoint). - `docs/explanation/architecture/control-surfaces.md` "A session verb never spawns a daemon implicitly": "a cold remote socket fails like a cold local one" becomes: "no daemon" is reported only after every eligible endpoint (`R`, and `LM` when `R == FB`) is `Absent`; an `Indeterminate` endpoint or an unverified `LM` fails the dial without spawning, with the path in the diagnostic; both linked to the normative rule in reference/ipc.md. - `docs/reference/workspace.md` runtime-dir bullet: "Two processes whose environments disagree here resolve different sockets and therefore different daemons" gets the relay/doctor exception, the Linux SSH case beside the macOS one, and the empty-variable normalization. - `docs/reference/spec.md`: new REQ (next free id near REQ-009a) for the relay's endpoint selection, sourced to `docs/reference/ipc.md` "Cross-host carrier: SSH stdio" (the normative lane), with the explanation ipc.md section as the rationale link; REQ-009a wording unchanged (a cold daemon still spawns on the dialing process's own resolved path). - `docs/explanation/non-goals.md` "Cross-platform constraints": the fifth platform shim, "the per-user daemon endpoint when the login manager's variable is missing", with the per-OS `LM` and the Windows no-op; "None of the four" becomes "None of the five". - `docs/reference/cli.md`: "Carrier resolution order" paragraph gains the relay sentence and the empty-variable normalization; the `doctor` section documents the `daemon-sibling` row and token; the auto-spawn matrix notes that `relay --no-spawn` probes `LM` before reporting "no daemon". - `docs/explanation/security-model.md` "Daemon IPC": one bullet stating the trust the `LM` probe relies on; nothing about scope. - `docs/reference/security-audits.md` (socket location claim, ~line 19-21): sockets live at the resolved default (`$XDG_RUNTIME_DIR/felis`, the macOS confstr dir, or the `/tmp/felis.<uid>` fallback), each a 0700 per-user directory; the relay/doctor probe reads the other canonical location under the same ownership assumption. - `docs/how-to/attach-over-ssh.md`: D above. - `skills/felis/SKILL.md` remote-verb matrix (line ~212-214): read/drive verbs exit 2 only when neither `R` nor, when eligible, `LM` has a daemon; and the "outside this skill" list (~line 241-244) that excludes `doctor` gains the one exception an agent needs: when a remote or local roster looks empty, `felis doctor --format json` on the host and its `daemon-sibling` row tell whether a second endpoint holds the sessions. - `CHANGELOG.md`: relay endpoint selection (user-affecting), the tightened indeterminate-error handling, empty `TMPDIR` normalization, doctor row, how-to note. - `felis-daemon --help` text for `relay --socket`. - Issue #41: a comment noting the macOS connect-only `LM` probe landed here and that #41's default move remains its own decision. - Grep sweep for `XDG_RUNTIME_DIR`, `TMPDIR`, `felis.<uid>`, `no daemon on this host`, `--no-spawn`, `daemon-sibling`. ## Acceptance - A relay under an SSH server without pam_systemd (Linux) or any SSH login (macOS) bridges to the daemon at `LM` when one is running there, for spawning and `--no-spawn` dials alike, and spawns nothing. - With `R` and `LM` both `Absent`, it spawns (or fails under `--no-spawn`) on `R` as before, and the spawn warns with the path on the relay's stderr. - A connect error other than `ENOENT` / `ECONNREFUSED` on `R` or `LM`, or a listener on `LM` that does not complete the felis handshake at probe time, ends the dial with a diagnostic naming the path and never spawns or hangs. A peer swapped in after the probe has the frozen path's existing exposure, documented, not claimed closed. - `--socket` on the relay and on the client stays an exact single endpoint; a custom `XDG_RUNTIME_DIR` / `TMPDIR` is an opt-out (no probe, no row). - The `doctor` deadline and connect-error partition apply to the local Unix carrier's default endpoint; `doctor --host`, `--socket`, stamped, and Windows targets keep today's dial and dial-failure renderings, with the relay's diagnostic on stderr over SSH. The "running, but ..." arms for an unknown preface status and for `Refused` apply on every target. - `felis doctor` with daemons at both `R` and `S` reports `S` as a `warn` row with the both-live wording; with `R` cold and `S` live it reports `S` without claiming two daemons; with `--socket`, `--host`, a stamped `FELIS_SOCKET`, or `R` neither it emits no sibling row; an alias, a silent listener, an accept-and-drop daemon, or an indeterminate connect error yields no row and no hang; a major-skewed or `Refused AtCapacity` daemon on `S` is reported with its detail; the primary row reports an unverified or undialable `R` as `warn`, never as "not running". - The same relay selection and doctor row exist on macOS via the confstr `LM` (unit-tested under `cfg(target_os = "macos")`, hand-verified end to end, recorded in the PR); on Windows the relay, topology, sibling row, deadline, and dial behavior are unchanged, and only the two new rendering arms apply. - The default endpoint rule for `serve` and local clients is unchanged except that an empty `TMPDIR` resolves like an unset one (#12 freeze boundary). - The residual probe-to-spawn race and the probe-to-connect swap exposure are documented in the decision record, not claimed closed. - `just schema` is clean after the `daemon-sibling` token is added; `just check` passes; the new tests exercise the spawn seam, not the test binary.
Author
Owner

Plan v3 (supersedes comment 2794 / rev 12; PR #281 is reworked to this). Reviewed by pi (sol PASS at rev 5, luna PASS at rev 2-4). Daemon lifetime after endpoint loss is split out to #282.

Issue: #275. Supersedes the plan in comment 2794 (rev 12) and
the relay-probe implementation on PR #281. Daemon lifetime after endpoint loss (logout removing the runtime
directory under a running daemon, replacement sockets, exit cleanup, the agent link, stale stamps) is out of
scope here and tracked as #282
; this plan only names the exposure it leaves.

Why the premise changed (issue-comment context only; none of this goes into docs)

Rev 12 kept the Linux default endpoint rule fixed because #41's triage (round-3 amendment) declared it frozen at
v0.1.0, and built a relay-side probe of the login manager's location around that. The premise does not hold: no
v0.1.0 tag exists (git ls-remote --tags is empty), #12's "Contract freeze boundary" lists environment
variables and config defaults but never the endpoint derivation, and the freeze was our own triage decision, not
a shipped promise. Changing the derivation is the smaller and better fix: every process, relay included, lands on
the same socket by construction, and the probe, topology classification, endpoint pair, and most of the doctor
provenance plumbing disappear.

Decision

  1. The endpoint is a function of the uid first, the environment second (SocketPath::resolve, felis-transport;
    shared by serve, local clients, and the relay; no caller-specific logic):

    OS order
    Linux $XDG_RUNTIME_DIR/felis when the variable is set and non-empty (XDG stays authoritative) → /run/user/<uid>/felis when that directory exists, is owned by the uid and is mode 0700${TMPDIR:-/tmp}/felis.<uid>
    macOS $TMPDIR/felis.<uid> when the variable is set and non-empty (the GUI session's launchd TMPDIR is the confstr directory, so the GUI daemon does not move; a custom TMPDIR stays honored as today) → <confstr(_CS_DARWIN_USER_TEMP_DIR)>/felis.<uid> (launchd's per-user 0700 directory) → /tmp/felis.<uid> when confstr fails
    Windows unchanged (SID-derived pipe name)

    Resolution is lexical plus one lstat (the /run/user/<uid> check); it never probes for a daemon and never
    checks whether an environment-selected directory exists (ensure_dir_0700 creates a missing one with
    create_dir_all today, and that stays).

    Empty TMPDIR / XDG_RUNTIME_DIR normalize to unset (today TMPDIR= fails resolution outright). A relative
    XDG_RUNTIME_DIR is also treated as unset, which is today's behavior kept on purpose (directories drops a
    non-absolute value and the current resolver falls through to ${TMPDIR:-/tmp}; making it an error would strand
    a daemon at that fallback while every upgraded default command, doctor included, failed before dialing); the
    resolver logs it at warn once per process. A relative TMPDIR keeps today's hard error ("TMPDIR is not an
    absolute path"), raised only when that candidate is consulted: Linux consults TMPDIR only when the
    fallback is reached, so TMPDIR=relative with a usable /run/user/<uid> resolves the canonical path and is not
    an error; macOS consults TMPDIR first and errors as today. Nothing is ever anchored to the working
    directory (a daemon and a client would anchor against different directories and split). Tests: relative
    XDG_RUNTIME_DIR resolves as if unset on a logind host (canonical) and on a non-logind host (/tmp), and the
    Linux TMPDIR=relative + usable /run/user/<uid> cell resolves without error; TMPDIR=relative in the
    consulted position still errors on both OSes.

    The rule, stated precisely: the platform's runtime-directory variable stays authoritative when set
    (XDG_RUNTIME_DIR on Linux, TMPDIR on macOS, where launchd uses it as the per-user runtime location); when it
    is absent, the canonical per-uid location replaces /tmp. On Linux TMPDIR is not a runtime-directory contract
    and only shapes the last fallback, so it loses to a usable /run/user/<uid>. That is the one intentional break
    beyond "absent → canonical": a Linux daemon an older build started under TMPDIR=/custom with no
    XDG_RUNTIME_DIR on a logind host is stranded at /custom/felis.<uid>; doctor's $TMPDIR candidate (below)
    finds it from a shell carrying that TMPDIR, and the CHANGELOG names the case. Preferring /run/user/<uid>
    over a custom TMPDIR is deliberate: a login shell that exports TMPDIR globally would otherwise split from
    the logind daemon in exactly the way this issue describes. TMPDIR=/tmp on Linux behaves as unset (it only
    names the fallback base, which is /tmp anyway). The resolver tests enumerate, on both a logind host (usable
    /run/user/<uid>) and a non-logind host (absent), each of XDG_RUNTIME_DIR set / unset / empty crossed with
    TMPDIR unset / empty / /tmp / custom, and assert the selected path for every cell; macOS the same with
    TMPDIR crossed with confstr success / failure. Transition tests: /run/user/<uid> absent → usable (a
    process resolving after the directory appears selects it) and usable → invalid (resolution fails with the
    recovery message, nothing falls back).

    /run/user/<uid> validation, non-following and at resolution time only (the path can still disappear or be
    replaced before connect or bind; that lifecycle race is #282): lstat the path (a symlink is rejected outright), it
    must be a directory, st_uid must equal the uid, and the mode bits must be exactly 0700 (any group/other bit
    rejects). A /run/user/<uid> that exists but fails validation is a hard resolution error naming the reason and the
    recovery ("/run/user/<uid> exists but is not a 0700 directory owned by you; fix it, or set
    XDG_RUNTIME_DIR"): falling through to /tmp would let an environment-less process autospawn a second daemon
    beside a live one on /run, the split this change removes. Only an absent /run/user/<uid> continues to
    ${TMPDIR:-/tmp}. Selection is a resolver decision only: if felis/ inside the selected directory cannot be created
    or bound at serve time, that is the same hard bind error it is today (no second fallback at bind time, which
    would silently reintroduce the split); a client that dials a selected path that vanished sees ENOENT and the
    ordinary no-daemon / autospawn behavior.

    Why not drop XDG_RUNTIME_DIR entirely and use /run/user/<uid> only: XDG is the documented contract on Linux
    and a user who points it elsewhere (a non-logind host, a test harness) expects felis to follow. The split this
    issue is about comes from the variable being absent, not from it being set differently; keeping "set wins"
    costs nothing for that case.

    Why /run/user/<uid> and not $HOME-rooted: sockets belong in a runtime location, not a persistent, backed-up
    directory (#41 triage, same reasoning), and /run/user/<uid> is what logind exports as XDG_RUNTIME_DIR on
    every systemd host, so the common case resolves to the byte-identical path an upgraded daemon already listens on.

    What this trades away, stated as a regression (#282): /run/user/<uid> lives as long as the uid's logind
    sessions (or linger), and a daemon forked under it is unreachable once logind removes the directory. That is
    already true of every XDG_RUNTIME_DIR daemon today. It is new for a daemon the relay spawns itself:
    today a relay without XDG_RUNTIME_DIR spawns on /tmp, and that daemon stays reachable across logouts;
    after this change the same relay spawns under /run/user/<uid> whenever the directory exists at that moment
    (a desktop or sshd session of the same uid, or linger), and if that directory is later removed by the last
    logout the daemon keeps its sessions (REQ-008) and its attached windows but no new process can reach it, and
    the next process starts a replacement at whatever the resolver selects at that moment (a /run/user/<uid>
    that a later PAM-backed login recreated, else /tmp). So the change replaces "the relay never sees the logind
    daemon" (this issue) with "a relay-spawned daemon shares the logind daemon's lifetime exposure". That trade is
    accepted here because the reachable-forever /tmp daemon was the split itself, and because the exposure has
    an operator answer today (loginctl enable-linger, which the how-to and the CHANGELOG state as the setting for
    hosts that serve persistent daemons over SSH) and a daemon-side answer in #282 (drain on loss,
    replacement-safe cleanup, stale stamps). On a host with no logind session and no linger nothing changes: the
    relay resolves /tmp as today.

  2. The relay keeps its single-endpoint shape, with the connect-error hardening from PR #281. felis-daemon relay resolves the default (or takes --socket) and connects. A raw connect that fails with
    io::ErrorKind::NotFound or ConnectionRefused (Unix: ENOENT / ECONNREFUSED; Windows: an absent pipe
    is ERROR_FILE_NOT_FOUND, which maps to NotFound) is a cold socket: --no-spawn fails, otherwise spawn. Any
    other raw-connect error (PermissionDenied, resource exhaustion, ...) is not evidence of a cold socket: the
    relay exits with a diagnostic naming the path and the error and never spawns (connect_error_is_absent, kept
    from #281, already written on ErrorKind, applied to the single path; tests for a permission and a
    resource-exhaustion failure). The local client's connect_or_spawn_daemon gets the same rule for the raw
    connect phase only
    . Today ConnectError::Io is the ?-converted error of local::connect on the local
    carrier and of spawning the stdio child on the SSH carrier, while preface I/O is already
    ConnectError::Preface(PrefaceExchangeError::Io); is_transient says yes to all of them, and
    may_be_a_cold_socket inherits that. The classification is applied by kind on the raw-connect error where it
    occurs (the open_carrier split that #281's dial_bounded already introduced), and that arm is kept distinct
    from the stdio child's spawn failure (a missing ssh binary is NotFound too and must not read as a cold
    socket; a dedicated ConnectError::Connect(io::Error) arm is the straightforward way): Connect with
    NotFound / ConnectionRefused → spawnable; Connect with anything else → not spawnable, the dial ends with
    the error; every post-connect failure (Preface(Io), EofBeforeWelcome, ...) keeps today's spawnable
    verdict. The
    systemd hand-off path (spawn.rs, connect_managed) carries the same classified error instead of collapsing
    it to None: a non-cold raw connect failure after the transient unit started is "must not spawn" and ends the
    dial with that error, never "hand-off failed, fork instead". Tests: EACCES and EMFILE-class raw connect
    (no spawn) on the fork path and on the managed path (the fork callback is not invoked); an end-to-end
    regression where a fake daemon accepts and exits mid-preface, and another that exits after Accept but before
    Welcome, both still reaching spawn-and-retry. No probe, no sibling, no endpoint pair: the relay's environment
    lacking XDG_RUNTIME_DIR no longer matters because step 1 lands on /run/user/<uid>/felis anyway. A listener
    on the resolved path that is not a felis daemon hangs the client's handshake as it does today on every
    carrier; this is pre-existing (rev 12 also left the resolved path ungated) and stays out of scope, recorded as
    such. The daemon's own startup probe (socket_is_live, which today unlinks on any connect error) is #282.

  3. doctor keeps a sibling row for the migration (the surviving part of C): on the local Unix carrier, after
    the daemon row, probe each sibling candidate that differs from the resolved endpoint (deduplicated after
    canonicalize): the endpoints the resolver can produce on this host from the current environment plus the
    environment-free ones, so a daemon reachable through another environment is found: the canonical runtime location (/run/user/<uid>/felis/daemon.sock on Linux when that
    directory exists; the confstr felis.<uid> on macOS), always /tmp/felis.<uid>/daemon.sock, plus
    $TMPDIR/felis.<uid>/daemon.sock when TMPDIR is set, non-empty, and absolute (the old resolver's own validity
    rule; an empty or relative value is not a path an older build ever bound, so probing it could only report an
    unrelated cwd-relative socket). Each uses the bounded four-outcome probe already on PR #281; a felis daemon
    answering yields one warn daemon-sibling row per candidate with the observable-fact wording ("a felis daemon
    also answers at ; if it is a different daemon, its sessions are not visible from . Inspect with
    felis --socket <path> sessions list"). This finds the daemon an older build's Tailscale relay spawned on
    /tmp on Linux, on macOS the one every older SSH login spawned there (where the GUI's TMPDIR equals the new
    resolved path, so only the always-included /tmp candidate catches it), and a daemon on the canonical path
    from a shell whose environment now resolves a custom directory. Provenance, with D the freshly resolved
    default and L the sibling candidates: the target T is Default (T == D), a FELIS_SOCKET stamp (every
    shell inside a felis session carries one, so this is the ordinary place to type felis doctor), or
    --socket. Provenance is checked first: an explicit --socket target never gets sibling reasoning (no note,
    no row), even when it equals D. For Default and Stamped targets: T == D → probe L \ {D}; T ∈ L
    (the stamp points at a stranded location, e.g. /tmp/felis.<uid>) → the daemon row's detail notes "this
    shell targets , which is not the endpoint processes without FELIS_SOCKET resolve (); commands typed in
    this shell keep targeting unless run with env -u FELIS_SOCKET or --socket <D>" (the note states the
    target, not that a daemon listens there; whether one does is the daemon row's own outcome), and D is
    probed; a live daemon at D is reported in the daemon-sibling row with wording keyed to the primary
    outcome: "a felis daemon also answers at ; if it is a different daemon, ..." only when the daemon row
    found T live, otherwise "this shell targets , where no daemon answered (); a felis daemon
    answers at , the endpoint processes without FELIS_SOCKET resolve"; a stamp that is neither D nor in
    L → no note and no row. When T is stamped or explicit and the fresh default resolution fails (an invalid
    /run/user/<uid>, a relative TMPDIR in the consulted position), doctor keeps its primary row on T, skips
    the sibling probes, and adds the resolution error to the daemon row's detail ("the default endpoint cannot
    be resolved: "); the exit status stays what the primary row dictates. Test: stamped T live plus an
    injected default-resolution failure. Documented limit: a daemon started under a custom TMPDIR or a custom
    XDG_RUNTIME_DIR that the current shell does not carry is not discoverable (its path is not derivable without
    the variable); the how-to says to run felis doctor from a shell that carries that variable (with env -u FELIS_SOCKET when typed inside another felis session, so the stamp does not select a different daemon), or
    felis --socket <path> sessions list directly. The "unnameable
    endpoint" row, the reverse (runtime-dir) probe, and the custom-variable opt-out logic go away.

  4. Keep from PR #281, because they are correct on their own: the bounded probe helper and its
    Absent | Indeterminate | ConnectedUnverified | Live result (used by doctor only), dial_bounded and the
    phase-aware doctor timeout, the Refused / DaemonPreface::Unknown "running, but ..." doctor arms, the
    daemon-sibling schema token, the macOS confstr FFI (now used by the resolver), the empty-variable
    normalization, the how-to's drain recipe (sessions list on both, then daemon stop --when-empty).

  5. Drop from PR #281: EndpointTopology and the LM/FB/custom classification, RelayEndpoint, the relay's
    sibling selection and its diagnostics (the connect-error classification stays, see 2), the "fifth platform
    shim" in non-goals.md, REQ-009b's relay rule, the relay-selection text in reference/ipc.md,
    control-surfaces.md, cli.md, skills/felis, CHANGELOG; the A warning on fallback spawn (the fallback is now the
    rare non-logind case, and the resolver's warn! on a skipped /run/user/<uid> covers the one surprising
    path); option B stays rejected (recorded in the explanation as before).

Compatibility

  • systemd Linux host, any SSH server: XDG_RUNTIME_DIR set → unchanged path; unset → /run/user/<uid>/felis, the
    same path the logind-started daemon already binds. An upgraded client never misses a daemon a logind session
    started.
  • Non-systemd Linux (no /run/user/<uid>): /tmp/felis.<uid> as today.
  • macOS GUI: identical path (launchd TMPDIR is the confstr dir). macOS SSH: moves from /tmp/felis.<uid> to the
    GUI's daemon, which is the fix #41 asked for; #41 is closed by this change (its option A, macOS half).
  • Daemons an upgrade can strand, enumerated: (a) one an older build spawned on /tmp/felis.<uid> under an env
    without XDG_RUNTIME_DIR (the Tailscale case) or without TMPDIR (every older macOS SSH login): doctor's
    always-included /tmp candidate finds it; (b) Linux only: one an older build spawned under a custom TMPDIR
    with no XDG_RUNTIME_DIR on a host that has a usable /run/user/<uid> (the intentional break above): found
    when doctor runs with that TMPDIR, otherwise by felis --socket <path> sessions list; the CHANGELOG names
    this case. macOS honors a set TMPDIR, so it has no case (b). (c) Linux without linger: a daemon started on
    /tmp/felis.<uid> while no logind session existed is stranded once a login creates /run/user/<uid>; doctor's
    /tmp candidate finds it. (d) Linux without linger, the reverse: a forked daemon under /run/user/<uid> whose
    directory logind removed at last logout keeps its sessions for the windows still attached and is not
    reachable by any new process. For a logind-started daemon this is today's behavior; for a relay-spawned daemon
    it is a regression against today's /tmp fallback (Decision 1 states the trade), and daemon-side handling is
    #282. The how-to's SSH section and the CHANGELOG tell hosts that serve persistent daemons to loginctl enable-linger, which removes both (c) and (d). (a)–(c) are drained with the how-to recipe; (d) is not
    drainable (no path reaches it) and is why the linger note is not optional.
  • #12: the endpoint derivation is added to the freeze list as of this change, so the rule is stated once before
    the first tag rather than inferred later.

Security

The check (lstat, no symlink, a directory, uid matches, mode exactly 0700) protects the environment-free
/run/user/<uid> candidate, the one felis picks on its own; an explicitly supplied XDG_RUNTIME_DIR keeps the
existing environment-authoritative trust model (the login manager set it, and ensure_dir_0700 creates felis/
inside it as today). The confstr directory is launchd's per-user 0700 directory, the same trust the GUI's
TMPDIR already carries. security-model.md "Daemon IPC" states the check; security-audits.md updates the
socket-location claim.

Docs cascade

  • docs/reference/cli.md "Carrier resolution order" and --socket default: the new per-OS order; "Doctor": the
    daemon-sibling row and token (the sibling candidate list).
  • docs/reference/workspace.md runtime-dir bullet: rewritten to the new order; the "different environments →
    different daemons" sentence becomes "different XDG_RUNTIME_DIR values → different daemons; an absent variable
    no longer does".
  • docs/reference/ipc.md "Cross-host carrier: SSH stdio": remove the probe text; one sentence that the relay
    resolves the same endpoint as every local process that uses the same effective default (a process whose
    XDG_RUNTIME_DIR / TMPDIR names a different directory still selects a different daemon, as today).
    docs/how-to/attach-over-ssh.md "Single daemon per user" ("all --host connections to a given user account
    share a single daemon instance") is qualified the same way: connections that resolve the same default share
    one daemon; a custom runtime variable on the remote login is the exception.
  • docs/explanation/architecture/ipc.md (or security-model.md "Daemon IPC", whichever owns the endpoint
    rationale today): the decision record, in present tense and without revision history: an absent runtime variable
    resolves to the canonical per-uid location; rejected alternatives: probing the login manager's location from the
    relay while leaving the derivation alone (a second mechanism to reach one daemon, needing a multi-outcome
    classification and leaving two races open), $HOME-rooted directories (persistent, backed up), a remote
    --socket flag, warn-only; Revisit if: logind stops exporting /run/user/<uid>, or macOS drops
    _CS_DARWIN_USER_TEMP_DIR. The same record states the runtime directory's lifetime as a known limit (a daemon
    under it outlives its reachability; linger is the operator's answer) without promising daemon-side handling.
  • docs/reference/spec.md: replace REQ-009b with the derivation rule, sourced to cli.md / workspace.md; the
    raw-connect classification becomes a numbered requirement (an amendment to REQ-009a or a new REQ next to it),
    stated in two clauses so it does not contradict Decision 2: (i) when the raw connect to the resolved endpoint
    fails, only io::ErrorKind::NotFound or ConnectionRefused (Unix ENOENT / ECONNREFUSED, Windows
    ERROR_FILE_NOT_FOUND for an absent pipe) licenses a spawn; any other raw-connect error ends the dial with that
    error, on the fork path, the systemd hand-off path, and the relay; (ii) a failure after the raw connect
    succeeded (preface I/O, EOF before Welcome) keeps the existing spawn-and-retry behavior. Sourced to
    docs/reference/cli.md "Auto-spawning", which defines "cold socket" in those terms. The daemon's own startup
    probe is not covered by this requirement (#282).
  • docs/reference/ipc.md "Cross-host carrier" reconnect guarantee ("the per-UID daemon keeps every PTY running,
    and a reconnecting client reattaches to the still-live session") and
    docs/explanation/architecture/session-lifecycle.md (forking "keeps the remote-attach host working: ... the
    SSH relay's forked daemon survives" the last logout): both are qualified. The process and its sessions survive;
    a new connection needs the daemon's runtime directory to still exist, which for /run/user/<uid> means a
    logind session or linger; hosts that serve persistent daemons enable linger. The explanation names the forked
    daemon under /run/user/<uid> as the case the manager-started daemon and the forked one now share, instead of
    contrasting them. The same qualification lands on every other unconditional reconnect or agent-forwarding
    promise: docs/how-to/attach-over-ssh.md ("leaves remote sessions intact ... To reattach afterwards" and
    "On reconnect, agent access resumes automatically"), docs/explanation/non-goals.md (the mosh-style entry's
    "reconnecting over SSH and reattaching to it already recovers the work"), skills/felis/SKILL.md ("agent
    forwarding survives the SSH connection that opened it"): each gains the runtime-directory condition in one
    clause and, in the how-to, the linger instruction for persistent Linux SSH hosts.
  • docs/explanation/non-goals.md: revert the fifth-shim entry ("None of the four" stays).
  • docs/how-to/update-felis.md: the update sequence currently installs the new binary before listing and
    draining, which under the new resolver can point sessions list and daemon stop --when-empty at the new
    canonical endpoint while the old daemon still holds the sessions. Rewrite the sequence for this upgrade: after
    installing, before any window or spawning command, run env -u FELIS_SOCKET felis doctor (the unset matters:
    every felis shell carries a stamp, and inside a legacy daemon's shell that daemon would be the primary row, or
    no row at all if the new default is cold); act on both signals, a daemon-sibling row and a note on the
    daemon row: for each old endpoint named, inspect with felis --socket <path> sessions list and drain with
    felis --socket <path> daemon stop --when-empty (or keep working against it with --socket until it is
    empty); only then launch. The remote sequence runs the same commands on the host itself, because --host
    cannot name a remote socket (--socket is local-only and mutually exclusive with --host): ssh <h> env -u FELIS_SOCKET felis doctor, then ssh <h> felis --socket <path> sessions list and ssh <h> felis --socket <path> daemon stop --when-empty. A short "why" ties it to the endpoint change in this release.
  • docs/how-to/attach-over-ssh.md: delete the macOS TMPDIR recipe and the Linux XDG_RUNTIME_DIR recipe as
    requirements; keep one "older builds on the remote host" note, the drain recipe (with the env -u FELIS_SOCKET
    / --socket <D> distinction for commands typed inside a legacy daemon's shell), and a "persistent daemon on a
    server: loginctl enable-linger" note tied to the runtime directory's lifetime.
  • docs/reference/security-audits.md, docs/explanation/security-model.md: as above.
  • skills/felis/SKILL.md: remote-verb matrix back to its pre-#281 wording plus the doctor sibling row.
  • --help strings: felis-daemon (serve / relay --socket, crates/felis-daemon/src/main.rs) and felis-client
    (--socket, crates/felis-client/src/main.rs) describe the new default order; help snapshots/tests updated.
  • CHANGELOG.md: "daemon endpoint no longer depends on XDG_RUNTIME_DIR / TMPDIR being set; a daemon an older
    build started on /tmp/felis.<uid> keeps running: felis doctor reports it, drain with ..."; doctor row; empty
    variable normalization; autospawn hardening (a raw connect failing with anything but not-found / refused no
    longer starts a daemon, on the fork path, the systemd hand-off, and the relay); the linger note for persistent
    SSH hosts.
  • Issue #12: add the endpoint derivation to the freeze list. Issue #41: close as done by this change (macOS half of
    its option A; option C's observability is the doctor row).
  • Grep sweep: XDG_RUNTIME_DIR, TMPDIR, felis.<uid>, /run/user, daemon-sibling, login manager, LM,
    FB, topology, REQ-009b, reattaches, survives, last logout, linger, single daemon,
    share a single daemon, same daemon.

Tests

  • socket.rs: resolver order per OS with injected env and an injected stat result for /run/user/<uid>
    (exists+ours+0700 → used; exists but wrong owner / wrong mode / not a directory / a symlink → hard resolution
    error carrying the recovery guidance, no fallback, and the autospawn sites are shown never to spawn on it;
    absent → tmp fallback; XDG set → wins; empty → unset; relative XDG → unset, as today); cfg(target_os = "macos") for the confstr success and
    failure paths (pure judgment function already extracted on #281).
  • relay.rs: the pre-#281 tests plus one: a relay whose environment lacks XDG_RUNTIME_DIR resolves the same
    path as one that has it, given an injected /run/user/<uid> (compare SocketPath::resolve outputs; no daemon
    needed).
  • cli_doctor.rs: sibling row for a live daemon on /tmp/felis.<uid>, on a $TMPDIR candidate, and on the
    canonical path when the environment resolves a custom directory; no $TMPDIR candidate for an empty or
    relative TMPDIR; dedupe when a candidate equals the resolved path; row for a stamp equal to the default; note
    plus a probe of D for a stamp on /tmp and for a stamp on a custom-TMPDIR path; none for --socket or a
    stamp elsewhere; none for cold / silent listener / indeterminate.
  • relay.rs and connect_or_spawn_daemon: EACCES and EMFILE-class connect errors do not spawn, on the fork
    and the managed path.
  • Existing just check, just check-windows.

Acceptance

  • felis ssh <tailscale host> from a window and felis --host <tailscale host> sessions list reach the daemon
    the logind session started, without any XDG_RUNTIME_DIR in the relay's environment.
  • macOS: an SSH login's relay reaches the GUI daemon without exporting TMPDIR (unit-tested via confstr; hand
    verification recorded in the PR if a Mac is available, otherwise stated as not done).
  • felis doctor on a host with a stranded /tmp/felis.<uid> daemon reports it; nothing is unlinked.
  • The path a logind session or the macOS GUI already uses does not change.
  • Stated, not solved: a relay-spawned daemon under /run/user/<uid> becomes unreachable when logind removes
    the directory (Compatibility (d)); the how-to's linger note and the CHANGELOG carry it; #282 owns the fix.
  • just check and just check-windows pass.
**Plan v3 (supersedes comment 2794 / rev 12; PR #281 is reworked to this).** Reviewed by pi (sol PASS at rev 5, luna PASS at rev 2-4). Daemon lifetime after endpoint loss is split out to #282. Issue: https://git.natsukium.com/natsukium/felis/issues/275. Supersedes the plan in comment 2794 (rev 12) and the relay-probe implementation on PR #281. Daemon lifetime after endpoint loss (logout removing the runtime directory under a running daemon, replacement sockets, exit cleanup, the agent link, stale stamps) is **out of scope here and tracked as #282**; this plan only names the exposure it leaves. ## Why the premise changed (issue-comment context only; none of this goes into docs) Rev 12 kept the Linux default endpoint rule fixed because #41's triage (round-3 amendment) declared it frozen at v0.1.0, and built a relay-side probe of the login manager's location around that. The premise does not hold: no v0.1.0 tag exists (`git ls-remote --tags` is empty), #12's "Contract freeze boundary" lists environment *variables* and config defaults but never the endpoint derivation, and the freeze was our own triage decision, not a shipped promise. Changing the derivation is the smaller and better fix: every process, relay included, lands on the same socket by construction, and the probe, topology classification, endpoint pair, and most of the doctor provenance plumbing disappear. ## Decision 1. **The endpoint is a function of the uid first, the environment second** (`SocketPath::resolve`, felis-transport; shared by `serve`, local clients, and the relay; no caller-specific logic): | OS | order | | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Linux | `$XDG_RUNTIME_DIR/felis` when the variable is set and non-empty (XDG stays authoritative) → `/run/user/<uid>/felis` when that directory exists, is owned by the uid and is mode `0700` → `${TMPDIR:-/tmp}/felis.<uid>` | | macOS | `$TMPDIR/felis.<uid>` when the variable is set and non-empty (the GUI session's launchd `TMPDIR` is the confstr directory, so the GUI daemon does not move; a custom `TMPDIR` stays honored as today) → `<confstr(_CS_DARWIN_USER_TEMP_DIR)>/felis.<uid>` (launchd's per-user `0700` directory) → `/tmp/felis.<uid>` when confstr fails | | Windows | unchanged (SID-derived pipe name) | Resolution is lexical plus one `lstat` (the `/run/user/<uid>` check); it never probes for a daemon and never checks whether an environment-selected directory exists (`ensure_dir_0700` creates a missing one with `create_dir_all` today, and that stays). Empty `TMPDIR` / `XDG_RUNTIME_DIR` normalize to unset (today `TMPDIR=` fails resolution outright). A relative `XDG_RUNTIME_DIR` is also treated as unset, which is today's behavior kept on purpose (`directories` drops a non-absolute value and the current resolver falls through to `${TMPDIR:-/tmp}`; making it an error would strand a daemon at that fallback while every upgraded default command, `doctor` included, failed before dialing); the resolver logs it at `warn` once per process. A relative `TMPDIR` keeps today's hard error ("TMPDIR is not an absolute path"), raised only *when that candidate is consulted*: Linux consults `TMPDIR` only when the fallback is reached, so `TMPDIR=relative` with a usable `/run/user/<uid>` resolves the canonical path and is not an error; macOS consults `TMPDIR` first and errors as today. Nothing is ever anchored to the working directory (a daemon and a client would anchor against different directories and split). Tests: relative `XDG_RUNTIME_DIR` resolves as if unset on a logind host (canonical) and on a non-logind host (`/tmp`), and the Linux `TMPDIR=relative` + usable `/run/user/<uid>` cell resolves without error; `TMPDIR=relative` in the consulted position still errors on both OSes. The rule, stated precisely: the platform's *runtime-directory* variable stays authoritative when set (`XDG_RUNTIME_DIR` on Linux, `TMPDIR` on macOS, where launchd uses it as the per-user runtime location); when it is absent, the canonical per-uid location replaces `/tmp`. On Linux `TMPDIR` is not a runtime-directory contract and only shapes the last fallback, so it loses to a usable `/run/user/<uid>`. That is the one intentional break beyond "absent → canonical": a Linux daemon an older build started under `TMPDIR=/custom` with no `XDG_RUNTIME_DIR` on a logind host is stranded at `/custom/felis.<uid>`; doctor's `$TMPDIR` candidate (below) finds it from a shell carrying that `TMPDIR`, and the CHANGELOG names the case. Preferring `/run/user/<uid>` over a custom `TMPDIR` is deliberate: a login shell that exports `TMPDIR` globally would otherwise split from the logind daemon in exactly the way this issue describes. `TMPDIR=/tmp` on Linux behaves as unset (it only names the fallback base, which is `/tmp` anyway). The resolver tests enumerate, on both a logind host (usable `/run/user/<uid>`) and a non-logind host (absent), each of `XDG_RUNTIME_DIR` set / unset / empty crossed with `TMPDIR` unset / empty / `/tmp` / custom, and assert the selected path for every cell; macOS the same with `TMPDIR` crossed with confstr success / failure. Transition tests: `/run/user/<uid>` absent → usable (a process resolving after the directory appears selects it) and usable → invalid (resolution fails with the recovery message, nothing falls back). `/run/user/<uid>` validation, non-following and at resolution time only (the path can still disappear or be replaced before connect or bind; that lifecycle race is #282): `lstat` the path (a symlink is rejected outright), it must be a directory, `st_uid` must equal the uid, and the mode bits must be exactly `0700` (any group/other bit rejects). A `/run/user/<uid>` that exists but fails validation is a hard resolution error naming the reason and the recovery ("`/run/user/<uid>` exists but is not a `0700` directory owned by you; fix it, or set `XDG_RUNTIME_DIR`"): falling through to `/tmp` would let an environment-less process autospawn a second daemon beside a live one on `/run`, the split this change removes. Only an *absent* `/run/user/<uid>` continues to `${TMPDIR:-/tmp}`. Selection is a resolver decision only: if `felis/` inside the selected directory cannot be created or bound at `serve` time, that is the same hard bind error it is today (no second fallback at bind time, which would silently reintroduce the split); a client that dials a selected path that vanished sees `ENOENT` and the ordinary no-daemon / autospawn behavior. Why not drop `XDG_RUNTIME_DIR` entirely and use `/run/user/<uid>` only: XDG is the documented contract on Linux and a user who points it elsewhere (a non-logind host, a test harness) expects felis to follow. The split this issue is about comes from the variable being *absent*, not from it being set differently; keeping "set wins" costs nothing for that case. Why `/run/user/<uid>` and not `$HOME`-rooted: sockets belong in a runtime location, not a persistent, backed-up directory (#41 triage, same reasoning), and `/run/user/<uid>` is what logind exports as `XDG_RUNTIME_DIR` on every systemd host, so the common case resolves to the byte-identical path an upgraded daemon already listens on. What this trades away, stated as a regression (#282): `/run/user/<uid>` lives as long as the uid's logind sessions (or linger), and a daemon forked under it is unreachable once logind removes the directory. That is already true of every `XDG_RUNTIME_DIR` daemon today. It is **new** for a daemon the relay spawns itself: today a relay without `XDG_RUNTIME_DIR` spawns on `/tmp`, and that daemon stays reachable across logouts; after this change the same relay spawns under `/run/user/<uid>` whenever the directory exists at that moment (a desktop or sshd session of the same uid, or linger), and if that directory is later removed by the last logout the daemon keeps its sessions (REQ-008) and its attached windows but no new process can reach it, and the next process starts a replacement at whatever the resolver selects at that moment (a `/run/user/<uid>` that a later PAM-backed login recreated, else `/tmp`). So the change replaces "the relay never sees the logind daemon" (this issue) with "a relay-spawned daemon shares the logind daemon's lifetime exposure". That trade is accepted here because the reachable-forever `/tmp` daemon was the split itself, and because the exposure has an operator answer today (`loginctl enable-linger`, which the how-to and the CHANGELOG state as the setting for hosts that serve persistent daemons over SSH) and a daemon-side answer in #282 (drain on loss, replacement-safe cleanup, stale stamps). On a host with no logind session and no linger nothing changes: the relay resolves `/tmp` as today. 2. **The relay keeps its single-endpoint shape, with the connect-error hardening from PR #281.** `felis-daemon relay` resolves the default (or takes `--socket`) and connects. A raw connect that fails with `io::ErrorKind::NotFound` or `ConnectionRefused` (Unix: `ENOENT` / `ECONNREFUSED`; Windows: an absent pipe is `ERROR_FILE_NOT_FOUND`, which maps to `NotFound`) is a cold socket: `--no-spawn` fails, otherwise spawn. Any other raw-connect error (`PermissionDenied`, resource exhaustion, ...) is not evidence of a cold socket: the relay exits with a diagnostic naming the path and the error and never spawns (`connect_error_is_absent`, kept from #281, already written on `ErrorKind`, applied to the single path; tests for a permission and a resource-exhaustion failure). The local client's `connect_or_spawn_daemon` gets the same rule for the *raw connect phase only*. Today `ConnectError::Io` is the `?`-converted error of `local::connect` on the local carrier and of spawning the stdio child on the SSH carrier, while preface I/O is already `ConnectError::Preface(PrefaceExchangeError::Io)`; `is_transient` says yes to all of them, and `may_be_a_cold_socket` inherits that. The classification is applied by kind on the raw-connect error where it occurs (the `open_carrier` split that #281's `dial_bounded` already introduced), and that arm is kept distinct from the stdio child's spawn failure (a missing `ssh` binary is `NotFound` too and must not read as a cold socket; a dedicated `ConnectError::Connect(io::Error)` arm is the straightforward way): `Connect` with `NotFound` / `ConnectionRefused` → spawnable; `Connect` with anything else → not spawnable, the dial ends with the error; every post-connect failure (`Preface(Io)`, `EofBeforeWelcome`, ...) keeps today's spawnable verdict. The systemd hand-off path (`spawn.rs`, `connect_managed`) carries the same classified error instead of collapsing it to `None`: a non-cold raw connect failure after the transient unit started is "must not spawn" and ends the dial with that error, never "hand-off failed, fork instead". Tests: `EACCES` and `EMFILE`-class raw connect (no spawn) on the fork path and on the managed path (the fork callback is not invoked); an end-to-end regression where a fake daemon accepts and exits mid-preface, and another that exits after `Accept` but before `Welcome`, both still reaching spawn-and-retry. No probe, no sibling, no endpoint pair: the relay's environment lacking `XDG_RUNTIME_DIR` no longer matters because step 1 lands on `/run/user/<uid>/felis` anyway. A listener on the resolved path that is not a felis daemon hangs the client's handshake as it does today on every carrier; this is pre-existing (rev 12 also left the resolved path ungated) and stays out of scope, recorded as such. The daemon's own startup probe (`socket_is_live`, which today unlinks on any connect error) is #282. 3. **`doctor` keeps a sibling row for the migration** (the surviving part of C): on the local Unix carrier, after the `daemon` row, probe each **sibling candidate** that differs from the resolved endpoint (deduplicated after `canonicalize`): the endpoints the resolver can produce on this host from the current environment plus the environment-free ones, so a daemon reachable through another environment is found: the canonical runtime location (`/run/user/<uid>/felis/daemon.sock` on Linux when that directory exists; the confstr `felis.<uid>` on macOS), always `/tmp/felis.<uid>/daemon.sock`, plus `$TMPDIR/felis.<uid>/daemon.sock` when `TMPDIR` is set, non-empty, and absolute (the old resolver's own validity rule; an empty or relative value is not a path an older build ever bound, so probing it could only report an unrelated cwd-relative socket). Each uses the bounded four-outcome probe already on PR #281; a felis daemon answering yields one `warn` `daemon-sibling` row per candidate with the observable-fact wording ("a felis daemon also answers at <path>; if it is a different daemon, its sessions are not visible from <resolved>. Inspect with `felis --socket <path> sessions list`"). This finds the daemon an older build's Tailscale relay spawned on `/tmp` on Linux, on macOS the one every older SSH login spawned there (where the GUI's `TMPDIR` equals the new resolved path, so only the always-included `/tmp` candidate catches it), and a daemon on the canonical path from a shell whose environment now resolves a custom directory. Provenance, with `D` the freshly resolved default and `L` the sibling candidates: the target `T` is `Default` (`T == D`), a `FELIS_SOCKET` stamp (every shell inside a felis session carries one, so this is the ordinary place to type `felis doctor`), or `--socket`. Provenance is checked first: an explicit `--socket` target never gets sibling reasoning (no note, no row), even when it equals `D`. For `Default` and `Stamped` targets: `T == D` → probe `L \ {D}`; `T ∈ L` (the stamp points at a stranded location, e.g. `/tmp/felis.<uid>`) → the `daemon` row's detail notes "this shell targets <T>, which is not the endpoint processes without `FELIS_SOCKET` resolve (<D>); commands typed in this shell keep targeting <T> unless run with `env -u FELIS_SOCKET` or `--socket <D>`" (the note states the target, not that a daemon listens there; whether one does is the `daemon` row's own outcome), and `D` is probed; a live daemon at `D` is reported in the `daemon-sibling` row with wording keyed to the primary outcome: "a felis daemon also answers at <D>; if it is a different daemon, ..." only when the `daemon` row found `T` live, otherwise "this shell targets <T>, where no daemon answered (<primary outcome>); a felis daemon answers at <D>, the endpoint processes without `FELIS_SOCKET` resolve"; a stamp that is neither `D` nor in `L` → no note and no row. When `T` is stamped or explicit and the fresh default resolution fails (an invalid `/run/user/<uid>`, a relative `TMPDIR` in the consulted position), doctor keeps its primary row on `T`, skips the sibling probes, and adds the resolution error to the `daemon` row's detail ("the default endpoint cannot be resolved: <reason>"); the exit status stays what the primary row dictates. Test: stamped `T` live plus an injected default-resolution failure. Documented limit: a daemon started under a custom `TMPDIR` or a custom `XDG_RUNTIME_DIR` that the current shell does not carry is not discoverable (its path is not derivable without the variable); the how-to says to run `felis doctor` from a shell that carries that variable (with `env -u FELIS_SOCKET` when typed inside another felis session, so the stamp does not select a different daemon), or `felis --socket <path> sessions list` directly. The "unnameable endpoint" row, the reverse (runtime-dir) probe, and the custom-variable opt-out logic go away. 4. **Keep from PR #281**, because they are correct on their own: the bounded probe helper and its `Absent | Indeterminate | ConnectedUnverified | Live` result (used by doctor only), `dial_bounded` and the phase-aware doctor timeout, the `Refused` / `DaemonPreface::Unknown` "running, but ..." doctor arms, the `daemon-sibling` schema token, the macOS `confstr` FFI (now used by the resolver), the empty-variable normalization, the how-to's drain recipe (`sessions list` on both, then `daemon stop --when-empty`). 5. **Drop from PR #281**: `EndpointTopology` and the LM/FB/custom classification, `RelayEndpoint`, the relay's sibling selection and its diagnostics (the connect-error classification stays, see 2), the "fifth platform shim" in non-goals.md, REQ-009b's relay rule, the relay-selection text in reference/ipc.md, control-surfaces.md, cli.md, skills/felis, CHANGELOG; the A warning on fallback spawn (the fallback is now the rare non-logind case, and the resolver's `warn!` on a skipped `/run/user/<uid>` covers the one surprising path); option B stays rejected (recorded in the explanation as before). ## Compatibility - systemd Linux host, any SSH server: `XDG_RUNTIME_DIR` set → unchanged path; unset → `/run/user/<uid>/felis`, the same path the logind-started daemon already binds. An upgraded client never misses a daemon a logind session started. - Non-systemd Linux (no `/run/user/<uid>`): `/tmp/felis.<uid>` as today. - macOS GUI: identical path (launchd `TMPDIR` is the confstr dir). macOS SSH: moves from `/tmp/felis.<uid>` to the GUI's daemon, which is the fix #41 asked for; #41 is closed by this change (its option A, macOS half). - Daemons an upgrade can strand, enumerated: (a) one an older build spawned on `/tmp/felis.<uid>` under an env without `XDG_RUNTIME_DIR` (the Tailscale case) or without `TMPDIR` (every older macOS SSH login): doctor's always-included `/tmp` candidate finds it; (b) Linux only: one an older build spawned under a custom `TMPDIR` with no `XDG_RUNTIME_DIR` on a host that has a usable `/run/user/<uid>` (the intentional break above): found when `doctor` runs with that `TMPDIR`, otherwise by `felis --socket <path> sessions list`; the CHANGELOG names this case. macOS honors a set `TMPDIR`, so it has no case (b). (c) Linux without linger: a daemon started on `/tmp/felis.<uid>` while no logind session existed is stranded once a login creates `/run/user/<uid>`; doctor's `/tmp` candidate finds it. (d) Linux without linger, the reverse: a forked daemon under `/run/user/<uid>` whose directory logind removed at last logout keeps its sessions for the windows still attached and is not reachable by any new process. For a logind-started daemon this is today's behavior; for a relay-spawned daemon it is a regression against today's `/tmp` fallback (Decision 1 states the trade), and daemon-side handling is #282. The how-to's SSH section and the CHANGELOG tell hosts that serve persistent daemons to `loginctl enable-linger`, which removes both (c) and (d). (a)–(c) are drained with the how-to recipe; (d) is not drainable (no path reaches it) and is why the linger note is not optional. - #12: the endpoint derivation is added to the freeze list *as of this change*, so the rule is stated once before the first tag rather than inferred later. ## Security The check (`lstat`, no symlink, a directory, uid matches, mode exactly `0700`) protects the *environment-free* `/run/user/<uid>` candidate, the one felis picks on its own; an explicitly supplied `XDG_RUNTIME_DIR` keeps the existing environment-authoritative trust model (the login manager set it, and `ensure_dir_0700` creates `felis/` inside it as today). The confstr directory is launchd's per-user `0700` directory, the same trust the GUI's `TMPDIR` already carries. `security-model.md` "Daemon IPC" states the check; `security-audits.md` updates the socket-location claim. ## Docs cascade - `docs/reference/cli.md` "Carrier resolution order" and `--socket` default: the new per-OS order; "Doctor": the `daemon-sibling` row and token (the sibling candidate list). - `docs/reference/workspace.md` runtime-dir bullet: rewritten to the new order; the "different environments → different daemons" sentence becomes "different `XDG_RUNTIME_DIR` values → different daemons; an absent variable no longer does". - `docs/reference/ipc.md` "Cross-host carrier: SSH stdio": remove the probe text; one sentence that the relay resolves the same endpoint as every local process that uses the same effective default (a process whose `XDG_RUNTIME_DIR` / `TMPDIR` names a different directory still selects a different daemon, as today). `docs/how-to/attach-over-ssh.md` "Single daemon per user" ("all `--host` connections to a given user account share a single daemon instance") is qualified the same way: connections that resolve the same default share one daemon; a custom runtime variable on the remote login is the exception. - `docs/explanation/architecture/ipc.md` (or `security-model.md` "Daemon IPC", whichever owns the endpoint rationale today): the decision record, in present tense and without revision history: an absent runtime variable resolves to the canonical per-uid location; rejected alternatives: probing the login manager's location from the relay while leaving the derivation alone (a second mechanism to reach one daemon, needing a multi-outcome classification and leaving two races open), `$HOME`-rooted directories (persistent, backed up), a remote `--socket` flag, warn-only; Revisit if: logind stops exporting `/run/user/<uid>`, or macOS drops `_CS_DARWIN_USER_TEMP_DIR`. The same record states the runtime directory's lifetime as a known limit (a daemon under it outlives its reachability; linger is the operator's answer) without promising daemon-side handling. - `docs/reference/spec.md`: replace REQ-009b with the derivation rule, sourced to cli.md / workspace.md; the raw-connect classification becomes a numbered requirement (an amendment to REQ-009a or a new REQ next to it), stated in two clauses so it does not contradict Decision 2: (i) when the raw connect to the resolved endpoint fails, only `io::ErrorKind::NotFound` or `ConnectionRefused` (Unix `ENOENT` / `ECONNREFUSED`, Windows `ERROR_FILE_NOT_FOUND` for an absent pipe) licenses a spawn; any other raw-connect error ends the dial with that error, on the fork path, the systemd hand-off path, and the relay; (ii) a failure after the raw connect succeeded (preface I/O, EOF before `Welcome`) keeps the existing spawn-and-retry behavior. Sourced to `docs/reference/cli.md` "Auto-spawning", which defines "cold socket" in those terms. The daemon's own startup probe is not covered by this requirement (#282). - `docs/reference/ipc.md` "Cross-host carrier" reconnect guarantee ("the per-UID daemon keeps every PTY running, and a reconnecting client reattaches to the still-live session") and `docs/explanation/architecture/session-lifecycle.md` (forking "keeps the remote-attach host working: ... the SSH relay's forked daemon survives" the last logout): both are qualified. The process and its sessions survive; a new connection needs the daemon's runtime directory to still exist, which for `/run/user/<uid>` means a logind session or linger; hosts that serve persistent daemons enable linger. The explanation names the forked daemon under `/run/user/<uid>` as the case the manager-started daemon and the forked one now share, instead of contrasting them. The same qualification lands on every other unconditional reconnect or agent-forwarding promise: `docs/how-to/attach-over-ssh.md` ("leaves remote sessions intact ... To reattach afterwards" and "On reconnect, agent access resumes automatically"), `docs/explanation/non-goals.md` (the mosh-style entry's "reconnecting over SSH and reattaching to it already recovers the work"), `skills/felis/SKILL.md` ("agent forwarding survives the SSH connection that opened it"): each gains the runtime-directory condition in one clause and, in the how-to, the linger instruction for persistent Linux SSH hosts. - `docs/explanation/non-goals.md`: revert the fifth-shim entry ("None of the four" stays). - `docs/how-to/update-felis.md`: the update sequence currently installs the new binary before listing and draining, which under the new resolver can point `sessions list` and `daemon stop --when-empty` at the new canonical endpoint while the old daemon still holds the sessions. Rewrite the sequence for this upgrade: after installing, before any window or spawning command, run `env -u FELIS_SOCKET felis doctor` (the unset matters: every felis shell carries a stamp, and inside a legacy daemon's shell that daemon would be the primary row, or no row at all if the new default is cold); act on both signals, a `daemon-sibling` row and a note on the `daemon` row: for each old endpoint named, inspect with `felis --socket <path> sessions list` and drain with `felis --socket <path> daemon stop --when-empty` (or keep working against it with `--socket` until it is empty); only then launch. The remote sequence runs the same commands on the host itself, because `--host` cannot name a remote socket (`--socket` is local-only and mutually exclusive with `--host`): `ssh <h> env -u FELIS_SOCKET felis doctor`, then `ssh <h> felis --socket <path> sessions list` and `ssh <h> felis --socket <path> daemon stop --when-empty`. A short "why" ties it to the endpoint change in this release. - `docs/how-to/attach-over-ssh.md`: delete the macOS `TMPDIR` recipe and the Linux `XDG_RUNTIME_DIR` recipe as requirements; keep one "older builds on the remote host" note, the drain recipe (with the `env -u FELIS_SOCKET` / `--socket <D>` distinction for commands typed inside a legacy daemon's shell), and a "persistent daemon on a server: `loginctl enable-linger`" note tied to the runtime directory's lifetime. - `docs/reference/security-audits.md`, `docs/explanation/security-model.md`: as above. - `skills/felis/SKILL.md`: remote-verb matrix back to its pre-#281 wording plus the doctor sibling row. - `--help` strings: `felis-daemon` (`serve` / `relay --socket`, crates/felis-daemon/src/main.rs) and `felis-client` (`--socket`, crates/felis-client/src/main.rs) describe the new default order; help snapshots/tests updated. - `CHANGELOG.md`: "daemon endpoint no longer depends on `XDG_RUNTIME_DIR` / `TMPDIR` being set; a daemon an older build started on `/tmp/felis.<uid>` keeps running: `felis doctor` reports it, drain with ..."; doctor row; empty variable normalization; autospawn hardening (a raw connect failing with anything but not-found / refused no longer starts a daemon, on the fork path, the systemd hand-off, and the relay); the linger note for persistent SSH hosts. - Issue #12: add the endpoint derivation to the freeze list. Issue #41: close as done by this change (macOS half of its option A; option C's observability is the doctor row). - Grep sweep: `XDG_RUNTIME_DIR`, `TMPDIR`, `felis.<uid>`, `/run/user`, `daemon-sibling`, `login manager`, `LM`, `FB`, `topology`, `REQ-009b`, `reattaches`, `survives`, `last logout`, `linger`, `single daemon`, `share a single daemon`, `same daemon`. ## Tests - `socket.rs`: resolver order per OS with injected env and an injected `stat` result for `/run/user/<uid>` (exists+ours+0700 → used; exists but wrong owner / wrong mode / not a directory / a symlink → hard resolution error carrying the recovery guidance, no fallback, and the autospawn sites are shown never to spawn on it; absent → tmp fallback; XDG set → wins; empty → unset; relative XDG → unset, as today); `cfg(target_os = "macos")` for the confstr success and failure paths (pure judgment function already extracted on #281). - `relay.rs`: the pre-#281 tests plus one: a relay whose environment lacks `XDG_RUNTIME_DIR` resolves the same path as one that has it, given an injected `/run/user/<uid>` (compare `SocketPath::resolve` outputs; no daemon needed). - `cli_doctor.rs`: sibling row for a live daemon on `/tmp/felis.<uid>`, on a `$TMPDIR` candidate, and on the canonical path when the environment resolves a custom directory; no `$TMPDIR` candidate for an empty or relative `TMPDIR`; dedupe when a candidate equals the resolved path; row for a stamp equal to the default; note plus a probe of `D` for a stamp on `/tmp` and for a stamp on a custom-`TMPDIR` path; none for `--socket` or a stamp elsewhere; none for cold / silent listener / indeterminate. - `relay.rs` and `connect_or_spawn_daemon`: `EACCES` and `EMFILE`-class connect errors do not spawn, on the fork and the managed path. - Existing `just check`, `just check-windows`. ## Acceptance - `felis ssh <tailscale host>` from a window and `felis --host <tailscale host> sessions list` reach the daemon the logind session started, without any `XDG_RUNTIME_DIR` in the relay's environment. - macOS: an SSH login's relay reaches the GUI daemon without exporting `TMPDIR` (unit-tested via confstr; hand verification recorded in the PR if a Mac is available, otherwise stated as not done). - `felis doctor` on a host with a stranded `/tmp/felis.<uid>` daemon reports it; nothing is unlinked. - The path a logind session or the macOS GUI already uses does not change. - Stated, not solved: a relay-spawned daemon under `/run/user/<uid>` becomes unreachable when logind removes the directory (Compatibility (d)); the how-to's linger note and the CHANGELOG carry it; #282 owns the fix. - `just check` and `just check-windows` pass.
Sign in to join this conversation.
No description provided.