Skip to main content

qualia_client_core/
browser_agent.rs

1//! **Browser agent (P2 / B4)** — page-aware helper for the Webizen browser.
2//!
3//! Scope (honest):
4//! - Structured intents: `summarise` | `trust` | `privacy` | `navigate_help` | `general`.
5//! - Always return provenance + CML signals + trust verdict.
6//! - Optionally ingest page topics into the hypermedia library.
7//! - Deterministic grounded answers (no unbounded tools; 20s fetch timeout).
8//! - Local LLM path is optional and not required for acceptance.
9
10use std::path::Path;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use serde::{Deserialize, Serialize};
14
15use crate::webizen_trust::{self, TrustStore, TrustVerdict};
16use crate::wellfair::cml_context::{build_document_context, units_from_headings, ContextUnit};
17use crate::wellfair::hypermedia_store::{
18    CommonsVisibility, HypermediaStore, LibraryEntry, LibrarySection,
19};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum BrowserAgentIntent {
24    Summarise,
25    Trust,
26    Privacy,
27    NavigateHelp,
28    General,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct BrowserAgentRequest {
33    pub url: String,
34    pub question: String,
35    /// When true, write a library entry under Work for page topics.
36    #[serde(default)]
37    pub ingest_to_library: bool,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct BrowserAgentResponse {
42    pub url: String,
43    pub answer: String,
44    pub intent: String,
45    pub trust: TrustVerdict,
46    pub cml_signals: Vec<String>,
47    pub topics: Vec<String>,
48    pub deontic_norms: usize,
49    pub privacy_hits: usize,
50    pub page_excerpt: String,
51    pub provenance: Vec<String>,
52    pub library_asset_uri: Option<String>,
53    pub curation: String,
54}
55
56fn now_unix() -> u64 {
57    SystemTime::now()
58        .duration_since(UNIX_EPOCH)
59        .map(|d| d.as_secs())
60        .unwrap_or(0)
61}
62
63/// Classify the user question into a bounded intent.
64pub fn classify_intent(question: &str) -> BrowserAgentIntent {
65    let q = question.trim().to_ascii_lowercase();
66    if q.is_empty() {
67        return BrowserAgentIntent::Summarise;
68    }
69    if q.contains("trust")
70        || q.contains("certificate")
71        || q.contains("secure")
72        || q.contains("trusted")
73        || q.contains("is this safe")
74    {
75        return BrowserAgentIntent::Trust;
76    }
77    if q.contains("privacy")
78        || q.contains("gdpr")
79        || q.contains("personal data")
80        || q.contains("cookie")
81        || q.contains("tracker")
82    {
83        return BrowserAgentIntent::Privacy;
84    }
85    if q.contains("how do i")
86        || q.contains("navigate")
87        || q.contains("where is")
88        || q.contains("open ")
89        || q.contains("bookmark")
90    {
91        return BrowserAgentIntent::NavigateHelp;
92    }
93    if q.contains("about")
94        || q.contains("summar")
95        || q.contains("what is")
96        || q.contains("page")
97        || q.contains("tell me")
98    {
99        return BrowserAgentIntent::Summarise;
100    }
101    BrowserAgentIntent::General
102}
103
104/// How agent HTTPS was configured (honesty for answers + UI).
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct AgentTlsStatus {
107    pub mode: String,
108    pub n_custom_roots: usize,
109    pub note: String,
110}
111
112pub fn agent_tls_status(storage_root: &Path) -> AgentTlsStatus {
113    use crate::webizen_x509::{agent_tls_mode, AgentTlsMode};
114    let store = TrustStore::load(storage_root);
115    match agent_tls_mode(&store) {
116        AgentTlsMode::CustomRootsOnly { n_roots } => AgentTlsStatus {
117            mode: "custom_enabled_pems_only".into(),
118            n_custom_roots: n_roots,
119            note: "Agent HTTPS: tls_certs_only(principal PEMs) — platform roots off; same enabled set as cert policy B.".into(),
120        },
121        AgentTlsMode::SystemDefault => AgentTlsStatus {
122            mode: "system_default".into(),
123            n_custom_roots: 0,
124            note: "No enabled PEM roots — agent uses default reqwest/rustls roots (not Webizen-custom). WebView TLS remains OS-mediated.".into(),
125        },
126    }
127}
128
129/// Build reqwest client: custom roots when enabled PEMs present; else system default.
130pub fn build_agent_http_client(
131    storage_root: &Path,
132) -> Result<(reqwest::Client, AgentTlsStatus), String> {
133    use crate::webizen_x509::{agent_tls_mode, root_cert_store_from_trust, AgentTlsMode};
134    let store = TrustStore::load(storage_root);
135    let status = agent_tls_status(storage_root);
136    let builder = reqwest::Client::builder()
137        .timeout(std::time::Duration::from_secs(20))
138        .user_agent("WebizenBrowserAgent/0.0.25 (+https://ns.webcivics.net)");
139
140    match agent_tls_mode(&store) {
141        AgentTlsMode::CustomRootsOnly { n_roots } => {
142            // Custom-only: tls_certs_only() disables platform roots (reqwest 0.13).
143            let mut certs: Vec<reqwest::Certificate> = Vec::new();
144            for a in store
145                .anchors
146                .iter()
147                .filter(|a| a.enabled && a.kind == webizen_trust::AnchorKind::PemRoot)
148            {
149                match reqwest::Certificate::from_pem(a.material.as_bytes()) {
150                    Ok(cert) => certs.push(cert),
151                    Err(_) => {
152                        if let Ok(ders) = crate::webizen_x509::pem_to_ders(&a.material) {
153                            for der in ders {
154                                let pem = der_to_pem_cert(&der);
155                                if let Ok(cert) = reqwest::Certificate::from_pem(pem.as_bytes()) {
156                                    certs.push(cert);
157                                }
158                            }
159                        }
160                    }
161                }
162            }
163            if certs.is_empty() {
164                return Err("enabled PEM roots present but none parsed into certificates".into());
165            }
166            let added = certs.len();
167            let _ = root_cert_store_from_trust(&store); // validate same path as B
168            let client = builder
169                .tls_certs_only(certs)
170                .build()
171                .map_err(|e| e.to_string())?;
172            let mut st = status;
173            st.mode = "custom_enabled_pems_only".into();
174            st.n_custom_roots = n_roots.max(added);
175            st.note = format!(
176                "Agent HTTPS: custom-only TLS with {added} principal PEM root(s) \
177                 (platform roots disabled via tls_certs_only). Same store as cert policy B. \
178                 WebView TLS remains OS + ServerCertificateErrorDetected override hook."
179            );
180            Ok((client, st))
181        }
182        AgentTlsMode::SystemDefault => {
183            let client = builder.build().map_err(|e| e.to_string())?;
184            Ok((client, status))
185        }
186    }
187}
188
189fn der_to_pem_cert(der: &[u8]) -> String {
190    let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, der);
191    let mut out = String::from("-----BEGIN CERTIFICATE-----\n");
192    for chunk in b64.as_bytes().chunks(64) {
193        out.push_str(std::str::from_utf8(chunk).unwrap_or(""));
194        out.push('\n');
195    }
196    out.push_str("-----END CERTIFICATE-----\n");
197    out
198}
199
200/// Fetch page body (best-effort HTML → text). Aligns TLS policy with trust store when possible.
201pub async fn fetch_page_text(url: &str) -> Result<String, String> {
202    fetch_page_text_with_storage(
203        url,
204        &std::path::PathBuf::from(crate::state::dirs_default_path()),
205    )
206    .await
207}
208
209pub async fn fetch_page_text_with_storage(
210    url: &str,
211    storage_root: &Path,
212) -> Result<String, String> {
213    let u = url.trim();
214    if u.starts_with("qualia://") || u.starts_with("webizen://") {
215        return Ok(format!(
216            "Local Webizen resource: {u}\n(Content is served by the desktop protocol handler, not HTTP.)"
217        ));
218    }
219    let (client, _tls) = build_agent_http_client(storage_root)?;
220    let resp = client.get(u).send().await.map_err(|e| e.to_string())?;
221    if !resp.status().is_success() {
222        return Err(format!("HTTP {}", resp.status()));
223    }
224    let html = resp.text().await.map_err(|e| e.to_string())?;
225    Ok(html_to_text(&html))
226}
227
228fn html_to_text(html: &str) -> String {
229    let mut text = String::new();
230    let doc = scraper::Html::parse_document(html);
231    let sel = scraper::Selector::parse("body").ok();
232    if let Some(sel) = sel {
233        if let Some(body) = doc.select(&sel).next() {
234            text = body.text().collect::<Vec<_>>().join(" ");
235        }
236    }
237    if text.trim().is_empty() {
238        let mut out = String::new();
239        let mut in_tag = false;
240        for c in html.chars() {
241            match c {
242                '<' => in_tag = true,
243                '>' => in_tag = false,
244                _ if !in_tag => out.push(c),
245                _ => {}
246            }
247        }
248        text = out;
249    }
250    text.split_whitespace().collect::<Vec<_>>().join(" ")
251}
252
253fn build_answer(
254    intent: BrowserAgentIntent,
255    question: &str,
256    url: &str,
257    page: &str,
258    trust: &TrustVerdict,
259    signals: &[String],
260    topics: &[String],
261) -> String {
262    let excerpt: String = page.chars().take(600).collect();
263    let signal_join = |cap: usize| {
264        if signals.is_empty() && topics.is_empty() {
265            "(none extracted)".into()
266        } else {
267            signals
268                .iter()
269                .chain(topics.iter())
270                .take(cap)
271                .cloned()
272                .collect::<Vec<_>>()
273                .join(", ")
274        }
275    };
276
277    match intent {
278        BrowserAgentIntent::Trust => {
279            let tls = agent_tls_status(Path::new(
280                &std::env::var("QUALIA_STORAGE")
281                    .unwrap_or_else(|_| crate::state::dirs_default_path()),
282            ));
283            format!(
284                "Trust verdict for {url}: **{}** — {}\n\
285                 Matching anchors: {}\n\
286                 Notes: {}\n\
287                 Cert policy: default **deny**; host-pin (A) allows; enabled PEM roots (B) only after chain verify; \
288                 never auto-allow; Allow once/Always/Deny are logged escape hatches (no WebID-TLS nag loops).\n\
289                 Agent TLS mode: {} — {}\n\
290                 (WebView TLS: OS + ServerCertificateErrorDetected hook; this verdict is Webizen policy + store.)",
291                trust.level,
292                trust.summary,
293                if trust.matching_anchors.is_empty() {
294                    "none".into()
295                } else {
296                    trust.matching_anchors.join(", ")
297                },
298                trust.notes.join(" · "),
299                tls.mode,
300                tls.note,
301            )
302        }
303        BrowserAgentIntent::Summarise => format!(
304            "Page: {url}\n\
305             Trust: {} ({})\n\
306             Topics/signals: {}\n\
307             Excerpt (grounded in fetched text):\n{excerpt}{}",
308            trust.level,
309            trust.summary,
310            signal_join(16),
311            if page.len() > 600 { "…" } else { "" }
312        ),
313        BrowserAgentIntent::Privacy => {
314            let privacy_sigs: Vec<_> = signals
315                .iter()
316                .filter(|s| s.starts_with("privacy:"))
317                .cloned()
318                .collect();
319            format!(
320                "Privacy signals on {url}: {}\n\
321                 Trust: {}.\n\
322                 Grounded excerpt: {excerpt}{}",
323                if privacy_sigs.is_empty() {
324                    "none detected by deterministic extractors (privacy:* tags)".into()
325                } else {
326                    privacy_sigs.join(", ")
327                },
328                trust.level,
329                if page.len() > 600 { "…" } else { "" }
330            )
331        }
332        BrowserAgentIntent::NavigateHelp => format!(
333            "Webizen Browser help for {url}:\n\
334             · Omnibox Go / back / forward / reload in chrome\n\
335             · 🔖 saves a bookmark (qlinks + Library purpose=bookmark)\n\
336             · Trust panel manages your DID/PEM store (agent policy; OS TLS separate)\n\
337             · Agent answers summarise / trust / privacy about the current page\n\
338             Question was: {question}\n\
339             Current trust: {} — {}",
340            trust.level, trust.summary
341        ),
342        BrowserAgentIntent::General => format!(
343            "Question: {question}\n\
344             URL: {url}\n\
345             Trust: {} — {}\n\
346             Detected: {}\n\
347             Grounded excerpt:\n{excerpt}{}\n\
348             Provenance: fetched page text + CML context extractors + Webizen trust store (cml:Proposed).",
349            trust.level,
350            trust.summary,
351            signal_join(12),
352            if page.len() > 600 { "…" } else { "" }
353        ),
354    }
355}
356
357/// Run the browser agent against the current page.
358pub async fn run_browser_agent(
359    storage_root: &Path,
360    req: BrowserAgentRequest,
361) -> Result<BrowserAgentResponse, String> {
362    let intent = classify_intent(&req.question);
363    let store = TrustStore::load(storage_root);
364    let trust = webizen_trust::evaluate_url(&store, &req.url);
365    let page = fetch_page_text(&req.url)
366        .await
367        .unwrap_or_else(|e| format!("(fetch failed: {e})"));
368
369    let units = if page.len() > 80 {
370        units_from_headings(&page)
371    } else {
372        vec![ContextUnit {
373            frag: "page".into(),
374            kind: "document".into(),
375            label: req.url.clone(),
376            text: page.clone(),
377            page: None,
378            parent: None,
379        }]
380    };
381    let g = build_document_context(&req.url, &req.url, &units);
382
383    let answer = build_answer(
384        intent,
385        &req.question,
386        &req.url,
387        &page,
388        &trust,
389        &g.signal_tags,
390        &g.topics,
391    );
392
393    let mut library_asset_uri = None;
394    if req.ingest_to_library && page.len() > 40 {
395        let store_lib = HypermediaStore::open(storage_root).map_err(|e| e.to_string())?;
396        let uri = format!("urn:webizen:browser-page:{}", short_id(req.url.as_bytes()));
397        let mut entry = LibraryEntry {
398            asset_uri: uri.clone(),
399            primary_subject: fnv60(uri.as_bytes()),
400            media_type: "text/html".into(),
401            quins: g.quins.clone(),
402            topics: g.topics.clone(),
403            projects: vec!["browser".into()],
404            purposes: {
405                let mut p = g.purposes.clone();
406                p.push("browser".into());
407                p.push("research".into());
408                p
409            },
410            place: None,
411            occurred_at: None,
412            lat: None,
413            lon: None,
414            flags: Vec::new(),
415            ingested_unix: now_unix(),
416            excerpt: page.chars().take(400).collect(),
417            sensitivity: "public".into(),
418            section: LibrarySection::Work.as_str().into(),
419            commons_visibility: CommonsVisibility::None,
420            cml_signals: g.signal_tags.clone(),
421            cml_concept_count: g.concepts.len() as u32,
422            cml_n3: g.n3.chars().take(24_000).collect(),
423            cof_html: String::new(),
424            cof_segment_count: 0,
425            cof_segment_index: 0,
426            cof_profile: String::new(),
427        };
428        entry.recompute_section();
429        store_lib.add(entry).map_err(|e| e.to_string())?;
430        library_asset_uri = Some(uri);
431    }
432
433    let intent_str = match intent {
434        BrowserAgentIntent::Summarise => "summarise",
435        BrowserAgentIntent::Trust => "trust",
436        BrowserAgentIntent::Privacy => "privacy",
437        BrowserAgentIntent::NavigateHelp => "navigate_help",
438        BrowserAgentIntent::General => "general",
439    };
440
441    Ok(BrowserAgentResponse {
442        url: req.url,
443        answer,
444        intent: intent_str.into(),
445        trust,
446        cml_signals: g.signal_tags,
447        topics: g.topics,
448        deontic_norms: g.deontic_norms,
449        privacy_hits: g.privacy_hits,
450        page_excerpt: page.chars().take(800).collect(),
451        provenance: vec![
452            "page-fetch".into(),
453            "cml_context".into(),
454            "webizen_trust".into(),
455            "cml:Proposed".into(),
456            format!("intent:{intent_str}"),
457        ],
458        library_asset_uri,
459        curation: "cml:Proposed".into(),
460    })
461}
462
463fn short_id(bytes: &[u8]) -> String {
464    use sha2::{Digest, Sha256};
465    let mut h = Sha256::new();
466    h.update(bytes);
467    hex::encode(&h.finalize()[..10])
468}
469
470fn fnv60(bytes: &[u8]) -> u64 {
471    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
472    const FNV_PRIME: u64 = 0x100_0000_01b3;
473    let mut h = FNV_OFFSET;
474    for b in bytes {
475        h ^= u64::from(*b);
476        h = h.wrapping_mul(FNV_PRIME);
477    }
478    h & 0x0FFF_FFFF_FFFF_FFFF
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn intent_classify() {
487        assert_eq!(
488            classify_intent("Is this trusted?"),
489            BrowserAgentIntent::Trust
490        );
491        assert_eq!(
492            classify_intent("Privacy signals?"),
493            BrowserAgentIntent::Privacy
494        );
495        assert_eq!(
496            classify_intent("What is this page about?"),
497            BrowserAgentIntent::Summarise
498        );
499        assert_eq!(
500            classify_intent("How do I bookmark?"),
501            BrowserAgentIntent::NavigateHelp
502        );
503    }
504}