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