diff --git a/Cargo.lock b/Cargo.lock index 35cc22843..b90d562db 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", diff --git a/Cargo.toml b/Cargo.toml index 2ea66a422..392759c62 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.2" +version = "0.16.0" edition = "2024" rust-version = "1.89" license = "GPL-3.0-only" @@ -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" } @@ -74,13 +77,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.6, <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.6, <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 409735a85..7e8dce8f4 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-browser/src/egress/egress_tests/http_tests.rs b/crates/tinycomputer-browser/src/egress/egress_tests/http_tests.rs index 575451b22..c02f9586e 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 d52f5d79f..841810492 100644 --- a/crates/tinycomputer-browser/src/egress/forward.rs +++ b/crates/tinycomputer-browser/src/egress/forward.rs @@ -48,18 +48,35 @@ 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"); } diff --git a/crates/tinycomputer-browser/src/egress/request.rs b/crates/tinycomputer-browser/src/egress/request.rs index 2fa6a61b8..39427475f 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 000000000..cd3c14a19 --- /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:?}"); + } +} diff --git a/crates/tinycomputer-browser/src/linked/linked_tests.rs b/crates/tinycomputer-browser/src/linked/linked_tests.rs index f5197bde4..3bf99e96e 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,8 @@ 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(), + host_executable: None, enforced_launch: true, }; let reply = engine @@ -113,6 +123,8 @@ 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(), + host_executable: None, enforced_launch: true, }; @@ -130,6 +142,8 @@ 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(), + host_executable: None, enforced_launch: true, }; let reply = engine @@ -155,6 +169,8 @@ 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(), + 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 04fc3cdf5..e8cebe517 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] @@ -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,99 @@ fn enforced_launch_refuses_external_cdp_and_caller_flags() { "http://127.0.0.1:39123", "session-secret", true, + None, ); 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}"); + } +} + +#[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, + 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 191ec8419..12e029d88 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 } } @@ -53,6 +65,8 @@ impl Launcher for AgentBrowser { egress_authorizer: None, proxy: None, proxy_session_id: String::new(), + proxy_password: String::new(), + host_executable: None, enforced_launch: false, }) } @@ -60,13 +74,24 @@ 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, - }) + let mut engine = enforced(self.authorizer.clone(), session); + engine.host_executable.clone_from(&self.executable); + Box::new(engine) + } +} + +/// 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(), + host_executable: None, + enforced_launch: false, } } @@ -75,6 +100,8 @@ struct Linked { egress_authorizer: Option>, proxy: Option, proxy_session_id: String, + proxy_password: String, + host_executable: Option, enforced_launch: bool, } @@ -87,7 +114,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 +127,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 +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_session_id) { + 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), } @@ -200,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( @@ -212,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); @@ -224,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) @@ -238,6 +284,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"); } @@ -250,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", @@ -259,18 +318,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)] diff --git a/crates/tinycomputer-bus/README.md b/crates/tinycomputer-bus/README.md index f97a3cc78..06329ff3f 100644 --- a/crates/tinycomputer-bus/README.md +++ b/crates/tinycomputer-bus/README.md @@ -32,9 +32,13 @@ payload vocabulary has to be published as an ordinary library. This is it. | `browser` | the `Browser…` members' payloads, names, and error codes | | `trace_sink` | the optional `TraceSinkHost` callback names and payloads | | `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 | Four dependencies, all pure Rust: `thiserror`, `serde`, `serde_json`, and -`base64` for compact binary artifact payloads. +`base64` for compact binary artifact payloads. 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/agent/agent_tests.rs b/crates/tinycomputer-bus/src/agent/agent_tests.rs index a4d745c64..763a3fddc 100644 --- a/crates/tinycomputer-bus/src/agent/agent_tests.rs +++ b/crates/tinycomputer-bus/src/agent/agent_tests.rs @@ -10,9 +10,9 @@ use std::collections::BTreeMap; use serde_json::json; use super::{ - AgentError, AgentResponse, AwaitTaskRequest, ContinueTaskRequest, InputField, InputKind, - PaymentMode, StartTaskRequest, SurfaceKind, TaskId, TaskReportRequest, TaskStatus, TaskUsage, - TaskView, + AGENT_ENVELOPE_VERSION, AgentError, AgentResponse, AwaitTaskRequest, ContinueTaskRequest, + InputField, InputKind, PaymentMode, StartTaskRequest, SurfaceKind, TaskId, TaskReportRequest, + TaskStatus, TaskUsage, TaskView, }; #[test] @@ -251,6 +251,7 @@ fn replies_carry_data_or_an_actionable_error() { usage: TaskUsage::default(), }; 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 ab8c2e5e2..918b2fdba 100644 --- a/crates/tinycomputer-bus/src/agent/mod.rs +++ b/crates/tinycomputer-bus/src/agent/mod.rs @@ -51,12 +51,12 @@ pub mod names; mod types; pub use types::{ - AgentError, AgentResponse, AwaitTaskRequest, Capabilities, ContinueTaskRequest, Example, - HostContext, InputField, InputKind, LanguageModelConfiguration, LanguageModelProvider, - MemberDoc, ModelUsage, PaymentMode, PlanTaskRequest, Rescue, RescueOutcome, SecretHandle, - StartTaskRequest, StepView, SurfaceAvailability, SurfaceKind, TaskBudget, TaskConstraints, - TaskId, TaskOutput, TaskPlan, TaskRef, TaskReport, TaskReportRequest, TaskStatus, TaskUsage, - TaskView, + AGENT_ENVELOPE_VERSION, AgentError, AgentResponse, AgentResponseError, AwaitTaskRequest, + Capabilities, ContinueTaskRequest, Example, HostContext, InputField, InputKind, + LanguageModelConfiguration, LanguageModelProvider, MemberDoc, ModelUsage, PaymentMode, + PlanTaskRequest, Rescue, RescueOutcome, SecretHandle, StartTaskRequest, StepView, + SurfaceAvailability, SurfaceKind, TaskBudget, TaskConstraints, TaskId, TaskOutput, TaskPlan, + TaskRef, TaskReport, TaskReportRequest, TaskStatus, TaskUsage, TaskView, }; #[cfg(test)] diff --git a/crates/tinycomputer-bus/src/agent/types/mod.rs b/crates/tinycomputer-bus/src/agent/types/mod.rs index bcb920f2d..bb4ea3ec8 100644 --- a/crates/tinycomputer-bus/src/agent/types/mod.rs +++ b/crates/tinycomputer-bus/src/agent/types/mod.rs @@ -10,7 +10,7 @@ mod usage; 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, HostContext, PaymentMode, PlanTaskRequest, SecretHandle, diff --git a/crates/tinycomputer-bus/src/agent/types/reply.rs b/crates/tinycomputer-bus/src/agent/types/reply.rs index db6451603..685282628 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 000000000..28f8b56dd --- /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 267392f88..859ecdd2e 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!((options.screen.expect("screen").device_scale_factor - 1.0).abs() < f64::EPSILON); + 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 000000000..6c217fd13 --- /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 000000000..271ec6634 --- /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 000000000..a11463371 --- /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 000000000..63baef1fa --- /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 000000000..7c8db33d4 --- /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 9e70e1c65..2eb63a968 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 1e3a0b253..13b8ca387 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 ae041984b..985ac5430 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 e799910b1..a859e91d5 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 //! @@ -120,6 +122,7 @@ pub mod browser; pub mod browser_egress; pub mod catalogue; pub mod clipboard; +pub mod config; pub mod consequence_host; pub mod desktop_tool; pub mod envelope; @@ -131,6 +134,7 @@ pub mod names; pub mod notifications; pub mod observation; pub mod site_memory; +pub mod sites; pub mod system; pub mod trace_sink; pub mod version; @@ -148,8 +152,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/site_memory/mod.rs b/crates/tinycomputer-bus/src/site_memory/mod.rs index 45b20262c..8e5969be8 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: //! diff --git a/crates/tinycomputer-bus/src/sites/mod.rs b/crates/tinycomputer-bus/src/sites/mod.rs new file mode 100644 index 000000000..17af116d5 --- /dev/null +++ b/crates/tinycomputer-bus/src/sites/mod.rs @@ -0,0 +1,29 @@ +//! Plain site-name validation shared by the host and module. + +/// `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'-') + }) + // 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()) +} + +#[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 000000000..d37007de1 --- /dev/null +++ b/crates/tinycomputer-bus/src/sites/sites_tests.rs @@ -0,0 +1,20 @@ +//! Tests for canonical site normalization shared by the host and module. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use super::site_named; + +#[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 cf33c202b..7e6ba65a7 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, 28); +pub const CONTRACT_VERSION: (u32, u32) = (2, 29); /// 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, 28); /// ``` /// # use tinycomputer_bus::{is_compatible, CONTRACT_VERSION}; /// assert!(is_compatible(CONTRACT_VERSION)); -/// assert!(is_compatible((2, 28))); +/// assert!(is_compatible((2, 29))); /// 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 e25ea5aca..c539fc90e 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, 28)); + assert_eq!(CONTRACT_VERSION, (2, 29)); } #[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, 28))); + assert!(is_compatible((2, 29))); assert!(is_compatible((2, 97))); // 2.24 adds same-task flow-to-planner handoff to StartTask. // 2.22 adds optional host-held secret handles to StartTask and RunFlow. @@ -46,6 +46,9 @@ fn a_newer_minor_on_the_module_side_binds() { // module would drop that context, so a host requiring it cannot bind. // 2.28 adds `max_do_turns` to TaskBudget and RunFlowRequest. A 2.27 // module ignores it and keeps every `do` step at 8 turns. + // 2.29 adds typed ComputerConfig, shared strict site normalization, and + // versioned consistency-checked Desktop and Agent response extraction. + assert!(!is_compatible((2, 28))); assert!(!is_compatible((2, 27))); assert!(!is_compatible((2, 26))); assert!(!is_compatible((2, 25))); diff --git a/crates/tinycomputer-client/Cargo.toml b/crates/tinycomputer-client/Cargo.toml new file mode 100644 index 000000000..873d09fdc --- /dev/null +++ b/crates/tinycomputer-client/Cargo.toml @@ -0,0 +1,27 @@ +[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 +# 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. +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 000000000..cf5e760ff --- /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 000000000..28d5c1348 --- /dev/null +++ b/crates/tinycomputer-client/src/client.rs @@ -0,0 +1,218 @@ +//! Configuration-safe calls to `TinyComputer`'s contract families. + +use std::{sync::Arc, time::Duration}; + +use serde::{Serialize, de::DeserializeOwned}; +use tinybus::{ + Proxy, + module::{ConfigFingerprint, ModuleCallError, ReconfigurableModule}, +}; +use tinycomputer_bus::{ + DesktopResponse, + agent::{AgentResponse, Capabilities, names as agent_names}, + catalogue::{self, Family, Member}, + config::ComputerConfig, + names, +}; +use tokio::sync::{RwLock, RwLockReadGuard}; + +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. +/// +/// 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 { + 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), + active: Arc::new(RwLock::new(None)), + } + } + + /// 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<(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)) + } + + 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 000000000..5012d3f2e --- /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 000000000..3be2a9da1 --- /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`](tinycomputer_bus::config::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 000000000..60e301b1e --- /dev/null +++ b/crates/tinycomputer-client/tests/client_tests.rs @@ -0,0 +1,48 @@ +//! 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}; + +#[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 000000000..878f78acc --- /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 000000000..1488fd88c --- /dev/null +++ b/crates/tinycomputer-client/tests/client_tests/fail_closed_tests.rs @@ -0,0 +1,84 @@ +//! Unknown members, wrong families, malformed envelopes, and an unknown module. + +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(()) +} + +#[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/linked_module_tests.rs b/crates/tinycomputer-client/tests/client_tests/linked_module_tests.rs new file mode 100644 index 000000000..0f4d5a135 --- /dev/null +++ b/crates/tinycomputer-client/tests/client_tests/linked_module_tests.rs @@ -0,0 +1,395 @@ +//! 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?; + + // 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(); + 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 000000000..6e3b52627 --- /dev/null +++ b/crates/tinycomputer-client/tests/client_tests/support/common.rs @@ -0,0 +1,394 @@ +//! 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) + }) + } +} + +/// 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), + 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 000000000..a68b45318 --- /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 000000000..74d7125b7 --- /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(()) +} diff --git a/crates/tinycomputer-core/Cargo.toml b/crates/tinycomputer-core/Cargo.toml index ac14c6883..1bcba4cb4 100644 --- a/crates/tinycomputer-core/Cargo.toml +++ b/crates/tinycomputer-core/Cargo.toml @@ -20,9 +20,9 @@ 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 } +# Parse browser URLs into the plain site names carried by the bus contract. url = { workspace = true } [lints] diff --git a/crates/tinycomputer-core/src/sites/mod.rs b/crates/tinycomputer-core/src/sites/mod.rs index c942cbb87..8659db18c 100644 --- a/crates/tinycomputer-core/src/sites/mod.rs +++ b/crates/tinycomputer-core/src/sites/mod.rs @@ -28,6 +28,19 @@ 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; + +/// 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; /// Most plans kept per site; the one finished on longest ago goes first. @@ -114,31 +127,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 4ebdc4eff..4f558d211 100644 --- a/crates/tinycomputer-core/src/sites/sites_tests.rs +++ b/crates/tinycomputer-core/src/sites/sites_tests.rs @@ -64,6 +64,38 @@ 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_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); +} + +#[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!( diff --git a/crates/tinycomputer-engine/src/planner/config.rs b/crates/tinycomputer-engine/src/planner/config.rs index 40437ef20..06c398b5e 100644 --- a/crates/tinycomputer-engine/src/planner/config.rs +++ b/crates/tinycomputer-engine/src/planner/config.rs @@ -8,6 +8,7 @@ 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,76 +29,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, -} - /// A host-owned OpenAI-compatible model endpoint. /// /// Use one configuration per role when planner, rescue, or output models need @@ -136,19 +67,6 @@ pub struct HostModels { pub output: 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, @@ -156,47 +74,43 @@ 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()); +/// 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()); + } + if route.provider == LanguageModelProvider::OpenAiCompatible { + return Err( + "open_ai_compatible routes are available only to Rust embedding hosts".to_owned(), + ); + } + let base_url = match &route.endpoint_url { + Some(endpoint) if trusted_base_url(route.provider, endpoint) => { + endpoint.trim_end_matches('/').to_owned() } - if self.provider == LanguageModelProvider::OpenAiCompatible { - return Err( - "open_ai_compatible routes are available only to Rust embedding hosts".to_owned(), - ); + Some(_) => { + return Err("endpoint_url is not an approved language model route".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 | LanguageModelProvider::OpenAiCompatible => None, - }; - Ok(ResolvedRoute { - provider: self.provider, - base_url, - sdk_name, - }) - } + 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 | LanguageModelProvider::OpenAiCompatible => None, + }; + Ok(ResolvedRoute { + provider: route.provider, + base_url, + sdk_name, + }) +} - /// 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(), - } +/// 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 e2b7c9c86..f291ba69f 100644 --- a/crates/tinycomputer-engine/src/planner/hosted.rs +++ b/crates/tinycomputer-engine/src/planner/hosted.rs @@ -23,7 +23,7 @@ use tinyinference_llm::{ use super::config::{ HostModelConfig, HostModels, ModelRoute, OUTPUT_MODEL, PLANNER_MODEL, PlannerConfig, - RESCUE_MODEL, + RESCUE_MODEL, describe_route, resolve_route, }; use super::{ Completion, LanguageModel, ModelFailure, ModelReply, Planner, ReportedCompletion, @@ -49,7 +49,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 @@ -71,7 +71,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 @@ -93,7 +93,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))) } /// Builds planner, rescue, and output adapters from host-owned compatible @@ -175,7 +175,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 0490a7f41..60fb080b0 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-examples/Cargo.toml b/crates/tinycomputer-examples/Cargo.toml index 4e0dd7564..0668ae7dc 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 1e7970c29..9ace70223 100644 --- a/crates/tinycomputer-examples/src/bin/verify_module.rs +++ b/crates/tinycomputer-examples/src/bin/verify_module.rs @@ -13,9 +13,98 @@ use std::time::Duration; use tinybus::Connection; use tinybus::broker::Broker; -use tinybus::module::{HostCallbackEndpoint, ModuleHost}; +use tinybus::module::{ + AccessFuture, FailureReport, FailureReporter, FailureSink, HostCallbackEndpoint, ModuleAccess, + ModuleHost, ProxyFailure, ReconfigurableModule, ReconfigureFailure, ReleaseRecord, +}; use tinybus::transport::memory::MemoryBus; +use tinycomputer_bus::config::ComputerConfig; use tinycomputer_bus::{DesktopResponse, METHODS, names, trace_sink}; +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_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) + }) + } +} #[derive(Clone, Default)] struct TraceSinkCapture { @@ -67,7 +156,7 @@ 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 module_host = Arc::new(ModuleHost::new(broker)); let callback = module_host .prepare_host_callbacks(vec![HostCallbackEndpoint { path: trace_sink::OBJECT_PATH.parse()?, @@ -98,6 +187,7 @@ async fn main() -> Result<(), Box> { let info = module_host .load_file_with_host_callbacks(&module, config, &callback) .await?; + let client_config = ComputerConfig::default(); if info.name != "tinycomputer" { return Err(io::Error::other(format!( @@ -137,6 +227,8 @@ async fn main() -> Result<(), Box> { verify_report_sink(&proxy, &capture).await?; + verify_typed_client(&bus, &module_host, &client_config).await?; + let declared: Vec<&str> = info .manifest .provides @@ -148,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", @@ -176,6 +252,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(()) +} + /// Removes a trace path if a regression sends sink-mode events to disk. struct RemoveTraceFileOnDrop(PathBuf); @@ -288,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. diff --git a/crates/tinycomputer/src/tinybus_module/README.md b/crates/tinycomputer/src/tinybus_module/README.md index 480af3ea0..680722040 100644 --- a/crates/tinycomputer/src/tinybus_module/README.md +++ b/crates/tinycomputer/src/tinybus_module/README.md @@ -52,28 +52,31 @@ 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. Setting `trace_sink` to an object with -the unique `destination` supplied by `ModuleHost::prepare_host_callbacks` opts -into the host's confidential `TraceSinkHost` interface for ordered desktop -trace events, trace artifacts, and requested task reports; selected sink mode -never writes local trace files. Artifact bytes use base64 and are capped at -8 MiB decoded so the callback stays below TinyBus's frame limit. Leaving it -absent or `false` retains the existing `trace_path` and session behavior. -`true` without a callback -destination is rejected. `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: `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. + +Setting `trace_sink` to an object with the unique `destination` supplied by +`ModuleHost::prepare_host_callbacks` opts into the host's confidential +`TraceSinkHost` interface for ordered desktop trace events, trace artifacts, +and requested task reports; selected sink mode never writes local trace files. +Artifact bytes use base64 and are capped at 8 MiB decoded so the callback stays +below TinyBus's frame limit. Leaving it absent or `false` retains the existing +`trace_path` and session behavior. `true` without a callback destination is +rejected. + +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. Setting `consequence_host` to an object with the unique `destination` supplied by `ModuleHost::prepare_host_callbacks` enables confidential consequence diff --git a/crates/tinycomputer/src/tinybus_module/config.rs b/crates/tinycomputer/src/tinybus_module/config.rs index 45560e8c1..bd91c1cce 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; @@ -61,101 +62,72 @@ 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()); - }; - 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, a `prelaunch` boolean, and optional private `egress` policy", - }; - 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() + /// Applies the browser defaults decoded from the public module config. + pub(crate) fn from_config( + config: Option<&BrowserConfig>, + egress: Option, + ) -> Self { + let Some(config) = config else { + return Self { + egress, + ..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::>()?; + 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, + egress, } - 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()), + } + + 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); }; - defaults.prelaunch = match browser.get("prelaunch") { - None => true, - Some(serde_json::Value::Bool(prelaunch)) => *prelaunch, - Some(_) => return Err(invalid()), + let Some(egress) = browser.as_object().and_then(|object| object.get("egress")) else { + return Ok(None); }; - defaults.egress = match browser.get("egress") { - None => None, - Some(serde_json::Value::Object(egress)) => { - let policy_id = egress - .get("policy_id") - .and_then(serde_json::Value::as_str) - .filter(|id| !id.is_empty() && id.len() <= 64) - .ok_or_else(invalid)?; - let generation = egress - .get("generation") - .and_then(serde_json::Value::as_u64) - .filter(|generation| *generation > 0) - .ok_or_else(invalid)?; - if egress.len() != 2 - || browser.contains_key("args") - || browser.contains_key("executable") - { - return Err(invalid()); - } - Some(BrowserEgressConfig { - policy_id: policy_id.to_owned(), - generation, - }) - } - Some(_) => return Err(invalid()), + let invalid = || crate::Error::ConfigFieldType { + field: "browser", + expected: "an object with a valid private egress policy", }; - if browser.keys().any(|key| { - ![ - "executable", - "user_agent", - "args", - "perception", - "settle", - "prelaunch", - "egress", - ] - .contains(&key.as_str()) - }) { + 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)?; + // 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")) + { return Err(invalid()); } - Ok(defaults) + 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/dispatch/accessibility_tests.rs b/crates/tinycomputer/src/tinybus_module/dispatch/accessibility_tests.rs index c0997de38..efe83a143 100644 --- a/crates/tinycomputer/src/tinybus_module/dispatch/accessibility_tests.rs +++ b/crates/tinycomputer/src/tinybus_module/dispatch/accessibility_tests.rs @@ -4,13 +4,59 @@ reason = "test assertions require fixture operations to succeed" )] use super::*; -#[cfg(not(target_os = "macos"))] -use std::sync::atomic::AtomicBool; 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] +fn thread_restore_worker_reports_completion_and_joins_once() { + 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(), + "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"); +} + +#[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 3cfb9f04d..829437c31 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, EnforcedAgentBrowser}; 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; @@ -21,6 +23,73 @@ use crate::tinybus_module::site_memory::{SiteLink, SiteMemoryPort, refused_flow} use crate::tinybus_module::trace_sink::TraceLink; 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); + } + 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( + config, + "planner", + "a planner configuration object with an api_key and an approved route", + )?, + browser: config_field( + &browser_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. /// @@ -29,6 +98,12 @@ impl DesktopService { /// 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)))) } @@ -39,12 +114,21 @@ impl DesktopService { config: &serde_json::Value, connection: tinybus::Connection, ) -> TinyBusResult { - let defaults = BrowserDefaults::from_config(config) + let egress = BrowserDefaults::egress_from_raw(config) .map_err(|error| TinyBusError::failed(error.to_string()))?; - let browser = if let Some(egress) = defaults.egress { - Arc::new(Browser::new(Arc::new(EnforcedAgentBrowser::new(Arc::new( - BusAuthorizer::new(connection.clone(), egress.policy_id, egress.generation), - ))))) + let browser = if let Some(egress) = &egress { + 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))) }; @@ -55,7 +139,7 @@ impl DesktopService { } else { None }; - Self::with_browser_and_trace_sink(config, browser, trace_link) + Self::with_browser_and_defaults(config, browser, egress, trace_link) .map_err(|error| TinyBusError::failed(error.to_string())) } @@ -67,6 +151,13 @@ impl DesktopService { /// 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", + }); + } if trace_sink_destination(config)?.is_some() { return Err(crate::Error::ConfigFieldType { field: "trace_sink", @@ -79,30 +170,27 @@ impl DesktopService { expected: "a module set up with its confidential host callback connection", }); } - Self::with_browser_and_trace_sink(config, browser, None) + Self::with_browser_and_defaults(config, browser, egress, None) } - fn with_browser_and_trace_sink( + fn with_browser_and_defaults( config: &serde_json::Value, browser: Arc, + egress: Option, trace_link: Option>, ) -> Result { let cursor = Arc::new(cursor_config(config)?); - let mut desktop = Desktop::from_config(config)?.with_cursor(cursor.clone()); + let computer = computer_config(config)?; + let mut desktop = + Desktop::from_config(&desktop_config(config, &computer))?.with_cursor(cursor.clone()); if let Some(link) = &trace_link { desktop = desktop.with_trace_sink(link.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 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", }) @@ -111,23 +199,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(), egress); let mut runner = WorkspaceRunner::new(desktop.clone(), jev.clone(), browser.clone()); runner.defaults.clone_from(&browser_defaults); runner.cursor = cursor; @@ -187,6 +273,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 000000000..7f9b1016e --- /dev/null +++ b/crates/tinycomputer/src/tinybus_module/dispatch/service_tests.rs @@ -0,0 +1,83 @@ +#![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()); +} + +#[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 22d6b0f78..31d933de8 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(), None) +} #[test] fn a_malformed_configuration_is_rejected_rather_than_defaulted() { @@ -13,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; @@ -155,42 +191,54 @@ 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); 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); + // 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(&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(); 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"}), @@ -206,20 +254,21 @@ fn the_browser_configuration_is_read_or_refused() { json!({"browser": {"egress": {"policy_id": "p", "generation": 1, "extra": true}}}), json!({"browser": {"egress": {"policy_id": "p", "generation": 1}, "args": ["--proxy-server=bad"]}}), ] { - 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 b63bb8ddc..8d87c3143 100644 --- a/crates/tinycomputer/tests/public_api_tests.rs +++ b/crates/tinycomputer/tests/public_api_tests.rs @@ -34,7 +34,8 @@ 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, 28)); + assert_eq!(CONTRACT_VERSION, (2, 29)); + assert!(!is_compatible((2, 28))); assert!(!is_compatible((2, 25))); } diff --git a/docs/README.md b/docs/README.md index e3e5a6e1a..c1dfdd2e1 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-bus/versioning.md b/docs/crates/tinycomputer-bus/versioning.md index edf255cf1..325d92e50 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, 27); +pub const CONTRACT_VERSION: (u32, u32) = (2, 29); ``` 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.29 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,17 +50,16 @@ 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, 27)` expects at least the members and fields that - existed at `(2, 27)`. A module reporting `(2, 26)` lacks host-context relay - for `ConsequenceHost`, so a 2.27 host rejects it. A module reporting - `(2, 25)` predates the - `TraceSinkHost` callback added in 2.26, so a 2.26 host rejects it. It does - include `BrowserEgressHost`, which was added in 2.25. A newer module with - the same major is accepted. + built against `(2, 29)` expects at least the members and fields that + existed at `(2, 29)`. A module reporting `(2, 28)` lacks the typed + `ComputerConfig` and versioned response extraction, so a 2.29 host rejects + it. A module reporting `(2, 26)` lacks host-context relay for + `ConsequenceHost`, and one reporting `(2, 25)` predates the `TraceSinkHost` + callback added in 2.26. A newer module with the same major is accepted. ```rust,ignore -assert!(is_compatible(CONTRACT_VERSION)); // (2, 27), the exact version this crate ships -assert!(is_compatible((2, 28))); // a newer, still-compatible module +assert!(is_compatible(CONTRACT_VERSION)); // (2, 29), the exact version this crate ships +assert!(is_compatible((2, 30))); // a newer, still-compatible module assert!(!is_compatible((2, 11))); // an older module, missing something assert!(!is_compatible((2, 26))); // predates ConsequenceHost host-context relay assert!(!is_compatible((1, 10))); // a different major, all bets off @@ -90,6 +94,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/crates/tinycomputer-client/README.md b/docs/crates/tinycomputer-client/README.md new file mode 100644 index 000000000..3bc3ab06d --- /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/docs/crates/tinycomputer/configuration.md b/docs/crates/tinycomputer/configuration.md index 561cd88cf..305cd89e1 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 098e16284..636814990 100644 --- a/docs/technical/specs/desktop-module-contract.md +++ b/docs/technical/specs/desktop-module-contract.md @@ -211,10 +211,40 @@ or false keeps the existing start behavior. 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.29) + +- `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. +- `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`, 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 + 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 `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, @@ -237,12 +267,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 diff --git a/vendor/tinybus b/vendor/tinybus index 6ce06bc67..106eda127 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 6ce06bc674e4f4d718aa51e643ef62c1f372e270 +Subproject commit 106eda127f8a40c0ba401efc0074667e43d4c52e