Skip to main content

agent_first_http/sdk/
takeover.rs

1//! Client helpers for human-takeover URLs and process-bound UI leases.
2
3use std::time::Duration;
4
5use agent_first_data::value_source::SecretString;
6use reqwest::StatusCode;
7use serde::{Deserialize, Serialize};
8
9use crate::sdk::client::Client;
10use crate::shared::error::{Error, ErrorCode};
11
12#[derive(Debug, Clone, Serialize)]
13struct TakeoverHandoffRequest<'a> {
14    #[serde(skip_serializing_if = "Option::is_none")]
15    ttl_s: Option<u64>,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    tab_id: Option<&'a str>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct TakeoverHandoffResponse {
22    #[serde(alias = "takeover_url")]
23    pub takeover_url_secret: String,
24    pub takeover_url_expires_at_rfc3339: String,
25    pub takeover_url_ttl_s: u64,
26    pub takeover_url_scope: String,
27}
28
29#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
30struct TakeoverUiSessionResponse {
31    takeover_url_secret: String,
32    takeover_session_idle_timeout_s: u64,
33    takeover_url_scope: String,
34}
35
36/// The private upstream credential behind one AFUI delivery.
37///
38/// AFUI owns the window, public Link page, attention policy, and registered
39/// session. This lease only keeps the separately hosted takeover panel alive
40/// while that delivery exists. Its short idle timeout is crash cleanup, not a
41/// user-visible UI expiry.
42pub struct TakeoverUiSession {
43    client: reqwest::Client,
44    maintenance_url: url::Url,
45    handoff_secret: SecretString,
46    takeover_url_secret: String,
47    keep_alive_every: Duration,
48}
49
50impl Client {
51    /// Mint a short-lived URL capability for `/takeover/*`.
52    pub async fn takeover_handoff(
53        &self,
54        ttl_s: Option<u64>,
55        tab_id: Option<&str>,
56    ) -> Result<TakeoverHandoffResponse, Error> {
57        let endpoint = self.effective_endpoint().await?;
58        let base = endpoint.http_base();
59        let url = format!("{base}/takeover/handoff");
60        let body = TakeoverHandoffRequest { ttl_s, tab_id };
61        let mut req = self.http().post(&url).json(&body);
62        if let Some(token) = self.token() {
63            req = req.bearer_auth(token);
64        }
65        let resp = req
66            .send()
67            .await
68            .map_err(|e| Error::new(ErrorCode::HostUnreachable, format!("POST {url}: {e}")))?;
69        let status = resp.status();
70        let bytes = resp.bytes().await.map_err(|e| {
71            Error::new(
72                ErrorCode::InternalError,
73                format!("takeover_handoff: read response: {e}"),
74            )
75        })?;
76        if !status.is_success() {
77            if let Ok(err) = crate::shared::afdata::decode_error(&bytes) {
78                return Err(err);
79            }
80            return Err(Error::new(
81                ErrorCode::InternalError,
82                format!(
83                    "takeover_handoff: status {status}; failed to decode error envelope: {}",
84                    String::from_utf8_lossy(&bytes)
85                ),
86            ));
87        }
88        crate::shared::afdata::decode_result(&bytes)
89    }
90
91    /// Mint a process-bound upstream credential for an AFUI delivery.
92    pub async fn takeover_ui_session(&self) -> Result<TakeoverUiSession, Error> {
93        let endpoint = self.effective_endpoint().await?;
94        let url = format!("{}/takeover/ui-session", endpoint.http_base());
95        let mut request = self.http().post(&url);
96        if let Some(token) = self.token() {
97            request = request.bearer_auth(token);
98        }
99        let response = request.send().await.map_err(|error| {
100            Error::new(ErrorCode::HostUnreachable, format!("POST {url}: {error}"))
101        })?;
102        let response = decode_ui_session_response(response, "create takeover UI session").await?;
103        TakeoverUiSession::from_response(self.http().clone(), response)
104    }
105}
106
107impl TakeoverUiSession {
108    /// Exchange an already-minted panel handoff for a process-bound UI lease.
109    ///
110    /// The existing handoff authorizes only this exchange; callers do not need
111    /// the host's long-lived API bearer merely to put a URL they already hold
112    /// into AFUI's delivery lifecycle.
113    pub async fn exchange(takeover_url_secret: &str) -> Result<Self, Error> {
114        crate::sdk::client::ensure_rustls_provider();
115        let (maintenance_url, handoff_secret) = maintenance_url(takeover_url_secret)?;
116        let client = reqwest::Client::builder()
117            .no_proxy()
118            .user_agent(concat!("afhttp/", env!("CARGO_PKG_VERSION")))
119            .build()
120            .map_err(|error| {
121                Error::new(
122                    ErrorCode::InternalError,
123                    format!("build takeover UI session client: {error}"),
124                )
125            })?;
126        let response = client
127            .post(maintenance_url.clone())
128            .header(
129                reqwest::header::AUTHORIZATION,
130                handoff_authorization(&handoff_secret),
131            )
132            .send()
133            .await
134            .map_err(|error| {
135                Error::new(
136                    ErrorCode::HostUnreachable,
137                    format!("exchange takeover handoff for UI session: {error}"),
138                )
139            })?;
140        let response =
141            decode_ui_session_response(response, "exchange takeover handoff for UI session")
142                .await?;
143        Self::from_response(client, response)
144    }
145
146    fn from_response(
147        client: reqwest::Client,
148        response: TakeoverUiSessionResponse,
149    ) -> Result<Self, Error> {
150        if response.takeover_url_scope != "takeover_ui_session" {
151            return Err(Error::new(
152                ErrorCode::InternalError,
153                format!(
154                    "takeover UI session: host returned scope {:?}, expected \"takeover_ui_session\"",
155                    response.takeover_url_scope
156                ),
157            ));
158        }
159        if response.takeover_session_idle_timeout_s < 3 {
160            return Err(Error::new(
161                ErrorCode::InternalError,
162                "takeover UI session: host returned an idle timeout shorter than 3 seconds",
163            ));
164        }
165        let (maintenance_url, handoff_secret) = maintenance_url(&response.takeover_url_secret)?;
166        Ok(Self {
167            client,
168            maintenance_url,
169            handoff_secret,
170            takeover_url_secret: response.takeover_url_secret,
171            keep_alive_every: Duration::from_secs(response.takeover_session_idle_timeout_s / 3),
172        })
173    }
174
175    /// The upstream URL AFUI keeps private behind its own delivery.
176    #[must_use]
177    pub fn takeover_url_secret(&self) -> &str {
178        &self.takeover_url_secret
179    }
180
181    /// Renew until this future is cancelled or the host can no longer do so.
182    pub async fn keep_alive(&self) -> Result<(), Error> {
183        loop {
184            tokio::time::sleep(self.keep_alive_every).await;
185            let response = self
186                .client
187                .put(self.maintenance_url.clone())
188                .header(
189                    reqwest::header::AUTHORIZATION,
190                    handoff_authorization(&self.handoff_secret),
191                )
192                .send()
193                .await
194                .map_err(|error| {
195                    Error::new(
196                        ErrorCode::HostUnreachable,
197                        format!("renew takeover UI session: {error}"),
198                    )
199                })?;
200            if !response.status().is_success() {
201                return Err(response_error(response, "renew takeover UI session").await);
202            }
203        }
204    }
205
206    /// Revoke the upstream credential. Already gone is the same outcome.
207    pub async fn revoke(&self) -> Result<bool, Error> {
208        let response = self
209            .client
210            .delete(self.maintenance_url.clone())
211            .header(
212                reqwest::header::AUTHORIZATION,
213                handoff_authorization(&self.handoff_secret),
214            )
215            .send()
216            .await
217            .map_err(|error| {
218                Error::new(
219                    ErrorCode::HostUnreachable,
220                    format!("revoke takeover UI session: {error}"),
221                )
222            })?;
223        match response.status() {
224            StatusCode::NO_CONTENT => Ok(true),
225            StatusCode::NOT_FOUND | StatusCode::UNAUTHORIZED => Ok(false),
226            _ => Err(response_error(response, "revoke takeover UI session").await),
227        }
228    }
229}
230
231fn maintenance_url(takeover_url_secret: &str) -> Result<(url::Url, SecretString), Error> {
232    let mut url = url::Url::parse(takeover_url_secret).map_err(|error| {
233        Error::new(
234            ErrorCode::InvalidEndpoint,
235            format!("takeover UI session: invalid takeover URL: {error}"),
236        )
237    })?;
238    let secret = url
239        .query_pairs()
240        .find(|(key, _)| key == "handoff_secret")
241        .map(|(_, value)| value.into_owned())
242        .filter(|value| value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()))
243        .ok_or_else(|| {
244            Error::new(
245                ErrorCode::InvalidEndpoint,
246                "takeover UI session: URL has no valid handoff_secret",
247            )
248        })?;
249    url.set_path("/takeover/ui-session");
250    url.set_query(None);
251    url.set_fragment(None);
252    Ok((url, SecretString::new(secret)))
253}
254
255fn handoff_authorization(secret: &SecretString) -> String {
256    format!("Handoff {}", secret.expose_secret())
257}
258
259async fn decode_ui_session_response(
260    response: reqwest::Response,
261    operation: &str,
262) -> Result<TakeoverUiSessionResponse, Error> {
263    let status = response.status();
264    let bytes = response.bytes().await.map_err(|error| {
265        Error::new(
266            ErrorCode::InternalError,
267            format!("{operation}: read response: {error}"),
268        )
269    })?;
270    if !status.is_success() {
271        if let Ok(error) = crate::shared::afdata::decode_error(&bytes) {
272            return Err(error);
273        }
274        return Err(Error::new(
275            ErrorCode::InternalError,
276            format!(
277                "{operation}: status {status}; failed to decode error envelope: {}",
278                String::from_utf8_lossy(&bytes)
279            ),
280        ));
281    }
282    crate::shared::afdata::decode_result(&bytes)
283}
284
285async fn response_error(response: reqwest::Response, operation: &str) -> Error {
286    let status = response.status();
287    match response.bytes().await {
288        Ok(bytes) => crate::shared::afdata::decode_error(&bytes).unwrap_or_else(|_| {
289            Error::new(
290                ErrorCode::InternalError,
291                format!("{operation}: host returned {status}"),
292            )
293        }),
294        Err(error) => Error::new(
295            ErrorCode::InternalError,
296            format!("{operation}: host returned {status}; read response: {error}"),
297        ),
298    }
299}