1use 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
36pub 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 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 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 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 #[must_use]
177 pub fn takeover_url_secret(&self) -> &str {
178 &self.takeover_url_secret
179 }
180
181 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 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}