Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/src/wasm/web.rs

20.2 KiB, 1 run

created by r2519314175:999, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The Web panel edge — thin bindings to the JS driver `window.DaimondWeb`.
2//!
3//! `DaimondWeb` is the one interface the agent's web tools call, and it
4//! hides which driver is attached (none, an iframe, or the Daimond Hands
5//! extension), so the tools do not change when the extension appears.
6//! Every method returns a `Promise`; each binding below awaits it and
7//! hands back the resolved JSON, stringified.
8//!
9//! A rejection carries a plain-English `Error` the model is meant to read
10//! and act on ("No page is open. Call web_open first."), so its `message`
11//! is passed through **verbatim**: no prefix, no rewording. Mangling it
12//! would destroy the only instruction the model gets about what to do
13//! next.
14
15use crate::llm::json_escape;
16use crate::tools::SearchAnswer;
17use crate::tools::SearchHit;
18use crate::tools::Verdict;
19use crate::wasm::js_str;
20
21use oxedyne_fe2o3_core::prelude::*;
22
23use wasm_bindgen::prelude::wasm_bindgen;
24use wasm_bindgen::{JsCast, JsValue};
25use wasm_bindgen_futures::JsFuture;
26
27
28#[wasm_bindgen]
29extern "C" {
30
31 /// The driver object the Web panel installs at `window.DaimondWeb`.
32 #[wasm_bindgen(js_name = DaimondWeb)]
33 type Driver;
34
35 /// Which driver is attached, what is open, and who is driving.
36 #[wasm_bindgen(method)]
37 fn status(this: &Driver) -> js_sys::Promise;
38
39 /// Dock the panel and navigate it to `url`.
40 #[wasm_bindgen(method)]
41 fn open(this: &Driver, url: &str) -> js_sys::Promise;
42
43 /// Read `url` through the gateway, without a driver.
44 #[wasm_bindgen(method)]
45 fn fetch(this: &Driver, url: &str) -> js_sys::Promise;
46
47 /// The accessibility tree of the open page.
48 #[wasm_bindgen(method)]
49 fn snapshot(this: &Driver) -> js_sys::Promise;
50
51 /// The rendered text of the open page.
52 #[wasm_bindgen(method)]
53 fn read(this: &Driver) -> js_sys::Promise;
54
55 /// Click the node named by `node_ref`.
56 #[wasm_bindgen(method)]
57 fn click(this: &Driver, node_ref: u32) -> js_sys::Promise;
58
59 /// Type into the node named by `node_ref` (`type` is a Rust keyword).
60 #[wasm_bindgen(method, js_name = "type")]
61 fn type_into(this: &Driver, node_ref: u32, text: &str, submit: bool) -> js_sys::Promise;
62
63 /// Scroll the open page; `amount` may be `undefined` for the default.
64 #[wasm_bindgen(method)]
65 fn scroll(this: &Driver, dir: &str, amount: JsValue) -> js_sys::Promise;
66
67 /// Undock the panel and drop the page.
68 #[wasm_bindgen(method)]
69 fn close(this: &Driver) -> js_sys::Promise;
70}
71
72
73#[wasm_bindgen]
74extern "C" {
75
76 /// The search half the app installs at `window.DaimondSearch`.
77 ///
78 /// A global of its own rather than a `DaimondWeb` method, because it holds something the
79 /// Web panel's driver knows nothing about: WHICH ENGINE the user chose, and the key that
80 /// pays for it. Keeping that on the driver would put the user's setting behind the thing
81 /// that owns pages, and searching needs no page at all.
82 #[wasm_bindgen(js_name = DaimondSearch)]
83 type Searcher;
84
85 /// Run one query, with the engine and any key the user's settings supply.
86 ///
87 /// Renamed on the Rust side only because [`search`] below is the function callers use; the
88 /// JavaScript method is `search`, exactly as the contract names it.
89 #[wasm_bindgen(method, js_name = "search")]
90 fn run_query(this: &Searcher, query: &str, opts: &JsValue) -> js_sys::Promise;
91}
92
93
94/// Reach the driver object on `window`, or refuse in the model's language.
95fn driver() -> Outcome<Driver> {
96 let win = res!(web_sys::window()
97 .ok_or_else(|| err!("The web tools need a browser window."; System, Missing)));
98 let obj = res!(js_sys::Reflect::get(&win, &JsValue::from_str("DaimondWeb"))
99 .map_err(|e| err!("Reading window.DaimondWeb failed: {}.", js_str(&e); System, Missing)));
100 if obj.is_undefined() || obj.is_null() {
101 return Err(err!(
102 "The Web panel is not loaded in this page, so there is nothing to drive. \
103 Tell the user, and carry on without the web tools.";
104 System, Missing));
105 }
106 Ok(obj.unchecked_into::<Driver>())
107}
108
109/// The `message` of a rejected JS `Error`, verbatim, falling back to the
110/// value's own rendering when it is not an `Error`.
111fn refusal(e: &JsValue) -> String {
112 match js_sys::Reflect::get(e, &JsValue::from_str("message")) {
113 Ok(m) => m.as_string().unwrap_or_else(|| js_str(e)),
114 Err(_) => js_str(e),
115 }
116}
117
118/// Render a resolved JS value as the JSON string the tool result carries.
119fn stringify(v: &JsValue) -> Outcome<String> {
120 if v.is_undefined() || v.is_null() {
121 return Ok("{}".to_string());
122 }
123 if let Some(s) = v.as_string() {
124 return Ok(s); // the driver resolved with JSON already
125 }
126 match js_sys::JSON::stringify(v) {
127 Ok(s) => Ok(String::from(s)),
128 Err(e) => Err(err!(
129 "The Web panel returned a result that cannot be read: {}.", refusal(&e);
130 Invalid, Data)),
131 }
132}
133
134/// Await a driver promise, passing a refusal through untouched.
135async fn settle(promise: js_sys::Promise) -> Outcome<String> {
136 match JsFuture::from(promise).await {
137 Ok(v) => stringify(&v),
138 Err(e) => Err(err!("{}", refusal(&e); Network, Invalid)),
139 }
140}
141
142/// Which driver is attached, what is open, and who is driving.
143pub async fn status() -> Outcome<String> {
144 let d = res!(driver());
145 settle(d.status()).await
146}
147
148/// The address of the page currently open, or empty when nothing says.
149///
150/// Best effort: the driver reports its state as JSON, and the one field wanted here is the URL.
151/// An action on a page has no address of its own, so this is what names the destination.
152pub async fn current_url() -> String {
153 let st = match status().await {
154 Ok(s) => s,
155 Err(_) => return String::new(),
156 };
157 let key = "\"url\":\"";
158 let from = match st.find(key) {
159 Some(i) => i + key.len(),
160 None => return String::new(),
161 };
162 match st[from..].find('"') {
163 Some(end) => st[from..from + end].to_string(),
164 None => String::new(),
165 }
166}
167
168/// Show `url` in the Web panel.
169pub async fn open(url: &str) -> Outcome<String> {
170 let d = res!(driver());
171 settle(d.open(url)).await
172}
173
174/// Read `url` through the gateway, driver or no driver.
175pub async fn fetch(url: &str) -> Outcome<String> {
176 let d = res!(driver());
177 settle(d.fetch(url)).await
178}
179
180// ── Searching ───────────────────────────────────────────────────────
181
182/// Reach the search object on `window`, or refuse in the model's language.
183///
184/// Separate from [`driver`] because the two can be absent independently: a build with a Web
185/// panel and no search settings is a real state, and telling the model the Web panel is missing
186/// would send it looking in the wrong place.
187fn searcher() -> Outcome<Searcher> {
188 let win = res!(web_sys::window()
189 .ok_or_else(|| err!("Searching needs a browser window."; System, Missing)));
190 let obj = res!(js_sys::Reflect::get(&win, &JsValue::from_str("DaimondSearch"))
191 .map_err(|e| err!("Reading window.DaimondSearch failed: {}.", js_str(&e);
192 System, Missing)));
193 if obj.is_undefined() || obj.is_null() {
194 return Err(err!(
195 "Search is not set up in this page, so there is nothing to search with. \
196 Tell the user, and carry on without it.";
197 System, Missing));
198 }
199 Ok(obj.unchecked_into::<Searcher>())
200}
201
202/// Put one option on the object handed to the search driver.
203///
204/// # Arguments
205/// * `obj` - The options object being built.
206/// * `key` - The option's name.
207/// * `val` - Its value.
208fn set(obj: &js_sys::Object, key: &str, val: JsValue) -> Outcome<()> {
209 match js_sys::Reflect::set(obj, &JsValue::from_str(key), &val) {
210 Ok(_) => Ok(()),
211 Err(e) => Err(err!("Building the search request failed at '{}': {}.", key, js_str(&e);
212 System, Invalid)),
213 }
214}
215
216/// A string property of a JavaScript object, empty where it is absent or is not a string.
217fn prop(v: &JsValue, key: &str) -> String {
218 match js_sys::Reflect::get(v, &JsValue::from_str(key)) {
219 Ok(x) => x.as_string().unwrap_or_default(),
220 Err(_) => String::new(),
221 }
222}
223
224/// Read the driver's reply into the common shape.
225///
226/// A result missing a title or a url is DROPPED rather than passed on empty, per the contract: a
227/// row with no address is nothing the model can follow up, and a row with no title reads as a
228/// blank line in the middle of the list. One bad row never fails the whole answer.
229///
230/// # Arguments
231/// * `v` - What the promise resolved with: the result object, or the JSON text of one.
232/// * `asked` - The query as it was sent.
233fn answer(v: &JsValue, asked: &str) -> Outcome<SearchAnswer> {
234 // A driver that resolves with JSON TEXT rather than an object is read anyway: the Web panel's
235 // driver already resolves both ways (see `stringify`), and a working search reported as a
236 // broken one because of which of the two it picked would be a poor trade for strictness.
237 let obj = match v.as_string() {
238 Some(s) => match js_sys::JSON::parse(&s) {
239 Ok(o) => o,
240 Err(_) => JsValue::NULL,
241 },
242 None => v.clone(),
243 };
244 let list = match js_sys::Reflect::get(&obj, &JsValue::from_str("results")) {
245 Ok(l) => l,
246 Err(_) => JsValue::UNDEFINED,
247 };
248 // No list at all is a MALFORMED reply, and it is not the same thing as an empty one. Reporting
249 // it as "no results" would tell the model the web has nothing on the subject, which is a lie
250 // about a broken reply and one it cannot see through.
251 if !js_sys::Array::is_array(&list) {
252 return Err(err!(
253 "The search service answered without a result list, so nothing can be read from it.";
254 Invalid, Data));
255 }
256 let mut out = SearchAnswer {
257 engine: prop(&obj, "engine"),
258 // OURS, not the reply's echo of it: see the doc comment on `search`.
259 query: asked.to_string(),
260 results: Vec::new(),
261 };
262 for item in js_sys::Array::from(&list).iter() {
263 let title = prop(&item, "title");
264 let url = prop(&item, "url");
265 if title.trim().is_empty() || url.trim().is_empty() {
266 continue;
267 }
268 out.results.push(SearchHit {
269 title,
270 url,
271 snippet: prop(&item, "snippet"),
272 age: prop(&item, "age"),
273 });
274 }
275 Ok(out)
276}
277
278/// Search the web through the engine the USER chose.
279///
280/// The engine, and any key of the user's own that pays for it, are supplied by the JavaScript
281/// half and are deliberately not arguments: the choice is a setting, not the model's.
282///
283/// **The query in the answer is the one that was ASKED, not the one the reply echoed.** The
284/// origin line of the untrusted envelope is a record of what the model went looking for, and
285/// nothing arriving from outside is entitled to rewrite that record.
286///
287/// # Arguments
288/// * `query` - What to search for.
289/// * `kind` - `web`, `news` or `academic`, already checked by the tool.
290/// * `limit` - How many results to ask for, left to the engine when unset.
291pub async fn search(query: &str, kind: &str, limit: Option<u32>) -> Outcome<SearchAnswer> {
292 let s = res!(searcher());
293 let opts = js_sys::Object::new();
294 res!(set(&opts, "kind", JsValue::from_str(kind)));
295 if let Some(n) = limit {
296 res!(set(&opts, "limit", JsValue::from_f64(n as f64)));
297 }
298 let v = match JsFuture::from(s.run_query(query, opts.as_ref())).await {
299 Ok(v) => v,
300 Err(e) => return Err(err!("{}", refusal(&e); Network, Invalid)),
301 };
302 answer(&v, query)
303}
304
305/// The accessibility tree of the open page, whose refs the actions take.
306pub async fn snapshot() -> Outcome<String> {
307 let d = res!(driver());
308 settle(d.snapshot()).await
309}
310
311/// The rendered text of the open page -- the way to READ its content.
312pub async fn read() -> Outcome<String> {
313 let d = res!(driver());
314 settle(d.read()).await
315}
316
317/// Click the node named by `node_ref` from the latest snapshot.
318pub async fn click(node_ref: u32) -> Outcome<String> {
319 let d = res!(driver());
320 settle(d.click(node_ref)).await
321}
322
323/// Type `text` into the node named by `node_ref`, optionally submitting.
324pub async fn type_into(node_ref: u32, text: &str, submit: bool) -> Outcome<String> {
325 let d = res!(driver());
326 settle(d.type_into(node_ref, text, submit)).await
327}
328
329/// Scroll the open page, leaving `amount` to the driver when unset.
330pub async fn scroll(dir: &str, amount: Option<u32>) -> Outcome<String> {
331 let d = res!(driver());
332 let amt = match amount {
333 Some(n) => JsValue::from_f64(n as f64),
334 None => JsValue::UNDEFINED,
335 };
336 settle(d.scroll(dir, amt)).await
337}
338
339/// Close the Web panel and drop the page.
340pub async fn close() -> Outcome<String> {
341 let d = res!(driver());
342 settle(d.close()).await
343}
344
345
346// ── The egress gate's edge ──────────────────────────────────────────
347
348/// The global the JavaScript half installs to answer whether this turn may reach a destination.
349///
350/// It is not a `DaimondWeb` method: the question is about the turn, not about the panel, and the
351/// half that answers it owns the user's standing decisions rather than the browser driver.
352const EGRESS_GLOBAL: &str = "__daimondEgressAllowed";
353
354/// Ask the JavaScript half whether this conversation may reach `url`, and how far its answer
355/// reaches.
356///
357/// The payload is a JSON string, per the contract:
358/// `{"tool":"web_fetch","url":"…","detail":"","alone":false,"granted":false}`.
359///
360/// `granted` is the whole of what this adds, and it flows one way only. The SCOPE of the answer
361/// is decided in Rust ([`crate::tools::web_step`]); the WORDING of the question lives on the page,
362/// with the dialogs and the translations. So the page is told whether the conversation has
363/// already said yes, and answers either the wide question or the one about a single overlong
364/// address -- and says which, in the word it answers with (see [`crate::tools::EgressWord`]).
365///
366/// `None` from the door is not a verdict, so it is read as a refusal spent on this one call.
367///
368/// # Arguments
369/// * `tool` - The wire name of the tool asking.
370/// * `url` - The destination it wants.
371/// * `granted` - Whether the conversation has already granted the web.
372pub async fn egress_reach(tool: &str, url: &str, granted: bool) -> crate::tools::EgressWord {
373 let answer = egress_ask_raw(tool, url, "", false, granted).await;
374 crate::tools::egress_word(answer.as_deref())
375}
376
377/// As [`egress_reach`], with a `detail` the user should see -- the text about to be typed into a
378/// page, say, which is the thing being sent and therefore the thing to look at.
379///
380/// # Arguments
381/// * `tool` - The wire name of the tool asking.
382/// * `url` - The destination it wants.
383/// * `detail` - What is being sent, when the tool sends something other than the address.
384pub async fn egress_allowed_detail(tool: &str, url: &str, detail: &str) -> Option<Verdict> {
385 egress_allowed_act(tool, url, detail, false).await
386}
387
388/// As [`egress_allowed_detail`], saying whether the actor is acting ALONE.
389///
390/// `alone` does exactly one thing at the other end, and it is not about what the dialog says: it
391/// stops the answer being remembered. The page keeps a per-host note of "acting here is
392/// approved", which is right for a person clicking through a site and wrong for a dispatched
393/// worker, where one yes about a host would license every later act on it.
394///
395/// # Arguments
396/// * `tool` - The wire name of the tool asking.
397/// * `url` - The destination it wants.
398/// * `detail` - What is being sent, when the tool sends something other than the address.
399/// * `alone` - Whether the agent asking has nobody watching it.
400pub async fn egress_allowed_act(tool: &str, url: &str, detail: &str, alone: bool)
401 -> Option<Verdict>
402{
403 egress_ask_js(tool, url, detail, alone, crate::tools::EGRESS_ALLOW_WORD).await
404}
405
406/// Ask whether a command on a turn that has read outside content may reach the network.
407///
408/// The same door, the same payload and the same dialog machinery as every other question here --
409/// what differs is the word a yes comes back as (see [`crate::tools::RUN_NET_ALLOW_WORD`]),
410/// because a command line resolves as a same-origin URL and the page's gate waves those through.
411///
412/// # Arguments
413/// * `cmd` - The command line, which is what the user is being asked about.
414/// * `cwd` - The folder it would run in.
415pub async fn egress_allowed_net(cmd: &str, cwd: &str) -> Option<Verdict> {
416 egress_ask_js(crate::tools::RUN_NET_TOOL, cmd, cwd, false,
417 crate::tools::RUN_NET_ALLOW_WORD).await
418}
419
420/// Ask whether one publication on the Social panel may go out.
421///
422/// The same door, the same payload and the same dialog machinery as every other question here.
423/// Three things differ, and each is a decision rather than a shape:
424///
425/// * the word a yes comes back as (see [`crate::tools::PUBLISH_ALLOW_WORD`]), because the forge
426/// is reached through our own gateway and the page's gate waves same-origin addresses through;
427/// * `url` carries the exact characters that would be published rather than an address, because
428/// the address is not the question -- what the user is approving is the text;
429/// * `alone` is always false, because a dispatched worker never gets here: it is refused by
430/// [`crate::tools::social_send_step`] before anything is composed. Publishing in somebody's
431/// name is not a question to be parked on a tile for whoever passes.
432///
433/// # Arguments
434/// * `shown` - Exactly what would be published, as the panel composed it.
435pub async fn egress_allowed_publish(shown: &str) -> Option<Verdict> {
436 egress_ask_js(crate::tools::SOCIAL_SEND_TOOL, shown, "", false,
437 crate::tools::PUBLISH_ALLOW_WORD).await
438}
439
440/// Put one question to the page's gate and read its answer.
441///
442/// # Arguments
443/// * `tool` - The wire name of the tool asking.
444/// * `url` - The destination it wants, or the command it wants to run.
445/// * `detail` - What is being sent, when the tool sends something other than the address.
446/// * `alone` - Whether the agent asking has nobody watching it.
447/// * `word` - The one string that means yes to this question.
448async fn egress_ask_js(tool: &str, url: &str, detail: &str, alone: bool, word: &str)
449 -> Option<Verdict>
450{
451 let answer = egress_ask_raw(tool, url, detail, alone, false).await;
452 answer.map(|s| crate::tools::verdict_of(Some(s.as_str()), word))
453}
454
455/// Put one question to the page's gate and read its answer back as the string the page resolved
456/// with, or `None` where the question could not be put at all.
457///
458/// The one place the payload is composed, so the two callers above cannot spell it differently.
459///
460/// # Arguments
461/// * `tool` - The wire name of the tool asking.
462/// * `url` - The destination it wants, or the command it wants to run.
463/// * `detail` - What is being sent, when the tool sends something other than the address.
464/// * `alone` - Whether the agent asking has nobody watching it.
465/// * `granted` - Whether the conversation has already granted the web (reaching questions only).
466async fn egress_ask_raw(tool: &str, url: &str, detail: &str, alone: bool, granted: bool)
467 -> Option<String>
468{
469 let win = match web_sys::window() {
470 Some(w) => w,
471 None => return None,
472 };
473 let f = match js_sys::Reflect::get(&win, &JsValue::from_str(EGRESS_GLOBAL)) {
474 Ok(v) => v,
475 Err(_) => return None,
476 };
477 if !f.is_function() {
478 return None;
479 }
480 let f = f.unchecked_into::<js_sys::Function>();
481 let payload = fmt!(
482 "{{\"tool\":\"{}\",\"url\":\"{}\",\"detail\":\"{}\",\"alone\":{},\"granted\":{}}}",
483 json_escape(tool), json_escape(url), json_escape(detail), alone, granted);
484 let ret = match f.call1(&JsValue::NULL, &JsValue::from_str(&payload)) {
485 Ok(v) => v,
486 Err(_) => return None,
487 };
488 // The contract says a Promise; a value returned outright is read anyway rather than refused
489 // on a technicality, since it is still an answer.
490 let promise = match ret.dyn_into::<js_sys::Promise>() {
491 Ok(p) => p,
492 Err(v) => return v.as_string(),
493 };
494 match JsFuture::from(promise).await {
495 Ok(v) => v.as_string(),
496 Err(_) => None,
497 }
498}