agentmux_srv/server/app_api/
tower_pane.rs1use super::*;
14use crate::backend::process_tracker::registry::AgentProcessRegistry;
15use crate::backend::{tower_peers, tower_remote};
16use crate::backend::tower_agentmux::{own_view_pid, parse_renderer_map, renderer_serves, RendererEntry};
17use crate::backend::tower_sampler::{block_label, Inputs, Tower};
18
19pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
20 register_peers(engine, state);
21 let (mstore, hostname, tracker) = (state.mstore.clone(), state.hostname.clone(), state.process_tracker.clone());
22 let auth_key = state.auth_key.clone();
23 let lan = state.lan_discovery.clone();
24 let (http_client, host_ipc) = (state.http_client.clone(), state.host_ipc.clone());
25 engine.register_typed(COMMAND_TOWER_SAMPLE, move |req: TowerSampleReq, ctx| {
26 let (mstore, hostname, tracker, auth_key) = (mstore.clone(), hostname.clone(), tracker.clone(), auth_key.clone());
27 let lan = lan.clone();
28 let (http_client, host_ipc) = (http_client.clone(), host_ipc.clone());
29 async move {
30 not_an_agent_api(&ctx)?;
31 if let Some(id) = req.connection.as_deref().and_then(|c| c.strip_prefix(tower_peers::PEER_PREFIX)) {
33 let peers = tower_peers::Peers::global();
34 let hostname = peers.get(id).map(|p| p.hostname).unwrap_or_default();
35 let moved: Vec<String> = lan
36 .get_instances()
37 .into_iter()
38 .filter(|i| !hostname.is_empty() && i.hostname.eq_ignore_ascii_case(&hostname))
39 .map(|i| i.address)
40 .collect();
41 return tower_peers::sample(peers, id, req.filter.as_deref().unwrap_or(""), moved).await;
42 }
43 if let Some(conn) = req.connection.as_deref().filter(|c| !tower_remote::is_local(c)) {
45 let block_id = req.block_id.clone().unwrap_or_default();
46 let ask = (!block_id.is_empty()).then(|| crate::backend::remote::sessions::AskIn {
47 block_id: &block_id,
48 auth_key: &auth_key,
49 });
50 return tower_remote::sample(conn, req.filter.as_deref().unwrap_or(""), ask).await;
51 }
52 let renderers = renderer_map(&http_client, &host_ipc).await;
53 tokio::task::spawn_blocking(move || {
55 let page = |id: &str| browser_page(&mstore, id);
56 let workspace = |id: &str| window_workspace(&mstore, id);
57 Tower::global()
58 .sample(
59 req.host.unwrap_or(false),
60 &hostname,
61 || inputs(&tracker),
62 |id| block_label(&mstore, id),
63 |pid| renderer_serves(&renderers, own_view_pid(pid), &page, &workspace),
65 )
66 .map_err(|e| format!("tower.sample: {e}"))
67 })
68 .await
69 .map_err(|e| format!("tower.sample: {e}"))?
70 }
71 });
72}
73
74fn register_peers(engine: &Arc<WshRpcEngine>, state: &AppState) {
77 engine.register_typed(COMMAND_TOWER_PEERS, |_req: Option<NoArgsReq>, ctx| async move {
78 not_an_agent_api(&ctx)?;
79 Ok(TowerPeersResult { peers: tower_peers::Peers::global().list() })
80 });
81 let hostname = state.hostname.clone();
82 engine.register_typed(COMMAND_TOWER_PAIR, move |req: TowerPairReq, ctx| {
83 let device_name = format!("{hostname} (Tower)");
84 async move {
85 not_an_agent_api(&ctx)?;
86 tower_peers::pair(tower_peers::Peers::global(), &req.link, &device_name).await
87 }
88 });
89 engine.register_typed(COMMAND_TOWER_FORGET, |req: TowerForgetReq, ctx| async move {
90 not_an_agent_api(&ctx)?;
91 let id = req.connection.strip_prefix(tower_peers::PEER_PREFIX).ok_or("not a paired computer")?;
92 tokio::task::spawn_blocking({
93 let id = id.to_string();
94 move || tower_peers::Peers::global().forget(&id)
95 })
96 .await
97 .map_err(|e| e.to_string())??;
98 Ok(TowerPeersResult { peers: tower_peers::Peers::global().list() })
99 });
100}
101
102pub(super) fn not_an_agent_api(ctx: &RpcContext) -> Result<(), String> {
105 if ctx.agent_id.is_empty() {
106 Ok(())
107 } else {
108 Err("FORBIDDEN: Tower is the AgentMux window's, not an agent API".to_string())
109 }
110}
111
112pub(crate) fn inputs(tracker: &AgentProcessRegistry) -> Inputs {
113 Inputs {
114 blocks: tracker.members_by_block(),
115 roots: blockcontroller::pidregistry::get_all(),
116 own_pid: std::process::id(),
117 }
118}
119
120async fn renderer_map(
124 http_client: &reqwest::Client,
125 host_ipc: &tokio::sync::Mutex<Option<crate::server::HostIpc>>,
126) -> Vec<RendererEntry> {
127 let Some(host) = host_ipc.lock().await.clone() else {
128 return Vec::new();
129 };
130 let timeout = Some(std::time::Duration::from_millis(500));
131 match crate::server::host_http::post_to_host(http_client, &host, "renderer_map", serde_json::json!({}), timeout).await {
132 Ok(answer) => parse_renderer_map(&answer),
133 Err(e) => {
134 tracing::debug!(error = %e, "[tower] couldn't ask the host which renderer serves what");
135 Vec::new()
136 }
137 }
138}
139
140fn browser_page(mstore: &Store, block_id: &str) -> Option<String> {
143 let block = mstore.get::<Block>(block_id).ok().flatten()?;
144 let meta = |key: &str| obj::meta_get_string(&block.meta, key, "");
145 let title = meta("frame:title");
146 if !title.is_empty() {
147 return Some(title);
148 }
149 crate::backend::tower_agentmux::page_host(&meta("url"))
150}
151
152fn window_workspace(mstore: &Store, window_id: &str) -> Option<String> {
154 let window = mstore.get::<obj::Window>(window_id).ok().flatten()?;
155 let workspace = mstore.get::<obj::Workspace>(&window.workspaceid).ok().flatten()?;
156 Some(workspace.name).filter(|n| !n.is_empty())
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 async fn call(command: &str, data: serde_json::Value, agent_id: &str) -> (serde_json::Value, String) {
164 let state = crate::server::tests::test_state();
165 let (engine, mut rx) = WshRpcEngine::new();
166 register(&engine, &state);
167 if !agent_id.is_empty() {
170 engine.set_rpc_context(RpcContext { agent_id: agent_id.to_string(), ..Default::default() });
171 }
172 engine.handle_message(RpcMessage {
173 command: command.to_string(),
174 reqid: "req-1".to_string(),
175 data: Some(data),
176 ..Default::default()
177 });
178 let resp = tokio::time::timeout(std::time::Duration::from_secs(20), rx.recv()).await.unwrap().unwrap();
179 (resp.data.unwrap_or_default(), resp.error)
180 }
181
182 #[tokio::test(flavor = "multi_thread")]
183 async fn the_window_gets_a_sample_with_the_host_list() {
184 let (data, err) = call(COMMAND_TOWER_SAMPLE, json!({ "host": true }), "").await;
185 assert_eq!(err, "");
186 let snap: TowerSnapshot = serde_json::from_value(data).unwrap();
187 assert!(snap.cpu_count >= 1);
188 assert_eq!(snap.os, std::env::consts::OS);
189 let host = snap.host.expect("host list asked for");
190 assert!(host.processes.iter().any(|p| p.pid == std::process::id()));
191 }
192
193 #[tokio::test(flavor = "multi_thread")]
194 async fn a_registered_agent_is_refused() {
195 let (_, err) = call(COMMAND_TOWER_SAMPLE, json!({}), "AgentX").await;
196 assert!(err.contains("FORBIDDEN"), "{err}");
197 }
198}