0073 — Login-shell semantics for service-managed pane spawns
A server started by phux service install's generated unit stamps PHUX_SERVICE_MANAGED into the unit's own environment; phux server reads it back and, only...
Full source summary
A server started by phux service install's generated unit stamps PHUX_SERVICE_MANAGED into the unit's own environment; phux server reads it back and, only then, invokes the resolved shell in its platform login mode (-l for bash/zsh/sh, --login for fish) for every command-less pane spawn. An ordinary hand-started server never sees the marker and stays a plain shell, exactly as before.
Status: Accepted Date: 2026-08-07
Context
ADR-0055 gave phux service install a generated launchd LaunchAgent /
systemd user unit so a self-hosted server survives logout and reboot.
Both init systems start that unit with a minimal environment: no login
shell ever ran, so the PATH additions Homebrew’s or Nix’s installer put
in ~/.zprofile / ~/.profile never took effect. Every pane the server
spawns inherits that minimal PATH; nvim and brew report “command
not found” even though the user’s ordinary interactive shell has them.
Environment markers such as NIX_PROFILES can still be inherited from
whatever built the unit, so a guard some profile script uses to skip
re-initialization (“have I already run?”) can be fooled into thinking
it already ran when it never has — ruling out “just re-source the
profile unconditionally” as a fix.
The natural remedy — spawn the pane’s shell in login mode so it sources
the same profile scripts an interactive terminal login would — has one
hazard that makes it wrong as a blanket default: a server a human started
directly from their own terminal (phux server, or the naked auto-spawn
path) already runs inside a fully profile-initialized environment.
Re-running login-shell initialization there is not idempotent for every
setup — PATH duplication is the mild failure, nvm/rbenv/direnv
guards misfiring is not. Login-shell treatment therefore has to be
conditional on how the server itself was started, and that condition
has to be something the server knows, not something it guesses: sniffing
“my PATH looks short” or “my parent is launchd” is exactly the kind of
heuristic the NIX_PROFILES problem above already shows is unreliable in
both directions.
Decision
-
The installer stamps a marker into the unit it generates.
ServicePlan::environment()(crates/phux/src/commands/service.rs) unconditionally addsPHUX_SERVICE_MANAGED=1to both the launchdEnvironmentVariablesdict and the systemdEnvironment=lines. This is not a heuristic: a server without it was never started from a unit thisphuxwrote, full stop. A value this code itself writes at install time, read back by the same code family at startup, has no ambiguity a sniffed signal can have. -
phux serverreads the marker once, at startup.run_server(crates/phux/src/commands/server.rs) checksPHUX_SERVICE_MANAGED’s presence and threads the resultingboolthroughphux_server::runtime::ServerConfig::login_shell, mirrored intoServerStateexactly like the existingshellfield (phux-i0e8.4.1’s precedent), and consumed at every command-less pane spawn site (the pre-seeded session,--seed-command/spawn-on-attach, attach-timeCreateIfMissing,SESSION_CREATE_KEY, a command-lessSPAWN_TERMINAL). -
Login mode is a per-shell argv flag, looked up by basename, applied only for recognized shells:
shell flag bash-lzsh-lfish--loginsh-lshis included because/bin/shis the documented last-resort fallback (resolve_shell): macOS ships it as bash’sshpersonality, Linux almost always asdash; both read/etc/profilethen~/.profileunder-l, non-interactively included.fishtakes--login, not-l. Adefaults.shellthis table does not recognize — a custom shell, a wrapper script, a typo — gets no login flag at all, even when the server is service-managed: an unrecognized program has unknown flag semantics, and hard-failing every pane spawn on an unrecognized shell is a worse regression than a pane whose profile never ran.login_flag_for_shell(crates/phux-server/src/terminal_actor/spawn.rs) is the single source of truth for this table. -
The installer never captures its own transient
PATH.ServicePlan::environment()never readsPATHat all — resolving it was already unnecessary before this decision — so a unit generated from inside anix developor direnv shell freezes none of that shell’sPATHinto the unit. The init system’s ownPATHreaches the server process unmodified; login-shell treatment is how the pane recovers the profile’sPATH, not a baked-in snapshot of the installer’s.
Why
- A self-written marker is the only signal in this problem that is
actually reliable. Every environment-shape heuristic considered
(
PATHlength, parent pid, presence ofNIX_PROFILES) is defeated by the same fact that motivates this ADR: a service-managed server can still inherit profile-shaped environment markers from whatever built its unit, and an ordinary terminal-launched server can have an unusually shortPATHfor reasons that have nothing to do with how it was started. - Per-shell flags, not a single blanket
-l, because-lis not universal — fish’s login flag is a distinct word, and blindly passing-lto an unknown program risks a fatal exec rather than a merely incomplete environment. - Threading
login_shellthrough the exact same mirror-into-ServerStatepatternshellalready uses (rather than inventing a second channel) keeps every spawn site’s precedence identical and auditable in one place per phux-i0e8.4.1’s precedent.
Tradeoffs
- Rerunning
phux service installis required to pick up this fix on an already-running service-managed server: the marker is not present in a unit generated before this ADR, and the server only reads it at its own startup. - Only four shell families are recognized. An operator on a shell with
its own login-mode spelling not in the table (e.g. a very old
kshvariant, ornushell) gets a spawn that behaves exactly as it did before this ADR — not a regression, but not a fix either — until the table gains an entry. - The marker lives in the unit’s own environment, which means it also
survives a graceful upgrade re-exec (ADR-0032) — the resumed image
reads the same environment, so this needs no separate handoff wiring,
but it also means an operator who manually copies the unit’s
EnvironmentVariables/Environment=block into a hand-startedphux serverinvocation would (correctly, if surprisingly) get login-shell panes too.
Alternatives
- Sniff environment shape (short
PATH, parent is launchd/systemd, absence of an interactive tty) — rejected: this is precisely the class of heuristic the bug report’sNIX_PROFILESobservation shows is unreliable in both directions. - Bake the installer’s own
PATHinto the generated unit — rejected: aphux service installrun from insidenix developor direnv would freeze that shell’s transientPATHinto the unit forever, going stale the moment the installing shell exits, and shadowing whatever login-shellPATHa pane would otherwise resolve. - Always spawn every pane’s shell in login mode, unconditionally — rejected: an ordinary terminal-launched server’s environment is already login-shell-initialized, and re-running profile scripts a second time is not idempotent for every setup.
- A
defaults.login-shellconfig knob the operator sets by hand — rejected as the primary mechanism: it demands the operator diagnose whynvimis missing before they can fix it, which is the exact discovery cost this ADR removes. Nothing here precludes adding one later as an override if a real setup needs to force the mode either way.