localharness 0.54.0

Agents that own themselves: one Rust crate that's both an agent SDK (streaming, tools, hooks, policies, triggers, MCP) and a wallet-owning, self-sovereign agent that runs in the browser.
Documentation
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
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
//! Thin web-sys helpers. Every function in this module is a one-liner
//! over web-sys; they exist so the rest of the app reads as HTMX-style
//! HTML swaps ("find this id, swap its inner") instead of web-sys
//! incantations. **Nothing here builds DOM nodes**; that's maud's job.

use std::cell::RefCell;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{Document, Element, HtmlInputElement, HtmlTextAreaElement, Storage, Window};

pub(crate) fn window() -> Result<Window, JsValue> {
    web_sys::window().ok_or_else(|| JsValue::from_str("no window — wrong execution context"))
}

pub(crate) fn document() -> Result<Document, JsValue> {
    window()?
        .document()
        .ok_or_else(|| JsValue::from_str("no document — wrong execution context"))
}

pub(crate) fn session_storage() -> Result<Option<Storage>, JsValue> {
    window()?.session_storage()
}

pub(crate) fn by_id(id: &str) -> Option<Element> {
    document().ok()?.get_element_by_id(id)
}

pub(crate) fn input_by_id(id: &str) -> Option<HtmlInputElement> {
    by_id(id)?.dyn_into::<HtmlInputElement>().ok()
}

pub(crate) fn textarea_by_id(id: &str) -> Option<HtmlTextAreaElement> {
    by_id(id)?.dyn_into::<HtmlTextAreaElement>().ok()
}

/// Semantic colour for a status / result message span.
#[derive(Clone, Copy)]
pub(crate) enum Msg {
    Error,
    Muted,
    Accent,
}

impl Msg {
    fn css_var(self) -> &'static str {
        match self {
            Msg::Error => "--error",
            Msg::Muted => "--muted",
            Msg::Accent => "--accent",
        }
    }
}

/// Build a coloured status `<span>` whose body is HTML-escaped by maud.
/// Use this for ANY message that interpolates dynamic or externally-
/// sourced text — error strings, JSON-RPC node `err.message`, agent
/// summaries — instead of `format!("<span …>{err}</span>")`. Escaping
/// stops a hostile error message from injecting live markup into a
/// wallet-bearing origin (any localharness origin can iframe the apex
/// signer, so XSS there == full wallet compromise). Returns the span as
/// a string so it composes with `swap_inner` / `set_inner_html` / maud.
pub(crate) fn msg_span(kind: Msg, text: &str) -> String {
    let style = format!("color:var({})", kind.css_var());
    maud::html! { span style=(style) { (text) } }.into_string()
}

/// HTMX-style "swap inner". Replaces the entire inside of `#id` with
/// the supplied HTML string. No-op if the element doesn't exist.
pub(crate) fn swap_inner(id: &str, html: &str) {
    if let Some(el) = by_id(id) {
        el.set_inner_html(html);
    }
}

/// HTMX-style "swap outer". Replaces `#id` and all its children with
/// the supplied HTML. No-op if the element doesn't exist. Use this
/// instead of `swap_inner` when you want to change the element's own
/// tag, attributes, or classes.
pub(crate) fn swap_outer(id: &str, html: &str) {
    if let Some(el) = by_id(id) {
        el.set_outer_html(html);
    }
}

/// HTMX-style "set one attribute on `#id`". Wraps `Element::set_attribute`;
/// no-op if the element doesn't exist. A targeted attribute swap (like
/// [`swap_inner`]), NOT DOM-tree building — used to drive purely-CSS state off
/// a data attribute (e.g. `data-stage` on the pending assistant body, which the
/// `::before` cue reads via `attr(data-stage)`).
pub(crate) fn set_attr(id: &str, name: &str, value: &str) {
    if let Some(el) = by_id(id) {
        let _ = el.set_attribute(name, value);
    }
}

thread_local! {
    /// The elements focused when modals/overlays opened, so closing each returns
    /// focus where it was when THAT modal opened (a11y #58) instead of stranding
    /// the user on `<body>`. A STACK, not a single slot: nested modals (e.g.
    /// unlink opened from inside the admin dropdown) each push their
    /// own return target; closing them pops in reverse so the innermost restores
    /// to the dropdown and the dropdown to the original trigger. A lone slot let
    /// the inner open clobber the outer's saved focus.
    static FOCUS_RETURN: RefCell<Vec<Option<Element>>> = const { RefCell::new(Vec::new()) };
}

/// Save the currently-focused element so a later [`restore_focus`] can return
/// to it. Call right before opening a modal/overlay. PUSHES onto the focus
/// stack so nested modals each remember their own return target. Focus is a
/// BEHAVIOUR, not DOM construction — the no-imperative-DOM rule is about
/// building nodes.
pub(crate) fn remember_focus() {
    if let Ok(doc) = document() {
        FOCUS_RETURN.with(|c| c.borrow_mut().push(doc.active_element()));
    }
}

/// Return focus to the element the most recent [`remember_focus`] saved (call
/// when closing a modal/overlay). POPS the focus stack so a nested modal
/// restores to its parent, then the parent to the original trigger. No-op if
/// the stack is empty or the saved element is gone.
pub(crate) fn restore_focus() {
    FOCUS_RETURN.with(|c| {
        if let Some(Some(el)) = c.borrow_mut().pop() {
            if let Some(h) = el.dyn_ref::<web_sys::HtmlElement>() {
                let _ = h.focus();
            }
        }
    });
}

/// CSS selector for natively-focusable elements (shared by [`focus_first_in`]
/// and [`trap_tab_in`]). Excludes hidden inputs and `tabindex=-1` (programmatic-
/// only focus), includes role="button" clickables we activate via the keydown
/// handler.
const FOCUSABLE_SEL: &str =
    "button:not([disabled]), a[href], input:not([type=hidden]):not([disabled]), \
     textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex='-1'])";

/// Move keyboard focus to the first focusable element inside `#container_id`
/// (a11y #58: an opened modal/overlay should take focus so keyboard + screen-
/// reader users land INSIDE it, not stranded on the trigger behind it). No-op
/// if the container or a focusable child is missing.
pub(crate) fn focus_first_in(container_id: &str) {
    let Some(c) = by_id(container_id) else { return };
    let Ok(list) = c.query_selector_all(FOCUSABLE_SEL) else { return };
    // Pick the first VISIBLE focusable. A modal often renders inactive tab
    // panels as `display:none` (e.g. the admin Account/Usage/Feedback tabs);
    // `.focus()` no-ops on a non-rendered element, which would silently strand
    // focus. `offset_parent() == None` flags a `display:none` subtree, so skip
    // those and focus the first one that's actually on screen.
    for i in 0..list.length() {
        if let Some(h) = list.get(i).and_then(|n| n.dyn_into::<web_sys::HtmlElement>().ok()) {
            if h.offset_parent().is_some() {
                let _ = h.focus();
                return;
            }
        }
    }
}

/// The id of the currently-open modal trap, if any — the `[data-modal-trap]`
/// element a swapped-in confirmation/transaction panel carries while armed.
/// One value-moving confirm is open at a time, so the first match wins. Used by
/// the keydown listener to decide whether Tab should be CONFINED to the panel
/// and whether Escape should dismiss it (a11y #75: a transaction confirmation
/// must not strand keyboard focus on the page behind it).
pub(crate) fn open_modal_trap() -> Option<String> {
    let el = document().ok()?.query_selector("[data-modal-trap]").ok()??;
    let id = el.id();
    if id.is_empty() { None } else { Some(id) }
}

/// Confine Tab / Shift+Tab to the focusable elements inside `#container_id`
/// (a focus TRAP). Returns `true` when it moved focus (the caller should then
/// `prevent_default`), `false` when it left the browser's default tab order
/// intact (no container, no visible focusables, or focus is mid-list so the
/// native order already keeps it inside).
///
/// Only the EDGES are intercepted: Tab off the last element wraps to the first,
/// Shift+Tab off the first wraps to the last, and a Tab from outside the panel
/// (e.g. the trigger still has focus) is pulled to the first element. Skips
/// `display:none` candidates (`offset_parent() == None`) like [`focus_first_in`].
pub(crate) fn trap_tab_in(container_id: &str, shift: bool) -> bool {
    let Some(c) = by_id(container_id) else { return false };
    let Ok(list) = c.query_selector_all(FOCUSABLE_SEL) else { return false };
    // Collect the visible focusables in tab order.
    let mut items: Vec<web_sys::HtmlElement> = Vec::new();
    for i in 0..list.length() {
        if let Some(h) = list.get(i).and_then(|n| n.dyn_into::<web_sys::HtmlElement>().ok()) {
            if h.offset_parent().is_some() {
                items.push(h);
            }
        }
    }
    let Some(first) = items.first() else { return false };
    let Some(last) = items.last() else { return false };
    let active = document().ok().and_then(|d| d.active_element());
    // Is focus currently inside this panel? `closest` walks up from the active
    // element to the nearest `[data-modal-trap]` ancestor (or self); if that's
    // this container, focus is inside it. (Avoids `Node::contains`, keeping this
    // on the `Element` surface.)
    let active_in_panel = active
        .as_ref()
        .and_then(|a| a.closest("[data-modal-trap]").ok().flatten())
        .map(|m| m.id() == container_id)
        .unwrap_or(false);
    // Focus sitting OUTSIDE the panel (e.g. still on the trigger) → pull it in.
    if !active_in_panel {
        let target = if shift { last } else { first };
        let _ = target.focus();
        return true;
    }
    // Wrap only at the boundary; mid-list, the native order already stays inside.
    let first_el: &Element = first.as_ref();
    let last_el: &Element = last.as_ref();
    let on_first = active.as_ref().map(|a| a == first_el).unwrap_or(false);
    let on_last = active.as_ref().map(|a| a == last_el).unwrap_or(false);
    if shift && on_first {
        let _ = last.focus();
        true
    } else if !shift && on_last {
        let _ = first.focus();
        true
    } else {
        false
    }
}

/// HTMX-style "append a fragment at the end of `#id`". Wraps
/// `Element::insert_adjacent_html("beforeend", ...)`. No-op on missing
/// id or on an HTML error.
pub(crate) fn append_html(id: &str, html: &str) {
    if let Some(el) = by_id(id) {
        let _ = el.insert_adjacent_html("beforeend", html);
    }
}

/// Remove an element from the DOM by id (no-op if it's already gone).
/// Used to drop a pre-painted shell that ended up with nothing to show
/// (e.g. a pure-`finish` assistant turn — see `chat::stream_turn`).
pub(crate) fn remove(id: &str) {
    if let Some(el) = by_id(id) {
        el.remove();
    }
}

/// Scroll an element to the bottom. Used by the chat to keep the
/// latest content in view as the assistant streams.
pub(crate) fn scroll_to_bottom(id: &str) {
    if let Some(el) = by_id(id) {
        el.set_scroll_top(el.scroll_height());
    }
}

/// Scroll to the bottom now AND again shortly after, so content that
/// grows post-append still ends pinned to the latest entry. On first
/// load the transcript is restored before layout/font swap settles, so
/// a single synchronous scroll lands at the wrong offset; the delayed
/// passes (one quick, one after the web-font swaps in) correct it.
pub(crate) fn scroll_to_bottom_soon(id: &str) {
    scroll_to_bottom(id);
    let Ok(win) = window() else { return };
    for delay in [60, 350] {
        let id = id.to_string();
        let cb = Closure::once_into_js(move || scroll_to_bottom(&id));
        let _ = win.set_timeout_with_callback_and_timeout_and_arguments_0(
            cb.unchecked_ref(),
            delay,
        );
    }
}

/// Stamp `data-lh-ready` on `<html>` once an interactive surface is in
/// the DOM. Chrome's paint-holding keeps the PREVIOUS page's pixels on
/// screen across a reload, so the app can LOOK interactive seconds before
/// this bundle has mounted — clicks in that window land on a
/// not-yet-listening document and vanish. Automation (and the smoke drive,
/// `scripts/browser-smoke.md`) polls this attribute instead of guessing.
pub(crate) fn mark_ready() {
    if let Ok(doc) = document() {
        if let Some(el) = doc.document_element() {
            let _ = el.set_attribute("data-lh-ready", "1");
        }
    }
}

/// Focus the chat `#prompt` textarea if it exists and is empty — called once
/// after the chat chrome mounts so the cursor lands in the input (feedback
/// #44). No-op when the textarea already has text (don't yank focus on a
/// repaint that preserved a draft) or when `#prompt` is absent (apex / visitor
/// public-face render no input). A direct `.focus()` (no listener), so the
/// no-Closure rule holds. NOTE: mobile Safari/Chrome won't raise the soft
/// keyboard without a user gesture — this only places the caret.
pub(crate) fn focus_prompt_if_empty() {
    if let Some(ta) = textarea_by_id("prompt") {
        if ta.value().is_empty() {
            let _ = ta.focus();
        }
    }
}

/// Blur the prompt textarea (on-chain #55): dropping focus collapses the mobile
/// soft keyboard the instant a turn is sent — whether sent via the send button
/// or the Enter key. A no-op on desktop beyond losing the caret. Direct
/// `.blur()` (no listener) so the no-Closure rule holds.
pub(crate) fn blur_prompt() {
    if let Some(ta) = textarea_by_id("prompt") {
        let _ = ta.blur();
    }
}

pub(crate) fn set_status(message: &str, is_error: bool) {
    // Status lives IN THE STREAM (a single replaceable system line at the end
    // of the transcript), never in the input container — the user rejected
    // input-chrome status messages repeatedly (feedback #45/#64 + direct).
    // Empty message = clear the line. The node is recreated at the transcript
    // tail so it always reads as the latest event.
    let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
        return;
    };
    if let Some(el) = doc.get_element_by_id("system-status") {
        el.remove();
    }
    if message.is_empty() {
        return;
    }
    let Some(transcript) = doc.get_element_by_id("transcript") else {
        return;
    };
    let cls = if is_error { "system-status err" } else { "system-status" };
    let _ = transcript.insert_adjacent_html(
        "beforeend",
        &format!(
            "<div id=\"system-status\" class=\"{cls}\">{}</div>",
            html_escape(message)
        ),
    );
    scroll_to_bottom("transcript");
}

/// Surface the typed-confirmation prompt as a bordered system callout at the
/// transcript tail (a distinct system panel, NOT the borderless gray that
/// reads as a chat turn). Reuses the `#system-status` id so the normal
/// status-clear (next send / `set_status("")`) removes it.
pub(crate) fn set_confirm_callout(tool_name: &str, code: &str) {
    let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
        return;
    };
    if let Some(el) = doc.get_element_by_id("system-status") {
        el.remove();
    }
    let Some(transcript) = doc.get_element_by_id("transcript") else {
        return;
    };
    let _ = transcript.insert_adjacent_html(
        "beforeend",
        &crate::app::templates::confirm_callout(tool_name, code).into_string(),
    );
    scroll_to_bottom("transcript");
}

/// HTML-escape an UNTRUSTED string before it is concatenated into a raw
/// HTML string that is then injected via [`swap_inner`] / [`swap_outer`] /
/// [`append_html`] / `set_inner_html` etc.
///
/// **Prefer wrapping dynamic text in a maud `html! { (text) }` block** — maud
/// auto-escapes its interpolations and is the idiomatic path in this app (see
/// [`msg_span`]). Reach for this helper only when a sink is unavoidably built
/// with `format!("…{x}…")` raw HTML and routing through maud would be awkward
/// (e.g. interpolating a value into a fixed wrapper element, as [`set_status`]
/// does). NEVER inject an unescaped dynamic/external string: the seed sits in
/// OPFS as plaintext (it is the key root, so it can't be sealed without a
/// passphrase), and any localharness origin can iframe the apex signer, so an
/// XSS in a wallet-bearing origin is full wallet compromise. Untrusted sources
/// include error strings (RPC / proxy / registry / `anyhow` Display) and any
/// on-chain text rendered in the UI (persona, feedback, names, metadata, call
/// replies).
///
/// Escapes the five HTML-significant characters so the result is safe in BOTH
/// element-text and double/single-quoted attribute contexts: `&` `<` `>` `"`
/// `'`. Escape `&` FIRST so the entities this introduces aren't re-escaped.
pub(crate) fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}

#[cfg(test)]
mod html_escape_tests {
    use super::html_escape;

    #[test]
    fn escapes_all_five_html_significant_chars() {
        assert_eq!(
            html_escape(r#"<>&"'"#),
            "&lt;&gt;&amp;&quot;&#39;"
        );
    }

    #[test]
    fn ampersand_is_escaped_first_so_entities_are_not_double_escaped() {
        // Naive ordering would turn `<` into `&lt;` then re-escape the `&`,
        // yielding `&amp;lt;`. `&`-first keeps a single round of escaping.
        assert_eq!(html_escape("<"), "&lt;");
        assert_eq!(html_escape("&amp;"), "&amp;amp;");
    }

    #[test]
    fn neutralizes_a_script_injection_attempt() {
        // A hostile error / on-chain string must reach the DOM as inert text.
        let evil = r#"</span><img src=x onerror="alert(document.cookie)">"#;
        let out = html_escape(evil);
        assert!(!out.contains("<img"), "live <img> leaked: {out}");
        assert!(!out.contains("</span>"), "live tag leaked: {out}");
        assert!(out.contains("&lt;img"), "img not escaped: {out}");
        assert!(out.contains("&quot;"), "attribute quote not escaped: {out}");
    }

    #[test]
    fn leaves_plain_text_untouched() {
        assert_eq!(html_escape("redeem failed: node down (502)"), "redeem failed: node down (502)");
    }
}