Skip to main content

agent_first_http/cli/cmd/
panel.rs

1//! `afhttp panel` subcommand. Prints a short-lived takeover URL for an endpoint.
2
3use serde::Serialize;
4
5use crate::cli::output;
6use crate::sdk::Client;
7use crate::shared::error::Error;
8
9#[derive(Debug)]
10pub struct Args {
11    pub endpoint: String,
12    pub token: Option<String>,
13}
14
15#[derive(Serialize)]
16struct PanelResult {
17    takeover_url_secret: String,
18    takeover_url_expires_at_rfc3339: String,
19    takeover_url_ttl_s: u64,
20    takeover_url_scope: String,
21    provider: Option<String>,
22    #[serde(skip_serializing_if = "Vec::is_empty")]
23    warnings: Vec<String>,
24}
25
26pub async fn run(args: Args) -> Result<(), Error> {
27    let result = build_result(&args.endpoint, args.token.as_deref()).await?;
28    output::emit_revealing_takeover("panel", &result)
29}
30
31async fn build_result(endpoint: &str, token: Option<&str>) -> Result<PanelResult, Error> {
32    let mut client = Client::connect(endpoint)?;
33    if let Some(token) = token {
34        client = client.with_token(token);
35    }
36    let handoff = client.takeover_handoff(None, None).await?;
37    let mut warnings = Vec::new();
38    let mut provider = None;
39    match client.capabilities().await {
40        Ok(caps) => {
41            if caps.takeover.supported {
42                provider = caps.takeover.provider;
43            } else {
44                warnings.push(
45                    "this host has no takeover panel; build a takeover-ready host with `afhttp container install` and reconnect. This is not a captcha bypass.".into(),
46                );
47            }
48        }
49        Err(e) => {
50            warnings.push(format!(
51                "could not read host capabilities; returning the takeover_url_secret anyway: {}",
52                e.detail
53            ));
54        }
55    }
56    Ok(PanelResult {
57        takeover_url_secret: handoff.takeover_url_secret,
58        takeover_url_expires_at_rfc3339: handoff.takeover_url_expires_at_rfc3339,
59        takeover_url_ttl_s: handoff.takeover_url_ttl_s,
60        takeover_url_scope: handoff.takeover_url_scope,
61        provider,
62        warnings,
63    })
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn panel_result_exposes_takeover_url_secret_only() {
72        let value = serde_json::to_value(PanelResult {
73            takeover_url_secret: "http://localhost:9222/takeover/panel?handoff_secret=h".into(),
74            takeover_url_expires_at_rfc3339: "2026-06-11T00:00:00Z".into(),
75            takeover_url_ttl_s: 900,
76            takeover_url_scope: "takeover".into(),
77            provider: Some("kasmvnc".into()),
78            warnings: Vec::new(),
79        })
80        .unwrap();
81        assert!(value.get("url").is_none());
82        assert!(value.get("display_url").is_none());
83        assert!(value.get("panel_url").is_none());
84        assert!(value.get("token_secret").is_none());
85        assert!(
86            value["takeover_url_secret"]
87                .as_str()
88                .unwrap()
89                .contains("handoff_secret=")
90        );
91        assert!(value.get("provider").is_some());
92    }
93}