eoka-cli 0.3.7

Browser automation CLI for AI agents and pentesting
use clap::{Args, Subcommand};
use eoka_protocol::{DownloadIdArgs, DownloadStartArgs, DownloadWaitArgs, Request, Response};
use std::path::PathBuf;

#[derive(Args)]
#[command(args_conflicts_with_subcommands = true, subcommand_negates_reqs = true)]
#[command(group(clap::ArgGroup::new("download_trigger").args(["click", "url"]).required(true).multiple(false)))]
pub struct DownloadCli {
    #[command(subcommand)]
    pub action: Option<DownloadAction>,
    #[arg(
        long,
        help = "Existing click target: @e1, text:Download, or css:button"
    )]
    pub click: Option<String>,
    #[arg(long, help = "Direct HTTP(S) attachment URL")]
    pub url: Option<String>,
    #[arg(
        long = "out",
        required = true,
        help = "New output filename; parent must exist"
    )]
    pub output: Option<PathBuf>,
    #[arg(long)]
    pub expect_bytes: Option<u64>,
    #[arg(long, default_value_t = 30_000)]
    pub start_timeout_ms: u64,
    #[arg(long, help = "Wait for this job without blocking other commands")]
    pub wait: bool,
    #[arg(long, default_value_t = 30_000)]
    pub timeout_ms: u64,
}

#[derive(Subcommand)]
pub enum DownloadAction {
    List,
    Status {
        id: String,
    },
    Wait {
        id: String,
        #[arg(long, default_value_t = 30_000)]
        timeout_ms: u64,
    },
    Cancel {
        id: String,
    },
}

impl DownloadCli {
    pub fn request(&self) -> Result<Request, String> {
        Ok(match &self.action {
            Some(DownloadAction::List) => Request::DownloadList,
            Some(DownloadAction::Status { id }) => {
                Request::DownloadGet(DownloadIdArgs { id: id.clone() })
            }
            Some(DownloadAction::Wait { id, timeout_ms }) => {
                Request::DownloadWait(DownloadWaitArgs {
                    id: id.clone(),
                    timeout_ms: *timeout_ms,
                })
            }
            Some(DownloadAction::Cancel { id }) => {
                Request::DownloadCancel(DownloadIdArgs { id: id.clone() })
            }
            None => {
                let output = self.output.as_ref().ok_or("--out is required")?;
                let output = if output.is_absolute() {
                    output.clone()
                } else {
                    std::env::current_dir()
                        .map_err(|e| e.to_string())?
                        .join(output)
                };
                let output = output
                    .to_str()
                    .ok_or("Output path must be valid UTF-8")?
                    .to_owned();
                let args = DownloadStartArgs {
                    click: self.click.clone(),
                    url: self.url.clone(),
                    output,
                    expect_bytes: self.expect_bytes,
                    start_timeout_ms: self.start_timeout_ms,
                };
                args.validate()?;
                Request::DownloadStart(args)
            }
        })
    }
}

pub async fn run(
    args: &DownloadCli,
    session: &str,
    spec: crate::launch_spec::LaunchSpec,
    json: bool,
) -> Response {
    let request = match args.request() {
        Ok(request) => request,
        Err(error) => return Response::err(error),
    };
    let response = match crate::client::send_command(session, request, spec.clone()).await {
        Ok(response) => response,
        Err(error) => return Response::err(error.to_string()),
    };
    if !response.ok || args.action.is_some() || !args.wait {
        return response;
    }
    let Some(id) = response.data.as_ref().and_then(|data| data["id"].as_str()) else {
        return response;
    };
    if !json {
        eprintln!("Download {id} started; waiting for the saved file");
    }
    let mut result = match crate::client::send_command(
        session,
        Request::DownloadWait(DownloadWaitArgs {
            id: id.into(),
            timeout_ms: args.timeout_ms,
        }),
        spec,
    )
    .await
    {
        Ok(response) => response,
        Err(error) => Response::err_detail(
            eoka_protocol::ErrorDetail::new("download_transport_error", error.to_string())
                .retryable(false),
        ),
    };
    if let Some(detail) = result.error_detail.as_mut() {
        detail
            .context
            .get_or_insert_with(Default::default)
            .download_id
            .get_or_insert_with(|| id.to_owned());
        detail.hint.get_or_insert_with(|| {
            format!("Inspect download status {id} or wait again; do not replay the start action.")
        });
    }
    result
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::Parser;

    #[tokio::test]
    async fn lost_wait_response_keeps_known_id_without_replaying_start() {
        use eoka_protocol::{read_msg, write_msg};
        use tokio::net::UnixListener;
        let session = format!("download-wait-loss-{}", std::process::id());
        crate::session::ensure_runtime_dir().unwrap();
        let path = crate::session::socket_path(&session);
        let listener = UnixListener::bind(&path).unwrap();
        let server = tokio::spawn(async move {
            let (mut start, _) = listener.accept().await.unwrap();
            assert!(matches!(
                read_msg::<_, Request>(&mut start).await.unwrap(),
                Request::DownloadStart(_)
            ));
            write_msg(
                &mut start,
                &Response::ok(serde_json::json!({"id":"known-job","state":"pending"})),
            )
            .await
            .unwrap();
            drop(start);
            let (mut wait, _) = listener.accept().await.unwrap();
            let request: Request = read_msg(&mut wait).await.unwrap();
            assert!(matches!(request, Request::DownloadWait(ref args) if args.id == "known-job"));
            drop(wait);
            assert!(
                tokio::time::timeout(std::time::Duration::from_millis(150), listener.accept())
                    .await
                    .is_err()
            );
        });
        let cli = crate::cli::Cli::try_parse_from([
            "eoka",
            "download",
            "--click",
            "css:button",
            "--out",
            "unused.bin",
            "--wait",
        ])
        .unwrap();
        let Some(crate::cli::Command::Download(args)) = cli.command else {
            panic!()
        };
        let response = run(
            &args,
            &session,
            crate::launch_spec::LaunchSpec::Connect {
                ws_url: "unused".into(),
            },
            true,
        )
        .await;
        assert!(!response.ok);
        assert!(response.data.is_none());
        let error = response.error_detail.unwrap();
        assert_eq!(error.code, "download_transport_error");
        assert_eq!(
            error.context.unwrap().download_id.as_deref(),
            Some("known-job")
        );
        server.await.unwrap();
        std::fs::remove_file(path).unwrap();
    }

    #[test]
    fn cli_triggers_are_exclusive_and_paths_are_absolute() {
        for args in [
            vec!["eoka", "download", "--out", "file"],
            vec![
                "eoka",
                "download",
                "--click",
                "@e1",
                "--url",
                "https://example.test/file",
                "--out",
                "file",
            ],
            vec!["eoka", "download", "--click", "@e1"],
        ] {
            assert!(crate::cli::Cli::try_parse_from(args).is_err());
        }
        let cli = crate::cli::Cli::try_parse_from([
            "eoka", "download", "--click", "@e1", "--out", "file", "--wait",
        ])
        .unwrap();
        let Some(crate::cli::Command::Download(args)) = cli.command else {
            panic!()
        };
        assert!(args.wait);
        let Request::DownloadStart(request) = args.request().unwrap() else {
            panic!()
        };
        assert_eq!(
            PathBuf::from(request.output),
            std::env::current_dir().unwrap().join("file")
        );
        for args in [
            vec!["eoka", "download", "list"],
            vec!["eoka", "download", "status", "job"],
            vec!["eoka", "download", "wait", "job"],
            vec!["eoka", "download", "cancel", "job"],
        ] {
            assert!(crate::cli::Cli::try_parse_from(args).is_ok());
        }
    }
}