From 06776564694cbfb287f47d0c4869848d64963199 Mon Sep 17 00:00:00 2001 From: Eric W Cochran Date: Sat, 10 Oct 2026 19:25:44 -0500 Subject: [PATCH 1/2] fix: restore caller custody during tab refresh (#175) --- README.md | 9 +- cli/src/mcp.rs | 2 +- cli/src/native/browser_lifecycle.rs | 292 +++++++++++++--- .../native/browser_lifecycle/action_tests.rs | 322 ++++++++++++++++++ cli/src/output.rs | 1 + docs/dev/active-lanes.yaml | 14 + .../contracts/service-request.v1.schema.json | 2 +- ...7-2026-10-10-tab-refresh-caller-custody.md | 38 +++ docs/dev/plans/README.md | 2 + docs/src/app/service-mode/page.mdx | 2 +- .../client/src/service-request.generated.d.ts | 10 + scripts/generate-service-request-client.js | 10 + .../smoke-service-tab-handle-refresh-live.js | 208 ++++++++++- scripts/test-service-request-client.js | 14 +- skills/agent-browser/SKILL.md | 10 +- 15 files changed, 866 insertions(+), 70 deletions(-) create mode 100644 docs/dev/plans/0207-2026-10-10-tab-refresh-caller-custody.md diff --git a/README.md b/README.md index 330f107760..515f223e98 100644 --- a/README.md +++ b/README.md @@ -4420,9 +4420,12 @@ classification or repair before more work. The refresh action accepts stale handles, returns candidate evidence, and supports `reject_only`, `reuse_compatible`, `open_if_missing`, or `replace_duplicates` repair policies using the current service session and optional `desiredUrl` hint. -`replace_duplicates` selects one compatible target and best-effort closes other -compatible live targets so repeated route-bound opens do not grow stale tab -sets. `service_remote_view_route_preflight`, HTTP +Compatible reuse requires canonical Service State custody for the same caller. +`open_if_missing` persists a canonical replacement when no caller-bound target +exists. `replace_duplicates` closes only compatible same-caller targets and +preserves foreign or unattributed peers. Every response includes +`duplicateCleanupAttempted`, `peerCleanupAttempted`, and nested +`duplicateTargetCleanup` evidence. `service_remote_view_route_preflight`, HTTP `GET /api/service/remote-view/route-preflight`, MCP `service_remote_view_route_preflight`, and `getServiceRemoteViewRoutePreflight()` return a no-launch `fastPreflight` summary with component evidence for the diff --git a/cli/src/mcp.rs b/cli/src/mcp.rs index b9f9144390..f41538cc0e 100644 --- a/cli/src/mcp.rs +++ b/cli/src/mcp.rs @@ -1503,7 +1503,7 @@ fn service_mcp_tools() -> Vec { "repairPolicy": { "type": "string", "enum": ["reject_only", "reuse_compatible", "open_if_missing", "replace_duplicates"], - "description": "Repair policy for action=tab_handle_refresh." + "description": "Repair policy for action=tab_handle_refresh. Reuse and duplicate cleanup are limited to canonical targets owned by the same caller; responses include explicit duplicate and peer cleanup-attempt evidence." }, "includeScreenshot": { "type": "boolean", diff --git a/cli/src/native/browser_lifecycle.rs b/cli/src/native/browser_lifecycle.rs index 71a23f4f3e..b820088d2d 100644 --- a/cli/src/native/browser_lifecycle.rs +++ b/cli/src/native/browser_lifecycle.rs @@ -203,6 +203,26 @@ pub(crate) mod action_commands { .ok_or_else(|| "persisted service tab handle is unavailable".to_string()) }) } + pub(crate) fn persist_tab_handle_refresh_replacement( + cmd: &Value, + session_id: &str, + target_id: &str, + url: &str, + title: &str, + provisional_handle: &Value, + ) -> Result { + let canonical = persist_service_owned_tab_new( + cmd, + session_id, + Some(target_id), + Some(url), + Some(title), + provisional_handle, + )? + .ok_or_else(|| "persisted replacement service tab handle is unavailable".to_string())?; + serde_json::to_value(canonical) + .map_err(|error| format!("Invalid persisted replacement service tab handle: {error}")) + } pub(crate) fn profile_child_access_from_command(cmd: &Value) -> Value { cmd.get("profileChildAccess") @@ -542,9 +562,13 @@ pub(crate) mod action_commands { "decision" : "rejected_inactive_lease", "repairPolicy" : repair_policy, "observedAt" : observed_at, "browserId" : browser_id, "staleReason" : stale_reason, "serviceTabHandle" : cmd.get("serviceTabHandle").cloned() - .unwrap_or(Value::Null), "candidates" : candidates, } + .unwrap_or(Value::Null), "duplicateCleanupAttempted" : false, + "peerCleanupAttempted" : false, "duplicateTargetCleanup" : + no_duplicate_target_cleanup(), "candidates" : candidates, } )); } + let mut refresh_snapshot = LockedServiceStateRepository::default_json()?.load_snapshot()?; + refresh_snapshot.refresh_service_tab_handles(); let mgr = state.browser.as_mut().ok_or_else(|| { "Cannot refresh service tab handle: routed browser session is not running".to_string() })?; @@ -585,22 +609,25 @@ pub(crate) mod action_commands { let duplicate_target_cleanup = if repair_policy == "replace_duplicates" { close_compatible_duplicate_targets( mgr, + &refresh_snapshot, + handle, target_id, - Some(target_id), desired_origin.as_deref(), ) .await } else { no_duplicate_target_cleanup() }; - return Ok(json!( - { "ok" : true, "action" : "tab_handle_refresh", "refreshed" : - true, "decision" : "exact_handle_still_valid", "repairPolicy" - : repair_policy, "observedAt" : observed_at, "browserId" : - browser_id, "targetId" : target_id, "url" : url, "title" : - title, "tabSwitch" : switched, "serviceTabHandle" : - refreshed_handle, "duplicateTargetCleanup" : - duplicate_target_cleanup, "candidates" : candidates, } + return Ok(tab_handle_refresh_success_with_cleanup( + json!( + { "ok" : true, "action" : "tab_handle_refresh", "refreshed" : + true, "decision" : "exact_handle_still_valid", "repairPolicy" + : repair_policy, "observedAt" : observed_at, "browserId" : + browser_id, "targetId" : target_id, "url" : url, "title" : + title, "tabSwitch" : switched, "serviceTabHandle" : + refreshed_handle, "candidates" : candidates, } + ), + duplicate_target_cleanup, )); } } @@ -621,33 +648,35 @@ pub(crate) mod action_commands { "staleReason" : handle.get("staleReason").cloned() .unwrap_or(Value::Null), "serviceTabHandle" : cmd .get("serviceTabHandle").cloned().unwrap_or(Value::Null), - "candidates" : candidates, } + "duplicateCleanupAttempted" : false, "peerCleanupAttempted" : false, + "duplicateTargetCleanup" : no_duplicate_target_cleanup(), "candidates" : + candidates, } )); } if repair_policy == "reuse_compatible" || repair_policy == "open_if_missing" || repair_policy == "replace_duplicates" { - if let Some(page) = mgr.pages_list().into_iter().find(|page| { - classify_live_page_candidate( - &page.target_id, - page.url.as_str(), - target_id, - desired_origin.as_deref(), - ) - .starts_with("compatible_") - }) { - let mut switched = mgr.tab_switch_target_id(&page.target_id).await?; + let refresh_plan = plan_tab_handle_refresh( + &refresh_snapshot, + handle, + &mgr.pages_list(), + desired_origin.as_deref(), + &repair_policy, + ); + if let TabHandleRefreshPlan::ReuseCallerBound { + target_id: selected_target_id, + canonical_handle, + } = refresh_plan + { + let mut switched = mgr.tab_switch_target_id(&selected_target_id).await?; let url = mgr.get_url().await.unwrap_or_default(); let title = mgr.get_title().await.unwrap_or_default(); switched["refreshDecision"] = json!("reused_compatible_target"); - let refreshed_handle = service_tab_handle_from_parts( - handle, - &state.session_id, - &page.target_id, - url.as_str(), - title.as_str(), - ); + let mut refreshed_handle = serde_json::to_value(canonical_handle) + .map_err(|error| format!("Invalid canonical service tab handle: {error}"))?; + refreshed_handle["url"] = json!(url.clone()); + refreshed_handle["title"] = json!(title.clone()); persist_tab_handle_refresh_event( cmd, &browser_id, @@ -659,22 +688,25 @@ pub(crate) mod action_commands { let duplicate_target_cleanup = if repair_policy == "replace_duplicates" { close_compatible_duplicate_targets( mgr, - &page.target_id, - target_id, + &refresh_snapshot, + handle, + &selected_target_id, desired_origin.as_deref(), ) .await } else { no_duplicate_target_cleanup() }; - return Ok(json!( - { "ok" : true, "action" : "tab_handle_refresh", "refreshed" : - true, "decision" : "reused_compatible_target", "repairPolicy" : - repair_policy, "observedAt" : observed_at, "browserId" : - browser_id, "targetId" : page.target_id, "url" : url, "title" : - title, "tabSwitch" : switched, "serviceTabHandle" : - refreshed_handle, "duplicateTargetCleanup" : - duplicate_target_cleanup, "candidates" : candidates, } + return Ok(tab_handle_refresh_success_with_cleanup( + json!( + { "ok" : true, "action" : "tab_handle_refresh", "refreshed" : + true, "decision" : "reused_compatible_target", "repairPolicy" : + repair_policy, "observedAt" : observed_at, "browserId" : + browser_id, "targetId" : selected_target_id, "url" : url, "title" : + title, "tabSwitch" : switched, "serviceTabHandle" : + refreshed_handle, "candidates" : candidates, } + ), + duplicate_target_cleanup, )); } } @@ -689,13 +721,21 @@ pub(crate) mod action_commands { let url = mgr.get_url().await.unwrap_or_else(|_| open_url.to_string()); let title = mgr.get_title().await.unwrap_or_default(); opened["refreshDecision"] = json!("opened_replacement_target"); - let refreshed_handle = service_tab_handle_from_parts( + let provisional_handle = service_tab_handle_from_parts( handle, &state.session_id, &new_target_id, &url, &title, ); + let refreshed_handle = persist_tab_handle_refresh_replacement( + cmd, + &state.session_id, + &new_target_id, + &url, + &title, + &provisional_handle, + )?; persist_tab_handle_refresh_event( cmd, &browser_id, @@ -707,22 +747,24 @@ pub(crate) mod action_commands { let duplicate_target_cleanup = if repair_policy == "replace_duplicates" { close_compatible_duplicate_targets( mgr, + &refresh_snapshot, + handle, &new_target_id, - target_id, desired_origin.as_deref(), ) .await } else { no_duplicate_target_cleanup() }; - return Ok(json!( - { "ok" : true, "action" : "tab_handle_refresh", "refreshed" : true, - "decision" : "opened_replacement_target", "repairPolicy" : - repair_policy, "observedAt" : observed_at, "browserId" : browser_id, - "targetId" : new_target_id, "url" : url, "title" : title, "tabNew" : - opened, "serviceTabHandle" : refreshed_handle, - "duplicateTargetCleanup" : duplicate_target_cleanup, "candidates" : - candidates, } + return Ok(tab_handle_refresh_success_with_cleanup( + json!( + { "ok" : true, "action" : "tab_handle_refresh", "refreshed" : true, + "decision" : "opened_replacement_target", "repairPolicy" : + repair_policy, "observedAt" : observed_at, "browserId" : browser_id, + "targetId" : new_target_id, "url" : url, "title" : title, "tabNew" : + opened, "serviceTabHandle" : refreshed_handle, "candidates" : candidates, } + ), + duplicate_target_cleanup, )); } persist_tab_handle_refresh_event( @@ -738,7 +780,9 @@ pub(crate) mod action_commands { "decision" : "no_compatible_target", "repairPolicy" : repair_policy, "observedAt" : observed_at, "browserId" : browser_id, "serviceTabHandle" : cmd.get("serviceTabHandle").cloned().unwrap_or(Value::Null), - "candidates" : candidates, } + "duplicateCleanupAttempted" : false, "peerCleanupAttempted" : false, + "duplicateTargetCleanup" : no_duplicate_target_cleanup(), "candidates" : + candidates, } )) } pub(crate) async fn handle_tab_handle_release( @@ -1129,6 +1173,114 @@ pub(crate) mod action_commands { } "incompatible_tab" } + /// Pure refresh decision. URL compatibility is only a discovery hint; + /// reusable targets must carry canonical Service State custody for the + /// stale handle's caller. + #[derive(Debug, Clone, PartialEq, Eq)] + pub(crate) enum TabHandleRefreshPlan { + ExactTarget { + target_id: String, + }, + ReuseCallerBound { + target_id: String, + canonical_handle: Box, + }, + OpenReplacement, + Reject, + } + pub(crate) fn plan_tab_handle_refresh( + service_state: &ServiceState, + handle: &Map, + pages: &[PageInfo], + desired_origin: Option<&str>, + repair_policy: &str, + ) -> TabHandleRefreshPlan { + let handle_target_id = handle.get("targetId").and_then(Value::as_str); + if let Some(page) = pages + .iter() + .find(|page| Some(page.target_id.as_str()) == handle_target_id) + { + return TabHandleRefreshPlan::ExactTarget { + target_id: page.target_id.clone(), + }; + } + if repair_policy == "reject_only" { + return TabHandleRefreshPlan::Reject; + } + if let Some((page, canonical_handle)) = pages.iter().find_map(|page| { + let compatible = classify_live_page_candidate( + &page.target_id, + page.url.as_str(), + handle_target_id, + desired_origin, + ) + .starts_with("compatible_"); + compatible + .then(|| caller_bound_refresh_handle(service_state, handle, &page.target_id)) + .flatten() + .map(|canonical_handle| (page, canonical_handle)) + }) { + return TabHandleRefreshPlan::ReuseCallerBound { + target_id: page.target_id.clone(), + canonical_handle: Box::new(canonical_handle), + }; + } + if matches!(repair_policy, "open_if_missing" | "replace_duplicates") { + TabHandleRefreshPlan::OpenReplacement + } else { + TabHandleRefreshPlan::Reject + } + } + /// Return the canonical handle only when both tabs share exact retained + /// caller custody. Missing or mixed evidence fails closed. + pub(crate) fn caller_bound_refresh_handle( + service_state: &ServiceState, + stale_handle: &Map, + candidate_target_id: &str, + ) -> Option { + let source_tab = stale_handle + .get("tabId") + .and_then(Value::as_str) + .and_then(|tab_id| service_state.tabs.get(tab_id))?; + let candidate_tab = service_state.tabs.values().find(|tab| { + tab.target_id.as_deref() == Some(candidate_target_id) + && tab.lifecycle == TabLifecycle::Ready + })?; + let candidate_browser = service_state.browsers.get(&candidate_tab.browser_id)?; + if candidate_browser.health != ServiceBrowserHealth::Ready + || source_tab.browser_id != candidate_tab.browser_id + { + return None; + } + let source_owner = source_tab + .owner_session_id + .as_deref() + .or(source_tab.session_id.as_deref())?; + let candidate_owner = candidate_tab + .owner_session_id + .as_deref() + .or(candidate_tab.session_id.as_deref())?; + if source_owner != candidate_owner + || source_tab.principal_id != candidate_tab.principal_id + || source_tab.principal_provenance != candidate_tab.principal_provenance + || source_tab.work_lease_id != candidate_tab.work_lease_id + || source_tab.work_lease_revision != candidate_tab.work_lease_revision + { + return None; + } + match (&source_tab.profile_access, &candidate_tab.profile_access) { + (None, None) => {} + (Some(source), Some(candidate)) + if source.subject_id == candidate.subject_id + && source.identity_assurance == candidate.identity_assurance + && source.parent_policy_revision == candidate.parent_policy_revision + && source.access_decision_id == candidate.access_decision_id => {} + _ => return None, + } + service_state + .service_tab_handle(&candidate_tab.id) + .filter(|handle| handle.valid) + } pub(crate) fn compatible_duplicate_live_pages( pages: &[PageInfo], selected_target_id: &str, @@ -1157,22 +1309,58 @@ pub(crate) mod action_commands { }) .collect() } + pub(crate) fn caller_bound_compatible_duplicate_live_pages( + service_state: &ServiceState, + stale_handle: &Map, + pages: &[PageInfo], + selected_target_id: &str, + desired_origin: Option<&str>, + ) -> Vec { + let handle_target_id = stale_handle.get("targetId").and_then(Value::as_str); + compatible_duplicate_live_pages(pages, selected_target_id, handle_target_id, desired_origin) + .into_iter() + .filter(|candidate| { + candidate + .get("targetId") + .and_then(Value::as_str) + .is_some_and(|target_id| { + caller_bound_refresh_handle(service_state, stale_handle, target_id) + .is_some() + }) + }) + .collect() + } pub(crate) fn no_duplicate_target_cleanup() -> Value { json!( { "policy" : "preserve", "attempted" : false, "closedCount" : 0, "closedTargets" : [], "failedTargets" : [], } ) } + pub(crate) fn tab_handle_refresh_success_with_cleanup( + mut response: Value, + duplicate_target_cleanup: Value, + ) -> Value { + let duplicate_cleanup_attempted = duplicate_target_cleanup + .get("attempted") + .and_then(Value::as_bool) + .unwrap_or(false); + response["duplicateCleanupAttempted"] = json!(duplicate_cleanup_attempted); + response["peerCleanupAttempted"] = json!(false); + response["duplicateTargetCleanup"] = duplicate_target_cleanup; + response + } pub(crate) async fn close_compatible_duplicate_targets( mgr: &mut BrowserManager, + service_state: &ServiceState, + stale_handle: &Map, selected_target_id: &str, - handle_target_id: Option<&str>, desired_origin: Option<&str>, ) -> Value { - let duplicates = compatible_duplicate_live_pages( + let duplicates = caller_bound_compatible_duplicate_live_pages( + service_state, + stale_handle, &mgr.pages_list(), selected_target_id, - handle_target_id, desired_origin, ); if duplicates.is_empty() { diff --git a/cli/src/native/browser_lifecycle/action_tests.rs b/cli/src/native/browser_lifecycle/action_tests.rs index edd0325293..823f837f41 100644 --- a/cli/src/native/browser_lifecycle/action_tests.rs +++ b/cli/src/native/browser_lifecycle/action_tests.rs @@ -361,6 +361,84 @@ fn tab_persistence_uses_service_handle_browser_identity() { ); } +#[test] +fn test_tab_handle_refresh_opened_replacement_is_canonical_and_durable() { + let guard = EnvGuard::new(&["HOME"]); + let home = unique_socket_dir("tab-handle-refresh-replacement"); + fs::create_dir_all(&home).unwrap(); + guard.set("HOME", home.to_str().unwrap()); + let store = JsonServiceStateStore::new(JsonServiceStateStore::default_path().unwrap()); + store + .save(&ServiceState { + browsers: BTreeMap::from([( + "browser-shared".to_string(), + BrowserProcess { + id: "browser-shared".to_string(), + health: ServiceBrowserHealth::Ready, + profile_id: Some("profile-shared".to_string()), + ..BrowserProcess::default() + }, + )]), + sessions: BTreeMap::from([( + "caller-a".to_string(), + BrowserSession { + id: "caller-a".to_string(), + service_name: Some("books-receipts".to_string()), + agent_name: Some("receipt-agent".to_string()), + task_name: Some("collect-receipts".to_string()), + profile_id: Some("profile-shared".to_string()), + browser_ids: vec!["browser-shared".to_string()], + ..BrowserSession::default() + }, + )]), + ..ServiceState::default() + }) + .unwrap(); + let stale_handle = serde_json::Map::from_iter([ + ("browserId".to_string(), json!("browser-shared")), + ("sessionName".to_string(), json!("caller-a")), + ("tabId".to_string(), json!("target:closed")), + ("targetId".to_string(), json!("closed")), + ("profileId".to_string(), json!("profile-shared")), + ("leaseState".to_string(), json!("shared")), + ]); + let provisional = service_tab_handle_from_parts( + &stale_handle, + "caller-a", + "replacement", + "https://books.example/receipts", + "Receipts", + ); + + let returned = persist_tab_handle_refresh_replacement( + &json!({ + "action": "tab_handle_refresh", + "serviceName": "books-receipts", + "agentName": "receipt-agent", + "taskName": "collect-receipts" + }), + "caller-a", + "replacement", + "https://books.example/receipts", + "Receipts", + &provisional, + ) + .unwrap(); + + assert_eq!(returned["tabId"], "target:replacement"); + assert_eq!(returned["targetId"], "replacement"); + assert_eq!(returned["traceFilter"]["serviceName"], "books-receipts"); + assert_eq!(returned["traceFilter"]["agentName"], "receipt-agent"); + assert_eq!(returned["traceFilter"]["taskName"], "collect-receipts"); + let current = store.load().unwrap(); + assert_eq!( + current.tabs["target:replacement"] + .owner_session_id + .as_deref(), + Some("caller-a") + ); +} + #[test] fn test_tab_handle_refresh_classifies_retained_candidates() { let ready_browser = BrowserProcess { @@ -812,6 +890,250 @@ fn test_tab_handle_refresh_classifies_live_pages_by_origin() { ); } #[test] +fn test_tab_handle_refresh_open_if_missing_does_not_adopt_peer_blank_and_reports_no_cleanup() { + let mut service_state = ServiceState::default(); + service_state.browsers.insert( + "browser-shared".to_string(), + BrowserProcess { + id: "browser-shared".to_string(), + health: ServiceBrowserHealth::Ready, + profile_id: Some("profile-shared".to_string()), + ..BrowserProcess::default() + }, + ); + for (session_id, service_name) in [ + ("caller-a", "books-receipts"), + ("caller-b", "unrelated-service"), + ] { + service_state.sessions.insert( + session_id.to_string(), + BrowserSession { + id: session_id.to_string(), + service_name: Some(service_name.to_string()), + profile_id: Some("profile-shared".to_string()), + ..BrowserSession::default() + }, + ); + } + service_state.tabs.insert( + "target:caller-a-closed".to_string(), + BrowserTab { + id: "target:caller-a-closed".to_string(), + browser_id: "browser-shared".to_string(), + target_id: Some("caller-a-closed".to_string()), + session_id: Some("caller-a".to_string()), + owner_session_id: Some("caller-a".to_string()), + lifecycle: TabLifecycle::Closed, + url: Some("https://books.example/receipts".to_string()), + ..BrowserTab::default() + }, + ); + service_state.tabs.insert( + "target:caller-b-blank".to_string(), + BrowserTab { + id: "target:caller-b-blank".to_string(), + browser_id: "browser-shared".to_string(), + target_id: Some("caller-b-blank".to_string()), + session_id: Some("caller-b".to_string()), + owner_session_id: Some("caller-b".to_string()), + lifecycle: TabLifecycle::Ready, + url: Some("about:blank".to_string()), + ..BrowserTab::default() + }, + ); + let stale_handle = serde_json::to_value( + service_state + .service_tab_handle("target:caller-a-closed") + .expect("caller A handle should remain projected"), + ) + .unwrap(); + let pages = vec![PageInfo { + target_id: "caller-b-blank".to_string(), + session_id: "cdp-caller-b".to_string(), + url: "about:blank".to_string(), + title: String::new(), + target_type: "page".to_string(), + }]; + + let plan = plan_tab_handle_refresh( + &service_state, + stale_handle.as_object().unwrap(), + &pages, + Some("https://books.example"), + "open_if_missing", + ); + + assert_eq!(plan, TabHandleRefreshPlan::OpenReplacement); + let response = tab_handle_refresh_success_with_cleanup( + json!({"ok": true, "action": "tab_handle_refresh"}), + no_duplicate_target_cleanup(), + ); + assert_eq!(response["duplicateCleanupAttempted"], false); + assert_eq!(response["peerCleanupAttempted"], false); + assert_eq!(response["duplicateTargetCleanup"]["attempted"], false); +} +#[test] +fn test_tab_handle_refresh_replace_duplicates_preserves_peer_targets() { + let mut service_state = ServiceState::default(); + service_state.browsers.insert( + "browser-shared".to_string(), + BrowserProcess { + id: "browser-shared".to_string(), + health: ServiceBrowserHealth::Ready, + profile_id: Some("profile-shared".to_string()), + ..BrowserProcess::default() + }, + ); + for (session_id, service_name) in [ + ("caller-a", "books-receipts"), + ("caller-b", "unrelated-service"), + ] { + service_state.sessions.insert( + session_id.to_string(), + BrowserSession { + id: session_id.to_string(), + service_name: Some(service_name.to_string()), + agent_name: Some(format!("{session_id}-agent")), + task_name: Some(format!("{session_id}-task")), + profile_id: Some("profile-shared".to_string()), + ..BrowserSession::default() + }, + ); + } + for (target_id, owner, lifecycle, url) in [ + ( + "caller-a-closed", + "caller-a", + TabLifecycle::Closed, + "https://books.example/closed", + ), + ( + "caller-a-selected", + "caller-a", + TabLifecycle::Ready, + "https://books.example/current", + ), + ( + "caller-a-duplicate", + "caller-a", + TabLifecycle::Ready, + "about:blank", + ), + ( + "caller-b-peer", + "caller-b", + TabLifecycle::Ready, + "about:blank", + ), + ( + "caller-b-same-origin", + "caller-b", + TabLifecycle::Ready, + "https://books.example/peer", + ), + ] { + service_state.tabs.insert( + format!("target:{target_id}"), + BrowserTab { + id: format!("target:{target_id}"), + browser_id: "browser-shared".to_string(), + target_id: Some(target_id.to_string()), + session_id: Some(owner.to_string()), + owner_session_id: Some(owner.to_string()), + lifecycle, + url: Some(url.to_string()), + ..BrowserTab::default() + }, + ); + } + let stale_handle = serde_json::to_value( + service_state + .service_tab_handle("target:caller-a-closed") + .expect("caller A handle should remain projected"), + ) + .unwrap(); + let pages = [ + ("caller-a-selected", "https://books.example/current"), + ("caller-a-duplicate", "about:blank"), + ("caller-b-peer", "about:blank"), + ("caller-b-same-origin", "https://books.example/peer"), + ("unattributed-blank", "about:blank"), + ] + .into_iter() + .map(|(target_id, url)| PageInfo { + target_id: target_id.to_string(), + session_id: format!("cdp-{target_id}"), + url: url.to_string(), + title: String::new(), + target_type: "page".to_string(), + }) + .collect::>(); + + let plan = plan_tab_handle_refresh( + &service_state, + stale_handle.as_object().unwrap(), + &pages, + Some("https://books.example"), + "replace_duplicates", + ); + let selected_target_id = match plan { + TabHandleRefreshPlan::ReuseCallerBound { + target_id, + canonical_handle, + } => { + assert_eq!( + canonical_handle.trace_filter.service_name.as_deref(), + Some("books-receipts") + ); + assert_eq!( + canonical_handle.trace_filter.agent_name.as_deref(), + Some("caller-a-agent") + ); + assert_eq!( + canonical_handle.trace_filter.task_name.as_deref(), + Some("caller-a-task") + ); + target_id + } + other => panic!("expected caller-bound reuse, got {other:?}"), + }; + let duplicates = caller_bound_compatible_duplicate_live_pages( + &service_state, + stale_handle.as_object().unwrap(), + &pages, + &selected_target_id, + Some("https://books.example"), + ); + + assert_eq!(duplicates.len(), 1); + assert_eq!(duplicates[0]["targetId"], "caller-a-duplicate"); + let candidate = service_state.tabs["target:caller-a-duplicate"].clone(); + service_state + .tabs + .get_mut("target:caller-a-duplicate") + .unwrap() + .principal_id = Some("different-principal".to_string()); + assert!(caller_bound_refresh_handle( + &service_state, + stale_handle.as_object().unwrap(), + "caller-a-duplicate" + ) + .is_none()); + service_state.tabs.insert(candidate.id.clone(), candidate); + let candidate = service_state + .tabs + .get_mut("target:caller-a-duplicate") + .unwrap(); + candidate.owner_session_id = None; + candidate.session_id = None; + assert!(caller_bound_refresh_handle( + &service_state, + stale_handle.as_object().unwrap(), + "caller-a-duplicate" + ) + .is_none()); +} +#[test] fn test_tab_handle_refresh_selects_compatible_duplicate_live_pages() { let pages = vec![ PageInfo { diff --git a/cli/src/output.rs b/cli/src/output.rs index 7c86aaadb2..23d14292f0 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -7050,6 +7050,7 @@ Notes: - The stdio server reads newline-delimited JSON-RPC messages from stdin and writes MCP messages to stdout. - MCP tools include service_access_plan, service_request, service_job_cancel, service_incidents, service_remedies_apply, service_trace, service_profile_upsert, service_profile_freshness_update, service_profile_seeding_handoff_update, service_profile_delete, service_session_upsert, service_session_delete, service_site_policy_upsert, service_site_policy_delete, service_monitor_upsert, service_monitor_delete, service_monitors_run_due, service_monitor_pause, service_monitor_resume, service_monitor_reset_failures, service_monitor_triage, service_provider_upsert, service_provider_delete, service_browser_capability_registry_upsert, browser_navigate, browser_requests, browser_request_detail, browser_headers, browser_offline, browser_cookies_get, browser_cookies_set, browser_cookies_clear, browser_storage_get, browser_storage_set, browser_storage_clear, browser_user_agent, browser_viewport, browser_geolocation, browser_permissions, browser_timezone, browser_locale, browser_media, browser_dialog, browser_upload, browser_download, browser_wait_for_download, browser_har_start, browser_har_stop, browser_route, browser_unroute, browser_console, browser_errors, browser_pdf, browser_response_body, browser_clipboard, browser_back, browser_forward, browser_reload, browser_tab_new, browser_tab_switch, browser_tab_close, browser_set_content, browser_command, browser_snapshot, browser_get_url, browser_get_title, browser_tabs, browser_screenshot, browser_click, browser_fill, browser_wait, browser_type, browser_press, browser_hover, browser_select, browser_get_text, browser_get_value, browser_get_attribute, browser_get_html, browser_get_styles, browser_count, browser_get_box, browser_is_visible, browser_is_enabled, browser_check, browser_is_checked, browser_uncheck, browser_scroll, browser_scroll_into_view, browser_focus, and browser_clear. - service_request accepts one intent object with serviceName, agentName, taskName, siteId/loginId/accountId/url, targetServiceId, browserBuild, browserHost, viewStreamProvider, controlInputProvider, displayIsolation, profile or runtimeProfile hints, top-level browserId/sessionName reuse route hints, profileLeasePolicy, profileLeaseWaitTimeoutMs, action, params, and jobTimeoutMs, then queues the browser command through the same service-owned control path. Top-level browserId/sessionName route ordinary commands to an existing daemon lane selected by access-plan profileReuse; params.browserId/params.sessionName remain action parameters. Direct launches that select a profile already backed by a live retained browser are rejected unless they use those route hints or allowDuplicateProfileLane=true for reviewed isolation or throwaway work. Use action=external_byop_adopt with a registered external_byop profile and exactly one explicit cdpUrl or cdpPort when an already-running Chrome lane should become retained attached_existing browser/session/tab state before the next access plan. Use action=probe with a valid serviceTabHandle, timeoutMs, maxReturnBytes, and a provider-neutral probe.detectors recipe for bounded identity, account, readiness, or page-state evidence; supported generic detector types are url_title, selector_text, evaluate, and client_evidence. Optional probe.recordFreshness merges target/account freshness evidence into the selected service profile. Use action=tab_handle_refresh with a current or stale serviceTabHandle, optional desiredUrl, and repairPolicy=reject_only, reuse_compatible, open_if_missing, or replace_duplicates when a client needs structured stale-tab evidence, a refreshed generic handle, or one compatible target with duplicate cleanup before follow-on work. Use action=tab_handle_release with a serviceTabHandle when a client is finished with a leased shared-profile tab; release recovers the identity-verified retained connection when needed and verifies exact physical target removal plus observed peer survival before marking the tab closed; unverified cleanup fails and preserves the handle for diagnosis. Use action=ui_action with a valid serviceTabHandle, timeoutMs, optional maxTextBytes, and a bounded uiAction.steps recipe for generic find, focus, fill, type, select, menu_select, click, wait, clear, or guarded dialog steps; clients supply website-specific selectors and instructions while agent-browser owns handle validation, caps, trace, and per-step evidence. Use action=network_capture with a valid serviceTabHandle, timeoutMs, and bounded networkCapture.maxEvents for capped network evidence; metadata is default, headers are redacted unless allowlisted, and response bodies require captureBodies=true plus maxBodyBytes. Use action=file_transfer with a valid serviceTabHandle, timeoutMs, and a fileTransfer upload and/or download recipe for service-owned file input and download capture; uploads require selector or labelText, files, allowedPaths, and maxFiles, while downloads require selector, directory, allowedDirectories, and optional maxBytes. Authenticated dashboard RDP focus requires its own current controller lease and profile view_open permission; operator_focus_* failures retain exact recourse without rebinding the agent tab owner. Focus refuses competing desktop interaction without cancelling it. Operator-focus provenance records the authenticated account separately from caller labels and excludes the signed proof token. Controller leases without an expiry remain valid until release or replacement; each focus proof is independently short-lived. Use action=view_focus with params.targetId for exact selection, or params.index only when no target ID is supplied, plus params.maximize before opening a dashboard remote-view iframe for a retained tab. Use action=view_takeover with params.browserId, params.sessionName, params.streamId, params.provider, and params.openMode when a single-active-viewer RDP or Guacamole connection should be taken over or reconnected while preserving the browser process and session. Use action=service_remote_view_route_preflight to get a no-launch fastPreflight response with ready, partial, stale, or blocked component evidence before route-bound launch. Use action=service_remote_view_browser_reattach to show a retained RDP browser again without launching Chrome or acquiring its profile, and action=service_remote_view_route_switch to bind that browser to another available or parkable Guacamole route; route switch returns routeSwitchParking when it parks another live browser route because no route-pool entry was available. Use action=service_route_pool_repair with an exact acquisitionLeaseId and apply=false before attempting terminal quarantine recovery. Apply is accepted only when the matching browser, process identity, and session are absent and the route, display, and pool are terminal; otherwise the response retains quarantine with a typed skipped reason. Use service_viewer_lease_request, service_viewer_lease_heartbeat, service_viewer_lease_release, and service_controller_lease_takeover for explicit remote-view observer and controller leases. Use action=cdp_free_launch when the request should launch and track a headed browser without a DevTools port. Remote-headed CDP-free requests retain their explicit hidden display and RDP view-stream metadata without enabling DevTools. Its response includes unsupportedCommands for service request actions that still require CDP; software clients can use summarizeServiceCdpFreeLaunchAvailability for API, MCP, or dashboard control availability. + - tab_handle_refresh reuses only a compatible target with canonical Service State custody for the same caller. open_if_missing persists a canonical replacement when needed. replace_duplicates closes only same-caller targets and preserves foreign or unattributed peers. Read duplicateCleanupAttempted, peerCleanupAttempted, and duplicateTargetCleanup from every response. - challenge_control_evaluate is a no-launch provider-free Service action. It accepts one registered challengeProfileId, one enumerated scenarioOutcome, and attribution labels, then returns a composite receipt with evidenceClass=provider_free_scenario and emittedEffects=false. It does not accept pixels, coordinates, detector settings, provider controls, or retry options, and its result is not live CAPTCHA acceptance. - service_challenge_task_start, status, resume, and cancel provide durable provider-free orchestration bound to one caller principal and exact current Service tab handle. Start accepts only a registered challenge profile and fixture scenario, site-policy digest, downstream intent, idempotency key, deadline, and transition budget. Resume and cancel require an operationId. Receipts separate delivery, verification, outcome, cooldown, intervention, and admission; these actions never launch a browser, emit input, contact a provider, use credentials, or prove live CAPTCHA acceptance. - Challenge-aware Authentication Run start accepts challengeTaskId and sitePolicyId only together. Challenge-aware navigate also requires authenticated clientSubjectId, or callerId on the direct native path, plus a unique operationId and exact current serviceTabHandle. Before confirmation, recovery, browser launch, or dispatch, the Service resolves and hashes the effective site policy and verifies the principal, downstream intent, and tab. Successful admission and later consumer failures expose the typed challengeConsumerAdmission receipt without rewriting the retained challenge outcome or admission decision. Consumer admission grants no CAPTCHA, provider, credential, retry, or production authority. diff --git a/docs/dev/active-lanes.yaml b/docs/dev/active-lanes.yaml index 52fb41bdcd..8efdd04d91 100644 --- a/docs/dev/active-lanes.yaml +++ b/docs/dev/active-lanes.yaml @@ -111,3 +111,17 @@ lanes: remote_ref: refs/heads/fix/native-released-acquisition integration_receipt: https://github.com/CochranResearchGroup/agent-browser/pull/237 validation_status: Installed twelve-browser pressure passed at ten desktops with wraparound; own browsers closed without process residue; foreign desktop preserved + - id: P207 + product_lane: PL-BUGFIX + objective: Preserve caller custody and explicit cleanup evidence during tab refresh + plan: docs/dev/plans/0207-2026-10-10-tab-refresh-caller-custody.md + branch: fix/p207-current-custody + target: main + plan_state: IMPLEMENTING + custody_state: ACTIVE_WORKTREE + work_items: [CochranResearchGroup/agent-browser#175] + dependencies: [] + overlaps: ["CochranResearchGroup/agent-browser#184", P227] + reconciled_overlaps: ["P227 integrated; this checkout alone adapts PR 184 refresh surfaces; original branch and dirty primary work preserved"] + blocker: null + updated_at: 2026-10-10 diff --git a/docs/dev/contracts/service-request.v1.schema.json b/docs/dev/contracts/service-request.v1.schema.json index afcb034882..43d23c574c 100644 --- a/docs/dev/contracts/service-request.v1.schema.json +++ b/docs/dev/contracts/service-request.v1.schema.json @@ -285,7 +285,7 @@ "repairPolicy": { "type": "string", "enum": ["reject_only", "reuse_compatible", "open_if_missing", "replace_duplicates"], - "description": "Repair policy for action=tab_handle_refresh. reject_only returns structured stale evidence, reuse_compatible can bind to a compatible retained target, open_if_missing can create a replacement tab in the routed browser session, and replace_duplicates reuses or opens one compatible target while best-effort closing other compatible live targets." + "description": "Repair policy for action=tab_handle_refresh. Compatible reuse requires canonical Service State custody for the same caller. open_if_missing persists a canonical replacement when no caller-bound target exists. replace_duplicates closes only compatible targets with the same caller custody. Responses report duplicateCleanupAttempted, peerCleanupAttempted, and duplicateTargetCleanup evidence." }, "browserBuild": { "type": "string", diff --git a/docs/dev/plans/0207-2026-10-10-tab-refresh-caller-custody.md b/docs/dev/plans/0207-2026-10-10-tab-refresh-caller-custody.md new file mode 100644 index 0000000000..7120cd42ca --- /dev/null +++ b/docs/dev/plans/0207-2026-10-10-tab-refresh-caller-custody.md @@ -0,0 +1,38 @@ +# P207: preserve caller custody during tab refresh + +Product lane: PL-BUGFIX +Work item: [issue 175](https://github.com/CochranResearchGroup/agent-browser/issues/175) +Baseline: `4cbea18ecf0879b71a578e508cc3770ab1e719b2` +Branch: `fix/p207-current-custody` +State: IMPLEMENTING + +When two tasks intentionally share a profile, refreshing one task's closed tab +must preserve the other task's tab. Blank pages and matching origins are discovery +hints, not ownership evidence. Issue 175 also requires explicit cleanup-attempt +fields so consumers can accept a repair without guessing. + +This repair adapts the retained PR 184 implementation to current main. It owns +only refresh reuse, canonical replacement persistence, duplicate selection and +response evidence. It does not revive the archived platform requirements or +change Remote View desktop allocation. PR 191 remains separate. + +Acceptance: + +1. A closed caller handle cannot adopt an unrelated blank or same-origin target. +2. Reuse returns the compatible target's canonical caller-bound handle. +3. A new replacement persists caller identity and is usable by subsequent actions. +4. Duplicate cleanup closes only compatible targets with matching canonical + session, principal, work lease and profile-access custody. +5. Every structured refresh response reports duplicate and peer cleanup attempts. +6. A disposable real-browser workflow preserves the peer target while opening a + replacement and closing a caller-owned duplicate. + +Validation starts with a failing fixture exercising current main's actual +selection function, followed by focused Rust tests, client contracts, formatting, +strict workspace Clippy and the isolated browser workflow. Review Standards and +Spec separately against the pinned baseline and issue 175. + +The clean checkout is the primary writer for these surfaces. The dirty primary +checkout and retained PR 184 branch are preserved. The open operator Facebook +profile, production services and other agents' desktops remain outside the test. +No reboot or memory write is authorized by this packet. diff --git a/docs/dev/plans/README.md b/docs/dev/plans/README.md index dc0f95fb48..23d9ef44d8 100644 --- a/docs/dev/plans/README.md +++ b/docs/dev/plans/README.md @@ -1,5 +1,7 @@ # Active plan index +[P207 caller-custody tab refresh](0207-2026-10-10-tab-refresh-caller-custody.md) is implementing the bounded issue 175 repair against current main. + [P228 native released acquisition regrowth](0228-2026-10-09-native-released-acquisition.md) is CLOSED: PR 237 integrated and installed twelve-browser pressure acceptance passed on 2026-10-10. [P227 shared profiles, retention and desktop capacity](0227-2026-10-08-alice-bob-reconciliation.md) is CLOSED under issue 233 after the four agreed behavior steps, isolated installed validation, cleanup and canonical source integration through PR 234. RUNBOOK preserves the failed attempts and qualification limits. diff --git a/docs/src/app/service-mode/page.mdx b/docs/src/app/service-mode/page.mdx index bbed6d1178..92d710c24e 100644 --- a/docs/src/app/service-mode/page.mdx +++ b/docs/src/app/service-mode/page.mdx @@ -1452,7 +1452,7 @@ CDP-sensitive sites can declare requiresCdpFree on their site polic For bring-your-own-profile migrations, register the profile as external_byop with registerExternalProfile(), then use requestServiceExternalByopAdopt() or adoptExternalByopBrowser() only when the client can provide exactly one explicit cdpUrl or cdpPort for the already-running Chrome lane. The external_byop_adopt service request records that lane as retained host: "attached_existing" browser/session/tab state, returns a serviceTabHandle, and leaves cleanup as detach so agent-browser does not close the caller-owned browser by default. The next access plan for the same identity can then recommend reuse_existing_browser with browserId and sessionName route hints. external_observed profiles remain process evidence only and are not reusable identity lanes. Run pnpm test:service-external-byop-adopt-live to validate the generic adoption path against an isolated source Chrome and service daemon. -When a retained service-tab handle may be stale, call refreshServiceTabHandle() or service request action: "tab_handle_refresh" with the old serviceTabHandle, optional desiredUrl, and a repair policy. reject_only returns structured stale evidence, reuse_compatible binds a compatible same-origin or blank target, open_if_missing creates a replacement target in the routed browser, and replace_duplicates selects one compatible target while best-effort closing other compatible live targets. +When a retained service-tab handle may be stale, call refreshServiceTabHandle() or service request action: "tab_handle_refresh" with the old serviceTabHandle, optional desiredUrl, and a repair policy. reject_only returns structured stale evidence. reuse_compatible reuses a same-origin or blank target only when canonical Service State proves the same caller owns it. open_if_missing persists a canonical replacement when no caller-bound target exists. replace_duplicates closes only compatible same-caller targets and preserves foreign or unattributed peers. Responses expose duplicateCleanupAttempted, peerCleanupAttempted, and nested duplicateTargetCleanup evidence. When a shared-profile client is finished with its leased tab, call releaseServiceTabHandle() or service request action: "tab_handle_release". Release best-effort closes that exact physical target when the routed live browser owns it, marks only the retained tab closed in service state, returns stale-handle evidence, and preserves the browser process plus session route so other clients can keep using the shared profile lane. diff --git a/packages/client/src/service-request.generated.d.ts b/packages/client/src/service-request.generated.d.ts index 1b1a9a32d9..70d660f9bd 100644 --- a/packages/client/src/service-request.generated.d.ts +++ b/packages/client/src/service-request.generated.d.ts @@ -439,6 +439,16 @@ export interface ServiceTabHandleRefreshData { url?: string | null; title?: string | null; serviceTabHandle?: ServiceTabHandle | null; + duplicateCleanupAttempted: boolean; + peerCleanupAttempted: boolean; + duplicateTargetCleanup: { + policy: "preserve" | "replace_duplicates" | string; + attempted: boolean; + closedCount: number; + closedTargets: Record[]; + failedTargets: Record[]; + [key: string]: unknown; + }; candidates: Record[]; [key: string]: unknown; } diff --git a/scripts/generate-service-request-client.js b/scripts/generate-service-request-client.js index 340f2b73f6..e3b190c94c 100644 --- a/scripts/generate-service-request-client.js +++ b/scripts/generate-service-request-client.js @@ -316,6 +316,16 @@ export interface ServiceTabHandleRefreshData { url?: string | null; title?: string | null; serviceTabHandle?: ServiceTabHandle | null; + duplicateCleanupAttempted: boolean; + peerCleanupAttempted: boolean; + duplicateTargetCleanup: { + policy: "preserve" | "replace_duplicates" | string; + attempted: boolean; + closedCount: number; + closedTargets: Record[]; + failedTargets: Record[]; + [key: string]: unknown; + }; candidates: Record[]; [key: string]: unknown; } diff --git a/scripts/smoke-service-tab-handle-refresh-live.js b/scripts/smoke-service-tab-handle-refresh-live.js index 6f6dcf6abc..0df82ece95 100644 --- a/scripts/smoke-service-tab-handle-refresh-live.js +++ b/scripts/smoke-service-tab-handle-refresh-live.js @@ -1,6 +1,8 @@ #!/usr/bin/env node import { request } from 'node:http'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { assert, @@ -21,8 +23,8 @@ const { session } = context; const serviceName = 'ServiceTabHandleRefreshSmoke'; const agentName = 'smoke-agent'; const taskName = 'plan0034TabHandleRefresh'; +const profileId = 'tab-handle-refresh-profile'; const targetServiceId = 'generic-tab-handle-site'; -const browserId = `session:${session}`; const primaryHtml = 'Plan 0034 Refresh Primary
primary
'; const fallbackHtml = 'Plan 0034 Refresh Fallback
fallback
'; const primaryUrl = `data:text/html;charset=utf-8,${encodeURIComponent(primaryHtml)}`; @@ -32,10 +34,29 @@ const timeout = setTimeout(() => { }, 180000); let streamPort; +const routedSessions = new Set([session]); async function cleanup() { clearTimeout(timeout); - await closeSession(context); + for (const routedSession of routedSessions) { + await closeSession({ ...context, session: routedSession }); + } + const pidPath = join(context.socketDir, 'runtime-host.pid'); + if (existsSync(pidPath)) { + const pid = Number.parseInt(readFileSync(pidPath, 'utf8'), 10); + const procDir = `/proc/${pid}`; + if (Number.isInteger(pid) && pid > 1 && existsSync(procDir)) { + const environment = readFileSync(join(procDir, 'environ'), 'utf8').split('\0'); + assert(environment.includes(`AGENT_BROWSER_HOME=${context.agentHome}`), + 'refusing to stop a runtime host outside the disposable smoke home'); + process.kill(pid, 'SIGTERM'); + const deadline = Date.now() + 10000; + while (existsSync(procDir) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + assert(!existsSync(procDir), 'disposable runtime host did not exit'); + } + } context.cleanupTempHome(); } @@ -98,6 +119,7 @@ async function serviceRequest(body, label) { agentName, taskName, targetServiceId, + runtimeProfile: profileId, jobTimeoutMs: 60000, ...body, }, 90000); @@ -133,9 +155,122 @@ async function serviceTrace() { return trace.data; } +async function registerStockChromeCapability() { + const checkedAt = new Date().toISOString(); + const records = [ + ['browserHosts', 'local-linux', { + id: 'local-linux', + name: 'Disposable local Linux host', + hostKind: 'local', + operatingSystem: 'linux', + displaySupport: 'x11', + remoteViewSupport: false, + reachable: true, + lifecycleOwner: 'agent_browser', + health: 'ready', + lastCheckedAt: checkedAt, + tags: ['disposable-smoke'], + }], + ['browserExecutables', 'local-google-chrome', { + id: 'local-google-chrome', + hostId: 'local-linux', + browserFamily: 'chrome', + vendor: 'google', + channel: 'stable', + buildLabel: 'stock_chrome', + executablePath: '/usr/bin/google-chrome', + source: 'system', + manifestPath: null, + version: null, + patchsetId: null, + fresh: true, + lastCheckedAt: checkedAt, + tags: ['disposable-smoke'], + }], + ['browserCapabilities', 'local-google-chrome-capability', { + id: 'local-google-chrome-capability', + hostId: 'local-linux', + executableId: 'local-google-chrome', + cdpSupported: true, + cdpFreeLaunchSupported: true, + extensionsSupported: true, + passkeysSupported: false, + headedSupported: true, + headlessSupported: true, + streamingSupported: true, + profileLockBehavior: 'exclusive_user_data_dir', + keyringBehavior: 'basic_password_store', + knownLimits: ['Disposable unauthenticated smoke only'], + }], + ['profileCompatibility', 'refresh-profile-local-google-chrome', { + id: 'refresh-profile-local-google-chrome', + profileId, + hostId: 'local-linux', + executableId: 'local-google-chrome', + compatible: true, + requiresOperatorOverride: false, + reason: 'same_browser_family', + notes: 'Disposable profile created for the tab-handle refresh smoke.', + }], + ['browserPreferenceBindings', 'refresh-smoke-stock-chrome', { + id: 'refresh-smoke-stock-chrome', + scope: 'service', + targetServiceIds: [targetServiceId], + accountIds: [], + serviceNames: [serviceName], + taskNames: [], + preferredHostId: 'local-linux', + preferredExecutableId: 'local-google-chrome', + preferredCapabilityId: 'local-google-chrome-capability', + browserBuild: 'stock_chrome', + priority: 100, + reason: 'Disposable real-browser acceptance binding.', + }], + ['validationEvidence', 'refresh-smoke-stock-chrome-launch', { + id: 'refresh-smoke-stock-chrome-launch', + hostId: 'local-linux', + executableId: 'local-google-chrome', + capabilityId: 'local-google-chrome-capability', + kind: 'launch', + state: 'passed', + checkedAt, + evidence: 'Local executable presence checked before disposable smoke launch.', + artifactPath: null, + }], + ]; + for (const [collection, id, record] of records) { + const result = await httpJsonWithTimeout( + streamPort, + 'POST', + `/api/service/browser-capability-registry/${collection}/${id}`, + record, + 60000, + ); + assert(result.success === true, `${collection}/${id} upsert failed: ${JSON.stringify(result)}`); + } +} + try { streamPort = await ensureStreamPort(context, 120000); + const profileUpsert = await httpJsonWithTimeout( + streamPort, + 'POST', + `/api/service/profiles/${encodeURIComponent(profileId)}`, + { + name: 'Tab Handle Refresh Profile', + allocation: 'per_service', + keyring: 'basic_password_store', + persistent: true, + targetServiceIds: [targetServiceId], + sharedServiceIds: [serviceName], + }, + 60000, + ); + assert(profileUpsert.success === true, `profile upsert failed: ${JSON.stringify(profileUpsert)}`); + assert(profileUpsert.data?.profile?.id === profileId, `profile id mismatch: ${JSON.stringify(profileUpsert)}`); + await registerStockChromeCapability(); + const primaryTab = await serviceRequest( { action: 'tab_new', @@ -149,7 +284,9 @@ try { ); const handle = primaryTab.data?.serviceTabHandle; assert(handle?.valid === true, `primary tab_new did not return a valid handle: ${JSON.stringify(primaryTab)}`); - assert(handle?.browserId === browserId, `primary handle browser mismatch: ${JSON.stringify(handle)}`); + const browserId = handle?.browserId; + if (handle?.sessionName) routedSessions.add(handle.sessionName); + assert(typeof browserId === 'string' && browserId, `primary handle missing browserId: ${JSON.stringify(handle)}`); assert(typeof handle?.targetId === 'string' && handle.targetId, `primary handle missing targetId: ${JSON.stringify(handle)}`); const validRefresh = await serviceRequest( @@ -164,6 +301,8 @@ try { assert(validRefresh.data?.ok === true, `valid refresh was not ok: ${JSON.stringify(validRefresh)}`); assert(validRefresh.data?.decision === 'exact_handle_still_valid', `valid refresh decision mismatch: ${JSON.stringify(validRefresh.data)}`); assert(validRefresh.data?.serviceTabHandle?.targetId === handle.targetId, `valid refresh changed target: ${JSON.stringify(validRefresh.data)}`); + assert(validRefresh.data?.duplicateCleanupAttempted === false, `valid refresh attempted duplicate cleanup: ${JSON.stringify(validRefresh.data)}`); + assert(validRefresh.data?.peerCleanupAttempted === false, `valid refresh attempted peer cleanup: ${JSON.stringify(validRefresh.data)}`); const fallbackTab = await serviceRequest( { @@ -194,6 +333,7 @@ try { const closePrimary = await serviceRequest( { action: 'tab_close', + serviceTabHandle: handle, }, 'primary tab_close', ); @@ -212,6 +352,19 @@ try { assert(rejectRefresh.data?.ok === false, `reject refresh unexpectedly succeeded: ${JSON.stringify(rejectRefresh)}`); assert(rejectRefresh.data?.decision === 'rejected_stale_or_missing_target', `reject refresh decision mismatch: ${JSON.stringify(rejectRefresh.data)}`); assert(Array.isArray(rejectRefresh.data?.candidates), `reject refresh missing candidates: ${JSON.stringify(rejectRefresh.data)}`); + assert(rejectRefresh.data?.duplicateCleanupAttempted === false, `reject refresh attempted duplicate cleanup: ${JSON.stringify(rejectRefresh.data)}`); + assert(rejectRefresh.data?.peerCleanupAttempted === false, `reject refresh attempted peer cleanup: ${JSON.stringify(rejectRefresh.data)}`); + + const peerTask = { agentName: 'peer-agent', taskName: 'peer-task' }; + const peerTab = await serviceRequest( + { ...peerTask, action: 'tab_new', params: { headless: true, url: 'about:blank' } }, + 'peer blank tab_new', + ); + const peerHandle = peerTab.data?.serviceTabHandle; + assert(peerHandle?.valid === true, 'peer tab did not return a valid handle'); + if (peerHandle.sessionName) routedSessions.add(peerHandle.sessionName); + assert(peerHandle.browserId === browserId, 'peer did not share the browser'); + assert(peerHandle.sessionName !== handle.sessionName, 'peer did not have independent caller custody'); const openRefresh = await serviceRequest( { @@ -223,13 +376,54 @@ try { 'open stale tab_handle_refresh', ); assert(openRefresh.data?.ok === true, `open refresh was not ok: ${JSON.stringify(openRefresh)}`); - assert( - ['opened_replacement_target', 'reused_compatible_target'].includes(openRefresh.data?.decision), - `open refresh decision mismatch: ${JSON.stringify(openRefresh.data)}`, - ); + assert(openRefresh.data?.decision === 'opened_replacement_target', `open refresh decision mismatch: ${JSON.stringify(openRefresh.data)}`); assert(openRefresh.data?.serviceTabHandle?.valid === true, `open refresh did not return valid handle: ${JSON.stringify(openRefresh.data)}`); assert(openRefresh.data?.serviceTabHandle?.targetId !== handle.targetId, `open refresh reused stale target: ${JSON.stringify(openRefresh.data)}`); assert(openRefresh.data?.serviceTabHandle?.browserId === browserId, `open refresh browser mismatch: ${JSON.stringify(openRefresh.data)}`); + assert(openRefresh.data?.serviceTabHandle?.traceFilter?.serviceName === serviceName, `open refresh lost service attribution: ${JSON.stringify(openRefresh.data)}`); + assert(openRefresh.data?.serviceTabHandle?.traceFilter?.agentName === agentName, `open refresh lost agent attribution: ${JSON.stringify(openRefresh.data)}`); + assert(openRefresh.data?.serviceTabHandle?.traceFilter?.taskName === taskName, `open refresh lost task attribution: ${JSON.stringify(openRefresh.data)}`); + assert(openRefresh.data?.duplicateCleanupAttempted === false, `open refresh attempted duplicate cleanup: ${JSON.stringify(openRefresh.data)}`); + assert(openRefresh.data?.peerCleanupAttempted === false, `open refresh attempted peer cleanup: ${JSON.stringify(openRefresh.data)}`); + assert(openRefresh.data?.duplicateTargetCleanup?.attempted === false, `open refresh nested cleanup evidence mismatch: ${JSON.stringify(openRefresh.data)}`); + + const replacementHandle = openRefresh.data.serviceTabHandle; + const ownBlank = await serviceRequest( + { action: 'tab_new', params: { url: 'about:blank' } }, + 'own duplicate tab_new', + ); + const ownBlankHandle = ownBlank.data?.serviceTabHandle; + assert(ownBlankHandle?.valid === true, 'own duplicate did not return a valid handle'); + const cleanupRefresh = await serviceRequest( + { action: 'tab_handle_refresh', serviceTabHandle: replacementHandle, + repairPolicy: 'replace_duplicates', desiredUrl: primaryUrl }, + 'caller duplicate cleanup', + ); + const closedTargets = cleanupRefresh.data?.duplicateTargetCleanup?.closedTargets ?? []; + assert(closedTargets.some((target) => target.targetId === ownBlankHandle.targetId), + 'cleanup did not close the caller-owned blank duplicate'); + assert(!closedTargets.some((target) => target.targetId === peerHandle.targetId), + 'cleanup closed the peer target'); + const preservedPeer = await serviceRequest( + { ...peerTask, action: 'tab_handle_refresh', serviceTabHandle: peerHandle, + repairPolicy: 'reject_only', desiredUrl: 'about:blank' }, + 'preserved peer refresh', + ); + assert(preservedPeer.data?.ok === true, 'peer target was lost'); + assert(preservedPeer.data?.targetId === peerHandle.targetId, 'peer target changed'); + assert(preservedPeer.data?.url === 'about:blank', 'peer URL changed'); + + const preservedFallback = await serviceRequest( + { + action: 'tab_handle_refresh', + serviceTabHandle: fallbackHandle, + repairPolicy: 'reject_only', + desiredUrl: fallbackUrl, + }, + 'preserved fallback tab_handle_refresh', + ); + assert(preservedFallback.data?.ok === true, `fallback peer was not preserved: ${JSON.stringify(preservedFallback)}`); + assert(preservedFallback.data?.serviceTabHandle?.targetId === fallbackHandle.targetId, `fallback peer target changed: ${JSON.stringify(preservedFallback.data)}`); const trace = await serviceTrace(); const refreshJobs = (trace?.jobs ?? []).filter((job) => job.action === 'tab_handle_refresh'); diff --git a/scripts/test-service-request-client.js b/scripts/test-service-request-client.js index f6c143c291..11a5454eef 100644 --- a/scripts/test-service-request-client.js +++ b/scripts/test-service-request-client.js @@ -2472,9 +2472,18 @@ async function main() { refreshed: true, decision: 'exact_handle_still_valid', serviceTabHandle: tabHandle, + duplicateCleanupAttempted: false, + peerCleanupAttempted: false, + duplicateTargetCleanup: { + policy: 'preserve', + attempted: false, + closedCount: 0, + closedTargets: [], + failedTargets: [], + }, }, }); - await requestServiceTabHandleRefresh({ + const refreshResponse = await requestServiceTabHandleRefresh({ baseUrl: 'http://127.0.0.1:4849', fetch: refreshRecorder.fetch, serviceTabHandle: tabHandle, @@ -2482,6 +2491,9 @@ async function main() { }); assert.equal(refreshRecorder.calls[0].body.action, 'tab_handle_refresh'); assert.equal(refreshRecorder.calls[0].body.repairPolicy, 'reject_only'); + assert.equal(refreshResponse.data.duplicateCleanupAttempted, false); + assert.equal(refreshResponse.data.peerCleanupAttempted, false); + assert.equal(refreshResponse.data.duplicateTargetCleanup.attempted, false); const refreshAliasRecorder = createFetchRecorder({ success: true, data: { ok: true } }); await refreshServiceTabHandle({ baseUrl: 'http://127.0.0.1:4849', diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index e568978b49..2793cdcec6 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -1474,10 +1474,12 @@ If a follow-on service tab handle may be stale, use instead of inspecting raw CDP targets. Refresh accepts the old `serviceTabHandle`, optional `desiredUrl`, and a generic repair policy: `reject_only` for evidence-only rejection, `reuse_compatible` to bind a -compatible same-origin or blank tab, or `open_if_missing` to create a -replacement in the routed service session. Use `replace_duplicates` when the -client wants one compatible target and best-effort cleanup of other compatible -live targets before follow-on work. +compatible same-origin or blank tab only when canonical Service State proves +the same caller owns it, or `open_if_missing` to persist a canonical replacement +in the routed service session. Use `replace_duplicates` only for same-caller +compatible targets; foreign and unattributed peers are preserved. Check +`duplicateCleanupAttempted`, `peerCleanupAttempted`, and +`duplicateTargetCleanup` before follow-on work. Dashboard workspace viewport URLs that carry a stale `tab=target:*` selection are replaced with the current live service tab before control mode queues `view_focus`; do not treat that recovery as browser failure. From b050bb2dfbc76dfd29684abdd0e837851d1166ea Mon Sep 17 00:00:00 2001 From: Eric W Cochran Date: Sat, 10 Oct 2026 19:49:56 -0500 Subject: [PATCH 2/2] test: qualify shared-session refresh custody and record limits --- README.md | 2 +- RUNBOOK.md | 6 +++ .../native/browser_lifecycle/action_tests.rs | 36 +++++++++++++ docs/dev/active-lanes.yaml | 7 ++- ...7-2026-10-10-tab-refresh-caller-custody.md | 51 ++++++++++++++++++- docs/dev/plans/README.md | 2 +- .../smoke-service-tab-handle-refresh-live.js | 31 +++++++++-- 7 files changed, 126 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 515f223e98..8973aaef54 100644 --- a/README.md +++ b/README.md @@ -4423,7 +4423,7 @@ using the current service session and optional `desiredUrl` hint. Compatible reuse requires canonical Service State custody for the same caller. `open_if_missing` persists a canonical replacement when no caller-bound target exists. `replace_duplicates` closes only compatible same-caller targets and -preserves foreign or unattributed peers. Every response includes +preserves foreign or unattributed peers. Structured refresh responses include `duplicateCleanupAttempted`, `peerCleanupAttempted`, and nested `duplicateTargetCleanup` evidence. `service_remote_view_route_preflight`, HTTP `GET /api/service/remote-view/route-preflight`, MCP diff --git a/RUNBOOK.md b/RUNBOOK.md index a2a9b6ff88..5494295c80 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -4,6 +4,12 @@ Updated: 2026-10-06 ## Current execution +### 2026-10-10 P207 caller-custody refresh + +[P207](docs/dev/plans/0207-2026-10-10-tab-refresh-caller-custody.md) qualifies issue 175's bounded source repair. [PR 239](https://github.com/CochranResearchGroup/agent-browser/pull/239) owns canonical integration and supersedes the refresh implementation in PR 184. Distinct client subjects sharing one physical browser passed replacement persistence, compatible canonical reuse, caller-only duplicate cleanup and peer preservation. Fresh test process census was empty. Seven actual-function Rust fixtures, format, strict Clippy, client contracts, parity, docs links and the active planning audit passed. + +Preserve the limits: the first browser fixture did not establish separate custody; the corrected baseline reproduced peer adoption. An optimized compile hit a 4 GiB cap, initial Clippy hit 2 GiB before a 4 GiB run passed, and the full CLI focused unit binary hit 4 GiB before tests ran. That unit lane is not passed; the bounded decision relies on selected actual-function fixtures and the reproduced public workflow. The unoptimized candidate was smoke-only. Production promotion and broader regression qualification remain separate. PR 191 is unchanged; reboot remains deferred. The policy-wiring checker also fails its unchanged baseline requirement for a direct policy 0047 link; policy loading is outside this repair. Private receipts retain the failed and passed attempts. Memory disposition: forbidden; no memory-write authorization. + ### 2026-10-10 P228 acquisition closeout P228 is CLOSED under issue 236. Consumer PR 237 and provider PR 348 are integrated. Installed synthetic acceptance reached twelve independent browser profiles across ten native desktops with wraparound. All task closures passed; fresh process readback found zero test browser residue. Exact recovery retired a stale failed production reservation while preserving the foreign development desktop. Reboot remains deferred. Human continuation, durable profile continuity and retention timing remain separate lifecycle checks. diff --git a/cli/src/native/browser_lifecycle/action_tests.rs b/cli/src/native/browser_lifecycle/action_tests.rs index 823f837f41..131af9cefe 100644 --- a/cli/src/native/browser_lifecycle/action_tests.rs +++ b/cli/src/native/browser_lifecycle/action_tests.rs @@ -1132,6 +1132,42 @@ fn test_tab_handle_refresh_replace_duplicates_preserves_peer_targets() { "caller-a-duplicate" ) .is_none()); + + // The shared-profile relay can use one physical session. Canonical child + // subjects still distinguish its independent callers. + for tab in service_state.tabs.values_mut() { + let subject = if tab + .target_id + .as_deref() + .is_some_and(|id| id.starts_with("caller-b")) + { + "caller-b" + } else { + "caller-a" + }; + tab.owner_session_id = Some("caller-a".to_string()); + tab.session_id = Some("caller-a".to_string()); + tab.principal_id = None; + tab.profile_access = Some( + serde_json::from_value(json!({ + "subjectId": subject, + "identityAssurance": "self-declared", + "parentPolicyRevision": 1, + "accessDecisionId": format!("decision:{subject}"), + "permissions": ["profile_use", "tab_control_own", "tab_close_own"] + })) + .unwrap(), + ); + } + let duplicates = caller_bound_compatible_duplicate_live_pages( + &service_state, + stale_handle.as_object().unwrap(), + &pages, + &selected_target_id, + Some("https://books.example"), + ); + assert_eq!(duplicates.len(), 1); + assert_eq!(duplicates[0]["targetId"], "caller-a-duplicate"); } #[test] fn test_tab_handle_refresh_selects_compatible_duplicate_live_pages() { diff --git a/docs/dev/active-lanes.yaml b/docs/dev/active-lanes.yaml index 8efdd04d91..e0a30e90f3 100644 --- a/docs/dev/active-lanes.yaml +++ b/docs/dev/active-lanes.yaml @@ -117,11 +117,14 @@ lanes: plan: docs/dev/plans/0207-2026-10-10-tab-refresh-caller-custody.md branch: fix/p207-current-custody target: main - plan_state: IMPLEMENTING - custody_state: ACTIVE_WORKTREE + plan_state: COMPLETED + custody_state: INTEGRATED work_items: [CochranResearchGroup/agent-browser#175] dependencies: [] overlaps: ["CochranResearchGroup/agent-browser#184", P227] reconciled_overlaps: ["P227 integrated; this checkout alone adapts PR 184 refresh surfaces; original branch and dirty primary work preserved"] blocker: null updated_at: 2026-10-10 + integration_receipt: https://github.com/CochranResearchGroup/agent-browser/pull/239 + validation_status: Public two-subject recovery/reuse/cleanup, seven selected Rust fixtures, format, strict Clippy, clients and docs passed + limitations: ["Full CLI unit binary compilation reached 4 GiB before tests ran", "Smoke-only candidate; production runtime unchanged"] diff --git a/docs/dev/plans/0207-2026-10-10-tab-refresh-caller-custody.md b/docs/dev/plans/0207-2026-10-10-tab-refresh-caller-custody.md index 7120cd42ca..0d40befb85 100644 --- a/docs/dev/plans/0207-2026-10-10-tab-refresh-caller-custody.md +++ b/docs/dev/plans/0207-2026-10-10-tab-refresh-caller-custody.md @@ -4,7 +4,8 @@ Product lane: PL-BUGFIX Work item: [issue 175](https://github.com/CochranResearchGroup/agent-browser/issues/175) Baseline: `4cbea18ecf0879b71a578e508cc3770ab1e719b2` Branch: `fix/p207-current-custody` -State: IMPLEMENTING +State: COMPLETED within the source qualification below +Integration: [PR 239](https://github.com/CochranResearchGroup/agent-browser/pull/239); verify merged status for canonical custody When two tasks intentionally share a profile, refreshing one task's closed tab must preserve the other task's tab. Blank pages and matching origins are discovery @@ -36,3 +37,51 @@ The clean checkout is the primary writer for these surfaces. The dirty primary checkout and retained PR 184 branch are preserved. The open operator Facebook profile, production services and other agents' desktops remain outside the test. No reboot or memory write is authorized by this packet. + +## Validation and limits + +Current main's actual selection function failed on a peer blank target. The +isolated installed baseline also reproduced the reported peer adoption once the +fixture supplied distinct client subjects. The first live fixture used labels +without establishing independent custody; that attempt is inconclusive. + +The source candidate passed a disposable two-subject, one-browser workflow: +replacement creation, subsequent canonical-handle close, compatible reuse with +the canonical tab ID and caller attribution, caller-owned duplicate cleanup, +and peer target/URL preservation. Fresh process readback found no test residue. +Seven existing Rust test bodies passed against unchanged production helper +bodies and the current Service Model types in an isolated fixture executable. +The replacement-persistence invariant was also exercised through the public +live workflow. These are selected checks, not the comprehensive CLI suite. + +Workspace format, strict workspace Clippy, service-client contracts, API/MCP +parity, docs build, documentation links, handoff guidance and the active planning audit passed. Resource failures remain separate: the optimized +candidate compile reached its 4 GiB cap; initial Clippy reached 2 GiB before a +4 GiB run passed; the full CLI focused unit binary reached 4 GiB before its tests +ran. That Cargo test lane is not passed. The bounded source decision uses the +actual-function fixtures plus reproduced public workflow and required source +checks; broader unit regression qualification and production promotion are not +claimed. No host-reserve reduction, reboot or production publication occurred. + +The policy-wiring check fails an unchanged baseline assertion requiring a direct AGENTS.md link to policy 0047. AGENTS.md and that checker match the pinned baseline; this repair does not change policy loading. The failure is retained separately. + +## Standards + +Reviewed against AGENTS.md, policies 0031/0042/0050, and the baseline diff. +The repair uses existing Service State custody and persistence adapters. All +user-facing guidance and generated response contracts are updated. Duplicate +classification stays in one helper before caller filtering. No accepted +standards findings remain. + +## Spec + +Reviewed separately against issue 175 and the six acceptance statements above. +The live workflow proves the reported unsafe adoption is removed, canonical +replacement/reuse works, explicit no-cleanup evidence is present, and cleanup +preserves the peer. Same-origin, unattributed, missing-owner and shared-physical- +session child-subject cases are covered by the selected Rust fixtures. No +accepted spec findings remain; the full CLI unit-build limit is retained as a +qualification limit rather than relabeled as a passed test. + +Review summary: Standards 0 accepted findings; Spec 0 accepted findings. +Memory disposition: forbidden; no user authorization for persistent memory writes. diff --git a/docs/dev/plans/README.md b/docs/dev/plans/README.md index 23d9ef44d8..293f6de508 100644 --- a/docs/dev/plans/README.md +++ b/docs/dev/plans/README.md @@ -1,6 +1,6 @@ # Active plan index -[P207 caller-custody tab refresh](0207-2026-10-10-tab-refresh-caller-custody.md) is implementing the bounded issue 175 repair against current main. +[P207 caller-custody tab refresh](0207-2026-10-10-tab-refresh-caller-custody.md) completed the bounded issue 175 source repair; PR 239 owns canonical integration and the plan records the unit-build qualification limit. [P228 native released acquisition regrowth](0228-2026-10-09-native-released-acquisition.md) is CLOSED: PR 237 integrated and installed twelve-browser pressure acceptance passed on 2026-10-10. diff --git a/scripts/smoke-service-tab-handle-refresh-live.js b/scripts/smoke-service-tab-handle-refresh-live.js index 0df82ece95..5d8c544dfa 100644 --- a/scripts/smoke-service-tab-handle-refresh-live.js +++ b/scripts/smoke-service-tab-handle-refresh-live.js @@ -119,6 +119,7 @@ async function serviceRequest(body, label) { agentName, taskName, targetServiceId, + clientSubjectId: agentName, runtimeProfile: profileId, jobTimeoutMs: 60000, ...body, @@ -355,7 +356,7 @@ try { assert(rejectRefresh.data?.duplicateCleanupAttempted === false, `reject refresh attempted duplicate cleanup: ${JSON.stringify(rejectRefresh.data)}`); assert(rejectRefresh.data?.peerCleanupAttempted === false, `reject refresh attempted peer cleanup: ${JSON.stringify(rejectRefresh.data)}`); - const peerTask = { agentName: 'peer-agent', taskName: 'peer-task' }; + const peerTask = { agentName: 'peer-agent', taskName: 'peer-task', clientSubjectId: 'peer-agent' }; const peerTab = await serviceRequest( { ...peerTask, action: 'tab_new', params: { headless: true, url: 'about:blank' } }, 'peer blank tab_new', @@ -364,7 +365,8 @@ try { assert(peerHandle?.valid === true, 'peer tab did not return a valid handle'); if (peerHandle.sessionName) routedSessions.add(peerHandle.sessionName); assert(peerHandle.browserId === browserId, 'peer did not share the browser'); - assert(peerHandle.sessionName !== handle.sessionName, 'peer did not have independent caller custody'); + assert(handle.profileAccess?.subjectId === agentName, 'caller lacks canonical subject custody'); + assert(peerHandle.profileAccess?.subjectId === peerTask.clientSubjectId, 'peer lacks independent canonical subject custody'); const openRefresh = await serviceRequest( { @@ -394,13 +396,34 @@ try { ); const ownBlankHandle = ownBlank.data?.serviceTabHandle; assert(ownBlankHandle?.valid === true, 'own duplicate did not return a valid handle'); - const cleanupRefresh = await serviceRequest( + await serviceRequest( + { action: 'tab_close', serviceTabHandle: replacementHandle }, + 'canonical replacement tab_close', + ); + const ownReuse = await serviceRequest( { action: 'tab_handle_refresh', serviceTabHandle: replacementHandle, + repairPolicy: 'reuse_compatible', desiredUrl: primaryUrl }, + 'caller-bound compatible reuse', + ); + const reusedHandle = ownReuse.data?.serviceTabHandle; + assert(ownReuse.data?.decision === 'reused_compatible_target', 'own compatible target was not reused'); + assert(reusedHandle?.tabId === ownBlankHandle.tabId, 'reuse did not return the canonical tab ID'); + assert(reusedHandle?.targetId === ownBlankHandle.targetId, 'reuse returned the wrong target'); + assert(reusedHandle?.profileAccess?.subjectId === agentName, 'reuse lost caller custody'); + assert(reusedHandle?.traceFilter?.agentName === agentName, 'reuse lost caller attribution'); + const ownDuplicate = await serviceRequest( + { action: 'tab_new', params: { url: 'about:blank' } }, + 'second own duplicate tab_new', + ); + const ownDuplicateHandle = ownDuplicate.data?.serviceTabHandle; + assert(ownDuplicateHandle?.valid === true, 'second own duplicate did not return a valid handle'); + const cleanupRefresh = await serviceRequest( + { action: 'tab_handle_refresh', serviceTabHandle: reusedHandle, repairPolicy: 'replace_duplicates', desiredUrl: primaryUrl }, 'caller duplicate cleanup', ); const closedTargets = cleanupRefresh.data?.duplicateTargetCleanup?.closedTargets ?? []; - assert(closedTargets.some((target) => target.targetId === ownBlankHandle.targetId), + assert(closedTargets.some((target) => target.targetId === ownDuplicateHandle.targetId), 'cleanup did not close the caller-owned blank duplicate'); assert(!closedTargets.some((target) => target.targetId === peerHandle.targetId), 'cleanup closed the peer target');