Skip to main content

agent_first_http/cli/cmd/
upload.rs

1//! `afhttp upload` subcommand — inject a local file into an `<input type=file>`
2//! using the privileged CDP `DOM.setFileInputFiles` primitive.
3
4use std::path::PathBuf;
5
6use clap::Args as ClapArgs;
7use serde::Serialize;
8
9use crate::cli::output;
10use crate::sdk::Client;
11use crate::shared::error::{Error, ErrorCode};
12use crate::shared::ids::TabId;
13
14#[derive(ClapArgs, Debug)]
15pub struct Args {
16    /// CDP endpoint of the running host (e.g. `ws://127.0.0.1:9222`). Falls back to `AFHTTP_ENDPOINT_URL`.
17    #[arg(long = "endpoint-url", env = "AFHTTP_ENDPOINT_URL")]
18    pub endpoint: String,
19    /// Bearer token, if the host was started with `--token-secret`.
20    /// Falls back to `AFHTTP_TOKEN_SECRET`.
21    #[arg(long = "token-secret", env = "AFHTTP_TOKEN_SECRET")]
22    pub token: Option<String>,
23    /// CDP target id (tab) to operate in.
24    #[arg(long)]
25    pub tab: String,
26    /// CSS selector for the `<input type=file>` element.
27    #[arg(long)]
28    pub selector: String,
29    /// Local file path to upload.
30    #[arg(long)]
31    pub file: PathBuf,
32}
33
34#[derive(Serialize)]
35struct UploadResult {
36    tab_id: String,
37    selector: String,
38    path: String,
39    size_bytes: u64,
40}
41
42pub async fn run(args: Args) -> Result<(), Error> {
43    let file_meta = tokio::fs::metadata(&args.file).await.map_err(|e| {
44        Error::new(
45            ErrorCode::IoError,
46            format!("upload: stat {}: {e}", args.file.display()),
47        )
48    })?;
49    let bytes = file_meta.len();
50
51    let abs_path = tokio::fs::canonicalize(&args.file).await.map_err(|e| {
52        Error::new(
53            ErrorCode::IoError,
54            format!("upload: canonicalize {}: {e}", args.file.display()),
55        )
56    })?;
57    let path_str = abs_path.to_string_lossy().to_string();
58
59    let mut client = Client::connect(&args.endpoint)?;
60    if let Some(t) = args.token.as_deref() {
61        client = client.with_token(t);
62    }
63
64    let conn = client.cdp_connection().await?;
65    let tab = TabId::new(args.tab.clone());
66    let session_id = crate::sdk::cdp::session::attach_to_target(&conn, tab.as_str()).await?;
67
68    conn.send("DOM.enable", &serde_json::json!({}), Some(&session_id))
69        .await?;
70    let document = conn
71        .send(
72            "DOM.getDocument",
73            &serde_json::json!({"depth": 1, "pierce": true}),
74            Some(&session_id),
75        )
76        .await?;
77    let root_id = document["root"]["nodeId"].as_i64().ok_or_else(|| {
78        Error::new(
79            ErrorCode::CdpError,
80            "upload: DOM.getDocument missing root nodeId",
81        )
82    })?;
83    let query = conn
84        .send(
85            "DOM.querySelector",
86            &serde_json::json!({"nodeId": root_id, "selector": args.selector}),
87            Some(&session_id),
88        )
89        .await?;
90    let node_id = query["nodeId"]
91        .as_i64()
92        .filter(|id| *id > 0)
93        .ok_or_else(|| {
94            Error::new(
95                ErrorCode::InvalidArgument,
96                format!(
97                    "upload: selector {:?} did not match an element",
98                    args.selector
99                ),
100            )
101        })?;
102    ensure_file_input(&conn, &session_id, node_id, &args.selector).await?;
103    conn.send(
104        "DOM.setFileInputFiles",
105        &serde_json::json!({
106            "files": [path_str],
107            "nodeId": node_id,
108        }),
109        Some(&session_id),
110    )
111    .await?;
112
113    let _ = crate::sdk::cdp::session::detach_from_target(&conn, &session_id).await;
114
115    output::emit(
116        "upload",
117        &UploadResult {
118            tab_id: args.tab,
119            selector: args.selector,
120            path: path_str,
121            size_bytes: bytes,
122        },
123    )
124}
125
126async fn ensure_file_input(
127    conn: &crate::sdk::cdp::ws_client::Connection,
128    session_id: &str,
129    node_id: i64,
130    selector: &str,
131) -> Result<(), Error> {
132    let described = conn
133        .send(
134            "DOM.describeNode",
135            &serde_json::json!({"nodeId": node_id}),
136            Some(session_id),
137        )
138        .await?;
139    let node = &described["node"];
140    let is_input = node["nodeName"]
141        .as_str()
142        .is_some_and(|name| name.eq_ignore_ascii_case("input"));
143    let attrs = node["attributes"].as_array().cloned().unwrap_or_default();
144    let mut is_file = false;
145    for pair in attrs.chunks(2) {
146        if pair.first().and_then(|v| v.as_str()) == Some("type")
147            && pair
148                .get(1)
149                .and_then(|v| v.as_str())
150                .is_some_and(|value| value.eq_ignore_ascii_case("file"))
151        {
152            is_file = true;
153        }
154    }
155    if is_input && is_file {
156        Ok(())
157    } else {
158        Err(Error::new(
159            ErrorCode::InvalidArgument,
160            format!("upload: selector {selector:?} must resolve to <input type=file>"),
161        ))
162    }
163}