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