tawasuyu

tawasuyu/brahman

Rust 231TOML 70JSON 14HTML 12905 Sutichaykuna

feat(carmen): modo greeter — mirada-compositor como DM

`mirada-compositor --greeter` arranca como gestor de login: lanza
mirada-greeter como proceso hijo, lee su stdout y, al recibir el
SessionTicket, muta de BodyMode::Greeter a BodyMode::Session sin
reiniciar el servidor Wayland — la «mutación atómica» del DM.

- BodyMode { Greeter, Session }: eje ortogonal a Brain (Embedded/Linked).
- modo greeter: sin atajos registrados, rechaza Spawn, sin autoarranque.
- traspaso (complete_greeter_handoff): registra los atajos y arranca la
  sesión — el comando del tiquet, o el autoarranque del usuario.
- privilegios: el compositor corre como root; spawn_command baja a
  setuid/setgid + grupos suplementarios del usuario autenticado.
- bandera ortogonal al backend (--greeter [--drm|--winit]); el tiquet
  llega por un canal calloop en DRM y por mpsc en winit.

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

758f61f52a35514669064a1aefd44f22f07a9e9c · ruwaq sergio · (5 killa ñawpaq) · tayta 634a43006 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 15

crates/apps/mirada-compositor/src/drm_backend.rs

  • silq'uyninpi t'ikrakurqan fn run pub

crates/apps/mirada-compositor/src/main.rs

6 qillqakuna · +352 −69

diff --git a/Cargo.lock b/Cargo.lock
index df4b287..2c439fa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7827,9 +7827,11 @@ dependencies = [
 name = "mirada-compositor"
 version = "0.1.0"
 dependencies = [
+ "brahman-auth",
  "mirada-body",
  "mirada-brain",
  "mirada-link",
+ "nix 0.29.0",
  "smithay",
 ]
 
diff --git a/crates/apps/mirada-compositor/Cargo.toml b/crates/apps/mirada-compositor/Cargo.toml
index e7547a0..522de8b 100644
--- a/crates/apps/mirada-compositor/Cargo.toml
+++ b/crates/apps/mirada-compositor/Cargo.toml
@@ -16,4 +16,6 @@ path = "src/main.rs"
 mirada-brain = { path = "../../modules/mirada/mirada-brain" }
 mirada-body = { path = "../../modules/mirada/mirada-body" }
 mirada-link = { path = "../../modules/mirada/mirada-link" }
+brahman-auth = { path = "../../protocol/brahman-auth" }
+nix = { workspace = true }
 smithay = "0.7"
diff --git a/crates/apps/mirada-compositor/README.md b/crates/apps/mirada-compositor/README.md
index eaa1fcc..f38f757 100644
--- a/crates/apps/mirada-compositor/README.md
+++ b/crates/apps/mirada-compositor/README.md
@@ -18,6 +18,9 @@ Tiene **dos backends gráficos**:
 Sin argumentos elige solo: con `DISPLAY`/`WAYLAND_DISPLAY` → `winit`;
 sin ellos → `drm`. O fuérzalo: `mirada-compositor --winit` / `--drm`.
 
+La bandera `--greeter` (ortogonal al backend) arranca el compositor como
+gestor de login — ver **Modo greeter (DM)** más abajo.
+
 ## Backends
 
 ### winit — anidado
@@ -87,6 +90,35 @@ sólo probarlo:
 Dentro de la sesión, `Ctrl+Alt+F1…F12` salta a otra TTY y vuelve sin
 romper carmen.
 
+## Modo greeter (DM)
+
+`mirada-compositor --greeter` arranca el compositor como **gestor de
+login**: en vez de la sesión, compone el greeter (`mirada-greeter`),
+que lanza como proceso hijo. El usuario teclea sus credenciales; cuando
+el login es válido el greeter emite un `SessionTicket` por su stdout y
+el compositor **muta a modo sesión sin reiniciar el servidor Wayland**
+— el mismo proceso, la misma GPU, las mismas ventanas («mutación
+atómica»). Desde ahí baja privilegios al usuario autenticado
+(`setuid`/`setgid` + grupos) para todo lo que lanza.
+
+La bandera es ortogonal al backend: `--greeter` solo (auto), o
+`--greeter --drm` / `--greeter --winit`.
+
+```sh
+# DM real, sobre una TTY — el compositor corre como root: PAM lo exige
+sudo mirada-compositor --greeter --drm
+
+# iterar el greeter anidado, con credenciales de prueba
+MIRADA_GREETER_MOCK=demo:demo \
+  cargo run -p mirada-compositor -- --greeter --winit
+```
+
+En modo greeter no se registran atajos (todas las teclas van al
+greeter — que el usuario no pueda lanzar nada ni cerrar el compositor),
+se rechaza `spawn:` y no corre el autoarranque; los atajos y la sesión
+arrancan sólo tras el traspaso. `MIRADA_GREETER_BIN` apunta a otro
+binario de greeter (cómodo para señalar a `target/…` en desarrollo).
+
 ## Lanzador de aplicaciones
 
 `mirada-launcher` escanea los `.desktop` del sistema y lanza el que
diff --git a/crates/apps/mirada-compositor/src/drm_backend.rs b/crates/apps/mirada-compositor/src/drm_backend.rs
index 4953de9..5be079f 100644
--- a/crates/apps/mirada-compositor/src/drm_backend.rs
+++ b/crates/apps/mirada-compositor/src/drm_backend.rs
@@ -44,6 +44,7 @@ use smithay::backend::udev;
 use smithay::input::keyboard::FilterResult;
 use smithay::input::pointer::{AxisFrame, ButtonEvent, CursorImageStatus, MotionEvent};
 use smithay::output::OutputModeSource;
+use smithay::reexports::calloop::channel::{channel as ticket_channel, Event as TicketEvent};
 use smithay::reexports::calloop::generic::Generic;
 use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
 use smithay::reexports::calloop::{EventLoop, Interest, Mode as CalloopMode, PostAction};
@@ -56,9 +57,13 @@ use smithay::utils::{
     DeviceFd, IsAlive, Logical, Physical, Point, Rectangle, Scale, Size, Transform, SERIAL_COUNTER,
 };
 
+use brahman_auth::SessionTicket;
 use mirada_brain::{BodyEvent, CtlReply, Keymap, Rect};
 
-use crate::{combo_string, send_frames_surface_tree, App, Brain, ClientState, DragGrab, DragMode, Setup};
+use crate::{
+    combo_string, send_frames_surface_tree, App, BodyMode, Brain, ClientState, DragGrab, DragMode,
+    Setup,
+};
 
 /// El `DrmCompositor` concreto para la salida (un solo GPU).
 type Compositor =
@@ -604,8 +609,9 @@ impl DrmState {
     }
 }
 
-/// Arranca el Cuerpo sobre DRM/KMS — fases 1, 2a y 2b.
-pub fn run() -> Result<(), Box<dyn Error>> {
+/// Arranca el Cuerpo sobre DRM/KMS — fases 1, 2a y 2b. Con `greeter`,
+/// el compositor nace en modo DM: ver [`BodyMode`].
+pub fn run(greeter: bool) -> Result<(), Box<dyn Error>> {
     println!("mirada-compositor · backend DRM.");
     println!("──────────────────────────────────────────────────");
 
@@ -733,7 +739,8 @@ pub fn run() -> Result<(), Box<dyn Error>> {
 
     // 7 · El estado Wayland (Cerebro, teclado, keymap, control).
     println!("[7/8] armando el estado Wayland …");
-    let Setup { mut display, mut app, keymap_path, keymap_watch, ctl } = crate::build_app()?;
+    let Setup { mut display, mut app, keymap_path, keymap_watch, ctl } =
+        crate::build_app(greeter)?;
     // Con el renderer ya creado, anuncia dmabuf — sin esto las apps que
     // pintan por GPU (GPUI, navegadores acelerados) no pueden conectarse.
     crate::announce_dmabuf(&mut app, &display.handle(), &renderer);
@@ -762,14 +769,25 @@ pub fn run() -> Result<(), Box<dyn Error>> {
     std::env::set_var("WAYLAND_DISPLAY", &socket_name);
     println!("      escuchando en WAYLAND_DISPLAY={socket_name}");
 
-    // Autoarranque: los programas de `~/.config/mirada/autostart`.
-    crate::spawn_autostart();
-
-    // App de arranque: si `MIRADA_STARTUP` trae un comando, se lanza como
-    // hijo (hereda `WAYLAND_DISPLAY`) — cómodo para probar sin saltar de VT.
-    if let Ok(cmd) = std::env::var("MIRADA_STARTUP") {
-        crate::spawn_command(&cmd);
-    }
+    // Modo DM: lanza el greeter y recibe su tiquet por un canal de
+    // `calloop`. Modo normal: autoarranque + `MIRADA_STARTUP`.
+    let greeter_rx = if app.mode == BodyMode::Greeter {
+        let (tx, rx) = ticket_channel::<SessionTicket>();
+        crate::spawn_greeter(move |ticket| {
+            let _ = tx.send(ticket);
+        })?;
+        Some(rx)
+    } else {
+        // Autoarranque: los programas de `~/.config/mirada/autostart`.
+        crate::spawn_autostart(None);
+        // App de arranque: si `MIRADA_STARTUP` trae un comando, se lanza
+        // como hijo (hereda `WAYLAND_DISPLAY`) — cómodo para probar sin
+        // saltar de VT.
+        if let Ok(cmd) = std::env::var("MIRADA_STARTUP") {
+            crate::spawn_command(&cmd, None);
+        }
+        None
+    };
 
     // 8 · El bucle `calloop`: VBlank, teclado, clientes y un timer.
     println!("[8/8] montando el bucle de eventos …");
@@ -853,6 +871,18 @@ pub fn run() -> Result<(), Box<dyn Error>> {
         })
         .map_err(|e| format!("insert timer: {e}"))?;
 
+    // Tiquet del greeter (modo DM): al llegar, el traspaso a la sesión.
+    // El hilo lector del greeter despierta el bucle por este canal.
+    if let Some(rx) = greeter_rx {
+        handle
+            .insert_source(rx, |event, _, state: &mut DrmState| {
+                if let TicketEvent::Msg(ticket) = event {
+                    state.app.complete_greeter_handoff(ticket);
+                }
+            })
+            .map_err(|e| format!("insert greeter: {e}"))?;
+    }
+
     // Tope de tiempo opcional: `MIRADA_DRM_TIMEOUT=<segundos>` cierra el
     // compositor solo (0 o sin definir = sin tope). El teclado ya
     // funciona — `Super+Shift+e` o `Ctrl+C` son la salida normal.
diff --git a/crates/apps/mirada-compositor/src/main.rs b/crates/apps/mirada-compositor/src/main.rs
index 4bb4602..02af6d1 100644
--- a/crates/apps/mirada-compositor/src/main.rs
+++ b/crates/apps/mirada-compositor/src/main.rs
@@ -69,6 +69,7 @@ use smithay::{
     delegate_shm, delegate_xdg_decoration, delegate_xdg_shell,
 };
 
+use brahman_auth::{SessionTicket, UserInfo};
 use mirada_body::{BodyOp, BodyState};
 use mirada_brain::{
     BodyEvent, BrainCommand, CtlReply, CtlRequest, CtlServer, Desktop, Keymap, Rules,
@@ -89,6 +90,21 @@ enum Brain {
     Linked(BodyLink),
 }
 
+/// La fase del ciclo de vida del Cuerpo. Es un eje **ortogonal** a
+/// [`Brain`]: `Brain` dice de dónde sale la geometría; `BodyMode` dice
+/// si el compositor está pidiendo credenciales o sirviendo una sesión.
+/// Un arranque normal nace ya en [`BodyMode::Session`]; un arranque de
+/// DM (`--greeter`) nace en [`BodyMode::Greeter`] y muta una sola vez,
+/// al recibir el tiquet de un login válido — la «mutación atómica».
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum BodyMode {
+    /// Pantalla de login: el único cliente es el greeter, no se
+    /// registran atajos, se rechaza `Spawn` y no hay autoarranque.
+    Greeter,
+    /// Sesión de usuario: el compositor funciona con normalidad.
+    Session,
+}
+
 /// `app_id` que distingue a la ventana del shell del escritorio. carmen
 /// no la tesela: la acopla a una franja al pie de la pantalla.
 const SHELL_APP_ID: &str = "carmen.shell";
@@ -169,6 +185,12 @@ struct App {
     body: BodyState,
     /// El Cerebro: embebido o enlazado.
     brain: Brain,
+    /// Fase del ciclo de vida — login o sesión (ver [`BodyMode`]).
+    mode: BodyMode,
+    /// Identidad a la que rebajar privilegios al lanzar procesos de
+    /// sesión. `None` salvo tras el traspaso del DM — entonces cada
+    /// `spawn` hace `setuid`/`setgid` a este usuario (si somos root).
+    session_user: Option<UserInfo>,
     /// Atajos globales a interceptar (los registra el Cerebro).
     grabs: Vec<String>,
     /// Atajo capturado en el último evento de teclado, pendiente de enviar.
@@ -292,7 +314,15 @@ impl App {
             }
             BodyOp::SetGrabs(keys) => self.grabs = keys,
             BodyOp::SetCursor(_) => {}
-            BodyOp::Spawn(cmd) => spawn_command(&cmd),
+            BodyOp::Spawn(cmd) => {
+                // En modo greeter no se lanza nada: la pantalla de login
+                // no es un sitio desde donde abrir programas.
+                if self.mode == BodyMode::Greeter {
+                    eprintln!("mirada-compositor · «{cmd}» rechazado — modo greeter.");
+                } else {
+                    spawn_command(&cmd, self.session_user.as_ref());
+                }
+            }
             BodyOp::Shutdown => self.running = false,
         }
     }
@@ -378,6 +408,45 @@ impl App {
             self.brain_feed(ev);
         }
     }
+
+    /// El traspaso del DM — la «mutación atómica». Llega el tiquet de un
+    /// login válido y el compositor pasa de la pantalla de greeter a la
+    /// sesión del usuario **sin reiniciar el servidor Wayland**: el mismo
+    /// proceso, la misma GPU, las mismas ventanas. Idempotente — un
+    /// segundo tiquet (no debería llegar) se ignora.
+    fn complete_greeter_handoff(&mut self, ticket: SessionTicket) {
+        if self.mode == BodyMode::Session {
+            return; // ya en sesión — un tiquet de más, se ignora
+        }
+        println!(
+            "mirada-compositor · traspaso a la sesión de «{}» (uid {}).",
+            ticket.user.name, ticket.user.uid
+        );
+        if !nix::unistd::geteuid().is_root() {
+            eprintln!(
+                "mirada-compositor · aviso: no corro como root — la sesión \
+                 heredará mis privilegios, sin setuid al usuario."
+            );
+        }
+        self.mode = BodyMode::Session;
+        self.session_user = Some(ticket.user.clone());
+
+        // Ya en sesión: registra los atajos del escritorio (en modo
+        // greeter se omitieron a propósito — ver `build_app`).
+        if let Brain::Embedded(desktop) = &self.brain {
+            let grab = desktop.grab_keys();
+            self.apply_commands(vec![grab]);
+        }
+
+        // Arranca la sesión: el comando del tiquet, o el autoarranque
+        // del usuario si el tiquet no trae ninguno.
+        let user = self.session_user.clone();
+        if ticket.session.trim().is_empty() {
+            spawn_autostart(user.as_ref());
+        } else {
+            spawn_command(&ticket.session, user.as_ref());
+        }
+    }
 }
 
 // ---------------------------------------------------------------------
@@ -738,33 +807,85 @@ const THEME_ENV: &[(&str, &str)] = &[
 /// conecta a este compositor; además se le inyecta [`THEME_ENV`] para
 /// que GTK y Qt adopten el tema del escritorio. Lo usan la acción
 /// `spawn:…` del keymap, la variable `MIRADA_STARTUP` y el autoarranque.
-fn spawn_command(cmd: &str) {
+///
+/// `as_user`: si viene una identidad y el compositor corre como root
+/// (modo DM, tras el traspaso), el hijo baja a ese usuario — ver
+/// [`apply_user`]. Con `None`, o sin ser root, lanza con la identidad
+/// actual del compositor.
+fn spawn_command(cmd: &str, as_user: Option<&UserInfo>) {
     let cmd = cmd.trim();
     if cmd.is_empty() {
         return;
     }
-    match std::process::Command::new("sh")
-        .arg("-c")
-        .arg(cmd)
-        .envs(THEME_ENV.iter().copied())
-        .spawn()
-    {
+    let mut command = std::process::Command::new("sh");
+    command.arg("-c").arg(cmd).envs(THEME_ENV.iter().copied());
+    if let Some(user) = as_user {
+        if nix::unistd::geteuid().is_root() {
+            apply_user(&mut command, user);
+        }
+    }
+    match command.spawn() {
         Ok(child) => println!("mirada-compositor · lanzado (pid {}): {cmd}", child.id()),
         Err(e) => eprintln!("mirada-compositor · no pude lanzar «{cmd}»: {e}"),
     }
 }
 
-/// La ruta del archivo de autoarranque del usuario,
-/// `~/.config/mirada/autostart` — junto al keymap y las reglas.
-fn autostart_path() -> Option<std::path::PathBuf> {
-    Keymap::default_path().and_then(|p| p.parent().map(|d| d.join("autostart")))
+/// Prepara un `Command` para que el hijo corra como `user`: fija grupos
+/// suplementarios, gid, uid y una sesión propia, hace `cd` a su home e
+/// inyecta las variables de identidad. Sólo se llama tras comprobar que
+/// el compositor es root.
+///
+/// La lista de grupos se calcula **en el padre**: `getgrouplist`
+/// consulta NSS (abre `/etc/group`), y eso no es seguro entre `fork` y
+/// `exec`; en `pre_exec` quedan sólo syscalls async-signal-safe.
+fn apply_user(command: &mut std::process::Command, user: &UserInfo) {
+    use nix::unistd::{setgid, setgroups, setuid, Gid, Uid};
+    use std::os::unix::process::CommandExt;
+
+    let uid = Uid::from_raw(user.uid);
+    let gid = Gid::from_raw(user.gid);
+    let groups: Vec<Gid> = std::ffi::CString::new(user.name.as_bytes())
+        .ok()
+        .and_then(|name| nix::unistd::getgrouplist(&name, gid).ok())
+        .unwrap_or_else(|| vec![gid]);
+
+    command
+        .env("HOME", &user.home)
+        .env("USER", &user.name)
+        .env("LOGNAME", &user.name)
+        .env("SHELL", &user.shell)
+        .current_dir(&user.home);
+
+    // SAFETY: corre en el hijo, entre `fork` y `exec`. Sólo syscalls
+    // async-signal-safe. El orden es obligatorio: grupos y gid ANTES que
+    // uid — al rebajar el uid se pierde el privilegio para fijarlos.
+    unsafe {
+        command.pre_exec(move || {
+            setgroups(&groups)?;
+            setgid(gid)?;
+            setuid(uid)?;
+            let _ = nix::unistd::setsid(); // sesión propia; no es crítico
+            Ok(())
+        });
+    }
+}
+
+/// La ruta del archivo de autoarranque, `…/mirada/autostart` — junto al
+/// keymap y las reglas. Con un usuario (tras el traspaso del DM) se
+/// resuelve bajo su home; sin él, bajo la config del proceso actual.
+fn autostart_path(user: Option<&UserInfo>) -> Option<std::path::PathBuf> {
+    match user {
+        Some(u) => Some(u.home.join(".config/mirada/autostart")),
+        None => Keymap::default_path().and_then(|p| p.parent().map(|d| d.join("autostart"))),
+    }
 }
 
 /// Lanza los programas del archivo de autoarranque: un comando por
 /// línea, `#` comenta y las líneas en blanco se saltan. Sin archivo, no
-/// hace nada. Se llama una vez al arrancar, con el socket ya abierto.
-fn spawn_autostart() {
-    let Some(path) = autostart_path() else {
+/// hace nada. Se llama una vez al arrancar (o tras el traspaso del DM),
+/// con el socket ya abierto. `as_user` se propaga a [`spawn_command`].
+fn spawn_autostart(as_user: Option<&UserInfo>) {
+    let Some(path) = autostart_path(as_user) else {
         return;
     };
     let Ok(text) = std::fs::read_to_string(&path) else {
@@ -776,7 +897,7 @@ fn spawn_autostart() {
         if line.is_empty() || line.starts_with('#') {
             continue;
         }
-        spawn_command(line);
+        spawn_command(line, as_user);
         n += 1;
     }
     if n > 0 {
@@ -784,6 +905,49 @@ fn spawn_autostart() {
     }
 }
 
+/// Nombre o ruta del binario del greeter. `MIRADA_GREETER_BIN` lo
+/// sobreescribe — cómodo en desarrollo para apuntar a `target/…`.
+fn greeter_bin() -> String {
+    std::env::var("MIRADA_GREETER_BIN").unwrap_or_else(|_| "mirada-greeter".to_string())
+}
+
+/// Lanza `mirada-greeter` como proceso hijo, en modo DM, con el stdout
+/// capturado. Un hilo lee sus líneas: la que sea un [`SessionTicket`] se
+/// entrega por `send` (el bucle de eventos hará el traspaso); el resto
+/// del stdout se reenvía a la consola con el prefijo `greeter ·`. El
+/// hilo es dueño del `Child` y lo cosecha cuando el greeter termina.
+fn spawn_greeter<S>(send: S) -> std::io::Result<()>
+where
+    S: Fn(SessionTicket) + Send + 'static,
+{
+    use std::io::{BufRead, BufReader};
+    use std::process::{Command, Stdio};
+
+    let mut child = Command::new(greeter_bin())
+        .envs(THEME_ENV.iter().copied())
+        .stdout(Stdio::piped())
+        .spawn()?;
+    let stdout = child.stdout.take().expect("stdout pedido con Stdio::piped");
+    println!("mirada-compositor · greeter lanzado (pid {}).", child.id());
+
+    std::thread::spawn(move || {
+        for line in BufReader::new(stdout).lines().map_while(Result::ok) {
+            match SessionTicket::from_line(&line) {
+                Some(ticket) => {
+                    println!("mirada-compositor · tiquet de sesión recibido del greeter.");
+                    send(ticket);
+                }
+                None => println!("greeter · {line}"),
+            }
+        }
+        match child.wait() {
+            Ok(status) => println!("mirada-compositor · el greeter terminó ({status})."),
+            Err(e) => eprintln!("mirada-compositor · wait(greeter): {e}"),
+        }
+    });
+    Ok(())
+}
+
 /// Carga las reglas de ventana del usuario, o ninguna si no hay archivo.
 fn load_user_rules() -> Rules {
     match Rules::default_path() {
@@ -792,6 +956,20 @@ fn load_user_rules() -> Rules {
     }
 }
 
+/// Arma un Cerebro embebido: un `Desktop` con el keymap del usuario y
+/// sus reglas de ventana. Lo usan tanto el modo autónomo como el modo
+/// greeter (el DM es siempre autónomo — un Cerebro externo no tiene
+/// sentido en la pantalla de login).
+fn embedded_brain(keymap_path: &Option<std::path::PathBuf>) -> Brain {
+    let keymap = match keymap_path {
+        Some(p) => Keymap::load_or_init(p),
+        None => Keymap::default(),
+    };
+    let mut desktop = Desktop::with_keymap(keymap);
+    desktop.set_rules(load_user_rules());
+    Brain::Embedded(desktop)
+}
+
 /// Crea y anuncia un `wl_output` (un monitor) en el protocolo Wayland —
 /// muchos clientes (`foot` entre ellos) se niegan a arrancar sin uno.
 /// Devuelve el [`Output`](smithay::output::Output); hay que mantenerlo
@@ -851,7 +1029,7 @@ struct Setup {
 /// Arma el estado del compositor — todo lo independiente del backend
 /// gráfico (Wayland, Cerebro, teclado, keymap, control). Cada backend
 /// (winit o DRM) registra luego su propia salida y monta su bucle.
-fn build_app() -> Result<Setup, Box<dyn std::error::Error>> {
+fn build_app(greeter: bool) -> Result<Setup, Box<dyn std::error::Error>> {
     let display: Display<App> = Display::new()?;
     let dh = display.handle();
 
@@ -867,23 +1045,23 @@ fn build_app() -> Result<Setup, Box<dyn std::error::Error>> {
     // el Cerebro embebido; con un Cerebro enlazado, el keymap es asunto suyo.
     let keymap_path = Keymap::default_path();
 
-    // Elige el Cerebro: enlazado si `MIRADA_SOCKET` está puesto.
-    let brain = match std::env::var("MIRADA_SOCKET") {
-        Ok(path) => {
-            println!("mirada-compositor · esperando al Cerebro en {path} …");
-            let link = BodyLink::listen(&path)?;
-            println!("mirada-compositor · Cerebro conectado.");
-            Brain::Linked(link)
-        }
-        Err(_) => {
-            println!("mirada-compositor · modo autónomo (Cerebro embebido).");
-            let keymap = match &keymap_path {
-                Some(p) => Keymap::load_or_init(p),
-                None => Keymap::default(),
-            };
-            let mut desktop = Desktop::with_keymap(keymap);
-            desktop.set_rules(load_user_rules());
-            Brain::Embedded(desktop)
+    // Elige el Cerebro. El modo greeter (DM) fuerza Cerebro embebido;
+    // si no, enlazado cuando `MIRADA_SOCKET` está puesto, autónomo si no.
+    let brain = if greeter {
+        println!("mirada-compositor · modo greeter (DM) — Cerebro embebido.");
+        embedded_brain(&keymap_path)
+    } else {
+        match std::env::var("MIRADA_SOCKET") {
+            Ok(path) => {
+                println!("mirada-compositor · esperando al Cerebro en {path} …");
+                let link = BodyLink::listen(&path)?;
+                println!("mirada-compositor · Cerebro conectado.");
+                Brain::Linked(link)
+            }
+            Err(_) => {
+                println!("mirada-compositor · modo autónomo (Cerebro embebido).");
+                embedded_brain(&keymap_path)
+            }
         }
     };
 
@@ -904,6 +1082,8 @@ fn build_app() -> Result<Setup, Box<dyn std::error::Error>> {
         windows: Vec::new(),
         body: BodyState::new(),
         brain,
+        mode: if greeter { BodyMode::Greeter } else { BodyMode::Session },
+        session_user: None,
         grabs: Vec::new(),
         pending_keybind: None,
         next_id: 1,
@@ -914,16 +1094,23 @@ fn build_app() -> Result<Setup, Box<dyn std::error::Error>> {
     app.keyboard = Some(keyboard);
     app.pointer = Some(app.seat.add_pointer());
 
-    // En modo embebido, el propio Desktop dicta los atajos a interceptar.
-    if let Brain::Embedded(desktop) = &app.brain {
-        let grab = desktop.grab_keys();
-        app.apply_commands(vec![grab]);
+    // En modo embebido, el propio Desktop dicta los atajos a
+    // interceptar — salvo en modo greeter: en la pantalla de login
+    // todas las teclas van al greeter (que el usuario no pueda lanzar
+    // nada ni cerrar el compositor). Los atajos se registran luego, en
+    // el traspaso a la sesión (`complete_greeter_handoff`).
+    if !greeter {
+        if let Brain::Embedded(desktop) = &app.brain {
+            let grab = desktop.grab_keys();
+            app.apply_commands(vec![grab]);
+        }
     }
 
     // Vigilancia del keymap para recargarlo en caliente — sólo tiene
-    // sentido con el Cerebro embebido.
+    // sentido con el Cerebro embebido y fuera del modo greeter (donde
+    // no hay atajos registrados que recargar).
     let keymap_watch = match (&app.brain, &keymap_path) {
-        (Brain::Embedded(_), Some(p)) => Keymap::watch(p).ok(),
+        (Brain::Embedded(_), Some(p)) if !greeter => Keymap::watch(p).ok(),
         _ => None,
     };
     if keymap_watch.is_some() {
@@ -953,14 +1140,14 @@ fn build_app() -> Result<Setup, Box<dyn std::error::Error>> {
 }
 
 /// El backend `winit`: corre anidado dentro de una sesión gráfica.
-fn run_winit() -> Result<(), Box<dyn std::error::Error>> {
+fn run_winit(greeter: bool) -> Result<(), Box<dyn std::error::Error>> {
     let Setup {
         mut display,
         app: mut state,
         keymap_path,
         keymap_watch,
         ctl,
-    } = build_app()?;
+    } = build_app(greeter)?;
     let keyboard = state.keyboard.clone().expect("teclado inicializado");
 
     // El backend gráfico va primero. winit abre la ventana del compositor
@@ -1016,6 +1203,18 @@ fn run_winit() -> Result<(), Box<dyn std::error::Error>> {
         state.output_size = (win_size.w, win_size.h);
     }
 
+    // Modo greeter (DM anidado — útil para iterar la UI del login):
+    // lanza el greeter y recibe su tiquet por un canal que el bucle sondea.
+    let greeter_rx = if state.mode == BodyMode::Greeter {
+        let (tx, rx) = std::sync::mpsc::channel::<SessionTicket>();
+        spawn_greeter(move |ticket| {
+            let _ = tx.send(ticket);
+        })?;
+        Some(rx)
+    } else {
+        None
+    };
+
     while state.running {
         // 1 · Eventos del backend (teclado, redimensión, cierre).
         let status = winit.dispatch_new_events(|event| match event {
@@ -1061,7 +1260,14 @@ fn run_winit() -> Result<(), Box<dyn std::error::Error>> {
         // 2 · Comandos de un Cerebro enlazado.
         state.brain_poll();
 
-        // 2 bis · Recarga del keymap si el archivo cambió en disco.
+        // 2 bis · El tiquet del greeter (modo DM): dispara el traspaso.
+        if let Some(rx) = &greeter_rx {
+            while let Ok(ticket) = rx.try_recv() {
+                state.complete_greeter_handoff(ticket);
+            }
+        }
+
+        // 2 ter · Recarga del keymap si el archivo cambió en disco.
         if keymap_watch.as_ref().is_some_and(|w| w.changed()) {
             if let Some(path) = &keymap_path {
                 match Keymap::load(path) {
@@ -1083,7 +1289,7 @@ fn run_winit() -> Result<(), Box<dyn std::error::Error>> {
             }
         }
 
-        // 2 ter · Peticiones del API de control (mirada-ctl).
+        // 2 quater · Peticiones del API de control (mirada-ctl).
         if let Some(ctl) = &ctl {
             while let Some(mut conn) = ctl.poll() {
                 let reply = match conn.read_request() {
@@ -1154,25 +1360,34 @@ fn run_winit() -> Result<(), Box<dyn std::error::Error>> {
 }
 
 fn main() {
-    let arg = std::env::args().nth(1);
-    let result = match arg.as_deref() {
-        Some("--drm") => drm_backend::run(),
-        Some("--winit") => run_winit(),
-        Some(other) => {
-            eprintln!("mirada-compositor: opción desconocida «{other}» — usa --drm o --winit");
+    // Banderas en cualquier orden: `--greeter` (modo DM) es ortogonal
+    // al backend (`--winit` anidado · `--drm` nativo · auto si falta).
+    let args: Vec<String> = std::env::args().skip(1).collect();
+    for a in &args {
+        if !matches!(a.as_str(), "--greeter" | "--winit" | "--drm") {
+            eprintln!(
+                "mirada-compositor: opción desconocida «{a}» — usa --greeter, --winit o --drm"
+            );
             std::process::exit(2);
         }
-        None => {
+    }
+    let greeter = args.iter().any(|a| a == "--greeter");
+    let backend = args.iter().find(|a| matches!(a.as_str(), "--winit" | "--drm"));
+
+    let result = match backend.map(String::as_str) {
+        Some("--drm") => drm_backend::run(greeter),
+        Some("--winit") => run_winit(greeter),
+        _ => {
             // Auto: con sesión gráfica anfitriona → winit (anidado);
             // sin ella (una TTY pelada) → backend DRM.
             let nested = std::env::var_os("WAYLAND_DISPLAY").is_some()
                 || std::env::var_os("DISPLAY").is_some();
             if nested {
                 println!("mirada-compositor · sesión gráfica detectada → backend winit.");
-                run_winit()
+                run_winit(greeter)
             } else {
                 println!("mirada-compositor · sin sesión gráfica → backend DRM.");
-                drm_backend::run()
+                drm_backend::run(greeter)
             }
         }
     };
diff --git a/crates/apps/mirada-greeter/README.md b/crates/apps/mirada-greeter/README.md
index fd1330b..ba844de 100644
--- a/crates/apps/mirada-greeter/README.md
+++ b/crates/apps/mirada-greeter/README.md
@@ -35,8 +35,10 @@ anidado dentro de otro escritorio:
 MIRADA_GREETER_MOCK=demo:demo cargo run -p mirada-greeter
 ```
 
-## Pendiente
+## Integración con el compositor
 
-El consumo del tiquet en `mirada-compositor` (modo greeter +
-`BodyMode::Session` + arranque de la sesión con setuid) — siguiente
-slice del DM.
+El consumo del tiquet ya está cableado. `mirada-compositor --greeter`
+lanza este greeter, lee su stdout y, al recibir el `SessionTicket`,
+muta de `BodyMode::Greeter` a `BodyMode::Session` y arranca la sesión
+del usuario con `setuid`/`setgid` — sin reiniciar el servidor Wayland.
+Ver el README de `mirada-compositor`, sección **Modo greeter (DM)**.