Skip to main content

agent_first_http/cli/cmd/
ui.rs

1//! `afhttp ui takeover` subcommand. Publishes the takeover panel as a UI
2//! session and blocks until that session ends.
3//!
4//! `panel` and `fetch --takeover` hand back a URL and exit; whether anyone ever
5//! opened it, and whether they are finished, is left for the agent to guess.
6//! This command is the other half: it holds the session open and returns when
7//! it ends, which for a takeover is the only ending there is. A VNC canvas
8//! carries no submit control, so there is nothing for the person to confirm and
9//! no typed result to collect.
10//!
11//! Three deliveries of that one session, because the person is not always at
12//! this machine, and the way to them is not always this machine's screen.
13//! `--mode` names which, in AFUI's own three words, and an unset flag means
14//! `AFUI_DELIVERY` decides — the variable an unattended runner sets once so
15//! nothing under it pops a window nobody is there to see.
16//!
17//! * `window` opens the panel here, and closing it is the ending.
18//! * `link` starts AFUI's one-session remote page and hands back that page's
19//!   URL. The takeover credential stays behind AFUI's proxy.
20//! * `session` announces the panel and nothing more, for `afui session serve`
21//!   to frame later.
22//!
23//! All three go through [`agent_first_ui::UiDeliveryPlan`]. The separately
24//! hosted panel uses a short internal lease which this process renews while the
25//! AFUI delivery exists and revokes when it ends. That lease is crash cleanup,
26//! never the user-visible lifetime of a Link or Session.
27
28use 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    /// Already resolved against `AFUI_DELIVERY`, because the flag having no
42    /// default of its own is what lets that variable be reached at all.
43    pub delivery: UiDeliveryMode,
44}
45
46#[derive(Debug)]
47pub enum UiSub {
48    Takeover(TakeoverArgs),
49}
50
51#[derive(Debug)]
52pub enum TakeoverArgs {
53    /// Mint a fresh credential from a running host, then open its panel.
54    Mint { connection: Connection },
55    /// Open a panel URL an earlier `panel` or `fetch --takeover` already
56    /// minted. The credential is in the URL, so no host call is needed.
57    Open { takeover_url_secret: String },
58}
59
60/// afhttp's own half of the event AFUI has made reachable.
61///
62/// The delivery half — mode, session identity, the bearer URL when there is
63/// one, the attention intervals — is AFUI's and is merged in rather than
64/// restated here, so afhttp cannot drift from what every other AFUI-delivered
65/// UI reports.
66///
67/// The upstream takeover credential is not in either half and never reaches
68/// output: the URL a `link` publishes is AFUI's own shell URL, which keeps
69/// that credential behind its proxy.
70#[derive(Serialize)]
71struct UiReady {
72    /// Where the upstream panel is served, credential-free.
73    panel_url: String,
74    /// No submit control on a VNC canvas: the person watches and acts, and the
75    /// session ends without a result.
76    session: &'static str,
77}
78
79/// The terminal event: the session is over, so the person is done with it.
80#[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
91/// afhttp's fields and AFUI's delivery facts, as one event.
92fn 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
105/// The Provider and UI identifiers this panel is listed under. `takeover` is
106/// the `ui_kind` an AFUI frontend override would key on, if a panel afhttp did
107/// not write were ever overridable.
108const 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    // AFUI refuses an https upstream behind `link` or `session` — its remote
121    // shell proxies plain HTTP, and a window needs no proxy because the browser
122    // talks to the panel directly. Saying so here rather than letting that
123    // refusal arrive from `start_upstream` matters because a credential has
124    // already been minted by this point: caught here it is given straight back,
125    // and caught later it is a live capability nobody asked for.
126    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    // The same offer the `--mode` flag was built from.
134    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    // The one event whose job is to hand a `link` URL over. AFUI publishes it
142    // under a name the emitter will not mask, so this goes out the ordinary
143    // way — afhttp used to carry an emitter of its own that redacted the event
144    // and then put this one field back.
145    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    // AFUI owns every delivery-specific ending. afhttp contributes only the
156    // process shutdown signal and the private upstream lease maintenance.
157    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
192/// Whether this panel can only be delivered as a local window.
193///
194/// A takeover host behind TLS is a perfectly good panel; what it is not is
195/// something AFUI's remote shell can proxy, because that shell speaks plain
196/// HTTP to its upstream. `window` never proxies at all — the browser opens the
197/// panel itself — so it is the one delivery that still works.
198fn 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
212/// One AFUI failure, in afhttp's vocabulary.
213///
214/// Keyed on AFUI's own classification rather than on its variants, and
215/// exhaustive over it. The comment here used to claim that a new AFUI variant
216/// "lands in the category it was classified as" — it did not: the arms named
217/// individual classifications and everything else fell into `InternalError`,
218/// so every classification AFUI added later would have been reported as an
219/// afhttp bug. Now there is no wildcard, and adding one stops this compiling
220/// until somebody says which afhttp error it is.
221fn 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        // The panel is asked for by `--mode`, so a delivery AFUI will not
232        // resolve or cannot reach is what the caller passed, not a fault here.
233        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
253/// Resolves when the agent asks this command to stop.
254async 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
294/// The panel without its query, which is where the credential lives. Every
295/// event this command emits names the panel this way, so the secret reaches the
296/// window and nothing else. Same spelling as `capabilities.takeover.panel_url`,
297/// which is credential-free for the same reason.
298fn 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    /// Link exposes AFUI's outer credential and never the takeover credential
381    /// AFUI keeps behind its proxy.
382    #[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        // Never under the suffixed name, which an emitter would mask.
394        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    /// A credential minted a moment ago must not be spent on a delivery that
405    /// cannot run — and the refusal has to name the one that can.
406    #[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        // A window opens the panel itself, so TLS is not in anybody's way.
415        assert!(
416            window_only_upstream(
417                "https://takeover.example/takeover/panel",
418                UiDeliveryMode::Window
419            )
420            .is_ok()
421        );
422        // And plain HTTP is what every delivery was always able to reach.
423        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    /// The panel is announced under identifiers AFUI accepts. Getting these
433    /// wrong is a runtime error at the one moment a person is waiting.
434    #[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}