Files
obscura/crates/obscura-cdp/src/domains/target.rs
T
SGavrl f4f49ebab4 fix(cdp): unblock puppeteer connect, bound Runtime.evaluate, full TargetInfo (#181)
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.
2026-05-24 20:14:35 +00:00

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);
}
}