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