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