Skip to main content

k_ruoka_mcp/
login_flow.rs

1//! Driving `login` from inside `serve`, so an assistant can walk a user through it.
2//!
3//! The point is that a model can start the flow and relay the instructions itself,
4//! rather than telling someone to go and find a terminal. Credentials are still never
5//! automated: all this does is put a browser in front of the human and watch for
6//! K-Ruoka to start reporting an account.
7//!
8//! It runs the existing `login` subcommand as a child process rather than growing a
9//! second browser mode inside `serve`. Two reasons: a profile directory supports only
10//! one Chrome, so `serve` has to let go of it anyway (see
11//! [`Session::release_for_login`]), and the subcommand already handles the parts that
12//! were awkward to get right -- the xvfb re-exec, the separate tab for the human, the
13//! poller, and the graceful close that makes the cookies persist.
14
15use std::path::PathBuf;
16use std::process::Stdio;
17use std::sync::Arc;
18
19use serde::Serialize;
20use tokio::io::{AsyncBufReadExt, BufReader};
21use tokio::process::{Child, Command};
22use tokio::sync::Mutex;
23
24use crate::browser::Session;
25use crate::browser::session::ApiError;
26
27/// How long to wait for `login` to print its instructions before answering anyway.
28const INSTRUCTIONS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
29
30/// The banner `login` prints once the browser is up and it is waiting for the human.
31const READY_MARKER: &str = "Sign in by hand";
32
33// `Deserialize` is only for the test fake, which scripts a progress value from JSON
34// rather than reimplementing the struct.
35#[derive(Debug, Clone, Serialize, serde::Deserialize, schemars::JsonSchema)]
36#[serde(rename_all = "camelCase", default)]
37pub struct LoginProgress {
38    /// `waiting`, `signedIn`, `failed`, or `notStarted`.
39    pub state: String,
40    pub detail: String,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub account: Option<String>,
43    /// What `login` printed. For `waiting` these are the steps to give the user
44    /// verbatim: they differ between a desktop and a headless host, and only the
45    /// running process knows which it is.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub instructions: Option<String>,
48}
49
50impl Default for LoginProgress {
51    fn default() -> Self {
52        Self::new("notStarted", String::new())
53    }
54}
55
56impl LoginProgress {
57    fn new(state: &str, detail: impl Into<String>) -> Self {
58        Self {
59            state: state.to_string(),
60            detail: detail.into(),
61            account: None,
62            instructions: None,
63        }
64    }
65}
66
67/// What the login tools need. A trait so the tool surface can be tested without
68/// spawning a browser, the same reason [`crate::browser::KrApi`] is one.
69#[async_trait::async_trait]
70pub trait LoginFlow: Send + Sync {
71    /// Open a browser for the user to sign into, and return the instructions to relay.
72    async fn start(&self, debug_port: u16) -> Result<LoginProgress, ApiError>;
73    async fn status(&self) -> Result<LoginProgress, ApiError>;
74    async fn cancel(&self) -> Result<LoginProgress, ApiError>;
75}
76
77struct Running {
78    child: Child,
79    /// Everything the child has printed, stdout and stderr merged in arrival order.
80    output: Arc<Mutex<String>>,
81}
82
83pub struct ChildLogin {
84    session: Arc<Session>,
85    running: Mutex<Option<Running>>,
86    /// Test seam: lets `start` spawn a scripted child instead of re-execing `login`.
87    /// `cfg(test)` instead of a runtime flag, so it can't ship in a production binary.
88    #[cfg(test)]
89    spawn_override: Option<(PathBuf, Vec<String>)>,
90}
91
92impl ChildLogin {
93    pub fn new(session: Arc<Session>) -> Self {
94        Self {
95            session,
96            running: Mutex::new(None),
97            #[cfg(test)]
98            spawn_override: None,
99        }
100    }
101
102    // Unix-only: the tests using this signal process groups, which Windows lacks.
103    #[cfg(all(test, unix))]
104    fn with_command(session: Arc<Session>, program: &str, args: &[&str]) -> Self {
105        Self {
106            session,
107            running: Mutex::new(None),
108            spawn_override: Some((
109                PathBuf::from(program),
110                args.iter().map(|a| a.to_string()).collect(),
111            )),
112        }
113    }
114
115    /// Stop a login that is still running, for `serve`'s own shutdown.
116    ///
117    /// Not part of [`LoginFlow`]: it is not a tool, and it must not report anything to a
118    /// model. `serve` exits with `std::process::exit`, which runs no destructors, so
119    /// `kill_on_drop` never fires -- and the child is in its own process group precisely
120    /// so that a signal to `serve` does not reach it. Without this a client shutting the
121    /// server down mid-login leaves Chrome holding the profile's lock.
122    pub async fn shutdown(&self) {
123        if let Some(mut running) = self.running.lock().await.take() {
124            terminate_group(&mut running.child).await;
125        }
126    }
127
128    fn spawn_target(&self, debug_port: u16) -> Result<(PathBuf, Vec<String>), ApiError> {
129        #[cfg(test)]
130        if let Some((program, args)) = &self.spawn_override {
131            return Ok((program.clone(), args.clone()));
132        }
133        let exe = std::env::current_exe().map_err(|e| {
134            ApiError::Other(anyhow::anyhow!(
135                "cannot find this executable to re-run it: {e}"
136            ))
137        })?;
138        Ok((
139            exe,
140            vec![
141                "login".to_string(),
142                "--port".to_string(),
143                debug_port.to_string(),
144            ],
145        ))
146    }
147}
148
149#[async_trait::async_trait]
150impl LoginFlow for ChildLogin {
151    async fn start(&self, debug_port: u16) -> Result<LoginProgress, ApiError> {
152        let mut slot = self.running.lock().await;
153        if let Some(running) = slot.as_mut() {
154            // Already going: report it rather than starting a second browser on the
155            // same profile, which cannot work.
156            if running.child.try_wait().map_err(wrap)?.is_none() {
157                let output = running.output.lock().await.clone();
158                let mut progress = LoginProgress::new(
159                    "waiting",
160                    "A login is already in progress. Give the user these instructions.",
161                );
162                progress.instructions = Some(output);
163                return Ok(progress);
164            }
165            // The child is gone but nothing has observed that yet, so the session is
166            // still holding the profile for it. Hand it back before asking for it again:
167            // `release_for_login` refuses while the flag is set, and forgetting the
168            // handle here without clearing it left every tool refusing until a restart.
169            *slot = None;
170            self.session.resume_after_login().await;
171        }
172
173        // Hand the profile over before spawning: the child needs the SingletonLock this
174        // session is holding.
175        self.session.release_for_login().await?;
176
177        let (exe, args) = self.spawn_target(debug_port)?;
178        let mut command = Command::new(&exe);
179        command
180            .args(&args)
181            // stdin must be null and both streams piped: this process's stdout is the
182            // MCP JSON-RPC channel, and anything the child wrote to it would corrupt
183            // the protocol.
184            .stdin(Stdio::null())
185            .stdout(Stdio::piped())
186            .stderr(Stdio::piped())
187            .kill_on_drop(true);
188        // Its own process group, so cancelling can signal the whole tree. Chrome is a
189        // *grandchild* here (and `login` may re-exec itself under xvfb-run in between),
190        // so killing only the direct child leaves Chrome running and holding the
191        // profile's SingletonLock -- which then blocks `serve` from ever launching again.
192        #[cfg(unix)]
193        command.process_group(0);
194        let spawned = command.spawn();
195
196        let mut child = match spawned {
197            Ok(child) => child,
198            Err(e) => {
199                self.session.resume_after_login().await;
200                return Err(ApiError::Other(anyhow::anyhow!(
201                    "could not start `{} {}`: {e}",
202                    exe.display(),
203                    args.join(" ")
204                )));
205            }
206        };
207
208        let output = Arc::new(Mutex::new(String::new()));
209        for stream in [
210            child.stdout.take().map(Pipe::Out),
211            child.stderr.take().map(Pipe::Err),
212        ]
213        .into_iter()
214        .flatten()
215        {
216            let sink = Arc::clone(&output);
217            tokio::spawn(async move {
218                let mut lines = match stream {
219                    Pipe::Out(s) => BufReader::new(Box::pin(s) as PinnedRead).lines(),
220                    Pipe::Err(s) => BufReader::new(Box::pin(s) as PinnedRead).lines(),
221                };
222                while let Ok(Some(line)) = lines.next_line().await {
223                    let mut buf = sink.lock().await;
224                    buf.push_str(&line);
225                    buf.push('\n');
226                }
227            });
228        }
229
230        // Wait for the instructions rather than returning immediately, so the caller
231        // gets something to show the user in the same turn.
232        if let Err(e) = wait_for_ready(&mut child, &output).await {
233            self.session.resume_after_login().await;
234            return Err(e);
235        }
236
237        let instructions = output.lock().await.clone();
238        *slot = Some(Running { child, output });
239
240        let mut progress = LoginProgress::new(
241            "waiting",
242            "A browser is open and waiting for the user to sign in. Give them the \
243             instructions verbatim, then poll login_status. Nothing here sees their \
244             credentials.",
245        );
246        progress.instructions = Some(instructions);
247        Ok(progress)
248    }
249
250    async fn status(&self) -> Result<LoginProgress, ApiError> {
251        let mut slot = self.running.lock().await;
252        let Some(running) = slot.as_mut() else {
253            return Ok(LoginProgress::new(
254                "notStarted",
255                "No login is in progress. Call start_login to begin one, or auth_status \
256                 to check whether the stored session is already signed in.",
257            ));
258        };
259
260        let exited = running.child.try_wait().map_err(wrap)?;
261        let output = running.output.lock().await.clone();
262        let Some(status) = exited else {
263            let mut progress = LoginProgress::new(
264                "waiting",
265                "Still waiting for the user to finish signing in.",
266            );
267            progress.instructions = Some(output);
268            return Ok(progress);
269        };
270
271        *slot = None;
272        self.session.resume_after_login().await;
273
274        if status.success() {
275            let mut progress = LoginProgress::new(
276                "signedIn",
277                "Signed in. The session is stored in the browser profile and the cart \
278                 tools will use it from now on.",
279            );
280            progress.account = signed_in_account(&output);
281            Ok(progress)
282        } else {
283            let mut progress = LoginProgress::new(
284                "failed",
285                "The login did not complete. The stored profile was left untouched, so \
286                 any previous session is still there.",
287            );
288            progress.instructions = Some(output);
289            Ok(progress)
290        }
291    }
292
293    async fn cancel(&self) -> Result<LoginProgress, ApiError> {
294        let mut slot = self.running.lock().await;
295        let Some(mut running) = slot.take() else {
296            // Unconditional: cancel_login is the documented escape hatch, so it has to
297            // work even when the handle is already gone and only the flag is left.
298            self.session.resume_after_login().await;
299            return Ok(LoginProgress::new(
300                "notStarted",
301                "No login was in progress.",
302            ));
303        };
304        // SIGTERM the group first so `login` can close Chrome cleanly, then insist.
305        // A cancelled login has nothing to flush, but Chrome left running would keep the
306        // profile locked.
307        terminate_group(&mut running.child).await;
308        self.session.resume_after_login().await;
309        Ok(LoginProgress::new(
310            "notStarted",
311            "Login cancelled and the browser closed. The cart tools work again.",
312        ))
313    }
314}
315
316/// `login` prints `Signed in as <name> <email>` on success.
317fn signed_in_account(output: &str) -> Option<String> {
318    output
319        .lines()
320        .find_map(|l| l.trim().strip_prefix("Signed in as "))
321        .map(|who| who.trim_end_matches('.').to_string())
322}
323
324/// Stop the child and everything it started, Chrome included.
325async fn terminate_group(child: &mut Child) {
326    // Chrome is a grandchild, and Windows has no process groups to signal: terminating
327    // only the direct child would leave a headful Chrome holding the profile lock while
328    // the tool reported the browser closed. taskkill /T takes the tree.
329    #[cfg(windows)]
330    if let Some(pid) = child.id() {
331        let _ = Command::new("taskkill")
332            .args(["/PID", &pid.to_string(), "/T", "/F"])
333            .stdin(Stdio::null())
334            .stdout(Stdio::null())
335            .stderr(Stdio::null())
336            .status()
337            .await;
338    }
339    #[cfg(unix)]
340    if let Some(pid) = child.id() {
341        // Negative pid means "the process group", which is why it was spawned into one.
342        // SIGTERM lets `login` close Chrome gracefully; SIGKILL is the backstop for
343        // anything that ignores it.
344        unsafe { libc::kill(-(pid as i32), libc::SIGTERM) };
345        for _ in 0..20 {
346            if matches!(child.try_wait(), Ok(Some(_))) {
347                return;
348            }
349            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
350        }
351        unsafe { libc::kill(-(pid as i32), libc::SIGKILL) };
352    }
353    let _ = child.start_kill();
354    let _ = child.wait().await;
355}
356
357/// Block until `login` prints [`READY_MARKER`] or [`INSTRUCTIONS_TIMEOUT`] elapses.
358/// Errors if the child dies first (e.g. no display and no xvfb-run), or if watching it
359/// via `try_wait` itself fails with an I/O error.
360async fn wait_for_ready(child: &mut Child, output: &Arc<Mutex<String>>) -> Result<(), ApiError> {
361    let deadline = tokio::time::Instant::now() + INSTRUCTIONS_TIMEOUT;
362    loop {
363        if output.lock().await.contains(READY_MARKER) {
364            return Ok(());
365        }
366        if child.try_wait().map_err(wrap)?.is_some() {
367            let detail = output.lock().await.clone();
368            return Err(ApiError::Other(anyhow::anyhow!(
369                "login exited before it was ready:\n{}",
370                detail.trim()
371            )));
372        }
373        if tokio::time::Instant::now() >= deadline {
374            return Ok(());
375        }
376        tokio::time::sleep(std::time::Duration::from_millis(250)).await;
377    }
378}
379
380fn wrap(e: std::io::Error) -> ApiError {
381    ApiError::Other(anyhow::anyhow!("watching the login process: {e}"))
382}
383
384type PinnedRead = std::pin::Pin<Box<dyn tokio::io::AsyncRead + Send>>;
385
386enum Pipe {
387    Out(tokio::process::ChildStdout),
388    Err(tokio::process::ChildStderr),
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    /// A session with nothing launched. Cheap and hermetic: `Session::new` touches no
396    /// Chrome, and `release_for_login` with no live browser only moves the flag.
397    fn scratch_session(name: &str) -> Arc<Session> {
398        let profile = std::env::temp_dir().join(format!("k-ruoka-login-flow-{name}"));
399        let _ = std::fs::remove_dir_all(&profile);
400        Arc::new(Session::new(&profile, crate::browser::LaunchMode::Headless).unwrap())
401    }
402
403    /// The flag that makes the cart tools refuse is only cleared by whoever set it, so
404    /// `cancel_login` has to clear it even when there is no child left to kill. Without
405    /// this, a login whose child had already gone left every tool refusing until the
406    /// process was restarted, while telling the caller to run the very tool that could
407    /// not help.
408    #[tokio::test]
409    async fn cancelling_frees_the_profile_even_with_no_child_left() {
410        let session = scratch_session("cancel");
411        session.release_for_login().await.unwrap();
412        assert!(
413            session.release_for_login().await.is_err(),
414            "a second login must be refused while one owns the profile"
415        );
416
417        ChildLogin::new(Arc::clone(&session))
418            .cancel()
419            .await
420            .unwrap();
421
422        session
423            .release_for_login()
424            .await
425            .expect("cancel_login must hand the profile back");
426    }
427
428    #[test]
429    fn the_account_is_read_out_of_logins_own_output() {
430        let output = "Opening a browser against /x\n\nSigned in as Niko Savola <a@b.c>.\n\
431                      Session saved to /x.\n";
432        assert_eq!(
433            signed_in_account(output).as_deref(),
434            Some("Niko Savola <a@b.c>")
435        );
436    }
437
438    #[test]
439    fn no_account_line_is_not_an_account() {
440        assert_eq!(signed_in_account("timed out after 15 minutes\n"), None);
441    }
442
443    // Scripted `sh` children stand in for the real `login` subprocess: CI has no display
444    // or Chrome. A scripted child can exit before the drain task reads what it wrote, so
445    // these tests need multi_thread and the small sleeps in their scripts.
446
447    #[cfg(unix)]
448    async fn wait_for_pid_file(path: &std::path::Path) -> i32 {
449        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
450        loop {
451            if let Ok(contents) = std::fs::read_to_string(path)
452                && let Ok(pid) = contents.trim().parse::<i32>()
453            {
454                return pid;
455            }
456            assert!(
457                tokio::time::Instant::now() < deadline,
458                "grandchild never wrote its pid to {}",
459                path.display()
460            );
461            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
462        }
463    }
464
465    /// `kill(pid, 0)` sends no signal; it's the standard way to check a pid is alive.
466    #[cfg(unix)]
467    fn process_alive(pid: i32) -> bool {
468        if unsafe { libc::kill(pid, 0) } != 0 {
469            return false;
470        }
471        // A zombie still answers signal 0, and the group kill can outlive the shell that
472        // would have reaped it, so an unreaped exit would otherwise look alive. Only Linux
473        // has procfs to tell the difference; elsewhere signal 0 is all there is.
474        if !cfg!(target_os = "linux") {
475            return true;
476        }
477        match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
478            Ok(stat) => !stat
479                .rsplit(')')
480                .next()
481                .is_some_and(|rest| rest.split_whitespace().next() == Some("Z")),
482            Err(_) => false,
483        }
484    }
485
486    #[cfg(unix)]
487    async fn wait_for_process_death(pid: i32) -> bool {
488        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
489        while tokio::time::Instant::now() < deadline {
490            if !process_alive(pid) {
491                return true;
492            }
493            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
494        }
495        false
496    }
497
498    #[cfg(unix)]
499    async fn poll_until_not_waiting(login: &ChildLogin) -> LoginProgress {
500        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
501        loop {
502            let progress = login.status().await.unwrap();
503            if progress.state != "waiting" {
504                return progress;
505            }
506            assert!(
507                tokio::time::Instant::now() < deadline,
508                "child never left the waiting state"
509            );
510            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
511        }
512    }
513
514    #[cfg(unix)]
515    #[tokio::test(flavor = "multi_thread")]
516    async fn a_child_that_dies_before_printing_the_marker_reports_why_and_frees_the_profile() {
517        let session = scratch_session("dies-before-ready");
518        let login = ChildLogin::with_command(
519            Arc::clone(&session),
520            "sh",
521            &["-c", "echo 'boom from child' >&2; sleep 1; exit 1"],
522        );
523
524        let err = login.start(0).await.unwrap_err();
525        assert!(
526            err.to_string().contains("boom from child"),
527            "error should surface what the dead child printed, got: {err}"
528        );
529
530        session
531            .release_for_login()
532            .await
533            .expect("a login that died before ready must hand the profile back");
534    }
535
536    #[cfg(unix)]
537    #[tokio::test(flavor = "multi_thread")]
538    async fn a_second_start_after_the_first_child_died_unobserved_spawns_a_fresh_one() {
539        let session = scratch_session("stale-slot");
540        let login = ChildLogin::with_command(
541            Arc::clone(&session),
542            "sh",
543            &["-c", "echo 'Sign in by hand'; sleep 1; exit 0"],
544        );
545
546        let first = login.start(0).await.unwrap();
547        assert_eq!(first.state, "waiting");
548
549        // Dies unobserved: calling `status` here would itself clear the stale slot.
550        tokio::time::sleep(std::time::Duration::from_millis(1_300)).await;
551
552        let second = login.start(0).await.unwrap();
553        assert_eq!(
554            second.state, "waiting",
555            "a stale, unobserved dead child must not block a fresh login"
556        );
557
558        // The second child is still sleeping; kill_on_drop would only reach the shell.
559        login.cancel().await.unwrap();
560    }
561
562    /// Cancel must reach the grandchild via the process group, not just the direct child.
563    #[cfg(unix)]
564    #[tokio::test(flavor = "multi_thread")]
565    async fn a_child_that_only_prints_the_marker_stays_waiting_until_cancelled() {
566        let session = scratch_session("waiting-then-cancelled");
567        let pidfile = std::env::temp_dir().join(format!(
568            "k-ruoka-login-flow-grandchild-{}",
569            std::process::id()
570        ));
571        let _ = std::fs::remove_file(&pidfile);
572        let script = format!(
573            "echo 'Sign in by hand'; sleep 60 & echo $! > {}; wait",
574            pidfile.display()
575        );
576        let login = ChildLogin::with_command(Arc::clone(&session), "sh", &["-c", &script]);
577
578        let first = login.start(0).await.unwrap();
579        assert_eq!(first.state, "waiting");
580        assert!(first.instructions.unwrap().contains("Sign in by hand"));
581
582        let second = login.start(0).await.unwrap();
583        assert!(
584            second.detail.contains("already in progress"),
585            "a second start must not spawn another browser onto the same profile"
586        );
587
588        assert_eq!(login.status().await.unwrap().state, "waiting");
589
590        let grandchild = wait_for_pid_file(&pidfile).await;
591        assert!(
592            process_alive(grandchild),
593            "the grandchild should still be running before cancel"
594        );
595
596        let cancelled = login.cancel().await.unwrap();
597        assert_eq!(cancelled.state, "notStarted");
598        assert!(
599            wait_for_process_death(grandchild).await,
600            "cancel must reach the whole process group, including grandchildren, \
601             not just the direct child"
602        );
603
604        session
605            .release_for_login()
606            .await
607            .expect("cancel must free the profile");
608
609        let _ = std::fs::remove_file(&pidfile);
610    }
611
612    #[cfg(unix)]
613    #[tokio::test(flavor = "multi_thread")]
614    async fn a_child_that_signs_in_reports_the_account_once_it_exits() {
615        let session = scratch_session("signs-in");
616        let login = ChildLogin::with_command(
617            Arc::clone(&session),
618            "sh",
619            &[
620                "-c",
621                "echo 'Sign in by hand'; sleep 1; \
622                 echo 'Signed in as Test User <test@example.com>.'; exit 0",
623            ],
624        );
625
626        let started = login.start(0).await.unwrap();
627        assert_eq!(started.state, "waiting");
628
629        let finished = poll_until_not_waiting(&login).await;
630        assert_eq!(finished.state, "signedIn");
631        assert_eq!(
632            finished.account.as_deref(),
633            Some("Test User <test@example.com>")
634        );
635    }
636}