Skip to main content

agent_first_http/cli/cmd/
takeover.rs

1//! `afhttp takeover` workflows. These commands prepare a browser tab and print
2//! the URL a human should open for manual login/captcha/2FA handling.
3
4use clap::{Args as ClapArgs, Subcommand};
5use serde::Serialize;
6
7use crate::cli::output;
8use crate::sdk::endpoint::Endpoint;
9use crate::sdk::Client;
10use crate::shared::error::{Error, ErrorCode};
11use crate::shared::ids::TabId;
12
13#[derive(ClapArgs, Debug)]
14pub struct Args {
15    #[command(subcommand)]
16    pub sub: TakeoverSub,
17}
18
19#[derive(Subcommand, Debug)]
20pub enum TakeoverSub {
21    /// Open a persistent tab, navigate it, and print the takeover URL.
22    Prepare(PrepareArgs),
23}
24
25#[derive(ClapArgs, Debug)]
26pub struct PrepareArgs {
27    /// URL to open in the takeover tab.
28    pub url: String,
29    /// CDP endpoint of the running host. Defaults to the container host port.
30    #[arg(long = "endpoint-url", default_value = "ws://127.0.0.1:9222")]
31    pub endpoint: String,
32    /// Bearer token, if the host was started with `--token-secret`.
33    #[arg(long = "token-secret")]
34    pub token: Option<String>,
35    /// Prefer the real display takeover URL and warn when the host lacks it.
36    #[arg(long = "hard-site")]
37    pub hard_site: bool,
38}
39
40#[derive(Debug, Serialize)]
41struct PrepareResult {
42    url: String,
43    endpoint: String,
44    tab_id: String,
45    screencast_url: String,
46    display_url: String,
47    recommended_url: String,
48    recommended_url_kind: String,
49    display_provider: Option<String>,
50    hard_site: bool,
51    #[serde(skip_serializing_if = "Vec::is_empty")]
52    warnings: Vec<String>,
53}
54
55#[derive(Debug, Clone)]
56struct TakeoverUrls {
57    screencast_url: String,
58    display_url: String,
59}
60
61pub async fn run(args: Args) -> Result<(), Error> {
62    match args.sub {
63        TakeoverSub::Prepare(a) => prepare(a).await,
64    }
65}
66
67async fn prepare(args: PrepareArgs) -> Result<(), Error> {
68    let mut client = Client::connect(&args.endpoint)?;
69    if let Some(token) = args.token.as_deref() {
70        client = client.with_token(token);
71    }
72    let urls = build_urls(&args.endpoint, args.token.as_deref())?;
73    let mut warnings = Vec::new();
74
75    let health = client.health().await.map_err(|e| {
76        Error::new(
77            e.error_code,
78            format!(
79                "takeover prepare could not reach host at {}; run `afhttp container status` or `afhttp container install` for a local host; for hard sites use `afhttp container install --from-source --with kasmvnc -- --takeover display --display-provider kasmvnc`: {}",
80                args.endpoint, e.detail
81            ),
82        )
83        .with_retryable(e.retryable)
84    })?;
85    if health.status != "ok" {
86        if let Some(backend_error) = health.backend_error {
87            return Err(Error::new(
88                backend_error.error_code,
89                format!(
90                    "takeover prepare requires a ready browser backend; /health status={} backend_error={}",
91                    health.status, backend_error.error
92                ),
93            ));
94        }
95        warnings.push(format!(
96            "/health status was {}; attempting to prepare a tab anyway",
97            health.status
98        ));
99    }
100
101    let mut recommended_url = urls.screencast_url.clone();
102    let mut recommended_url_kind = "screencast_url".to_string();
103    let mut display_provider = None;
104    match client.capabilities().await {
105        Ok(caps) => {
106            if caps.ops_panel.display {
107                recommended_url = urls.display_url.clone();
108                recommended_url_kind = "display_url".into();
109                display_provider = caps.ops_panel.display_provider;
110            } else if args.hard_site {
111                warnings.push(
112                    "hard-site requested, but this host does not expose display takeover; start a host with `--takeover display --display-provider kasmvnc` for more reliable human input. This is not a captcha bypass.".into(),
113                );
114            } else if caps.backend.family == "camoufox" || !caps.ops_panel.screencast {
115                warnings.push(
116                    "screencast takeover may be unavailable or limited for this backend; use a real-display takeover host for hard sites. This is not a captcha bypass.".into(),
117                );
118            }
119        }
120        Err(e) => warnings.push(format!(
121            "could not read /capabilities; defaulting recommended_url to screencast_url: {}",
122            e.detail
123        )),
124    }
125
126    let tab_id = create_target(&client).await?;
127    let tab = TabId::new(tab_id.clone());
128    let _ = client.cdp("Page.enable").tab(tab.clone()).send().await;
129    let navigate = client
130        .cdp("Page.navigate")
131        .tab(tab)
132        .params(serde_json::json!({ "url": args.url }))
133        .send()
134        .await?;
135    if let Some(err) = navigate.get("errorText").and_then(|v| v.as_str()) {
136        if !err.is_empty() {
137            return Err(Error::new(
138                ErrorCode::NavigationTimeout,
139                format!("Page.navigate for takeover tab returned {err}"),
140            ));
141        }
142    }
143
144    output::emit(
145        "takeover_prepare",
146        &PrepareResult {
147            url: args.url,
148            endpoint: args.endpoint,
149            tab_id,
150            screencast_url: urls.screencast_url,
151            display_url: urls.display_url,
152            recommended_url,
153            recommended_url_kind,
154            display_provider,
155            hard_site: args.hard_site,
156            warnings,
157        },
158    )
159}
160
161async fn create_target(client: &Client) -> Result<String, Error> {
162    let target = client
163        .cdp("Target.createTarget")
164        .params(serde_json::json!({"url": "about:blank"}))
165        .send()
166        .await?;
167    target
168        .get("targetId")
169        .and_then(|v| v.as_str())
170        .filter(|s| !s.is_empty())
171        .map(str::to_string)
172        .ok_or_else(|| Error::new(ErrorCode::CdpError, "Target.createTarget: missing targetId"))
173}
174
175fn build_urls(endpoint: &str, token: Option<&str>) -> Result<TakeoverUrls, Error> {
176    let endpoint = Endpoint::parse(endpoint)?;
177    let base = endpoint.http_base();
178    let mut screencast_url = url::Url::parse(&format!("{base}/ops/screencast")).map_err(|e| {
179        Error::new(
180            ErrorCode::InvalidEndpoint,
181            format!("takeover screencast URL from endpoint {base:?}: {e}"),
182        )
183    })?;
184    let mut display_url = url::Url::parse(&format!("{base}/ops/display")).map_err(|e| {
185        Error::new(
186            ErrorCode::InvalidEndpoint,
187            format!("takeover display URL from endpoint {base:?}: {e}"),
188        )
189    })?;
190    if let Some(token) = token {
191        screencast_url
192            .query_pairs_mut()
193            .append_pair("token_secret", token);
194        display_url
195            .query_pairs_mut()
196            .append_pair("token_secret", token);
197    }
198    let screencast_url = screencast_url.to_string();
199    Ok(TakeoverUrls {
200        screencast_url,
201        display_url: display_url.to_string(),
202    })
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn takeover_urls_encode_token() {
211        let urls = build_urls("http://localhost:9222", Some("a+b&c%20")).unwrap();
212        assert_eq!(
213            urls.screencast_url,
214            "http://localhost:9222/ops/screencast?token_secret=a%2Bb%26c%2520"
215        );
216        assert_eq!(
217            urls.display_url,
218            "http://localhost:9222/ops/display?token_secret=a%2Bb%26c%2520"
219        );
220    }
221
222    #[test]
223    fn prepare_result_exposes_recommended_url() {
224        let value = serde_json::to_value(PrepareResult {
225            url: "https://example.com".into(),
226            endpoint: "ws://127.0.0.1:9222".into(),
227            tab_id: "tab-1".into(),
228            screencast_url: "http://127.0.0.1:9222/ops/screencast".into(),
229            display_url: "http://127.0.0.1:9222/ops/display".into(),
230            recommended_url: "http://127.0.0.1:9222/ops/display".into(),
231            recommended_url_kind: "display_url".into(),
232            display_provider: Some("kasmvnc".into()),
233            hard_site: true,
234            warnings: Vec::new(),
235        })
236        .unwrap();
237        assert_eq!(value["code"], serde_json::Value::Null);
238        assert!(value.get("panel_url").is_none());
239        assert_eq!(value["recommended_url_kind"], "display_url");
240    }
241}