1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright (c) 2026 2youg1 and the sprawling contributors
//! The `browser` tool: every action over one session.
//!
//! It lives in the assembly layer rather than in `browser` because a
//! screenshot has to land in `storage::cas`, and the browser crate has no
//! dependency on memory and should not grow one. What crosses between
//! them is a `Shot` — bytes and two integer sides — so the decisions
//! stay in the pure crate and the bytes stay here.
use std::sync::Mutex;
use browser::{
BrowserPort, ContextId, DevLoop, OwnListeners, PageSnapshot, ResolvedOrigin, SHOT_MAX_EDGE_PX,
Session, SessionRequest, Shot, Verb,
};
use kernel::{
AxCode, AxError, CostTier, Effect, GateSubject, Payload, RenderIntent, Temporal, Tool,
ToolCall, ToolMeta, ToolName, ToolOutcome,
};
use serde_json::Value;
use storage::Cas;
mod answering;
mod building;
mod guarding;
mod person;
mod storing;
mod surveying;
use building::BUILDING_DISCLOSURE;
pub(crate) use building::for_rules;
pub(crate) use guarding::{Served, serve};
use person::PERSON_DISCLOSURE;
pub(crate) use person::Role;
/// The tool a building whose rules say `browser = true` gets.
pub(crate) struct BrowserTool {
meta: ToolMeta,
/// One tab, driven by one call at a time: two calls interleaving
/// their frames would each act on a page the other changed.
browser: Mutex<Browser>,
}
/// What driving the tab holds between calls.
struct Browser {
port: Box<dyn BrowserPort + Send>,
session: Session,
/// The tab this tool drives, opened on the first call rather than at
/// construction: a building that admits the tool and never uses it
/// should not start a browser.
context: Option<ContextId>,
/// The page as it was last looked at. A reference is a position in
/// this, which is why acting without it is refused rather than
/// guessed.
snapshot: Option<PageSnapshot>,
generation: u64,
cas: Cas,
/// The run and building a screenshot is pinned for.
origin: storage::BlockOrigin,
/// The development loop. Every look folds into it, so "change
/// something, look at it, decide" is one object rather than a habit
/// the model has to remember.
devloop: DevLoop,
/// Whether the last console read found an error. Carried because a
/// look and a complaint arrive in two different calls.
complained: bool,
}
impl BrowserTool {
/// # Errors
/// Refuses a name this build cannot spell, which the literals below
/// cannot produce, and a parameter schema that does not build.
pub(crate) fn new(
role: Role,
port: Box<dyn BrowserPort + Send>,
cas: Cas,
origin: storage::BlockOrigin,
) -> Result<BrowserTool, AxError> {
let (name, disclosure, params, effect) = match role {
Role::Building => (
ToolName::BROWSER,
BUILDING_DISCLOSURE,
Payload::empty(),
// Every page a browser opens leaves this machine, so the
// egress door decides it - which is also what keeps a
// confidential building from ever holding one.
Effect::Egress,
),
Role::PersonAt { host } => (
ToolName::USER_BROWSER,
PERSON_DISCLOSURE,
person::user_browser_params()?,
Effect::AttachUserBrowser {
address: Some(host),
},
),
Role::PersonWaiting => (
ToolName::USER_BROWSER,
PERSON_DISCLOSURE,
person::user_browser_params()?,
Effect::AttachUserBrowser { address: None },
),
};
Ok(BrowserTool {
meta: ToolMeta {
name: ToolName::parse(name)?,
disclosure: disclosure.to_owned(),
params,
effect,
cost_tier: CostTier::Light,
timeout: None,
render: RenderIntent::Generic,
temporal: Temporal::Timestamped,
},
browser: Mutex::new(Browser {
port,
session: Session::new(),
context: None,
snapshot: None,
generation: 0,
cas,
origin,
devloop: DevLoop::new(),
complained: false,
}),
})
}
}
impl Tool for BrowserTool {
fn meta(&self) -> &ToolMeta {
&self.meta
}
/// The host this call names, read by the same grammar `invoke`
/// reads, so the bench and the tool cannot disagree about which host
/// a call names.
///
/// # Errors
/// Refuses arguments this tool cannot read, which is what `invoke`
/// would refuse them with.
fn subject(&self, call: &ToolCall) -> Result<GateSubject, AxError> {
match Verb::read(&call.args)?.destination()? {
Some(host) => Ok(GateSubject::Host(host)),
None => Ok(GateSubject::None),
}
}
fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
if call.name != self.meta.name {
return Err(AxError::failure(
AxCode::InvalidArgs,
"drive a browser",
call.name.to_string(),
)
.with_recovery(format!(
"this tool answers to `{}` and routes nothing else",
self.meta.name
)));
}
self.browser
.lock()
.map_err(|_| {
AxError::failure(
AxCode::ToolUnavailable,
"drive a browser",
"an earlier call left the tab locked when its thread died",
)
.with_recovery("dispatch the work again; the next run opens a fresh tab")
})?
.drive(call)
}
}
impl Browser {
/// One call against the tab, after `invoke` has checked the name.
fn drive(&mut self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
let verb = Verb::read(&call.args)?;
// Neither browser acts on the city's own pages (sprawling D74):
// the address an action names, the page it would act on, and the
// page an `open` lands on are each checked.
let own = guarding::own();
if let Some(address) = verb.address() {
own.refuse("drive a browser", address)?;
}
let context = self.tab(&own)?;
let opens = matches!(verb, Verb::Open { .. });
if !opens {
self.refuse_own_page(&own, &context)?;
}
let frames = verb.frames(&mut self.session, &context, self.snapshot.as_ref())?;
let mut last = Value::Null;
for frame in &frames {
last = self.port.send(frame)?.into_result()?;
}
if opens {
self.refuse_own_page(&own, &context)?;
}
// The frames that produced `last`, kept for the one case that has to
// ask the same thing twice: a picture past the cap.
let mut sent = frames;
// A drag from a reference is two frames: the page names the
// element, and the input frame then acts on it. The vocabulary
// is `desktop.act`'s — a ref or a point, to a point — so the
// same gesture has one description on both sides.
if let Verb::Act { action, .. } = &verb
&& action.resolves_element()
{
let origin = ResolvedOrigin::Element(browser::shared_id_of(&last)?);
let frame = verb.input_frame(&mut self.session, &context, Some(origin))?;
last = self.port.send(&frame)?.into_result()?;
}
// A shot by reference is the same two frames: the page names the
// element, and the capture then covers it. The handle is read out
// of the reply by the request itself, so nothing else can crop to
// an element the page never named.
if let Verb::Screenshot(request) = &verb
&& request.waits_for_page()
{
let frame = request.capture_frame(&mut self.session, &context, &last)?;
last = self.port.send(&frame)?.into_result()?;
sent.push(frame);
}
// A picture past the cap is asked for once more: the density that
// fits it is set, and then the same frames are replayed, because
// the capture itself is what has to come back smaller. The cost of
// touching the page's density is paid only here, where the
// alternative is refusing the shot.
if let Verb::Screenshot(request) = &verb {
let shot = Shot::read(&last, request.format())?;
if SHOT_MAX_EDGE_PX.exceeds(shot.width(), shot.height()) {
let longest = shot.width().max(shot.height());
let refit = request.refit_frame(&mut self.session, &context, longest)?;
self.port.send(&refit)?.into_result()?;
for again in &sent {
last = self.port.send(again)?.into_result()?;
}
}
}
self.answer(&verb, &last)
}
}
impl Browser {
/// Refuses when the tab is on one of the city's own pages, read from
/// the browser's own account of where the tab is. Nothing to read
/// when no listener is registered.
///
/// # Errors
/// `E_GATE_DENIED` for the city's own page; propagates a tree the
/// browser will not give.
fn refuse_own_page(&mut self, own: &OwnListeners, context: &ContextId) -> Result<(), AxError> {
if own.is_empty() {
return Ok(());
}
let tree = self.session.tree()?;
let answered = self.port.send(&tree)?.into_result()?;
match browser::page_address(&answered, context) {
Some(address) => own.refuse("act on a browser tab", &address),
None => Ok(()),
}
}
/// The tab this tool drives, opening the session the first time: the
/// first one that is not on one of the city's own pages.
///
/// Both frames are minted by this tool's own `Session`, so the ids
/// on one socket stay unique: a second counter would send two
/// frames numbered one and take the wrong reply for each.
///
/// # Errors
/// Propagates a session the engine refuses to open, and a browser
/// with no tab in it - which is a browser that is closing, and is
/// not a page anybody can act on - or with every tab on one of the
/// city's own pages.
fn tab(&mut self, own: &OwnListeners) -> Result<ContextId, AxError> {
if let Some(open) = &self.context {
return Ok(open.clone());
}
let begin = self.session.begin(SessionRequest::default())?;
self.port.send(&begin)?.into_result()?;
let tree = self.session.tree()?;
let answered = self.port.send(&tree)?.into_result()?;
let context = Session::read_tree(&answered)?
.into_iter()
.find(|context| {
browser::page_address(&answered, context).is_none_or(|address| !own.holds(&address))
})
.ok_or_else(|| {
AxError::failure(
AxCode::BrowserUnavailable,
"open a browser tab",
"the browser has no tab open other than this city's own pages",
)
.with_recovery(
"open another tab, or start the browser again; a window with no tab is closing",
)
})?;
self.context = Some(context.clone());
Ok(context)
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::wildcard_enum_match_arm,
clippy::let_underscore_must_use,
clippy::let_underscore_untyped,
reason = "test code"
)]
mod tests;