Skip to main content

browser_control/cli/
type_cmd.rs

1//! `browser-control type` — type into the focused element.
2//!
3//! Exists chiefly so a secret can reach a login form without passing through
4//! the agent driving the login:
5//!
6//! ```sh
7//! op read op://Automation/site/password |
8//!   browser-control type -b brave/login --stdin --submit
9//! ```
10//!
11//! The value travels an OS pipe between two processes the agent spawned. It
12//! is never a tool result, never a log line, and never in the agent's
13//! context — only the reference (`op://…`) is, which is not a secret.
14//!
15//! Any vault works: anything that prints a secret on stdout is a resolver
16//! (`op read`, `bw get password`, `vault kv get -field`, `security
17//! find-internet-password -w`, `browser-control vault read`). browser-control
18//! knows about none of them.
19//!
20//! Targets the **focused** element rather than a ref, because refs live in
21//! the MCP server's state and a separate CLI process cannot see them. Focus
22//! the field first with a ref-based click over MCP, or by tabbing to it.
23
24use std::io::Read;
25use std::time::Duration;
26
27use anyhow::{bail, Result};
28
29use crate::cli::env_resolver::Source;
30use crate::cli::route;
31use crate::cli::trace::CommandTrace;
32use crate::session::backend::open_backend;
33use crate::session::with_scratch_recovery;
34
35const TYPE_TIMEOUT: Duration = Duration::from_secs(30);
36
37pub async fn run(
38    browser: Option<String>,
39    text: Option<String>,
40    stdin: bool,
41    submit: bool,
42    press_sequentially: bool,
43    target: Option<String>,
44) -> Result<()> {
45    let mut trace = CommandTrace::new("type");
46    // Resolve the value before touching the browser, so a bad invocation
47    // costs nothing and never half-fills a form.
48    let value = match (text, stdin) {
49        (Some(_), true) => bail!("`--text` and `--stdin` are mutually exclusive; pass one"),
50        (None, false) => bail!("one of `--text` or `--stdin` is required"),
51        (Some(t), false) => t,
52        (None, true) => read_stdin()?,
53    };
54    let result = run_inner(
55        browser,
56        &value,
57        submit,
58        press_sequentially,
59        target,
60        &mut trace,
61    )
62    .await;
63    trace.finish(result)?;
64    // Report the length, never the value.
65    println!("typed {} characters", value.chars().count());
66    Ok(())
67}
68
69/// Read the secret from stdin.
70///
71/// Every rejection here is a real failure mode of a vault CLI, and every one
72/// of them would otherwise be typed verbatim into a password field:
73/// an empty read means a mis-scoped token, and a multi-line read means the
74/// resolver printed a banner or more than one secret.
75fn read_stdin() -> Result<String> {
76    let mut buf = String::new();
77    std::io::stdin()
78        .read_to_string(&mut buf)
79        .map_err(|e| anyhow::anyhow!("reading stdin: {e}"))?;
80    // One trailing newline is normal from a CLI; strip exactly that.
81    let value = buf.strip_suffix('\n').unwrap_or(&buf);
82    let value = value.strip_suffix('\r').unwrap_or(value);
83    if value.trim().is_empty() {
84        bail!("stdin was empty; the resolver produced no value (a mis-scoped token?)");
85    }
86    if value.contains('\n') {
87        bail!(
88            "stdin held {} lines; a resolver should print exactly one secret \
89             (a warning banner on stdout?)",
90            value.lines().count()
91        );
92    }
93    Ok(value.to_string())
94}
95
96async fn run_inner(
97    browser: Option<String>,
98    value: &str,
99    submit: bool,
100    press_sequentially: bool,
101    target: Option<String>,
102    trace: &mut CommandTrace,
103) -> Result<()> {
104    let r = route::preamble(browser, target.as_deref(), trace).await?;
105    let resolved = &r.resolved;
106
107    match (r.tab_name.clone(), target) {
108        // Path 1: <browser>/<tab> — named tab with recover-once.
109        (Some(name), None) => {
110            trace.route("named-tab").tab_name(&name);
111            let value = value.to_string();
112            route::run_named_tab(
113                &r,
114                &name,
115                "named tabs (`<browser>/<name>`) require a registered browser; \
116                 external endpoints can't carry tab names",
117                move |b, target_id| {
118                    let value = value.clone();
119                    async move {
120                        b.type_into_focused(
121                            &target_id,
122                            &value,
123                            press_sequentially,
124                            submit,
125                            TYPE_TIMEOUT,
126                        )
127                        .await
128                    }
129                },
130            )
131            .await
132        }
133        // Path 2: bare browser → scratch tab with recover-once.
134        (None, None) => {
135            let browser_name = match &resolved.source {
136                Source::Registered { name } => name.clone(),
137                Source::External => bail!(
138                    "`type` needs a registered browser or an explicit `--target`; \
139                     external endpoints have no tab to focus"
140                ),
141            };
142            trace.route("scratch");
143            let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
144            let value = value.to_string();
145            with_scratch_recovery(&backend, &r.registry, &browser_name, move |b, target_id| {
146                let value = value.clone();
147                async move {
148                    b.type_into_focused(
149                        &target_id,
150                        &value,
151                        press_sequentially,
152                        submit,
153                        TYPE_TIMEOUT,
154                    )
155                    .await
156                }
157            })
158            .await
159        }
160        // Path 3: bare browser, --target regex. Resolved through the backend
161        // we then use, not a throwaway PageSession: BiDi permits only one
162        // session per browser, so attaching twice fails with "Maximum number
163        // of active sessions".
164        (None, Some(regex)) => {
165            trace.route("target-regex");
166            let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
167            let target_id = target_matching(&backend, &regex).await?;
168            let out = backend
169                .type_into_focused(&target_id, value, press_sequentially, submit, TYPE_TIMEOUT)
170                .await;
171            backend.release().await;
172            out
173        }
174        _ => unreachable!("mutual exclusion checked in preamble"),
175    }
176}
177
178/// First live target whose URL matches `regex`, using an existing backend.
179///
180/// Deliberately not `PageSession::attach`: that opens a second connection,
181/// and BiDi permits only one session per browser.
182pub(crate) async fn target_matching(
183    backend: &crate::session::backend::TabBackend,
184    regex: &str,
185) -> Result<String> {
186    let re = regex::Regex::new(regex).map_err(|e| anyhow::anyhow!("bad --target regex: {e}"))?;
187    let targets = backend.live_targets().await?;
188    targets
189        .into_iter()
190        .find(|t| re.is_match(&t.url))
191        .map(|t| t.id)
192        .ok_or_else(|| anyhow::anyhow!("no tab matched `{regex}`"))
193}
194
195#[cfg(test)]
196mod tests {
197
198    // read_stdin is exercised through its rejection paths, which are the
199    // ones that would otherwise put junk into a password field.
200
201    #[test]
202    fn trailing_newline_is_stripped_but_inner_content_kept() {
203        // Simulated by exercising the same trimming logic.
204        let cases = [
205            ("hunter2\n", "hunter2"),
206            ("hunter2\r\n", "hunter2"),
207            ("hunter2", "hunter2"),
208        ];
209        for (raw, want) in cases {
210            let v = raw.strip_suffix('\n').unwrap_or(raw);
211            let v = v.strip_suffix('\r').unwrap_or(v);
212            assert_eq!(v, want);
213        }
214    }
215
216    #[test]
217    fn a_password_may_contain_spaces_and_symbols() {
218        let raw = "p@ss word!+/=\n";
219        let v = raw.strip_suffix('\n').unwrap();
220        assert_eq!(v, "p@ss word!+/=");
221        assert!(!v.trim().is_empty());
222        assert!(!v.contains('\n'));
223    }
224}