Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/src/wasm/hand.rs

11.7 KiB, 1 run

created by r2519314175:983, 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 machine hand's edge — thin bindings to the JS relay `window.DaimondHand`.
2//!
3//! A web page cannot create a process. There is no flag and no future API, so
4//! the capability lives in a program outside the page: a native messaging host
5//! that Chrome launches and connects to the Daimond Hands extension, and to
6//! nothing else. The browser is the doorman. A loopback daemon was rejected
7//! because its port is reachable by any page the user visits and its whole
8//! defence would be one pasted secret.
9//!
10//! This file is the narrow part of that road. `window.DaimondHand` hides
11//! which transport is attached -- the extension on this machine, or the same
12//! hand binary over a WebSocket on a machine you own -- so the tool above does
13//! not change when the second one appears.
14//!
15//! A rejection carries a plain-English `Error` the model is meant to read and
16//! act on, so its `message` is passed through **verbatim**, exactly as the Web
17//! panel edge does. Mangling it would destroy the only instruction the model
18//! gets about what to do next.
19
20use crate::wasm::js_str;
21
22use oxedyne_fe2o3_core::prelude::*;
23
24use wasm_bindgen::prelude::wasm_bindgen;
25use wasm_bindgen::{JsCast, JsValue};
26use wasm_bindgen_futures::JsFuture;
27
28
29#[wasm_bindgen]
30extern "C" {
31
32 /// The relay object the page installs at `window.DaimondHand`.
33 #[wasm_bindgen(js_name = DaimondHand)]
34 type Relay;
35
36 /// Whether a hand is paired, which machine it is, and what it can enforce.
37 #[wasm_bindgen(method)]
38 fn status(this: &Relay) -> js_sys::Promise;
39
40 /// Run one command, resolving when it has finished.
41 ///
42 /// The relay streams the output to the panel as it arrives; what comes
43 /// back here is the whole run, because a tool result is one blob and the
44 /// model reads it once. The live stream is for the person watching.
45 #[wasm_bindgen(method)]
46 fn run(this: &Relay, spec_json: &str) -> js_sys::Promise;
47
48 /// What the hand is still running, standing process groups included.
49 #[wasm_bindgen(method)]
50 fn runs(this: &Relay) -> js_sys::Promise;
51
52 /// The output a page reload left this page holding for one run.
53 #[wasm_bindgen(method)]
54 fn held(this: &Relay, id: &str) -> js_sys::Promise;
55
56 /// Signal one run by the identifier it was given, resolving when the
57 /// message has been handed over.
58 #[wasm_bindgen(method)]
59 fn signal(this: &Relay, id: &str, sig: &str) -> js_sys::Promise;
60}
61
62
63/// Reach the relay object on `window`, or refuse in the model's language.
64///
65/// The refusal is the most-read sentence in this file: a user who has not
66/// installed the hand meets it on their first command, so it says what is
67/// missing and what to do, not merely that something failed.
68fn relay() -> Outcome<Relay> {
69 let win = res!(web_sys::window()
70 .ok_or_else(|| err!("The machine hand needs a browser window."; System, Missing)));
71 let obj = res!(js_sys::Reflect::get(&win, &JsValue::from_str("DaimondHand"))
72 .map_err(|e| err!("Reading window.DaimondHand failed: {}.", js_str(&e); System, Missing)));
73 if obj.is_undefined() || obj.is_null() {
74 return Err(err!(
75 "There is no machine hand in this page, so there is nothing to run \
76 commands on. Daimond runs in the browser and a browser cannot start \
77 a program; the hand is a small companion that can. Tell the user it \
78 is not installed, and carry on with the file tools, which do not \
79 need it.";
80 System, Missing));
81 }
82 Ok(obj.unchecked_into::<Relay>())
83}
84
85/// The `message` of a rejected JS `Error`, verbatim, falling back to the
86/// value's own rendering when it is not an `Error`.
87fn refusal(e: &JsValue) -> String {
88 match js_sys::Reflect::get(e, &JsValue::from_str("message")) {
89 Ok(m) => m.as_string().unwrap_or_else(|| js_str(e)),
90 Err(_) => js_str(e),
91 }
92}
93
94/// Render a resolved JS value as the JSON string the tool result carries.
95fn stringify(v: &JsValue) -> Outcome<String> {
96 if v.is_undefined() || v.is_null() {
97 return Ok("{}".to_string());
98 }
99 if let Some(s) = v.as_string() {
100 return Ok(s); // the relay resolved with JSON already
101 }
102 match js_sys::JSON::stringify(v) {
103 Ok(s) => Ok(String::from(s)),
104 Err(e) => Err(err!(
105 "The machine hand returned a result that cannot be read: {}.", refusal(&e);
106 Invalid, Data)),
107 }
108}
109
110/// Await a relay promise, passing a refusal through untouched.
111async fn settle(promise: js_sys::Promise) -> Outcome<String> {
112 match JsFuture::from(promise).await {
113 Ok(v) => stringify(&v),
114 Err(e) => Err(err!("{}", refusal(&e); IO, Invalid)),
115 }
116}
117
118/// Whether a machine hand is present.
119///
120/// AUTHORED BY A DAIMON, `dev/reflux.mjs --task opfssay`, 2026-08-24, comment included. It is
121/// asked by [`crate::tools::two_places_note`] and by [`crate::tools::write_place`] to decide
122/// whether there is a second filesystem worth naming; [`status`] answers more and costs a round
123/// trip, on a path that has already failed.
124///
125/// **It was `relay().is_ok()` until 2026-08-24 and that was the wrong question.** `hand.js`
126/// installs `window.DaimondHand` on EVERY page, paired or not -- it is the shim that answers
127/// "no hand is installed", so its presence is not evidence of one. So the two-filesystems note
128/// was appearing for a user with no hand at all, telling them about a granted folder on their
129/// computer that did not exist. `hasHand()` is the relay's own answer, `transport !== 'none'`,
130/// and is what "present" always meant.
131///
132/// A relay that cannot answer is taken as no hand. Silence loses a hint; the other direction
133/// invents a second filesystem, which is the fault being fixed.
134pub fn present() -> bool {
135 let r = match relay() {
136 Ok(r) => r,
137 Err(_) => return false,
138 };
139 let obj: &JsValue = r.as_ref();
140 let f = match js_sys::Reflect::get(obj, &JsValue::from_str("hasHand")) {
141 Ok(v) => v,
142 Err(_) => return false,
143 };
144 let f: js_sys::Function = match f.dyn_into() {
145 Ok(f) => f,
146 Err(_) => return false,
147 };
148 matches!(f.call0(obj), Ok(v) if v.is_truthy())
149}
150
151/// Whether a hand is paired, which machine it is, and what it can enforce.
152pub async fn status() -> Outcome<String> {
153 let r = res!(relay());
154 settle(r.status()).await
155}
156
157/// Run one command and return the whole result as JSON.
158///
159/// A rejection from the relay is **not** returned as an error, and that is
160/// deliberate. Every one of them is a whole sentence written for the model to
161/// act on -- the hand is not installed, the user declined, it stopped
162/// part-way -- and an `Err` reaches the daimon wrapped in an fe2o3 chain,
163/// carrying ANSI colour and a `src/*.rs:line` frame around the one sentence
164/// that matters. A hand that will not run a command has refused it, so it is
165/// handed on as a refusal and rendered as one.
166///
167/// # Arguments
168/// * `spec_json` - The `exec` request, already rendered as the wire's JSON.
169pub async fn run(spec_json: &str) -> Outcome<String> {
170 let r = res!(relay());
171 match JsFuture::from(r.run(spec_json)).await {
172 Ok(v) => stringify(&v),
173 Err(e) => Ok(fmt!(r#"{{"refused":"{}"}}"#, crate::llm::json_escape(&refusal(&e)))),
174 }
175}
176
177/// Carry out one file operation and return the answer as JSON.
178///
179/// **Reached reflectively, not through a `#[wasm_bindgen(method)]` declaration, and the
180/// reason is a hang.** A declared method that is not on the object throws when it is called,
181/// and a throw out of a declared-infallible import does not become an `Err` -- the promise is
182/// never made, nothing resolves, and the tool call waits for ever. A relay older than this
183/// build is exactly that case, and so is every test stub written before the verb existed:
184/// `dev/verify_chatfence.mjs`'s stub answers `hasHand`, `status` and `run`, and its first
185/// `file_write` to a marked folder hung the whole verifier.
186///
187/// So the function is looked up, and its absence is a SENTENCE. Never a fall back to browser
188/// storage: a write that lands in the other filesystem while the daimon believes it changed
189/// the machine is the failure `write_place` exists to refuse, and doing it here silently would
190/// be that failure with a new door on it.
191///
192/// A rejection is handed on as a refusal for exactly the reason [`run`] gives: every one of
193/// them is a whole sentence written for the model to act on, and an `Err` would wrap it in an
194/// fe2o3 chain carrying colour and a `src/*.rs:line` frame around the one sentence that
195/// matters.
196///
197/// # Arguments
198/// * `spec_json` - The `file` request, already rendered as the wire's JSON.
199pub async fn file(spec_json: &str) -> Outcome<String> {
200 let r = res!(relay());
201 let obj: &JsValue = r.as_ref();
202 let f = match js_sys::Reflect::get(obj, &JsValue::from_str("file")) {
203 Ok(v) => v,
204 Err(_) => return Ok(no_file_door()),
205 };
206 let f: js_sys::Function = match f.dyn_into() {
207 Ok(f) => f,
208 Err(_) => return Ok(no_file_door()),
209 };
210 let p = match f.call1(obj, &JsValue::from_str(spec_json)) {
211 Ok(p) => p,
212 Err(e) => return Ok(fmt!(r#"{{"refused":"{}"}}"#,
213 crate::llm::json_escape(&refusal(&e)))),
214 };
215 let p: js_sys::Promise = match p.dyn_into() {
216 Ok(p) => p,
217 Err(_) => return Ok(no_file_door()),
218 };
219 match JsFuture::from(p).await {
220 Ok(v) => stringify(&v),
221 Err(e) => Ok(fmt!(r#"{{"refused":"{}"}}"#, crate::llm::json_escape(&refusal(&e)))),
222 }
223}
224
225/// What to say when the relay attached to this page cannot carry a file operation.
226///
227/// It names the two things the reader can do about it, because "the hand is old" is not an
228/// instruction: update it, or reach the file with `run`, which every hand has always had.
229fn no_file_door() -> String {
230 fmt!(r#"{{"refused":"{}"}}"#, crate::llm::json_escape(
231 "The machine hand attached to this page cannot change files directly -- it is older \
232 than this version of Daimond. Nothing was read or changed. Tell the user to update \
233 the hand, and meanwhile reach the file with run, which every hand can do."))
234}
235
236/// What this hand is still running, as the relay's JSON.
237///
238/// Two things a caller must not read into it. The listing is a MEASUREMENT
239/// taken a moment ago, so a run in it may have ended since; and a run absent
240/// from it is one the hand can no longer reach, which is not the same claim as
241/// one that has stopped.
242pub async fn runs() -> Outcome<String> {
243 let r = res!(relay());
244 settle(r.runs()).await
245}
246
247/// The output held for one run across a page reload, as the relay's JSON.
248///
249/// **Handed over once.** The relay lets go of what it answers with, because the whole of it has
250/// gone to the reader and a second copy of a build's output in a tab is one nobody will look at
251/// again. So a caller that discards this answer has discarded the output.
252///
253/// # Arguments
254/// * `id` - The run's identifier, as the listing carries it.
255pub async fn held(id: &str) -> Outcome<String> {
256 let r = res!(relay());
257 settle(r.held(id)).await
258}
259
260/// Signal one run this hand started, by the identifier the run was given.
261///
262/// **Resolving promises nothing about the process.** There is deliberately no
263/// "stopped" answer on the wire: a signal that could not be delivered comes back
264/// as an error, and a signal that could is confirmed by asking [`runs`] again.
265/// Reporting success on a kill that failed is the defect that arrangement exists
266/// to close, so nothing here may be read as one.
267///
268/// # Arguments
269/// * `id` - The identifier the run was given, never a pid and never a pattern.
270/// * `sig` - `term`, `kill` or `int`.
271pub async fn signal(id: &str, sig: &str) -> Outcome<()> {
272 let r = res!(relay());
273 res!(settle(r.signal(id, sig)).await);
274 Ok(())
275}