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
43#[derive(Debug, Clone)]
45pub struct BrowserOperationDefinition {
46 pub name: &'static str,
48 pub cli_name: &'static str,
50 pub description: &'static str,
52 pub mutates_page: bool,
54 pub permission: &'static str,
56 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
76fn 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
98pub 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
394pub 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
401pub 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
655pub 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 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 "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 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
838pub fn register_browser_tools(registry: &mut ToolRegistry) {
842 for operation in browser_operation_registry() {
843 registry.register(BrowserTool { operation });
844 }
845}