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-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-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..1105235f 100644 --- a/crates/tinycomputer-examples/src/bin/verify_module.rs +++ b/crates/tinycomputer-examples/src/bin/verify_module.rs @@ -393,15 +393,17 @@ 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!([])) .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(), - ); + return Err(io::Error::other( + "a confidential member failed to require confidential delivery", + ) + .into()); } } Ok(()) 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..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 @@ -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,17 @@ 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 +185,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 +199,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/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 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..45f4eb49 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,11 @@ 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 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 63681499..5b258dab 100644 --- a/docs/technical/specs/desktop-module-contract.md +++ b/docs/technical/specs/desktop-module-contract.md @@ -336,3 +336,15 @@ 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 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. 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`.