fix: ephemeral CDP attach

This commit is contained in:
zhom committed 2026-10-19 05:22:43 +04:00
1 parent 42783778f1
commit 448303ee40
5 files changed
+209 -90

No files matched your search

+61
View File
@@ -503,6 +503,8 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
assert.equal(typeof checks.consistency, "object");
assert.equal(typeof checks.exit_probe_pending, "boolean");
assert.equal(typeof checks.exit_measurement_unreliable, "boolean");
// A profile with no proxy or VPN has no route to check before launch.
assert.equal(checks.route_unreachable, null);
// The third consistency state: what no probe can ever verify for this
// profile. Reported so a launch that compared nothing is never rendered as
// a launch that compared everything and agreed.
@@ -1143,6 +1145,23 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
);
}
// The window's DevTools endpoint and network inspector reach the same
// browser the automation port does, and the capture the agent left
// running.
const endpoint = await app.invoke("get_profile_debug_endpoint", {
profileId: profile.id,
});
assert.equal(endpoint.port, launched.value.remote_debugging_port);
assert.match(
endpoint.ws_url,
/^ws:\/\/127\.0\.0\.1:\d+\/devtools\/browser\//,
);
const windowCapture = await app.invoke("start_network_capture", {
profileId: profile.id,
});
assert.equal(windowCapture.capturing, true);
assert.equal(windowCapture.already_running, true);
const opened = await request(`${base}/v1/profiles/${profile.id}/open-url`, {
method: "POST",
token: saved.api_token,
@@ -1158,6 +1177,26 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
},
{ timeoutMs: 20_000, description: "API-opened Wayfern target" },
);
const capturedRequest = await app.waitFor(
async () => {
const listed = await app.invoke("list_network_requests", {
profileId: profile.id,
});
assert.equal(listed.capturing, true);
return (
listed.requests.find((item) =>
item.url.includes("/opened-via-api"),
) ?? false
);
},
{ timeoutMs: 20_000, description: "captured API-opened request" },
);
const capturedDetail = await app.invoke("get_network_request", {
profileId: profile.id,
requestId: capturedRequest.id,
});
assert.equal(capturedDetail.id, capturedRequest.id);
assert.equal(typeof capturedDetail.request_headers, "object");
const killed = await request(`${base}/v1/profiles/${profile.id}/kill`, {
method: "POST",
@@ -1176,6 +1215,14 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
(await toolError("list_network_requests")).data.code,
"NETWORK_CAPTURE_NOT_RUNNING",
);
assert.deepEqual(
await app.invoke("list_network_requests", { profileId: profile.id }),
{ capturing: false },
);
assert.deepEqual(
await app.invoke("stop_network_capture", { profileId: profile.id }),
{ stopped: false },
);
const stoppedProfile = (await app.invoke("list_browser_profiles")).find(
(item) => item.id === profile.id,
);
@@ -1183,6 +1230,10 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
await app.invoke("check_browser_status", { profile: stoppedProfile }),
false,
);
assert.equal(
await app.invoke("get_profile_debug_endpoint", { profileId: profile.id }),
null,
);
const batchProfile = await createRealProfile(
app,
@@ -1202,6 +1253,8 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
profile_ids: [batchProfile.id],
url: `${fixtureUrl}/batch`,
headless: true,
delay_ms: 0,
concurrency: 1,
},
});
assert.equal(batchRun.response.status, 200);
@@ -1374,6 +1427,14 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
temporaryPid,
"the temporary profile must report the browser it started",
);
// The browser runs from the profile's RAM directory, and the tools must
// find it there: they once looked only under the profiles dir.
const ephemeralPage = await mcp("tools/call", {
name: "get_page_info",
arguments: { profile_id: temporaryId },
});
assert.notEqual(ephemeralPage.isError, true, JSON.stringify(ephemeralPage));
assert.match(ephemeralPage.content[0].text, /temporary/);
await request(`${base}/v1/profiles/${temporaryId}/kill`, {
method: "POST",
token: saved.api_token,
+2 -3
View File
@@ -13,7 +13,7 @@ use std::time::Duration;
use serde::Serialize;
use serde_json::Value;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::profile::BrowserProfile;
const HTTP_TIMEOUT: Duration = Duration::from_secs(5);
@@ -38,9 +38,8 @@ pub async fn local_port(profile: &BrowserProfile) -> Option<u16> {
if !crate::browser_runner::browser_is_running_for(&profile.id.to_string()) {
return None;
}
let path = profile.get_profile_data_path(&ProfileManager::instance().get_profiles_dir());
crate::wayfern_manager::WayfernManager::instance()
.get_cdp_port(&path.to_string_lossy())
.cdp_port_for_profile(profile)
.await
}
+1 -5
View File
@@ -240,10 +240,6 @@ pub async fn resolve(profile: &BrowserProfile) -> Result<CdpTarget, ResolveError
/// The debugging port a locally launched browser registered for this profile.
async fn local_cdp_port(profile: &BrowserProfile, patience: Patience) -> Option<u16> {
let profiles_dir = crate::profile::manager::ProfileManager::instance().get_profiles_dir();
let profile_path = profile.get_profile_data_path(&profiles_dir);
let profile_path_str = profile_path.to_string_lossy().to_string();
// Port info is written once the process is up, so a tool called straight
// after a launch has to wait for it.
for attempt in 0..patience.attempts(10) {
@@ -251,7 +247,7 @@ async fn local_cdp_port(profile: &BrowserProfile, patience: Patience) -> Option<
tokio::time::sleep(Duration::from_secs(1)).await;
}
if let Some(port) = crate::wayfern_manager::WayfernManager::instance()
.get_cdp_port(&profile_path_str)
.cdp_port_for_profile(profile)
.await
{
return Some(port);
+15 -15
View File
@@ -1033,16 +1033,20 @@ impl SynchronizerManager {
))
}
}
"wheel" => {
let dx = -event.delta_x.unwrap_or(0.0);
let dy = -event.delta_y.unwrap_or(0.0);
Some((
"Runtime.evaluate",
serde_json::json!({
"expression": format!("window.scrollBy({dx},{dy})"),
}),
))
}
// A wheel event at the pointer scrolls whatever is under it (a list, a
// pane, a modal), as on the leader. The captured deltas have the
// opposite sign of the command's.
"wheel" => Some((
"Input.dispatchMouseEvent",
serde_json::json!({
"type": "mouseWheel",
"x": event.x.unwrap_or(0.0),
"y": event.y.unwrap_or(0.0),
"deltaX": -event.delta_x.unwrap_or(0.0),
"deltaY": -event.delta_y.unwrap_or(0.0),
"modifiers": event.modifiers.unwrap_or(0),
}),
)),
_ => None,
};
@@ -1369,16 +1373,12 @@ impl SynchronizerManager {
}
async fn get_cdp_port(profile: &BrowserProfile) -> Result<u16, String> {
let profiles_dir = ProfileManager::instance().get_profiles_dir();
let profile_path = profile.get_profile_data_path(&profiles_dir);
let profile_path_str = profile_path.to_string_lossy();
for attempt in 0..15 {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
let port = crate::wayfern_manager::WayfernManager::instance()
.get_cdp_port(&profile_path_str)
.cdp_port_for_profile(profile)
.await;
if let Some(p) = port {
return Ok(p);
+130 -67
View File
@@ -3914,6 +3914,15 @@ impl WayfernManager {
.unwrap_or_default()
}
/// The DevTools port of the browser running `profile`. An ephemeral or
/// password-protected profile runs from its RAM directory, not from its
/// path under the profiles dir, so the lookup goes by the path it ran with.
pub async fn cdp_port_for_profile(&self, profile: &BrowserProfile) -> Option<u16> {
let profiles_dir = crate::profile::ProfileManager::instance().get_profiles_dir();
let path = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
self.get_cdp_port(&path.to_string_lossy()).await
}
pub async fn get_cdp_port(&self, profile_path: &str) -> Option<u16> {
let inner = self.inner.lock().await;
let target_path = std::path::Path::new(profile_path)
@@ -3933,65 +3942,69 @@ impl WayfernManager {
None
}
/// The in-memory instance registered for a profile path, if any.
fn instance_for_path(
inner: &WayfernManagerInner,
target_path: &std::path::Path,
) -> Option<WayfernLaunchResult> {
inner.instances.iter().find_map(|(id, instance)| {
let path = instance.profile_path.as_deref()?;
let instance_path = std::path::Path::new(path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(path).to_path_buf());
(instance_path == target_path).then(|| WayfernLaunchResult {
id: id.clone(),
processId: instance.process_id,
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
cdp_port: instance.cdp_port,
stored_profile: None,
})
})
}
/// The running browser of a profile. Process checks run without the
/// `inner` lock, so launch, stop and CDP calls never wait on them.
pub async fn find_wayfern_by_profile(&self, profile_path: &str) -> Option<WayfernLaunchResult> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let mut inner = self.inner.lock().await;
// Canonicalize the target path for comparison
let target_path = std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
// Find the instance with the matching profile path
let mut found_id: Option<String> = None;
for (id, instance) in &inner.instances {
if let Some(path) = &instance.profile_path {
let instance_path = std::path::Path::new(path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(path).to_path_buf());
if instance_path == target_path {
found_id = Some(id.clone());
break;
}
}
}
let known = {
let inner = self.inner.lock().await;
Self::instance_for_path(&inner, &target_path)
};
// If we found an instance, verify the process is still running
if let Some(id) = found_id {
if let Some(instance) = inner.instances.get(&id) {
if let Some(pid) = instance.process_id {
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
if let Some(found) = known {
if let Some(pid) = found.processId {
if crate::proxy_storage::is_process_running(pid) {
return Some(found);
}
let mut inner = self.inner.lock().await;
let same_process = inner
.instances
.get(&found.id)
.is_some_and(|instance| instance.process_id == Some(pid));
if same_process {
log::info!(
"Browser exited profile={} pid={pid}",
profile_id_in_path(profile_path)
);
let sysinfo_pid = sysinfo::Pid::from_u32(pid);
if system.process(sysinfo_pid).is_some() {
return Some(WayfernLaunchResult {
id: id.clone(),
processId: instance.process_id,
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
cdp_port: instance.cdp_port,
stored_profile: None,
});
} else {
log::info!(
"Browser exited profile={} pid={pid}",
profile_id_in_path(profile_path)
);
inner.instances.remove(&id);
return None;
}
inner.instances.remove(&found.id);
}
return None;
}
}
// If not found in in-memory instances, scan system processes.
// This handles the case where the GUI was restarted but Wayfern is still running.
if let Some((pid, found_profile_path, cdp_port)) =
Self::find_wayfern_process_by_profile(&target_path)
{
let scan_path = target_path.clone();
let scanned =
tokio::task::spawn_blocking(move || Self::find_wayfern_process_by_profile(&scan_path))
.await
.ok()
.flatten();
if let Some((pid, found_profile_path, cdp_port)) = scanned {
// A browser seconds old is almost always one this app is still
// launching: its instance is registered only once the launch finishes.
let age_s = crate::proxy_storage::process_start_time(pid).map(|start| {
@@ -4000,6 +4013,15 @@ impl WayfernManager {
.map(|now| now.as_secs().saturating_sub(start))
.unwrap_or_default()
});
let mut inner = self.inner.lock().await;
// A launch may have registered its browser while the scan ran.
if let Some(registered) = Self::instance_for_path(&inner, &target_path)
.filter(|registered| registered.processId.is_some())
{
return Some(registered);
}
let level = if age_s.is_some_and(|age| age < 120) {
log::Level::Debug
} else {
@@ -4040,10 +4062,14 @@ impl WayfernManager {
fn find_wayfern_process_by_profile(
target_path: &std::path::Path,
) -> Option<(u32, String, Option<u16>)> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
use sysinfo::{ProcessRefreshKind, RefreshKind, System, UpdateKind};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
RefreshKind::nothing().with_processes(
ProcessRefreshKind::nothing()
.with_cmd(UpdateKind::OnlyIfNotSet)
.without_tasks(),
),
);
let target_path_str = target_path.to_string_lossy();
@@ -4138,27 +4164,32 @@ impl WayfernManager {
#[allow(dead_code)]
pub async fn cleanup_dead_instances(&self) {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let mut inner = self.inner.lock().await;
let mut dead_ids = Vec::new();
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
for (id, instance) in &inner.instances {
if let Some(pid) = instance.process_id {
let pid = sysinfo::Pid::from_u32(pid);
if !system.processes().contains_key(&pid) {
dead_ids.push(id.clone());
}
}
let known: Vec<(String, u32)> = {
let inner = self.inner.lock().await;
inner
.instances
.iter()
.filter_map(|(id, instance)| instance.process_id.map(|pid| (id.clone(), pid)))
.collect()
};
let dead: Vec<(String, u32)> = known
.into_iter()
.filter(|(_, pid)| !crate::proxy_storage::is_process_running(*pid))
.collect();
if dead.is_empty() {
return;
}
for id in dead_ids {
log::debug!("Removed dead browser instance id={id}");
inner.instances.remove(&id);
let mut inner = self.inner.lock().await;
for (id, pid) in dead {
if inner
.instances
.get(&id)
.is_some_and(|instance| instance.process_id == Some(pid))
{
log::debug!("Removed dead browser instance id={id}");
inner.instances.remove(&id);
}
}
}
}
@@ -6013,4 +6044,36 @@ mod tests {
(800, 600)
);
}
#[tokio::test]
#[serial_test::serial]
async fn an_ephemeral_profile_finds_the_browser_running_from_its_ram_dir() {
let root = tempfile::tempdir().unwrap();
std::env::set_var("DONUTBROWSER_EPHEMERAL_ROOT", root.path());
let profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
browser: "wayfern".to_string(),
ephemeral: true,
..Default::default()
};
let id = profile.id.to_string();
let ram_dir = crate::ephemeral_dirs::create_ephemeral_dir(&id).unwrap();
let manager = WayfernManager::new();
manager.inner.lock().await.instances.insert(
"launch".to_string(),
WayfernInstance {
process_id: Some(1),
profile_path: Some(ram_dir.to_string_lossy().into_owned()),
url: None,
cdp_port: Some(9333),
log_tap: BrowserLogTap::default(),
},
);
assert_eq!(manager.cdp_port_for_profile(&profile).await, Some(9333));
crate::ephemeral_dirs::remove_ephemeral_dir(&id);
std::env::remove_var("DONUTBROWSER_EPHEMERAL_ROOT");
}
}