1use super::{transport_error, Cdp, DOCUMENT_CHECK, OPTIONAL_STEP};
2use serde_json::{json, Value};
3use std::collections::{BTreeMap, BTreeSet, VecDeque};
4use std::time::{Duration, Instant};
5
6const SHIM: &str = include_str!("webmcp-shim.js");
7const SHIM_HASH: &str = "911b9c665b7bf8183de1d4eeebb4a96d72ddcfe7b6b222316998c595b410fe53";
8const GATE: &str = "__slicc_webmcp_gate";
9const PERMIT: &str = "__slicc_webmcp_admitted";
10
11fn vetted_source(source: &str) -> Result<&str, String> {
12 if crate::hash::sha256_hex(source.as_bytes()) != SHIM_HASH { return Err("slicc: refused: WebMCP shim integrity check failed\n".into()); }
13 Ok(source)
14}
15
16fn private_bridge(fill: impl FnOnce(&mut [u8]) -> Result<(), getrandom::Error>) -> Result<String, String> {
17 let mut bytes = [0; 16];
18 fill(&mut bytes).map_err(|_| "slicc: refused: WebMCP bridge entropy unavailable\n")?;
19 Ok(format!("__wmcp_{}", bytes.iter().map(|byte| format!("{byte:02x}")).collect::<String>()))
20}
21
22struct Arm {
23 source: String,
24 bridge: String,
25 identifier: Option<String>,
26 admitted: usize,
27 revoked: bool,
28 revoked_at: Option<(&'static str, u32)>,
29 revoked_reason: Option<&'static str>,
30 pending_contexts: BTreeMap<i64, Instant>,
31 pending_install: Option<Instant>,
32 pending_frames: BTreeMap<String, (String, Instant)>,
33 accepted: BTreeSet<(String, String, String)>,
34 loaders: BTreeMap<String, String>,
35 documents: BTreeMap<String, Value>,
36}
37
38#[derive(Clone, PartialEq)]
39struct Context {
40 frame: String,
41 origin: String,
42 unique: String,
43 loader: String,
44}
45
46#[derive(Default)]
47pub(super) struct Webmcp {
48 arms: BTreeMap<String, Arm>,
49 contexts: BTreeMap<(String, i64), Context>,
50 scripts: BTreeMap<(String, String), i64>,
51 paused: BTreeSet<String>,
52 handling_gate: bool,
53 installing_children: BTreeSet<String>,
54 pending_gates: VecDeque<(Value, Option<(i64, Context)>)>,
55 pub(super) deferred: BTreeSet<String>,
56 pub(super) quarantined: BTreeSet<String>,
57 parents: BTreeMap<String, String>,
58 children: Vec<(String, String, String)>,
59 samples: Vec<u128>,
60 trace_sequence: u64,
61 trace_lifecycle: BTreeMap<(String, &'static str), u64>,
62 trace_requests: BTreeMap<(String, String), (String, u64)>,
63 trace_fetch: BTreeMap<String, Value>,
64 trace_roles: BTreeMap<String, &'static str>,
65}
66
67impl Webmcp {
68 pub(super) fn forget(&mut self, session: &str) {
69 self.arms.remove(session);
70 self.contexts.retain(|(owner, _), _| owner != session);
71 self.scripts.retain(|(owner, _), _| owner != session);
72 self.paused.remove(session);
73 self.installing_children.remove(session);
74 self.pending_gates.retain(|(value, _)| value.get("sessionId").and_then(Value::as_str) != Some(session) && value.pointer("/params/sessionId").and_then(Value::as_str) != Some(session));
75 self.deferred.remove(session);
76 self.quarantined.remove(session);
77 self.parents.retain(|child, parent| child != session && parent != session);
78 self.children.retain(|(_, owner, _)| owner != session);
79 }
80}
81
82impl Cdp {
83 pub fn start_webmcp(&mut self, session: &str) -> Result<String, String> {
84 self.admit("Runtime.enable", &json!({}), Some(session))?;
85 let tree = self.webmcp_stable(session, Instant::now() + DOCUMENT_CHECK, |cdp, left| {
86 let target = cdp.session_target(session).ok_or_else(|| cdp.refusal())?;
87 let guard = cdp.guards.get(&target).cloned().ok_or_else(|| cdp.refusal())?;
88 let tree = cdp.raw_call("Page.getFrameTree", json!({}), Some(&guard), left.min(OPTIONAL_STEP))?;
89 if !cdp.webmcp_tree_verified(&tree, None) || cdp.content_blocked(session) || cdp.unobserved.contains(&target) {
90 return Err(cdp.refusal());
91 }
92 Ok(tree)
93 })?;
94 self.webmcp_install(session, false, Some(&tree))
95 }
96
97 fn webmcp_stable<T>(&mut self, session: &str, deadline: Instant, mut attempt: impl FnMut(&mut Self, Duration) -> Result<T, String>) -> Result<T, String> {
98 loop {
99 let left = deadline.saturating_duration_since(Instant::now());
100 if left.is_zero() || self.blocked.contains(session) || self.webmcp.arms.get(session).is_some_and(|arm| arm.revoked) { return Err(self.refusal()); }
101 let epoch = self.frame_epoch;
102 let result = attempt(self, left);
103 if result.as_ref().is_err_and(|message| epoch != self.frame_epoch && !transport_error(message)) { continue; }
104 return result;
105 }
106 }
107
108 pub fn webmcp_auto_attach(&mut self, session: &str) -> Result<(), String> {
109 if !self.webmcp.arms.contains_key(session) || self.content_blocked(session) {
110 return Err(self.refusal());
111 }
112 self.raw_call("Target.setAutoAttach", json!({"autoAttach":true,"waitForDebuggerOnStart":true,"flatten":true,"filter":[{"type":"iframe","exclude":false}]}), Some(session), OPTIONAL_STEP)?;
113 Ok(())
114 }
115
116 pub fn take_webmcp_children(&mut self) -> Vec<(String, String, String)> {
117 std::mem::take(&mut self.webmcp.children)
118 }
119
120 pub fn webmcp_pause_micros(&self) -> &[u128] {
121 &self.webmcp.samples
122 }
123
124 pub fn webmcp_document(&mut self, session: &str, frame: &str) -> Result<Value, String> {
125 self.webmcp_stable(session, Instant::now() + DOCUMENT_CHECK, |cdp, left| cdp.webmcp_document_once(session, frame, left))
126 }
127
128 fn webmcp_document_once(&mut self, session: &str, frame: &str, left: Duration) -> Result<Value, String> {
129 if self.content_blocked(session) { return Err(self.refusal()); }
130 let target = self.session_target(session).ok_or_else(|| self.refusal())?;
131 let guard = self.guards.get(&target).cloned().ok_or_else(|| self.refusal())?;
132 let epoch = self.frame_epoch;
133 let tree = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), left.min(OPTIONAL_STEP))?;
134 if epoch != self.frame_epoch || !self.webmcp_tree_verified(&tree, Some(frame)) || self.content_blocked(session) { return Err(self.refusal()); }
135 let document = Self::webmcp_frame(&tree["frameTree"], frame).ok_or_else(|| self.refusal())?;
136 Ok(json!({"frameId":frame,"loaderId":document["loaderId"],"origin":document["securityOrigin"],"parentFrameId":document.get("parentId")}))
137 }
138
139 pub fn webmcp_snapshot(&self) -> Vec<Value> {
140 self.webmcp.arms.iter().filter(|(session, arm)| !arm.revoked && !self.webmcp.quarantined.contains(*session) && !self.content_blocked(session)).map(|(session, arm)| {
141 json!({"session":session,"bridge":arm.bridge,"target":self.session_target(session),"documents":arm.documents})
142 }).collect()
143 }
144
145 pub fn webmcp_alive(&self, session: &str) -> bool {
146 self.webmcp.arms.get(session).is_some_and(|arm| !arm.revoked) && !self.content_blocked(session)
147 }
148
149 pub fn webmcp_settled(&self) -> bool {
150 self.webmcp.arms.iter().all(|(session, arm)| {
151 let quarantined = self.webmcp.quarantined.contains(session);
152 arm.revoked || self.blocked.contains(session) || !quarantined && (self.content_blocked(session)
153 || arm.pending_install.is_none() && arm.pending_contexts.is_empty() && arm.pending_frames.is_empty())
154 })
155 }
156
157 pub fn webmcp_generations(&self) -> Vec<Value> {
158 let mut generations = Vec::new();
159 for (session, arm, target) in self.webmcp.arms.iter().filter_map(|(session, arm)| self.session_target(session).map(|target| (session, arm, target))) {
160 let document = self.documents.get(&target).is_some_and(|doc| !doc.navigating && !doc.probed && !doc.previous && doc.unreachable_url.is_empty());
161 let live = document && !arm.revoked && !self.webmcp.quarantined.contains(session) && !self.content_blocked(session) && !self.unobserved.contains(&target);
162 let admitted = |context: &&Context| arm.loaders.get(&context.frame) == Some(&context.loader) && !arm.pending_frames.contains_key(&context.frame)
163 && !self.frame_protected(&context.frame) && !self.guard.url(&context.origin) && arm.accepted.contains(&(context.frame.clone(), context.loader.clone(), context.unique.clone()));
164 let contexts = self.webmcp.contexts.iter().filter(|((owner, _), _)| live && owner == session).map(|(_, context)| context).filter(admitted);
165 generations.extend(contexts.map(|context| json!({"session":session,"target":target,"frameId":context.frame,"loaderId":context.loader,"uniqueId":context.unique,"origin":context.origin,"bridge":arm.bridge})));
166 }
167 generations
168 }
169
170 pub(super) fn webmcp_sweep(&mut self) {
171 let now = Instant::now();
172 let stale = self.webmcp.arms.iter().filter(|(_, arm)| !arm.revoked && (arm.pending_install.is_some_and(|deadline| deadline <= now) || arm.pending_contexts.values().any(|deadline| *deadline <= now) || arm.pending_frames.values().any(|(_, deadline)| *deadline <= now))).map(|(session, _)| session.clone()).collect::<Vec<_>>();
173 for session in stale {
174 let reason = self.webmcp.arms.get(&session).map(|arm| if arm.pending_install.is_some_and(|deadline| deadline <= now) { "installer deadline" } else if arm.pending_contexts.values().any(|deadline| *deadline <= now) { "context deadline" } else { "frame deadline" }).unwrap_or("missing holder");
175 self.webmcp_revoke_because(&session, reason);
176 let _ = self.send("Debugger.resume", json!({}), Some(&session));
177 }
178 }
179
180 pub fn stop_webmcp(&mut self, session: &str) {
181 self.webmcp_revoke(session);
182 let _ = self.raw_call("Target.setAutoAttach", json!({"autoAttach":false,"waitForDebuggerOnStart":false,"flatten":true}), Some(session), Duration::from_secs(2));
183 let _ = self.raw_call("Debugger.resume", json!({}), Some(session), Duration::from_secs(2));
184 self.webmcp.arms.remove(session);
185 }
186
187 fn webmcp_install(&mut self, session: &str, pending: bool, seed: Option<&Value>) -> Result<String, String> {
188 let shim = vetted_source(SHIM)?;
189 let bridge = private_bridge(getrandom::fill)?;
190 let source = format!("const {bridge} = (function {GATE}() {{ 'use strict'; let {PERMIT} = false; debugger; if (!{PERMIT}) return; return {shim}; }})();");
191 let mut loaders = BTreeMap::new();
192 if let Some(tree) = seed { Self::webmcp_loaders(&tree["frameTree"], &mut loaders); }
193 self.webmcp.arms.insert(session.to_string(), Arm { source: source.clone(), bridge: bridge.clone(), identifier: None, admitted: 0, revoked: false, revoked_at: None, revoked_reason: None, pending_contexts: BTreeMap::new(), pending_install: pending.then(|| Instant::now() + Duration::from_secs(2)), pending_frames: BTreeMap::new(), accepted: BTreeSet::new(), loaders, documents: BTreeMap::new() });
194 self.raw_call("Runtime.disable", json!({}), Some(session), OPTIONAL_STEP)?;
195 self.raw_call("Runtime.enable", json!({}), Some(session), OPTIONAL_STEP)?;
196 self.worlds.enable(session);
197 self.raw_call("Page.enable", json!({}), Some(session), OPTIONAL_STEP)?;
198 self.raw_call("Network.enable", json!({}), Some(session), OPTIONAL_STEP)?;
199 self.raw_call("Debugger.enable", json!({}), Some(session), OPTIONAL_STEP)?;
200 let added = self.raw_call("Page.addScriptToEvaluateOnNewDocument", json!({"source":source,"runImmediately":seed.is_some()}), Some(session), OPTIONAL_STEP);
201 let identifier = added.ok().and_then(|result| result.get("identifier").and_then(Value::as_str).map(str::to_string));
202 let Some(identifier) = identifier else {
203 self.webmcp_revoke(session);
204 self.block(session);
205 return Err(self.refusal());
206 };
207 if let Some(arm) = self.webmcp.arms.get_mut(session) {
208 arm.identifier = Some(identifier);
209 }
210 if !pending {
211 let until = Instant::now() + Duration::from_secs(2);
212 while self.webmcp.arms.get(session).is_some_and(|arm| (arm.admitted == 0 || !arm.pending_contexts.is_empty()) && !arm.revoked) && Instant::now() < until {
213 if !self.pump(until)? { break; }
214 }
215 if self.webmcp.arms.get(session).is_none_or(|arm| arm.admitted == 0 || !arm.pending_contexts.is_empty() || arm.revoked) {
216 self.webmcp_revoke(session);
217 let _ = self.raw_call("Debugger.resume", json!({}), Some(session), Duration::from_secs(2));
218 return Err(self.refusal());
219 }
220 }
221 if self.webmcp.arms.get(session).is_some_and(|arm| arm.revoked) {
222 self.webmcp_revoke(session);
223 return Err(self.refusal());
224 }
225 Ok(bridge)
226 }
227
228 pub(super) fn webmcp_is_paused(&self, session: &str) -> bool {
229 self.webmcp.paused.contains(session)
230 }
231
232 #[track_caller]
233 pub(super) fn webmcp_revoke(&mut self, session: &str) {
234 self.webmcp_revoke_because(session, "guard or lifecycle refusal");
235 }
236
237 #[track_caller]
238 fn webmcp_revoke_because(&mut self, session: &str, reason: &'static str) {
239 let location = std::panic::Location::caller();
240 if let Some(arm) = self.webmcp.arms.get_mut(session) {
241 if arm.revoked_at.is_none() { arm.revoked_at = Some((location.file(), location.line())); arm.revoked_reason = Some(reason); }
242 arm.revoked = true;
243 if let Some(identifier) = arm.identifier.take() {
244 let _ = self.send("Page.removeScriptToEvaluateOnNewDocument", json!({"identifier":identifier}), Some(session));
245 }
246 }
247 self.webmcp_trace_stage(session, "revoke", reason);
248 }
249
250 pub fn webmcp_trace_result<T>(&self, session: &str, stage: &str, result: Result<T, String>) -> Result<T, String> {
251 if result.is_err() { self.webmcp_trace_stage(session, "holder-wait-refused", stage); }
252 result
253 }
254
255 pub fn webmcp_trace_stage(&self, session: &str, event: &str, reason: &str) {
256 if std::env::var("SLICC_WEBMCP_TRACE").ok().as_deref() != Some("1") { return; }
257 let arm = self.webmcp.arms.get(session);
258 let location = arm.and_then(|arm| arm.revoked_at).map(|(file, line)| format!("{}:{line}", file.rsplit('/').next().unwrap_or("guard")));
259 let child = self.webmcp.parents.contains_key(session);
260 let trace = json!({"event":event,"reason":reason,"sessionRole":if child {"child"} else {"root"},"frameRole":self.webmcp.trace_roles.get(session).copied().unwrap_or(if child {"OOPIF"} else {"unknown"}),"sequence":self.webmcp.trace_sequence,"firstReason":arm.and_then(|arm| arm.revoked_reason),"firstLocation":location,"revoked":arm.is_some_and(|arm| arm.revoked),"blocked":self.blocked.contains(session),"contentBlocked":self.content_blocked(session),"pendingInstall":arm.is_some_and(|arm| arm.pending_install.is_some()),"pendingContexts":arm.map(|arm| arm.pending_contexts.len()),"pendingFrames":arm.map(|arm| arm.pending_frames.len()),"page":self.webmcp_trace_page(session)});
261 eprintln!("webmcp-polyfill: {trace}");
262 }
263
264 fn webmcp_frame_refusal(&self, frame: &Value) -> Option<&'static str> {
265 let text = |key: &str| frame.get(key).and_then(Value::as_str).unwrap_or("");
266 if text("url").is_empty() { return Some("empty document URL"); }
267 let Ok(url) = url::Url::parse(text("url")) else { return Some("unknown document URL"); };
268 if self.guard.url(text("url")) || self.guard.url(text("securityOrigin")) { return Some("protected document"); }
269 if !matches!(url.scheme(), "http" | "https") { return Some("non-HTTP document"); }
270 if url.origin().ascii_serialization() != text("securityOrigin") { return Some("document origin mismatch"); }
271 if text("id").is_empty() { return Some("missing frame id"); }
272 if text("loaderId").is_empty() { return Some("missing document loader"); }
273 if !text("unreachableUrl").is_empty() { return Some("unreachable document"); }
274 None
275 }
276
277 fn webmcp_frame_verified(&self, frame: &Value) -> bool {
278 self.webmcp_frame_refusal(frame).is_none()
279 }
280
281 fn webmcp_tree_verified(&self, tree: &Value, frame: Option<&str>) -> bool {
282 fn find(cdp: &Cdp, node: &Value, frame: Option<&str>) -> bool {
283 let current = &node["frame"];
284 let id = current.get("id").and_then(Value::as_str).unwrap_or("");
285 if !cdp.webmcp_frame_verified(current) || cdp.frame_protected(id) { return false; }
286 if frame.is_none_or(|frame| frame == id) { return true; }
287 node.get("childFrames").and_then(Value::as_array).is_some_and(|children| children.iter().any(|child| find(cdp, child, frame)))
288 }
289 tree.get("frameTree").is_some_and(|node| find(self, node, frame))
290 }
291
292 fn webmcp_loaders(node: &Value, out: &mut BTreeMap<String, String>) {
293 if let (Some(id), Some(loader)) = (node.pointer("/frame/id").and_then(Value::as_str), node.pointer("/frame/loaderId").and_then(Value::as_str)) {
294 if !loader.is_empty() { out.insert(id.to_string(), loader.to_string()); }
295 }
296 for child in node.get("childFrames").and_then(Value::as_array).into_iter().flatten() { Self::webmcp_loaders(child, out); }
297 }
298
299 fn webmcp_frame<'a>(node: &'a Value, frame: &str) -> Option<&'a Value> {
300 if node.pointer("/frame/id").and_then(Value::as_str) == Some(frame) { return node.get("frame"); }
301 node.get("childFrames").and_then(Value::as_array)?.iter().find_map(|child| Self::webmcp_frame(child, frame))
302 }
303
304 fn webmcp_verify_pause(&mut self, session: &str, paused: &Value, deadline: Instant) -> Result<(String, u64, i64, Context), String> {
305 let call = paused.pointer("/params/callFrames/0").ok_or_else(|| self.refusal())?;
306 if call.get("functionName").and_then(Value::as_str) != Some(GATE) { return Err(self.refusal()); }
307 let script = call.pointer("/location/scriptId").and_then(Value::as_str).ok_or_else(|| self.refusal())?;
308 let context_id = *self.webmcp.scripts.get(&(session.to_string(), script.to_string())).ok_or_else(|| self.refusal())?;
309 let context = self.webmcp.contexts.get(&(session.to_string(), context_id)).cloned().ok_or_else(|| self.refusal())?;
310 let source = self.raw_call("Debugger.getScriptSource", json!({"scriptId":script}), Some(session), deadline.saturating_duration_since(Instant::now()))?;
311 if self.webmcp.arms.get(session).is_none_or(|arm| arm.revoked || source.get("scriptSource").and_then(Value::as_str) != Some(&arm.source)) {
312 return Err(self.refusal());
313 }
314 let target = self.session_target(session).ok_or_else(|| self.refusal())?;
315 let guard = self.guards.get(&target).cloned().ok_or_else(|| self.refusal())?;
316 let epoch = self.frame_epoch;
317 let mut owner = session.to_string();
318 let mut ancestors = BTreeSet::new();
319 let mut ancestor_documents = Vec::new();
320 while let Some(parent) = self.webmcp.parents.get(&owner).cloned() {
321 if !ancestors.insert(parent.clone()) || !self.webmcp_alive(&parent) {
322 return Err(self.refusal());
323 }
324 let target = self.session_target(&parent).ok_or_else(|| self.refusal())?;
325 let guard = self.guards.get(&target).cloned().ok_or_else(|| self.refusal())?;
326 if self.documents.get(&target).is_none_or(|doc| doc.navigating || doc.probed || doc.previous) {
327 return Err(self.refusal());
328 }
329 let ancestor = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), deadline.saturating_duration_since(Instant::now()))?;
330 if !self.webmcp_tree_verified(&ancestor, None) { return Err(self.refusal()); }
331 ancestor_documents.push((parent.clone(), guard, ancestor["frameTree"]["frame"].clone()));
332 owner = parent;
333 }
334 let tree = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), deadline.saturating_duration_since(Instant::now()))?;
335 if self.webmcp_tree_verified(&tree, Some(&context.frame)) {
336 self.main_commit(&guard, &tree["frameTree"]["frame"]);
337 }
338 let blocked = self.blocked.contains(session) || !self.webmcp.quarantined.contains(session) && self.content_blocked(session);
339 if !self.webmcp_tree_verified(&tree, Some(&context.frame)) || blocked || self.unobserved.contains(&target)
340 || self.documents.get(&target).is_none_or(|doc| doc.navigating || doc.probed || doc.previous || !doc.unreachable_url.is_empty()) {
341 return Err(self.refusal());
342 }
343 let frame = Self::webmcp_frame(&tree["frameTree"], &context.frame).ok_or_else(|| self.refusal())?;
344 if frame.get("securityOrigin").and_then(Value::as_str) != Some(&context.origin) || context.unique.is_empty()
345 || context.loader.is_empty() || frame.get("loaderId").and_then(Value::as_str) != Some(&context.loader) {
346 return Err(self.refusal());
347 }
348 let fresh = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), deadline.saturating_duration_since(Instant::now()))?;
349 let next = Self::webmcp_frame(&fresh["frameTree"], &context.frame).ok_or_else(|| self.refusal())?;
350 if epoch != self.frame_epoch || !self.webmcp_tree_verified(&fresh, Some(&context.frame))
351 || frame.get("loaderId") != next.get("loaderId") || frame.get("url") != next.get("url")
352 || self.session_target(session).as_deref() != Some(&target) || self.guards.get(&target) != Some(&guard)
353 || self.webmcp.contexts.get(&(session.to_string(), context_id)) != Some(&context) {
354 return Err(self.refusal());
355 }
356 for (parent, guard, document) in ancestor_documents {
357 let ancestor = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), deadline.saturating_duration_since(Instant::now()))?;
358 let current = &ancestor["frameTree"]["frame"];
359 if !self.webmcp_alive(&parent) || !self.webmcp_tree_verified(&ancestor, None) || current.get("loaderId") != document.get("loaderId") || current.get("url") != document.get("url") {
360 return Err(self.refusal());
361 }
362 }
363 if epoch != self.frame_epoch { return Err(self.refusal()); }
364 let call_id = call.get("callFrameId").and_then(Value::as_str).ok_or_else(|| self.refusal())?.to_string();
365 Ok((call_id, epoch, context_id, context))
366 }
367
368 fn webmcp_provisional_child(&self, top: &Value, frame: &Value, context: &Context) -> bool {
369 top.get("id").and_then(Value::as_str).is_some_and(|id| !id.is_empty() && frame.get("parentId").and_then(Value::as_str) == Some(id))
370 && frame.get("url").and_then(Value::as_str).is_some_and(|url| url.is_empty() || url == "about:blank")
371 && frame.get("unreachableUrl").and_then(Value::as_str).is_none_or(|url| url.is_empty())
372 && frame.get("securityOrigin").and_then(Value::as_str).is_some_and(|origin| origin != context.origin && !self.guard.url(origin))
373 && !self.guard.url(&context.origin)
374 }
375
376 fn webmcp_keep_parent_installer(&mut self, session: &str, context: &Context, id: i64, deadline: Instant) -> bool {
377 let skip = |cdp: &Cdp, reason: &str| { cdp.webmcp_trace_stage(session, "parent-retention-skipped", reason); false };
378 let Some(target) = self.session_target(session) else { return skip(self, "missing target"); };
379 let Some(guard) = self.guards.get(&target).cloned() else { return skip(self, "missing guard"); };
380 if self.content_blocked(session) || self.unobserved.contains(&target) || context.frame.is_empty() || context.unique.is_empty() { return skip(self, "blocked or unknown child evidence"); }
381 let epoch = self.frame_epoch;
382 let Ok(tree) = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), deadline.saturating_duration_since(Instant::now()).min(OPTIONAL_STEP)) else { return skip(self, "parent tree read failed"); };
383 let top = &tree["frameTree"]["frame"];
384 if self.webmcp.contexts.get(&(session.to_string(), id)) == Some(context) && Self::webmcp_frame(&tree["frameTree"], &context.frame).is_some_and(|frame| (frame.get("url").and_then(Value::as_str) == Some("about:blank") || !self.webmcp_frame_verified(frame)) && !self.webmcp_provisional_child(top, frame, context)) { return skip(self, "live blank or unverified child"); }
385 if top.get("id").and_then(Value::as_str) == Some(&context.frame) || !self.webmcp_tree_verified(&tree, None)
386 || self.documents.get(&target).is_none_or(|document| document.navigating || document.previous || document.probed || !document.unreachable_url.is_empty()) { return skip(self, "parent document not verified or unsettled"); }
387 let Some(arm) = self.webmcp.arms.get(session) else { return skip(self, "missing parent arm"); };
388 if arm.revoked || arm.identifier.is_none() || !arm.accepted.iter().any(|(frame, loader, _)| top.get("id").and_then(Value::as_str) == Some(frame) && top.get("loaderId").and_then(Value::as_str) == Some(loader)) { return skip(self, "parent current loader not admitted"); }
389 let Ok(fresh) = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), deadline.saturating_duration_since(Instant::now()).min(OPTIONAL_STEP)) else { return skip(self, "fresh parent tree read failed"); };
390 if epoch != self.frame_epoch || Instant::now() >= deadline || fresh["frameTree"]["frame"] != *top || !self.webmcp_tree_verified(&fresh, None)
391 || self.content_blocked(session) || self.session_target(session).as_deref() != Some(&target) || self.guards.get(&target) != Some(&guard) { return skip(self, "parent changed or retention deadline"); }
392 let Some(arm) = self.webmcp.arms.get_mut(session).filter(|arm| !arm.revoked && arm.identifier.is_some()) else { return skip(self, "parent arm revoked during check"); };
393 arm.pending_contexts.remove(&id);
394 arm.pending_frames.remove(&context.frame);
395 arm.accepted.retain(|(frame, _, _)| frame != &context.frame);
396 arm.documents.retain(|_, document| document.get("frameId").and_then(Value::as_str) != Some(&context.frame) || document.get("loaderId").and_then(Value::as_str) != Some(&context.loader));
397 self.webmcp_trace_stage(session, "parent-installer-retained", "abandoned local child gate");
398 true
399 }
400
401 fn webmcp_handle_pause(&mut self, value: &Value, session: &str, child: Option<(i64, Context)>) {
402 let started = Instant::now();
403 self.webmcp.paused.insert(session.to_string());
404 let deadline = started + Duration::from_secs(2);
405 let (verified, mut admitted) = loop {
406 let epoch = self.frame_epoch;
407 let (verified, admitted) = self.webmcp_admit_pause(value, session, deadline);
408 let settled = admitted || epoch == self.frame_epoch || Instant::now() >= deadline || verified.as_ref().is_err_and(|message| transport_error(message));
409 if settled || self.blocked.contains(session) || self.webmcp.arms.get(session).is_none_or(|arm| arm.revoked) { break (verified, admitted); }
410 };
411 let kept = !admitted && verified.as_ref().is_err_and(|message| !transport_error(message)) && child.as_ref().is_some_and(|(id, context)| self.webmcp_keep_parent_installer(session, context, *id, deadline));
412 if !admitted && !kept {
413 let removable = self.webmcp.arms.get(session).is_some_and(|arm| arm.identifier.is_some());
414 let previous = self.webmcp.arms.get(session).filter(|arm| arm.revoked).and_then(|arm| arm.revoked_at)
415 .map(|(file, line)| format!(" installer revoked at {}:{line};", file.rsplit('/').next().unwrap_or("guard"))).unwrap_or_default();
416 self.webmcp_revoke_because(session, "gate not admitted");
417 let rearmed = if removable { self.webmcp_rearm_blank_child(session, value, deadline) } else { Err(self.refusal()) };
418 if let Err(message) = rearmed {
419 let stage = message.strip_prefix("slicc: refused: WebMCP blank-frame rearm ").map(str::trim).unwrap_or("unverified gate");
420 self.webmcp_trace_stage(session, "gate-refused", stage);
421 self.notices.push(format!("webmcp-polyfill: document gate refused;{previous} blank-frame rearm {stage}\n"));
422 }
423 }
424 let resumed = self.raw_call("Debugger.resume", json!({}), Some(session), Duration::from_secs(2));
425 if resumed.is_err() {
426 admitted = false;
427 self.webmcp_revoke(session);
428 if let Ok((call, _, _, _)) = &verified {
429 let _ = self.raw_call("Debugger.evaluateOnCallFrame", json!({"callFrameId":call,"expression":format!("{PERMIT} = false"),"returnByValue":true,"silent":true}), Some(session), Duration::from_secs(2));
430 }
431 let _ = self.raw_call("Debugger.resume", json!({}), Some(session), Duration::from_secs(2));
432 }
433 self.webmcp.paused.remove(session);
434 if let (true, Ok((_, _, id, context))) = (admitted, verified) {
435 if self.webmcp_mark_admitted(session, id, &context, started).is_err() { self.webmcp_revoke(session); }
436 }
437 if self.webmcp.deferred.contains(session) { self.block(session); }
438 }
439
440 fn webmcp_admit_pause(&mut self, value: &Value, session: &str, deadline: Instant) -> (Result<(String, u64, i64, Context), String>, bool) {
441 let verified = self.webmcp_verify_pause(session, value, deadline).and_then(|verified| {
442 if self.webmcp.quarantined.contains(session) {
443 self.raw_call("Target.setAutoAttach", json!({"autoAttach":true,"waitForDebuggerOnStart":true,"flatten":true,"filter":[{"type":"iframe","exclude":false}]}), Some(session), deadline.saturating_duration_since(Instant::now()))?;
444 }
445 Ok(verified)
446 });
447 let mut admitted = false;
448 if let Ok((call, epoch, id, context)) = &verified {
449 let result = self.raw_call("Debugger.evaluateOnCallFrame", json!({"callFrameId":call,"expression":format!("{PERMIT} = true"),"returnByValue":true,"silent":true}), Some(session), deadline.saturating_duration_since(Instant::now()));
450 let failure = result.as_ref().err().filter(|message| transport_error(message)).cloned();
451 admitted = Instant::now() < deadline && result.is_ok_and(|result| result.get("exceptionDetails").is_none() && result.pointer("/result/value").and_then(Value::as_bool) == Some(true))
452 && *epoch == self.frame_epoch && !self.blocked.contains(session)
453 && (self.webmcp.quarantined.contains(session) || !self.content_blocked(session))
454 && self.webmcp.contexts.get(&(session.to_string(), *id)) == Some(context)
455 && self.webmcp.arms.get(session).is_some_and(|arm| !arm.revoked);
456 if !admitted {
457 let _ = self.raw_call("Debugger.evaluateOnCallFrame", json!({"callFrameId":call,"expression":format!("{PERMIT} = false"),"returnByValue":true,"silent":true}), Some(session), Duration::from_secs(2));
458 }
459 if let Some(message) = failure { return (Err(message), false); }
460 }
461 (verified, admitted)
462 }
463
464 fn webmcp_mark_admitted(&mut self, session: &str, id: i64, expected: &Context, started: Instant) -> Result<(), String> {
465 let target = self.session_target(session).ok_or_else(|| self.refusal())?;
466 let context = self.webmcp.contexts.get(&(session.to_string(), id)).filter(|context| *context == expected).cloned().ok_or_else(|| self.refusal())?;
467 let arm = self.webmcp.arms.get_mut(session).ok_or_else(|| "slicc: refused: WebMCP holder disappeared\n".to_string())?;
468 arm.admitted += 1;
469 arm.pending_install = None;
470 arm.pending_contexts.remove(&id);
471 arm.pending_frames.remove(&context.frame);
472 if arm.accepted.len() >= 1024 { arm.accepted.clear(); }
473 arm.accepted.insert((context.frame, context.loader, context.unique));
474 let bridge = arm.bridge.clone();
475 self.webmcp.samples.push(started.elapsed().as_micros());
476 if self.webmcp.quarantined.remove(session) {
477 self.guard_sessions.remove(session);
478 self.webmcp.children.push((target, session.to_string(), bridge));
479 }
480 Ok(())
481 }
482
483 fn webmcp_rearm_blank_child(&mut self, session: &str, paused: &Value, deadline: Instant) -> Result<(), String> {
484 loop {
485 let epoch = self.frame_epoch;
486 let result = self.webmcp_rearm_blank_child_once(session, paused, deadline);
487 if result.as_ref().is_err_and(|message| epoch != self.frame_epoch && !transport_error(message)) && Instant::now() < deadline && !self.content_blocked(session) { continue; }
488 return result;
489 }
490 }
491
492 fn webmcp_rearm_blank_child_once(&mut self, session: &str, paused: &Value, deadline: Instant) -> Result<(), String> {
493 if self.content_blocked(session) || self.webmcp.quarantined.contains(session) { return Err(self.refusal()); }
494 let call = paused.pointer("/params/callFrames/0").ok_or_else(|| self.refusal())?;
495 if call.get("functionName").and_then(Value::as_str) != Some(GATE) { return Err(self.refusal()); }
496 let script = call.pointer("/location/scriptId").and_then(Value::as_str).ok_or_else(|| self.refusal())?;
497 let id = *self.webmcp.scripts.get(&(session.to_string(), script.to_string())).ok_or_else(|| self.refusal())?;
498 let context = self.webmcp.contexts.get(&(session.to_string(), id)).cloned().ok_or_else(|| "slicc: refused: WebMCP blank-frame rearm lost context\n".to_string())?;
499 let target = self.session_target(session).ok_or_else(|| self.refusal())?;
500 let guard = self.guards.get(&target).cloned().ok_or_else(|| self.refusal())?;
501 let epoch = self.frame_epoch;
502 let tree = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), deadline.saturating_duration_since(Instant::now()))?;
503 let top = &tree["frameTree"]["frame"];
504 let child = Self::webmcp_frame(&tree["frameTree"], &context.frame).ok_or_else(|| "slicc: refused: WebMCP blank-frame rearm lost child frame\n".to_string())?;
505 let arm = self.webmcp.arms.get(session).ok_or_else(|| self.refusal())?;
506 if epoch != self.frame_epoch || Instant::now() >= deadline { return Err("slicc: refused: WebMCP blank-frame rearm changed frame epoch\n".into()); }
507 if !self.webmcp_tree_verified(&tree, None) || self.documents.get(&target).is_none_or(|document| document.navigating || document.previous || document.probed) {
508 return Err("slicc: refused: WebMCP blank-frame rearm unverified parent document\n".into());
509 }
510 if child.get("url").and_then(Value::as_str) != Some("about:blank") || child.get("unreachableUrl").and_then(Value::as_str).is_some_and(|url| !url.is_empty())
511 || child.get("parentId") != top.get("id") || child.get("securityOrigin") != top.get("securityOrigin")
512 || child.get("securityOrigin").and_then(Value::as_str) != Some(&context.origin) || context.unique.is_empty() {
513 return Err("slicc: refused: WebMCP blank-frame rearm unverified child document\n".into());
514 }
515 if !arm.accepted.iter().any(|(frame, loader, _)| Some(frame.as_str()) == top.get("id").and_then(Value::as_str) && Some(loader.as_str()) == top.get("loaderId").and_then(Value::as_str)) {
516 return Err("slicc: refused: WebMCP blank-frame rearm parent gate not admitted\n".into());
517 }
518 let source = arm.source.clone();
519 let result = self.raw_call("Page.addScriptToEvaluateOnNewDocument", json!({"source":source,"runImmediately":false}), Some(session), deadline.saturating_duration_since(Instant::now()))?;
520 let identifier = result.get("identifier").and_then(Value::as_str).ok_or_else(|| self.refusal())?.to_string();
521 let arm = self.webmcp.arms.get_mut(session).ok_or_else(|| "slicc: refused: WebMCP holder disappeared\n".to_string())?;
522 arm.identifier = Some(identifier);
523 if epoch != self.frame_epoch || Instant::now() >= deadline || self.blocked.contains(session) { self.webmcp_revoke(session); return Err(self.refusal()); }
524 let arm = self.webmcp.arms.get_mut(session).unwrap();
525 arm.revoked = false;
526 arm.pending_contexts.remove(&id);
527 arm.pending_frames.remove(&context.frame);
528 Ok(())
529 }
530
531 fn webmcp_adopt_child(&mut self, value: &Value, parent: &str) {
532 let Some(child) = value.pointer("/params/sessionId").and_then(Value::as_str) else { return };
533 let info = &value["params"]["targetInfo"];
534 let target = info.get("targetId").and_then(Value::as_str).unwrap_or("");
535 let url = info.get("url").and_then(Value::as_str).unwrap_or("");
536 let allowed = info.get("type").and_then(Value::as_str) == Some("iframe") && !target.is_empty()
537 && (url.is_empty() || url == "about:blank" || url::Url::parse(url).is_ok_and(|url| matches!(url.scheme(), "http" | "https")))
538 && !self.guard.url(url) && !self.info_protected(info, &[]) && !self.content_blocked(parent);
539 self.session_targets.insert(child.to_string(), target.to_string());
540 self.guard_sessions.insert(child.to_string());
541 self.webmcp.quarantined.insert(child.to_string());
542 self.webmcp.parents.insert(child.to_string(), parent.to_string());
543 self.targets.insert(target.to_string(), info.clone());
544 let mut stage = "target";
545 let installed = (|| -> Result<(), String> {
546 if !allowed { return Err(self.refusal()); }
547 stage = "guard setup";
548 self.touch(target)?;
549 stage = "protected target";
550 if self.is_protected_target(target) { return Err(self.refusal()); }
551 stage = "guard frame tree";
552 let guard = self.guards.get(target).cloned().ok_or_else(|| self.refusal())?;
553 let seed = self.webmcp_stable(child, Instant::now() + DOCUMENT_CHECK, |cdp, left| cdp.raw_call("Page.getFrameTree", json!({}), Some(&guard), left.min(OPTIONAL_STEP)))?;
554 let verified = self.webmcp_tree_verified(&seed, None);
555 let future_only = seed.pointer("/frameTree/frame/id").and_then(Value::as_str) == Some(target)
556 && seed.pointer("/frameTree/frame/url").and_then(Value::as_str).is_some_and(|url| url.is_empty() || url == "about:blank")
557 && seed.pointer("/frameTree/frame/unreachableUrl").and_then(Value::as_str).is_none_or(|url| url.is_empty())
558 && self.webmcp_alive(parent);
559 if !verified && !future_only { return Err(self.refusal()); }
560 stage = "pinned installer";
561 self.webmcp.installing_children.insert(child.to_string());
562 let installed = self.webmcp_install(child, true, verified.then_some(&seed));
563 self.webmcp.installing_children.remove(child);
564 installed?;
565 Ok(())
566 })().is_ok();
567 let _ = self.raw_call("Runtime.runIfWaitingForDebugger", json!({}), Some(child), Duration::from_secs(2));
568 if !installed {
569 self.notices.push(format!("webmcp-polyfill: child refused at {stage}\n"));
570 self.webmcp_revoke(child);
571 let _ = self.raw_call("Target.detachFromTarget", json!({"sessionId":child}), None, Duration::from_secs(2));
572 self.blocked.insert(child.to_string());
573 }
574 }
575
576 pub(super) fn webmcp_cancel_queued(&mut self, session: &str) {
577 let cancelled = self.webmcp.pending_gates.iter().filter(|(value, _)| value.get("sessionId").and_then(Value::as_str) == Some(session) || value.pointer("/params/sessionId").and_then(Value::as_str) == Some(session)).map(|(value, _)| value.clone()).collect::<Vec<_>>();
578 self.webmcp.pending_gates.retain(|(value, _)| value.get("sessionId").and_then(Value::as_str) != Some(session) && value.pointer("/params/sessionId").and_then(Value::as_str) != Some(session));
579 for value in cancelled {
580 let owner = value.get("sessionId").and_then(Value::as_str).unwrap_or("");
581 if value.get("method").and_then(Value::as_str) == Some("Debugger.paused") {
582 let _ = self.send("Debugger.resume", json!({}), Some(owner));
583 } else if let Some(child) = value.pointer("/params/sessionId").and_then(Value::as_str) {
584 self.webmcp_revoke(child);
585 let _ = self.send("Runtime.runIfWaitingForDebugger", json!({}), Some(child));
586 let _ = self.send("Debugger.resume", json!({}), Some(child));
587 let _ = self.send("Target.detachFromTarget", json!({"sessionId":child}), None);
588 }
589 }
590 }
591
592 fn webmcp_gate_event(&mut self, value: &Value) {
593 if value.get("method").and_then(Value::as_str) == Some("Debugger.paused") {
594 if let Some(session) = value.get("sessionId").and_then(Value::as_str) { self.webmcp.paused.insert(session.to_string()); }
595 }
596 let owner = value.get("sessionId").and_then(Value::as_str).unwrap_or("");
597 let context = value.pointer("/params/callFrames/0/location/scriptId").and_then(Value::as_str).and_then(|script| self.webmcp.scripts.get(&(owner.to_string(), script.to_string()))).and_then(|id| self.webmcp.contexts.get(&(owner.to_string(), *id)).cloned().map(|context| (*id, context)));
598 if self.webmcp.handling_gate && self.webmcp.installing_children.contains(owner) && value.get("method").and_then(Value::as_str) == Some("Debugger.paused") {
599 self.webmcp_handle_pause(value, owner, context);
600 return;
601 }
602 self.webmcp.pending_gates.push_back((value.clone(), context));
603 if self.webmcp.handling_gate { return; }
604 self.webmcp.handling_gate = true;
605 while let Some((gate, context)) = self.webmcp.pending_gates.pop_front() {
606 let owner = gate.get("sessionId").and_then(Value::as_str).unwrap_or("");
607 if gate.get("method").and_then(Value::as_str) == Some("Target.attachedToTarget") {
608 self.webmcp_adopt_child(&gate, owner);
609 } else {
610 self.webmcp_handle_pause(&gate, owner, context);
611 }
612 }
613 self.webmcp.handling_gate = false;
614 }
615
616 fn webmcp_trace_scheme(value: &str) -> &'static str {
617 if value.is_empty() { return "empty"; }
618 let Ok(url) = url::Url::parse(value) else { return "invalid"; };
619 match (url.scheme(), url.path()) {
620 ("about", "blank") => "about:blank",
621 ("about", "srcdoc") => "about:srcdoc",
622 ("about", _) => "about:other",
623 ("chrome-error", _) => "chrome-error",
624 ("http", _) => "http",
625 ("https", _) => "https",
626 ("data", _) => "data",
627 ("blob", _) => "blob",
628 ("file", _) => "file",
629 ("chrome", _) => "chrome",
630 ("chrome-extension", _) => "extension",
631 _ => "other",
632 }
633 }
634
635 fn webmcp_trace_frame(&self, frame: &Value) -> Value {
636 let text = |key| frame.get(key).and_then(Value::as_str).unwrap_or("");
637 json!({"scheme":Self::webmcp_trace_scheme(text("url")),"parentIdPresent":frame.get("parentId").is_some(),"unreachableUrlPresent":frame.get("unreachableUrl").is_some(),"unreachableUrlNonempty":!text("unreachableUrl").is_empty(),"loaderPresent":!text("loaderId").is_empty(),"refusal":self.webmcp_frame_refusal(frame)})
638 }
639
640 fn webmcp_trace_order(&self, target: &str) -> Value {
641 let mut order = serde_json::Map::new();
642 for method in ["attachedToTarget", "detachedFromTarget", "targetInfoChanged"] {
643 order.insert(method.into(), json!(self.webmcp.trace_lifecycle.get(&(target.to_string(), method))));
644 }
645 Value::Object(order)
646 }
647
648 pub(super) fn webmcp_trace_fetch_command(&mut self, method: &str, params: &Value, session: Option<&str>) {
649 let decision = match method { "Fetch.continueRequest" => "continue", "Fetch.failRequest" => "fail", "Fetch.fulfillRequest" => "fulfill", _ => return };
650 let Some(request) = params.get("requestId").and_then(Value::as_str) else { return; };
651 let Some((frame, sequence)) = self.webmcp.trace_requests.remove(&(session.unwrap_or("").to_string(), request.to_string())) else { return; };
652 let reason = params.get("errorReason").and_then(Value::as_str).map(|reason| match reason { "BlockedByClient" => "BlockedByClient", "Aborted" => "Aborted", "TimedOut" => "TimedOut", _ => "other" });
653 if let Some(record) = self.webmcp.trace_fetch.get_mut(&frame).filter(|record| record.get("pauseSequence").and_then(Value::as_u64) == Some(sequence)) { record["decision"] = json!(decision); record["errorReason"] = json!(reason); record["decisionSequence"] = json!(self.webmcp.trace_sequence); }
654 }
655
656 fn webmcp_trace_page(&self, session: &str) -> &'static str {
657 let mut root = session;
658 let mut visited = BTreeSet::new();
659 while let Some(parent) = self.webmcp.parents.get(root) {
660 if !visited.insert(root) { return "unknown"; }
661 root = parent;
662 }
663 let url = self.session_target(root).and_then(|target| self.documents.get(&target)).map(|document| document.url.as_str()).unwrap_or("");
664 match url::Url::parse(url).ok().map(|url| url.path().to_string()).as_deref() { Some("/frames") => "frames", Some("/ordinary-frames") => "ordinary-frames", Some(_) => "other", None => "unknown" }
665 }
666
667 fn webmcp_trace_navigation(&mut self, session: &str, frame: &Value) {
668 if std::env::var("SLICC_WEBMCP_TRACE").ok().as_deref() != Some("1") { return; }
669 let sequence = self.webmcp.trace_sequence;
670 let page = self.webmcp_trace_page(session);
671 let target = self.session_target(session).unwrap_or_default();
672 let kind = self.targets.get(&target).and_then(|info| info.get("type")).and_then(Value::as_str).unwrap_or("");
673 let kind = match kind { "page" => "page", "iframe" => "iframe", "worker" => "worker", "" => "unknown", _ => "other" };
674 let before = self.webmcp_trace_order(&target);
675 let epoch = self.frame_epoch;
676 let tree = self.guards.get(&target).cloned().and_then(|guard| self.raw_exchange("Page.getFrameTree", json!({}), Some(&guard), OPTIONAL_STEP, false).ok());
677 let id = frame.get("id").and_then(Value::as_str).unwrap_or("");
678 let current = tree.as_ref().and_then(|tree| tree.get("frameTree")).and_then(|node| Self::webmcp_frame(node, id));
679 let current_metadata = current.map(|current| self.webmcp_trace_frame(current));
680 let loader_equal = current.map(|current| current.get("loaderId").and_then(Value::as_str).filter(|loader| !loader.is_empty()) == frame.get("loaderId").and_then(Value::as_str).filter(|loader| !loader.is_empty()));
681 let trace = json!({"event":"guard-navigation-trace","sequence":sequence,"targetType":kind,"eventFrame":self.webmcp_trace_frame(frame),"guardTreeAvailable":tree.is_some(),"frameInGuardTree":current.is_some(),"guardFrame":current_metadata,"loaderEqual":loader_equal,"frameEpochStable":epoch == self.frame_epoch,"lifecycleBefore":before,"lifecycleAfter":self.webmcp_trace_order(&target),"probeEndSequence":self.webmcp.trace_sequence,"documentFetch":self.webmcp.trace_fetch.get(id),"page":page,"pageAfter":self.webmcp_trace_page(session),"unreachableProtected":frame.get("unreachableUrl").and_then(Value::as_str).is_some_and(|url| !url.is_empty() && self.guard.url(url)),"targetURLProtected":self.targets.get(&target).and_then(|info| info.get("url")).and_then(Value::as_str).is_some_and(|url| self.guard.url(url))});
682 self.notices.push(format!("webmcp-polyfill: {trace}\n"));
683 }
684
685 fn webmcp_trace_event(&mut self, value: &Value) {
686 if std::env::var("SLICC_WEBMCP_TRACE").ok().as_deref() != Some("1") { return; }
687 self.webmcp.trace_sequence += 1;
688 let session = value.get("sessionId").and_then(Value::as_str).unwrap_or("");
689 let method = value.get("method").and_then(Value::as_str).unwrap_or("");
690 if method == "Page.frameNavigated" || method == "Debugger.paused" {
691 let frame = if method == "Page.frameNavigated" { value.pointer("/params/frame/id").and_then(Value::as_str) } else { value.pointer("/params/callFrames/0/location/scriptId").and_then(Value::as_str).and_then(|script| self.webmcp.scripts.get(&(session.to_string(), script.to_string()))).and_then(|id| self.webmcp.contexts.get(&(session.to_string(), *id))).map(|context| context.frame.as_str()) };
692 let top = self.session_target(session).and_then(|target| self.documents.get(&target)).map(|doc| doc.frame_id.as_str());
693 let role = if self.webmcp.parents.contains_key(session) { "OOPIF" } else if frame.is_none() { "unknown" } else if frame == top { "top" } else { "subframe" };
694 self.webmcp.trace_roles.insert(session.to_string(), role);
695 }
696 let lifecycle = match method { "Target.attachedToTarget" => Some("attachedToTarget"), "Target.detachedFromTarget" => Some("detachedFromTarget"), "Target.targetInfoChanged" => Some("targetInfoChanged"), _ => None };
697 if method == "Fetch.requestPaused" && value.pointer("/params/resourceType").and_then(Value::as_str) == Some("Document") {
698 if let (Some(request), Some(frame)) = (value.pointer("/params/requestId").and_then(Value::as_str), value.pointer("/params/frameId").and_then(Value::as_str)) {
699 if self.webmcp.trace_requests.len() >= 1024 || self.webmcp.trace_fetch.len() >= 1024 { self.webmcp.trace_requests.clear(); self.webmcp.trace_fetch.clear(); }
700 self.webmcp.trace_requests.insert((session.to_string(), request.to_string()), (frame.to_string(), self.webmcp.trace_sequence));
701 let protected = value.pointer("/params/request/url").and_then(Value::as_str).is_some_and(|url| self.guard.url(url));
702 self.webmcp.trace_fetch.insert(frame.to_string(), json!({"pauseSequence":self.webmcp.trace_sequence,"decision":"pending","destinationProtected":protected,"guardSession":self.guard_sessions.contains(session)}));
703 }
704 }
705 if let Some(lifecycle) = lifecycle {
706 let target = value.pointer("/params/targetInfo/targetId").or_else(|| value.pointer("/params/targetId")).and_then(Value::as_str).map(str::to_string)
707 .or_else(|| value.pointer("/params/sessionId").and_then(Value::as_str).and_then(|session| self.session_target(session))).unwrap_or_default();
708 if self.webmcp.trace_lifecycle.len() >= 1024 { self.webmcp.trace_lifecycle.clear(); }
709 self.webmcp.trace_lifecycle.insert((target, lifecycle), self.webmcp.trace_sequence);
710 }
711 }
712
713 pub(super) fn webmcp_event(&mut self, value: &Value) -> bool {
714 self.webmcp_trace_event(value);
715 let session = value.get("sessionId").and_then(Value::as_str).unwrap_or("");
716 let method = value.get("method").and_then(Value::as_str).unwrap_or("");
717 if method == "Target.attachedToTarget" && self.webmcp.arms.contains_key(session) {
718 self.webmcp_gate_event(value);
719 return true;
720 }
721 if matches!(method, "Page.frameDetached" | "Runtime.executionContextCreated" | "Runtime.executionContextDestroyed" | "Runtime.executionContextsCleared" | "Debugger.scriptParsed") {
722 self.webmcp_context_event(value, session, method);
723 return false;
724 }
725 if !self.webmcp.arms.contains_key(session) { return false; }
726 if method == "Network.responseReceived" && value.pointer("/params/type").and_then(Value::as_str) == Some("Document") {
727 let params = &value["params"];
728 let url = params.pointer("/response/url").and_then(Value::as_str).unwrap_or("");
729 let frame = params.get("frameId").and_then(Value::as_str).unwrap_or("");
730 let loader = params.get("loaderId").and_then(Value::as_str).unwrap_or("");
731 if !frame.is_empty() && !loader.is_empty() && !self.guard.url(url) {
732 let policy = params.pointer("/response/headers").and_then(Value::as_object).and_then(|headers| headers.iter().find(|(name, _)| name.eq_ignore_ascii_case("permissions-policy"))).map(|(_, value)| value.as_str().unwrap_or("tools=()")).unwrap_or("");
733 let policy = if policy.len() > 16384 { "tools=()" } else { policy };
734 let origin = url::Url::parse(url).map(|url| url.origin().ascii_serialization()).unwrap_or_default();
735 if let Some(arm) = self.webmcp.arms.get_mut(session) {
736 arm.documents.insert(format!("{frame}:{loader}"), json!({"frameId":frame,"loaderId":loader,"origin":origin,"policy":policy}));
737 }
738 }
739 }
740 if method == "Page.frameNavigated" {
741 let frame = &value["params"]["frame"];
742 let initial_child = (frame.get("parentId").is_some() || self.webmcp.parents.contains_key(session))
743 && frame.get("url").and_then(Value::as_str).is_some_and(|url| url.is_empty() || url == "about:blank")
744 && frame.get("unreachableUrl").and_then(Value::as_str).is_none_or(|url| url.is_empty());
745 if let Some(reason) = self.webmcp_frame_refusal(frame).filter(|_| !initial_child) {
746 self.webmcp_trace_navigation(session, frame);
747 let role = if self.webmcp.parents.contains_key(session) { "OOPIF" } else if frame.get("parentId").is_some() { "subframe" } else { "top frame" };
748 self.notices.push(format!("webmcp-polyfill: frame navigation refused ({role}, {reason})\n"));
749 self.webmcp_revoke_because(session, "frame navigation refused");
750 }
751 self.webmcp_navigated(session, value);
752 }
753 if method == "Debugger.paused" {
754 self.webmcp_gate_event(value);
755 return true;
756 }
757 false
758 }
759
760 fn webmcp_navigated(&mut self, session: &str, value: &Value) {
761 let Some(arm) = self.webmcp.arms.get_mut(session) else { return; };
762 let frame = &value["params"]["frame"];
763 Self::webmcp_loaders(&json!({"frame":frame}), &mut arm.loaders);
764 if let (Some(id), Some(loader)) = (frame.get("id").and_then(Value::as_str), frame.get("loaderId").and_then(Value::as_str)) {
765 let restored = value.pointer("/params/type").and_then(Value::as_str) == Some("BackForwardCacheRestore") && arm.accepted.iter().any(|(known, generation, _)| known == id && generation == loader);
766 if restored { arm.pending_frames.remove(id); }
767 else { arm.pending_frames.insert(id.to_string(), (loader.to_string(), Instant::now() + Duration::from_secs(2))); }
768 }
769 }
770
771 fn webmcp_context_event(&mut self, value: &Value, session: &str, method: &str) {
772 let Some(arm) = self.webmcp.arms.get_mut(session) else { return; };
773 if method == "Page.frameDetached" {
774 if let Some(frame) = value.pointer("/params/frameId").and_then(Value::as_str) {
775 arm.pending_frames.remove(frame);
776 arm.loaders.remove(frame);
777 for ((owner, id), context) in &self.webmcp.contexts { if owner == session && context.frame == frame { arm.pending_contexts.remove(id); } }
778 self.webmcp.contexts.retain(|(owner, _), context| owner != session || context.frame != frame);
779 }
780 }
781 if method == "Runtime.executionContextCreated" {
782 let context = &value["params"]["context"];
783 if context.pointer("/auxData/isDefault").and_then(Value::as_bool) == Some(true) {
784 if let Some(id) = context.get("id").and_then(Value::as_i64) {
785 let frame = context.pointer("/auxData/frameId").and_then(Value::as_str).unwrap_or("");
786 let unique = context.get("uniqueId").and_then(Value::as_str).unwrap_or("");
787 let loader = arm.loaders.get(frame).cloned().unwrap_or_default();
788 if !arm.accepted.contains(&(frame.to_string(), loader.clone(), unique.to_string())) {
789 arm.pending_contexts.insert(id, Instant::now() + Duration::from_secs(2));
790 }
791 self.webmcp.contexts.insert((session.to_string(), id), Context {
792 frame: frame.to_string(),
793 origin: context.get("origin").and_then(Value::as_str).unwrap_or("").to_string(),
794 unique: unique.to_string(),
795 loader,
796 });
797 }
798 }
799 }
800 if method == "Runtime.executionContextDestroyed" {
801 if let Some(id) = value.pointer("/params/executionContextId").and_then(Value::as_i64) {
802 self.webmcp.contexts.remove(&(session.to_string(), id));
803 arm.pending_contexts.remove(&id);
804 }
805 }
806 if method == "Runtime.executionContextsCleared" {
807 arm.pending_contexts.clear();
808 self.webmcp.contexts.retain(|(owner, _), _| owner != session);
809 self.webmcp.scripts.retain(|(owner, _), _| owner != session);
810 }
811 if method == "Debugger.scriptParsed" {
812 if let (Some(script), Some(context)) = (value.pointer("/params/scriptId").and_then(Value::as_str), value.pointer("/params/executionContextId").and_then(Value::as_i64)) {
813 self.webmcp.scripts.insert((session.to_string(), script.to_string()), context);
814 }
815 }
816 }
817}
818
819#[cfg(test)]
820mod tests {
821 include!("webmcp_guard_test.rs");
822}