Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/tinycomputer-bus/src/browser/names/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion crates/tinycomputer-bus/src/catalogue/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions crates/tinycomputer-bus/src/version/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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]
Expand Down
12 changes: 8 additions & 4 deletions crates/tinycomputer-bus/src/version/version_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand Down Expand Up @@ -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)));
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}
}
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>(
Expand All @@ -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
Expand Down Expand Up @@ -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));
Expand Down
8 changes: 5 additions & 3 deletions crates/tinycomputer-examples/src/bin/verify_module.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<DesktopResponse>(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(())
Expand Down
11 changes: 6 additions & 5 deletions crates/tinycomputer-examples/src/host/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand Down Expand Up @@ -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)?;
Expand Down
2 changes: 1 addition & 1 deletion crates/tinycomputer-skills/skills/tinycomputer/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion crates/tinycomputer/src/tinybus_module/dispatch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -97,10 +97,18 @@ async fn serve_with(
config: &Value,
launcher: Arc<ScriptedLauncher>,
) -> 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<Browser>) -> 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
Expand All @@ -119,6 +127,17 @@ async fn call(proxy: &Proxy, member: &str, request: Value) -> tinybus::Result<De
proxy.call(member, (request,)).await
}

/// Calls `BrowserReadOutput` on `service` directly. It is confidential, and the
/// in-memory module is never attested, so the bus would refuse it.
async fn read_output(service: &DesktopService, request: Value) -> tinybus::Result<DesktopResponse> {
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
Expand Down Expand Up @@ -166,32 +185,31 @@ 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();

let shot = call(&proxy, methods::SCREENSHOT, json!({"session": session})).await?;
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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions crates/tinycomputer/tests/public_api_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}

Expand Down
3 changes: 2 additions & 1 deletion docs/crates/tinycomputer-bus/agent-and-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions docs/crates/tinycomputer-bus/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading