boxxkite_client/takeover.rs
1//! Human takeover: `WS /v1/sandboxes/{id}/takeover` -- an interactive,
2//! duplex byte stream proxied straight through to the sandbox's PTY, no
3//! message envelope. Mirrors `sdk-python`'s `takeover()` (which
4//! authenticates the WebSocket upgrade with a normal `Authorization: Bearer`
5//! header, unlike the JS SDK/dashboard, which cannot set a custom header on
6//! a browser WebSocket and must mint a short-lived takeover token first --
7//! see `docs/API.md` and `SECURITY.md`'s "Human takeover" section). This
8//! client is not a browser, so it uses the same header-based path as
9//! `sdk-python`.
10
11use tokio::net::TcpStream;
12use tokio_tungstenite::tungstenite::client::IntoClientRequest;
13use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
14use tokio_tungstenite::tungstenite::http::HeaderValue;
15use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
16
17use crate::client::{to_ws_url, Client};
18use crate::error::BoxxkiteError;
19
20/// The duplex byte stream returned by [`Client::takeover`]. Send and
21/// receive raw bytes on it exactly as you would over a local terminal --
22/// there is no separate message envelope.
23pub type TakeoverStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
24
25impl Client {
26 /// `WS /v1/sandboxes/{id}/takeover` -- interactive human takeover of a
27 /// sandbox session's shell.
28 ///
29 /// Requires an **`"admin"`-role** API key (see `POST /v1/api-keys`'s
30 /// `role` field) -- a `"member"`-role key closes the connection with
31 /// close code `4403`. A missing/invalid/expired credential closes with
32 /// `4401`; an unowned or already-destroyed `session_id` closes with
33 /// `4404`. All three surface as a [`BoxxkiteError::WebSocket`] (or, for a
34 /// close that happens after a clean handshake, as a close frame on the
35 /// returned stream itself -- inspect it if the connection ends
36 /// unexpectedly).
37 pub async fn takeover(&self, session_id: &str) -> Result<TakeoverStream, BoxxkiteError> {
38 let ws_url = to_ws_url(
39 &self.base_url,
40 &format!("/v1/sandboxes/{session_id}/takeover"),
41 );
42 let mut request = ws_url.into_client_request()?;
43 let header_value = HeaderValue::from_str(&format!("Bearer {}", self.api_key))
44 .map_err(|err| BoxxkiteError::Config(format!("invalid api_key: {err}")))?;
45 request.headers_mut().insert(AUTHORIZATION, header_value);
46
47 let (stream, _response) = tokio_tungstenite::connect_async(request).await?;
48 Ok(stream)
49 }
50}