1use 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 #[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
63pub 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#[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
129pub 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 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); 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
200pub 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
357pub 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}