Skip to main content

agentmux_mcp/tools/
ui.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! UI automation and window capture.
5
6use super::*;
7
8pub(super) async fn call(name: &str, arguments: &Value, cx: &ToolCtx<'_>) -> Result<String> {
9    let ToolCtx { local_url, auth_key, block_id, client, .. } = *cx;
10    match name {
11        "UIScreenshot" => {
12            require_agent_env(local_url, auth_key, block_id)?;
13            let auth = sign_ui_automation_auth()?;
14            let url = format!("{}/api/v1/ui/screenshot", local_url.trim_end_matches('/'));
15            let resp = client
16                .post(&url)
17                .header(AUTH_KEY_HEADER, auth_key)
18                .json(&UiScreenshotRequest { auth, pane: pane_arg(arguments) })
19                .send()
20                .await
21                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
22            if !resp.status().is_success() {
23                let status = resp.status();
24                let text = resp.text().await.unwrap_or_default();
25                anyhow::bail!("screenshot failed: HTTP {status} — {text}");
26            }
27            let result: UiScreenshotResponse = resp
28                .json()
29                .await
30                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
31            Ok(format!(
32                "Screenshot saved to {} — use Read on that path to view it yourself, or OpenMedia to show it to the user.",
33                result.path
34            ))
35        }
36        "CaptureWindow" => {
37            // `index` stays an Option all the way into capture_window_impl —
38            // NOT defaulted to 0 here. Defaulting it here is exactly what
39            // let an ambiguous match silently capture the wrong (and once,
40            // a genuinely unrelated/sensitive) window with no warning —
41            // see docs/reports/REPORT_AGENT_SCREENSHOT_WINDOW_CONTROL_BLOCKERS_2026_08_24.md
42            // §1. Losing "the caller didn't specify an index at all" vs.
43            // "the caller explicitly asked for index 0" was the bug.
44            let title_contains = arguments
45                .get("title_contains")
46                .and_then(|v| v.as_str())
47                .map(|s| s.to_string());
48            let index = arguments
49                .get("index")
50                .and_then(|v| v.as_u64())
51                .map(|v| v as usize);
52            let pid = arguments
53                .get("pid")
54                .and_then(|v| v.as_u64())
55                .map(|v| v as u32);
56
57            let query_desc = match (pid, title_contains.as_deref()) {
58                (Some(p), _) => format!("pid={p}"),
59                (None, Some(t)) => format!("title_contains={t:?}"),
60                (None, None) => "<no target given>".to_string(),
61            };
62
63            // Audit trail, not a capability gate — reagent P1 (PR #2709
64            // round 4): a real per-agent opt-in gate is a bigger feature
65            // (settings storage + enforcement, no existing mechanism
66            // anywhere in the codebase to build on — confirmed by the
67            // spec this tool's own doc comment already cites) than fits
68            // reactively on this PR, and this tool's actual residual risk
69            // after rounds 2-3's scoping is disclosure across a human
70            // boundary (a different AgentMux instance's window can belong
71            // to a different OS user on a shared machine), not a technical
72            // one — logging every call (who, what was requested, what
73            // happened) is the honest, shippable Phase-1 answer while the
74            // real gate is tracked separately (operator-confirmed).
75            let mut resolved: Option<(CaptureTier, String)> = None;
76            let outcome = capture_window_impl(title_contains.as_deref(), index, pid, &mut resolved);
77            audit_log_capture_window(&query_desc, &outcome, &resolved);
78            return outcome.map(|c| c.message);
79        }
80        "DiscoverWindows" => {
81            let include_self = arguments
82                .get("include_self")
83                .and_then(|v| v.as_bool())
84                .unwrap_or(false);
85            // Non-AgentMux windows are enumerated now (they're T4 capture
86            // targets) but stay OUT of the default listing: ordinary discovery
87            // shouldn't disclose the titles of a user's unrelated applications
88            // — their browser tabs, their password manager — as a side effect
89            // of looking for AgentMux windows.
90            let include_foreign = arguments
91                .get("include_foreign")
92                .and_then(|v| v.as_bool())
93                .unwrap_or(false);
94            let windows = enumerate_agentmux_windows()?;
95            let list: Vec<Value> = windows
96                .iter()
97                // These two filters decide WHICH windows are listed. What each
98                // listed entry may say about itself — in particular that a
99                // withheld window is redacted rather than omitted — belongs to
100                // `window_listing_entry` below, which documents that rationale
101                // rather than repeating it here (reagentx P2 on PR #2845: an
102                // earlier version of this comment still described a
103                // `tier.allowed()` filter that has since been replaced, and
104                // contradicted the code under it).
105                .filter(|w| include_self || !w.is_self)
106                .filter(|w| include_foreign || w.is_agentmux)
107                .map(window_listing_entry)
108                .collect();
109            // reagent P1 on PR #2810: exe_path (embeds the OS username) for
110            // OTHER instances/users on a shared machine is the same
111            // disclosure-across-a-human-boundary risk CaptureWindow already
112            // logs — this tool must too, not just the tool that follows it.
113            audit_log_discover_windows(include_self, include_foreign, &list);
114            return Ok(serde_json::to_string_pretty(&json!({ "windows": list }))
115                .unwrap_or_else(|_| "{\"windows\":[]}".to_string()));
116        }
117        "UIClick" => {
118            require_agent_env(local_url, auth_key, block_id)?;
119            let auth = sign_ui_automation_auth()?;
120            let selector = arguments
121                .get("selector")
122                .and_then(|v| v.as_str())
123                .ok_or_else(|| anyhow::anyhow!("missing required parameter: selector"))?;
124            let url = format!("{}/api/v1/ui/click", local_url.trim_end_matches('/'));
125            let resp = client
126                .post(&url)
127                .header(AUTH_KEY_HEADER, auth_key)
128                .json(&UiClickRequest {
129                    auth,
130                    pane: pane_arg(arguments),
131                    selector: selector.to_string(),
132                })
133                .send()
134                .await
135                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
136            if !resp.status().is_success() {
137                let status = resp.status();
138                let text = resp.text().await.unwrap_or_default();
139                anyhow::bail!("click failed: HTTP {status} — {text}");
140            }
141            Ok(format!("Clicked {selector:?}"))
142        }
143        "UIQuery" => {
144            require_agent_env(local_url, auth_key, block_id)?;
145            let auth = sign_ui_automation_auth()?;
146            let selector = arguments
147                .get("selector")
148                .and_then(|v| v.as_str())
149                .ok_or_else(|| anyhow::anyhow!("missing required parameter: selector"))?;
150            let limit = arguments.get("limit").and_then(|v| v.as_u64()).map(|n| n as u32);
151            let url = format!("{}/api/v1/ui/query", local_url.trim_end_matches('/'));
152            let resp = client
153                .post(&url)
154                .header(AUTH_KEY_HEADER, auth_key)
155                .json(&UiQueryRequest {
156                    auth,
157                    pane: pane_arg(arguments),
158                    selector: selector.to_string(),
159                    limit,
160                })
161                .send()
162                .await
163                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
164            if !resp.status().is_success() {
165                let status = resp.status();
166                let text = resp.text().await.unwrap_or_default();
167                anyhow::bail!("query failed: HTTP {status} — {text}");
168            }
169            let body: Value = resp
170                .json()
171                .await
172                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
173            let matches = body
174                .get("data")
175                .and_then(|d| d.get("matches"))
176                .cloned()
177                .unwrap_or_else(|| json!([]));
178            Ok(serde_json::to_string_pretty(&matches).unwrap_or_else(|_| matches.to_string()))
179        }
180        "ListShortcuts" => {
181            require_agent_env(local_url, auth_key, block_id)?;
182            let auth = sign_ui_automation_auth()?;
183            let data = post_shortcuts(cx, "list", &UiShortcutsListRequest { auth }).await?;
184            let list = data.get("shortcuts").cloned().unwrap_or_else(|| json!([]));
185            Ok(serde_json::to_string_pretty(&list).unwrap_or_else(|_| list.to_string()))
186        }
187        "RunCommand" => {
188            require_agent_env(local_url, auth_key, block_id)?;
189            let auth = sign_ui_automation_auth()?;
190            let command = arguments
191                .get("command")
192                .and_then(|v| v.as_str())
193                .ok_or_else(|| anyhow::anyhow!("missing required parameter: command"))?;
194            let req = UiRunCommandRequest { auth, command: command.to_string(), target: str_arg(arguments, "target") };
195            let data = post_shortcuts(cx, "run", &req).await?;
196            if data.get("ran").and_then(|v| v.as_bool()) == Some(true) {
197                Ok(format!("Ran {command}."))
198            } else if let Some(pending) = data.get("pending") {
199                // pane:close of another agent's pane: its user has 15 s to keep it.
200                let why = data.get("reason").and_then(|v| v.as_str()).unwrap_or("waiting for the user");
201                let id = pending.get("request_id").and_then(|v| v.as_str()).unwrap_or("?");
202                Ok(format!("{command} is pending: {why} (request {id})."))
203            } else {
204                let why = data.get("reason").and_then(|v| v.as_str()).unwrap_or("no reason given");
205                Ok(format!("Didn't run {command}: {why}"))
206            }
207        }
208        "PressKeys" => {
209            require_agent_env(local_url, auth_key, block_id)?;
210            let auth = sign_ui_automation_auth()?;
211            let keys = arguments
212                .get("keys")
213                .and_then(|v| v.as_str())
214                .ok_or_else(|| anyhow::anyhow!("missing required parameter: keys"))?;
215            let req = UiPressKeysRequest { auth, keys: keys.to_string(), target: str_arg(arguments, "target") };
216            let data = post_shortcuts(cx, "press", &req).await?;
217            Ok(serde_json::to_string_pretty(&data).unwrap_or_else(|_| data.to_string()))
218        }
219        _ => Err(not_in_family()),
220    }
221}
222
223fn str_arg(arguments: &Value, name: &str) -> Option<String> {
224    arguments
225        .get(name)
226        .and_then(|v| v.as_str())
227        .map(str::trim)
228        .filter(|s| !s.is_empty())
229        .map(str::to_string)
230}
231
232/// POST to `/api/v1/ui/shortcuts/{route}` and return the response's `data`.
233async fn post_shortcuts<T: serde::Serialize>(cx: &ToolCtx<'_>, route: &str, body: &T) -> Result<Value> {
234    let url = format!("{}/api/v1/ui/shortcuts/{route}", cx.local_url.trim_end_matches('/'));
235    let resp = cx
236        .client
237        .post(&url)
238        .header(AUTH_KEY_HEADER, cx.auth_key)
239        .json(body)
240        .send()
241        .await
242        .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
243    let status = resp.status();
244    let body: Value = resp.json().await.unwrap_or(Value::Null);
245    if !status.is_success() || body.get("ok").and_then(|v| v.as_bool()) != Some(true) {
246        let err = body.get("error").and_then(|v| v.as_str()).unwrap_or("no error message");
247        anyhow::bail!("shortcuts/{route} failed: HTTP {status} — {err}");
248    }
249    Ok(body.get("data").cloned().unwrap_or(Value::Null))
250}