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