agent_first_http/cli/cmd/
ui.rs1use std::time::Instant;
29
30use agent_first_ui::{UiDeliveryMode, UiUpstream};
31use serde::Serialize;
32
33use crate::cli::connect::Connection;
34use crate::cli::output;
35use crate::sdk::takeover::TakeoverUiSession;
36use crate::shared::error::{Error, ErrorCode};
37
38#[derive(Debug)]
39pub struct Args {
40 pub sub: UiSub,
41 pub delivery: UiDeliveryMode,
44}
45
46#[derive(Debug)]
47pub enum UiSub {
48 Takeover(TakeoverArgs),
49}
50
51#[derive(Debug)]
52pub enum TakeoverArgs {
53 Mint { connection: Connection },
55 Open { takeover_url_secret: String },
58}
59
60#[derive(Serialize)]
71struct UiReady {
72 panel_url: String,
74 session: &'static str,
77}
78
79#[derive(Serialize)]
81struct UiTakeoverResult {
82 panel_url: String,
83 session: &'static str,
84 mode: &'static str,
85 outcome: &'static str,
86 open_s: u64,
87}
88
89const SESSION_KIND: &str = "watch";
90
91fn ready_event<T: Serialize>(
93 facts: &agent_first_ui::UiDeliveryFacts,
94 own: T,
95) -> Result<serde_json::Value, Error> {
96 let own = serde_json::to_value(own).map_err(|error| {
97 Error::new(
98 ErrorCode::InternalError,
99 format!("build takeover UI readiness event: {error}"),
100 )
101 })?;
102 Ok(agent_first_ui::cli::ready_event_revealing_link(facts, own))
103}
104
105const PROVIDER_ID: &str = "afhttp";
109const UI_KIND: &str = "takeover";
110
111pub async fn run(args: Args) -> Result<(), Error> {
112 match args.sub {
113 UiSub::Takeover(takeover) => takeover_run(takeover, args.delivery).await,
114 }
115}
116
117async fn takeover_run(args: TakeoverArgs, delivery: UiDeliveryMode) -> Result<(), Error> {
118 let panel = resolve_panel(args).await?;
119 let panel_url = credential_free_panel_url(panel.lease.takeover_url_secret())?;
120 if let Err(reason) = window_only_upstream(&panel_url, delivery) {
127 let _revoked = panel.lease.revoke().await;
128 return Err(reason);
129 }
130 let upstream = UiUpstream::new(PROVIDER_ID, UI_KIND, panel.lease.takeover_url_secret())
131 .map_err(delivery_error)?
132 .with_subject(&panel_url);
133 let active = crate::cli::spec::PANEL_DELIVERY
135 .resolve(Some(delivery))
136 .map_err(delivery_error)?
137 .start_upstream(upstream)
138 .await
139 .map_err(delivery_error)?;
140
141 let ready = ready_event(
146 &active.facts(),
147 UiReady {
148 panel_url: panel_url.clone(),
149 session: SESSION_KIND,
150 },
151 )?;
152 output::emit_progress("ui_takeover", &ready)?;
153
154 let opened_at = Instant::now();
155 let ended = {
158 let delivery_wait = active.wait();
159 let keep_alive = panel.lease.keep_alive();
160 tokio::pin!(delivery_wait);
161 tokio::pin!(keep_alive);
162 tokio::select! {
163 result = &mut delivery_wait => result
164 .map(|outcome| outcome.ending())
165 .map_err(delivery_error),
166 () = stop_requested() => Ok("stopped"),
167 result = &mut keep_alive => match result {
168 Err(error) => Err(error),
169 Ok(()) => Err(Error::new(
170 ErrorCode::InternalError,
171 "takeover UI session keep-alive ended unexpectedly",
172 )),
173 },
174 }
175 };
176 let revoked = panel.lease.revoke().await;
177 let outcome = ended?;
178 revoked?;
179
180 output::emit(
181 "ui_takeover",
182 &UiTakeoverResult {
183 panel_url,
184 session: SESSION_KIND,
185 mode: delivery.as_str(),
186 outcome,
187 open_s: opened_at.elapsed().as_secs(),
188 },
189 )
190}
191
192fn window_only_upstream(panel_url: &str, delivery: UiDeliveryMode) -> Result<(), Error> {
199 if delivery == UiDeliveryMode::Window || !panel_url.starts_with("https://") {
200 return Ok(());
201 }
202 Err(Error::new(
203 ErrorCode::InvalidEndpoint,
204 format!(
205 "`{}` delivery cannot proxy the https panel at {panel_url}; use `--mode window`, \
206 which opens it directly and needs no proxy",
207 delivery.as_str()
208 ),
209 ))
210}
211
212fn delivery_error(error: agent_first_ui::Error) -> Error {
222 use agent_first_ui::UiErrorKind;
223
224 let error_code = match error.kind() {
225 UiErrorKind::WindowUnavailable | UiErrorKind::WindowWaitFailed => {
226 ErrorCode::BrowserLaunchFailed
227 }
228 UiErrorKind::InvalidArgument | UiErrorKind::UpstreamNotProxyable => {
229 ErrorCode::InvalidEndpoint
230 }
231 UiErrorKind::DeliveryModeInvalid
234 | UiErrorKind::DeliveryModeNotOffered
235 | UiErrorKind::LinkAddressUnavailable => ErrorCode::InvalidEndpoint,
236 UiErrorKind::FrontendUnreadable
237 | UiErrorKind::FrontendIncompatible
238 | UiErrorKind::FrontendUnsafe
239 | UiErrorKind::PageRender
240 | UiErrorKind::PageIncomplete
241 | UiErrorKind::RuntimeMisconfigured
242 | UiErrorKind::RuntimeClosed
243 | UiErrorKind::RuntimeBusy
244 | UiErrorKind::RuntimeMessageTooLarge
245 | UiErrorKind::RuntimeBlob
246 | UiErrorKind::RuntimePayload
247 | UiErrorKind::ConfigUnreadable
248 | UiErrorKind::Io => ErrorCode::InternalError,
249 };
250 Error::new(error_code, error.to_string())
251}
252
253async fn stop_requested() {
255 let interrupt = async {
256 let _ignored = tokio::signal::ctrl_c().await;
257 };
258 #[cfg(unix)]
259 let terminate = async {
260 if let Ok(mut signal) =
261 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
262 {
263 signal.recv().await;
264 }
265 };
266 #[cfg(not(unix))]
267 let terminate = std::future::pending::<()>();
268 tokio::select! {
269 () = interrupt => {},
270 () = terminate => {},
271 }
272}
273
274struct Panel {
275 lease: TakeoverUiSession,
276}
277
278async fn resolve_panel(args: TakeoverArgs) -> Result<Panel, Error> {
279 match args {
280 TakeoverArgs::Open {
281 takeover_url_secret,
282 } => Ok(Panel {
283 lease: TakeoverUiSession::exchange(&takeover_url_secret).await?,
284 }),
285 TakeoverArgs::Mint { connection } => {
286 let client = connection.client().await?;
287 Ok(Panel {
288 lease: client.takeover_ui_session().await?,
289 })
290 }
291 }
292}
293
294fn credential_free_panel_url(url: &str) -> Result<String, Error> {
299 let mut panel = url::Url::parse(url).map_err(|error| {
300 Error::new(
301 ErrorCode::InvalidEndpoint,
302 format!("--takeover-url-secret is not a valid URL: {error}"),
303 )
304 })?;
305 panel.set_query(None);
306 panel.set_fragment(None);
307 Ok(panel.to_string())
308}
309
310#[cfg(test)]
311mod tests {
312 use agent_first_ui::UiAttentionPolicy;
313
314 use super::*;
315
316 #[test]
317 fn the_reported_panel_url_drops_the_credential() {
318 let panel = credential_free_panel_url(
319 "http://127.0.0.1:9222/takeover/panel?handoff_secret=deadbeef#frag",
320 )
321 .unwrap();
322 assert_eq!(panel, "http://127.0.0.1:9222/takeover/panel");
323 }
324
325 #[test]
326 fn a_non_url_panel_is_an_argument_error_not_a_launch() {
327 let error = credential_free_panel_url("not a url").unwrap_err();
328 assert_eq!(error.error_code, ErrorCode::InvalidEndpoint);
329 }
330
331 const SECRET: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
332
333 fn ready_event(delivery: UiDeliveryMode, panel: &str) -> serde_json::Value {
334 let attention = (delivery == UiDeliveryMode::Link).then(UiAttentionPolicy::default);
335 let facts = agent_first_ui::UiDeliveryFacts {
336 mode: delivery,
337 session_id: "aabbccdd".to_string(),
338 link_url_secret: (delivery == UiDeliveryMode::Link)
339 .then(|| "http://192.168.1.20:9888/afui-capability/".to_string()),
340 idle_timeout_s: attention
341 .and_then(|policy| policy.idle_timeout())
342 .map(|duration| duration.as_secs()),
343 grace_period_s: attention.map(|policy| policy.grace_period().as_secs()),
344 };
345 super::ready_event(
346 &facts,
347 UiReady {
348 panel_url: panel.to_string(),
349 session: SESSION_KIND,
350 },
351 )
352 .unwrap()
353 }
354
355 #[test]
356 fn no_delivery_event_carries_the_upstream_secret() {
357 let url = format!("http://127.0.0.1:9222/takeover/panel?handoff_secret={SECRET}");
358 let panel = credential_free_panel_url(&url).unwrap();
359 for delivery in [
360 UiDeliveryMode::Window,
361 UiDeliveryMode::Link,
362 UiDeliveryMode::Session,
363 ] {
364 let ready = serde_json::to_string(&ready_event(delivery, &panel)).unwrap();
365 let done = serde_json::to_string(&UiTakeoverResult {
366 panel_url: panel.clone(),
367 session: SESSION_KIND,
368 mode: delivery.as_str(),
369 outcome: "expired",
370 open_s: 12,
371 })
372 .unwrap();
373 assert!(!ready.contains(SECRET), "{ready}");
374 assert!(!ready.contains("handoff_secret"), "{ready}");
375 assert!(!done.contains(SECRET), "{done}");
376 assert!(!done.contains("handoff_secret"), "{done}");
377 }
378 }
379
380 #[test]
383 fn a_link_carries_only_the_afui_url() {
384 let value = serde_json::to_value(ready_event(
385 UiDeliveryMode::Link,
386 "http://127.0.0.1:9222/takeover/panel",
387 ))
388 .unwrap();
389 assert_eq!(
390 value[agent_first_ui::cli::LINK_URL_FIELD],
391 serde_json::Value::String("http://192.168.1.20:9888/afui-capability/".to_string())
392 );
393 assert!(value.get("link_url_secret").is_none(), "{value}");
395 assert!(value.get("takeover_url_secret").is_none());
396 let policy = UiAttentionPolicy::default();
397 assert_eq!(
398 value["idle_timeout_s"],
399 policy.idle_timeout().unwrap().as_secs()
400 );
401 assert_eq!(value["grace_period_s"], policy.grace_period().as_secs());
402 }
403
404 #[test]
407 fn an_https_panel_is_refused_before_a_proxying_delivery_starts() {
408 for delivery in [UiDeliveryMode::Link, UiDeliveryMode::Session] {
409 let refused = window_only_upstream("https://takeover.example/takeover/panel", delivery)
410 .expect_err("a proxying delivery cannot reach an https panel");
411 assert_eq!(refused.error_code, ErrorCode::InvalidEndpoint);
412 assert!(refused.detail.contains("--mode window"), "{refused:?}");
413 }
414 assert!(
416 window_only_upstream(
417 "https://takeover.example/takeover/panel",
418 UiDeliveryMode::Window
419 )
420 .is_ok()
421 );
422 for delivery in [
424 UiDeliveryMode::Window,
425 UiDeliveryMode::Link,
426 UiDeliveryMode::Session,
427 ] {
428 assert!(window_only_upstream("http://127.0.0.1:9222/takeover/panel", delivery).is_ok());
429 }
430 }
431
432 #[test]
435 fn the_panel_is_announced_under_identifiers_afui_accepts() {
436 let announced = UiUpstream::new(
437 PROVIDER_ID,
438 UI_KIND,
439 "http://127.0.0.1:9222/takeover/panel?handoff_secret=deadbeef",
440 );
441 assert!(announced.is_ok());
442 }
443}