git.lucas.co / cce-display-manager
login greeter
git clone https://git.lucas.co/cce-display-manager.git

commit9e10f579e2152cade7f6f66fd5ce31cd33a7c6ea
parent563fdbef62
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-25 15:12
fix: the session worker is a fresh process, and the session has its own log

The daemon forked its session worker, and the daemon is multi-threaded
(the resume watchdog): the child inherited whatever locks another thread
held at that instant, then did PAM, env writes, logging and spawns under
them -- the class of wedge that froze the greeter's login until
2026-09-18. The worker is now <binary> --session-worker <tty> <user>
<is_wayland> <exec>, run with Command (no pre_exec, so only
async-signal-safe work between its fork and exec). The password rides
its stdin, never argv; it reports SESSION_ID <id> on stdout and points
stdout at /dev/null right after, so the daemon's read ends at once.

A graphical session's stdout/stderr go to /run/user/<uid>/cce-session.log
(the previous one kept as .old), opened as the user in pre_exec. They
used to inherit the daemon's, which put the user's session output into a
world-readable root log truncated at every daemon start. The Bash
console session keeps the tty.

The daemon, greeter, cage and worker log to the journal: the units set
StandardOutput/StandardError=journal, and the daemon skips its
/var/log file when JOURNAL_STREAM says systemd connected it -- so an old
unit with the new binary still logs to the file, in either install order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 cce-display-manager.service  |   7 +-
 cce-display-manager@.service |   7 +-
 src/main.rs                  | 609 +++++++++++++++++++++++++++----------------
 tests/session_worker.rs      |  46 ++++
 4 files changed, 449 insertions(+), 220 deletions(-)

diff --git a/cce-display-manager.service b/cce-display-manager.service
index 75da772..9bf0774 100644
--- a/cce-display-manager.service
+++ b/cce-display-manager.service
@@ -8,7 +8,12 @@ Before=graphical.target
 Type=simple
 ExecStart=/usr/bin/cce-display-manager
 StandardInput=tty
-StandardOutput=tty
+# The daemon, greeter, cage and session worker log to the journal
+# (journalctl -u); the daemon sees JOURNAL_STREAM and skips its old
+# /var/log file. The session's own output goes to the user's
+# /run/user/<uid>/cce-session.log, not here.
+StandardOutput=journal
+StandardError=journal
 TTYPath=/dev/tty1
 TTYReset=yes
 TTYVHangup=yes
diff --git a/cce-display-manager@.service b/cce-display-manager@.service
index 445b5a0..a6dcda0 100644
--- a/cce-display-manager@.service
+++ b/cce-display-manager@.service
@@ -8,7 +8,12 @@ Before=graphical.target
 Type=simple
 ExecStart=/usr/bin/cce-display-manager
 StandardInput=tty
-StandardOutput=tty
+# The daemon, greeter, cage and session worker log to the journal
+# (journalctl -u); the daemon sees JOURNAL_STREAM and skips its old
+# /var/log file. The session's own output goes to the user's
+# /run/user/<uid>/cce-session.log, not here.
+StandardOutput=journal
+StandardError=journal
 TTYPath=/dev/%I
 TTYReset=yes
 TTYVHangup=yes
diff --git a/src/main.rs b/src/main.rs
index 8355b30..29f14c1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -124,7 +124,7 @@ fn discover_sessions() -> Vec<Session> {
     }
     sessions.push(Session {
         name: "Bash Shell".to_string(),
-        exec: "bash".to_string(),
+        exec: CONSOLE_SESSION_EXEC.to_string(),
         is_wayland: true,
     });
     sessions
@@ -1433,21 +1433,28 @@ fn run_daemon() {
     let is_real_tty = raw_tty.starts_with("tty");
     let tty_name = if is_real_tty { raw_tty } else { "tty1".to_string() };
 
-    // Redirect stdout and stderr of the daemon to a log file. /var/log, not
-    // /tmp: only root can create names there, so a local user cannot pre-place
-    // a file or symlink at the predictable path for root to open and truncate.
+    // Where the daemon, the greeter, cage and the session worker log. Under
+    // the unit's StandardOutput=journal systemd has connected stdout/stderr
+    // to the journal and says so in JOURNAL_STREAM: leave them there —
+    // journald keeps every boot and rotates. Otherwise (an older unit, which
+    // points them at the tty) redirect to a file. /var/log, not /tmp: only
+    // root can create names there, so a local user cannot pre-place a file
+    // or symlink at the predictable path for root to open and truncate.
     let log_path = format!("/var/log/cce-display-manager-{}.log", tty_name);
-    if let Ok(log_file) = std::fs::OpenOptions::new()
-        .create(true)
-        .write(true)
-        .truncate(true)
-        .open(&log_path)
-    {
-        use std::os::unix::io::AsRawFd;
-        let fd = log_file.as_raw_fd();
-        unsafe {
-            libc::dup2(fd, 1);
-            libc::dup2(fd, 2);
+    let journaled = std::env::var_os("JOURNAL_STREAM").is_some();
+    if !journaled {
+        if let Ok(log_file) = std::fs::OpenOptions::new()
+            .create(true)
+            .write(true)
+            .truncate(true)
+            .open(&log_path)
+        {
+            use std::os::unix::io::AsRawFd;
+            let fd = log_file.as_raw_fd();
+            unsafe {
+                libc::dup2(fd, 1);
+                libc::dup2(fd, 2);
+            }
         }
     }
 
@@ -1794,12 +1801,22 @@ fn terminate_session(session_id: &str) {
     kill("SIGKILL");
 }
 
-/// Fork the session worker (PAM open_session + user-session spawn) and wait
-/// for it — shared by the greeter login path and the compositor-restart
-/// relaunch path. If the session left a restart flag (`ccectl
-/// restart-compositor` writes it before a clean exit), arm
+/// Run the session worker (PAM open_session + user-session spawn, see
+/// [`run_session_worker`]) and wait for it — shared by the greeter login path
+/// and the compositor-restart relaunch path. If the session left a restart
+/// flag (`ccectl restart-compositor` writes it before a clean exit), arm
 /// `pending_relaunch` so the daemon loop relaunches this same session
 /// directly, greeter skipped (empty password → the autologin PAM service).
+///
+/// The worker is `<this binary> --session-worker`, a fresh process — NOT a
+/// `fork()` of the daemon, as it was until 2026-09-25. The daemon is
+/// multi-threaded (the resume watchdog), and a forked child inherits whatever
+/// locks another thread held at that instant; the worker then did PAM, env
+/// writes, logging and process spawns under them — the class of wedge that
+/// froze the greeter's login (fixed there on 2026-09-18 by the same cure).
+/// `Command` with no `pre_exec` does only async-signal-safe work between its
+/// fork and exec. The password rides the worker's STDIN, never its argv
+/// (world-readable in /proc); the session id comes back on its STDOUT.
 fn launch_session(
     username: String,
     exec: String,
@@ -1808,7 +1825,7 @@ fn launch_session(
     tty_name: &str,
     pending_relaunch: &mut Option<(String, String, bool)>,
 ) {
-    use users::os::unix::UserExt;
+    use std::io::{BufRead, Write};
     log::info!("Launching user session Exec: '{}' (Wayland: {}) for user: '{}'", exec, is_wayland, username);
     // A stale flag from a previous session must not trigger a phantom relaunch.
     let flag_path = format!("/tmp/cce-restart-requested-{}", username);
@@ -1816,219 +1833,324 @@ fn launch_session(
 
     ensure_vt_active(tty_name);
 
-    // The worker reports its logind session id up this pipe, so that once the
-    // session is over the daemon can end it (see `terminate_session`).
-    // O_CLOEXEC: the user session the worker execs must not inherit the write
-    // end, or the daemon's read would wait on every process the session ever
-    // spawns.
-    let mut id_pipe = [-1 as libc::c_int; 2];
-    let have_pipe = unsafe { libc::pipe2(id_pipe.as_mut_ptr(), libc::O_CLOEXEC) } == 0;
-    if !have_pipe {
-        log::warn!("no session-id pipe ({}); the session will not be ended on exit", std::io::Error::last_os_error());
-    }
-
-    let pid = unsafe { libc::fork() };
-    if pid < 0 {
-        log::error!("Fork failed: {}", std::io::Error::last_os_error());
-        if have_pipe {
-            unsafe {
-                libc::close(id_pipe[0]);
-                libc::close(id_pipe[1]);
+    let spawned = std::process::Command::new(daemon_exe())
+        .args(session_worker_args(tty_name, &username, is_wayland, &exec))
+        .stdin(std::process::Stdio::piped())
+        .stdout(std::process::Stdio::piped())
+        .spawn();
+    let mut worker = match spawned {
+        Ok(w) => w,
+        Err(e) => {
+            log::error!("Failed to start the session worker: {}", e);
+            return;
+        }
+    };
+    if let Some(mut stdin) = worker.stdin.take() {
+        // Dropped at the end of this block: EOF tells the worker it has all
+        // of it. An empty write is the autologin path.
+        let _ = stdin.write_all(password.as_bytes());
+    }
+    // Read to EOF, which comes as soon as the worker has reported (it points
+    // its stdout at /dev/null right after) or has exited — never held open by
+    // the session, which does not inherit the pipe.
+    let mut session_id = String::new();
+    if let Some(stdout) = worker.stdout.take() {
+        for line in std::io::BufReader::new(stdout).lines().map_while(Result::ok) {
+            if let Some(id) = parse_session_id_line(&line) {
+                session_id = id.to_string();
             }
         }
-        return;
-    } else if pid == 0 {
-                if have_pipe {
-                    unsafe { libc::close(id_pipe[0]) };
-                }
-                // Child process: execute PAM session and spawn the compositor/user session
-                let user = match users::get_user_by_name(&username) {
-                    Some(u) => u,
-                    None => {
-                        log::error!("Error: User '{}' not found in system.", username);
-                        std::process::exit(1);
-                    }
-                };
-
-                let user_uid = user.uid();
-                let user_gid = user.primary_group_id();
-                let home_dir = user.home_dir().to_path_buf();
-                let shell = user.shell().to_str().unwrap_or("/bin/bash").to_string();
-
-                let user_runtime_dir = format!("/run/user/{}", user_uid);
-
-                // Set environment variables in the session worker process before PAM open_session.
-                // This is crucial for pam_gnome_keyring.so / pam_kwallet5.so to run successfully.
-                std::env::set_var("USER", &username);
-                std::env::set_var("LOGNAME", &username);
-                std::env::set_var("HOME", home_dir.to_str().unwrap_or(""));
-                std::env::set_var("SHELL", &shell);
-                std::env::set_var("XDG_RUNTIME_DIR", &user_runtime_dir);
-
-                // No fallback service: pam_start does not fail for a missing
-                // stack (PAM falls back to /etc/pam.d/other), so the old
-                // retry on ly's `ly-autologin` / `login` could never run.
-                let service = session_pam_service(&password);
-                let mut auth = match PamSession::new(service, &username, &password, 0, None) {
-                    Ok(a) => a,
-                    Err(e) => {
-                        log::error!("PAM Init Error in child: {:?}", e);
-                        std::process::exit(1);
-                    }
-                };
+    }
+    match worker.wait() {
+        Ok(status) => log::info!("Session worker (PID {}) exited with {}", worker.id(), status),
+        Err(e) => log::error!("waiting on the session worker: {}", e),
+    }
+    terminate_session(&session_id);
 
-                let session_type_env = if is_wayland {
-                    "XDG_SESSION_TYPE=wayland"
-                } else {
-                    "XDG_SESSION_TYPE=x11"
-                };
-                let _ = auth.putenv(session_type_env);
-                let _ = auth.putenv("XDG_SESSION_CLASS=user");
-
-                if let Err(e) = auth.authenticate() {
-                    log::error!("PAM Authentication failed in child: {:?}", e);
-                    std::process::exit(1);
-                }
+    // Compositor-requested restart: honor the flag only when it is a
+    // regular file owned by the session user (anyone can create names in
+    // /tmp). symlink_metadata, not metadata: a plain stat follows
+    // symlinks, so another user's link pointing at any file the session
+    // user owns would pass the owner check.
+    if let Ok(meta) = std::fs::symlink_metadata(&flag_path) {
+        use std::os::unix::fs::MetadataExt;
+        let owner_ok = meta.file_type().is_file()
+            && users::get_user_by_name(&username)
+                .map_or(false, |u| u.uid() == meta.uid());
+        let _ = std::fs::remove_file(&flag_path);
+        if owner_ok {
+            *pending_relaunch = Some((username, exec, is_wayland));
+            return; // relaunching immediately — no VT switch back
+        }
+        log::warn!("Ignoring restart flag {} with wrong owner", flag_path);
+    }
 
-                if let Err(e) = auth.open_session() {
-                    log::error!("PAM Session failed in child: {:?}", e);
-                    std::process::exit(1);
-                }
+    if let Some(vt) = tty_name.strip_prefix("tty").and_then(|s| s.parse::<u32>().ok()) {
+        log::info!("Switching back to VT {}...", vt);
+        let _ = std::process::Command::new("chvt")
+            .arg(vt.to_string())
+            .status();
+    }
+}
 
-                let pam_env = auth.get_env();
-                log::info!("PAM Environment variables: {:?}", pam_env);
+/// The installed binary, for the processes the daemon runs as itself.
+fn daemon_exe() -> std::path::PathBuf {
+    let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("/usr/bin/cce-display-manager"));
+    if exe.exists() { exe } else { std::path::PathBuf::from("/usr/bin/cce-display-manager") }
+}
 
-                if have_pipe {
-                    if let Some((_, session_id)) = pam_env.iter().find(|(k, _)| k == "XDG_SESSION_ID") {
-                        let line = format!("{}\n", session_id);
-                        unsafe {
-                            libc::write(id_pipe[1], line.as_ptr() as *const libc::c_void, line.len());
-                        }
-                    }
-                    unsafe { libc::close(id_pipe[1]) };
-                }
+/// The session worker's argv after the program name. `exec` goes last and
+/// whole — it may contain spaces. No password: see [`launch_session`].
+fn session_worker_args(tty_name: &str, username: &str, is_wayland: bool, exec: &str) -> Vec<String> {
+    vec![
+        "--session-worker".to_string(),
+        tty_name.to_string(),
+        username.to_string(),
+        is_wayland.to_string(),
+        exec.to_string(),
+    ]
+}
 
-                if let Some((_, session_id)) = pam_env.iter().find(|(k, _)| k == "XDG_SESSION_ID") {
-                    log::info!("Explicitly activating logind session {} via loginctl...", session_id);
-                    let _ = std::process::Command::new("loginctl")
-                        .arg("activate")
-                        .arg(session_id)
-                        .status();
-                }
-                
-                let (cmd_bin, cmd_args): (String, Vec<String>) = if is_wayland {
-                    sanitize_exec(&exec)
-                } else {
-                    let (client_bin, client_args) = sanitize_exec(&exec);
-                    let xinit_bin = "/usr/sbin/xinit".to_string();
-                    let mut args = vec![client_bin];
-                    args.extend(client_args);
-                    args.push("--".to_string());
-                    args.push("-keeptty".to_string());
-                    (xinit_bin, args)
-                };
-
-                if cmd_bin.is_empty() {
-                    log::error!("Error: Resolved execution command is empty.");
-                    std::process::exit(1);
-                }
+/// The other half of [`session_worker_args`]: `(tty, username, is_wayland,
+/// exec)` from a full argv, or `None` if it is not a session-worker argv.
+fn parse_session_worker_args(args: &[String]) -> Option<(String, String, bool, String)> {
+    if args.len() != 6 || args[1] != "--session-worker" {
+        return None;
+    }
+    Some((args[2].clone(), args[3].clone(), args[4] == "true", args[5].clone()))
+}
 
-                log::info!("Spawning session: {} with args {:?} for UID={}, GID={}", cmd_bin, cmd_args, user_uid, user_gid);
-
-                use std::os::unix::process::CommandExt;
-                let mut session_cmd = std::process::Command::new(&cmd_bin);
-                session_cmd
-                    .args(&cmd_args)
-                    .envs(pam_env)
-                    .current_dir(&home_dir)
-                    .env("USER", &username)
-                    .env("LOGNAME", &username)
-                    .env("HOME", home_dir.to_str().unwrap())
-                    .env("SHELL", &shell)
-                    .env("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
-                    .env("XDG_RUNTIME_DIR", &user_runtime_dir)
-                    .env("XDG_SESSION_TYPE", if is_wayland { "wayland" } else { "x11" })
-                    .env("XDG_SESSION_CLASS", "user")
-                    .stdin(std::process::Stdio::inherit())
-                    .stdout(std::process::Stdio::inherit())
-                    .stderr(std::process::Stdio::inherit());
-
-                // Filter out sudo env vars so they don't leak into the user session
-                for key in &["SUDO_USER", "SUDO_UID", "SUDO_GID", "SUDO_COMMAND"] {
-                    session_cmd.env_remove(key);
-                }
+const SESSION_ID_PREFIX: &str = "SESSION_ID ";
 
-                let username_c = std::ffi::CString::new(username.clone()).unwrap();
-                unsafe {
-                    session_cmd.pre_exec(move || {
-                        if libc::initgroups(username_c.as_ptr(), user_gid as libc::gid_t) != 0 {
-                            return Err(std::io::Error::last_os_error());
-                        }
-                        if libc::setgid(user_gid as libc::gid_t) != 0 {
-                            return Err(std::io::Error::last_os_error());
-                        }
-                        if libc::setuid(user_uid as libc::uid_t) != 0 {
-                            return Err(std::io::Error::last_os_error());
-                        }
-                        Ok(())
-                    });
-                }
+/// The worker's report line, `SESSION_ID <id>`: the id, if this is one.
+fn parse_session_id_line(line: &str) -> Option<&str> {
+    line.strip_prefix(SESSION_ID_PREFIX).map(str::trim).filter(|id| !id.is_empty())
+}
 
-                match session_cmd.spawn() {
-                    Ok(mut child_proc) => {
-                        let _ = child_proc.wait();
-                    }
-                    Err(e) => {
-                        log::error!("Failed to launch session: {}", e);
-                    }
-                }
-                log::info!("User session ended.");
-                std::mem::drop(auth);
-                std::process::exit(0);
+/// The console session's Exec — a shell ON the tty, where a graphical
+/// session's output goes to its log instead (see [`run_session_worker`]).
+const CONSOLE_SESSION_EXEC: &str = "bash";
+
+/// The graphical session's stdout/stderr, and where the previous session's
+/// is kept: in the user's runtime dir beside `startcce`'s own logs, one
+/// session back — a compositor restart starts a new session, and the log of
+/// the one that died is the log worth reading.
+fn session_log_paths(uid: u32) -> (String, String) {
+    let log = format!("/run/user/{}/cce-session.log", uid);
+    let old = format!("{}.old", log);
+    (log, old)
+}
+
+/// `--session-worker <tty> <user> <is_wayland> <exec>`, password on stdin:
+/// open the user's PAM session, report its logind id on stdout, run the
+/// session as the user, and close PAM when it exits. Run by the daemon only.
+fn run_session_worker(tty_name: String, username: String, is_wayland: bool, exec: String) -> ! {
+    use std::io::{Read, Write};
+    use users::os::unix::UserExt;
+    if users::get_current_uid() != 0 {
+        log::error!("--session-worker is the daemon's; it must run as root");
+        std::process::exit(1);
+    }
+    let mut password = String::new();
+    let _ = std::io::stdin().read_to_string(&mut password);
+
+    // Stdin back onto the tty the daemon was given: PamSession reads the TTY
+    // item off fd 0, and the console session is a shell on it. Only a name
+    // like "tty1" is opened.
+    let tty_ok = tty_name.len() > 3 && tty_name.starts_with("tty") && tty_name[3..].chars().all(|c| c.is_ascii_digit());
+    if tty_ok {
+        if let Ok(tty) = std::fs::OpenOptions::new().read(true).write(true).open(format!("/dev/{}", tty_name)) {
+            use std::os::unix::io::AsRawFd;
+            unsafe { libc::dup2(tty.as_raw_fd(), 0) };
+        }
+    }
+
+    let user = match users::get_user_by_name(&username) {
+        Some(u) => u,
+        None => {
+            log::error!("Error: User '{}' not found in system.", username);
+            std::process::exit(1);
+        }
+    };
+
+    let user_uid = user.uid();
+    let user_gid = user.primary_group_id();
+    let home_dir = user.home_dir().to_path_buf();
+    let shell = user.shell().to_str().unwrap_or("/bin/bash").to_string();
+
+    let user_runtime_dir = format!("/run/user/{}", user_uid);
+
+    // The session's identity, in the worker's own environment before PAM
+    // open_session, for the modules that read it from there.
+    std::env::set_var("USER", &username);
+    std::env::set_var("LOGNAME", &username);
+    std::env::set_var("HOME", home_dir.to_str().unwrap_or(""));
+    std::env::set_var("SHELL", &shell);
+    std::env::set_var("XDG_RUNTIME_DIR", &user_runtime_dir);
+
+    // No fallback service: pam_start does not fail for a missing
+    // stack (PAM falls back to /etc/pam.d/other), so the old
+    // retry on ly's `ly-autologin` / `login` could never run.
+    let service = session_pam_service(&password);
+    let mut auth = match PamSession::new(service, &username, &password, 0, None) {
+        Ok(a) => a,
+        Err(e) => {
+            log::error!("PAM Init Error in session worker: {:?}", e);
+            std::process::exit(1);
+        }
+    };
+
+    let session_type_env = if is_wayland {
+        "XDG_SESSION_TYPE=wayland"
     } else {
-        // Parent process: take the session id the worker reports (EOF once
-        // it closes its end — after reporting, or by exiting), then block
-        // until the worker terminates.
-        let mut session_id = String::new();
-        if have_pipe {
-            use std::io::Read;
-            use std::os::unix::io::FromRawFd;
-            unsafe { libc::close(id_pipe[1]) };
-            let mut reader = unsafe { std::fs::File::from_raw_fd(id_pipe[0]) };
-            let _ = reader.read_to_string(&mut session_id);
-        }
-        let mut status: libc::c_int = 0;
-        unsafe {
-            libc::waitpid(pid, &mut status, 0);
-        }
-        log::info!("Session worker child (PID {}) exited with status: {}", pid, status);
-        terminate_session(session_id.trim());
-
-        // Compositor-requested restart: honor the flag only when it is a
-        // regular file owned by the session user (anyone can create names in
-        // /tmp). symlink_metadata, not metadata: a plain stat follows
-        // symlinks, so another user's link pointing at any file the session
-        // user owns would pass the owner check.
-        if let Ok(meta) = std::fs::symlink_metadata(&flag_path) {
-            use std::os::unix::fs::MetadataExt;
-            let owner_ok = meta.file_type().is_file()
-                && users::get_user_by_name(&username)
-                    .map_or(false, |u| u.uid() == meta.uid());
-            let _ = std::fs::remove_file(&flag_path);
-            if owner_ok {
-                *pending_relaunch = Some((username, exec, is_wayland));
-                return; // relaunching immediately — no VT switch back
+        "XDG_SESSION_TYPE=x11"
+    };
+    let _ = auth.putenv(session_type_env);
+    let _ = auth.putenv("XDG_SESSION_CLASS=user");
+
+    if let Err(e) = auth.authenticate() {
+        log::error!("PAM Authentication failed in session worker: {:?}", e);
+        std::process::exit(1);
+    }
+
+    if let Err(e) = auth.open_session() {
+        log::error!("PAM Session failed in session worker: {:?}", e);
+        std::process::exit(1);
+    }
+
+    let pam_env = auth.get_env();
+    log::info!("PAM Environment variables: {:?}", pam_env);
+    let session_id = pam_env.iter().find(|(k, _)| k == "XDG_SESSION_ID").map(|(_, v)| v.clone());
+
+    // Report the session to the daemon, then let go of the pipe so its read
+    // ends now rather than when the session does.
+    if let Some(id) = &session_id {
+        let mut out = std::io::stdout();
+        let _ = writeln!(out, "{}{}", SESSION_ID_PREFIX, id);
+        let _ = out.flush();
+    }
+    if let Ok(null) = std::fs::OpenOptions::new().write(true).open("/dev/null") {
+        use std::os::unix::io::AsRawFd;
+        unsafe { libc::dup2(null.as_raw_fd(), 1) };
+    }
+
+    if let Some(id) = &session_id {
+        log::info!("Explicitly activating logind session {} via loginctl...", id);
+        let _ = std::process::Command::new("loginctl")
+            .arg("activate")
+            .arg(id)
+            .status();
+    }
+
+    let (cmd_bin, cmd_args): (String, Vec<String>) = if is_wayland {
+        sanitize_exec(&exec)
+    } else {
+        let (client_bin, client_args) = sanitize_exec(&exec);
+        let xinit_bin = "/usr/sbin/xinit".to_string();
+        let mut args = vec![client_bin];
+        args.extend(client_args);
+        args.push("--".to_string());
+        args.push("-keeptty".to_string());
+        (xinit_bin, args)
+    };
+
+    if cmd_bin.is_empty() {
+        log::error!("Error: Resolved execution command is empty.");
+        std::process::exit(1);
+    }
+
+    log::info!("Spawning session: {} with args {:?} for UID={}, GID={}", cmd_bin, cmd_args, user_uid, user_gid);
+
+    // The console session is a shell on the tty; anything else writes its
+    // stdout/stderr to the user's session log, opened in pre_exec AS THE
+    // USER (a root open in a directory the user owns could be pointed at any
+    // file by a symlink). Until 2026-09-25 the session inherited the
+    // daemon's stdout, which put the user's session output into a
+    // world-readable root log that was truncated at every daemon start.
+    let console = exec.trim() == CONSOLE_SESSION_EXEC;
+    let (log_path, old_path) = session_log_paths(user_uid);
+    let log_c = std::ffi::CString::new(log_path.clone()).unwrap();
+    let old_c = std::ffi::CString::new(old_path).unwrap();
+    let tty_out = || -> std::process::Stdio {
+        std::fs::OpenOptions::new()
+            .write(true)
+            .open(format!("/dev/{}", tty_name))
+            .map(std::process::Stdio::from)
+            .unwrap_or_else(|_| std::process::Stdio::null())
+    };
+
+    use std::os::unix::process::CommandExt;
+    let mut session_cmd = std::process::Command::new(&cmd_bin);
+    session_cmd
+        .args(&cmd_args)
+        .envs(pam_env)
+        .current_dir(&home_dir)
+        .env("USER", &username)
+        .env("LOGNAME", &username)
+        .env("HOME", home_dir.to_str().unwrap())
+        .env("SHELL", &shell)
+        .env("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
+        .env("XDG_RUNTIME_DIR", &user_runtime_dir)
+        .env("XDG_SESSION_TYPE", if is_wayland { "wayland" } else { "x11" })
+        .env("XDG_SESSION_CLASS", "user")
+        .stdin(std::process::Stdio::inherit());
+    if console {
+        session_cmd.stdout(tty_out()).stderr(tty_out());
+    } else {
+        session_cmd.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
+        log::info!("Session output goes to {}", log_path);
+    }
+
+    // Filter out sudo env vars so they don't leak into the user session, and
+    // the daemon's JOURNAL_STREAM, which describes the daemon's stderr, not
+    // the session's (systemd-aware programs read it).
+    for key in &["SUDO_USER", "SUDO_UID", "SUDO_GID", "SUDO_COMMAND", "JOURNAL_STREAM"] {
+        session_cmd.env_remove(key);
+    }
+
+    let username_c = std::ffi::CString::new(username.clone()).unwrap();
+    unsafe {
+        session_cmd.pre_exec(move || {
+            if libc::initgroups(username_c.as_ptr(), user_gid as libc::gid_t) != 0 {
+                return Err(std::io::Error::last_os_error());
             }
-            log::warn!("Ignoring restart flag {} with wrong owner", flag_path);
-        }
+            if libc::setgid(user_gid as libc::gid_t) != 0 {
+                return Err(std::io::Error::last_os_error());
+            }
+            if libc::setuid(user_uid as libc::uid_t) != 0 {
+                return Err(std::io::Error::last_os_error());
+            }
+            if !console {
+                // As the user now. A log that cannot be opened (the runtime
+                // dir not there yet, say) leaves output at /dev/null rather
+                // than failing the login.
+                libc::rename(log_c.as_ptr(), old_c.as_ptr());
+                let fd = libc::open(
+                    log_c.as_ptr(),
+                    libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_NOFOLLOW | libc::O_CLOEXEC,
+                    0o600,
+                );
+                if fd >= 0 {
+                    libc::dup2(fd, 1);
+                    libc::dup2(fd, 2);
+                    libc::close(fd);
+                }
+            }
+            Ok(())
+        });
+    }
 
-        if let Some(vt) = tty_name.strip_prefix("tty").and_then(|s| s.parse::<u32>().ok()) {
-            log::info!("Switching back to VT {}...", vt);
-            let _ = std::process::Command::new("chvt")
-                .arg(vt.to_string())
-                .status();
+    match session_cmd.spawn() {
+        Ok(mut child_proc) => {
+            let _ = child_proc.wait();
+        }
+        Err(e) => {
+            log::error!("Failed to launch session: {}", e);
         }
     }
+    log::info!("User session ended.");
+    std::mem::drop(auth);
+    std::process::exit(0);
 }
 
 fn main() {
@@ -2041,6 +2163,11 @@ fn main() {
         run_greeter();
     } else if args.len() > 2 && args[1] == "--fprint-auth" {
         run_fprint_helper(&args[2]);
+    } else if let Some((tty, user, is_wayland, exec)) = parse_session_worker_args(&args) {
+        run_session_worker(tty, user, is_wayland, exec);
+    } else if args.len() > 1 && args[1] == "--session-worker" {
+        log::error!("--session-worker takes <tty> <user> <is_wayland> <exec>");
+        std::process::exit(2);
     } else {
         run_daemon();
     }
@@ -2142,6 +2269,52 @@ mod tests {
         assert!(seen >= 4, "the four stacks were read");
     }
 
+    /// The daemon's argv for the worker and the worker's parse agree, an
+    /// Exec with spaces arrives whole, and no password is in it.
+    #[test]
+    fn session_worker_args_round_trip() {
+        let args = super::session_worker_args("tty1", "lucas", true, "/home/lucas/.local/bin/startcce --logging");
+        assert!(!args.iter().any(|a| a.contains("hunter2")));
+        let mut argv = vec!["/usr/bin/cce-display-manager".to_string()];
+        argv.extend(args);
+        assert_eq!(
+            super::parse_session_worker_args(&argv),
+            Some(("tty1".into(), "lucas".into(), true, "/home/lucas/.local/bin/startcce --logging".into()))
+        );
+        let argv_x11: Vec<String> = ["x", "--session-worker", "tty2", "u", "false", "startx"].iter().map(|s| s.to_string()).collect();
+        assert_eq!(super::parse_session_worker_args(&argv_x11).map(|t| t.2), Some(false));
+        // Anything else is not a worker argv.
+        let short: Vec<String> = ["x", "--session-worker", "tty1"].iter().map(|s| s.to_string()).collect();
+        assert_eq!(super::parse_session_worker_args(&short), None);
+        let greeter: Vec<String> = ["x", "--greeter", "a", "b", "c", "d"].iter().map(|s| s.to_string()).collect();
+        assert_eq!(super::parse_session_worker_args(&greeter), None);
+    }
+
+    #[test]
+    fn the_worker_reports_its_session_id_on_one_line() {
+        assert_eq!(super::parse_session_id_line("SESSION_ID 17"), Some("17"));
+        assert_eq!(super::parse_session_id_line("SESSION_ID c3\r"), Some("c3"));
+        assert_eq!(super::parse_session_id_line("SESSION_ID "), None);
+        assert_eq!(super::parse_session_id_line("[INFO] something else"), None);
+    }
+
+    #[test]
+    fn the_session_log_lives_in_the_users_runtime_dir_one_session_back() {
+        assert_eq!(
+            super::session_log_paths(1000),
+            ("/run/user/1000/cce-session.log".to_string(), "/run/user/1000/cce-session.log.old".to_string())
+        );
+    }
+
+    /// The console entry's Exec is the constant the worker keys the tty on,
+    /// so the Bash session keeps its terminal.
+    #[test]
+    fn the_console_session_is_the_one_the_worker_keeps_on_the_tty() {
+        let sessions = super::discover_sessions();
+        let bash = sessions.iter().find(|s| s.name == "Bash Shell").expect("the console entry");
+        assert_eq!(bash.exec, super::CONSOLE_SESSION_EXEC);
+    }
+
     #[test]
     fn auth_success_bad_bool_defaults_wayland() {
         let got = parse_auth_success("AUTH_SUCCESS|lucas|startcce|banana|pw");
diff --git a/tests/session_worker.rs b/tests/session_worker.rs
new file mode 100644
index 0000000..fe626c1
--- /dev/null
+++ b/tests/session_worker.rs
@@ -0,0 +1,46 @@
+//! The session worker is a separate process now (`--session-worker`), run by
+//! the root daemon with the password on its stdin. These run the real binary
+//! as the (non-root) test user and check the two ways it must refuse before
+//! it goes anywhere near PAM.
+
+use std::io::Write;
+use std::process::{Command, Stdio};
+
+fn worker(args: &[&str]) -> std::process::Output {
+    let mut child = Command::new(env!("CARGO_BIN_EXE_cce-display-manager"))
+        .args(args)
+        .stdin(Stdio::piped())
+        .stdout(Stdio::piped())
+        .stderr(Stdio::piped())
+        .spawn()
+        .expect("run the binary");
+    // A worker that refuses early may not read this; ignore EPIPE.
+    let _ = child.stdin.take().unwrap().write_all(b"not-a-password");
+    child.wait_with_output().expect("wait")
+}
+
+/// Anyone can run the installed binary. As a user it must not open a PAM
+/// session or report one — and it checks before reading the password.
+#[test]
+fn a_non_root_session_worker_refuses() {
+    if unsafe { libc_getuid() } == 0 {
+        eprintln!("running as root; the refusal is not what this run can test");
+        return;
+    }
+    let out = worker(&["--session-worker", "tty1", "nobody", "true", "bash"]);
+    assert_eq!(out.status.code(), Some(1), "{out:?}");
+    assert!(out.stdout.is_empty(), "no SESSION_ID line: {:?}", String::from_utf8_lossy(&out.stdout));
+}
+
+/// A malformed worker argv is an error, not a fall-through into the daemon
+/// (which, as root, would start a second greeter on the tty).
+#[test]
+fn a_malformed_session_worker_argv_is_an_error() {
+    let out = worker(&["--session-worker", "tty1"]);
+    assert_eq!(out.status.code(), Some(2), "{out:?}");
+}
+
+extern "C" {
+    #[link_name = "getuid"]
+    fn libc_getuid() -> u32;
+}