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