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