Skip to main content

teams_cli/desktop/
browser.rs

1//! A dedicated Chromium profile. Verification precedes every page connection; closing
2//! uses the verified browser's DevTools socket, never a signal to an arbitrary PID. Only
3//! the verified browser is signalled, when it does not exit after closing its endpoint.
4use super::{ConnectionMetadata, ProbeStatus, cdp::Cdp, connection::Action, session::Verifier};
5use crate::{config::DesktopAccount, error::AppError};
6use serde::{Deserialize, Serialize};
7use serde_json::{Value, json};
8use std::{
9    ffi::OsString,
10    fs::{File, OpenOptions},
11    io::{Read, Seek, Write},
12    os::unix::{ffi::OsStringExt, fs::OpenOptionsExt, process::CommandExt},
13    path::{Path, PathBuf},
14    process::{Command, Stdio},
15    time::Duration,
16};
17
18pub struct BrowserSystem {
19    pub core: bool,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23struct Identity {
24    pid: u32,
25    started: String,
26    executable: PathBuf,
27}
28
29#[derive(Debug, Serialize, Deserialize)]
30struct State {
31    version: u8,
32    port: u16,
33    owner: Option<Identity>,
34    visible: bool,
35    #[serde(default)]
36    core: bool,
37    phase: String,
38}
39
40fn failed(message: &str) -> AppError {
41    AppError::Desktop(message.into())
42}
43
44pub(super) fn data_directory() -> Result<PathBuf, AppError> {
45    let base = match std::env::var_os("TEAMS_DESKTOP_BROWSER_DATA_DIR") {
46        Some(path) => {
47            let path = PathBuf::from(path);
48            if !path.is_absolute() {
49                return Err(failed(
50                    "TEAMS_DESKTOP_BROWSER_DATA_DIR must be an absolute path",
51                ));
52            }
53            path
54        }
55        None => dirs::data_local_dir()
56            .ok_or_else(|| failed("could not locate the browser profile directory"))?
57            .join("teams-cli/browser"),
58    };
59    Ok(base)
60}
61
62fn directory(port: u16) -> Result<PathBuf, AppError> {
63    Ok(data_directory()?.join(port.to_string()))
64}
65
66/// Avoid assigning a new account a port with an older browser's saved sign-in.
67pub fn has_saved_data(port: u16) -> Result<bool, AppError> {
68    Ok(directory(port)?.try_exists()?)
69}
70
71fn profile(dir: &Path) -> PathBuf {
72    dir.join("profile")
73}
74
75pub fn executable() -> Result<PathBuf, AppError> {
76    if let Some(path) = std::env::var_os("TEAMS_DESKTOP_BROWSER") {
77        let path = PathBuf::from(path);
78        if !path.is_absolute() {
79            return Err(failed(
80                "TEAMS_DESKTOP_BROWSER must be an absolute path to the Chrome/Chromium/Edge executable (not a shell wrapper)",
81            ));
82        }
83        return std::fs::canonicalize(path)
84            .map_err(|_| failed("TEAMS_DESKTOP_BROWSER does not exist; fix or unset this override, then retry setup"));
85    }
86    let candidates = if cfg!(target_os = "macos") {
87        vec![
88            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
89            "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
90            "/Applications/Chromium.app/Contents/MacOS/Chromium",
91        ]
92    } else {
93        vec![
94            "/opt/google/chrome/chrome",
95            "/opt/microsoft/msedge/msedge",
96            "/opt/microsoft/msedge-beta/msedge",
97            "/usr/lib/chromium/chromium",
98            "/usr/lib64/chromium-browser/chromium-browser",
99            "/usr/lib/chromium-browser/chromium-browser",
100        ]
101    };
102    candidates.iter().find_map(|p| std::fs::canonicalize(p).ok())
103        .ok_or_else(|| failed("install Chrome, Chromium or Edge, or set TEAMS_DESKTOP_BROWSER to its executable; WSL2 uses a Linux browser"))
104}
105
106pub fn prerequisites() -> Result<(), AppError> {
107    prerequisites_for(false)
108}
109
110pub fn prerequisites_for(core: bool) -> Result<(), AppError> {
111    if core {
112        super::browser_install::installed().map_err(|error| failed(&format!("The lightweight browser is not ready: {error}. Run `teams desktop login --desktop-client browser-core` to set it up.")))?;
113    } else {
114        executable()?;
115    }
116    verification_prerequisites()
117}
118
119fn verification_prerequisites() -> Result<(), AppError> {
120    #[cfg(target_os = "macos")]
121    if !Path::new("/usr/sbin/lsof").is_file() {
122        return Err(failed("lsof is required to verify the browser listener"));
123    }
124    #[cfg(target_os = "linux")]
125    if !Path::new("/proc/net/tcp").is_file() {
126        return Err(failed(
127            "Linux /proc is required to verify the browser listener",
128        ));
129    }
130    Ok(())
131}
132
133fn display_prerequisites() -> Result<(), AppError> {
134    #[cfg(target_os = "linux")]
135    if !["DISPLAY", "WAYLAND_DISPLAY"]
136        .iter()
137        .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()))
138    {
139        return Err(failed(
140            "Browser sign-in needs a display. Enable WSLg on WSL2, or run from a Linux desktop session.",
141        ));
142    }
143    Ok(())
144}
145
146/// Local prerequisites and (only if needed) public download metadata. No profile is created.
147pub async fn preflight(core: bool) -> Result<bool, AppError> {
148    display_prerequisites()?;
149    verification_prerequisites()?;
150    let base = data_directory()?;
151    if base.try_exists()? {
152        validate_directory(&base)?;
153    }
154    if core {
155        super::browser_install::preflight().await
156    } else {
157        browser_version(&executable()?)?;
158        Ok(false)
159    }
160}
161
162fn session_executable(core: bool, visible: bool) -> Result<PathBuf, AppError> {
163    if core {
164        let (chrome, shell) = super::browser_install::installed()?;
165        Ok(if visible { chrome } else { shell })
166    } else {
167        executable()
168    }
169}
170
171pub(super) fn browser_version(path: &Path) -> Result<String, AppError> {
172    browser_version_with_timeout(path, Duration::from_secs(5))
173}
174
175fn browser_version_with_timeout(path: &Path, timeout: Duration) -> Result<String, AppError> {
176    // Files avoid pipe backpressure; a broken executable must not stall setup.
177    let mut stdout = tempfile::tempfile()?;
178    let mut stderr = tempfile::tempfile()?;
179    let mut child = Command::new(path)
180        .arg("--version")
181        .stdin(Stdio::null())
182        .stdout(stdout.try_clone()?)
183        .stderr(stderr.try_clone()?)
184        .spawn()?;
185    let deadline = std::time::Instant::now() + timeout;
186    let status = loop {
187        match child.try_wait() {
188            Ok(Some(status)) => break status,
189            Ok(None) if std::time::Instant::now() < deadline => {
190                std::thread::sleep(Duration::from_millis(20));
191            }
192            result => {
193                let _ = child.kill();
194                let _ = child.wait();
195                return Err(match result {
196                    Err(error) => error.into(),
197                    _ => failed(
198                        "Checking the browser version timed out. Check its executable or unset the browser overrides, then retry setup.",
199                    ),
200                });
201            }
202        }
203    };
204    if !status.success() {
205        stderr.rewind()?;
206        let mut detail = Vec::new();
207        stderr.take(400).read_to_end(&mut detail)?;
208        return Err(failed(&format!(
209            "The browser could not start: {}. On Linux, install the Chrome system libraries named in this error and retry setup.",
210            String::from_utf8_lossy(&detail).trim()
211        )));
212    }
213    stdout.rewind()?;
214    let mut version = Vec::new();
215    stdout.take(4096).read_to_end(&mut version)?;
216    String::from_utf8_lossy(&version)
217        .split_whitespace()
218        .find(|s| s.split('.').count() == 4 && s.bytes().all(|b| b.is_ascii_digit() || b == b'.'))
219        .map(str::to_owned)
220        .ok_or_else(|| failed("unrecognized browser version"))
221}
222
223fn validate_core(account: DesktopAccount) -> Result<(), AppError> {
224    if account != DesktopAccount::Personal {
225        return Err(failed(
226            "browser-core currently requires --desktop-account personal",
227        ));
228    }
229    if browser_version(&session_executable(true, true)?)?
230        != browser_version(&session_executable(true, false)?)?
231    {
232        return Err(failed(
233            "visible browser and headless shell must have exactly matching versions",
234        ));
235    }
236    Ok(())
237}
238
239fn load(dir: &Path, port: u16) -> Result<Option<State>, AppError> {
240    match std::fs::read(dir.join("connection.json")) {
241        Ok(bytes) => {
242            let state: State = serde_json::from_slice(&bytes)
243                .map_err(|_| failed("invalid browser connection record; preserved for recovery"))?;
244            if state.version != 1
245                || state.port != port
246                || !matches!(state.phase.as_str(), "starting" | "active" | "core_ready")
247            {
248                return Err(failed(
249                    "invalid browser connection record; preserved for recovery",
250                ));
251            }
252            Ok(Some(state))
253        }
254        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
255        Err(error) => Err(error.into()),
256    }
257}
258
259fn save(dir: &Path, state: &State) -> Result<(), AppError> {
260    let mut file = tempfile::NamedTempFile::new_in(dir)?;
261    file.write_all(&serde_json::to_vec(state).map_err(|_| failed("invalid browser state"))?)?;
262    file.as_file().sync_all()?;
263    file.persist(dir.join("connection.json"))
264        .map_err(|e| e.error)?;
265    Ok(())
266}
267
268fn arguments_match(args: &[OsString], profile: &Path, port: u16) -> bool {
269    let mut expected = OsString::from("--user-data-dir=");
270    expected.push(profile);
271    let debug = OsString::from(format!("--remote-debugging-port={port}"));
272    args.iter()
273        .filter(|a| a.to_string_lossy().starts_with("--user-data-dir"))
274        .count()
275        == 1
276        && args.contains(&expected)
277        && args
278            .iter()
279            .filter(|a| a.to_string_lossy().starts_with("--remote-debugging-port"))
280            .count()
281            == 1
282        && args.contains(&debug)
283        && !args
284            .iter()
285            .any(|a| a == "--remote-debugging-pipe" || a.to_string_lossy().starts_with("--type="))
286}
287
288fn owned(state: &State, dir: &Path) -> bool {
289    if validate_directory(dir).is_err() || validate_directory(&profile(dir)).is_err() {
290        return false;
291    }
292    state.owner.as_ref().is_some_and(|owner| {
293        system::process(owner.pid).is_ok_and(|(identity, args)| {
294            identity == *owner && arguments_match(&args, &profile(dir), state.port)
295        })
296    })
297}
298
299impl Verifier for BrowserSystem {
300    fn core_only(&self, port: u16) -> bool {
301        self.core
302            && directory(port)
303                .and_then(|d| load(&d, port))
304                .map_or(true, |state| state.is_none_or(|s| !s.visible))
305    }
306
307    async fn verify(&self, port: u16) -> Result<(ConnectionMetadata, Vec<Value>), AppError> {
308        prerequisites_for(self.core)?;
309        let mut metadata = ConnectionMetadata::empty(port);
310        let sockets = system::listeners(port)?;
311        if sockets.is_empty() {
312            return Ok((metadata, vec![]));
313        }
314        metadata.loopback_only = Some(sockets.iter().all(|s| s.loopback));
315        if metadata.loopback_only != Some(true) {
316            metadata.status = ProbeStatus::UnsafeListenerRefused;
317            return Ok((metadata, vec![]));
318        }
319        let dir = directory(port)?;
320        let state = load(&dir, port)?;
321        let trusted = state.as_ref().is_some_and(|s| {
322            s.core == self.core
323                && owned(s, &dir)
324                && s.owner.as_ref().is_some_and(|o| {
325                    session_executable(s.core, s.visible).is_ok_and(|e| e == o.executable)
326                        && system::owns_sockets(o.pid, &sockets)
327                })
328                && owned(s, &dir)
329        });
330        metadata.teams_owned = Some(trusted);
331        if !trusted {
332            metadata.status = ProbeStatus::NonTeamsListenerRefused;
333            return Ok((metadata, vec![]));
334        }
335        let targets = super::fill_metadata(&mut metadata).await;
336        if state
337            .as_ref()
338            .is_some_and(|s| s.core && !s.visible && s.phase != "core_ready")
339        {
340            return Ok((metadata, vec![]));
341        }
342        Ok((metadata, targets))
343    }
344}
345
346fn launch_args(
347    dir: &Path,
348    port: u16,
349    account: DesktopAccount,
350    visible: bool,
351    core: bool,
352) -> Vec<OsString> {
353    let mut data = OsString::from("--user-data-dir=");
354    data.push(profile(dir));
355    let mut args = vec![
356        data,
357        format!("--remote-debugging-port={port}").into(),
358        "--remote-debugging-address=127.0.0.1".into(),
359        "--no-first-run".into(),
360        "--no-default-browser-check".into(),
361    ];
362    if core && cfg!(target_os = "macos") {
363        args.push("--use-mock-keychain".into());
364    }
365    #[cfg(target_os = "linux")]
366    if visible && std::env::var_os("WAYLAND_DISPLAY").is_some() {
367        args.push("--ozone-platform-hint=auto".into());
368    }
369    if !visible {
370        args.push("--headless=new".into());
371        if core {
372            args.push("--disable-gpu".into());
373            args.push("--js-flags=--lite-mode --optimize-for-size".into());
374        }
375    }
376    let url = if account == DesktopAccount::Personal {
377        "https://teams.live.com"
378    } else {
379        "https://teams.cloud.microsoft"
380    };
381    args.push(if core && !visible {
382        "about:blank".into()
383    } else {
384        format!("--app={url}").into()
385    });
386    args
387}
388
389struct Guard(File);
390impl Drop for Guard {
391    fn drop(&mut self) {
392        let _ = self.0.unlock();
393    }
394}
395
396pub(super) fn prepare_directory(dir: &Path) -> Result<(), AppError> {
397    use std::os::unix::fs::DirBuilderExt;
398    std::fs::DirBuilder::new()
399        .recursive(true)
400        .mode(0o700)
401        .create(dir)?;
402    validate_directory(dir)
403}
404
405pub(super) fn validate_directory(dir: &Path) -> Result<(), AppError> {
406    use std::os::unix::fs::MetadataExt;
407    let metadata = std::fs::symlink_metadata(dir)?;
408    let me = system::uid()?;
409    if !metadata.is_dir() || metadata.uid() != me || metadata.mode() & 0o077 != 0 {
410        return Err(failed(
411            "the dedicated browser directory must be owned by you with permissions 0700",
412        ));
413    }
414    Ok(())
415}
416
417/// Browser sessions persist independently of the CLI. Sign-in data survives disconnect.
418// Separate orchestration from OS/browser operations so the visibility transition
419// and failure behavior can be tested without a signed-in Microsoft account.
420trait LoginSession {
421    async fn connect(&mut self, visible: bool) -> Result<(), AppError>;
422    async fn disconnect(&mut self) -> Result<(), AppError>;
423    async fn wait_read(&mut self, timeout: Duration) -> Result<(), AppError>;
424    /// Whether the headless browser keeps Teams signed in for `period`.
425    async fn stays_signed_in(&mut self, period: Duration) -> Result<bool, AppError>;
426}
427
428/// Returns whether the browser was left visible.
429async fn login_flow(session: &mut impl LoginSession) -> Result<bool, AppError> {
430    session.connect(true).await?;
431    session
432        .wait_read(Duration::from_secs(300))
433        .await
434        .map_err(|error| {
435            error.with_hint(
436        "The visible browser and its profile were preserved; sign-in has not completed there yet.")
437        })?;
438    session.disconnect().await?;
439    session.connect(false).await?;
440    session
441        .wait_read(Duration::from_secs(60))
442        .await
443        .map_err(|error| {
444            error.with_hint(
445        "The headless browser and saved profile were preserved; headless chat access is not ready.")
446        })?;
447    if session.stays_signed_in(Duration::from_secs(60)).await? {
448        return Ok(false);
449    }
450    // Teams renews its sign-in shortly after loading. Organisations that sign in through
451    // their own identity provider (such as AD FS with Windows sign-in) refuse that renewal
452    // headless and send the page back to sign-in, so the visible browser stays open.
453    session.disconnect().await?;
454    session.connect(true).await?;
455    session.wait_read(Duration::from_secs(300)).await.map_err(|error| error.with_hint(
456        "Your organisation's sign-in needs the visible browser; sign-in has not completed there yet."))?;
457    Ok(true)
458}
459
460async fn resume_flow(session: &mut impl LoginSession, visible: bool) -> Result<bool, AppError> {
461    session.connect(visible).await?;
462    session.wait_read(Duration::from_secs(60)).await?;
463    Ok(visible)
464}
465
466async fn check_resumed_session(session: &mut impl LoginSession) -> Result<(), AppError> {
467    if session.stays_signed_in(Duration::from_secs(60)).await? {
468        Ok(())
469    } else {
470        Err(AppError::Auth("Teams needs its browser window to stay signed in. Reopen sign-in to continue; your saved session is preserved.".into()))
471    }
472}
473
474/// Reuse an opted-in inbox's saved session without forcing a visible login or mode switch.
475pub async fn resume(
476    port: u16,
477    account: DesktopAccount,
478    core: bool,
479    quiet: bool,
480) -> Result<(bool, Option<super::ConnectionWatch>), AppError> {
481    let status = manage(port, account, Action::Status, false, core).await?;
482    let already_running = status["endpoint_available"] == true;
483    let visible = status["management"]["visible"].as_bool().unwrap_or(false);
484    let mut session = BrowserLogin {
485        port,
486        account,
487        core,
488        quiet,
489        resume_only: true,
490    };
491    let result = resume_flow(&mut session, visible).await;
492    if let Err(error) = result {
493        // Only a fresh, verified sign-in redirect is classified as expired auth.
494        // Ownership failures and unknown cache layouts must never trigger a login restart.
495        let metadata = (BrowserSystem { core }).probe(port).await?;
496        if metadata.signed_out() {
497            return Err(AppError::Auth(
498                "Your saved Teams session needs sign-in again.".into(),
499            ));
500        }
501        return Err(error);
502    }
503    let visible = result?;
504    let watch = if !visible && !already_running && account != DesktopAccount::Personal {
505        let (sender, receiver) = std::sync::mpsc::channel();
506        let task = tokio::spawn(async move {
507            session.quiet = true;
508            let result = check_resumed_session(&mut session).await;
509            let _ = sender.send(result);
510        });
511        Some(super::ConnectionWatch { receiver, task })
512    } else {
513        None
514    };
515    Ok((visible, watch))
516}
517
518struct BrowserLogin {
519    port: u16,
520    account: DesktopAccount,
521    core: bool,
522    quiet: bool,
523    resume_only: bool,
524}
525
526impl LoginSession for BrowserLogin {
527    async fn connect(&mut self, visible: bool) -> Result<(), AppError> {
528        // An existing session can be reused in its current mode. A mode change
529        // closes only the verified managed browser and keeps its profile.
530        let dir = directory(self.port)?;
531        if let Some(state) = load(&dir, self.port)?
532            && state.core == self.core
533            && !owned(&state, &dir)
534            && system::listeners(self.port)?.is_empty()
535        {
536            manage(self.port, self.account, Action::Recover, false, self.core).await?;
537        }
538        if let Some(state) = load(&dir, self.port)?
539            && state.visible != visible
540        {
541            self.disconnect().await?;
542        }
543        manage(self.port, self.account, Action::Connect, visible, self.core).await?;
544        Ok(())
545    }
546
547    async fn disconnect(&mut self) -> Result<(), AppError> {
548        manage(
549            self.port,
550            self.account,
551            Action::Disconnect,
552            false,
553            self.core,
554        )
555        .await?;
556        Ok(())
557    }
558
559    async fn wait_read(&mut self, timeout: Duration) -> Result<(), AppError> {
560        tokio::time::timeout(timeout, async {
561            loop {
562                // Fresh verification on every attempt, including during navigation.
563                let mut session = super::session::Session::new(BrowserSystem { core: self.core });
564                let mut request = super::read_request(self.port, None, 1, None)?;
565                request["account_type"] = json!(self.account);
566                let reply = session.read(&request).await?;
567                if login_read_ready(&reply)? {
568                    return Ok(());
569                }
570                if self.resume_only
571                    && (BrowserSystem { core: self.core })
572                        .probe(self.port)
573                        .await?
574                        .signed_out()
575                {
576                    return Err(AppError::Auth(
577                        "Your saved Teams session needs sign-in again.".into(),
578                    ));
579                }
580                tokio::time::sleep(Duration::from_secs(1)).await;
581            }
582        })
583        .await
584        .map_err(|_| failed("Timed out waiting for a readable Teams chat cache"))?
585    }
586
587    async fn stays_signed_in(&mut self, period: Duration) -> Result<bool, AppError> {
588        // Personal accounts renew headless; their sign-in needs no extra wait.
589        if self.account == DesktopAccount::Personal {
590            return Ok(true);
591        }
592        if !self.quiet {
593            eprintln!("Checking that Teams stays signed in without its window (up to a minute).");
594        }
595        if !watch_sign_in(&BrowserSystem { core: self.core }, self.port, period).await? {
596            return Ok(false);
597        }
598        self.wait_read(Duration::from_secs(60)).await?;
599        Ok(true)
600    }
601}
602
603async fn watch_sign_in(
604    system: &impl Verifier,
605    port: u16,
606    period: Duration,
607) -> Result<bool, AppError> {
608    let deadline = tokio::time::Instant::now() + period;
609    while tokio::time::Instant::now() < deadline {
610        let (metadata, _) = system.verify(port).await?;
611        if !metadata.endpoint_available() {
612            return Err(failed(&format!(
613                "The dedicated browser became unavailable while checking sign-in. {}",
614                metadata.detail()
615            )));
616        }
617        if metadata.signed_out() {
618            return Ok(false);
619        }
620        tokio::time::sleep(Duration::from_secs(2)).await;
621    }
622    Ok(true)
623}
624
625fn login_read_ready(reply: &Value) -> Result<bool, AppError> {
626    if matches!(reply["error"].as_str(), Some("no_target" | "read_failed")) {
627        return Ok(false);
628    }
629    // The cache is not yet available during sign-in and initial Teams loading.
630    // Ownership/origin/account refusals still fail immediately.
631    if reply["result"].get("exceptionDetails").is_some() {
632        return Ok(false);
633    }
634    if let Some(raw) = reply["result"]["result"]["value"].as_str()
635        && let Ok(value) = serde_json::from_str::<Value>(raw)
636        && matches!(value["error"].as_str(), Some("layout" | "core_not_ready"))
637    {
638        return Ok(false);
639    }
640    super::read_page(reply).map(|_| true)
641}
642
643pub async fn login(port: u16, account: DesktopAccount, core: bool) -> Result<Value, AppError> {
644    login_with_progress(port, account, core, false).await
645}
646
647pub async fn login_with_progress(
648    port: u16,
649    account: DesktopAccount,
650    core: bool,
651    quiet: bool,
652) -> Result<Value, AppError> {
653    login_with_recovery(port, account, core, quiet, true).await
654}
655
656pub(super) async fn login_with_recovery(
657    port: u16,
658    account: DesktopAccount,
659    core: bool,
660    quiet: bool,
661    recovery: bool,
662) -> Result<Value, AppError> {
663    display_prerequisites()?;
664    if core {
665        super::browser_install::ensure_with_progress(quiet).await?;
666    }
667    prerequisites_for(core)?;
668    let dir = directory(port)?;
669    prepare_directory(&dir)?;
670    let lock = OpenOptions::new()
671        .create(true)
672        .truncate(false)
673        .read(true)
674        .write(true)
675        .mode(0o600)
676        .open(dir.join("login.lock"))?;
677    lock.try_lock()
678        .map_err(|_| failed("another browser login is running"))?;
679    let _guard = Guard(lock);
680    if !quiet {
681        eprintln!(
682            "Opening Teams sign-in. Complete sign-in in the window; it will hide automatically once your chats are ready."
683        );
684    }
685    let visible = login_flow(&mut BrowserLogin {
686        port,
687        account,
688        core,
689        quiet,
690        resume_only: false,
691    })
692    .await.map_err(|error| if recovery {
693        error.with_hint(&format!("Retry sign-in: `teams desktop login --desktop-client {} --port {port}`. To close this dedicated browser: `teams desktop disconnect --desktop-client {} --port {port}`.", if core { "browser-core" } else { "browser" }, if core { "browser-core" } else { "browser" }))
694    } else { error })?;
695    if visible && !quiet {
696        eprintln!(
697            "Your organisation's sign-in does not allow Teams to stay signed in without its window, so the browser stays visible. Minimise it, but keep it open."
698        );
699    }
700    let mut value = manage(port, account, Action::Status, false, core).await?;
701    if value["endpoint_available"] != true {
702        return Err(failed(
703            "The dedicated browser closed or changed after sign-in; retry the sign-in command.",
704        ));
705    }
706    value["session_verified"] = json!(true);
707    Ok(value)
708}
709
710pub async fn manage(
711    port: u16,
712    account: DesktopAccount,
713    action: Action,
714    visible: bool,
715    core: bool,
716) -> Result<Value, AppError> {
717    if core && matches!(action, Action::Connect) {
718        super::browser_install::ensure().await?;
719    }
720    prerequisites_for(core)?;
721    let dir = directory(port)?;
722    prepare_directory(&dir)?;
723    prepare_directory(&profile(&dir))?;
724    let lock = OpenOptions::new()
725        .create(true)
726        .truncate(false)
727        .read(true)
728        .write(true)
729        .mode(0o600)
730        .open(dir.join("connection.lock"))?;
731    lock.try_lock()
732        .map_err(|_| failed("another browser connection command is running"))?;
733    let _guard = Guard(lock);
734    let mut state = load(&dir, port)?;
735    let system = BrowserSystem { core };
736    let recover = format!(
737        "teams desktop recover --desktop-client {} --port {port}",
738        if core { "browser-core" } else { "browser" }
739    );
740    if state.as_ref().is_some_and(|s| s.core != core) {
741        return Err(failed(
742            "this port uses another browser mode; use its original --desktop-client to disconnect it first",
743        ));
744    }
745    match action {
746        Action::Status => {}
747        Action::Connect => {
748            if core {
749                validate_core(account)?;
750            }
751            // A dead/reused owner with an empty port can be forgotten without
752            // touching a process. Unrecorded startups and occupied ports still refuse.
753            if state
754                .as_ref()
755                .is_some_and(|s| s.owner.is_some() && !owned(s, &dir))
756                && system::listeners(port)?.is_empty()
757            {
758                std::fs::remove_file(dir.join("connection.json"))?;
759                state = None;
760            }
761            if let Some(current) = state.as_ref() {
762                if !owned(current, &dir) {
763                    return Err(failed(&format!(
764                        "the saved browser session is no longer owned; run `{recover}` before connecting"
765                    )));
766                }
767                if current.visible != visible {
768                    return Err(failed(
769                        "disconnect the browser before changing between visible and headless mode",
770                    ));
771                }
772            } else {
773                let (metadata, _) = system.verify(port).await?;
774                if metadata.status != ProbeStatus::NoListener {
775                    return Err(failed(
776                        "the browser debugging port is already in use; its listener was left untouched",
777                    ));
778                }
779                let mut next = State {
780                    version: 1,
781                    port,
782                    owner: None,
783                    visible,
784                    core,
785                    phase: "starting".into(),
786                };
787                save(&dir, &next)?;
788                let mut child = Command::new(session_executable(core, visible)?)
789                    .args(launch_args(&dir, port, account, visible, core))
790                    .stdin(Stdio::null())
791                    .stdout(Stdio::null())
792                    .stderr(Stdio::null())
793                    .process_group(0)
794                    .spawn()
795                    .map_err(|error| {
796                        failed(&format!(
797                            "could not launch the Teams browser: {error}; run `{recover}`"
798                        ))
799                    })?;
800                let pid = child.id();
801                // Capture the process after exec, then reap it without tying its life to the CLI.
802                let owner = system::process(pid).map(|(identity, _)| identity);
803                std::thread::spawn(move || {
804                    let _ = child.wait();
805                });
806                next.owner = Some(owner?);
807                save(&dir, &next)?;
808                state = Some(next);
809            }
810            let mut ready = false;
811            for _ in 0..100 {
812                let current = state.as_ref().expect("browser state");
813                if !owned(current, &dir) {
814                    break;
815                }
816                if system.probe(port).await?.endpoint_available() {
817                    ready = true;
818                    break;
819                }
820                tokio::time::sleep(Duration::from_millis(200)).await;
821            }
822            if !ready {
823                return Err(failed(&format!(
824                    "the browser did not open its debugging endpoint; its recovery record was preserved. Inspect it with `teams desktop status`, or run `{recover}`."
825                )));
826            }
827            let current = state.as_mut().expect("browser state");
828            if core && !visible && current.phase != "core_ready" {
829                let targets = super::fill_metadata(&mut ConnectionMetadata::empty(port)).await;
830                let pages: Vec<_> = targets.iter().filter(|t| t["type"] == "page").collect();
831                let target = pages
832                    .first()
833                    .filter(|_| pages.len() == 1)
834                    .and_then(|t| t["id"].as_str().filter(|id| super::valid_id(id)))
835                    .ok_or_else(|| {
836                        failed("core startup requires exactly one managed browser page")
837                    })?;
838                super::browser_core::start(port, target).await?;
839                current.phase = "core_ready".into();
840            } else if !core || visible {
841                current.phase = "active".into();
842            }
843            save(&dir, current)?;
844        }
845        Action::Disconnect | Action::Recover => {
846            if matches!(action, Action::Recover)
847                && let Some(current) = state.as_mut()
848                && current.owner.is_none()
849            {
850                let expected = session_executable(current.core, current.visible)?;
851                let mut candidates = system::pids()?
852                    .into_iter()
853                    .filter_map(|pid| system::process(pid).ok())
854                    .filter(|(identity, args)| {
855                        identity.executable == expected
856                            && arguments_match(args, &profile(&dir), port)
857                    });
858                if let Some((identity, _)) = candidates.next() {
859                    if candidates.next().is_some() {
860                        return Err(failed(
861                            "multiple browser processes match the interrupted startup; they were left untouched",
862                        ));
863                    }
864                    current.owner = Some(identity);
865                    save(&dir, current)?;
866                }
867            }
868            if let Some(current) = state.as_ref() {
869                if owned(current, &dir) && system::listeners(port)?.is_empty() {
870                    // It already released its endpoint, as after a close request it never
871                    // finished, so DevTools cannot reach it any more.
872                    stop(current, &dir).await?;
873                } else if owned(current, &dir) {
874                    let (metadata, _) = system.verify(port).await?;
875                    if !metadata.endpoint_available() {
876                        return Err(failed(&format!(
877                            "browser ownership could not be verified; close the dedicated browser manually, then run `{recover}`"
878                        )));
879                    }
880                    let (version, _) = super::read_metadata(port).await?;
881                    let id = browser_id(&version, port)?;
882                    if !owned(current, &dir) {
883                        return Err(failed("the browser process changed; it was left untouched"));
884                    }
885                    Cdp::close_browser(port, &id).await?;
886                    if !exited(current, &dir, Duration::from_secs(10)).await {
887                        stop(current, &dir).await?;
888                    }
889                } else if current.owner.is_some() {
890                    // A reused or changed PID is never contacted or signalled. Recover
891                    // clears stale state only when there is no listener left on its port.
892                    if !matches!(action, Action::Recover) || !system::listeners(port)?.is_empty() {
893                        return Err(failed(
894                            "the saved browser process changed; it was left untouched. Close the dedicated browser, then recover",
895                        ));
896                    }
897                } else if std::fs::symlink_metadata(profile(&dir).join("SingletonLock")).is_ok()
898                    || !system::listeners(port)?.is_empty()
899                {
900                    return Err(failed(
901                        "browser startup was interrupted before ownership was saved; close the dedicated browser manually, then recover",
902                    ));
903                }
904                std::fs::remove_file(dir.join("connection.json"))?;
905                state = None;
906            } else if !system::listeners(port)?.is_empty() {
907                return Err(failed(
908                    "this browser is not managed by teams-cli; it was left untouched",
909                ));
910            }
911        }
912    }
913    let metadata = system.probe(port).await?;
914    let live = state.as_ref().is_some_and(|s| owned(s, &dir));
915    Ok(
916        json!({"port":port,"endpoint_available":metadata.endpoint_available(),"session_verified":false,
917        "connection":metadata,"browser_profile":profile(&dir),
918        "management":{"managed":state.is_some(),"port":state.as_ref().map(|s|s.port),
919        "phase":state.as_ref().map(|s|s.phase.as_str()).unwrap_or("idle"),
920        "visible":state.as_ref().map(|s|s.visible),"core_only":core,"settings_pending":false,
921        "needs_recovery":state.as_ref().is_some_and(|s|!matches!(s.phase.as_str(), "active" | "core_ready") || !live)}}),
922    )
923}
924
925/// Whether the saved browser exits within `timeout`.
926async fn exited(state: &State, dir: &Path, timeout: Duration) -> bool {
927    let deadline = tokio::time::Instant::now() + timeout;
928    while owned(state, dir) {
929        if tokio::time::Instant::now() >= deadline {
930            return false;
931        }
932        tokio::time::sleep(Duration::from_millis(100)).await;
933    }
934    true
935}
936
937/// Stops the saved browser when it was asked to close and did not: Chrome can hang on
938/// exit after closing its endpoint. Only the process verified by PID, start time,
939/// executable and profile arguments is signalled.
940async fn stop(state: &State, dir: &Path) -> Result<(), AppError> {
941    if let Some(owner) = state.owner.as_ref()
942        && owned(state, dir)
943    {
944        // SAFETY: kill(2) has no memory effects; the PID was verified just above.
945        unsafe { libc::kill(owner.pid as libc::pid_t, libc::SIGKILL) };
946    }
947    if exited(state, dir, Duration::from_secs(5)).await {
948        return Ok(());
949    }
950    Err(failed(
951        "the browser has not closed; its recovery record was preserved. Close the dedicated browser manually, then recover.",
952    ))
953}
954
955impl BrowserSystem {
956    async fn probe(&self, port: u16) -> Result<ConnectionMetadata, AppError> {
957        Ok(self.verify(port).await?.0)
958    }
959}
960
961fn browser_id(version: &Value, port: u16) -> Result<String, AppError> {
962    let url = version["webSocketDebuggerUrl"]
963        .as_str()
964        .and_then(|s| url::Url::parse(s).ok())
965        .ok_or_else(|| failed("the browser did not advertise a valid control socket"))?;
966    let id = url.path().strip_prefix("/devtools/browser/").unwrap_or("");
967    if url.scheme() != "ws"
968        || !matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "[::1]"))
969        || url.port() != Some(port)
970        || !url.username().is_empty()
971        || url.password().is_some()
972        || url.query().is_some()
973        || url.fragment().is_some()
974        || !(8..=64).contains(&id.len())
975        || !id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-')
976    {
977        return Err(failed("the browser advertised an unsafe control socket"));
978    }
979    Ok(id.into())
980}
981
982#[cfg(target_os = "linux")]
983mod system {
984    use super::*;
985    use std::{collections::HashSet, os::unix::fs::MetadataExt};
986    #[derive(Debug)]
987    pub struct Socket {
988        pub loopback: bool,
989        inode: u64,
990        uid: u32,
991    }
992
993    pub fn parse_sockets(text: &str, port: u16, ipv6: bool) -> Result<Vec<Socket>, AppError> {
994        let mut sockets = vec![];
995        for row in text.lines().skip(1) {
996            let fields: Vec<_> = row.split_whitespace().collect();
997            if fields.len() < 10 {
998                return Err(failed("invalid Linux socket metadata"));
999            }
1000            let (address, number) = fields[1]
1001                .split_once(':')
1002                .ok_or_else(|| failed("invalid Linux socket address"))?;
1003            let number =
1004                u16::from_str_radix(number, 16).map_err(|_| failed("invalid Linux socket port"))?;
1005            if number != port || fields[3] != "0A" {
1006                continue;
1007            }
1008            sockets.push(Socket {
1009                loopback: address
1010                    == if ipv6 {
1011                        "00000000000000000000000001000000"
1012                    } else {
1013                        "0100007F"
1014                    },
1015                uid: fields[7]
1016                    .parse()
1017                    .map_err(|_| failed("invalid Linux socket owner"))?,
1018                inode: fields[9]
1019                    .parse()
1020                    .map_err(|_| failed("invalid Linux socket inode"))?,
1021            });
1022        }
1023        Ok(sockets)
1024    }
1025    pub fn listeners(port: u16) -> Result<Vec<Socket>, AppError> {
1026        let mut sockets = parse_sockets(&std::fs::read_to_string("/proc/net/tcp")?, port, false)?;
1027        match std::fs::read_to_string("/proc/net/tcp6") {
1028            Ok(text) => sockets.extend(parse_sockets(&text, port, true)?),
1029            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1030            Err(e) => return Err(e.into()),
1031        }
1032        Ok(sockets)
1033    }
1034    pub fn process(pid: u32) -> Result<(Identity, Vec<OsString>), AppError> {
1035        let dir = PathBuf::from(format!("/proc/{pid}"));
1036        let uid = std::fs::metadata("/proc/self")?.uid();
1037        if std::fs::metadata(&dir)?.uid() != uid {
1038            return Err(failed("browser belongs to another user"));
1039        }
1040        let before = std::fs::read_to_string(dir.join("stat"))?;
1041        let args = arguments(&std::fs::read(dir.join("cmdline"))?);
1042        let exe = std::fs::read_link(dir.join("exe"))?;
1043        let after = std::fs::read_to_string(dir.join("stat"))?;
1044        let started = start_time(&before)?;
1045        if start_time(&after)? != started {
1046            return Err(failed("browser process changed during verification"));
1047        }
1048        Ok((
1049            Identity {
1050                pid,
1051                started,
1052                executable: exe,
1053            },
1054            args,
1055        ))
1056    }
1057    /// Chrome rewrites its process title as its arguments joined by spaces, so /proc
1058    /// can report a single argument; split that title back at each option.
1059    pub fn arguments(cmdline: &[u8]) -> Vec<OsString> {
1060        let args: Vec<_> = cmdline
1061            .split(|b| *b == 0)
1062            .filter(|a| !a.is_empty())
1063            .collect();
1064        let parts = match args[..] {
1065            [title] => {
1066                let mut parts = vec![];
1067                let mut start = 0;
1068                for i in 0..title.len().saturating_sub(2) {
1069                    if &title[i..i + 3] == b" --" {
1070                        parts.push(&title[start..i]);
1071                        start = i + 1;
1072                    }
1073                }
1074                parts.push(&title[start..]);
1075                parts
1076            }
1077            _ => args,
1078        };
1079        parts
1080            .into_iter()
1081            .map(|a| OsString::from_vec(a.into()))
1082            .collect()
1083    }
1084    pub fn start_time(stat: &str) -> Result<String, AppError> {
1085        stat.rsplit_once(") ")
1086            .and_then(|(_, rest)| rest.split_whitespace().nth(19))
1087            .filter(|s| s.bytes().all(|b| b.is_ascii_digit()))
1088            .map(String::from)
1089            .ok_or_else(|| failed("invalid Linux process identity"))
1090    }
1091    pub fn uid() -> Result<u32, AppError> {
1092        Ok(std::fs::metadata("/proc/self")?.uid())
1093    }
1094    pub fn pids() -> Result<Vec<u32>, AppError> {
1095        Ok(std::fs::read_dir("/proc")?
1096            .filter_map(Result::ok)
1097            .filter_map(|e| e.file_name().to_str()?.parse().ok())
1098            .collect())
1099    }
1100    pub fn owns_sockets(pid: u32, sockets: &[Socket]) -> bool {
1101        let Ok(fds) = std::fs::read_dir(format!("/proc/{pid}/fd")) else {
1102            return false;
1103        };
1104        let inodes: HashSet<_> = fds
1105            .filter_map(Result::ok)
1106            .filter_map(|e| std::fs::read_link(e.path()).ok())
1107            .filter_map(|p| {
1108                p.to_str()?
1109                    .strip_prefix("socket:[")?
1110                    .strip_suffix(']')?
1111                    .parse::<u64>()
1112                    .ok()
1113            })
1114            .collect();
1115        let Ok(me) = std::fs::metadata("/proc/self") else {
1116            return false;
1117        };
1118        sockets
1119            .iter()
1120            .all(|s| s.uid == me.uid() && inodes.contains(&s.inode))
1121    }
1122}
1123
1124#[cfg(target_os = "macos")]
1125mod system {
1126    use super::*;
1127    #[derive(Debug)]
1128    pub struct Socket {
1129        pub loopback: bool,
1130        pid: u32,
1131    }
1132    #[link(name = "proc")]
1133    unsafe extern "C" {
1134        fn proc_pidpath(pid: libc::c_int, buffer: *mut libc::c_void, size: u32) -> libc::c_int;
1135    }
1136    fn run(path: &str, args: &[&str]) -> Result<String, AppError> {
1137        let output = Command::new(path)
1138            .args(args)
1139            .stdin(Stdio::null())
1140            .output()?;
1141        if !output.status.success() {
1142            return Err(failed("macOS browser process inspection failed"));
1143        }
1144        String::from_utf8(output.stdout).map_err(|_| failed("invalid macOS process metadata"))
1145    }
1146    pub fn listeners(port: u16) -> Result<Vec<Socket>, AppError> {
1147        let output = Command::new("/usr/sbin/lsof")
1148            .args(["-nP", &format!("-iTCP:{port}"), "-sTCP:LISTEN", "-Fpn"])
1149            .stdin(Stdio::null())
1150            .output()?;
1151        if !output.status.success() && output.status.code() != Some(1) {
1152            return Err(failed("browser listener inspection failed"));
1153        }
1154        super::super::macos::parse_listeners(&String::from_utf8_lossy(&output.stdout)).map(|list| {
1155            list.into_iter()
1156                .map(|(pid, address)| Socket {
1157                    pid,
1158                    loopback: super::super::macos::loopback_address(&address, port),
1159                })
1160                .collect()
1161        })
1162    }
1163    pub fn process(pid: u32) -> Result<(Identity, Vec<OsString>), AppError> {
1164        let snapshot = run("/bin/ps", &["-p", &pid.to_string(), "-o", "uid=,lstart="])?;
1165        let (uid, stamp) = snapshot
1166            .trim()
1167            .split_once(char::is_whitespace)
1168            .ok_or_else(|| failed("invalid browser process metadata"))?;
1169        // These OS queries do not inspect browser credentials or page data.
1170        if uid.parse::<u32>().ok() != Some(unsafe { libc::geteuid() }) {
1171            return Err(failed("browser belongs to another user"));
1172        }
1173        let mut path = vec![0u8; 4096];
1174        let n = unsafe { proc_pidpath(pid as i32, path.as_mut_ptr().cast(), path.len() as u32) };
1175        if n <= 0 {
1176            return Err(failed("could not inspect the browser executable"));
1177        }
1178        let executable = PathBuf::from(OsString::from_vec(
1179            path.split(|b| *b == 0).next().unwrap_or_default().into(),
1180        ));
1181        let mut mib = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid as i32];
1182        let mut bytes = vec![0u8; 1024 * 1024];
1183        let mut len = bytes.len();
1184        if unsafe {
1185            libc::sysctl(
1186                mib.as_mut_ptr(),
1187                3,
1188                bytes.as_mut_ptr().cast(),
1189                &mut len,
1190                std::ptr::null_mut(),
1191                0,
1192            )
1193        } != 0
1194        {
1195            return Err(failed("could not inspect the browser launch arguments"));
1196        }
1197        bytes.truncate(len);
1198        let args = parse_args(&bytes)?;
1199        if run("/bin/ps", &["-p", &pid.to_string(), "-o", "uid=,lstart="])? != snapshot {
1200            return Err(failed("browser process changed"));
1201        }
1202        Ok((
1203            Identity {
1204                pid,
1205                started: stamp.trim().into(),
1206                executable,
1207            },
1208            args,
1209        ))
1210    }
1211    pub fn parse_args(bytes: &[u8]) -> Result<Vec<OsString>, AppError> {
1212        let count = bytes
1213            .get(..4)
1214            .map(|b| i32::from_ne_bytes(b.try_into().expect("four bytes")))
1215            .filter(|n| *n > 0 && *n <= 4096)
1216            .ok_or_else(|| failed("invalid browser arguments"))? as usize;
1217        let end = bytes[4..]
1218            .iter()
1219            .position(|b| *b == 0)
1220            .ok_or_else(|| failed("invalid browser arguments"))?
1221            + 4;
1222        let args = bytes[end..]
1223            .split(|b| *b == 0)
1224            .skip_while(|a| a.is_empty())
1225            .take(count)
1226            .map(|a| OsString::from_vec(a.into()))
1227            .collect::<Vec<_>>();
1228        if args.len() != count {
1229            return Err(failed("invalid browser arguments"));
1230        }
1231        Ok(args)
1232    }
1233    pub fn uid() -> Result<u32, AppError> {
1234        Ok(unsafe { libc::geteuid() })
1235    }
1236    pub fn pids() -> Result<Vec<u32>, AppError> {
1237        Ok(run("/bin/ps", &["-axo", "pid="])?
1238            .lines()
1239            .filter_map(|line| line.trim().parse().ok())
1240            .collect())
1241    }
1242    pub fn owns_sockets(pid: u32, sockets: &[Socket]) -> bool {
1243        sockets.iter().all(|s| s.pid == pid)
1244    }
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249    use super::*;
1250
1251    #[derive(Default)]
1252    struct FakeLogin {
1253        events: Vec<&'static str>,
1254        fail_visible: bool,
1255        fail_headless: bool,
1256        fail_close: bool,
1257        signs_out_headless: bool,
1258    }
1259    impl LoginSession for FakeLogin {
1260        async fn connect(&mut self, visible: bool) -> Result<(), AppError> {
1261            self.events
1262                .push(if visible { "visible" } else { "headless" });
1263            Ok(())
1264        }
1265        async fn disconnect(&mut self) -> Result<(), AppError> {
1266            self.events.push("close");
1267            if self.fail_close {
1268                return Err(failed("ownership changed"));
1269            }
1270            Ok(())
1271        }
1272        async fn wait_read(&mut self, timeout: Duration) -> Result<(), AppError> {
1273            let visible = timeout.as_secs() == 300;
1274            self.events.push(if visible {
1275                "read-visible"
1276            } else {
1277                "read-headless"
1278            });
1279            if (visible && self.fail_visible) || (!visible && self.fail_headless) {
1280                return Err(failed("not signed in"));
1281            }
1282            Ok(())
1283        }
1284        async fn stays_signed_in(&mut self, _period: Duration) -> Result<bool, AppError> {
1285            self.events.push("watch-headless");
1286            Ok(!self.signs_out_headless)
1287        }
1288    }
1289
1290    #[tokio::test]
1291    async fn resume_preserves_visible_sessions_and_skips_repeated_work_sign_in_checks() {
1292        for visible in [false, true] {
1293            let mut session = FakeLogin::default();
1294            assert_eq!(resume_flow(&mut session, visible).await.unwrap(), visible);
1295            assert_eq!(
1296                session.events,
1297                [
1298                    if visible { "visible" } else { "headless" },
1299                    "read-headless"
1300                ]
1301            );
1302        }
1303    }
1304
1305    #[tokio::test]
1306    async fn cold_resume_opens_a_verified_inbox_without_blocking_on_the_background_watch() {
1307        let mut session = FakeLogin {
1308            signs_out_headless: true,
1309            ..Default::default()
1310        };
1311        assert!(!resume_flow(&mut session, false).await.unwrap());
1312        assert_eq!(session.events, ["headless", "read-headless"]);
1313        let mut refused = FakeLogin {
1314            fail_headless: true,
1315            ..Default::default()
1316        };
1317        assert!(resume_flow(&mut refused, false).await.is_err());
1318        assert_eq!(refused.events, ["headless", "read-headless"]);
1319    }
1320
1321    #[tokio::test]
1322    async fn the_background_watch_reports_sign_in_loss_without_restarting_the_browser() {
1323        let mut session = FakeLogin {
1324            signs_out_headless: true,
1325            ..Default::default()
1326        };
1327        assert!(matches!(
1328            check_resumed_session(&mut session).await,
1329            Err(AppError::Auth(_))
1330        ));
1331        assert_eq!(session.events, ["watch-headless"]);
1332    }
1333
1334    #[tokio::test]
1335    async fn login_verifies_reads_before_and_after_hiding() {
1336        let mut session = FakeLogin::default();
1337        assert!(!login_flow(&mut session).await.unwrap());
1338        assert_eq!(
1339            session.events,
1340            [
1341                "visible",
1342                "read-visible",
1343                "close",
1344                "headless",
1345                "read-headless",
1346                "watch-headless"
1347            ]
1348        );
1349    }
1350
1351    #[tokio::test]
1352    async fn sign_in_that_needs_the_window_reopens_it_visible() {
1353        let mut session = FakeLogin {
1354            signs_out_headless: true,
1355            ..Default::default()
1356        };
1357        assert!(login_flow(&mut session).await.unwrap());
1358        assert_eq!(
1359            session.events,
1360            [
1361                "visible",
1362                "read-visible",
1363                "close",
1364                "headless",
1365                "read-headless",
1366                "watch-headless",
1367                "close",
1368                "visible",
1369                "read-visible"
1370            ]
1371        );
1372    }
1373
1374    #[tokio::test]
1375    async fn sign_in_watch_refuses_a_closed_or_unsafe_browser() {
1376        use super::super::owner::tests::FakeSystem;
1377        for listeners in [vec![], vec![(30, false)]] {
1378            let system = FakeSystem {
1379                listeners,
1380                ..FakeSystem::teams()
1381            };
1382            let error = watch_sign_in(&system, 9224, Duration::from_secs(1))
1383                .await
1384                .unwrap_err();
1385            assert!(error.to_string().contains("became unavailable"));
1386        }
1387    }
1388
1389    #[tokio::test]
1390    async fn failed_visible_login_keeps_window_open() {
1391        let mut session = FakeLogin {
1392            fail_visible: true,
1393            ..Default::default()
1394        };
1395        let error = login_flow(&mut session).await.unwrap_err();
1396        assert!(
1397            error
1398                .to_string()
1399                .contains("visible browser and its profile were preserved")
1400        );
1401        assert_eq!(session.events, ["visible", "read-visible"]);
1402    }
1403
1404    #[tokio::test]
1405    async fn headless_sign_in_is_verified_before_success() {
1406        let mut session = FakeLogin {
1407            fail_headless: true,
1408            ..Default::default()
1409        };
1410        let error = login_flow(&mut session).await.unwrap_err();
1411        assert!(
1412            error
1413                .to_string()
1414                .contains("headless browser and saved profile were preserved")
1415        );
1416        assert_eq!(session.events.last(), Some(&"read-headless"));
1417    }
1418
1419    #[tokio::test]
1420    async fn failed_verified_close_prevents_headless_restart() {
1421        let mut session = FakeLogin {
1422            fail_close: true,
1423            ..Default::default()
1424        };
1425        assert!(login_flow(&mut session).await.is_err());
1426        assert_eq!(session.events, ["visible", "read-visible", "close"]);
1427    }
1428
1429    #[test]
1430    fn login_retries_loading_but_refuses_unsafe_or_ambiguous_sessions() {
1431        for code in ["no_target", "read_failed"] {
1432            assert!(!login_read_ready(&json!({"error":code})).unwrap());
1433        }
1434        assert!(!login_read_ready(&json!({"result":{"exceptionDetails":{}}})).unwrap());
1435        for code in ["account_ambiguous", "not_loopback", "metadata_probe_failed"] {
1436            assert!(login_read_ready(&json!({"error":code})).is_err());
1437        }
1438        assert!(
1439            login_read_ready(
1440                &json!({"result":{"result":{"value":"{\"items\":[],\"next\":null}"}}})
1441            )
1442            .unwrap()
1443        );
1444    }
1445
1446    #[test]
1447    fn browser_directories_refuse_shared_permissions_and_symlinks() {
1448        use std::os::unix::fs::{PermissionsExt, symlink};
1449        let temp = tempfile::tempdir().unwrap();
1450        let private = temp.path().join("private");
1451        prepare_directory(&private).unwrap();
1452        validate_directory(&private).unwrap();
1453        let link = temp.path().join("link");
1454        symlink(&private, &link).unwrap();
1455        assert!(prepare_directory(&link).is_err());
1456        std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o755)).unwrap();
1457        assert!(validate_directory(&private).is_err());
1458    }
1459
1460    #[test]
1461    fn core_launch_preserves_sign_in_and_skips_ui_startup() {
1462        let dir = Path::new("/tmp/core profile");
1463        let args = launch_args(dir, 9333, DesktopAccount::Personal, false, true);
1464        assert!(args.contains(&OsString::from("about:blank")));
1465        assert!(args.contains(&OsString::from(
1466            "--js-flags=--lite-mode --optimize-for-size"
1467        )));
1468        assert!(arguments_match(&args, &profile(dir), 9333));
1469        let visible = launch_args(dir, 9333, DesktopAccount::Personal, true, true);
1470        assert!(!visible.contains(&OsString::from("--headless=new")));
1471        if cfg!(target_os = "macos") {
1472            assert!(visible.contains(&OsString::from("--use-mock-keychain")));
1473        }
1474    }
1475
1476    #[test]
1477    fn launch_is_isolated_and_headless_unless_signing_in() {
1478        let dir = Path::new("/tmp/teams profile");
1479        let headless = launch_args(dir, 9333, DesktopAccount::Personal, false, false);
1480        assert!(headless.contains(&OsString::from("--headless=new")));
1481        assert!(headless.contains(&OsString::from("--app=https://teams.live.com")));
1482        assert!(arguments_match(&headless, &profile(dir), 9333));
1483        assert!(!arguments_match(
1484            &headless,
1485            Path::new("/tmp/normal-browser"),
1486            9333
1487        ));
1488        assert!(!arguments_match(&headless, &profile(dir), 9222));
1489        let visible = launch_args(dir, 9333, DesktopAccount::WorkSchool, true, false);
1490        assert!(!visible.contains(&OsString::from("--headless=new")));
1491        assert!(visible.contains(&OsString::from("--app=https://teams.cloud.microsoft")));
1492        let mut conflicting = headless.clone();
1493        conflicting.push("--user-data-dir=/tmp/normal-browser".into());
1494        assert!(!arguments_match(&conflicting, &profile(dir), 9333));
1495        let mut renderer = headless;
1496        renderer.push("--type=renderer".into());
1497        assert!(!arguments_match(&renderer, &profile(dir), 9333));
1498    }
1499
1500    #[test]
1501    fn a_hung_browser_version_check_times_out_and_reaps_its_process() {
1502        use std::os::unix::fs::PermissionsExt;
1503        let dir = tempfile::tempdir().unwrap();
1504        let browser = dir.path().join("browser");
1505        std::fs::write(
1506            &browser,
1507            "#!/bin/sh\nprintf '%s' \"$$\" > \"${0}.pid\"\nexec sleep 30\n",
1508        )
1509        .unwrap();
1510        std::fs::set_permissions(&browser, std::fs::Permissions::from_mode(0o700)).unwrap();
1511        let started = std::time::Instant::now();
1512        let error = browser_version_with_timeout(&browser, Duration::from_millis(100)).unwrap_err();
1513        assert!(error.to_string().contains("timed out"));
1514        assert!(started.elapsed() < Duration::from_secs(2));
1515        let pid: i32 = std::fs::read_to_string(dir.path().join("browser.pid"))
1516            .unwrap()
1517            .parse()
1518            .unwrap();
1519        assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
1520        assert_eq!(
1521            std::io::Error::last_os_error().raw_os_error(),
1522            Some(libc::ESRCH)
1523        );
1524    }
1525
1526    #[test]
1527    fn browser_control_socket_never_follows_an_advertised_remote_host() {
1528        let socket = |url: &str| json!({"webSocketDebuggerUrl":url});
1529        assert_eq!(
1530            browser_id(
1531                &socket("ws://localhost:9333/devtools/browser/abcdef12-3456"),
1532                9333
1533            )
1534            .unwrap(),
1535            "abcdef12-3456"
1536        );
1537        for url in [
1538            "ws://example.com:9333/devtools/browser/abcdef12",
1539            "ws://127.0.0.1:9222/devtools/browser/abcdef12",
1540            "ws://user@127.0.0.1:9333/devtools/browser/abcdef12",
1541            "ws://127.0.0.1:9333/devtools/page/abcdef12",
1542            "ws://127.0.0.1:9333/devtools/browser/../../x",
1543            "ws://127.0.0.1:9333/devtools/browser/abcdef12?secret=x",
1544        ] {
1545            assert!(browser_id(&socket(url), 9333).is_err(), "{url}");
1546        }
1547    }
1548
1549    #[test]
1550    fn corrupt_or_wrong_port_state_is_preserved() {
1551        let dir = tempfile::tempdir().unwrap();
1552        let path = dir.path().join("connection.json");
1553        std::fs::write(&path, b"corrupt").unwrap();
1554        assert!(load(dir.path(), 9333).is_err());
1555        assert_eq!(std::fs::read(&path).unwrap(), b"corrupt");
1556        let state = State {
1557            version: 1,
1558            port: 9333,
1559            owner: None,
1560            visible: true,
1561            core: false,
1562            phase: "starting".into(),
1563        };
1564        save(dir.path(), &state).unwrap();
1565        assert!(load(dir.path(), 9222).is_err());
1566        assert!(load(dir.path(), 9333).unwrap().is_some());
1567    }
1568
1569    #[cfg(target_os = "linux")]
1570    #[test]
1571    fn linux_sockets_include_unsafe_and_foreign_listeners() {
1572        let table = "header\n 0: 0100007F:2475 00000000:0000 0A 0:0 0:0 0 1000 0 42\n 1: 00000000:2475 00000000:0000 0A 0:0 0:0 0 2000 0 43\n 2: 0100007F:2475 00000000:0000 01 0:0 0:0 0 1000 0 44\n";
1573        let sockets = system::parse_sockets(table, 9333, false).unwrap();
1574        assert_eq!(sockets.len(), 2);
1575        assert!(sockets[0].loopback);
1576        assert!(!sockets[1].loopback);
1577        assert!(system::parse_sockets("header\nbroken", 9333, false).is_err());
1578        let v6 = "header\n 0: 00000000000000000000000001000000:2475 0:0 0A 0:0 0:0 0 1000 0 42\n";
1579        assert!(system::parse_sockets(v6, 9333, true).unwrap()[0].loopback);
1580    }
1581
1582    #[cfg(target_os = "linux")]
1583    #[test]
1584    fn linux_identity_and_live_socket_ownership_are_checked() {
1585        let pid = std::process::id();
1586        let (identity, _) = system::process(pid).unwrap();
1587        assert_eq!(identity.executable, std::env::current_exe().unwrap());
1588        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1589        let sockets = system::listeners(listener.local_addr().unwrap().port()).unwrap();
1590        assert!(!sockets.is_empty());
1591        assert!(system::owns_sockets(pid, &sockets));
1592        assert!(!system::owns_sockets(0, &sockets));
1593        assert!(system::start_time("broken stat").is_err());
1594        // Command names can contain ')' and spaces; field positions start after the last ') '.
1595        let stat = format!("1 (browser ) name) S {} 1234 0", "0 ".repeat(18));
1596        assert_eq!(system::start_time(&stat).unwrap(), "1234");
1597    }
1598
1599    #[cfg(target_os = "linux")]
1600    #[tokio::test]
1601    async fn a_verified_browser_that_hangs_is_stopped() {
1602        let base = tempfile::tempdir().unwrap();
1603        let dir = base.path().join("9333");
1604        prepare_directory(&dir).unwrap();
1605        prepare_directory(&profile(&dir)).unwrap();
1606        let mut data = OsString::from("--user-data-dir=");
1607        data.push(profile(&dir));
1608        // The trailing command keeps the shell from replacing itself with `sleep`.
1609        let mut child = Command::new("/bin/sh")
1610            .args(["-c", "sleep 30; :", "browser"])
1611            .arg(data)
1612            .arg("--remote-debugging-port=9333")
1613            .spawn()
1614            .unwrap();
1615        let (owner, _) = system::process(child.id()).unwrap();
1616        let state = State {
1617            version: 1,
1618            port: 9333,
1619            owner: Some(owner),
1620            visible: false,
1621            core: false,
1622            phase: "active".into(),
1623        };
1624        assert!(owned(&state, &dir));
1625        let reaper = std::thread::spawn(move || child.wait());
1626        stop(&state, &dir).await.unwrap();
1627        assert!(!owned(&state, &dir));
1628        assert!(!reaper.join().unwrap().unwrap().success());
1629    }
1630
1631    #[cfg(target_os = "linux")]
1632    #[test]
1633    fn linux_arguments_survive_chrome_process_title_rewrite() {
1634        let profile = Path::new("/home/me/teams data/9333/profile");
1635        let launched = b"/opt/my chrome/chrome\0--user-data-dir=/home/me/teams data/9333/profile\0--remote-debugging-port=9333\0--app=https://teams.live.com\0";
1636        let mut title = launched.map(|b| if b == 0 { b' ' } else { b });
1637        *title.last_mut().unwrap() = 0;
1638        for cmdline in [launched.as_slice(), title.as_slice()] {
1639            let args = system::arguments(cmdline);
1640            assert_eq!(args[0], "/opt/my chrome/chrome");
1641            assert!(arguments_match(&args, profile, 9333));
1642            assert!(!arguments_match(&args, profile, 9222));
1643        }
1644    }
1645
1646    #[cfg(target_os = "macos")]
1647    #[test]
1648    fn macos_launch_arguments_keep_paths_with_spaces_and_exclude_environment() {
1649        let args = [
1650            b"Chrome".as_slice(),
1651            b"--user-data-dir=/tmp/teams profile",
1652            b"--remote-debugging-port=9333",
1653        ];
1654        let mut bytes = (args.len() as i32).to_ne_bytes().to_vec();
1655        bytes.extend_from_slice(b"/Applications/Google Chrome\0\0\0");
1656        for arg in args {
1657            bytes.extend_from_slice(arg);
1658            bytes.push(0);
1659        }
1660        bytes.extend_from_slice(b"SECRET=not-an-argument\0");
1661        let parsed = system::parse_args(&bytes).unwrap();
1662        assert_eq!(parsed.len(), 3);
1663        assert!(arguments_match(
1664            &parsed,
1665            Path::new("/tmp/teams profile"),
1666            9333
1667        ));
1668        assert!(system::parse_args(b"bad").is_err());
1669    }
1670}