mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Initial public release
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user