From b0c62b35d36a002d47dd6b3b99b9aa22ade53a63 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 15:17:51 +0530 Subject: [PATCH 1/4] Make BrowserReadOutput confidential (contract 3.0) A held output is a screenshot, and a task's last screenshot can show the form it filled, card details included. Its TaskReport handle was already confidential, but the bytes were read with an ordinary member, so they crossed the bus in plain frames. The same held for any BrowserScreenshot of a page showing typed secrets. Mark the member confidential in the served interface and the catalogue. Every plain call to it now fails, so the contract major moves to 3.0. The example host reads outputs with a confidential call. The in-memory module is never attested, so the module test reads outputs from the service directly, and asserts that the plain bus call is refused. Closes #61 --- .../tinycomputer-bus/src/catalogue/browser.rs | 2 +- crates/tinycomputer-bus/src/version/mod.rs | 6 +-- .../src/version/version_tests.rs | 12 +++-- crates/tinycomputer-examples/src/host/mod.rs | 11 +++-- .../skills/tinycomputer/SKILL.md | 2 +- .../src/tinybus_module/dispatch/mod.rs | 4 +- .../tinybus_module_tests/browser_tests.rs | 47 ++++++++++++------- .../tinybus_module_tests/wire_tests.rs | 4 +- crates/tinycomputer/tests/public_api_tests.rs | 4 +- .../tinycomputer-bus/agent-and-tasks.md | 3 +- docs/crates/tinycomputer-bus/versioning.md | 27 ++++++----- docs/crates/tinycomputer/members.md | 9 +++- .../specs/desktop-module-contract.md | 10 ++++ docs/technical/tasks.md | 3 +- 14 files changed, 94 insertions(+), 50 deletions(-) diff --git a/crates/tinycomputer-bus/src/catalogue/browser.rs b/crates/tinycomputer-bus/src/catalogue/browser.rs index fbb6b182..2a07bfbd 100644 --- a/crates/tinycomputer-bus/src/catalogue/browser.rs +++ b/crates/tinycomputer-bus/src/catalogue/browser.rs @@ -64,7 +64,7 @@ pub(super) const BROWSER: &[Member] = &[ browser::READ_OUTPUT, Family::Browser, "Reads one base64 chunk of a held screenshot or PDF.", - false, + true, ), entry( browser::RELEASE_OUTPUT, diff --git a/crates/tinycomputer-bus/src/version/mod.rs b/crates/tinycomputer-bus/src/version/mod.rs index 7e6ba65a..7cb6ede7 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, 29); +pub const CONTRACT_VERSION: (u32, u32) = (3, 0); /// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module /// reporting `module`. @@ -22,8 +22,8 @@ pub const CONTRACT_VERSION: (u32, u32) = (2, 29); /// ``` /// # use tinycomputer_bus::{is_compatible, CONTRACT_VERSION}; /// assert!(is_compatible(CONTRACT_VERSION)); -/// assert!(is_compatible((2, 29))); -/// assert!(!is_compatible((2, 11))); +/// assert!(is_compatible((3, 0))); +/// assert!(!is_compatible((2, 29))); /// assert!(!is_compatible((1, 8))); /// ``` #[must_use] diff --git a/crates/tinycomputer-bus/src/version/version_tests.rs b/crates/tinycomputer-bus/src/version/version_tests.rs index c539fc90..d101323c 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, 29)); + assert_eq!(CONTRACT_VERSION, (3, 0)); } #[test] @@ -16,8 +16,9 @@ fn the_contract_binds_to_itself() { #[test] fn a_newer_minor_on_the_module_side_binds() { - assert!(is_compatible((2, 29))); - assert!(is_compatible((2, 97))); + assert!(is_compatible((3, 0))); + assert!(is_compatible((3, 97))); + // The history below is the 2.x line, which 3.0 no longer binds at all. // 2.24 adds same-task flow-to-planner handoff to StartTask. // 2.22 adds optional host-held secret handles to StartTask and RunFlow. // A host-only handle is ignored by older modules, which then refuse it @@ -99,5 +100,8 @@ fn a_different_major_is_rejected() { // 2.0 renamed the interface from `tinydesktop` to `tinycomputer`: a // 1.x host calls names a 2.x module does not serve. assert!(!is_compatible((1, 8))); - assert!(!is_compatible((3, 0))); + // 3.0 makes BrowserReadOutput confidential: a 2.x host reads held + // screenshots in plain frames, which a 3.0 module refuses. + assert!(!is_compatible((2, 29))); + assert!(!is_compatible((4, 0))); } diff --git a/crates/tinycomputer-examples/src/host/mod.rs b/crates/tinycomputer-examples/src/host/mod.rs index 4a54cb69..4aead027 100644 --- a/crates/tinycomputer-examples/src/host/mod.rs +++ b/crates/tinycomputer-examples/src/host/mod.rs @@ -381,10 +381,11 @@ impl Host { } /// A held output's bytes — a screenshot this host took, or one a task - /// report names: `BrowserReadOutput` chunk by chunk until the end, then - /// `BrowserReleaseOutput`, the handle protocol a host follows. After a - /// transport failure the output is kept, so a retry can still read it - /// before it expires; after any other failure it is released. + /// report names: `BrowserReadOutput` (a confidential call) chunk by chunk + /// until the end, then `BrowserReleaseOutput`, the handle protocol a host + /// follows. After a transport failure the output is kept, so a retry can + /// still read it before it expires; after any other failure it is + /// released. /// /// # Errors /// @@ -425,7 +426,7 @@ impl Host { }; let reply = self .proxy - .call(methods::READ_OUTPUT, (request,)) + .call_confidential(methods::READ_OUTPUT, (request,)) .await .map_err(|error| Unread::Transport(error.into()))?; let chunk: OutputChunk = data(reply).map_err(Unread::Invalid)?; diff --git a/crates/tinycomputer-skills/skills/tinycomputer/SKILL.md b/crates/tinycomputer-skills/skills/tinycomputer/SKILL.md index 5a0bf097..5ce5851b 100644 --- a/crates/tinycomputer-skills/skills/tinycomputer/SKILL.md +++ b/crates/tinycomputer-skills/skills/tinycomputer/SKILL.md @@ -46,7 +46,7 @@ Tasks are the way in; the primitives are there when you need to look or act dire - `BrowserListSessions` shows every open browser session, a running task's included. - `BrowserOpenSession`, then `BrowserNavigate` and `BrowserSnapshot` with `{"session": id, …}`; act on a ref with `BrowserPerform`, such as `{"session": "s-1", "action": "click", "target": {"kind": "ref", "value": "e3"}}`. -- `BrowserScreenshot` returns an output id, not an image: read it with `BrowserReadOutput` from `offset` 0 until `eof`, then `BrowserReleaseOutput`. The same works for a screenshot in `TaskReport.artifacts`: a task keeps one each time it stops, and a task view never carries one. +- `BrowserScreenshot` returns an output id, not an image: read it with `BrowserReadOutput` (a confidential call) from `offset` 0 until `eof`, then `BrowserReleaseOutput`. The same works for a screenshot in `TaskReport.artifacts`: a task keeps one each time it stops, and a task view never carries one. - `BrowserCloseSession` when you are done with a session you opened; leave a task's session to the task. ## Writing flows that work diff --git a/crates/tinycomputer/src/tinybus_module/dispatch/mod.rs b/crates/tinycomputer/src/tinybus_module/dispatch/mod.rs index 5efed919..a9c98371 100644 --- a/crates/tinycomputer/src/tinybus_module/dispatch/mod.rs +++ b/crates/tinycomputer/src/tinybus_module/dispatch/mod.rs @@ -750,7 +750,9 @@ impl DesktopService { )) } - /// Reads one chunk of a held output. + /// Reads one chunk of a held output. Confidential: a screenshot can show + /// what a task typed, card details included. + #[tinybus(confidential)] async fn browser_read_output( &self, request: ReadOutputRequest, diff --git a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/browser_tests.rs b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/browser_tests.rs index ad5bede6..9d7023e5 100644 --- a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/browser_tests.rs +++ b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/browser_tests.rs @@ -11,7 +11,7 @@ use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; use tinybus::broker::Broker; use tinybus::transport::memory::MemoryBus; -use tinybus::{Connection, Proxy}; +use tinybus::{Connection, Interface, Proxy}; use tinycomputer_browser::{Browser, Engine, Launcher, Reply}; use tinycomputer_bus::browser::names::methods; use tinycomputer_bus::{DeliveryDisposition, DesktopResponse, names}; @@ -97,10 +97,18 @@ async fn serve_with( config: &Value, launcher: Arc, ) -> tinybus::Result<(Connection, Proxy)> { + serve_on( + config, + Arc::new(Browser::with_scratch(launcher, scratch.0.clone())), + ) + .await +} + +/// Serves a service configured by `config` on `browser`. +async fn serve_on(config: &Value, browser: Arc) -> tinybus::Result<(Connection, Proxy)> { let bus = MemoryBus::new(); Broker::new().spawn(bus.clone()); - let browser = Arc::new(Browser::with_scratch(launcher, scratch.0.clone())); let service = DesktopService::with_browser(config, browser).expect("the config is valid"); let server = Connection::connect(bus.connect().await?).await?; server @@ -119,6 +127,14 @@ async fn call(proxy: &Proxy, member: &str, request: Value) -> tinybus::Result tinybus::Result { + let member = methods::READ_OUTPUT.try_into()?; + assert!(service.requires_confidential(&member)); + Ok(serde_json::from_value(service.call(&member, json!([request])).await?).expect("an envelope")) +} + fn data(reply: &DesktopResponse) -> &Value { assert!(reply.ok, "{} failed: {:?}", reply.command, reply.error); reply @@ -166,7 +182,13 @@ async fn a_session_runs_open_navigate_click_and_close_over_the_bus() -> tinybus: #[tokio::test] async fn a_screenshot_is_read_back_in_chunks_and_released() -> tinybus::Result<()> { let scratch = Scratch::new("shot"); - let (_server, proxy) = serve(&scratch).await?; + let browser = Arc::new(Browser::with_scratch( + Arc::new(ScriptedLauncher::default()), + scratch.0.clone(), + )); + let (_server, proxy) = serve_on(&json!({}), browser.clone()).await?; + // A second service on the same browser sees the same held outputs. + let direct = DesktopService::with_browser(&json!({}), browser).expect("the config is valid"); let opened = call(&proxy, methods::OPEN_SESSION, json!({})).await?; let session = data(&opened)["id"].clone(); @@ -174,24 +196,17 @@ async fn a_screenshot_is_read_back_in_chunks_and_released() -> tinybus::Result<( let output = data(&shot)["id"].clone(); assert_eq!(data(&shot)["total_bytes"], 14); - let first = call( - &proxy, - methods::READ_OUTPUT, - json!({"output": output, "max_len": 8}), - ) - .await?; + call(&proxy, methods::READ_OUTPUT, json!({"output": output})) + .await + .expect_err("the bytes never cross the bus in a plain frame"); + let first = read_output(&direct, json!({"output": output, "max_len": 8})).await?; assert_eq!(data(&first)["eof"], false); - let rest = call( - &proxy, - methods::READ_OUTPUT, - json!({"output": output, "offset": 8}), - ) - .await?; + let rest = read_output(&direct, json!({"output": output, "offset": 8})).await?; assert_eq!(data(&rest)["eof"], true); let released = call(&proxy, methods::RELEASE_OUTPUT, json!({"output": output})).await?; assert_eq!(data(&released)["released"], true); - let gone = call(&proxy, methods::READ_OUTPUT, json!({"output": output})).await?; + let gone = read_output(&direct, json!({"output": output})).await?; let error = gone.error.expect("a released output is gone"); assert_eq!(error.code, "OUTPUT_NOT_FOUND"); assert!(error.suggestion.is_some()); diff --git a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/wire_tests.rs b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/wire_tests.rs index 5f909b18..92b09b98 100644 --- a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/wire_tests.rs +++ b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/wire_tests.rs @@ -198,7 +198,6 @@ fn wire_sweep() -> Vec<(&'static str, serde_json::Value)> { json!([{ "session": "s-0", "expression": "1" }]), ), (browser_methods::SCREENSHOT, no_session.clone()), - (browser_methods::READ_OUTPUT, no_output.clone()), (browser_methods::RELEASE_OUTPUT, no_output), (browser_methods::LIST_DOWNLOADS, no_session.clone()), (browser_methods::WAIT_DOWNLOAD, no_session), @@ -270,6 +269,9 @@ fn the_wire_sweep_covers_every_member_except_the_one_with_no_safe_input() { // stop that lease again without carrying the returned handle. &names::accessibility::GLOBE_START, &browser_methods::OPEN_SESSION, + // Confidential: the unattested in-memory module cannot take it; + // `browser_tests.rs` reads outputs from the service directly. + &browser_methods::READ_OUTPUT, // Every valid claim touches the filesystem; `browser_tests.rs` // exercises these two over the bus. &browser_methods::CLAIM_PROFILE, diff --git a/crates/tinycomputer/tests/public_api_tests.rs b/crates/tinycomputer/tests/public_api_tests.rs index 8d87c314..bb2cbf44 100644 --- a/crates/tinycomputer/tests/public_api_tests.rs +++ b/crates/tinycomputer/tests/public_api_tests.rs @@ -34,8 +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, 29)); - assert!(!is_compatible((2, 28))); + assert_eq!(CONTRACT_VERSION, (3, 0)); + assert!(!is_compatible((2, 29))); assert!(!is_compatible((2, 25))); } diff --git a/docs/crates/tinycomputer-bus/agent-and-tasks.md b/docs/crates/tinycomputer-bus/agent-and-tasks.md index 2ef98445..61ec0197 100644 --- a/docs/crates/tinycomputer-bus/agent-and-tasks.md +++ b/docs/crates/tinycomputer-bus/agent-and-tasks.md @@ -247,7 +247,8 @@ ten seconds). `CancelTask` releases at once without one; take a only — never on a status's `screenshot`, because a `TaskView` also travels through `AwaitTask` and `ListTasks`, which are not confidential, and a held output's id is all `BrowserReadOutput` needs. Read it with -`BrowserReadOutput` within five minutes, before it expires. +`BrowserReadOutput`, a confidential call, within five minutes, before it +expires. Each `Rescue` records one time a failed step was handed to a reasoning model for a second opinion: diff --git a/docs/crates/tinycomputer-bus/versioning.md b/docs/crates/tinycomputer-bus/versioning.md index 325d92e5..1af10207 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, 29); +pub const CONTRACT_VERSION: (u32, u32) = (3, 0); ``` This describes the *vocabulary*: the member set and the payload shapes, @@ -25,7 +25,13 @@ 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 +Contract 3.0 makes `BrowserReadOutput` confidential. A held screenshot can +show what a task typed, card details included, so its bytes no longer cross +the bus in plain frames. Every plain call to it now fails, which is why this is +a major bump: a host reads outputs with a confidential call, from an attested +module. + +Contract 2.29 published 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. @@ -50,18 +56,15 @@ Two conditions, both necessary: relies on changed shape or disappeared, so a mismatched major is refused outright, in either direction. 2. **The module must be at least as new, minor-wise, as the host.** A host - built against `(2, 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. + built against `(3, 0)` expects at least the members and fields that + existed at `(3, 0)`. A newer module with the same major is accepted. On + the 2.x line, for example, a 2.29 host rejected a `(2, 28)` module, which + lacked the typed `ComputerConfig` and versioned response extraction. ```rust,ignore -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(CONTRACT_VERSION)); // (3, 0), the exact version this crate ships +assert!(is_compatible((3, 1))); // a newer, still-compatible module +assert!(!is_compatible((2, 29))); // a different major: plain BrowserReadOutput assert!(!is_compatible((1, 10))); // a different major, all bets off ``` diff --git a/docs/crates/tinycomputer/members.md b/docs/crates/tinycomputer/members.md index e72d54c1..4bf6a1f8 100644 --- a/docs/crates/tinycomputer/members.md +++ b/docs/crates/tinycomputer/members.md @@ -196,7 +196,7 @@ for the engine underneath. ## Confidential members -Six members are marked `#[tinybus(confidential)]` in +These members, among others, are marked `#[tinybus(confidential)]` in `crates/tinycomputer/src/tinybus_module/dispatch/mod.rs`: - `ResolveIntent` @@ -205,10 +205,11 @@ Six members are marked `#[tinybus(confidential)]` in - `StartTask` - `ContinueTask` - `TaskReport` +- `BrowserReadOutput` TinyBus treats a confidential call as sensitive host-control traffic rather than ordinary bus chatter, the same category the module's own configuration -travels in. These six are the ones marked that way because of what they can +travels in. They are marked that way because of what they can carry or reveal: - `ResolveIntent`, `RunGoal`, and `RunFlow` are the members that actually @@ -223,6 +224,10 @@ carry or reveal: - `TaskReport` is confidential because it is the member that hands back everything a task did, potentially including the full Jev exchange trace when the task requested one. +- `BrowserReadOutput` returns the bytes of a held screenshot, and a + screenshot can show what a task typed into a form, card details included. + `BrowserScreenshot` and `BrowserReleaseOutput` stay ordinary: they carry + only the output's id. The other two task members that touch a running task, `AwaitTask` and `CancelTask`, are not confidential: they carry or return only a task id and diff --git a/docs/technical/specs/desktop-module-contract.md b/docs/technical/specs/desktop-module-contract.md index 63681499..0ffa2e25 100644 --- a/docs/technical/specs/desktop-module-contract.md +++ b/docs/technical/specs/desktop-module-contract.md @@ -336,3 +336,13 @@ identifies a host-owned compatible route in the non-secret model summary; the module's private TinyBus model configuration still accepts only its fixed provider endpoints. Rust embedding hosts may supply arbitrary model and Jev endpoints directly. + +## Confidential output reads (3.0) + +- `BrowserReadOutput` is `#[tinybus(confidential)]`, and its catalogue entry + says so. A held output is a screenshot that can show what a task typed, + card details included; its `TaskReport` handle was already confidential, so + the bytes now are too. +- Every plain call to it fails, so this is a major bump. A host reads outputs + with a confidential call to an attested module. `BrowserScreenshot` and + `BrowserReleaseOutput` stay ordinary: they carry an id, never the bytes. diff --git a/docs/technical/tasks.md b/docs/technical/tasks.md index b46f5b07..1a21ddbc 100644 --- a/docs/technical/tasks.md +++ b/docs/technical/tasks.md @@ -70,7 +70,8 @@ The browser session stays open on the payment page so a person can finish. `CancelTask` releases it afterwards. `TaskReport` (confidential) returns every step report, the records the task read or extracted, the trace, the grounding hints it learned, and `artifacts`: the best-effort screenshot each stopped run -took before its surfaces were released, read with `BrowserReadOutput` within +took before its surfaces were released, read with the confidential +`BrowserReadOutput` within five minutes. No status carries a screenshot, since views also reach the non-confidential `AwaitTask` and `ListTasks`. From de73e0b5681493d6c731844a97b98636240d9b31 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 15:28:39 +0530 Subject: [PATCH 2/4] Treat BrowserReadOutput as confidential in clients and checks The client's mock-bus tests modelled BrowserReadOutput as public. Its untrusted fixture now refuses the read like every other confidential member. verify_module also checks that a plain BrowserReadOutput call is refused, and the browser guide's sample reads with a confidential call. --- .../tinycomputer-bus/src/browser/names/mod.rs | 3 ++- .../tests/client_tests/support/common.rs | 7 +----- .../client_tests/typed_round_trip_tests.rs | 23 +++++++------------ .../src/bin/verify_module.rs | 3 ++- docs/crates/tinycomputer-bus/browser.md | 6 +++-- 5 files changed, 17 insertions(+), 25 deletions(-) diff --git a/crates/tinycomputer-bus/src/browser/names/mod.rs b/crates/tinycomputer-bus/src/browser/names/mod.rs index fb122ebd..47c80594 100644 --- a/crates/tinycomputer-bus/src/browser/names/mod.rs +++ b/crates/tinycomputer-bus/src/browser/names/mod.rs @@ -96,7 +96,8 @@ pub mod methods { /// one). /// /// Takes a [`crate::browser::ReadOutputRequest`]; its `data` is an - /// [`crate::browser::OutputChunk`]. + /// [`crate::browser::OutputChunk`]. Requires confidential delivery: a + /// screenshot can show what a task typed, card details included. pub const READ_OUTPUT: &str = "BrowserReadOutput"; /// Releases a held output before it expires. diff --git a/crates/tinycomputer-client/tests/client_tests/support/common.rs b/crates/tinycomputer-client/tests/client_tests/support/common.rs index 6e3b5262..8b60f858 100644 --- a/crates/tinycomputer-client/tests/client_tests/support/common.rs +++ b/crates/tinycomputer-client/tests/client_tests/support/common.rs @@ -188,12 +188,6 @@ pub(crate) fn assert_catalogue_reply(member: &str, value: &Value) { 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"), } } @@ -354,6 +348,7 @@ pub(crate) fn handler_requires_confidential(member: &str) -> bool { names::accessibility::ACCESSIBILITY_FOCUS, names::accessibility::ACCESSIBILITY_VALIDATE_TARGET, names::accessibility::ACCESSIBILITY_PASTE, + tinycomputer_bus::browser::names::methods::READ_OUTPUT, ] .contains(&member) } 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 index 74d7125b..33afb941 100644 --- a/crates/tinycomputer-client/tests/client_tests/typed_round_trip_tests.rs +++ b/crates/tinycomputer-client/tests/client_tests/typed_round_trip_tests.rs @@ -41,17 +41,16 @@ async fn typed_accessibility_browser_profile_task_and_held_output_calls_round_tr .await?; assert_eq!(navigated.url, "https://example.com"); + // A held output's bytes travel only confidentially, which this + // unattested recipient cannot take. 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); + .await; + assert!(matches!(chunk, Err(ClientError::Bus(_)))); let planned = client .call_task::<_, Value>( @@ -70,7 +69,6 @@ async fn typed_accessibility_browser_profile_task_and_held_output_calls_round_tr 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 @@ -98,15 +96,10 @@ async fn typed_accessibility_browser_profile_task_and_held_output_calls_round_tr .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!( + seen.iter() + .all(|call| call.member != tinycomputer_bus::browser::names::methods::READ_OUTPUT), + "the refused read never reached the service" ); assert_eq!(RUN_GOAL_TIMEOUT, Duration::from_secs(330)); diff --git a/crates/tinycomputer-examples/src/bin/verify_module.rs b/crates/tinycomputer-examples/src/bin/verify_module.rs index 9ace7022..1c9a585e 100644 --- a/crates/tinycomputer-examples/src/bin/verify_module.rs +++ b/crates/tinycomputer-examples/src/bin/verify_module.rs @@ -393,6 +393,7 @@ async fn verify_confidential_arity( names::accessibility::ACCESSIBILITY_FOCUS, names::accessibility::ACCESSIBILITY_VALIDATE_TARGET, names::accessibility::ACCESSIBILITY_PASTE, + tinycomputer_bus::browser::names::methods::READ_OUTPUT, ] { let reply = proxy .call::(member, serde_json::json!([])) @@ -400,7 +401,7 @@ async fn verify_confidential_arity( 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(), + io::Error::other("a confidential member failed to require confidential delivery").into(), ); } } diff --git a/docs/crates/tinycomputer-bus/browser.md b/docs/crates/tinycomputer-bus/browser.md index c158c20e..e4b5e68b 100644 --- a/docs/crates/tinycomputer-bus/browser.md +++ b/docs/crates/tinycomputer-bus/browser.md @@ -285,13 +285,15 @@ let clicked: DesktopResponse = proxy let shot: DesktopResponse = proxy.call(methods::SCREENSHOT, (json!({"session": session}),)).await?; let output = shot.data.as_ref().unwrap()["id"].clone(); +// BrowserReadOutput is confidential: the bytes never travel in a plain frame. let chunk: DesktopResponse = proxy - .call(methods::READ_OUTPUT, (json!({"output": output}),)) + .call_confidential(methods::READ_OUTPUT, (json!({"output": output}),)) .await?; ``` (`crates/tinycomputer/src/tinybus_module/tinybus_module_tests/browser_tests.rs` runs this same -sequence against the in-memory bus.) +sequence against the in-memory bus. That module is never attested, so it reads +the output from the service directly, and checks that a plain call is refused.) ## Where the browser fits into flows and tasks From 3a8c13dca256071be3be6597cdd486f174301295 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 16:12:58 +0530 Subject: [PATCH 3/4] Format the confidential read-output changes --- crates/tinycomputer-examples/src/bin/verify_module.rs | 7 ++++--- .../tinybus_module/tinybus_module_tests/browser_tests.rs | 5 ++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/tinycomputer-examples/src/bin/verify_module.rs b/crates/tinycomputer-examples/src/bin/verify_module.rs index 1c9a585e..1105235f 100644 --- a/crates/tinycomputer-examples/src/bin/verify_module.rs +++ b/crates/tinycomputer-examples/src/bin/verify_module.rs @@ -400,9 +400,10 @@ async fn verify_confidential_arity( .await; if !matches!(reply, Err(tinybus::Error::MethodFailed { name, .. }) if name == tinybus::Error::CONFIDENTIALITY_REQUIRED) { - return Err( - io::Error::other("a confidential member failed to require confidential delivery").into(), - ); + return Err(io::Error::other( + "a confidential member failed to require confidential delivery", + ) + .into()); } } Ok(()) diff --git a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/browser_tests.rs b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/browser_tests.rs index 9d7023e5..4da31cfb 100644 --- a/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/browser_tests.rs +++ b/crates/tinycomputer/src/tinybus_module/tinybus_module_tests/browser_tests.rs @@ -132,7 +132,10 @@ async fn call(proxy: &Proxy, member: &str, request: Value) -> tinybus::Result tinybus::Result { let member = methods::READ_OUTPUT.try_into()?; assert!(service.requires_confidential(&member)); - Ok(serde_json::from_value(service.call(&member, json!([request])).await?).expect("an envelope")) + Ok( + serde_json::from_value(service.call(&member, json!([request])).await?) + .expect("an envelope"), + ) } fn data(reply: &DesktopResponse) -> &Value { From 409a81a186ff6b3f4196c44c56134c8da565d6dc Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 17:30:09 +0530 Subject: [PATCH 4/4] Say what a screenshot reply still shows in plain frames BrowserScreenshot's OutputRef carries the output's size, SHA-256 digest, media type and dimensions as well as its id. Only the bytes moved behind the confidential read. --- docs/crates/tinycomputer/members.md | 5 +++-- docs/technical/specs/desktop-module-contract.md | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/crates/tinycomputer/members.md b/docs/crates/tinycomputer/members.md index 4bf6a1f8..45f4eb49 100644 --- a/docs/crates/tinycomputer/members.md +++ b/docs/crates/tinycomputer/members.md @@ -226,8 +226,9 @@ carry or reveal: when the task requested one. - `BrowserReadOutput` returns the bytes of a held screenshot, and a screenshot can show what a task typed into a form, card details included. - `BrowserScreenshot` and `BrowserReleaseOutput` stay ordinary: they carry - only the output's id. + `BrowserScreenshot` and `BrowserReleaseOutput` stay ordinary: they never + carry the bytes, though a screenshot reply's `OutputRef` does show the + output's id, size, SHA-256 digest, media type, and pixel dimensions. The other two task members that touch a running task, `AwaitTask` and `CancelTask`, are not confidential: they carry or return only a task id and diff --git a/docs/technical/specs/desktop-module-contract.md b/docs/technical/specs/desktop-module-contract.md index 0ffa2e25..5b258dab 100644 --- a/docs/technical/specs/desktop-module-contract.md +++ b/docs/technical/specs/desktop-module-contract.md @@ -345,4 +345,6 @@ endpoints directly. the bytes now are too. - Every plain call to it fails, so this is a major bump. A host reads outputs with a confidential call to an attested module. `BrowserScreenshot` and - `BrowserReleaseOutput` stay ordinary: they carry an id, never the bytes. + `BrowserReleaseOutput` stay ordinary: they never carry the bytes. A + screenshot reply's `OutputRef` does expose the output's id, size, SHA-256 + digest, media type, and pixel dimensions in ordinary traffic.