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