Three independent gaps that hurt browser-based MCP clients connecting
to obscura's CDP endpoint:
- Runtime.enable returned "No page" when called before a session was
attached. puppeteer-extra's FrameManager.initialize fires it on the
browser-level WebSocket BEFORE any page target exists, so the
standard puppeteer.connect / browser.newPage flow died on connect.
Now reply with {} when no session is attached and let the next
Target.attachToTarget emit executionContextCreated.
- Runtime.evaluate had no per-call timeout. A long synchronous JS
expression held the global V8 lock indefinitely and starved every
other CDP command on the session. Honor the CDP `timeout` parameter
and default to 30s (Chrome's protocolTimeout). Returns a clear error
on expiry so client-side deadlines fire cleanly instead of hanging.
- Target.getTargetInfo for the browser target omitted canAccessOpener.
Strict CDP clients (chromiumoxide) panic when the field is missing.
Fill it in (false for the browser target itself).
Partial fix for #122. The remaining items (networkIdle hard-deadline
on SPAs, /json/list hardcoded data, docs on cheap-vs-queued methods)
need bigger or non-code changes and are deferred.
306 lines
12 KiB
Rust
306 lines
12 KiB
Rust
use serde_json::{json, Value};
|
|
|
|
use crate::dispatch::CdpContext;
|
|
use crate::types::CdpEvent;
|
|
use crate::util::url_is_file_scheme;
|
|
|
|
pub async fn handle(method: &str, params: &Value, ctx: &mut CdpContext) -> Result<Value, String> {
|
|
match method {
|
|
"setDiscoverTargets" => {
|
|
ctx.pending_events.push(CdpEvent::new(
|
|
"Target.targetCreated",
|
|
json!({
|
|
"targetInfo": {
|
|
"targetId": "browser",
|
|
"type": "browser",
|
|
"title": "",
|
|
"url": "",
|
|
"attached": true,
|
|
"canAccessOpener": false,
|
|
"browserContextId": "",
|
|
}
|
|
}),
|
|
));
|
|
for page in &ctx.pages {
|
|
ctx.pending_events.push(CdpEvent::new(
|
|
"Target.targetCreated",
|
|
json!({
|
|
"targetInfo": {
|
|
"targetId": page.id,
|
|
"type": "page",
|
|
"title": page.title,
|
|
"url": page.url_string(),
|
|
"attached": false,
|
|
"canAccessOpener": false,
|
|
"browserContextId": page.context.id,
|
|
}
|
|
}),
|
|
));
|
|
}
|
|
Ok(json!({}))
|
|
}
|
|
"getTargets" => {
|
|
let targets: Vec<Value> = ctx
|
|
.pages
|
|
.iter()
|
|
.map(|page| {
|
|
json!({
|
|
"targetId": page.id,
|
|
"type": "page",
|
|
"title": page.title,
|
|
"url": page.url_string(),
|
|
"attached": true,
|
|
"canAccessOpener": false,
|
|
"browserContextId": page.context.id,
|
|
})
|
|
})
|
|
.collect();
|
|
Ok(json!({ "targetInfos": targets }))
|
|
}
|
|
"createTarget" => {
|
|
let url = params.get("url").and_then(|v| v.as_str()).unwrap_or("about:blank");
|
|
|
|
// Same gate as Page.navigate (GHSA-q55h-vfv9-qcr5). Without this,
|
|
// a CDP client can call Target.createTarget {url:"file:///etc/passwd"}
|
|
// and then Runtime.evaluate the body off the created target,
|
|
// bypassing the page-domain check entirely.
|
|
if url_is_file_scheme(url) && !ctx.default_context.allow_file_access {
|
|
return Err(
|
|
"Target.createTarget to file:// is disabled. Restart with `obscura serve --allow-file-access` to enable.".to_string()
|
|
);
|
|
}
|
|
|
|
let page_id = ctx.create_page();
|
|
let session_id = format!("{}-session", page_id);
|
|
|
|
if let Some(page) = ctx.get_page_mut(&page_id) {
|
|
if url == "about:blank" || url.is_empty() {
|
|
page.navigate_blank();
|
|
} else {
|
|
let _ = page.navigate(url).await;
|
|
}
|
|
}
|
|
|
|
ctx.sessions.insert(session_id.clone(), page_id.clone());
|
|
|
|
if let Some(page) = ctx.get_page(&page_id) {
|
|
ctx.pending_events.push(CdpEvent::new(
|
|
"Target.targetCreated",
|
|
json!({
|
|
"targetInfo": {
|
|
"targetId": page_id,
|
|
"type": "page",
|
|
"title": page.title,
|
|
"url": page.url_string(),
|
|
"attached": false,
|
|
"canAccessOpener": false,
|
|
"browserContextId": page.context.id,
|
|
}
|
|
}),
|
|
));
|
|
}
|
|
|
|
if let Some(page) = ctx.get_page(&page_id) {
|
|
ctx.pending_events.push(CdpEvent::new(
|
|
"Target.attachedToTarget",
|
|
json!({
|
|
"sessionId": session_id,
|
|
"targetInfo": {
|
|
"targetId": page_id,
|
|
"type": "page",
|
|
"title": page.title,
|
|
"url": page.url_string(),
|
|
"attached": true,
|
|
"canAccessOpener": false,
|
|
"browserContextId": page.context.id,
|
|
},
|
|
"waitingForDebugger": false,
|
|
}),
|
|
));
|
|
}
|
|
|
|
Ok(json!({ "targetId": page_id }))
|
|
}
|
|
"attachToBrowserTarget" => {
|
|
// Playwright calls this on connect to obtain a session for the
|
|
// implicit "browser" target. Returning Unknown method aborts
|
|
// the connect handshake before any user code runs.
|
|
let session_id = "browser-session".to_string();
|
|
ctx.sessions.insert(session_id.clone(), "browser".to_string());
|
|
|
|
ctx.pending_events.push(CdpEvent::new(
|
|
"Target.attachedToTarget",
|
|
json!({
|
|
"sessionId": session_id,
|
|
"targetInfo": {
|
|
"targetId": "browser",
|
|
"type": "browser",
|
|
"title": "",
|
|
"url": "",
|
|
"attached": true,
|
|
"canAccessOpener": false,
|
|
"browserContextId": "",
|
|
},
|
|
"waitingForDebugger": false,
|
|
}),
|
|
));
|
|
|
|
Ok(json!({ "sessionId": session_id }))
|
|
}
|
|
"attachToTarget" => {
|
|
let target_id = params.get("targetId").and_then(|v| v.as_str())
|
|
.ok_or("targetId required")?;
|
|
let session_id = format!("{}-session", target_id);
|
|
ctx.sessions.insert(session_id.clone(), target_id.to_string());
|
|
|
|
if let Some(page) = ctx.get_page(target_id) {
|
|
ctx.pending_events.push(CdpEvent::new(
|
|
"Target.attachedToTarget",
|
|
json!({
|
|
"sessionId": session_id,
|
|
"targetInfo": {
|
|
"targetId": target_id,
|
|
"type": "page",
|
|
"title": page.title,
|
|
"url": page.url_string(),
|
|
"attached": true,
|
|
"canAccessOpener": false,
|
|
"browserContextId": page.context.id,
|
|
},
|
|
"waitingForDebugger": false,
|
|
}),
|
|
));
|
|
}
|
|
|
|
Ok(json!({ "sessionId": session_id }))
|
|
}
|
|
"closeTarget" => {
|
|
let target_id = params.get("targetId").and_then(|v| v.as_str())
|
|
.ok_or("targetId required")?;
|
|
let session_id = format!("{}-session", target_id);
|
|
|
|
ctx.pending_events.push(CdpEvent::new(
|
|
"Target.detachedFromTarget",
|
|
json!({
|
|
"sessionId": session_id,
|
|
"targetId": target_id,
|
|
}),
|
|
));
|
|
ctx.pending_events.push(CdpEvent::new(
|
|
"Target.targetDestroyed",
|
|
json!({ "targetId": target_id }),
|
|
));
|
|
|
|
ctx.remove_page(target_id);
|
|
Ok(json!({ "success": true }))
|
|
}
|
|
"setAutoAttach" => Ok(json!({})),
|
|
"getBrowserContexts" => {
|
|
Ok(json!({ "browserContextIds": [ctx.default_context.id] }))
|
|
}
|
|
"createBrowserContext" => {
|
|
ctx.default_context.cookie_jar.clear();
|
|
Ok(json!({ "browserContextId": ctx.default_context.id }))
|
|
}
|
|
"disposeBrowserContext" => {
|
|
ctx.default_context.cookie_jar.clear();
|
|
Ok(json!({}))
|
|
}
|
|
"getTargetInfo" => {
|
|
let target_id = params.get("targetId").and_then(|v| v.as_str());
|
|
match target_id {
|
|
Some(id) => {
|
|
let page = ctx.get_page(id).ok_or("Target not found")?;
|
|
Ok(json!({
|
|
"targetInfo": {
|
|
"targetId": id,
|
|
"type": "page",
|
|
"title": page.title,
|
|
"url": page.url_string(),
|
|
"attached": true,
|
|
"canAccessOpener": false,
|
|
"browserContextId": page.context.id,
|
|
}
|
|
}))
|
|
}
|
|
None => {
|
|
// canAccessOpener is required on every TargetInfo per the
|
|
// CDP spec. Strict clients (chromiumoxide) panic if it's
|
|
// missing. The browser target itself has no opener.
|
|
Ok(json!({
|
|
"targetInfo": {
|
|
"targetId": "browser",
|
|
"type": "browser",
|
|
"title": "",
|
|
"url": "",
|
|
"attached": true,
|
|
"canAccessOpener": false,
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
_ => Err(format!("Unknown Target method: {}", method)),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn attach_to_browser_target_returns_session_id() {
|
|
let mut ctx = CdpContext::new();
|
|
let result = handle("attachToBrowserTarget", &json!({}), &mut ctx)
|
|
.await
|
|
.expect("attachToBrowserTarget should succeed");
|
|
|
|
assert_eq!(result["sessionId"], "browser-session");
|
|
assert_eq!(
|
|
ctx.sessions.get("browser-session").map(String::as_str),
|
|
Some("browser")
|
|
);
|
|
|
|
// Playwright/Puppeteer expect a Target.attachedToTarget event before
|
|
// they finish wiring up the session — without it the connect promise
|
|
// hangs.
|
|
let attached_evt = ctx
|
|
.pending_events
|
|
.iter()
|
|
.find(|e| e.method == "Target.attachedToTarget")
|
|
.expect("attachedToTarget event must be emitted");
|
|
assert_eq!(attached_evt.params["sessionId"], "browser-session");
|
|
assert_eq!(attached_evt.params["targetInfo"]["type"], "browser");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_target_method_still_errors() {
|
|
let mut ctx = CdpContext::new();
|
|
let err = handle("notARealMethod", &json!({}), &mut ctx)
|
|
.await
|
|
.expect_err("unknown methods must surface as errors");
|
|
assert!(err.contains("Unknown Target method"));
|
|
}
|
|
|
|
/// Regression for #122 item 5: every TargetInfo payload must carry the
|
|
/// `canAccessOpener` field. The browser-target branch of getTargetInfo
|
|
/// (no targetId passed → no page) used to omit it; strict CDP clients
|
|
/// like chromiumoxide panic when the field is missing.
|
|
#[tokio::test]
|
|
async fn get_target_info_browser_target_includes_can_access_opener() {
|
|
let mut ctx = CdpContext::new();
|
|
// No targetId → falls through to the browser-target branch.
|
|
let result = handle("getTargetInfo", &json!({}), &mut ctx)
|
|
.await
|
|
.expect("getTargetInfo with no targetId must return browser info");
|
|
|
|
let info = &result["targetInfo"];
|
|
assert_eq!(info["type"], "browser", "must be the browser target");
|
|
assert!(
|
|
info.get("canAccessOpener").is_some(),
|
|
"canAccessOpener must be present on every TargetInfo, got: {result}"
|
|
);
|
|
assert_eq!(info["canAccessOpener"], false);
|
|
}
|
|
}
|