From 9d0f6f0213f2a552c5fd42349706b2c07154c259 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 17 Oct 2026 09:06:53 +0530 Subject: [PATCH 01/32] docs(site-memory): clarify backing-file maintenance ownership --- crates/tinycomputer-bus/src/site_memory/mod.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/tinycomputer-bus/src/site_memory/mod.rs b/crates/tinycomputer-bus/src/site_memory/mod.rs index 45b20262..8e5969be 100644 --- a/crates/tinycomputer-bus/src/site_memory/mod.rs +++ b/crates/tinycomputer-bus/src/site_memory/mod.rs @@ -4,9 +4,13 @@ //! The module returns, with every finished task, the plan it ran and the //! elements its steps found, and takes them back on the next task on that //! site. It keeps nothing between tasks itself, so the host serves this -//! interface and decides only *where* the bytes live (a file, a storage -//! port). The reuse rules, the fact-value filtering and the 30-day expiry -//! are the module's (`tinycomputer-core::sites`), not the host's. +//! interface and owns where the bytes live (a file, a storage port). The +//! reuse rules, the fact-value filtering and each plan's or hint's 30-day +//! freshness are the module's (`tinycomputer-core::sites`), not the host's. +//! +//! A host may also periodically remove unchanged backing files as physical +//! storage maintenance. Schedule that cleanup outside `Load` requests; it is +//! independent of the module's per-plan and per-hint freshness rules. //! //! Three members, all synchronous request/response over plain data: //! From 67001a2de9b1f005163739fbfe7d95789dfe4a90 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 16:13:05 +0530 Subject: [PATCH 02/32] feat(tinycomputer): publish v0.16 module contract Co-authored-by: Medulla --- Cargo.lock | 123 ++++++++------- Cargo.toml | 6 +- crates/tinycomputer-bus/Cargo.toml | 3 + crates/tinycomputer-bus/README.md | 5 +- .../tinycomputer-bus/src/agent/agent_tests.rs | 6 +- crates/tinycomputer-bus/src/agent/mod.rs | 10 +- .../tinycomputer-bus/src/agent/types/mod.rs | 2 +- .../tinycomputer-bus/src/agent/types/reply.rs | 86 +++++++++- .../src/agent/types/reply_tests.rs | 94 +++++++++++ .../src/browser/session/session_tests.rs | 9 +- crates/tinycomputer-bus/src/config/browser.rs | 100 ++++++++++++ .../tinycomputer-bus/src/config/computer.rs | 70 ++++++++ .../src/config/config_tests.rs | 136 ++++++++++++++++ crates/tinycomputer-bus/src/config/mod.rs | 12 ++ crates/tinycomputer-bus/src/config/planner.rs | 82 ++++++++++ .../src/envelope/envelope_tests.rs | 86 +++++++++- crates/tinycomputer-bus/src/envelope/mod.rs | 4 +- crates/tinycomputer-bus/src/envelope/types.rs | 78 ++++++++- crates/tinycomputer-bus/src/lib.rs | 12 +- crates/tinycomputer-bus/src/sites/mod.rs | 37 +++++ .../tinycomputer-bus/src/sites/sites_tests.rs | 40 +++++ crates/tinycomputer-bus/src/version/mod.rs | 4 +- .../src/version/version_tests.rs | 7 +- crates/tinycomputer-core/Cargo.toml | 4 +- crates/tinycomputer-core/src/sites/mod.rs | 27 +--- .../src/sites/sites_tests.rs | 20 +++ .../tinycomputer-engine/src/planner/config.rs | 149 ++++-------------- .../tinycomputer-engine/src/planner/hosted.rs | 13 +- .../src/planner/planner_tests/route_tests.rs | 7 +- .../tinycomputer/src/tinybus_module/README.md | 27 ++-- .../tinycomputer/src/tinybus_module/config.rs | 84 +++------- .../dispatch/accessibility_tests.rs | 42 ++++- .../src/tinybus_module/dispatch/service.rs | 103 +++++++++--- .../tinybus_module/dispatch/service_tests.rs | 61 +++++++ .../tinybus_module_tests/config_tests.rs | 32 ++-- crates/tinycomputer/tests/public_api_tests.rs | 2 +- docs/crates/tinycomputer-bus/versioning.md | 22 ++- .../specs/desktop-module-contract.md | 24 ++- vendor/tinybus | 2 +- 39 files changed, 1260 insertions(+), 371 deletions(-) create mode 100644 crates/tinycomputer-bus/src/agent/types/reply_tests.rs create mode 100644 crates/tinycomputer-bus/src/config/browser.rs create mode 100644 crates/tinycomputer-bus/src/config/computer.rs create mode 100644 crates/tinycomputer-bus/src/config/config_tests.rs create mode 100644 crates/tinycomputer-bus/src/config/mod.rs create mode 100644 crates/tinycomputer-bus/src/config/planner.rs create mode 100644 crates/tinycomputer-bus/src/sites/mod.rs create mode 100644 crates/tinycomputer-bus/src/sites/sites_tests.rs create mode 100644 crates/tinycomputer/src/tinybus_module/dispatch/service_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 96628891..b2684eea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,7 +83,7 @@ dependencies = [ "urlencoding", "uuid", "windows-sys 0.52.0", - "zip 8.6.0", + "zip", ] [[package]] @@ -181,15 +181,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "arboard" version = "3.6.1" @@ -529,12 +520,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - [[package]] name = "crypto-common" version = "0.1.7" @@ -582,17 +567,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "digest" version = "0.10.7" @@ -1079,6 +1053,28 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_casemap" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2edfd120d8445de853cf53a1abec916f638f933a70a6f4bf8f7e2d7b7f884e9" +dependencies = [ + "icu_casemap_data", + "icu_collections", + "icu_locale_core", + "icu_properties", + "icu_provider", + "potential_utf", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_casemap_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059fcda0fc8d33effc6633a8cf0d8a3c17caf7d7c91918fba9918744f501e4e8" + [[package]] name = "icu_collections" version = "2.3.0" @@ -1087,6 +1083,7 @@ checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "serde", "utf8_iter", "yoke", "zerofrom", @@ -1101,6 +1098,7 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", @@ -1155,6 +1153,8 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1832,6 +1832,8 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -2007,6 +2009,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rawzip" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9999039f74cd73c03158cddfe4362991703c74efe5ce5c8073026f80a5e48c" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2572,11 +2580,13 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.1.5" +version = "0.3.5" dependencies = [ "async-trait", "flate2", + "icu_casemap", "libc", + "rawzip", "serde", "serde_json", "sha2 0.10.9", @@ -2587,13 +2597,13 @@ dependencies = [ "tokio", "toml", "tracing", + "unicode-normalization", "ureq", - "zip 2.4.2", ] [[package]] name = "tinybus-macros" -version = "0.1.5" +version = "0.3.5" dependencies = [ "proc-macro2", "quote", @@ -2602,7 +2612,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.1.5" +version = "0.3.5" dependencies = [ "async-trait", "serde", @@ -2614,7 +2624,7 @@ dependencies = [ [[package]] name = "tinycomputer" -version = "0.15.0" +version = "0.16.0" dependencies = [ "getrandom 0.4.3", "serde", @@ -2632,7 +2642,7 @@ dependencies = [ [[package]] name = "tinycomputer-accessibility" -version = "0.15.0" +version = "0.16.0" dependencies = [ "arboard", "command-group", @@ -2648,7 +2658,7 @@ dependencies = [ [[package]] name = "tinycomputer-browser" -version = "0.15.0" +version = "0.16.0" dependencies = [ "agent-browser", "base64 0.22.1", @@ -2666,28 +2676,28 @@ dependencies = [ [[package]] name = "tinycomputer-bus" -version = "0.15.0" +version = "0.16.0" dependencies = [ "serde", "serde_json", "thiserror 2.0.20", + "url", ] [[package]] name = "tinycomputer-core" -version = "0.15.0" +version = "0.16.0" dependencies = [ "serde", "serde_json", "sha2 0.10.9", "thiserror 2.0.20", "tinycomputer-bus", - "url", ] [[package]] name = "tinycomputer-cursor" -version = "0.15.0" +version = "0.16.0" dependencies = [ "block2 0.6.2", "getrandom 0.4.3", @@ -2702,7 +2712,7 @@ dependencies = [ [[package]] name = "tinycomputer-desktop" -version = "0.15.0" +version = "0.16.0" dependencies = [ "agent-desktop-core", "agent-desktop-linux", @@ -2717,7 +2727,7 @@ dependencies = [ [[package]] name = "tinycomputer-engine" -version = "0.15.0" +version = "0.16.0" dependencies = [ "getrandom 0.4.3", "serde", @@ -2732,7 +2742,7 @@ dependencies = [ [[package]] name = "tinycomputer-examples" -version = "0.15.0" +version = "0.16.0" dependencies = [ "base64 0.22.1", "serde", @@ -2750,7 +2760,7 @@ dependencies = [ [[package]] name = "tinycomputer-skills" -version = "0.15.0" +version = "0.16.0" dependencies = [ "serde_json", "tinycomputer-bus", @@ -2804,6 +2814,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -3078,6 +3089,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -3851,6 +3871,7 @@ dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -3859,6 +3880,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -3875,23 +3897,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "zip" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" -dependencies = [ - "arbitrary", - "crc32fast", - "crossbeam-utils", - "displaydoc", - "flate2", - "indexmap", - "memchr", - "thiserror 2.0.20", - "zopfli", -] - [[package]] name = "zip" version = "8.6.0" diff --git a/Cargo.toml b/Cargo.toml index 5fbbdf34..b4bb8175 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ exclude = ["vendor", "worktrees"] # true`, so the version the release workflow bumps is written in exactly one # place and every crate moves together. [workspace.package] -version = "0.15.0" +version = "0.16.0" edition = "2024" rust-version = "1.89" license = "GPL-3.0-only" @@ -73,13 +73,13 @@ agent-desktop-linux = { path = "vendor/agent-desktop/crates/linux" } # TinyBus defines the message types, interface macro, and frozen module ABI # used by the generated integration. Socket and CLI features are unnecessary # here. -tinybus = { version = ">=0.1.3, <0.2", default-features = false, features = [ +tinybus = { version = ">=0.3.5, <0.4", default-features = false, features = [ "macros", "modules", ] } # The module-side SDK owns the isolated runtime and exports the ABI entrypoints # required by TinyBus's dynamic loader. -tinybus-module = { version = ">=0.1.3, <0.2" } +tinybus-module = { version = ">=0.3.5, <0.4" } # Derive macros for the crate-wide error types, e.g. # `crates/tinycomputer-desktop/src/error/`. # Every dependency entry should carry a comment like this one saying why it is diff --git a/crates/tinycomputer-bus/Cargo.toml b/crates/tinycomputer-bus/Cargo.toml index 2c577733..39d7149d 100644 --- a/crates/tinycomputer-bus/Cargo.toml +++ b/crates/tinycomputer-bus/Cargo.toml @@ -22,6 +22,9 @@ publish = false thiserror = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +# URL parsing and IDNA normalization for the site-name helpers shared by hosts +# and the module; this is a pure parser with no transport or I/O. +url = { workspace = true } [lints] workspace = true diff --git a/crates/tinycomputer-bus/README.md b/crates/tinycomputer-bus/README.md index a556aec4..16f1bc83 100644 --- a/crates/tinycomputer-bus/README.md +++ b/crates/tinycomputer-bus/README.md @@ -31,8 +31,11 @@ payload vocabulary has to be published as an ordinary library. This is it. | `flow` | the flow grammar `RunFlow` and `StartTask` carry | | `browser` | the `Browser…` members' payloads, names, and error codes | | `version` | `CONTRACT_VERSION` and the bind rule a host applies to it | +| `config` | the typed load-time configuration shared by hosts and the module | +| `sites` | canonical, strict HTTP(S) site-name normalization | -Three dependencies, all pure Rust: `thiserror`, `serde`, and `serde_json`. +Four dependencies, all pure Rust: `thiserror`, `serde`, `serde_json`, and +`url`. The URL parser is used only for shared, IDNA-aware site normalization. ## This crate sits underneath `tinycomputer` diff --git a/crates/tinycomputer-bus/src/agent/agent_tests.rs b/crates/tinycomputer-bus/src/agent/agent_tests.rs index 2bea8074..b599f9a5 100644 --- a/crates/tinycomputer-bus/src/agent/agent_tests.rs +++ b/crates/tinycomputer-bus/src/agent/agent_tests.rs @@ -10,8 +10,9 @@ use std::collections::BTreeMap; use serde_json::json; use super::{ - AgentError, AgentResponse, AwaitTaskRequest, ContinueTaskRequest, InputField, InputKind, - PaymentMode, StartTaskRequest, SurfaceKind, TaskId, TaskReportRequest, TaskStatus, TaskView, + AGENT_ENVELOPE_VERSION, AgentError, AgentResponse, AwaitTaskRequest, ContinueTaskRequest, + InputField, InputKind, PaymentMode, StartTaskRequest, SurfaceKind, TaskId, TaskReportRequest, + TaskStatus, TaskView, }; #[test] @@ -225,6 +226,7 @@ fn replies_carry_data_or_an_actionable_error() { next: vec!["AwaitTask".to_owned()], }; let ok = serde_json::to_value(AgentResponse::ok(view)).unwrap(); + assert_eq!(ok["version"], AGENT_ENVELOPE_VERSION); assert_eq!(ok["ok"], true); assert!(ok.get("error").is_none()); assert!(ok["data"].get("step").is_none()); diff --git a/crates/tinycomputer-bus/src/agent/mod.rs b/crates/tinycomputer-bus/src/agent/mod.rs index f93f96a4..abd85eb5 100644 --- a/crates/tinycomputer-bus/src/agent/mod.rs +++ b/crates/tinycomputer-bus/src/agent/mod.rs @@ -51,11 +51,11 @@ pub mod names; mod types; pub use types::{ - AgentError, AgentResponse, AwaitTaskRequest, Capabilities, ContinueTaskRequest, Example, - InputField, InputKind, LanguageModelConfiguration, LanguageModelProvider, MemberDoc, - PaymentMode, PlanTaskRequest, Rescue, RescueOutcome, StartTaskRequest, StepView, - SurfaceAvailability, SurfaceKind, TaskBudget, TaskConstraints, TaskId, TaskOutput, TaskPlan, - TaskRef, TaskReport, TaskReportRequest, TaskStatus, TaskView, + AGENT_ENVELOPE_VERSION, AgentError, AgentResponse, AgentResponseError, AwaitTaskRequest, + Capabilities, ContinueTaskRequest, Example, InputField, InputKind, LanguageModelConfiguration, + LanguageModelProvider, MemberDoc, PaymentMode, PlanTaskRequest, Rescue, RescueOutcome, + StartTaskRequest, StepView, SurfaceAvailability, SurfaceKind, TaskBudget, TaskConstraints, + TaskId, TaskOutput, TaskPlan, TaskRef, TaskReport, TaskReportRequest, TaskStatus, TaskView, }; #[cfg(test)] diff --git a/crates/tinycomputer-bus/src/agent/types/mod.rs b/crates/tinycomputer-bus/src/agent/types/mod.rs index 3c3a10a8..efd3cd4f 100644 --- a/crates/tinycomputer-bus/src/agent/types/mod.rs +++ b/crates/tinycomputer-bus/src/agent/types/mod.rs @@ -9,7 +9,7 @@ mod status; pub use describe::{Capabilities, Example, MemberDoc, SurfaceAvailability}; pub use models::{LanguageModelConfiguration, LanguageModelProvider}; -pub use reply::{AgentError, AgentResponse}; +pub use reply::{AGENT_ENVELOPE_VERSION, AgentError, AgentResponse, AgentResponseError}; pub use report::{Rescue, RescueOutcome, TaskReport}; pub use request::{ AwaitTaskRequest, ContinueTaskRequest, PaymentMode, PlanTaskRequest, StartTaskRequest, diff --git a/crates/tinycomputer-bus/src/agent/types/reply.rs b/crates/tinycomputer-bus/src/agent/types/reply.rs index db645160..68528262 100644 --- a/crates/tinycomputer-bus/src/agent/types/reply.rs +++ b/crates/tinycomputer-bus/src/agent/types/reply.rs @@ -2,25 +2,49 @@ use serde::{Deserialize, Serialize}; +/// The version of the Agent response envelope this contract produces. +pub const AGENT_ENVELOPE_VERSION: &str = "1"; + /// Every reply on the Agent interface: the value, or an error a caller can /// act on. Never a transport failure for something the caller did. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(bound(deserialize = "T: Deserialize<'de>"))] pub struct AgentResponse { + /// The envelope version. [`AGENT_ENVELOPE_VERSION`] for module replies. + #[serde(default)] + pub version: String, /// Whether the call did what was asked. pub ok: bool, /// The result, when `ok`. - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default = "missing_data", + deserialize_with = "deserialize_present_data", + skip_serializing_if = "Option::is_none" + )] pub data: Option, /// Why not, when not `ok`. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } +fn deserialize_present_data<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + T::deserialize(deserializer).map(Some) +} + +fn missing_data() -> Option { + None +} + impl AgentResponse { /// A successful reply carrying `data`. #[must_use] pub fn ok(data: T) -> Self { Self { + version: AGENT_ENVELOPE_VERSION.to_owned(), ok: true, data: Some(data), error: None, @@ -31,11 +55,67 @@ impl AgentResponse { #[must_use] pub fn err(error: AgentError) -> Self { Self { + version: AGENT_ENVELOPE_VERSION.to_owned(), ok: false, data: None, error: Some(error), } } + + /// Checks the envelope version and outcome fields, then returns the + /// successful value. + /// + /// A failed call is returned as [`AgentResponseError::Remote`]. + /// + /// # Errors + /// + /// Returns an error when the version is unsupported, the outcome fields + /// disagree, or the remote call failed. + pub fn into_data(self) -> Result { + if self.version != AGENT_ENVELOPE_VERSION { + return Err(AgentResponseError::Version { + expected: AGENT_ENVELOPE_VERSION, + actual: self.version, + }); + } + match (self.ok, self.data, self.error) { + (true, Some(data), None) => Ok(data), + (false, None, Some(error)) => Err(AgentResponseError::Remote(error)), + (ok, data, error) => Err(AgentResponseError::InconsistentOutcome { + ok, + has_data: data.is_some(), + has_error: error.is_some(), + }), + } + } +} + +/// A failed result from [`AgentResponse::into_data`]. +#[derive(Debug, thiserror::Error)] +pub enum AgentResponseError { + /// The module returned an envelope version this crate does not understand. + #[error("unsupported Agent response version {actual}; expected {expected}")] + Version { + /// The envelope version this crate accepts. + expected: &'static str, + /// The version sent by the module. + actual: String, + }, + /// The success flag disagrees with whether data or an error is present. + #[error( + "inconsistent Agent response outcome (ok={ok}, has_data={has_data}, has_error={has_error})" + )] + InconsistentOutcome { + /// Whether the module marked the response successful. + ok: bool, + /// Whether a data field was present. + has_data: bool, + /// Whether an error field was present. + has_error: bool, + }, + /// The Agent command failed with a structured module error. + #[error("Agent call failed")] + Remote(AgentError), } /// A failed call, phrased for a model to act on. @@ -68,3 +148,7 @@ impl AgentError { } } } + +#[cfg(test)] +#[path = "reply_tests.rs"] +mod tests; diff --git a/crates/tinycomputer-bus/src/agent/types/reply_tests.rs b/crates/tinycomputer-bus/src/agent/types/reply_tests.rs new file mode 100644 index 00000000..28f8b56d --- /dev/null +++ b/crates/tinycomputer-bus/src/agent/types/reply_tests.rs @@ -0,0 +1,94 @@ +//! Tests for Agent response version and outcome consistency. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use serde_json::json; + +use super::{AGENT_ENVELOPE_VERSION, AgentError, AgentResponse, AgentResponseError}; + +#[test] +fn agent_responses_carry_the_version_and_extract_success_data() { + let response = AgentResponse::ok("done".to_owned()); + let wire = serde_json::to_value(&response).unwrap(); + assert_eq!(wire["version"], AGENT_ENVELOPE_VERSION); + assert_eq!(response.into_data().unwrap(), "done"); +} + +#[test] +fn agent_responses_accept_unit_data_after_wire_round_trip() { + let response = AgentResponse::ok(()); + let wire = serde_json::to_value(response).unwrap(); + let decoded: AgentResponse<()> = serde_json::from_value(wire).unwrap(); + + assert!(matches!(decoded.into_data(), Ok(()))); + + let response = AgentResponse::ok(None::); + let wire = serde_json::to_value(response).unwrap(); + let decoded: AgentResponse> = serde_json::from_value(wire).unwrap(); + assert!(matches!(decoded.into_data(), Ok(None))); +} + +#[test] +fn agent_responses_still_reject_a_missing_success_value() { + let response: AgentResponse<()> = serde_json::from_value(json!({ + "version": AGENT_ENVELOPE_VERSION, + "ok": true + })) + .unwrap(); + + assert!(matches!( + response.into_data(), + Err(AgentResponseError::InconsistentOutcome { + ok: true, + has_data: false, + has_error: false + }) + )); + + assert!( + serde_json::from_value::>(json!({ + "version": AGENT_ENVELOPE_VERSION, + "ok": true, + "data": null + })) + .is_err() + ); +} + +#[test] +fn agent_responses_reject_stale_versions_and_inconsistent_outcomes() { + let stale: AgentResponse = serde_json::from_value(json!({ + "ok": true, + "data": "old module" + })) + .unwrap(); + assert!(matches!( + stale.into_data(), + Err(AgentResponseError::Version { actual, .. }) if actual.is_empty() + )); + + let mut inconsistent = AgentResponse::ok("done".to_owned()); + inconsistent.error = Some(AgentError::new("BROKEN", "conflict", "retry", true)); + assert!(matches!( + inconsistent.into_data(), + Err(AgentResponseError::InconsistentOutcome { + ok: true, + has_data: true, + has_error: true + }) + )); +} + +#[test] +fn agent_responses_return_structured_remote_errors() { + let response: AgentResponse = AgentResponse::err(AgentError::new( + "NO_SUCH_TASK", + "task is gone", + "start it again", + true, + )); + assert!(matches!( + response.into_data(), + Err(AgentResponseError::Remote(error)) if error.code == "NO_SUCH_TASK" + )); +} diff --git a/crates/tinycomputer-bus/src/browser/session/session_tests.rs b/crates/tinycomputer-bus/src/browser/session/session_tests.rs index 85122f10..859ecdd2 100644 --- a/crates/tinycomputer-bus/src/browser/session/session_tests.rs +++ b/crates/tinycomputer-bus/src/browser/session/session_tests.rs @@ -182,7 +182,14 @@ fn a_minimal_metadata_and_screen_fill_their_defaults() { assert_eq!(metadata.platform, "Windows"); assert_eq!(metadata.platform_version, ""); assert!(!metadata.mobile); - assert_eq!(options.screen.expect("screen").device_scale_factor, 1.0); + assert_eq!( + options + .screen + .expect("screen") + .device_scale_factor + .to_bits(), + 1.0_f64.to_bits() + ); } #[test] diff --git a/crates/tinycomputer-bus/src/config/browser.rs b/crates/tinycomputer-bus/src/config/browser.rs new file mode 100644 index 00000000..6c217fd1 --- /dev/null +++ b/crates/tinycomputer-bus/src/config/browser.rs @@ -0,0 +1,100 @@ +//! Portable browser-session defaults in [`super::ComputerConfig`]. + +use serde::{Deserialize, Serialize}; + +/// How a task gathers page evidence. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BrowserPerception { + /// Use the browser's visual and accessibility evidence. + #[default] + Sight, + /// Use only the accessibility tree. + Tree, +} + +/// How long a task lets a page settle after an action. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BrowserSettle { + /// Use the prompt's page-settle instruction. + #[default] + Prompt, + /// Wait for a steady page before continuing. + Steady, +} + +/// Defaults applied when the module opens a browser session. +/// +/// An attached session does not launch a browser, so launch-only settings do +/// not replace its endpoint or arguments. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct BrowserConfig { + /// The Chrome or Chromium executable to launch, if the host selected one. + #[serde(skip_serializing_if = "Option::is_none")] + pub executable: Option, + /// The User-Agent sent by every launched browser, when set. + #[serde(skip_serializing_if = "Option::is_none")] + pub user_agent: Option, + /// Extra command-line arguments for every launched browser. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + /// How a task reads a page. + #[serde(skip_serializing_if = "is_default_perception")] + pub perception: BrowserPerception, + /// How a task lets a page settle after an action. + #[serde(skip_serializing_if = "is_default_settle")] + pub settle: BrowserSettle, + /// Whether a browser-only task prelaunches its browser while planning. + #[serde(skip_serializing_if = "is_true")] + pub prelaunch: bool, +} + +impl Default for BrowserConfig { + fn default() -> Self { + Self { + executable: None, + user_agent: None, + args: Vec::new(), + perception: BrowserPerception::default(), + settle: BrowserSettle::default(), + prelaunch: true, + } + } +} + +impl std::fmt::Debug for BrowserConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("BrowserConfig") + .field( + "executable", + &self.executable.as_ref().map(|_| "[REDACTED]"), + ) + .field( + "user_agent", + &self.user_agent.as_ref().map(|_| "[REDACTED]"), + ) + .field("args_count", &self.args.len()) + .field("perception", &self.perception) + .field("settle", &self.settle) + .field("prelaunch", &self.prelaunch) + .finish() + } +} + +#[allow(clippy::trivially_copy_pass_by_ref)] // serde's skip_serializing_if requires &T. +fn is_default_perception(value: &BrowserPerception) -> bool { + *value == BrowserPerception::default() +} + +#[allow(clippy::trivially_copy_pass_by_ref)] // serde's skip_serializing_if requires &T. +fn is_default_settle(value: &BrowserSettle) -> bool { + *value == BrowserSettle::default() +} + +#[allow(clippy::trivially_copy_pass_by_ref)] // serde's skip_serializing_if requires &T. +fn is_true(value: &bool) -> bool { + *value +} diff --git a/crates/tinycomputer-bus/src/config/computer.rs b/crates/tinycomputer-bus/src/config/computer.rs new file mode 100644 index 00000000..271ec663 --- /dev/null +++ b/crates/tinycomputer-bus/src/config/computer.rs @@ -0,0 +1,70 @@ +//! The public, typed part of the module's load-time configuration. + +use serde::{Deserialize, Serialize, de::DeserializeOwned}; + +use crate::agentic::JevConfig; + +use super::{BrowserConfig, PlannerConfig}; + +/// The configuration shared by hosts and the loaded `TinyComputer` module. +/// +/// Credentials in `jev` and `planner` must be delivered through `TinyBus`'s +/// confidential module-configuration path. Omit an unset `jev`, `planner`, or +/// `browser` section; an explicit `null` is rejected. `trace_path` retains the +/// desktop adapter's historical behavior where `null` means unset. The nested +/// configuration types redact their credentials from `Debug` output. +#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ComputerConfig { + /// The decision model and its approved provider route, when configured. + #[serde( + default = "missing_option", + deserialize_with = "deserialize_present_option", + skip_serializing_if = "Option::is_none" + )] + pub jev: Option, + /// The planner, rescue, and output model routes, when configured. + #[serde( + default = "missing_option", + deserialize_with = "deserialize_present_option", + skip_serializing_if = "Option::is_none" + )] + pub planner: Option, + /// Defaults applied to browser sessions opened by the module. + #[serde( + default = "missing_option", + deserialize_with = "deserialize_present_option", + skip_serializing_if = "Option::is_none" + )] + pub browser: Option, + /// The desktop trace file path, when tracing is enabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub trace_path: Option, +} + +fn deserialize_present_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: DeserializeOwned, +{ + T::deserialize(deserializer).map(Some) +} + +fn missing_option() -> Option { + None +} + +impl std::fmt::Debug for ComputerConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ComputerConfig") + .field("jev", &self.jev) + .field("planner", &self.planner) + .field("browser", &self.browser) + .field( + "trace_path", + &self.trace_path.as_ref().map(|_| "[REDACTED]"), + ) + .finish() + } +} diff --git a/crates/tinycomputer-bus/src/config/config_tests.rs b/crates/tinycomputer-bus/src/config/config_tests.rs new file mode 100644 index 00000000..a1146337 --- /dev/null +++ b/crates/tinycomputer-bus/src/config/config_tests.rs @@ -0,0 +1,136 @@ +//! Tests for the public module configuration's wire shape and secret handling. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use serde_json::json; + +use super::{BrowserConfig, BrowserPerception, BrowserSettle, ComputerConfig}; + +#[test] +fn computer_config_deserializes_the_host_fields_and_module_defaults() { + let config: ComputerConfig = serde_json::from_value(json!({ + "jev": {"api_key": "th-secret", "provider": "tiny_humans_open_router"}, + "planner": { + "api_key": "or-secret", + "provider": "open_router", + "model": "anthropic/claude-sonnet-5", + "rescue_route": {"api_key": "rescue-secret", "provider": "tiny_humans"} + }, + "browser": {"executable": "/usr/bin/chromium"}, + "trace_path": "/workspace/state/computer/desktop-trace.jsonl", + "session_id": "legacy-module-option" + })) + .expect("valid host and module-only fields decode"); + + assert_eq!(config.jev.as_ref().unwrap().api_key(), "th-secret"); + assert_eq!(config.planner.as_ref().unwrap().route.api_key, "or-secret"); + assert_eq!( + config.planner.as_ref().unwrap().rescuer_route().api_key, + "rescue-secret" + ); + let browser = config.browser.unwrap(); + assert_eq!(browser.executable.as_deref(), Some("/usr/bin/chromium")); + assert_eq!(browser.perception, BrowserPerception::Sight); + assert_eq!(browser.settle, BrowserSettle::Prompt); + assert!(browser.prelaunch); + assert_eq!( + config.trace_path.as_deref(), + Some("/workspace/state/computer/desktop-trace.jsonl") + ); +} + +#[test] +fn computer_config_serializes_without_empty_optional_sections() { + let config = ComputerConfig { + browser: Some(BrowserConfig { + executable: Some("/usr/bin/chromium".into()), + ..BrowserConfig::default() + }), + trace_path: Some("/trace.jsonl".into()), + ..ComputerConfig::default() + }; + + assert_eq!( + serde_json::to_value(config).unwrap(), + json!({ + "browser": {"executable": "/usr/bin/chromium"}, + "trace_path": "/trace.jsonl" + }) + ); +} + +#[test] +fn browser_config_defaults_and_serializes_non_default_fields() { + let default = BrowserConfig::default(); + assert_eq!(default.perception, BrowserPerception::Sight); + assert_eq!(default.settle, BrowserSettle::Prompt); + assert!(default.prelaunch); + + let configured = BrowserConfig { + perception: BrowserPerception::Tree, + settle: BrowserSettle::Steady, + prelaunch: false, + ..default + }; + assert_eq!( + serde_json::to_value(configured).unwrap(), + json!({ + "perception": "tree", + "settle": "steady", + "prelaunch": false + }) + ); +} + +#[test] +fn browser_config_debug_redacts_sensitive_values() { + let config = BrowserConfig { + executable: Some("/secret/chromium".into()), + user_agent: Some("private agent string".into()), + args: vec!["--private-argument".into()], + ..BrowserConfig::default() + }; + let debug = format!("{config:?}"); + + assert!(!debug.contains("/secret/chromium")); + assert!(!debug.contains("private agent string")); + assert!(!debug.contains("--private-argument")); + assert!(debug.contains("args_count: 1")); +} + +#[test] +fn computer_configuration_debug_redacts_all_model_credentials() { + let config: ComputerConfig = serde_json::from_value(json!({ + "jev": {"api_key": "jev-secret"}, + "planner": { + "api_key": "planner-secret", + "rescue_route": {"api_key": "rescue-secret"} + }, + "trace_path": "/private/workspace/computer-trace.jsonl" + })) + .unwrap(); + + let debug = format!("{config:?}"); + for secret in ["jev-secret", "planner-secret", "rescue-secret"] { + assert!(!debug.contains(secret), "{secret} leaked in {debug}"); + } + assert!(!debug.contains("/private/workspace/computer-trace.jsonl")); +} + +#[test] +fn browser_configuration_rejects_unknown_fields() { + assert!(serde_json::from_value::(json!({"executabel": "chrome"})).is_err()); +} + +#[test] +fn computer_config_rejects_null_sections_but_keeps_nullable_trace_path() { + for field in ["jev", "planner", "browser"] { + assert!( + serde_json::from_value::(json!({field: null})).is_err(), + "explicit null must not silently disable {field}" + ); + } + let config: ComputerConfig = + serde_json::from_value(json!({"trace_path": null})).expect("legacy null trace path"); + assert!(config.trace_path.is_none()); +} diff --git a/crates/tinycomputer-bus/src/config/mod.rs b/crates/tinycomputer-bus/src/config/mod.rs new file mode 100644 index 00000000..63baef1f --- /dev/null +++ b/crates/tinycomputer-bus/src/config/mod.rs @@ -0,0 +1,12 @@ +//! Typed configuration passed when the `TinyComputer` module is loaded. + +mod browser; +mod computer; +mod planner; + +pub use browser::{BrowserConfig, BrowserPerception, BrowserSettle}; +pub use computer::ComputerConfig; +pub use planner::{ModelRoute, PlannerConfig}; + +#[cfg(test)] +mod config_tests; diff --git a/crates/tinycomputer-bus/src/config/planner.rs b/crates/tinycomputer-bus/src/config/planner.rs new file mode 100644 index 00000000..7c8db33d --- /dev/null +++ b/crates/tinycomputer-bus/src/config/planner.rs @@ -0,0 +1,82 @@ +//! Typed planner and model-route configuration carried in a module load. + +use serde::{Deserialize, Serialize}; + +use crate::agent::LanguageModelProvider; + +/// Which approved provider and credential the planner roles use. +/// +/// The module checks the provider's endpoint against its own allow-list before +/// constructing a client. `api_key` is never included in the custom `Debug` +/// representation. +#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelRoute { + /// The `OpenRouter` key or `TinyHumans` bearer. + pub api_key: String, + /// The OpenAI-compatible provider; `OpenRouter` when absent. + #[serde(default)] + pub provider: LanguageModelProvider, + /// An exact repetition of the provider's approved base URL, when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint_url: Option, + /// Product attribution for the `TinyHumans` route, when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sdk_name: Option, +} + +impl std::fmt::Debug for ModelRoute { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ModelRoute") + .field("api_key", &"[REDACTED]") + .field("provider", &self.provider) + .field("endpoint_url", &self.endpoint_url) + .field("sdk_name", &self.sdk_name) + .finish() + } +} + +/// The planner, rescue, and output roles configured for the module. +/// +/// The route fields (`api_key`, `provider`, `endpoint_url`, and `sdk_name`) sit +/// at the top level of the object beside the three model names. The planner +/// and output roles use this route; the rescuer uses `rescue_route` when set. +#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlannerConfig { + /// The planner, output, and fallback rescue route. + #[serde(flatten)] + pub route: ModelRoute, + /// The planning model, or the module's default when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// The rescue model, or the module's default when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rescue_model: Option, + /// The output-shaping model, or the module's default when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_model: Option, + /// A separate route with its own credential for rescue calls. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rescue_route: Option, +} + +impl PlannerConfig { + /// Returns the rescue route, falling back to the planner route. + #[must_use] + pub fn rescuer_route(&self) -> &ModelRoute { + self.rescue_route.as_ref().unwrap_or(&self.route) + } +} + +impl std::fmt::Debug for PlannerConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PlannerConfig") + .field("route", &self.route) + .field("model", &self.model) + .field("rescue_model", &self.rescue_model) + .field("output_model", &self.output_model) + .field("rescue_route", &self.rescue_route) + .finish() + } +} diff --git a/crates/tinycomputer-bus/src/envelope/envelope_tests.rs b/crates/tinycomputer-bus/src/envelope/envelope_tests.rs index 9e70e1c6..2eb63a96 100644 --- a/crates/tinycomputer-bus/src/envelope/envelope_tests.rs +++ b/crates/tinycomputer-bus/src/envelope/envelope_tests.rs @@ -7,9 +7,10 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::{ - Delivery, DeliveryDisposition, DesktopError, DesktopResponse, ENVELOPE_VERSION, RecoveryHint, - RetryDisposition, + Delivery, DeliveryDisposition, DesktopError, DesktopResponse, DesktopResponseError, + ENVELOPE_VERSION, RecoveryHint, RetryDisposition, }; +use serde::Deserialize; use serde_json::json; #[test] @@ -139,3 +140,84 @@ fn an_absent_disposition_decodes_as_unknown() { assert_eq!(error.disposition, Delivery::default()); assert_eq!(error.disposition.delivery, DeliveryDisposition::Unknown); } + +#[derive(Debug, Deserialize, PartialEq, Eq)] +struct Payload { + value: String, +} + +#[test] +fn into_data_checks_version_and_decodes_a_successful_reply() { + let reply = DesktopResponse::ok("example", json!({"value": "done"})); + let payload: Payload = reply.into_data().expect("success payload decodes"); + assert_eq!( + payload, + Payload { + value: "done".into() + } + ); + + let mut stale = DesktopResponse::ok("example", json!({"value": "done"})); + stale.version = "2.4".into(); + assert!(matches!( + stale.into_data::(), + Err(DesktopResponseError::Version { actual, .. }) if actual == "2.4" + )); +} + +#[test] +fn into_data_accepts_a_present_null_value_after_wire_round_trip() { + let reply = DesktopResponse::ok("noop", json!(null)); + let wire = serde_json::to_value(reply).expect("response serializes"); + let decoded: DesktopResponse = serde_json::from_value(wire).expect("wire response decodes"); + + assert!(matches!(decoded.into_data::>(), Ok(None))); +} + +#[test] +fn into_data_still_rejects_a_missing_success_value() { + let decoded: DesktopResponse = serde_json::from_value(json!({ + "version": "2.5", + "ok": true, + "command": "noop" + })) + .expect("wire response decodes"); + + assert!(matches!( + decoded.into_data::<()>(), + Err(DesktopResponseError::InconsistentOutcome { + ok: true, + has_data: false, + has_error: false + }) + )); +} + +#[test] +fn into_data_returns_remote_errors_and_rejects_inconsistent_outcomes() { + let failed = DesktopResponse::err("example", DesktopError::new("DENIED", "refused")); + assert!(matches!( + failed.into_data::(), + Err(DesktopResponseError::Remote(error)) if error.code == "DENIED" + )); + + let mut inconsistent = DesktopResponse::ok("example", json!({"value": "done"})); + inconsistent.error = Some(DesktopError::new("DENIED", "contradictory")); + assert!(matches!( + inconsistent.into_data::(), + Err(DesktopResponseError::InconsistentOutcome { + ok: true, + has_data: true, + has_error: true + }) + )); +} + +#[test] +fn into_data_rejects_data_with_the_wrong_shape() { + let reply = DesktopResponse::ok("example", json!({"value": 4})); + assert!(matches!( + reply.into_data::(), + Err(DesktopResponseError::Decode(_)) + )); +} diff --git a/crates/tinycomputer-bus/src/envelope/mod.rs b/crates/tinycomputer-bus/src/envelope/mod.rs index 1e3a0b25..13b8ca38 100644 --- a/crates/tinycomputer-bus/src/envelope/mod.rs +++ b/crates/tinycomputer-bus/src/envelope/mod.rs @@ -25,8 +25,8 @@ mod types; pub use types::{ - Delivery, DeliveryDisposition, DesktopError, DesktopResponse, ENVELOPE_VERSION, RecoveryHint, - RetryDisposition, + Delivery, DeliveryDisposition, DesktopError, DesktopResponse, DesktopResponseError, + ENVELOPE_VERSION, RecoveryHint, RetryDisposition, }; #[cfg(test)] diff --git a/crates/tinycomputer-bus/src/envelope/types.rs b/crates/tinycomputer-bus/src/envelope/types.rs index ae041984..985ac543 100644 --- a/crates/tinycomputer-bus/src/envelope/types.rs +++ b/crates/tinycomputer-bus/src/envelope/types.rs @@ -1,6 +1,6 @@ //! The response envelope, its error payload, and the delivery vocabulary. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Value; /// The envelope version this contract encodes. @@ -18,6 +18,7 @@ pub const ENVELOPE_VERSION: &str = "2.5"; pub struct DesktopResponse { /// The envelope version. [`ENVELOPE_VERSION`] for replies this module /// produces. + #[serde(default)] pub version: String, /// Whether the command succeeded. pub ok: bool, @@ -25,13 +26,24 @@ pub struct DesktopResponse { /// `ListApps`. Present on both outcomes so a batched caller can correlate. pub command: String, /// The command's result. Present when [`DesktopResponse::ok`]. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_present_value", + skip_serializing_if = "Option::is_none" + )] pub data: Option, /// Why the command failed. Present when not [`DesktopResponse::ok`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, } +fn deserialize_present_value<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Value::deserialize(deserializer).map(Some) +} + impl DesktopResponse { /// Builds a successful reply to `command` carrying `data`. /// @@ -72,6 +84,68 @@ impl DesktopResponse { error: Some(error), } } + + /// Checks the envelope version and outcome fields, then decodes the + /// successful result as `T`. + /// + /// A failed command is returned as [`DesktopResponseError::Remote`]. A + /// malformed envelope is rejected before its data can be mistaken for a + /// successful result. + /// + /// # Errors + /// + /// Returns an error when the version is unsupported, the outcome fields + /// disagree, the remote command failed, or its data has the wrong shape. + pub fn into_data(self) -> Result { + if self.version != ENVELOPE_VERSION { + return Err(DesktopResponseError::Version { + expected: ENVELOPE_VERSION, + actual: self.version, + }); + } + match (self.ok, self.data, self.error) { + (true, Some(data), None) => { + serde_json::from_value(data).map_err(DesktopResponseError::Decode) + } + (false, None, Some(error)) => Err(DesktopResponseError::Remote(Box::new(error))), + (ok, data, error) => Err(DesktopResponseError::InconsistentOutcome { + ok, + has_data: data.is_some(), + has_error: error.is_some(), + }), + } + } +} + +/// A failed result from [`DesktopResponse::into_data`]. +#[derive(Debug, thiserror::Error)] +pub enum DesktopResponseError { + /// The module returned an envelope version this crate does not understand. + #[error("unsupported desktop response version {actual}; expected {expected}")] + Version { + /// The envelope version this crate accepts. + expected: &'static str, + /// The version sent by the module. + actual: String, + }, + /// The success flag disagrees with whether data or an error is present. + #[error( + "inconsistent desktop response outcome (ok={ok}, has_data={has_data}, has_error={has_error})" + )] + InconsistentOutcome { + /// Whether the module marked the response successful. + ok: bool, + /// Whether a data field was present. + has_data: bool, + /// Whether an error field was present. + has_error: bool, + }, + /// The command failed with a structured module error. + #[error("desktop command failed")] + Remote(Box), + /// The success data did not match the expected payload type. + #[error("desktop response data had the wrong shape: {0}")] + Decode(#[source] serde_json::Error), } /// Why a command failed, in the form a caller can act on. diff --git a/crates/tinycomputer-bus/src/lib.rs b/crates/tinycomputer-bus/src/lib.rs index c35f152c..0dcd1873 100644 --- a/crates/tinycomputer-bus/src/lib.rs +++ b/crates/tinycomputer-bus/src/lib.rs @@ -31,6 +31,8 @@ //! `Browser`-prefixed), namespaced so its snapshot and screenshot types //! never shadow the desktop ones. They reply with the same envelope. //! - [`version`] — [`CONTRACT_VERSION`] and the [`is_compatible`] bind rule. +//! - [`config`] — the typed load-time settings shared by hosts and the module. +//! - [`sites`] — the canonical HTTP(S) site-name normalization used by both. //! //! # How the pieces fit together at a call site //! @@ -84,8 +86,8 @@ //! vendored as a submodule whose manifest inherits fields from its own nested //! `[workspace.package]`. A crate that every workspace member can depend on has //! to stay transport-free, and staying transport-free is what keeps this crate -//! down to three pure-Rust dependencies: `thiserror`, `serde`, and -//! `serde_json`. +//! down to pure-Rust dependencies: `thiserror`, `serde`, `serde_json`, and +//! `url`, which provides the shared IDNA-aware HTTP(S) site parser. //! //! **No session, trace, or skills members.** The engine also offers session //! lifecycle, trace read and export, and a bundled documentation loader. They @@ -119,6 +121,7 @@ pub mod apps; pub mod browser; pub mod catalogue; pub mod clipboard; +pub mod config; pub mod consequence_host; pub mod desktop_tool; pub mod envelope; @@ -130,6 +133,7 @@ pub mod names; pub mod notifications; pub mod observation; pub mod site_memory; +pub mod sites; pub mod system; pub mod version; pub mod vocabulary; @@ -146,8 +150,8 @@ pub use apps::{ }; pub use clipboard::{ClipboardGetRequest, ClipboardSetRequest}; pub use envelope::{ - Delivery, DeliveryDisposition, DesktopError, DesktopResponse, ENVELOPE_VERSION, RecoveryHint, - RetryDisposition, + Delivery, DeliveryDisposition, DesktopError, DesktopResponse, DesktopResponseError, + ENVELOPE_VERSION, RecoveryHint, RetryDisposition, }; pub use flow::{ ChooseStep, Deliberation, FLOW_GUIDE, Flow, FlowAction, FlowActionRecord, FlowBrief, FlowLoop, diff --git a/crates/tinycomputer-bus/src/sites/mod.rs b/crates/tinycomputer-bus/src/sites/mod.rs new file mode 100644 index 00000000..c391b5a9 --- /dev/null +++ b/crates/tinycomputer-bus/src/sites/mod.rs @@ -0,0 +1,37 @@ +//! The canonical HTTP(S) site names shared by the host and module. + +/// The site `url` belongs to: its host, lower-cased and without a leading +/// `www.`. Returns `None` for non-HTTP(S) URLs or hosts that cannot safely be +/// used as a site name. +#[must_use] +pub fn site_of(url: &str) -> Option { + let parsed = url::Url::parse(url.trim()).ok()?; + if !matches!(parsed.scheme(), "http" | "https") { + return None; + } + site_named(parsed.host_str()?) +} + +/// `host` as a normalized plain site name, or `None` when it is unsafe. +/// +/// The returned name is trimmed, ASCII-lowercased, and has one leading +/// `www.` removed. Its maximum byte length is 253, and it contains only ASCII +/// letters, digits, dots, and hyphens; leading dots and adjacent dots are +/// refused so the name is also a safe site-memory file stem. +#[must_use] +pub fn site_named(host: &str) -> Option { + let host = host.trim().to_ascii_lowercase(); + let host = host.strip_prefix("www.").unwrap_or(&host); + let plain = !host.is_empty() + && host.len() <= 253 + && !host.starts_with('.') + && !host.contains("..") + && host.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'-') + }); + plain.then(|| host.to_owned()) +} + +#[cfg(test)] +#[path = "sites_tests.rs"] +mod tests; diff --git a/crates/tinycomputer-bus/src/sites/sites_tests.rs b/crates/tinycomputer-bus/src/sites/sites_tests.rs new file mode 100644 index 00000000..2d9d447a --- /dev/null +++ b/crates/tinycomputer-bus/src/sites/sites_tests.rs @@ -0,0 +1,40 @@ +//! Tests for canonical site normalization shared by the host and module. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use super::{site_named, site_of}; + +#[test] +fn site_urls_use_url_idna_and_reject_non_http_and_ip_hosts() { + assert_eq!( + site_of(" https://www.bücher.example/shop ").as_deref(), + Some("xn--bcher-kva.example") + ); + assert_eq!( + site_of("http://shop.test:8080/cart").as_deref(), + Some("shop.test") + ); + for url in [ + "ftp://shop.test/", + "file:///tmp/page.html", + "not a URL", + "https://[::1]/", + ] { + assert_eq!(site_of(url), None, "{url}"); + } +} + +#[test] +fn site_names_are_ascii_normalized_and_safe_file_stems() { + assert_eq!( + site_named(" WWW.Example.COM ").as_deref(), + Some("example.com") + ); + assert_eq!(site_named("example.com.").as_deref(), Some("example.com.")); + assert_eq!(site_named("bücher.example"), None); + assert_eq!(site_named(".hidden"), None); + assert_eq!(site_named("a..b"), None); + assert_eq!(site_named(&"a".repeat(254)), None); + let longest = "a".repeat(253); + assert_eq!(site_named(&longest).as_deref(), Some(longest.as_str())); +} diff --git a/crates/tinycomputer-bus/src/version/mod.rs b/crates/tinycomputer-bus/src/version/mod.rs index df0cfe38..c26022e0 100644 --- a/crates/tinycomputer-bus/src/version/mod.rs +++ b/crates/tinycomputer-bus/src/version/mod.rs @@ -8,7 +8,7 @@ //! release workflow bumps, which tracks the shipped artifact. /// The wire contract version this crate defines. -pub const CONTRACT_VERSION: (u32, u32) = (2, 20); +pub const CONTRACT_VERSION: (u32, u32) = (2, 21); /// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module /// reporting `module`. @@ -22,7 +22,7 @@ pub const CONTRACT_VERSION: (u32, u32) = (2, 20); /// ``` /// # use tinycomputer_bus::{is_compatible, CONTRACT_VERSION}; /// assert!(is_compatible(CONTRACT_VERSION)); -/// assert!(is_compatible((2, 20))); +/// assert!(is_compatible((2, 21))); /// assert!(!is_compatible((2, 11))); /// assert!(!is_compatible((1, 8))); /// ``` diff --git a/crates/tinycomputer-bus/src/version/version_tests.rs b/crates/tinycomputer-bus/src/version/version_tests.rs index f3d7b52b..059453e4 100644 --- a/crates/tinycomputer-bus/src/version/version_tests.rs +++ b/crates/tinycomputer-bus/src/version/version_tests.rs @@ -6,7 +6,7 @@ use super::{CONTRACT_VERSION, binds, is_compatible}; #[test] fn the_shipped_contract_version_is_pinned() { - assert_eq!(CONTRACT_VERSION, (2, 20)); + assert_eq!(CONTRACT_VERSION, (2, 21)); } #[test] @@ -16,7 +16,7 @@ fn the_contract_binds_to_itself() { #[test] fn a_newer_minor_on_the_module_side_binds() { - assert!(is_compatible((2, 20))); + assert!(is_compatible((2, 21))); assert!(is_compatible((2, 97))); // 2.12 adds replayable GlobeRead and joined terminal GlobeShutdown. // A 2.11 module does not implement those members. @@ -35,6 +35,9 @@ fn a_newer_minor_on_the_module_side_binds() { // 2.19 adds the ConsequenceHost host-callback interface (Review): a 2.19 // host serves it, which a 2.18 module does not call, so its approvals // would never reach the host's policy. + // 2.21 adds typed ComputerConfig, shared strict site normalization, and + // versioned consistency-checked Desktop and Agent response extraction. + assert!(!is_compatible((2, 20))); assert!(!is_compatible((2, 18))); assert!(!is_compatible((2, 17))); assert!(!is_compatible((2, 16))); diff --git a/crates/tinycomputer-core/Cargo.toml b/crates/tinycomputer-core/Cargo.toml index ac14c688..0f00b65d 100644 --- a/crates/tinycomputer-core/Cargo.toml +++ b/crates/tinycomputer-core/Cargo.toml @@ -20,10 +20,8 @@ serde = { workspace = true } serde_json = { workspace = true } # Derive macros for the crate-wide error type in `src/error/`. thiserror = { workspace = true } -# Site memory keys a plan by a digest of its facts, so no value is stored, and -# reads a site off an address by the WHATWG URL rules. +# Site memory keys a plan by a digest of its facts, so no value is stored. sha2 = { workspace = true } -url = { workspace = true } [lints] workspace = true diff --git a/crates/tinycomputer-core/src/sites/mod.rs b/crates/tinycomputer-core/src/sites/mod.rs index c942cbb8..4d00acdf 100644 --- a/crates/tinycomputer-core/src/sites/mod.rs +++ b/crates/tinycomputer-core/src/sites/mod.rs @@ -28,6 +28,8 @@ use tinycomputer_bus::agent::TaskReport; use tinycomputer_bus::site_memory::{SavedHint, SavedPlan, SiteMemory}; use tinycomputer_bus::{Flow, GroundingHint}; +pub use tinycomputer_bus::sites::{site_named, site_of}; + /// Most elements kept per site; the oldest go first. pub const MAX_HINTS: usize = 50; /// Most plans kept per site; the one finished on longest ago goes first. @@ -114,31 +116,6 @@ impl Following { } } -/// The site `url` belongs to: its host, lower-cased and without a leading -/// `www.`; `None` for an address that is not `http(s)` or names no plain -/// host. -#[must_use] -pub fn site_of(url: &str) -> Option { - let parsed = url::Url::parse(url.trim()).ok()?; - if !matches!(parsed.scheme(), "http" | "https") { - return None; - } - site_named(parsed.host_str()?) -} - -/// `host` as a site name, or `None` when it is not a plain host name. -#[must_use] -pub fn site_named(host: &str) -> Option { - let host = host.trim().to_ascii_lowercase(); - let host = host.strip_prefix("www.").unwrap_or(&host); - let plain = !host.is_empty() - && !host.starts_with('.') - && host - .chars() - .all(|character| character.is_ascii_alphanumeric() || matches!(character, '.' | '-')); - plain.then(|| host.to_owned()) -} - /// A goal as plans are matched by: lower-cased, its whitespace collapsed. #[must_use] pub fn goal_key(goal: &str) -> String { diff --git a/crates/tinycomputer-core/src/sites/sites_tests.rs b/crates/tinycomputer-core/src/sites/sites_tests.rs index 4ebdc4ef..75777870 100644 --- a/crates/tinycomputer-core/src/sites/sites_tests.rs +++ b/crates/tinycomputer-core/src/sites/sites_tests.rs @@ -64,6 +64,26 @@ fn a_site_is_its_host_without_www() { assert_eq!(site_named(".hidden"), None); } +#[test] +fn site_names_reject_adjacent_dots() { + assert_eq!(site_named("a..b"), None); +} + +#[test] +fn site_names_reject_overlong_file_stems() { + assert_eq!(site_named(&"a".repeat(254)), None); +} + +#[test] +fn site_urls_keep_url_parser_idna_and_ip_address_behavior() { + assert_eq!( + site_of("https://www.bücher.example/").as_deref(), + Some("xn--bcher-kva.example") + ); + assert_eq!(site_of("https://[::1]/"), None); + assert_eq!(site_named("bücher.example"), None); +} + #[test] fn goals_are_matched_whatever_their_case_and_spacing() { assert_eq!( diff --git a/crates/tinycomputer-engine/src/planner/config.rs b/crates/tinycomputer-engine/src/planner/config.rs index a798aefd..b0a9ee6a 100644 --- a/crates/tinycomputer-engine/src/planner/config.rs +++ b/crates/tinycomputer-engine/src/planner/config.rs @@ -6,8 +6,8 @@ //! allow-list principle as Jev's endpoint, so private configuration can never //! point a model's key at an arbitrary host. -use serde::Deserialize; use tinycomputer_bus::agent::{LanguageModelConfiguration, LanguageModelProvider}; +pub use tinycomputer_bus::config::{ModelRoute, PlannerConfig}; /// The model used when the configuration names none. pub const PLANNER_MODEL: &str = "anthropic/claude-sonnet-5"; @@ -28,89 +28,6 @@ pub const OPEN_ROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; /// `tiny_humans` route accepts. pub const TINYHUMANS_BASE_URL: &str = "https://api.tinyhumans.ai/openai/v1"; -/// The module's private `planner` configuration. -/// -/// Its route fields (`api_key`, `provider`, `endpoint_url`, `sdk_name`) sit -/// at the top level of the object, beside the three models. The planner and -/// the shaper always take that route; the rescuer takes `rescue_route` when -/// one is given. -#[derive(Clone, Deserialize)] -pub struct PlannerConfig { - /// The route the planner, the shaper, and (without `rescue_route`) the - /// rescuer call their models through. - #[serde(flatten)] - pub route: ModelRoute, - /// The model id; [`PLANNER_MODEL`] when absent. - #[serde(default)] - pub model: Option, - /// The model id failed steps are rescued with; [`RESCUE_MODEL`] when - /// absent. - #[serde(default)] - pub rescue_model: Option, - /// The model id a finished task's answer is shaped with; - /// [`OUTPUT_MODEL`] when absent. - #[serde(default)] - pub output_model: Option, - /// A separate route, with its own key, for the rescuer alone. Nothing is - /// inherited from the planner's route: it is a complete route of its own. - #[serde(default)] - pub rescue_route: Option, -} - -impl PlannerConfig { - /// The route the rescuer calls through: `rescue_route`, or the planner's. - #[must_use] - pub fn rescuer_route(&self) -> &ModelRoute { - self.rescue_route.as_ref().unwrap_or(&self.route) - } -} - -impl std::fmt::Debug for PlannerConfig { - /// Never prints a key. - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("PlannerConfig") - .field("route", &self.route) - .field("model", &self.model) - .field("rescue_model", &self.rescue_model) - .field("output_model", &self.output_model) - .field("rescue_route", &self.rescue_route) - .finish() - } -} - -/// Where a language model's requests go, and with which credential. -#[derive(Clone, Default, Deserialize)] -pub struct ModelRoute { - /// The provider's credential: an `OpenRouter` key, or the host's - /// `TinyHumans` bearer. - pub api_key: String, - /// The OpenAI-compatible route; `open_router` when absent. - #[serde(default)] - pub provider: LanguageModelProvider, - /// The provider's approved base URL, repeated exactly; any other value is - /// refused. - #[serde(default)] - pub endpoint_url: Option, - /// Host product attribution, sent as `x-sdk-name` to the `tiny_humans` - /// route only. - #[serde(default)] - pub sdk_name: Option, -} - -impl std::fmt::Debug for ModelRoute { - /// Never prints the key. - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("ModelRoute") - .field("api_key", &"[REDACTED]") - .field("provider", &self.provider) - .field("endpoint_url", &self.endpoint_url) - .field("sdk_name", &self.sdk_name) - .finish() - } -} - /// A route checked and ready to build a model on. pub(super) struct ResolvedRoute { pub(super) provider: LanguageModelProvider, @@ -118,42 +35,38 @@ pub(super) struct ResolvedRoute { pub(super) sdk_name: Option, } -impl ModelRoute { - /// This route checked: a key present, and an endpoint, if any, that is - /// the provider's own. - pub(super) fn resolve(&self) -> Result { - if self.api_key.trim().is_empty() { - return Err("the planner configuration needs an api_key".to_owned()); - } - let base_url = match &self.endpoint_url { - Some(endpoint) if trusted_base_url(self.provider, endpoint) => { - endpoint.trim_end_matches('/').to_owned() - } - Some(_) => { - return Err("endpoint_url is not an approved language model route".to_owned()); - } - None => approved_base_url(self.provider).to_owned(), - }; - let sdk_name = match self.provider { - LanguageModelProvider::TinyHumans => { - self.sdk_name.as_deref().and_then(sanitized_sdk_name) - } - LanguageModelProvider::OpenRouter => None, - }; - Ok(ResolvedRoute { - provider: self.provider, - base_url, - sdk_name, - }) +/// Validates a configured route and resolves its approved base URL. +pub(super) fn resolve_route(route: &ModelRoute) -> Result { + if route.api_key.trim().is_empty() { + return Err("the planner configuration needs an api_key".to_owned()); } - - /// The non-secret summary of `model` on this route. - pub(super) fn describe(&self, model: &str) -> LanguageModelConfiguration { - LanguageModelConfiguration { - provider: self.provider, - model: model.to_owned(), - endpoint_url: self.endpoint_url.clone(), + let base_url = match &route.endpoint_url { + Some(endpoint) if trusted_base_url(route.provider, endpoint) => { + endpoint.trim_end_matches('/').to_owned() + } + Some(_) => { + return Err("endpoint_url is not an approved language model route".to_owned()); } + None => approved_base_url(route.provider).to_owned(), + }; + let sdk_name = match route.provider { + LanguageModelProvider::TinyHumans => route.sdk_name.as_deref().and_then(sanitized_sdk_name), + LanguageModelProvider::OpenRouter => None, + }; + Ok(ResolvedRoute { + provider: route.provider, + base_url, + sdk_name, + }) +} + +/// Returns the non-secret model summary stored on the planner, rescuer, or +/// shaper. +pub(super) fn describe_route(route: &ModelRoute, model: &str) -> LanguageModelConfiguration { + LanguageModelConfiguration { + provider: route.provider, + model: model.to_owned(), + endpoint_url: route.endpoint_url.clone(), } } diff --git a/crates/tinycomputer-engine/src/planner/hosted.rs b/crates/tinycomputer-engine/src/planner/hosted.rs index ecb817dd..24aaee34 100644 --- a/crates/tinycomputer-engine/src/planner/hosted.rs +++ b/crates/tinycomputer-engine/src/planner/hosted.rs @@ -20,7 +20,10 @@ use tinyinference_llm::{ classify_provider_failure, }; -use super::config::{ModelRoute, OUTPUT_MODEL, PLANNER_MODEL, PlannerConfig, RESCUE_MODEL}; +use super::config::{ + ModelRoute, OUTPUT_MODEL, PLANNER_MODEL, PlannerConfig, RESCUE_MODEL, describe_route, + resolve_route, +}; use super::{Completion, LanguageModel, Planner, Role, Turn}; use crate::rescue::Rescuer; use crate::shape::Shaper; @@ -41,7 +44,7 @@ pub fn open_router(config: &PlannerConfig) -> Result { reasoning: None, max_tokens: 4_000, })) - .with_configuration(config.route.describe(&model))) + .with_configuration(describe_route(&config.route, &model))) } /// A [`Rescuer`] configured by `config`: the `rescue_model` (or @@ -63,7 +66,7 @@ pub fn open_router_rescuer(config: &PlannerConfig) -> Result { reasoning: Some(ReasoningEffort::Low), max_tokens: 8_000, })) - .with_configuration(route.describe(&model))) + .with_configuration(describe_route(route, &model))) } /// A [`Shaper`] configured by `config`: the `output_model` (or @@ -85,7 +88,7 @@ pub fn open_router_shaper(config: &PlannerConfig) -> Result { // Room for a result built from many records. max_tokens: 16_000, })) - .with_configuration(config.route.describe(&model))) + .with_configuration(describe_route(&config.route, &model))) } /// `configured`, unless absent or blank, else `default`. @@ -98,7 +101,7 @@ fn model_name(configured: Option<&String>, default: &str) -> String { /// The OpenAI-compatible client for `model` on `route`. pub(super) fn chat_model(route: &ModelRoute, model: &str) -> Result, String> { - let resolved = route.resolve()?; + let resolved = resolve_route(route)?; let kind = match resolved.provider { LanguageModelProvider::OpenRouter => ProviderKind::OpenRouter, LanguageModelProvider::TinyHumans => ProviderKind::TinyHumans, diff --git a/crates/tinycomputer-engine/src/planner/planner_tests/route_tests.rs b/crates/tinycomputer-engine/src/planner/planner_tests/route_tests.rs index 0490a7f4..60fb080b 100644 --- a/crates/tinycomputer-engine/src/planner/planner_tests/route_tests.rs +++ b/crates/tinycomputer-engine/src/planner/planner_tests/route_tests.rs @@ -5,7 +5,8 @@ use serde_json::json; use tinycomputer_bus::agent::{LanguageModelConfiguration, LanguageModelProvider}; use super::super::config::{ - ModelRoute, OPEN_ROUTER_BASE_URL, PlannerConfig, TINYHUMANS_BASE_URL, trusted_base_url, + ModelRoute, OPEN_ROUTER_BASE_URL, PlannerConfig, TINYHUMANS_BASE_URL, resolve_route, + trusted_base_url, }; use super::super::hosted::chat_model; use super::super::{open_router, open_router_rescuer, open_router_shaper}; @@ -136,7 +137,7 @@ fn each_selection_builds_its_client_on_the_selected_route() { assert_eq!(chat.provider(), "tinyhumans"); assert_eq!(chat.model(), "openai/gpt-6-luna"); assert_eq!( - tiny.resolve().unwrap().sdk_name.as_deref(), + resolve_route(&tiny).unwrap().sdk_name.as_deref(), Some("openhumanapp"), "the product name is sanitized" ); @@ -145,7 +146,7 @@ fn each_selection_builds_its_client_on_the_selected_route() { ..open }; assert_eq!( - open_with_name.resolve().unwrap().sdk_name, + resolve_route(&open_with_name).unwrap().sdk_name, None, "attribution goes to the TinyHumans gateway only" ); diff --git a/crates/tinycomputer/src/tinybus_module/README.md b/crates/tinycomputer/src/tinybus_module/README.md index 65ec251e..3ee5c462 100644 --- a/crates/tinycomputer/src/tinybus_module/README.md +++ b/crates/tinycomputer/src/tinybus_module/README.md @@ -52,20 +52,19 @@ against a scripted engine, so no browser is launched. ## Configuration -The module takes its configuration from the loader as a JSON object, parsed into -`Desktop` by `Desktop::from_config`: `session_id` and `trace_path` as strings, -`trace_strict` and `headed` as booleans. `browser` sets how every browser -launches — `executable`, `user_agent`, `args`, a task's page -`perception`, how a page `settle`s after an action, and whether to -`prelaunch` a planned browser task's browser (a planned task also warms -the `jev` runtime's connections meanwhile) — and `cursor` sets the -agent's on-screen cursor: a pace (`off`, `brisk`, `natural`, `calm`) or -`{pace, overlay}` with the overlay -helper's path. An optional `jev` object configures the -provider, model, endpoint, and API key before the service is registered. -TinyBus treats initial and replacement module configuration as sensitive -host-control traffic. An unreadable configuration fails before replacing the -served object rather than falling back to defaults. +The host-visible loader fields are decoded as +`tinycomputer_bus::config::ComputerConfig`: optional `jev`, `planner`, +`browser`, and `trace_path`. The planner's route types are the same public +types used by the engine. The module adapter converts `trace_path` into +`Desktop` settings and applies the typed browser defaults. + +Module-only legacy options remain outside this host contract: `session_id`, +`trace_strict`, and `headed` still configure `Desktop`, while `cursor` sets the +agent's on-screen cursor (a pace of `off`, `brisk`, `natural`, or `calm`, or +`{pace, overlay}`). The module keeps accepting these fields from existing +loader configurations. An unreadable option fails before replacing the served +object rather than falling back to defaults. TinyBus treats initial and +replacement module configuration as sensitive host-control traffic. ## The manifest diff --git a/crates/tinycomputer/src/tinybus_module/config.rs b/crates/tinycomputer/src/tinybus_module/config.rs index b1ae35ae..8c95c109 100644 --- a/crates/tinycomputer/src/tinybus_module/config.rs +++ b/crates/tinycomputer/src/tinybus_module/config.rs @@ -7,6 +7,7 @@ //! request. use tinycomputer_browser::{CursorPace, Perception, ScreenCursor, SessionOptions, Settle}; +use tinycomputer_bus::config::{BrowserConfig, BrowserPerception, BrowserSettle}; use crate::Result; @@ -45,74 +46,25 @@ impl Default for BrowserDefaults { } impl BrowserDefaults { - /// Reads the optional `browser` object: `executable` and `user_agent` - /// strings, `args` an array of strings, `perception` either `sight` or - /// `tree`, `settle` either `prompt` or `steady`, and `prelaunch` a - /// boolean. - /// - /// # Errors - /// - /// [`crate::Error::ConfigFieldType`] when `browser` or any of its fields - /// has the wrong shape; an unknown field is refused too, so a misspelt - /// setting cannot be silently ignored. - pub(crate) fn from_config(config: &serde_json::Value) -> Result { - let Some(browser) = config.as_object().and_then(|object| object.get("browser")) else { - return Ok(Self::default()); + /// Applies the browser defaults decoded from the public module config. + pub(crate) fn from_config(config: Option<&BrowserConfig>) -> Self { + let Some(config) = config else { + return Self::default(); }; - let invalid = || crate::Error::ConfigFieldType { - field: "browser", - expected: "an object with optional `executable` and `user_agent` strings, an `args` \ - array of strings, a `perception` of sight or tree, a `settle` of \ - prompt or steady, and a `prelaunch` boolean", - }; - let browser = browser.as_object().ok_or_else(invalid)?; - let text = |name: &str| match browser.get(name) { - None => Ok(None), - Some(serde_json::Value::String(value)) => Ok(Some(value.clone())), - Some(_) => Err(invalid()), - }; - let mut defaults = Self { - executable: text("executable")?, - user_agent: text("user_agent")?, - ..Self::default() - }; - if let Some(args) = browser.get("args") { - defaults.args = args - .as_array() - .ok_or_else(invalid)? - .iter() - .map(|arg| arg.as_str().map(str::to_owned).ok_or_else(invalid)) - .collect::>()?; - } - defaults.perception = match text("perception")?.as_deref() { - None | Some("sight") => Perception::Sight, - Some("tree") => Perception::Tree, - Some(_) => return Err(invalid()), - }; - defaults.settle = match text("settle")?.as_deref() { - None | Some("prompt") => Settle::Prompt, - Some("steady") => Settle::Steady, - Some(_) => return Err(invalid()), - }; - defaults.prelaunch = match browser.get("prelaunch") { - None => true, - Some(serde_json::Value::Bool(prelaunch)) => *prelaunch, - Some(_) => return Err(invalid()), - }; - if browser.keys().any(|key| { - ![ - "executable", - "user_agent", - "args", - "perception", - "settle", - "prelaunch", - ] - .contains(&key.as_str()) - }) { - return Err(invalid()); + Self { + executable: config.executable.clone(), + user_agent: config.user_agent.clone(), + args: config.args.clone(), + perception: match config.perception { + BrowserPerception::Sight => Perception::Sight, + BrowserPerception::Tree => Perception::Tree, + }, + settle: match config.settle { + BrowserSettle::Prompt => Settle::Prompt, + BrowserSettle::Steady => Settle::Steady, + }, + prelaunch: config.prelaunch, } - Ok(defaults) } /// `options` with these defaults filled in where the caller left them diff --git a/crates/tinycomputer/src/tinybus_module/dispatch/accessibility_tests.rs b/crates/tinycomputer/src/tinybus_module/dispatch/accessibility_tests.rs index 06c22d90..548a0cf0 100644 --- a/crates/tinycomputer/src/tinybus_module/dispatch/accessibility_tests.rs +++ b/crates/tinycomputer/src/tinybus_module/dispatch/accessibility_tests.rs @@ -4,14 +4,48 @@ reason = "test assertions require fixture operations to succeed" )] use super::*; -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, -}; +use std::sync::{Arc, atomic::Ordering}; use tinycomputer_bus::accessibility::PermissionState; use super::fixture::Fixture; +#[test] +fn thread_restore_worker_reports_completion_and_joins_once() { + let (finished, observed) = std::sync::mpsc::channel(); + let thread = std::thread::spawn(move || finished.send(()).expect("signal completion")); + observed.recv().expect("worker finished"); + let mut worker = ThreadRestoreWorker(Some(thread)); + + assert!(worker.is_finished()); + worker.join().expect("join completed worker"); + assert!(worker.is_finished()); + worker.join().expect("joining an empty worker is harmless"); +} + +#[test] +fn paste_reaps_finished_restore_workers_before_accepting_another() { + use std::sync::atomic::AtomicUsize; + + let fixture = Arc::new(Fixture::default()); + let joins = Arc::new(AtomicUsize::new(0)); + let access = Access::new(fixture.clone()); + let request = PasteRequest { + text: "authorized".into(), + target: FocusTarget::default(), + }; + *fixture.restore_worker.lock().expect("first restore worker") = Some(joins.clone()); + access.paste(request.clone()).expect("first paste"); + + *fixture.restore_worker.lock().expect("next restore worker") = Some(joins.clone()); + access.paste(request).expect("second paste after reaping"); + + assert_eq!(joins.load(Ordering::SeqCst), 1); + access + .shutdown() + .expect("shutdown joins the remaining worker"); + assert_eq!(joins.load(Ordering::SeqCst), 2); +} + #[test] fn insertion_validates_the_captured_target_before_touching_the_platform() { let fixture = Arc::new(Fixture::default()); diff --git a/crates/tinycomputer/src/tinybus_module/dispatch/service.rs b/crates/tinycomputer/src/tinybus_module/dispatch/service.rs index f4d1847b..dcc4ee9d 100644 --- a/crates/tinycomputer/src/tinybus_module/dispatch/service.rs +++ b/crates/tinycomputer/src/tinybus_module/dispatch/service.rs @@ -3,12 +3,14 @@ use std::sync::Arc; +use serde::de::DeserializeOwned; use tinybus::{Error as TinyBusError, Result as TinyBusResult}; use tinycomputer_browser::{AgentBrowser, Browser}; use tinycomputer_bus::agent::{ AgentResponse, ContinueTaskRequest, StartTaskRequest, SurfaceAvailability, SurfaceKind, TaskStatus, TaskView, }; +use tinycomputer_bus::config::{ComputerConfig, PlannerConfig}; use tinycomputer_bus::{DesktopResponse, JevConfig}; use tinycomputer_engine as agentic; @@ -19,6 +21,65 @@ use crate::tinybus_module::runner::WorkspaceRunner; use crate::tinybus_module::site_memory::{SiteLink, SiteMemoryPort, refused_flow}; use crate::{Desktop, Result}; +/// Decodes the host-owned fields from the public config while leaving the +/// module's existing desktop and cursor extensions in their original object. +fn computer_config(config: &serde_json::Value) -> Result { + if !config.is_null() && config.as_object().is_none() { + return Err(crate::Error::ConfigNotAnObject); + } + Ok(ComputerConfig { + jev: config_field(config, "jev", "a valid Jev configuration object")?, + planner: config_field( + config, + "planner", + "a planner configuration object with an api_key and an approved route", + )?, + browser: config_field( + config, + "browser", + "an object with optional executable and browser defaults", + )?, + trace_path: config_field(config, "trace_path", "a string trace path")?, + }) +} + +fn config_field( + config: &serde_json::Value, + field: &'static str, + expected: &'static str, +) -> Result> { + let Some(value) = config.as_object().and_then(|object| object.get(field)) else { + return Ok(None); + }; + if field == "trace_path" && value.is_null() { + return Ok(None); + } + serde_json::from_value(value.clone()) + .map(Some) + .map_err(|_| crate::Error::ConfigFieldType { field, expected }) +} + +/// Keeps module-owned desktop options outside the shared host contract. +/// `session_id`, `trace_strict`, and `headed` belong to `Desktop`; `cursor` is +/// decoded separately by the existing cursor adapter. +fn desktop_config(raw: &serde_json::Value, computer: &ComputerConfig) -> serde_json::Value { + let mut desktop = serde_json::Map::new(); + if let Some(options) = raw.as_object() { + for key in ["session_id", "trace_strict", "headed"] { + if let Some(value) = options.get(key) { + desktop.insert(key.to_owned(), value.clone()); + } + } + } + if let Some(trace_path) = &computer.trace_path { + desktop.insert( + "trace_path".into(), + serde_json::Value::String(trace_path.clone()), + ); + } + serde_json::Value::Object(desktop) +} + impl DesktopService { /// Builds the service from the module configuration blob. /// @@ -37,18 +98,14 @@ impl DesktopService { /// Propagates whatever [`Desktop::from_config`] rejects. pub(crate) fn with_browser(config: &serde_json::Value, browser: Arc) -> Result { let cursor = Arc::new(cursor_config(config)?); - let desktop = Desktop::from_config(config)?.with_cursor(cursor.clone()); - let jev = config - .as_object() - .and_then(|object| object.get("jev")) - .map(|value| { - let config: JevConfig = serde_json::from_value(value.clone()).map_err(|_| { - crate::Error::ConfigFieldType { - field: "jev", - expected: "a valid Jev configuration object", - } - })?; - agentic::JevRuntime::configure(&config).map_err(|_| crate::Error::ConfigFieldType { + let computer = computer_config(config)?; + let desktop = + Desktop::from_config(&desktop_config(config, &computer))?.with_cursor(cursor.clone()); + let jev = computer + .jev + .as_ref() + .map(|config: &JevConfig| { + agentic::JevRuntime::configure(config).map_err(|_| crate::Error::ConfigFieldType { field: "jev", expected: "a valid Jev configuration object", }) @@ -57,23 +114,21 @@ impl DesktopService { // The planner's configuration also brings the rescuer, on the same // route unless it names its own `rescue_route`: a failed step is // handed to it before a task fails. - let planner = config - .as_object() - .and_then(|object| object.get("planner")) - .map(|value| { + let planner = computer + .planner + .as_ref() + .map(|config: &PlannerConfig| { let invalid = || crate::Error::ConfigFieldType { field: "planner", expected: "a planner configuration object with an api_key and an approved route", }; - let config: agentic::PlannerConfig = - serde_json::from_value(value.clone()).map_err(|_| invalid())?; - let planner = agentic::open_router(&config).map_err(|_| invalid())?; - let rescuer = agentic::open_router_rescuer(&config).map_err(|_| invalid())?; - let shaper = agentic::open_router_shaper(&config).map_err(|_| invalid())?; + let planner = agentic::open_router(config).map_err(|_| invalid())?; + let rescuer = agentic::open_router_rescuer(config).map_err(|_| invalid())?; + let shaper = agentic::open_router_shaper(config).map_err(|_| invalid())?; Ok::<_, crate::Error>((planner, rescuer, shaper)) }) .transpose()?; - let browser_defaults = BrowserDefaults::from_config(config)?; + let browser_defaults = BrowserDefaults::from_config(computer.browser.as_ref()); let mut runner = WorkspaceRunner::new(desktop.clone(), jev.clone(), browser.clone()); runner.defaults.clone_from(&browser_defaults); runner.cursor = cursor; @@ -132,6 +187,10 @@ impl DesktopService { } } +#[cfg(test)] +#[path = "service_tests.rs"] +mod tests; + /// Whether the desktop surface is usable, from a `Permissions` reply: the /// accessibility permission must be granted (or not needed on this platform). impl DesktopService { diff --git a/crates/tinycomputer/src/tinybus_module/dispatch/service_tests.rs b/crates/tinycomputer/src/tinybus_module/dispatch/service_tests.rs new file mode 100644 index 00000000..40a2adbb --- /dev/null +++ b/crates/tinycomputer/src/tinybus_module/dispatch/service_tests.rs @@ -0,0 +1,61 @@ +#![allow( + clippy::expect_used, + reason = "the test's fixture setup must succeed before its assertions are meaningful" +)] + +use serde_json::json; +use tinycomputer_bus::config::{BrowserConfig, BrowserPerception, BrowserSettle, ComputerConfig}; + +use super::DesktopService; + +#[test] +fn module_service_consumes_shared_config_and_preserves_its_extensions() { + let shared = ComputerConfig { + browser: Some(BrowserConfig { + executable: Some("/opt/chromium".into()), + user_agent: None, + args: vec!["--disable-gpu".into()], + perception: BrowserPerception::Tree, + settle: BrowserSettle::Steady, + prelaunch: false, + }), + trace_path: Some("/tmp/computer.jsonl".into()), + ..ComputerConfig::default() + }; + let mut config = serde_json::to_value(shared).expect("shared config serializes"); + let object = config.as_object_mut().expect("object config"); + object.insert("session_id".into(), json!("run-42")); + object.insert("trace_strict".into(), json!(true)); + object.insert("headed".into(), json!(true)); + object.insert("cursor".into(), json!("off")); + + let service = DesktopService::from_config(&config).expect("typed config is consumed"); + + assert_eq!(service.desktop.session_id(), Some("run-42")); + assert_eq!( + service.desktop.trace_path(), + Some(std::path::Path::new("/tmp/computer.jsonl")) + ); + assert!(service.desktop.is_tracing()); + assert!(service.desktop.is_headed()); + assert_eq!( + service.browser_defaults.executable.as_deref(), + Some("/opt/chromium") + ); + assert_eq!(service.browser_defaults.args, ["--disable-gpu"]); + assert!(!service.browser_defaults.prelaunch); +} + +#[test] +fn module_rejects_null_shared_sections_but_preserves_nullable_trace_path() { + for field in ["jev", "planner", "browser"] { + assert!( + DesktopService::from_config(&json!({field: null})).is_err(), + "explicit null must not silently disable {field}" + ); + } + + let service = DesktopService::from_config(&json!({"trace_path": null})) + .expect("null trace path keeps the desktop's unset behavior"); + assert!(!service.desktop.is_tracing()); +} diff --git a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs index 38f6d41d..26e7c2a6 100644 --- a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs +++ b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs @@ -4,6 +4,12 @@ use crate::tinybus_module::DesktopService; use serde_json::json; use tinycomputer_bus::DesktopResponse; +use tinycomputer_bus::config::ComputerConfig; + +fn browser_defaults(config: serde_json::Value) -> crate::tinybus_module::config::BrowserDefaults { + let config: ComputerConfig = serde_json::from_value(config).expect("typed computer config"); + crate::tinybus_module::config::BrowserDefaults::from_config(config.browser.as_ref()) +} #[test] fn a_malformed_configuration_is_rejected_rather_than_defaulted() { @@ -155,20 +161,16 @@ fn the_browser_configuration_is_read_or_refused() { use crate::tinybus_module::config::BrowserDefaults; use tinycomputer_browser::{Perception, Settle}; - assert_eq!( - BrowserDefaults::from_config(&json!({})).unwrap(), - BrowserDefaults::default() - ); + assert_eq!(browser_defaults(json!({})), BrowserDefaults::default()); assert!(DesktopService::from_config(&json!({"browser": {}})).is_ok()); - let defaults = BrowserDefaults::from_config(&json!({"browser": { + let defaults = browser_defaults(json!({"browser": { "executable": "/usr/bin/chromium", "user_agent": "Mozilla/5.0", "args": ["--disable-blink-features=AutomationControlled"], "perception": "tree", "settle": "steady", "prelaunch": false - }})) - .unwrap(); + }})); assert_eq!(defaults.executable.as_deref(), Some("/usr/bin/chromium")); assert_eq!(defaults.user_agent.as_deref(), Some("Mozilla/5.0")); assert_eq!(defaults.args.len(), 1); @@ -180,11 +182,10 @@ fn the_browser_configuration_is_read_or_refused() { let unset = BrowserDefaults::default(); assert_eq!(unset.settle, Settle::Prompt); assert!(unset.prelaunch); - let asked = BrowserDefaults::from_config(&json!({"browser": { + let asked = browser_defaults(json!({"browser": { "settle": "prompt", "prelaunch": true - }})) - .unwrap(); + }})); assert_eq!(asked, unset); for invalid in [ json!({"browser": "chrome"}), @@ -197,20 +198,21 @@ fn the_browser_configuration_is_read_or_refused() { json!({"browser": {"prelaunch": "yes"}}), json!({"browser": {"useragent": "typo"}}), ] { - assert!(BrowserDefaults::from_config(&invalid).is_err(), "{invalid}"); + assert!( + serde_json::from_value::(invalid.clone()).is_err(), + "{invalid}" + ); assert!(DesktopService::from_config(&invalid).is_err(), "{invalid}"); } } #[test] fn browser_defaults_fill_only_what_the_caller_left_unset() { - use crate::tinybus_module::config::BrowserDefaults; use tinycomputer_browser::SessionOptions; - let defaults = BrowserDefaults::from_config(&json!({"browser": { + let defaults = browser_defaults(json!({"browser": { "executable": "/opt/chromium", "user_agent": "UA", "args": ["--a"] - }})) - .unwrap(); + }})); let launched = defaults.apply(SessionOptions::default()); assert_eq!(launched.executable.as_deref(), Some("/opt/chromium")); assert_eq!(launched.user_agent.as_deref(), Some("UA")); diff --git a/crates/tinycomputer/tests/public_api_tests.rs b/crates/tinycomputer/tests/public_api_tests.rs index e13461a8..04065f62 100644 --- a/crates/tinycomputer/tests/public_api_tests.rs +++ b/crates/tinycomputer/tests/public_api_tests.rs @@ -34,7 +34,7 @@ fn the_crate_re_exports_the_contract_rather_than_redefining_it() { #[test] fn the_shipped_contract_binds_to_itself() { assert!(is_compatible(CONTRACT_VERSION)); - assert_eq!(CONTRACT_VERSION, (2, 20)); + assert_eq!(CONTRACT_VERSION, (2, 21)); } #[test] diff --git a/docs/crates/tinycomputer-bus/versioning.md b/docs/crates/tinycomputer-bus/versioning.md index b8229e67..bdec1145 100644 --- a/docs/crates/tinycomputer-bus/versioning.md +++ b/docs/crates/tinycomputer-bus/versioning.md @@ -9,7 +9,7 @@ code, so this page exists mostly to keep them apart. ## `CONTRACT_VERSION`: does this host understand this module ```rust,ignore -pub const CONTRACT_VERSION: (u32, u32) = (2, 20); +pub const CONTRACT_VERSION: (u32, u32) = (2, 21); ``` This describes the *vocabulary*: the member set and the payload shapes, @@ -25,6 +25,11 @@ bumping it is fixed: was removed or renamed. A host built against the old major version can no longer assume it is talking to something it understands. +Contract 2.21 publishes the typed `ComputerConfig`, shared strict site-name +normalization, and versioned consistency-checked extraction for desktop and +Agent responses. The Agent reply gains its own `version` field; this is separate +from `ENVELOPE_VERSION`, which tracks the desktop response envelope. + ### The bind rule ```rust,ignore @@ -45,15 +50,15 @@ Two conditions, both necessary: relies on changed shape or disappeared, so a mismatched major is refused outright, in either direction. 2. **The module must be at least as new, minor-wise, as the host.** A host - built against `(2, 20)` expects at least the members and fields that - existed at `(2, 20)`. A module reporting `(2, 11)` might be missing one of - them, so it is rejected. A module reporting `(2, 20)` has everything the + built against `(2, 21)` expects at least the members and fields that + existed at `(2, 21)`. A module reporting `(2, 11)` might be missing one of + them, so it is rejected. A module reporting `(2, 21)` has everything the host expects, plus something newer the host simply does not use yet, so it is accepted. ```rust,ignore -assert!(is_compatible(CONTRACT_VERSION)); // (2, 20), the exact version this crate ships -assert!(is_compatible((2, 20))); // a newer, still-compatible module +assert!(is_compatible(CONTRACT_VERSION)); // (2, 21), the exact version this crate ships +assert!(is_compatible((2, 21))); // a newer, still-compatible module assert!(!is_compatible((2, 11))); // an older module, missing something assert!(!is_compatible((1, 10))); // a different major, all bets off ``` @@ -87,6 +92,11 @@ one as implying anything about the other; check the crate's changelog or the git history of `crates/tinycomputer-bus/src/envelope/` and `crates/tinycomputer-bus/src/version/` for what actually happened. +The Agent interface has a separate `AGENT_ENVELOPE_VERSION`, carried by every +`AgentResponse`. `DesktopResponse::into_data` and `AgentResponse::into_data` +check their respective version and require exactly one consistent outcome +(data on success, structured error on failure) before returning a value. + ## No members ever silently disappear The crate's own `lib.rs` doc comment is explicit about one more thing worth diff --git a/docs/technical/specs/desktop-module-contract.md b/docs/technical/specs/desktop-module-contract.md index 41774150..ecee43a7 100644 --- a/docs/technical/specs/desktop-module-contract.md +++ b/docs/technical/specs/desktop-module-contract.md @@ -150,10 +150,32 @@ them. They apply to a browser the module launches, not one it attaches to. those may be flattened into the message. - The envelope's wire form is byte-identical to the `agent-desktop` CLI's stdout envelope, version `2.5`, so a host needs one parser rather than two. +- `DesktopResponse::into_data` checks `ENVELOPE_VERSION`, requires exactly one + consistent outcome (`ok` plus data, or not-ok plus error), and then decodes + the result. An inconsistent envelope never returns its data. +- Agent members use `AgentResponse`, whose `version` is + `AGENT_ENVELOPE_VERSION`. `AgentResponse::into_data` applies the same version + and outcome checks before returning the typed value. Every module producer + uses the versioned constructor, including errors. + +### Module configuration and shared site names (2.21) + +- `tinycomputer_bus::config::ComputerConfig` is the typed serde contract for + host-supplied `jev`, `planner`, `browser`, and `trace_path` settings. Planner + wire types live here too and are re-exported by the engine; credentials are + redacted by `Debug` and the module receives them only through confidential + module configuration. +- The module retains its existing loader-only extensions `session_id`, + `trace_strict`, `headed`, and `cursor`. They remain outside the shared host + config; the module adapter forwards them to their existing owners. +- `tinycomputer_bus::sites::{site_of, site_named}` own the shared strict site + normalization used for site-memory keys and file names. URL parsing keeps + the WHATWG parser's IDNA and IP behavior; names reject adjacent dots and + names longer than 253 bytes. ### The contract crate -- `tinycomputer-bus` depends on `thiserror`, `serde`, and `serde_json`. It may +- `tinycomputer-bus` depends on `thiserror`, `serde`, `serde_json`, and `url`. It may not depend on a transport, an async runtime, an HTTP client, a native library, or the engine. CI asserts the resolved dependency tree. - It mirrors the engine's enumerations by wire form rather than importing them, diff --git a/vendor/tinybus b/vendor/tinybus index eb5cf727..482b1c81 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit eb5cf727071a6eb12f9efa3db8ec59e54039f336 +Subproject commit 482b1c81d3c3a169e6b9d3d5a84fbcbc3e0fc519 From 5e442e89df2d2a486228098fe41e1f78c2968caa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 20 Oct 2026 16:19:35 +0530 Subject: [PATCH 03/32] fix(tinycomputer): import atomic test type portably Co-authored-by: Medulla --- .../src/tinybus_module/dispatch/accessibility_tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/tinycomputer/src/tinybus_module/dispatch/accessibility_tests.rs b/crates/tinycomputer/src/tinybus_module/dispatch/accessibility_tests.rs index 548a0cf0..ba432f62 100644 --- a/crates/tinycomputer/src/tinybus_module/dispatch/accessibility_tests.rs +++ b/crates/tinycomputer/src/tinybus_module/dispatch/accessibility_tests.rs @@ -7,6 +7,9 @@ use super::*; use std::sync::{Arc, atomic::Ordering}; use tinycomputer_bus::accessibility::PermissionState; +#[cfg(not(target_os = "macos"))] +use std::sync::atomic::AtomicBool; + use super::fixture::Fixture; #[test] From 05230abddeb87d3e0bf09da43e4434e6a4ca00e2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 07:29:55 +0530 Subject: [PATCH 04/32] feat(tinycomputer): add typed host client Co-authored-by: Medulla --- Cargo.lock | 78 +- Cargo.toml | 7 +- README.md | 1 + crates/tinycomputer-client/Cargo.toml | 25 + crates/tinycomputer-client/README.md | 8 + crates/tinycomputer-client/src/client.rs | 203 ++++ crates/tinycomputer-client/src/error.rs | 35 + crates/tinycomputer-client/src/lib.rs | 34 + .../tinycomputer-client/tests/client_tests.rs | 1060 +++++++++++++++++ crates/tinycomputer-examples/Cargo.toml | 2 + .../src/bin/verify_module.rs | 120 +- .../dispatch/accessibility_tests.rs | 16 +- deny.toml | 8 +- docs/README.md | 1 + docs/crates/tinycomputer-client/README.md | 40 + vendor/agent-browser | 2 +- vendor/tinybus | 2 +- 17 files changed, 1599 insertions(+), 43 deletions(-) create mode 100644 crates/tinycomputer-client/Cargo.toml create mode 100644 crates/tinycomputer-client/README.md create mode 100644 crates/tinycomputer-client/src/client.rs create mode 100644 crates/tinycomputer-client/src/error.rs create mode 100644 crates/tinycomputer-client/src/lib.rs create mode 100644 crates/tinycomputer-client/tests/client_tests.rs create mode 100644 docs/crates/tinycomputer-client/README.md diff --git a/Cargo.lock b/Cargo.lock index b2684eea..455e7dc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,7 +54,7 @@ dependencies = [ [[package]] name = "agent-browser" -version = "0.38.1" +version = "0.38.3" dependencies = [ "aes-gcm", "async-trait", @@ -386,7 +386,7 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a68fa787550392a9d58f44c21a3022cfb3ea3e2458b7f85d3b399d0ceeccf409" dependencies = [ - "nix", + "nix 0.27.1", "winapi", ] @@ -1003,7 +1003,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.9", + "webpki-roots", ] [[package]] @@ -1465,6 +1465,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -2102,7 +2114,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.9", + "webpki-roots", ] [[package]] @@ -2184,9 +2196,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", @@ -2209,9 +2221,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -2580,7 +2592,7 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.3.5" +version = "0.3.11" dependencies = [ "async-trait", "flate2", @@ -2603,7 +2615,7 @@ dependencies = [ [[package]] name = "tinybus-macros" -version = "0.3.5" +version = "0.3.11" dependencies = [ "proc-macro2", "quote", @@ -2612,7 +2624,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.3.5" +version = "0.3.11" dependencies = [ "async-trait", "serde", @@ -2684,6 +2696,20 @@ dependencies = [ "url", ] +[[package]] +name = "tinycomputer-client" +version = "0.16.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "thiserror 2.0.20", + "tinybus", + "tinybus-module", + "tinycomputer-bus", + "tokio", +] + [[package]] name = "tinycomputer-core" version = "0.16.0" @@ -2751,6 +2777,7 @@ dependencies = [ "tinycomputer", "tinycomputer-browser", "tinycomputer-bus", + "tinycomputer-client", "tinycomputer-core", "tinycomputer-cursor", "tinycomputer-engine", @@ -2885,9 +2912,8 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +version = "0.24.1" +source = "git+https://github.com/snapview/tokio-tungstenite?rev=4b9c73b27301d16f54a3ec9e886fadb49d2f05c2#4b9c73b27301d16f54a3ec9e886fadb49d2f05c2" dependencies = [ "futures-util", "log", @@ -2896,7 +2922,7 @@ dependencies = [ "tokio", "tokio-rustls", "tungstenite", - "webpki-roots 0.26.11", + "webpki-roots", ] [[package]] @@ -3128,7 +3154,7 @@ dependencies = [ "rustls-pki-types", "ureq-proto", "utf8-zero", - "webpki-roots 1.0.9", + "webpki-roots", ] [[package]] @@ -3311,18 +3337,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.11" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.9", -] - -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] @@ -3349,7 +3366,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -3899,14 +3916,15 @@ dependencies = [ [[package]] name = "zip" -version = "8.6.0" +version = "9.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ "crc32fast", "flate2", "indexmap", "memchr", + "nix 0.31.3", "typed-path", "zopfli", ] diff --git a/Cargo.toml b/Cargo.toml index b4bb8175..ca3c2557 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,9 @@ repository = "https://github.com/tinyhumansai/tinycomputer" # release, and a pinned requirement here would stop resolving the moment it did. # Nothing in this workspace is published, so the path is the whole address. tinycomputer-bus = { path = "crates/tinycomputer-bus" } +# Host-side typed calls over TinyBus. Kept apart from the module implementation +# so a host can depend on the wire contract and client without linking desktop engines. +tinycomputer-client = { path = "crates/tinycomputer-client" } # Shared, engine-free domain logic: keys, safety, records, and facts. Taken by # the surface adapters and the engine. tinycomputer-core = { path = "crates/tinycomputer-core" } @@ -73,13 +76,13 @@ agent-desktop-linux = { path = "vendor/agent-desktop/crates/linux" } # TinyBus defines the message types, interface macro, and frozen module ABI # used by the generated integration. Socket and CLI features are unnecessary # here. -tinybus = { version = ">=0.3.5, <0.4", default-features = false, features = [ +tinybus = { version = ">=0.3.11, <0.4", default-features = false, features = [ "macros", "modules", ] } # The module-side SDK owns the isolated runtime and exports the ABI entrypoints # required by TinyBus's dynamic loader. -tinybus-module = { version = ">=0.3.5, <0.4" } +tinybus-module = { version = ">=0.3.11, <0.4" } # Derive macros for the crate-wide error types, e.g. # `crates/tinycomputer-desktop/src/error/`. # Every dependency entry should carry a comment like this one saying why it is diff --git a/README.md b/README.md index 409735a8..7e8dce8f 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ scripts/docker-lab -- crates/tinycomputer-examples/fixtures/run task_fixture | `tinycomputer` | the loadable module | | `tinycomputer-engine` | tasks, the planner, rescues, the Jev runtime, the flow runtime | | `tinycomputer-bus` | the wire contract | +| `tinycomputer-client` | typed, configuration-safe host calls | | `tinycomputer-core` | the shared screen model, safety rules, facts | | `tinycomputer-desktop` | desktop apps, through agent-desktop | | `tinycomputer-browser` | web pages, through agent-browser | diff --git a/crates/tinycomputer-client/Cargo.toml b/crates/tinycomputer-client/Cargo.toml new file mode 100644 index 00000000..f7ec9ba9 --- /dev/null +++ b/crates/tinycomputer-client/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "tinycomputer-client" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Typed TinyBus host client for TinyComputer" +publish = false + +[dependencies] +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tinybus.workspace = true +tinycomputer-bus.workspace = true + +[dev-dependencies] +# The in-memory service fixtures implement TinyBus' asynchronous interface. +async-trait = "0.1" +tinybus-module = { workspace = true, features = ["static-link"] } +tokio = { workspace = true, features = ["test-util"] } + +[lints] +workspace = true diff --git a/crates/tinycomputer-client/README.md b/crates/tinycomputer-client/README.md new file mode 100644 index 00000000..cf5e760f --- /dev/null +++ b/crates/tinycomputer-client/README.md @@ -0,0 +1,8 @@ +# tinycomputer-client + +The host-side TinyBus client for the TinyComputer contract. It depends on the +wire contract and TinyBus host SDK; it does not link the module, desktop or +browser implementation. + +See [`docs/crates/tinycomputer-client/`](../../docs/crates/tinycomputer-client/README.md) +for the call model, family boundaries and confidentiality rules. diff --git a/crates/tinycomputer-client/src/client.rs b/crates/tinycomputer-client/src/client.rs new file mode 100644 index 00000000..0bbdeca1 --- /dev/null +++ b/crates/tinycomputer-client/src/client.rs @@ -0,0 +1,203 @@ +//! Configuration-safe calls to `TinyComputer`'s contract families. + +use std::{sync::Arc, time::Duration}; + +use serde::{Serialize, de::DeserializeOwned}; +use tinybus::{ + Proxy, + module::{ReconfigurableModule, ReconfigureGuard}, +}; +use tinycomputer_bus::{ + DesktopResponse, + agent::{AgentResponse, Capabilities, names as agent_names}, + catalogue::{self, Family, Member}, + config::ComputerConfig, + names, +}; + +use crate::ClientError; + +/// Deadline for a bounded Jev goal plus transport and serialization overhead. +pub const RUN_GOAL_TIMEOUT: Duration = Duration::from_secs(330); + +/// Deadline for task-member long polls, which the module caps at sixty seconds. +pub const TASK_CALL_TIMEOUT: Duration = Duration::from_secs(75); + +/// A typed caller for the shared desktop, flow, browser, and task interface. +/// +/// The module coordinator serializes configuration transitions. Each call +/// keeps its exclusive guard until the bus reply has been decoded, so another +/// caller cannot replace `ComputerConfig` during an in-flight request. +#[derive(Clone)] +pub struct ComputerClient { + module: Arc, +} + +impl std::fmt::Debug for ComputerClient { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ComputerClient") + .finish_non_exhaustive() + } +} + +impl ComputerClient { + /// Wrap a `TinyBus` configuration coordinator for one registered module. + #[must_use] + pub fn new(module: ReconfigurableModule) -> Self { + Self { + module: Arc::new(module), + } + } + + /// Record the configuration already applied by the module loader. + /// + /// This avoids a redundant reinitialization on the first call when the + /// host has just loaded the module with this exact configuration. + /// + /// # Errors + /// Returns an error if the configuration cannot be serialized. + pub fn record_loaded_with(&self, config: &ComputerConfig) -> Result<(), serde_json::Error> { + self.module.record_loaded_with(config) + } + + /// Call a catalogued desktop, flow, browser, or accessibility member. + /// + /// The request and response remain typed by the caller's `TinyComputer` bus + /// types. Confidential delivery is selected from the bus catalogue, and + /// the successful result is validated by [`DesktopResponse::into_data`]. + /// + /// # Errors + /// Returns an error for an unknown or task-family member, module setup, + /// transport, confidentiality, remote command, or response decoding. + pub async fn call_desktop( + &self, + config: &ComputerConfig, + member: &str, + request: Request, + ) -> Result + where + Request: Serialize + Send, + Response: DeserializeOwned, + { + let entry = catalogued_member(member)?; + match entry.family { + Family::Task => { + return Err(ClientError::WrongFamily { + member: member.to_owned(), + actual: entry.family, + expected: "desktop, flow, or browser", + }); + } + Family::Flow | Family::Desktop | Family::Browser => {} + } + + let timeout = (member == names::methods::RUN_GOAL).then_some(RUN_GOAL_TIMEOUT); + let response = self + .call_wire::<_, DesktopResponse>(config, entry, request, timeout) + .await?; + response.into_data().map_err(ClientError::from) + } + + /// Call a task member and decode its Agent envelope. + /// + /// `Describe` is deliberately separate: the contract serves its raw + /// [`Capabilities`] value rather than an [`AgentResponse`]. + /// + /// # Errors + /// Returns an error for an unknown, non-task, or `Describe` member, module + /// setup, transport, confidentiality, remote task, or response decoding. + pub async fn call_task( + &self, + config: &ComputerConfig, + member: &str, + request: Request, + ) -> Result + where + Request: Serialize + Send, + Response: DeserializeOwned, + { + let entry = catalogued_member(member)?; + if entry.family != Family::Task || member == agent_names::methods::DESCRIBE { + return Err(ClientError::WrongFamily { + member: member.to_owned(), + actual: entry.family, + expected: "task member other than Describe", + }); + } + + let response = self + .call_wire::<_, AgentResponse>( + config, + entry, + request, + Some(TASK_CALL_TIMEOUT), + ) + .await?; + response.into_data().map_err(ClientError::from) + } + + /// Read capabilities through the catalogued `Describe` member. + /// + /// # Errors + /// Returns an error if the module is unavailable, the bus call fails, or + /// the reply does not decode as [`Capabilities`]. + pub async fn describe(&self, config: &ComputerConfig) -> Result { + let entry = catalogued_member(agent_names::methods::DESCRIBE)?; + let (_guard, proxy) = self.proxy(config, None).await?; + self.call_entry::<(), Capabilities>(&proxy, entry, ()).await + } + + async fn call_wire( + &self, + config: &ComputerConfig, + entry: &'static Member, + request: Request, + timeout: Option, + ) -> Result + where + Request: Serialize + Send, + Reply: DeserializeOwned, + { + let (_guard, proxy) = self.proxy(config, timeout).await?; + self.call_entry(&proxy, entry, (request,)).await + } + + async fn proxy( + &self, + config: &ComputerConfig, + timeout: Option, + ) -> Result<(ReconfigureGuard<'_>, Proxy), ClientError> { + let mut guard = self.module.exclusive().await; + let proxy = guard.proxy(config).await?; + let proxy = timeout.map_or(proxy.clone(), |duration| proxy.with_timeout(duration)); + Ok((guard, proxy)) + } + + async fn call_entry( + &self, + proxy: &Proxy, + entry: &'static Member, + request: Request, + ) -> Result + where + Request: Serialize + Send, + Reply: DeserializeOwned, + { + if entry.confidential { + proxy + .call_confidential(entry.name, request) + .await + .map_err(ClientError::from) + } else { + proxy + .call(entry.name, request) + .await + .map_err(ClientError::from) + } + } +} + +fn catalogued_member(member: &str) -> Result<&'static Member, ClientError> { + catalogue::member(member).ok_or_else(|| ClientError::UnknownMember(member.to_owned())) +} diff --git a/crates/tinycomputer-client/src/error.rs b/crates/tinycomputer-client/src/error.rs new file mode 100644 index 00000000..5012d3f2 --- /dev/null +++ b/crates/tinycomputer-client/src/error.rs @@ -0,0 +1,35 @@ +//! Errors returned by typed `TinyComputer` host calls. + +use thiserror::Error; +use tinybus::{Error as TinyBusError, module::ModuleCallError}; +use tinycomputer_bus::{DesktopResponseError, agent::AgentResponseError, catalogue::Family}; + +/// A rejected configuration, transport call, or typed response. +#[derive(Debug, Error)] +pub enum ClientError { + /// `TinyBus` could not prepare or reconfigure the module. + #[error("TinyComputer module is unavailable: {0}")] + Module(#[from] ModuleCallError), + /// `TinyBus` rejected or could not deliver a member call. + #[error("TinyComputer bus call failed: {0}")] + Bus(#[from] TinyBusError), + /// A desktop, browser, or flow member returned a malformed or failed envelope. + #[error("TinyComputer desktop response failed: {0}")] + DesktopResponse(#[from] DesktopResponseError), + /// A task member returned a malformed or failed envelope. + #[error("TinyComputer task response failed: {0}")] + AgentResponse(#[from] AgentResponseError), + /// The requested name is not in this `TinyComputer` contract catalogue. + #[error("TinyComputer does not catalogue member `{0}`")] + UnknownMember(String), + /// The member belongs to a different response family than this call method. + #[error("TinyComputer member `{member}` belongs to {actual:?}, expected {expected}")] + WrongFamily { + /// The member that was requested. + member: String, + /// Its catalogue family. + actual: Family, + /// The family accepted by the selected client method. + expected: &'static str, + }, +} diff --git a/crates/tinycomputer-client/src/lib.rs b/crates/tinycomputer-client/src/lib.rs new file mode 100644 index 00000000..cd08d25e --- /dev/null +++ b/crates/tinycomputer-client/src/lib.rs @@ -0,0 +1,34 @@ +//! Typed host calls to the `TinyComputer` `TinyBus` module. +//! +//! [`ComputerClient`] owns no desktop or browser implementation. It takes the +//! contract's request and response types, coordinates each call with the +//! module's current [`ComputerConfig`], and applies the contract catalogue's +//! confidential-delivery rules before sending a frame. +//! +//! # Example +//! +//! ```no_run +//! use tinybus::module::{ModuleAccess, ReconfigurableModule}; +//! use tinycomputer_bus::{agentic::RunGoalRequest, config::ComputerConfig, names}; +//! use tinycomputer_client::ComputerClient; +//! +//! # async fn example(access: impl ModuleAccess + 'static) -> Result<(), Box> { +//! let module = ReconfigurableModule::new(access, "tinycomputer"); +//! let client = ComputerClient::new(module); +//! let config = ComputerConfig::default(); +//! let _result = client +//! .call_desktop::<_, serde_json::Value>(&config, names::methods::RUN_GOAL, RunGoalRequest::default()) +//! .await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! The client deliberately does not hold browser policy, Chrome hints, task +//! constraints, or the `TinyComputer` engine. Hosts decide those values and pass +//! the resulting contract request types to the client. + +mod client; +mod error; + +pub use client::{ComputerClient, RUN_GOAL_TIMEOUT, TASK_CALL_TIMEOUT}; +pub use error::ClientError; diff --git a/crates/tinycomputer-client/tests/client_tests.rs b/crates/tinycomputer-client/tests/client_tests.rs new file mode 100644 index 00000000..48b6f28c --- /dev/null +++ b/crates/tinycomputer-client/tests/client_tests.rs @@ -0,0 +1,1060 @@ +//! In-memory `TinyBus` tests for the public `TinyComputer` client. + +use std::{ + future::Future, + pin::Pin, + sync::{Arc, Mutex as StdMutex, OnceLock}, + time::Duration, +}; + +use async_trait::async_trait; +use serde_json::{Value, json}; +use tinybus::module::ReconfigurableModule; +use tinybus::{ + Connection, Error as TinyBusError, Interface, InterfaceName, MemberName, ObjectPath, Proxy, + broker::Broker, + module::{ + AccessFuture, FailureReporter, FailureSink, ModuleAccess, ModuleHost, ProxyFailure, + ReconfigureFailure, ReleaseRecord, + }, + transport::memory::MemoryBus, +}; +use tinycomputer_bus::JevConfig; +use tinycomputer_bus::{ + DesktopResponse, + accessibility::{FocusQuery, FocusedTextContext}, + agent::{AgentResponse, Capabilities, names as task_names}, + browser::{ + ClaimProfileRequest, ClaimProfileResponse, NavigateRequest, OutputChunk, OutputId, + PageState, ProfileClaim, ReadOutputRequest, SessionId, SessionRequest, + }, + catalogue::{self, Family}, + config::ComputerConfig, + names, +}; +use tinycomputer_client::{ClientError, ComputerClient, RUN_GOAL_TIMEOUT, TASK_CALL_TIMEOUT}; + +const TEST_MODULE_ID: &str = env!("CARGO_PKG_NAME"); + +fn static_contract_value(result: Result) -> T { + match result { + Ok(value) => value, + Err(error) => { + eprintln!("client test failed: {error:?}"); + std::process::exit(1); + } + } +} + +static TEST_RECORD: ReleaseRecord = ReleaseRecord { + id: TEST_MODULE_ID, + bus_name: names::INTERFACE, + object_path: names::OBJECT_PATH, + version: "0.16.0", + release_url: "https://example.invalid/test", + assets: &[], +}; + +#[derive(Default)] +struct SilentSink; + +impl FailureSink for SilentSink { + fn capture(&self, _report: &tinybus::module::failure::FailureReport<'_>) {} +} + +struct TestAccess { + bus: MemoryBus, + host: Option>, + reporter: FailureReporter, +} + +impl TestAccess { + fn new(bus: MemoryBus, host: Option>) -> Self { + Self { + bus, + host, + reporter: FailureReporter::new(Arc::new(SilentSink)), + } + } +} + +impl ModuleAccess for TestAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == TEST_MODULE_ID).then_some(&TEST_RECORD) + } + + fn enabled(&self) -> bool { + true + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>(&'a self, _id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async move { + if let Some(host) = &self.host { + host.ensure_ready(names::INTERFACE) + .await + .map_err(|error| error.to_string())?; + } + Ok(()) + }) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + let bus = self.bus.clone(); + let bus_name = bus_name.to_owned(); + let object_path = object_path.to_owned(); + Box::pin(async move { + let transport = bus.connect().await.map_err(|_| ProxyFailure::Transport)?; + let connection = Connection::connect(transport) + .await + .map_err(|_| ProxyFailure::Transport)?; + connection + .proxy(&bus_name, &object_path, &bus_name) + .map_err(|_| ProxyFailure::Contract) + }) + } + + fn reinitialize<'a>( + &'a self, + id: &'a str, + config: Value, + ) -> AccessFuture<'a, Result<(), ReconfigureFailure>> { + Box::pin(async move { + let Some(host) = &self.host else { + return Ok(()); + }; + host.reinitialize(id, config) + .await + .map(|_| ()) + .map_err(|_| ReconfigureFailure::Failed) + }) + } +} + +fn client_for(bus: &MemoryBus, host: Option>) -> ComputerClient { + ComputerClient::new(ReconfigurableModule::new( + TestAccess::new(bus.clone(), host), + TEST_MODULE_ID, + )) +} + +fn test_capabilities(config: &ComputerConfig) -> Capabilities { + Capabilities { + contract_version: tinycomputer_bus::CONTRACT_VERSION, + surfaces: Vec::new(), + jev_configured: config.jev.is_some(), + planner_configured: config.planner.is_some(), + rescue_configured: false, + output_configured: false, + decision_model: None, + planner_model: None, + rescue_model: None, + output_model: None, + step_kinds: Vec::new(), + guide: String::new(), + members: Vec::new(), + examples: Vec::new(), + catalogue: catalogue::summaries(), + } +} + +const FLOW_MEMBERS: &[&str] = &[ + names::methods::RESOLVE_INTENT, + names::methods::RUN_GOAL, + names::methods::RUN_FLOW, + names::methods::VALIDATE_FLOW, + names::methods::FLOW_GUIDE, +]; + +fn expected_family(member: &str) -> Family { + if task_names::METHODS.contains(&member) { + Family::Task + } else if tinycomputer_bus::browser::names::METHODS.contains(&member) { + Family::Browser + } else if FLOW_MEMBERS.contains(&member) { + Family::Flow + } else { + Family::Desktop + } +} + +fn catalogue_request(member: &str) -> Result { + Ok(match member { + tinycomputer_bus::browser::names::methods::NAVIGATE => { + serde_json::to_value(SessionRequest::new( + SessionId::new("catalogue-session"), + NavigateRequest::new("https://example.com"), + ))? + } + tinycomputer_bus::browser::names::methods::CLAIM_PROFILE => { + serde_json::to_value(ClaimProfileRequest::new("/profile/catalogue", 0))? + } + tinycomputer_bus::browser::names::methods::READ_OUTPUT => { + serde_json::to_value(ReadOutputRequest::new(OutputId::new("catalogue-output")))? + } + _ => json!({"member": member}), + }) +} + +fn assert_catalogue_reply(member: &str, value: &Value) { + match member { + tinycomputer_bus::browser::names::methods::NAVIGATE => { + assert_eq!(value["url"], "https://example.com"); + } + tinycomputer_bus::browser::names::methods::CLAIM_PROFILE => { + assert_eq!(value["outcome"], "claimed"); + assert_eq!(value["path"], "/profile/fixture"); + } + tinycomputer_bus::browser::names::methods::READ_OUTPUT => { + assert_eq!(value["id"], "catalogue-output"); + assert_eq!(value["offset"], 0); + assert_eq!(value["data"], "aGVsbG8="); + assert_eq!(value["eof"], true); + } + _ => assert_eq!(*value, json!(member), "member {member} reply"), + } +} + +fn response(member: &str, request: &Value, config: &ComputerConfig) -> tinybus::Result { + let malformed_version = request + .get("malformed_version") + .and_then(Value::as_bool) + .unwrap_or(false); + let wrong_data = request + .get("wrong_data") + .and_then(Value::as_bool) + .unwrap_or(false); + + if member == task_names::methods::DESCRIBE { + return serde_json::to_value(test_capabilities(config)) + .map_err(|error| TinyBusError::failed(error.to_string())); + } + + let family = expected_family(member); + if family == Family::Task { + if malformed_version { + return Ok(json!({ + "version": "unsupported", + "ok": true, + "data": "unexpected", + "error": null + })); + } + let data = if wrong_data { json!(17) } else { json!(member) }; + return serde_json::to_value(AgentResponse::ok(data)) + .map_err(|error| TinyBusError::failed(error.to_string())); + } + + if malformed_version { + return Ok(json!({ + "version": "unsupported", + "ok": true, + "command": member, + "data": "unexpected", + "error": null + })); + } + + let data = if wrong_data { + json!(17) + } else { + match member { + "AccessibilityFocus" => serde_json::to_value(FocusedTextContext { + app_name: Some("fixture".to_owned()), + role: Some("text".to_owned()), + text: "typed response".to_owned(), + selected_text: None, + raw_error: None, + bounds: None, + }) + .map_err(|error| TinyBusError::failed(error.to_string()))?, + "BrowserClaimProfile" => serde_json::to_value(ClaimProfileResponse { + outcome: ProfileClaim::Claimed, + path: Some("/profile/fixture".to_owned()), + }) + .map_err(|error| TinyBusError::failed(error.to_string()))?, + "BrowserNavigate" => serde_json::to_value(PageState::new("https://example.com")) + .map_err(|error| TinyBusError::failed(error.to_string()))?, + "BrowserReadOutput" => { + let read: ReadOutputRequest = serde_json::from_value(request.clone()) + .map_err(|error| TinyBusError::failed(error.to_string()))?; + serde_json::to_value(OutputChunk { + id: read.output, + offset: read.offset, + data: "aGVsbG8=".to_owned(), + eof: true, + }) + .map_err(|error| TinyBusError::failed(error.to_string()))? + } + _ => json!(member), + } + }; + serde_json::to_value(DesktopResponse::ok(member, data)) + .map_err(|error| TinyBusError::failed(error.to_string())) +} + +struct EchoService { + config: Arc>, + calls: Arc, + seen: Arc>>, +} + +#[async_trait] +impl Interface for EchoService { + fn name(&self) -> InterfaceName { + static_contract_value(InterfaceName::new(names::INTERFACE)) + } + + fn members(&self) -> Vec { + names::METHODS + .iter() + .map(|member| static_contract_value(MemberName::new(*member))) + .collect() + } + + fn requires_confidential(&self, member: &MemberName) -> bool { + handler_requires_confidential(member.as_str()) + } + + async fn call(&self, member: &MemberName, args: Value) -> tinybus::Result { + self.invoke(member, args).await + } + + async fn call_with_context( + &self, + member: &MemberName, + args: Value, + context: &tinybus::service::CallContext, + ) -> tinybus::Result { + self.seen + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(ObservedCall { + member: member.as_str().to_owned(), + confidential: context.is_confidential(), + args: args.clone(), + }); + self.invoke(member, args).await + } +} + +impl EchoService { + async fn invoke(&self, member: &MemberName, args: Value) -> tinybus::Result { + self.calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel); + let request = args + .as_array() + .and_then(|items| items.first()) + .cloned() + .unwrap_or(Value::Null); + let config = self.config.read().await.clone(); + response(member.as_str(), &request, &config) + } +} + +#[derive(Debug, Clone)] +struct ObservedCall { + member: String, + confidential: bool, + args: Value, +} + +fn handler_requires_confidential(member: &str) -> bool { + task_names::CONFIDENTIAL.contains(&member) + || [ + names::methods::RESOLVE_INTENT, + names::methods::RUN_GOAL, + names::methods::RUN_FLOW, + names::accessibility::ACCESSIBILITY_FOCUS, + names::accessibility::ACCESSIBILITY_VALIDATE_TARGET, + names::accessibility::ACCESSIBILITY_PASTE, + ] + .contains(&member) +} + +struct UntrustedFixture { + bus: MemoryBus, + _broker_task: tokio::task::JoinHandle>, + _service_connection: Connection, + calls: Arc, + seen: Arc>>, +} + +async fn untrusted_fixture() -> Result> { + let bus = MemoryBus::new(); + let broker_task = Broker::new().spawn(bus.clone()); + let transport = bus.connect().await?; + let service_connection = Connection::connect(transport).await?; + service_connection.request_name(names::INTERFACE).await?; + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = Arc::new(StdMutex::new(Vec::new())); + service_connection + .serve_at( + ObjectPath::new(names::OBJECT_PATH)?, + EchoService { + config: Arc::new(tokio::sync::RwLock::new(ComputerConfig::default())), + calls: Arc::clone(&calls), + seen: Arc::clone(&seen), + }, + ) + .await?; + Ok(UntrustedFixture { + bus, + _broker_task: broker_task, + _service_connection: service_connection, + calls, + seen, + }) +} + +#[tokio::test] +async fn every_catalogued_operation_is_routed_by_family_and_confidentiality() +-> Result<(), Box> { + let fixture = untrusted_fixture().await?; + let client = client_for(&fixture.bus, None); + let config = ComputerConfig::default(); + client.record_loaded_with(&config)?; + let mut called = 0; + let mut refused_confidential = 0; + + for entry in catalogue::MEMBERS { + assert_eq!( + entry.family, + expected_family(entry.name), + "family for {}", + entry.name + ); + assert_eq!( + entry.confidential, + handler_requires_confidential(entry.name), + "confidential flag for {}", + entry.name + ); + let request = catalogue_request(entry.name)?; + let result = if entry.name == task_names::methods::DESCRIBE { + let described = client.describe(&config).await?; + assert_eq!( + described.contract_version, + tinycomputer_bus::CONTRACT_VERSION + ); + called += 1; + continue; + } else { + match entry.family { + Family::Task => { + client + .call_task::<_, Value>(&config, entry.name, request) + .await + } + Family::Flow | Family::Desktop | Family::Browser => { + client + .call_desktop::<_, Value>(&config, entry.name, request) + .await + } + } + }; + + if entry.confidential { + assert!( + matches!(result, Err(ClientError::Bus(_))), + "unattested recipient must refuse confidential member {}", + entry.name + ); + refused_confidential += 1; + } else { + let value = result?; + assert_catalogue_reply(entry.name, &value); + called += 1; + } + } + + assert!(called > 0); + assert!(refused_confidential > 0); + assert_eq!( + fixture.calls.load(std::sync::atomic::Ordering::Acquire), + called, + "only public calls and Describe reach an untrusted service" + ); + let seen = fixture + .seen + .lock() + .map_err(|_| std::io::Error::other("untrusted call log poisoned"))? + .clone(); + assert!(seen.iter().all(|call| !call.confidential)); + Ok(()) +} + +#[tokio::test] +async fn typed_accessibility_browser_profile_task_and_held_output_calls_round_trip() +-> Result<(), Box> { + let fixture = untrusted_fixture().await?; + let client = client_for(&fixture.bus, None); + let config = ComputerConfig::default(); + client.record_loaded_with(&config)?; + + let focus = client + .call_desktop::<_, FocusedTextContext>( + &config, + names::accessibility::ACCESSIBILITY_FOCUS, + FocusQuery::default(), + ) + .await; + assert!(matches!(focus, Err(ClientError::Bus(_)))); + + let claimed = client + .call_desktop::<_, ClaimProfileResponse>( + &config, + tinycomputer_bus::browser::names::methods::CLAIM_PROFILE, + ClaimProfileRequest::new("/profile/fixture", 0), + ) + .await?; + assert_eq!(claimed.outcome, ProfileClaim::Claimed); + assert_eq!(claimed.path.as_deref(), Some("/profile/fixture")); + + let navigated = client + .call_desktop::<_, PageState>( + &config, + tinycomputer_bus::browser::names::methods::NAVIGATE, + SessionRequest::new( + SessionId::new("session-1"), + NavigateRequest::new("https://example.com"), + ), + ) + .await?; + assert_eq!(navigated.url, "https://example.com"); + + let chunk = client + .call_desktop::<_, OutputChunk>( + &config, + tinycomputer_bus::browser::names::methods::READ_OUTPUT, + ReadOutputRequest::new(OutputId::new("held-image-1")), + ) + .await?; + assert_eq!(chunk.id.as_str(), "held-image-1"); + assert_eq!(chunk.offset, 0); + assert_eq!(chunk.data, "aGVsbG8="); + assert!(chunk.eof); + + let planned = client + .call_task::<_, Value>( + &config, + task_names::methods::PLAN_TASK, + tinycomputer_bus::agent::PlanTaskRequest::default(), + ) + .await?; + assert_eq!(planned, json!(task_names::methods::PLAN_TASK)); + + let seen = fixture + .seen + .lock() + .map_err(|_| std::io::Error::other("untrusted call log poisoned"))? + .clone(); + for member in [ + tinycomputer_bus::browser::names::methods::CLAIM_PROFILE, + tinycomputer_bus::browser::names::methods::NAVIGATE, + tinycomputer_bus::browser::names::methods::READ_OUTPUT, + task_names::methods::PLAN_TASK, + ] { + let observed = seen + .iter() + .find(|call| call.member == member) + .ok_or_else(|| std::io::Error::other("typed member was not observed"))?; + assert!(!observed.confidential, "{member} is public"); + assert_eq!(observed.args.as_array().map(Vec::len), Some(1)); + } + let claim = seen + .iter() + .find(|call| call.member == tinycomputer_bus::browser::names::methods::CLAIM_PROFILE) + .ok_or_else(|| std::io::Error::other("claim request was not observed"))?; + assert_eq!( + claim.args[0], + json!({ + "path": "/profile/fixture", + "wait_ms": 0, + "fall_back_to_fresh": true + }) + ); + let navigation = seen + .iter() + .find(|call| call.member == tinycomputer_bus::browser::names::methods::NAVIGATE) + .ok_or_else(|| std::io::Error::other("navigation request was not observed"))?; + assert_eq!(navigation.args[0]["session"], "session-1"); + assert_eq!(navigation.args[0]["url"], "https://example.com"); + let held_output = seen + .iter() + .find(|call| call.member == tinycomputer_bus::browser::names::methods::READ_OUTPUT) + .ok_or_else(|| std::io::Error::other("held-output request was not observed"))?; + assert_eq!(held_output.args[0]["output"], "held-image-1"); + assert_eq!(held_output.args[0]["offset"], 0); + assert_eq!( + held_output.args[0]["max_len"], + ReadOutputRequest::DEFAULT_LEN + ); + + assert_eq!(RUN_GOAL_TIMEOUT, Duration::from_secs(330)); + assert_eq!(TASK_CALL_TIMEOUT, Duration::from_secs(75)); + Ok(()) +} + +#[tokio::test] +async fn unknown_members_wrong_families_and_bad_envelopes_fail_closed() +-> Result<(), Box> { + let fixture = untrusted_fixture().await?; + let client = client_for(&fixture.bus, None); + let config = ComputerConfig::default(); + client.record_loaded_with(&config)?; + + assert!(matches!( + client + .call_desktop::<_, Value>(&config, "AccessibilityFutureMember", Value::Null) + .await, + Err(ClientError::UnknownMember(_)) + )); + assert!(matches!( + client + .call_task::<_, Value>( + &config, + tinycomputer_bus::browser::names::methods::NAVIGATE, + Value::Null, + ) + .await, + Err(ClientError::WrongFamily { .. }) + )); + assert!(matches!( + client + .call_desktop::<_, Value>(&config, task_names::methods::PLAN_TASK, Value::Null,) + .await, + Err(ClientError::WrongFamily { .. }) + )); + assert!(matches!( + client + .call_desktop::<_, Value>( + &config, + names::methods::PERMISSIONS, + json!({"malformed_version": true}), + ) + .await, + Err(ClientError::DesktopResponse( + tinycomputer_bus::DesktopResponseError::Version { .. } + )) + )); + assert!(matches!( + client + .call_desktop::<_, String>( + &config, + names::methods::PERMISSIONS, + json!({"wrong_data": true}), + ) + .await, + Err(ClientError::DesktopResponse( + tinycomputer_bus::DesktopResponseError::Decode(_) + )) + )); + assert!(matches!( + client + .call_task::<_, Value>( + &config, + task_names::methods::PLAN_TASK, + json!({"malformed_version": true}), + ) + .await, + Err(ClientError::AgentResponse( + tinycomputer_bus::agent::AgentResponseError::Version { .. } + )) + )); + Ok(()) +} + +#[derive(Default)] +struct TestModuleState { + config: tokio::sync::RwLock, + applied: StdMutex>, + observed: StdMutex>, + focus_gate: tokio::sync::Mutex>, + run_goal_gate: tokio::sync::Mutex>, + name_acquired: std::sync::atomic::AtomicBool, +} + +struct FocusGate { + entered: tokio::sync::oneshot::Sender<()>, + release: tokio::sync::oneshot::Receiver<()>, +} + +static LINKED_STATE: OnceLock>>> = OnceLock::new(); +static LINKED_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +fn linked_state_slot() -> &'static StdMutex>> { + LINKED_STATE.get_or_init(|| StdMutex::new(None)) +} + +fn linked_setup_factory() +-> impl Fn(Connection, ComputerConfig) -> Pin> + Send>> ++ Clone ++ Send ++ Sync ++ 'static { + let state = Arc::new(TestModuleState::default()); + *linked_state_slot() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::clone(&state)); + move |connection, config| { + let state = Arc::clone(&state); + Box::pin(async move { + state + .applied + .lock() + .map_err(|_| TinyBusError::failed("test configuration state poisoned"))? + .push(config.clone()); + *state.config.write().await = config; + connection + .serve_at( + ObjectPath::new(names::OBJECT_PATH)?, + LinkedService(Arc::clone(&state)), + ) + .await?; + if !state + .name_acquired + .swap(true, std::sync::atomic::Ordering::AcqRel) + { + connection.request_name(names::INTERFACE).await?; + } + Ok(()) + }) + } +} + +mod linked_service { + fn setup_factory() -> impl Fn( + tinybus::Connection, + tinycomputer_bus::config::ComputerConfig, + ) -> std::pin::Pin< + Box> + Send>, + > + Clone + + Send + + Sync { + super::linked_setup_factory() + } + + tinybus_module::module_export_static! { + setup_factory = setup_factory, + config = tinycomputer_bus::config::ComputerConfig, + worker_threads = 1, + provides = ["ai.tinyhumans.tinycomputer.Desktop"], + methods = ["Describe", "AccessibilityFocus", "RunGoal"], + signals = [], + requires = [], + optional = [], + lazy = false, + } +} + +struct LinkedService(Arc); + +#[async_trait] +impl Interface for LinkedService { + fn name(&self) -> InterfaceName { + static_contract_value(InterfaceName::new(names::INTERFACE)) + } + + fn members(&self) -> Vec { + [ + task_names::methods::DESCRIBE, + names::accessibility::ACCESSIBILITY_FOCUS, + names::methods::RUN_GOAL, + ] + .iter() + .map(|member| static_contract_value(MemberName::new(*member))) + .collect() + } + + fn requires_confidential(&self, member: &MemberName) -> bool { + handler_requires_confidential(member.as_str()) + } + + async fn call(&self, member: &MemberName, args: Value) -> tinybus::Result { + self.reply(member, &args).await + } + + async fn call_with_context( + &self, + member: &MemberName, + args: Value, + context: &tinybus::service::CallContext, + ) -> tinybus::Result { + self.0 + .observed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(ObservedCall { + member: member.as_str().to_owned(), + confidential: context.is_confidential(), + args: args.clone(), + }); + self.reply(member, &args).await + } +} + +impl LinkedService { + async fn reply(&self, member: &MemberName, args: &Value) -> tinybus::Result { + if member.as_str() == task_names::methods::DESCRIBE + && !args.as_array().is_some_and(Vec::is_empty) + { + return Err(TinyBusError::failed("Describe takes no arguments")); + } + + if member.as_str() == names::accessibility::ACCESSIBILITY_FOCUS { + if let Some(gate) = self.0.focus_gate.lock().await.take() { + let _ = gate.entered.send(()); + let _ = gate.release.await; + } + return serde_json::to_value(DesktopResponse::ok( + member.as_str(), + serde_json::to_value(FocusedTextContext { + app_name: Some("linked fixture".to_owned()), + role: Some("text".to_owned()), + text: "attested result".to_owned(), + selected_text: None, + raw_error: None, + bounds: None, + }) + .map_err(|error| TinyBusError::failed(error.to_string()))?, + )) + .map_err(|error| TinyBusError::failed(error.to_string())); + } + + let config = self.0.config.read().await.clone(); + if member.as_str() == names::methods::RUN_GOAL { + if let Some(gate) = self.0.run_goal_gate.lock().await.take() { + let _ = gate.entered.send(()); + let _ = gate.release.await; + } + let request = args + .as_array() + .and_then(|items| items.first()) + .cloned() + .unwrap_or(Value::Null); + return response(member.as_str(), &request, &config); + } + + serde_json::to_value(test_capabilities(&config)) + .map_err(|error| TinyBusError::failed(error.to_string())) + } +} + +struct LinkedFixture { + client: ComputerClient, + state: Arc, + _host: Arc, + _broker_task: tokio::task::JoinHandle>, +} + +fn linked_fixture(initial: &ComputerConfig) -> Result> { + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let host = Arc::new(ModuleHost::new(broker)); + let initial_value = serde_json::to_value(initial)?; + host.attach_linked_with_config(linked_service::linked_module()?, initial_value)?; + let state_guard = linked_state_slot() + .lock() + .map_err(|_| std::io::Error::other("linked test state poisoned"))?; + let state = Arc::clone( + state_guard + .as_ref() + .ok_or_else(|| std::io::Error::other("linked setup did not publish its test state"))?, + ); + let client = client_for(&bus, Some(Arc::clone(&host))); + client.record_loaded_with(initial)?; + Ok(LinkedFixture { + client, + state, + _host: host, + _broker_task: broker_task, + }) +} + +#[tokio::test(flavor = "current_thread")] +async fn describe_uses_the_zero_argument_wire_shape() -> Result<(), Box> { + let _serial = LINKED_TEST_LOCK.lock().await; + let config = ComputerConfig::default(); + let fixture = linked_fixture(&config)?; + + let described = fixture.client.describe(&config).await?; + + assert_eq!( + described.contract_version, + tinycomputer_bus::CONTRACT_VERSION + ); + let describe_call = fixture + .state + .observed + .lock() + .map_err(|_| std::io::Error::other("observed calls poisoned"))? + .iter() + .find(|call| call.member == task_names::methods::DESCRIBE) + .cloned() + .ok_or_else(|| std::io::Error::other("Describe call was not observed"))?; + assert_eq!(describe_call.args, Value::Array(Vec::new())); + Ok(()) +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn run_goal_remains_pending_past_the_default_bus_timeout() +-> Result<(), Box> { + let _serial = LINKED_TEST_LOCK.lock().await; + let config = ComputerConfig::default(); + let fixture = linked_fixture(&config)?; + let (entered_tx, mut entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + *fixture.state.run_goal_gate.lock().await = Some(FocusGate { + entered: entered_tx, + release: release_rx, + }); + let keep_clock_scheduled = tokio::spawn(async { + loop { + tokio::task::yield_now().await; + } + }); + + let client = fixture.client.clone(); + let call_config = config.clone(); + let mut call = tokio::spawn(async move { + client + .call_desktop::<_, String>( + &call_config, + names::methods::RUN_GOAL, + json!({"goal": "wait for the fixture"}), + ) + .await + }); + tokio::select! { + biased; + result = &mut call => { + keep_clock_scheduled.abort(); + return Err(std::io::Error::other(format!( + "RunGoal call finished before reaching its service gate: {result:?}" + )).into()); + } + result = &mut entered_rx => { + result.map_err(|_| std::io::Error::other("RunGoal service exited before the gate"))?; + } + } + + tokio::time::advance(Duration::from_secs(31)).await; + tokio::task::yield_now().await; + assert!( + !call.is_finished(), + "RunGoal must use its extended deadline instead of TinyBus' default" + ); + + release_tx + .send(()) + .map_err(|()| std::io::Error::other("RunGoal gate closed before release"))?; + let result = call.await?; + keep_clock_scheduled.abort(); + let _ = keep_clock_scheduled.await; + assert_eq!(result?, names::methods::RUN_GOAL); + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn attested_client_keeps_configuration_exclusive_through_member_completion() +-> Result<(), Box> { + let _serial = LINKED_TEST_LOCK.lock().await; + let first_config = ComputerConfig { + jev: Some(JevConfig::new("fixture-secret")), + ..ComputerConfig::default() + }; + let second_config = ComputerConfig::default(); + let fixture = linked_fixture(&first_config)?; + assert!(format!("{:?}", fixture.client).contains("ComputerClient")); + + let described = fixture.client.describe(&first_config).await?; + assert!(described.jev_configured); + assert_eq!( + fixture + .state + .applied + .lock() + .map_err(|_| std::io::Error::other("applied config log poisoned"))? + .len(), + 1 + ); + + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + *fixture.state.focus_gate.lock().await = Some(FocusGate { + entered: entered_tx, + release: release_rx, + }); + let first_client = fixture.client.clone(); + let call_config = first_config.clone(); + let focus = tokio::spawn(async move { + first_client + .call_desktop::<_, FocusedTextContext>( + &call_config, + names::accessibility::ACCESSIBILITY_FOCUS, + FocusQuery::default(), + ) + .await + }); + entered_rx.await?; + + let second_client = fixture.client.clone(); + let next_config = second_config.clone(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let next = tokio::spawn(async move { + let _ = started_tx.send(()); + second_client.describe(&next_config).await + }); + started_rx.await?; + tokio::task::yield_now().await; + assert!( + !next.is_finished(), + "second call waits for the active guard" + ); + assert_eq!( + fixture + .state + .applied + .lock() + .map_err(|_| std::io::Error::other("applied config log poisoned"))? + .len(), + 1 + ); + + release_tx + .send(()) + .map_err(|()| std::io::Error::other("focus gate closed before release"))?; + let focused = focus.await??; + assert_eq!(focused.text, "attested result"); + let focus_call = fixture + .state + .observed + .lock() + .map_err(|_| std::io::Error::other("observed calls poisoned"))? + .iter() + .find(|call| call.member == names::accessibility::ACCESSIBILITY_FOCUS) + .cloned() + .ok_or_else(|| std::io::Error::other("linked focus call was not observed"))?; + assert!(focus_call.confidential); + assert_eq!(focus_call.args[0]["verbose"], false); + let reconfigured = next.await??; + assert!(!reconfigured.jev_configured); + assert_eq!( + fixture + .state + .applied + .lock() + .map_err(|_| std::io::Error::other("applied config log poisoned"))? + .len(), + 2 + ); + Ok(()) +} diff --git a/crates/tinycomputer-examples/Cargo.toml b/crates/tinycomputer-examples/Cargo.toml index 4e0dd756..0668ae7d 100644 --- a/crates/tinycomputer-examples/Cargo.toml +++ b/crates/tinycomputer-examples/Cargo.toml @@ -11,6 +11,8 @@ publish = false [dependencies] tinycomputer = { path = "../tinycomputer" } tinycomputer-bus = { workspace = true } +# Exercise the typed host API against the release artifact in verify_module. +tinycomputer-client = { workspace = true } # `browser_fixture` drives a real browser through the linked agent-browser and # checks the shared domain logic against the travel fixture. tinycomputer-browser = { workspace = true, features = ["agent-browser"] } diff --git a/crates/tinycomputer-examples/src/bin/verify_module.rs b/crates/tinycomputer-examples/src/bin/verify_module.rs index d682e94a..a1341802 100644 --- a/crates/tinycomputer-examples/src/bin/verify_module.rs +++ b/crates/tinycomputer-examples/src/bin/verify_module.rs @@ -7,13 +7,104 @@ use std::io; use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use tinybus::Connection; use tinybus::broker::Broker; -use tinybus::module::ModuleHost; +use tinybus::module::{ + AccessFuture, FailureReport, FailureReporter, FailureSink, ModuleAccess, ModuleHost, + ProxyFailure, ReconfigurableModule, ReconfigureFailure, ReleaseRecord, +}; use tinybus::transport::memory::MemoryBus; +use tinycomputer_bus::config::ComputerConfig; use tinycomputer_bus::{DesktopResponse, METHODS, names}; +use tinycomputer_client::ComputerClient; + +static TINYCOMPUTER_RELEASE: ReleaseRecord = ReleaseRecord { + id: "tinycomputer", + bus_name: names::INTERFACE, + object_path: names::OBJECT_PATH, + version: "0.16.0", + release_url: "https://example.invalid/tinycomputer", + assets: &[], +}; + +#[derive(Default)] +struct SilentFailureSink; + +impl FailureSink for SilentFailureSink { + fn capture(&self, _report: &FailureReport<'_>) {} +} + +struct LoadedModuleAccess { + bus: MemoryBus, + host: Arc, + reporter: FailureReporter, +} + +impl ModuleAccess for LoadedModuleAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == TINYCOMPUTER_RELEASE.id).then_some(&TINYCOMPUTER_RELEASE) + } + + fn enabled(&self) -> bool { + true + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + let Some(record) = self.find(id) else { + return Box::pin( + async move { Err(format!("module `{id}` is not in the release table")) }, + ); + }; + let bus_name = record.bus_name; + Box::pin(async move { + self.host + .ensure_ready(bus_name) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + }) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + let bus = self.bus.clone(); + let bus_name = bus_name.to_owned(); + let object_path = object_path.to_owned(); + Box::pin(async move { + let transport = bus.connect().await.map_err(|_| ProxyFailure::Transport)?; + let connection = Connection::connect(transport) + .await + .map_err(|_| ProxyFailure::Transport)?; + connection + .proxy(&bus_name, &object_path, &bus_name) + .map_err(|_| ProxyFailure::Contract) + }) + } + + fn reinitialize<'a>( + &'a self, + id: &'a str, + config: serde_json::Value, + ) -> AccessFuture<'a, Result<(), ReconfigureFailure>> { + Box::pin(async move { + self.host + .reinitialize(id, config) + .await + .map(|_| ()) + .map_err(|_| ReconfigureFailure::Failed) + }) + } +} #[tokio::main] async fn main() -> Result<(), Box> { @@ -21,8 +112,9 @@ async fn main() -> Result<(), Box> { let bus = MemoryBus::new(); let broker = Broker::new(); let broker_task = broker.spawn(bus.clone()); - let module_host = ModuleHost::new(broker); - let info = module_host.load_file(&module)?; + let module_host = Arc::new(ModuleHost::new(broker)); + let client_config = ComputerConfig::default(); + let info = module_host.load_file_with_config(&module, serde_json::to_value(&client_config)?)?; if info.name != "tinycomputer" { return Err(io::Error::other(format!( @@ -58,6 +150,7 @@ async fn main() -> Result<(), Box> { )) .into()); } + verify_typed_client(&bus, &module_host, &client_config).await?; let declared: Vec<&str> = info .manifest @@ -98,6 +191,27 @@ async fn main() -> Result<(), Box> { Ok(()) } +async fn verify_typed_client( + bus: &MemoryBus, + host: &Arc, + config: &ComputerConfig, +) -> Result<(), Box> { + let client = ComputerClient::new(ReconfigurableModule::new( + LoadedModuleAccess { + bus: bus.clone(), + host: Arc::clone(host), + reporter: FailureReporter::new(Arc::new(SilentFailureSink)), + }, + TINYCOMPUTER_RELEASE.id, + )); + client.record_loaded_with(config)?; + let capabilities = client.describe(config).await?; + if capabilities.contract_version != tinycomputer_bus::CONTRACT_VERSION { + return Err(io::Error::other("typed client Describe contract version mismatch").into()); + } + Ok(()) +} + /// Checks native lease admission and terminal shutdown without device access. async fn verify_globe_lifecycle(proxy: &tinybus::Proxy) -> Result<(), Box> { // Invalid handles exercise the new artifact paths without touching devices. diff --git a/crates/tinycomputer/src/tinybus_module/dispatch/accessibility_tests.rs b/crates/tinycomputer/src/tinybus_module/dispatch/accessibility_tests.rs index ba432f62..efe83a14 100644 --- a/crates/tinycomputer/src/tinybus_module/dispatch/accessibility_tests.rs +++ b/crates/tinycomputer/src/tinybus_module/dispatch/accessibility_tests.rs @@ -14,12 +14,20 @@ use super::fixture::Fixture; #[test] fn thread_restore_worker_reports_completion_and_joins_once() { - let (finished, observed) = std::sync::mpsc::channel(); - let thread = std::thread::spawn(move || finished.send(()).expect("signal completion")); - observed.recv().expect("worker finished"); + let (started, observed_start) = std::sync::mpsc::channel(); + let (release, wait_for_release) = std::sync::mpsc::channel(); + let thread = std::thread::spawn(move || { + started.send(()).expect("signal worker started"); + wait_for_release.recv().expect("release worker to finish"); + }); + observed_start.recv().expect("worker started"); let mut worker = ThreadRestoreWorker(Some(thread)); - assert!(worker.is_finished()); + assert!( + !worker.is_finished(), + "a completion signal before thread exit is not a finished worker" + ); + release.send(()).expect("release worker to finish"); worker.join().expect("join completed worker"); assert!(worker.is_finished()); worker.join().expect("joining an empty worker is harmless"); diff --git a/deny.toml b/deny.toml index efb5ea4a..cd054f52 100644 --- a/deny.toml +++ b/deny.toml @@ -52,5 +52,9 @@ unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] # The lab's optional LLM author (`tinycomputer-examples`, feature `inference`) # takes `vendor/tinyinference`, which pins the organisation's own `tinytools` -# by exact git revision. Nothing the shipped module links comes from here. -allow-git = ["https://github.com/tinyhumansai/tinytools"] +# by exact git revision. Browser's websocket transport is pinned to an immutable +# revision of its upstream source to carry the compatible webpki-roots patch. +allow-git = [ + "https://github.com/tinyhumansai/tinytools", + "https://github.com/snapview/tokio-tungstenite", +] diff --git a/docs/README.md b/docs/README.md index e3e5a6e1..c1dfdd2e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,6 +39,7 @@ One friendly guide per crate and per top-level folder. Start at | [`tinycomputer`](crates/tinycomputer/README.md) | the installable module a host loads | | [`tinycomputer-engine`](crates/tinycomputer-engine/README.md) | tasks, the planner, rescues, the Jev runtime, and the [flow runtime](crates/tinycomputer-engine/flow/README.md) | | [`tinycomputer-bus`](crates/tinycomputer-bus/README.md) | the wire contract: every type that crosses the bus | +| [`tinycomputer-client`](crates/tinycomputer-client/README.md) | typed host calls, configuration serialization, and confidential delivery | | [`tinycomputer-core`](crates/tinycomputer-core/README.md) | the shared screen model, safety rules, facts, and parsers | | [`tinycomputer-desktop`](crates/tinycomputer-desktop/README.md) | desktop apps through the accessibility tree | | [`tinycomputer-browser`](crates/tinycomputer-browser/README.md) | web pages through Chrome, and sight | diff --git a/docs/crates/tinycomputer-client/README.md b/docs/crates/tinycomputer-client/README.md new file mode 100644 index 00000000..3bc3ab06 --- /dev/null +++ b/docs/crates/tinycomputer-client/README.md @@ -0,0 +1,40 @@ +# tinycomputer-client + +`tinycomputer-client` is the reusable host-side caller for the TinyComputer +TinyBus interface. It takes request and response types from +`tinycomputer-bus`, so OpenHuman and other hosts do not need to copy member +names, payload types, envelope decoding, or confidential-call selection. + +## Calls and configuration + +Construct a [`tinycomputer_client::ComputerClient`] around one +`tinybus::module::ReconfigurableModule`. Every call takes a typed +`tinycomputer_bus::config::ComputerConfig`; the client holds the coordinator's +exclusive guard until the reply is decoded. Concurrent calls therefore cannot +reconfigure the module between configuration selection and dispatch. A host +that has just loaded the module with a known configuration can call +`record_loaded_with` to seed the coordinator's fingerprint. + +Use `call_desktop` for desktop, accessibility, flow and browser members, and +`call_task` for the typed task envelopes. `describe` is separate because the +contract returns raw `Capabilities` rather than an Agent envelope. Each call +requires a name in the contract catalogue; unknown names and families added by +a newer contract fail closed. The response is checked with +`DesktopResponse::into_data` or `AgentResponse::into_data` before it reaches +the host. + +The catalogue controls confidential delivery. Requests for member names marked +confidential are sent with TinyBus' confidential call path, which requires an +attested recipient; other members use ordinary routing. `RunGoal` gets a +330-second transport deadline so its bounded execution can return its +structured result. Task calls use a 75-second deadline, above the module's +long-poll cap. + +## Policy stays with the host + +This crate does not decide which websites a browser may visit, choose Chrome +paths, grant task origins or constraints, or link the module's execution +engines. Hosts build those values and pass the resulting contract configuration +and request types. Browser profile claims and held-output handles are ordinary +typed contract calls; the client does not retain, transform, or release those +payloads behind the caller's back. diff --git a/vendor/agent-browser b/vendor/agent-browser index f98d5fef..af35a880 160000 --- a/vendor/agent-browser +++ b/vendor/agent-browser @@ -1 +1 @@ -Subproject commit f98d5fef6a57b6067e6c5710e6f0ea6fb0b3bd47 +Subproject commit af35a880d3593e7a5aa4acca9513c7af7e333790 diff --git a/vendor/tinybus b/vendor/tinybus index 482b1c81..9e14f656 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 482b1c81d3c3a169e6b9d3d5a84fbcbc3e0fc519 +Subproject commit 9e14f65666b11782ec1054aba576114026d180f8 From dddad90a748465975983459adfbf304ed547bdc7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 07:35:56 +0530 Subject: [PATCH 05/32] fix(tinycomputer): satisfy docs and clippy gates Co-authored-by: Medulla --- crates/tinycomputer-client/src/lib.rs | 2 +- crates/tinycomputer-examples/src/bin/verify_module.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinycomputer-client/src/lib.rs b/crates/tinycomputer-client/src/lib.rs index cd08d25e..3be2a9da 100644 --- a/crates/tinycomputer-client/src/lib.rs +++ b/crates/tinycomputer-client/src/lib.rs @@ -2,7 +2,7 @@ //! //! [`ComputerClient`] owns no desktop or browser implementation. It takes the //! contract's request and response types, coordinates each call with the -//! module's current [`ComputerConfig`], and applies the contract catalogue's +//! module's current [`ComputerConfig`](tinycomputer_bus::config::ComputerConfig), and applies the contract catalogue's //! confidential-delivery rules before sending a frame. //! //! # Example diff --git a/crates/tinycomputer-examples/src/bin/verify_module.rs b/crates/tinycomputer-examples/src/bin/verify_module.rs index a1341802..6e7288a6 100644 --- a/crates/tinycomputer-examples/src/bin/verify_module.rs +++ b/crates/tinycomputer-examples/src/bin/verify_module.rs @@ -67,7 +67,7 @@ impl ModuleAccess for LoadedModuleAccess { self.host .ensure_ready(bus_name) .await - .map(|_| ()) + .map(|()| ()) .map_err(|error| error.to_string()) }) } From dcd0f2899e7905e39b7d195ff135bc84babdea4b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 08:08:32 +0530 Subject: [PATCH 06/32] fix: remove identity map in module verifier Co-authored-by: Medulla --- crates/tinycomputer-examples/src/bin/verify_module.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/tinycomputer-examples/src/bin/verify_module.rs b/crates/tinycomputer-examples/src/bin/verify_module.rs index 6e7288a6..faab1f5c 100644 --- a/crates/tinycomputer-examples/src/bin/verify_module.rs +++ b/crates/tinycomputer-examples/src/bin/verify_module.rs @@ -67,7 +67,6 @@ impl ModuleAccess for LoadedModuleAccess { self.host .ensure_ready(bus_name) .await - .map(|()| ()) .map_err(|error| error.to_string()) }) } From 1a36e47ec856f24011b16136e98b44d8c6522e81 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 08:47:41 +0530 Subject: [PATCH 07/32] test(tinycomputer): split client integration tests Co-authored-by: Medulla --- .../tinycomputer-client/tests/client_tests.rs | 1036 +---------------- .../tests/client_tests/catalogue_tests.rs | 80 ++ .../tests/client_tests/fail_closed_tests.rs | 72 ++ .../tests/client_tests/linked_module_tests.rs | 391 +++++++ .../tests/client_tests/support/common.rs | 386 ++++++ .../tests/client_tests/support/mod.rs | 6 + .../client_tests/typed_round_trip_tests.rs | 115 ++ 7 files changed, 1062 insertions(+), 1024 deletions(-) create mode 100644 crates/tinycomputer-client/tests/client_tests/catalogue_tests.rs create mode 100644 crates/tinycomputer-client/tests/client_tests/fail_closed_tests.rs create mode 100644 crates/tinycomputer-client/tests/client_tests/linked_module_tests.rs create mode 100644 crates/tinycomputer-client/tests/client_tests/support/common.rs create mode 100644 crates/tinycomputer-client/tests/client_tests/support/mod.rs create mode 100644 crates/tinycomputer-client/tests/client_tests/typed_round_trip_tests.rs diff --git a/crates/tinycomputer-client/tests/client_tests.rs b/crates/tinycomputer-client/tests/client_tests.rs index 48b6f28c..60e301b1 100644 --- a/crates/tinycomputer-client/tests/client_tests.rs +++ b/crates/tinycomputer-client/tests/client_tests.rs @@ -34,1027 +34,15 @@ use tinycomputer_bus::{ }; use tinycomputer_client::{ClientError, ComputerClient, RUN_GOAL_TIMEOUT, TASK_CALL_TIMEOUT}; -const TEST_MODULE_ID: &str = env!("CARGO_PKG_NAME"); - -fn static_contract_value(result: Result) -> T { - match result { - Ok(value) => value, - Err(error) => { - eprintln!("client test failed: {error:?}"); - std::process::exit(1); - } - } -} - -static TEST_RECORD: ReleaseRecord = ReleaseRecord { - id: TEST_MODULE_ID, - bus_name: names::INTERFACE, - object_path: names::OBJECT_PATH, - version: "0.16.0", - release_url: "https://example.invalid/test", - assets: &[], -}; - -#[derive(Default)] -struct SilentSink; - -impl FailureSink for SilentSink { - fn capture(&self, _report: &tinybus::module::failure::FailureReport<'_>) {} -} - -struct TestAccess { - bus: MemoryBus, - host: Option>, - reporter: FailureReporter, -} - -impl TestAccess { - fn new(bus: MemoryBus, host: Option>) -> Self { - Self { - bus, - host, - reporter: FailureReporter::new(Arc::new(SilentSink)), - } - } -} - -impl ModuleAccess for TestAccess { - fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { - (id == TEST_MODULE_ID).then_some(&TEST_RECORD) - } - - fn enabled(&self) -> bool { - true - } - - fn reporter(&self) -> &FailureReporter { - &self.reporter - } - - fn ensure_loaded<'a>(&'a self, _id: &'a str) -> AccessFuture<'a, Result<(), String>> { - Box::pin(async move { - if let Some(host) = &self.host { - host.ensure_ready(names::INTERFACE) - .await - .map_err(|error| error.to_string())?; - } - Ok(()) - }) - } - - fn proxy<'a>( - &'a self, - bus_name: &'a str, - object_path: &'a str, - ) -> AccessFuture<'a, Result> { - let bus = self.bus.clone(); - let bus_name = bus_name.to_owned(); - let object_path = object_path.to_owned(); - Box::pin(async move { - let transport = bus.connect().await.map_err(|_| ProxyFailure::Transport)?; - let connection = Connection::connect(transport) - .await - .map_err(|_| ProxyFailure::Transport)?; - connection - .proxy(&bus_name, &object_path, &bus_name) - .map_err(|_| ProxyFailure::Contract) - }) - } - - fn reinitialize<'a>( - &'a self, - id: &'a str, - config: Value, - ) -> AccessFuture<'a, Result<(), ReconfigureFailure>> { - Box::pin(async move { - let Some(host) = &self.host else { - return Ok(()); - }; - host.reinitialize(id, config) - .await - .map(|_| ()) - .map_err(|_| ReconfigureFailure::Failed) - }) - } -} - -fn client_for(bus: &MemoryBus, host: Option>) -> ComputerClient { - ComputerClient::new(ReconfigurableModule::new( - TestAccess::new(bus.clone(), host), - TEST_MODULE_ID, - )) -} - -fn test_capabilities(config: &ComputerConfig) -> Capabilities { - Capabilities { - contract_version: tinycomputer_bus::CONTRACT_VERSION, - surfaces: Vec::new(), - jev_configured: config.jev.is_some(), - planner_configured: config.planner.is_some(), - rescue_configured: false, - output_configured: false, - decision_model: None, - planner_model: None, - rescue_model: None, - output_model: None, - step_kinds: Vec::new(), - guide: String::new(), - members: Vec::new(), - examples: Vec::new(), - catalogue: catalogue::summaries(), - } -} - -const FLOW_MEMBERS: &[&str] = &[ - names::methods::RESOLVE_INTENT, - names::methods::RUN_GOAL, - names::methods::RUN_FLOW, - names::methods::VALIDATE_FLOW, - names::methods::FLOW_GUIDE, -]; - -fn expected_family(member: &str) -> Family { - if task_names::METHODS.contains(&member) { - Family::Task - } else if tinycomputer_bus::browser::names::METHODS.contains(&member) { - Family::Browser - } else if FLOW_MEMBERS.contains(&member) { - Family::Flow - } else { - Family::Desktop - } -} - -fn catalogue_request(member: &str) -> Result { - Ok(match member { - tinycomputer_bus::browser::names::methods::NAVIGATE => { - serde_json::to_value(SessionRequest::new( - SessionId::new("catalogue-session"), - NavigateRequest::new("https://example.com"), - ))? - } - tinycomputer_bus::browser::names::methods::CLAIM_PROFILE => { - serde_json::to_value(ClaimProfileRequest::new("/profile/catalogue", 0))? - } - tinycomputer_bus::browser::names::methods::READ_OUTPUT => { - serde_json::to_value(ReadOutputRequest::new(OutputId::new("catalogue-output")))? - } - _ => json!({"member": member}), - }) -} - -fn assert_catalogue_reply(member: &str, value: &Value) { - match member { - tinycomputer_bus::browser::names::methods::NAVIGATE => { - assert_eq!(value["url"], "https://example.com"); - } - tinycomputer_bus::browser::names::methods::CLAIM_PROFILE => { - assert_eq!(value["outcome"], "claimed"); - assert_eq!(value["path"], "/profile/fixture"); - } - tinycomputer_bus::browser::names::methods::READ_OUTPUT => { - assert_eq!(value["id"], "catalogue-output"); - assert_eq!(value["offset"], 0); - assert_eq!(value["data"], "aGVsbG8="); - assert_eq!(value["eof"], true); - } - _ => assert_eq!(*value, json!(member), "member {member} reply"), - } -} - -fn response(member: &str, request: &Value, config: &ComputerConfig) -> tinybus::Result { - let malformed_version = request - .get("malformed_version") - .and_then(Value::as_bool) - .unwrap_or(false); - let wrong_data = request - .get("wrong_data") - .and_then(Value::as_bool) - .unwrap_or(false); - - if member == task_names::methods::DESCRIBE { - return serde_json::to_value(test_capabilities(config)) - .map_err(|error| TinyBusError::failed(error.to_string())); - } - - let family = expected_family(member); - if family == Family::Task { - if malformed_version { - return Ok(json!({ - "version": "unsupported", - "ok": true, - "data": "unexpected", - "error": null - })); - } - let data = if wrong_data { json!(17) } else { json!(member) }; - return serde_json::to_value(AgentResponse::ok(data)) - .map_err(|error| TinyBusError::failed(error.to_string())); - } - - if malformed_version { - return Ok(json!({ - "version": "unsupported", - "ok": true, - "command": member, - "data": "unexpected", - "error": null - })); - } - - let data = if wrong_data { - json!(17) - } else { - match member { - "AccessibilityFocus" => serde_json::to_value(FocusedTextContext { - app_name: Some("fixture".to_owned()), - role: Some("text".to_owned()), - text: "typed response".to_owned(), - selected_text: None, - raw_error: None, - bounds: None, - }) - .map_err(|error| TinyBusError::failed(error.to_string()))?, - "BrowserClaimProfile" => serde_json::to_value(ClaimProfileResponse { - outcome: ProfileClaim::Claimed, - path: Some("/profile/fixture".to_owned()), - }) - .map_err(|error| TinyBusError::failed(error.to_string()))?, - "BrowserNavigate" => serde_json::to_value(PageState::new("https://example.com")) - .map_err(|error| TinyBusError::failed(error.to_string()))?, - "BrowserReadOutput" => { - let read: ReadOutputRequest = serde_json::from_value(request.clone()) - .map_err(|error| TinyBusError::failed(error.to_string()))?; - serde_json::to_value(OutputChunk { - id: read.output, - offset: read.offset, - data: "aGVsbG8=".to_owned(), - eof: true, - }) - .map_err(|error| TinyBusError::failed(error.to_string()))? - } - _ => json!(member), - } - }; - serde_json::to_value(DesktopResponse::ok(member, data)) - .map_err(|error| TinyBusError::failed(error.to_string())) -} - -struct EchoService { - config: Arc>, - calls: Arc, - seen: Arc>>, -} - -#[async_trait] -impl Interface for EchoService { - fn name(&self) -> InterfaceName { - static_contract_value(InterfaceName::new(names::INTERFACE)) - } - - fn members(&self) -> Vec { - names::METHODS - .iter() - .map(|member| static_contract_value(MemberName::new(*member))) - .collect() - } - - fn requires_confidential(&self, member: &MemberName) -> bool { - handler_requires_confidential(member.as_str()) - } - - async fn call(&self, member: &MemberName, args: Value) -> tinybus::Result { - self.invoke(member, args).await - } - - async fn call_with_context( - &self, - member: &MemberName, - args: Value, - context: &tinybus::service::CallContext, - ) -> tinybus::Result { - self.seen - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .push(ObservedCall { - member: member.as_str().to_owned(), - confidential: context.is_confidential(), - args: args.clone(), - }); - self.invoke(member, args).await - } -} - -impl EchoService { - async fn invoke(&self, member: &MemberName, args: Value) -> tinybus::Result { - self.calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel); - let request = args - .as_array() - .and_then(|items| items.first()) - .cloned() - .unwrap_or(Value::Null); - let config = self.config.read().await.clone(); - response(member.as_str(), &request, &config) - } -} - -#[derive(Debug, Clone)] -struct ObservedCall { - member: String, - confidential: bool, - args: Value, -} - -fn handler_requires_confidential(member: &str) -> bool { - task_names::CONFIDENTIAL.contains(&member) - || [ - names::methods::RESOLVE_INTENT, - names::methods::RUN_GOAL, - names::methods::RUN_FLOW, - names::accessibility::ACCESSIBILITY_FOCUS, - names::accessibility::ACCESSIBILITY_VALIDATE_TARGET, - names::accessibility::ACCESSIBILITY_PASTE, - ] - .contains(&member) -} - -struct UntrustedFixture { - bus: MemoryBus, - _broker_task: tokio::task::JoinHandle>, - _service_connection: Connection, - calls: Arc, - seen: Arc>>, -} - -async fn untrusted_fixture() -> Result> { - let bus = MemoryBus::new(); - let broker_task = Broker::new().spawn(bus.clone()); - let transport = bus.connect().await?; - let service_connection = Connection::connect(transport).await?; - service_connection.request_name(names::INTERFACE).await?; - let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let seen = Arc::new(StdMutex::new(Vec::new())); - service_connection - .serve_at( - ObjectPath::new(names::OBJECT_PATH)?, - EchoService { - config: Arc::new(tokio::sync::RwLock::new(ComputerConfig::default())), - calls: Arc::clone(&calls), - seen: Arc::clone(&seen), - }, - ) - .await?; - Ok(UntrustedFixture { - bus, - _broker_task: broker_task, - _service_connection: service_connection, - calls, - seen, - }) -} - -#[tokio::test] -async fn every_catalogued_operation_is_routed_by_family_and_confidentiality() --> Result<(), Box> { - let fixture = untrusted_fixture().await?; - let client = client_for(&fixture.bus, None); - let config = ComputerConfig::default(); - client.record_loaded_with(&config)?; - let mut called = 0; - let mut refused_confidential = 0; - - for entry in catalogue::MEMBERS { - assert_eq!( - entry.family, - expected_family(entry.name), - "family for {}", - entry.name - ); - assert_eq!( - entry.confidential, - handler_requires_confidential(entry.name), - "confidential flag for {}", - entry.name - ); - let request = catalogue_request(entry.name)?; - let result = if entry.name == task_names::methods::DESCRIBE { - let described = client.describe(&config).await?; - assert_eq!( - described.contract_version, - tinycomputer_bus::CONTRACT_VERSION - ); - called += 1; - continue; - } else { - match entry.family { - Family::Task => { - client - .call_task::<_, Value>(&config, entry.name, request) - .await - } - Family::Flow | Family::Desktop | Family::Browser => { - client - .call_desktop::<_, Value>(&config, entry.name, request) - .await - } - } - }; - - if entry.confidential { - assert!( - matches!(result, Err(ClientError::Bus(_))), - "unattested recipient must refuse confidential member {}", - entry.name - ); - refused_confidential += 1; - } else { - let value = result?; - assert_catalogue_reply(entry.name, &value); - called += 1; - } - } - - assert!(called > 0); - assert!(refused_confidential > 0); - assert_eq!( - fixture.calls.load(std::sync::atomic::Ordering::Acquire), - called, - "only public calls and Describe reach an untrusted service" - ); - let seen = fixture - .seen - .lock() - .map_err(|_| std::io::Error::other("untrusted call log poisoned"))? - .clone(); - assert!(seen.iter().all(|call| !call.confidential)); - Ok(()) -} - -#[tokio::test] -async fn typed_accessibility_browser_profile_task_and_held_output_calls_round_trip() --> Result<(), Box> { - let fixture = untrusted_fixture().await?; - let client = client_for(&fixture.bus, None); - let config = ComputerConfig::default(); - client.record_loaded_with(&config)?; - - let focus = client - .call_desktop::<_, FocusedTextContext>( - &config, - names::accessibility::ACCESSIBILITY_FOCUS, - FocusQuery::default(), - ) - .await; - assert!(matches!(focus, Err(ClientError::Bus(_)))); - - let claimed = client - .call_desktop::<_, ClaimProfileResponse>( - &config, - tinycomputer_bus::browser::names::methods::CLAIM_PROFILE, - ClaimProfileRequest::new("/profile/fixture", 0), - ) - .await?; - assert_eq!(claimed.outcome, ProfileClaim::Claimed); - assert_eq!(claimed.path.as_deref(), Some("/profile/fixture")); - - let navigated = client - .call_desktop::<_, PageState>( - &config, - tinycomputer_bus::browser::names::methods::NAVIGATE, - SessionRequest::new( - SessionId::new("session-1"), - NavigateRequest::new("https://example.com"), - ), - ) - .await?; - assert_eq!(navigated.url, "https://example.com"); - - let chunk = client - .call_desktop::<_, OutputChunk>( - &config, - tinycomputer_bus::browser::names::methods::READ_OUTPUT, - ReadOutputRequest::new(OutputId::new("held-image-1")), - ) - .await?; - assert_eq!(chunk.id.as_str(), "held-image-1"); - assert_eq!(chunk.offset, 0); - assert_eq!(chunk.data, "aGVsbG8="); - assert!(chunk.eof); - - let planned = client - .call_task::<_, Value>( - &config, - task_names::methods::PLAN_TASK, - tinycomputer_bus::agent::PlanTaskRequest::default(), - ) - .await?; - assert_eq!(planned, json!(task_names::methods::PLAN_TASK)); - - let seen = fixture - .seen - .lock() - .map_err(|_| std::io::Error::other("untrusted call log poisoned"))? - .clone(); - for member in [ - tinycomputer_bus::browser::names::methods::CLAIM_PROFILE, - tinycomputer_bus::browser::names::methods::NAVIGATE, - tinycomputer_bus::browser::names::methods::READ_OUTPUT, - task_names::methods::PLAN_TASK, - ] { - let observed = seen - .iter() - .find(|call| call.member == member) - .ok_or_else(|| std::io::Error::other("typed member was not observed"))?; - assert!(!observed.confidential, "{member} is public"); - assert_eq!(observed.args.as_array().map(Vec::len), Some(1)); - } - let claim = seen - .iter() - .find(|call| call.member == tinycomputer_bus::browser::names::methods::CLAIM_PROFILE) - .ok_or_else(|| std::io::Error::other("claim request was not observed"))?; - assert_eq!( - claim.args[0], - json!({ - "path": "/profile/fixture", - "wait_ms": 0, - "fall_back_to_fresh": true - }) - ); - let navigation = seen - .iter() - .find(|call| call.member == tinycomputer_bus::browser::names::methods::NAVIGATE) - .ok_or_else(|| std::io::Error::other("navigation request was not observed"))?; - assert_eq!(navigation.args[0]["session"], "session-1"); - assert_eq!(navigation.args[0]["url"], "https://example.com"); - let held_output = seen - .iter() - .find(|call| call.member == tinycomputer_bus::browser::names::methods::READ_OUTPUT) - .ok_or_else(|| std::io::Error::other("held-output request was not observed"))?; - assert_eq!(held_output.args[0]["output"], "held-image-1"); - assert_eq!(held_output.args[0]["offset"], 0); - assert_eq!( - held_output.args[0]["max_len"], - ReadOutputRequest::DEFAULT_LEN - ); - - assert_eq!(RUN_GOAL_TIMEOUT, Duration::from_secs(330)); - assert_eq!(TASK_CALL_TIMEOUT, Duration::from_secs(75)); - Ok(()) -} - -#[tokio::test] -async fn unknown_members_wrong_families_and_bad_envelopes_fail_closed() --> Result<(), Box> { - let fixture = untrusted_fixture().await?; - let client = client_for(&fixture.bus, None); - let config = ComputerConfig::default(); - client.record_loaded_with(&config)?; - - assert!(matches!( - client - .call_desktop::<_, Value>(&config, "AccessibilityFutureMember", Value::Null) - .await, - Err(ClientError::UnknownMember(_)) - )); - assert!(matches!( - client - .call_task::<_, Value>( - &config, - tinycomputer_bus::browser::names::methods::NAVIGATE, - Value::Null, - ) - .await, - Err(ClientError::WrongFamily { .. }) - )); - assert!(matches!( - client - .call_desktop::<_, Value>(&config, task_names::methods::PLAN_TASK, Value::Null,) - .await, - Err(ClientError::WrongFamily { .. }) - )); - assert!(matches!( - client - .call_desktop::<_, Value>( - &config, - names::methods::PERMISSIONS, - json!({"malformed_version": true}), - ) - .await, - Err(ClientError::DesktopResponse( - tinycomputer_bus::DesktopResponseError::Version { .. } - )) - )); - assert!(matches!( - client - .call_desktop::<_, String>( - &config, - names::methods::PERMISSIONS, - json!({"wrong_data": true}), - ) - .await, - Err(ClientError::DesktopResponse( - tinycomputer_bus::DesktopResponseError::Decode(_) - )) - )); - assert!(matches!( - client - .call_task::<_, Value>( - &config, - task_names::methods::PLAN_TASK, - json!({"malformed_version": true}), - ) - .await, - Err(ClientError::AgentResponse( - tinycomputer_bus::agent::AgentResponseError::Version { .. } - )) - )); - Ok(()) -} - -#[derive(Default)] -struct TestModuleState { - config: tokio::sync::RwLock, - applied: StdMutex>, - observed: StdMutex>, - focus_gate: tokio::sync::Mutex>, - run_goal_gate: tokio::sync::Mutex>, - name_acquired: std::sync::atomic::AtomicBool, -} - -struct FocusGate { - entered: tokio::sync::oneshot::Sender<()>, - release: tokio::sync::oneshot::Receiver<()>, -} - -static LINKED_STATE: OnceLock>>> = OnceLock::new(); -static LINKED_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - -fn linked_state_slot() -> &'static StdMutex>> { - LINKED_STATE.get_or_init(|| StdMutex::new(None)) -} - -fn linked_setup_factory() --> impl Fn(Connection, ComputerConfig) -> Pin> + Send>> -+ Clone -+ Send -+ Sync -+ 'static { - let state = Arc::new(TestModuleState::default()); - *linked_state_slot() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::clone(&state)); - move |connection, config| { - let state = Arc::clone(&state); - Box::pin(async move { - state - .applied - .lock() - .map_err(|_| TinyBusError::failed("test configuration state poisoned"))? - .push(config.clone()); - *state.config.write().await = config; - connection - .serve_at( - ObjectPath::new(names::OBJECT_PATH)?, - LinkedService(Arc::clone(&state)), - ) - .await?; - if !state - .name_acquired - .swap(true, std::sync::atomic::Ordering::AcqRel) - { - connection.request_name(names::INTERFACE).await?; - } - Ok(()) - }) - } -} - -mod linked_service { - fn setup_factory() -> impl Fn( - tinybus::Connection, - tinycomputer_bus::config::ComputerConfig, - ) -> std::pin::Pin< - Box> + Send>, - > + Clone - + Send - + Sync { - super::linked_setup_factory() - } - - tinybus_module::module_export_static! { - setup_factory = setup_factory, - config = tinycomputer_bus::config::ComputerConfig, - worker_threads = 1, - provides = ["ai.tinyhumans.tinycomputer.Desktop"], - methods = ["Describe", "AccessibilityFocus", "RunGoal"], - signals = [], - requires = [], - optional = [], - lazy = false, - } -} - -struct LinkedService(Arc); - -#[async_trait] -impl Interface for LinkedService { - fn name(&self) -> InterfaceName { - static_contract_value(InterfaceName::new(names::INTERFACE)) - } - - fn members(&self) -> Vec { - [ - task_names::methods::DESCRIBE, - names::accessibility::ACCESSIBILITY_FOCUS, - names::methods::RUN_GOAL, - ] - .iter() - .map(|member| static_contract_value(MemberName::new(*member))) - .collect() - } - - fn requires_confidential(&self, member: &MemberName) -> bool { - handler_requires_confidential(member.as_str()) - } - - async fn call(&self, member: &MemberName, args: Value) -> tinybus::Result { - self.reply(member, &args).await - } - - async fn call_with_context( - &self, - member: &MemberName, - args: Value, - context: &tinybus::service::CallContext, - ) -> tinybus::Result { - self.0 - .observed - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .push(ObservedCall { - member: member.as_str().to_owned(), - confidential: context.is_confidential(), - args: args.clone(), - }); - self.reply(member, &args).await - } -} - -impl LinkedService { - async fn reply(&self, member: &MemberName, args: &Value) -> tinybus::Result { - if member.as_str() == task_names::methods::DESCRIBE - && !args.as_array().is_some_and(Vec::is_empty) - { - return Err(TinyBusError::failed("Describe takes no arguments")); - } - - if member.as_str() == names::accessibility::ACCESSIBILITY_FOCUS { - if let Some(gate) = self.0.focus_gate.lock().await.take() { - let _ = gate.entered.send(()); - let _ = gate.release.await; - } - return serde_json::to_value(DesktopResponse::ok( - member.as_str(), - serde_json::to_value(FocusedTextContext { - app_name: Some("linked fixture".to_owned()), - role: Some("text".to_owned()), - text: "attested result".to_owned(), - selected_text: None, - raw_error: None, - bounds: None, - }) - .map_err(|error| TinyBusError::failed(error.to_string()))?, - )) - .map_err(|error| TinyBusError::failed(error.to_string())); - } - - let config = self.0.config.read().await.clone(); - if member.as_str() == names::methods::RUN_GOAL { - if let Some(gate) = self.0.run_goal_gate.lock().await.take() { - let _ = gate.entered.send(()); - let _ = gate.release.await; - } - let request = args - .as_array() - .and_then(|items| items.first()) - .cloned() - .unwrap_or(Value::Null); - return response(member.as_str(), &request, &config); - } - - serde_json::to_value(test_capabilities(&config)) - .map_err(|error| TinyBusError::failed(error.to_string())) - } -} - -struct LinkedFixture { - client: ComputerClient, - state: Arc, - _host: Arc, - _broker_task: tokio::task::JoinHandle>, -} - -fn linked_fixture(initial: &ComputerConfig) -> Result> { - let bus = MemoryBus::new(); - let broker = Broker::new(); - let broker_task = broker.spawn(bus.clone()); - let host = Arc::new(ModuleHost::new(broker)); - let initial_value = serde_json::to_value(initial)?; - host.attach_linked_with_config(linked_service::linked_module()?, initial_value)?; - let state_guard = linked_state_slot() - .lock() - .map_err(|_| std::io::Error::other("linked test state poisoned"))?; - let state = Arc::clone( - state_guard - .as_ref() - .ok_or_else(|| std::io::Error::other("linked setup did not publish its test state"))?, - ); - let client = client_for(&bus, Some(Arc::clone(&host))); - client.record_loaded_with(initial)?; - Ok(LinkedFixture { - client, - state, - _host: host, - _broker_task: broker_task, - }) -} - -#[tokio::test(flavor = "current_thread")] -async fn describe_uses_the_zero_argument_wire_shape() -> Result<(), Box> { - let _serial = LINKED_TEST_LOCK.lock().await; - let config = ComputerConfig::default(); - let fixture = linked_fixture(&config)?; - - let described = fixture.client.describe(&config).await?; - - assert_eq!( - described.contract_version, - tinycomputer_bus::CONTRACT_VERSION - ); - let describe_call = fixture - .state - .observed - .lock() - .map_err(|_| std::io::Error::other("observed calls poisoned"))? - .iter() - .find(|call| call.member == task_names::methods::DESCRIBE) - .cloned() - .ok_or_else(|| std::io::Error::other("Describe call was not observed"))?; - assert_eq!(describe_call.args, Value::Array(Vec::new())); - Ok(()) -} - -#[tokio::test(flavor = "current_thread", start_paused = true)] -async fn run_goal_remains_pending_past_the_default_bus_timeout() --> Result<(), Box> { - let _serial = LINKED_TEST_LOCK.lock().await; - let config = ComputerConfig::default(); - let fixture = linked_fixture(&config)?; - let (entered_tx, mut entered_rx) = tokio::sync::oneshot::channel(); - let (release_tx, release_rx) = tokio::sync::oneshot::channel(); - *fixture.state.run_goal_gate.lock().await = Some(FocusGate { - entered: entered_tx, - release: release_rx, - }); - let keep_clock_scheduled = tokio::spawn(async { - loop { - tokio::task::yield_now().await; - } - }); - - let client = fixture.client.clone(); - let call_config = config.clone(); - let mut call = tokio::spawn(async move { - client - .call_desktop::<_, String>( - &call_config, - names::methods::RUN_GOAL, - json!({"goal": "wait for the fixture"}), - ) - .await - }); - tokio::select! { - biased; - result = &mut call => { - keep_clock_scheduled.abort(); - return Err(std::io::Error::other(format!( - "RunGoal call finished before reaching its service gate: {result:?}" - )).into()); - } - result = &mut entered_rx => { - result.map_err(|_| std::io::Error::other("RunGoal service exited before the gate"))?; - } - } - - tokio::time::advance(Duration::from_secs(31)).await; - tokio::task::yield_now().await; - assert!( - !call.is_finished(), - "RunGoal must use its extended deadline instead of TinyBus' default" - ); - - release_tx - .send(()) - .map_err(|()| std::io::Error::other("RunGoal gate closed before release"))?; - let result = call.await?; - keep_clock_scheduled.abort(); - let _ = keep_clock_scheduled.await; - assert_eq!(result?, names::methods::RUN_GOAL); - Ok(()) -} - -#[tokio::test(flavor = "current_thread")] -async fn attested_client_keeps_configuration_exclusive_through_member_completion() --> Result<(), Box> { - let _serial = LINKED_TEST_LOCK.lock().await; - let first_config = ComputerConfig { - jev: Some(JevConfig::new("fixture-secret")), - ..ComputerConfig::default() - }; - let second_config = ComputerConfig::default(); - let fixture = linked_fixture(&first_config)?; - assert!(format!("{:?}", fixture.client).contains("ComputerClient")); - - let described = fixture.client.describe(&first_config).await?; - assert!(described.jev_configured); - assert_eq!( - fixture - .state - .applied - .lock() - .map_err(|_| std::io::Error::other("applied config log poisoned"))? - .len(), - 1 - ); - - let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); - let (release_tx, release_rx) = tokio::sync::oneshot::channel(); - *fixture.state.focus_gate.lock().await = Some(FocusGate { - entered: entered_tx, - release: release_rx, - }); - let first_client = fixture.client.clone(); - let call_config = first_config.clone(); - let focus = tokio::spawn(async move { - first_client - .call_desktop::<_, FocusedTextContext>( - &call_config, - names::accessibility::ACCESSIBILITY_FOCUS, - FocusQuery::default(), - ) - .await - }); - entered_rx.await?; - - let second_client = fixture.client.clone(); - let next_config = second_config.clone(); - let (started_tx, started_rx) = tokio::sync::oneshot::channel(); - let next = tokio::spawn(async move { - let _ = started_tx.send(()); - second_client.describe(&next_config).await - }); - started_rx.await?; - tokio::task::yield_now().await; - assert!( - !next.is_finished(), - "second call waits for the active guard" - ); - assert_eq!( - fixture - .state - .applied - .lock() - .map_err(|_| std::io::Error::other("applied config log poisoned"))? - .len(), - 1 - ); - - release_tx - .send(()) - .map_err(|()| std::io::Error::other("focus gate closed before release"))?; - let focused = focus.await??; - assert_eq!(focused.text, "attested result"); - let focus_call = fixture - .state - .observed - .lock() - .map_err(|_| std::io::Error::other("observed calls poisoned"))? - .iter() - .find(|call| call.member == names::accessibility::ACCESSIBILITY_FOCUS) - .cloned() - .ok_or_else(|| std::io::Error::other("linked focus call was not observed"))?; - assert!(focus_call.confidential); - assert_eq!(focus_call.args[0]["verbose"], false); - let reconfigured = next.await??; - assert!(!reconfigured.jev_configured); - assert_eq!( - fixture - .state - .applied - .lock() - .map_err(|_| std::io::Error::other("applied config log poisoned"))? - .len(), - 2 - ); - Ok(()) -} +#[path = "client_tests/support/mod.rs"] +mod support; +use support::*; + +#[path = "client_tests/catalogue_tests.rs"] +mod catalogue_tests; +#[path = "client_tests/fail_closed_tests.rs"] +mod fail_closed_tests; +#[path = "client_tests/linked_module_tests.rs"] +mod linked_module_tests; +#[path = "client_tests/typed_round_trip_tests.rs"] +mod typed_round_trip_tests; diff --git a/crates/tinycomputer-client/tests/client_tests/catalogue_tests.rs b/crates/tinycomputer-client/tests/client_tests/catalogue_tests.rs new file mode 100644 index 00000000..878f78ac --- /dev/null +++ b/crates/tinycomputer-client/tests/client_tests/catalogue_tests.rs @@ -0,0 +1,80 @@ +//! Catalogue routing and confidentiality behavior. + +use super::*; + +#[tokio::test] +async fn every_catalogued_operation_is_routed_by_family_and_confidentiality() +-> Result<(), Box> { + let fixture = untrusted_fixture().await?; + let client = client_for(&fixture.bus, None); + let config = ComputerConfig::default(); + client.record_loaded_with(&config)?; + let mut called = 0; + let mut refused_confidential = 0; + + for entry in catalogue::MEMBERS { + assert_eq!( + entry.family, + expected_family(entry.name), + "family for {}", + entry.name + ); + assert_eq!( + entry.confidential, + handler_requires_confidential(entry.name), + "confidential flag for {}", + entry.name + ); + let request = catalogue_request(entry.name)?; + let result = if entry.name == task_names::methods::DESCRIBE { + let described = client.describe(&config).await?; + assert_eq!( + described.contract_version, + tinycomputer_bus::CONTRACT_VERSION + ); + called += 1; + continue; + } else { + match entry.family { + Family::Task => { + client + .call_task::<_, Value>(&config, entry.name, request) + .await + } + Family::Flow | Family::Desktop | Family::Browser => { + client + .call_desktop::<_, Value>(&config, entry.name, request) + .await + } + } + }; + + if entry.confidential { + assert!( + matches!(result, Err(ClientError::Bus(_))), + "unattested recipient must refuse confidential member {}", + entry.name + ); + refused_confidential += 1; + } else { + let value = result?; + assert_catalogue_reply(entry.name, &value); + called += 1; + } + } + + assert!(called > 0); + assert!(refused_confidential > 0); + assert_eq!( + fixture.calls.load(std::sync::atomic::Ordering::Acquire), + called, + "only public calls and Describe reach an untrusted service" + ); + let seen = fixture + .seen + .lock() + .map_err(|_| std::io::Error::other("untrusted call log poisoned"))? + .clone(); + assert!(seen.iter().all(|call| !call.confidential)); + Ok(()) +} diff --git a/crates/tinycomputer-client/tests/client_tests/fail_closed_tests.rs b/crates/tinycomputer-client/tests/client_tests/fail_closed_tests.rs new file mode 100644 index 00000000..ce4569f4 --- /dev/null +++ b/crates/tinycomputer-client/tests/client_tests/fail_closed_tests.rs @@ -0,0 +1,72 @@ +//! Unknown members, wrong families, and malformed envelopes. + +use super::*; + +#[tokio::test] +async fn unknown_members_wrong_families_and_bad_envelopes_fail_closed() +-> Result<(), Box> { + let fixture = untrusted_fixture().await?; + let client = client_for(&fixture.bus, None); + let config = ComputerConfig::default(); + client.record_loaded_with(&config)?; + + assert!(matches!( + client + .call_desktop::<_, Value>(&config, "AccessibilityFutureMember", Value::Null) + .await, + Err(ClientError::UnknownMember(_)) + )); + assert!(matches!( + client + .call_task::<_, Value>( + &config, + tinycomputer_bus::browser::names::methods::NAVIGATE, + Value::Null, + ) + .await, + Err(ClientError::WrongFamily { .. }) + )); + assert!(matches!( + client + .call_desktop::<_, Value>(&config, task_names::methods::PLAN_TASK, Value::Null,) + .await, + Err(ClientError::WrongFamily { .. }) + )); + assert!(matches!( + client + .call_desktop::<_, Value>( + &config, + names::methods::PERMISSIONS, + json!({"malformed_version": true}), + ) + .await, + Err(ClientError::DesktopResponse( + tinycomputer_bus::DesktopResponseError::Version { .. } + )) + )); + assert!(matches!( + client + .call_desktop::<_, String>( + &config, + names::methods::PERMISSIONS, + json!({"wrong_data": true}), + ) + .await, + Err(ClientError::DesktopResponse( + tinycomputer_bus::DesktopResponseError::Decode(_) + )) + )); + assert!(matches!( + client + .call_task::<_, Value>( + &config, + task_names::methods::PLAN_TASK, + json!({"malformed_version": true}), + ) + .await, + Err(ClientError::AgentResponse( + tinycomputer_bus::agent::AgentResponseError::Version { .. } + )) + )); + Ok(()) +} diff --git a/crates/tinycomputer-client/tests/client_tests/linked_module_tests.rs b/crates/tinycomputer-client/tests/client_tests/linked_module_tests.rs new file mode 100644 index 00000000..9902237a --- /dev/null +++ b/crates/tinycomputer-client/tests/client_tests/linked_module_tests.rs @@ -0,0 +1,391 @@ +//! Linked `TinyBus` fixture tests, serialized around their shared static state. + +use super::*; + +#[derive(Default)] +struct TestModuleState { + config: tokio::sync::RwLock, + applied: StdMutex>, + observed: StdMutex>, + focus_gate: tokio::sync::Mutex>, + run_goal_gate: tokio::sync::Mutex>, + name_acquired: std::sync::atomic::AtomicBool, +} + +struct FocusGate { + entered: tokio::sync::oneshot::Sender<()>, + release: tokio::sync::oneshot::Receiver<()>, +} + +static LINKED_STATE: OnceLock>>> = OnceLock::new(); +static LINKED_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +fn linked_state_slot() -> &'static StdMutex>> { + LINKED_STATE.get_or_init(|| StdMutex::new(None)) +} + +fn linked_setup_factory() +-> impl Fn(Connection, ComputerConfig) -> Pin> + Send>> ++ Clone ++ Send ++ Sync ++ 'static { + let state = Arc::new(TestModuleState::default()); + *linked_state_slot() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::clone(&state)); + move |connection, config| { + let state = Arc::clone(&state); + Box::pin(async move { + state + .applied + .lock() + .map_err(|_| TinyBusError::failed("test configuration state poisoned"))? + .push(config.clone()); + *state.config.write().await = config; + connection + .serve_at( + ObjectPath::new(names::OBJECT_PATH)?, + LinkedService(Arc::clone(&state)), + ) + .await?; + if !state + .name_acquired + .swap(true, std::sync::atomic::Ordering::AcqRel) + { + connection.request_name(names::INTERFACE).await?; + } + Ok(()) + }) + } +} + +mod linked_service { + fn setup_factory() -> impl Fn( + tinybus::Connection, + tinycomputer_bus::config::ComputerConfig, + ) -> std::pin::Pin< + Box> + Send>, + > + Clone + + Send + + Sync { + super::linked_setup_factory() + } + + tinybus_module::module_export_static! { + setup_factory = setup_factory, + config = tinycomputer_bus::config::ComputerConfig, + worker_threads = 1, + provides = ["ai.tinyhumans.tinycomputer.Desktop"], + methods = ["Describe", "AccessibilityFocus", "RunGoal"], + signals = [], + requires = [], + optional = [], + lazy = false, + } +} + +struct LinkedService(Arc); + +#[async_trait] +impl Interface for LinkedService { + fn name(&self) -> InterfaceName { + static_contract_value(InterfaceName::new(names::INTERFACE)) + } + + fn members(&self) -> Vec { + [ + task_names::methods::DESCRIBE, + names::accessibility::ACCESSIBILITY_FOCUS, + names::methods::RUN_GOAL, + ] + .iter() + .map(|member| static_contract_value(MemberName::new(*member))) + .collect() + } + + fn requires_confidential(&self, member: &MemberName) -> bool { + handler_requires_confidential(member.as_str()) + } + + async fn call(&self, member: &MemberName, args: Value) -> tinybus::Result { + self.reply(member, &args).await + } + + async fn call_with_context( + &self, + member: &MemberName, + args: Value, + context: &tinybus::service::CallContext, + ) -> tinybus::Result { + self.0 + .observed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(ObservedCall { + member: member.as_str().to_owned(), + confidential: context.is_confidential(), + args: args.clone(), + }); + self.reply(member, &args).await + } +} + +impl LinkedService { + async fn reply(&self, member: &MemberName, args: &Value) -> tinybus::Result { + if member.as_str() == task_names::methods::DESCRIBE + && !args.as_array().is_some_and(Vec::is_empty) + { + return Err(TinyBusError::failed("Describe takes no arguments")); + } + + if member.as_str() == names::accessibility::ACCESSIBILITY_FOCUS { + if let Some(gate) = self.0.focus_gate.lock().await.take() { + let _ = gate.entered.send(()); + let _ = gate.release.await; + } + return serde_json::to_value(DesktopResponse::ok( + member.as_str(), + serde_json::to_value(FocusedTextContext { + app_name: Some("linked fixture".to_owned()), + role: Some("text".to_owned()), + text: "attested result".to_owned(), + selected_text: None, + raw_error: None, + bounds: None, + }) + .map_err(|error| TinyBusError::failed(error.to_string()))?, + )) + .map_err(|error| TinyBusError::failed(error.to_string())); + } + + let config = self.0.config.read().await.clone(); + if member.as_str() == names::methods::RUN_GOAL { + if let Some(gate) = self.0.run_goal_gate.lock().await.take() { + let _ = gate.entered.send(()); + let _ = gate.release.await; + } + let request = args + .as_array() + .and_then(|items| items.first()) + .cloned() + .unwrap_or(Value::Null); + return response(member.as_str(), &request, &config); + } + + serde_json::to_value(test_capabilities(&config)) + .map_err(|error| TinyBusError::failed(error.to_string())) + } +} + +struct LinkedFixture { + client: ComputerClient, + state: Arc, + _host: Arc, + _broker_task: tokio::task::JoinHandle>, +} + +fn linked_fixture(initial: &ComputerConfig) -> Result> { + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let host = Arc::new(ModuleHost::new(broker)); + let initial_value = serde_json::to_value(initial)?; + host.attach_linked_with_config(linked_service::linked_module()?, initial_value)?; + let state_guard = linked_state_slot() + .lock() + .map_err(|_| std::io::Error::other("linked test state poisoned"))?; + let state = Arc::clone( + state_guard + .as_ref() + .ok_or_else(|| std::io::Error::other("linked setup did not publish its test state"))?, + ); + let client = client_for(&bus, Some(Arc::clone(&host))); + client.record_loaded_with(initial)?; + Ok(LinkedFixture { + client, + state, + _host: host, + _broker_task: broker_task, + }) +} + +#[tokio::test(flavor = "current_thread")] +async fn describe_uses_the_zero_argument_wire_shape() -> Result<(), Box> { + let _serial = LINKED_TEST_LOCK.lock().await; + let config = ComputerConfig::default(); + let fixture = linked_fixture(&config)?; + + let described = fixture.client.describe(&config).await?; + + assert_eq!( + described.contract_version, + tinycomputer_bus::CONTRACT_VERSION + ); + let describe_call = fixture + .state + .observed + .lock() + .map_err(|_| std::io::Error::other("observed calls poisoned"))? + .iter() + .find(|call| call.member == task_names::methods::DESCRIBE) + .cloned() + .ok_or_else(|| std::io::Error::other("Describe call was not observed"))?; + assert_eq!(describe_call.args, Value::Array(Vec::new())); + Ok(()) +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn run_goal_remains_pending_past_the_default_bus_timeout() +-> Result<(), Box> { + let _serial = LINKED_TEST_LOCK.lock().await; + let config = ComputerConfig::default(); + let fixture = linked_fixture(&config)?; + let (entered_tx, mut entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + *fixture.state.run_goal_gate.lock().await = Some(FocusGate { + entered: entered_tx, + release: release_rx, + }); + let keep_clock_scheduled = tokio::spawn(async { + loop { + tokio::task::yield_now().await; + } + }); + + let client = fixture.client.clone(); + let call_config = config.clone(); + let mut call = tokio::spawn(async move { + client + .call_desktop::<_, String>( + &call_config, + names::methods::RUN_GOAL, + json!({"goal": "wait for the fixture"}), + ) + .await + }); + tokio::select! { + biased; + result = &mut call => { + keep_clock_scheduled.abort(); + return Err(std::io::Error::other(format!( + "RunGoal call finished before reaching its service gate: {result:?}" + )).into()); + } + result = &mut entered_rx => { + result.map_err(|_| std::io::Error::other("RunGoal service exited before the gate"))?; + } + } + + tokio::time::advance(Duration::from_secs(31)).await; + tokio::task::yield_now().await; + assert!( + !call.is_finished(), + "RunGoal must use its extended deadline instead of TinyBus' default" + ); + + release_tx + .send(()) + .map_err(|()| std::io::Error::other("RunGoal gate closed before release"))?; + let result = call.await?; + keep_clock_scheduled.abort(); + let _ = keep_clock_scheduled.await; + assert_eq!(result?, names::methods::RUN_GOAL); + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn attested_client_keeps_configuration_exclusive_through_member_completion() +-> Result<(), Box> { + let _serial = LINKED_TEST_LOCK.lock().await; + let first_config = ComputerConfig { + jev: Some(JevConfig::new("fixture-secret")), + ..ComputerConfig::default() + }; + let second_config = ComputerConfig::default(); + let fixture = linked_fixture(&first_config)?; + assert!(format!("{:?}", fixture.client).contains("ComputerClient")); + + let described = fixture.client.describe(&first_config).await?; + assert!(described.jev_configured); + assert_eq!( + fixture + .state + .applied + .lock() + .map_err(|_| std::io::Error::other("applied config log poisoned"))? + .len(), + 1 + ); + + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + *fixture.state.focus_gate.lock().await = Some(FocusGate { + entered: entered_tx, + release: release_rx, + }); + let first_client = fixture.client.clone(); + let call_config = first_config.clone(); + let focus = tokio::spawn(async move { + first_client + .call_desktop::<_, FocusedTextContext>( + &call_config, + names::accessibility::ACCESSIBILITY_FOCUS, + FocusQuery::default(), + ) + .await + }); + entered_rx.await?; + + let second_client = fixture.client.clone(); + let next_config = second_config.clone(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let next = tokio::spawn(async move { + let _ = started_tx.send(()); + second_client.describe(&next_config).await + }); + started_rx.await?; + tokio::task::yield_now().await; + assert!( + !next.is_finished(), + "second call waits for the active guard" + ); + assert_eq!( + fixture + .state + .applied + .lock() + .map_err(|_| std::io::Error::other("applied config log poisoned"))? + .len(), + 1 + ); + + release_tx + .send(()) + .map_err(|()| std::io::Error::other("focus gate closed before release"))?; + let focused = focus.await??; + assert_eq!(focused.text, "attested result"); + let focus_call = fixture + .state + .observed + .lock() + .map_err(|_| std::io::Error::other("observed calls poisoned"))? + .iter() + .find(|call| call.member == names::accessibility::ACCESSIBILITY_FOCUS) + .cloned() + .ok_or_else(|| std::io::Error::other("linked focus call was not observed"))?; + assert!(focus_call.confidential); + assert_eq!(focus_call.args[0]["verbose"], false); + let reconfigured = next.await??; + assert!(!reconfigured.jev_configured); + assert_eq!( + fixture + .state + .applied + .lock() + .map_err(|_| std::io::Error::other("applied config log poisoned"))? + .len(), + 2 + ); + Ok(()) +} diff --git a/crates/tinycomputer-client/tests/client_tests/support/common.rs b/crates/tinycomputer-client/tests/client_tests/support/common.rs new file mode 100644 index 00000000..a8cdd9cd --- /dev/null +++ b/crates/tinycomputer-client/tests/client_tests/support/common.rs @@ -0,0 +1,386 @@ +//! In-memory services and common contract assertions. + +use super::*; + +pub(crate) const TEST_MODULE_ID: &str = env!("CARGO_PKG_NAME"); + +pub(crate) fn static_contract_value(result: Result) -> T { + match result { + Ok(value) => value, + Err(error) => { + eprintln!("client test failed: {error:?}"); + std::process::exit(1); + } + } +} + +pub(crate) static TEST_RECORD: ReleaseRecord = ReleaseRecord { + id: TEST_MODULE_ID, + bus_name: names::INTERFACE, + object_path: names::OBJECT_PATH, + version: "0.16.0", + release_url: "https://example.invalid/test", + assets: &[], +}; + +#[derive(Default)] +pub(crate) struct SilentSink; + +impl FailureSink for SilentSink { + fn capture(&self, _report: &tinybus::module::failure::FailureReport<'_>) {} +} + +pub(crate) struct TestAccess { + pub(crate) bus: MemoryBus, + host: Option>, + reporter: FailureReporter, +} + +impl TestAccess { + fn new(bus: MemoryBus, host: Option>) -> Self { + Self { + bus, + host, + reporter: FailureReporter::new(Arc::new(SilentSink)), + } + } +} + +impl ModuleAccess for TestAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == TEST_MODULE_ID).then_some(&TEST_RECORD) + } + + fn enabled(&self) -> bool { + true + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>(&'a self, _id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async move { + if let Some(host) = &self.host { + host.ensure_ready(names::INTERFACE) + .await + .map_err(|error| error.to_string())?; + } + Ok(()) + }) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + let bus = self.bus.clone(); + let bus_name = bus_name.to_owned(); + let object_path = object_path.to_owned(); + Box::pin(async move { + let transport = bus.connect().await.map_err(|_| ProxyFailure::Transport)?; + let connection = Connection::connect(transport) + .await + .map_err(|_| ProxyFailure::Transport)?; + connection + .proxy(&bus_name, &object_path, &bus_name) + .map_err(|_| ProxyFailure::Contract) + }) + } + + fn reinitialize<'a>( + &'a self, + id: &'a str, + config: Value, + ) -> AccessFuture<'a, Result<(), ReconfigureFailure>> { + Box::pin(async move { + let Some(host) = &self.host else { + return Ok(()); + }; + host.reinitialize(id, config) + .await + .map(|_| ()) + .map_err(|_| ReconfigureFailure::Failed) + }) + } +} + +pub(crate) fn client_for(bus: &MemoryBus, host: Option>) -> ComputerClient { + ComputerClient::new(ReconfigurableModule::new( + TestAccess::new(bus.clone(), host), + TEST_MODULE_ID, + )) +} + +pub(crate) fn test_capabilities(config: &ComputerConfig) -> Capabilities { + Capabilities { + contract_version: tinycomputer_bus::CONTRACT_VERSION, + surfaces: Vec::new(), + jev_configured: config.jev.is_some(), + planner_configured: config.planner.is_some(), + rescue_configured: false, + output_configured: false, + decision_model: None, + planner_model: None, + rescue_model: None, + output_model: None, + step_kinds: Vec::new(), + guide: String::new(), + members: Vec::new(), + examples: Vec::new(), + catalogue: catalogue::summaries(), + } +} + +pub(crate) const FLOW_MEMBERS: &[&str] = &[ + names::methods::RESOLVE_INTENT, + names::methods::RUN_GOAL, + names::methods::RUN_FLOW, + names::methods::VALIDATE_FLOW, + names::methods::FLOW_GUIDE, +]; + +pub(crate) fn expected_family(member: &str) -> Family { + if task_names::METHODS.contains(&member) { + Family::Task + } else if tinycomputer_bus::browser::names::METHODS.contains(&member) { + Family::Browser + } else if FLOW_MEMBERS.contains(&member) { + Family::Flow + } else { + Family::Desktop + } +} + +pub(crate) fn catalogue_request(member: &str) -> Result { + Ok(match member { + tinycomputer_bus::browser::names::methods::NAVIGATE => { + serde_json::to_value(SessionRequest::new( + SessionId::new("catalogue-session"), + NavigateRequest::new("https://example.com"), + ))? + } + tinycomputer_bus::browser::names::methods::CLAIM_PROFILE => { + serde_json::to_value(ClaimProfileRequest::new("/profile/catalogue", 0))? + } + tinycomputer_bus::browser::names::methods::READ_OUTPUT => { + serde_json::to_value(ReadOutputRequest::new(OutputId::new("catalogue-output")))? + } + _ => json!({"member": member}), + }) +} + +pub(crate) fn assert_catalogue_reply(member: &str, value: &Value) { + match member { + tinycomputer_bus::browser::names::methods::NAVIGATE => { + assert_eq!(value["url"], "https://example.com"); + } + tinycomputer_bus::browser::names::methods::CLAIM_PROFILE => { + assert_eq!(value["outcome"], "claimed"); + assert_eq!(value["path"], "/profile/fixture"); + } + tinycomputer_bus::browser::names::methods::READ_OUTPUT => { + assert_eq!(value["id"], "catalogue-output"); + assert_eq!(value["offset"], 0); + assert_eq!(value["data"], "aGVsbG8="); + assert_eq!(value["eof"], true); + } + _ => assert_eq!(*value, json!(member), "member {member} reply"), + } +} + +pub(crate) fn response( + member: &str, + request: &Value, + config: &ComputerConfig, +) -> tinybus::Result { + let malformed_version = request + .get("malformed_version") + .and_then(Value::as_bool) + .unwrap_or(false); + let wrong_data = request + .get("wrong_data") + .and_then(Value::as_bool) + .unwrap_or(false); + + if member == task_names::methods::DESCRIBE { + return serde_json::to_value(test_capabilities(config)) + .map_err(|error| TinyBusError::failed(error.to_string())); + } + + let family = expected_family(member); + if family == Family::Task { + if malformed_version { + return Ok(json!({ + "version": "unsupported", + "ok": true, + "data": "unexpected", + "error": null + })); + } + let data = if wrong_data { json!(17) } else { json!(member) }; + return serde_json::to_value(AgentResponse::ok(data)) + .map_err(|error| TinyBusError::failed(error.to_string())); + } + + if malformed_version { + return Ok(json!({ + "version": "unsupported", + "ok": true, + "command": member, + "data": "unexpected", + "error": null + })); + } + + let data = if wrong_data { + json!(17) + } else { + match member { + "AccessibilityFocus" => serde_json::to_value(FocusedTextContext { + app_name: Some("fixture".to_owned()), + role: Some("text".to_owned()), + text: "typed response".to_owned(), + selected_text: None, + raw_error: None, + bounds: None, + }) + .map_err(|error| TinyBusError::failed(error.to_string()))?, + "BrowserClaimProfile" => serde_json::to_value(ClaimProfileResponse { + outcome: ProfileClaim::Claimed, + path: Some("/profile/fixture".to_owned()), + }) + .map_err(|error| TinyBusError::failed(error.to_string()))?, + "BrowserNavigate" => serde_json::to_value(PageState::new("https://example.com")) + .map_err(|error| TinyBusError::failed(error.to_string()))?, + "BrowserReadOutput" => { + let read: ReadOutputRequest = serde_json::from_value(request.clone()) + .map_err(|error| TinyBusError::failed(error.to_string()))?; + serde_json::to_value(OutputChunk { + id: read.output, + offset: read.offset, + data: "aGVsbG8=".to_owned(), + eof: true, + }) + .map_err(|error| TinyBusError::failed(error.to_string()))? + } + _ => json!(member), + } + }; + serde_json::to_value(DesktopResponse::ok(member, data)) + .map_err(|error| TinyBusError::failed(error.to_string())) +} + +pub(crate) struct EchoService { + config: Arc>, + pub(crate) calls: Arc, + pub(crate) seen: Arc>>, +} + +#[async_trait] +impl Interface for EchoService { + fn name(&self) -> InterfaceName { + static_contract_value(InterfaceName::new(names::INTERFACE)) + } + + fn members(&self) -> Vec { + names::METHODS + .iter() + .map(|member| static_contract_value(MemberName::new(*member))) + .collect() + } + + fn requires_confidential(&self, member: &MemberName) -> bool { + handler_requires_confidential(member.as_str()) + } + + async fn call(&self, member: &MemberName, args: Value) -> tinybus::Result { + self.invoke(member, args).await + } + + async fn call_with_context( + &self, + member: &MemberName, + args: Value, + context: &tinybus::service::CallContext, + ) -> tinybus::Result { + self.seen + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(ObservedCall { + member: member.as_str().to_owned(), + confidential: context.is_confidential(), + args: args.clone(), + }); + self.invoke(member, args).await + } +} + +impl EchoService { + async fn invoke(&self, member: &MemberName, args: Value) -> tinybus::Result { + self.calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel); + let request = args + .as_array() + .and_then(|items| items.first()) + .cloned() + .unwrap_or(Value::Null); + let config = self.config.read().await.clone(); + response(member.as_str(), &request, &config) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ObservedCall { + pub(crate) member: String, + pub(crate) confidential: bool, + pub(crate) args: Value, +} + +pub(crate) fn handler_requires_confidential(member: &str) -> bool { + task_names::CONFIDENTIAL.contains(&member) + || [ + names::methods::RESOLVE_INTENT, + names::methods::RUN_GOAL, + names::methods::RUN_FLOW, + names::accessibility::ACCESSIBILITY_FOCUS, + names::accessibility::ACCESSIBILITY_VALIDATE_TARGET, + names::accessibility::ACCESSIBILITY_PASTE, + ] + .contains(&member) +} + +pub(crate) struct UntrustedFixture { + pub(crate) bus: MemoryBus, + pub(crate) _broker_task: tokio::task::JoinHandle>, + pub(crate) _service_connection: Connection, + pub(crate) calls: Arc, + pub(crate) seen: Arc>>, +} + +pub(crate) async fn untrusted_fixture() -> Result> { + let bus = MemoryBus::new(); + let broker_task = Broker::new().spawn(bus.clone()); + let transport = bus.connect().await?; + let service_connection = Connection::connect(transport).await?; + service_connection.request_name(names::INTERFACE).await?; + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = Arc::new(StdMutex::new(Vec::new())); + service_connection + .serve_at( + ObjectPath::new(names::OBJECT_PATH)?, + EchoService { + config: Arc::new(tokio::sync::RwLock::new(ComputerConfig::default())), + calls: Arc::clone(&calls), + seen: Arc::clone(&seen), + }, + ) + .await?; + Ok(UntrustedFixture { + bus, + _broker_task: broker_task, + _service_connection: service_connection, + calls, + seen, + }) +} diff --git a/crates/tinycomputer-client/tests/client_tests/support/mod.rs b/crates/tinycomputer-client/tests/client_tests/support/mod.rs new file mode 100644 index 00000000..a68b4531 --- /dev/null +++ b/crates/tinycomputer-client/tests/client_tests/support/mod.rs @@ -0,0 +1,6 @@ +//! Shared fixtures and helpers for the client integration tests. + +use super::*; + +mod common; +pub(crate) use common::*; diff --git a/crates/tinycomputer-client/tests/client_tests/typed_round_trip_tests.rs b/crates/tinycomputer-client/tests/client_tests/typed_round_trip_tests.rs new file mode 100644 index 00000000..74d7125b --- /dev/null +++ b/crates/tinycomputer-client/tests/client_tests/typed_round_trip_tests.rs @@ -0,0 +1,115 @@ +//! Typed member request and response behavior. + +use super::*; + +#[tokio::test] +async fn typed_accessibility_browser_profile_task_and_held_output_calls_round_trip() +-> Result<(), Box> { + let fixture = untrusted_fixture().await?; + let client = client_for(&fixture.bus, None); + let config = ComputerConfig::default(); + client.record_loaded_with(&config)?; + + let focus = client + .call_desktop::<_, FocusedTextContext>( + &config, + names::accessibility::ACCESSIBILITY_FOCUS, + FocusQuery::default(), + ) + .await; + assert!(matches!(focus, Err(ClientError::Bus(_)))); + + let claimed = client + .call_desktop::<_, ClaimProfileResponse>( + &config, + tinycomputer_bus::browser::names::methods::CLAIM_PROFILE, + ClaimProfileRequest::new("/profile/fixture", 0), + ) + .await?; + assert_eq!(claimed.outcome, ProfileClaim::Claimed); + assert_eq!(claimed.path.as_deref(), Some("/profile/fixture")); + + let navigated = client + .call_desktop::<_, PageState>( + &config, + tinycomputer_bus::browser::names::methods::NAVIGATE, + SessionRequest::new( + SessionId::new("session-1"), + NavigateRequest::new("https://example.com"), + ), + ) + .await?; + assert_eq!(navigated.url, "https://example.com"); + + let chunk = client + .call_desktop::<_, OutputChunk>( + &config, + tinycomputer_bus::browser::names::methods::READ_OUTPUT, + ReadOutputRequest::new(OutputId::new("held-image-1")), + ) + .await?; + assert_eq!(chunk.id.as_str(), "held-image-1"); + assert_eq!(chunk.offset, 0); + assert_eq!(chunk.data, "aGVsbG8="); + assert!(chunk.eof); + + let planned = client + .call_task::<_, Value>( + &config, + task_names::methods::PLAN_TASK, + tinycomputer_bus::agent::PlanTaskRequest::default(), + ) + .await?; + assert_eq!(planned, json!(task_names::methods::PLAN_TASK)); + + let seen = fixture + .seen + .lock() + .map_err(|_| std::io::Error::other("untrusted call log poisoned"))? + .clone(); + for member in [ + tinycomputer_bus::browser::names::methods::CLAIM_PROFILE, + tinycomputer_bus::browser::names::methods::NAVIGATE, + tinycomputer_bus::browser::names::methods::READ_OUTPUT, + task_names::methods::PLAN_TASK, + ] { + let observed = seen + .iter() + .find(|call| call.member == member) + .ok_or_else(|| std::io::Error::other("typed member was not observed"))?; + assert!(!observed.confidential, "{member} is public"); + assert_eq!(observed.args.as_array().map(Vec::len), Some(1)); + } + let claim = seen + .iter() + .find(|call| call.member == tinycomputer_bus::browser::names::methods::CLAIM_PROFILE) + .ok_or_else(|| std::io::Error::other("claim request was not observed"))?; + assert_eq!( + claim.args[0], + json!({ + "path": "/profile/fixture", + "wait_ms": 0, + "fall_back_to_fresh": true + }) + ); + let navigation = seen + .iter() + .find(|call| call.member == tinycomputer_bus::browser::names::methods::NAVIGATE) + .ok_or_else(|| std::io::Error::other("navigation request was not observed"))?; + assert_eq!(navigation.args[0]["session"], "session-1"); + assert_eq!(navigation.args[0]["url"], "https://example.com"); + let held_output = seen + .iter() + .find(|call| call.member == tinycomputer_bus::browser::names::methods::READ_OUTPUT) + .ok_or_else(|| std::io::Error::other("held-output request was not observed"))?; + assert_eq!(held_output.args[0]["output"], "held-image-1"); + assert_eq!(held_output.args[0]["offset"], 0); + assert_eq!( + held_output.args[0]["max_len"], + ReadOutputRequest::DEFAULT_LEN + ); + + assert_eq!(RUN_GOAL_TIMEOUT, Duration::from_secs(330)); + assert_eq!(TASK_CALL_TIMEOUT, Duration::from_secs(75)); + Ok(()) +} From a3609594a7db8cee29e3c4b49cad690d221111ea Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 04:38:27 +0000 Subject: [PATCH 08/32] feat: enforce browser egress through host callbacks Co-authored-by: Medulla --- Cargo.lock | 1 + crates/tinycomputer-browser/Cargo.toml | 6 +- .../tinycomputer-browser/src/egress/README.md | 61 ++++ .../src/egress/egress_tests.rs | 35 ++ .../egress/egress_tests/authority_tests.rs | 48 +++ .../src/egress/egress_tests/body_tests.rs | 344 ++++++++++++++++++ .../src/egress/egress_tests/connect_tests.rs | 63 ++++ .../src/egress/egress_tests/forward_tests.rs | 225 ++++++++++++ .../src/egress/egress_tests/http_tests.rs | 210 +++++++++++ .../src/egress/egress_tests/lease_tests.rs | 124 +++++++ .../egress/egress_tests/lifecycle_tests.rs | 26 ++ .../src/egress/egress_tests/retry_tests.rs | 56 +++ .../src/egress/egress_tests/simulator.rs | 169 +++++++++ .../egress/egress_tests/websocket_tests.rs | 77 ++++ .../src/egress/forward.rs | 258 +++++++++++++ crates/tinycomputer-browser/src/egress/mod.rs | 201 ++++++++++ .../src/egress/request.rs | 263 +++++++++++++ .../src/egress/request/body.rs | 72 ++++ .../src/egress/request/body/chunked.rs | 184 ++++++++++ .../src/egress/request/body/chunked_tests.rs | 75 ++++ .../tinycomputer-browser/src/egress/serve.rs | 140 +++++++ crates/tinycomputer-browser/src/lib.rs | 4 +- .../src/linked/linked_tests.rs | 70 +++- crates/tinycomputer-browser/src/linked/mod.rs | 287 ++++++++++++++- crates/tinycomputer-bus/src/agent/mod.rs | 9 +- .../tinycomputer-bus/src/agent/types/mod.rs | 6 +- .../src/agent/types/request.rs | 37 +- .../src/agent/types/request_tests.rs | 24 ++ .../browser_egress/browser_egress_tests.rs | 106 ++++++ .../src/browser_egress/mod.rs | 28 ++ .../src/browser_egress/types.rs | 86 +++++ .../consequence_host_tests.rs | 33 ++ .../src/consequence_host/mod.rs | 6 + crates/tinycomputer-bus/src/lib.rs | 1 + .../src/tinybus_module/browser_egress.rs | 153 ++++++++ .../browser_egress/browser_egress_tests.rs | 186 ++++++++++ .../tinycomputer/src/tinybus_module/config.rs | 64 +++- .../consequence_gate_tests.rs | 27 +- .../tinybus_module/consequence_gate/mod.rs | 19 +- .../src/tinybus_module/dispatch/service.rs | 57 ++- crates/tinycomputer/src/tinybus_module/mod.rs | 5 +- .../tinybus_module_tests/config_tests.rs | 32 +- 42 files changed, 3843 insertions(+), 35 deletions(-) create mode 100644 crates/tinycomputer-browser/src/egress/README.md create mode 100644 crates/tinycomputer-browser/src/egress/egress_tests.rs create mode 100644 crates/tinycomputer-browser/src/egress/egress_tests/authority_tests.rs create mode 100644 crates/tinycomputer-browser/src/egress/egress_tests/body_tests.rs create mode 100644 crates/tinycomputer-browser/src/egress/egress_tests/connect_tests.rs create mode 100644 crates/tinycomputer-browser/src/egress/egress_tests/forward_tests.rs create mode 100644 crates/tinycomputer-browser/src/egress/egress_tests/http_tests.rs create mode 100644 crates/tinycomputer-browser/src/egress/egress_tests/lease_tests.rs create mode 100644 crates/tinycomputer-browser/src/egress/egress_tests/lifecycle_tests.rs create mode 100644 crates/tinycomputer-browser/src/egress/egress_tests/retry_tests.rs create mode 100644 crates/tinycomputer-browser/src/egress/egress_tests/simulator.rs create mode 100644 crates/tinycomputer-browser/src/egress/egress_tests/websocket_tests.rs create mode 100644 crates/tinycomputer-browser/src/egress/forward.rs create mode 100644 crates/tinycomputer-browser/src/egress/mod.rs create mode 100644 crates/tinycomputer-browser/src/egress/request.rs create mode 100644 crates/tinycomputer-browser/src/egress/request/body.rs create mode 100644 crates/tinycomputer-browser/src/egress/request/body/chunked.rs create mode 100644 crates/tinycomputer-browser/src/egress/request/body/chunked_tests.rs create mode 100644 crates/tinycomputer-browser/src/egress/serve.rs create mode 100644 crates/tinycomputer-bus/src/agent/types/request_tests.rs create mode 100644 crates/tinycomputer-bus/src/browser_egress/browser_egress_tests.rs create mode 100644 crates/tinycomputer-bus/src/browser_egress/mod.rs create mode 100644 crates/tinycomputer-bus/src/browser_egress/types.rs create mode 100644 crates/tinycomputer/src/tinybus_module/browser_egress.rs create mode 100644 crates/tinycomputer/src/tinybus_module/browser_egress/browser_egress_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 455e7dc0..54b028ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2675,6 +2675,7 @@ dependencies = [ "agent-browser", "base64 0.22.1", "getrandom 0.4.3", + "log", "serde_json", "sha2 0.10.9", "sysinfo", diff --git a/crates/tinycomputer-browser/Cargo.toml b/crates/tinycomputer-browser/Cargo.toml index c699924e..723330ba 100644 --- a/crates/tinycomputer-browser/Cargo.toml +++ b/crates/tinycomputer-browser/Cargo.toml @@ -36,9 +36,10 @@ tinycomputer-cursor = { workspace = true } url = { workspace = true } # Per-session locks held across engine calls; `rt` for the runtime handle a # blocking surface call runs the async browser on. -tokio = { workspace = true, features = ["sync", "rt", "time"] } +tokio = { workspace = true, features = ["sync", "rt", "time", "net", "io-util"] } # Whether the process a Chrome profile lock names is alive. sysinfo = { workspace = true } +log = { workspace = true } # The linked engine, with the `agent-browser` feature. Without it the crate # is the adapter alone, for hosts that bring their own `Launcher`. @@ -48,9 +49,10 @@ agent-browser = { workspace = true, optional = true } default = [] # Link agent-browser in-process and provide `AgentBrowser`, its `Launcher`. agent-browser = ["dep:agent-browser"] +test-connector = [] [dev-dependencies] -tokio = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } [lints] workspace = true diff --git a/crates/tinycomputer-browser/src/egress/README.md b/crates/tinycomputer-browser/src/egress/README.md new file mode 100644 index 00000000..2b4d7e4b --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/README.md @@ -0,0 +1,61 @@ +# Browser egress proxy + +This module gives an explicitly launched Chromium session a private HTTP proxy +whose destination decisions come from the TinyComputer host callback. It keeps +the engine adapter (`EgressAuthorizer`) small: the host authorizes the parsed +destination, returns a one-use grant and exact socket addresses, consumes a +grant before each dial, and revalidates active leases. + +Host integration tests can enable the `test-connector` feature and provide +`EgressProxy::bind_with_test_connector` (or the corresponding +`EnforcedAgentBrowser` builder) to replace only the final TCP operation. The +connector receives the exact approved socket after grant consumption. The +TinyComputer module exposes a stricter module-config mapping for dynamic test +artifacts; it accepts one `8.8.8.8:port` to loopback `127.0.0.1:port` mapping +and denies all other sockets. Production builds do not parse that test-only +field. In a `test-connector` build, a host fixture supplies it at +`browser.egress.test_connector_map` as +`{"approved":"8.8.8.8:","fixture":"127.0.0.1:"}`. + +The loaded-module Chromium test is opt-in and is not part of hosted CI, whose +module-e2e lane packages and verifies the artifact without launching Chromium. +With Chromium installed, run the focused test locally with: + +```sh +TINYCOMPUTER_LIVE_BROWSER_EGRESS_TESTS=1 cargo test -p tinycomputer \ + --features static-link,test-connector \ + browser_egress_live_tests::public_connector_tests::loaded_module_consumes_public_socket_before_fixture_mapping \ + -- --nocapture +``` + +It uses a local HTTP listener and the TinyComputer fixture callback host; the +host's real TinySecurity policy composition is covered by the separate +OpenHuman fixture. + +## Structure + +- `mod.rs` defines the public `BrowserDialTarget`, `DialGrant`, + `EgressAuthorizer`, and `EgressProxy` surface and owns the loopback listener. +- `request.rs` authenticates each local proxy client, parses HTTP proxy + requests, validates authorities and framing, and streams fixed-length and + chunked request bodies with bounded buffers. +- `forward.rs` writes sanitized upstream headers and forwards request and + response data while checking active leases. +- `serve.rs` sequences authorization, grant consumption, exact-address + connection, request forwarding, and response handling for one connection. + +## Operational constraints + +The proxy binds IPv4 loopback and requires Basic authentication with a random +per-session password. It never resolves destination names; it dials only an +address returned by the host and consumes the matching one-use grant first. +Only absolute `http` proxy requests, CONNECT requests, WebSocket upgrades, +fixed-length uploads, and valid HTTP/1.1 chunked uploads are accepted. Ambiguous +framing, caller-selected transfer encodings, unauthenticated clients, malformed +chunk framing, and bytes following a framed request body are refused. + +HTTPS payloads remain opaque after CONNECT. The proxy checks the lease at least +once per second while uploading, tunneling, or receiving a response, and closes +the connection when validation is denied or fails. Do not add a direct-dial +fallback or locally resolve a host here: destination policy belongs to the +authenticated host callback. diff --git a/crates/tinycomputer-browser/src/egress/egress_tests.rs b/crates/tinycomputer-browser/src/egress/egress_tests.rs new file mode 100644 index 00000000..f864482e --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/egress_tests.rs @@ -0,0 +1,35 @@ +//! Listener lifecycle tests for the browser egress proxy. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::io; +use tokio::net::{TcpListener, TcpStream}; + +mod authority_tests; +mod body_tests; +mod connect_tests; +mod forward_tests; +mod http_tests; +mod lease_tests; +mod lifecycle_tests; +mod retry_tests; +mod simulator; +mod websocket_tests; + +#[tokio::test] +async fn an_accept_error_waits_then_allows_later_connections() { + let failed = super::accept_connection(std::future::ready(Err(io::Error::other( + "transient accept failure", + )))) + .await; + assert!(failed.is_none()); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let client = TcpStream::connect(address); + let (accepted, connected) = tokio::join!(listener.accept(), client); + let accepted = super::accept_connection(std::future::ready(accepted)).await; + + assert!(accepted.is_some()); + connected.unwrap(); +} diff --git a/crates/tinycomputer-browser/src/egress/egress_tests/authority_tests.rs b/crates/tinycomputer-browser/src/egress/egress_tests/authority_tests.rs new file mode 100644 index 00000000..546651d5 --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/egress_tests/authority_tests.rs @@ -0,0 +1,48 @@ +//! Authority parsing and redaction tests. + +use crate::{BrowserDialTarget, DialGrant, egress::parse_authority}; + +#[test] +fn browser_dial_target_debug_does_not_disclose_session_or_destination() { + let target = BrowserDialTarget { + scheme: "https".into(), + hostname: "private.example.test".into(), + port: 443, + session_id: "private-browser-session".into(), + }; + let debug = format!("{target:?}"); + assert!(!debug.contains("private.example.test")); + assert!(!debug.contains("private-browser-session")); + assert!(debug.contains("REDACTED")); +} + +#[test] +fn dial_grant_debug_does_not_disclose_lease_or_destination() { + let grant = DialGrant { + lease: "private-opaque-nonce".into(), + addresses: vec!["203.0.113.77:8443".parse().unwrap()], + }; + let debug = format!("{grant:?}"); + assert!(!debug.contains("private-opaque-nonce")); + assert!(!debug.contains("203.0.113.77")); + assert!(debug.contains("REDACTED")); +} + +#[test] +fn proxy_authority_uses_scheme_defaults_and_canonical_ipv6_literals() { + assert_eq!( + parse_authority("example.test", "http", "session") + .unwrap() + .port, + 80 + ); + assert_eq!( + parse_authority("example.test", "https", "session") + .unwrap() + .port, + 443 + ); + let ipv6 = parse_authority("[2001:4860:4860::8888]:443", "https", "session").unwrap(); + assert_eq!(ipv6.hostname, "2001:4860:4860::8888"); + assert!(parse_authority("https://example.test:443", "https", "session").is_err()); +} diff --git a/crates/tinycomputer-browser/src/egress/egress_tests/body_tests.rs b/crates/tinycomputer-browser/src/egress/egress_tests/body_tests.rs new file mode 100644 index 00000000..b82d4fb6 --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/egress_tests/body_tests.rs @@ -0,0 +1,344 @@ +//! Fixed-length and chunked HTTP upload forwarding tests. + +use super::simulator::*; +use crate::EgressProxy; +use std::sync::{Arc, atomic::AtomicBool}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; + +#[tokio::test] +async fn approved_http_request_body_is_forwarded_without_framing_changes() { + let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = destination.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = destination.accept().await.unwrap(); + let mut request = Vec::new(); + let mut chunk = [0; 512]; + loop { + let n = socket.read(&mut chunk).await.unwrap(); + request.extend_from_slice(&chunk[..n]); + if let Some(split) = request.windows(4).position(|window| window == b"\r\n\r\n") + && request.len() >= split + 8 + { + break; + } + } + assert!(request.ends_with(b"data")); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .await + .unwrap(); + }); + let proxy = EgressProxy::bind( + Arc::new(FixtureAuthorizer { + address, + allow: true, + consumed: Arc::new(AtomicBool::new(false)), + }), + "body-session".into(), + FIXTURE_PASSWORD.into(), + ) + .await + .unwrap(); + let mut client = TcpStream::connect(proxy.url().replace("http://", "")) + .await + .unwrap(); + client + .write_all( + format!( + "POST http://body.test:{}/submit HTTP/1.1\r\nHost: body.test:{}\r\nProxy-Authorization: {}\r\nContent-Length: 4\r\n\r\ndata", + address.port(), + address.port(), + proxy_authorization() + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut response = Vec::new(); + client.read_to_end(&mut response).await.unwrap(); + assert!( + std::str::from_utf8(&response) + .unwrap() + .ends_with("\r\n\r\nok") + ); + server.await.unwrap(); +} + +#[tokio::test] +async fn approved_http_request_streams_bodies_larger_than_one_mibibyte() { + const BODY_LENGTH: usize = 1536 * 1024; + let expected_body = vec![b'x'; BODY_LENGTH]; + let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = destination.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = destination.accept().await.unwrap(); + let mut request = Vec::new(); + let mut chunk = [0; 8192]; + let split = loop { + let n = socket.read(&mut chunk).await.unwrap(); + assert_ne!(n, 0); + request.extend_from_slice(&chunk[..n]); + if let Some(split) = request.windows(4).position(|window| window == b"\r\n\r\n") { + break split + 4; + } + }; + let mut body = request.split_off(split); + let received = body.len(); + assert!(received <= BODY_LENGTH); + body.resize(BODY_LENGTH, 0); + if received < BODY_LENGTH { + socket.read_exact(&mut body[received..]).await.unwrap(); + } + assert_eq!(body, vec![b'x'; BODY_LENGTH]); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .await + .unwrap(); + }); + let proxy = EgressProxy::bind( + Arc::new(FixtureAuthorizer { + address, + allow: true, + consumed: Arc::new(AtomicBool::new(false)), + }), + "large-body-session".into(), + FIXTURE_PASSWORD.into(), + ) + .await + .unwrap(); + let mut client = TcpStream::connect(proxy.url().replace("http://", "")) + .await + .unwrap(); + client + .write_all( + format!( + "POST http://body.test:{}/upload HTTP/1.1\r\nHost: body.test:{}\r\nProxy-Authorization: {}\r\nContent-Length: {BODY_LENGTH}\r\n\r\n", + address.port(), + address.port(), + proxy_authorization() + ) + .as_bytes(), + ) + .await + .unwrap(); + client.write_all(&expected_body).await.unwrap(); + let mut response = Vec::new(); + client.read_to_end(&mut response).await.unwrap(); + assert!( + std::str::from_utf8(&response) + .unwrap() + .ends_with("\r\n\r\nok") + ); + server.await.unwrap(); +} + +#[tokio::test] +async fn approved_http_request_forwards_chunked_uploads() { + let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = destination.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = destination.accept().await.unwrap(); + let mut request = Vec::new(); + let mut chunk = [0; 1024]; + loop { + let n = socket.read(&mut chunk).await.unwrap(); + assert_ne!(n, 0); + request.extend_from_slice(&chunk[..n]); + if request.windows(5).any(|window| window == b"0\r\n\r\n") { + break; + } + } + assert!(request.windows(7).any(|window| window == b"4\r\ndata")); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .await + .unwrap(); + }); + let proxy = EgressProxy::bind( + Arc::new(FixtureAuthorizer { + address, + allow: true, + consumed: Arc::new(AtomicBool::new(false)), + }), + "chunked-body-session".into(), + FIXTURE_PASSWORD.into(), + ) + .await + .unwrap(); + let mut client = TcpStream::connect(proxy.url().replace("http://", "")) + .await + .unwrap(); + client + .write_all( + format!( + "POST http://body.test:{}/stream HTTP/1.1\r\nHost: body.test:{}\r\nProxy-Authorization: {}\r\nTransfer-Encoding: chunked\r\n\r\n4\r\ndata\r\n0\r\n\r\n", + address.port(), + address.port(), + proxy_authorization() + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut response = Vec::new(); + client.read_to_end(&mut response).await.unwrap(); + assert!( + std::str::from_utf8(&response) + .unwrap() + .ends_with("\r\n\r\nok") + ); + server.await.unwrap(); +} + +#[tokio::test] +async fn an_early_upstream_rejection_is_forwarded_before_upload_completion() { + let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = destination.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = destination.accept().await.unwrap(); + let mut request = Vec::new(); + let mut chunk = [0; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let n = socket.read(&mut chunk).await.unwrap(); + assert_ne!(n, 0); + request.extend_from_slice(&chunk[..n]); + } + socket + .write_all( + b"HTTP/1.1 413 Payload Too Large\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + }); + let proxy = EgressProxy::bind( + Arc::new(FixtureAuthorizer { + address, + allow: true, + consumed: Arc::new(AtomicBool::new(false)), + }), + "early-rejection-session".into(), + FIXTURE_PASSWORD.into(), + ) + .await + .unwrap(); + let mut client = TcpStream::connect(proxy.url().replace("http://", "")) + .await + .unwrap(); + client + .write_all( + format!( + "POST http://body.test:{}/upload HTTP/1.1\r\nHost: body.test:{}\r\nProxy-Authorization: {}\r\nContent-Length: 10485760\r\n\r\n", + address.port(), + address.port(), + proxy_authorization() + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut response = Vec::new(); + tokio::time::timeout( + std::time::Duration::from_secs(2), + client.read_to_end(&mut response), + ) + .await + .expect("the early upstream response should reach the browser") + .unwrap(); + assert!( + std::str::from_utf8(&response) + .unwrap() + .starts_with("HTTP/1.1 413") + ); + server.await.unwrap(); +} + +#[tokio::test] +async fn a_response_chunk_survives_upload_completion_while_the_client_is_backpressured() { + const REQUEST_BODY_LENGTH: usize = 1024 * 1024; + const RESPONSE_BODY_LENGTH: usize = 8 * 1024 * 1024; + let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = destination.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = destination.accept().await.unwrap(); + let mut request = Vec::new(); + let mut chunk = [0; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let n = socket.read(&mut chunk).await.unwrap(); + assert_ne!(n, 0); + request.extend_from_slice(&chunk[..n]); + } + let request_body_start = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .unwrap() + + 4; + let mut request_body = request.split_off(request_body_start); + let (mut reader, mut writer) = socket.into_split(); + let request_reader = tokio::spawn(async move { + reader.read_to_end(&mut request_body).await.unwrap(); + request_body + }); + writer + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {RESPONSE_BODY_LENGTH}\r\nConnection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await + .unwrap(); + let body = vec![b'x'; RESPONSE_BODY_LENGTH]; + writer.write_all(&body).await.unwrap(); + writer.shutdown().await.unwrap(); + request_reader.await.unwrap() + }); + let proxy = EgressProxy::bind( + Arc::new(FixtureAuthorizer { + address, + allow: true, + consumed: Arc::new(AtomicBool::new(false)), + }), + "response-backpressure-session".into(), + FIXTURE_PASSWORD.into(), + ) + .await + .unwrap(); + let mut client = TcpStream::connect(proxy.url().replace("http://", "")) + .await + .unwrap(); + client + .write_all( + format!( + "POST http://body.test:{}/upload HTTP/1.1\r\nHost: body.test:{}\r\nProxy-Authorization: {}\r\nContent-Length: {REQUEST_BODY_LENGTH}\r\n\r\n", + address.port(), + address.port(), + proxy_authorization() + ) + .as_bytes(), + ) + .await + .unwrap(); + let chunk = [b'y'; 16 * 1024]; + for _ in 0..REQUEST_BODY_LENGTH / chunk.len() { + client.write_all(&chunk).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + let mut response = Vec::new(); + client.read_to_end(&mut response).await.unwrap(); + let response_header_end = response + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("upstream response headers should be present") + + 4; + assert_eq!(response.len() - response_header_end, RESPONSE_BODY_LENGTH); + assert!( + response[response_header_end..] + .iter() + .all(|byte| *byte == b'x') + ); + assert_eq!(server.await.unwrap().len(), REQUEST_BODY_LENGTH); +} diff --git a/crates/tinycomputer-browser/src/egress/egress_tests/connect_tests.rs b/crates/tinycomputer-browser/src/egress/egress_tests/connect_tests.rs new file mode 100644 index 00000000..9d2ccd4c --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/egress_tests/connect_tests.rs @@ -0,0 +1,63 @@ +//! CONNECT proxy tests. + +use super::simulator::*; +use crate::EgressProxy; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; + +#[tokio::test] +async fn approved_connect_reaches_only_the_consumed_socket() { + let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = destination.local_addr().unwrap(); + let authorizer = Arc::new(FixtureAuthorizer { + address, + allow: true, + consumed: Arc::new(AtomicBool::new(false)), + }); + let proxy = EgressProxy::bind( + authorizer.clone(), + "test-session".into(), + FIXTURE_PASSWORD.into(), + ) + .await + .unwrap(); + let ((), upstream_client) = tokio::join!( + async { + let mut client = TcpStream::connect(proxy.url().replace("http://", "")) + .await + .unwrap(); + client + .write_all( + format!( + "CONNECT example.test:{} HTTP/1.1\r\nHost: example.test:{}\r\nProxy-Authorization: {}\r\n\r\n", + address.port(), + address.port(), + proxy_authorization() + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut response = [0; 128]; + let n = client.read(&mut response).await.unwrap(); + assert!( + std::str::from_utf8(&response[..n]) + .unwrap() + .contains("200 Connection Established") + ); + client.write_all(b"ping").await.unwrap(); + }, + destination.accept() + ); + let (mut upstream_client, _) = upstream_client.unwrap(); + assert!(authorizer.consumed.load(Ordering::SeqCst)); + let mut payload = [0; 4]; + upstream_client.read_exact(&mut payload).await.unwrap(); + assert_eq!(&payload, b"ping"); +} diff --git a/crates/tinycomputer-browser/src/egress/egress_tests/forward_tests.rs b/crates/tinycomputer-browser/src/egress/egress_tests/forward_tests.rs new file mode 100644 index 00000000..bf799cc1 --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/egress_tests/forward_tests.rs @@ -0,0 +1,225 @@ +//! Lease validation tests for response forwarding. + +use std::{ + future::Future, + io, + net::SocketAddr, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + task::{Context, Poll}, + time::Duration, +}; + +use tokio::io::{AsyncWrite, AsyncWriteExt}; + +use super::super::{BrowserDialTarget, DialGrant, EgressAuthorizer}; + +struct RevokingAuthorizer(AtomicUsize); + +impl EgressAuthorizer for RevokingAuthorizer { + fn authorize( + &self, + _target: BrowserDialTarget, + ) -> Pin> + Send>> { + Box::pin(async { Err(io::Error::other("unused authorization")) }) + } + + fn consume( + &self, + _target: BrowserDialTarget, + _grant: DialGrant, + _address: SocketAddr, + ) -> Pin> + Send>> { + Box::pin(async { Err(io::Error::other("unused grant")) }) + } + + fn validate(&self, _lease: String) -> Pin> + Send>> { + let validation = self.0.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { Ok(validation == 0) }) + } +} + +struct BlockedWriter; + +impl AsyncWrite for BlockedWriter { + fn poll_write( + self: Pin<&mut Self>, + _context: &mut Context<'_>, + _bytes: &[u8], + ) -> Poll> { + Poll::Pending + } + + fn poll_flush(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +#[tokio::test(start_paused = true)] +async fn validates_lease_while_response_write_is_blocked() { + let (mut upstream_writer, upstream) = tokio::io::duplex(32); + upstream_writer.write_all(b"response").await.unwrap(); + drop(upstream_writer); + + let authorizer = Arc::new(RevokingAuthorizer(AtomicUsize::new(0))); + let validation_count = Arc::clone(&authorizer); + let authorizer: Arc = authorizer; + let validations = super::super::forward::validation_interval(); + let task = tokio::spawn(async move { + let mut upstream = upstream; + super::super::forward::forward_response_with_lease( + &mut BlockedWriter, + &mut upstream, + &authorizer, + "lease".to_owned(), + &validations, + ) + .await + }); + + tokio::task::yield_now().await; + assert_eq!(validation_count.0.load(Ordering::SeqCst), 0); + + tokio::time::advance(Duration::from_secs(1)).await; + tokio::task::yield_now().await; + assert_eq!(validation_count.0.load(Ordering::SeqCst), 1); + assert!(!task.is_finished()); + + tokio::time::advance(Duration::from_secs(1)).await; + tokio::task::yield_now().await; + assert!(task.await.unwrap().is_ok()); + assert_eq!(validation_count.0.load(Ordering::SeqCst), 2); +} + +#[tokio::test(start_paused = true)] +async fn stops_initial_upstream_write_when_lease_is_revoked() { + let authorizer = Arc::new(RevokingAuthorizer(AtomicUsize::new(0))); + let validation_count = Arc::clone(&authorizer); + let authorizer: Arc = authorizer; + let validations = super::super::forward::validation_interval(); + let task = tokio::spawn(async move { + super::super::forward::write_all_with_lease( + &mut BlockedWriter, + b"request headers", + "browser upstream request timed out", + &authorizer, + "lease", + &validations, + ) + .await + }); + + tokio::task::yield_now().await; + assert_eq!(validation_count.0.load(Ordering::SeqCst), 0); + + tokio::time::advance(Duration::from_secs(1)).await; + tokio::task::yield_now().await; + assert_eq!(validation_count.0.load(Ordering::SeqCst), 1); + assert!(!task.is_finished()); + + tokio::time::advance(Duration::from_secs(1)).await; + tokio::task::yield_now().await; + assert!(!task.await.unwrap().unwrap()); + assert_eq!(validation_count.0.load(Ordering::SeqCst), 2); +} + +#[tokio::test(start_paused = true)] +async fn request_write_phases_share_the_lease_validation_interval() { + let authorizer = Arc::new(RevokingAuthorizer(AtomicUsize::new(0))); + let validation_count = Arc::clone(&authorizer); + let authorizer: Arc = authorizer; + let validations = super::super::forward::validation_interval(); + let (mut writer, _reader) = tokio::io::duplex(32); + + assert!( + super::super::forward::write_all_with_lease( + &mut writer, + b"request headers", + "browser upstream request timed out", + &authorizer, + "lease", + &validations, + ) + .await + .unwrap() + ); + tokio::time::advance(Duration::from_millis(900)).await; + + let task = tokio::spawn(async move { + super::super::forward::write_all_with_lease( + &mut BlockedWriter, + b"request body", + "browser upstream body timed out", + &authorizer, + "lease", + &validations, + ) + .await + }); + tokio::task::yield_now().await; + assert_eq!(validation_count.0.load(Ordering::SeqCst), 0); + + tokio::time::advance(Duration::from_millis(100)).await; + tokio::task::yield_now().await; + assert_eq!(validation_count.0.load(Ordering::SeqCst), 1); + + tokio::time::advance(Duration::from_secs(1)).await; + tokio::task::yield_now().await; + assert!(!task.await.unwrap().unwrap()); + assert_eq!(validation_count.0.load(Ordering::SeqCst), 2); +} + +#[tokio::test(start_paused = true)] +async fn response_forwarding_keeps_the_request_lease_validation_schedule() { + let authorizer = Arc::new(RevokingAuthorizer(AtomicUsize::new(0))); + let validation_count = Arc::clone(&authorizer); + let authorizer: Arc = authorizer; + let validations = super::super::forward::validation_interval(); + let (mut upstream_writer, upstream) = tokio::io::duplex(32); + upstream_writer.write_all(b"response").await.unwrap(); + drop(upstream_writer); + + assert!( + super::super::forward::write_all_with_lease( + &mut tokio::io::sink(), + b"request headers", + "browser upstream request timed out", + &authorizer, + "lease", + &validations, + ) + .await + .unwrap() + ); + tokio::time::advance(Duration::from_millis(900)).await; + + let task = tokio::spawn(async move { + let mut upstream = upstream; + super::super::forward::forward_response_with_lease( + &mut BlockedWriter, + &mut upstream, + &authorizer, + "lease".to_owned(), + &validations, + ) + .await + }); + tokio::task::yield_now().await; + assert_eq!(validation_count.0.load(Ordering::SeqCst), 0); + + tokio::time::advance(Duration::from_millis(100)).await; + tokio::task::yield_now().await; + assert_eq!(validation_count.0.load(Ordering::SeqCst), 1); + + tokio::time::advance(Duration::from_secs(1)).await; + tokio::task::yield_now().await; + assert!(task.await.unwrap().is_ok()); + assert_eq!(validation_count.0.load(Ordering::SeqCst), 2); +} diff --git a/crates/tinycomputer-browser/src/egress/egress_tests/http_tests.rs b/crates/tinycomputer-browser/src/egress/egress_tests/http_tests.rs new file mode 100644 index 00000000..575451b2 --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/egress_tests/http_tests.rs @@ -0,0 +1,210 @@ +//! HTTP request, body, authentication, and refusal tests. + +use super::simulator::*; +use crate::EgressProxy; +use std::{ + io, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; + +#[tokio::test] +async fn denied_destination_never_connects_to_the_fixture() { + let destination = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + destination.set_nonblocking(true).unwrap(); + let address = destination.local_addr().unwrap(); + let proxy = EgressProxy::bind( + Arc::new(FixtureAuthorizer { + address, + allow: false, + consumed: Arc::new(AtomicBool::new(false)), + }), + "denied-session".into(), + FIXTURE_PASSWORD.into(), + ) + .await + .unwrap(); + let mut client = TcpStream::connect(proxy.url().replace("http://", "")) + .await + .unwrap(); + client + .write_all( + format!( + "CONNECT metadata.test:{} HTTP/1.1\r\nHost: metadata.test:{}\r\nProxy-Authorization: {}\r\n\r\n", + address.port(), + address.port(), + proxy_authorization() + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut response = [0; 128]; + let n = client.read(&mut response).await.unwrap(); + assert!( + std::str::from_utf8(&response[..n]) + .unwrap() + .contains("403 Forbidden") + ); + assert_eq!( + destination.accept().unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); +} +#[tokio::test] +async fn unauthenticated_local_clients_cannot_use_the_session_policy_broker() { + let destination = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + destination.set_nonblocking(true).unwrap(); + let address = destination.local_addr().unwrap(); + let proxy = EgressProxy::bind( + Arc::new(FixtureAuthorizer { + address, + allow: true, + consumed: Arc::new(AtomicBool::new(false)), + }), + "protected-session".into(), + FIXTURE_PASSWORD.into(), + ) + .await + .unwrap(); + let mut client = TcpStream::connect(proxy.url().replace("http://", "")) + .await + .unwrap(); + client + .write_all( + format!( + "CONNECT public.example:{} HTTP/1.1\r\nHost: public.example:{}\r\n\r\n", + address.port(), + address.port() + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut response = [0; 256]; + let n = client.read(&mut response).await.unwrap(); + let response = std::str::from_utf8(&response[..n]).unwrap(); + assert!(response.contains("407 Proxy Authentication Required")); + assert_eq!( + destination.accept().unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); +} + +#[tokio::test] +async fn approved_http_request_is_rewritten_and_forwarded_to_approved_fixture() { + let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = destination.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = destination.accept().await.unwrap(); + let mut request = Vec::new(); + let mut chunk = [0; 512]; + loop { + let n = socket.read(&mut chunk).await.unwrap(); + request.extend_from_slice(&chunk[..n]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let request = std::str::from_utf8(&request).unwrap(); + assert!(request.starts_with("GET /control?ready=1 HTTP/1.1\r\n")); + assert!(request.contains(&format!("Host: control.test:{}\r\n", address.port()))); + assert!(request.contains("Connection: close\r\n")); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .await + .unwrap(); + }); + let authorizer = Arc::new(FixtureAuthorizer { + address, + allow: true, + consumed: Arc::new(AtomicBool::new(false)), + }); + let proxy = EgressProxy::bind( + authorizer.clone(), + "http-session".into(), + FIXTURE_PASSWORD.into(), + ) + .await + .unwrap(); + let mut client = TcpStream::connect(proxy.url().replace("http://", "")) + .await + .unwrap(); + client + .write_all( + format!( + "GET http://control.test:{}/control?ready=1 HTTP/1.1\r\nHost: control.test:{}\r\nProxy-Authorization: {}\r\nConnection: keep-alive\r\n\r\n", + address.port(), + address.port(), + proxy_authorization() + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut response = Vec::new(); + client.read_to_end(&mut response).await.unwrap(); + assert!( + std::str::from_utf8(&response) + .unwrap() + .ends_with("\r\n\r\nok") + ); + assert!(authorizer.consumed.load(Ordering::SeqCst)); + server.await.unwrap(); +} + +#[tokio::test] +async fn duplicate_lengths_and_pipelined_authorities_are_refused_before_dial() { + let destination = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + destination.set_nonblocking(true).unwrap(); + let address = destination.local_addr().unwrap(); + for suffix in [ + "Content-Length: 0\r\nContent-Length: 0\r\n\r\n", + "Content-Length: 0\r\n\r\nGET http://other.test/ HTTP/1.1\r\nHost: other.test\r\n\r\n", + ] { + let proxy = EgressProxy::bind( + Arc::new(FixtureAuthorizer { + address, + allow: true, + consumed: Arc::new(AtomicBool::new(false)), + }), + "malformed-session".into(), + FIXTURE_PASSWORD.into(), + ) + .await + .unwrap(); + let mut client = TcpStream::connect(proxy.url().replace("http://", "")) + .await + .unwrap(); + client + .write_all( + format!( + "GET http://one.test:{}/ HTTP/1.1\r\nHost: one.test:{}\r\nProxy-Authorization: {}\r\n{}", + address.port(), + address.port(), + proxy_authorization(), + suffix + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut response = [0; 128]; + let n = client.read(&mut response).await.unwrap(); + assert!( + std::str::from_utf8(&response[..n]) + .unwrap() + .contains("403 Forbidden") + ); + assert_eq!( + destination.accept().unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + } +} diff --git a/crates/tinycomputer-browser/src/egress/egress_tests/lease_tests.rs b/crates/tinycomputer-browser/src/egress/egress_tests/lease_tests.rs new file mode 100644 index 00000000..a1e1c547 --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/egress_tests/lease_tests.rs @@ -0,0 +1,124 @@ +//! Lease validation and revocation tests for active streams. + +use super::simulator::*; +use crate::EgressProxy; +use std::sync::{Arc, atomic::Ordering}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; + +#[tokio::test] +async fn continuous_http_response_still_revalidates_and_closes_revoked_lease() { + use std::sync::atomic::AtomicUsize; + let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = destination.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = destination.accept().await.unwrap(); + let mut request = [0; 2048]; + let _ = socket.read(&mut request).await.unwrap(); + for _ in 0..4096 { + if socket.write_all(&[b'x'; 1024]).await.is_err() { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }); + let validations = Arc::new(AtomicUsize::new(0)); + let proxy = EgressProxy::bind( + Arc::new(RevokingAuthorizer { + address, + validations: validations.clone(), + }), + "stream-session".into(), + FIXTURE_PASSWORD.into(), + ) + .await + .unwrap(); + let mut client = TcpStream::connect(proxy.url().replace("http://", "")) + .await + .unwrap(); + client + .write_all( + format!( + "GET http://stream.test:{}/ HTTP/1.1\r\nHost: stream.test:{}\r\nProxy-Authorization: {}\r\n\r\n", + address.port(), + address.port(), + proxy_authorization() + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut response = Vec::new(); + let _ = tokio::time::timeout( + std::time::Duration::from_secs(4), + client.read_to_end(&mut response), + ) + .await + .expect("revoked response stream should be closed promptly") + .unwrap(); + assert!(validations.load(Ordering::SeqCst) > 0); + assert!( + response.len() < 4096 * 1024, + "revoked response kept streaming" + ); + server.await.unwrap(); +} + +#[tokio::test] +async fn a_revoked_lease_closes_a_large_request_upload() { + use std::sync::atomic::AtomicUsize; + const BODY_LENGTH: usize = 10 * 1024 * 1024; + let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = destination.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = destination.accept().await.unwrap(); + let mut received = Vec::new(); + socket.read_to_end(&mut received).await.unwrap(); + received + }); + let validations = Arc::new(AtomicUsize::new(0)); + let proxy = EgressProxy::bind( + Arc::new(RevokingAuthorizer { + address, + validations: validations.clone(), + }), + "upload-revocation-session".into(), + FIXTURE_PASSWORD.into(), + ) + .await + .unwrap(); + let mut client = TcpStream::connect(proxy.url().replace("http://", "")) + .await + .unwrap(); + client + .write_all( + format!( + "POST http://upload.test:{}/ HTTP/1.1\r\nHost: upload.test:{}\r\nProxy-Authorization: {}\r\nContent-Length: {BODY_LENGTH}\r\n\r\n", + address.port(), + address.port(), + proxy_authorization() + ) + .as_bytes(), + ) + .await + .unwrap(); + let chunk = [b'x'; 1024]; + for _ in 0..BODY_LENGTH / chunk.len() { + if client.write_all(&chunk).await.is_err() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + let mut response = Vec::new(); + let _ = tokio::time::timeout( + std::time::Duration::from_secs(4), + client.read_to_end(&mut response), + ) + .await + .expect("revoked upload should be closed promptly"); + let received = server.await.unwrap(); + assert!(validations.load(Ordering::SeqCst) > 1); + assert!(received.len() < BODY_LENGTH); +} diff --git a/crates/tinycomputer-browser/src/egress/egress_tests/lifecycle_tests.rs b/crates/tinycomputer-browser/src/egress/egress_tests/lifecycle_tests.rs new file mode 100644 index 00000000..74acaf4c --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/egress_tests/lifecycle_tests.rs @@ -0,0 +1,26 @@ +//! Session proxy listener lifecycle tests. + +use super::simulator::*; +use crate::EgressProxy; +use std::sync::{Arc, atomic::AtomicBool}; +use tokio::net::{TcpListener, TcpStream}; + +#[tokio::test] +async fn dropping_session_proxy_closes_its_listener() { + let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy = EgressProxy::bind( + Arc::new(FixtureAuthorizer { + address: destination.local_addr().unwrap(), + allow: false, + consumed: Arc::new(AtomicBool::new(false)), + }), + "closed-session".into(), + FIXTURE_PASSWORD.into(), + ) + .await + .unwrap(); + let endpoint = proxy.url().replace("http://", ""); + drop(proxy); + tokio::task::yield_now().await; + assert!(TcpStream::connect(endpoint).await.is_err()); +} diff --git a/crates/tinycomputer-browser/src/egress/egress_tests/retry_tests.rs b/crates/tinycomputer-browser/src/egress/egress_tests/retry_tests.rs new file mode 100644 index 00000000..f82361ed --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/egress_tests/retry_tests.rs @@ -0,0 +1,56 @@ +//! Proxy retry behavior for multiple approved addresses. + +use super::simulator::*; +use crate::{BrowserDialTarget, EgressAuthorizer, egress::connect_approved_addresses}; +use std::{ + io, + net::SocketAddr, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; +use tokio::{net::TcpListener, net::TcpStream}; + +#[tokio::test] +async fn proxy_tries_later_addresses_when_the_first_granted_address_is_unreachable() { + let first_address = SocketAddr::from(([127, 0, 0, 1], 1)); + let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let second_address = destination.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (socket, _) = destination.accept().await.unwrap(); + socket.local_addr().unwrap() + }); + let target = BrowserDialTarget { + scheme: "http".into(), + hostname: "control.test".into(), + port: second_address.port(), + session_id: "multi-address-session".into(), + }; + let authorizer: Arc = Arc::new(MultiAddressAuthorizer { + addresses: vec![first_address, second_address], + }); + let first_grant = authorizer.authorize(target.clone()).await.unwrap(); + let dial_attempts = Arc::new(AtomicUsize::new(0)); + let counted_attempts = Arc::clone(&dial_attempts); + let mut connect = move |address| { + counted_attempts.fetch_add(1, Ordering::SeqCst); + async move { + if address == first_address { + Err(io::Error::other("simulated unreachable address")) + } else { + TcpStream::connect(address).await + } + } + }; + + let (upstream, lease) = + connect_approved_addresses(&authorizer, &target, first_grant, &mut connect) + .await + .unwrap(); + + assert_eq!(lease, "multi-address-lease"); + assert_eq!(upstream.peer_addr().unwrap(), second_address); + assert_eq!(server.await.unwrap(), second_address); + assert_eq!(dial_attempts.load(Ordering::SeqCst), 2); +} diff --git a/crates/tinycomputer-browser/src/egress/egress_tests/simulator.rs b/crates/tinycomputer-browser/src/egress/egress_tests/simulator.rs new file mode 100644 index 00000000..0dbf8d2c --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/egress_tests/simulator.rs @@ -0,0 +1,169 @@ +//! Local authorizers and fixture helpers shared by egress tests. + +use crate::{BrowserDialTarget, DialGrant, EgressAuthorizer}; +use base64::Engine as _; +use std::{ + io, + net::SocketAddr, + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, +}; + +pub(super) const FIXTURE_PASSWORD: &str = "fixture-proxy-secret"; + +pub(super) fn proxy_authorization() -> String { + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD + .encode(format!("tinycomputer:{FIXTURE_PASSWORD}")) + ) +} + +#[derive(Clone)] +pub(super) struct FixtureAuthorizer { + pub(super) address: SocketAddr, + pub(super) allow: bool, + pub(super) consumed: Arc, +} + +impl EgressAuthorizer for FixtureAuthorizer { + fn authorize( + &self, + _target: BrowserDialTarget, + ) -> std::pin::Pin> + Send>> { + let address = self.address; + let allow = self.allow; + Box::pin(async move { + if allow { + Ok(DialGrant { + lease: "fixture-lease".into(), + addresses: vec![address], + }) + } else { + Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")) + } + }) + } + fn consume( + &self, + _target: BrowserDialTarget, + _grant: DialGrant, + address: SocketAddr, + ) -> std::pin::Pin> + Send>> { + let expected = self.address; + let consumed = self.consumed.clone(); + Box::pin(async move { + if address == expected { + consumed.store(true, Ordering::SeqCst); + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "wrong address", + )) + } + }) + } + fn validate( + &self, + _lease: String, + ) -> std::pin::Pin> + Send>> { + Box::pin(async { Ok(true) }) + } +} + +pub(super) struct MultiAddressAuthorizer { + pub(super) addresses: Vec, +} + +impl EgressAuthorizer for MultiAddressAuthorizer { + fn authorize( + &self, + _target: BrowserDialTarget, + ) -> std::pin::Pin> + Send>> { + let addresses = self.addresses.clone(); + Box::pin(async move { + Ok(DialGrant { + lease: "multi-address-lease".into(), + addresses, + }) + }) + } + + fn consume( + &self, + _target: BrowserDialTarget, + grant: DialGrant, + address: SocketAddr, + ) -> std::pin::Pin> + Send>> { + Box::pin(async move { + if grant.addresses.contains(&address) { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "wrong address", + )) + } + }) + } + + fn validate( + &self, + _lease: String, + ) -> std::pin::Pin> + Send>> { + Box::pin(async { Ok(true) }) + } +} + +pub(super) struct RevokingAuthorizer { + pub(super) address: SocketAddr, + pub(super) validations: Arc, +} + +impl EgressAuthorizer for RevokingAuthorizer { + fn authorize( + &self, + _target: BrowserDialTarget, + ) -> std::pin::Pin> + Send>> { + let address = self.address; + Box::pin(async move { + Ok(DialGrant { + lease: "revoked-during-stream".into(), + addresses: vec![address], + }) + }) + } + + fn consume( + &self, + _target: BrowserDialTarget, + _grant: DialGrant, + address: SocketAddr, + ) -> std::pin::Pin> + Send>> { + let expected = self.address; + Box::pin(async move { + if address == expected { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "wrong address", + )) + } + }) + } + + fn validate( + &self, + _lease: String, + ) -> std::pin::Pin> + Send>> { + let validations = self.validations.clone(); + Box::pin(async move { + let call = validations.fetch_add(1, Ordering::SeqCst); + Ok(call == 0) + }) + } +} diff --git a/crates/tinycomputer-browser/src/egress/egress_tests/websocket_tests.rs b/crates/tinycomputer-browser/src/egress/egress_tests/websocket_tests.rs new file mode 100644 index 00000000..a1542d3d --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/egress_tests/websocket_tests.rs @@ -0,0 +1,77 @@ +//! WebSocket upgrade forwarding tests. + +use super::simulator::*; +use crate::EgressProxy; +use std::sync::{Arc, atomic::AtomicBool}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; + +#[tokio::test] +async fn approved_websocket_upgrade_is_forwarded_and_tunneled() { + let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = destination.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = destination.accept().await.unwrap(); + let mut request = Vec::new(); + let mut chunk = [0; 512]; + loop { + let n = socket.read(&mut chunk).await.unwrap(); + request.extend_from_slice(&chunk[..n]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let request = std::str::from_utf8(&request).unwrap(); + assert!(request.starts_with("GET /socket HTTP/1.1\r\n")); + assert!(request.contains("Connection: Upgrade\r\n")); + assert!(request.contains("Upgrade: websocket\r\n")); + socket + .write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n") + .await + .unwrap(); + let mut ping = [0; 4]; + socket.read_exact(&mut ping).await.unwrap(); + assert_eq!(&ping, b"ping"); + socket.write_all(b"pong").await.unwrap(); + }); + let proxy = EgressProxy::bind( + Arc::new(FixtureAuthorizer { + address, + allow: true, + consumed: Arc::new(AtomicBool::new(false)), + }), + "websocket-session".into(), + FIXTURE_PASSWORD.into(), + ) + .await + .unwrap(); + let mut client = TcpStream::connect(proxy.url().replace("http://", "")) + .await + .unwrap(); + client + .write_all( + format!( + "GET ws://socket.test:{}/socket HTTP/1.1\r\nHost: socket.test:{}\r\nProxy-Authorization: {}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", + address.port(), + address.port(), + proxy_authorization() + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut handshake = [0; 256]; + let n = client.read(&mut handshake).await.unwrap(); + assert!( + std::str::from_utf8(&handshake[..n]) + .unwrap() + .contains("101 Switching Protocols") + ); + client.write_all(b"ping").await.unwrap(); + let mut pong = [0; 4]; + client.read_exact(&mut pong).await.unwrap(); + assert_eq!(&pong, b"pong"); + server.await.unwrap(); +} diff --git a/crates/tinycomputer-browser/src/egress/forward.rs b/crates/tinycomputer-browser/src/egress/forward.rs new file mode 100644 index 00000000..d52f5d79 --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/forward.rs @@ -0,0 +1,258 @@ +//! Forwards authorized proxy traffic and keeps active streams tied to their lease. + +use std::{io, sync::Arc, time::Duration}; + +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt, copy_bidirectional}, + net::TcpStream, +}; + +use super::{ + EgressAuthorizer, + request::{ProxyRequest, buffered_body_len, read_request_body}, +}; + +pub(super) async fn initialize_upstream( + client: &mut TcpStream, + upstream: &mut TcpStream, + request: &ProxyRequest, + auth: &Arc, + lease: &str, + validations: &LeaseValidationClock, +) -> io::Result { + if request.connect { + write_all_bounded( + client, + b"HTTP/1.1 200 Connection Established\r\n\r\n", + "browser CONNECT response timed out", + ) + .await?; + if request.bytes.len() > request.split { + return write_all_with_lease( + upstream, + &request.bytes[request.split..], + "browser CONNECT payload timed out", + auth, + lease, + validations, + ) + .await; + } + return Ok(true); + } + let url = url::Url::parse(&request.uri) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid HTTP proxy URL"))?; + let path = if let Some(query) = url.query() { + format!("{}?{query}", url.path()) + } else { + url.path().to_owned() + }; + let mut forwarded = format!("{} {path} {}\r\n", request.method, request.version); + for line in request + .lines + .iter() + .skip(1) + .map(String::as_str) + .filter(|line| !line.is_empty()) + { + let name = line.split_once(':').map_or("", |(name, _)| name); + if !name.eq_ignore_ascii_case("connection") + && !name.eq_ignore_ascii_case("proxy-connection") + && !name.eq_ignore_ascii_case("proxy-authorization") + { + forwarded.push_str(line); + forwarded.push_str("\r\n"); + } + } + if request.has_upgrade { + forwarded.push_str("Connection: Upgrade\r\n\r\n"); + } else { + forwarded.push_str("Connection: close\r\n\r\n"); + } + if !write_all_with_lease( + upstream, + forwarded.as_bytes(), + "browser upstream request timed out", + auth, + lease, + validations, + ) + .await? + { + return Ok(false); + } + if request.chunked { + Ok(true) + } else { + write_all_with_lease( + upstream, + &request.bytes[request.split..], + "browser upstream body timed out", + auth, + lease, + validations, + ) + .await + } +} +pub(super) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + let mut difference = left.len() ^ right.len(); + for index in 0..left.len().max(right.len()) { + difference |= usize::from( + left.get(index).copied().unwrap_or(0) ^ right.get(index).copied().unwrap_or(0), + ); + } + difference == 0 +} + +pub(super) async fn tunnel_with_lease( + client: &mut TcpStream, + upstream: &mut TcpStream, + auth: &Arc, + lease: String, + validations: &LeaseValidationClock, +) -> io::Result<()> { + let relay = copy_bidirectional(client, upstream); + tokio::pin!(relay); + loop { + tokio::select! { + biased; + () = wait_for_validation(validations) => { + if !validate_lease(auth, &lease).await? { return Ok(()); } + }, + result = &mut relay => { result?; return Ok(()); }, + } + } +} + +pub(super) async fn forward_response_with_lease( + client: &mut C, + upstream: &mut U, + auth: &Arc, + lease: String, + validations: &LeaseValidationClock, +) -> io::Result<()> +where + C: tokio::io::AsyncWrite + Unpin, + U: tokio::io::AsyncRead + Unpin, +{ + let mut chunk = [0u8; 8192]; + loop { + tokio::select! { + biased; + () = wait_for_validation(validations) => { + if !validate_lease(auth, &lease).await? { return Ok(()); } + }, + result = upstream.read(&mut chunk) => { + let n = result?; + if n == 0 { return Ok(()); } + let write = write_all_bounded( + client, + &chunk[..n], + "browser response write timed out", + ); + tokio::pin!(write); + loop { + tokio::select! { + biased; + () = wait_for_validation(validations) => { + if !validate_lease(auth, &lease).await? { return Ok(()); } + }, + result = &mut write => { result?; break; }, + } + } + }, + } + } +} + +pub(super) async fn forward_request_body_with_lease( + client: &mut C, + upstream: &mut U, + request: &ProxyRequest, + auth: &Arc, + lease: &str, + validations: &LeaseValidationClock, +) -> io::Result +where + C: tokio::io::AsyncRead + Unpin, + U: tokio::io::AsyncWrite + Unpin, +{ + let body = read_request_body(client, upstream, request, buffered_body_len(request)); + tokio::pin!(body); + loop { + tokio::select! { + biased; + () = wait_for_validation(validations) => { + if !validate_lease(auth, lease).await? { return Ok(false); } + }, + result = &mut body => { result?; return Ok(true); }, + } + } +} + +pub(super) type LeaseValidationClock = Arc>; + +pub(super) fn validation_interval() -> LeaseValidationClock { + let period = Duration::from_secs(1); + let mut interval = tokio::time::interval_at(tokio::time::Instant::now() + period, period); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + Arc::new(tokio::sync::Mutex::new(interval)) +} + +async fn wait_for_validation(validations: &LeaseValidationClock) { + validations.lock().await.tick().await; +} + +pub(super) async fn validate_lease( + auth: &Arc, + lease: &str, +) -> io::Result { + tokio::time::timeout(Duration::from_secs(2), auth.validate(lease.to_owned())) + .await + .map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + "browser lease validation timed out", + ) + })? +} +pub(super) async fn refuse(client: &mut TcpStream) -> io::Result<()> { + write_all_bounded( + client, + b"HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", + "browser refusal response timed out", + ) + .await +} + +pub(super) async fn write_all_bounded( + stream: &mut W, + bytes: &[u8], + message: &'static str, +) -> io::Result<()> { + tokio::time::timeout(Duration::from_secs(5), stream.write_all(bytes)) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, message))? +} + +pub(super) async fn write_all_with_lease( + stream: &mut W, + bytes: &[u8], + message: &'static str, + auth: &Arc, + lease: &str, + validations: &LeaseValidationClock, +) -> io::Result { + let write = write_all_bounded(stream, bytes, message); + tokio::pin!(write); + loop { + tokio::select! { + biased; + () = wait_for_validation(validations) => { + if !validate_lease(auth, lease).await? { return Ok(false); } + }, + result = &mut write => { result?; return Ok(true); }, + } + } +} diff --git a/crates/tinycomputer-browser/src/egress/mod.rs b/crates/tinycomputer-browser/src/egress/mod.rs new file mode 100644 index 00000000..391c914f --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/mod.rs @@ -0,0 +1,201 @@ +//! Session-local browser HTTP proxy with destination authorization before each dial. +//! +//! The proxy accepts only browser HTTP proxy requests. It never resolves a name +//! locally: the authorizer returns a finite set of vetted socket addresses and +//! the proxy consumes the grant before dialing one exact address. + +use std::{future::Future, io, net::SocketAddr, pin::Pin, sync::Arc, time::Duration}; + +use tokio::{ + net::{TcpListener, TcpStream}, + task::JoinSet, +}; + +mod forward; +mod request; +mod serve; + +/// Fixture-only replacement for the final TCP connect in host integration tests. +/// +/// The callback receives the exact socket selected by authorization and grant +/// consumption. Test connectors should map it to a local fixture only. +#[cfg(feature = "test-connector")] +pub type TestConnector = Arc< + dyn Fn(SocketAddr) -> Pin> + Send>> + Send + Sync, +>; + +#[cfg(test)] +pub(crate) use serve::connect_approved_addresses; + +#[cfg(test)] +mod egress_tests; + +#[cfg(test)] +pub(crate) use request::parse_authority; + +/// A destination parsed from the browser's actual proxy request. +#[derive(Clone, PartialEq, Eq)] +pub struct BrowserDialTarget { + /// `http` or `https`. + pub scheme: String, + /// Canonical host, without brackets for IPv6 literals. + pub hostname: String, + /// Effective destination port. + pub port: u16, + /// Opaque per-browser-session binding generated by `TinyComputer`. + pub session_id: String, +} + +impl std::fmt::Debug for BrowserDialTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("BrowserDialTarget([REDACTED])") + } +} + +/// Opaque authorizer state plus the exact permitted sockets. +#[derive(Clone)] +pub struct DialGrant { + /// Opaque lease returned by the authenticated `TinySecurity` adapter. + pub lease: String, + /// Finite address set vetted by `TinySecurity`. + pub addresses: Vec, +} + +impl std::fmt::Debug for DialGrant { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("DialGrant([REDACTED])") + } +} + +/// The host adapter delegates every decision to `TinySecurity` over `TinyBus`. +pub trait EgressAuthorizer: Send + Sync + 'static { + /// Authorize the actual destination and return a one-use grant. + fn authorize( + &self, + target: BrowserDialTarget, + ) -> Pin> + Send>>; + /// Consume the grant before the selected socket is opened. + fn consume( + &self, + target: BrowserDialTarget, + grant: DialGrant, + address: SocketAddr, + ) -> Pin> + Send>>; + /// Revalidate an active tunnel lease. False or error closes the tunnel. + fn validate(&self, lease: String) -> Pin> + Send>>; +} + +/// An ephemeral loopback proxy owned by one browser session. +#[derive(Debug)] +pub struct EgressProxy { + address: SocketAddr, + task: tokio::task::JoinHandle<()>, +} + +impl EgressProxy { + /// Bind a private IPv4 loopback listener for one session. + /// + /// # Errors + /// + /// Returns an I/O error when the listener cannot bind or its address cannot + /// be read. + pub async fn bind( + authorizer: Arc, + session_id: String, + proxy_password: String, + ) -> io::Result { + Self::bind_inner(authorizer, session_id, proxy_password, TcpStream::connect).await + } + + /// Bind the production proxy path with a fixture-only final socket connector. + /// + /// This feature-gated seam is for host integration tests that need to keep + /// policy authorization and one-use grant consumption real while mapping an + /// approved public socket operation onto a local fixture listener. The + /// connector receives the exact socket address after authorization and + /// consumption; it must not make public network connections. + /// + /// # Errors + /// + /// Returns an I/O error when the proxy listener cannot bind or its address + /// cannot be read. + #[cfg(feature = "test-connector")] + pub async fn bind_with_test_connector( + authorizer: Arc, + session_id: String, + proxy_password: String, + connector: C, + ) -> io::Result + where + C: Fn(SocketAddr) -> F + Clone + Send + Sync + 'static, + F: Future> + Send + 'static, + { + Self::bind_inner(authorizer, session_id, proxy_password, connector).await + } + + async fn bind_inner( + authorizer: Arc, + session_id: String, + proxy_password: String, + connector: C, + ) -> io::Result + where + C: Fn(SocketAddr) -> F + Clone + Send + Sync + 'static, + F: Future> + Send + 'static, + { + let listener = TcpListener::bind(("127.0.0.1", 0)).await?; + let address = listener.local_addr()?; + let task = tokio::spawn(async move { + let mut connections = JoinSet::new(); + loop { + tokio::select! { + accepted = accept_connection(listener.accept()), if connections.len() < 64 => { + let Some((stream, _)) = accepted else { continue; }; + let auth = authorizer.clone(); + let session = session_id.clone(); + let password = proxy_password.clone(); + let connector = connector.clone(); + connections.spawn(async move { + let _ = Box::pin(serve::serve_with_connector( + stream, + auth, + session, + password, + connector, + )) + .await; + }); + } + _ = connections.join_next(), if !connections.is_empty() => {} + } + } + }); + Ok(Self { address, task }) + } + + /// Chromium proxy URL; it contains no policy authority. + #[must_use] + pub fn url(&self) -> String { + format!("http://{}:{}", self.address.ip(), self.address.port()) + } +} + +async fn accept_connection(accepted: A) -> Option<(TcpStream, SocketAddr)> +where + A: Future>, +{ + match accepted.await { + Ok(connection) => Some(connection), + Err(error) => { + log::warn!("browser proxy listener accept failed: {error}"); + tokio::time::sleep(Duration::from_millis(50)).await; + None + } + } +} + +impl Drop for EgressProxy { + fn drop(&mut self) { + self.task.abort(); + } +} diff --git a/crates/tinycomputer-browser/src/egress/request.rs b/crates/tinycomputer-browser/src/egress/request.rs new file mode 100644 index 00000000..2fa6a61b --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/request.rs @@ -0,0 +1,263 @@ +//! Parses and validates browser proxy requests before a destination is authorized. + +use std::{io, time::Duration}; + +mod body; +pub(super) use body::{buffered_body_len, has_valid_buffered_body, read_request_body}; + +use base64::Engine as _; +use tokio::{io::AsyncReadExt, net::TcpStream}; + +use super::{ + BrowserDialTarget, + forward::{constant_time_eq, refuse, write_all_bounded}, +}; + +pub(super) struct ProxyRequest { + pub(super) bytes: Vec, + pub(super) split: usize, + pub(super) lines: Vec, + pub(super) method: String, + pub(super) uri: String, + pub(super) version: String, + pub(super) target: BrowserDialTarget, + pub(super) connect: bool, + pub(super) body_len: usize, + pub(super) chunked: bool, + pub(super) has_upgrade: bool, +} + +pub(super) async fn read_proxy_request( + client: &mut TcpStream, + session_id: &str, + proxy_password: &str, +) -> io::Result> { + let mut bytes = Vec::with_capacity(4096); + let mut chunk = [0u8; 1024]; + loop { + let n = tokio::time::timeout(Duration::from_secs(10), client.read(&mut chunk)) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "proxy request timed out"))??; + if n == 0 { + return Ok(None); + } + bytes.extend_from_slice(&chunk[..n]); + if bytes.len() > 32 * 1024 { + refuse(client).await?; + return Ok(None); + } + if bytes.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let Some(header_end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") else { + refuse(client).await?; + return Ok(None); + }; + let split = header_end + 4; + let Ok(head) = std::str::from_utf8(&bytes[..split]) else { + refuse(client).await?; + return Ok(None); + }; + let lines: Vec = head.split("\r\n").map(str::to_owned).collect(); + let Some(request_line) = lines.first() else { + refuse(client).await?; + return Ok(None); + }; + let mut parts = request_line.split_ascii_whitespace(); + let method = parts.next().unwrap_or_default().to_owned(); + let uri = parts.next().unwrap_or_default().to_owned(); + let version = parts.next().unwrap_or_default().to_owned(); + if parts.next().is_some() || (version != "HTTP/1.1" && version != "HTTP/1.0") { + refuse(client).await?; + return Ok(None); + } + let Some(target) = proxy_target(&method, &uri, session_id) else { + refuse(client).await?; + return Ok(None); + }; + let connect = method.eq_ignore_ascii_case("CONNECT"); + let Some(headers) = parse_proxy_headers(&lines, &target, connect, session_id) else { + refuse(client).await?; + return Ok(None); + }; + if !has_proxy_auth(headers.proxy_authorization.as_deref(), proxy_password) { + write_all_bounded( + client, + b"HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"tinycomputer-browser\"\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", + "browser proxy challenge timed out", + ) + .await?; + return Ok(None); + } + if connect && bytes.len() > split { + refuse(client).await?; + return Ok(None); + } + Ok(Some(ProxyRequest { + bytes, + split, + lines, + method, + uri, + version, + target, + connect, + body_len: headers.body_len, + chunked: headers.chunked, + has_upgrade: headers.has_upgrade, + })) +} + +fn proxy_target(method: &str, uri: &str, session_id: &str) -> Option { + if method.eq_ignore_ascii_case("CONNECT") { + return parse_authority(uri, "https", session_id).ok(); + } + let url = url::Url::parse(uri).ok()?; + if !matches!(url.scheme(), "http" | "ws") + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return None; + } + Some(BrowserDialTarget { + scheme: "http".to_owned(), + hostname: url + .host_str()? + .trim_matches(['[', ']']) + .to_ascii_lowercase(), + port: url.port_or_known_default()?, + session_id: session_id.to_owned(), + }) +} + +struct ParsedHeaders { + body_len: usize, + chunked: bool, + has_upgrade: bool, + proxy_authorization: Option, +} + +fn parse_proxy_headers( + lines: &[String], + target: &BrowserDialTarget, + connect: bool, + session_id: &str, +) -> Option { + let mut body_len = 0usize; + let mut host_header = None; + let mut has_upgrade = false; + let mut connection_upgrade = false; + let mut proxy_authorization = None; + let mut saw_content_length = false; + let mut chunked = false; + let mut saw_transfer_encoding = false; + for line in lines + .iter() + .skip(1) + .map(String::as_str) + .filter(|line| !line.is_empty()) + { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("host") { + if host_header.replace(value.trim().to_owned()).is_some() { + return None; + } + } else if name.eq_ignore_ascii_case("content-length") { + if saw_content_length { + return None; + } + saw_content_length = true; + body_len = value.trim().parse().ok()?; + } else if name.eq_ignore_ascii_case("transfer-encoding") { + if saw_transfer_encoding || !value.trim().eq_ignore_ascii_case("chunked") { + return None; + } + saw_transfer_encoding = true; + chunked = true; + } else if name.eq_ignore_ascii_case("expect") { + return None; + } else if name.eq_ignore_ascii_case("proxy-authorization") { + if proxy_authorization + .replace(value.trim().to_owned()) + .is_some() + { + return None; + } + } else if name.eq_ignore_ascii_case("upgrade") + && value.trim().eq_ignore_ascii_case("websocket") + { + has_upgrade = true; + } else if name.eq_ignore_ascii_case("connection") + && value + .split(',') + .any(|token| token.trim().eq_ignore_ascii_case("upgrade")) + { + connection_upgrade = true; + } + } + let host_matches = host_header.as_deref().is_some_and(|host| { + parse_authority(host, if connect { "https" } else { "http" }, session_id) + .is_ok_and(|parsed| parsed.hostname == target.hostname && parsed.port == target.port) + }); + if has_upgrade != connection_upgrade + || !host_matches + || (saw_content_length && chunked) + || ((connect || has_upgrade) && (body_len != 0 || chunked)) + { + return None; + } + Some(ParsedHeaders { + body_len, + chunked, + has_upgrade, + proxy_authorization, + }) +} + +fn has_proxy_auth(proxy_authorization: Option<&str>, password: &str) -> bool { + let expected = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("tinycomputer:{password}")) + ); + proxy_authorization + .is_some_and(|actual| constant_time_eq(actual.as_bytes(), expected.as_bytes())) +} +pub(crate) fn parse_authority( + value: &str, + scheme: &str, + session_id: &str, +) -> io::Result { + if value.contains("://") { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "authority form required", + )); + } + let authority = url::Url::parse(&format!("{scheme}://{value}/")) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid CONNECT authority"))?; + if authority.path() != "/" + || authority.query().is_some() + || authority.fragment().is_some() + || !authority.username().is_empty() + || authority.password().is_some() + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid CONNECT authority", + )); + } + Ok(BrowserDialTarget { + scheme: scheme.to_owned(), + hostname: authority + .host_str() + .unwrap_or_default() + .trim_matches(['[', ']']) + .to_ascii_lowercase(), + port: authority + .port() + .unwrap_or(if scheme == "http" { 80 } else { 443 }), + session_id: session_id.to_owned(), + }) +} diff --git a/crates/tinycomputer-browser/src/egress/request/body.rs b/crates/tinycomputer-browser/src/egress/request/body.rs new file mode 100644 index 00000000..6d2356eb --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/request/body.rs @@ -0,0 +1,72 @@ +//! Streams validated fixed-length and chunked request bodies with bounded memory. + +use std::{io, time::Duration}; + +use tokio::io::AsyncReadExt; + +use crate::egress::forward::write_all_bounded; + +mod chunked; + +use super::ProxyRequest; + +pub(in crate::egress) fn has_valid_buffered_body(request: &ProxyRequest) -> bool { + if request.has_upgrade || request.connect || request.chunked { + return true; + } + let received = request.bytes.len().saturating_sub(request.split); + received <= request.body_len +} + +pub(in crate::egress) fn buffered_body_len(request: &ProxyRequest) -> usize { + if request.has_upgrade || request.connect || request.chunked { + 0 + } else { + request.bytes.len().saturating_sub(request.split) + } +} + +pub(in crate::egress) async fn read_request_body( + client: &mut C, + upstream: &mut U, + request: &ProxyRequest, + mut received: usize, +) -> io::Result<()> +where + C: tokio::io::AsyncRead + Unpin, + U: tokio::io::AsyncWrite + Unpin, +{ + if request.has_upgrade || request.connect { + return Ok(()); + } + if request.chunked { + return chunked::forward_chunked_request_body( + client, + upstream, + &request.bytes[request.split..], + ) + .await; + } + let mut chunk = [0u8; 1024]; + while received < request.body_len { + let n = tokio::time::timeout(Duration::from_secs(10), client.read(&mut chunk)) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "proxy body timed out"))??; + if n == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "browser request body ended early", + )); + } + let remaining = request.body_len - received; + if n > remaining { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "browser request body exceeded content length", + )); + } + write_all_bounded(upstream, &chunk[..n], "browser upstream body timed out").await?; + received += n; + } + Ok(()) +} diff --git a/crates/tinycomputer-browser/src/egress/request/body/chunked.rs b/crates/tinycomputer-browser/src/egress/request/body/chunked.rs new file mode 100644 index 00000000..592d1546 --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/request/body/chunked.rs @@ -0,0 +1,184 @@ +//! Bounded HTTP/1.1 chunk framing validation and forwarding. + +use std::{io, time::Duration}; + +use tokio::io::AsyncReadExt; + +use crate::egress::forward::write_all_bounded; + +#[cfg(test)] +#[path = "chunked_tests.rs"] +mod chunked_tests; + +pub(super) async fn forward_chunked_request_body( + client: &mut C, + upstream: &mut U, + initial: &[u8], +) -> io::Result<()> +where + C: tokio::io::AsyncRead + Unpin, + U: tokio::io::AsyncWrite + Unpin, +{ + let mut pending = initial.to_vec(); + let mut offset = 0; + let mut trailer_bytes = 0; + loop { + let line = read_chunk_line(client, &mut pending, &mut offset).await?; + let size_text = std::str::from_utf8(&line[..line.len() - 2]) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid chunk size"))?; + let size_text = size_text.split(';').next().unwrap_or_default(); + if size_text.is_empty() || !size_text.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid chunk size", + )); + } + let size = usize::from_str_radix(size_text, 16) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid chunk size"))?; + write_all_bounded(upstream, &line, "browser upstream body timed out").await?; + if size == 0 { + loop { + let trailer = read_chunk_line(client, &mut pending, &mut offset).await?; + trailer_bytes += trailer.len(); + let trailer_line = &trailer[..trailer.len() - 2]; + let forbidden_trailer = std::str::from_utf8(trailer_line) + .ok() + .and_then(|line| line.split_once(':')) + .is_some_and(|(name, _)| { + name.is_empty() + || name.eq_ignore_ascii_case("content-length") + || name.eq_ignore_ascii_case("transfer-encoding") + || name.eq_ignore_ascii_case("host") + }); + if trailer_bytes > 32 * 1024 + || (trailer != b"\r\n" && (!trailer_line.contains(&b':') || forbidden_trailer)) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid chunk trailer", + )); + } + write_all_bounded(upstream, &trailer, "browser upstream body timed out").await?; + if trailer == b"\r\n" { + if offset != pending.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "bytes after chunked request body", + )); + } + return Ok(()); + } + } + } + let mut remaining = size; + let mut chunk = [0u8; 8192]; + while remaining > 0 { + let buffered = pending.len().saturating_sub(offset); + let n = if buffered > 0 { + let n = buffered.min(remaining).min(chunk.len()); + chunk[..n].copy_from_slice(&pending[offset..offset + n]); + offset += n; + n + } else { + let read_length = remaining.min(chunk.len()); + let n = tokio::time::timeout( + Duration::from_secs(10), + client.read(&mut chunk[..read_length]), + ) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "proxy body timed out"))??; + if n == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "browser request body ended early", + )); + } + n + }; + write_all_bounded(upstream, &chunk[..n], "browser upstream body timed out").await?; + remaining -= n; + } + let ending = read_body_bytes(client, &mut pending, &mut offset, 2).await?; + if ending != b"\r\n" { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid chunk ending", + )); + } + write_all_bounded(upstream, &ending, "browser upstream body timed out").await?; + } +} + +async fn read_chunk_line( + client: &mut C, + pending: &mut Vec, + offset: &mut usize, +) -> io::Result> { + loop { + if let Some(end) = pending[*offset..] + .windows(2) + .position(|window| window == b"\r\n") + { + let end = *offset + end + 2; + if end.saturating_sub(*offset) > 8192 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "chunk line is too long", + )); + } + let line = pending[*offset..end].to_vec(); + *offset = end; + return Ok(line); + } + if pending.len().saturating_sub(*offset) > 8192 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "chunk line is too long", + )); + } + if *offset > 8192 { + pending.drain(..*offset); + *offset = 0; + } + let mut chunk = [0u8; 1024]; + let n = tokio::time::timeout(Duration::from_secs(10), client.read(&mut chunk)) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "proxy body timed out"))??; + if n == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "browser request body ended early", + )); + } + pending.extend_from_slice(&chunk[..n]); + } +} + +async fn read_body_bytes( + client: &mut C, + pending: &mut Vec, + offset: &mut usize, + length: usize, +) -> io::Result> { + while pending.len().saturating_sub(*offset) < length { + if *offset > 8192 { + pending.drain(..*offset); + *offset = 0; + } + let mut chunk = [0u8; 1024]; + let n = tokio::time::timeout(Duration::from_secs(10), client.read(&mut chunk)) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "proxy body timed out"))??; + if n == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "browser request body ended early", + )); + } + pending.extend_from_slice(&chunk[..n]); + } + let end = *offset + length; + let bytes = pending[*offset..end].to_vec(); + *offset = end; + Ok(bytes) +} diff --git a/crates/tinycomputer-browser/src/egress/request/body/chunked_tests.rs b/crates/tinycomputer-browser/src/egress/request/body/chunked_tests.rs new file mode 100644 index 00000000..2aab0e17 --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/request/body/chunked_tests.rs @@ -0,0 +1,75 @@ +//! Failure cases for bounded chunked request framing. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use super::forward_chunked_request_body; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +async fn forward(input: &[u8]) -> std::io::Result> { + let (mut client_reader, client_writer) = tokio::io::duplex(input.len().max(1)); + let (mut upstream_writer, mut upstream_reader) = tokio::io::duplex(input.len().max(1)); + let input = input.to_vec(); + let write = tokio::spawn(async move { + use tokio::io::AsyncWriteExt as _; + let mut client_writer = client_writer; + client_writer.write_all(&input).await?; + Ok::<_, std::io::Error>(()) + }); + let result = forward_chunked_request_body(&mut client_reader, &mut upstream_writer, &[]).await; + if result.is_ok() { + upstream_writer.shutdown().await?; + } + drop(client_reader); + drop(upstream_writer); + let mut output = Vec::new(); + upstream_reader.read_to_end(&mut output).await?; + write.await.expect("client writer task should finish")?; + result.map(|()| output) +} + +#[tokio::test] +async fn rejects_invalid_chunk_size_forms() { + for input in [ + &[0xff, b'\r', b'\n'][..], + b"\r\n", + b"not-hex\r\n", + b"ffffffffffffffffffffffffffffffff\r\n", + ] { + assert!(forward(input).await.is_err()); + } +} + +#[tokio::test] +async fn rejects_incomplete_chunks_and_invalid_chunk_endings() { + assert!(forward(b"1\r\n").await.is_err()); + assert!(forward(b"1\r\nxNO").await.is_err()); +} + +#[tokio::test] +async fn rejects_invalid_and_forbidden_trailers() { + assert!(forward(b"0\r\nmissing-colon\r\n\r\n").await.is_err()); + assert!(forward(b"0\r\nHost: forged.test\r\n\r\n").await.is_err()); + assert!(forward(b"0\r\nContent-Length: 1\r\n\r\n").await.is_err()); +} + +#[tokio::test] +async fn rejects_bytes_after_the_terminal_chunk() { + assert!(forward(b"0\r\n\r\nextra").await.is_err()); +} + +#[tokio::test] +async fn rejects_chunk_lines_over_the_limit() { + let mut input = b"1;".to_vec(); + input.resize(8193, b'x'); + input.extend_from_slice(b"\r\n"); + let error = forward(&input).await.unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert_eq!(error.to_string(), "chunk line is too long"); +} + +#[tokio::test] +async fn forwards_valid_chunk_data_and_trailers_verbatim() { + let input = b"4;ext=yes\r\ndata\r\n0\r\nX-Trace: ok\r\n\r\n"; + let forwarded = forward(input).await.unwrap(); + assert_eq!(forwarded, input); +} diff --git a/crates/tinycomputer-browser/src/egress/serve.rs b/crates/tinycomputer-browser/src/egress/serve.rs new file mode 100644 index 00000000..9b33c83c --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/serve.rs @@ -0,0 +1,140 @@ +//! Authorizes requests and consumes grants before dialing approved addresses. + +use std::{future::Future, io, net::SocketAddr, sync::Arc, time::Duration}; + +use tokio::net::TcpStream; + +use super::{ + BrowserDialTarget, DialGrant, EgressAuthorizer, + forward::{ + forward_request_body_with_lease, forward_response_with_lease, initialize_upstream, refuse, + tunnel_with_lease, validate_lease, validation_interval, + }, + request::{has_valid_buffered_body, read_proxy_request}, +}; + +pub(super) async fn serve_with_connector( + mut client: TcpStream, + auth: Arc, + session_id: String, + proxy_password: String, + mut connect: C, +) -> io::Result<()> +where + C: FnMut(SocketAddr) -> F, + F: Future>, +{ + let Some(request) = read_proxy_request(&mut client, &session_id, &proxy_password).await? else { + return Ok(()); + }; + if !has_valid_buffered_body(&request) { + return refuse(&mut client).await; + } + let Some(first_grant) = authorize(&auth, &request.target).await else { + return refuse(&mut client).await; + }; + let Some((mut upstream, lease)) = + connect_approved_addresses(&auth, &request.target, first_grant, &mut connect).await + else { + return refuse(&mut client).await; + }; + let validations = validation_interval(); + if !validate_lease(&auth, &lease).await? { + return Ok(()); + } + if !initialize_upstream( + &mut client, + &mut upstream, + &request, + &auth, + &lease, + &validations, + ) + .await? + { + return Ok(()); + } + if request.connect || request.has_upgrade { + tunnel_with_lease(&mut client, &mut upstream, &auth, lease, &validations).await?; + } else { + let (mut client_reader, mut client_writer) = client.into_split(); + let (mut upstream_reader, mut upstream_writer) = upstream.into_split(); + let response = forward_response_with_lease( + &mut client_writer, + &mut upstream_reader, + &auth, + lease.clone(), + &validations, + ); + tokio::pin!(response); + tokio::select! { + result = &mut response => result?, + upload = forward_request_body_with_lease( + &mut client_reader, + &mut upstream_writer, + &request, + &auth, + &lease, + &validations, + ) => { + if upload? { + response.await?; + } + } + } + } + Ok(()) +} + +pub(crate) async fn connect_approved_addresses( + auth: &Arc, + target: &BrowserDialTarget, + first_grant: DialGrant, + connect: &mut C, +) -> Option<(TcpStream, String)> +where + C: FnMut(SocketAddr) -> F, + F: Future>, +{ + let addresses = first_grant.addresses.clone(); + let mut connected = None; + for (index, address) in addresses.into_iter().enumerate() { + // A consume call spends its one-use grant. Reauthorize every later + // candidate so a failed dial cannot reuse a spent authorization. + let grant = if index == 0 { + first_grant.clone() + } else { + match authorize(auth, target).await { + Some(grant) if grant.addresses.contains(&address) => grant, + _ => continue, + } + }; + if !matches!( + tokio::time::timeout( + Duration::from_secs(5), + auth.consume(target.clone(), grant.clone(), address), + ) + .await, + Ok(Ok(())) + ) { + return None; + } + if let Ok(Ok(upstream)) = + tokio::time::timeout(Duration::from_secs(5), connect(address)).await + { + connected = Some((upstream, grant.lease)); + break; + } + } + connected +} + +async fn authorize( + auth: &Arc, + target: &BrowserDialTarget, +) -> Option { + match tokio::time::timeout(Duration::from_secs(5), auth.authorize(target.clone())).await { + Ok(Ok(grant)) if !grant.addresses.is_empty() && grant.addresses.len() <= 64 => Some(grant), + _ => None, + } +} diff --git a/crates/tinycomputer-browser/src/lib.rs b/crates/tinycomputer-browser/src/lib.rs index f42cc376..cd0936cd 100644 --- a/crates/tinycomputer-browser/src/lib.rs +++ b/crates/tinycomputer-browser/src/lib.rs @@ -31,6 +31,7 @@ mod convert; mod deep; +mod egress; mod engine; mod error; #[cfg(test)] @@ -44,10 +45,11 @@ mod reply; mod sessions; mod surface; +pub use egress::{BrowserDialTarget, DialGrant, EgressAuthorizer, EgressProxy}; pub use engine::{Engine, Launcher, Reply}; pub use error::{Error, Result}; #[cfg(feature = "agent-browser")] -pub use linked::AgentBrowser; +pub use linked::{AgentBrowser, EnforcedAgentBrowser}; pub use outputs::SWEEP_INTERVAL; pub use profile_lock::{claim_profile, in_use as profile_in_use, release as release_profile}; pub use sessions::{Browser, MAX_SESSIONS}; diff --git a/crates/tinycomputer-browser/src/linked/linked_tests.rs b/crates/tinycomputer-browser/src/linked/linked_tests.rs index e19488e1..25fe21a1 100644 --- a/crates/tinycomputer-browser/src/linked/linked_tests.rs +++ b/crates/tinycomputer-browser/src/linked/linked_tests.rs @@ -7,9 +7,38 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::json; +use std::{io, net::SocketAddr, sync::Arc}; -use super::AgentBrowser; -use crate::engine::Launcher; +use super::{AgentBrowser, EnforcedAgentBrowser}; +use crate::{BrowserDialTarget, DialGrant, EgressAuthorizer, engine::Launcher}; + +#[derive(Debug)] +struct RejectAll; + +impl EgressAuthorizer for RejectAll { + fn authorize( + &self, + _: BrowserDialTarget, + ) -> std::pin::Pin> + Send>> { + Box::pin(async { Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")) }) + } + + fn consume( + &self, + _: BrowserDialTarget, + _: DialGrant, + _: SocketAddr, + ) -> std::pin::Pin> + Send>> { + Box::pin(async { Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")) }) + } + + fn validate( + &self, + _: String, + ) -> std::pin::Pin> + Send>> { + Box::pin(async { Ok(false) }) + } +} #[tokio::test] async fn an_empty_command_is_answered_without_launching_a_browser() { @@ -22,3 +51,40 @@ async fn an_empty_command_is_answered_without_launching_a_browser() { .is_some_and(|error| !error.is_empty()) ); } + +#[tokio::test] +async fn an_enforced_launcher_refuses_commands_until_a_private_proxy_launch() { + let mut engine = EnforcedAgentBrowser::new(Arc::new(RejectAll)).open("unit"); + let reply = engine + .execute(json!({"id":"navigate","action":"navigate","url":"https://example.test"})) + .await; + assert_eq!(reply["success"], false); + assert!(reply["error"].as_str().unwrap().contains("enforced launch")); +} + +#[test] +fn the_enforced_launch_replaces_caller_network_arguments() { + assert!(super::enforce_launch_with_config( + json!({"action":"launch","args":["--proxy-server=http://attacker","--user-data-dir=/tmp/custom"]}), + "http://127.0.0.1:43210", + "private-password", + false, + ) + .is_err()); + let command = super::enforce_launch_with_config( + json!({"action":"launch"}), + "http://127.0.0.1:43210", + "private-password", + false, + ) + .expect("host controlled launch config"); + assert_eq!(command["proxy"]["server"], "http://127.0.0.1:43210"); + assert_eq!(command["proxy"]["password"], "private-password"); + assert!( + command["args"] + .as_array() + .unwrap() + .iter() + .all(|arg| { !arg.as_str().unwrap_or_default().contains("attacker") }) + ); +} diff --git a/crates/tinycomputer-browser/src/linked/mod.rs b/crates/tinycomputer-browser/src/linked/mod.rs index f570def6..9502fa2c 100644 --- a/crates/tinycomputer-browser/src/linked/mod.rs +++ b/crates/tinycomputer-browser/src/linked/mod.rs @@ -10,10 +10,13 @@ //! serialized through [`ENGINE`]. Calls on one session are already serialized //! by the session lock. +use crate::{ + egress::{EgressAuthorizer, EgressProxy}, + engine::{Engine, Launcher, Reply}, +}; use agent_browser::{DaemonState, StateOptions, execute_command}; -use serde_json::Value; - -use crate::engine::{Engine, Launcher, Reply}; +use serde_json::{Value, json}; +use std::sync::Arc; /// Serializes commands across sessions; see the module docs. static ENGINE: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); @@ -22,31 +25,295 @@ static ENGINE: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); #[derive(Debug, Clone, Copy, Default)] pub struct AgentBrowser; +/// A launcher that pins every owned Chromium process to a host-created proxy. +/// The proxy address is host control and cannot be selected by browser calls. +#[derive(Clone)] +pub struct EnforcedAgentBrowser { + authorizer: Arc, + #[cfg(feature = "test-connector")] + test_connector: Option, +} + +impl std::fmt::Debug for EnforcedAgentBrowser { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("EnforcedAgentBrowser([host authorizer])") + } +} + +impl EnforcedAgentBrowser { + /// Use the authenticated host callback for every launched session. + #[must_use] + pub fn new(authorizer: Arc) -> Self { + Self { + authorizer, + #[cfg(feature = "test-connector")] + test_connector: None, + } + } + + /// Replace only the final TCP connect for a fixture-backed host test. + /// + /// Authorization, grant consumption, and proxy forwarding remain on their + /// normal paths. The fixture connector receives the exact approved socket + /// and must map it to a local listener rather than public network access. + #[cfg(feature = "test-connector")] + #[must_use] + pub fn with_test_connector(mut self, connector: crate::egress::TestConnector) -> Self { + self.test_connector = Some(connector); + self + } +} + impl Launcher for AgentBrowser { fn open(&self, session: &str) -> Box { Box::new(Linked { - state: DaemonState::with_options(StateOptions { - session_id: format!("tinycomputer-{}-{session}", std::process::id()), - ..StateOptions::default() - }), + state: DaemonState::with_options(state_options(session)), + egress_authorizer: None, + proxy: None, + proxy_session_id: String::new(), + enforced_launch: false, + #[cfg(feature = "test-connector")] + test_connector: None, + }) + } +} + +impl Launcher for EnforcedAgentBrowser { + fn open(&self, session: &str) -> Box { + Box::new(Linked { + state: DaemonState::with_options(state_options(session)), + egress_authorizer: Some(self.authorizer.clone()), + proxy: None, + proxy_session_id: private_session_id(), + enforced_launch: false, + #[cfg(feature = "test-connector")] + test_connector: self.test_connector.clone(), }) } } struct Linked { state: DaemonState, + egress_authorizer: Option>, + proxy: Option, + proxy_session_id: String, + enforced_launch: bool, + #[cfg(feature = "test-connector")] + test_connector: Option, +} + +impl Linked { + async fn bind_egress_proxy( + &self, + authorizer: Arc, + ) -> std::io::Result { + #[cfg(feature = "test-connector")] + if let Some(connector) = self.test_connector.clone() { + return EgressProxy::bind_with_test_connector( + authorizer, + self.proxy_session_id.clone(), + self.proxy_session_id.clone(), + move |address| connector(address), + ) + .await; + } + EgressProxy::bind( + authorizer, + self.proxy_session_id.clone(), + self.proxy_session_id.clone(), + ) + .await + } } impl Engine for Linked { fn execute(&mut self, command: Value) -> Reply<'_> { + let action = command + .get("action") + .and_then(Value::as_str) + .map(str::to_owned); + let launch = self.egress_authorizer.is_some() && action.as_deref() == Some("launch"); Box::pin(async move { + let command = if launch { + if self.proxy_session_id.len() != 48 { + return failure_reply( + &command, + "network policy could not create a private session identity", + ); + } + let Some(authorizer) = self.egress_authorizer.clone() else { + return failure_reply(&command, "network policy has no host authorizer"); + }; + if self.proxy.is_none() { + match self.bind_egress_proxy(authorizer).await { + Ok(proxy) => self.proxy = Some(proxy), + Err(_) => { + return failure_reply(&command, "network policy proxy could not start"); + } + } + } + let Some(proxy) = self.proxy.as_ref() else { + return failure_reply(&command, "network policy proxy could not start"); + }; + match enforce_launch(command.clone(), &proxy.url(), &self.proxy_session_id) { + Ok(command) => command, + Err(reason) => return failure_reply(&command, reason), + } + } else { + command + }; let _serialized = ENGINE.lock().await; - // The dispatcher's future is large (it spans every action), so it - // is boxed rather than held inline in this one. - Box::pin(execute_command(&command, &mut self.state)).await + if self.egress_authorizer.is_some() + && action.as_deref() == Some("read") + && command.get("url").and_then(Value::as_str).is_some() + { + return failure_reply( + &command, + "network policy refuses process-side reads with a URL", + ); + } + #[allow(clippy::redundant_closure_for_method_calls)] + let browser_unavailable = self + .state + .browser + .as_mut() + // BrowserManager is private in agent-browser, so its method cannot + // be named as a function item here. + .is_none_or(|browser| browser.has_process_exited()); + let closing_exited_browser = + action.as_deref() == Some("close") && self.enforced_launch && browser_unavailable; + if self.egress_authorizer.is_some() + && !launch + && !closing_exited_browser + && (!self.enforced_launch || browser_unavailable) + { + self.enforced_launch = false; + self.proxy.take(); + return failure_reply( + &command, + "network policy requires an enforced launch with a live browser session", + ); + } + let reply = Box::pin(execute_command(&command, &mut self.state)).await; + if launch && reply.get("success").and_then(Value::as_bool) == Some(true) { + self.enforced_launch = true; + } + if action.as_deref() == Some("close") + && reply.get("success").and_then(Value::as_bool) == Some(true) + { + self.enforced_launch = false; + self.proxy.take(); + } + reply }) } } +fn private_session_id() -> String { + let mut bytes = [0u8; 24]; + if getrandom::fill(&mut bytes).is_err() { + return String::new(); + } + bytes + .iter() + .fold(String::with_capacity(48), |mut output, byte| { + use std::fmt::Write as _; + let _ = write!(output, "{byte:02x}"); + output + }) +} + +fn state_options(session: &str) -> StateOptions { + StateOptions { + session_id: format!("tinycomputer-{}-{session}", std::process::id()), + ..StateOptions::default() + } +} + +fn failure_reply(command: &Value, reason: &str) -> Value { + json!({ + "id": command.get("id").cloned().unwrap_or(Value::Null), + "success": false, + "error": reason + }) +} + +fn enforce_launch( + command: Value, + proxy: &str, + proxy_password: &str, +) -> Result { + let has_ambient_browser_config = command.get("action").and_then(Value::as_str) + == Some("launch") + && has_ambient_browser_config(); + enforce_launch_with_config(command, proxy, proxy_password, has_ambient_browser_config) +} + +fn enforce_launch_with_config( + mut command: Value, + proxy: &str, + proxy_password: &str, + has_ambient_browser_config: bool, +) -> Result { + if command.get("action").and_then(Value::as_str) != Some("launch") { + return Ok(command); + } + if [ + "cdpUrl", + "cdpPort", + "autoConnect", + "provider", + "profile", + "storageState", + "restore", + "executablePath", + ] + .iter() + .any(|key| command.get(key).is_some()) + { + return Err("network policy refuses attached or externally managed browsers"); + } + if command + .get("args") + .and_then(Value::as_array) + .is_some_and(|args| !args.is_empty()) + { + return Err("network policy refuses caller-supplied browser launch arguments"); + } + if has_ambient_browser_config { + return Err("network policy refuses ambient external browser configuration"); + } + // Chromium's special <-loopback> token removes its implicit direct + // localhost bypass. Without this, loopback requests evade authorization. + command["proxy"] = json!({ + "server": proxy, + "bypass": "<-loopback>", + "username": "tinycomputer", + "password": proxy_password + }); + command["engine"] = json!("chrome"); + command["args"] = json!([ + "--disable-quic", + "--disable-http2", + "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1", + "--force-webrtc-ip-handling-policy=disable_non_proxied_udp" + ]); + Ok(command) +} + +fn has_ambient_browser_config() -> bool { + std::env::var_os("AGENT_BROWSER_CDP").is_some() + || std::env::var_os("AGENT_BROWSER_AUTO_CONNECT").is_some() + || std::env::var_os("AGENT_BROWSER_PROVIDER").is_some() + || std::env::var_os("AGENT_BROWSER_ENGINE").is_some() + || std::env::var_os("AGENT_BROWSER_EXECUTABLE_PATH").is_some() + || std::env::var_os("AGENT_BROWSER_PROXY").is_some() + || std::env::var_os("AGENT_BROWSER_PROXY_BYPASS").is_some() + || std::env::var_os("AGENT_BROWSER_PROXY_USERNAME").is_some() + || std::env::var_os("AGENT_BROWSER_PROXY_PASSWORD").is_some() + || std::env::var_os("AGENT_BROWSER_PROFILE").is_some() + || std::env::var_os("AGENT_BROWSER_RESTORE").is_some() +} + #[cfg(test)] mod linked_tests; diff --git a/crates/tinycomputer-bus/src/agent/mod.rs b/crates/tinycomputer-bus/src/agent/mod.rs index abd85eb5..6ddf0036 100644 --- a/crates/tinycomputer-bus/src/agent/mod.rs +++ b/crates/tinycomputer-bus/src/agent/mod.rs @@ -52,10 +52,11 @@ mod types; pub use types::{ AGENT_ENVELOPE_VERSION, AgentError, AgentResponse, AgentResponseError, AwaitTaskRequest, - Capabilities, ContinueTaskRequest, Example, InputField, InputKind, LanguageModelConfiguration, - LanguageModelProvider, MemberDoc, PaymentMode, PlanTaskRequest, Rescue, RescueOutcome, - StartTaskRequest, StepView, SurfaceAvailability, SurfaceKind, TaskBudget, TaskConstraints, - TaskId, TaskOutput, TaskPlan, TaskRef, TaskReport, TaskReportRequest, TaskStatus, TaskView, + Capabilities, ContinueTaskRequest, Example, HostContext, InputField, InputKind, + LanguageModelConfiguration, LanguageModelProvider, MemberDoc, PaymentMode, PlanTaskRequest, + Rescue, RescueOutcome, StartTaskRequest, StepView, SurfaceAvailability, SurfaceKind, + TaskBudget, TaskConstraints, TaskId, TaskOutput, TaskPlan, TaskRef, TaskReport, + TaskReportRequest, TaskStatus, TaskView, }; #[cfg(test)] diff --git a/crates/tinycomputer-bus/src/agent/types/mod.rs b/crates/tinycomputer-bus/src/agent/types/mod.rs index efd3cd4f..c41b4bbf 100644 --- a/crates/tinycomputer-bus/src/agent/types/mod.rs +++ b/crates/tinycomputer-bus/src/agent/types/mod.rs @@ -12,8 +12,8 @@ pub use models::{LanguageModelConfiguration, LanguageModelProvider}; pub use reply::{AGENT_ENVELOPE_VERSION, AgentError, AgentResponse, AgentResponseError}; pub use report::{Rescue, RescueOutcome, TaskReport}; pub use request::{ - AwaitTaskRequest, ContinueTaskRequest, PaymentMode, PlanTaskRequest, StartTaskRequest, - SurfaceKind, TaskBudget, TaskConstraints, TaskId, TaskOutput, TaskPlan, TaskRef, - TaskReportRequest, + AwaitTaskRequest, ContinueTaskRequest, HostContext, PaymentMode, PlanTaskRequest, + StartTaskRequest, SurfaceKind, TaskBudget, TaskConstraints, TaskId, TaskOutput, TaskPlan, + TaskRef, TaskReportRequest, }; pub use status::{InputField, InputKind, StepView, TaskStatus, TaskView}; diff --git a/crates/tinycomputer-bus/src/agent/types/request.rs b/crates/tinycomputer-bus/src/agent/types/request.rs index 18e0b79a..a48c92e9 100644 --- a/crates/tinycomputer-bus/src/agent/types/request.rs +++ b/crates/tinycomputer-bus/src/agent/types/request.rs @@ -75,7 +75,7 @@ pub struct StartTaskRequest { /// hands what its steps read to one reasoning-model pass that returns /// JSON in this shape, as `done.result`; absent, a task ends with its /// records only. - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub output: Option, /// The site the task starts on, a plain host name. When set and the host /// serves `SiteMemoryHost`, the module hands the task what finished tasks @@ -215,8 +215,43 @@ pub struct ContinueTaskRequest { /// A free-text answer, for a pause that asked a question — or, for /// `needs_human`, `"done"` once the person has finished. pub answer: Option, + /// Opaque host-issued context to relay unchanged to consequence review. + /// This value is not authorization and is never interpreted by the module. + #[serde(skip_serializing_if = "Option::is_none")] + pub host_context: Option, } +/// Opaque, host-issued context carried from a task continuation to a +/// `ConsequenceHost` review. The module relays it unchanged and never treats +/// it as authorization. Debug output deliberately hides the value. +#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct HostContext(String); + +impl HostContext { + /// Wraps a host-issued opaque context value. + #[must_use] + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Returns the opaque value for host-side lookup. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for HostContext { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("HostContext([opaque])") + } +} + +#[cfg(test)] +#[path = "request_tests.rs"] +mod tests; + /// A task named in a request. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TaskRef { diff --git a/crates/tinycomputer-bus/src/agent/types/request_tests.rs b/crates/tinycomputer-bus/src/agent/types/request_tests.rs new file mode 100644 index 00000000..fe2ed4f9 --- /dev/null +++ b/crates/tinycomputer-bus/src/agent/types/request_tests.rs @@ -0,0 +1,24 @@ +//! Tests for opaque, host-issued values in Agent requests. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +#[test] +fn host_context_is_transparent_on_the_wire_and_redacted_in_debug() { + let context = HostContext::new("private-host-capability"); + assert_eq!(context.as_str(), "private-host-capability"); + assert_eq!( + serde_json::to_value(&context).unwrap(), + "private-host-capability" + ); + assert_eq!(format!("{context:?}"), "HostContext([opaque])"); + + let request = ContinueTaskRequest { + host_context: Some(context), + ..ContinueTaskRequest::default() + }; + assert_eq!( + serde_json::to_value(request).unwrap()["host_context"], + "private-host-capability" + ); +} diff --git a/crates/tinycomputer-bus/src/browser_egress/browser_egress_tests.rs b/crates/tinycomputer-bus/src/browser_egress/browser_egress_tests.rs new file mode 100644 index 00000000..d20a19c6 --- /dev/null +++ b/crates/tinycomputer-bus/src/browser_egress/browser_egress_tests.rs @@ -0,0 +1,106 @@ +//! Browser egress callbacks carry destinations and opaque grants only. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +#[test] +fn browser_callback_contract_has_no_http_payload_or_caller_selected_addresses() { + let target = BrowserEgressTarget { + policy_id: "private-policy".into(), + expected_generation: 8, + session_id: "private-session".into(), + scheme: "https".into(), + hostname: "example.test".into(), + port: 443, + }; + assert_eq!( + serde_json::to_value(&target).unwrap(), + serde_json::json!({ + "policy_id": "private-policy", + "expected_generation": 8, + "session_id": "private-session", + "scheme": "https", + "hostname": "example.test", + "port": 443 + }) + ); + let grant = BrowserEgressGrant { + generation: 8, + authorization: "opaque serialized TinySecurity grant".into(), + addresses: vec!["203.0.113.8:443".into()], + }; + let consume = BrowserEgressConsumeRequest { + target: target.clone(), + authorization: grant.authorization.clone(), + address: grant.addresses[0].clone(), + }; + let validate = BrowserEgressValidateRequest { + authorization: grant.authorization.clone(), + }; + let decision = BrowserEgressDecision { allowed: true }; + + assert_eq!(METHODS, ["Authorize", "Consume", "Validate"]); + assert_eq!( + serde_json::to_value(&consume).unwrap()["address"], + "203.0.113.8:443" + ); + assert_eq!( + serde_json::to_value(&validate).unwrap(), + serde_json::json!({ "authorization": "opaque serialized TinySecurity grant" }) + ); + assert_eq!( + serde_json::to_value(decision).unwrap(), + serde_json::json!({ "allowed": true }) + ); +} + +#[test] +fn callback_types_reject_unrecognized_fields() { + let malformed = serde_json::json!({ + "policy_id": "p", "expected_generation": 1, "session_id": "s", + "scheme": "https", "hostname": "example.test", "port": 443, + "headers": ["secret"] + }); + assert!(serde_json::from_value::(malformed).is_err()); +} + +#[test] +fn callback_debug_redacts_policy_destination_and_grant_material() { + let target = BrowserEgressTarget { + policy_id: "secret-subject-policy".into(), + expected_generation: 9, + session_id: "private-browser-session".into(), + scheme: "https".into(), + hostname: "private.example.test".into(), + port: 443, + }; + let grant = BrowserEgressGrant { + generation: 9, + authorization: "private-one-use-nonce".into(), + addresses: vec!["198.51.100.99:443".into()], + }; + let consume = BrowserEgressConsumeRequest { + target, + authorization: grant.authorization.clone(), + address: grant.addresses[0].clone(), + }; + let validate = BrowserEgressValidateRequest { + authorization: grant.authorization.clone(), + }; + for debug in [ + format!("{consume:?}"), + format!("{validate:?}"), + format!("{grant:?}"), + ] { + for private in [ + "secret-subject-policy", + "private-browser-session", + "private.example.test", + "private-one-use-nonce", + "198.51.100.99", + ] { + assert!(!debug.contains(private)); + } + assert!(debug.contains("REDACTED")); + } +} diff --git a/crates/tinycomputer-bus/src/browser_egress/mod.rs b/crates/tinycomputer-bus/src/browser_egress/mod.rs new file mode 100644 index 00000000..c9b023ac --- /dev/null +++ b/crates/tinycomputer-bus/src/browser_egress/mod.rs @@ -0,0 +1,28 @@ +//! Private callback contract for the module's authenticated browser egress. + +mod types; +pub use types::{ + BrowserEgressConsumeRequest, BrowserEgressDecision, BrowserEgressGrant, BrowserEgressTarget, + BrowserEgressValidateRequest, +}; + +/// Host interface for TinySecurity-backed browser egress decisions. +pub const INTERFACE: &str = "ai.tinyhumans.tinycomputer.BrowserEgressHost"; +/// Object path for [`INTERFACE`]. +pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinycomputer/BrowserEgressHost"; + +/// Callback members in dispatch order. +pub mod methods { + /// Authorize one actual browser destination. + pub const AUTHORIZE: &str = "Authorize"; + /// Consume a one-use grant for an exact socket address. + pub const CONSUME: &str = "Consume"; + /// Validate a live tunnel lease. + pub const VALIDATE: &str = "Validate"; +} + +/// Callback names in dispatch order. +pub const METHODS: [&str; 3] = [methods::AUTHORIZE, methods::CONSUME, methods::VALIDATE]; + +#[cfg(test)] +mod browser_egress_tests; diff --git a/crates/tinycomputer-bus/src/browser_egress/types.rs b/crates/tinycomputer-bus/src/browser_egress/types.rs new file mode 100644 index 00000000..ed3f0c91 --- /dev/null +++ b/crates/tinycomputer-bus/src/browser_egress/types.rs @@ -0,0 +1,86 @@ +//! Payload types for browser egress host callbacks. + +use serde::{Deserialize, Serialize}; + +/// Destination parsed from the browser's actual HTTP proxy request. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserEgressTarget { + /// Immutable host-provided policy identifier. + pub policy_id: String, + /// Expected `TinySecurity` generation captured at browser session creation. + pub expected_generation: u64, + /// Unpredictable browser-proxy session ID generated by `TinyComputer`. + pub session_id: String, + /// Canonical `http` or `https` scheme (WS maps to the corresponding HTTP scheme). + pub scheme: String, + /// Canonical DNS hostname or IP literal. + pub hostname: String, + /// Effective destination port. + pub port: u16, +} + +impl std::fmt::Debug for BrowserEgressTarget { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("BrowserEgressTarget([REDACTED])") + } +} + +/// Opaque `TinySecurity` grant and its vetted socket addresses. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserEgressGrant { + /// Active generation; mismatch causes refusal before a socket is opened. + pub generation: u64, + /// Opaque serialized `TinySecurity` authorization; the module treats it as data. + pub authorization: String, + /// Exact vetted socket addresses; the module does not resolve hostnames. + pub addresses: Vec, +} + +impl std::fmt::Debug for BrowserEgressGrant { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("BrowserEgressGrant([REDACTED])") + } +} + +/// Exact socket selected from the returned grant. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserEgressConsumeRequest { + /// Destination used for authorization. + pub target: BrowserEgressTarget, + /// Opaque, unmodified `TinySecurity` authorization returned by `Authorize`. + pub authorization: String, + /// One exact member of `BrowserEgressGrant.addresses`. + pub address: String, +} + +impl std::fmt::Debug for BrowserEgressConsumeRequest { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("BrowserEgressConsumeRequest([REDACTED])") + } +} + +/// Lease validation request for an already consumed tunnel. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserEgressValidateRequest { + /// Opaque, unmodified `TinySecurity` authorization returned by `Authorize`. + pub authorization: String, +} + +impl std::fmt::Debug for BrowserEgressValidateRequest { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("BrowserEgressValidateRequest([REDACTED])") + } +} + +/// Result for `Consume` and `Validate`; anything other than `allowed: true` +/// refuses or closes the connection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserEgressDecision { + /// Whether `TinySecurity` accepted the exact request or live lease. + pub allowed: bool, +} diff --git a/crates/tinycomputer-bus/src/consequence_host/consequence_host_tests.rs b/crates/tinycomputer-bus/src/consequence_host/consequence_host_tests.rs index 7a341a29..7d444c7f 100644 --- a/crates/tinycomputer-bus/src/consequence_host/consequence_host_tests.rs +++ b/crates/tinycomputer-bus/src/consequence_host/consequence_host_tests.rs @@ -25,6 +25,39 @@ fn a_review_request_keeps_its_wire_form() { assert_eq!(irreversible, "irreversible"); } +#[test] +fn review_request_host_context_is_optional_and_round_trips() { + let legacy: ReviewActionRequest = serde_json::from_value(serde_json::json!({ + "task": "t1", "action": "pay now", "target": "Pay", "kind": "payment" + })) + .unwrap(); + assert_eq!(legacy.host_context, None); + assert!( + serde_json::to_value(&legacy) + .unwrap() + .get("host_context") + .is_none() + ); + + let with_context = ReviewActionRequest { + task: "t2".into(), + action: "submit".into(), + target: "Submit".into(), + kind: ConsequenceKind::Irreversible, + host_context: Some(crate::agent::HostContext::new("opaque-host-context")), + }; + let encoded = serde_json::to_value(&with_context).unwrap(); + assert_eq!(encoded["host_context"], "opaque-host-context"); + let decoded: ReviewActionRequest = serde_json::from_value(encoded).unwrap(); + assert_eq!( + decoded + .host_context + .as_ref() + .map(crate::agent::HostContext::as_str), + Some("opaque-host-context") + ); +} + #[test] fn a_reason_is_optional_and_left_out_when_absent() { let reply: ReviewActionResponse = serde_json::from_str(r#"{"allow":true}"#).unwrap(); diff --git a/crates/tinycomputer-bus/src/consequence_host/mod.rs b/crates/tinycomputer-bus/src/consequence_host/mod.rs index ec2491e8..7c402135 100644 --- a/crates/tinycomputer-bus/src/consequence_host/mod.rs +++ b/crates/tinycomputer-bus/src/consequence_host/mod.rs @@ -21,6 +21,8 @@ use serde::{Deserialize, Serialize}; +use crate::agent::HostContext; + /// The interface the host serves consequence review on. pub const INTERFACE: &str = "ai.tinyhumans.tinycomputer.ConsequenceHost"; @@ -58,6 +60,10 @@ pub struct ReviewActionRequest { pub target: String, /// What the module classified the action as. pub kind: ConsequenceKind, + /// Opaque host-issued context from `ContinueTask`. The module relays it + /// unchanged and grants it no authority. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_context: Option, } /// The reply to `Review`. diff --git a/crates/tinycomputer-bus/src/lib.rs b/crates/tinycomputer-bus/src/lib.rs index 0dcd1873..e4d4e497 100644 --- a/crates/tinycomputer-bus/src/lib.rs +++ b/crates/tinycomputer-bus/src/lib.rs @@ -119,6 +119,7 @@ pub mod agent; pub mod agentic; pub mod apps; pub mod browser; +pub mod browser_egress; pub mod catalogue; pub mod clipboard; pub mod config; diff --git a/crates/tinycomputer/src/tinybus_module/browser_egress.rs b/crates/tinycomputer/src/tinybus_module/browser_egress.rs new file mode 100644 index 00000000..2ad5a1bd --- /dev/null +++ b/crates/tinycomputer/src/tinybus_module/browser_egress.rs @@ -0,0 +1,153 @@ +//! Browser network callback adapter. Policy and DNS decisions live in the +//! authenticated host callback, which delegates to `TinySecurity`; this module +//! only forwards requests and enforces the returned exact sockets. + +use std::{future::Future, io, net::SocketAddr, pin::Pin}; + +use tinybus::Connection; +use tinycomputer_browser::{BrowserDialTarget, DialGrant, EgressAuthorizer}; +use tinycomputer_bus::browser_egress::{ + self as contract, BrowserEgressConsumeRequest, BrowserEgressTarget, + BrowserEgressValidateRequest, +}; + +const CALLBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// Authenticated callback adapter to the host's `TinySecurity` bus bridge. +#[derive(Clone)] +pub(super) struct BusAuthorizer { + connection: Connection, + policy_id: String, + generation: u64, +} + +impl std::fmt::Debug for BusAuthorizer { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("BusAuthorizer") + .finish_non_exhaustive() + } +} + +impl BusAuthorizer { + pub(super) fn new(connection: Connection, policy_id: String, generation: u64) -> Self { + Self { + connection, + policy_id, + generation, + } + } + + fn proxy(&self) -> io::Result { + self.connection + .proxy( + contract::INTERFACE, + contract::OBJECT_PATH, + contract::INTERFACE, + ) + .map(|proxy| proxy.with_timeout(CALLBACK_TIMEOUT)) + .map_err(denied) + } + + fn target(&self, target: BrowserDialTarget) -> BrowserEgressTarget { + BrowserEgressTarget { + policy_id: self.policy_id.clone(), + expected_generation: self.generation, + session_id: target.session_id, + scheme: target.scheme, + hostname: target.hostname, + port: target.port, + } + } +} + +impl EgressAuthorizer for BusAuthorizer { + fn authorize( + &self, + target: BrowserDialTarget, + ) -> Pin> + Send>> { + let this = self.clone(); + Box::pin(async move { + let expected_port = target.port; + let request = this.target(target); + let response: contract::BrowserEgressGrant = this + .proxy()? + .call(contract::methods::AUTHORIZE, (request,)) + .await + .map_err(denied)?; + if response.generation != this.generation + || response.authorization.is_empty() + || response.addresses.is_empty() + || response.addresses.len() > 64 + { + return Err(denied("host returned a stale or empty browser grant")); + } + let addresses = response + .addresses + .iter() + .map(|address| address.parse::().map_err(denied)) + .collect::>>()?; + if addresses + .iter() + .any(|address| address.port() != expected_port) + { + return Err(denied("host returned an address for a different port")); + } + Ok(DialGrant { + lease: response.authorization, + addresses, + }) + }) + } + + fn consume( + &self, + target: BrowserDialTarget, + grant: DialGrant, + address: SocketAddr, + ) -> Pin> + Send>> { + let this = self.clone(); + Box::pin(async move { + if !grant.addresses.contains(&address) { + return Err(denied("socket address was not in the host grant")); + } + let request = BrowserEgressConsumeRequest { + target: this.target(target), + authorization: grant.lease, + address: address.to_string(), + }; + let decision: contract::BrowserEgressDecision = this + .proxy()? + .call(contract::methods::CONSUME, (request,)) + .await + .map_err(denied)?; + if decision.allowed { + Ok(()) + } else { + Err(denied("TinySecurity refused browser socket grant")) + } + }) + } + + fn validate(&self, lease: String) -> Pin> + Send>> { + let this = self.clone(); + Box::pin(async move { + let request = BrowserEgressValidateRequest { + authorization: lease, + }; + let decision: contract::BrowserEgressDecision = this + .proxy()? + .call(contract::methods::VALIDATE, (request,)) + .await + .map_err(denied)?; + Ok(decision.allowed) + }) + } +} + +fn denied(error: impl std::fmt::Display) -> io::Error { + io::Error::new(io::ErrorKind::PermissionDenied, error.to_string()) +} + +#[cfg(test)] +mod browser_egress_tests; diff --git a/crates/tinycomputer/src/tinybus_module/browser_egress/browser_egress_tests.rs b/crates/tinycomputer/src/tinybus_module/browser_egress/browser_egress_tests.rs new file mode 100644 index 00000000..5e385e67 --- /dev/null +++ b/crates/tinycomputer/src/tinybus_module/browser_egress/browser_egress_tests.rs @@ -0,0 +1,186 @@ +//! Tests for the mechanical TinyBus-to-TinySecurity browser egress adapter. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +use super::*; +use std::sync::{Arc, Mutex}; +use tinybus::{Connection, broker::Broker, transport::memory::MemoryBus}; +use tinycomputer_bus::browser_egress::{ + self as contract, BrowserEgressConsumeRequest, BrowserEgressDecision, BrowserEgressGrant, + BrowserEgressTarget, BrowserEgressValidateRequest, +}; + +#[derive(Clone)] +struct Host { + addresses: Vec, + generation: u64, + consume_allowed: bool, + validate_allowed: bool, + seen: Arc>>, +} + +#[tinybus::interface(name = "ai.tinyhumans.tinycomputer.BrowserEgressHost")] +impl Host { + async fn authorize(&self, request: BrowserEgressTarget) -> tinybus::Result { + tokio::task::yield_now().await; + self.seen.lock().unwrap().push(format!( + "authorize:{}:{}:{}:{}:{}:{}", + request.policy_id, + request.expected_generation, + request.session_id, + request.scheme, + request.hostname, + request.port + )); + Ok(BrowserEgressGrant { + generation: self.generation, + authorization: "secret-lease".into(), + addresses: self.addresses.clone(), + }) + } + + async fn consume( + &self, + request: BrowserEgressConsumeRequest, + ) -> tinybus::Result { + tokio::task::yield_now().await; + self.seen.lock().unwrap().push(format!( + "consume:{}:{}:{}:{}:{}:{}", + request.target.policy_id, + request.target.expected_generation, + request.target.session_id, + request.target.scheme, + request.target.hostname, + request.address + )); + Ok(BrowserEgressDecision { + allowed: self.consume_allowed, + }) + } + + async fn validate( + &self, + request: BrowserEgressValidateRequest, + ) -> tinybus::Result { + tokio::task::yield_now().await; + self.seen + .lock() + .unwrap() + .push(format!("validate:{}", request.authorization)); + Ok(BrowserEgressDecision { + allowed: self.validate_allowed, + }) + } +} + +async fn connect_host(host: Host) -> tinybus::Result<(BusAuthorizer, Connection)> { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let connection = Connection::connect(bus.connect().await?).await?; + connection + .serve_at(contract::OBJECT_PATH.try_into()?, host) + .await?; + connection.request_name(contract::INTERFACE).await?; + let module = Connection::connect(bus.connect().await?).await?; + Ok(( + BusAuthorizer::new(module, "policy-private".into(), 7), + connection, + )) +} + +fn dial_target() -> BrowserDialTarget { + BrowserDialTarget { + scheme: "https".into(), + hostname: "example.test".into(), + port: 443, + session_id: "session-private".into(), + } +} + +fn host(addresses: Vec) -> Host { + Host { + addresses, + generation: 7, + consume_allowed: true, + validate_allowed: true, + seen: Arc::new(Mutex::new(Vec::new())), + } +} + +#[tokio::test] +async fn bus_adapter_delegates_exact_target_and_checks_the_fixed_socket() -> tinybus::Result<()> { + let host = host(vec!["8.8.8.8:443".into()]); + let seen = host.seen.clone(); + let (authorizer, _host_connection) = connect_host(host).await?; + let target = dial_target(); + let grant = authorizer.authorize(target.clone()).await?; + let address = grant.addresses[0]; + authorizer.consume(target, grant.clone(), address).await?; + assert!(authorizer.validate(grant.lease).await?); + let calls = seen.lock().unwrap(); + assert_eq!(calls.len(), 3); + assert!(calls[0].contains("policy-private:7:session-private:https:example.test:443")); + assert!(calls[1].contains("policy-private:7:session-private:https:example.test:8.8.8.8:443")); + assert!(calls[2].contains("validate:secret-lease")); + Ok(()) +} + +#[tokio::test] +async fn bus_adapter_rejects_wrong_generation_port_and_ungranted_socket() -> tinybus::Result<()> { + let (wrong_generation, _wrong_generation_connection) = connect_host(Host { + generation: 8, + ..host(vec!["8.8.8.8:443".into()]) + }) + .await?; + assert!(wrong_generation.authorize(dial_target()).await.is_err()); + + let (wrong_port, _wrong_port_connection) = + connect_host(host(vec!["8.8.8.8:80".into()])).await?; + assert!(wrong_port.authorize(dial_target()).await.is_err()); + + let host = host(vec!["8.8.8.8:443".into()]); + let seen = host.seen.clone(); + let (authorizer, _host_connection) = connect_host(host).await?; + let target = dial_target(); + let grant = authorizer.authorize(target.clone()).await?; + assert!( + authorizer + .consume( + target, + grant, + std::net::SocketAddr::from(([1, 1, 1, 1], 443)), + ) + .await + .is_err() + ); + assert_eq!(seen.lock().unwrap().len(), 1); + Ok(()) +} + +#[tokio::test] +async fn bus_adapter_refuses_host_denials_and_malformed_grants() -> tinybus::Result<()> { + let denied_host = Host { + consume_allowed: false, + validate_allowed: false, + ..host(vec!["8.8.8.8:443".into()]) + }; + let (authorizer, _denied_host_connection) = connect_host(denied_host).await?; + let target = dial_target(); + let grant = authorizer.authorize(target.clone()).await?; + let address = grant.addresses[0]; + assert!( + authorizer + .consume(target, grant.clone(), address) + .await + .is_err() + ); + assert!(!authorizer.validate(grant.lease).await?); + + for addresses in [ + vec![], + (0..65).map(|_| "8.8.8.8:443".into()).collect(), + vec!["not-a-socket".into()], + ] { + let (malformed, _malformed_connection) = connect_host(host(addresses)).await?; + assert!(malformed.authorize(dial_target()).await.is_err()); + } + Ok(()) +} diff --git a/crates/tinycomputer/src/tinybus_module/config.rs b/crates/tinycomputer/src/tinybus_module/config.rs index 8c95c109..5916fb5f 100644 --- a/crates/tinycomputer/src/tinybus_module/config.rs +++ b/crates/tinycomputer/src/tinybus_module/config.rs @@ -30,6 +30,21 @@ pub(crate) struct BrowserDefaults { /// drafted (the default), at the one web address the task's text names /// if it names one, rather than at its first step. pub(crate) prelaunch: bool, + /// Private host selected scope for enforcing browser socket egress. + pub(crate) egress: Option, +} + +/// Host-selected policy identity and generation; never an agent argument. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct BrowserEgressConfig { + pub(crate) policy_id: String, + pub(crate) generation: u64, +} + +impl std::fmt::Debug for BrowserEgressConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("BrowserEgressConfig([REDACTED])") + } } impl Default for BrowserDefaults { @@ -41,15 +56,22 @@ impl Default for BrowserDefaults { perception: Perception::default(), settle: Settle::default(), prelaunch: true, + egress: None, } } } impl BrowserDefaults { /// Applies the browser defaults decoded from the public module config. - pub(crate) fn from_config(config: Option<&BrowserConfig>) -> Self { + pub(crate) fn from_config( + config: Option<&BrowserConfig>, + egress: Option, + ) -> Self { let Some(config) = config else { - return Self::default(); + return Self { + egress, + ..Self::default() + }; }; Self { executable: config.executable.clone(), @@ -64,7 +86,45 @@ impl BrowserDefaults { BrowserSettle::Steady => Settle::Steady, }, prelaunch: config.prelaunch, + egress, + } + } + + pub(crate) fn egress_from_raw( + config: &serde_json::Value, + ) -> Result> { + let Some(browser) = config.as_object().and_then(|object| object.get("browser")) else { + return Ok(None); + }; + let Some(egress) = browser.as_object().and_then(|object| object.get("egress")) else { + return Ok(None); + }; + let invalid = || crate::Error::ConfigFieldType { + field: "browser", + expected: "an object with a valid private egress policy", + }; + let object = egress.as_object().ok_or_else(invalid)?; + let policy_id = object + .get("policy_id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.is_empty() && id.len() <= 64) + .ok_or_else(invalid)?; + let generation = object + .get("generation") + .and_then(serde_json::Value::as_u64) + .filter(|generation| *generation > 0) + .ok_or_else(invalid)?; + if object.len() != 2 + || browser + .as_object() + .is_some_and(|browser| browser.contains_key("args")) + { + return Err(invalid()); } + Ok(Some(BrowserEgressConfig { + policy_id: policy_id.to_owned(), + generation, + })) } /// `options` with these defaults filled in where the caller left them diff --git a/crates/tinycomputer/src/tinybus_module/consequence_gate/consequence_gate_tests.rs b/crates/tinycomputer/src/tinybus_module/consequence_gate/consequence_gate_tests.rs index dbc9a423..0b110338 100644 --- a/crates/tinycomputer/src/tinybus_module/consequence_gate/consequence_gate_tests.rs +++ b/crates/tinycomputer/src/tinybus_module/consequence_gate/consequence_gate_tests.rs @@ -60,6 +60,7 @@ fn approval(approve: Option) -> ContinueTaskRequest { inputs: std::collections::BTreeMap::new(), approve, answer: None, + host_context: None, } } @@ -104,6 +105,25 @@ async fn an_allowed_approval_goes_through() { assert_eq!(seen[0].kind, ConsequenceKind::Irreversible); } +#[tokio::test] +async fn host_context_is_relayed_unchanged_to_consequence_review() { + let host = FakeHost::new(Answer::Allow); + let link = ConsequenceLink::new(Box::new(host.clone())); + let mut request = approval(Some(true)); + request.host_context = Some(tinycomputer_bus::agent::HostContext::new("host-token")); + + link.screen(&mut request, Some(pending())).await; + + assert_eq!(request.approve, Some(true)); + assert_eq!( + host.seen.lock().unwrap()[0] + .host_context + .as_ref() + .map(tinycomputer_bus::agent::HostContext::as_str), + Some("host-token") + ); +} + #[tokio::test] async fn a_denied_approval_becomes_a_decline() { let (approve, _) = screened(Answer::Deny(Some("policy: no sends")), Some(true)).await; @@ -174,7 +194,7 @@ async fn the_bus_port_reaches_the_hosts_interface() -> tinybus::Result<()> { let module = Connection::connect(bus.connect().await?).await?; let link = ConsequenceLink::new(Box::new(BusPort::new(module))); - let verdict = link.review("t1", "pay now", "Pay").await; + let verdict = link.review("t1", "pay now", "Pay", None).await; assert_eq!(verdict, Verdict::Denied("no".to_owned())); assert_eq!(fake.seen.lock().unwrap()[0].kind, ConsequenceKind::Payment); Ok(()) @@ -189,6 +209,9 @@ async fn the_bus_port_treats_a_host_that_serves_nothing_as_unreviewed() -> tinyb Broker::new().spawn(bus.clone()); let module = Connection::connect(bus.connect().await?).await?; let link = ConsequenceLink::new(Box::new(BusPort::new(module))); - assert_eq!(link.review("t1", "send", "Send").await, Verdict::Unreviewed); + assert_eq!( + link.review("t1", "send", "Send", None).await, + Verdict::Unreviewed + ); Ok(()) } diff --git a/crates/tinycomputer/src/tinybus_module/consequence_gate/mod.rs b/crates/tinycomputer/src/tinybus_module/consequence_gate/mod.rs index 93dd0e77..7e80a2a8 100644 --- a/crates/tinycomputer/src/tinybus_module/consequence_gate/mod.rs +++ b/crates/tinycomputer/src/tinybus_module/consequence_gate/mod.rs @@ -129,13 +129,20 @@ impl ConsequenceLink { /// Asks the host whether task `task` may go ahead with `action` on /// `target`. - pub(crate) async fn review(&self, task: &str, action: &str, target: &str) -> Verdict { + pub(crate) async fn review( + &self, + task: &str, + action: &str, + target: &str, + host_context: Option, + ) -> Verdict { let kind = kind_of(action, target); let request = ReviewActionRequest { task: task.to_owned(), action: action.to_owned(), target: target.to_owned(), kind, + host_context, }; match self.port.review(request).await { Ok(reply) if reply.allow => Verdict::Allowed, @@ -169,7 +176,15 @@ impl ConsequenceLink { let Some((action, target)) = paused else { return; }; - if let Verdict::Denied(_) = self.review(&request.id.to_string(), &action, &target).await { + if let Verdict::Denied(_) = self + .review( + &request.id.to_string(), + &action, + &target, + request.host_context.clone(), + ) + .await + { request.approve = Some(false); } } diff --git a/crates/tinycomputer/src/tinybus_module/dispatch/service.rs b/crates/tinycomputer/src/tinybus_module/dispatch/service.rs index dcc4ee9d..9a20a2f2 100644 --- a/crates/tinycomputer/src/tinybus_module/dispatch/service.rs +++ b/crates/tinycomputer/src/tinybus_module/dispatch/service.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde::de::DeserializeOwned; use tinybus::{Error as TinyBusError, Result as TinyBusResult}; -use tinycomputer_browser::{AgentBrowser, Browser}; +use tinycomputer_browser::{AgentBrowser, Browser, EnforcedAgentBrowser}; use tinycomputer_bus::agent::{ AgentResponse, ContinueTaskRequest, StartTaskRequest, SurfaceAvailability, SurfaceKind, TaskStatus, TaskView, @@ -15,6 +15,7 @@ use tinycomputer_bus::{DesktopResponse, JevConfig}; use tinycomputer_engine as agentic; use super::DesktopService; +use crate::tinybus_module::browser_egress::BusAuthorizer; use crate::tinybus_module::config::{BrowserDefaults, cursor_config}; use crate::tinybus_module::consequence_gate::{ConsequenceLink, ConsequencePort}; use crate::tinybus_module::runner::WorkspaceRunner; @@ -27,6 +28,14 @@ fn computer_config(config: &serde_json::Value) -> Result { if !config.is_null() && config.as_object().is_none() { return Err(crate::Error::ConfigNotAnObject); } + let mut browser_config = config.clone(); + if let Some(browser) = browser_config + .as_object_mut() + .and_then(|object| object.get_mut("browser")) + .and_then(serde_json::Value::as_object_mut) + { + browser.remove("egress"); + } Ok(ComputerConfig { jev: config_field(config, "jev", "a valid Jev configuration object")?, planner: config_field( @@ -35,7 +44,7 @@ fn computer_config(config: &serde_json::Value) -> Result { "a planner configuration object with an api_key and an approved route", )?, browser: config_field( - config, + &browser_config, "browser", "an object with optional executable and browser defaults", )?, @@ -86,17 +95,59 @@ impl DesktopService { /// # Errors /// /// Propagates whatever [`Desktop::from_config`] rejects. + #[cfg(test)] pub(crate) fn from_config(config: &serde_json::Value) -> Result { + if super::super::config::BrowserDefaults::egress_from_raw(config)?.is_some() { + return Err(crate::Error::ConfigFieldType { + field: "browser", + expected: "a module set up with its authenticated host callback connection", + }); + } Self::with_browser(config, Arc::new(Browser::new(Arc::new(AgentBrowser)))) } + /// Builds the service with the host callback connection used by private + /// browser egress policy. + pub(crate) fn from_config_with_connection( + config: &serde_json::Value, + connection: tinybus::Connection, + ) -> TinyBusResult { + let egress = BrowserDefaults::egress_from_raw(config) + .map_err(|error| TinyBusError::failed(error.to_string()))?; + let browser = if let Some(egress) = &egress { + Arc::new(Browser::new(Arc::new(EnforcedAgentBrowser::new(Arc::new( + BusAuthorizer::new(connection, egress.policy_id.clone(), egress.generation), + ))))) + } else { + Arc::new(Browser::new(Arc::new(AgentBrowser))) + }; + Self::with_browser_and_defaults(config, browser, egress) + .map_err(|error| TinyBusError::failed(error.to_string())) + } + /// Builds the service from the module configuration blob, opening browser /// sessions on `browser`. /// /// # Errors /// /// Propagates whatever [`Desktop::from_config`] rejects. + #[cfg(test)] pub(crate) fn with_browser(config: &serde_json::Value, browser: Arc) -> Result { + let egress = BrowserDefaults::egress_from_raw(config)?; + if egress.is_some() { + return Err(crate::Error::ConfigFieldType { + field: "browser", + expected: "a module set up with its authenticated host callback connection", + }); + } + Self::with_browser_and_defaults(config, browser, egress) + } + + fn with_browser_and_defaults( + config: &serde_json::Value, + browser: Arc, + egress: Option, + ) -> Result { let cursor = Arc::new(cursor_config(config)?); let computer = computer_config(config)?; let desktop = @@ -128,7 +179,7 @@ impl DesktopService { Ok::<_, crate::Error>((planner, rescuer, shaper)) }) .transpose()?; - let browser_defaults = BrowserDefaults::from_config(computer.browser.as_ref()); + let browser_defaults = BrowserDefaults::from_config(computer.browser.as_ref(), egress); let mut runner = WorkspaceRunner::new(desktop.clone(), jev.clone(), browser.clone()); runner.defaults.clone_from(&browser_defaults); runner.cursor = cursor; diff --git a/crates/tinycomputer/src/tinybus_module/mod.rs b/crates/tinycomputer/src/tinybus_module/mod.rs index 7fd82e39..c091e081 100644 --- a/crates/tinycomputer/src/tinybus_module/mod.rs +++ b/crates/tinycomputer/src/tinybus_module/mod.rs @@ -19,6 +19,7 @@ //! call — nothing platform-specific has to cross a thread boundary or survive //! an `await`. +mod browser_egress; mod config; mod consequence_gate; mod dispatch; @@ -38,8 +39,7 @@ pub(crate) use dispatch::DesktopService; /// silently ignoring the session it was told to join would allocate refs /// nothing else can spend. async fn setup(connection: Connection, config: Value) -> TinyBusResult<()> { - let service = DesktopService::from_config(&config) - .map_err(|error| tinybus::Error::failed(error.to_string()))?; + let service = DesktopService::from_config_with_connection(&config, connection.clone())?; // The host's per-site memory; a host that does not serve it only costs // tasks what earlier ones learned. let service = service.with_site_memory(Box::new(site_memory::BusPort::new(connection.clone()))); @@ -94,6 +94,7 @@ tinybus_module::module_export_optional_static! { optional = [ "ai.tinyhumans.tinycomputer.SiteMemoryHost", "ai.tinyhumans.tinycomputer.ConsequenceHost", + "ai.tinyhumans.tinycomputer.BrowserEgressHost", ], lazy = false, } diff --git a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs index 26e7c2a6..e6aa9a32 100644 --- a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs +++ b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs @@ -8,7 +8,7 @@ use tinycomputer_bus::config::ComputerConfig; fn browser_defaults(config: serde_json::Value) -> crate::tinybus_module::config::BrowserDefaults { let config: ComputerConfig = serde_json::from_value(config).expect("typed computer config"); - crate::tinybus_module::config::BrowserDefaults::from_config(config.browser.as_ref()) + crate::tinybus_module::config::BrowserDefaults::from_config(config.browser.as_ref(), None) } #[test] @@ -19,6 +19,36 @@ fn a_malformed_configuration_is_rejected_rather_than_defaulted() { assert!(error.to_string().contains("session_id")); } +#[test] +fn browser_egress_is_private_strict_and_requires_the_host_connection() { + let config = json!({"browser":{"egress":{"policy_id":"policy-secret","generation":7}}}); + let scope = crate::tinybus_module::config::BrowserDefaults::egress_from_raw(&config) + .expect("valid host scope") + .expect("scope is present"); + assert_eq!(scope.policy_id, "policy-secret"); + assert_eq!(scope.generation, 7); + assert!(!format!("{scope:?}").contains("policy-secret")); + assert!(DesktopService::from_config(&config).is_err()); + assert!(crate::tinybus_module::config::BrowserDefaults::egress_from_raw( + &json!({"browser":{"args":["--proxy-server=http://untrusted"],"egress":{"policy_id":"p","generation":7}}}) + ) + .is_err()); + + for egress in [ + json!({"policy_id":"","generation":7}), + json!({"policy_id":"p","generation":0}), + json!({"policy_id":"p","generation":7,"extra":true}), + json!([]), + ] { + assert!( + crate::tinybus_module::config::BrowserDefaults::egress_from_raw( + &json!({"browser":{"egress":egress}}) + ) + .is_err() + ); + } +} + #[test] fn the_desktop_is_available_only_with_accessibility() { use crate::tinybus_module::dispatch::desktop_availability; From 989d9d89af880b28347ba469bf7c4837609ab056 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 04:44:43 +0000 Subject: [PATCH 09/32] fix: clear TinyComputer CI security and lint gates Co-authored-by: Medulla --- Cargo.lock | 20 ++++++++++---------- crates/tinycomputer-browser/src/lib.rs | 2 ++ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 54b028ae..ed4d82c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -363,9 +363,9 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "5.4.1" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" dependencies = [ "error-code", ] @@ -663,7 +663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1947,7 +1947,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2191,7 +2191,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2517,7 +2517,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2876,9 +2876,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -3823,9 +3823,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", diff --git a/crates/tinycomputer-browser/src/lib.rs b/crates/tinycomputer-browser/src/lib.rs index cd0936cd..6aac5ee1 100644 --- a/crates/tinycomputer-browser/src/lib.rs +++ b/crates/tinycomputer-browser/src/lib.rs @@ -45,6 +45,8 @@ mod reply; mod sessions; mod surface; +#[cfg(feature = "test-connector")] +pub use egress::TestConnector; pub use egress::{BrowserDialTarget, DialGrant, EgressAuthorizer, EgressProxy}; pub use engine::{Engine, Launcher, Reply}; pub use error::{Error, Result}; From 090647150da791346186a1dffd732d3ee5a09832 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 04:58:53 +0000 Subject: [PATCH 10/32] test: cover browser egress enforcement paths Co-authored-by: Medulla --- .../src/linked/linked_tests.rs | 243 ++++++++++++++++-- .../src/linked/linked_tests/launch_tests.rs | 61 +++++ crates/tinycomputer-browser/src/linked/mod.rs | 7 +- .../browser_egress/browser_egress_tests.rs | 1 + 4 files changed, 283 insertions(+), 29 deletions(-) create mode 100644 crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs diff --git a/crates/tinycomputer-browser/src/linked/linked_tests.rs b/crates/tinycomputer-browser/src/linked/linked_tests.rs index 25fe21a1..3af3d297 100644 --- a/crates/tinycomputer-browser/src/linked/linked_tests.rs +++ b/crates/tinycomputer-browser/src/linked/linked_tests.rs @@ -9,8 +9,12 @@ use serde_json::json; use std::{io, net::SocketAddr, sync::Arc}; -use super::{AgentBrowser, EnforcedAgentBrowser}; -use crate::{BrowserDialTarget, DialGrant, EgressAuthorizer, engine::Launcher}; +use super::{AgentBrowser, ENGINE, EnforcedAgentBrowser, Linked, state_options}; +use crate::{ + BrowserDialTarget, DialGrant, EgressAuthorizer, + engine::{Engine, Launcher}, +}; +use agent_browser::DaemonState; #[derive(Debug)] struct RejectAll; @@ -22,7 +26,6 @@ impl EgressAuthorizer for RejectAll { ) -> std::pin::Pin> + Send>> { Box::pin(async { Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")) }) } - fn consume( &self, _: BrowserDialTarget, @@ -31,7 +34,6 @@ impl EgressAuthorizer for RejectAll { ) -> std::pin::Pin> + Send>> { Box::pin(async { Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")) }) } - fn validate( &self, _: String, @@ -53,38 +55,223 @@ async fn an_empty_command_is_answered_without_launching_a_browser() { } #[tokio::test] -async fn an_enforced_launcher_refuses_commands_until_a_private_proxy_launch() { - let mut engine = EnforcedAgentBrowser::new(Arc::new(RejectAll)).open("unit"); +async fn an_invalid_private_session_id_stops_before_binding_a_proxy() { + let mut engine = Linked { + state: agent_browser::DaemonState::with_options(super::state_options("invalid-id")), + egress_authorizer: Some(Arc::new(RejectAll)), + proxy: None, + proxy_session_id: "too-short".into(), + enforced_launch: false, + #[cfg(feature = "test-connector")] + test_connector: None, + }; + let reply = engine + .execute(json!({"id":"launch","action":"launch"})) + .await; + assert_eq!(reply["success"], false); + assert!( + reply["error"] + .as_str() + .unwrap() + .contains("private session identity") + ); + assert!(engine.proxy.is_none()); +} + +#[tokio::test] +async fn proxy_is_bound_before_caller_launch_options_are_refused() { + let mut engine = Linked { + state: agent_browser::DaemonState::with_options(super::state_options("refused-args")), + egress_authorizer: Some(Arc::new(RejectAll)), + proxy: None, + proxy_session_id: "0123456789abcdef0123456789abcdef0123456789abcdef".into(), + enforced_launch: false, + #[cfg(feature = "test-connector")] + test_connector: None, + }; + let reply = engine + .execute(json!({"id":"launch","action":"launch","args":["--proxy-server=http://caller"]})) + .await; + assert_eq!(reply["success"], false); + assert!(reply["error"].as_str().unwrap().contains("caller-supplied")); + assert!(engine.proxy.is_some()); +} + +#[test] +fn failed_entropy_does_not_create_a_private_session_id() { + assert_eq!(super::private_session_id_from_entropy([0; 24], false), ""); + let id = super::private_session_id_from_entropy([0xab; 24], true); + assert_eq!(id, "ab".repeat(24)); +} + +#[cfg(feature = "test-connector")] +#[tokio::test] +async fn fixture_connector_is_selected_for_the_session_proxy() { + let launcher = + EnforcedAgentBrowser::new(Arc::new(RejectAll)).with_test_connector(Arc::new(|_| { + Box::pin(async { Err(io::Error::other("fixture not contacted")) }) + })); + let mut engine = launcher.open("fixture-connector"); let reply = engine .execute(json!({"id":"navigate","action":"navigate","url":"https://example.test"})) .await; assert_eq!(reply["success"], false); +} + +#[tokio::test] +async fn enforced_launcher_creates_a_private_session_without_implicit_launch() { + let launcher = super::EnforcedAgentBrowser::new(Arc::new(RejectAll)); + assert_eq!( + format!("{launcher:?}"), + "EnforcedAgentBrowser([host authorizer])" + ); + + let mut engine = launcher.open("private-launcher"); + let reply = engine + .execute(json!({"id": "read", "action": "read"})) + .await; + + assert_eq!(reply["success"], false); + assert!( + reply["error"] + .as_str() + .is_some_and(|error| error.contains("enforced launch with a live browser session")) + ); +} + +#[tokio::test] +async fn enforced_launcher_binds_its_proxy_on_loopback_before_launch() { + let linked = Linked { + state: DaemonState::with_options(state_options("proxy-bind")), + egress_authorizer: Some(Arc::new(RejectAll)), + proxy: None, + proxy_session_id: "private-session-id".into(), + enforced_launch: false, + #[cfg(feature = "test-connector")] + test_connector: None, + }; + + let proxy = linked + .bind_egress_proxy(Arc::new(RejectAll)) + .await + .expect("the local egress proxy binds without opening a browser"); + + assert!(proxy.url().starts_with("http://127.0.0.1:")); +} + +#[tokio::test] +async fn enforced_browser_refuses_implicit_launch_before_any_request() { + let mut engine = EnforcedAgentBrowser::new(Arc::new(RejectAll)).open("unit"); + let reply = engine + .execute(json!({"id":"navigate","action":"navigate","url":"http://127.0.0.1/"})) + .await; + assert_eq!(reply["success"], false); assert!(reply["error"].as_str().unwrap().contains("enforced launch")); } -#[test] -fn the_enforced_launch_replaces_caller_network_arguments() { - assert!(super::enforce_launch_with_config( - json!({"action":"launch","args":["--proxy-server=http://attacker","--user-data-dir=/tmp/custom"]}), - "http://127.0.0.1:43210", - "private-password", - false, - ) - .is_err()); - let command = super::enforce_launch_with_config( - json!({"action":"launch"}), - "http://127.0.0.1:43210", - "private-password", - false, - ) - .expect("host controlled launch config"); - assert_eq!(command["proxy"]["server"], "http://127.0.0.1:43210"); - assert_eq!(command["proxy"]["password"], "private-password"); +#[tokio::test] +async fn a_closed_or_lost_browser_cannot_implicitly_launch_without_the_proxy() { + let mut engine = Linked { + state: DaemonState::with_options(state_options("closed")), + egress_authorizer: Some(Arc::new(RejectAll)), + proxy: None, + proxy_session_id: "private-session-id".into(), + enforced_launch: true, + #[cfg(feature = "test-connector")] + test_connector: None, + }; + let reply = engine + .execute(json!({"id":"navigate","action":"navigate","url":"http://public.example/"})) + .await; + assert_eq!(reply["success"], false); assert!( - command["args"] - .as_array() + reply["error"] + .as_str() .unwrap() - .iter() - .all(|arg| { !arg.as_str().unwrap_or_default().contains("attacker") }) + .contains("live browser session") ); } + +#[tokio::test] +async fn an_exited_enforced_browser_can_still_be_closed() { + let mut engine = Linked { + state: DaemonState::with_options(state_options("close-exited")), + egress_authorizer: Some(Arc::new(RejectAll)), + proxy: None, + proxy_session_id: "private-session-id".into(), + enforced_launch: true, + #[cfg(feature = "test-connector")] + test_connector: None, + }; + + let reply = engine.execute(json!({"id":"close","action":"close"})).await; + + assert_eq!(reply["success"], true); + assert!(!engine.enforced_launch); + assert!(engine.proxy.is_none()); +} + +#[tokio::test] +async fn enforced_browser_refuses_process_side_reads_with_a_url() { + let mut engine = Linked { + state: DaemonState::with_options(state_options("process-read")), + egress_authorizer: Some(Arc::new(RejectAll)), + proxy: None, + proxy_session_id: "private-session-id".into(), + enforced_launch: true, + #[cfg(feature = "test-connector")] + test_connector: None, + }; + let reply = engine + .execute(json!({ + "id":"read", + "action":"read", + "url":"http://public.example/" + })) + .await; + assert_eq!(reply["success"], false); + assert!( + reply["error"] + .as_str() + .unwrap() + .contains("process-side reads with a URL") + ); +} + +#[tokio::test] +async fn enforced_browser_rechecks_liveness_after_waiting_for_engine_lock() { + let engine = Linked { + state: DaemonState::with_options(state_options("closed-after-lock")), + egress_authorizer: Some(Arc::new(RejectAll)), + proxy: None, + proxy_session_id: "private-session-id".into(), + enforced_launch: true, + #[cfg(feature = "test-connector")] + test_connector: None, + }; + let serialized = ENGINE.lock().await; + let pending = tokio::spawn(async move { + let mut engine = engine; + engine + .execute(json!({ + "id":"navigate", + "action":"navigate", + "url":"http://public.example/" + })) + .await + }); + tokio::task::yield_now().await; + assert!(!pending.is_finished(), "the call should wait for ENGINE"); + drop(serialized); + + let reply = pending.await.unwrap(); + assert_eq!(reply["success"], false); + assert!( + reply["error"] + .as_str() + .unwrap() + .contains("live browser session") + ); +} + +mod launch_tests; diff --git a/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs b/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs new file mode 100644 index 00000000..04fc3cdf --- /dev/null +++ b/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs @@ -0,0 +1,61 @@ +//! Tests for browser launch command rewriting and policy refusal. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::super::enforce_launch_with_config; +use serde_json::json; + +#[test] +fn enforced_launch_uses_only_the_host_proxy_and_network_containment_flags() { + let launch = enforce_launch_with_config( + json!({"id":"launch","action":"launch","headless":true,"args":[]}), + "http://127.0.0.1:39123", + "session-secret", + false, + ) + .expect("enforced launch"); + assert_eq!(launch["proxy"]["server"], "http://127.0.0.1:39123"); + assert_eq!(launch["proxy"]["bypass"], "<-loopback>"); + assert_eq!(launch["proxy"]["username"], "tinycomputer"); + assert_eq!(launch["proxy"]["password"], "session-secret"); + assert_eq!( + launch["args"], + json!([ + "--disable-quic", + "--disable-http2", + "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1", + "--force-webrtc-ip-handling-policy=disable_non_proxied_udp" + ]) + ); +} + +#[test] +fn enforced_launch_refuses_external_cdp_and_caller_flags() { + let cdp = enforce_launch_with_config( + json!({"action":"launch","cdpUrl":"http://127.0.0.1:9222"}), + "http://127.0.0.1:39123", + "session-secret", + false, + ); + assert!(cdp.is_err()); + let args = enforce_launch_with_config( + json!({"action":"launch","args":["--proxy-server=http://attacker.test"]}), + "http://127.0.0.1:39123", + "session-secret", + false, + ); + assert!(args.is_err()); + let executable = enforce_launch_with_config( + json!({"action":"launch","executablePath":"/tmp/attacker-browser"}), + "http://127.0.0.1:39123", + "session-secret", + false, + ); + assert!(executable.is_err()); + let ambient = enforce_launch_with_config( + json!({"action":"launch"}), + "http://127.0.0.1:39123", + "session-secret", + true, + ); + assert!(ambient.is_err()); +} diff --git a/crates/tinycomputer-browser/src/linked/mod.rs b/crates/tinycomputer-browser/src/linked/mod.rs index 9502fa2c..905c3882 100644 --- a/crates/tinycomputer-browser/src/linked/mod.rs +++ b/crates/tinycomputer-browser/src/linked/mod.rs @@ -211,7 +211,12 @@ impl Engine for Linked { fn private_session_id() -> String { let mut bytes = [0u8; 24]; - if getrandom::fill(&mut bytes).is_err() { + let entropy_succeeded = getrandom::fill(&mut bytes).is_ok(); + private_session_id_from_entropy(bytes, entropy_succeeded) +} + +fn private_session_id_from_entropy(bytes: [u8; 24], entropy_succeeded: bool) -> String { + if !entropy_succeeded { return String::new(); } bytes diff --git a/crates/tinycomputer-bus/src/browser_egress/browser_egress_tests.rs b/crates/tinycomputer-bus/src/browser_egress/browser_egress_tests.rs index d20a19c6..e7f74f13 100644 --- a/crates/tinycomputer-bus/src/browser_egress/browser_egress_tests.rs +++ b/crates/tinycomputer-bus/src/browser_egress/browser_egress_tests.rs @@ -24,6 +24,7 @@ fn browser_callback_contract_has_no_http_payload_or_caller_selected_addresses() "port": 443 }) ); + assert_eq!(format!("{target:?}"), "BrowserEgressTarget([REDACTED])"); let grant = BrowserEgressGrant { generation: 8, authorization: "opaque serialized TinySecurity grant".into(), From e2bce6cc43017033fd989f2af6eea67f605044fa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:22:51 +0000 Subject: [PATCH 11/32] chore(vendor): bump agent-browser, agent-desktop, tinybus and tinyinference Updated the pinned commits for the four vendored submodules to their latest revisions. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- vendor/agent-browser | 2 +- vendor/agent-desktop | 2 +- vendor/tinybus | 2 +- vendor/tinyinference | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/vendor/agent-browser b/vendor/agent-browser index af35a880..9a749e47 160000 --- a/vendor/agent-browser +++ b/vendor/agent-browser @@ -1 +1 @@ -Subproject commit af35a880d3593e7a5aa4acca9513c7af7e333790 +Subproject commit 9a749e47aa872315db661cb00ff27e909bdce2e4 diff --git a/vendor/agent-desktop b/vendor/agent-desktop index 9d7ba42a..b8d538c0 160000 --- a/vendor/agent-desktop +++ b/vendor/agent-desktop @@ -1 +1 @@ -Subproject commit 9d7ba42a94bc4ec018e624f3033602188f64af63 +Subproject commit b8d538c038872c72dbf7ae42e002d6fa03a3e599 diff --git a/vendor/tinybus b/vendor/tinybus index 9e14f656..e5f1cd2d 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 9e14f65666b11782ec1054aba576114026d180f8 +Subproject commit e5f1cd2d2a7b905ee82410a61219292ca8a85c1f diff --git a/vendor/tinyinference b/vendor/tinyinference index abf90568..c29d5118 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit abf90568a24b826d49900150b20856ef166df3f8 +Subproject commit c29d5118eddfa12b83c266d7d5f07f6918dc3787 From 29300b8a4c4dd68f38bdb20489e75992fc1e5af6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:23:51 +0000 Subject: [PATCH 12/32] test(sites): cover site name derivation from url authority Add a test asserting that the site name comes only from the URL authority, so ports, paths, queries, and fragments cannot influence it. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinycomputer-core/src/sites/sites_tests.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/tinycomputer-core/src/sites/sites_tests.rs b/crates/tinycomputer-core/src/sites/sites_tests.rs index 75777870..502bbf4f 100644 --- a/crates/tinycomputer-core/src/sites/sites_tests.rs +++ b/crates/tinycomputer-core/src/sites/sites_tests.rs @@ -84,6 +84,14 @@ fn site_urls_keep_url_parser_idna_and_ip_address_behavior() { assert_eq!(site_named("bücher.example"), None); } +#[test] +fn site_names_are_derived_from_the_url_authority_only() { + assert_eq!( + site_of("https://www.shop.test:8443/a/path?next=evil.example#section").as_deref(), + Some("shop.test") + ); +} + #[test] fn goals_are_matched_whatever_their_case_and_spacing() { assert_eq!( From 7f9c7b59810785c47d399e304876ae89edd3c156 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:24:12 +0000 Subject: [PATCH 13/32] chore(deps): update tinybus submodule and lockfile dependencies Bump the vendored tinybus submodule to 0.3.12 and refresh Cargo.lock, which also rolls back tinytools to 0.3.0, zip to 8.6.0, and tokio-tungstenite to the crates.io 0.24.0 release while consolidating windows-sys and webpki-roots versions. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- Cargo.lock | 70 ++++++++++++++++++++++++-------------------------- vendor/tinybus | 2 +- 2 files changed, 34 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ed4d82c7..458f4321 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,7 +54,7 @@ dependencies = [ [[package]] name = "agent-browser" -version = "0.38.3" +version = "0.38.1" dependencies = [ "aes-gcm", "async-trait", @@ -386,7 +386,7 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a68fa787550392a9d58f44c21a3022cfb3ea3e2458b7f85d3b399d0ceeccf409" dependencies = [ - "nix 0.27.1", + "nix", "winapi", ] @@ -663,7 +663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1003,7 +1003,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.10", ] [[package]] @@ -1465,18 +1465,6 @@ dependencies = [ "libc", ] -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "ntapi" version = "0.4.3" @@ -1947,7 +1935,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2114,7 +2102,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", + "webpki-roots 1.0.10", ] [[package]] @@ -2191,7 +2179,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2517,7 +2505,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2592,7 +2580,7 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.3.11" +version = "0.3.12" dependencies = [ "async-trait", "flate2", @@ -2615,7 +2603,7 @@ dependencies = [ [[package]] name = "tinybus-macros" -version = "0.3.11" +version = "0.3.12" dependencies = [ "proc-macro2", "quote", @@ -2624,7 +2612,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.3.11" +version = "0.3.12" dependencies = [ "async-trait", "serde", @@ -2800,7 +2788,6 @@ version = "0.3.0" dependencies = [ "anyhow", "httpdate", - "regex", "url", ] @@ -2822,12 +2809,12 @@ name = "tinyinference-llm" version = "0.3.0" dependencies = [ "async-trait", - "base64 0.22.1", "bytes", "futures", "reqwest", "serde", "serde_json", + "sha2 0.11.0", "thiserror 2.0.20", "tinyinference-core", "tinytools-agent", @@ -2848,8 +2835,8 @@ dependencies = [ [[package]] name = "tinytools" -version = "0.5.0" -source = "git+https://github.com/tinyhumansai/tinytools?rev=bd60b9b52f1998b4483b9cf107a72d3338e672f8#bd60b9b52f1998b4483b9cf107a72d3338e672f8" +version = "0.3.0" +source = "git+https://github.com/tinyhumansai/tinytools?rev=a5d04a7f3af0abc1dd748f87f94871b36ae9fe71#a5d04a7f3af0abc1dd748f87f94871b36ae9fe71" dependencies = [ "anyhow", "async-trait", @@ -2859,8 +2846,8 @@ dependencies = [ [[package]] name = "tinytools-agent" -version = "0.5.0" -source = "git+https://github.com/tinyhumansai/tinytools?rev=bd60b9b52f1998b4483b9cf107a72d3338e672f8#bd60b9b52f1998b4483b9cf107a72d3338e672f8" +version = "0.3.0" +source = "git+https://github.com/tinyhumansai/tinytools?rev=a5d04a7f3af0abc1dd748f87f94871b36ae9fe71#a5d04a7f3af0abc1dd748f87f94871b36ae9fe71" dependencies = [ "regex", "serde", @@ -2913,8 +2900,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.24.1" -source = "git+https://github.com/snapview/tokio-tungstenite?rev=4b9c73b27301d16f54a3ec9e886fadb49d2f05c2#4b9c73b27301d16f54a3ec9e886fadb49d2f05c2" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" dependencies = [ "futures-util", "log", @@ -2923,7 +2911,7 @@ dependencies = [ "tokio", "tokio-rustls", "tungstenite", - "webpki-roots", + "webpki-roots 0.26.11", ] [[package]] @@ -3155,7 +3143,7 @@ dependencies = [ "rustls-pki-types", "ureq-proto", "utf8-zero", - "webpki-roots", + "webpki-roots 1.0.10", ] [[package]] @@ -3336,6 +3324,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.10", +] + [[package]] name = "webpki-roots" version = "1.0.10" @@ -3367,7 +3364,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -3917,15 +3914,14 @@ dependencies = [ [[package]] name = "zip" -version = "9.0.5" +version = "8.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ "crc32fast", "flate2", "indexmap", "memchr", - "nix 0.31.3", "typed-path", "zopfli", ] diff --git a/vendor/tinybus b/vendor/tinybus index e5f1cd2d..06895661 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit e5f1cd2d2a7b905ee82410a61219292ca8a85c1f +Subproject commit 0689566109edfc28c416ba927b2af4b88f1fb4cf From adc5a79bf6a80138020b9d333106a9e0c208f089 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:24:32 +0000 Subject: [PATCH 14/32] refactor(sites): move url parsing out of the bus contract The bus crate now only validates plain host-name site names, dropping its url dependency, while tinycomputer-core gains the url parser and owns site_of for turning browser URLs into those names. This keeps the shared contract free of URL and IDNA handling. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinycomputer-bus/README.md | 5 +++-- crates/tinycomputer-bus/src/sites/mod.rs | 14 +----------- .../tinycomputer-bus/src/sites/sites_tests.rs | 22 +------------------ crates/tinycomputer-core/Cargo.toml | 2 ++ crates/tinycomputer-core/src/sites/mod.rs | 13 ++++++++++- 5 files changed, 19 insertions(+), 37 deletions(-) diff --git a/crates/tinycomputer-bus/README.md b/crates/tinycomputer-bus/README.md index 16f1bc83..7429a5d9 100644 --- a/crates/tinycomputer-bus/README.md +++ b/crates/tinycomputer-bus/README.md @@ -34,8 +34,9 @@ payload vocabulary has to be published as an ordinary library. This is it. | `config` | the typed load-time configuration shared by hosts and the module | | `sites` | canonical, strict HTTP(S) site-name normalization | -Four dependencies, all pure Rust: `thiserror`, `serde`, `serde_json`, and -`url`. The URL parser is used only for shared, IDNA-aware site normalization. +Two dependencies, both pure Rust: `serde` and `serde_json`. The contract keeps +site names as strings and validates their plain host-name form; URL parsing and +IDNA normalization live in `tinycomputer-core`. ## This crate sits underneath `tinycomputer` diff --git a/crates/tinycomputer-bus/src/sites/mod.rs b/crates/tinycomputer-bus/src/sites/mod.rs index c391b5a9..aced4aed 100644 --- a/crates/tinycomputer-bus/src/sites/mod.rs +++ b/crates/tinycomputer-bus/src/sites/mod.rs @@ -1,16 +1,4 @@ -//! The canonical HTTP(S) site names shared by the host and module. - -/// The site `url` belongs to: its host, lower-cased and without a leading -/// `www.`. Returns `None` for non-HTTP(S) URLs or hosts that cannot safely be -/// used as a site name. -#[must_use] -pub fn site_of(url: &str) -> Option { - let parsed = url::Url::parse(url.trim()).ok()?; - if !matches!(parsed.scheme(), "http" | "https") { - return None; - } - site_named(parsed.host_str()?) -} +//! Plain site-name validation shared by the host and module. /// `host` as a normalized plain site name, or `None` when it is unsafe. /// diff --git a/crates/tinycomputer-bus/src/sites/sites_tests.rs b/crates/tinycomputer-bus/src/sites/sites_tests.rs index 2d9d447a..d37007de 100644 --- a/crates/tinycomputer-bus/src/sites/sites_tests.rs +++ b/crates/tinycomputer-bus/src/sites/sites_tests.rs @@ -2,27 +2,7 @@ #![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] -use super::{site_named, site_of}; - -#[test] -fn site_urls_use_url_idna_and_reject_non_http_and_ip_hosts() { - assert_eq!( - site_of(" https://www.bücher.example/shop ").as_deref(), - Some("xn--bcher-kva.example") - ); - assert_eq!( - site_of("http://shop.test:8080/cart").as_deref(), - Some("shop.test") - ); - for url in [ - "ftp://shop.test/", - "file:///tmp/page.html", - "not a URL", - "https://[::1]/", - ] { - assert_eq!(site_of(url), None, "{url}"); - } -} +use super::site_named; #[test] fn site_names_are_ascii_normalized_and_safe_file_stems() { diff --git a/crates/tinycomputer-core/Cargo.toml b/crates/tinycomputer-core/Cargo.toml index 0f00b65d..1bcba4cb 100644 --- a/crates/tinycomputer-core/Cargo.toml +++ b/crates/tinycomputer-core/Cargo.toml @@ -22,6 +22,8 @@ serde_json = { workspace = true } thiserror = { workspace = true } # Site memory keys a plan by a digest of its facts, so no value is stored. sha2 = { workspace = true } +# Parse browser URLs into the plain site names carried by the bus contract. +url = { workspace = true } [lints] workspace = true diff --git a/crates/tinycomputer-core/src/sites/mod.rs b/crates/tinycomputer-core/src/sites/mod.rs index 4d00acdf..8659db18 100644 --- a/crates/tinycomputer-core/src/sites/mod.rs +++ b/crates/tinycomputer-core/src/sites/mod.rs @@ -28,7 +28,18 @@ use tinycomputer_bus::agent::TaskReport; use tinycomputer_bus::site_memory::{SavedHint, SavedPlan, SiteMemory}; use tinycomputer_bus::{Flow, GroundingHint}; -pub use tinycomputer_bus::sites::{site_named, site_of}; +pub use tinycomputer_bus::sites::site_named; + +/// The normalized site name in an HTTP(S) URL, or `None` when the URL has no +/// safe HTTP(S) host. +#[must_use] +pub fn site_of(url: &str) -> Option { + let parsed = url::Url::parse(url.trim()).ok()?; + if !matches!(parsed.scheme(), "http" | "https") { + return None; + } + site_named(parsed.host_str()?) +} /// Most elements kept per site; the oldest go first. pub const MAX_HINTS: usize = 50; From ecf97c83850774e403d359967f3b2427bf0b0f84 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:24:48 +0000 Subject: [PATCH 15/32] chore(tinycomputer-bus): drop unused url dependency Remove the url dependency from tinycomputer-bus since URL parsing and IDNA normalization live in tinycomputer-core, and update the README to list the crate's actual dependencies. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- Cargo.lock | 2 +- crates/tinycomputer-bus/Cargo.toml | 4 ---- crates/tinycomputer-bus/README.md | 6 +++--- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 458f4321..4c6a29d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2682,7 +2682,6 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.20", - "url", ] [[package]] @@ -2708,6 +2707,7 @@ dependencies = [ "sha2 0.10.9", "thiserror 2.0.20", "tinycomputer-bus", + "url", ] [[package]] diff --git a/crates/tinycomputer-bus/Cargo.toml b/crates/tinycomputer-bus/Cargo.toml index 39d7149d..104b383c 100644 --- a/crates/tinycomputer-bus/Cargo.toml +++ b/crates/tinycomputer-bus/Cargo.toml @@ -22,9 +22,5 @@ publish = false thiserror = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -# URL parsing and IDNA normalization for the site-name helpers shared by hosts -# and the module; this is a pure parser with no transport or I/O. -url = { workspace = true } - [lints] workspace = true diff --git a/crates/tinycomputer-bus/README.md b/crates/tinycomputer-bus/README.md index 7429a5d9..d60193f4 100644 --- a/crates/tinycomputer-bus/README.md +++ b/crates/tinycomputer-bus/README.md @@ -34,9 +34,9 @@ payload vocabulary has to be published as an ordinary library. This is it. | `config` | the typed load-time configuration shared by hosts and the module | | `sites` | canonical, strict HTTP(S) site-name normalization | -Two dependencies, both pure Rust: `serde` and `serde_json`. The contract keeps -site names as strings and validates their plain host-name form; URL parsing and -IDNA normalization live in `tinycomputer-core`. +Three dependencies, all pure Rust: `thiserror`, `serde`, and `serde_json`. The +contract keeps site names as strings and validates their plain host-name form; +URL parsing and IDNA normalization live in `tinycomputer-core`. ## This crate sits underneath `tinycomputer` From 0a6a9a807637dd9e00952d4433ef01b2097acefc Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 2 Nov 2026 23:30:29 +0530 Subject: [PATCH 16/32] fix(client): let same-configuration calls run concurrently ComputerClient held TinyBus' exclusive reconfiguration guard for the whole bus call, so one client serialized everything: a RunGoal (up to 330 s) or a task long-poll blocked CancelTask and Describe. Track the configuration in-flight calls hold behind a read/write lock. Calls with the same configuration share it; a call with another configuration takes the write lock, so it still waits for every in-flight call before the module is reconfigured. --- crates/tinycomputer-client/Cargo.toml | 2 ++ crates/tinycomputer-client/src/client.rs | 29 ++++++++++++++----- .../tests/client_tests/linked_module_tests.rs | 4 +++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/crates/tinycomputer-client/Cargo.toml b/crates/tinycomputer-client/Cargo.toml index f7ec9ba9..873d09fd 100644 --- a/crates/tinycomputer-client/Cargo.toml +++ b/crates/tinycomputer-client/Cargo.toml @@ -14,6 +14,8 @@ serde_json.workspace = true thiserror.workspace = true tinybus.workspace = true tinycomputer-bus.workspace = true +# Calls sharing a configuration hold a read lock; changing it takes the write lock. +tokio = { workspace = true, features = ["sync"] } [dev-dependencies] # The in-memory service fixtures implement TinyBus' asynchronous interface. diff --git a/crates/tinycomputer-client/src/client.rs b/crates/tinycomputer-client/src/client.rs index 0bbdeca1..ad031e4a 100644 --- a/crates/tinycomputer-client/src/client.rs +++ b/crates/tinycomputer-client/src/client.rs @@ -5,8 +5,9 @@ use std::{sync::Arc, time::Duration}; use serde::{Serialize, de::DeserializeOwned}; use tinybus::{ Proxy, - module::{ReconfigurableModule, ReconfigureGuard}, + module::{ConfigFingerprint, ModuleCallError, ReconfigurableModule}, }; +use tokio::sync::{RwLock, RwLockReadGuard}; use tinycomputer_bus::{ DesktopResponse, agent::{AgentResponse, Capabilities, names as agent_names}, @@ -25,12 +26,15 @@ pub const TASK_CALL_TIMEOUT: Duration = Duration::from_secs(75); /// A typed caller for the shared desktop, flow, browser, and task interface. /// -/// The module coordinator serializes configuration transitions. Each call -/// keeps its exclusive guard until the bus reply has been decoded, so another -/// caller cannot replace `ComputerConfig` during an in-flight request. +/// Calls with the same `ComputerConfig` run concurrently. A call with a +/// different configuration waits until every in-flight call has finished, so +/// no caller's configuration is replaced while its request is in flight, and +/// a long `RunGoal` or task poll does not block `CancelTask` or `Describe`. #[derive(Clone)] pub struct ComputerClient { module: Arc, + /// The configuration in-flight calls hold, shared by every clone. + active: Arc>>, } impl std::fmt::Debug for ComputerClient { @@ -47,6 +51,7 @@ impl ComputerClient { pub fn new(module: ReconfigurableModule) -> Self { Self { module: Arc::new(module), + active: Arc::new(RwLock::new(None)), } } @@ -167,9 +172,19 @@ impl ComputerClient { &self, config: &ComputerConfig, timeout: Option, - ) -> Result<(ReconfigureGuard<'_>, Proxy), ClientError> { - let mut guard = self.module.exclusive().await; - let proxy = guard.proxy(config).await?; + ) -> Result<(RwLockReadGuard<'_, Option>, Proxy), ClientError> { + let wanted = + ConfigFingerprint::of(config).map_err(|_| ClientError::from(ModuleCallError::ModuleFault))?; + let mut guard = self.active.read().await; + if guard.as_ref() != Some(&wanted) { + drop(guard); + // Exclusive only while no call is in flight; then shared again so + // later calls with this configuration run alongside. + let mut switching = self.active.write().await; + *switching = Some(wanted); + guard = switching.downgrade(); + } + let proxy = self.module.proxy(config).await?; let proxy = timeout.map_or(proxy.clone(), |duration| proxy.with_timeout(duration)); Ok((guard, proxy)) } diff --git a/crates/tinycomputer-client/tests/client_tests/linked_module_tests.rs b/crates/tinycomputer-client/tests/client_tests/linked_module_tests.rs index 9902237a..0f4d5a13 100644 --- a/crates/tinycomputer-client/tests/client_tests/linked_module_tests.rs +++ b/crates/tinycomputer-client/tests/client_tests/linked_module_tests.rs @@ -337,6 +337,10 @@ async fn attested_client_keeps_configuration_exclusive_through_member_completion }); entered_rx.await?; + // A call with the same configuration runs alongside the one in flight. + let alongside = fixture.client.describe(&first_config).await?; + assert!(alongside.jev_configured); + let second_client = fixture.client.clone(); let next_config = second_config.clone(); let (started_tx, started_rx) = tokio::sync::oneshot::channel(); From 8ed20c4156a55c4a322477160246b00bfb99d5d6 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 2 Nov 2026 23:31:50 +0530 Subject: [PATCH 17/32] fix(browser): refuse ambient agent-browser plugins, extensions, args and init scripts has_ambient_browser_config missed AGENT_BROWSER_PLUGINS: agent-browser falls back to it and appends the plugins' launch arguments after the egress proxy and resolver arguments, so the host environment could undo egress enforcement. AGENT_BROWSER_EXTENSIONS, AGENT_BROWSER_ARGS and AGENT_BROWSER_INIT_SCRIPTS change the launch the same way. Refuse an egress launch when any of them is set, from one named list. --- .../src/linked/linked_tests/launch_tests.rs | 18 +++++++++- crates/tinycomputer-browser/src/linked/mod.rs | 36 +++++++++++++------ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs b/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs index 04fc3cdf..96276eef 100644 --- a/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs +++ b/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs @@ -1,7 +1,7 @@ //! Tests for browser launch command rewriting and policy refusal. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -use super::super::enforce_launch_with_config; +use super::super::{AMBIENT_BROWSER_ENV, enforce_launch_with_config}; use serde_json::json; #[test] @@ -59,3 +59,19 @@ fn enforced_launch_refuses_external_cdp_and_caller_flags() { ); assert!(ambient.is_err()); } + +/// agent-browser applies these after the enforced proxy arguments, so an +/// ambient value must refuse the launch rather than reach the browser. +#[test] +fn ambient_plugins_extensions_args_and_init_scripts_refuse_the_launch() { + for name in [ + "AGENT_BROWSER_PLUGINS", + "AGENT_BROWSER_EXTENSIONS", + "AGENT_BROWSER_ARGS", + "AGENT_BROWSER_INIT_SCRIPTS", + "AGENT_BROWSER_PROXY", + "AGENT_BROWSER_CDP", + ] { + assert!(AMBIENT_BROWSER_ENV.contains(&name), "{name}"); + } +} diff --git a/crates/tinycomputer-browser/src/linked/mod.rs b/crates/tinycomputer-browser/src/linked/mod.rs index 905c3882..da9d0e6d 100644 --- a/crates/tinycomputer-browser/src/linked/mod.rs +++ b/crates/tinycomputer-browser/src/linked/mod.rs @@ -306,18 +306,32 @@ fn enforce_launch_with_config( Ok(command) } +/// agent-browser environment variables that can attach another browser or +/// change how the egress-enforced one launches. Plugins, extensions, extra +/// arguments and init scripts are applied after this module's proxy and +/// resolver arguments, so any of them could undo egress enforcement. +const AMBIENT_BROWSER_ENV: &[&str] = &[ + "AGENT_BROWSER_CDP", + "AGENT_BROWSER_AUTO_CONNECT", + "AGENT_BROWSER_PROVIDER", + "AGENT_BROWSER_ENGINE", + "AGENT_BROWSER_EXECUTABLE_PATH", + "AGENT_BROWSER_PROXY", + "AGENT_BROWSER_PROXY_BYPASS", + "AGENT_BROWSER_PROXY_USERNAME", + "AGENT_BROWSER_PROXY_PASSWORD", + "AGENT_BROWSER_PROFILE", + "AGENT_BROWSER_RESTORE", + "AGENT_BROWSER_PLUGINS", + "AGENT_BROWSER_EXTENSIONS", + "AGENT_BROWSER_ARGS", + "AGENT_BROWSER_INIT_SCRIPTS", +]; + fn has_ambient_browser_config() -> bool { - std::env::var_os("AGENT_BROWSER_CDP").is_some() - || std::env::var_os("AGENT_BROWSER_AUTO_CONNECT").is_some() - || std::env::var_os("AGENT_BROWSER_PROVIDER").is_some() - || std::env::var_os("AGENT_BROWSER_ENGINE").is_some() - || std::env::var_os("AGENT_BROWSER_EXECUTABLE_PATH").is_some() - || std::env::var_os("AGENT_BROWSER_PROXY").is_some() - || std::env::var_os("AGENT_BROWSER_PROXY_BYPASS").is_some() - || std::env::var_os("AGENT_BROWSER_PROXY_USERNAME").is_some() - || std::env::var_os("AGENT_BROWSER_PROXY_PASSWORD").is_some() - || std::env::var_os("AGENT_BROWSER_PROFILE").is_some() - || std::env::var_os("AGENT_BROWSER_RESTORE").is_some() + AMBIENT_BROWSER_ENV + .iter() + .any(|name| std::env::var_os(name).is_some()) } #[cfg(test)] From cc10576de24db0194e9b68a6e7ea47d94b9bfba0 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 2 Nov 2026 23:33:09 +0530 Subject: [PATCH 18/32] fix(sites): refuse IPv4 literals as site names site_named accepted digits and dots, so https://127.0.0.1/ became the site "127.0.0.1" and an address got site memory of its own. IPv6 was already refused by the character set. Refuse a name whose labels are all digits (no TLD is) in the shared function, which every caller uses. --- crates/tinycomputer-bus/src/sites/mod.rs | 6 +++++- crates/tinycomputer-core/src/sites/sites_tests.rs | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/tinycomputer-bus/src/sites/mod.rs b/crates/tinycomputer-bus/src/sites/mod.rs index aced4aed..17af116d 100644 --- a/crates/tinycomputer-bus/src/sites/mod.rs +++ b/crates/tinycomputer-bus/src/sites/mod.rs @@ -16,7 +16,11 @@ pub fn site_named(host: &str) -> Option { && !host.contains("..") && host.bytes().all(|byte| { byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'-') - }); + }) + // An IPv4 literal is an address, not a site: no TLD is all digits. + && !host + .split('.') + .all(|label| label.bytes().all(|byte| byte.is_ascii_digit())); plain.then(|| host.to_owned()) } diff --git a/crates/tinycomputer-core/src/sites/sites_tests.rs b/crates/tinycomputer-core/src/sites/sites_tests.rs index 502bbf4f..4f558d21 100644 --- a/crates/tinycomputer-core/src/sites/sites_tests.rs +++ b/crates/tinycomputer-core/src/sites/sites_tests.rs @@ -81,6 +81,10 @@ fn site_urls_keep_url_parser_idna_and_ip_address_behavior() { Some("xn--bcher-kva.example") ); assert_eq!(site_of("https://[::1]/"), None); + assert_eq!(site_of("https://127.0.0.1/"), None); + assert_eq!(site_of("http://10.0.0.1:8080/login"), None); + assert_eq!(site_named("192.168.1.1"), None); + assert_eq!(site_named("123.example").as_deref(), Some("123.example")); assert_eq!(site_named("bücher.example"), None); } From 3cf7f5be8abd5821a8a619302faa9b2642d0b6fb Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 2 Nov 2026 23:34:02 +0530 Subject: [PATCH 19/32] fix(egress): refuse whitespace in or after a proxy header name parse_proxy_headers split on the first colon and compared the raw name, so "Content-Length : 5" or "Proxy-Authorization : ..." were not recognized and passed through unchecked to the upstream server. RFC 9112 forbids whitespace before the colon; refuse such a request, and an empty field name, before any header is interpreted. --- .../src/egress/request.rs | 9 +++++ .../src/egress/request_tests.rs | 40 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 crates/tinycomputer-browser/src/egress/request_tests.rs diff --git a/crates/tinycomputer-browser/src/egress/request.rs b/crates/tinycomputer-browser/src/egress/request.rs index 2fa6a61b..39427475 100644 --- a/crates/tinycomputer-browser/src/egress/request.rs +++ b/crates/tinycomputer-browser/src/egress/request.rs @@ -160,6 +160,11 @@ fn parse_proxy_headers( .filter(|line| !line.is_empty()) { let (name, value) = line.split_once(':')?; + // RFC 9112 forbids whitespace in or after a field name: a server + // could read `Content-Length : 5` as a header this check skipped. + if name.is_empty() || name.bytes().any(|byte| byte.is_ascii_whitespace()) { + return None; + } if name.eq_ignore_ascii_case("host") { if host_header.replace(value.trim().to_owned()).is_some() { return None; @@ -261,3 +266,7 @@ pub(crate) fn parse_authority( session_id: session_id.to_owned(), }) } + +#[cfg(test)] +#[path = "request_tests.rs"] +mod tests; diff --git a/crates/tinycomputer-browser/src/egress/request_tests.rs b/crates/tinycomputer-browser/src/egress/request_tests.rs new file mode 100644 index 00000000..cd3c14a1 --- /dev/null +++ b/crates/tinycomputer-browser/src/egress/request_tests.rs @@ -0,0 +1,40 @@ +//! Tests for proxy request header parsing. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +fn target() -> BrowserDialTarget { + BrowserDialTarget { + scheme: "http".to_owned(), + hostname: "site.test".to_owned(), + port: 80, + session_id: "session".to_owned(), + } +} + +fn parse(extra: &str) -> Option { + let lines = vec![ + "POST http://site.test/form HTTP/1.1".to_owned(), + "Host: site.test".to_owned(), + extra.to_owned(), + ]; + parse_proxy_headers(&lines, &target(), false, "session") +} + +#[test] +fn a_well_formed_header_is_read() { + assert_eq!(parse("Content-Length: 5").expect("parsed").body_len, 5); +} + +#[test] +fn whitespace_in_or_after_a_field_name_refuses_the_request() { + for header in [ + "Content-Length : 5", + "Content-Length\t: 5", + "Proxy-Authorization : Basic x", + " Host: site.test", + ": no name", + ] { + assert!(parse(header).is_none(), "{header:?}"); + } +} From ce0271e31e2af67a98f0548bd375505dc1565856 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 04:21:09 +0530 Subject: [PATCH 20/32] style(client): rustfmt the configuration lock --- crates/tinycomputer-client/src/client.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tinycomputer-client/src/client.rs b/crates/tinycomputer-client/src/client.rs index ad031e4a..28d5c134 100644 --- a/crates/tinycomputer-client/src/client.rs +++ b/crates/tinycomputer-client/src/client.rs @@ -7,7 +7,6 @@ use tinybus::{ Proxy, module::{ConfigFingerprint, ModuleCallError, ReconfigurableModule}, }; -use tokio::sync::{RwLock, RwLockReadGuard}; use tinycomputer_bus::{ DesktopResponse, agent::{AgentResponse, Capabilities, names as agent_names}, @@ -15,6 +14,7 @@ use tinycomputer_bus::{ config::ComputerConfig, names, }; +use tokio::sync::{RwLock, RwLockReadGuard}; use crate::ClientError; @@ -173,8 +173,8 @@ impl ComputerClient { config: &ComputerConfig, timeout: Option, ) -> Result<(RwLockReadGuard<'_, Option>, Proxy), ClientError> { - let wanted = - ConfigFingerprint::of(config).map_err(|_| ClientError::from(ModuleCallError::ModuleFault))?; + let wanted = ConfigFingerprint::of(config) + .map_err(|_| ClientError::from(ModuleCallError::ModuleFault))?; let mut guard = self.active.read().await; if guard.as_ref() != Some(&wanted) { drop(guard); From e4d635a2701bc1eb40c43fcfedd9130ae78b9c54 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 04:23:45 +0530 Subject: [PATCH 21/32] fix(vendor): restore the agent-desktop pin that has PressArgs::window_id e2bce6cc moved vendor/agent-desktop back from 9d7ba42 to its ancestor b8d538c, which predates PressArgs::window_id, so tinycomputer-desktop stopped compiling. --- vendor/agent-desktop | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/agent-desktop b/vendor/agent-desktop index b8d538c0..9d7ba42a 160000 --- a/vendor/agent-desktop +++ b/vendor/agent-desktop @@ -1 +1 @@ -Subproject commit b8d538c038872c72dbf7ae42e002d6fa03a3e599 +Subproject commit 9d7ba42a94bc4ec018e624f3033602188f64af63 From 0e7f61c663b5b150904bc739bc30e14eb8fb0178 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 04:30:51 +0530 Subject: [PATCH 22/32] fix(vendor): bump agent-browser to d8b5b76 for zip 9 agent-browser 9a749e4 required zip ^8.2.0, and zip 8.6.0 carries RUSTSEC-2026-0367, -0368, -0369 and -0429. d8b5b76 ("fix: update vulnerable ZIP and TLS roots") is a fast-forward on agent-browser main that moves to zip 9; the lock takes zip 9.2.2. --- Cargo.lock | 43 +++++++++++++++++++++++++------------------ vendor/agent-browser | 2 +- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4c6a29d6..ff5cb736 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,7 @@ dependencies = [ "url", "urlencoding", "uuid", + "webpki-roots", "windows-sys 0.52.0", "zip", ] @@ -386,7 +387,7 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a68fa787550392a9d58f44c21a3022cfb3ea3e2458b7f85d3b399d0ceeccf409" dependencies = [ - "nix", + "nix 0.27.1", "winapi", ] @@ -1003,7 +1004,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.10", + "webpki-roots", ] [[package]] @@ -1465,6 +1466,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -2102,7 +2115,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.10", + "webpki-roots", ] [[package]] @@ -2911,7 +2924,6 @@ dependencies = [ "tokio", "tokio-rustls", "tungstenite", - "webpki-roots 0.26.11", ] [[package]] @@ -3082,9 +3094,9 @@ dependencies = [ [[package]] name = "typed-path" -version = "0.12.3" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" [[package]] name = "typenum" @@ -3143,7 +3155,7 @@ dependencies = [ "rustls-pki-types", "ureq-proto", "utf8-zero", - "webpki-roots 1.0.10", + "webpki-roots", ] [[package]] @@ -3324,15 +3336,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.10", -] - [[package]] name = "webpki-roots" version = "1.0.10" @@ -3914,15 +3917,19 @@ dependencies = [ [[package]] name = "zip" -version = "8.6.0" +version = "9.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +checksum = "e408c6cc4699dd017d53dc080cd51c2cea2b5354af57a1479bd265ed42911487" dependencies = [ "crc32fast", "flate2", + "getrandom 0.4.3", "indexmap", + "libc", "memchr", + "nix 0.31.3", "typed-path", + "windows-sys 0.61.2", "zopfli", ] diff --git a/vendor/agent-browser b/vendor/agent-browser index 9a749e47..d8b5b76d 160000 --- a/vendor/agent-browser +++ b/vendor/agent-browser @@ -1 +1 @@ -Subproject commit 9a749e47aa872315db661cb00ff27e909bdce2e4 +Subproject commit d8b5b76d73fe3806dc9a0afc5ad8698c98ef3914 From 12c9dc43b5dddb0003d0295bd0ae413df3af0324 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:26:02 +0530 Subject: [PATCH 23/32] fix(browser): give the egress proxy its own password The proxy password was the session id, which every BrowserDialTarget carries to the host's egress callbacks. A host that logs or stores the id would hand any local process the proxy credential. An enforced session now draws a second random 24-byte secret for the password. --- .../src/linked/linked_tests.rs | 12 ++++++++ crates/tinycomputer-browser/src/linked/mod.rs | 30 ++++++++++++------- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/crates/tinycomputer-browser/src/linked/linked_tests.rs b/crates/tinycomputer-browser/src/linked/linked_tests.rs index f5197bde..3ad998b8 100644 --- a/crates/tinycomputer-browser/src/linked/linked_tests.rs +++ b/crates/tinycomputer-browser/src/linked/linked_tests.rs @@ -75,6 +75,14 @@ async fn enforced_launcher_creates_a_private_session_without_implicit_launch() { ); } +#[tokio::test] +async fn an_enforced_session_has_a_proxy_password_apart_from_its_session_id() { + let engine = super::enforced(Arc::new(RejectAll), "secrets"); + assert_eq!(engine.proxy_session_id.len(), 48); + assert_eq!(engine.proxy_password.len(), 48); + assert_ne!(engine.proxy_password, engine.proxy_session_id); +} + #[tokio::test] async fn enforced_browser_refuses_implicit_launch_before_any_request() { let mut engine = EnforcedAgentBrowser::new(Arc::new(RejectAll)).open("unit"); @@ -92,6 +100,7 @@ async fn a_closed_or_lost_browser_cannot_implicitly_launch_without_the_proxy() { egress_authorizer: Some(Arc::new(RejectAll)), proxy: None, proxy_session_id: "private-session-id".into(), + proxy_password: "private-proxy-password".into(), enforced_launch: true, }; let reply = engine @@ -113,6 +122,7 @@ async fn an_exited_enforced_browser_can_still_be_closed() { egress_authorizer: Some(Arc::new(RejectAll)), proxy: None, proxy_session_id: "private-session-id".into(), + proxy_password: "private-proxy-password".into(), enforced_launch: true, }; @@ -130,6 +140,7 @@ async fn enforced_browser_refuses_process_side_reads_with_a_url() { egress_authorizer: Some(Arc::new(RejectAll)), proxy: None, proxy_session_id: "private-session-id".into(), + proxy_password: "private-proxy-password".into(), enforced_launch: true, }; let reply = engine @@ -155,6 +166,7 @@ async fn enforced_browser_rechecks_liveness_after_waiting_for_engine_lock() { egress_authorizer: Some(Arc::new(RejectAll)), proxy: None, proxy_session_id: "private-session-id".into(), + proxy_password: "private-proxy-password".into(), enforced_launch: true, }; let serialized = ENGINE.lock().await; diff --git a/crates/tinycomputer-browser/src/linked/mod.rs b/crates/tinycomputer-browser/src/linked/mod.rs index cfa90be8..2f92aacf 100644 --- a/crates/tinycomputer-browser/src/linked/mod.rs +++ b/crates/tinycomputer-browser/src/linked/mod.rs @@ -53,6 +53,7 @@ impl Launcher for AgentBrowser { egress_authorizer: None, proxy: None, proxy_session_id: String::new(), + proxy_password: String::new(), enforced_launch: false, }) } @@ -60,13 +61,21 @@ impl Launcher for AgentBrowser { impl Launcher for EnforcedAgentBrowser { fn open(&self, session: &str) -> Box { - Box::new(Linked { - state: DaemonState::with_options(state_options(session)), - egress_authorizer: Some(self.authorizer.clone()), - proxy: None, - proxy_session_id: private_session_id(), - enforced_launch: false, - }) + Box::new(enforced(self.authorizer.clone(), session)) + } +} + +/// An enforced session. The proxy password is its own secret: the session id +/// reaches the host in every egress callback, so it must not also unlock the +/// proxy. +fn enforced(authorizer: Arc, session: &str) -> Linked { + Linked { + state: DaemonState::with_options(state_options(session)), + egress_authorizer: Some(authorizer), + proxy: None, + proxy_session_id: private_session_id(), + proxy_password: private_session_id(), + enforced_launch: false, } } @@ -75,6 +84,7 @@ struct Linked { egress_authorizer: Option>, proxy: Option, proxy_session_id: String, + proxy_password: String, enforced_launch: bool, } @@ -87,7 +97,7 @@ impl Engine for Linked { let launch = self.egress_authorizer.is_some() && action.as_deref() == Some("launch"); Box::pin(async move { let command = if launch { - if self.proxy_session_id.len() != 48 { + if self.proxy_session_id.len() != 48 || self.proxy_password.len() != 48 { return failure_reply( &command, "network policy could not create a private session identity", @@ -100,7 +110,7 @@ impl Engine for Linked { match EgressProxy::bind( authorizer, self.proxy_session_id.clone(), - self.proxy_session_id.clone(), + self.proxy_password.clone(), ) .await { @@ -113,7 +123,7 @@ impl Engine for Linked { let Some(proxy) = self.proxy.as_ref() else { return failure_reply(&command, "network policy proxy could not start"); }; - match enforce_launch(command.clone(), &proxy.url(), &self.proxy_session_id) { + match enforce_launch(command.clone(), &proxy.url(), &self.proxy_password) { Ok(command) => command, Err(reason) => return failure_reply(&command, reason), } From 1877702ae218f3cbeef9659b192da738d693b403 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:26:39 +0530 Subject: [PATCH 24/32] fix(egress): drop hop-by-hop request headers before forwarding The forwarder removed only Connection, Proxy-Connection and Proxy-Authorization. RFC 9110 7.6.1 has a proxy also remove the fields Connection nominates, plus Keep-Alive and TE, and Upgrade on a request that is not a websocket upgrade. A nomination never removes Upgrade from a websocket request, or Host, Content-Length or Transfer-Encoding: the proxy framed the body by those, so the upstream has to read the same ones. --- .../src/egress/egress_tests/http_tests.rs | 62 +++++++++++++++++++ .../src/egress/forward.rs | 39 ++++++++---- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/crates/tinycomputer-browser/src/egress/egress_tests/http_tests.rs b/crates/tinycomputer-browser/src/egress/egress_tests/http_tests.rs index 575451b2..c02f9586 100644 --- a/crates/tinycomputer-browser/src/egress/egress_tests/http_tests.rs +++ b/crates/tinycomputer-browser/src/egress/egress_tests/http_tests.rs @@ -159,6 +159,68 @@ async fn approved_http_request_is_rewritten_and_forwarded_to_approved_fixture() server.await.unwrap(); } +#[tokio::test] +async fn hop_by_hop_headers_are_not_forwarded() { + let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = destination.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = destination.accept().await.unwrap(); + let mut request = Vec::new(); + let mut chunk = [0; 512]; + loop { + let n = socket.read(&mut chunk).await.unwrap(); + request.extend_from_slice(&chunk[..n]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let request = std::str::from_utf8(&request).unwrap().to_ascii_lowercase(); + for dropped in ["x-internal:", "keep-alive:", "te:", "upgrade:"] { + assert!( + !request.contains(&format!("\r\n{dropped}")), + "{dropped} was forwarded" + ); + } + assert!(request.contains("x-kept: yes\r\n")); + assert!(request.contains("connection: close\r\n")); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .await + .unwrap(); + }); + let authorizer = Arc::new(FixtureAuthorizer { + address, + allow: true, + consumed: Arc::new(AtomicBool::new(false)), + }); + let proxy = EgressProxy::bind(authorizer, "hop-session".into(), FIXTURE_PASSWORD.into()) + .await + .unwrap(); + let mut client = TcpStream::connect(proxy.url().replace("http://", "")) + .await + .unwrap(); + client + .write_all( + format!( + "GET http://control.test:{}/ HTTP/1.1\r\nHost: control.test:{}\r\nProxy-Authorization: {}\r\nConnection: keep-alive, X-Internal\r\nX-Internal: 1\r\nKeep-Alive: timeout=5\r\nTE: trailers\r\nUpgrade: h2c\r\nX-Kept: yes\r\n\r\n", + address.port(), + address.port(), + proxy_authorization() + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut response = Vec::new(); + client.read_to_end(&mut response).await.unwrap(); + assert!( + std::str::from_utf8(&response) + .unwrap() + .ends_with("\r\n\r\nok") + ); + server.await.unwrap(); +} + #[tokio::test] async fn duplicate_lengths_and_pipelined_authorities_are_refused_before_dial() { let destination = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/crates/tinycomputer-browser/src/egress/forward.rs b/crates/tinycomputer-browser/src/egress/forward.rs index d52f5d79..bdb0378e 100644 --- a/crates/tinycomputer-browser/src/egress/forward.rs +++ b/crates/tinycomputer-browser/src/egress/forward.rs @@ -48,18 +48,33 @@ pub(super) async fn initialize_upstream( url.path().to_owned() }; let mut forwarded = format!("{} {path} {}\r\n", request.method, request.version); - for line in request - .lines - .iter() - .skip(1) - .map(String::as_str) - .filter(|line| !line.is_empty()) - { - let name = line.split_once(':').map_or("", |(name, _)| name); - if !name.eq_ignore_ascii_case("connection") - && !name.eq_ignore_ascii_case("proxy-connection") - && !name.eq_ignore_ascii_case("proxy-authorization") - { + let headers = || { + request + .lines + .iter() + .skip(1) + .filter(|line| !line.is_empty()) + .map(|line| (line, line.split_once(':').map_or("", |(name, _)| name))) + }; + // RFC 9110 §7.6.1: the fields `Connection` nominates are this hop's only. + // `Upgrade` stays for a websocket request, and the framing fields the + // proxy read the body by stay so the upstream frames it the same way. + let nominated: Vec = headers() + .filter(|(_, name)| { + name.eq_ignore_ascii_case("connection") || name.eq_ignore_ascii_case("proxy-connection") + }) + .flat_map(|(line, name)| line[name.len() + 1..].split(',')) + .map(|token| token.trim().to_ascii_lowercase()) + .filter(|token| !["upgrade", "host", "content-length", "transfer-encoding"].contains(&token.as_str())) + .collect(); + for (line, name) in headers() { + let name = name.to_ascii_lowercase(); + let hop_by_hop = matches!( + name.as_str(), + "connection" | "proxy-connection" | "proxy-authorization" | "keep-alive" | "te" + ) || (name == "upgrade" && !request.has_upgrade) + || nominated.contains(&name); + if !hop_by_hop { forwarded.push_str(line); forwarded.push_str("\r\n"); } From be6408073f5385497437b64f1b10e478da16b26f Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:27:28 +0530 Subject: [PATCH 25/32] fix(browser): refuse caller plugins, extensions and init scripts on an egress launch The pinned agent-browser honours plugins, extensions and initScripts on the launch command itself, not only from its environment, and applies them after the egress proxy and resolver arguments. An enforced launch now refuses any of the three. --- .../src/linked/linked_tests/launch_tests.rs | 13 +++++++++++++ crates/tinycomputer-browser/src/linked/mod.rs | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs b/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs index 96276eef..603eeeb1 100644 --- a/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs +++ b/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs @@ -75,3 +75,16 @@ fn ambient_plugins_extensions_args_and_init_scripts_refuse_the_launch() { assert!(AMBIENT_BROWSER_ENV.contains(&name), "{name}"); } } + +#[test] +fn enforced_launch_refuses_caller_plugins_extensions_and_init_scripts() { + for field in ["plugins", "extensions", "initScripts"] { + let mut command = json!({"action":"launch"}); + command[field] = json!([]); + assert!( + enforce_launch_with_config(command, "http://127.0.0.1:39123", "session-secret", false) + .is_err(), + "{field} was accepted" + ); + } +} diff --git a/crates/tinycomputer-browser/src/linked/mod.rs b/crates/tinycomputer-browser/src/linked/mod.rs index 2f92aacf..9c1f43b1 100644 --- a/crates/tinycomputer-browser/src/linked/mod.rs +++ b/crates/tinycomputer-browser/src/linked/mod.rs @@ -248,6 +248,15 @@ fn enforce_launch_with_config( { return Err("network policy refuses caller-supplied browser launch arguments"); } + // agent-browser runs plugins and loads extensions and init scripts after + // this module's proxy and resolver arguments, so any of them could undo + // egress enforcement. + if ["plugins", "extensions", "initScripts"] + .iter() + .any(|key| command.get(key).is_some()) + { + return Err("network policy refuses caller-supplied plugins, extensions and init scripts"); + } if has_ambient_browser_config { return Err("network policy refuses ambient external browser configuration"); } From 1ff34f363f99018ea583e4a0459ce1c2280b6ff5 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:28:23 +0530 Subject: [PATCH 26/32] test(client): cover the module error a host returns for an unknown module --- .../tests/client_tests/fail_closed_tests.rs | 14 +++++++++++++- .../tests/client_tests/support/common.rs | 8 ++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/tinycomputer-client/tests/client_tests/fail_closed_tests.rs b/crates/tinycomputer-client/tests/client_tests/fail_closed_tests.rs index ce4569f4..1488fd88 100644 --- a/crates/tinycomputer-client/tests/client_tests/fail_closed_tests.rs +++ b/crates/tinycomputer-client/tests/client_tests/fail_closed_tests.rs @@ -1,4 +1,4 @@ -//! Unknown members, wrong families, and malformed envelopes. +//! Unknown members, wrong families, malformed envelopes, and an unknown module. use super::*; @@ -70,3 +70,15 @@ async fn unknown_members_wrong_families_and_bad_envelopes_fail_closed() )); Ok(()) } + +#[tokio::test] +async fn a_module_the_host_cannot_find_fails_with_a_module_error() +-> Result<(), Box> { + let fixture = untrusted_fixture().await?; + let client = client_for_unknown_module(&fixture.bus); + assert!(matches!( + client.describe(&ComputerConfig::default()).await, + Err(ClientError::Module(_)) + )); + Ok(()) +} diff --git a/crates/tinycomputer-client/tests/client_tests/support/common.rs b/crates/tinycomputer-client/tests/client_tests/support/common.rs index a8cdd9cd..6e3b5262 100644 --- a/crates/tinycomputer-client/tests/client_tests/support/common.rs +++ b/crates/tinycomputer-client/tests/client_tests/support/common.rs @@ -106,6 +106,14 @@ impl ModuleAccess for TestAccess { } } +/// A client for a module id the host's release table does not hold. +pub(crate) fn client_for_unknown_module(bus: &MemoryBus) -> ComputerClient { + ComputerClient::new(ReconfigurableModule::new( + TestAccess::new(bus.clone(), None), + "tinycomputer-unknown", + )) +} + pub(crate) fn client_for(bus: &MemoryBus, host: Option>) -> ComputerClient { ComputerClient::new(ReconfigurableModule::new( TestAccess::new(bus.clone(), host), From f18cbfd69aae4570e0f848e5294be966b289829a Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:28:42 +0530 Subject: [PATCH 27/32] test(module): read the egress scope through egress_from_raw and refuse an executable beside it Main's test decoded egress through the raw-JSON BrowserDefaults::from_config, which this branch replaced with typed ComputerConfig decoding. --- .../tinybus_module_tests/config_tests.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs index 10bf2d6a..8b5bdd32 100644 --- a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs +++ b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs @@ -207,12 +207,20 @@ fn the_browser_configuration_is_read_or_refused() { assert_eq!(defaults.perception, Perception::Tree); assert_eq!(defaults.settle, Settle::Steady); assert!(!defaults.prelaunch); - let egress = BrowserDefaults::from_config(&json!({"browser": { + let egress = BrowserDefaults::egress_from_raw(&json!({"browser": { "egress": {"policy_id": "host-policy", "generation": 7} }})) .unwrap(); - assert_eq!(egress.egress.as_ref().unwrap().policy_id, "host-policy"); - assert_eq!(egress.egress.as_ref().unwrap().generation, 7); + assert_eq!(egress.as_ref().unwrap().policy_id, "host-policy"); + assert_eq!(egress.as_ref().unwrap().generation, 7); + // An egress launch refuses `executablePath`, so the pair fails at load. + assert!( + BrowserDefaults::egress_from_raw(&json!({"browser": { + "executable": "/usr/bin/chromium", + "egress": {"policy_id": "host-policy", "generation": 7} + }})) + .is_err() + ); // Settling promptly and opening the browser early are on unless turned // off. let unset = BrowserDefaults::default(); From bf6643af9defc4fa002c71a5ff77e97724a6527e Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:29:06 +0530 Subject: [PATCH 28/32] docs(contract): document browser.egress and the typed configuration's null rule The spec now describes the private browser.egress loader field, says an explicit null is refused for jev, planner and browser (trace_path alone takes it as unset), lists the trace_sink and consequence_host callback objects, and names base64 rather than url among the contract crate's dependencies. --- .../specs/desktop-module-contract.md | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/technical/specs/desktop-module-contract.md b/docs/technical/specs/desktop-module-contract.md index dc8debbe..8d21da67 100644 --- a/docs/technical/specs/desktop-module-contract.md +++ b/docs/technical/specs/desktop-module-contract.md @@ -229,6 +229,12 @@ or false keeps the existing start behavior. - The module retains its existing loader-only extensions `session_id`, `trace_strict`, `headed`, and `cursor`. They remain outside the shared host config; the module adapter forwards them to their existing owners. +- `browser.egress` is a private loader field, not part of `ComputerConfig`: an + object holding exactly a host-selected `policy_id` (1 to 64 bytes) and a + positive `generation`. It selects the egress-enforced launcher, whose dials + go through `BrowserEgressHost`, so it needs a module loaded with its host + callback connection. It is refused beside `browser.args` or + `browser.executable`, which an enforced launch cannot honour. - `tinycomputer_bus::sites::{site_of, site_named}` own the shared strict site normalization used for site-memory keys and file names. URL parsing keeps the WHATWG parser's IDNA and IP behavior; names reject adjacent dots and @@ -236,7 +242,7 @@ or false keeps the existing start behavior. ### The contract crate -- `tinycomputer-bus` depends on `thiserror`, `serde`, `serde_json`, and `url`. It may +- `tinycomputer-bus` depends on `thiserror`, `serde`, `serde_json`, and `base64`. It may not depend on a transport, an async runtime, an HTTP client, a native library, or the engine. CI asserts the resolved dependency tree. - It mirrors the engine's enumerations by wire form rather than importing them, @@ -259,12 +265,15 @@ or false keeps the existing start behavior. ### Configuration and concurrency -- The module's configuration is a JSON object with optional `session_id` and - `trace_path` strings, `trace_strict` and `headed` booleans, an optional - `trace_sink` object containing the host-prepared unique callback destination, and a `jev` - object containing provider configuration and its API key. `null` and `{}` - yield desktop defaults with Jev disabled; invalid recognized fields fail - before the served object is replaced. +- The module's configuration is `null` or a JSON object. Its host fields decode + as `ComputerConfig`: `jev`, `planner`, and `browser` are omitted when unset, + and an explicit `null` for any of them is rejected; `trace_path` alone takes + `null` as unset. Beside them sit the loader-only `session_id`, + `trace_strict`, `headed`, and `cursor`, an optional `trace_sink` or + `consequence_host` object containing the host-prepared unique callback + destination, and the private `browser.egress`. `null` and `{}` yield desktop + defaults with Jev disabled; invalid recognized fields fail before the served + object is replaced. - TinyBus sensitive initialization and reinitialization carry the `jev` object. Reinitialization constructs the complete replacement service before `serve_at`, so a rejected key, endpoint, or desktop field leaves the existing From ef975a2b70b63549e992992c717f59474460c491 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:34:48 +0530 Subject: [PATCH 29/32] fix(browser): launch the host's configured Chrome under egress The module refused browser.executable beside browser.egress at load. OpenHuman sends its chrome_path as browser.executable and always adds egress, so a user with a Chrome path set lost the whole module. The executable in the host's module configuration is host control, like the proxy itself. The enforced launcher now takes it and launches it with the same proxy, bypass and resolver flags: agent-browser builds those flags independently of the binary it starts. A launch may repeat the host's executable, as hosts do on every session; any other executable, a non-string one, or one with no host executable configured is refused. Extra args, plugins, extensions, init scripts, attached browsers and ambient AGENT_BROWSER_* settings stay refused. --- .../src/linked/linked_tests.rs | 4 + .../src/linked/linked_tests/launch_tests.rs | 73 ++++++++++++++++++- crates/tinycomputer-browser/src/linked/mod.rs | 50 +++++++++++-- .../tinycomputer/src/tinybus_module/config.rs | 9 ++- .../src/tinybus_module/dispatch/service.rs | 15 +++- .../tinybus_module/dispatch/service_tests.rs | 22 ++++++ .../tinybus_module_tests/config_tests.rs | 19 +++-- docs/crates/tinycomputer/configuration.md | 10 ++- .../specs/desktop-module-contract.md | 6 +- 9 files changed, 184 insertions(+), 24 deletions(-) diff --git a/crates/tinycomputer-browser/src/linked/linked_tests.rs b/crates/tinycomputer-browser/src/linked/linked_tests.rs index 3ad998b8..3bf99e96 100644 --- a/crates/tinycomputer-browser/src/linked/linked_tests.rs +++ b/crates/tinycomputer-browser/src/linked/linked_tests.rs @@ -101,6 +101,7 @@ async fn a_closed_or_lost_browser_cannot_implicitly_launch_without_the_proxy() { proxy: None, proxy_session_id: "private-session-id".into(), proxy_password: "private-proxy-password".into(), + host_executable: None, enforced_launch: true, }; let reply = engine @@ -123,6 +124,7 @@ async fn an_exited_enforced_browser_can_still_be_closed() { proxy: None, proxy_session_id: "private-session-id".into(), proxy_password: "private-proxy-password".into(), + host_executable: None, enforced_launch: true, }; @@ -141,6 +143,7 @@ async fn enforced_browser_refuses_process_side_reads_with_a_url() { proxy: None, proxy_session_id: "private-session-id".into(), proxy_password: "private-proxy-password".into(), + host_executable: None, enforced_launch: true, }; let reply = engine @@ -167,6 +170,7 @@ async fn enforced_browser_rechecks_liveness_after_waiting_for_engine_lock() { proxy: None, proxy_session_id: "private-session-id".into(), proxy_password: "private-proxy-password".into(), + host_executable: None, enforced_launch: true, }; let serialized = ENGINE.lock().await; diff --git a/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs b/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs index 603eeeb1..e6a0d5e2 100644 --- a/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs +++ b/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs @@ -11,6 +11,7 @@ fn enforced_launch_uses_only_the_host_proxy_and_network_containment_flags() { "http://127.0.0.1:39123", "session-secret", false, + None, ) .expect("enforced launch"); assert_eq!(launch["proxy"]["server"], "http://127.0.0.1:39123"); @@ -35,6 +36,7 @@ fn enforced_launch_refuses_external_cdp_and_caller_flags() { "http://127.0.0.1:39123", "session-secret", false, + None, ); assert!(cdp.is_err()); let args = enforce_launch_with_config( @@ -42,6 +44,7 @@ fn enforced_launch_refuses_external_cdp_and_caller_flags() { "http://127.0.0.1:39123", "session-secret", false, + None, ); assert!(args.is_err()); let executable = enforce_launch_with_config( @@ -49,6 +52,7 @@ fn enforced_launch_refuses_external_cdp_and_caller_flags() { "http://127.0.0.1:39123", "session-secret", false, + None, ); assert!(executable.is_err()); let ambient = enforce_launch_with_config( @@ -56,6 +60,7 @@ fn enforced_launch_refuses_external_cdp_and_caller_flags() { "http://127.0.0.1:39123", "session-secret", true, + None, ); assert!(ambient.is_err()); } @@ -82,9 +87,75 @@ fn enforced_launch_refuses_caller_plugins_extensions_and_init_scripts() { let mut command = json!({"action":"launch"}); command[field] = json!([]); assert!( - enforce_launch_with_config(command, "http://127.0.0.1:39123", "session-secret", false) + enforce_launch_with_config( + command, + "http://127.0.0.1:39123", + "session-secret", + false, + None, + ) .is_err(), "{field} was accepted" ); } } + +#[test] +fn a_host_executable_launches_under_the_enforced_proxy() { + for command in [ + json!({"action":"launch"}), + json!({"action":"launch","executablePath":"/opt/host/chrome"}), + ] { + let launch = enforce_launch_with_config( + command, + "http://127.0.0.1:39123", + "session-secret", + false, + Some("/opt/host/chrome"), + ) + .expect("host executable launches"); + assert_eq!(launch["executablePath"], "/opt/host/chrome"); + assert_eq!(launch["engine"], "chrome"); + assert_eq!(launch["proxy"]["server"], "http://127.0.0.1:39123"); + assert_eq!(launch["proxy"]["bypass"], "<-loopback>"); + assert!( + launch["args"] + .as_array() + .expect("args") + .contains(&json!("--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1")) + ); + } +} + +#[test] +fn a_request_cannot_name_an_executable_the_host_did_not_configure() { + for (requested, host) in [ + (json!("/tmp/attacker-browser"), Some("/opt/host/chrome")), + (json!(7), Some("/opt/host/chrome")), + (json!("/opt/host/chrome"), None), + (json!(7), None), + ] { + assert!( + enforce_launch_with_config( + json!({"action":"launch","executablePath":requested}), + "http://127.0.0.1:39123", + "session-secret", + false, + host, + ) + .is_err(), + "{requested} with host {host:?}" + ); + } + // The host binary does not lift the other refusals. + assert!( + enforce_launch_with_config( + json!({"action":"launch"}), + "http://127.0.0.1:39123", + "session-secret", + true, + Some("/opt/host/chrome"), + ) + .is_err() + ); +} diff --git a/crates/tinycomputer-browser/src/linked/mod.rs b/crates/tinycomputer-browser/src/linked/mod.rs index 9c1f43b1..12e029d8 100644 --- a/crates/tinycomputer-browser/src/linked/mod.rs +++ b/crates/tinycomputer-browser/src/linked/mod.rs @@ -30,6 +30,7 @@ pub struct AgentBrowser; #[derive(Clone)] pub struct EnforcedAgentBrowser { authorizer: Arc, + executable: Option, } impl std::fmt::Debug for EnforcedAgentBrowser { @@ -42,7 +43,18 @@ impl EnforcedAgentBrowser { /// Use the authenticated host callback for every launched session. #[must_use] pub fn new(authorizer: Arc) -> Self { - Self { authorizer } + Self { + authorizer, + executable: None, + } + } + + /// Launch the host-configured Chrome or Chromium binary. It is host + /// control, like the proxy: a launch may repeat it but never name another. + #[must_use] + pub fn with_executable(mut self, executable: Option) -> Self { + self.executable = executable; + self } } @@ -54,6 +66,7 @@ impl Launcher for AgentBrowser { proxy: None, proxy_session_id: String::new(), proxy_password: String::new(), + host_executable: None, enforced_launch: false, }) } @@ -61,7 +74,9 @@ impl Launcher for AgentBrowser { impl Launcher for EnforcedAgentBrowser { fn open(&self, session: &str) -> Box { - Box::new(enforced(self.authorizer.clone(), session)) + let mut engine = enforced(self.authorizer.clone(), session); + engine.host_executable.clone_from(&self.executable); + Box::new(engine) } } @@ -75,6 +90,7 @@ fn enforced(authorizer: Arc, session: &str) -> Linked { proxy: None, proxy_session_id: private_session_id(), proxy_password: private_session_id(), + host_executable: None, enforced_launch: false, } } @@ -85,6 +101,7 @@ struct Linked { proxy: Option, proxy_session_id: String, proxy_password: String, + host_executable: Option, enforced_launch: bool, } @@ -123,7 +140,12 @@ impl Engine for Linked { let Some(proxy) = self.proxy.as_ref() else { return failure_reply(&command, "network policy proxy could not start"); }; - match enforce_launch(command.clone(), &proxy.url(), &self.proxy_password) { + match enforce_launch( + command.clone(), + &proxy.url(), + &self.proxy_password, + self.host_executable.as_deref(), + ) { Ok(command) => command, Err(reason) => return failure_reply(&command, reason), } @@ -210,11 +232,18 @@ fn enforce_launch( command: Value, proxy: &str, proxy_password: &str, + host_executable: Option<&str>, ) -> Result { let has_ambient_browser_config = command.get("action").and_then(Value::as_str) == Some("launch") && has_ambient_browser_config(); - enforce_launch_with_config(command, proxy, proxy_password, has_ambient_browser_config) + enforce_launch_with_config( + command, + proxy, + proxy_password, + has_ambient_browser_config, + host_executable, + ) } fn enforce_launch_with_config( @@ -222,6 +251,7 @@ fn enforce_launch_with_config( proxy: &str, proxy_password: &str, has_ambient_browser_config: bool, + host_executable: Option<&str>, ) -> Result { if command.get("action").and_then(Value::as_str) != Some("launch") { return Ok(command); @@ -234,13 +264,19 @@ fn enforce_launch_with_config( "profile", "storageState", "restore", - "executablePath", ] .iter() .any(|key| command.get(key).is_some()) { return Err("network policy refuses attached or externally managed browsers"); } + // Only the host chooses the binary. A launch may repeat the host's own + // executable (hosts send it with each session) but not name another. + if let Some(requested) = command.get("executablePath") + && (host_executable.is_none() || requested.as_str() != host_executable) + { + return Err("network policy refuses a browser executable the host did not configure"); + } if command .get("args") .and_then(Value::as_array) @@ -269,6 +305,10 @@ fn enforce_launch_with_config( "password": proxy_password }); command["engine"] = json!("chrome"); + // The enforced flags above do not depend on which Chrome binary runs. + if let Some(executable) = host_executable { + command["executablePath"] = json!(executable); + } command["args"] = json!([ "--disable-quic", "--disable-http2", diff --git a/crates/tinycomputer/src/tinybus_module/config.rs b/crates/tinycomputer/src/tinybus_module/config.rs index c0145a64..bd91c1cc 100644 --- a/crates/tinycomputer/src/tinybus_module/config.rs +++ b/crates/tinycomputer/src/tinybus_module/config.rs @@ -114,10 +114,13 @@ impl BrowserDefaults { .and_then(serde_json::Value::as_u64) .filter(|generation| *generation > 0) .ok_or_else(invalid)?; + // The host's own `executable` stays allowed: the enforced launcher + // runs it with the proxy and resolver flags. Extra `args` could undo + // them, so they are refused. if object.len() != 2 - || browser.as_object().is_some_and(|browser| { - browser.contains_key("args") || browser.contains_key("executable") - }) + || browser + .as_object() + .is_some_and(|browser| browser.contains_key("args")) { return Err(invalid()); } diff --git a/crates/tinycomputer/src/tinybus_module/dispatch/service.rs b/crates/tinycomputer/src/tinybus_module/dispatch/service.rs index e34e2df0..829437c3 100644 --- a/crates/tinycomputer/src/tinybus_module/dispatch/service.rs +++ b/crates/tinycomputer/src/tinybus_module/dispatch/service.rs @@ -117,9 +117,18 @@ impl DesktopService { let egress = BrowserDefaults::egress_from_raw(config) .map_err(|error| TinyBusError::failed(error.to_string()))?; let browser = if let Some(egress) = &egress { - Arc::new(Browser::new(Arc::new(EnforcedAgentBrowser::new(Arc::new( - BusAuthorizer::new(connection.clone(), egress.policy_id.clone(), egress.generation), - ))))) + let executable = computer_config(config) + .map_err(|error| TinyBusError::failed(error.to_string()))? + .browser + .and_then(|browser| browser.executable); + Arc::new(Browser::new(Arc::new( + EnforcedAgentBrowser::new(Arc::new(BusAuthorizer::new( + connection.clone(), + egress.policy_id.clone(), + egress.generation, + ))) + .with_executable(executable), + ))) } else { Arc::new(Browser::new(Arc::new(AgentBrowser))) }; diff --git a/crates/tinycomputer/src/tinybus_module/dispatch/service_tests.rs b/crates/tinycomputer/src/tinybus_module/dispatch/service_tests.rs index 40a2adbb..7f9b1016 100644 --- a/crates/tinycomputer/src/tinybus_module/dispatch/service_tests.rs +++ b/crates/tinycomputer/src/tinybus_module/dispatch/service_tests.rs @@ -59,3 +59,25 @@ fn module_rejects_null_shared_sections_but_preserves_nullable_trace_path() { .expect("null trace path keeps the desktop's unset behavior"); assert!(!service.desktop.is_tracing()); } + +#[tokio::test] +async fn a_host_executable_beside_egress_loads_the_enforced_service() { + let bus = tinybus::transport::memory::MemoryBus::new(); + tinybus::broker::Broker::new().spawn(bus.clone()); + let connection = tinybus::Connection::connect(bus.connect().await.expect("transport")) + .await + .expect("module connection"); + let service = DesktopService::from_config_with_connection( + &json!({"browser": { + "executable": "/opt/host/chrome", + "egress": {"policy_id": "host-policy", "generation": 7} + }}), + connection, + ) + .expect("host executable and egress load together"); + assert_eq!( + service.browser_defaults.executable.as_deref(), + Some("/opt/host/chrome") + ); + assert!(service.browser_defaults.egress.is_some()); +} diff --git a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs index 8b5bdd32..883b6acc 100644 --- a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs +++ b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs @@ -213,14 +213,21 @@ fn the_browser_configuration_is_read_or_refused() { .unwrap(); assert_eq!(egress.as_ref().unwrap().policy_id, "host-policy"); assert_eq!(egress.as_ref().unwrap().generation, 7); - // An egress launch refuses `executablePath`, so the pair fails at load. + // The host's own executable loads beside egress; the enforced launcher + // runs it under the proxy. + let with_executable = json!({"browser": { + "executable": "/usr/bin/chromium", + "egress": {"policy_id": "host-policy", "generation": 7} + }}); assert!( - BrowserDefaults::egress_from_raw(&json!({"browser": { - "executable": "/usr/bin/chromium", - "egress": {"policy_id": "host-policy", "generation": 7} - }})) - .is_err() + BrowserDefaults::egress_from_raw(&with_executable) + .unwrap() + .is_some() ); + assert!(serde_json::from_value::(json!({"browser": { + "executable": "/usr/bin/chromium" + }})) + .is_ok()); // Settling promptly and opening the browser early are on unless turned // off. let unset = BrowserDefaults::default(); diff --git a/docs/crates/tinycomputer/configuration.md b/docs/crates/tinycomputer/configuration.md index 561cd88c..305cd89e 100644 --- a/docs/crates/tinycomputer/configuration.md +++ b/docs/crates/tinycomputer/configuration.md @@ -283,11 +283,13 @@ optional: | `perception` | how a task reads a page: `sight` (the default) reads the rendered page as a person sees it, `tree` the accessibility tree alone ([`browser-sight.md`](../../technical/specs/browser-sight.md)) | | `settle` | how a task lets a page settle after an action before reading it again: `prompt` (the default) counts the network's 500 ms of quiet from the start, counting only requests that can change the page and for at most 1 s, and then waits only while the page is still changing (at most 400 ms), so an idle page is read again after about 0.6 s; `steady` waits for the network to go idle, at least about 1.1 s, then 400 ms more | | `prelaunch` | `true` (the default) opens a browser-only task's browser while `StartTask` plans it (a `task` with no `flow`), so the first step does not wait for the launch, and loads in it the one web address the task's text names, if it names one, so a first step that browses there does not wait for the page either; `false` opens it at the first step | -| `egress` | Host-private TinySecurity scope. When present, `policy_id` and `generation` are required; browser arguments, an executable override, attached DevTools endpoints, and caller-supplied launch flags are refused. The scope is never a browser or task argument. | +| `egress` | Host-private TinySecurity scope. When present, `policy_id` and `generation` are required; browser `args`, attached DevTools endpoints, caller-supplied launch flags, and any executable other than this section's `executable` are refused. The scope is never a browser or task argument. | -The launch and attach options above apply only when `egress` is absent. With -`egress` set, TinyComputer owns the executable and launch arguments and refuses -task `browser_executable` overrides and attached DevTools endpoints. +The launch and attach options above apply only when `egress` is absent, except +`executable`. With `egress` set, TinyComputer owns the launch arguments and +launches the host's `executable` (or the discovered Chrome) with its proxy and +resolver flags. A task `browser_executable` or session `executable` may repeat +the host's path; any other is refused, as are attached DevTools endpoints. With `egress`, every launched browser session uses a private, authenticated loopback proxy. TinyComputer parses each HTTP request, asks the authenticated diff --git a/docs/technical/specs/desktop-module-contract.md b/docs/technical/specs/desktop-module-contract.md index 8d21da67..63681499 100644 --- a/docs/technical/specs/desktop-module-contract.md +++ b/docs/technical/specs/desktop-module-contract.md @@ -233,8 +233,10 @@ or false keeps the existing start behavior. object holding exactly a host-selected `policy_id` (1 to 64 bytes) and a positive `generation`. It selects the egress-enforced launcher, whose dials go through `BrowserEgressHost`, so it needs a module loaded with its host - callback connection. It is refused beside `browser.args` or - `browser.executable`, which an enforced launch cannot honour. + callback connection. It is refused beside `browser.args`, which could undo + the enforced proxy flags. `browser.executable` is allowed: the enforced + launch runs that host-chosen binary with the same proxy and resolver flags, + and a launch may repeat it but never name another. - `tinycomputer_bus::sites::{site_of, site_named}` own the shared strict site normalization used for site-memory keys and file names. URL parsing keeps the WHATWG parser's IDNA and IP behavior; names reject adjacent dots and From 63cb42245246e6c087b7221ed00c5950944a2dab Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:39:02 +0530 Subject: [PATCH 30/32] refactor(examples): check confidential arity in its own verify step Merging main's trace and report checks with the typed-client check put verify_module's main over clippy's 100-line limit. --- .../src/bin/verify_module.rs | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/crates/tinycomputer-examples/src/bin/verify_module.rs b/crates/tinycomputer-examples/src/bin/verify_module.rs index 2d013245..9ace7022 100644 --- a/crates/tinycomputer-examples/src/bin/verify_module.rs +++ b/crates/tinycomputer-examples/src/bin/verify_module.rs @@ -240,23 +240,7 @@ async fn main() -> Result<(), Box> { return Err(io::Error::other("manifest members differ from contract").into()); } verify_globe_lifecycle(&proxy).await?; - - // Wrong-arity inputs stay safe even if a confidentiality guard regresses. - for member in [ - names::accessibility::ACCESSIBILITY_FOCUS, - names::accessibility::ACCESSIBILITY_VALIDATE_TARGET, - names::accessibility::ACCESSIBILITY_PASTE, - ] { - let reply = proxy - .call::(member, serde_json::json!([])) - .await; - if !matches!(reply, Err(tinybus::Error::MethodFailed { name, .. }) if name == tinybus::Error::CONFIDENTIALITY_REQUIRED) - { - return Err( - io::Error::other("native member failed to require confidential delivery").into(), - ); - } - } + verify_confidential_arity(&proxy).await?; println!( "verified {} as TinyBus module `{}`, serving {} members", @@ -401,6 +385,28 @@ async fn verify_report_sink( Ok(()) } +/// Wrong-arity inputs stay safe even if a confidentiality guard regresses. +async fn verify_confidential_arity( + proxy: &tinybus::Proxy, +) -> Result<(), Box> { + for member in [ + names::accessibility::ACCESSIBILITY_FOCUS, + names::accessibility::ACCESSIBILITY_VALIDATE_TARGET, + names::accessibility::ACCESSIBILITY_PASTE, + ] { + let reply = proxy + .call::(member, serde_json::json!([])) + .await; + if !matches!(reply, Err(tinybus::Error::MethodFailed { name, .. }) if name == tinybus::Error::CONFIDENTIALITY_REQUIRED) + { + return Err( + io::Error::other("native member failed to require confidential delivery").into(), + ); + } + } + Ok(()) +} + /// Checks native lease admission and terminal shutdown without device access. async fn verify_globe_lifecycle(proxy: &tinybus::Proxy) -> Result<(), Box> { // Invalid handles exercise the new artifact paths without touching devices. From aec420cd937ed52b8434b150818efdd48ef76804 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:39:14 +0530 Subject: [PATCH 31/32] chore(deps): record tinycomputer-client and tinybus 0.3.12 in Cargo.lock Generated by cargo metadata on the merged manifests, with no cargo update. It adds the client crate and the packages the new tinybus pin brings in (icu_casemap, rawzip, unicode-normalization), moves tinybus to 0.3.12 and the workspace crates to 0.16.0, and the resolver moved the icu, tinystr, yoke and zerovec crates up by one patch or minor release. --- Cargo.lock | 139 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 101 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 35cc2284..b90d562d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,14 +1059,37 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_casemap" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a6f143e9349267b80ae071a6ec25d4797c45756d8b2671a8b24b7b25b81ab94" +dependencies = [ + "icu_casemap_data", + "icu_collections", + "icu_locale_core", + "icu_properties", + "icu_provider", + "potential_utf", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_casemap_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71a5c321208537711696af55bc2ab5dad939e4f6e0aef92dd6a2555deceedf3a" + [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", + "serde", "utf8_iter", "yoke", "zerofrom", @@ -1075,12 +1098,13 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", @@ -1088,9 +1112,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1102,15 +1126,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -1123,18 +1147,20 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1824,6 +1850,8 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -1999,6 +2027,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rawzip" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9999039f74cd73c03158cddfe4362991703c74efe5ce5c8073026f80a5e48c" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2564,11 +2598,13 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.3.6" +version = "0.3.12" dependencies = [ "async-trait", "flate2", + "icu_casemap", "libc", + "rawzip", "serde", "serde_json", "sha2 0.10.9", @@ -2579,13 +2615,13 @@ dependencies = [ "tokio", "toml", "tracing", + "unicode-normalization", "ureq", - "zip", ] [[package]] name = "tinybus-macros" -version = "0.3.6" +version = "0.3.12" dependencies = [ "proc-macro2", "quote", @@ -2594,7 +2630,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.3.6" +version = "0.3.12" dependencies = [ "async-trait", "serde", @@ -2606,7 +2642,7 @@ dependencies = [ [[package]] name = "tinycomputer" -version = "0.15.2" +version = "0.16.0" dependencies = [ "getrandom 0.4.3", "serde", @@ -2625,7 +2661,7 @@ dependencies = [ [[package]] name = "tinycomputer-accessibility" -version = "0.15.2" +version = "0.16.0" dependencies = [ "arboard", "command-group", @@ -2641,7 +2677,7 @@ dependencies = [ [[package]] name = "tinycomputer-browser" -version = "0.15.2" +version = "0.16.0" dependencies = [ "agent-browser", "base64 0.22.1", @@ -2660,7 +2696,7 @@ dependencies = [ [[package]] name = "tinycomputer-bus" -version = "0.15.2" +version = "0.16.0" dependencies = [ "base64 0.22.1", "serde", @@ -2668,9 +2704,23 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "tinycomputer-client" +version = "0.16.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "thiserror 2.0.20", + "tinybus", + "tinybus-module", + "tinycomputer-bus", + "tokio", +] + [[package]] name = "tinycomputer-core" -version = "0.15.2" +version = "0.16.0" dependencies = [ "serde", "serde_json", @@ -2682,7 +2732,7 @@ dependencies = [ [[package]] name = "tinycomputer-cursor" -version = "0.15.2" +version = "0.16.0" dependencies = [ "block2 0.6.2", "getrandom 0.4.3", @@ -2697,7 +2747,7 @@ dependencies = [ [[package]] name = "tinycomputer-desktop" -version = "0.15.2" +version = "0.16.0" dependencies = [ "agent-desktop-core", "agent-desktop-linux", @@ -2712,7 +2762,7 @@ dependencies = [ [[package]] name = "tinycomputer-engine" -version = "0.15.2" +version = "0.16.0" dependencies = [ "getrandom 0.4.3", "serde", @@ -2729,7 +2779,7 @@ dependencies = [ [[package]] name = "tinycomputer-examples" -version = "0.15.2" +version = "0.16.0" dependencies = [ "base64 0.22.1", "serde", @@ -2738,6 +2788,7 @@ dependencies = [ "tinycomputer", "tinycomputer-browser", "tinycomputer-bus", + "tinycomputer-client", "tinycomputer-core", "tinycomputer-cursor", "tinycomputer-engine", @@ -2747,7 +2798,7 @@ dependencies = [ [[package]] name = "tinycomputer-skills" -version = "0.15.2" +version = "0.16.0" dependencies = [ "serde_json", "tinycomputer-bus", @@ -2857,11 +2908,12 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -3158,6 +3210,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -3845,9 +3906,9 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3856,9 +3917,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", @@ -3922,14 +3983,16 @@ dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -3937,9 +4000,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", From 5dae7c17a12ac30fce292839260bf9c206b094a5 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:43:46 +0530 Subject: [PATCH 32/32] style: apply rustfmt to the egress, launch and config tests --- crates/tinycomputer-browser/src/egress/forward.rs | 4 +++- .../src/linked/linked_tests/launch_tests.rs | 11 ++++------- .../tinybus_module_tests/config_tests.rs | 10 ++++++---- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/crates/tinycomputer-browser/src/egress/forward.rs b/crates/tinycomputer-browser/src/egress/forward.rs index bdb0378e..84181049 100644 --- a/crates/tinycomputer-browser/src/egress/forward.rs +++ b/crates/tinycomputer-browser/src/egress/forward.rs @@ -65,7 +65,9 @@ pub(super) async fn initialize_upstream( }) .flat_map(|(line, name)| line[name.len() + 1..].split(',')) .map(|token| token.trim().to_ascii_lowercase()) - .filter(|token| !["upgrade", "host", "content-length", "transfer-encoding"].contains(&token.as_str())) + .filter(|token| { + !["upgrade", "host", "content-length", "transfer-encoding"].contains(&token.as_str()) + }) .collect(); for (line, name) in headers() { let name = name.to_ascii_lowercase(); diff --git a/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs b/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs index e6a0d5e2..e8cebe51 100644 --- a/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs +++ b/crates/tinycomputer-browser/src/linked/linked_tests/launch_tests.rs @@ -94,7 +94,7 @@ fn enforced_launch_refuses_caller_plugins_extensions_and_init_scripts() { false, None, ) - .is_err(), + .is_err(), "{field} was accepted" ); } @@ -118,12 +118,9 @@ fn a_host_executable_launches_under_the_enforced_proxy() { assert_eq!(launch["engine"], "chrome"); assert_eq!(launch["proxy"]["server"], "http://127.0.0.1:39123"); assert_eq!(launch["proxy"]["bypass"], "<-loopback>"); - assert!( - launch["args"] - .as_array() - .expect("args") - .contains(&json!("--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1")) - ); + assert!(launch["args"].as_array().expect("args").contains(&json!( + "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1" + ))); } } diff --git a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs index 883b6acc..31d933de 100644 --- a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs +++ b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/config_tests.rs @@ -224,10 +224,12 @@ fn the_browser_configuration_is_read_or_refused() { .unwrap() .is_some() ); - assert!(serde_json::from_value::(json!({"browser": { - "executable": "/usr/bin/chromium" - }})) - .is_ok()); + assert!( + serde_json::from_value::(json!({"browser": { + "executable": "/usr/bin/chromium" + }})) + .is_ok() + ); // Settling promptly and opening the browser early are on unless turned // off. let unset = BrowserDefaults::default();