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//! Two deliveries of that one session, because the person is not always at this
12//! machine. A window is the local one: it opens here, and closing it is the
13//! ending. `--takeover-no-window` is the remote one: the panel is announced to
14//! the cross-process session registry so `afui session list` can see it and
15//! `afui session serve` can frame it on a phone, and the ending is the
16//! credential running out or this command being stopped. The panel is announced
17//! either way — a person at this machine may still want it on their phone.
18//!
19//! The credential in that panel URL is the host's, not AFUI's. AFUI is told
20//! where the panel is and lists it; afhttp minted the credential, afhttp's TTL
21//! governs it, and afhttp revokes it on a profile switch or host shutdown.
22//! Ending this command withdraws the listing and nothing else.
23
24use std::time::{Duration, Instant};
25
26use agent_first_ui::{UiUpstream, UiWindow, UiWindowConfig};
27use serde::Serialize;
28
29use crate::cli::connect::Connection;
30use crate::cli::output;
31use crate::shared::error::{Error, ErrorCode};
32
33#[derive(Debug)]
34pub struct Args {
35    pub sub: UiSub,
36    pub delivery: Delivery,
37}
38
39#[derive(Debug)]
40pub enum UiSub {
41    Takeover(TakeoverArgs),
42}
43
44/// How the panel is put in front of a person.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Delivery {
47    /// An isolated window on this machine. Closing it ends the session.
48    Window,
49    /// No window: the session is left in the registry for a shell to frame,
50    /// and this command waits.
51    Listed,
52}
53
54#[derive(Debug)]
55pub enum TakeoverArgs {
56    /// Mint a fresh credential from a running host, then open its panel.
57    Mint { connection: Connection },
58    /// Open a panel URL an earlier `panel` or `fetch --takeover` already
59    /// minted. The credential is in the URL, so no host call is needed.
60    Open { takeover_url_secret: String },
61}
62
63/// Emitted before the session blocks.
64///
65/// `session_id` is absent exactly when the panel could not be announced, which
66/// only a window delivery survives. Absence is the signal: a window is open, and
67/// nothing else can see it.
68#[derive(Serialize)]
69struct UiReady {
70    /// Where the panel is served, credential-free — the secret stays out of
71    /// every event this command emits.
72    panel_url: String,
73    /// No submit control on a VNC canvas: the person watches and acts, and the
74    /// session ends without a result.
75    session: &'static str,
76    /// How this session is being put in front of a person.
77    delivery: &'static str,
78    /// What `afui session list` calls this panel, so an agent can point a
79    /// person at the right one without ever handling the credential.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    session_id: Option<String>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    takeover_url_expires_at_rfc3339: Option<String>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    takeover_url_ttl_s: Option<u64>,
86}
87
88/// The terminal event: the session is over, so the person is done with it.
89#[derive(Serialize)]
90struct UiTakeoverResult {
91    panel_url: String,
92    session: &'static str,
93    delivery: &'static str,
94    outcome: &'static str,
95    open_s: u64,
96}
97
98const SESSION_KIND: &str = "watch";
99
100/// The Provider and UI identifiers this panel is listed under. `takeover` is
101/// the `ui_kind` an AFUI frontend override would key on, if a panel afhttp did
102/// not write were ever overridable.
103const PROVIDER_ID: &str = "afhttp";
104const UI_KIND: &str = "takeover";
105
106pub async fn run(args: Args) -> Result<(), Error> {
107    match args.sub {
108        UiSub::Takeover(takeover) => takeover_run(takeover, args.delivery).await,
109    }
110}
111
112async fn takeover_run(args: TakeoverArgs, delivery: Delivery) -> Result<(), Error> {
113    let panel = resolve_panel(args).await?;
114    let panel_url = credential_free_panel_url(&panel.url)?;
115
116    // Announced before the wait starts, for both deliveries: a window is where
117    // the person at this machine looks, and the registry is where a person
118    // holding a phone looks. Held for exactly as long as this command runs —
119    // the listing is withdrawn on the way out of this function, and pruned by
120    // whoever reads the registry next if this process dies instead.
121    //
122    // Failing to announce is fatal for one delivery and not the other, because
123    // the deliveries do not depend on it equally: without a window the listing
124    // *is* the delivery, and with one the panel is already in front of the
125    // person. So a window opens anyway — and says so by carrying no
126    // `session_id`, which is the difference an agent can see.
127    let announced = UiUpstream::new(PROVIDER_ID, UI_KIND, &panel.url)
128        .and_then(|upstream| upstream.with_subject(&panel_url).announce());
129    let announced = match (announced, delivery) {
130        (Ok(announced), _) => Some(announced),
131        (Err(error), Delivery::Listed) => {
132            return Err(Error::new(
133                ErrorCode::InternalError,
134                format!(
135                    "could not publish the takeover panel as a UI session: {error}. \
136                     Without a window there is nothing else to deliver it with."
137                ),
138            ));
139        }
140        (Err(_), Delivery::Window) => None,
141    };
142
143    output::emit_progress(
144        "ui_takeover",
145        &UiReady {
146            panel_url: panel_url.clone(),
147            session: SESSION_KIND,
148            delivery: delivery.as_str(),
149            session_id: announced
150                .as_ref()
151                .map(|announced| announced.metadata().session_id.to_string()),
152            takeover_url_expires_at_rfc3339: panel.expires_at_rfc3339,
153            takeover_url_ttl_s: panel.ttl_s,
154        },
155    )?;
156
157    let opened_at = Instant::now();
158    let outcome = match delivery {
159        Delivery::Window => wait_on_window(&panel.url).await?,
160        Delivery::Listed => wait_while_listed(panel.ttl_s).await,
161    };
162    drop(announced);
163
164    output::emit(
165        "ui_takeover",
166        &UiTakeoverResult {
167            panel_url,
168            session: SESSION_KIND,
169            delivery: delivery.as_str(),
170            outcome,
171            open_s: opened_at.elapsed().as_secs(),
172        },
173    )
174}
175
176impl Delivery {
177    fn as_str(self) -> &'static str {
178        match self {
179            Self::Window => "window",
180            Self::Listed => "listed",
181        }
182    }
183}
184
185/// Open the panel here and wait for the person to close it.
186async fn wait_on_window(url_secret: &str) -> Result<&'static str, Error> {
187    // No window is the same failure as no browser: the machine running the
188    // agent has nowhere to show a person the panel. That is the case
189    // `--takeover-no-window` exists for, so the error says so.
190    let mut window = UiWindow::launch(url_secret, &UiWindowConfig::default()).map_err(|error| {
191        Error::new(
192            ErrorCode::BrowserLaunchFailed,
193            format!(
194                "could not open a takeover window: {error}. \
195                 This delivery needs a display and a Chromium-family browser on this machine; \
196                 use `--takeover-no-window` to hand the panel to a phone through \
197                 `afui session serve`, or `afhttp panel` to hand the URL to someone else."
198            ),
199        )
200    })?;
201    window.wait_closed().await.map_err(|error| {
202        Error::new(
203            ErrorCode::InternalError,
204            format!("waiting on the takeover window: {error}"),
205        )
206    })?;
207    Ok("closed")
208}
209
210/// Hold the session open for whoever is framing it, and say how it ended.
211///
212/// A panel with no window here has no close event to wait on, so the two
213/// endings left are the ones that are still real: the credential afhttp minted
214/// runs out, or the agent stops waiting. Both bound the session by this
215/// process, which is what §4.6 asks — there is no page left hanging either way,
216/// because the listing goes when this returns.
217///
218/// A panel opened from an already-minted URL has no lifetime this command
219/// knows, so only the second ending applies.
220async fn wait_while_listed(ttl_s: Option<u64>) -> &'static str {
221    let stopped = stop_requested();
222    match ttl_s {
223        Some(ttl_s) => {
224            let expiry = tokio::time::sleep(Duration::from_secs(ttl_s));
225            tokio::select! {
226                () = expiry => "expired",
227                () = stopped => "stopped",
228            }
229        }
230        None => {
231            stopped.await;
232            "stopped"
233        }
234    }
235}
236
237/// Resolves when the agent asks this command to stop.
238async fn stop_requested() {
239    let interrupt = async {
240        let _ignored = tokio::signal::ctrl_c().await;
241    };
242    #[cfg(unix)]
243    let terminate = async {
244        if let Ok(mut signal) =
245            tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
246        {
247            signal.recv().await;
248        }
249    };
250    #[cfg(not(unix))]
251    let terminate = std::future::pending::<()>();
252    tokio::select! {
253        () = interrupt => {},
254        () = terminate => {},
255    }
256}
257
258/// A panel URL and, when this run minted it, what the host said about its
259/// lifetime. Nothing here is emitted except through `credential_free_panel_url`.
260struct Panel {
261    url: String,
262    expires_at_rfc3339: Option<String>,
263    ttl_s: Option<u64>,
264}
265
266async fn resolve_panel(args: TakeoverArgs) -> Result<Panel, Error> {
267    match args {
268        TakeoverArgs::Open {
269            takeover_url_secret,
270        } => Ok(Panel {
271            url: takeover_url_secret,
272            expires_at_rfc3339: None,
273            ttl_s: None,
274        }),
275        TakeoverArgs::Mint { connection } => {
276            let client = connection.client().await?;
277            let handoff = client.takeover_handoff(None, None).await?;
278            Ok(Panel {
279                url: handoff.takeover_url_secret,
280                expires_at_rfc3339: Some(handoff.takeover_url_expires_at_rfc3339),
281                ttl_s: Some(handoff.takeover_url_ttl_s),
282            })
283        }
284    }
285}
286
287/// The panel without its query, which is where the credential lives. Every
288/// event this command emits names the panel this way, so the secret reaches the
289/// window and nothing else. Same spelling as `capabilities.takeover.panel_url`,
290/// which is credential-free for the same reason.
291fn credential_free_panel_url(url: &str) -> Result<String, Error> {
292    let mut panel = url::Url::parse(url).map_err(|error| {
293        Error::new(
294            ErrorCode::InvalidEndpoint,
295            format!("--takeover-url-secret: {url:?} is not a URL: {error}"),
296        )
297    })?;
298    panel.set_query(None);
299    panel.set_fragment(None);
300    Ok(panel.to_string())
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn the_reported_panel_url_drops_the_credential() {
309        let panel = credential_free_panel_url(
310            "http://127.0.0.1:9222/takeover/panel?handoff_secret=deadbeef#frag",
311        )
312        .unwrap();
313        assert_eq!(panel, "http://127.0.0.1:9222/takeover/panel");
314    }
315
316    #[test]
317    fn a_non_url_panel_is_an_argument_error_not_a_launch() {
318        let error = credential_free_panel_url("not a url").unwrap_err();
319        assert_eq!(error.error_code, ErrorCode::InvalidEndpoint);
320    }
321
322    #[test]
323    fn neither_emitted_event_carries_the_secret() {
324        const SECRET: &str = "0123456789abcdef";
325        let url = format!("http://127.0.0.1:9222/takeover/panel?handoff_secret={SECRET}");
326        let panel = credential_free_panel_url(&url).unwrap();
327        let ready = serde_json::to_string(&UiReady {
328            panel_url: panel.clone(),
329            session: SESSION_KIND,
330            delivery: Delivery::Listed.as_str(),
331            session_id: Some("aabbccdd".to_string()),
332            takeover_url_expires_at_rfc3339: Some("2026-06-11T00:00:00Z".into()),
333            takeover_url_ttl_s: Some(900),
334        })
335        .unwrap();
336        let done = serde_json::to_string(&UiTakeoverResult {
337            panel_url: panel,
338            session: SESSION_KIND,
339            delivery: Delivery::Listed.as_str(),
340            outcome: "expired",
341            open_s: 12,
342        })
343        .unwrap();
344        assert!(!ready.contains(SECRET), "{ready}");
345        assert!(!ready.contains("handoff_secret"), "{ready}");
346        assert!(!done.contains(SECRET), "{done}");
347        assert!(!done.contains("handoff_secret"), "{done}");
348    }
349
350    /// The panel is announced under identifiers AFUI accepts. Getting these
351    /// wrong is a runtime error at the one moment a person is waiting.
352    #[test]
353    fn the_panel_is_announced_under_identifiers_afui_accepts() {
354        let announced = UiUpstream::new(
355            PROVIDER_ID,
356            UI_KIND,
357            "http://127.0.0.1:9222/takeover/panel?handoff_secret=deadbeef",
358        );
359        assert!(announced.is_ok());
360    }
361}