Skip to main content

supercode_harness/
browser.rs

1//! Provider-independent browser capability owned by Supercode.
2//!
3//! Supercode owns the operation names, schemas, CLI/MCP projections and
4//! policy boundary. A browser product such as Vibewaiting implements the
5//! versioned provider wire out of process; it never becomes the canonical
6//! agent API. Providers advertise honest fidelity and receive only
7//! structured locator/action payloads — arbitrary JavaScript is not part of
8//! this contract.
9
10use std::cmp::Reverse;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, SystemTime};
13
14use async_trait::async_trait;
15use serde::Deserialize;
16use serde_json::{json, Value};
17use tokio::io::{AsyncReadExt, AsyncWriteExt};
18use tokio::net::TcpStream;
19use tokio::time::timeout;
20
21use crate::error::{Error, Result};
22use crate::tools::{Tool, ToolContext, ToolRegistry};
23
24/// Provider discovery and socket protocol version.
25pub const BROWSER_PROVIDER_PROTOCOL: &str = "supercode/browser-provider-v1";
26/// Structured operation envelope version sent to providers.
27pub const BROWSER_OPERATION_PROTOCOL: &str = "supercode/browser-operation-v1";
28/// Bound for one provider request, including locator values and fill text.
29pub const BROWSER_PROVIDER_MAX_REQUEST_BYTES: usize = 256 * 1024;
30/// Bound for one provider result. Accessibility snapshots are much smaller,
31/// but this leaves room for future bounded image/artifact references.
32pub const BROWSER_PROVIDER_MAX_RESPONSE_BYTES: usize = 1024 * 1024;
33/// End-to-end provider call timeout.
34pub const BROWSER_PROVIDER_TIMEOUT: Duration = Duration::from_secs(12);
35
36/// One operation in the canonical browser registry.
37#[derive(Debug, Clone)]
38pub struct BrowserOperationDefinition {
39    /// Stable name used unchanged by SDK, CLI, MCP and providers.
40    pub name: &'static str,
41    /// CLI shorthand below `supercode browser`.
42    pub cli_name: &'static str,
43    /// Agent-facing description.
44    pub description: &'static str,
45    /// Whether the operation can change page or navigation state.
46    pub mutates_page: bool,
47    /// Permission requested from the Supercode policy layer.
48    pub permission: &'static str,
49    /// JSON Schema for the operation input.
50    pub input_schema: Value,
51}
52
53fn locator_schema() -> Value {
54    json!({
55        "oneOf": [
56            {"type":"object","properties":{"by":{"const":"css"},"value":{"type":"string"}},"required":["by","value"],"additionalProperties":false},
57            {"type":"object","properties":{"by":{"const":"ref"},"value":{"type":"string"}},"required":["by","value"],"additionalProperties":false},
58            {"type":"object","properties":{"by":{"const":"role"},"role":{"type":"string"},"name":{"type":"string"},"exact":{"type":"boolean"}},"required":["by","role"],"additionalProperties":false},
59            {"type":"object","properties":{"by":{"const":"text"},"text":{"type":"string"},"exact":{"type":"boolean"}},"required":["by","text"],"additionalProperties":false},
60            {"type":"object","properties":{"by":{"const":"testId"},"value":{"type":"string"}},"required":["by","value"],"additionalProperties":false},
61            {"type":"object","properties":{"by":{"const":"label"},"text":{"type":"string"},"exact":{"type":"boolean"}},"required":["by","text"],"additionalProperties":false},
62            {"type":"object","properties":{"by":{"const":"placeholder"},"text":{"type":"string"},"exact":{"type":"boolean"}},"required":["by","text"],"additionalProperties":false},
63            {"type":"object","properties":{"by":{"const":"altText"},"text":{"type":"string"},"exact":{"type":"boolean"}},"required":["by","text"],"additionalProperties":false},
64            {"type":"object","properties":{"by":{"const":"title"},"text":{"type":"string"},"exact":{"type":"boolean"}},"required":["by","text"],"additionalProperties":false}
65        ]
66    })
67}
68
69/// A drag endpoint: either an explicit viewport point or a locator's centre.
70fn endpoint_schema() -> Value {
71    json!({
72        "oneOf": [
73            {"type":"object","properties":{"x":{"type":"number"},"y":{"type":"number"}},"required":["x","y"],"additionalProperties":false},
74            {"type":"object","properties":{"locator": locator_schema()},"required":["locator"],"additionalProperties":false}
75        ]
76    })
77}
78
79fn target_properties() -> serde_json::Map<String, Value> {
80    serde_json::Map::from_iter([
81        ("page".into(), json!({"type":"string"})),
82        ("locator".into(), locator_schema()),
83        ("index".into(), json!({"type":"integer","minimum":0})),
84        (
85            "expectedRevision".into(),
86            json!({"type":"integer","minimum":0}),
87        ),
88    ])
89}
90
91/// Return the authoritative operation registry in stable order.
92pub fn browser_operation_registry() -> Vec<BrowserOperationDefinition> {
93    let empty = || json!({"type":"object","properties":{},"additionalProperties":false});
94    let object = |properties: serde_json::Map<String, Value>, required: &[&str]| {
95        json!({
96            "type":"object",
97            "properties": properties,
98            "required": required,
99            "additionalProperties": false
100        })
101    };
102    vec![
103        BrowserOperationDefinition {
104            name: "browser.status",
105            cli_name: "status",
106            description: "Report the available browser provider and its active page fidelity.",
107            mutates_page: false,
108            permission: "browser.read",
109            input_schema: empty(),
110        },
111        BrowserOperationDefinition {
112            name: "browser.snapshot",
113            cli_name: "snapshot",
114            description: "Return a bounded accessibility snapshot with stable page-local refs.",
115            mutates_page: false,
116            permission: "browser.read",
117            input_schema: object(
118                serde_json::Map::from_iter([
119                    ("page".into(), json!({"type":"string"})),
120                    ("locator".into(), locator_schema()),
121                ]),
122                &[],
123            ),
124        },
125        BrowserOperationDefinition {
126            name: "browser.query",
127            cli_name: "query",
128            description: "Resolve a CSS, accessibility-ref, role, text or test-id locator.",
129            mutates_page: false,
130            permission: "browser.read",
131            input_schema: object(target_properties(), &["locator"]),
132        },
133        BrowserOperationDefinition {
134            name: "browser.wait",
135            cli_name: "wait",
136            description: "Wait for a locator to become attached or visible.",
137            mutates_page: false,
138            permission: "browser.read",
139            input_schema: {
140                let mut properties = target_properties();
141                properties.insert("state".into(), json!({"enum":["attached","visible"]}));
142                properties.insert(
143                    "timeout".into(),
144                    json!({"type":"number","minimum":0,"maximum":30000}),
145                );
146                object(properties, &["locator"])
147            },
148        },
149        BrowserOperationDefinition {
150            name: "browser.click",
151            cli_name: "click",
152            description: "Click a page locator through the selected browser provider.",
153            mutates_page: true,
154            permission: "browser.interact",
155            input_schema: object(target_properties(), &["locator"]),
156        },
157        BrowserOperationDefinition {
158            name: "browser.fill",
159            cli_name: "fill",
160            description: "Fill an input, textarea or contenteditable locator.",
161            mutates_page: true,
162            permission: "browser.interact",
163            input_schema: {
164                let mut properties = target_properties();
165                properties.insert("value".into(), json!({"type":"string"}));
166                object(properties, &["locator", "value"])
167            },
168        },
169        BrowserOperationDefinition {
170            name: "browser.press",
171            cli_name: "press",
172            description: "Dispatch one keyboard press to a locator or the active element.",
173            mutates_page: true,
174            permission: "browser.interact",
175            input_schema: {
176                let mut properties = target_properties();
177                properties.insert("key".into(), json!({"type":"string"}));
178                object(properties, &["key"])
179            },
180        },
181        BrowserOperationDefinition {
182            name: "browser.hover",
183            cli_name: "hover",
184            description: "Hover a page locator using synthetic DOM pointer semantics.",
185            mutates_page: true,
186            permission: "browser.interact",
187            input_schema: object(target_properties(), &["locator"]),
188        },
189        BrowserOperationDefinition {
190            name: "browser.focus",
191            cli_name: "focus",
192            description: "Focus a page locator.",
193            mutates_page: true,
194            permission: "browser.interact",
195            input_schema: object(target_properties(), &["locator"]),
196        },
197        BrowserOperationDefinition {
198            name: "browser.check",
199            cli_name: "check",
200            description: "Check a checkbox or radio locator.",
201            mutates_page: true,
202            permission: "browser.interact",
203            input_schema: object(target_properties(), &["locator"]),
204        },
205        BrowserOperationDefinition {
206            name: "browser.uncheck",
207            cli_name: "uncheck",
208            description: "Uncheck a checkbox locator.",
209            mutates_page: true,
210            permission: "browser.interact",
211            input_schema: object(target_properties(), &["locator"]),
212        },
213        BrowserOperationDefinition {
214            name: "browser.select",
215            cli_name: "select",
216            description: "Select one or more options by value or label.",
217            mutates_page: true,
218            permission: "browser.interact",
219            input_schema: {
220                let mut properties = target_properties();
221                properties.insert(
222                    "values".into(),
223                    json!({"type":"array","items":{"type":"string"},"maxItems":100}),
224                );
225                object(properties, &["locator", "values"])
226            },
227        },
228        BrowserOperationDefinition {
229            name: "browser.scroll",
230            cli_name: "scroll",
231            description: "Scroll the selected page in one direction by a bounded amount.",
232            mutates_page: true,
233            permission: "browser.interact",
234            input_schema: object(
235                serde_json::Map::from_iter([
236                    ("page".into(), json!({"type":"string"})),
237                    (
238                        "direction".into(),
239                        json!({"enum":["up","down","left","right"]}),
240                    ),
241                    (
242                        "amount".into(),
243                        json!({"type":"number","minimum":1,"maximum":10000}),
244                    ),
245                    (
246                        "expectedRevision".into(),
247                        json!({"type":"integer","minimum":0}),
248                    ),
249                ]),
250                &["direction"],
251            ),
252        },
253        BrowserOperationDefinition {
254            name: "browser.script",
255            cli_name: "script",
256            description: "Run an author-written Playwright script against the provider's page. `page` is the shared in-page Playwright shim; `args` is passed alongside it; the returned value must be JSON-serializable.",
257            mutates_page: true,
258            permission: "browser.script",
259            input_schema: object(
260                serde_json::Map::from_iter([
261                    ("page".into(), json!({"type":"string"})),
262                    (
263                        "source".into(),
264                        json!({"type":"string","minLength":1,"maxLength":100000}),
265                    ),
266                    ("args".into(), json!({"type":"object"})),
267                    (
268                        "timeout".into(),
269                        json!({"type":"number","minimum":0,"maximum":120000}),
270                    ),
271                ]),
272                &["source"],
273            ),
274        },
275        BrowserOperationDefinition {
276            name: "browser.box",
277            cli_name: "box",
278            description: "Return a locator's bounding box in CSS pixels, for pointer work on canvases and free-form surfaces.",
279            mutates_page: false,
280            permission: "browser.read",
281            input_schema: object(target_properties(), &["locator"]),
282        },
283        BrowserOperationDefinition {
284            name: "browser.mouse",
285            cli_name: "mouse",
286            description: "Move, press, release, or click the pointer at viewport coordinates.",
287            mutates_page: true,
288            permission: "browser.interact",
289            input_schema: object(
290                serde_json::Map::from_iter([
291                    ("page".into(), json!({"type":"string"})),
292                    ("action".into(), json!({"enum":["move","down","up","click"]})),
293                    ("x".into(), json!({"type":"number"})),
294                    ("y".into(), json!({"type":"number"})),
295                ]),
296                &["action"],
297            ),
298        },
299        BrowserOperationDefinition {
300            name: "browser.drag",
301            cli_name: "drag",
302            description: "Press, move, and release the pointer from one point or locator to another.",
303            mutates_page: true,
304            permission: "browser.interact",
305            input_schema: object(
306                serde_json::Map::from_iter([
307                    ("page".into(), json!({"type":"string"})),
308                    ("from".into(), endpoint_schema()),
309                    ("to".into(), endpoint_schema()),
310                    (
311                        "steps".into(),
312                        json!({"type":"integer","minimum":1,"maximum":100}),
313                    ),
314                ]),
315                &["from", "to"],
316            ),
317        },
318        BrowserOperationDefinition {
319            name: "browser.wheel",
320            cli_name: "wheel",
321            description: "Dispatch a wheel event at the pointer position.",
322            mutates_page: true,
323            permission: "browser.interact",
324            input_schema: object(
325                serde_json::Map::from_iter([
326                    ("page".into(), json!({"type":"string"})),
327                    ("deltaX".into(), json!({"type":"number"})),
328                    ("deltaY".into(), json!({"type":"number"})),
329                ]),
330                &[],
331            ),
332        },
333        BrowserOperationDefinition {
334            name: "browser.back",
335            cli_name: "back",
336            description: "Navigate the selected page one entry backward in session history.",
337            mutates_page: true,
338            permission: "browser.interact",
339            input_schema: object(
340                serde_json::Map::from_iter([
341                    ("page".into(), json!({"type":"string"})),
342                    (
343                        "expectedRevision".into(),
344                        json!({"type":"integer","minimum":0}),
345                    ),
346                ]),
347                &[],
348            ),
349        },
350        BrowserOperationDefinition {
351            name: "browser.forward",
352            cli_name: "forward",
353            description: "Navigate the selected page one entry forward in session history.",
354            mutates_page: true,
355            permission: "browser.interact",
356            input_schema: object(
357                serde_json::Map::from_iter([
358                    ("page".into(), json!({"type":"string"})),
359                    (
360                        "expectedRevision".into(),
361                        json!({"type":"integer","minimum":0}),
362                    ),
363                ]),
364                &[],
365            ),
366        },
367        BrowserOperationDefinition {
368            name: "browser.reload",
369            cli_name: "reload",
370            description: "Reload the selected page.",
371            mutates_page: true,
372            permission: "browser.interact",
373            input_schema: object(
374                serde_json::Map::from_iter([
375                    ("page".into(), json!({"type":"string"})),
376                    (
377                        "expectedRevision".into(),
378                        json!({"type":"integer","minimum":0}),
379                    ),
380                ]),
381                &[],
382            ),
383        },
384    ]
385}
386
387/// Resolve a registry entry by canonical or CLI name.
388pub fn browser_operation(name: &str) -> Option<BrowserOperationDefinition> {
389    browser_operation_registry()
390        .into_iter()
391        .find(|operation| operation.name == name || operation.cli_name == name)
392}
393
394/// Directory in which out-of-process browser providers publish owner-only
395/// discovery records. This follows Supercode's user configuration root,
396/// never a project-controlled directory.
397pub fn browser_provider_directory() -> PathBuf {
398    let root = std::env::var_os("SUPERCODE_HOME")
399        .filter(|value| !value.is_empty())
400        .map(PathBuf::from)
401        .or_else(|| {
402            std::env::var_os("XDG_CONFIG_HOME")
403                .filter(|value| !value.is_empty())
404                .map(PathBuf::from)
405                .map(|path| path.join("supercode"))
406        })
407        .or_else(|| {
408            std::env::var_os("HOME")
409                .map(PathBuf::from)
410                .map(|path| path.join(".config").join("supercode"))
411        })
412        .unwrap_or_else(|| std::env::temp_dir().join("supercode"));
413    root.join("providers").join("browser")
414}
415
416#[derive(Debug, Clone, Deserialize)]
417struct ProviderIdentity {
418    id: String,
419    name: String,
420    #[serde(default)]
421    fidelity: Value,
422}
423
424#[derive(Debug, Clone, Deserialize)]
425struct ProviderDiscovery {
426    protocol: String,
427    workspace: String,
428    host: String,
429    port: u16,
430    token: String,
431    provider: ProviderIdentity,
432}
433
434#[derive(Debug)]
435struct DiscoveryCandidate {
436    discovery: ProviderDiscovery,
437    modified: SystemTime,
438}
439
440fn canonical_workspace(path: &Path) -> PathBuf {
441    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
442}
443
444#[cfg(unix)]
445fn owner_only(metadata: &std::fs::Metadata) -> bool {
446    use std::os::unix::fs::PermissionsExt;
447    metadata.permissions().mode() & 0o077 == 0
448}
449
450#[cfg(not(unix))]
451fn owner_only(_metadata: &std::fs::Metadata) -> bool {
452    true
453}
454
455fn discovery_candidates(workspace: &Path) -> Vec<DiscoveryCandidate> {
456    let canonical = canonical_workspace(workspace);
457    let Ok(entries) = std::fs::read_dir(browser_provider_directory()) else {
458        return Vec::new();
459    };
460    let mut candidates = Vec::new();
461    for entry in entries.flatten() {
462        let path = entry.path();
463        if path.extension().and_then(|value| value.to_str()) != Some("json") {
464            continue;
465        }
466        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
467            continue;
468        };
469        if !metadata.file_type().is_file() || !owner_only(&metadata) {
470            continue;
471        }
472        let Ok(bytes) = std::fs::read(&path) else {
473            continue;
474        };
475        if bytes.len() > 64 * 1024 {
476            continue;
477        }
478        let Ok(discovery) = serde_json::from_slice::<ProviderDiscovery>(&bytes) else {
479            continue;
480        };
481        if discovery.protocol != BROWSER_PROVIDER_PROTOCOL
482            || discovery.host != "127.0.0.1"
483            || discovery.token.len() < 32
484            || canonical_workspace(Path::new(&discovery.workspace)) != canonical
485            || discovery.provider.id.trim().is_empty()
486            || discovery.provider.name.trim().is_empty()
487        {
488            continue;
489        }
490        candidates.push(DiscoveryCandidate {
491            discovery,
492            modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
493        });
494    }
495    candidates.sort_by_key(|candidate| Reverse(candidate.modified));
496    candidates
497}
498
499fn failure(operation: &str, code: &str, message: impl Into<String>) -> Value {
500    json!({
501        "ok": false,
502        "operation": operation,
503        "error": {"code": code, "message": message.into()}
504    })
505}
506
507fn validate_input(
508    operation: &BrowserOperationDefinition,
509    input: &Value,
510) -> std::result::Result<(), String> {
511    let Some(object) = input.as_object() else {
512        return Err("browser operation input must be an object".into());
513    };
514    let properties = operation
515        .input_schema
516        .get("properties")
517        .and_then(Value::as_object)
518        .expect("browser registry schemas are object schemas");
519    if let Some(unknown) = object.keys().find(|key| !properties.contains_key(*key)) {
520        return Err(format!("unknown input field `{unknown}`"));
521    }
522    let required = operation
523        .input_schema
524        .get("required")
525        .and_then(Value::as_array)
526        .into_iter()
527        .flatten()
528        .filter_map(Value::as_str);
529    for field in required {
530        if !object.contains_key(field) {
531            return Err(format!("missing required input field `{field}`"));
532        }
533    }
534    if let Some(page) = object.get("page") {
535        if !page
536            .as_str()
537            .is_some_and(|value| !value.is_empty() && value.len() <= 512)
538        {
539            return Err("`page` must be a non-empty opaque handle of at most 512 bytes".into());
540        }
541    }
542    if let Some(locator) = object.get("locator") {
543        validate_locator(locator)?;
544    }
545    if let Some(index) = object.get("index") {
546        if index.as_u64().is_none() {
547            return Err("`index` must be a non-negative integer".into());
548        }
549    }
550    if let Some(revision) = object.get("expectedRevision") {
551        if revision.as_u64().is_none() {
552            return Err("`expectedRevision` must be a non-negative integer".into());
553        }
554    }
555    if operation.name == "browser.fill" && !object.get("value").is_some_and(Value::is_string) {
556        return Err("`value` must be a string".into());
557    }
558    if operation.name == "browser.press"
559        && !object
560            .get("key")
561            .and_then(Value::as_str)
562            .is_some_and(|value| !value.is_empty() && value.len() <= 100)
563    {
564        return Err("`key` must be a non-empty string of at most 100 bytes".into());
565    }
566    if operation.name == "browser.wait" {
567        if !matches!(
568            object.get("state").and_then(Value::as_str),
569            None | Some("attached" | "visible")
570        ) {
571            return Err("`state` must be attached or visible".into());
572        }
573        if let Some(wait) = object.get("timeout") {
574            if !wait
575                .as_f64()
576                .is_some_and(|value| (0.0..=30_000.0).contains(&value))
577            {
578                return Err("`timeout` must be between 0 and 30000".into());
579            }
580        }
581    }
582    if operation.name == "browser.select"
583        && !object
584            .get("values")
585            .and_then(Value::as_array)
586            .is_some_and(|values| values.len() <= 100 && values.iter().all(Value::is_string))
587    {
588        return Err("`values` must be an array of at most 100 strings".into());
589    }
590    if operation.name == "browser.scroll" {
591        if !matches!(
592            object.get("direction").and_then(Value::as_str),
593            Some("up" | "down" | "left" | "right")
594        ) {
595            return Err("`direction` must be up, down, left, or right".into());
596        }
597        if let Some(amount) = object.get("amount") {
598            if !amount
599                .as_f64()
600                .is_some_and(|value| (1.0..=10_000.0).contains(&value))
601            {
602                return Err("`amount` must be between 1 and 10000".into());
603            }
604        }
605    }
606    Ok(())
607}
608
609fn validate_locator(value: &Value) -> std::result::Result<(), String> {
610    let Some(locator) = value.as_object() else {
611        return Err("`locator` must be an object".into());
612    };
613    let Some(kind) = locator.get("by").and_then(Value::as_str) else {
614        return Err("`locator.by` is required".into());
615    };
616    let allowed: &[&str] = match kind {
617        "css" | "ref" | "testId" => &["by", "value"],
618        "role" => &["by", "role", "name", "exact"],
619        "text" => &["by", "text", "exact"],
620        _ => return Err(format!("unsupported locator kind `{kind}`")),
621    };
622    if let Some(unknown) = locator.keys().find(|key| !allowed.contains(&key.as_str())) {
623        return Err(format!("unknown locator field `{unknown}`"));
624    }
625    let primary = match kind {
626        "css" | "ref" | "testId" => "value",
627        "role" => "role",
628        "text" => "text",
629        _ => unreachable!(),
630    };
631    if !locator
632        .get(primary)
633        .and_then(Value::as_str)
634        .is_some_and(|value| !value.is_empty() && value.len() <= 2_000)
635    {
636        return Err(format!("`locator.{primary}` must be a non-empty string"));
637    }
638    if locator.get("name").is_some_and(|value| !value.is_string())
639        || locator
640            .get("exact")
641            .is_some_and(|value| !value.is_boolean())
642    {
643        return Err("locator `name` must be a string and `exact` must be boolean".into());
644    }
645    Ok(())
646}
647
648/// Call the first reachable provider for `workspace`. Transport and
649/// availability failures are returned as the same structured outcome shape
650/// providers use, so CLI/SDK/MCP all observe identical semantics.
651pub async fn call_browser_operation(workspace: &Path, name: &str, input: Value) -> Value {
652    let Some(operation) = browser_operation(name) else {
653        return failure(name, "OPERATION_NOT_FOUND", "Unknown browser operation");
654    };
655    if let Err(message) = validate_input(&operation, &input) {
656        return failure(operation.name, "INVALID_INPUT", message);
657    }
658    let candidates = discovery_candidates(workspace);
659    if candidates.is_empty() {
660        return failure(
661            operation.name,
662            "PROVIDER_UNAVAILABLE",
663            "No browser provider is running for this workspace",
664        );
665    }
666    let mut last_error = "No browser provider answered".to_string();
667    for candidate in candidates {
668        match call_provider(&candidate.discovery, &operation, &input).await {
669            Ok(mut result) => {
670                if let Some(object) = result.as_object_mut() {
671                    object.insert(
672                        "provider".into(),
673                        json!({
674                            "id": candidate.discovery.provider.id,
675                            "name": candidate.discovery.provider.name,
676                            "fidelity": candidate.discovery.provider.fidelity,
677                        }),
678                    );
679                }
680                return result;
681            }
682            Err(error) => last_error = error.to_string(),
683        }
684    }
685    failure(operation.name, "PROVIDER_UNAVAILABLE", last_error)
686}
687
688async fn call_provider(
689    discovery: &ProviderDiscovery,
690    operation: &BrowserOperationDefinition,
691    input: &Value,
692) -> Result<Value> {
693    let address = format!("{}:{}", discovery.host, discovery.port);
694    let mut stream = timeout(BROWSER_PROVIDER_TIMEOUT, TcpStream::connect(&address))
695        .await
696        .map_err(|_| Error::tool(operation.name, "browser provider connection timed out"))??;
697    let id = format!("sc-{}", random_hex_16()?);
698    let request = json!({
699        "protocol": BROWSER_PROVIDER_PROTOCOL,
700        "id": id,
701        "token": discovery.token,
702        "call": {
703            "protocol": BROWSER_OPERATION_PROTOCOL,
704            "operation": operation.name,
705            "input": input,
706        }
707    });
708    let mut bytes = serde_json::to_vec(&request)?;
709    bytes.push(b'\n');
710    if bytes.len() > BROWSER_PROVIDER_MAX_REQUEST_BYTES {
711        return Err(Error::tool(
712            operation.name,
713            "browser provider request exceeds 256 KiB",
714        ));
715    }
716    timeout(BROWSER_PROVIDER_TIMEOUT, stream.write_all(&bytes))
717        .await
718        .map_err(|_| Error::tool(operation.name, "browser provider write timed out"))??;
719    let mut response = Vec::new();
720    let mut bounded = stream.take((BROWSER_PROVIDER_MAX_RESPONSE_BYTES + 1) as u64);
721    timeout(BROWSER_PROVIDER_TIMEOUT, bounded.read_to_end(&mut response))
722        .await
723        .map_err(|_| Error::tool(operation.name, "browser provider response timed out"))??;
724    if response.len() > BROWSER_PROVIDER_MAX_RESPONSE_BYTES {
725        return Err(Error::tool(
726            operation.name,
727            "browser provider response exceeds 1 MiB",
728        ));
729    }
730    let envelope: Value = serde_json::from_slice(&response)?;
731    if envelope.get("protocol").and_then(Value::as_str) != Some(BROWSER_PROVIDER_PROTOCOL)
732        || envelope.get("id").and_then(Value::as_str) != Some(&id)
733    {
734        return Err(Error::tool(
735            operation.name,
736            "invalid browser provider response envelope",
737        ));
738    }
739    let result = envelope
740        .get("result")
741        .cloned()
742        .ok_or_else(|| Error::tool(operation.name, "browser provider response omitted result"))?;
743    if result.get("ok").and_then(Value::as_bool).is_none()
744        || result.get("operation").and_then(Value::as_str) != Some(operation.name)
745    {
746        return Err(Error::tool(
747            operation.name,
748            "invalid browser provider operation result",
749        ));
750    }
751    Ok(result)
752}
753
754fn random_hex_16() -> Result<String> {
755    let mut bytes = [0_u8; 16];
756    getrandom::getrandom(&mut bytes)
757        .map_err(|error| Error::Other(format!("browser request id generation failed: {error}")))?;
758    Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
759}
760
761#[derive(Clone)]
762struct BrowserTool {
763    operation: BrowserOperationDefinition,
764}
765
766#[async_trait]
767impl Tool for BrowserTool {
768    fn name(&self) -> &str {
769        self.operation.name
770    }
771
772    fn description(&self) -> &str {
773        self.operation.description
774    }
775
776    fn parameters(&self) -> Value {
777        self.operation.input_schema.clone()
778    }
779
780    fn structured_output(&self) -> bool {
781        true
782    }
783
784    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
785        Ok(call_browser_operation(&ctx.cwd, self.operation.name, args)
786            .await
787            .to_string())
788    }
789}
790
791/// Register every canonical browser operation into an existing tool registry.
792/// MCP calls this directly; optional agent surfaces can reuse it without
793/// letting providers add or remove operations.
794pub fn register_browser_tools(registry: &mut ToolRegistry) {
795    for operation in browser_operation_registry() {
796        registry.register(BrowserTool { operation });
797    }
798}