Initial public release

This commit is contained in:
BB-fat
2026-06-22 17:36:26 +08:00
commit f1036a860c
289 changed files with 54589 additions and 0 deletions
@@ -0,0 +1,94 @@
//! Emit JSON Schema files for handshake + §7 tool params/results (`schema/`).
use std::fs;
use std::path::PathBuf;
use bsk_protocol::system::{
HandshakeParams, HandshakeResult, PingParams, PingResult, StatusParams, StatusResult,
};
use bsk_protocol::tools::*;
use bsk_protocol::{CancelParams, CancelResult};
use schemars::schema_for;
fn write_schema(name: &str, schema: impl serde::Serialize) {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("schema");
fs::create_dir_all(&dir).expect("create schema dir");
let path = dir.join(format!("{name}.json"));
let json = serde_json::to_string_pretty(&schema).expect("serialize schema");
let mut json = json;
json.push('\n');
fs::write(&path, json).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
}
macro_rules! dump {
($ty:ty, $file:literal) => {
write_schema($file, schema_for!($ty));
};
}
fn main() {
dump!(HandshakeParams, "handshake_params");
dump!(HandshakeResult, "handshake_result");
dump!(PingParams, "system_ping_params");
dump!(PingResult, "system_ping_result");
dump!(StatusParams, "system_status_params");
dump!(StatusResult, "system_status_result");
dump!(CancelParams, "cancel_params");
dump!(CancelResult, "cancel_result");
dump!(SessionStartParams, "tool_session_start_params");
dump!(SessionStartResult, "tool_session_start_result");
dump!(SessionStopParams, "tool_session_stop_params");
dump!(SessionStopResult, "tool_session_stop_result");
dump!(TabListParams, "tool_tab_list_params");
dump!(TabListResult, "tool_tab_list_result");
dump!(TabCreateParams, "tool_tab_create_params");
dump!(TabCreateResult, "tool_tab_create_result");
dump!(TabCloseParams, "tool_tab_close_params");
dump!(TabCloseResult, "tool_tab_close_result");
dump!(TabBorrowParams, "tool_tab_borrow_params");
dump!(TabBorrowResult, "tool_tab_borrow_result");
dump!(TabReturnParams, "tool_tab_return_params");
dump!(TabReturnResult, "tool_tab_return_result");
dump!(TabSelectParams, "tool_tab_select_params");
dump!(TabSelectResult, "tool_tab_select_result");
dump!(NavigateParams, "tool_navigate_params");
dump!(NavigateResult, "tool_navigate_result");
dump!(NavigateBackParams, "tool_navigate_back_params");
dump!(NavigateBackResult, "tool_navigate_back_result");
dump!(NavigateForwardParams, "tool_navigate_forward_params");
dump!(NavigateForwardResult, "tool_navigate_forward_result");
dump!(ReloadParams, "tool_reload_params");
dump!(ReloadResult, "tool_reload_result");
dump!(ClickParams, "tool_click_params");
dump!(ClickResult, "tool_click_result");
dump!(FillParams, "tool_fill_params");
dump!(FillResult, "tool_fill_result");
dump!(PressParams, "tool_press_params");
dump!(PressResult, "tool_press_result");
dump!(SelectParams, "tool_select_params");
dump!(SelectResult, "tool_select_result");
dump!(SnapshotParams, "tool_snapshot_params");
dump!(SnapshotResult, "tool_snapshot_result");
dump!(GetHtmlParams, "tool_get_html_params");
dump!(GetHtmlResult, "tool_get_html_result");
dump!(ScreenshotParams, "tool_screenshot_params");
dump!(ScreenshotResult, "tool_screenshot_result");
dump!(EvaluateParams, "tool_evaluate_params");
dump!(EvaluateResult, "tool_evaluate_result");
dump!(EvaluateError, "tool_evaluate_error");
dump!(WaitForNavigationParams, "tool_wait_for_navigation_params");
dump!(WaitForNavigationResult, "tool_wait_for_navigation_result");
dump!(WaitMsParams, "tool_wait_ms_params");
dump!(WaitMsResult, "tool_wait_ms_result");
dump!(RequestHelpParams, "tool_request_help_params");
dump!(RequestHelpResult, "tool_request_help_result");
}
+16
View File
@@ -0,0 +1,16 @@
//! Generic JSON-RPC cancellation envelope.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::RpcId;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CancelParams {
pub rpc_id: RpcId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CancelResult {
pub cancelled: bool,
}
+64
View File
@@ -0,0 +1,64 @@
//! RPC error codes and structured JSON-RPC-style errors.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Wire-format JSON-RPC error object carried inside [`crate::frame::ResponseFrame`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct RpcError {
pub code: ErrorCode,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
/// Stable daemon/extension error codes (§4.5).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
UnknownMethod,
Unsupported,
InvalidParams,
NotFound,
PermissionDenied,
Timeout,
CdpFailed,
ProtocolError,
Cancelled,
UserAborted,
VersionTooOld,
MultipleBrowsersOnline,
NoBrowserConnected,
}
#[derive(Debug, Error)]
pub enum DecodeError {
#[error("ambiguous response frame: expected exactly one of result or error")]
AmbiguousResponse,
#[error("invalid protocol frame: {0}")]
InvalidFrame(String),
}
#[cfg(test)]
mod user_aborted_tests {
use super::*;
#[test]
fn user_aborted_serialises_as_snake_case() {
let v = serde_json::to_value(ErrorCode::UserAborted).unwrap();
assert_eq!(v, serde_json::json!("user_aborted"));
}
#[test]
fn user_aborted_round_trips() {
let parsed: ErrorCode = serde_json::from_value(serde_json::json!("user_aborted")).unwrap();
assert_eq!(parsed, ErrorCode::UserAborted);
}
#[test]
fn user_aborted_is_distinct_from_cancelled() {
assert_ne!(ErrorCode::UserAborted, ErrorCode::Cancelled);
}
}
+253
View File
@@ -0,0 +1,253 @@
//! JSON-RPC–style frames shared by CLI↔daemon and daemon↔extension (§4.1).
use std::fmt;
use serde::de::{self, MapAccess, Visitor};
use serde::ser::SerializeStruct;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::{DecodeError, RpcError};
use crate::method::Method;
pub type RpcId = String;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RequestFrame {
pub id: RpcId,
pub method: Method,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResponseFrame {
pub id: RpcId,
pub body: ResponseBody,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ResponseBody {
Ok(serde_json::Value),
Err(RpcError),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EventFrame {
pub event: EventKind,
#[serde(default)]
pub payload: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EventKind {
#[serde(rename = "session.activity")]
SessionActivity,
#[serde(rename = "session.window_closed")]
SessionWindowClosed,
#[serde(rename = "session.user_interrupt")]
SessionUserInterrupt,
#[serde(rename = "browser.disconnected")]
BrowserDisconnected,
#[serde(rename = "browser.connected")]
BrowserConnected,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Frame {
Request(RequestFrame),
Response(ResponseFrame),
Event(EventFrame),
}
impl Serialize for ResponseFrame {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut s = serializer.serialize_struct("ResponseFrame", 2)?;
s.serialize_field("id", &self.id)?;
match &self.body {
ResponseBody::Ok(v) => s.serialize_field("result", v)?,
ResponseBody::Err(e) => s.serialize_field("error", e)?,
}
s.end()
}
}
impl<'de> Deserialize<'de> for ResponseFrame {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct Flat {
id: RpcId,
result: Option<serde_json::Value>,
error: Option<RpcError>,
}
let Flat { id, result, error } = Flat::deserialize(deserializer)?;
match (result, error) {
(Some(v), None) => Ok(ResponseFrame {
id,
body: ResponseBody::Ok(v),
}),
(None, Some(e)) => Ok(ResponseFrame {
id,
body: ResponseBody::Err(e),
}),
(None, None) => Err(de::Error::custom(DecodeError::AmbiguousResponse)),
(Some(_), Some(_)) => Err(de::Error::custom(DecodeError::AmbiguousResponse)),
}
}
}
impl Serialize for Frame {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
Frame::Request(v) => v.serialize(serializer),
Frame::Response(v) => v.serialize(serializer),
Frame::Event(v) => v.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for Frame {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_any(FrameVisitor)
}
}
struct FrameVisitor;
impl<'de> Visitor<'de> for FrameVisitor {
type Value = Frame;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "a protocol frame object")
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut id = None::<RpcId>;
let mut method = None::<Method>;
let mut params = None::<serde_json::Value>;
let mut result = None::<serde_json::Value>;
let mut error = None::<RpcError>;
let mut event = None::<EventKind>;
let mut payload = None::<serde_json::Value>;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"id" => {
if id.is_some() {
return Err(de::Error::duplicate_field("id"));
}
id = Some(map.next_value()?);
}
"method" => {
if method.is_some() {
return Err(de::Error::duplicate_field("method"));
}
method = Some(map.next_value()?);
}
"params" => {
if params.is_some() {
return Err(de::Error::duplicate_field("params"));
}
params = Some(map.next_value()?);
}
"result" => {
if result.is_some() {
return Err(de::Error::duplicate_field("result"));
}
result = Some(map.next_value()?);
}
"error" => {
if error.is_some() {
return Err(de::Error::duplicate_field("error"));
}
error = Some(map.next_value()?);
}
"event" => {
if event.is_some() {
return Err(de::Error::duplicate_field("event"));
}
event = Some(map.next_value()?);
}
"payload" => {
if payload.is_some() {
return Err(de::Error::duplicate_field("payload"));
}
payload = Some(map.next_value()?);
}
other => {
let _: de::IgnoredAny = map.next_value()?;
let _ = other;
}
}
}
if event.is_some() {
let event = event.ok_or_else(|| de::Error::missing_field("event"))?;
let payload = payload.unwrap_or(serde_json::Value::Object(Default::default()));
return Ok(Frame::Event(EventFrame { event, payload }));
}
if method.is_some() {
let id = id.ok_or_else(|| de::Error::missing_field("id"))?;
let method = method.ok_or_else(|| de::Error::missing_field("method"))?;
return Ok(Frame::Request(RequestFrame { id, method, params }));
}
let id = id.ok_or_else(|| de::Error::missing_field("id"))?;
match (result, error) {
(Some(v), None) => Ok(Frame::Response(ResponseFrame {
id,
body: ResponseBody::Ok(v),
})),
(None, Some(e)) => Ok(Frame::Response(ResponseFrame {
id,
body: ResponseBody::Err(e),
})),
(None, None) => Err(de::Error::custom(DecodeError::InvalidFrame(
"expected result or error".into(),
))),
(Some(_), Some(_)) => Err(de::Error::custom(DecodeError::AmbiguousResponse)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_user_interrupt_serialises_as_snake_case() {
let v = serde_json::to_value(EventKind::SessionUserInterrupt).unwrap();
assert_eq!(v, serde_json::json!("session.user_interrupt"));
}
#[test]
fn session_user_interrupt_event_frame_round_trips() {
let frame = EventFrame {
event: EventKind::SessionUserInterrupt,
payload: serde_json::json!({ "session_id": "sess-1" }),
};
let v = serde_json::to_value(&frame).unwrap();
let back: EventFrame = serde_json::from_value(v).unwrap();
assert_eq!(back.event, EventKind::SessionUserInterrupt);
assert_eq!(
back.payload.get("session_id").and_then(|v| v.as_str()),
Some("sess-1"),
);
}
}
+19
View File
@@ -0,0 +1,19 @@
//! browser-skill wire protocol — frames, methods, tool payloads, handshake.
pub mod cancel;
pub mod error;
pub mod frame;
pub mod method;
pub mod system;
pub mod tools;
pub use cancel::{CancelParams, CancelResult};
pub use error::{DecodeError, ErrorCode, RpcError};
pub use frame::{EventFrame, EventKind, Frame, RequestFrame, ResponseBody, ResponseFrame, RpcId};
pub use method::Method;
pub use system::{
BrowserPeerInfo, BrowserStatusEntry, HandshakeCompat, HandshakeParams, HandshakeResult,
PingParams, PingResult, SessionStatusEntry, StatusParams, StatusResult, VersionSkewEntry,
compare_protocol, evaluate_handshake_compat,
};
pub use tools::*;
+223
View File
@@ -0,0 +1,223 @@
//! Namespaced RPC methods (§4.3).
use serde::{Deserialize, Serialize};
/// Namespaced method string (`system.handshake`, `tool.tab_list`, …).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Method {
#[serde(rename = "system.handshake")]
SystemHandshake,
#[serde(rename = "system.ping")]
SystemPing,
#[serde(rename = "system.status")]
SystemStatus,
#[serde(rename = "session.start")]
SessionStart,
#[serde(rename = "session.stop")]
SessionStop,
#[serde(rename = "session.stop_all")]
SessionStopAll,
#[serde(rename = "session.list")]
SessionList,
#[serde(rename = "browser.list")]
BrowserList,
#[serde(rename = "tool.session_start")]
ToolSessionStart,
#[serde(rename = "tool.session_stop")]
ToolSessionStop,
#[serde(rename = "tool.tab_list")]
ToolTabList,
#[serde(rename = "tool.tab_create")]
ToolTabCreate,
#[serde(rename = "tool.tab_close")]
ToolTabClose,
#[serde(rename = "tool.tab_borrow")]
ToolTabBorrow,
#[serde(rename = "tool.tab_return")]
ToolTabReturn,
#[serde(rename = "tool.tab_select")]
ToolTabSelect,
#[serde(rename = "tool.navigate")]
ToolNavigate,
#[serde(rename = "tool.navigate_back")]
ToolNavigateBack,
#[serde(rename = "tool.navigate_forward")]
ToolNavigateForward,
#[serde(rename = "tool.reload")]
ToolReload,
#[serde(rename = "tool.click")]
ToolClick,
#[serde(rename = "tool.fill")]
ToolFill,
#[serde(rename = "tool.press")]
ToolPress,
#[serde(rename = "tool.select")]
ToolSelect,
#[serde(rename = "tool.snapshot")]
ToolSnapshot,
#[serde(rename = "tool.get_html")]
ToolGetHtml,
#[serde(rename = "tool.screenshot")]
ToolScreenshot,
#[serde(rename = "tool.evaluate")]
ToolEvaluate,
#[serde(rename = "tool.wait_for_navigation")]
ToolWaitForNavigation,
#[serde(rename = "tool.wait_ms")]
ToolWaitMs,
#[serde(rename = "tool.request_help")]
ToolRequestHelp,
#[serde(rename = "cancel")]
Cancel,
}
impl Method {
/// Whether this RPC may modify browser state.
///
/// Used by the daemon's pending-interrupt machinery: when the
/// user has clicked the agent-window mask's stop button, the
/// next *mutating* tool call for that session is rejected with
/// `ErrorCode::UserAborted`. Read-only tools and session-
/// lifecycle RPCs pass through transparently.
///
/// **Compile-time enforcement.** The match below is exhaustive
/// (no `_ =>` fallthrough). Adding a new `Method` variant is a
/// compile error here, so classification cannot silently be
/// skipped — the author has to make a deliberate choice.
///
/// **Judgment calls** (read these before adding new variants):
///
/// * `tool.evaluate` is classified as mutating because the
/// daemon cannot statically distinguish a `document.title`
/// read from a `form.submit()` write.
/// * `tool.wait_*` are classified as read-only: they do not
/// initiate any browser action; they observe state only.
/// * `session.*` and `tool.session_*` are NOT gated. Blocking
/// `session.stop` would prevent the agent from gracefully
/// tearing down after observing the user's interrupt.
/// * `cancel` is NOT gated. It's a control-plane operation
/// (stops another in-flight RPC), not a browser action.
pub fn is_mutating(&self) -> bool {
match self {
// Mutating tool calls — gated by pending-interrupt.
Method::ToolTabCreate
| Method::ToolTabClose
| Method::ToolTabBorrow
| Method::ToolTabReturn
| Method::ToolTabSelect
| Method::ToolNavigate
| Method::ToolNavigateBack
| Method::ToolNavigateForward
| Method::ToolReload
| Method::ToolClick
| Method::ToolFill
| Method::ToolPress
| Method::ToolSelect
| Method::ToolEvaluate => true,
// Read-only tool calls — transparent.
Method::ToolTabList
| Method::ToolSnapshot
| Method::ToolGetHtml
| Method::ToolScreenshot
| Method::ToolWaitForNavigation
| Method::ToolWaitMs
| Method::ToolRequestHelp => false,
// Session lifecycle — not gated.
Method::SessionStart
| Method::SessionStop
| Method::SessionStopAll
| Method::SessionList
| Method::ToolSessionStart
| Method::ToolSessionStop => false,
// System / control — not gated.
Method::SystemHandshake
| Method::SystemPing
| Method::SystemStatus
| Method::BrowserList
| Method::Cancel => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CancelParams, CancelResult};
use serde_json::json;
#[test]
fn cancel_method_round_trips() {
let method: Method = serde_json::from_value(json!("cancel")).unwrap();
assert_eq!(method, Method::Cancel);
assert_eq!(serde_json::to_value(method).unwrap(), json!("cancel"));
}
#[test]
fn cancel_params_and_result_round_trip() {
let params: CancelParams = serde_json::from_value(json!({ "rpc_id": "wait-1" })).unwrap();
assert_eq!(params.rpc_id, "wait-1");
let result = CancelResult { cancelled: true };
assert_eq!(
serde_json::to_value(result).unwrap(),
json!({ "cancelled": true })
);
}
#[test]
fn is_mutating_classifies_read_only_tools_as_non_mutating() {
assert!(!Method::ToolTabList.is_mutating());
assert!(!Method::ToolSnapshot.is_mutating());
assert!(!Method::ToolGetHtml.is_mutating());
assert!(!Method::ToolScreenshot.is_mutating());
assert!(!Method::ToolWaitForNavigation.is_mutating());
assert!(!Method::ToolWaitMs.is_mutating());
}
#[test]
fn is_mutating_classifies_mutating_tools_as_mutating() {
assert!(Method::ToolTabCreate.is_mutating());
assert!(Method::ToolTabClose.is_mutating());
assert!(Method::ToolTabBorrow.is_mutating());
assert!(Method::ToolTabReturn.is_mutating());
assert!(Method::ToolTabSelect.is_mutating());
assert!(Method::ToolNavigate.is_mutating());
assert!(Method::ToolNavigateBack.is_mutating());
assert!(Method::ToolNavigateForward.is_mutating());
assert!(Method::ToolReload.is_mutating());
assert!(Method::ToolClick.is_mutating());
assert!(Method::ToolFill.is_mutating());
assert!(Method::ToolPress.is_mutating());
assert!(Method::ToolSelect.is_mutating());
assert!(Method::ToolEvaluate.is_mutating());
}
#[test]
fn is_mutating_classifies_session_lifecycle_as_non_mutating() {
// Session lifecycle RPCs are not "mutating" for the purposes
// of pending-interrupt gating — gating them would prevent the
// agent from gracefully tearing down after observing the
// user's interrupt.
assert!(!Method::SessionStart.is_mutating());
assert!(!Method::SessionStop.is_mutating());
assert!(!Method::SessionStopAll.is_mutating());
assert!(!Method::SessionList.is_mutating());
assert!(!Method::ToolSessionStart.is_mutating());
assert!(!Method::ToolSessionStop.is_mutating());
}
#[test]
fn is_mutating_classifies_system_methods_as_non_mutating() {
assert!(!Method::SystemHandshake.is_mutating());
assert!(!Method::SystemPing.is_mutating());
assert!(!Method::SystemStatus.is_mutating());
assert!(!Method::BrowserList.is_mutating());
assert!(!Method::Cancel.is_mutating());
}
}
+616
View File
@@ -0,0 +1,616 @@
//! Non-tool protocol payloads (`system.handshake`, `system.ping`, `system.status`).
use std::cmp::Ordering;
use schemars::JsonSchema;
use semver::Version;
use serde::{Deserialize, Serialize};
/// Outcome of comparing peer + local **protocol** versions during the
/// `system.handshake` exchange (design §10, M10.4).
///
/// ```text
/// protocol unparseable or major differs → Reject
/// peer_protocol < our_min_compatible_protocol → Reject
/// our_protocol < peer_min_compatible_protocol → Reject (skipped when peer omits floor)
/// peer_protocol != our_protocol (same major) → Skew (warn but allow)
/// otherwise → Ok
/// ```
///
/// Application semvers (`HandshakeParams::version`, etc.) are **not**
/// part of this decision — CLI and extension ship on independent
/// version lines. Legacy `min_compatible_peer` is optional and ignored
/// here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HandshakeCompat {
/// Protocol strings match exactly; no UX surface needs to flag skew.
Ok,
/// Same protocol major and both protocol floors satisfied, but the
/// full `protocol_version` strings differ (minor drift). Connection
/// is accepted; surfaces render "warn but allow".
Skew,
/// Connection must be refused. `reason` is a short
/// human-readable explanation suitable for the WS error frame.
Reject { reason: String },
}
/// Evaluate handshake compatibility using **protocol_version** only.
///
/// `peer_min_compatible_protocol = None` when the peer is a legacy build
/// that predates the field — the "local below peer protocol floor" branch
/// is skipped in that case.
pub fn evaluate_handshake_compat(
peer_protocol_version: &str,
peer_min_compatible_protocol: Option<&str>,
our_protocol_version: &str,
our_min_compatible_protocol: &str,
) -> HandshakeCompat {
let peer_major = parse_major(peer_protocol_version);
let our_major = parse_major(our_protocol_version);
if peer_major.is_none() || our_major.is_none() || peer_major != our_major {
return HandshakeCompat::Reject {
reason: format!(
"protocol major mismatch (peer={peer_protocol_version}, local={our_protocol_version})"
),
};
}
match compare_protocol(peer_protocol_version, our_min_compatible_protocol) {
Some(Ordering::Less) => {
return HandshakeCompat::Reject {
reason: format!(
"peer protocol {peer_protocol_version} is below local min_compatible_protocol {our_min_compatible_protocol}"
),
};
}
None => {
return HandshakeCompat::Reject {
reason: format!("peer protocol version unparseable: {peer_protocol_version}"),
};
}
_ => {}
}
if let Some(peer_floor) = peer_min_compatible_protocol {
match compare_protocol(our_protocol_version, peer_floor) {
Some(Ordering::Less) => {
return HandshakeCompat::Reject {
reason: format!(
"local protocol {our_protocol_version} is below peer min_compatible_protocol {peer_floor}"
),
};
}
None => {
if parse_protocol_parts(our_protocol_version).is_none() {
return HandshakeCompat::Reject {
reason: format!(
"local protocol version unparseable: {our_protocol_version}"
),
};
}
return HandshakeCompat::Reject {
reason: format!("peer min_compatible_protocol unparseable: {peer_floor}"),
};
}
_ => {}
}
}
if peer_protocol_version != our_protocol_version {
return HandshakeCompat::Skew;
}
HandshakeCompat::Ok
}
/// Parsed `(major, minor)` pair for a wire `protocol_version` string.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ProtocolParts {
major: u64,
minor: u64,
}
/// Compare two protocol-version strings lexicographically on
/// `(major, minor)`. Returns `None` when either side is unparseable.
pub fn compare_protocol(a: &str, b: &str) -> Option<Ordering> {
let pa = parse_protocol_parts(a)?;
let pb = parse_protocol_parts(b)?;
Some(pa.major.cmp(&pb.major).then(pa.minor.cmp(&pb.minor)))
}
fn parse_protocol_parts(value: &str) -> Option<ProtocolParts> {
let major = parse_major(value)?;
let rest = value.split('.').nth(1);
let minor = match rest {
None | Some("") => 0,
Some(seg) => {
if seg.is_empty() || !seg.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let n: u64 = seg.parse().ok()?;
if n > MAX_PROTOCOL_MAJOR {
return None;
}
n
}
};
Some(ProtocolParts { major, minor })
}
/// Upper bound the daemon will accept for the protocol-major field.
///
/// Matches JavaScript's `Number.MAX_SAFE_INTEGER` (`2^53 - 1 =
/// 9_007_199_254_740_991`) so the daemon ↔ extension parsers reject
/// the *same* set of inputs (review round 5 I1). Without this cap a
/// peer advertising e.g. `"18446744073709551615"` (u64::MAX) is
/// accepted here as `Some(u64::MAX)` but rejected by the TS side's
/// `!Number.isSafeInteger(...)` guard, violating "daemon accepts ⇒
/// extension accepts". The TS side intentionally caps at MAX_SAFE_INTEGER
/// to avoid silently rounding through `Number()`; pushing the daemon
/// down to that same ceiling is far cheaper than teaching the extension
/// to compare arbitrary-precision integers (which would force a
/// rewrite of `compareSemver` and friends). In practice
/// `protocol_version` only ever carries small values like `0` / `1`
/// / `2`, so a cap at `2^53 - 1` has zero impact on real traffic.
const MAX_PROTOCOL_MAJOR: u64 = (1u64 << 53) - 1;
/// Extract the leading numeric major component from a protocol-version
/// string such as `"1.0"` or `"1.0.0"`.
///
/// We deliberately do NOT use the bare `u64::from_str` accept set here:
/// `u64::from_str("+1")` returns `Ok(1)` because the std-lib parser is
/// happy to peel off a leading `+`. For a *wire* field that's the wrong
/// trade-off — the TypeScript extension's `parseProtocolMajor`
/// (`apps/extension/src/lib/semver.ts`) is built around `/^\d+$/` and
/// rejects any non-digit prefix. To keep "both peers reach the same
/// verdict" as a hard contract (review round 4 I1), the Rust side has
/// to be equally strict: take the segment before the first `.`, then
/// require it to be a non-empty run of ASCII digits before delegating
/// the actual parse. Without this guard a peer advertising `"+1.0"`
/// would be accepted by the daemon and rejected by the extension —
/// the same asymmetry round 3 fought on the app-version axis.
///
/// Round 5 I1 closes the last remaining accept-set gap on the upper
/// bound: after the digit guard passes and `head.parse::<u64>()`
/// succeeds, we also refuse values strictly above
/// [`MAX_PROTOCOL_MAJOR`] (= `Number.MAX_SAFE_INTEGER`). This
/// matches the TS side's `!Number.isSafeInteger(n)` reject so the
/// `MAX_SAFE_INTEGER < n <= u64::MAX` band (which `u64::from_str`
/// would otherwise happily accept) is rejected on both peers.
fn parse_major(value: &str) -> Option<u64> {
let head = value.split('.').next()?;
if head.is_empty() || !head.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let n: u64 = head.parse().ok()?;
if n > MAX_PROTOCOL_MAJOR {
return None;
}
Some(n)
}
#[cfg(test)]
mod parse_major_tests {
//! Round 4 I1: lock the *exact* accept set of `parse_major` so the
//! daemon's verdict on the protocol-major field is bit-for-bit
//! identical to the extension's `parseProtocolMajor` (see
//! `apps/extension/src/lib/__tests__/semver.test.ts`). Any input
//! the daemon accepts here must be accepted by the extension; any
//! input the daemon rejects must round-trip to `null` over there.
//! These cases mirror the extension test names so a future
//! contributor who edits one side will see the matching coverage
//! on the other.
use super::parse_major;
#[test]
fn rejects_explicit_plus_sign() {
// `u64::from_str("+1") = Ok(1)` in std; we MUST refuse so the
// daemon's verdict matches the extension's `/^\d+$/` reject.
assert_eq!(parse_major("+1"), None);
assert_eq!(parse_major("+1.0"), None);
}
#[test]
fn rejects_explicit_minus_sign() {
// `u64::from_str("-1")` already fails in std (u64 is unsigned),
// but we still pin the behaviour through the new strict path
// so it can't accidentally drift if the function is refactored.
assert_eq!(parse_major("-1"), None);
assert_eq!(parse_major("-1.0"), None);
}
#[test]
fn rejects_leading_whitespace() {
assert_eq!(parse_major(" 1"), None);
assert_eq!(parse_major(" 1.0"), None);
assert_eq!(parse_major("\t1.0"), None);
}
#[test]
fn rejects_empty_input() {
assert_eq!(parse_major(""), None);
}
#[test]
fn rejects_empty_leading_segment() {
// `split('.').next()` returns `Some("")` for `".1"`, which our
// emptiness guard must catch.
assert_eq!(parse_major(".1"), None);
}
#[test]
fn rejects_trailing_non_digit_characters() {
// Pre-fix `u64::from_str("1x")` already fails, but pin both
// shapes so the strict guard is locked.
assert_eq!(parse_major("1x"), None);
}
#[test]
fn rejects_scientific_notation() {
assert_eq!(parse_major("1e3"), None);
}
#[test]
fn rejects_non_numeric_leading_segment() {
assert_eq!(parse_major("x.0"), None);
}
#[test]
fn accepts_leading_zeros() {
// `u64::from_str("01")` already accepts leading zeros; the TS
// side does the same (`/^\d+$/.test("01")` && `Number("01") =
// 1`). Lock the behaviour so a future tightening doesn't
// diverge silently.
assert_eq!(parse_major("01"), Some(1));
assert_eq!(parse_major("01.0"), Some(1));
}
#[test]
fn accepts_single_segment() {
assert_eq!(parse_major("1"), Some(1));
assert_eq!(parse_major("123"), Some(123));
}
#[test]
fn accepts_canonical_two_segment() {
assert_eq!(parse_major("1.0"), Some(1));
}
#[test]
fn accepts_canonical_three_segment() {
assert_eq!(parse_major("1.0.0"), Some(1));
}
#[test]
fn ignores_trailing_segments() {
// Confirms the `split('.').next()` semantics: anything past
// the first `.` is irrelevant, including shapes the minor
// segment alone could not parse.
assert_eq!(parse_major("1."), Some(1));
assert_eq!(parse_major("1.x"), Some(1));
}
#[test]
fn accepts_value_at_max_safe_integer() {
// 2^53 - 1 = 9_007_199_254_740_991 is the upper bound both
// peers agree on (review round 5 I1). It round-trips through
// `Number()` losslessly on the TS side, so the daemon must
// accept it here too.
assert_eq!(parse_major("9007199254740991"), Some(9_007_199_254_740_991));
}
#[test]
fn rejects_value_above_max_safe_integer() {
// Round 5 I1: previously the daemon accepted any `u64::from_str`
// value all the way up to `u64::MAX`, which the TS side's
// `!Number.isSafeInteger` guard would reject. Cap at
// `Number.MAX_SAFE_INTEGER` to keep parser verdicts symmetric.
// - MAX_SAFE_INTEGER + 1 (2^53 = 9_007_199_254_740_992):
assert_eq!(parse_major("9007199254740992"), None);
// - u64::MAX itself (a valid u64 but well above the cap):
assert_eq!(parse_major("18446744073709551615"), None);
}
#[test]
fn rejects_value_above_u64_max() {
// u64::MAX + 1 (21 digits). The strict ASCII-digit guard
// lets it through to `head.parse()` which errors with overflow —
// we get `None`, never a wraparound.
assert_eq!(parse_major("18446744073709551616"), None);
// And a much larger value (23 digits) still fails cleanly.
assert_eq!(parse_major("99999999999999999999999"), None);
}
}
#[cfg(test)]
mod compare_protocol_tests {
use super::*;
#[test]
fn equal_protocols() {
assert_eq!(compare_protocol("1.0", "1.0"), Some(Ordering::Equal));
}
#[test]
fn minor_ordering() {
assert_eq!(compare_protocol("1.1", "1.0"), Some(Ordering::Greater));
assert_eq!(compare_protocol("1.0", "1.1"), Some(Ordering::Less));
}
#[test]
fn major_only_treated_as_zero_minor() {
assert_eq!(compare_protocol("1", "1.0"), Some(Ordering::Equal));
}
#[test]
fn unparseable_returns_none() {
assert_eq!(compare_protocol("oops", "1.0"), None);
assert_eq!(compare_protocol("1.0", "1.x"), None);
}
}
#[cfg(test)]
mod compat_tests {
use super::*;
#[test]
fn ok_when_protocol_strings_match() {
let outcome = evaluate_handshake_compat("1.0", Some("1.0"), "1.0", "1.0");
assert_eq!(outcome, HandshakeCompat::Ok);
}
#[test]
fn skew_when_same_major_different_minor() {
let outcome = evaluate_handshake_compat("1.1", Some("1.0"), "1.0", "1.0");
assert_eq!(outcome, HandshakeCompat::Skew);
}
#[test]
fn skew_when_peer_minor_newer() {
let outcome = evaluate_handshake_compat("1.0", Some("1.0"), "1.1", "1.0");
assert_eq!(outcome, HandshakeCompat::Skew);
}
#[test]
fn reject_when_peer_below_protocol_floor() {
let outcome = evaluate_handshake_compat("0.9", Some("0.5"), "1.0", "1.0");
assert!(matches!(outcome, HandshakeCompat::Reject { .. }));
}
#[test]
fn reject_when_self_below_peer_protocol_floor() {
let outcome = evaluate_handshake_compat("1.0", Some("1.5"), "1.0", "1.0");
match outcome {
HandshakeCompat::Reject { reason } => assert!(
reason.contains("local protocol") && reason.contains("min_compatible_protocol"),
"reason: {reason}"
),
other => panic!("expected reject, got {other:?}"),
}
}
#[test]
fn reject_when_peer_protocol_floor_unparseable_reports_peer_floor() {
let outcome = evaluate_handshake_compat("1.0", Some("not-a-protocol"), "1.0", "1.0");
match outcome {
HandshakeCompat::Reject { reason } => assert!(
reason.contains("peer min_compatible_protocol")
&& reason.contains("not-a-protocol"),
"reason: {reason}"
),
other => panic!("expected reject, got {other:?}"),
}
}
#[test]
fn ok_when_peer_protocol_floor_absent() {
let outcome = evaluate_handshake_compat("1.0", None, "1.0", "1.0");
assert_eq!(outcome, HandshakeCompat::Ok);
}
#[test]
fn reject_when_protocol_major_differs() {
let outcome = evaluate_handshake_compat("2.0", Some("1.0"), "1.0", "1.0");
assert!(matches!(outcome, HandshakeCompat::Reject { .. }));
}
#[test]
fn reject_when_protocol_string_unparseable() {
let outcome = evaluate_handshake_compat("oops", Some("1.0"), "1.0", "1.0");
assert!(matches!(outcome, HandshakeCompat::Reject { .. }));
}
#[test]
fn app_version_drift_does_not_affect_verdict() {
// CLI 0.1.0 ↔ extension 9.9.9 with matching protocol → Ok
let outcome = evaluate_handshake_compat("1.0", Some("1.0"), "1.0", "1.0");
assert_eq!(outcome, HandshakeCompat::Ok);
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct BrowserPeerInfo {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct HandshakeParams {
pub client: String,
#[schemars(with = "String")]
pub version: Version,
/// Logical protocol revision (for example `"1.0"`), independent of SemVer app releases.
pub protocol_version: String,
pub instance_id: String,
pub browser: BrowserPeerInfo,
pub label: String,
/// **Deprecated** — legacy app-semver floor kept for wire compat with
/// pre-protocol peers. New code sends `"0.0.0"` and ignores on read.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<String>")]
pub min_compatible_peer: Option<Version>,
/// Lowest peer **protocol** version this side accepts (e.g. `"1.0"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_compatible_protocol: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct HandshakeResult {
pub server: String,
#[schemars(with = "String")]
pub version: Version,
pub protocol_version: String,
/// **Deprecated** — legacy app-semver floor. New peers should use
/// `min_compatible_protocol`; this field is optional and ignored.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<String>")]
pub min_compatible_peer: Option<Version>,
/// Lowest peer protocol version this daemon accepts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_compatible_protocol: Option<String>,
}
#[cfg(test)]
mod handshake_payload_tests {
use super::*;
#[test]
fn handshake_result_deserializes_without_legacy_min_compatible_peer() {
let result: HandshakeResult = serde_json::from_value(serde_json::json!({
"server": "browser-skill-daemon",
"version": "0.1.0",
"protocol_version": "1.0",
"min_compatible_protocol": "1.0"
}))
.expect("legacy app-semver floor is optional in new handshake results");
assert_eq!(result.min_compatible_peer, None);
assert_eq!(result.min_compatible_protocol.as_deref(), Some("1.0"));
}
}
/// `system.ping` request payload. Empty (the response carries `pong`).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct PingParams {}
/// `system.ping` response payload.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct PingResult {
pub pong: bool,
}
/// Snapshot of a single connected extension (extension client view).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct BrowserStatusEntry {
/// Stable id assigned by the extension; matches `HandshakeParams::instance_id`.
pub instance_id: String,
pub browser_name: String,
pub browser_version: String,
pub extension_version: String,
pub label: String,
pub session_count: u32,
/// Unix epoch milliseconds at which the extension completed the
/// daemon handshake. Stable across reconnects only via the
/// best-effort registry generation; clients should treat this as a
/// rough "online since" hint, not a strong identity.
#[serde(default)]
pub connected_at_ms: i64,
/// `true` when the browser's `protocol_version` differs from the
/// daemon's (same major, minor drift). Connection is still allowed.
#[serde(default)]
pub version_skew: bool,
/// Protocol version the extension advertised at handshake.
#[serde(default)]
pub extension_protocol_version: String,
}
/// Snapshot of a single live session.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct SessionStatusEntry {
pub session_id: String,
pub browser_instance_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_window_id: Option<i64>,
/// Unix epoch milliseconds.
pub created_at_ms: i64,
}
/// `system.status` request payload. Empty.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct StatusParams {}
/// One entry per connected browser whose protocol version drifts from
/// the daemon's (same major). Surfaced in `system.status` for
/// `bsk status` / `bsk doctor` / popup "warn but allow" UX.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct VersionSkewEntry {
pub instance_id: String,
pub browser_name: String,
pub label: String,
/// Daemon app semver at snapshot time (informational).
pub server_version: String,
/// Extension app semver at snapshot time (informational).
pub client_version: String,
/// Daemon `protocol_version` at snapshot time.
#[serde(default)]
pub server_protocol_version: String,
/// Extension `protocol_version` from handshake.
#[serde(default)]
pub client_protocol_version: String,
}
/// `system.status` reply: daemon meta + connected browsers + live sessions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct StatusResult {
/// Semver of the daemon binary (`env!("CARGO_PKG_VERSION")`).
pub daemon_version: String,
/// Logical protocol revision (e.g. `"1.0"`).
pub protocol_version: String,
/// Daemon process id.
pub pid: u32,
/// Daemon uptime in whole seconds.
pub uptime_secs: u64,
/// WS port the daemon listens on for extension clients.
pub ws_port: u16,
/// Absolute path of the IPC socket / named pipe handle.
pub sock_path: String,
/// Snapshot of connected extension clients.
pub browsers: Vec<BrowserStatusEntry>,
/// Snapshot of live sessions.
pub sessions: Vec<SessionStatusEntry>,
/// Subset of `browsers` whose `protocol_version` differs from the
/// daemon's (minor drift, same major). Entries mirror
/// `BrowserStatusEntry.version_skew`.
#[serde(default)]
pub version_skew_browsers: Vec<VersionSkewEntry>,
}
#[cfg(test)]
mod status_compat_tests {
use super::*;
#[test]
fn status_result_deserializes_legacy_version_skew_entries_without_protocol_fields() {
let status: StatusResult = serde_json::from_value(serde_json::json!({
"daemon_version": "0.1.0",
"protocol_version": "1.0",
"pid": 42,
"uptime_secs": 7,
"ws_port": 52700,
"sock_path": "/tmp/bsk.sock",
"browsers": [],
"sessions": [],
"version_skew_browsers": [{
"instance_id": "legacy-browser",
"browser_name": "chrome",
"label": "Legacy",
"server_version": "0.1.0",
"client_version": "0.0.9"
}]
}))
.expect("new CLI must tolerate old daemon skew payloads");
let skew = status
.version_skew_browsers
.first()
.expect("legacy skew entry should deserialize");
assert_eq!(skew.server_protocol_version, "");
assert_eq!(skew.client_protocol_version, "");
}
}
+95
View File
@@ -0,0 +1,95 @@
//! Shared JavaScript dialog observability types.
//!
//! Mirrors CDP `Page.javascriptDialogOpening` / `Page.handleJavaScriptDialog`.
//! Dialogs are surfaced as in-band data on tool results — not RPC errors.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Native JS dialog kind reported by CDP.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum JavaScriptDialogType {
Alert,
Confirm,
Prompt,
#[serde(rename = "beforeunload")]
BeforeUnload,
}
/// How the extension resolved the dialog so CDP could continue.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum JavaScriptDialogHandledAction {
Accepted,
Dismissed,
}
impl JavaScriptDialogType {
pub fn as_str(self) -> &'static str {
match self {
Self::Alert => "alert",
Self::Confirm => "confirm",
Self::Prompt => "prompt",
Self::BeforeUnload => "beforeunload",
}
}
}
impl JavaScriptDialogHandledAction {
pub fn as_str(self) -> &'static str {
match self {
Self::Accepted => "accepted",
Self::Dismissed => "dismissed",
}
}
}
/// One observed + handled JavaScript dialog during a tool call.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct JavaScriptDialogInfo {
pub tab_id: i64,
#[serde(rename = "type")]
pub dialog_type: JavaScriptDialogType,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub has_browser_handler: Option<bool>,
pub handled: JavaScriptDialogHandledAction,
/// Monotonic per-tab sequence for ordering within a session.
pub sequence: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn dialog_info_round_trips() {
let info = JavaScriptDialogInfo {
tab_id: 4,
dialog_type: JavaScriptDialogType::Alert,
message: "hello".into(),
url: Some("https://example.com/".into()),
default_prompt: None,
has_browser_handler: Some(false),
handled: JavaScriptDialogHandledAction::Accepted,
sequence: 1,
};
let v = serde_json::to_value(&info).unwrap();
assert_eq!(v["type"], json!("alert"));
assert_eq!(v["handled"], json!("accepted"));
let round: JavaScriptDialogInfo = serde_json::from_value(v).unwrap();
assert_eq!(round, info);
}
#[test]
fn beforeunload_serialises_as_beforeunload() {
let v = serde_json::to_value(JavaScriptDialogType::BeforeUnload).unwrap();
assert_eq!(v, json!("beforeunload"));
}
}
+173
View File
@@ -0,0 +1,173 @@
//! Human-in-loop tool (`tool.request_help`).
//!
//! Lets an agent pause and ask the human to complete an in-page step
//! (captcha, login, confirmation). The extension brings the target tab
//! to the foreground, highlights the requested components, and blocks
//! until the user clicks Continue / Cancel (or the call times out).
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// One component the user is asked to look at / interact with. Exactly
/// one of `ref` / `selector` should be supplied (mirrors the
/// click/fill convention). When neither matches a live element the
/// overlay still shows the prompt and reports the miss via
/// [`ResolvedTarget`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct HelpTarget {
#[serde(
rename = "ref",
alias = "ref_",
default,
skip_serializing_if = "Option::is_none"
)]
pub ref_: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct RequestHelpParams {
pub session_id: String,
/// Target tab. Defaults to the Agent Window's active tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Message shown to the user explaining what to do.
pub prompt: String,
/// Optional custom title for the help overlay. Falls back to the
/// extension's default localized title when omitted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Optional components to scroll to + flash-highlight.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub targets: Option<Vec<HelpTarget>>,
/// Hard upper bound on the wait. Defaults to 300000 (5 min),
/// enforced daemon-side via the generic tool timeout.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum HelpOutcome {
/// User completed the step and clicked Continue.
Continued,
/// User declined / aborted via the overlay's Cancel button.
Cancelled,
/// The wait expired before the user acted.
TimedOut,
/// The page navigated while waiting (full reload or SPA URL change).
Navigated,
}
impl HelpOutcome {
pub fn as_str(self) -> &'static str {
match self {
Self::Continued => "continued",
Self::Cancelled => "cancelled",
Self::TimedOut => "timed_out",
Self::Navigated => "navigated",
}
}
}
/// Per-target resolution status echoed back so the agent can tell which
/// `ref` / `selector` actually matched a live element.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ResolvedTarget {
pub matched: bool,
#[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
pub ref_: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct RequestHelpResult {
pub outcome: HelpOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_targets: Option<Vec<ResolvedTarget>>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn target_serialises_ref_field_name() {
let t = HelpTarget {
ref_: Some("@e3".into()),
selector: None,
};
let v = serde_json::to_value(&t).unwrap();
assert_eq!(v.get("ref").and_then(|v| v.as_str()), Some("@e3"));
assert!(v.get("selector").is_none());
}
#[test]
fn target_accepts_legacy_ref_alias() {
let t: HelpTarget = serde_json::from_value(json!({ "ref_": "e1" })).unwrap();
assert_eq!(t.ref_.as_deref(), Some("e1"));
}
#[test]
fn params_omit_optional_fields() {
let p = RequestHelpParams {
session_id: "abcd".into(),
tab_id: None,
prompt: "log in".into(),
title: None,
targets: None,
timeout_ms: None,
};
let v = serde_json::to_value(&p).unwrap();
assert!(v.get("tab_id").is_none());
assert!(v.get("title").is_none());
assert!(v.get("targets").is_none());
assert!(v.get("timeout_ms").is_none());
let round: RequestHelpParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
#[test]
fn outcome_serialises_snake_case() {
assert_eq!(
serde_json::to_value(HelpOutcome::TimedOut).unwrap(),
json!("timed_out")
);
assert_eq!(
serde_json::to_value(HelpOutcome::Continued).unwrap(),
json!("continued")
);
assert_eq!(
serde_json::to_value(HelpOutcome::Cancelled).unwrap(),
json!("cancelled")
);
assert_eq!(
serde_json::to_value(HelpOutcome::Navigated).unwrap(),
json!("navigated")
);
}
#[test]
fn result_round_trips() {
let r = RequestHelpResult {
outcome: HelpOutcome::Continued,
note: Some("done".into()),
tab_id: 7,
resolved_targets: Some(vec![ResolvedTarget {
matched: true,
ref_: Some("@e1".into()),
selector: None,
}]),
};
let v = serde_json::to_value(&r).unwrap();
let round: RequestHelpResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
}
@@ -0,0 +1,316 @@
//! DOM interaction tools (`tool.click`, `tool.fill`, `tool.press`,
//! `tool.select`).
//!
//! Element-targeted tools accept either a snapshot `ref` (`@e<N>` form,
//! normalised against the session's RefStore) **or** a CSS selector
//! resolved at call time. Modifiers / mouse buttons are encoded as
//! lowercase JSON strings so the same wire shape works for CLI flags and
//! the extension's CDP bridge.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::JavaScriptDialogInfo;
/// Keyboard modifier flags. Multiple flags may be combined; the
/// extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4,
/// shift=8`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum KeyModifier {
Alt,
Ctrl,
Meta,
Shift,
}
/// Mouse button selector for `tool.click`.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum MouseButton {
#[default]
Left,
Middle,
Right,
}
// ---------------------------------------------------------------------------
// click
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ClickParams {
pub session_id: String,
/// Optional `@e<N>` ref allocated by the last `tool.snapshot`.
/// Mutually exclusive with `selector` (caller must supply exactly
/// one). Accepts both `"e3"` and `"@e3"`.
#[serde(
rename = "ref",
alias = "ref_",
default,
skip_serializing_if = "Option::is_none"
)]
pub ref_: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
/// Target tab. Defaults to the Agent Window's active tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub button: Option<MouseButton>,
/// Number of consecutive mouse presses (double-click = 2).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub click_count: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modifiers: Option<Vec<KeyModifier>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ClickResult {
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub used_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub used_selector: Option<String>,
/// Viewport-relative click coordinates (CSS pixels). Reported so
/// agents can correlate with a follow-up `tool.screenshot`.
pub x: f64,
pub y: f64,
/// Native JS dialogs observed and auto-handled during this call.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
// ---------------------------------------------------------------------------
// fill
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct FillParams {
pub session_id: String,
pub value: String,
#[serde(
rename = "ref",
alias = "ref_",
default,
skip_serializing_if = "Option::is_none"
)]
pub ref_: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Clear the field before typing. Defaults to `true`; pass `false`
/// to append instead of replacing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clear_before: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct FillResult {
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub used_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub used_selector: Option<String>,
/// UTF-16 code-unit length of the value that was finally typed
/// (matches what `input.value.length` would report in the page).
pub value_length: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
// ---------------------------------------------------------------------------
// press
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct PressParams {
pub session_id: String,
/// Logical key name. Accepts CDP `key` strings (`Enter`, `Escape`,
/// `ArrowDown`, single characters like `a`), or a compound
/// expression such as `Ctrl+A` / `Meta+Shift+P`. Modifiers in the
/// compound form combine with anything supplied via `modifiers`.
pub key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modifiers: Option<Vec<KeyModifier>>,
/// Optional target to focus before dispatching the key.
#[serde(
rename = "ref",
alias = "ref_",
default,
skip_serializing_if = "Option::is_none"
)]
pub ref_: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Hold the key down for this many milliseconds between `keyDown`
/// and `keyUp`. Useful for testing long-press handlers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hold_ms: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct PressResult {
pub tab_id: i64,
pub key: String,
pub code: String,
#[serde(default)]
pub modifiers: Vec<KeyModifier>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
// ---------------------------------------------------------------------------
// select
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SelectParams {
pub session_id: String,
/// Option `value` strings to set as the final selection. For a
/// single-select `<select>` exactly one value is required; for
/// `<select multiple>` the list replaces the current selection
/// (an empty list clears all selections).
pub values: Vec<String>,
/// Optional `@e<N>` ref allocated by the last `tool.snapshot`.
/// Mutually exclusive with `selector`. Accepts both `"e3"` and
/// `"@e3"`.
#[serde(
rename = "ref",
alias = "ref_",
default,
skip_serializing_if = "Option::is_none"
)]
pub ref_: Option<String>,
/// CSS selector resolved against the live DOM. Mutually exclusive
/// with `ref_`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
/// Target tab. Defaults to the Agent Window's active tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Maximum time the daemon waits for the tool call before timing out.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SelectResult {
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub used_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub used_selector: Option<String>,
/// Whether the target `<select>` had the `multiple` attribute.
pub multiple: bool,
/// Final selected option `value` attributes after the call.
pub selected_values: Vec<String>,
/// Visible labels of the selected options (same order as
/// `selected_values`).
pub selected_labels: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn click_params_serialise_ref_field_name() {
let p = ClickParams {
session_id: "abcd".into(),
ref_: Some("@e3".into()),
selector: None,
tab_id: Some(42),
button: Some(MouseButton::Left),
click_count: Some(1),
modifiers: Some(vec![KeyModifier::Ctrl]),
timeout_ms: Some(5_000),
};
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v.get("ref").and_then(|v| v.as_str()), Some("@e3"));
assert!(v.get("ref_").is_none());
let round: ClickParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
#[test]
fn click_params_accept_legacy_ref_alias() {
let p: ClickParams = serde_json::from_value(json!({
"session_id": "a",
"ref_": "e1",
"selector": null,
}))
.unwrap();
assert_eq!(p.ref_.as_deref(), Some("e1"));
}
#[test]
fn modifiers_render_as_lowercase_strings() {
let v = serde_json::to_value(KeyModifier::Ctrl).unwrap();
assert_eq!(v, json!("ctrl"));
let v = serde_json::to_value(KeyModifier::Meta).unwrap();
assert_eq!(v, json!("meta"));
}
#[test]
fn press_result_round_trips() {
let r = PressResult {
tab_id: 5,
key: "a".into(),
code: "KeyA".into(),
modifiers: vec![KeyModifier::Ctrl, KeyModifier::Shift],
dialogs: vec![],
};
let v = serde_json::to_value(&r).unwrap();
let round: PressResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
#[test]
fn fill_params_default_clear_before_is_omitted() {
let p = FillParams {
session_id: "abcd".into(),
value: "hello".into(),
ref_: Some("@e1".into()),
selector: None,
tab_id: None,
clear_before: None,
timeout_ms: None,
};
let v = serde_json::to_value(&p).unwrap();
assert!(v.get("clear_before").is_none());
}
#[test]
fn select_params_round_trips_values() {
let p = SelectParams {
session_id: "abcd".into(),
values: vec!["us".into(), "ca".into()],
ref_: Some("@e3".into()),
selector: None,
tab_id: Some(12),
timeout_ms: Some(5_000),
};
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v.get("values").cloned(), Some(json!(["us", "ca"])));
let round: SelectParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
}
+21
View File
@@ -0,0 +1,21 @@
//! Typed params/results for the 21 `tool.*` RPC methods (§7).
pub mod dialog;
pub mod human_loop;
pub mod interaction;
pub mod navigation;
pub mod observation;
pub mod script;
pub mod session;
pub mod tabs;
pub mod waits;
pub use dialog::*;
pub use human_loop::*;
pub use interaction::*;
pub use navigation::*;
pub use observation::*;
pub use script::*;
pub use session::*;
pub use tabs::*;
pub use waits::*;
+230
View File
@@ -0,0 +1,230 @@
//! Navigation tools (`tool.navigate*`, `tool.reload`).
//!
//! M7 expands each method with `wait_until`, `timeout_ms`, and a
//! richer result shape that lets the daemon / CLI know which lifecycle
//! phase actually fired (so a `--wait-until=load` that times out still
//! reports `reached: "commit"` instead of an empty success). All
//! `tab_id` fields are optional and resolve to the requesting
//! session's Agent Window active tab when omitted (design §7).
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::JavaScriptDialogInfo;
/// Page-lifecycle checkpoint a navigation should wait on.
///
/// Mapping to CDP `Page.lifecycleEvent` names:
/// * `Load` → `load`
/// * `DomContentLoaded` → `DOMContentLoaded`
/// * `NetworkIdle` → `networkIdle`
/// * `Commit` → `commit` (earliest event after the navigation actually
/// commits to a new document)
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum WaitUntil {
#[default]
Load,
DomContentLoaded,
NetworkIdle,
Commit,
}
// ---------------------------------------------------------------------------
// navigate
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct NavigateParams {
pub session_id: String,
pub url: String,
/// Target tab. Defaults to the Agent Window's currently active tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Lifecycle phase to wait on. Defaults to [`WaitUntil::Load`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_until: Option<WaitUntil>,
/// Hard upper bound on the wait; the extension also enforces its
/// own AbortController hookup (M7 stops listening for further
/// lifecycle events once exceeded). Defaults to 30s if omitted.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
/// Outcome of a navigation. `reached` is the lifecycle name the
/// extension actually observed before returning — either the requested
/// `wait_until` value, or `"timeout"` when the wait expired before
/// that event fired.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct NavigateResult {
pub tab_id: i64,
pub url: String,
/// Tab URL after the navigation settles. May differ from `url` if
/// the server redirected (e.g. http → https) or `wait_until=commit`
/// raced.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_url: Option<String>,
pub reached: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_text: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
// ---------------------------------------------------------------------------
// navigate_back / navigate_forward
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct NavigateBackParams {
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_until: Option<WaitUntil>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct NavigateBackResult {
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_url: Option<String>,
pub reached: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_text: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct NavigateForwardParams {
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_until: Option<WaitUntil>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct NavigateForwardResult {
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_url: Option<String>,
pub reached: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_text: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
// ---------------------------------------------------------------------------
// reload
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ReloadParams {
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_until: Option<WaitUntil>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
/// When `true`, bypass the HTTP cache (CDP `Page.reload(ignoreCache=true)`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hard: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ReloadResult {
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_url: Option<String>,
pub reached: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_text: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn wait_until_round_trips_through_lowercase() {
for v in [
WaitUntil::Load,
WaitUntil::DomContentLoaded,
WaitUntil::NetworkIdle,
WaitUntil::Commit,
] {
let s = serde_json::to_string(&v).unwrap();
let parsed: WaitUntil = serde_json::from_str(&s).unwrap();
assert_eq!(v, parsed);
}
assert_eq!(
serde_json::to_string(&WaitUntil::Commit).unwrap(),
"\"commit\""
);
assert_eq!(
serde_json::to_string(&WaitUntil::DomContentLoaded).unwrap(),
"\"domcontentloaded\"",
);
}
#[test]
fn navigate_params_omit_tab_id_when_none() {
let p = NavigateParams {
session_id: "abcd".into(),
url: "https://example.com/".into(),
tab_id: None,
wait_until: Some(WaitUntil::Load),
timeout_ms: Some(30_000),
};
let v = serde_json::to_value(&p).unwrap();
assert!(v.get("tab_id").is_none());
let round: NavigateParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
#[test]
fn navigate_result_round_trips_with_optional_fields() {
let r = NavigateResult {
tab_id: 7,
url: "https://example.com/".into(),
final_url: Some("https://www.example.com/".into()),
reached: "load".into(),
error_text: None,
dialogs: vec![],
};
let v = serde_json::to_value(&r).unwrap();
let round: NavigateResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
#[test]
fn reload_params_accept_hard_flag() {
let raw = json!({
"session_id": "abcd",
"hard": true,
});
let parsed: ReloadParams = serde_json::from_value(raw).unwrap();
assert_eq!(parsed.hard, Some(true));
}
}
@@ -0,0 +1,230 @@
//! Read-only observation tools (`tool.snapshot`, `tool.get_html`, `tool.screenshot`).
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::JavaScriptDialogInfo;
// ---------------------------------------------------------------------------
// snapshot
// ---------------------------------------------------------------------------
/// Parameters for `tool.snapshot` — produce an indented accessibility
/// tree for one tab plus a fresh `@e<N>` ref-store on the extension
/// side (design §4 / §7).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SnapshotParams {
pub session_id: String,
/// Optional target tab. Defaults to the Agent Window's currently
/// active tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Cap the depth of the rendered tree (defense against very deep
/// pages). Implementation-defined default; agents typically use
/// `Some(16)` for "browse and read" prompts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_depth: Option<u32>,
/// Soft cap on rendered tokens (approximate; extension uses a
/// best-effort heuristic based on character count).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SnapshotResult {
/// Indented aria-snapshot text. Refs are rendered as `@e<N>` so the
/// agent can copy them into subsequent `tool.click` / `tool.fill`
/// selectors.
pub text: String,
/// Number of `@e<N>` refs registered for this session by this
/// snapshot. Equivalent to the size of the new ref-store map.
pub ref_count: u32,
/// Tab the snapshot was actually computed for (after resolving the
/// optional `tab_id` default).
pub tab_id: i64,
/// Whether the rendered tree was truncated because of `max_depth` /
/// `max_tokens`. Agents may re-run with looser caps.
#[serde(default)]
pub truncated: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
// ---------------------------------------------------------------------------
// get_html
// ---------------------------------------------------------------------------
/// Parameters for `tool.get_html`. Either give a tab and (optionally) a
/// snapshot ref to scope the dump to one subtree, or leave both empty
/// to fall back to the Agent Window's active tab and the document
/// root.
///
/// The wire field is `"ref"` (a Rust keyword); the Rust binding lives
/// in [`Self::ref_`] and uses serde rename / alias so JSON stays clean.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct GetHtmlParams {
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Optional `@e<N>` ref allocated by the last `tool.snapshot`.
/// Accepts both `"e3"` and `"@e3"`. When given the extension calls
/// `DOM.getOuterHTML({ backendNodeId })` for the matching node;
/// otherwise it fetches the document element.
#[serde(
rename = "ref",
alias = "ref_",
default,
skip_serializing_if = "Option::is_none"
)]
pub ref_: Option<String>,
/// Truncate the HTML to at most this many bytes. Optional;
/// extension applies a sensible default (`524288` = 512 KiB) when
/// omitted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_bytes: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct GetHtmlResult {
pub html: String,
/// True when `html` was truncated to honour `max_bytes`. Agents
/// should treat the missing tail as elided.
#[serde(default)]
pub truncated: bool,
/// Total byte length of the original (un-truncated) HTML payload.
pub byte_size: u64,
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
// ---------------------------------------------------------------------------
// screenshot
// ---------------------------------------------------------------------------
/// Parameters for `tool.screenshot`. Omit `ref` to capture the visible
/// tab; pass an `@e<N>` ref from the last `tool.snapshot` to crop to
/// that element.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ScreenshotParams {
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Optional `@e<N>` ref allocated by the last `tool.snapshot`.
/// Accepts both `"e3"` and `"@e3"`.
#[serde(
rename = "ref",
alias = "ref_",
default,
skip_serializing_if = "Option::is_none"
)]
pub ref_: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ScreenshotResult {
/// Base64-encoded PNG payload (no `data:` prefix).
pub image_base64: String,
/// Pixel width parsed from the PNG IHDR. May be `0` when parsing
/// fails (extension only logs a warning in that case so the agent
/// still gets the image).
#[serde(default)]
pub width: u32,
/// Pixel height (see [`Self::width`]).
#[serde(default)]
pub height: u32,
/// Always `"png"` in v0.1; reserved for future JPEG / WebP support.
pub format: String,
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn get_html_params_serialises_with_ref_field_name() {
let p = GetHtmlParams {
session_id: "abcd".into(),
tab_id: Some(7),
ref_: Some("@e3".into()),
max_bytes: Some(1024),
};
let v = serde_json::to_value(&p).unwrap();
// Wire field name is `ref`, never `ref_`.
assert_eq!(v.get("ref").and_then(|v| v.as_str()), Some("@e3"));
assert!(v.get("ref_").is_none());
let round: GetHtmlParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
#[test]
fn get_html_params_accepts_both_ref_aliases() {
let from_wire: GetHtmlParams =
serde_json::from_value(json!({ "session_id": "a", "ref": "e1" })).unwrap();
assert_eq!(from_wire.ref_.as_deref(), Some("e1"));
let from_alias: GetHtmlParams =
serde_json::from_value(json!({ "session_id": "a", "ref_": "e2" })).unwrap();
assert_eq!(from_alias.ref_.as_deref(), Some("e2"));
}
#[test]
fn snapshot_result_round_trips_with_ref_count() {
let r = SnapshotResult {
text: "root\n @e1 button \"submit\"\n".into(),
ref_count: 1,
tab_id: 42,
truncated: false,
dialogs: vec![],
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["ref_count"], 1);
assert_eq!(v["text"], "root\n @e1 button \"submit\"\n");
let round: SnapshotResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
#[test]
fn screenshot_params_serialises_with_ref_field_name() {
let p = ScreenshotParams {
session_id: "abcd".into(),
tab_id: Some(7),
ref_: Some("@e3".into()),
};
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v.get("ref").and_then(|v| v.as_str()), Some("@e3"));
assert!(v.get("ref_").is_none());
let round: ScreenshotParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
#[test]
fn screenshot_params_accepts_both_ref_aliases() {
let from_wire: ScreenshotParams =
serde_json::from_value(json!({ "session_id": "a", "ref": "e1" })).unwrap();
assert_eq!(from_wire.ref_.as_deref(), Some("e1"));
let from_alias: ScreenshotParams =
serde_json::from_value(json!({ "session_id": "a", "ref_": "e2" })).unwrap();
assert_eq!(from_alias.ref_.as_deref(), Some("e2"));
}
#[test]
fn screenshot_result_round_trips_with_image_fields() {
let r = ScreenshotResult {
image_base64: "iVBORw0KGgo=".into(),
width: 800,
height: 600,
format: "png".into(),
tab_id: 7,
dialogs: vec![],
};
let v = serde_json::to_value(&r).unwrap();
let round: ScreenshotResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
}
+131
View File
@@ -0,0 +1,131 @@
//! `tool.evaluate` (design §4 / §7).
//!
//! M9.1 expands the M1 placeholder into the wire shape the extension
//! reports back from `Runtime.evaluate`: the call is sandboxed to the
//! session's Agent Window (borrowed tabs included), `tab_id` is
//! optional and defaults to the Agent Window's active tab, and JS
//! exceptions thrown by the evaluated expression are surfaced as
//! `ok: false` payloads (NOT structured RPC errors) so an agent can
//! inspect the `throw` text in-band.
//!
//! `value` is always a `serde_json::Value` — when the underlying CDP
//! result is unserialisable (`Infinity`, `NaN`, `BigInt`, …) the
//! extension stringifies it before returning so the wire payload stays
//! self-describing.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::JavaScriptDialogInfo;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct EvaluateParams {
pub session_id: String,
pub expression: String,
/// Target tab. Defaults to the Agent Window's currently active tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Await the expression when it resolves to a Promise (CDP
/// `Runtime.evaluate(awaitPromise=...)`). Defaults to `true`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub await_promise: Option<bool>,
/// Ask CDP to serialise the result by value (vs. returning a
/// `RemoteObject` id we cannot use). Defaults to `true`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub return_by_value: Option<bool>,
/// Hard upper bound on the evaluation. Defaults to 30s.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
/// Outcome of an `evaluate` call.
///
/// * `ok: true` → expression evaluated successfully; `value` holds the
/// JSON-serialised return value (`null` is meaningful and distinct
/// from "missing").
/// * `ok: false` → the expression threw; `error.text` holds the
/// stringified `exceptionDetails.text` / `exception.description`.
/// The RPC itself still succeeds — the daemon does NOT map JS throws
/// to top-level RPC errors so agents can act on the throw text.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct EvaluateResult {
pub ok: bool,
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<EvaluateError>,
/// Native JS dialogs observed and auto-handled during this call.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct EvaluateError {
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column: Option<i64>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn params_omit_optional_fields() {
let p = EvaluateParams {
session_id: "abcd".into(),
expression: "1+1".into(),
tab_id: None,
await_promise: None,
return_by_value: None,
timeout_ms: None,
};
let v = serde_json::to_value(&p).unwrap();
assert!(v.get("tab_id").is_none());
assert!(v.get("await_promise").is_none());
assert!(v.get("return_by_value").is_none());
assert!(v.get("timeout_ms").is_none());
let round: EvaluateParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
#[test]
fn result_ok_with_value_round_trips() {
let r = EvaluateResult {
ok: true,
tab_id: 7,
value: Some(json!(2)),
error: None,
dialogs: vec![],
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v.get("value"), Some(&json!(2)));
assert!(v.get("error").is_none());
let round: EvaluateResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
#[test]
fn result_throw_carries_error_text_and_position() {
let r = EvaluateResult {
ok: false,
tab_id: 7,
value: None,
error: Some(EvaluateError {
text: "Uncaught Error: boom".into(),
line: Some(1),
column: Some(6),
}),
dialogs: vec![],
};
let v = serde_json::to_value(&r).unwrap();
assert!(v.get("value").is_none());
let round: EvaluateResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
}
+63
View File
@@ -0,0 +1,63 @@
//! Session-scoped tools (`tool.session_*`).
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::ErrorCode;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SessionStartParams {
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub browser_instance_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SessionStartResult {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_window_id: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SessionStopParams {
pub session_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ReturnFailure {
pub tab_id: i64,
pub code: ErrorCode,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SessionStopResult {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub returned_tab_ids: Vec<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub return_failures: Vec<ReturnFailure>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn session_stop_result_round_trips_auto_return_payload() {
let result: SessionStopResult = serde_json::from_value(json!({
"returned_tab_ids": [7, 8],
"return_failures": [
{ "tab_id": 9, "code": "cdp_failed", "message": "move failed" }
]
}))
.unwrap();
assert_eq!(result.returned_tab_ids, vec![7, 8]);
assert_eq!(result.return_failures[0].tab_id, 9);
assert_eq!(result.return_failures[0].code, ErrorCode::CdpFailed);
let encoded = serde_json::to_value(result).unwrap();
assert_eq!(encoded["returned_tab_ids"], json!([7, 8]));
assert_eq!(encoded["return_failures"][0]["code"], "cdp_failed");
}
}
+297
View File
@@ -0,0 +1,297 @@
//! Tab tools (`tool.tab_*`).
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// View scope for [`TabListParams`] (§6 sandbox table).
///
/// * `User` — tabs that live in any window other than an Agent Window.
/// * `Agent` — tabs in the requesting session's Agent Window only.
/// * `All` — both of the above; never reveals other sessions' Agent
/// Windows (cross-session isolation per §6).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "lowercase")]
pub enum TabScope {
User,
Agent,
#[default]
All,
}
/// A single tab entry returned by [`TabListResult`].
///
/// `scope` reports how the requesting session sees this tab: tabs owned
/// by the session's own Agent Window are tagged `agent`; everything
/// else falls into `user`. (Other sessions' Agent Windows are filtered
/// out before they reach this struct.)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabInfo {
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub window_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<bool>,
/// Where the tab sits relative to the requesting session: `user` or
/// `agent`. `all` is a request-side filter only and never appears
/// on an individual tab entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<TabScope>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabListParams {
#[serde(default)]
pub scope: TabScope,
pub session_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabListResult {
pub tabs: Vec<TabInfo>,
}
// ---------------------------------------------------------------------------
// tab_create (M8.1)
// ---------------------------------------------------------------------------
/// Params for `tool.tab_create`. The new tab is always created inside
/// the requesting session's Agent Window (design §6 sandbox rule —
/// agents never spawn tabs in user windows).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabCreateParams {
pub session_id: String,
/// Destination URL. Defaults to `chrome://newtab` when omitted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// Focus the new tab? Defaults to `true` (matches `chrome.tabs.create`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<bool>,
/// Insertion index within the Agent Window's tab strip. Omit to
/// append at the end.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index: Option<i32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabCreateResult {
pub tab_id: i64,
pub window_id: i64,
/// Best-effort URL of the newly created tab. May be empty if Chrome
/// has not yet committed the navigation (e.g. pending load).
#[serde(default)]
pub url: String,
}
// ---------------------------------------------------------------------------
// tab_close (M8.1)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabCloseParams {
pub tab_id: i64,
pub session_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabCloseResult {
pub tab_id: i64,
}
// ---------------------------------------------------------------------------
// tab_select (M8.1)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabSelectParams {
pub tab_id: i64,
pub session_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabSelectResult {
pub tab_id: i64,
pub window_id: i64,
}
// ---------------------------------------------------------------------------
// tab_borrow (M8.2)
// ---------------------------------------------------------------------------
/// Params for `tool.tab_borrow`. Moves a *user* tab into the
/// requesting session's Agent Window, recording its original window /
/// index so `tab_return` (or session_stop) can put it back.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabBorrowParams {
pub tab_id: i64,
pub session_id: String,
/// Whether to wait for an inline user confirmation overlay (M10
/// will introduce the UI). Currently ignored: the M8 stub always
/// proceeds when `false` and forwards `true` to a no-op approver.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confirm: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabBorrowResult {
pub tab_id: i64,
pub original_window_id: i64,
pub original_index: i32,
pub agent_window_id: i64,
}
// ---------------------------------------------------------------------------
// tab_return (M8.3)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabReturnParams {
pub tab_id: i64,
pub session_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabReturnResult {
pub tab_id: i64,
pub returned_to_window_id: i64,
pub returned_to_index: i32,
/// True when the original window had been closed and we fell back
/// to either the last focused normal window or a newly-created
/// window. Lets the CLI surface "tab returned to a different
/// window" hints.
#[serde(default, skip_serializing_if = "core::ops::Not::not")]
pub fallback: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn tab_info_scope_is_omitted_when_none() {
let info = TabInfo {
tab_id: 1,
title: Some("hi".into()),
url: None,
window_id: Some(2),
active: Some(true),
scope: None,
};
let v = serde_json::to_value(&info).unwrap();
assert!(v.get("scope").is_none());
let round: TabInfo = serde_json::from_value(v).unwrap();
assert_eq!(round, info);
}
#[test]
fn tab_info_scope_renders_as_lowercase_enum() {
let info = TabInfo {
tab_id: 1,
title: None,
url: None,
window_id: None,
active: None,
scope: Some(TabScope::Agent),
};
let v = serde_json::to_value(&info).unwrap();
assert_eq!(v["scope"], "agent");
// Round-trip from upstream JSON (no `title` / `url` keys).
let from_json: TabInfo = serde_json::from_value(json!({
"tab_id": 1,
"scope": "user"
}))
.unwrap();
assert_eq!(from_json.scope, Some(TabScope::User));
}
#[test]
fn tab_create_params_default_optional_fields() {
let v = json!({ "session_id": "aa11" });
let p: TabCreateParams = serde_json::from_value(v).unwrap();
assert_eq!(p.session_id, "aa11");
assert!(p.url.is_none());
assert!(p.active.is_none());
assert!(p.index.is_none());
}
#[test]
fn tab_create_params_round_trip_full() {
let p = TabCreateParams {
session_id: "aa11".into(),
url: Some("https://example.com/".into()),
active: Some(false),
index: Some(3),
};
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["url"], "https://example.com/");
assert_eq!(v["active"], false);
assert_eq!(v["index"], 3);
let round: TabCreateParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
#[test]
fn tab_create_result_serialises_url_even_when_empty() {
let r = TabCreateResult {
tab_id: 7,
window_id: 100,
url: String::new(),
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["tab_id"], 7);
assert_eq!(v["url"], "");
}
#[test]
fn tab_close_select_result_round_trips() {
let c: TabCloseResult = serde_json::from_value(json!({ "tab_id": 9 })).unwrap();
assert_eq!(c.tab_id, 9);
let s: TabSelectResult =
serde_json::from_value(json!({ "tab_id": 9, "window_id": 100 })).unwrap();
assert_eq!(s.window_id, 100);
}
#[test]
fn tab_borrow_params_confirm_defaults_to_none() {
let p: TabBorrowParams =
serde_json::from_value(json!({ "tab_id": 1, "session_id": "aa11" })).unwrap();
assert!(p.confirm.is_none());
}
#[test]
fn tab_borrow_result_round_trip() {
let r = TabBorrowResult {
tab_id: 9,
original_window_id: 200,
original_index: 4,
agent_window_id: 100,
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["original_window_id"], 200);
assert_eq!(v["original_index"], 4);
let round: TabBorrowResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
#[test]
fn tab_return_result_omits_fallback_when_false() {
let r = TabReturnResult {
tab_id: 9,
returned_to_window_id: 200,
returned_to_index: 4,
fallback: false,
};
let v = serde_json::to_value(&r).unwrap();
assert!(v.get("fallback").is_none());
let r2 = TabReturnResult {
fallback: true,
..r
};
let v2 = serde_json::to_value(&r2).unwrap();
assert_eq!(v2["fallback"], true);
}
}
+142
View File
@@ -0,0 +1,142 @@
//! Timing helpers (`tool.wait_for_navigation`, `tool.wait_ms`).
//!
//! M9.2: `wait_for_navigation` mirrors the `navigate` wire shape — it
//! waits on a CDP `Page.lifecycleEvent` and reports back via `reached`
//! / `error_text` so a timeout still tells the caller which lifecycle
//! phase the page actually reached. `wait_until` defaults to `load`.
//!
//! M9.3: `wait_ms` is a pure daemon-side sleep (no extension hop, no
//! session needed). The result echoes the requested duration so the
//! caller can confirm a 0ms wait still went through the IPC layer.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::JavaScriptDialogInfo;
use super::navigation::WaitUntil;
// ---------------------------------------------------------------------------
// wait_for_navigation
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct WaitForNavigationParams {
pub session_id: String,
/// Target tab. Defaults to the Agent Window's currently active tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Lifecycle phase to wait on. Defaults to [`WaitUntil::Load`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_until: Option<WaitUntil>,
/// Hard upper bound on the wait. Defaults to 30s.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
/// Outcome of a wait_for_navigation. `reached` is the wire name of the
/// lifecycle phase the extension actually observed before returning —
/// either the requested `wait_until`, or `"timeout"` when the wait
/// expired before that event fired.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct WaitForNavigationResult {
pub tab_id: i64,
pub reached: WaitForNavigationReached,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_text: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum WaitForNavigationReached {
#[serde(rename = "load")]
Load,
#[serde(rename = "domcontentloaded")]
DomContentLoaded,
#[serde(rename = "networkidle")]
NetworkIdle,
#[serde(rename = "commit")]
Commit,
#[serde(rename = "timeout")]
Timeout,
}
impl WaitForNavigationReached {
pub fn as_str(self) -> &'static str {
match self {
Self::Load => "load",
Self::DomContentLoaded => "domcontentloaded",
Self::NetworkIdle => "networkidle",
Self::Commit => "commit",
Self::Timeout => "timeout",
}
}
}
// ---------------------------------------------------------------------------
// wait_ms
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct WaitMsParams {
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct WaitMsResult {
pub waited_ms: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn wait_for_navigation_params_omit_optional_fields() {
let p = WaitForNavigationParams {
session_id: "abcd".into(),
tab_id: None,
wait_until: None,
timeout_ms: None,
};
let v = serde_json::to_value(&p).unwrap();
assert!(v.get("tab_id").is_none());
assert!(v.get("wait_until").is_none());
assert!(v.get("timeout_ms").is_none());
let round: WaitForNavigationParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
#[test]
fn wait_for_navigation_result_round_trips_timeout() {
let r = WaitForNavigationResult {
tab_id: 9,
reached: WaitForNavigationReached::Timeout,
error_text: Some("timed out waiting for lifecycle \"load\"".into()),
dialogs: vec![],
};
let v = serde_json::to_value(&r).unwrap();
let round: WaitForNavigationResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
#[test]
fn wait_for_navigation_result_rejects_unknown_reached_value() {
let res = serde_json::from_value::<WaitForNavigationResult>(json!({
"tab_id": 9,
"reached": "painted"
}));
assert!(res.is_err());
}
#[test]
fn wait_ms_round_trips() {
let params: WaitMsParams = serde_json::from_value(json!({ "duration_ms": 250 })).unwrap();
assert_eq!(params.duration_ms, 250);
let result = WaitMsResult { waited_ms: 250 };
let v = serde_json::to_value(&result).unwrap();
assert_eq!(v, json!({ "waited_ms": 250 }));
}
}