Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
861b071
Check allowed origins on pages, not on every request
YellowSnnowmann Oct 9, 2026
161b69b
Let a task name its browser and profile; accept * among origins
YellowSnnowmann Oct 9, 2026
8ad6049
Read calendars drawn in rows of weeks as dates
YellowSnnowmann Oct 9, 2026
fa22ecc
Stop paging a calendar once its heading names the month
YellowSnnowmann Oct 9, 2026
1438fe0
Offer the slot opener that names the slot soonest and says least
YellowSnnowmann Oct 9, 2026
9edc70d
Read allowed origins by the browser's URL rules
YellowSnnowmann Oct 9, 2026
7562102
Check the page around every browser call; report a late refusal apart
YellowSnnowmann Oct 9, 2026
7b9162b
Keep a task's browser binary and profile the host's to set
YellowSnnowmann Oct 9, 2026
dbbebfd
Grant location in the profile a task keeps between runs
YellowSnnowmann Oct 9, 2026
02938ea
Date a shared header's months only when each finds its grid
YellowSnnowmann Oct 9, 2026
9ec1c89
Stop paging at a heading only for undated days, beside the arrow
YellowSnnowmann Oct 9, 2026
c8b39c6
Rank slot openers by the slot's own words, not label length
YellowSnnowmann Oct 9, 2026
52d1a28
Check reads before they run; never open a session on a refused page
YellowSnnowmann Oct 9, 2026
7fad1f6
Count a calendar's bare days only near the arrow that pages it
YellowSnnowmann Oct 9, 2026
91e8274
Offer no model a task's browser binary or profile
YellowSnnowmann Oct 9, 2026
e747749
Fail a page check closed unless the page is mid-navigation
YellowSnnowmann Oct 9, 2026
d3789d1
Refuse a task browser binary that would not run
YellowSnnowmann Oct 9, 2026
91a2b5d
Date priced calendar days by their inner labels
YellowSnnowmann Oct 9, 2026
7457762
Press the empty layer a popup leaves over a press, and name covers
YellowSnnowmann Oct 9, 2026
5c258f6
Say what to set when a task's browser cannot start
YellowSnnowmann Oct 9, 2026
f7d4cba
Check a raw box lookup and a permission grant against the origins
YellowSnnowmann Oct 9, 2026
b06b7bc
Tell rescues what a cover means and planners what a cart count shows
YellowSnnowmann Oct 9, 2026
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ getrandom = "0.4"
# versions agent-browser takes, so the lockfile holds one copy of each.
base64 = "0.22"
sha2 = "0.10"
# A browser session reads a page's address by the WHATWG URL rules Chrome
# reads it by, so an address written another way is judged as the page it
# opens. The version agent-browser takes, so the lockfile holds one copy.
url = "2"
# Module integration tests exercise the real asynchronous in-memory TinyBus.
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }

Expand Down
3 changes: 3 additions & 0 deletions crates/tinycomputer-browser/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ tinycomputer-core = { workspace = true }
# The agent's cursor: where it aims and the human path it glides along,
# drawn in the page before each action. Cosmetic; it sends no input.
tinycomputer-cursor = { workspace = true }
# Addresses checked against a session's allowed origins are read by the
# WHATWG URL rules a browser reads them by (`origins/`).
url = { workspace = true }
# Per-session locks held across engine calls; `rt` for the runtime handle a
# blocking surface call runs the async browser on.
tokio = { workspace = true, features = ["sync", "rt"] }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
//! Tests for the launch a session starts with, and its allowed domains.
//! Tests for the launch a session starts with.

use serde_json::json;
use tinycomputer_bus::browser::{SessionOptions, Viewport};

use crate::convert::session::allowed_domains;
use crate::convert::{launch, viewport};

#[test]
Expand Down Expand Up @@ -39,27 +38,11 @@ fn every_session_option_reaches_its_launch_field() {
"userAgent": "agent",
"profile": "/profiles/trip",
"downloadPath": "/downloads",
"allowedDomains": ["example.com"],
})
}),
"the allowed origins stay with the session, which checks pages itself"
Comment thread
YellowSnnowmann marked this conversation as resolved.
);
assert_eq!(
viewport(&options),
json!({"action": "viewport", "width": 800, "height": 600, "deviceScaleFactor": 1.0, "mobile": false})
);
}

#[test]
fn origins_become_host_patterns_and_subdomain_wildcards() {
let origins = [
"https://Example.com",
"https://.booking.com",
"http://localhost:8080/path",
"plain.host",
"https://",
]
.map(str::to_owned);
assert_eq!(
allowed_domains(&origins),
["example.com", "*.booking.com", "localhost", "plain.host"]
);
}
40 changes: 6 additions & 34 deletions crates/tinycomputer-browser/src/convert/session.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
//! Starting a session: the explicit launch, the viewport, and the allowed domains.
//! Starting a session: the explicit launch and the viewport.
//!
//! The allowed origins are not handed to the engine: agent-browser would
//! refuse every request outside them, a page's own files and APIs included,
//! and refuse a profile beside them. The session checks pages itself
//! (`crate::origins`).

use serde_json::{Value, json};
use tinycomputer_bus::browser::SessionOptions;
Expand Down Expand Up @@ -27,10 +32,6 @@ pub(crate) fn launch(options: &SessionOptions) -> Value {
command[key] = json!(value);
}
}
let domains = allowed_domains(&options.allowed_origins);
if !domains.is_empty() {
command["allowedDomains"] = json!(domains);
}
command
}

Expand All @@ -45,32 +46,3 @@ pub(crate) fn viewport(options: &SessionOptions) -> Value {
"mobile": options.viewport.mobile,
})
}

/// Origins as agent-browser domain patterns.
///
/// `https://example.com` admits that host; `https://.example.com` admits it
/// and its subdomains, which agent-browser spells `*.example.com`. The
/// engine filters by host, so the scheme is not enforced — the origin
/// allow-list was a guard rail, never a sandbox, and still is.
#[must_use]
pub(crate) fn allowed_domains(origins: &[String]) -> Vec<String> {
origins
.iter()
.filter_map(|origin| {
let host = origin
.split_once("://")
.map_or(origin.as_str(), |(_, rest)| rest)
.split(['/', ':'])
.next()?
.trim()
.to_ascii_lowercase();
if host.is_empty() {
None
} else if let Some(domain) = host.strip_prefix('.') {
Some(format!("*.{domain}"))
} else {
Some(host)
}
})
.collect()
}
22 changes: 21 additions & 1 deletion crates/tinycomputer-browser/src/error/error_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ fn every_variant() -> Vec<Error> {
Error::BlockedByPolicy {
url: "https://evil.test/".to_string(),
},
Error::LeftRefusedPage {
url: "https://evil.test/".to_string(),
},
Error::browser_unavailable("no chrome on this host"),
Error::page("ReferenceError: x is not defined"),
Error::NoSuchOutput {
Expand Down Expand Up @@ -179,13 +182,30 @@ fn a_refused_navigation_says_not_to_retry() {
.suggestion
.is_some_and(|s| s.starts_with("do not retry"))
);
// The domain filter refuses before any navigation reaches the page.
// The allowed origins refuse before any navigation reaches the page.
assert_eq!(
envelope.disposition.delivery,
DeliveryDisposition::NotDelivered
);
}

#[test]
fn a_page_refused_after_the_call_ran_is_the_same_refusal_but_may_have_taken_effect() {
// A click on a listed site's "Pay" that lands on an unlisted bank page
// paid: a host that retried "nothing was delivered" would pay twice.
let envelope = Error::LeftRefusedPage {
url: "https://bank.test/3ds".to_owned(),
}
.envelope();
assert_eq!(envelope.code, "POLICY_DENIED");
assert_eq!(envelope.disposition.delivery, DeliveryDisposition::Unknown);
assert!(
envelope
.suggestion
.is_some_and(|s| s.contains("check what it did"))
);
}

#[test]
fn only_failures_decided_before_the_page_claim_nothing_was_delivered() {
let before_the_page = |error: &Error| {
Expand Down
58 changes: 51 additions & 7 deletions crates/tinycomputer-browser/src/error/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,18 @@ pub enum Error {
url: String,
},

/// A call's work took the session to a page its origin allowlist does
/// not admit (a click, a key, a script, a redirect), and the session
/// left it. Unlike [`Error::BlockedByPolicy`], the call ran: its effect
/// may stand.
#[error(
"the page reached, {url}, is not permitted by this session's allowed origins; the session left it"
)]
LeftRefusedPage {
/// The page that was refused, then left.
url: String,
},

/// No browser could be launched or reached.
#[error("browser unavailable: {message}")]
BrowserUnavailable {
Expand Down Expand Up @@ -146,7 +158,9 @@ impl Error {
Self::StaleRef { .. } => errors::STALE_REF,
Self::NotActionable { .. } => errors::NOT_ACTIONABLE,
Self::Timeout { .. } => errors::TIMEOUT,
Self::BlockedByPolicy { .. } => errors::BLOCKED_BY_POLICY,
Self::BlockedByPolicy { .. } | Self::LeftRefusedPage { .. } => {
errors::BLOCKED_BY_POLICY
}
Self::BrowserUnavailable { .. } => errors::BROWSER_UNAVAILABLE,
Self::PageError { .. } => errors::PAGE_ERROR,
Self::NoSuchOutput { .. } => errors::NO_SUCH_OUTPUT,
Expand All @@ -163,9 +177,9 @@ impl Error {
/// `details.name` for a host that matches on it. Only a failure decided
/// before anything reaches the page is marked not delivered, so a caller
/// knows retrying it cannot repeat an effect: an unknown session or
/// output (local lookups), an unresolvable ref, or a refused origin
/// (rejected inside agent-browser before any input is sent). Every other
/// failure's delivery stays unknown.
/// output (local lookups), an unresolvable ref, or a page the allowed
/// origins refused before the call was sent. Every other failure's
/// delivery stays unknown, a page refused after the call ran among them.
///
/// # Examples
///
Expand Down Expand Up @@ -207,6 +221,9 @@ impl Error {
Self::BlockedByPolicy { .. } => {
Some("do not retry; the session's allowed origins refuse this destination")
}
Self::LeftRefusedPage { .. } => Some(
"the call ran before its page was refused and left; check what it did before repeating it",
),
Self::NoSuchOutput { .. } => {
Some("capture the screenshot again; held outputs expire after five minutes")
}
Expand All @@ -224,11 +241,13 @@ impl Error {
/// Whether this failure is always decided before anything reaches the
/// page, so repeating the call cannot repeat an effect: a session or an
/// output looked up locally and not found, a ref agent-browser could not
/// resolve, and a destination its domain filter refused — both of those
/// are rejected inside agent-browser before any input is sent to the page.
/// resolve inside itself before any input is sent to the page, and a
/// destination or page the session's allowed origins refused before the
/// call was sent.
///
/// Invalid input and a limit can also come back after work was done — a
/// capture that turned out too large — so their delivery stays unknown.
/// capture that turned out too large — so their delivery stays unknown,
/// as does a page refused after the call ran ([`Error::LeftRefusedPage`]).
fn refused_before_delivery(&self) -> bool {
matches!(
self,
Expand Down Expand Up @@ -271,6 +290,31 @@ impl Error {
}
}

/// This failure as a browser this module launched reports it, said so a
/// person can act on it: one that was not found or would not start says
/// what to set. `named` is whether the launch was given a browser binary
/// to run. Any other failure stays as it is.
#[must_use]
pub(crate) fn launching(self, named: bool) -> Self {
let Self::BrowserUnavailable { message } = self else {
return self;
};
let reason = message.lines().next().unwrap_or_default().trim();
let message = if named {
format!(
"the browser binary given could not be started ({reason}); check that its path names Chrome or Chromium"
)
} else if reason.to_lowercase().contains("not found") {
"no Chrome or Chromium was found on this machine; give the path of the browser to use"
.to_owned()
} else {
format!(
"the browser could not be started ({reason}); give the path of Chrome or Chromium if it is installed elsewhere"
)
};
Self::BrowserUnavailable { message }
}

/// Builds an [`Error::ConnectionLost`].
#[must_use]
pub fn connection_lost(message: impl Into<String>) -> Self {
Expand Down
6 changes: 4 additions & 2 deletions crates/tinycomputer-browser/src/fake/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,16 @@ impl Fake {
}

/// Whether a page script ran besides those every press runs: the one
/// that keeps a press in the tab, and the one that brings a control
/// sight found into the window.
/// that keeps a press in the tab, the one that brings a control sight
/// found into the window, and the one that names what covers a press
/// the page refused.
pub(crate) fn evaluated_besides_every_press(&self) -> bool {
self.sent().iter().any(|command| {
let script = command["script"].as_str().unwrap_or_default();
command["action"] == "evaluate"
&& !script.contains("__tcOpen")
&& !script.starts_with(crate::surface::INTO_VIEW_JS)
&& !script.starts_with(crate::surface::COVER_JS)
})
}

Expand Down
1 change: 1 addition & 0 deletions crates/tinycomputer-browser/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ mod error;
mod fake;
#[cfg(feature = "agent-browser")]
mod linked;
mod origins;
Comment thread
YellowSnnowmann marked this conversation as resolved.
mod outputs;
mod reply;
mod sessions;
Expand Down
Loading
Loading