Skip to main content

agentmux_srv/backend/
tower_agentmux.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Tower: what each of AgentMux's own processes is — "GPU", "Renderer",
5//! "Network service", "Server", "Launcher" — so the AgentMux task's rows say
6//! more than one executable name repeated a dozen times.
7//!
8//! Every CEF subprocess runs the host executable, so its name can't tell them
9//! apart (on Windows every one reads "AgentMux"); its command line can, with
10//! `--type=` and `--utility-sub-type=`. A command line can hold secrets, so it
11//! is read here only to pick a label and never leaves this module. On macOS the
12//! helper apps are already named per type ("AgentMux Helper (GPU)"), which is
13//! the fallback when a command line can't be read.
14//!
15//! The window host and the launcher carry no `--type`; they are told apart by
16//! the tree: the host is the parent of CEF subprocesses, the launcher an
17//! ancestor of this backend.
18//!
19//! A renderer also says what it serves — a window, a browser pane, an idle
20//! pool window ([`renderer_serves`]). Only the window host knows that: each
21//! renderer reports its PID to it (`crates/cef/src/renderer_map.rs`), and
22//! Tower asks it for the map while sampling.
23
24use std::collections::{HashMap, HashSet};
25
26use agentmux_procstats::{ProcInfo, ProcKey};
27use serde::Deserialize;
28
29/// What a process's own command line (or, failing that, its name) says.
30#[derive(Debug, Clone, PartialEq)]
31enum Kind {
32    /// A CEF subprocess: its type.
33    Cef(String),
34    /// srv itself in another role (it re-runs its own binary).
35    Srv(&'static str),
36    /// No `--type`: the window host or the launcher, or neither.
37    Plain,
38    /// The OS wouldn't give the command line, and the name says nothing:
39    /// read again on later samples, up to [`READ_ATTEMPTS`] in all, since a
40    /// read can fail on a process that is still starting.
41    Unreadable(u8),
42}
43
44/// How many times a command line that couldn't be read is tried. A process
45/// the OS never lets us read (another user's) is given up on after that.
46const READ_ATTEMPTS: u8 = 5;
47
48/// Remembers each process's [`Kind`], so a command line is read once per
49/// process rather than once per sample. Forgets processes that are gone.
50#[derive(Debug, Default)]
51pub struct Describer {
52    kinds: HashMap<ProcKey, Kind>,
53}
54
55impl Describer {
56    /// A label for each of `members` (indices into `snap`) that has one.
57    /// `cmdline` reads a process's command line (`agentmux_procstats::command_line_of`).
58    pub fn describe(
59        &mut self,
60        snap: &[ProcInfo],
61        members: &[usize],
62        own_pid: u32,
63        cmdline: &dyn Fn(ProcKey) -> Option<String>,
64    ) -> HashMap<usize, String> {
65        let present: HashSet<ProcKey> = members.iter().map(|&i| snap[i].key()).collect();
66        self.kinds.retain(|k, _| present.contains(k));
67        for &i in members {
68            let p = &snap[i];
69            if p.pid == own_pid {
70                continue;
71            }
72            let attempts = match self.kinds.get(&p.key()) {
73                None => 0,
74                Some(Kind::Unreadable(n)) if *n < READ_ATTEMPTS => *n,
75                Some(_) => continue,
76            };
77            let kind = match kind_of(p, cmdline(p.key()).as_deref()) {
78                Kind::Unreadable(_) => Kind::Unreadable(attempts + 1),
79                known => known,
80            };
81            self.kinds.insert(p.key(), kind);
82        }
83
84        let by_pid: HashMap<u32, usize> = members.iter().map(|&i| (snap[i].pid, i)).collect();
85        let parent_of = |i: usize| {
86            let p = &snap[i];
87            p.ppid.and_then(|pp| by_pid.get(&pp).copied()).filter(|&q| p.is_child_of(&snap[q]))
88        };
89        // The window host: whatever CEF subprocesses hang off.
90        let hosts: HashSet<usize> = members
91            .iter()
92            .filter(|&&i| matches!(self.kinds.get(&snap[i].key()), Some(Kind::Cef(_))))
93            .filter_map(|&i| parent_of(i))
94            .collect();
95        // The launcher: this backend's AgentMux ancestors.
96        let mut ancestors = HashSet::new();
97        let mut at = by_pid.get(&own_pid).copied();
98        for _ in 0..16 {
99            let Some(parent) = at.and_then(parent_of) else { break };
100            ancestors.insert(parent);
101            at = Some(parent);
102        }
103
104        let mut out = HashMap::new();
105        for &i in members {
106            let p = &snap[i];
107            let label = if p.pid == own_pid {
108                Some("Server".to_string())
109            } else {
110                match self.kinds.get(&p.key()) {
111                    Some(Kind::Cef(t)) => Some(t.clone()),
112                    Some(Kind::Srv(role)) => Some(role.to_string()),
113                    _ if hosts.contains(&i) => Some("Main process".to_string()),
114                    _ if ancestors.contains(&i) => Some("Launcher".to_string()),
115                    _ => None,
116                }
117            };
118            if let Some(label) = label {
119                out.insert(i, label);
120            }
121        }
122        out
123    }
124}
125
126/// One browser and the renderer serving it, as the window host reports it
127/// (`crates/cef/src/renderer_map.rs`, `POST /agentmux/browser/renderer_map`).
128#[derive(Debug, Clone, PartialEq, Deserialize)]
129pub struct RendererEntry {
130    pub pid: u32,
131    pub label: String,
132    /// A browser pane's block id.
133    #[serde(default)]
134    pub block_id: Option<String>,
135    /// An unpromoted pool window, kept ready and not shown yet.
136    #[serde(default)]
137    pub pool: bool,
138    /// An app window's srv window id.
139    #[serde(default)]
140    pub window_id: Option<String>,
141}
142
143/// The host's `renderer_map` answer (`{ok, data: {renderers}}`) as entries;
144/// none for anything else, so an older host just leaves renderers unnamed.
145pub fn parse_renderer_map(answer: &serde_json::Value) -> Vec<RendererEntry> {
146    answer
147        .get("data")
148        .and_then(|d| d.get("renderers"))
149        .and_then(|r| serde_json::from_value(r.clone()).ok())
150        .unwrap_or_default()
151}
152
153/// What the renderer `pid` serves, for its row: "window Main", "browser pane
154/// github.com", "pool window (idle)", joined when one renderer serves several
155/// (Chromium may share one between same-site browsers). `page` names a
156/// browser pane by its block id, `workspace` a window by its srv id.
157pub fn renderer_serves(
158    entries: &[RendererEntry],
159    pid: u32,
160    page: &dyn Fn(&str) -> Option<String>,
161    workspace: &dyn Fn(&str) -> Option<String>,
162) -> Option<String> {
163    let mut names: Vec<String> = Vec::new();
164    for e in entries.iter().filter(|e| e.pid == pid) {
165        let name = if let Some(block_id) = &e.block_id {
166            page(block_id).map_or_else(|| "browser pane".to_string(), |p| format!("browser pane {p}"))
167        } else if e.pool {
168            "pool window (idle)".to_string()
169        } else {
170            e.window_id
171                .as_deref()
172                .and_then(workspace)
173                .map_or_else(|| "window".to_string(), |w| format!("window {w}"))
174        };
175        if !names.contains(&name) {
176            names.push(name);
177        }
178    }
179    (!names.is_empty()).then(|| names.join(", "))
180}
181
182/// A page URL's host, for a browser pane's renderer row: never its user,
183/// password, port, path or query (`https://user:pass@host:8080/x` → `host`).
184pub fn page_host(url: &str) -> Option<String> {
185    let parsed = url::Url::parse(url).ok()?;
186    parsed.host_str().filter(|h| !h.is_empty()).map(str::to_string)
187}
188
189/// The PID a process sees for itself, which is what a renderer reports.
190///
191/// On Linux, Chromium's namespace sandbox runs renderers in a PID namespace
192/// of their own, where `getpid()` is a small number (2, 3, …) unrelated to
193/// the host PID this backend lists. The kernel records every level in
194/// `/proc/<pid>/status` (`NSpid: <host> … <innermost>`), so a renderer row is
195/// looked up by the innermost one. AgentMux's renderers share one namespace
196/// per instance, and only this instance's processes are looked up, so the
197/// number is unique among them. Elsewhere there is no namespace: the PID.
198pub fn own_view_pid(pid: u32) -> u32 {
199    #[cfg(target_os = "linux")]
200    {
201        if let Some(inner) = std::fs::read_to_string(format!("/proc/{pid}/status"))
202            .ok()
203            .and_then(|s| innermost_ns_pid(&s))
204        {
205            return inner;
206        }
207    }
208    pid
209}
210
211/// The last PID on a `/proc/<pid>/status` `NSpid:` line.
212#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
213fn innermost_ns_pid(status: &str) -> Option<u32> {
214    status
215        .lines()
216        .find_map(|l| l.strip_prefix("NSpid:"))
217        .and_then(|rest| rest.split_whitespace().last())
218        .and_then(|p| p.parse().ok())
219}
220
221fn kind_of(p: &ProcInfo, cmdline: Option<&str>) -> Kind {
222    let Some(cmd) = cmdline else {
223        return mac_helper_type(&p.name).map_or(Kind::Unreadable(1), Kind::Cef);
224    };
225    let name = p.name.to_ascii_lowercase();
226    // The AgentMux group holds every unclaimed descendant of the launcher, not
227    // only AgentMux's binaries, so a `--type=` switch counts only on one of
228    // ours (the host exe, "agentmux-cef", or a macOS "AgentMux Helper").
229    if name.starts_with("agentmux") {
230        if let Some(ty) = switch(cmd, "type") {
231            return Kind::Cef(cef_label(ty, switch(cmd, "utility-sub-type")));
232        }
233    }
234    if name.starts_with("agentmux-srv") {
235        return Kind::Srv(if has_arg(cmd, "__extract") {
236            "Document parser"
237        } else if has_arg(cmd, "--crash-monitor") {
238            "Crash monitor"
239        } else {
240            "Server"
241        });
242    }
243    Kind::Plain
244}
245
246fn args(cmd: &str) -> impl Iterator<Item = &str> {
247    cmd.split_whitespace().map(|a| a.trim_matches('"'))
248}
249
250/// `--name=value` → `value`. Chromium writes its switches in that form, and
251/// none of the values read here contain spaces.
252fn switch<'a>(cmd: &'a str, name: &str) -> Option<&'a str> {
253    let key = format!("--{name}=");
254    args(cmd).find_map(|a| a.strip_prefix(key.as_str()))
255}
256
257fn has_arg(cmd: &str, arg: &str) -> bool {
258    args(cmd).any(|a| a == arg)
259}
260
261fn cef_label(ty: &str, utility_sub_type: Option<&str>) -> String {
262    match ty {
263        "renderer" => "Renderer".to_string(),
264        "gpu-process" => "GPU".to_string(),
265        "utility" => utility_label(utility_sub_type),
266        "crashpad-handler" => "Crash reporter".to_string(),
267        "zygote" => "Zygote".to_string(),
268        "ppapi" | "ppapi-broker" => "Plugin".to_string(),
269        // A fixed label: never text taken from a command line.
270        _ => "Subprocess".to_string(),
271    }
272}
273
274/// `network.mojom.NetworkService` → "Network service".
275fn utility_label(sub_type: Option<&str>) -> String {
276    let Some(service) = sub_type.and_then(|s| s.split('.').next()).filter(|s| !s.is_empty()) else {
277        return "Utility".to_string();
278    };
279    match service {
280        "network" => "Network service".to_string(),
281        "storage" => "Storage service".to_string(),
282        "audio" => "Audio service".to_string(),
283        "video_capture" => "Video capture service".to_string(),
284        "data_decoder" => "Data decoder".to_string(),
285        // A fixed label: never text taken from a command line.
286        _ => "Utility".to_string(),
287    }
288}
289
290/// macOS helper apps are named per type: "AgentMux Helper (GPU)" → "GPU". The
291/// plain "AgentMux Helper" runs utility processes.
292fn mac_helper_type(name: &str) -> Option<String> {
293    let rest = name.strip_prefix("AgentMux Helper")?;
294    let rest = rest.trim();
295    if rest.is_empty() {
296        return Some("Utility".to_string());
297    }
298    let ty = rest.strip_prefix('(')?.strip_suffix(')')?;
299    Some(match ty {
300        "Renderer" | "Alloy" => "Renderer".to_string(),
301        other => other.to_string(),
302    })
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    fn p(pid: u32, ppid: u32, name: &str) -> ProcInfo {
310        ProcInfo {
311            pid,
312            ppid: Some(ppid).filter(|&x| x != 0),
313            start_key: pid as u64 * 10,
314            started_at_ms: Some(pid as u64 * 10),
315            name: name.to_string(),
316            cpu_ns: Some(0),
317            mem_private: Some(1000),
318            mem_resident: Some(2000),
319            mem_commit: None,
320        }
321    }
322
323    /// A Windows portable, as measured: the launcher starts the host and srv;
324    /// the host's CEF subprocesses hang off it; srv has a crash monitor.
325    fn windows_tree() -> (Vec<ProcInfo>, HashMap<u32, &'static str>) {
326        let snap = vec![
327            p(10, 1, "agentmux.exe"),
328            p(20, 10, "agentmux-0.59.18.exe"),
329            p(21, 20, "agentmux-0.59.18.exe"),
330            p(22, 20, "agentmux-0.59.18.exe"),
331            p(23, 20, "agentmux-0.59.18.exe"),
332            p(24, 20, "agentmux-0.59.18.exe"),
333            p(30, 10, "agentmux-srv-0.59.18-windows.x64.exe"),
334            p(31, 30, "agentmux-srv-0.59.18-windows.x64.exe"),
335            p(32, 30, "agentmux-srv-0.59.18-windows.x64.exe"),
336        ];
337        let cmd = HashMap::from([
338            (10, r#""C:\AgentMux\agentmux.exe""#),
339            (20, r#""C:\AgentMux\runtime\agentmux-0.59.18.exe" --some-flag"#),
340            (21, r#""C:\AgentMux\runtime\agentmux-0.59.18.exe" --type=gpu-process --gpu-preferences=abc"#),
341            (22, r#""C:\AgentMux\runtime\agentmux-0.59.18.exe" --type=renderer --renderer-client-id=4"#),
342            (23, r#""C:\AgentMux\runtime\agentmux-0.59.18.exe" --type=utility --utility-sub-type=network.mojom.NetworkService"#),
343            (24, r#""C:\AgentMux\runtime\agentmux-0.59.18.exe" --type=utility --utility-sub-type=video_capture.mojom.VideoCaptureService"#),
344            (31, r#""C:\AgentMux\runtime\agentmux-srv-0.59.18-windows.x64.exe" --crash-monitor"#),
345            (32, r#""C:\AgentMux\runtime\agentmux-srv-0.59.18-windows.x64.exe" __extract --kind pdf"#),
346        ]);
347        (snap, cmd)
348    }
349
350    fn labels(snap: &[ProcInfo], cmd: &HashMap<u32, &str>, own_pid: u32) -> HashMap<u32, String> {
351        let members: Vec<usize> = (0..snap.len()).collect();
352        let read = |k: ProcKey| cmd.get(&k.pid).map(|s| s.to_string());
353        Describer::default()
354            .describe(snap, &members, own_pid, &read)
355            .into_iter()
356            .map(|(i, l)| (snap[i].pid, l))
357            .collect()
358    }
359
360    #[test]
361    fn labels_every_process_of_a_windows_install() {
362        let (snap, cmd) = windows_tree();
363        let got = labels(&snap, &cmd, 30);
364        let want: HashMap<u32, String> = [
365            (10, "Launcher"),
366            (20, "Main process"),
367            (21, "GPU"),
368            (22, "Renderer"),
369            (23, "Network service"),
370            (24, "Video capture service"),
371            (30, "Server"),
372            (31, "Crash monitor"),
373            (32, "Document parser"),
374        ]
375        .into_iter()
376        .map(|(pid, l)| (pid, l.to_string()))
377        .collect();
378        assert_eq!(got, want);
379    }
380
381    #[test]
382    fn a_macos_helper_is_labelled_by_its_name_when_its_command_line_is_unreadable() {
383        let snap = vec![
384            p(10, 1, "agentmux-launcher"),
385            p(20, 10, "agentmux-cef"),
386            p(21, 20, "AgentMux Helper (GPU)"),
387            p(22, 20, "AgentMux Helper (Renderer)"),
388            p(23, 20, "AgentMux Helper"),
389            p(30, 10, "agentmux-srv-0.59.18-darwin.arm64"),
390        ];
391        let got = labels(&snap, &HashMap::new(), 30);
392        assert_eq!(got.get(&21).map(String::as_str), Some("GPU"));
393        assert_eq!(got.get(&22).map(String::as_str), Some("Renderer"));
394        assert_eq!(got.get(&23).map(String::as_str), Some("Utility"));
395        // The helpers make 20 the window host even without command lines.
396        assert_eq!(got.get(&20).map(String::as_str), Some("Main process"));
397        assert_eq!(got.get(&10).map(String::as_str), Some("Launcher"));
398        assert_eq!(got.get(&30).map(String::as_str), Some("Server"));
399    }
400
401    #[test]
402    fn a_process_that_is_neither_host_nor_launcher_gets_no_label() {
403        let snap = vec![p(10, 1, "agentmux.exe"), p(30, 10, "agentmux-srv.exe"), p(40, 30, "agentmux-helper-thing")];
404        let cmd = HashMap::from([(10, "agentmux.exe"), (40, "agentmux-helper-thing --x")]);
405        let got = labels(&snap, &cmd, 30);
406        assert_eq!(got.get(&40), None);
407        assert_eq!(got.get(&10).map(String::as_str), Some("Launcher"));
408    }
409
410    #[test]
411    fn reads_each_command_line_once_and_forgets_exited_processes() {
412        let (snap, cmd) = windows_tree();
413        let members: Vec<usize> = (0..snap.len()).collect();
414        let reads = std::cell::Cell::new(0);
415        let read = |k: ProcKey| {
416            reads.set(reads.get() + 1);
417            cmd.get(&k.pid).map(|s| s.to_string())
418        };
419        let mut d = Describer::default();
420        d.describe(&snap, &members, 30, &read);
421        let first = reads.get();
422        assert_eq!(first, snap.len() - 1, "every process but srv itself, once");
423        d.describe(&snap, &members, 30, &read);
424        assert_eq!(reads.get(), first, "a second sample reads nothing");
425        d.describe(&snap, &members[..2], 30, &read);
426        assert_eq!(d.kinds.len(), 2, "processes no longer present are forgotten");
427    }
428
429    fn entry(pid: u32, label: &str) -> RendererEntry {
430        RendererEntry { pid, label: label.to_string(), block_id: None, pool: false, window_id: None }
431    }
432
433    #[test]
434    fn names_what_each_renderer_serves() {
435        let entries = vec![
436            RendererEntry { window_id: Some("w1".into()), ..entry(1, "main") },
437            RendererEntry { block_id: Some("b1".into()), ..entry(2, "pane-b1") },
438            RendererEntry { pool: true, ..entry(3, "window-pool-2") },
439            RendererEntry { block_id: Some("gone".into()), ..entry(4, "pane-gone") },
440            entry(5, "floating-7"),
441        ];
442        let page = |id: &str| (id == "b1").then(|| "github.com".to_string());
443        let workspace = |id: &str| (id == "w1").then(|| "Main".to_string());
444        let serves = |pid| renderer_serves(&entries, pid, &page, &workspace);
445        assert_eq!(serves(1).as_deref(), Some("window Main"));
446        assert_eq!(serves(2).as_deref(), Some("browser pane github.com"));
447        assert_eq!(serves(3).as_deref(), Some("pool window (idle)"));
448        assert_eq!(serves(4).as_deref(), Some("browser pane"));
449        assert_eq!(serves(5).as_deref(), Some("window"));
450        assert_eq!(serves(9), None);
451    }
452
453    #[test]
454    fn one_renderer_serving_two_browsers_names_both_once() {
455        let entries = vec![
456            RendererEntry { block_id: Some("b1".into()), ..entry(2, "pane-a") },
457            RendererEntry { block_id: Some("b2".into()), ..entry(2, "pane-b") },
458            RendererEntry { block_id: Some("b3".into()), ..entry(2, "pane-c") },
459        ];
460        let page = |id: &str| Some(if id == "b3" { "a.test" } else { "b.test" }.to_string());
461        let none = |_: &str| None;
462        assert_eq!(
463            renderer_serves(&entries, 2, &page, &none).as_deref(),
464            Some("browser pane b.test, browser pane a.test")
465        );
466    }
467
468    #[test]
469    fn a_page_is_named_by_its_host_alone() {
470        assert_eq!(page_host("https://github.com/agentmuxai").as_deref(), Some("github.com"));
471        assert_eq!(page_host("https://user:secret@example.com:8080/a?b=c#d").as_deref(), Some("example.com"));
472        assert_eq!(page_host("http://127.0.0.1:5173/").as_deref(), Some("127.0.0.1"));
473        assert_eq!(page_host("about:blank"), None);
474        assert_eq!(page_host("not a url"), None);
475        assert_eq!(page_host(""), None);
476    }
477
478    #[test]
479    fn a_sandboxed_renderer_is_found_by_the_pid_it_sees_for_itself() {
480        // A renderer in Chromium's PID namespace, as the host sees it: host
481        // PID 48211, PID 3 inside the namespace, which is what it reported.
482        let status = "Name:\tagentmux-cef\nTgid:\t48211\nNSpid:\t48211\t3\nPPid:\t48190\n";
483        assert_eq!(innermost_ns_pid(status), Some(3));
484        // No namespace: one PID on the line.
485        assert_eq!(innermost_ns_pid("NSpid:\t912\n"), Some(912));
486        // An old kernel without the line.
487        assert_eq!(innermost_ns_pid("Name:\tx\nPid:\t912\n"), None);
488    }
489
490    #[test]
491    fn parses_the_hosts_answer_and_tolerates_anything_else() {
492        let answer = serde_json::json!({"ok": true, "data": {"renderers": [
493            {"pid": 7, "label": "main", "pool": false, "window_id": "w1"},
494            {"pid": 8, "label": "pane-x", "block_id": "b1", "pool": false}
495        ]}});
496        let got = parse_renderer_map(&answer);
497        assert_eq!(got.len(), 2);
498        assert_eq!(got[1].block_id.as_deref(), Some("b1"));
499        assert!(parse_renderer_map(&serde_json::json!({"ok": false, "error": "x"})).is_empty());
500        assert!(parse_renderer_map(&serde_json::json!({"ok": true, "data": {"renderers": "nope"}})).is_empty());
501    }
502
503    #[test]
504    fn a_command_line_that_could_not_be_read_is_tried_again_a_few_times() {
505        let (snap, cmd) = windows_tree();
506        let members: Vec<usize> = (0..snap.len()).collect();
507        // The GPU process (21) can't be read at first, then can.
508        let readable = std::cell::Cell::new(false);
509        let reads_of_21 = std::cell::Cell::new(0);
510        let read = |k: ProcKey| {
511            if k.pid == 21 {
512                reads_of_21.set(reads_of_21.get() + 1);
513                if !readable.get() {
514                    return None;
515                }
516            }
517            cmd.get(&k.pid).map(|s| s.to_string())
518        };
519        let mut d = Describer::default();
520        let first = d.describe(&snap, &members, 30, &read);
521        assert_eq!(first.get(&2), None, "unreadable at first: no label yet");
522        readable.set(true);
523        let second = d.describe(&snap, &members, 30, &read);
524        assert_eq!(second.get(&2).map(String::as_str), Some("GPU"));
525        assert_eq!(reads_of_21.get(), 2);
526        d.describe(&snap, &members, 30, &read);
527        assert_eq!(reads_of_21.get(), 2, "read once it's known");
528    }
529
530    #[test]
531    fn a_process_that_can_never_be_read_is_given_up_on() {
532        let snap = vec![p(10, 1, "agentmux.exe"), p(30, 10, "agentmux-srv.exe"), p(40, 30, "agentmux-thing")];
533        let members: Vec<usize> = (0..snap.len()).collect();
534        let reads = std::cell::Cell::new(0);
535        let read = |k: ProcKey| {
536            if k.pid == 40 {
537                reads.set(reads.get() + 1);
538            }
539            None
540        };
541        let mut d = Describer::default();
542        for _ in 0..10 {
543            d.describe(&snap, &members, 30, &read);
544        }
545        assert_eq!(reads.get(), READ_ATTEMPTS as usize);
546    }
547
548    #[test]
549    fn utility_sub_types() {
550        assert_eq!(utility_label(Some("storage.mojom.StorageService")), "Storage service");
551        assert_eq!(utility_label(Some("audio.mojom.AudioService")), "Audio service");
552        assert_eq!(utility_label(Some("proxy_resolver.mojom.ProxyResolverFactory")), "Utility");
553        assert_eq!(utility_label(None), "Utility");
554    }
555
556    #[test]
557    fn other_cef_types() {
558        assert_eq!(cef_label("crashpad-handler", None), "Crash reporter");
559        assert_eq!(cef_label("zygote", None), "Zygote");
560        assert_eq!(cef_label("broker", None), "Subprocess");
561    }
562
563    #[test]
564    fn a_type_switch_on_a_process_that_is_not_ours_means_nothing() {
565        // Some tool the user ran from an AgentMux terminal that wasn't claimed
566        // by a task, with a Chromium-style switch of its own.
567        let snap = vec![
568            p(10, 1, "agentmux.exe"),
569            p(30, 10, "agentmux-srv.exe"),
570            p(40, 30, "electron.exe"),
571        ];
572        let cmd = HashMap::from([(10, "agentmux.exe"), (40, "electron.exe --type=renderer")]);
573        let got = labels(&snap, &cmd, 30);
574        assert_eq!(got.get(&40), None);
575        // And it doesn't make its parent look like the window host.
576        assert_eq!(got.get(&30).map(String::as_str), Some("Server"));
577    }
578}