1use 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
24pub const BROWSER_PROVIDER_PROTOCOL: &str = "supercode/browser-provider-v1";
26pub const BROWSER_OPERATION_PROTOCOL: &str = "supercode/browser-operation-v1";
28pub const BROWSER_PROVIDER_MAX_REQUEST_BYTES: usize = 256 * 1024;
30pub const BROWSER_PROVIDER_MAX_RESPONSE_BYTES: usize = 1024 * 1024;
33pub const BROWSER_PROVIDER_TIMEOUT: Duration = Duration::from_secs(12);
35pub const BROWSER_PERSON_TIMEOUT: Duration = Duration::from_secs(120);
39pub const BROWSER_CALL_CAP: Duration = Duration::from_secs(600);
42
43static BROWSER_TASK: std::sync::OnceLock<String> = std::sync::OnceLock::new();
46
47pub fn begin_browser_task() {
51 let _ = BROWSER_TASK.set(format!("mcp-{}", random_hex_16().unwrap_or_default()));
52}
53
54fn 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#[derive(Debug, Clone)]
68pub struct BrowserOperationDefinition {
69 pub name: &'static str,
71 pub cli_name: &'static str,
73 pub description: &'static str,
75 pub mutates_page: bool,
77 pub permission: &'static str,
79 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
99fn 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
121pub 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
417pub 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
424pub 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
678pub 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 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 "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 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
862pub fn register_browser_tools(registry: &mut ToolRegistry) {
866 for operation in browser_operation_registry() {
867 registry.register(BrowserTool { operation });
868 }
869}