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.
3use super::{ConnectionMetadata, ProbeStatus, cdp::Cdp, connection::Action, session::Verifier};
4use crate::{config::DesktopAccount, error::AppError};
5use serde::{Deserialize, Serialize};
6use serde_json::{Value, json};
7use std::{
8    ffi::OsString,
9    fs::{File, OpenOptions},
10    io::Write,
11    os::unix::{ffi::OsStringExt, fs::OpenOptionsExt, process::CommandExt},
12    path::{Path, PathBuf},
13    process::{Command, Stdio},
14    time::Duration,
15};
16
17pub struct BrowserSystem {
18    pub core: bool,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22struct Identity {
23    pid: u32,
24    started: String,
25    executable: PathBuf,
26}
27
28#[derive(Debug, Serialize, Deserialize)]
29struct State {
30    version: u8,
31    port: u16,
32    owner: Option<Identity>,
33    visible: bool,
34    #[serde(default)]
35    core: bool,
36    phase: String,
37}
38
39fn failed(message: &str) -> AppError {
40    AppError::Desktop(message.into())
41}
42
43fn directory(port: u16) -> Result<PathBuf, AppError> {
44    let base = match std::env::var_os("TEAMS_DESKTOP_BROWSER_DATA_DIR") {
45        Some(path) => {
46            let path = PathBuf::from(path);
47            if !path.is_absolute() {
48                return Err(failed(
49                    "TEAMS_DESKTOP_BROWSER_DATA_DIR must be an absolute path",
50                ));
51            }
52            path
53        }
54        None => dirs::data_local_dir()
55            .ok_or_else(|| failed("could not locate the browser profile directory"))?
56            .join("teams-cli/browser"),
57    };
58    Ok(base.join(port.to_string()))
59}
60
61fn profile(dir: &Path) -> PathBuf {
62    dir.join("profile")
63}
64
65pub fn executable() -> Result<PathBuf, AppError> {
66    if let Some(path) = std::env::var_os("TEAMS_DESKTOP_BROWSER") {
67        let path = PathBuf::from(path);
68        if !path.is_absolute() {
69            return Err(failed(
70                "TEAMS_DESKTOP_BROWSER must be an absolute path to the Chrome/Chromium/Edge executable (not a shell wrapper)",
71            ));
72        }
73        return std::fs::canonicalize(path)
74            .map_err(|_| failed("TEAMS_DESKTOP_BROWSER does not exist"));
75    }
76    let candidates = if cfg!(target_os = "macos") {
77        vec![
78            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
79            "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
80            "/Applications/Chromium.app/Contents/MacOS/Chromium",
81        ]
82    } else {
83        vec![
84            "/opt/google/chrome/chrome",
85            "/opt/microsoft/msedge/msedge",
86            "/opt/microsoft/msedge-beta/msedge",
87            "/usr/lib/chromium/chromium",
88            "/usr/lib64/chromium-browser/chromium-browser",
89            "/usr/lib/chromium-browser/chromium-browser",
90        ]
91    };
92    candidates.iter().find_map(|p| std::fs::canonicalize(p).ok())
93        .ok_or_else(|| failed("install Chrome, Chromium or Edge, or set TEAMS_DESKTOP_BROWSER to its executable; WSL2 uses a Linux browser"))
94}
95
96pub fn prerequisites() -> Result<(), AppError> {
97    prerequisites_for(false)
98}
99
100pub fn prerequisites_for(core: bool) -> Result<(), AppError> {
101    if core {
102        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.")))?;
103    } else {
104        executable()?;
105    }
106    #[cfg(target_os = "macos")]
107    if !Path::new("/usr/sbin/lsof").is_file() {
108        return Err(failed("lsof is required to verify the browser listener"));
109    }
110    #[cfg(target_os = "linux")]
111    if !Path::new("/proc/net/tcp").is_file() {
112        return Err(failed(
113            "Linux /proc is required to verify the browser listener",
114        ));
115    }
116    Ok(())
117}
118
119fn session_executable(core: bool, visible: bool) -> Result<PathBuf, AppError> {
120    if core {
121        let (chrome, shell) = super::browser_install::installed()?;
122        Ok(if visible { chrome } else { shell })
123    } else {
124        executable()
125    }
126}
127
128pub(super) fn browser_version(path: &Path) -> Result<String, AppError> {
129    let output = Command::new(path).arg("--version").output()?;
130    if !output.status.success() {
131        let detail = String::from_utf8_lossy(&output.stderr)
132            .chars()
133            .take(400)
134            .collect::<String>();
135        return Err(failed(&format!(
136            "The browser could not start: {}. On Linux, install the Chrome system libraries named in this error and retry setup.",
137            detail.trim()
138        )));
139    }
140    String::from_utf8_lossy(&output.stdout)
141        .split_whitespace()
142        .find(|s| s.split('.').count() == 4 && s.bytes().all(|b| b.is_ascii_digit() || b == b'.'))
143        .map(str::to_owned)
144        .ok_or_else(|| failed("unrecognized browser version"))
145}
146
147fn validate_core(account: DesktopAccount) -> Result<(), AppError> {
148    if account != DesktopAccount::Personal {
149        return Err(failed(
150            "browser-core currently requires --desktop-account personal",
151        ));
152    }
153    if browser_version(&session_executable(true, true)?)?
154        != browser_version(&session_executable(true, false)?)?
155    {
156        return Err(failed(
157            "visible browser and headless shell must have exactly matching versions",
158        ));
159    }
160    Ok(())
161}
162
163fn load(dir: &Path, port: u16) -> Result<Option<State>, AppError> {
164    match std::fs::read(dir.join("connection.json")) {
165        Ok(bytes) => {
166            let state: State = serde_json::from_slice(&bytes)
167                .map_err(|_| failed("invalid browser connection record; preserved for recovery"))?;
168            if state.version != 1
169                || state.port != port
170                || !matches!(state.phase.as_str(), "starting" | "active" | "core_ready")
171            {
172                return Err(failed(
173                    "invalid browser connection record; preserved for recovery",
174                ));
175            }
176            Ok(Some(state))
177        }
178        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
179        Err(error) => Err(error.into()),
180    }
181}
182
183fn save(dir: &Path, state: &State) -> Result<(), AppError> {
184    let mut file = tempfile::NamedTempFile::new_in(dir)?;
185    file.write_all(&serde_json::to_vec(state).map_err(|_| failed("invalid browser state"))?)?;
186    file.as_file().sync_all()?;
187    file.persist(dir.join("connection.json"))
188        .map_err(|e| e.error)?;
189    Ok(())
190}
191
192fn arguments_match(args: &[OsString], profile: &Path, port: u16) -> bool {
193    let mut expected = OsString::from("--user-data-dir=");
194    expected.push(profile);
195    let debug = OsString::from(format!("--remote-debugging-port={port}"));
196    args.iter()
197        .filter(|a| a.to_string_lossy().starts_with("--user-data-dir"))
198        .count()
199        == 1
200        && args.contains(&expected)
201        && args
202            .iter()
203            .filter(|a| a.to_string_lossy().starts_with("--remote-debugging-port"))
204            .count()
205            == 1
206        && args.contains(&debug)
207        && !args
208            .iter()
209            .any(|a| a == "--remote-debugging-pipe" || a.to_string_lossy().starts_with("--type="))
210}
211
212fn owned(state: &State, dir: &Path) -> bool {
213    if validate_directory(dir).is_err() || validate_directory(&profile(dir)).is_err() {
214        return false;
215    }
216    state.owner.as_ref().is_some_and(|owner| {
217        system::process(owner.pid).is_ok_and(|(identity, args)| {
218            identity == *owner && arguments_match(&args, &profile(dir), state.port)
219        })
220    })
221}
222
223impl Verifier for BrowserSystem {
224    fn core_only(&self, port: u16) -> bool {
225        self.core
226            && directory(port)
227                .and_then(|d| load(&d, port))
228                .map_or(true, |state| state.is_none_or(|s| !s.visible))
229    }
230
231    async fn verify(&self, port: u16) -> Result<(ConnectionMetadata, Vec<Value>), AppError> {
232        prerequisites_for(self.core)?;
233        let mut metadata = ConnectionMetadata::empty(port);
234        let sockets = system::listeners(port)?;
235        if sockets.is_empty() {
236            return Ok((metadata, vec![]));
237        }
238        metadata.loopback_only = Some(sockets.iter().all(|s| s.loopback));
239        if metadata.loopback_only != Some(true) {
240            metadata.status = ProbeStatus::UnsafeListenerRefused;
241            return Ok((metadata, vec![]));
242        }
243        let dir = directory(port)?;
244        let state = load(&dir, port)?;
245        let trusted = state.as_ref().is_some_and(|s| {
246            s.core == self.core
247                && owned(s, &dir)
248                && s.owner.as_ref().is_some_and(|o| {
249                    session_executable(s.core, s.visible).is_ok_and(|e| e == o.executable)
250                        && system::owns_sockets(o.pid, &sockets)
251                })
252                && owned(s, &dir)
253        });
254        metadata.teams_owned = Some(trusted);
255        if !trusted {
256            metadata.status = ProbeStatus::NonTeamsListenerRefused;
257            return Ok((metadata, vec![]));
258        }
259        let targets = super::fill_metadata(&mut metadata).await;
260        if state
261            .as_ref()
262            .is_some_and(|s| s.core && !s.visible && s.phase != "core_ready")
263        {
264            return Ok((metadata, vec![]));
265        }
266        Ok((metadata, targets))
267    }
268}
269
270fn launch_args(
271    dir: &Path,
272    port: u16,
273    account: DesktopAccount,
274    visible: bool,
275    core: bool,
276) -> Vec<OsString> {
277    let mut data = OsString::from("--user-data-dir=");
278    data.push(profile(dir));
279    let mut args = vec![
280        data,
281        format!("--remote-debugging-port={port}").into(),
282        "--remote-debugging-address=127.0.0.1".into(),
283        "--no-first-run".into(),
284        "--no-default-browser-check".into(),
285    ];
286    if core && cfg!(target_os = "macos") {
287        args.push("--use-mock-keychain".into());
288    }
289    #[cfg(target_os = "linux")]
290    if visible && std::env::var_os("WAYLAND_DISPLAY").is_some() {
291        args.push("--ozone-platform-hint=auto".into());
292    }
293    if !visible {
294        args.push("--headless=new".into());
295        if core {
296            args.push("--disable-gpu".into());
297            args.push("--js-flags=--lite-mode --optimize-for-size".into());
298        }
299    }
300    let url = if account == DesktopAccount::Personal {
301        "https://teams.live.com"
302    } else {
303        "https://teams.cloud.microsoft"
304    };
305    args.push(if core && !visible {
306        "about:blank".into()
307    } else {
308        format!("--app={url}").into()
309    });
310    args
311}
312
313struct Guard(File);
314impl Drop for Guard {
315    fn drop(&mut self) {
316        let _ = self.0.unlock();
317    }
318}
319
320pub(super) fn prepare_directory(dir: &Path) -> Result<(), AppError> {
321    use std::os::unix::fs::DirBuilderExt;
322    std::fs::DirBuilder::new()
323        .recursive(true)
324        .mode(0o700)
325        .create(dir)?;
326    validate_directory(dir)
327}
328
329pub(super) fn validate_directory(dir: &Path) -> Result<(), AppError> {
330    use std::os::unix::fs::MetadataExt;
331    let metadata = std::fs::symlink_metadata(dir)?;
332    let me = system::uid()?;
333    if !metadata.is_dir() || metadata.uid() != me || metadata.mode() & 0o077 != 0 {
334        return Err(failed(
335            "the dedicated browser directory must be owned by you with permissions 0700",
336        ));
337    }
338    Ok(())
339}
340
341/// Browser sessions persist independently of the CLI. Sign-in data survives disconnect.
342// Separate orchestration from OS/browser operations so the visibility transition
343// and failure behavior can be tested without a signed-in Microsoft account.
344trait LoginSession {
345    async fn connect(&mut self, visible: bool) -> Result<(), AppError>;
346    async fn disconnect(&mut self) -> Result<(), AppError>;
347    async fn wait_read(&mut self, timeout: Duration) -> Result<(), AppError>;
348}
349
350async fn login_flow(session: &mut impl LoginSession) -> Result<(), AppError> {
351    session.connect(true).await?;
352    session.wait_read(Duration::from_secs(300)).await.map_err(|error| error.with_hint(
353        "The visible browser and its profile were preserved. Finish signing in and retry the sign-in command."))?;
354    session.disconnect().await?;
355    session.connect(false).await?;
356    session.wait_read(Duration::from_secs(60)).await.map_err(|error| error.with_hint(
357        "The headless browser and saved profile were preserved. Retry the sign-in command to open the visible window again."))
358}
359
360struct BrowserLogin {
361    port: u16,
362    account: DesktopAccount,
363    core: bool,
364}
365
366impl LoginSession for BrowserLogin {
367    async fn connect(&mut self, visible: bool) -> Result<(), AppError> {
368        // An existing session can be reused in its current mode. A mode change
369        // closes only the verified managed browser and keeps its profile.
370        let dir = directory(self.port)?;
371        if let Some(state) = load(&dir, self.port)?
372            && state.core == self.core
373            && !owned(&state, &dir)
374            && system::listeners(self.port)?.is_empty()
375        {
376            manage(self.port, self.account, Action::Recover, false, self.core).await?;
377        }
378        if let Some(state) = load(&dir, self.port)?
379            && state.visible != visible
380        {
381            self.disconnect().await?;
382        }
383        manage(self.port, self.account, Action::Connect, visible, self.core).await?;
384        Ok(())
385    }
386
387    async fn disconnect(&mut self) -> Result<(), AppError> {
388        manage(
389            self.port,
390            self.account,
391            Action::Disconnect,
392            false,
393            self.core,
394        )
395        .await?;
396        Ok(())
397    }
398
399    async fn wait_read(&mut self, timeout: Duration) -> Result<(), AppError> {
400        tokio::time::timeout(timeout, async {
401            loop {
402                // Fresh verification on every attempt, including during navigation.
403                let mut session = super::session::Session::new(BrowserSystem { core: self.core });
404                let mut request = super::read_request(self.port, None, 1, None)?;
405                request["account_type"] = json!(self.account);
406                let reply = session.read(&request).await?;
407                if login_read_ready(&reply)? {
408                    return Ok(());
409                }
410                tokio::time::sleep(Duration::from_secs(1)).await;
411            }
412        })
413        .await
414        .map_err(|_| failed("Timed out waiting for a readable Teams chat cache"))?
415    }
416}
417
418fn login_read_ready(reply: &Value) -> Result<bool, AppError> {
419    if matches!(reply["error"].as_str(), Some("no_target" | "read_failed")) {
420        return Ok(false);
421    }
422    // The cache is not yet available during sign-in and initial Teams loading.
423    // Ownership/origin/account refusals still fail immediately.
424    if reply["result"].get("exceptionDetails").is_some() {
425        return Ok(false);
426    }
427    if let Some(raw) = reply["result"]["result"]["value"].as_str()
428        && let Ok(value) = serde_json::from_str::<Value>(raw)
429        && matches!(value["error"].as_str(), Some("layout" | "core_not_ready"))
430    {
431        return Ok(false);
432    }
433    super::read_page(reply).map(|_| true)
434}
435
436pub async fn login(port: u16, account: DesktopAccount, core: bool) -> Result<Value, AppError> {
437    login_with_progress(port, account, core, false).await
438}
439
440pub async fn login_with_progress(
441    port: u16,
442    account: DesktopAccount,
443    core: bool,
444    quiet: bool,
445) -> Result<Value, AppError> {
446    #[cfg(target_os = "linux")]
447    if std::env::var_os("DISPLAY").is_none() && std::env::var_os("WAYLAND_DISPLAY").is_none() {
448        return Err(failed(
449            "Visible sign-in needs a display. On WSL2, enable WSLg; on Linux, run from a desktop session.",
450        ));
451    }
452    if core {
453        super::browser_install::ensure_with_progress(quiet).await?;
454    }
455    prerequisites_for(core)?;
456    let dir = directory(port)?;
457    prepare_directory(&dir)?;
458    let lock = OpenOptions::new()
459        .create(true)
460        .truncate(false)
461        .read(true)
462        .write(true)
463        .mode(0o600)
464        .open(dir.join("login.lock"))?;
465    lock.try_lock()
466        .map_err(|_| failed("another browser login is running"))?;
467    let _guard = Guard(lock);
468    if !quiet {
469        eprintln!(
470            "Opening Teams sign-in. Complete sign-in in the window; it will hide automatically once your chats are ready."
471        );
472    }
473    login_flow(&mut BrowserLogin {
474        port,
475        account,
476        core,
477    })
478    .await.map_err(|error| 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" })))?;
479    let mut value = manage(port, account, Action::Status, false, core).await?;
480    value["session_verified"] = json!(true);
481    Ok(value)
482}
483
484pub async fn manage(
485    port: u16,
486    account: DesktopAccount,
487    action: Action,
488    visible: bool,
489    core: bool,
490) -> Result<Value, AppError> {
491    if core && matches!(action, Action::Connect) {
492        super::browser_install::ensure().await?;
493    }
494    prerequisites_for(core)?;
495    let dir = directory(port)?;
496    prepare_directory(&dir)?;
497    prepare_directory(&profile(&dir))?;
498    let lock = OpenOptions::new()
499        .create(true)
500        .truncate(false)
501        .read(true)
502        .write(true)
503        .mode(0o600)
504        .open(dir.join("connection.lock"))?;
505    lock.try_lock()
506        .map_err(|_| failed("another browser connection command is running"))?;
507    let _guard = Guard(lock);
508    let mut state = load(&dir, port)?;
509    let system = BrowserSystem { core };
510    if state.as_ref().is_some_and(|s| s.core != core) {
511        return Err(failed(
512            "this port uses another browser mode; use its original --desktop-client to disconnect it first",
513        ));
514    }
515    match action {
516        Action::Status => {}
517        Action::Connect => {
518            if core {
519                validate_core(account)?;
520            }
521            // A dead/reused owner with an empty port can be forgotten without
522            // touching a process. Unrecorded startups and occupied ports still refuse.
523            if state
524                .as_ref()
525                .is_some_and(|s| s.owner.is_some() && !owned(s, &dir))
526                && system::listeners(port)?.is_empty()
527            {
528                std::fs::remove_file(dir.join("connection.json"))?;
529                state = None;
530            }
531            if let Some(current) = state.as_ref() {
532                if !owned(current, &dir) {
533                    return Err(failed(
534                        "the saved browser session is no longer owned; run desktop recover --desktop-client browser before connecting",
535                    ));
536                }
537                if current.visible != visible {
538                    return Err(failed(
539                        "disconnect the browser before changing between visible and headless mode",
540                    ));
541                }
542            } else {
543                let (metadata, _) = system.verify(port).await?;
544                if metadata.status != ProbeStatus::NoListener {
545                    return Err(failed(
546                        "the browser debugging port is already in use; its listener was left untouched",
547                    ));
548                }
549                let mut next = State {
550                    version: 1,
551                    port,
552                    owner: None,
553                    visible,
554                    core,
555                    phase: "starting".into(),
556                };
557                save(&dir, &next)?;
558                let mut child = Command::new(session_executable(core, visible)?).args(launch_args(&dir, port, account, visible, core))
559                    .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())
560                    .process_group(0).spawn().map_err(|error| failed(&format!("could not launch the Teams browser: {error}; run desktop recover --desktop-client browser")))?;
561                let pid = child.id();
562                // Capture the process after exec, then reap it without tying its life to the CLI.
563                let owner = system::process(pid).map(|(identity, _)| identity);
564                std::thread::spawn(move || {
565                    let _ = child.wait();
566                });
567                next.owner = Some(owner?);
568                save(&dir, &next)?;
569                state = Some(next);
570            }
571            let mut ready = false;
572            for _ in 0..100 {
573                let current = state.as_ref().expect("browser state");
574                if !owned(current, &dir) {
575                    break;
576                }
577                if system.probe(port).await?.endpoint_available() {
578                    ready = true;
579                    break;
580                }
581                tokio::time::sleep(Duration::from_millis(200)).await;
582            }
583            if !ready {
584                return Err(failed(
585                    "the browser did not open its debugging endpoint; its recovery record was preserved. Run desktop status/recover --desktop-client browser",
586                ));
587            }
588            let current = state.as_mut().expect("browser state");
589            if core && !visible && current.phase != "core_ready" {
590                let targets = super::fill_metadata(&mut ConnectionMetadata::empty(port)).await;
591                let pages: Vec<_> = targets.iter().filter(|t| t["type"] == "page").collect();
592                let target = pages
593                    .first()
594                    .filter(|_| pages.len() == 1)
595                    .and_then(|t| t["id"].as_str().filter(|id| super::valid_id(id)))
596                    .ok_or_else(|| {
597                        failed("core startup requires exactly one managed browser page")
598                    })?;
599                super::browser_core::start(port, target).await?;
600                current.phase = "core_ready".into();
601            } else if !core || visible {
602                current.phase = "active".into();
603            }
604            save(&dir, current)?;
605        }
606        Action::Disconnect | Action::Recover => {
607            if matches!(action, Action::Recover)
608                && let Some(current) = state.as_mut()
609                && current.owner.is_none()
610            {
611                let expected = session_executable(current.core, current.visible)?;
612                let mut candidates = system::pids()?
613                    .into_iter()
614                    .filter_map(|pid| system::process(pid).ok())
615                    .filter(|(identity, args)| {
616                        identity.executable == expected
617                            && arguments_match(args, &profile(&dir), port)
618                    });
619                if let Some((identity, _)) = candidates.next() {
620                    if candidates.next().is_some() {
621                        return Err(failed(
622                            "multiple browser processes match the interrupted startup; they were left untouched",
623                        ));
624                    }
625                    current.owner = Some(identity);
626                    save(&dir, current)?;
627                }
628            }
629            if let Some(current) = state.as_ref() {
630                if owned(current, &dir) {
631                    let (metadata, _) = system.verify(port).await?;
632                    if !metadata.endpoint_available() {
633                        return Err(failed(
634                            "browser ownership could not be verified; close the dedicated browser manually, then run desktop recover --desktop-client browser",
635                        ));
636                    }
637                    let (version, _) = super::read_metadata(port).await?;
638                    let id = browser_id(&version, port)?;
639                    if !owned(current, &dir) {
640                        return Err(failed("the browser process changed; it was left untouched"));
641                    }
642                    Cdp::close_browser(port, &id).await?;
643                    for _ in 0..50 {
644                        if !owned(current, &dir) {
645                            break;
646                        }
647                        tokio::time::sleep(Duration::from_millis(100)).await;
648                    }
649                    if owned(current, &dir) {
650                        return Err(failed(
651                            "the browser has not closed; its recovery record was preserved",
652                        ));
653                    }
654                } else if current.owner.is_some() {
655                    // A reused or changed PID is never contacted or signalled. Recover
656                    // clears stale state only when there is no listener left on its port.
657                    if !matches!(action, Action::Recover) || !system::listeners(port)?.is_empty() {
658                        return Err(failed(
659                            "the saved browser process changed; it was left untouched. Close the dedicated browser, then recover",
660                        ));
661                    }
662                } else if std::fs::symlink_metadata(profile(&dir).join("SingletonLock")).is_ok()
663                    || !system::listeners(port)?.is_empty()
664                {
665                    return Err(failed(
666                        "browser startup was interrupted before ownership was saved; close the dedicated browser manually, then recover",
667                    ));
668                }
669                std::fs::remove_file(dir.join("connection.json"))?;
670                state = None;
671            } else if !system::listeners(port)?.is_empty() {
672                return Err(failed(
673                    "this browser is not managed by teams-cli; it was left untouched",
674                ));
675            }
676        }
677    }
678    let metadata = system.probe(port).await?;
679    let live = state.as_ref().is_some_and(|s| owned(s, &dir));
680    Ok(
681        json!({"port":port,"endpoint_available":metadata.endpoint_available(),"session_verified":false,
682        "connection":metadata,"browser_profile":profile(&dir),
683        "management":{"managed":state.is_some(),"port":state.as_ref().map(|s|s.port),
684        "phase":state.as_ref().map(|s|s.phase.as_str()).unwrap_or("idle"),
685        "visible":state.as_ref().map(|s|s.visible),"core_only":core,"settings_pending":false,
686        "needs_recovery":state.as_ref().is_some_and(|s|!matches!(s.phase.as_str(), "active" | "core_ready") || !live)}}),
687    )
688}
689
690impl BrowserSystem {
691    async fn probe(&self, port: u16) -> Result<ConnectionMetadata, AppError> {
692        Ok(self.verify(port).await?.0)
693    }
694}
695
696fn browser_id(version: &Value, port: u16) -> Result<String, AppError> {
697    let url = version["webSocketDebuggerUrl"]
698        .as_str()
699        .and_then(|s| url::Url::parse(s).ok())
700        .ok_or_else(|| failed("the browser did not advertise a valid control socket"))?;
701    let id = url.path().strip_prefix("/devtools/browser/").unwrap_or("");
702    if url.scheme() != "ws"
703        || !matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "[::1]"))
704        || url.port() != Some(port)
705        || !url.username().is_empty()
706        || url.password().is_some()
707        || url.query().is_some()
708        || url.fragment().is_some()
709        || !(8..=64).contains(&id.len())
710        || !id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-')
711    {
712        return Err(failed("the browser advertised an unsafe control socket"));
713    }
714    Ok(id.into())
715}
716
717#[cfg(target_os = "linux")]
718mod system {
719    use super::*;
720    use std::{collections::HashSet, os::unix::fs::MetadataExt};
721    #[derive(Debug)]
722    pub struct Socket {
723        pub loopback: bool,
724        inode: u64,
725        uid: u32,
726    }
727
728    pub fn parse_sockets(text: &str, port: u16, ipv6: bool) -> Result<Vec<Socket>, AppError> {
729        let mut sockets = vec![];
730        for row in text.lines().skip(1) {
731            let fields: Vec<_> = row.split_whitespace().collect();
732            if fields.len() < 10 {
733                return Err(failed("invalid Linux socket metadata"));
734            }
735            let (address, number) = fields[1]
736                .split_once(':')
737                .ok_or_else(|| failed("invalid Linux socket address"))?;
738            let number =
739                u16::from_str_radix(number, 16).map_err(|_| failed("invalid Linux socket port"))?;
740            if number != port || fields[3] != "0A" {
741                continue;
742            }
743            sockets.push(Socket {
744                loopback: address
745                    == if ipv6 {
746                        "00000000000000000000000001000000"
747                    } else {
748                        "0100007F"
749                    },
750                uid: fields[7]
751                    .parse()
752                    .map_err(|_| failed("invalid Linux socket owner"))?,
753                inode: fields[9]
754                    .parse()
755                    .map_err(|_| failed("invalid Linux socket inode"))?,
756            });
757        }
758        Ok(sockets)
759    }
760    pub fn listeners(port: u16) -> Result<Vec<Socket>, AppError> {
761        let mut sockets = parse_sockets(&std::fs::read_to_string("/proc/net/tcp")?, port, false)?;
762        match std::fs::read_to_string("/proc/net/tcp6") {
763            Ok(text) => sockets.extend(parse_sockets(&text, port, true)?),
764            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
765            Err(e) => return Err(e.into()),
766        }
767        Ok(sockets)
768    }
769    pub fn process(pid: u32) -> Result<(Identity, Vec<OsString>), AppError> {
770        let dir = PathBuf::from(format!("/proc/{pid}"));
771        let uid = std::fs::metadata("/proc/self")?.uid();
772        if std::fs::metadata(&dir)?.uid() != uid {
773            return Err(failed("browser belongs to another user"));
774        }
775        let before = std::fs::read_to_string(dir.join("stat"))?;
776        let args = std::fs::read(dir.join("cmdline"))?
777            .split(|b| *b == 0)
778            .filter(|a| !a.is_empty())
779            .map(|a| OsString::from_vec(a.into()))
780            .collect();
781        let exe = std::fs::read_link(dir.join("exe"))?;
782        let after = std::fs::read_to_string(dir.join("stat"))?;
783        let started = start_time(&before)?;
784        if start_time(&after)? != started {
785            return Err(failed("browser process changed during verification"));
786        }
787        Ok((
788            Identity {
789                pid,
790                started,
791                executable: exe,
792            },
793            args,
794        ))
795    }
796    pub fn start_time(stat: &str) -> Result<String, AppError> {
797        stat.rsplit_once(") ")
798            .and_then(|(_, rest)| rest.split_whitespace().nth(19))
799            .filter(|s| s.bytes().all(|b| b.is_ascii_digit()))
800            .map(String::from)
801            .ok_or_else(|| failed("invalid Linux process identity"))
802    }
803    pub fn uid() -> Result<u32, AppError> {
804        Ok(std::fs::metadata("/proc/self")?.uid())
805    }
806    pub fn pids() -> Result<Vec<u32>, AppError> {
807        Ok(std::fs::read_dir("/proc")?
808            .filter_map(Result::ok)
809            .filter_map(|e| e.file_name().to_str()?.parse().ok())
810            .collect())
811    }
812    pub fn owns_sockets(pid: u32, sockets: &[Socket]) -> bool {
813        let Ok(fds) = std::fs::read_dir(format!("/proc/{pid}/fd")) else {
814            return false;
815        };
816        let inodes: HashSet<_> = fds
817            .filter_map(Result::ok)
818            .filter_map(|e| std::fs::read_link(e.path()).ok())
819            .filter_map(|p| {
820                p.to_str()?
821                    .strip_prefix("socket:[")?
822                    .strip_suffix(']')?
823                    .parse::<u64>()
824                    .ok()
825            })
826            .collect();
827        let Ok(me) = std::fs::metadata("/proc/self") else {
828            return false;
829        };
830        sockets
831            .iter()
832            .all(|s| s.uid == me.uid() && inodes.contains(&s.inode))
833    }
834}
835
836#[cfg(target_os = "macos")]
837mod system {
838    use super::*;
839    #[derive(Debug)]
840    pub struct Socket {
841        pub loopback: bool,
842        pid: u32,
843    }
844    #[link(name = "proc")]
845    unsafe extern "C" {
846        fn proc_pidpath(pid: libc::c_int, buffer: *mut libc::c_void, size: u32) -> libc::c_int;
847    }
848    fn run(path: &str, args: &[&str]) -> Result<String, AppError> {
849        let output = Command::new(path)
850            .args(args)
851            .stdin(Stdio::null())
852            .output()?;
853        if !output.status.success() {
854            return Err(failed("macOS browser process inspection failed"));
855        }
856        String::from_utf8(output.stdout).map_err(|_| failed("invalid macOS process metadata"))
857    }
858    pub fn listeners(port: u16) -> Result<Vec<Socket>, AppError> {
859        let output = Command::new("/usr/sbin/lsof")
860            .args(["-nP", &format!("-iTCP:{port}"), "-sTCP:LISTEN", "-Fpn"])
861            .stdin(Stdio::null())
862            .output()?;
863        if !output.status.success() && output.status.code() != Some(1) {
864            return Err(failed("browser listener inspection failed"));
865        }
866        super::super::macos::parse_listeners(&String::from_utf8_lossy(&output.stdout)).map(|list| {
867            list.into_iter()
868                .map(|(pid, address)| Socket {
869                    pid,
870                    loopback: super::super::macos::loopback_address(&address, port),
871                })
872                .collect()
873        })
874    }
875    pub fn process(pid: u32) -> Result<(Identity, Vec<OsString>), AppError> {
876        let snapshot = run("/bin/ps", &["-p", &pid.to_string(), "-o", "uid=,lstart="])?;
877        let (uid, stamp) = snapshot
878            .trim()
879            .split_once(char::is_whitespace)
880            .ok_or_else(|| failed("invalid browser process metadata"))?;
881        // These OS queries do not inspect browser credentials or page data.
882        if uid.parse::<u32>().ok() != Some(unsafe { libc::geteuid() }) {
883            return Err(failed("browser belongs to another user"));
884        }
885        let mut path = vec![0u8; 4096];
886        let n = unsafe { proc_pidpath(pid as i32, path.as_mut_ptr().cast(), path.len() as u32) };
887        if n <= 0 {
888            return Err(failed("could not inspect the browser executable"));
889        }
890        let executable = PathBuf::from(OsString::from_vec(
891            path.split(|b| *b == 0).next().unwrap_or_default().into(),
892        ));
893        let mut mib = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid as i32];
894        let mut bytes = vec![0u8; 1024 * 1024];
895        let mut len = bytes.len();
896        if unsafe {
897            libc::sysctl(
898                mib.as_mut_ptr(),
899                3,
900                bytes.as_mut_ptr().cast(),
901                &mut len,
902                std::ptr::null_mut(),
903                0,
904            )
905        } != 0
906        {
907            return Err(failed("could not inspect the browser launch arguments"));
908        }
909        bytes.truncate(len);
910        let args = parse_args(&bytes)?;
911        if run("/bin/ps", &["-p", &pid.to_string(), "-o", "uid=,lstart="])? != snapshot {
912            return Err(failed("browser process changed"));
913        }
914        Ok((
915            Identity {
916                pid,
917                started: stamp.trim().into(),
918                executable,
919            },
920            args,
921        ))
922    }
923    pub fn parse_args(bytes: &[u8]) -> Result<Vec<OsString>, AppError> {
924        let count = bytes
925            .get(..4)
926            .map(|b| i32::from_ne_bytes(b.try_into().expect("four bytes")))
927            .filter(|n| *n > 0 && *n <= 4096)
928            .ok_or_else(|| failed("invalid browser arguments"))? as usize;
929        let end = bytes[4..]
930            .iter()
931            .position(|b| *b == 0)
932            .ok_or_else(|| failed("invalid browser arguments"))?
933            + 4;
934        let args = bytes[end..]
935            .split(|b| *b == 0)
936            .skip_while(|a| a.is_empty())
937            .take(count)
938            .map(|a| OsString::from_vec(a.into()))
939            .collect::<Vec<_>>();
940        if args.len() != count {
941            return Err(failed("invalid browser arguments"));
942        }
943        Ok(args)
944    }
945    pub fn uid() -> Result<u32, AppError> {
946        Ok(unsafe { libc::geteuid() })
947    }
948    pub fn pids() -> Result<Vec<u32>, AppError> {
949        Ok(run("/bin/ps", &["-axo", "pid="])?
950            .lines()
951            .filter_map(|line| line.trim().parse().ok())
952            .collect())
953    }
954    pub fn owns_sockets(pid: u32, sockets: &[Socket]) -> bool {
955        sockets.iter().all(|s| s.pid == pid)
956    }
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962
963    #[derive(Default)]
964    struct FakeLogin {
965        events: Vec<&'static str>,
966        fail_visible: bool,
967        fail_headless: bool,
968        fail_close: bool,
969    }
970    impl LoginSession for FakeLogin {
971        async fn connect(&mut self, visible: bool) -> Result<(), AppError> {
972            self.events
973                .push(if visible { "visible" } else { "headless" });
974            Ok(())
975        }
976        async fn disconnect(&mut self) -> Result<(), AppError> {
977            self.events.push("close");
978            if self.fail_close {
979                return Err(failed("ownership changed"));
980            }
981            Ok(())
982        }
983        async fn wait_read(&mut self, timeout: Duration) -> Result<(), AppError> {
984            let visible = timeout.as_secs() == 300;
985            self.events.push(if visible {
986                "read-visible"
987            } else {
988                "read-headless"
989            });
990            if (visible && self.fail_visible) || (!visible && self.fail_headless) {
991                return Err(failed("not signed in"));
992            }
993            Ok(())
994        }
995    }
996
997    #[tokio::test]
998    async fn login_verifies_reads_before_and_after_hiding() {
999        let mut session = FakeLogin::default();
1000        login_flow(&mut session).await.unwrap();
1001        assert_eq!(
1002            session.events,
1003            [
1004                "visible",
1005                "read-visible",
1006                "close",
1007                "headless",
1008                "read-headless"
1009            ]
1010        );
1011    }
1012
1013    #[tokio::test]
1014    async fn failed_visible_login_keeps_window_open() {
1015        let mut session = FakeLogin {
1016            fail_visible: true,
1017            ..Default::default()
1018        };
1019        let error = login_flow(&mut session).await.unwrap_err();
1020        assert!(
1021            error
1022                .to_string()
1023                .contains("visible browser and its profile were preserved")
1024        );
1025        assert_eq!(session.events, ["visible", "read-visible"]);
1026    }
1027
1028    #[tokio::test]
1029    async fn headless_sign_in_is_verified_before_success() {
1030        let mut session = FakeLogin {
1031            fail_headless: true,
1032            ..Default::default()
1033        };
1034        let error = login_flow(&mut session).await.unwrap_err();
1035        assert!(error.to_string().contains("Retry the sign-in command"));
1036        assert_eq!(session.events.last(), Some(&"read-headless"));
1037    }
1038
1039    #[tokio::test]
1040    async fn failed_verified_close_prevents_headless_restart() {
1041        let mut session = FakeLogin {
1042            fail_close: true,
1043            ..Default::default()
1044        };
1045        assert!(login_flow(&mut session).await.is_err());
1046        assert_eq!(session.events, ["visible", "read-visible", "close"]);
1047    }
1048
1049    #[test]
1050    fn login_retries_loading_but_refuses_unsafe_or_ambiguous_sessions() {
1051        for code in ["no_target", "read_failed"] {
1052            assert!(!login_read_ready(&json!({"error":code})).unwrap());
1053        }
1054        assert!(!login_read_ready(&json!({"result":{"exceptionDetails":{}}})).unwrap());
1055        for code in ["account_ambiguous", "not_loopback", "metadata_probe_failed"] {
1056            assert!(login_read_ready(&json!({"error":code})).is_err());
1057        }
1058        assert!(
1059            login_read_ready(
1060                &json!({"result":{"result":{"value":"{\"items\":[],\"next\":null}"}}})
1061            )
1062            .unwrap()
1063        );
1064    }
1065
1066    #[test]
1067    fn browser_directories_refuse_shared_permissions_and_symlinks() {
1068        use std::os::unix::fs::{PermissionsExt, symlink};
1069        let temp = tempfile::tempdir().unwrap();
1070        let private = temp.path().join("private");
1071        prepare_directory(&private).unwrap();
1072        validate_directory(&private).unwrap();
1073        let link = temp.path().join("link");
1074        symlink(&private, &link).unwrap();
1075        assert!(prepare_directory(&link).is_err());
1076        std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o755)).unwrap();
1077        assert!(validate_directory(&private).is_err());
1078    }
1079
1080    #[test]
1081    fn core_launch_preserves_sign_in_and_skips_ui_startup() {
1082        let dir = Path::new("/tmp/core profile");
1083        let args = launch_args(dir, 9333, DesktopAccount::Personal, false, true);
1084        assert!(args.contains(&OsString::from("about:blank")));
1085        assert!(args.contains(&OsString::from(
1086            "--js-flags=--lite-mode --optimize-for-size"
1087        )));
1088        assert!(arguments_match(&args, &profile(dir), 9333));
1089        let visible = launch_args(dir, 9333, DesktopAccount::Personal, true, true);
1090        assert!(!visible.contains(&OsString::from("--headless=new")));
1091        if cfg!(target_os = "macos") {
1092            assert!(visible.contains(&OsString::from("--use-mock-keychain")));
1093        }
1094    }
1095
1096    #[test]
1097    fn launch_is_isolated_and_headless_unless_signing_in() {
1098        let dir = Path::new("/tmp/teams profile");
1099        let headless = launch_args(dir, 9333, DesktopAccount::Personal, false, false);
1100        assert!(headless.contains(&OsString::from("--headless=new")));
1101        assert!(headless.contains(&OsString::from("--app=https://teams.live.com")));
1102        assert!(arguments_match(&headless, &profile(dir), 9333));
1103        assert!(!arguments_match(
1104            &headless,
1105            Path::new("/tmp/normal-browser"),
1106            9333
1107        ));
1108        assert!(!arguments_match(&headless, &profile(dir), 9222));
1109        let visible = launch_args(dir, 9333, DesktopAccount::WorkSchool, true, false);
1110        assert!(!visible.contains(&OsString::from("--headless=new")));
1111        assert!(visible.contains(&OsString::from("--app=https://teams.cloud.microsoft")));
1112        let mut conflicting = headless.clone();
1113        conflicting.push("--user-data-dir=/tmp/normal-browser".into());
1114        assert!(!arguments_match(&conflicting, &profile(dir), 9333));
1115        let mut renderer = headless;
1116        renderer.push("--type=renderer".into());
1117        assert!(!arguments_match(&renderer, &profile(dir), 9333));
1118    }
1119
1120    #[test]
1121    fn browser_control_socket_never_follows_an_advertised_remote_host() {
1122        let socket = |url: &str| json!({"webSocketDebuggerUrl":url});
1123        assert_eq!(
1124            browser_id(
1125                &socket("ws://localhost:9333/devtools/browser/abcdef12-3456"),
1126                9333
1127            )
1128            .unwrap(),
1129            "abcdef12-3456"
1130        );
1131        for url in [
1132            "ws://example.com:9333/devtools/browser/abcdef12",
1133            "ws://127.0.0.1:9222/devtools/browser/abcdef12",
1134            "ws://user@127.0.0.1:9333/devtools/browser/abcdef12",
1135            "ws://127.0.0.1:9333/devtools/page/abcdef12",
1136            "ws://127.0.0.1:9333/devtools/browser/../../x",
1137            "ws://127.0.0.1:9333/devtools/browser/abcdef12?secret=x",
1138        ] {
1139            assert!(browser_id(&socket(url), 9333).is_err(), "{url}");
1140        }
1141    }
1142
1143    #[test]
1144    fn corrupt_or_wrong_port_state_is_preserved() {
1145        let dir = tempfile::tempdir().unwrap();
1146        let path = dir.path().join("connection.json");
1147        std::fs::write(&path, b"corrupt").unwrap();
1148        assert!(load(dir.path(), 9333).is_err());
1149        assert_eq!(std::fs::read(&path).unwrap(), b"corrupt");
1150        let state = State {
1151            version: 1,
1152            port: 9333,
1153            owner: None,
1154            visible: true,
1155            core: false,
1156            phase: "starting".into(),
1157        };
1158        save(dir.path(), &state).unwrap();
1159        assert!(load(dir.path(), 9222).is_err());
1160        assert!(load(dir.path(), 9333).unwrap().is_some());
1161    }
1162
1163    #[cfg(target_os = "linux")]
1164    #[test]
1165    fn linux_sockets_include_unsafe_and_foreign_listeners() {
1166        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";
1167        let sockets = system::parse_sockets(table, 9333, false).unwrap();
1168        assert_eq!(sockets.len(), 2);
1169        assert!(sockets[0].loopback);
1170        assert!(!sockets[1].loopback);
1171        assert!(system::parse_sockets("header\nbroken", 9333, false).is_err());
1172        let v6 = "header\n 0: 00000000000000000000000001000000:2475 0:0 0A 0:0 0:0 0 1000 0 42\n";
1173        assert!(system::parse_sockets(v6, 9333, true).unwrap()[0].loopback);
1174    }
1175
1176    #[cfg(target_os = "linux")]
1177    #[test]
1178    fn linux_identity_and_live_socket_ownership_are_checked() {
1179        let pid = std::process::id();
1180        let (identity, _) = system::process(pid).unwrap();
1181        assert_eq!(identity.executable, std::env::current_exe().unwrap());
1182        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1183        let sockets = system::listeners(listener.local_addr().unwrap().port()).unwrap();
1184        assert!(!sockets.is_empty());
1185        assert!(system::owns_sockets(pid, &sockets));
1186        assert!(!system::owns_sockets(0, &sockets));
1187        assert!(system::start_time("broken stat").is_err());
1188        // Command names can contain ')' and spaces; field positions start after the last ') '.
1189        let stat = format!("1 (browser ) name) S {} 1234 0", "0 ".repeat(18));
1190        assert_eq!(system::start_time(&stat).unwrap(), "1234");
1191    }
1192
1193    #[cfg(target_os = "macos")]
1194    #[test]
1195    fn macos_launch_arguments_keep_paths_with_spaces_and_exclude_environment() {
1196        let args = [
1197            b"Chrome".as_slice(),
1198            b"--user-data-dir=/tmp/teams profile",
1199            b"--remote-debugging-port=9333",
1200        ];
1201        let mut bytes = (args.len() as i32).to_ne_bytes().to_vec();
1202        bytes.extend_from_slice(b"/Applications/Google Chrome\0\0\0");
1203        for arg in args {
1204            bytes.extend_from_slice(arg);
1205            bytes.push(0);
1206        }
1207        bytes.extend_from_slice(b"SECRET=not-an-argument\0");
1208        let parsed = system::parse_args(&bytes).unwrap();
1209        assert_eq!(parsed.len(), 3);
1210        assert!(arguments_match(
1211            &parsed,
1212            Path::new("/tmp/teams profile"),
1213            9333
1214        ));
1215        assert!(system::parse_args(b"bad").is_err());
1216    }
1217}