From 581b14d4bfe7a08830003568dbaec557bc6056fa Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:40:50 +0100 Subject: [PATCH 1/7] Add smb run runners and the matching MCP tools smb run runners list shows the Run fleet, and smb run runners tags ... replaces a runner's tags. Both call the admin API, so they need an admin user's login. run_runners_list and run_runner_tags_set expose the same two operations over stdio MCP; the second carries the destructive hint because it replaces the whole list. Co-Authored-By: Claude Sonnet 5.5 --- crates/cli/src/cli/mod.rs | 7 +- crates/cli/src/lib.rs | 1 + crates/cli/src/main.rs | 2 + crates/cli/src/mcp.rs | 54 ++++++++++-- crates/cli/src/run.rs | 171 ++++++++++++++++++++++++++++++++++++++ docs/mcp.md | 16 +++- 6 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 crates/cli/src/run.rs diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index 32a4d76a..33644c3b 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -1,5 +1,5 @@ use { - crate::{account, cloud_auth, mail, project, tenant}, + crate::{account, cloud_auth, mail, project, run, tenant}, clap::{Parser, Subcommand, ValueEnum}, smbcloud_network::environment::Environment, spinners::Spinner, @@ -108,6 +108,11 @@ pub enum Commands { #[clap(subcommand)] command: project::cli::Commands, }, + #[clap(about = "Inspect and tag Run runners (admin only).")] + Run { + #[clap(subcommand)] + command: run::Commands, + }, #[clap(about = "Manage your tenants.")] Tenant { #[clap(subcommand)] diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 8ae2637e..7d3b9d97 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -13,6 +13,7 @@ pub mod interface; pub mod mail; pub mod mcp; pub mod project; +pub mod run; mod session_config; pub mod tenant; mod token; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 6bfe962e..8e2a51f4 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -14,6 +14,7 @@ use { }, mail::process::process_mail, project::{crud_create::process_project_init, process::process_project}, + run::process_run, tenant::process::process_tenant, }, smbcloud_network::environment::Environment, @@ -207,6 +208,7 @@ async fn run(cli: Cli) -> Result { Some(Commands::Mail { command }) => process_mail(cli.environment, command).await, Some(Commands::Auth { command }) => process_cloud_auth(cli.environment, command).await, Some(Commands::Project { command }) => process_project(cli.environment, command).await, + Some(Commands::Run { command }) => process_run(cli.environment, command).await, Some(Commands::Tenant { command }) => process_tenant(cli.environment, command).await, Some(Commands::ControlKit { command }) => process_controlkit(command).await, Some(Commands::Migrate {}) => process_migrate(cli.environment).await, diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs index 507e00a0..bf50a17b 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp.rs @@ -321,6 +321,16 @@ struct TenantUseArgs { id: String, } +#[derive(Debug, Deserialize, JsonSchema)] +struct RunRunnerTagsSetArgs { + /// The runner ID, from `run_runners_list`. + id: i64, + /// The complete tag list. It replaces the current one: lower-case letters, + /// digits and hyphens, at most 16. Executor tags the runner reports are + /// kept regardless. + tags: Vec, +} + #[derive(Debug, Deserialize, JsonSchema)] struct MailAppListArgs { /// Restrict to mail apps under this project ID. Defaults to the @@ -1116,6 +1126,37 @@ impl SmbMcpServer { json_result(&tenant) } + #[tool( + description = "List Run runners with status, tags, flags and active job count, as a \ + JSON array. Requires an admin user." + )] + async fn run_runners_list(&self) -> Result { + let token = self.access_token()?; + let runners = crate::run::list_runners(self.environment, client(), token) + .await + .map_err(to_error_data)?; + json_result(&runners) + } + + #[tool( + description = "Replace a Run runner's tags with the given list. Tags decide which \ + jobs the runner claims, so any tag not in the list is removed. Read \ + the current tags with `run_runners_list` first. Requires an admin \ + user. Returns the updated runner as JSON.", + annotations(destructive_hint = true) + )] + async fn run_runner_tags_set( + &self, + Parameters(args): Parameters, + ) -> Result { + let token = self.access_token()?; + let runner = + crate::run::set_runner_tags(self.environment, client(), token, args.id, args.tags) + .await + .map_err(to_error_data)?; + json_result(&runner) + } + #[tool( description = "Delete an organization tenant by ID. This is destructive and \ irreversible — it cascades to every project, Auth app, and Mail \ @@ -1925,6 +1966,8 @@ mod tests { "auth_app_new", "auth_app_update", "auth_app_delete", + "run_runners_list", + "run_runner_tags_set", ] { assert!( names.contains(&cloud_only), @@ -2100,15 +2143,16 @@ mod tests { fn delete_and_overwrite_tools_carry_destructive_hint() { let tools = SmbMcpServer::cloud_tool_router().list_all(); let mut checked = 0; - for tool in tools - .iter() - .filter(|tool| tool.name.ends_with("_delete") || tool.name == "project_init") - { + for tool in tools.iter().filter(|tool| { + tool.name.ends_with("_delete") + || tool.name == "project_init" + || tool.name == "run_runner_tags_set" + }) { let annotations = tool.annotations.as_ref().expect("annotations"); assert_eq!(annotations.destructive_hint, Some(true), "{}", tool.name); checked += 1; } - assert_eq!(checked, 6); + assert_eq!(checked, 7); } #[test] diff --git a/crates/cli/src/run.rs b/crates/cli/src/run.rs new file mode 100644 index 00000000..3065c53d --- /dev/null +++ b/crates/cli/src/run.rs @@ -0,0 +1,171 @@ +//! Admin view of the Run fleet. Both commands need an admin user's login; the +//! API refuses anyone else. + +use crate::{ + cli::CommandResult, + client, + token::get_smb_token::get_smb_token, + ui::{succeed_message, succeed_symbol}, +}; +use anyhow::{anyhow, Result}; +use clap::Subcommand; +use console::style; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use smbcloud_model::error_codes::ErrorResponse; +use smbcloud_network::{environment::Environment, network::request}; +use smbcloud_networking::{constants::SMB_USER_AGENT, smb_base_url_builder, smb_client::SmbClient}; +use spinners::Spinner; + +#[derive(Subcommand)] +pub enum Commands { + #[clap(about = "Manage Run runners (admin only).")] + Runners { + #[clap(subcommand)] + command: RunnerCommands, + }, +} + +#[derive(Subcommand)] +pub enum RunnerCommands { + #[clap(about = "List runners with their status and tags.")] + List {}, + #[clap(about = "Replace a runner's tags. Executor tags the runner reports are kept.")] + Tags { + /// The runner ID. + id: i64, + /// The full tag list. Lower-case letters, digits and hyphens, up to 16. + #[clap(required = true)] + tags: Vec, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Runner { + pub id: i64, + pub name: String, + pub status: String, + pub tags: Vec, + pub ci_enabled: bool, + pub publish_enabled: bool, + pub last_heartbeat_at: Option, + pub version: Option, + pub hostname: Option, + pub active_jobs: i64, +} + +#[derive(Serialize)] +struct TagsBody { + tags: Vec, +} + +fn runners_url(env: Environment, client: (&SmbClient, &str), id: Option) -> String { + let mut url_builder = smb_base_url_builder(env, client); + url_builder.add_route("v1/admin/run/runners"); + if let Some(id) = id { + url_builder.add_route(&id.to_string()); + } + url_builder.build() +} + +pub(crate) async fn list_runners( + env: Environment, + client: (&SmbClient, &str), + access_token: String, +) -> Result, ErrorResponse> { + let builder = Client::new() + .get(runners_url(env, client, None)) + .header("Authorization", access_token) + .header("User-agent", SMB_USER_AGENT); + request(builder).await +} + +pub(crate) async fn set_runner_tags( + env: Environment, + client: (&SmbClient, &str), + access_token: String, + id: i64, + tags: Vec, +) -> Result { + let builder = Client::new() + .patch(runners_url(env, client, Some(id))) + .json(&TagsBody { tags }) + .header("Authorization", access_token) + .header("User-agent", SMB_USER_AGENT); + request(builder).await +} + +pub async fn process_run(env: Environment, commands: Commands) -> Result { + match commands { + Commands::Runners { command } => match command { + RunnerCommands::List {} => process_runners_list(env).await, + RunnerCommands::Tags { id, tags } => process_runner_tags(env, id, tags).await, + }, + } +} + +async fn process_runners_list(env: Environment) -> Result { + let access_token = get_smb_token(env)?; + let mut spinner = loading_spinner("Loading runners"); + + let runners = list_runners(env, client(), access_token) + .await + .map_err(|error| anyhow!(error.to_string()))?; + + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded.")); + println!("\n{}", style("Runners").bold().underlined()); + for runner in &runners { + print_runner(runner); + } + + Ok(done_result(if runners.is_empty() { + "No runners found." + } else { + "Done." + })) +} + +async fn process_runner_tags( + env: Environment, + id: i64, + tags: Vec, +) -> Result { + let access_token = get_smb_token(env)?; + let mut spinner = loading_spinner("Saving tags"); + + let runner = set_runner_tags(env, client(), access_token, id, tags) + .await + .map_err(|error| anyhow!(error.to_string()))?; + + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Saved.")); + print_runner(&runner); + + Ok(done_result("Tags updated.")) +} + +fn print_runner(runner: &Runner) { + println!( + "#{} {} [{}] tags={} active_jobs={} host={}", + runner.id, + style(&runner.name).bold(), + runner.status, + runner.tags.join(","), + runner.active_jobs, + runner.hostname.as_deref().unwrap_or("-"), + ); +} + +fn loading_spinner(message: &str) -> Spinner { + Spinner::new( + spinners::Spinners::SimpleDotsScrolling, + succeed_message(message), + ) +} + +fn done_result(message: &str) -> CommandResult { + CommandResult { + spinner: loading_spinner("Done"), + symbol: succeed_symbol(), + msg: succeed_message(message), + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 2047382d..f2c82524 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5,8 +5,8 @@ assistant or agent — Claude Desktop, Claude Code, Cursor, or any other MCP-capable client — can send transactional email and manage your smbCloud projects, tenants, Mail apps, and Auth apps directly, without you leaving the chat to run `smb` commands by hand. -Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 41 -tools covering the account, project, deploy, tenant, Mail, and Auth surfaces. +Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 43 +tools covering the account, project, deploy, tenant, Run, Mail, and Auth surfaces. Cross-platform mobile and TV app testing is exposed as a separate, focused MCP profile through `xcrs --mcp` or `smb --mcp --scope automation`. Keeping cloud @@ -254,6 +254,18 @@ tenant is bootstrapped on signup and isn't user-managed. | `tenant_update` | `id`, `name` | The renamed tenant. | | `tenant_delete` | `id` | Confirmation. **Destructive and irreversible** — cascades to every project, Mail app, and Auth app the tenant owns. | +### Run runners + +Run executes jobs on registered runners, and a runner's tags decide which jobs +it claims. These tools are for an admin user: the API refuses anyone else. The +same operations are available as `smb run runners list` and +`smb run runners tags ...`. + +| Tool | Arguments | Returns | +| --- | --- | --- | +| `run_runners_list` | _(none)_ | Every runner with status, tags, flags, heartbeat, host, and active job count. | +| `run_runner_tags_set` | `id`, `tags` | The updated runner. **Replaces the whole tag list**, so read the current tags first. Tags are lower-cased and must use letters, digits, and hyphens, up to 16. An executor tag the runner reports is kept. | + ### Mail apps A Mail app owns one domain's mail routing; it belongs to a project. From 5e858f064eb92dd429e984f83052b2525601e430 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:40:50 +0100 Subject: [PATCH 2/7] Add smb run runners and the matching MCP tools smb run runners list shows the Run fleet, and smb run runners tags ... replaces a runner's tags. Both call the admin API, so they need an admin user's login. run_runners_list and run_runner_tags_set expose the same two operations over stdio MCP; the second carries the destructive hint because it replaces the whole list. Co-Authored-By: Claude Sonnet 5.5 (cherry picked from commit 581b14d4bfe7a08830003568dbaec557bc6056fa) --- crates/cli/src/cli/mod.rs | 7 +- crates/cli/src/lib.rs | 1 + crates/cli/src/main.rs | 2 + crates/cli/src/mcp.rs | 54 ++++++++++-- crates/cli/src/run.rs | 171 ++++++++++++++++++++++++++++++++++++++ docs/mcp.md | 16 +++- 6 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 crates/cli/src/run.rs diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index 32a4d76a..33644c3b 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -1,5 +1,5 @@ use { - crate::{account, cloud_auth, mail, project, tenant}, + crate::{account, cloud_auth, mail, project, run, tenant}, clap::{Parser, Subcommand, ValueEnum}, smbcloud_network::environment::Environment, spinners::Spinner, @@ -108,6 +108,11 @@ pub enum Commands { #[clap(subcommand)] command: project::cli::Commands, }, + #[clap(about = "Inspect and tag Run runners (admin only).")] + Run { + #[clap(subcommand)] + command: run::Commands, + }, #[clap(about = "Manage your tenants.")] Tenant { #[clap(subcommand)] diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 8ae2637e..7d3b9d97 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -13,6 +13,7 @@ pub mod interface; pub mod mail; pub mod mcp; pub mod project; +pub mod run; mod session_config; pub mod tenant; mod token; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 6bfe962e..8e2a51f4 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -14,6 +14,7 @@ use { }, mail::process::process_mail, project::{crud_create::process_project_init, process::process_project}, + run::process_run, tenant::process::process_tenant, }, smbcloud_network::environment::Environment, @@ -207,6 +208,7 @@ async fn run(cli: Cli) -> Result { Some(Commands::Mail { command }) => process_mail(cli.environment, command).await, Some(Commands::Auth { command }) => process_cloud_auth(cli.environment, command).await, Some(Commands::Project { command }) => process_project(cli.environment, command).await, + Some(Commands::Run { command }) => process_run(cli.environment, command).await, Some(Commands::Tenant { command }) => process_tenant(cli.environment, command).await, Some(Commands::ControlKit { command }) => process_controlkit(command).await, Some(Commands::Migrate {}) => process_migrate(cli.environment).await, diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs index 507e00a0..bf50a17b 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp.rs @@ -321,6 +321,16 @@ struct TenantUseArgs { id: String, } +#[derive(Debug, Deserialize, JsonSchema)] +struct RunRunnerTagsSetArgs { + /// The runner ID, from `run_runners_list`. + id: i64, + /// The complete tag list. It replaces the current one: lower-case letters, + /// digits and hyphens, at most 16. Executor tags the runner reports are + /// kept regardless. + tags: Vec, +} + #[derive(Debug, Deserialize, JsonSchema)] struct MailAppListArgs { /// Restrict to mail apps under this project ID. Defaults to the @@ -1116,6 +1126,37 @@ impl SmbMcpServer { json_result(&tenant) } + #[tool( + description = "List Run runners with status, tags, flags and active job count, as a \ + JSON array. Requires an admin user." + )] + async fn run_runners_list(&self) -> Result { + let token = self.access_token()?; + let runners = crate::run::list_runners(self.environment, client(), token) + .await + .map_err(to_error_data)?; + json_result(&runners) + } + + #[tool( + description = "Replace a Run runner's tags with the given list. Tags decide which \ + jobs the runner claims, so any tag not in the list is removed. Read \ + the current tags with `run_runners_list` first. Requires an admin \ + user. Returns the updated runner as JSON.", + annotations(destructive_hint = true) + )] + async fn run_runner_tags_set( + &self, + Parameters(args): Parameters, + ) -> Result { + let token = self.access_token()?; + let runner = + crate::run::set_runner_tags(self.environment, client(), token, args.id, args.tags) + .await + .map_err(to_error_data)?; + json_result(&runner) + } + #[tool( description = "Delete an organization tenant by ID. This is destructive and \ irreversible — it cascades to every project, Auth app, and Mail \ @@ -1925,6 +1966,8 @@ mod tests { "auth_app_new", "auth_app_update", "auth_app_delete", + "run_runners_list", + "run_runner_tags_set", ] { assert!( names.contains(&cloud_only), @@ -2100,15 +2143,16 @@ mod tests { fn delete_and_overwrite_tools_carry_destructive_hint() { let tools = SmbMcpServer::cloud_tool_router().list_all(); let mut checked = 0; - for tool in tools - .iter() - .filter(|tool| tool.name.ends_with("_delete") || tool.name == "project_init") - { + for tool in tools.iter().filter(|tool| { + tool.name.ends_with("_delete") + || tool.name == "project_init" + || tool.name == "run_runner_tags_set" + }) { let annotations = tool.annotations.as_ref().expect("annotations"); assert_eq!(annotations.destructive_hint, Some(true), "{}", tool.name); checked += 1; } - assert_eq!(checked, 6); + assert_eq!(checked, 7); } #[test] diff --git a/crates/cli/src/run.rs b/crates/cli/src/run.rs new file mode 100644 index 00000000..3065c53d --- /dev/null +++ b/crates/cli/src/run.rs @@ -0,0 +1,171 @@ +//! Admin view of the Run fleet. Both commands need an admin user's login; the +//! API refuses anyone else. + +use crate::{ + cli::CommandResult, + client, + token::get_smb_token::get_smb_token, + ui::{succeed_message, succeed_symbol}, +}; +use anyhow::{anyhow, Result}; +use clap::Subcommand; +use console::style; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use smbcloud_model::error_codes::ErrorResponse; +use smbcloud_network::{environment::Environment, network::request}; +use smbcloud_networking::{constants::SMB_USER_AGENT, smb_base_url_builder, smb_client::SmbClient}; +use spinners::Spinner; + +#[derive(Subcommand)] +pub enum Commands { + #[clap(about = "Manage Run runners (admin only).")] + Runners { + #[clap(subcommand)] + command: RunnerCommands, + }, +} + +#[derive(Subcommand)] +pub enum RunnerCommands { + #[clap(about = "List runners with their status and tags.")] + List {}, + #[clap(about = "Replace a runner's tags. Executor tags the runner reports are kept.")] + Tags { + /// The runner ID. + id: i64, + /// The full tag list. Lower-case letters, digits and hyphens, up to 16. + #[clap(required = true)] + tags: Vec, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Runner { + pub id: i64, + pub name: String, + pub status: String, + pub tags: Vec, + pub ci_enabled: bool, + pub publish_enabled: bool, + pub last_heartbeat_at: Option, + pub version: Option, + pub hostname: Option, + pub active_jobs: i64, +} + +#[derive(Serialize)] +struct TagsBody { + tags: Vec, +} + +fn runners_url(env: Environment, client: (&SmbClient, &str), id: Option) -> String { + let mut url_builder = smb_base_url_builder(env, client); + url_builder.add_route("v1/admin/run/runners"); + if let Some(id) = id { + url_builder.add_route(&id.to_string()); + } + url_builder.build() +} + +pub(crate) async fn list_runners( + env: Environment, + client: (&SmbClient, &str), + access_token: String, +) -> Result, ErrorResponse> { + let builder = Client::new() + .get(runners_url(env, client, None)) + .header("Authorization", access_token) + .header("User-agent", SMB_USER_AGENT); + request(builder).await +} + +pub(crate) async fn set_runner_tags( + env: Environment, + client: (&SmbClient, &str), + access_token: String, + id: i64, + tags: Vec, +) -> Result { + let builder = Client::new() + .patch(runners_url(env, client, Some(id))) + .json(&TagsBody { tags }) + .header("Authorization", access_token) + .header("User-agent", SMB_USER_AGENT); + request(builder).await +} + +pub async fn process_run(env: Environment, commands: Commands) -> Result { + match commands { + Commands::Runners { command } => match command { + RunnerCommands::List {} => process_runners_list(env).await, + RunnerCommands::Tags { id, tags } => process_runner_tags(env, id, tags).await, + }, + } +} + +async fn process_runners_list(env: Environment) -> Result { + let access_token = get_smb_token(env)?; + let mut spinner = loading_spinner("Loading runners"); + + let runners = list_runners(env, client(), access_token) + .await + .map_err(|error| anyhow!(error.to_string()))?; + + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded.")); + println!("\n{}", style("Runners").bold().underlined()); + for runner in &runners { + print_runner(runner); + } + + Ok(done_result(if runners.is_empty() { + "No runners found." + } else { + "Done." + })) +} + +async fn process_runner_tags( + env: Environment, + id: i64, + tags: Vec, +) -> Result { + let access_token = get_smb_token(env)?; + let mut spinner = loading_spinner("Saving tags"); + + let runner = set_runner_tags(env, client(), access_token, id, tags) + .await + .map_err(|error| anyhow!(error.to_string()))?; + + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Saved.")); + print_runner(&runner); + + Ok(done_result("Tags updated.")) +} + +fn print_runner(runner: &Runner) { + println!( + "#{} {} [{}] tags={} active_jobs={} host={}", + runner.id, + style(&runner.name).bold(), + runner.status, + runner.tags.join(","), + runner.active_jobs, + runner.hostname.as_deref().unwrap_or("-"), + ); +} + +fn loading_spinner(message: &str) -> Spinner { + Spinner::new( + spinners::Spinners::SimpleDotsScrolling, + succeed_message(message), + ) +} + +fn done_result(message: &str) -> CommandResult { + CommandResult { + spinner: loading_spinner("Done"), + symbol: succeed_symbol(), + msg: succeed_message(message), + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 2047382d..f2c82524 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5,8 +5,8 @@ assistant or agent — Claude Desktop, Claude Code, Cursor, or any other MCP-capable client — can send transactional email and manage your smbCloud projects, tenants, Mail apps, and Auth apps directly, without you leaving the chat to run `smb` commands by hand. -Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 41 -tools covering the account, project, deploy, tenant, Mail, and Auth surfaces. +Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 43 +tools covering the account, project, deploy, tenant, Run, Mail, and Auth surfaces. Cross-platform mobile and TV app testing is exposed as a separate, focused MCP profile through `xcrs --mcp` or `smb --mcp --scope automation`. Keeping cloud @@ -254,6 +254,18 @@ tenant is bootstrapped on signup and isn't user-managed. | `tenant_update` | `id`, `name` | The renamed tenant. | | `tenant_delete` | `id` | Confirmation. **Destructive and irreversible** — cascades to every project, Mail app, and Auth app the tenant owns. | +### Run runners + +Run executes jobs on registered runners, and a runner's tags decide which jobs +it claims. These tools are for an admin user: the API refuses anyone else. The +same operations are available as `smb run runners list` and +`smb run runners tags ...`. + +| Tool | Arguments | Returns | +| --- | --- | --- | +| `run_runners_list` | _(none)_ | Every runner with status, tags, flags, heartbeat, host, and active job count. | +| `run_runner_tags_set` | `id`, `tags` | The updated runner. **Replaces the whole tag list**, so read the current tags first. Tags are lower-cased and must use letters, digits, and hyphens, up to 16. An executor tag the runner reports is kept. | + ### Mail apps A Mail app owns one domain's mail routing; it belongs to a project. From aac057f9769a3bce5e73c537ed428a8c037b3c3d Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:57:51 +0100 Subject: [PATCH 3/7] Release 1.1.1 Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 34 +++++++++---------- Cargo.toml | 24 ++++++------- crates/cli/Cargo.toml | 2 +- crates/smbcloud-auth-sdk-py/Cargo.toml | 2 +- crates/smbcloud-auth-sdk-wasm/Cargo.toml | 2 +- crates/smbcloud-auth-sdk/Cargo.toml | 2 +- crates/smbcloud-auth/Cargo.toml | 2 +- crates/smbcloud-deploy/Cargo.toml | 2 +- crates/smbcloud-email-sdk/Cargo.toml | 2 +- crates/smbcloud-gresiq-sdk/Cargo.toml | 2 +- crates/smbcloud-gresiq/Cargo.toml | 2 +- crates/smbcloud-mail/Cargo.toml | 2 +- crates/smbcloud-model/Cargo.toml | 2 +- crates/smbcloud-network/Cargo.toml | 2 +- crates/smbcloud-networking-project/Cargo.toml | 2 +- crates/smbcloud-networking/Cargo.toml | 2 +- crates/smbcloud-s6n/Cargo.toml | 2 +- crates/smbcloud-utils/Cargo.toml | 2 +- crates/xcrs/Cargo.toml | 2 +- packaging/mcp/server-xcrs.json | 4 +-- packaging/mcp/server.json | 6 ++-- packaging/npm/smbcloud-cli/package-lock.json | 14 ++++---- packaging/npm/smbcloud-cli/package.json | 14 ++++---- packaging/npm/xcrs/package-lock.json | 14 ++++---- packaging/npm/xcrs/package.json | 14 ++++---- .../nuget/smbcloud-cli/SmbCloud.Cli.csproj | 2 +- packaging/nuget/xcrs/Xcrs.csproj | 2 +- sdk/gems/auth/Gemfile.lock | 2 +- sdk/gems/auth/ext/auth/Cargo.toml | 2 +- sdk/gems/auth/lib/auth/version.rb | 2 +- sdk/gems/email/Gemfile.lock | 2 +- sdk/gems/email/ext/email/Cargo.toml | 2 +- sdk/gems/email/lib/email/version.rb | 2 +- sdk/gems/model/Gemfile.lock | 2 +- sdk/gems/model/ext/model/Cargo.toml | 2 +- sdk/gems/model/lib/model/version.rb | 2 +- sdk/npm/smbcloud-auth/package.json | 2 +- 37 files changed, 91 insertions(+), 91 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee58c6c2..2a8616d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2653,7 +2653,7 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "smbcloud-auth" -version = "1.1.0" +version = "1.1.1" dependencies = [ "log", "reqwest", @@ -2668,7 +2668,7 @@ dependencies = [ [[package]] name = "smbcloud-auth-sdk" -version = "1.1.0" +version = "1.1.1" dependencies = [ "base64 0.22.1", "reqwest", @@ -2683,7 +2683,7 @@ dependencies = [ [[package]] name = "smbcloud-auth-sdk-py" -version = "1.1.0" +version = "1.1.1" dependencies = [ "pyo3", "pythonize", @@ -2697,7 +2697,7 @@ dependencies = [ [[package]] name = "smbcloud-auth-sdk-wasm" -version = "1.1.0" +version = "1.1.1" dependencies = [ "serde-wasm-bindgen", "smbcloud-auth-sdk", @@ -2710,7 +2710,7 @@ dependencies = [ [[package]] name = "smbcloud-cli" -version = "1.1.0" +version = "1.1.1" dependencies = [ "anyhow", "chrono", @@ -2758,7 +2758,7 @@ dependencies = [ [[package]] name = "smbcloud-deploy" -version = "1.1.0" +version = "1.1.1" dependencies = [ "anyhow", "ignore", @@ -2778,7 +2778,7 @@ dependencies = [ [[package]] name = "smbcloud-email-sdk" -version = "1.1.0" +version = "1.1.1" dependencies = [ "anyhow", "log", @@ -2793,7 +2793,7 @@ dependencies = [ [[package]] name = "smbcloud-gresiq" -version = "1.1.0" +version = "1.1.1" dependencies = [ "native-tls", "postgres-native-tls", @@ -2807,7 +2807,7 @@ dependencies = [ [[package]] name = "smbcloud-gresiq-sdk" -version = "1.1.0" +version = "1.1.1" dependencies = [ "anyhow", "log", @@ -2821,7 +2821,7 @@ dependencies = [ [[package]] name = "smbcloud-mail" -version = "1.1.0" +version = "1.1.1" dependencies = [ "reqwest", "serde", @@ -2833,7 +2833,7 @@ dependencies = [ [[package]] name = "smbcloud-model" -version = "1.1.0" +version = "1.1.1" dependencies = [ "chrono", "log", @@ -2849,7 +2849,7 @@ dependencies = [ [[package]] name = "smbcloud-network" -version = "1.1.0" +version = "1.1.1" dependencies = [ "clap 4.6.6", "log", @@ -2863,7 +2863,7 @@ dependencies = [ [[package]] name = "smbcloud-networking" -version = "1.1.0" +version = "1.1.1" dependencies = [ "smbcloud-network", "url-builder", @@ -2871,7 +2871,7 @@ dependencies = [ [[package]] name = "smbcloud-networking-project" -version = "1.1.0" +version = "1.1.1" dependencies = [ "anyhow", "async-trait", @@ -2889,11 +2889,11 @@ dependencies = [ [[package]] name = "smbcloud-s6n" -version = "1.1.0" +version = "1.1.1" [[package]] name = "smbcloud-utils" -version = "1.1.0" +version = "1.1.1" dependencies = [ "anyhow", "dirs", @@ -4141,7 +4141,7 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "xcrs" -version = "1.1.0" +version = "1.1.1" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index a2c85f88..8667256d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,17 +31,17 @@ serde = "1" serde_json = "1.0.82" serde_repr = "0.1" sha2 = "0.10" -smbcloud-auth = { version = "1.1.0", path = "crates/smbcloud-auth" } -smbcloud-auth-sdk = { version = "1.1.0", path = "crates/smbcloud-auth-sdk" } -smbcloud-deploy = { version = "1.1.0", path = "crates/smbcloud-deploy" } -smbcloud-email-sdk = { version = "1.1.0", path = "crates/smbcloud-email-sdk" } -smbcloud-gresiq-sdk = { version = "1.1.0", path = "crates/smbcloud-gresiq-sdk" } -smbcloud-mail = { version = "1.1.0", path = "crates/smbcloud-mail" } -smbcloud-model = { version = "1.1.0", path = "crates/smbcloud-model" } -smbcloud-network = { version = "1.1.0", path = "crates/smbcloud-network" } -smbcloud-networking = { version = "1.1.0", path = "crates/smbcloud-networking" } -smbcloud-networking-project = { version = "1.1.0", path = "crates/smbcloud-networking-project" } -smbcloud-utils = { version = "1.1.0", path = "crates/smbcloud-utils" } +smbcloud-auth = { version = "1.1.1", path = "crates/smbcloud-auth" } +smbcloud-auth-sdk = { version = "1.1.1", path = "crates/smbcloud-auth-sdk" } +smbcloud-deploy = { version = "1.1.1", path = "crates/smbcloud-deploy" } +smbcloud-email-sdk = { version = "1.1.1", path = "crates/smbcloud-email-sdk" } +smbcloud-gresiq-sdk = { version = "1.1.1", path = "crates/smbcloud-gresiq-sdk" } +smbcloud-mail = { version = "1.1.1", path = "crates/smbcloud-mail" } +smbcloud-model = { version = "1.1.1", path = "crates/smbcloud-model" } +smbcloud-network = { version = "1.1.1", path = "crates/smbcloud-network" } +smbcloud-networking = { version = "1.1.1", path = "crates/smbcloud-networking" } +smbcloud-networking-project = { version = "1.1.1", path = "crates/smbcloud-networking-project" } +smbcloud-utils = { version = "1.1.1", path = "crates/smbcloud-utils" } spinners = "4.1.0" strum = "0.27" strum_macros = "0.27" @@ -59,4 +59,4 @@ uniffi = "0.31" url-builder = "0.1.1" wiremock = "0.6" wasm-bindgen = "0.2" -xcrs = { version = "1.1.0", path = "crates/xcrs" } +xcrs = { version = "1.1.1", path = "crates/xcrs" } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index e45fc141..623a4ecb 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smbcloud-cli" -version = "1.1.0" +version = "1.1.1" edition = "2021" authors = ["Seto Elkahfi "] description = "smbCloud command line interface." diff --git a/crates/smbcloud-auth-sdk-py/Cargo.toml b/crates/smbcloud-auth-sdk-py/Cargo.toml index 9db07978..48d4e1f4 100644 --- a/crates/smbcloud-auth-sdk-py/Cargo.toml +++ b/crates/smbcloud-auth-sdk-py/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smbcloud-auth-sdk-py" -version = "1.1.0" +version = "1.1.1" edition = "2024" authors = ["Seto Elkahfi "] description = "Python bindings for the smbCloud Auth SDK." diff --git a/crates/smbcloud-auth-sdk-wasm/Cargo.toml b/crates/smbcloud-auth-sdk-wasm/Cargo.toml index 750af3e5..4e95c778 100644 --- a/crates/smbcloud-auth-sdk-wasm/Cargo.toml +++ b/crates/smbcloud-auth-sdk-wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smbcloud-auth-sdk-wasm" -version = "1.1.0" +version = "1.1.1" edition = "2024" authors = ["Seto Elkahfi "] description = "Browser bindings for the smbCloud Auth SDK." diff --git a/crates/smbcloud-auth-sdk/Cargo.toml b/crates/smbcloud-auth-sdk/Cargo.toml index e8679f0d..e1a4a50f 100644 --- a/crates/smbcloud-auth-sdk/Cargo.toml +++ b/crates/smbcloud-auth-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smbcloud-auth-sdk" -version = "1.1.0" +version = "1.1.1" edition = "2024" authors = ["Seto Elkahfi "] description = "Client SDK for smbCloud Auth — authenticate your users via smbCloud Auth service." diff --git a/crates/smbcloud-auth/Cargo.toml b/crates/smbcloud-auth/Cargo.toml index bd22dcb3..ec7ea36f 100644 --- a/crates/smbcloud-auth/Cargo.toml +++ b/crates/smbcloud-auth/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smbcloud-auth" -version = "1.1.0" +version = "1.1.1" edition = "2024" authors = ["Seto Elkahfi "] description = "Cross-platform Auth SDK for the smbCloud platform." diff --git a/crates/smbcloud-deploy/Cargo.toml b/crates/smbcloud-deploy/Cargo.toml index 9c59d87d..635a988a 100644 --- a/crates/smbcloud-deploy/Cargo.toml +++ b/crates/smbcloud-deploy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smbcloud-deploy" -version = "1.1.0" +version = "1.1.1" edition = "2021" authors = ["keypair34 <216233964+keypair34@users.noreply.github.com>, Seto Elkahfi "] description = "smbCloud build and deploy engine: UI-agnostic, reusable by the CLI, CI, and the server-side git receiver." diff --git a/crates/smbcloud-email-sdk/Cargo.toml b/crates/smbcloud-email-sdk/Cargo.toml index 755d2b36..b02f8141 100644 --- a/crates/smbcloud-email-sdk/Cargo.toml +++ b/crates/smbcloud-email-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smbcloud-email-sdk" -version = "1.1.0" +version = "1.1.1" edition = "2021" description = "Rust client for the smbCloud transactional email API — send email from a verified domain and read delivery status." license = "Apache-2.0" diff --git a/crates/smbcloud-gresiq-sdk/Cargo.toml b/crates/smbcloud-gresiq-sdk/Cargo.toml index 8e2fe462..e309e307 100644 --- a/crates/smbcloud-gresiq-sdk/Cargo.toml +++ b/crates/smbcloud-gresiq-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smbcloud-gresiq-sdk" -version = "1.1.0" +version = "1.1.1" edition = "2021" description = "Rust client for the smbCloud GresIQ REST gateway — API-key auth, app management, and model assignment for Onde Inference." license = "Apache-2.0" diff --git a/crates/smbcloud-gresiq/Cargo.toml b/crates/smbcloud-gresiq/Cargo.toml index 08e53ba1..17218b07 100644 --- a/crates/smbcloud-gresiq/Cargo.toml +++ b/crates/smbcloud-gresiq/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "smbcloud-gresiq" description = "GresIQ is a serverless PostgreSQL client on the smbCloud platform." -version = "1.1.0" +version = "1.1.1" edition = "2024" readme = "README.md" authors = ["Seto Elkahfi "] diff --git a/crates/smbcloud-mail/Cargo.toml b/crates/smbcloud-mail/Cargo.toml index a5c2c46c..18c6559d 100644 --- a/crates/smbcloud-mail/Cargo.toml +++ b/crates/smbcloud-mail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smbcloud-mail" -version = "1.1.0" +version = "1.1.1" edition = "2021" authors = ["Seto Elkahfi "] description = "smbCloud mail API client for the CLI." diff --git a/crates/smbcloud-model/Cargo.toml b/crates/smbcloud-model/Cargo.toml index bd12531f..503c3a52 100644 --- a/crates/smbcloud-model/Cargo.toml +++ b/crates/smbcloud-model/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smbcloud-model" -version = "1.1.0" +version = "1.1.1" edition = "2021" authors = ["Seto Elkahfi "] description = "Command line tool for deploying NextJS app on the smbCloud platform." diff --git a/crates/smbcloud-network/Cargo.toml b/crates/smbcloud-network/Cargo.toml index e2d7243b..6d14e9ce 100644 --- a/crates/smbcloud-network/Cargo.toml +++ b/crates/smbcloud-network/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smbcloud-network" -version = "1.1.0" +version = "1.1.1" edition = "2024" readme = "README.md" authors = ["Seto Elkahfi "] diff --git a/crates/smbcloud-networking-project/Cargo.toml b/crates/smbcloud-networking-project/Cargo.toml index 2417771b..47ad9d5f 100644 --- a/crates/smbcloud-networking-project/Cargo.toml +++ b/crates/smbcloud-networking-project/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smbcloud-networking-project" -version = "1.1.0" +version = "1.1.1" edition = "2021" authors = ["Seto Elkahfi "] description = "Command line tool for deploying NextJS app on the smbCloud platform." diff --git a/crates/smbcloud-networking/Cargo.toml b/crates/smbcloud-networking/Cargo.toml index 8d9454b3..edd79f57 100644 --- a/crates/smbcloud-networking/Cargo.toml +++ b/crates/smbcloud-networking/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smbcloud-networking" -version = "1.1.0" +version = "1.1.1" edition = "2021" readme = "README.md" authors = ["Seto Elkahfi "] diff --git a/crates/smbcloud-s6n/Cargo.toml b/crates/smbcloud-s6n/Cargo.toml index 5cb4eec6..d7134cd0 100644 --- a/crates/smbcloud-s6n/Cargo.toml +++ b/crates/smbcloud-s6n/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "smbcloud-s6n" description = "S6n is an S3-compatible object storage for smbCloud." -version = "1.1.0" +version = "1.1.1" edition = "2024" readme = "README.md" authors = ["Seto Elkahfi "] diff --git a/crates/smbcloud-utils/Cargo.toml b/crates/smbcloud-utils/Cargo.toml index 44104114..14bae31d 100644 --- a/crates/smbcloud-utils/Cargo.toml +++ b/crates/smbcloud-utils/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smbcloud-utils" -version = "1.1.0" +version = "1.1.1" edition = "2021" authors = ["Seto Elkahfi "] description = "Command line tool for deploying NextJS app on the smbCloud platform." diff --git a/crates/xcrs/Cargo.toml b/crates/xcrs/Cargo.toml index fe421083..e1515158 100644 --- a/crates/xcrs/Cargo.toml +++ b/crates/xcrs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xcrs" -version = "1.1.0" +version = "1.1.1" edition = "2021" authors = ["Seto Elkahfi "] description = "Cross-platform mobile and TV app automation over MCP." diff --git a/packaging/mcp/server-xcrs.json b/packaging/mcp/server-xcrs.json index a9e653cb..068ac4d6 100644 --- a/packaging/mcp/server-xcrs.json +++ b/packaging/mcp/server-xcrs.json @@ -3,7 +3,7 @@ "name": "io.github.smbcloudXYZ/xcrs", "title": "XCRS Mobile & TV Automation", "description": "MCP tools for testing mobile and TV apps on iOS simulators, Android phones, and Apple/Android TV.", - "version": "1.1.0", + "version": "1.1.1", "websiteUrl": "https://github.com/smbcloudXYZ/smbcloud-cli/tree/main/crates/xcrs", "repository": { "url": "https://github.com/smbcloudXYZ/smbcloud-cli", @@ -16,7 +16,7 @@ "registryType": "cargo", "registryBaseUrl": "https://crates.io", "identifier": "xcrs", - "version": "1.1.0", + "version": "1.1.1", "transport": { "type": "stdio" }, diff --git a/packaging/mcp/server.json b/packaging/mcp/server.json index 2a4748d7..ad61fa1c 100644 --- a/packaging/mcp/server.json +++ b/packaging/mcp/server.json @@ -3,7 +3,7 @@ "name": "io.github.smbcloudXYZ/smbcloud-cli", "title": "smbCloud Mail & Auth", "description": "Email infrastructure and authentication for developers: domains, inbox routes, auth apps, deploys.", - "version": "1.1.0", + "version": "1.1.1", "websiteUrl": "https://docs.smbcloud.xyz/developer/cli", "icons": [ { @@ -30,7 +30,7 @@ "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", "identifier": "@smbcloud/cli", - "version": "1.1.0", + "version": "1.1.1", "runtimeHint": "npx", "transport": { "type": "stdio" @@ -57,7 +57,7 @@ "registryType": "nuget", "registryBaseUrl": "https://api.nuget.org/v3/index.json", "identifier": "SmbCloud.Cli", - "version": "1.1.0", + "version": "1.1.1", "runtimeHint": "dnx", "transport": { "type": "stdio" diff --git a/packaging/npm/smbcloud-cli/package-lock.json b/packaging/npm/smbcloud-cli/package-lock.json index cd378db1..95b69685 100644 --- a/packaging/npm/smbcloud-cli/package-lock.json +++ b/packaging/npm/smbcloud-cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@smbcloud/cli", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@smbcloud/cli", - "version": "1.1.0", + "version": "1.1.1", "license": "Apache-2.0", "bin": { "smb": "lib/index.js" @@ -19,11 +19,11 @@ "typescript": "^5.0.0" }, "optionalDependencies": { - "@smbcloud/cli-darwin-arm64": "1.1.0", - "@smbcloud/cli-darwin-x64": "1.1.0", - "@smbcloud/cli-linux-x64": "1.1.0", - "@smbcloud/cli-windows-arm64": "1.1.0", - "@smbcloud/cli-windows-x64": "1.1.0" + "@smbcloud/cli-darwin-arm64": "1.1.1", + "@smbcloud/cli-darwin-x64": "1.1.1", + "@smbcloud/cli-linux-x64": "1.1.1", + "@smbcloud/cli-windows-arm64": "1.1.1", + "@smbcloud/cli-windows-x64": "1.1.1" } }, "node_modules/@eslint-community/eslint-utils": { diff --git a/packaging/npm/smbcloud-cli/package.json b/packaging/npm/smbcloud-cli/package.json index fdbb9f35..a37fdda1 100644 --- a/packaging/npm/smbcloud-cli/package.json +++ b/packaging/npm/smbcloud-cli/package.json @@ -1,6 +1,6 @@ { "name": "@smbcloud/cli", - "version": "1.1.0", + "version": "1.1.1", "keywords": [ "smbcloud", "cli", @@ -58,11 +58,11 @@ "typescript": "^5.0.0" }, "optionalDependencies": { - "@smbcloud/cli-darwin-arm64": "1.1.0", - "@smbcloud/cli-darwin-x64": "1.1.0", - "@smbcloud/cli-linux-arm64": "1.1.0", - "@smbcloud/cli-linux-x64": "1.1.0", - "@smbcloud/cli-windows-arm64": "1.1.0", - "@smbcloud/cli-windows-x64": "1.1.0" + "@smbcloud/cli-darwin-arm64": "1.1.1", + "@smbcloud/cli-darwin-x64": "1.1.1", + "@smbcloud/cli-linux-arm64": "1.1.1", + "@smbcloud/cli-linux-x64": "1.1.1", + "@smbcloud/cli-windows-arm64": "1.1.1", + "@smbcloud/cli-windows-x64": "1.1.1" } } diff --git a/packaging/npm/xcrs/package-lock.json b/packaging/npm/xcrs/package-lock.json index 4b9bb946..32fe09f4 100644 --- a/packaging/npm/xcrs/package-lock.json +++ b/packaging/npm/xcrs/package-lock.json @@ -1,12 +1,12 @@ { "name": "@smbcloud/xcrs", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@smbcloud/xcrs", - "version": "1.1.0", + "version": "1.1.1", "license": "Apache-2.0", "bin": { "xcrs": "lib/index.js" @@ -19,11 +19,11 @@ "typescript": "^5.0.0" }, "optionalDependencies": { - "@smbcloud/xcrs-darwin-arm64": "1.1.0", - "@smbcloud/xcrs-darwin-x64": "1.1.0", - "@smbcloud/xcrs-linux-x64": "1.1.0", - "@smbcloud/xcrs-windows-arm64": "1.1.0", - "@smbcloud/xcrs-windows-x64": "1.1.0" + "@smbcloud/xcrs-darwin-arm64": "1.1.1", + "@smbcloud/xcrs-darwin-x64": "1.1.1", + "@smbcloud/xcrs-linux-x64": "1.1.1", + "@smbcloud/xcrs-windows-arm64": "1.1.1", + "@smbcloud/xcrs-windows-x64": "1.1.1" } }, "node_modules/@eslint-community/eslint-utils": { diff --git a/packaging/npm/xcrs/package.json b/packaging/npm/xcrs/package.json index 08964f01..70a5aea2 100644 --- a/packaging/npm/xcrs/package.json +++ b/packaging/npm/xcrs/package.json @@ -1,6 +1,6 @@ { "name": "@smbcloud/xcrs", - "version": "1.1.0", + "version": "1.1.1", "keywords": [ "xcrs", "mcp", @@ -46,11 +46,11 @@ "typescript": "^5.0.0" }, "optionalDependencies": { - "@smbcloud/xcrs-darwin-arm64": "1.1.0", - "@smbcloud/xcrs-darwin-x64": "1.1.0", - "@smbcloud/xcrs-linux-arm64": "1.1.0", - "@smbcloud/xcrs-linux-x64": "1.1.0", - "@smbcloud/xcrs-windows-arm64": "1.1.0", - "@smbcloud/xcrs-windows-x64": "1.1.0" + "@smbcloud/xcrs-darwin-arm64": "1.1.1", + "@smbcloud/xcrs-darwin-x64": "1.1.1", + "@smbcloud/xcrs-linux-arm64": "1.1.1", + "@smbcloud/xcrs-linux-x64": "1.1.1", + "@smbcloud/xcrs-windows-arm64": "1.1.1", + "@smbcloud/xcrs-windows-x64": "1.1.1" } } diff --git a/packaging/nuget/smbcloud-cli/SmbCloud.Cli.csproj b/packaging/nuget/smbcloud-cli/SmbCloud.Cli.csproj index 431e0116..39a4a5ad 100644 --- a/packaging/nuget/smbcloud-cli/SmbCloud.Cli.csproj +++ b/packaging/nuget/smbcloud-cli/SmbCloud.Cli.csproj @@ -7,7 +7,7 @@ true smb SmbCloud.Cli - 1.1.0 + 1.1.1 $(PackageVersion) Seto Elkahfi smbCloud command line interface. diff --git a/packaging/nuget/xcrs/Xcrs.csproj b/packaging/nuget/xcrs/Xcrs.csproj index bf51252c..02130206 100644 --- a/packaging/nuget/xcrs/Xcrs.csproj +++ b/packaging/nuget/xcrs/Xcrs.csproj @@ -7,7 +7,7 @@ true xcrs SmbCloud.Xcrs - 1.1.0 + 1.1.1 $(PackageVersion) Seto Elkahfi Cross-platform mobile and TV app automation over MCP. diff --git a/sdk/gems/auth/Gemfile.lock b/sdk/gems/auth/Gemfile.lock index 30b74c50..400396b2 100644 --- a/sdk/gems/auth/Gemfile.lock +++ b/sdk/gems/auth/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - smbcloud-auth (1.1.0) + smbcloud-auth (1.1.1) json rb_sys (~> 0.9, >= 0.9.130) diff --git a/sdk/gems/auth/ext/auth/Cargo.toml b/sdk/gems/auth/ext/auth/Cargo.toml index b2d1c160..cd0747fe 100644 --- a/sdk/gems/auth/ext/auth/Cargo.toml +++ b/sdk/gems/auth/ext/auth/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "auth" -version = "1.1.0" +version = "1.1.1" edition = "2024" authors = ["Seto Elkahfi "] publish = false diff --git a/sdk/gems/auth/lib/auth/version.rb b/sdk/gems/auth/lib/auth/version.rb index 2b100d30..a8526d0e 100644 --- a/sdk/gems/auth/lib/auth/version.rb +++ b/sdk/gems/auth/lib/auth/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Auth - VERSION = '1.1.0' + VERSION = '1.1.1' end diff --git a/sdk/gems/email/Gemfile.lock b/sdk/gems/email/Gemfile.lock index 4d5ff2a9..2b01bd81 100644 --- a/sdk/gems/email/Gemfile.lock +++ b/sdk/gems/email/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - smbcloud-email (1.1.0) + smbcloud-email (1.1.1) json rb_sys (~> 0.9, >= 0.9.130) diff --git a/sdk/gems/email/ext/email/Cargo.toml b/sdk/gems/email/ext/email/Cargo.toml index b840a25d..4a1f8b2a 100644 --- a/sdk/gems/email/ext/email/Cargo.toml +++ b/sdk/gems/email/ext/email/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "email" -version = "1.1.0" +version = "1.1.1" edition = "2024" authors = ["Seto Elkahfi "] publish = false diff --git a/sdk/gems/email/lib/email/version.rb b/sdk/gems/email/lib/email/version.rb index 5e5f8571..06a65761 100644 --- a/sdk/gems/email/lib/email/version.rb +++ b/sdk/gems/email/lib/email/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Email - VERSION = '1.1.0' + VERSION = '1.1.1' end diff --git a/sdk/gems/model/Gemfile.lock b/sdk/gems/model/Gemfile.lock index 6e720259..7570612a 100644 --- a/sdk/gems/model/Gemfile.lock +++ b/sdk/gems/model/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - smbcloud-model (1.1.0) + smbcloud-model (1.1.1) rb_sys (~> 0.9, >= 0.9.130) GEM diff --git a/sdk/gems/model/ext/model/Cargo.toml b/sdk/gems/model/ext/model/Cargo.toml index 41560868..e6509ac9 100644 --- a/sdk/gems/model/ext/model/Cargo.toml +++ b/sdk/gems/model/ext/model/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "model" -version = "1.1.0" +version = "1.1.1" edition = "2021" authors = ["paydii "] publish = false diff --git a/sdk/gems/model/lib/model/version.rb b/sdk/gems/model/lib/model/version.rb index 857190dd..7fd72ed3 100644 --- a/sdk/gems/model/lib/model/version.rb +++ b/sdk/gems/model/lib/model/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Model - VERSION = '1.1.0' + VERSION = '1.1.1' end diff --git a/sdk/npm/smbcloud-auth/package.json b/sdk/npm/smbcloud-auth/package.json index 4872686f..54b4e5c3 100644 --- a/sdk/npm/smbcloud-auth/package.json +++ b/sdk/npm/smbcloud-auth/package.json @@ -1,7 +1,7 @@ { "name": "@smbcloud/sdk-auth", "description": "Browser Auth SDK for smbCloud, built from Rust and WebAssembly.", - "version": "1.1.0", + "version": "1.1.1", "type": "module", "main": "dist/smbcloud_auth_wasm.js", "module": "dist/smbcloud_auth_wasm.js", From 0315b4635af102300ee58dbf8b9ddb668c666baa Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:38:22 +0100 Subject: [PATCH 4/7] Add a desktop target to xcrs over W3C WebDriver SelectedTarget::Desktop, selected with device_select webdriver_url, drives an app through tauri-driver or any WebDriver endpoint using reqwest and plain JSON. app_install_launch and app_launch start a session, ui_describe returns the page source, ui_element_list and ui_tap work on labelled elements, input_text types into the focused element, and screen_capture returns a PNG. The other tools return an error saying they have no desktop meaning, and the 21-tool list is unchanged. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: siGit Code v1.7.6 --- crates/xcrs/README.md | 25 ++ crates/xcrs/src/mcp.rs | 345 +++++++++++++++++- crates/xcrs/src/webdriver.rs | 661 +++++++++++++++++++++++++++++++++++ crates/xcrs/src/xcrs.rs | 1 + 4 files changed, 1019 insertions(+), 13 deletions(-) create mode 100644 crates/xcrs/src/webdriver.rs diff --git a/crates/xcrs/README.md b/crates/xcrs/README.md index 20be9317..c3588452 100644 --- a/crates/xcrs/README.md +++ b/crates/xcrs/README.md @@ -28,6 +28,31 @@ The same automation profile is available from the full smbCloud CLI: smb --mcp --scope automation ``` +## Desktop apps + +A desktop target drives an application through a W3C WebDriver endpoint, for example +`tauri-driver` for a Tauri app on Linux (it proxies WebKitWebDriver). Select it once: + +```json +{ "name": "device_select", "arguments": { "webdriver_url": "http://127.0.0.1:4444" } } +``` + +`webdriver_url` cannot be combined with an Apple or Android field. The desktop tools then use the +stored target. + +| Tool | Desktop behaviour | +|------|-------------------| +| `app_install_launch`, `app_launch` | Start a WebDriver session, which launches the binary at `app_path` (`app_id` for `app_launch`). `bundle_id` is ignored. | +| `app_terminate` | End the session and close the app. | +| `ui_describe` | The page source. | +| `ui_element_list` | Visible interactive elements with label, id, role and rectangle, up to 200. | +| `ui_tap` | Click the one displayed element whose id, name, test id, ARIA label, title or text equals `element`. Zero or several matches are an error. | +| `input_text` | Type into the focused element. | +| `screen_capture` | A PNG of the window. | + +The other tools return an error saying they have no desktop meaning. A running app cannot be +attached to: the session is the launched app. + ## Repository Source and documentation are available in the diff --git a/crates/xcrs/src/mcp.rs b/crates/xcrs/src/mcp.rs index ebe3fa91..f4a22710 100644 --- a/crates/xcrs/src/mcp.rs +++ b/crates/xcrs/src/mcp.rs @@ -76,8 +76,8 @@ fn legacy_discovery_response(line: &str) -> Option> { } /// A validated, unambiguous automation target: exactly one of an Apple simulator, an -/// Apple ControlKit host (physical device, remote runner, or local Mac), or an -/// Android device. Produced by [`resolve_selected_target`], never constructed +/// Apple ControlKit host (physical device, remote runner, or local Mac), an +/// Android device, or a desktop app behind a W3C WebDriver endpoint. Produced by [`resolve_selected_target`], never constructed /// directly from untrusted input. #[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] #[serde(tag = "platform", rename_all = "snake_case")] @@ -107,6 +107,13 @@ pub enum SelectedTarget { #[serde(default)] serial: Option, }, + /// A desktop application driven through a W3C WebDriver endpoint, such as + /// `tauri-driver`. Starting the session launches the app. + Desktop { + /// WebDriver endpoint. `None` means `http://127.0.0.1:4444`. + #[serde(default)] + webdriver_url: Option, + }, } /// Validate and tag a target from optional per-call fields, falling back to @@ -124,10 +131,41 @@ pub fn resolve_selected_target( controlkit_port: Option, android_serial: Option, stored: Option, +) -> Result { + resolve_target( + simulator_name, + simulator_udid, + host, + controlkit_port, + android_serial, + None, + stored, + ) +} + +/// [`resolve_selected_target`] with the desktop field. `webdriver_url` selects a desktop +/// target and cannot be combined with any Apple or Android field. +pub fn resolve_target( + simulator_name: Option, + simulator_udid: Option, + host: Option, + controlkit_port: Option, + android_serial: Option, + webdriver_url: Option, + stored: Option, ) -> Result { let apple_simulator_provided = simulator_name.is_some() || simulator_udid.is_some(); let apple_host_provided = host.is_some(); let android_provided = android_serial.is_some(); + let desktop_provided = webdriver_url.is_some(); + + if desktop_provided && (android_provided || apple_simulator_provided || apple_host_provided) { + return Err( + "Ambiguous target: webdriver_url cannot be combined with simulator_name, \ + simulator_udid, host, or android_serial. Provide exactly one target." + .to_string(), + ); + } if android_provided && (apple_simulator_provided || apple_host_provided) { return Err( @@ -144,6 +182,11 @@ pub fn resolve_selected_target( ); } + if let Some(webdriver_url) = webdriver_url { + return Ok(SelectedTarget::Desktop { + webdriver_url: Some(webdriver_url), + }); + } if let Some(serial) = android_serial { return Ok(SelectedTarget::Android { serial: Some(serial), @@ -164,8 +207,8 @@ pub fn resolve_selected_target( } stored.ok_or_else(|| { - "No target provided. Pass simulator_name/simulator_udid, host, or android_serial, \ - or select a default target first with device_select." + "No target provided. Pass simulator_name/simulator_udid, host, android_serial, or \ + webdriver_url, or select a default target first with device_select." .to_string() }) } @@ -181,10 +224,92 @@ pub fn require_apple_target(tool_name: &str, target: &SelectedTarget) -> Result< simulator_name, simulator_udid, or host explicitly.", serial.as_deref().unwrap_or("auto-detected") )), + SelectedTarget::Desktop { .. } => Err(format!( + "{tool_name} is Apple-only; the active target is a desktop app. Select an Apple \ + simulator or ControlKit host with device_select, or pass simulator_name, \ + simulator_udid, or host explicitly." + )), SelectedTarget::AppleSimulator { .. } | SelectedTarget::AppleHost { .. } => Ok(()), } } +/// One desktop operation, mapped from an `xcrs` tool. +#[derive(Debug, Clone)] +pub enum DesktopCall { + /// `app_install_launch` or `app_launch`: start the app at this binary path. + Launch(String), + /// `app_terminate`: end the session, which closes the app. + Terminate, + /// `ui_describe`: the page source. + Describe, + /// `ui_element_list`: visible interactive elements. + Elements, + /// `ui_tap`: click the one element with this label. + Tap(String), + /// `input_text`: type into the focused element. + Text(String), + /// `screen_capture`: a PNG of the window. + Screenshot, +} + +/// Run one desktop operation against the WebDriver endpoint and shape the answer as MCP +/// content. A bad URL or a bad label is an invalid request; a failed driver call is internal. +pub async fn run_desktop( + webdriver_url: Option<&str>, + call: DesktopCall, +) -> Result, rmcp::model::ErrorData> { + use rmcp::model::{ContentBlock, ErrorData}; + let driver = crate::webdriver::WebDriver::new(webdriver_url) + .map_err(|error| ErrorData::invalid_request(error.to_string(), None))?; + let failed = |error: anyhow::Error| ErrorData::internal_error(error.to_string(), None); + match call { + DesktopCall::Launch(path) => { + let session = driver.start_session(&path).await.map_err(failed)?; + Ok(vec![ContentBlock::text(format!( + "Started {path} on {} (session {session}).", + driver.url() + ))]) + } + DesktopCall::Terminate => { + let ended = driver.end_session().await.map_err(failed)?; + Ok(vec![ContentBlock::text(if ended { + "Ended the desktop session; the app is closed." + } else { + "No desktop session was running." + })]) + } + DesktopCall::Describe => { + let source = driver.page_source().await.map_err(failed)?; + Ok(vec![ContentBlock::json(serde_json::json!({ + "target": SelectedTarget::Desktop { webdriver_url: webdriver_url.map(str::to_string) }, + "ui": source, + }))?]) + } + DesktopCall::Elements => { + let elements = driver.list_elements().await.map_err(failed)?; + Ok(vec![ContentBlock::json(serde_json::json!({ + "target": SelectedTarget::Desktop { webdriver_url: webdriver_url.map(str::to_string) }, + "elements": elements, + }))?]) + } + DesktopCall::Tap(label) => { + driver.click_label(&label).await.map_err(failed)?; + Ok(vec![ContentBlock::text(format!("Clicked '{label}'."))]) + } + DesktopCall::Text(text) => { + driver.type_text(&text).await.map_err(failed)?; + Ok(vec![ContentBlock::text(format!( + "Typed {} characters.", + text.chars().count() + ))]) + } + DesktopCall::Screenshot => { + let png = driver.screenshot_base64().await.map_err(failed)?; + Ok(vec![ContentBlock::image(png, "image/png")]) + } + } +} + /// Round a floating-point coordinate to the integer pixel value adb's `input` /// commands require, rejecting non-finite values instead of silently truncating. pub fn to_android_coordinate(value: f64) -> Result { @@ -490,6 +615,11 @@ pub struct DeviceSelectArgs { /// target. Mutually exclusive with every Apple field. #[serde(default)] pub android_serial: Option, + /// W3C WebDriver endpoint of a desktop app, such as `tauri-driver` at + /// `http://127.0.0.1:4444`, to remember as the active target. Mutually exclusive + /// with every Apple and Android field. The desktop tools use the stored target. + #[serde(default)] + pub webdriver_url: Option, } /// Structured result of `device_select`: the single unambiguous target that is @@ -518,6 +648,9 @@ pub struct DeviceCapabilitiesArgs { /// Android device serial from `adb devices -l`. #[serde(default)] pub android_serial: Option, + /// W3C WebDriver endpoint of a desktop app. + #[serde(default)] + pub webdriver_url: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -1033,6 +1166,12 @@ macro_rules! xcrs_mcp_tools { None, )) } + $crate::mcp::SelectedTarget::Desktop { .. } => { + Err(::rmcp::model::ErrorData::invalid_request( + "Desktop targets do not use ControlKit; this tool has no desktop meaning.", + None, + )) + } } } @@ -1069,6 +1208,9 @@ macro_rules! xcrs_mcp_tools { .clone() .unwrap_or_else(|| "the connected Android device".to_string()), $crate::mcp::SelectedTarget::AppleHost { host, .. } => host.clone(), + $crate::mcp::SelectedTarget::Desktop { webdriver_url } => webdriver_url + .clone() + .unwrap_or_else(|| $crate::webdriver::DEFAULT_WEBDRIVER_URL.to_string()), $crate::mcp::SelectedTarget::AppleSimulator { .. } => simulator .as_ref() .map(|simulator| simulator.name.clone()) @@ -1143,12 +1285,13 @@ macro_rules! xcrs_mcp_tools { ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let target = $crate::mcp::resolve_selected_target( + let target = $crate::mcp::resolve_target( args.simulator_name, args.simulator_udid, args.host, args.controlkit_port, args.android_serial, + args.webdriver_url, None, ) .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?; @@ -1185,14 +1328,32 @@ macro_rules! xcrs_mcp_tools { ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let target = Self::dispatch_target( + let target = $crate::mcp::resolve_target( args.simulator_name, args.simulator_udid, args.host, args.controlkit_port, args.android_serial, - )?; + args.webdriver_url, + Self::stored_target(), + ) + .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?; match &target { + $crate::mcp::SelectedTarget::Desktop { webdriver_url } => { + let driver = $crate::webdriver::WebDriver::new(webdriver_url.as_deref()) + .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error.to_string(), None))?; + let mut capabilities = $crate::webdriver::desktop_capabilities(); + if let Some(object) = capabilities.as_object_mut() { + object.insert("driver_ready".to_string(), ::serde_json::json!(driver.is_ready().await)); + object.insert("webdriver_url".to_string(), ::serde_json::json!(driver.url())); + } + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ + "platform": "desktop", + "capabilities": capabilities, + }))?, + ])) + } $crate::mcp::SelectedTarget::Android { serial } => { let device = Self::android_device_for(serial.clone()).await?; let mut capabilities = $crate::mcp::android_capabilities(); @@ -1274,6 +1435,17 @@ macro_rules! xcrs_mcp_tools { ::rmcp::model::ErrorData, > { if args.simulator_name.is_none() && args.simulator_udid.is_none() { + if let ::std::option::Option::Some($crate::mcp::SelectedTarget::Desktop { webdriver_url }) = + Self::stored_target() + { + return Ok(::rmcp::model::CallToolResult::success( + $crate::mcp::run_desktop( + webdriver_url.as_deref(), + $crate::mcp::DesktopCall::Launch(args.app_path.to_string_lossy().into_owned()), + ) + .await?, + )); + } return Err(::rmcp::model::ErrorData::invalid_request( "Provide either simulator_name or simulator_udid.", None, @@ -1351,7 +1523,18 @@ macro_rules! xcrs_mcp_tools { args.controlkit_port, args.android_serial, )?; + if let $crate::mcp::SelectedTarget::Desktop { webdriver_url } = &target { + return Ok(::rmcp::model::CallToolResult::success( + $crate::mcp::run_desktop(webdriver_url.as_deref(), $crate::mcp::DesktopCall::Screenshot).await?, + )); + } let content = match &target { + $crate::mcp::SelectedTarget::Desktop { .. } => { + return Err(::rmcp::model::ErrorData::internal_error( + "desktop targets are handled before this match", + None, + )); + } $crate::mcp::SelectedTarget::Android { serial } => { if args.display.is_some() { return Err(::rmcp::model::ErrorData::invalid_request( @@ -1420,7 +1603,18 @@ macro_rules! xcrs_mcp_tools { args.controlkit_port, args.android_serial, )?; + if let $crate::mcp::SelectedTarget::Desktop { webdriver_url } = &target { + return Ok(::rmcp::model::CallToolResult::success( + $crate::mcp::run_desktop(webdriver_url.as_deref(), $crate::mcp::DesktopCall::Launch(args.app_id.clone())).await?, + )); + } let label = match &target { + $crate::mcp::SelectedTarget::Desktop { .. } => { + return Err(::rmcp::model::ErrorData::internal_error( + "desktop targets are handled before this match", + None, + )); + } $crate::mcp::SelectedTarget::Android { serial } => { let device = Self::android_device_for(serial.clone()).await?; let device_serial = device.serial.clone(); @@ -1499,7 +1693,18 @@ macro_rules! xcrs_mcp_tools { args.controlkit_port, args.android_serial, )?; + if let $crate::mcp::SelectedTarget::Desktop { webdriver_url } = &target { + return Ok(::rmcp::model::CallToolResult::success( + $crate::mcp::run_desktop(webdriver_url.as_deref(), $crate::mcp::DesktopCall::Terminate).await?, + )); + } let label = match &target { + $crate::mcp::SelectedTarget::Desktop { .. } => { + return Err(::rmcp::model::ErrorData::internal_error( + "desktop targets are handled before this match", + None, + )); + } $crate::mcp::SelectedTarget::Android { serial } => { let device = Self::android_device_for(serial.clone()).await?; let device_serial = device.serial.clone(); @@ -1579,6 +1784,12 @@ macro_rules! xcrs_mcp_tools { args.android_serial, )?; let label = match &target { + $crate::mcp::SelectedTarget::Desktop { .. } => { + return Err(::rmcp::model::ErrorData::internal_error( + "desktop targets are handled before this match", + None, + )); + } $crate::mcp::SelectedTarget::Android { serial } => { let device = Self::android_device_for(serial.clone()).await?; let device_serial = device.serial.clone(); @@ -1640,14 +1851,19 @@ macro_rules! xcrs_mcp_tools { ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let app_id = Self::require_app_id($ui_describe_name, args.app_id, args.bundle_id)?; - let target = Self::dispatch_target( + let target = Self::dispatch_target( args.simulator_name, args.simulator_udid, args.host, args.controlkit_port, args.android_serial, )?; + if let $crate::mcp::SelectedTarget::Desktop { webdriver_url } = &target { + return Ok(::rmcp::model::CallToolResult::success( + $crate::mcp::run_desktop(webdriver_url.as_deref(), $crate::mcp::DesktopCall::Describe).await?, + )); + } + let app_id = Self::require_app_id($ui_describe_name, args.app_id, args.bundle_id)?; let (simulator, result) = match &target { $crate::mcp::SelectedTarget::Android { serial } => { let device = Self::android_device_for(serial.clone()).await?; @@ -1691,10 +1907,15 @@ macro_rules! xcrs_mcp_tools { ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let app_id = Self::require_app_id($ui_element_list_name, args.app_id, args.bundle_id)?; - let target = Self::dispatch_target( + let target = Self::dispatch_target( args.simulator_name, args.simulator_udid, args.host, args.controlkit_port, args.android_serial, )?; + if let $crate::mcp::SelectedTarget::Desktop { webdriver_url } = &target { + return Ok(::rmcp::model::CallToolResult::success( + $crate::mcp::run_desktop(webdriver_url.as_deref(), $crate::mcp::DesktopCall::Elements).await?, + )); + } + let app_id = Self::require_app_id($ui_element_list_name, args.app_id, args.bundle_id)?; let (simulator, elements) = match &target { $crate::mcp::SelectedTarget::Android { serial } => { let device = Self::android_device_for(serial.clone()).await?; @@ -1730,8 +1951,7 @@ macro_rules! xcrs_mcp_tools { &self, ::rmcp::handler::server::wrapper::Parameters(args): ::rmcp::handler::server::wrapper::Parameters<$crate::mcp::UiTapArgs>, ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { - let app_id = Self::require_app_id($ui_tap_name, args.target.app_id, args.target.bundle_id)?; - let element = args.element.trim().to_string(); + let element = args.element.trim().to_string(); if element.is_empty() { return Err(::rmcp::model::ErrorData::invalid_request( format!("{} requires a non-empty element.", $ui_tap_name), @@ -1745,6 +1965,12 @@ macro_rules! xcrs_mcp_tools { args.target.controlkit_port, args.target.android_serial, )?; + if let $crate::mcp::SelectedTarget::Desktop { webdriver_url } = &target { + return Ok(::rmcp::model::CallToolResult::success( + $crate::mcp::run_desktop(webdriver_url.as_deref(), $crate::mcp::DesktopCall::Tap(args.element.trim().to_string())).await?, + )); + } + let app_id = Self::require_app_id($ui_tap_name, args.target.app_id, args.target.bundle_id)?; let label = match &target { $crate::mcp::SelectedTarget::Android { serial } => { let device = Self::android_device_for(serial.clone()).await?; @@ -1856,6 +2082,11 @@ macro_rules! xcrs_mcp_tools { args.controlkit_port, args.android_serial, )?; + if let $crate::mcp::SelectedTarget::Desktop { webdriver_url } = &target { + return Ok(::rmcp::model::CallToolResult::success( + $crate::mcp::run_desktop(webdriver_url.as_deref(), $crate::mcp::DesktopCall::Text(args.text.clone())).await?, + )); + } let label = match &target { $crate::mcp::SelectedTarget::Android { serial } => { let device = Self::android_device_for(serial.clone()).await?; @@ -2869,6 +3100,7 @@ mod tests { host: None, controlkit_port: None, android_serial: None, + webdriver_url: None, }, )) .await @@ -3096,6 +3328,93 @@ mod tests { assert_eq!(Orientation::Landscape.as_str(), "LANDSCAPE"); } + #[test] + fn webdriver_url_selects_a_desktop_target() { + let target = resolve_target( + None, + None, + None, + None, + None, + Some("http://127.0.0.1:4444".to_string()), + None, + ) + .unwrap(); + assert_eq!( + target, + SelectedTarget::Desktop { + webdriver_url: Some("http://127.0.0.1:4444".to_string()) + } + ); + } + + #[test] + fn webdriver_url_is_ambiguous_with_every_other_target_field() { + let url = || Some("http://127.0.0.1:4444".to_string()); + for (sim, host, android) in [ + (Some("iPhone 16".to_string()), None, None), + (None, Some("10.0.0.2".to_string()), None), + (None, None, Some("emulator-5554".to_string())), + ] { + let error = resolve_target(sim, None, host, None, android, url(), None).unwrap_err(); + assert!( + error.contains("webdriver_url cannot be combined"), + "{error}" + ); + } + } + + #[test] + fn a_stored_desktop_target_is_used_and_an_explicit_field_overrides_it() { + let stored = SelectedTarget::Desktop { + webdriver_url: None, + }; + assert_eq!( + resolve_target(None, None, None, None, None, None, Some(stored.clone())).unwrap(), + stored + ); + let android = resolve_target( + None, + None, + None, + None, + Some("emulator-5554".to_string()), + None, + Some(stored), + ) + .unwrap(); + assert!(matches!(android, SelectedTarget::Android { .. })); + } + + #[test] + fn apple_only_tools_refuse_a_desktop_target() { + let error = require_apple_target( + "input_click", + &SelectedTarget::Desktop { + webdriver_url: None, + }, + ) + .unwrap_err(); + assert!(error.contains("input_click is Apple-only"), "{error}"); + } + + #[tokio::test] + async fn run_desktop_reports_a_missing_session_and_a_bad_url() { + let error = run_desktop(Some("ftp://nowhere"), DesktopCall::Describe) + .await + .unwrap_err(); + assert!(error.message.contains("http"), "{}", error.message); + // Nothing listens on port 9, so the call fails as an internal error, not a panic. + let error = run_desktop(Some("http://127.0.0.1:9"), DesktopCall::Screenshot) + .await + .unwrap_err(); + assert!( + error.message.contains("no desktop session"), + "{}", + error.message + ); + } + #[test] fn android_capabilities_lists_supported_and_unsupported_actions() { let capabilities = android_capabilities(); diff --git a/crates/xcrs/src/webdriver.rs b/crates/xcrs/src/webdriver.rs new file mode 100644 index 00000000..1fe88b6d --- /dev/null +++ b/crates/xcrs/src/webdriver.rs @@ -0,0 +1,661 @@ +//! A small W3C WebDriver client for the desktop target. +//! +//! It talks plain JSON over HTTP with `reqwest` to a WebDriver endpoint such as +//! `tauri-driver`, which launches a Tauri application and proxies the platform's native +//! driver (WebKitWebDriver on Linux, msedgedriver on Windows). No client library is used. +//! +//! One session per driver URL is kept for the life of the process. `tauri-driver` starts the +//! application when a session is created, so the session is the launched app. + +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::OnceLock; +use std::time::Duration; +use tokio::sync::Mutex; + +/// Where `tauri-driver` listens unless told otherwise. +pub const DEFAULT_WEBDRIVER_URL: &str = "http://127.0.0.1:4444"; + +/// The most elements `ui_element_list` returns, so a large page cannot flood the agent. +const MAX_ELEMENTS: usize = 200; + +/// The element key the W3C spec uses in element references. +const ELEMENT_KEY: &str = "element-6066-11e4-a52e-4f735466cecf"; + +/// Interactive elements and labelled landmarks, in document order. +const LIST_ELEMENTS_SCRIPT: &str = r#" +const selector = 'a[href], button, input, select, textarea, summary, [role], [tabindex], [aria-label], [data-testid]'; +const visible = (el) => { + const r = el.getBoundingClientRect(); + const s = getComputedStyle(el); + return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; +}; +const out = []; +for (const el of document.querySelectorAll(selector)) { + if (!visible(el)) continue; + const r = el.getBoundingClientRect(); + const label = (el.getAttribute('aria-label') || el.getAttribute('title') || el.innerText || el.value || el.getAttribute('placeholder') || '').trim().replace(/\s+/g, ' ').slice(0, 120); + const id = el.id || el.getAttribute('name') || el.getAttribute('data-testid') || ''; + if (!label && !id) continue; + out.push({ + tag: el.tagName.toLowerCase(), + role: el.getAttribute('role') || '', + id, + label, + type: el.getAttribute('type') || '', + disabled: el.disabled === true || el.getAttribute('aria-disabled') === 'true', + rect: { x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height) }, + }); +} +return out; +"#; + +fn sessions() -> &'static Mutex> { + static SESSIONS: OnceLock>> = OnceLock::new(); + SESSIONS.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// A client for one WebDriver endpoint. +#[derive(Debug, Clone)] +pub struct WebDriver { + base: String, + http: reqwest::Client, +} + +/// Quote a string as an XPath 1.0 literal. XPath has no escape, so a value holding both quote +/// kinds is built with `concat`. +pub fn xpath_literal(value: &str) -> String { + if !value.contains('\'') { + return format!("'{value}'"); + } + if !value.contains('"') { + return format!("\"{value}\""); + } + let parts: Vec = value.split('\'').map(|part| format!("'{part}'")).collect(); + format!("concat({})", parts.join(", \"'\", ")) +} + +/// The XPath that finds an element by the label forms `ui_tap` accepts: id, name, test id, +/// ARIA label, title, or its exact visible text. +pub fn element_xpath(label: &str) -> String { + let literal = xpath_literal(label); + format!( + "//*[@id={literal} or @name={literal} or @data-testid={literal} or @aria-label={literal} \ + or @title={literal} or normalize-space(text())={literal}]" + ) +} + +impl WebDriver { + /// Build a client. `url` defaults to [`DEFAULT_WEBDRIVER_URL`]; only http and https are + /// accepted. + pub fn new(url: Option<&str>) -> Result { + let base = url + .unwrap_or(DEFAULT_WEBDRIVER_URL) + .trim() + .trim_end_matches('/'); + if !(base.starts_with("http://") || base.starts_with("https://")) { + bail!("webdriver_url must start with http:// or https://, got {base:?}"); + } + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .context("building the HTTP client")?; + Ok(Self { + base: base.to_string(), + http, + }) + } + + pub fn url(&self) -> &str { + &self.base + } + + /// Send one WebDriver command and return its `value`. A non-2xx answer becomes an error + /// carrying the driver's `error` code and message. + async fn send( + &self, + method: reqwest::Method, + path: &str, + body: Option, + ) -> Result { + let url = format!("{}{}", self.base, path); + let mut request = self.http.request(method.clone(), &url); + if let Some(body) = body { + request = request.json(&body); + } + let response = request + .send() + .await + .with_context(|| format!("{method} {url}: is a WebDriver server listening?"))?; + let status = response.status(); + let json: Value = response.json().await.unwrap_or(Value::Null); + let value = json.get("value").cloned().unwrap_or(Value::Null); + if !status.is_success() { + let code = value + .get("error") + .and_then(Value::as_str) + .unwrap_or("error"); + let message = value + .get("message") + .and_then(Value::as_str) + .unwrap_or("no message"); + bail!("webdriver {code} ({status}): {message}"); + } + Ok(value) + } + + /// Whether the driver answers `/status`. + pub async fn is_ready(&self) -> bool { + self.send(reqwest::Method::GET, "/status", None) + .await + .is_ok() + } + + /// Start the application and remember the session. Replaces a session already held for + /// this URL, deleting it first. + pub async fn start_session(&self, application: &str) -> Result { + let application = application.trim(); + if application.is_empty() { + bail!("the application path is empty"); + } + let _ = self.end_session().await; + let value = self + .send( + reqwest::Method::POST, + "/session", + Some(json!({ + "capabilities": { "alwaysMatch": { "tauri:options": { "application": application } } } + })), + ) + .await?; + let id = value + .get("sessionId") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("the driver's answer has no sessionId"))? + .to_string(); + sessions() + .lock() + .await + .insert(self.base.clone(), id.clone()); + Ok(id) + } + + /// Delete the held session, if any. Returns whether one existed. + pub async fn end_session(&self) -> Result { + let id = sessions().lock().await.remove(&self.base); + match id { + Some(id) => { + self.send(reqwest::Method::DELETE, &format!("/session/{id}"), None) + .await?; + Ok(true) + } + None => Ok(false), + } + } + + async fn session(&self) -> Result { + sessions() + .lock() + .await + .get(&self.base) + .cloned() + .ok_or_else(|| { + anyhow!( + "no desktop session on {}: start the app with app_install_launch (app_path) \ + or app_launch (app_id is the binary path) first", + self.base + ) + }) + } + + async fn on_session( + &self, + method: reqwest::Method, + suffix: &str, + body: Option, + ) -> Result { + let id = self.session().await?; + self.send(method, &format!("/session/{id}{suffix}"), body) + .await + } + + /// The current document as HTML. + pub async fn page_source(&self) -> Result { + let value = self + .on_session(reqwest::Method::GET, "/source", None) + .await?; + value + .as_str() + .map(str::to_string) + .ok_or_else(|| anyhow!("the page source is not a string")) + } + + /// Visible interactive elements with a label or id, capped at `MAX_ELEMENTS`. + pub async fn list_elements(&self) -> Result> { + let value = self + .on_session( + reqwest::Method::POST, + "/execute/sync", + Some(json!({ "script": LIST_ELEMENTS_SCRIPT, "args": [] })), + ) + .await?; + let mut elements = value.as_array().cloned().unwrap_or_default(); + elements.truncate(MAX_ELEMENTS); + Ok(elements) + } + + /// Find the displayed elements for a label. Returns their element references. + async fn displayed_matches(&self, label: &str) -> Result> { + let found = self + .on_session( + reqwest::Method::POST, + "/elements", + Some(json!({ "using": "xpath", "value": element_xpath(label) })), + ) + .await?; + let mut shown = Vec::new(); + for item in found.as_array().cloned().unwrap_or_default() { + let Some(reference) = item.get(ELEMENT_KEY).and_then(Value::as_str) else { + continue; + }; + let displayed = self + .on_session( + reqwest::Method::GET, + &format!("/element/{reference}/displayed"), + None, + ) + .await?; + if displayed.as_bool().unwrap_or(false) { + shown.push(reference.to_string()); + } + } + Ok(shown) + } + + /// Click the one displayed element that matches `label`. Zero or several matches are an + /// error, so a vague label never clicks the wrong thing. + pub async fn click_label(&self, label: &str) -> Result<()> { + let matches = self.displayed_matches(label).await?; + match matches.as_slice() { + [one] => { + self.on_session( + reqwest::Method::POST, + &format!("/element/{one}/click"), + Some(json!({})), + ) + .await?; + Ok(()) + } + [] => bail!("no displayed element matches {label:?}"), + many => bail!( + "{} displayed elements match {label:?}; use a more specific label", + many.len() + ), + } + } + + /// Type into the element that has focus. + pub async fn type_text(&self, text: &str) -> Result<()> { + let active = self + .on_session(reqwest::Method::GET, "/element/active", None) + .await?; + let reference = active + .get(ELEMENT_KEY) + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("no element has focus; tap a field first"))? + .to_string(); + self.on_session( + reqwest::Method::POST, + &format!("/element/{reference}/value"), + Some(json!({ "text": text })), + ) + .await?; + Ok(()) + } + + /// A PNG screenshot of the window. + pub async fn screenshot_png(&self) -> Result> { + let value = self + .on_session(reqwest::Method::GET, "/screenshot", None) + .await?; + let encoded = value + .as_str() + .ok_or_else(|| anyhow!("the screenshot is not a string"))?; + decode_base64(encoded) + } + + /// The screenshot, still base64, for callers that pass it straight on. + pub async fn screenshot_base64(&self) -> Result { + let value = self + .on_session(reqwest::Method::GET, "/screenshot", None) + .await?; + value + .as_str() + .map(|s| s.trim().to_string()) + .ok_or_else(|| anyhow!("the screenshot is not a string")) + } +} + +fn decode_base64(encoded: &str) -> Result> { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .context("decoding the screenshot") +} + +/// The capability map for a desktop target, in the shape `device_capabilities` returns for the +/// other targets. +pub fn desktop_capabilities() -> Value { + json!({ + "platform": "desktop", + "backend": "w3c-webdriver", + "supported": [ + "app_install_launch", "app_launch", "app_terminate", "screen_capture", + "ui_describe", "ui_element_list", "ui_tap", "input_text" + ], + "unsupported": [ + "url_open", "input_tap", "input_swipe", "input_button", "input_click", + "input_spatial_tap", "orientation_get", "orientation_set", "posture_get", "posture_set" + ], + "notes": "ui_describe returns the page source. app_launch and app_install_launch take the application binary path and start a WebDriver session, which launches the app. The app cannot be attached to once running." + }) +} + +/// The error for a tool with no desktop meaning. +pub fn unsupported(tool: &str) -> String { + format!( + "{tool} has no meaning on a desktop target. Supported on desktop: app_install_launch, \ + app_launch, app_terminate, screen_capture, ui_describe, ui_element_list, ui_tap, input_text." + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex as StdMutex}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + /// A recorded request: method, path, body. + type Seen = Arc>>; + + /// A stub W3C WebDriver server. `route` maps (method, path) to a status and a JSON body. + async fn stub(route: F) -> (String, Seen) + where + F: Fn(&str, &str, &str) -> (u16, Value) + Send + Sync + 'static, + { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let seen: Seen = Arc::new(StdMutex::new(Vec::new())); + let recorded = seen.clone(); + let route = Arc::new(route); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + let recorded = recorded.clone(); + let route = route.clone(); + tokio::spawn(async move { + let mut data = Vec::new(); + let mut buf = [0u8; 4096]; + let (head_end, length) = loop { + let n = socket.read(&mut buf).await.unwrap_or(0); + if n == 0 { + return; + } + data.extend_from_slice(&buf[..n]); + let text = String::from_utf8_lossy(&data).to_string(); + if let Some(pos) = text.find("\r\n\r\n") { + let length = text[..pos] + .lines() + .find_map(|l| { + l.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(|v| v.trim().parse::().unwrap_or(0)) + }) + .unwrap_or(0); + break (pos + 4, length); + } + }; + while data.len() < head_end + length { + let n = socket.read(&mut buf).await.unwrap_or(0); + if n == 0 { + break; + } + data.extend_from_slice(&buf[..n]); + } + let text = String::from_utf8_lossy(&data).to_string(); + let mut first = text.lines().next().unwrap_or("").split_whitespace(); + let method = first.next().unwrap_or("").to_string(); + let path = first.next().unwrap_or("").to_string(); + let body = text[head_end..].to_string(); + recorded + .lock() + .unwrap() + .push((method.clone(), path.clone(), body.clone())); + let (status, value) = route(&method, &path, &body); + let payload = value.to_string(); + let reply = format!( + "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{payload}", + payload.len() + ); + let _ = socket.write_all(reply.as_bytes()).await; + }); + } + }); + (url, seen) + } + + fn ok(value: Value) -> (u16, Value) { + (200, json!({ "value": value })) + } + + #[test] + fn xpath_literal_quotes_each_kind() { + assert_eq!(xpath_literal("Sign in"), "'Sign in'"); + assert_eq!(xpath_literal("it's"), "\"it's\""); + assert_eq!(xpath_literal("a'b\"c"), "concat('a', \"'\", 'b\"c')"); + } + + #[test] + fn rejects_a_non_http_url() { + assert!(WebDriver::new(Some("ftp://x")).is_err()); + assert!(WebDriver::new(Some("localhost:4444")).is_err()); + assert_eq!( + WebDriver::new(Some("http://h:1/")).unwrap().url(), + "http://h:1" + ); + assert_eq!(WebDriver::new(None).unwrap().url(), DEFAULT_WEBDRIVER_URL); + } + + #[tokio::test] + async fn a_call_without_a_session_says_how_to_start_one() { + let (url, _) = stub(|_, _, _| ok(Value::Null)).await; + let driver = WebDriver::new(Some(&url)).unwrap(); + let error = driver.page_source().await.unwrap_err().to_string(); + assert!(error.contains("app_install_launch"), "{error}"); + } + + #[tokio::test] + async fn session_source_screenshot_and_end() { + let (url, seen) = stub(|method, path, _| match (method, path) { + ("POST", "/session") => ok(json!({ "sessionId": "s1", "capabilities": {} })), + ("GET", "/session/s1/source") => ok(json!("hello")), + ("GET", "/session/s1/screenshot") => ok(json!("aGVsbG8=")), + ("DELETE", "/session/s1") => ok(Value::Null), + _ => ( + 404, + json!({ "value": { "error": "unknown command", "message": path } }), + ), + }) + .await; + let driver = WebDriver::new(Some(&url)).unwrap(); + assert_eq!(driver.start_session("/opt/app").await.unwrap(), "s1"); + assert_eq!( + driver.page_source().await.unwrap(), + "hello" + ); + assert_eq!(driver.screenshot_png().await.unwrap(), b"hello"); + assert!(driver.end_session().await.unwrap()); + assert!(!driver.end_session().await.unwrap()); + + let seen = seen.lock().unwrap(); + let create = seen + .iter() + .find(|(m, p, _)| m == "POST" && p == "/session") + .unwrap(); + let body: Value = serde_json::from_str(&create.2).unwrap(); + assert_eq!( + body["capabilities"]["alwaysMatch"]["tauri:options"]["application"], + "/opt/app" + ); + } + + #[tokio::test] + async fn a_driver_error_carries_its_code_and_message() { + let (url, _) = stub(|_, _, _| { + ( + 500, + json!({ "value": { "error": "session not created", "message": "binary missing" } }), + ) + }) + .await; + let driver = WebDriver::new(Some(&url)).unwrap(); + let error = driver.start_session("/nope").await.unwrap_err().to_string(); + assert!( + error.contains("session not created") && error.contains("binary missing"), + "{error}" + ); + } + + #[tokio::test] + async fn click_label_clicks_exactly_one_displayed_match() { + let key = ELEMENT_KEY; + let (url, seen) = stub(move |method, path, body| match (method, path) { + ("POST", "/session") => ok(json!({ "sessionId": "s2" })), + ("POST", "/session/s2/elements") => { + let using: Value = serde_json::from_str(body).unwrap(); + assert_eq!(using["using"], "xpath"); + assert!(using["value"].as_str().unwrap().contains("'Sign in'")); + ok(json!([{ key: "e1" }, { key: "e2" }])) + } + ("GET", "/session/s2/element/e1/displayed") => ok(json!(true)), + ("GET", "/session/s2/element/e2/displayed") => ok(json!(false)), + ("POST", "/session/s2/element/e1/click") => ok(Value::Null), + ("DELETE", "/session/s2") => ok(Value::Null), + _ => ( + 404, + json!({ "value": { "error": "unknown command", "message": path } }), + ), + }) + .await; + let driver = WebDriver::new(Some(&url)).unwrap(); + driver.start_session("/opt/app").await.unwrap(); + driver.click_label("Sign in").await.unwrap(); + assert!(seen + .lock() + .unwrap() + .iter() + .any(|(m, p, _)| m == "POST" && p == "/session/s2/element/e1/click")); + driver.end_session().await.unwrap(); + } + + #[tokio::test] + async fn click_label_refuses_zero_and_several_matches() { + let key = ELEMENT_KEY; + let (url, _) = stub(move |method, path, body| match (method, path) { + ("POST", "/session") => ok(json!({ "sessionId": "s3" })), + ("POST", "/session/s3/elements") => { + if body.contains("Many") { + ok(json!([{ key: "a" }, { key: "b" }])) + } else { + ok(json!([])) + } + } + (_, p) if p.ends_with("/displayed") => ok(json!(true)), + ("DELETE", _) => ok(Value::Null), + _ => ( + 404, + json!({ "value": { "error": "unknown command", "message": path } }), + ), + }) + .await; + let driver = WebDriver::new(Some(&url)).unwrap(); + driver.start_session("/opt/app").await.unwrap(); + let none = driver.click_label("Nothing").await.unwrap_err().to_string(); + assert!(none.contains("no displayed element"), "{none}"); + let many = driver.click_label("Many").await.unwrap_err().to_string(); + assert!(many.contains("2 displayed elements"), "{many}"); + driver.end_session().await.unwrap(); + } + + #[tokio::test] + async fn type_text_goes_to_the_focused_element() { + let key = ELEMENT_KEY; + let (url, seen) = stub(move |method, path, _| match (method, path) { + ("POST", "/session") => ok(json!({ "sessionId": "s4" })), + ("GET", "/session/s4/element/active") => ok(json!({ key: "f1" })), + ("POST", "/session/s4/element/f1/value") => ok(Value::Null), + ("DELETE", _) => ok(Value::Null), + _ => ( + 404, + json!({ "value": { "error": "unknown command", "message": path } }), + ), + }) + .await; + let driver = WebDriver::new(Some(&url)).unwrap(); + driver.start_session("/opt/app").await.unwrap(); + driver.type_text("hello").await.unwrap(); + driver.end_session().await.unwrap(); + let seen = seen.lock().unwrap(); + let typed = seen + .iter() + .find(|(_, p, _)| p.ends_with("/f1/value")) + .unwrap(); + assert_eq!( + serde_json::from_str::(&typed.2).unwrap()["text"], + "hello" + ); + } + + #[tokio::test] + async fn list_elements_returns_the_scripts_rows_up_to_the_cap() { + let rows: Vec = (0..250) + .map(|i| json!({ "tag": "button", "label": format!("b{i}") })) + .collect(); + let (url, seen) = stub(move |method, path, _| match (method, path) { + ("POST", "/session") => ok(json!({ "sessionId": "s5" })), + ("POST", "/session/s5/execute/sync") => ok(json!(rows)), + ("DELETE", _) => ok(Value::Null), + _ => ( + 404, + json!({ "value": { "error": "unknown command", "message": path } }), + ), + }) + .await; + let driver = WebDriver::new(Some(&url)).unwrap(); + driver.start_session("/opt/app").await.unwrap(); + let elements = driver.list_elements().await.unwrap(); + assert_eq!(elements.len(), MAX_ELEMENTS); + assert!(seen + .lock() + .unwrap() + .iter() + .any(|(_, p, b)| p.ends_with("/execute/sync") && b.contains("querySelectorAll"))); + driver.end_session().await.unwrap(); + } + + #[test] + fn capabilities_and_unsupported_name_the_tools() { + let caps = desktop_capabilities(); + assert_eq!(caps["platform"], "desktop"); + assert!(caps["supported"] + .as_array() + .unwrap() + .iter() + .any(|v| v == "ui_tap")); + assert!(unsupported("input_swipe").starts_with("input_swipe has no meaning")); + } +} diff --git a/crates/xcrs/src/xcrs.rs b/crates/xcrs/src/xcrs.rs index 5e69677f..fdd90bb5 100644 --- a/crates/xcrs/src/xcrs.rs +++ b/crates/xcrs/src/xcrs.rs @@ -8,6 +8,7 @@ use std::process::{Child, Command, Output, Stdio}; use std::time::Duration; pub mod mcp; +pub mod webdriver; const IOS_SIMULATOR_DESTINATION_PREFIX: &str = "platform=iOS Simulator,id="; const CONTROLKIT_METHOD_NOT_FOUND: i64 = -32601; From 61aab339b9bead9845db8c3d2a18957eb2e0e65e Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:18:27 +0100 Subject: [PATCH 5/7] Address review: send keys with the legacy value array, keep failed deletes, show raw error bodies Element Send Keys now carries both text and the legacy value array, a failed session delete keeps the id so app_terminate can retry, a non-JSON error body is included in the message, and the capability probe times out after two seconds. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: siGit Code v1.7.6 --- crates/xcrs/src/webdriver.rs | 102 ++++++++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 7 deletions(-) diff --git a/crates/xcrs/src/webdriver.rs b/crates/xcrs/src/webdriver.rs index 1fe88b6d..ff6ad7cf 100644 --- a/crates/xcrs/src/webdriver.rs +++ b/crates/xcrs/src/webdriver.rs @@ -129,9 +129,15 @@ impl WebDriver { .await .with_context(|| format!("{method} {url}: is a WebDriver server listening?"))?; let status = response.status(); - let json: Value = response.json().await.unwrap_or(Value::Null); + let raw = response.text().await.unwrap_or_default(); + let json: Value = serde_json::from_str(&raw).unwrap_or(Value::Null); let value = json.get("value").cloned().unwrap_or(Value::Null); if !status.is_success() { + if json.is_null() { + // Not WebDriver JSON (a proxy page, a plain-text refusal): the body is the clue. + let body: String = raw.trim().chars().take(300).collect(); + bail!("webdriver error ({status}): {body}"); + } let code = value .get("error") .and_then(Value::as_str) @@ -147,9 +153,12 @@ impl WebDriver { /// Whether the driver answers `/status`. pub async fn is_ready(&self) -> bool { - self.send(reqwest::Method::GET, "/status", None) + self.http + .get(format!("{}/status", self.base)) + .timeout(Duration::from_secs(2)) + .send() .await - .is_ok() + .is_ok_and(|response| response.status().is_success()) } /// Start the application and remember the session. Replaces a session already held for @@ -183,11 +192,21 @@ impl WebDriver { /// Delete the held session, if any. Returns whether one existed. pub async fn end_session(&self) -> Result { - let id = sessions().lock().await.remove(&self.base); + // Delete first and forget the id only after the driver agrees, so a failed delete can be + // retried with app_terminate. + let id = sessions().lock().await.get(&self.base).cloned(); match id { Some(id) => { - self.send(reqwest::Method::DELETE, &format!("/session/{id}"), None) - .await?; + let deleted = self + .send(reqwest::Method::DELETE, &format!("/session/{id}"), None) + .await; + if let Err(error) = &deleted { + // A session the driver no longer knows is gone; anything else stays held. + if !error.to_string().contains("invalid session id") { + deleted?; + } + } + sessions().lock().await.remove(&self.base); Ok(true) } None => Ok(false), @@ -308,7 +327,9 @@ impl WebDriver { self.on_session( reqwest::Method::POST, &format!("/element/{reference}/value"), - Some(json!({ "text": text })), + // W3C `text`, plus the legacy `value` array: tauri-driver can drop `text` when it + // proxies to WebKitWebDriver, which then rejects the request. + Some(json!({ "text": text, "value": text.chars().map(String::from).collect::>() })), ) .await?; Ok(()) @@ -647,6 +668,73 @@ mod tests { driver.end_session().await.unwrap(); } + #[tokio::test] + async fn type_text_sends_both_the_text_and_the_legacy_value_array() { + let key = ELEMENT_KEY; + let (url, seen) = stub(move |method, path, _| match (method, path) { + ("POST", "/session") => ok(json!({ "sessionId": "s6" })), + ("GET", "/session/s6/element/active") => ok(json!({ key: "f1" })), + ("POST", "/session/s6/element/f1/value") => ok(Value::Null), + ("DELETE", _) => ok(Value::Null), + _ => ( + 404, + json!({ "value": { "error": "unknown command", "message": path } }), + ), + }) + .await; + let driver = WebDriver::new(Some(&url)).unwrap(); + driver.start_session("/opt/app").await.unwrap(); + driver.type_text("ab").await.unwrap(); + driver.end_session().await.unwrap(); + let seen = seen.lock().unwrap(); + let typed = seen + .iter() + .find(|(_, p, _)| p.ends_with("/f1/value")) + .unwrap(); + let body: Value = serde_json::from_str(&typed.2).unwrap(); + assert_eq!(body["text"], "ab"); + assert_eq!(body["value"], json!(["a", "b"])); + } + + #[tokio::test] + async fn a_non_json_error_body_is_kept_in_the_message() { + let (url, _) = stub(|_, _, _| (502, Value::String("bad gateway from proxy".into()))).await; + let driver = WebDriver::new(Some(&url)).unwrap(); + let error = driver + .start_session("/opt/app") + .await + .unwrap_err() + .to_string(); + assert!( + error.contains("502") && error.contains("bad gateway"), + "{error}" + ); + } + + #[tokio::test] + async fn a_failed_delete_keeps_the_session_so_it_can_be_retried() { + let fail = Arc::new(StdMutex::new(true)); + let flag = fail.clone(); + let (url, _) = stub(move |method, path, _| match (method, path) { + ("POST", "/session") => ok(json!({ "sessionId": "s7" })), + ("DELETE", "/session/s7") if *flag.lock().unwrap() => ( + 500, + json!({ "value": { "error": "unknown error", "message": "busy" } }), + ), + ("DELETE", "/session/s7") => ok(Value::Null), + _ => ( + 404, + json!({ "value": { "error": "unknown command", "message": path } }), + ), + }) + .await; + let driver = WebDriver::new(Some(&url)).unwrap(); + driver.start_session("/opt/app").await.unwrap(); + assert!(driver.end_session().await.is_err()); + *fail.lock().unwrap() = false; + assert!(driver.end_session().await.unwrap()); + } + #[test] fn capabilities_and_unsupported_name_the_tools() { let caps = desktop_capabilities(); From 3a2b851ec6a26092d6240edd32a327fe29e81400 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:19:18 +0100 Subject: [PATCH 6/7] Treat any non-object WebDriver reply as a raw error body Co-Authored-By: Claude Opus 5.5 Co-Authored-By: siGit Code v1.7.6 --- crates/xcrs/src/webdriver.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/xcrs/src/webdriver.rs b/crates/xcrs/src/webdriver.rs index ff6ad7cf..b2ce68db 100644 --- a/crates/xcrs/src/webdriver.rs +++ b/crates/xcrs/src/webdriver.rs @@ -133,7 +133,7 @@ impl WebDriver { let json: Value = serde_json::from_str(&raw).unwrap_or(Value::Null); let value = json.get("value").cloned().unwrap_or(Value::Null); if !status.is_success() { - if json.is_null() { + if !json.is_object() { // Not WebDriver JSON (a proxy page, a plain-text refusal): the body is the clue. let body: String = raw.trim().chars().take(300).collect(); bail!("webdriver error ({status}): {body}"); From 571acdba8702f8cb620de4a807326ae0e3e5dd24 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:25:36 +0100 Subject: [PATCH 7/7] Keep the body of a JSON error that is not WebDriver-shaped Co-Authored-By: Claude Opus 5.5 Co-Authored-By: siGit Code v1.7.6 --- crates/xcrs/src/webdriver.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/xcrs/src/webdriver.rs b/crates/xcrs/src/webdriver.rs index b2ce68db..55e57035 100644 --- a/crates/xcrs/src/webdriver.rs +++ b/crates/xcrs/src/webdriver.rs @@ -133,7 +133,7 @@ impl WebDriver { let json: Value = serde_json::from_str(&raw).unwrap_or(Value::Null); let value = json.get("value").cloned().unwrap_or(Value::Null); if !status.is_success() { - if !json.is_object() { + if value.get("error").and_then(Value::as_str).is_none() { // Not WebDriver JSON (a proxy page, a plain-text refusal): the body is the clue. let body: String = raw.trim().chars().take(300).collect(); bail!("webdriver error ({status}): {body}"); @@ -711,6 +711,21 @@ mod tests { ); } + #[tokio::test] + async fn a_json_error_without_the_webdriver_shape_keeps_its_body() { + let (url, _) = stub(|_, _, _| (503, json!({ "message": "no healthy upstream" }))).await; + let driver = WebDriver::new(Some(&url)).unwrap(); + let error = driver + .start_session("/opt/app") + .await + .unwrap_err() + .to_string(); + assert!( + error.contains("503") && error.contains("no healthy upstream"), + "{error}" + ); + } + #[tokio::test] async fn a_failed_delete_keeps_the_session_so_it_can_be_retried() { let fail = Arc::new(StdMutex::new(true));