diff --git a/README.md b/README.md index 39e4c4298..491e53103 100644 --- a/README.md +++ b/README.md @@ -1267,7 +1267,7 @@ AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com All options from the table above can be set in the config file using camelCase keys (e.g., `--executable-path` becomes `"executablePath"`, `--proxy-bypass` becomes `"proxyBypass"`). Unknown keys are ignored for forward compatibility. -Set `service.defaultBrowserBuild` to `stealthcdp_chromium` when service-owned launches should prefer the patched Chromium posture. If no explicit default is configured and a ready `stealthcdp_chromium` manifest is available, fresh installs prefer that build automatically. `agent-browser install stealthcdp-chromium` installs the public `chromium-stealthcdp` Windows asset under `%LOCALAPPDATA%\chromium-stealthcdp` on Windows or the matching WSL-mounted `AppData/Local` directory when available, then exposes `current/manifest.json` and `current/chrome.exe`. When WSL launches that Windows `chrome.exe`, agent-browser translates mounted Windows paths such as `/mnt/c/...` to `C:\...` for Chrome arguments and adds `--no-sandbox`, which this host mode requires for the patched Windows build to expose DevTools. Provide the binary through `executablePath`, `AGENT_BROWSER_EXECUTABLE_PATH`, `AGENT_BROWSER_STEALTHCDP_CHROMIUM_MANIFEST_PATH`, `AGENT_BROWSER_STEALTHCDP_CHROMIUM_INSTALL_ROOT`, or `service.browserBuildManifests.stealthcdp_chromium.manifestPath`. A manifest path points at a promoted artifact `manifest.json`; agent-browser resolves the executable relative to that manifest and reports artifact metadata from the same source. `agent-browser service status` and `GET /api/service/status` include a no-launch `launchConfig` diagnostic with the selected default browser build, executable source, resolved executable path, manifest metadata, file-existence check, `profileSmoke` readiness for validating WSL Windows profile writes, and warnings when `stealthcdp_chromium` is selected without a usable binary or ready manifest. +Set `service.defaultBrowserBuild` to `stealthcdp_chromium` when service-owned launches should prefer the patched Chromium posture. Ordinary launch and queued tab paths consume that default through the same access-plan resolver used by service clients, so a matching managed profile, site policy, remote view posture, and browser capability binding are applied unless the caller explicitly supplies a profile, browser host, headless mode, executable, or browser build. If no explicit default is configured and a ready `stealthcdp_chromium` manifest is available, fresh installs prefer that build automatically. `agent-browser install stealthcdp-chromium` installs the public `chromium-stealthcdp` Windows asset under `%LOCALAPPDATA%\chromium-stealthcdp` on Windows or the matching WSL-mounted `AppData/Local` directory when available, then exposes `current/manifest.json` and `current/chrome.exe`. When WSL launches that Windows `chrome.exe`, agent-browser translates mounted Windows paths such as `/mnt/c/...` to `C:\...` for Chrome arguments and adds `--no-sandbox`, which this host mode requires for the patched Windows build to expose DevTools. Provide the binary through `executablePath`, `AGENT_BROWSER_EXECUTABLE_PATH`, `AGENT_BROWSER_STEALTHCDP_CHROMIUM_MANIFEST_PATH`, `AGENT_BROWSER_STEALTHCDP_CHROMIUM_INSTALL_ROOT`, or `service.browserBuildManifests.stealthcdp_chromium.manifestPath`. A manifest path points at a promoted artifact `manifest.json`; agent-browser resolves the executable relative to that manifest and reports artifact metadata from the same source. `agent-browser service status` and `GET /api/service/status` include a no-launch `launchConfig` diagnostic with the selected default browser build, executable source, resolved executable path, manifest metadata, file-existence check, `profileSmoke` readiness for validating WSL Windows profile writes, and warnings when `stealthcdp_chromium` is selected without a usable binary or ready manifest. `stealthcdp_chromium` is worthwhile for the default service posture because it keeps the CDP control plane available while reducing the obvious automation signal exposed by ordinary DevTools-attached Chromium. It is not a captcha bypass and it does not replace site policy, pacing, or manual seeding rules, but it gives bot-sensitive sites a better baseline than stock headless Chrome when CDP-backed control is still acceptable. @@ -1682,7 +1682,9 @@ virtual display, reuse a configured shared display, or deliberately use the daemon's inherited `DISPLAY`. The shipped UPS policy selects `stealthcdp_chromium`, `remote_headed`, `rdp_gateway`, and `manual_attached_desktop` because headed stealth Chromium loaded UPS tracking -where true headless did not. Remote-headed browser records persist the selected +where true headless did not. The shipped Google Sheets policy applies the same +inspectable remote stealth lane for `https://docs.google.com/spreadsheets` +without changing Google sign-in seeding policy. Remote-headed browser records persist the selected view stream provider and control input provider on each `viewStreams` entry. They also report `displayIsolation` and `displayName` when the service can tell whether the browser is using a private virtual display, an explicitly shared @@ -2463,7 +2465,7 @@ performs a no-launch profile lookup and refuses the run when the broker-selected profile does not match the profile being verified. When no local site policy exists, agent-browser applies shipped defaults for -Canva, UPS, Google, Gmail, and Microsoft login identities. Local persisted or configured +Canva, UPS, Google Sheets, Google, Gmail, and Microsoft login identities. Local persisted or configured policies with the same IDs override those defaults. `sitePolicySource` reports whether the selected policy came from config, persisted state, or a built-in default, how it matched the request, and whether it is overrideable. diff --git a/cli/src/main.rs b/cli/src/main.rs index 5cd36edfa..dae018e27 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1792,6 +1792,10 @@ fn main() { return; } + if !command_skips_browser_launch_for_prestart(&cmd) { + cmd["serviceState"] = json!(flags.service_state.clone()); + } + if cmd .get("action") .and_then(|value| value.as_str()) @@ -2227,9 +2231,11 @@ fn main() { exit(1); }); + let executable_launch_config_requested = flags.executable_path.is_some() + && flags.executable_path_source.as_deref() != Some("manifest"); let launch_config_requested = flags.headed || flags.cli_headed // User explicitly set --headed (even if false) - || flags.executable_path.is_some() + || executable_launch_config_requested || flags.runtime_profile.is_some() || flags.profile.is_some() || flags.state.is_some() @@ -2276,7 +2282,8 @@ fn main() { let mut launch_cmd = json!({ "id": gen_id(), "action": "launch", - "headless": !flags.headed + "headless": !flags.headed, + "serviceState": flags.service_state.clone() }); if flags.cli_headed { launch_cmd["headlessExplicit"] = json!(true); @@ -2289,6 +2296,9 @@ fn main() { // Add executable path if specified if let Some(ref exec_path) = flags.executable_path { cmd_obj.insert("executablePath".to_string(), json!(exec_path)); + if let Some(ref source) = flags.executable_path_source { + cmd_obj.insert("executablePathSource".to_string(), json!(source)); + } } if let Some(status) = live_runtime_status.as_ref() { diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index fb30bd1d5..cfe9230f1 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -1,5 +1,5 @@ use chrono::{DateTime, FixedOffset}; -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::env; use std::fs; @@ -71,8 +71,8 @@ use super::service_lifecycle::{ use super::service_model::{ service_profile_allocations, service_profile_seeding_handoff, service_profile_sources, BrowserBuild, BrowserCapabilityRegistry, BrowserHealth as ServiceBrowserHealth, - BrowserHost as ServiceBrowserHost, BrowserSession, ControlInputProvider, DisplayAllocation, - JobState as ServiceJobState, LeaseState, MonitorState, ProfileKeyringPolicy, + BrowserHost as ServiceBrowserHost, BrowserProcess, BrowserSession, ControlInputProvider, + DisplayAllocation, JobState as ServiceJobState, LeaseState, MonitorState, ProfileKeyringPolicy, ProfileLeaseDisposition, ProfileSelectionReason, RemoteViewRoute, RoutePoolEntry, ServiceEvent, ServiceEventKind, ServiceState, SessionCleanupPolicy, TabLifecycle, ViewStream, ViewStreamProvider, ViewerLease, @@ -518,6 +518,180 @@ fn browser_build_from_command(cmd: &Value) -> Option { cmd.get("params").and_then(browser_build_from_command) } +fn launch_command_with_effective_service_defaults( + command: &Value, + options: &LaunchOptions, +) -> Value { + let Ok(service_state) = browser_capability_service_state(command) else { + return command.clone(); + }; + let request = ServiceAccessPlanRequest { + service_name: optional_command_string(command, "serviceName"), + agent_name: optional_command_string(command, "agentName"), + task_name: optional_command_string(command, "taskName"), + target_service_ids: target_service_ids_from_command(command), + account_ids: account_ids_from_command(command), + target_url: target_url_from_command(command), + site_policy_id: optional_command_string(command, "sitePolicyId"), + challenge_id: optional_command_string(command, "challengeId"), + readiness_profile_id: optional_command_string(command, "readinessProfileId"), + browser_build: browser_build_from_command(command), + browser_build_explicit: command + .get("browserBuild") + .and_then(Value::as_str) + .is_some(), + browser_host: browser_host_from_command(command), + view_stream_provider: optional_command_string(command, "viewStreamProvider") + .or_else(|| optional_command_string(command, "viewStream")) + .or_else(|| { + command.get("params").and_then(|params| { + optional_command_string(params, "viewStreamProvider") + .or_else(|| optional_command_string(params, "viewStream")) + }) + }) + .and_then(|value| parse_view_stream_provider(&value)), + control_input_provider: optional_command_string(command, "controlInputProvider") + .or_else(|| optional_command_string(command, "controlInput")) + .or_else(|| { + command.get("params").and_then(|params| { + optional_command_string(params, "controlInputProvider") + .or_else(|| optional_command_string(params, "controlInput")) + }) + }) + .and_then(|value| parse_control_input_provider(&value)), + display_isolation: remote_headed_display_isolation_from_command(command), + }; + let plan = service_access_plan_for_state(&service_state, request); + let Some(planned_request) = plan.pointer("/decision/serviceRequest/request") else { + return command.clone(); + }; + apply_planned_launch_defaults(command, &plan, planned_request, options) +} + +fn apply_planned_launch_defaults( + command: &Value, + plan: &Value, + planned_request: &Value, + options: &LaunchOptions, +) -> Value { + let mut object = command.as_object().cloned().unwrap_or_default(); + insert_planned_string_if_missing(&mut object, command, planned_request, "browserBuild"); + if options.runtime_profile.is_none() && command.get("runtimeProfile").is_none() { + insert_planned_string_if_missing(&mut object, command, planned_request, "runtimeProfile"); + } + if options.profile.is_none() && command.get("profile").is_none() { + insert_planned_string_if_missing(&mut object, command, planned_request, "profile"); + } + if command.get("profileLeasePolicy").is_none() { + insert_planned_string_if_missing( + &mut object, + command, + planned_request, + "profileLeasePolicy", + ); + } + if command.get("cdpAttachmentAllowed").is_none() { + if let Some(value) = planned_request.get("cdpAttachmentAllowed") { + object.insert("cdpAttachmentAllowed".to_string(), value.clone()); + } + } + + let planned_params = planned_request.get("params").and_then(Value::as_object); + if let Some(planned_params) = planned_params { + let mut params = command + .get("params") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let posture_source = plan + .pointer("/decision/launchPosture/source") + .and_then(Value::as_str); + if posture_source != Some("service_default") { + insert_planned_param_if_missing( + &mut object, + &mut params, + command, + planned_params, + "browserHost", + ); + } + insert_planned_param_if_missing( + &mut object, + &mut params, + command, + planned_params, + "viewStreamProvider", + ); + insert_planned_param_if_missing( + &mut object, + &mut params, + command, + planned_params, + "controlInputProvider", + ); + insert_planned_param_if_missing( + &mut object, + &mut params, + command, + planned_params, + "displayIsolation", + ); + if command.get("headlessExplicit").and_then(Value::as_bool) != Some(true) + && command + .get("params") + .and_then(|params| params.get("headlessExplicit")) + .and_then(Value::as_bool) + != Some(true) + && command.get("headless").is_none() + && !params.contains_key("headless") + { + if let Some(value) = planned_params.get("headless") { + object.insert("headless".to_string(), value.clone()); + } + } + if !params.is_empty() { + object.insert("params".to_string(), Value::Object(params)); + } + } + + Value::Object(object) +} + +fn insert_planned_string_if_missing( + object: &mut Map, + command: &Value, + planned_request: &Value, + key: &str, +) { + if command.get(key).is_some() { + return; + } + if let Some(value) = planned_request.get(key).and_then(Value::as_str) { + object.insert(key.to_string(), json!(value)); + } +} + +fn insert_planned_param_if_missing( + object: &mut Map, + params: &mut Map, + command: &Value, + planned_params: &Map, + key: &str, +) { + if command.get(key).is_some() + || command + .get("params") + .and_then(|params| params.get(key)) + .is_some() + { + return; + } + if let Some(value) = planned_params.get(key) { + object.insert(key.to_string(), value.clone()); + params.insert(key.to_string(), value.clone()); + } +} + fn apply_service_profile_selection( options: &mut LaunchOptions, cmd: &Value, @@ -694,7 +868,10 @@ fn browser_capability_service_state(cmd: &Value) -> Result fn executable_path_is_operator_supplied(executable_path: Option<&str>, cmd: &Value) -> bool { if cmd.get("executablePath").is_some() { - return true; + return !matches!( + optional_command_string(cmd, "executablePathSource").as_deref(), + Some("manifest") + ); } let Some(executable_path) = executable_path else { return false; @@ -1336,6 +1513,7 @@ fn apply_launch_host_hints(options: &mut LaunchOptions, command: &Value) -> Serv } if host == ServiceBrowserHost::LocalHeadless { options.headless = true; + options.remote_headed = false; } if host == ServiceBrowserHost::RemoteHeaded { options.remote_headed = true; @@ -1353,6 +1531,9 @@ fn apply_launch_host_hints(options: &mut LaunchOptions, command: &Value) -> Serv Some("private_virtual_display") | Some("ambient_display") => None, _ => remote_headed_display_from_command(command).or_else(|| options.display.clone()), }; + } else { + options.remote_headed = false; + options.remote_headed_display_isolation = None; } host } @@ -1767,6 +1948,58 @@ fn upsert_cdp_screencast_view_stream( } } +fn service_browser_session_id(browser: &BrowserProcess) -> Option { + browser + .active_session_ids + .iter() + .find(|session_id| !session_id.trim().is_empty()) + .cloned() + .or_else(|| { + browser + .id + .strip_prefix("session:") + .filter(|session_id| !session_id.trim().is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn read_stream_port_for_session(session_id: &str) -> Option { + fs::read_to_string(stream_file_path(session_id)) + .ok() + .and_then(|contents| contents.trim().parse::().ok()) +} + +fn upsert_browser_cdp_screencast_view_stream(browser: &mut BrowserProcess) { + let Some(session_id) = service_browser_session_id(browser) else { + return; + }; + let stream_port = read_stream_port_for_session(&session_id); + let Some(cdp_stream) = cdp_screencast_view_stream( + &session_id, + browser.host, + browser.health, + browser.cdp_endpoint.as_deref(), + stream_port, + ) else { + return; + }; + if let Some(existing) = browser + .view_streams + .iter_mut() + .find(|stream| stream.provider == ViewStreamProvider::CdpScreencast) + { + *existing = cdp_stream; + } else { + browser.view_streams.push(cdp_stream); + } +} + +fn refresh_cdp_screencast_view_streams(service_state: &mut ServiceState) { + for browser in service_state.browsers.values_mut() { + upsert_browser_cdp_screencast_view_stream(browser); + } +} + fn persist_current_browser_health( state: &DaemonState, host: ServiceBrowserHost, @@ -1935,13 +2168,21 @@ fn apply_auto_launch_command_hints( ServiceBrowserHost, Option, BrowserCapabilityLaunchResolution, + Value, ) { - apply_explicit_launch_identity_from_command(options, command); + let effective_command = launch_command_with_effective_service_defaults(command, options); + apply_explicit_launch_identity_from_command(options, &effective_command); apply_retained_remote_headed_launch_hints(options, retained_remote_headed); - let service_host = apply_launch_host_hints(options, command); - let selection_reason = apply_service_profile_selection(options, command); - let browser_capability_launch = apply_service_browser_capability_selection(options, command); - (service_host, selection_reason, browser_capability_launch) + let service_host = apply_launch_host_hints(options, &effective_command); + let selection_reason = apply_service_profile_selection(options, &effective_command); + let browser_capability_launch = + apply_service_browser_capability_selection(options, &effective_command); + ( + service_host, + selection_reason, + browser_capability_launch, + effective_command, + ) } fn service_profile_lease_conflict_session_ids( @@ -3937,13 +4178,17 @@ async fn auto_launch(state: &mut DaemonState, command: &Value) -> Result<(), Str } let engine = env::var("AGENT_BROWSER_ENGINE").ok(); let retained_remote_headed = retained_remote_headed_launch_hint(&state.session_id, command); - let (service_host, selection_reason, browser_capability_launch) = + let (service_host, selection_reason, browser_capability_launch, effective_command) = apply_auto_launch_command_hints(&mut options, command, retained_remote_headed.as_ref()); - let mut metadata = - ServiceLaunchMetadata::from_launch_options(&options, Some(command), selection_reason); + let mut metadata = ServiceLaunchMetadata::from_launch_options( + &options, + Some(&effective_command), + selection_reason, + ); apply_retained_remote_headed_metadata(&mut metadata, retained_remote_headed.as_ref()); metadata.browser_capability_launch = Some(browser_capability_launch.to_value()); - ensure_service_profile_lease_available(&metadata, &state.session_id, command).await?; + ensure_service_profile_lease_available(&metadata, &state.session_id, &effective_command) + .await?; // Store proxy credentials for Fetch.authRequired handling let has_proxy_auth = options.proxy_username.is_some(); @@ -4290,17 +4535,21 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result Result { }); } refresh_remote_view_stream_urls(&mut service_state); + refresh_cdp_screencast_view_streams(&mut service_state); service_state.refresh_profile_readiness(); let profile_allocations = service_profile_allocations(&service_state); @@ -9295,14 +9545,80 @@ async fn handle_service_status(cmd: &Value) -> Result { .get("launchConfig") .cloned() .unwrap_or_else(|| json!({})); + let mut service_state_json = + serde_json::to_value(&service_state).map_err(|err| err.to_string())?; + inject_browser_process_stats(&mut service_state_json); Ok(json!({ - "service_state": service_state, + "service_state": service_state_json, "profileAllocations": profile_allocations, "launchConfig": launch_config, })) } +fn inject_browser_process_stats(service_state: &mut Value) { + let Some(browsers) = service_state + .get_mut("browsers") + .and_then(|value| value.as_object_mut()) + else { + return; + }; + for browser in browsers.values_mut() { + let pid = browser + .get("pid") + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()); + if let Some(pid) = pid.and_then(process_stats_for_pid) { + browser["processStats"] = pid; + } + } +} + +fn process_stats_for_pid(pid: u32) -> Option { + #[cfg(target_os = "linux")] + { + linux_process_stats_for_pid(pid) + } + #[cfg(not(target_os = "linux"))] + { + let _ = pid; + None + } +} + +#[cfg(target_os = "linux")] +fn linux_process_stats_for_pid(pid: u32) -> Option { + let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let stat_tail = stat.rsplit_once(") ")?.1; + let fields = stat_tail.split_whitespace().collect::>(); + let clock_ticks = 100.0_f64; + let utime = fields + .get(11) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let stime = fields + .get(12) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let rss_bytes = fs::read_to_string(format!("/proc/{pid}/status")) + .ok() + .and_then(|status| { + status.lines().find_map(|line| { + let value = line.strip_prefix("VmRSS:")?.split_whitespace().next()?; + value.parse::().ok().map(|kib| kib * 1024) + }) + }); + let mut stats = json!({ + "pid": pid, + "running": pid_is_running(pid), + "cpuSeconds": ((utime + stime) as f64 / clock_ticks), + }); + if let Some(bytes) = rss_bytes { + stats["rssBytes"] = json!(bytes); + } + Some(stats) +} + /// Return the no-launch service access plan from the current service state. async fn handle_service_access_plan(cmd: &Value) -> Result { let mut service_state = cmd @@ -14933,10 +15249,13 @@ mod tests { }); let mut options = LaunchOptions::default(); - let (host, selection_reason, browser_capability_launch) = + let (host, selection_reason, browser_capability_launch, effective_command) = apply_auto_launch_command_hints(&mut options, &command, None); - let metadata = - ServiceLaunchMetadata::from_launch_options(&options, Some(&command), selection_reason); + let metadata = ServiceLaunchMetadata::from_launch_options( + &options, + Some(&effective_command), + selection_reason, + ); assert_eq!(host, ServiceBrowserHost::RemoteHeaded); assert!(!options.headless); @@ -14972,6 +15291,148 @@ mod tests { let _ = fs::remove_dir_all(&home); } + #[test] + fn test_apply_auto_launch_command_hints_uses_effective_service_default() { + let guard = EnvGuard::new(&["AGENT_BROWSER_EXECUTABLE_PATH"]); + guard.remove("AGENT_BROWSER_EXECUTABLE_PATH"); + let home = unique_socket_dir("auto-launch-effective-default"); + fs::create_dir_all(&home).expect("test home should be created"); + let executable = home.join("chrome"); + let user_data_dir = home.join("stealthcdp-default"); + fs::write(&executable, "#!/bin/sh\n").expect("test executable should be written"); + + let service_state = json!({ + "defaultBrowserBuild": "stealthcdp_chromium", + "profiles": { + "stealthcdp-default": { + "id": "stealthcdp-default", + "name": "Stealth default", + "userDataDir": user_data_dir.display().to_string(), + "defaultBrowserHost": "remote_headed", + "browserBuild": "stealthcdp_chromium", + "persistent": true + } + }, + "browserCapabilityRegistry": { + "browserHosts": [{ + "id": "linux-local", + "hostKind": "local", + "reachable": true, + "lifecycleOwner": "agent_browser" + }], + "browserExecutables": [{ + "id": "stealth-current", + "hostId": "linux-local", + "buildLabel": "stealthcdp_chromium", + "executablePath": executable.display().to_string() + }], + "browserCapabilities": [{ + "id": "stealth-capability", + "hostId": "linux-local", + "executableId": "stealth-current", + "cdpSupported": true, + "headedSupported": true, + "headlessSupported": true + }], + "profileCompatibility": [{ + "id": "stealth-profile-compatible", + "profileId": "stealthcdp-default", + "hostId": "linux-local", + "executableId": "stealth-current", + "compatible": true + }], + "browserPreferenceBindings": [{ + "id": "global-stealth-default", + "scope": "global", + "preferredHostId": "linux-local", + "preferredExecutableId": "stealth-current", + "preferredCapabilityId": "stealth-capability", + "browserBuild": "stealthcdp_chromium", + "priority": 50 + }], + "validationEvidence": [{ + "id": "stealth-launch-smoke", + "hostId": "linux-local", + "executableId": "stealth-current", + "capabilityId": "stealth-capability", + "kind": "launch", + "state": "passed" + }] + } + }); + let command = json!({ + "action": "tab_new", + "url": "https://example.com/", + "serviceState": service_state + }); + let mut options = LaunchOptions::default(); + + let (host, selection_reason, browser_capability_launch, effective_command) = + apply_auto_launch_command_hints(&mut options, &command, None); + let metadata = ServiceLaunchMetadata::from_launch_options( + &options, + Some(&effective_command), + selection_reason, + ); + + assert_eq!(host, ServiceBrowserHost::RemoteHeaded); + assert!(!options.headless); + assert!(options.remote_headed); + assert_eq!( + options.runtime_profile.as_deref(), + Some("stealthcdp-default") + ); + assert_eq!( + options.profile.as_deref(), + Some(user_data_dir.to_str().expect("path should be utf-8")) + ); + assert_eq!( + options.executable_path.as_deref(), + Some(executable.to_str().expect("path should be utf-8")) + ); + assert!(browser_capability_launch.applied); + assert_eq!(effective_command["browserBuild"], "stealthcdp_chromium"); + assert_eq!(effective_command["browserHost"], "remote_headed"); + assert_eq!(effective_command["viewStreamProvider"], "rdp_gateway"); + assert_eq!( + effective_command["controlInputProvider"], + "manual_attached_desktop" + ); + assert_eq!( + effective_command["displayIsolation"], + "private_virtual_display" + ); + assert_eq!(metadata.profile_id.as_deref(), Some("stealthcdp-default")); + assert_eq!(metadata.view_streams.len(), 1); + assert_eq!( + metadata.view_streams[0].provider, + ViewStreamProvider::RdpGateway + ); + let _ = fs::remove_dir_all(&home); + } + + #[test] + fn test_manifest_executable_path_does_not_block_capability_selection() { + let command = json!({ + "action": "launch", + "executablePath": "/opt/chromium-stealth/chrome", + "executablePathSource": "manifest" + }); + + assert!(!executable_path_is_operator_supplied( + Some("/opt/chromium-stealth/chrome"), + &command + )); + assert!(executable_path_is_operator_supplied( + Some("/opt/chromium-stealth/chrome"), + &json!({ + "action": "launch", + "executablePath": "/opt/chromium-stealth/chrome", + "executablePathSource": "config" + }) + )); + } + #[test] fn test_apply_auto_launch_command_hints_preserves_retained_remote_headed_surface() { let retained = RetainedRemoteHeadedLaunchHint { @@ -15011,10 +15472,13 @@ mod tests { "headlessExplicit": true }))); - let (host, selection_reason, _) = + let (host, selection_reason, _, effective_command) = apply_auto_launch_command_hints(&mut options, &command, Some(&retained)); - let mut metadata = - ServiceLaunchMetadata::from_launch_options(&options, Some(&command), selection_reason); + let mut metadata = ServiceLaunchMetadata::from_launch_options( + &options, + Some(&effective_command), + selection_reason, + ); apply_retained_remote_headed_metadata(&mut metadata, Some(&retained)); assert_eq!(host, ServiceBrowserHost::RemoteHeaded); @@ -15033,6 +15497,48 @@ mod tests { assert_eq!(metadata.display_name.as_deref(), Some(":10")); } + #[test] + fn test_explicit_local_headless_launch_surface_overrides_retained_remote_hint() { + let retained = RetainedRemoteHeadedLaunchHint { + view_streams: vec![ViewStream { + id: "remote-headed-view".to_string(), + provider: ViewStreamProvider::RdpGateway, + control_input: Some(ControlInputProvider::ManualAttachedDesktop), + url: None, + frame_url: None, + external_url: None, + route_id: None, + display_allocation_id: None, + connection_id: None, + connection_name: None, + route_source: None, + provider_mode: None, + viewer_lease_ids: Vec::new(), + controller_lease_id: None, + read_only: false, + readiness: None, + remote_readiness: None, + }], + display_isolation: Some("shared_display".to_string()), + display_name: Some(":10".to_string()), + }; + let command = json!({ + "action": "launch", + "browserHost": "local_headless", + "headless": true, + "headlessExplicit": true + }); + let mut options = LaunchOptions::default(); + + let (host, _, _, _) = + apply_auto_launch_command_hints(&mut options, &command, Some(&retained)); + + assert_eq!(host, ServiceBrowserHost::LocalHeadless); + assert!(options.headless); + assert!(!options.remote_headed); + assert!(options.remote_headed_display_isolation.is_none()); + } + #[test] fn test_private_remote_headed_metadata_waits_for_launched_display_name() { let guard = EnvGuard::new(&["DISPLAY"]); @@ -15045,10 +15551,13 @@ mod tests { }); let mut options = LaunchOptions::default(); - let (host, selection_reason, _) = + let (host, selection_reason, _, effective_command) = apply_auto_launch_command_hints(&mut options, &command, None); - let metadata = - ServiceLaunchMetadata::from_launch_options(&options, Some(&command), selection_reason); + let metadata = ServiceLaunchMetadata::from_launch_options( + &options, + Some(&effective_command), + selection_reason, + ); assert_eq!(host, ServiceBrowserHost::RemoteHeaded); assert_eq!( diff --git a/cli/src/native/service_model.rs b/cli/src/native/service_model.rs index 5dbacac02..8e1530670 100644 --- a/cli/src/native/service_model.rs +++ b/cli/src/native/service_model.rs @@ -2764,21 +2764,41 @@ pub(crate) fn service_site_policy_id_for_url( service_state: &ServiceState, raw_url: &str, ) -> Option { - let origin = url_origin(raw_url)?; let builtin_policies = builtin_site_policies(); service_state .site_policies .values() .chain(builtin_policies.iter()) - .find(|policy| { - let Some(policy_origin) = url_origin(&policy.origin_pattern) else { - return false; - }; - !policy.id.is_empty() && origin == policy_origin - }) + .find(|policy| !policy.id.is_empty() && url_matches_policy_pattern(raw_url, policy)) .map(|policy| policy.id.clone()) } +fn url_matches_policy_pattern(raw_url: &str, policy: &SitePolicy) -> bool { + let Ok(url) = url::Url::parse(raw_url) else { + return false; + }; + let Ok(pattern) = url::Url::parse(&policy.origin_pattern) else { + return false; + }; + if url.scheme() != pattern.scheme() || url.host_str() != pattern.host_str() { + return false; + } + match (url.port_or_known_default(), pattern.port_or_known_default()) { + (left, right) if left == right => {} + _ => return false, + } + let pattern_path = pattern.path(); + if pattern_path == "/" { + return true; + } + let pattern_path = pattern_path.trim_end_matches('/'); + url.path() == pattern_path + || url + .path() + .strip_prefix(pattern_path) + .is_some_and(|suffix| suffix.starts_with('/')) +} + fn url_origin(raw_url: &str) -> Option { let parsed = url::Url::parse(raw_url).ok()?; let scheme = parsed.scheme(); @@ -3046,6 +3066,29 @@ fn builtin_site_policies() -> Vec { ), ..SitePolicy::default() }, + SitePolicy { + id: "google_sheets".to_string(), + origin_pattern: "https://docs.google.com/spreadsheets".to_string(), + browser_host: Some(BrowserHost::RemoteHeaded), + browser_build: Some(BrowserBuild::StealthcdpChromium), + view_stream: Some(ViewStreamProvider::RdpGateway), + control_input: Some(ControlInputProvider::ManualAttachedDesktop), + interaction_mode: InteractionMode::HumanLikeInput, + rate_limit: RateLimitPolicy { + min_action_delay_ms: Some(500), + jitter_ms: Some(400), + cooldown_ms: Some(2_000), + max_parallel_sessions: Some(1), + retry_budget: Some(1), + }, + profile_required: true, + challenge_policy: ChallengePolicy::AvoidFirst, + notes: Some( + "Google Sheets work should stay on the managed stealth Chromium lane so rendered documents are inspectable through the service viewport." + .to_string(), + ), + ..SitePolicy::default() + }, SitePolicy { id: "microsoft".to_string(), origin_pattern: "https://login.microsoftonline.com".to_string(), @@ -7686,6 +7729,25 @@ mod tests { state.site_policies["gmail"].challenge_policy, ChallengePolicy::ManualOnly ); + assert_eq!( + state.site_policies["google_sheets"].browser_build, + Some(BrowserBuild::StealthcdpChromium) + ); + assert_eq!( + state.site_policies["google_sheets"].browser_host, + Some(BrowserHost::RemoteHeaded) + ); + assert_eq!( + service_site_policy_id_for_url( + &state, + "https://docs.google.com/spreadsheets/d/example/edit" + ), + Some("google_sheets".to_string()) + ); + assert_ne!( + service_site_policy_id_for_url(&state, "https://docs.google.com/document/d/example"), + Some("google_sheets".to_string()) + ); } #[test] diff --git a/cli/src/output.rs b/cli/src/output.rs index 48bb7d66f..e7c530818 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -5134,7 +5134,7 @@ Notes: - pnpm test:service-shutdown-health-live validates the polite-shutdown failure remedy against live service state. - Runtime profile and custom profile launches populate linked service profile and session records, including profileSelectionReason, profileLeaseDisposition, profileLeaseConflictSessionIds, and browserCapabilityLaunch diagnostics when known. - Service-scoped launches reject active exclusive profile conflicts by default before browser start; set profileLeasePolicy=wait and profileLeaseWaitTimeoutMs to keep the job queued while polling for release, leaving the worker available for other commands. Same-session retained browser reuse remains allowed. - - service status includes launchConfig, a no-launch diagnostic for service.defaultBrowserBuild and the resolved executablePath from config, AGENT_BROWSER_EXECUTABLE_PATH, or service.browserBuildManifests..manifestPath. launchConfig.profileSmoke tells API, MCP, and CLI clients whether the WSL Windows chromium-stealthcdp profile-write smoke is applicable. If stealthcdp_chromium is selected but no executable path or ready manifest exists, status reports a warning. When no explicit default is configured and a ready stealthcdp_chromium manifest is available, fresh installs prefer that build automatically. + - service status includes launchConfig, a no-launch diagnostic for service.defaultBrowserBuild and the resolved executablePath from config, AGENT_BROWSER_EXECUTABLE_PATH, or service.browserBuildManifests..manifestPath. launchConfig.profileSmoke tells API, MCP, and CLI clients whether the WSL Windows chromium-stealthcdp profile-write smoke is applicable. If stealthcdp_chromium is selected but no executable path or ready manifest exists, status reports a warning. Ordinary launch and queued tab paths consume service.defaultBrowserBuild through the service access-plan resolver unless the caller explicitly supplies a profile, browser host, headless mode, executable, or browser build. When no explicit default is configured and a ready stealthcdp_chromium manifest is available, fresh installs prefer that build automatically. - Service profiles can set browserBuild to stock_chrome, stealthcdp_chromium, or cdp_free_headed. Exact authenticated target, account, and target-site matches win first; browserBuild then breaks ties and can select a generic default profile for new identities. - service.browserCapabilityRegistry carries draft browser host, executable, capability, profile compatibility, preference binding, and validation evidence arrays into service_state.browserCapabilityRegistry for no-launch status consumers. Access-plan recommendations can use preference bindings for browserBuild selection, and populated target, account, service, and task filters are conjunctive. Guarded launches record browserCapabilityLaunch diagnostics explaining whether a local executable binding was applied or skipped. A matching failed, stale, incompatible, or operator-override row blocks launch routing even if another matching row passed. - service browser-capability preflight is the operator no-launch gate check for that same guarded launch path. It accepts the requested build, site/login/account hints, caller labels, profile hint, headed or headless posture, and CDP-free posture, evaluates effective configured service state, and returns browserCapabilityLaunch plus selected evidence IDs when the route passes. Manifest-derived default executables do not count as explicit operator overrides. @@ -5218,7 +5218,7 @@ Notes: - HTTP GET /api/service/profiles//readiness and MCP agent-browser://profiles/{profile_id}/readiness return one profile's no-launch targetReadiness rows for software clients and agents that do not need allocation details. - HTTP GET /api/service/profiles//allocation and MCP agent-browser://profiles/{profile_id}/allocation return one profile's lease, holder, conflict, recommended-action, and readiness state without fetching the full profile collection. - HTTP GET /api/service/profiles//seeding-handoff and MCP agent-browser://profiles/{profile_id}/seeding-handoff{?targetServiceId,siteId,loginId} return the exact detached runtime-login command, setup URL, operator steps, and warnings derived from one profile's targetReadiness rows. HTTP POST /api/service/profiles//seeding-handoff and MCP service_profile_seeding_handoff_update persist lifecycle changes through the same service worker. - - HTTP GET /api/service/access-plan, MCP service_access_plan, and MCP agent-browser://access-plan accept serviceName, agentName, taskName, targetServiceId, siteId, loginId, accountId, url, browserBuild, browserHost, viewStreamProvider, controlInputProvider, displayIsolation, or their array aliases, then return the no-launch service-owned profile, policy, provider, challenge, readiness, seedingHandoff, monitorFindings, advisory browserCapabilityEvidence, caller-label warning, and recommendation payload. decision.attention summarizes whether intervention is required, who owns it, severity, reason, message, and suggested actions while leaving popup or dashboard presentation to clients. browserCapabilityEvidence is filtered by the planned browser build and request identity. Preference bindings can set the access-plan browser build recommendation when no explicit request, site policy, or profile browser build has already won; that case reports routingApplied=true with routingScope=access_plan_recommendation. The queued launch path may apply the matching local executable only when the host is local, reachable, and agent-browser owned, the executable exists, selected-profile compatibility rows are all acceptable, and matching validation evidence includes a passed row with no failed or stale row. decision.launchPosture includes browserBuild, browserBuildSelection, requiresCdpFree, cdpAttachmentAllowed, browserHost, viewStreamProvider, controlInputProvider, and displayIsolation so agents and software clients can choose stock Chrome, stealth CDP Chromium, CDP-free headed, or remote viewport posture before opening a browser. Headed access plans include params.headless=false and params.browserHost in the queued tab request so clients do not accidentally launch true headless Chrome or drop the host selected by site policy. Copied remote_headed requests are executable: on Linux agent-browser starts a hidden Xvfb-backed headed browser when no display is supplied, keeps CDP control available, and records a view stream entry for operator or dashboard surfaces. Shipped site policies include UPS, which prefers stealthcdp_chromium with a remote-view-capable headed host because true headless stealth Chromium failed UPS tracking navigation during live 2026-05-17 testing. Matching active profile_readiness monitors that are due or never checked set monitorFindings.profileReadinessProbeDue and decision.monitorProbeDue, fill decision.monitorRunDue with HTTP, MCP, CLI, and service-client instructions, and recommend run_due_profile_readiness_monitor before relying on retained freshness. Access-plan-backed tab requests marked blockedByManualAction and manualSeedingRequired are refused by the service request client, HTTP POST /api/service/request, and MCP service_request unless allowManualAction is explicitly true. Raw requests carrying monitorRunDueSummary are refused when the summary reports expired, unverified, or missing target freshness evidence unless allowMonitorFreshnessRisk is explicitly true. Copied tab requests marked requiresCdpFree with cdpAttachmentAllowed=false are refused by those same request paths; decision.serviceRequest.cdpFreeAvailability names the no-launch lifecycle-only alternative. Use action=cdp_free_launch only when process lifecycle and service-state tracking are sufficient, then read unsupportedCommands before offering follow-up automation controls. + - HTTP GET /api/service/access-plan, MCP service_access_plan, and MCP agent-browser://access-plan accept serviceName, agentName, taskName, targetServiceId, siteId, loginId, accountId, url, browserBuild, browserHost, viewStreamProvider, controlInputProvider, displayIsolation, or their array aliases, then return the no-launch service-owned profile, policy, provider, challenge, readiness, seedingHandoff, monitorFindings, advisory browserCapabilityEvidence, caller-label warning, and recommendation payload. decision.attention summarizes whether intervention is required, who owns it, severity, reason, message, and suggested actions while leaving popup or dashboard presentation to clients. browserCapabilityEvidence is filtered by the planned browser build and request identity. Preference bindings can set the access-plan browser build recommendation when no explicit request, site policy, or profile browser build has already won; that case reports routingApplied=true with routingScope=access_plan_recommendation. The queued launch path may apply the matching local executable only when the host is local, reachable, and agent-browser owned, the executable exists, selected-profile compatibility rows are all acceptable, and matching validation evidence includes a passed row with no failed or stale row. decision.launchPosture includes browserBuild, browserBuildSelection, requiresCdpFree, cdpAttachmentAllowed, browserHost, viewStreamProvider, controlInputProvider, and displayIsolation so agents and software clients can choose stock Chrome, stealth CDP Chromium, CDP-free headed, or remote viewport posture before opening a browser. Headed access plans include params.headless=false and params.browserHost in the queued tab request so clients do not accidentally launch true headless Chrome or drop the host selected by site policy. Copied remote_headed requests are executable: on Linux agent-browser starts a hidden Xvfb-backed headed browser when no display is supplied, keeps CDP control available, and records a view stream entry for operator or dashboard surfaces. Shipped site policies include UPS and Google Sheets, which prefer stealthcdp_chromium with a remote-view-capable headed host; UPS uses that posture because true headless stealth Chromium failed tracking navigation during live 2026-05-17 testing. Matching active profile_readiness monitors that are due or never checked set monitorFindings.profileReadinessProbeDue and decision.monitorProbeDue, fill decision.monitorRunDue with HTTP, MCP, CLI, and service-client instructions, and recommend run_due_profile_readiness_monitor before relying on retained freshness. Access-plan-backed tab requests marked blockedByManualAction and manualSeedingRequired are refused by the service request client, HTTP POST /api/service/request, and MCP service_request unless allowManualAction is explicitly true. Raw requests carrying monitorRunDueSummary are refused when the summary reports expired, unverified, or missing target freshness evidence unless allowMonitorFreshnessRisk is explicitly true. Copied tab requests marked requiresCdpFree with cdpAttachmentAllowed=false are refused by those same request paths; decision.serviceRequest.cdpFreeAvailability names the no-launch lifecycle-only alternative. Use action=cdp_free_launch only when process lifecycle and service-state tracking are sufficient, then read unsupportedCommands before offering follow-up automation controls. - HTTP POST /api/service/browser-capability-registry//, MCP service_browser_capability_registry_upsert, service browser-capability prefer, and upsertServiceBrowserPreferenceBinding persist advisory browser capability registry records through the service worker queue. service browser-capability guide is the read-only CLI discovery step for executable IDs and copyable prefer commands. Path collection and ID are authoritative, and returned upsert records report routingApplied=false because an upsert does not itself route browser work. - The guarded service read surface has MCP resource parity; agents should usually start with agent-browser://access-plan{?...} and use narrower profile lookup, readiness, allocation, seeding-handoff, display-allocation, remote-view-route, route-pool, or viewer-lease resources only when the full recommendation is not needed. - browser_navigate, browser_back, browser_forward, browser_reload, browser_tab_*, browser_set_content, browser_requests, browser_request_detail, browser_headers, browser_offline, browser_cookies_*, browser_storage_*, 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_*, browser_route, browser_unroute, browser_console, browser_errors, browser_pdf, browser_response_body, and browser_clipboard provide typed schemas for common navigation, tab, page-content, request-inspection, session-shaping, observability, artifact, file-transfer, HAR, routing, cookie, and storage workflows. @@ -5823,7 +5823,7 @@ Configuration: --headed false (disables "headed": true from config) Extensions from user and project configs are merged (not replaced). - Set service.defaultBrowserBuild to stealthcdp_chromium after executablePath, AGENT_BROWSER_EXECUTABLE_PATH, or service.browserBuildManifests.stealthcdp_chromium.manifestPath points at the patched Chromium artifact. If no explicit default is configured and a ready stealthcdp_chromium manifest is available, fresh installs prefer that build automatically. + Set service.defaultBrowserBuild to stealthcdp_chromium after executablePath, AGENT_BROWSER_EXECUTABLE_PATH, or service.browserBuildManifests.stealthcdp_chromium.manifestPath points at the patched Chromium artifact. Ordinary launches consume that default through the service access-plan resolver unless the caller explicitly supplies a profile, browser host, headless mode, executable, or browser build. If no explicit default is configured and a ready stealthcdp_chromium manifest is available, fresh installs prefer that build automatically. Set service.profiles..browserBuild when a profile must stay on stock_chrome, stealthcdp_chromium, or cdp_free_headed. Runtime profiles can also be declared in config: diff --git a/docs/dev/plans/0016-2026-05-31-effective-stealth-remote-default-launch-plan.md b/docs/dev/plans/0016-2026-05-31-effective-stealth-remote-default-launch-plan.md new file mode 100644 index 000000000..eb4ed44f8 --- /dev/null +++ b/docs/dev/plans/0016-2026-05-31-effective-stealth-remote-default-launch-plan.md @@ -0,0 +1,384 @@ +# Effective Stealth Remote Default Launch Plan + +Date: 2026-05-31 +State: COMPLETE +Lane: P09/P12 runtime posture +Depends On: +- `docs/dev/plans/0009-2026-05-30-p08-packaging-and-integration-plan.md` +- `docs/dev/plans/0011-2026-05-30-live-dashboard-runtime-publish-plan.md` +- `docs/dev/plans/0012-2026-05-31-workspace-inspection-pane-app-intelligence-roadmap.md` + +## Purpose + +Make the workstation default real, not advisory. + +Today `service.defaultBrowserBuild=stealthcdp_chromium` is visible in service +status and honored by `service access-plan`, but ordinary fresh launches can +still fall through to `runtimeProfile=default`, `browserHost=local_headless`, +and `cdp_screencast`. That is why a Company Assets Google Sheets browser QA +opened in `session:default` instead of the hidden remote-headed +`stealthcdp-default` posture. + +This plan makes a bare or ordinary launch on this workstation resolve to the +configured stealth remote posture unless the caller explicitly overrides it. + +## Current Baseline + +Observed on 2026-05-31: + +- `~/.agent-browser/config.json` sets + `service.defaultBrowserBuild=stealthcdp_chromium`. +- `agent-browser service status` reports + `launchConfig.defaultBrowserBuild=stealthcdp_chromium` and + `launchConfig.stealthCdpChromiumReady=true`. +- `agent-browser service access-plan --service-name odollo --task-name ups` + selects `stealthcdp-default`, `stealthcdp_chromium`, `remote_headed`, + `rdp_gateway`, and `manual_attached_desktop`. +- The Company Assets Google Sheets QA tab was retained under + `browserId=session:default`, `profileId=default`, `host=local_headless`, and + `stream=cdp_screencast`. +- The `session:default` service-session record reported + `browserCapabilityLaunch.applied=false` with reason `missing_browser_build` + and `profileSelectionReason=explicit_profile`. + +The defect is not that stealth Chromium is unavailable. The defect is that the +ordinary launch path does not consume the configured default launch posture. + +## Implementation Progress + +Updated on 2026-05-31: + +- Added an effective launch-default resolver in `cli/src/native/actions.rs` + that builds the same service access-plan request used by service clients and + merges the planned `browserBuild`, managed profile, browser host, view stream, + control input, display isolation, and lease policy into ordinary launches + when those fields are not explicitly supplied by the caller. +- Preserved explicit caller overrides for profile, runtime profile, headless + mode, browser host, browser build, and operator-supplied executable paths. +- Tagged CLI-inserted executable paths with `executablePathSource` so + manifest-derived default executables no longer block guarded browser + capability selection. +- Added a built-in `google_sheets` site policy for + `https://docs.google.com/spreadsheets` that selects + `stealthcdp_chromium`, `remote_headed`, `rdp_gateway`, and + `manual_attached_desktop`. +- Tightened built-in site-policy URL matching so path-specific policies such as + Google Sheets do not match unrelated `docs.google.com` document URLs. +- Added focused unit coverage for effective service defaults, manifest + executable handling, explicit local-headless overrides, and Google Sheets + policy matching. + +## Validation Evidence + +Collected on 2026-05-31: + +- `cargo fmt --manifest-path cli/Cargo.toml -- --check` passed. +- `cargo clippy --manifest-path cli/Cargo.toml -- -D warnings` passed. +- `cargo test --manifest-path cli/Cargo.toml service_model -- --test-threads=1` + passed with 27 tests. +- `cargo test --manifest-path cli/Cargo.toml native::service_health -- + --nocapture` passed with 32 tests. +- `cargo test --manifest-path cli/Cargo.toml native::actions -- --nocapture + --test-threads=1` passed with 177 tests. The same filter is not safe in + parallel because existing tests mutate the shared service-state repository. +- `pnpm test:dashboard-view-streams` passed. +- `pnpm test:dashboard-workspace-navigator` passed. +- `pnpm --dir docs build` passed. +- `pnpm build:dashboard` passed. +- `git diff --check` passed. +- `pnpm publish:local-dashboard -- --expect-marker Workspaces --skip-browser + --json` rebuilt the dashboard and CLI, replaced `~/.local/bin/agent-browser`, + restarted `agent-browser-dashboard.service`, and proved the local dashboard + bundle contains the `Workspaces` marker. +- `agent-browser --json service access-plan --url + 'https://docs.google.com/spreadsheets/d/example/edit'` selected + `profileId=stealthcdp-default`, `browserHost=remote_headed`, + `browserBuild=stealthcdp_chromium`, `viewStreamProvider=rdp_gateway`, and + `controlInputProvider=manual_attached_desktop`. +- `agent-browser --json --session default-posture-smoke --leave-open open + https://example.com` succeeded without explicit profile, host, or browser + build flags. +- The retained `default-posture-smoke` service browser record is `ready`, + `host=remote_headed`, `profileId=stealthcdp-default`, has PID `87825`, and + exposes an `rdp_gateway` view stream with `manual_attached_desktop` input. +- The retained `default-posture-smoke` service session records + `browserCapabilityLaunch.applied=true`, binding + `default-stealthcdp-wsl-native`, executable + `stealthcdp-chromium-wsl-promoted`, and passed validation evidence. +- `node scripts/smoke-local-dashboard-runtime.js --dashboard-url + https://agent-browser.ecochran.dyndns.org/ --workspace-session + default-posture-smoke --session dashboard-smoke-plan0016 --browser-profile + /tmp/agent-browser-dashboard-smoke-plan0016-profile --expect-marker + Workspaces --json` passed after authenticating with the user-scoped + dashboard auth env file. It proved the hosted workspace-control route for + `default-posture-smoke` renders the workspace pane and viewport, reports + `readinessStatus=ready`, exposes a `cdp_screencast` canvas, and shows Codex + app server chat context for the selected browser. + +## Product Contract + +On this workstation, these should be equivalent for ordinary browser work when +the caller does not explicitly request a different profile, executable, or host: + +```bash +agent-browser open +``` + +and: + +```bash +agent-browser open \ + --runtime-profile stealthcdp-default \ + --browser-host remote_headed \ + --view-stream-provider rdp_gateway \ + --control-input-provider manual_attached_desktop \ + --display-isolation private_virtual_display +``` + +The exact profile and display isolation may still come from config, service +profile selection, or site policy, but the effective behavior must be a hidden +remote-headed stealth Chromium browser with an operator-visible viewport. + +## Non-Goals + +- Do not remove explicit local headless, local headed, stock Chrome, or + caller-supplied executable support. +- Do not force Google sign-in flows into CDP attachment when a site policy + requires detached seeding or CDP-free operation. +- Do not make remote-headed mandatory on machines without a ready remote-view + route. +- Do not silently attach patched Chromium to a Chrome-owned profile. +- Do not change Company Assets canonical catalog or Google Sheet contents. + +## Precedence Rules + +Explicit caller intent still wins: + +1. `--executable-path`, `AGENT_BROWSER_EXECUTABLE_PATH`, or command + `executablePath`. +2. `--profile`, command `profile`, or caller-supplied profile path. +3. `--runtime-profile`, command `runtimeProfile`. +4. Explicit `--browser-host`, command `browserHost`, or nested + `params.browserHost`. +5. Site policy and service profile selection for known target URLs. +6. Configured service default browser build and matching default profile. +7. Built-in fallback. + +The new behavior changes item 6: configured defaults must affect ordinary +launches, not only access-plan recommendations. + +## Desired Resolution + +For a fresh direct launch with no explicit profile, runtime profile, +browser host, or executable: + +- Read the same effective config used by service status. +- If `service.defaultBrowserBuild=stealthcdp_chromium` and the ready manifest + exists, set `browserBuild=stealthcdp_chromium`. +- Select a compatible managed profile, preferring the service profile marked + for that build, such as `stealthcdp-default`. +- If remote-view config is ready, set: + - `browserHost=remote_headed` + - `viewStreamProvider=rdp_gateway` + - `controlInputProvider=manual_attached_desktop` + - `displayIsolation=private_virtual_display` unless config says otherwise +- Record a launch diagnostic that says the defaults were applied, for example + `browserCapabilityLaunch.applied=true` and + `reason=configured_default_browser_build`. + +For direct launches with target URL metadata: + +- Apply matching site policy before the global default. +- A `docs.google.com/spreadsheets` policy should prefer the same hidden remote + posture for browser QA unless a future Google Workspace policy requires a + different login seeding flow. + +## Implementation Slices + +### Slice 1 | Locate The Direct Launch Gap + +Goal: identify the launch path that produces `missing_browser_build` for +ordinary `agent-browser open`. + +Tasks: + +- Trace command construction in `cli/src/main.rs`. +- Trace launch option application in `cli/src/native/actions.rs`. +- Confirm where service profile/default build lookup is skipped for direct + launches. +- Add a failing unit test or no-launch smoke fixture showing a bare open would + choose `default/local_headless`. + +Exit criteria: + +- A focused test captures the current bug without launching Chrome. + +### Slice 2 | Effective Launch Defaults Helper + +Goal: centralize default launch posture resolution. + +Tasks: + +- Add a helper that receives the command JSON, effective config, and optional + target URL. +- Return a normalized launch defaults object with `browserBuild`, + `runtimeProfile`, `browserHost`, `viewStreamProvider`, + `controlInputProvider`, `displayIsolation`, and a diagnostic reason. +- Reuse existing service profile and browser capability registry helpers where + possible instead of inventing a parallel selector. +- Keep explicit flags and explicit profile paths untouched. + +Exit criteria: + +- Unit tests cover default application, explicit override preservation, + incompatible profile avoidance, and missing manifest fallback. + +### Slice 3 | Apply Defaults To Direct Launches + +Goal: make ordinary launch commands consume the helper. + +Tasks: + +- Apply the helper before `apply_remote_headed_launch_env_hints`. +- Ensure prelaunch daemon command JSON carries the selected defaults. +- Ensure retained browser/session records preserve the applied diagnostic. +- Make `runtime status` and dashboard workspace rows show the selected + `stealthcdp-default` profile and `remote_headed` host. + +Exit criteria: + +- A no-launch or isolated temp-state test proves a bare launch command resolves + to the configured stealth remote posture. + +### Slice 4 | Site Policy For Google Workspace Review + +Goal: keep Company Assets and similar review workflows from falling into +`session:default`. + +Tasks: + +- Add or persist a site policy for `https://docs.google.com/spreadsheets`. +- Select `stealthcdp_chromium`, `remote_headed`, `rdp_gateway`, and + `manual_attached_desktop`. +- Decide whether it should require profile freshness or manual seeding + distinct from Google login. +- Add access-plan coverage proving the Sheets URL selects the intended posture. + +Exit criteria: + +- `agent-browser service access-plan --url ` reports the hidden + remote stealth posture without hand-entered browser flags. + +### Slice 5 | Dashboard And Docs Alignment + +Goal: remove the “song and dance” from operator-facing surfaces. + +Tasks: + +- Update dashboard guided launcher defaults to match the effective launch + default helper. +- Update `README.md`, `skills/agent-browser/SKILL.md`, and docs site language + so “default” means ordinary launches, not only access-plan output. +- Add a short troubleshooting note for explicit overrides that intentionally + choose local headless. + +Exit criteria: + +- Docs and skill guidance no longer tell agents to spell out the full remote + posture for ordinary work on a configured workstation. + +### Slice 6 | Runtime Publish And External Proof + +Goal: prove the installed runtime behaves correctly. + +Tasks: + +- Publish the local dashboard/runtime after source validation. +- Open a benign page with a bare command or the closest safe equivalent: + +```bash +agent-browser --session default-posture-smoke open https://example.com +``` + +- Verify service records show: + - `profileId=stealthcdp-default` + - `host=remote_headed` + - `viewStreams[0].provider=rdp_gateway` or configured remote view provider + - `browserCapabilityLaunch.applied=true` +- Open the dashboard workspace route and verify an operator-visible viewport. + +Exit criteria: + +- The installed runtime proves a short ordinary launch uses hidden remote + stealth by default. + +## Validation Matrix + +Required source checks: + +```bash +cargo test --manifest-path cli/Cargo.toml native::actions -- --nocapture +cargo test --manifest-path cli/Cargo.toml native::service_health -- --nocapture +cargo fmt --manifest-path cli/Cargo.toml -- --check +cargo clippy --manifest-path cli/Cargo.toml -- -D warnings +pnpm test:dashboard-workspace-navigator +pnpm test:dashboard-view-streams +git diff --check +``` + +Required runtime checks: + +```bash +agent-browser service status +agent-browser service access-plan --url 'https://docs.google.com/spreadsheets/d/example/edit' +pnpm publish:local-dashboard -- --expect-marker Workspaces --json +agent-browser --json --session default-posture-smoke open https://example.com +agent-browser --json service browsers +agent-browser --json service sessions +``` + +Required hosted smoke: + +```bash +node scripts/smoke-local-dashboard-runtime.js \ + --dashboard-url https://agent-browser.ecochran.dyndns.org/ \ + --workspace-session default-posture-smoke \ + --expect-marker data-codex-app-server-contextual-chat \ + --json +``` + +## Risks And Mitigations + +- Risk: breaking CI or tests that assume bare launches are headless. + Mitigation: make config-driven defaults explicit in tests and allow env or + temp config to force local headless. +- Risk: remote-headed launch on a host without route readiness. + Mitigation: only apply remote-headed when remote view config and display + readiness are present; otherwise apply stealth build/profile but report a + fallback diagnostic. +- Risk: profile family mismatch. + Mitigation: keep existing browser-family compatibility checks and refuse + patched Chromium on Chrome-owned profiles unless explicitly overridden. +- Risk: Google login flows need detached seeding. + Mitigation: distinguish `accounts.google.com` login policy from + `docs.google.com/spreadsheets` review policy. + +## Completion Criteria + +This plan is complete when: + +- Bare ordinary launches consume `service.defaultBrowserBuild`. +- A configured workstation defaults to hidden remote-headed + `stealthcdp_chromium` with a compatible managed profile. +- Explicit local/headless/profile/executable overrides still work. +- Google Sheets browser QA no longer lands in `session:default` unless the + caller explicitly asks for it. +- Source validation passes. +- Installed local runtime is republished. +- External dashboard smoke proves the short-launch default is inspectable in + the UX. + +## Recommended Next Step + +Start with Slice 1 and Slice 2. Do not patch callers or runbooks first; the +launch resolver itself needs to make the configured default effective. diff --git a/docs/dev/plans/0017-2026-05-31-plan-0016-integration-checkpoint.md b/docs/dev/plans/0017-2026-05-31-plan-0016-integration-checkpoint.md new file mode 100644 index 000000000..5c78e1516 --- /dev/null +++ b/docs/dev/plans/0017-2026-05-31-plan-0016-integration-checkpoint.md @@ -0,0 +1,149 @@ +# Plan 0016 Integration Checkpoint + +Date: 2026-05-31 +State: COMPLETE +Lane: integration hygiene +Depends On: +- `docs/dev/plans/0016-2026-05-31-effective-stealth-remote-default-launch-plan.md` + +## Purpose + +Make the completed Plan 0016 runtime posture work reviewable and durable +without hiding unrelated dashboard, retained-cleanup, or App Intelligence work +inside the same checkpoint. + +The active worktree contains several overlapping lanes. Plan 0016 is complete +as a runtime and hosted-dashboard validation result, but a clean integration +slice still needs to separate the effective stealth remote default launch work +from pre-existing dirty files before committing. + +## Scope + +In scope: + +- effective launch defaults for ordinary launches +- manifest executable source handling +- Google Sheets built-in site policy +- Plan 0016 validation evidence +- authenticated dashboard runtime smoke harness support needed to prove the + hosted UX without competing for the managed default runtime profile +- docs and skill updates that describe the stealth remote default behavior + +Out of scope: + +- retained orphan profile cleanup +- needs-attention and retained pool redesign +- broad inspector pane implementation +- contextual Chat/App Intelligence provider work beyond smoke evidence that + already exists +- package version changes or formal release preparation + +## Execution Plan + +1. Classify dirty files into Plan 0016, prerequisite smoke harness, and + unrelated prior lanes. +2. Stage only clean Plan 0016 hunks and required plan evidence. +3. Leave mixed files unstaged when clean hunk staging would risk dropping or + misattributing prior work. +4. Run validation that covers staged Rust, docs, skill, and smoke-script + changes. +5. Commit the checkpoint only if the staged diff is coherent and validation + proves the committed surface. + +## Validation + +Minimum validation before commit: + +```bash +cargo fmt --manifest-path cli/Cargo.toml -- --check +cargo clippy --manifest-path cli/Cargo.toml -- -D warnings +cargo test --manifest-path cli/Cargo.toml service_model -- --test-threads=1 +cargo test --manifest-path cli/Cargo.toml native::actions -- --nocapture --test-threads=1 +node --check scripts/smoke-local-dashboard-runtime.js +git diff --check +``` + +Runtime evidence can be reused from Plan 0016 only if the installed binary and +hosted smoke have not drifted since that plan was marked complete. + +## Completion Criteria + +- The repo contains this plan and completed Plan 0016 evidence. +- A focused commit exists for the Plan 0016 integration checkpoint, or the plan + records the exact file overlap that prevents a safe commit. +- Validation evidence is recorded in this file. +- Unrelated dirty work is preserved. + +## Execution Result + +Collected on 2026-05-31: + +- Graphiti discovery was healthy, but returned no newer Plan 0016 authority + than the repo-local plan and source files. +- `pnpm validation:select -- --base HEAD` reported 56 changed files and + recommended broad dashboard, service-client, Rust, docs, and local publish + gates for the whole dirty tree. +- The current dirty worktree spans multiple lanes: + - Plan 0016 effective stealth remote default launch work in + `cli/src/main.rs`, `cli/src/native/actions.rs`, + `cli/src/native/service_model.rs`, `README.md`, + `docs/src/app/service-mode/page.mdx`, `skills/agent-browser/SKILL.md`, + `scripts/smoke-local-dashboard-runtime.js`, and Plan 0016 itself. + - retained orphan profile cleanup in `cli/src/native/actions.rs`, + `cli/src/output.rs`, `README.md`, `docs/src/app/service-mode/page.mdx`, + and `skills/agent-browser/SKILL.md`. + - workspace navigator, CDP stream, inspector, and contextual Chat work across + dashboard source, stream backend source, service request contracts, scripts, + and Plans 0011 through 0015. +- `cli/src/native/actions.rs` contains both the Plan 0016 effective default + launch helper/tests and unrelated orphaned-profile prune logic/tests. Because + that is the core Plan 0016 source file, a whole-file commit would be + misleading and a partial commit should be done only in a clean integration + branch or side worktree. + +Validation run on the current integrated state: + +- `cargo fmt --manifest-path cli/Cargo.toml -- --check` passed. +- `node --check scripts/smoke-local-dashboard-runtime.js` passed. +- `git diff --check` passed. +- `cargo test --manifest-path cli/Cargo.toml service_model -- --test-threads=1` + passed with 27 tests. +- `cargo test --manifest-path cli/Cargo.toml native::actions -- --nocapture + --test-threads=1` passed with 177 tests. +- `cargo clippy --manifest-path cli/Cargo.toml -- -D warnings` passed. + +Decision: + +- Do not create a Plan 0016 commit directly from this dirty worktree. +- Preserve all existing dirty work. +- Create a clean side worktree from `main`, re-apply only Plan 0016 hunks plus + the smoke harness option, run validation there, and commit from that isolated + worktree. + +Side-worktree execution: + +- Created branch `plan0016-integration` in + `/home/ecochran76/workspace.local/agent-browser-plan0016-integration`. +- Re-applied the Plan 0016 source, docs, skill, plan, and smoke-harness + changes without the retained orphan profile cleanup or broad dashboard/App + Intelligence work. +- Verified no retained-orphan cleanup strings remained in the isolated Plan + 0016 candidate. +- Committed the isolated checkpoint on branch `plan0016-integration`. + +Side-worktree validation: + +- `cargo fmt --manifest-path cli/Cargo.toml -- --check` passed after applying + rustfmt to the isolated worktree. +- `node --check scripts/smoke-local-dashboard-runtime.js` passed. +- `git diff --check` passed. +- `cargo test --manifest-path cli/Cargo.toml service_model -- --test-threads=1` + passed with 27 tests. +- `cargo test --manifest-path cli/Cargo.toml native::actions -- --nocapture + --test-threads=1` passed with 176 tests. The retained orphan profile cleanup + test is intentionally absent from the isolated Plan 0016 branch. +- `cargo clippy --manifest-path cli/Cargo.toml -- -D warnings` passed. +- `pnpm --dir docs build` passed after installing the side-worktree docs + dependencies. A temporary symlink attempt failed because Turbopack rejects + `docs/node_modules` symlinks that point outside the workspace root; the + symlink was removed before the successful install and build. diff --git a/docs/src/app/service-mode/page.mdx b/docs/src/app/service-mode/page.mdx index 69fbee156..4efdeca23 100644 --- a/docs/src/app/service-mode/page.mdx +++ b/docs/src/app/service-mode/page.mdx @@ -269,7 +269,7 @@ await requestServiceTab({ }); ``` -If accessPlan.readinessSummary.needsManualSeeding is true, show the returned seedingHandoff command, setup URL, operator steps, warnings, and recommended actions to the operator before expecting authenticated automation to succeed for the requested identity. requestServiceTab({ accessPlan }) refuses this state by default, so clients should retry the same identity request after seeding or pass allowManualAction: true only for an intentional override. Access-plan readiness decisions are scoped to the requested target identities, so an unrelated stale or unseeded login on the same profile does not block the requested site. Access-plan responses echo agentName and taskName in query, and both query and decision include namingWarnings plus hasNamingWarning when serviceName, agentName, or taskName is missing. The response also includes monitorFindings and decision.monitorAttentionRequired when an active profile_readiness monitor is faulted for the requested target identity. When a matching active profile-readiness monitor is due or never checked, monitorFindings.profileReadinessProbeDue, profileReadinessDueMonitorIds, profileReadinessNeverCheckedMonitorIds, and decision.monitorProbeDue tell clients to run due monitors before trusting retained freshness. The decision then recommends run_due_profile_readiness_monitor and includes monitorRunDue with HTTP POST /api/service/monitors/run-due, MCP service_monitors_run_due, CLI agent-browser service monitors run-due, and runServiceAccessPlanMonitorRunDue() instructions. The decision separates auth providers from challenge-capable providers, reports the challenge strategy, lists missing provider capabilities such as captcha_solve, sms_code, or human_approval, includes interactionRisk plus a pacing block derived from site-policy rate limits, exposes launchPosture so clients can see whether the resolved browser host is headed, remote-view capable, requires detached first-login seeding, and recommends stock_chrome, stealthcdp_chromium, or cdp_free_headed through browserBuild. The nested browserBuildSelection object explains the winning browser-build source, evidence source, operator override status, selected preference binding, profile compatibility summary, and validation evidence summary. It includes freshnessUpdate with the selected profile, target identities, HTTP route, MCP tool, and updateServiceProfileFreshness helper to use after a bounded auth probe. It also includes postSeedingProbe, a copyable post-close verification recipe with the verify-seeding CLI command, freshness HTTP and MCP write path, runServiceAccessPlanPostSeedingProbe() and verifyServiceProfileSeeding() helpers, and examples/service-client/post-seeding-probe.mjs command. It also includes serviceRequest, a copyable queued tab-request recipe for HTTP POST /api/service/request, MCP service_request, and requestServiceTab(). serviceRequest.available is true when the planned tab request can be queued immediately, while recommendedAfterManualAction tells clients to reuse the same request after manual seeding, challenge approval, or provider work completes. Headed access plans include params.headless=false in the queued tab request so clients do not accidentally launch true headless Chrome for a headed site policy. When no local site policy exists, agent-browser applies shipped defaults for Google, Gmail, Microsoft login identities, and UPS tracking. Local persisted or configured policies with the same IDs override the built-in defaults, and sitePolicySource reports the selected policy source, match path, precedence, and whether the policy can be overridden. +If accessPlan.readinessSummary.needsManualSeeding is true, show the returned seedingHandoff command, setup URL, operator steps, warnings, and recommended actions to the operator before expecting authenticated automation to succeed for the requested identity. requestServiceTab({ accessPlan }) refuses this state by default, so clients should retry the same identity request after seeding or pass allowManualAction: true only for an intentional override. Access-plan readiness decisions are scoped to the requested target identities, so an unrelated stale or unseeded login on the same profile does not block the requested site. Access-plan responses echo agentName and taskName in query, and both query and decision include namingWarnings plus hasNamingWarning when serviceName, agentName, or taskName is missing. The response also includes monitorFindings and decision.monitorAttentionRequired when an active profile_readiness monitor is faulted for the requested target identity. When a matching active profile-readiness monitor is due or never checked, monitorFindings.profileReadinessProbeDue, profileReadinessDueMonitorIds, profileReadinessNeverCheckedMonitorIds, and decision.monitorProbeDue tell clients to run due monitors before trusting retained freshness. The decision then recommends run_due_profile_readiness_monitor and includes monitorRunDue with HTTP POST /api/service/monitors/run-due, MCP service_monitors_run_due, CLI agent-browser service monitors run-due, and runServiceAccessPlanMonitorRunDue() instructions. The decision separates auth providers from challenge-capable providers, reports the challenge strategy, lists missing provider capabilities such as captcha_solve, sms_code, or human_approval, includes interactionRisk plus a pacing block derived from site-policy rate limits, exposes launchPosture so clients can see whether the resolved browser host is headed, remote-view capable, requires detached first-login seeding, and recommends stock_chrome, stealthcdp_chromium, or cdp_free_headed through browserBuild. The nested browserBuildSelection object explains the winning browser-build source, evidence source, operator override status, selected preference binding, profile compatibility summary, and validation evidence summary. It includes freshnessUpdate with the selected profile, target identities, HTTP route, MCP tool, and updateServiceProfileFreshness helper to use after a bounded auth probe. It also includes postSeedingProbe, a copyable post-close verification recipe with the verify-seeding CLI command, freshness HTTP and MCP write path, runServiceAccessPlanPostSeedingProbe() and verifyServiceProfileSeeding() helpers, and examples/service-client/post-seeding-probe.mjs command. It also includes serviceRequest, a copyable queued tab-request recipe for HTTP POST /api/service/request, MCP service_request, and requestServiceTab(). serviceRequest.available is true when the planned tab request can be queued immediately, while recommendedAfterManualAction tells clients to reuse the same request after manual seeding, challenge approval, or provider work completes. Headed access plans include params.headless=false in the queued tab request so clients do not accidentally launch true headless Chrome for a headed site policy. When no local site policy exists, agent-browser applies shipped defaults for Google Sheets, Google, Gmail, Microsoft login identities, and UPS tracking. Local persisted or configured policies with the same IDs override the built-in defaults, and sitePolicySource reports the selected policy source, match path, precedence, and whether the policy can be overridden. Access-plan decision.launchPosture also reports viewStreamProvider, viewStreamProviderSource, controlInputProvider, controlInputProviderSource, and remote-headed displayIsolation. The copied queued request carries params.viewStreamProvider, params.controlInputProvider, and params.displayIsolation when those posture decisions are selected, so the dashboard and software clients consume the service-owned posture instead of guessing whether RDP, noVNC, WebRTC, CDP screencast, manual desktop input, or a private virtual display should be used. Raw HTTP, MCP, and client access-plan requests can set browserHost, viewStreamProvider, controlInputProvider, and displayIsolation; raw service requests can also set displayIsolation or params.displayIsolation to private_virtual_display, shared_display, or ambient_display. Remote-headed browser records persist the selected input provider on each viewStreams entry as controlInput, letting the dashboard distinguish a view-only stream from an operator-controllable stream. They also expose displayIsolation and displayName when the service can tell whether the browser is using a private virtual display, an explicitly shared display, or the daemon's ambient DISPLAY. The dashboard enables row-level View only for embeddable streams and Control only when the service reports an input provider, then uses the stream and display metadata for disabled-state explanations and selected-browser readiness. @@ -306,7 +306,7 @@ Run pnpm test:rdp-guac-route-cleanup-live for the guarded Slice G c Run pnpm test:rdp-guac-route-pool-readiness before the guarded Slice H many-to-many gate to inspect the local Guacamole database without printing passwords. It checks the Guacamole Compose containers, lists redacted RDP connection metadata, probes the Guacamole web route, checks guacd-to-RDP TCP reachability for the selected route candidates, requires at least two distinct route candidates by default, and emits a copyable AGENT_BROWSER_RDP_ROUTE_POOL_JSON value when the provider has enough ready routes. Emitted route-pool entries include redacted target identity and readiness metadata, not passwords. Pass --report-only to collect the JSON readiness report without failing the command on a one-route workstation. Run pnpm setup:rdp-guac-route-pool -- --dry-run to review the first static host-XRDP route-pool bootstrap without changing the host. Run pnpm install:privileges -- --dry-run to review the one-time privileged helper install, then pnpm install:privileges -- --apply from an interactive terminal to create the agent-browser group, install the root-owned helper under /usr/local/libexec/agent-browser, add the operator user to the group, and install the narrow sudoers rule. Open a new shell or run newgrp agent-browser after applying it. Re-running the privilege installer on an already-provisioned machine exits before privileged changes when the helper, sudoers policy, group, and membership are ready. Then run pnpm setup:rdp-guac-route-pool only after the doctor or display inspector proves the existing route topology collapsed to one display, or when a reviewed operator override passes --force. It creates or updates two local XRDP users, creates or updates two Guacamole RDP connections, grants Guacamole read permission, stores generated XRDP passwords under the user-scoped Guacamole secret file, restarts XRDP, and then tells you to rerun the route-pool readiness smoke. The setup command uses the installed privileged helper when available, falls back to interactive sudo otherwise, and does not print the generated passwords. If the reusable agent-browser-rdp user already exists, use pnpm sync:rdp-guac-existing-user-route-pool instead of the sudo setup. It updates only Guacamole connection records, reads the existing XRDP credentials from the user-scoped secret file, and creates route A/B records with distinct RDP color depths so the current XRDP Policy=Default can allocate distinct sessions for the same user. This bootstrap only creates distinct RDP sessions; P03 is complete only after the many-to-many live gate proves Browser A and Browser B are actually visible through those routes at the same time. After opening both route sessions, run pnpm inspect:rdp-route-displays to map the route users to active XRDP display names and print AGENT_BROWSER_RDP_ROUTE_A_DISPLAY_NAME and AGENT_BROWSER_RDP_ROUTE_B_DISPLAY_NAME for the live gate. Pass --shell to the display inspector or route-pool readiness smoke when you want copyable export ... lines instead of JSON. If the agent user cannot launch onto those XRDP-owned displays, run pnpm grant:rdp-route-display-access -- --dry-run to review the narrow local X grants, then run pnpm grant:rdp-route-display-access -- --apply. It uses the installed helper when available and falls back to interactive sudo otherwise. Run pnpm test:rdp-guac-many-to-many-live for the full Slice H gate. It prefers the installed agent-browser command, can hydrate route-pool and display-name variables from agent-browser doctor remote-view --json, and otherwise accepts two distinct route-pool entries supplied by AGENT_BROWSER_RDP_ROUTE_POOL_JSON or paired AGENT_BROWSER_RDP_ROUTE_A_* and AGENT_BROWSER_RDP_ROUTE_B_* environment variables. The harness auto-discovers common local viewer browsers when explicit viewer executable variables are unset, and it fails public Guacamole URLs with a non_embeddable_guacamole_url diagnostic unless AGENT_BROWSER_RDP_TEST_ALLOW_PUBLIC_GUAC_URL=1 is set for a reviewed diagnostic. Route entries may include target.displayName in the JSON pool, or AGENT_BROWSER_RDP_ROUTE_A_DISPLAY_NAME and AGENT_BROWSER_RDP_ROUTE_B_DISPLAY_NAME for paired environment variables. When display targets are present, the smoke launches each browser directly on that route's XRDP display with displayIsolation="shared_display" and requires the two display names to be distinct. When display targets are absent, it uses the service private-display allocator. The smoke launches two remote_headed browsers, checks out distinct Guacamole/RDP routes, records observer and controller leases for both routes, opens view=workspace:tile in two dashboard clients, refreshes Browser A's tile, closes Browser A, and verifies Browser B remains ready. It crops each dashboard iframe from the tile screenshots and runs OCR against the remote-view pixels so the gate proves route A shows Browser A and route B shows Browser B, not only that two iframe URLs loaded. Artifacts are written under /tmp/agent-browser-rdp-guac-many-to-many-<timestamp>/. -CDP-sensitive sites can declare requiresCdpFree on their site policy. Access-plan decision.launchPosture then reports browserBuild: "cdp_free_headed", requiresCdpFree: true, and cdpAttachmentAllowed: false so agents, MCP clients, and software clients avoid opening a DevTools port. Sites can also declare browserBuild: "stealthcdp_chromium" when CDP-backed control is acceptable but a patched Chromium build is preferred. Access-plan decision.serviceRequest.available is false for CDP-free tab requests because the normal tab path is CDP-backed, and decision.serviceRequest.cdpFreeAvailability.availableCommands names the lifecycle-only alternative before launch. The cdp_free_launch action can launch headed Chrome without DevTools and record browser PID, profile, session ownership, and lease state, but snapshot, screenshot, DOM, and input commands remain unavailable until later non-CDP control primitives exist. Clients should read unsupportedCommands from the launch response, or call summarizeServiceCdpFreeLaunchAvailability(response.data), before offering follow-up automation controls. The shipped Canva site policy uses cdp_free_headed by default and still prefers headed Chrome, human-like interaction, a persistent profile, and manual challenge handling. The shipped UPS policy uses stealthcdp_chromium with a remote-view-capable headed host because live 2026-05-17 testing showed true headless stealth Chromium failed UPS tracking navigation while headed stealth Chromium loaded the tracking page. Local configured or persisted policies with the same ID override the built-in default. +CDP-sensitive sites can declare requiresCdpFree on their site policy. Access-plan decision.launchPosture then reports browserBuild: "cdp_free_headed", requiresCdpFree: true, and cdpAttachmentAllowed: false so agents, MCP clients, and software clients avoid opening a DevTools port. Sites can also declare browserBuild: "stealthcdp_chromium" when CDP-backed control is acceptable but a patched Chromium build is preferred. Ordinary launch and queued tab paths consume service.defaultBrowserBuild through the same access-plan resolver unless the caller explicitly supplies a profile, browser host, headless mode, executable, or browser build. Access-plan decision.serviceRequest.available is false for CDP-free tab requests because the normal tab path is CDP-backed, and decision.serviceRequest.cdpFreeAvailability.availableCommands names the lifecycle-only alternative before launch. The cdp_free_launch action can launch headed Chrome without DevTools and record browser PID, profile, session ownership, and lease state, but snapshot, screenshot, DOM, and input commands remain unavailable until later non-CDP control primitives exist. Clients should read unsupportedCommands from the launch response, or call summarizeServiceCdpFreeLaunchAvailability(response.data), before offering follow-up automation controls. The shipped Canva site policy uses cdp_free_headed by default and still prefers headed Chrome, human-like interaction, a persistent profile, and manual challenge handling. The shipped UPS and Google Sheets policies use stealthcdp_chromium with a remote-view-capable headed host; UPS uses that posture because live 2026-05-17 testing showed true headless stealth Chromium failed UPS tracking navigation while headed stealth Chromium loaded the tracking page. Local configured or persisted policies with the same ID override the built-in default. When a software client has just completed a bounded auth probe, pass readinessState: "fresh", readinessEvidence, lastVerifiedAt, and freshnessExpiresAt to registerServiceLoginProfile(). For an already registered profile, call verifyServiceProfileSeeding() or updateServiceProfileFreshness() so the service merges the new row under the serialized service-state mutator, preserves unrelated fields, removes stale or blocked targets from authenticatedServiceIds, and records the post-close seeding verification result on any matching closed handoff. The service preserves explicit fresh, stale, and blocked_by_attached_devtools evidence through derived readiness refreshes. The examples/service-client/post-seeding-probe.mjs recipe demonstrates the bounded post-close path: confirm the broker-selected profile matches the profile being verified, request one service-owned tab for the seeded identity, read URL and title through queued service requests, evaluate optional URL or title expectations, then call verifyServiceProfileSeeding(). diff --git a/scripts/smoke-local-dashboard-runtime.js b/scripts/smoke-local-dashboard-runtime.js new file mode 100644 index 000000000..f755bb6d3 --- /dev/null +++ b/scripts/smoke-local-dashboard-runtime.js @@ -0,0 +1,402 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; + +const args = process.argv.slice(2); +const options = { + agentBrowserBin: process.env.AGENT_BROWSER_BIN || 'agent-browser', + dashboardUrl: process.env.AGENT_BROWSER_DASHBOARD_URL || 'http://127.0.0.1:4848/', + expectMarkers: [], + json: false, + keepBrowser: false, + browserProfile: '', + skipBrowser: false, + session: `local-dashboard-runtime-smoke-${process.pid}`, + workspaceSession: '', +}; + +for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--') { + continue; + } else if (arg === '--agent-browser-bin') { + options.agentBrowserBin = requiredValue(args, ++index, arg); + } else if (arg === '--dashboard-url') { + options.dashboardUrl = requiredValue(args, ++index, arg); + } else if (arg === '--expect-marker') { + options.expectMarkers.push(requiredValue(args, ++index, arg)); + } else if (arg === '--json') { + options.json = true; + } else if (arg === '--keep-browser') { + options.keepBrowser = true; + } else if (arg === '--browser-profile') { + options.browserProfile = requiredValue(args, ++index, arg); + } else if (arg === '--session') { + options.session = requiredValue(args, ++index, arg); + } else if (arg === '--skip-browser') { + options.skipBrowser = true; + } else if (arg === '--workspace-session') { + options.workspaceSession = requiredValue(args, ++index, arg); + } else if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } else { + fail(`Unknown argument: ${arg}`); + } +} + +const report = { + dashboardUrl: options.dashboardUrl, + http: null, + markers: [], + browser: null, +}; + +try { + await run(); + if (options.json) { + console.log(JSON.stringify({ success: true, ...report }, null, 2)); + } else { + console.log(`Local dashboard runtime smoke passed: ${options.dashboardUrl}`); + } +} catch (error) { + if (options.json) { + console.log(JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error), + ...report, + }, null, 2)); + } else { + console.error(error instanceof Error ? error.message : String(error)); + } + process.exit(1); +} + +async function run() { + const dashboardUrl = new URL(options.dashboardUrl); + const html = await getText(dashboardUrl); + report.http = { + htmlBytes: Buffer.byteLength(html), + title: html.match(/([^<]+)<\/title>/i)?.[1] ?? null, + }; + if (!html.includes('Agent Browser') && !html.includes('__next')) { + throw new Error(`Dashboard HTML at ${dashboardUrl.href} did not look like the Agent Browser dashboard.`); + } + + const chunks = [...new Set([...html.matchAll(/(?:\/_next\/)?static\/[^"']+\.js/g)].map((match) => match[0]))]; + report.http.chunkCount = chunks.length; + report.http.chunks = chunks.slice(0, 20); + + const chunkTexts = []; + for (const chunk of chunks) { + const chunkUrl = chunk.startsWith('/') + ? new URL(chunk, dashboardUrl.origin) + : new URL(`/_next/${chunk}`, dashboardUrl.origin); + try { + chunkTexts.push(await getText(chunkUrl)); + } catch (error) { + throw new Error(`Failed to read dashboard chunk ${chunkUrl.href}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + for (const marker of options.expectMarkers) { + const foundInHtml = html.includes(marker); + const foundInChunk = chunkTexts.some((text) => text.includes(marker)); + report.markers.push({ marker, foundInHtml, foundInChunk }); + if (!foundInHtml && !foundInChunk) { + throw new Error(`Expected dashboard marker was not served from ${dashboardUrl.origin}: ${marker}`); + } + } + + if (!options.skipBrowser) { + report.browser = await runBrowserSmoke(dashboardUrl); + } +} + +async function runBrowserSmoke(baseUrl) { + const smokeUrl = new URL(baseUrl.href); + if (options.workspaceSession) { + smokeUrl.searchParams.set('view', 'workspace:control'); + smokeUrl.searchParams.set('workspace', `daemon-session:${options.workspaceSession}`); + smokeUrl.searchParams.set('session', options.workspaceSession); + smokeUrl.searchParams.set('tab', '0'); + } + + try { + await runAgent([...baseAgentArgs(), 'open', smokeUrl.href], { timeoutMs: 90000 }); + await runAgent(['--json', '--session', options.session, 'wait', '1000'], { timeoutMs: 30000 }); + const first = await evalAgent(` +JSON.stringify({ + needsLogin: Boolean(document.querySelector('input[type="password"]')), + url: location.href, + title: document.title +}) +`); + let firstState = parseEvalJson(first, 'initial dashboard browser state'); + if (firstState.needsLogin) { + const credentials = dashboardCredentials(); + await evalAgent(` +(async () => { + await fetch('/api/dashboard-auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(${JSON.stringify(credentials)}) + }); + location.reload(); + return JSON.stringify({ loginSubmitted: true }); +})() +`); + await runAgent(['--json', '--session', options.session, 'wait', '1500'], { timeoutMs: 30000 }); + firstState = parseEvalJson(await evalAgent(` +JSON.stringify({ + needsLogin: Boolean(document.querySelector('input[type="password"]')), + url: location.href, + title: document.title +}) +`), 'post-login dashboard browser state'); + if (firstState.needsLogin) { + throw new Error('Dashboard browser smoke could not authenticate with the user-scoped dashboard auth file.'); + } + } + + const finalState = parseEvalJson(await evalAgent(` +JSON.stringify({ + url: location.href, + hasAgentBrowserChrome: document.body.innerText.includes('Agent Browser'), + hasWorkspacePane: document.body.innerText.includes('Workspaces'), + hasWorkspaceTab: Array.from(document.querySelectorAll('[role=tab],button')).some((element) => element.textContent?.trim() === 'Workspace'), + rightPaneText: document.querySelector('.dashboard-pane-right')?.innerText.slice(0, 500) || '', + viewport: Boolean(document.querySelector('.workspace-remote-viewport')), + frameSrc: document.querySelector('.workspace-remote-viewport-frame')?.getAttribute('src') || null, + hasCdpCanvas: Boolean(document.querySelector('.workspace-cdp-stream-canvas')), + cdpProvider: document.querySelector('.workspace-cdp-stream')?.getAttribute('data-provider') || null, + readinessStatus: document.querySelector('.workspace-remote-viewport')?.getAttribute('data-readiness-status') || null +}) +`), 'final dashboard browser state'); + + if (!finalState.hasAgentBrowserChrome || !finalState.hasWorkspacePane) { + throw new Error(`Dashboard browser smoke did not see the expected app chrome: ${JSON.stringify(finalState)}`); + } + if (options.workspaceSession && (!finalState.viewport || (!finalState.frameSrc && !finalState.hasCdpCanvas))) { + throw new Error(`Workspace route did not render an embedded viewport: ${JSON.stringify(finalState)}`); + } + let chatState = null; + if (options.workspaceSession) { + chatState = parseEvalJson(await evalAgent(` +(async () => { +const chatButton = Array.from(document.querySelectorAll('[role=tab],button')) + .find((element) => element.textContent?.trim() === 'Chat'); +for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) { + chatButton?.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window })); +} +await new Promise((resolve) => setTimeout(resolve, 500)); +const text = document.querySelector('.dashboard-pane-right')?.innerText || document.body.innerText; +const inspectButton = Array.from(document.querySelectorAll('button')) + .find((element) => element.textContent?.trim() === 'Inspect viewport readiness'); +for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) { + inspectButton?.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window })); +} +return JSON.stringify({ + hasCodexProvider: text.includes('Codex app server'), + hasReadOnlyProvider: text.includes('read-only'), + hasContextualChatMarker: Boolean(document.querySelector('[data-codex-app-server-contextual-chat="ready"]')), + hasModelSelectorText: /model|OpenAI|AI Gateway/i.test(text), + clickedInspect: Boolean(inspectButton), + rightPaneText: text.slice(0, 500) +}); +})() +`), 'workspace Chat contextual state'); + if (!chatState.clickedInspect) { + throw new Error(`Workspace Chat did not expose the viewport inspection action: ${JSON.stringify(chatState)}`); + } + for (let attempt = 0; attempt < 75; attempt += 1) { + const pollState = parseEvalJson(await evalAgent(` +(() => { +const inspectedText = document.querySelector('.dashboard-pane-right')?.innerText || document.body.innerText; +return JSON.stringify({ + hasStructuredObservation: /observation/i.test(inspectedText) && inspectedText.includes('codex-app-server'), + hasStructuredFailure: /inspection failure/i.test(inspectedText) && inspectedText.includes('codex-app-server'), + hasEventLog: /event log/i.test(inspectedText), + hasThreadOrTurn: /\\b(thread|turn)\\s+[a-zA-Z0-9_-]{4,}/.test(inspectedText), + rightPaneText: inspectedText.slice(0, 500) +}); +})() +`), 'workspace Chat inspection poll state'); + chatState = { ...chatState, ...pollState }; + if ((pollState.hasStructuredObservation || pollState.hasStructuredFailure) && pollState.hasEventLog) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + if (!chatState.hasCodexProvider || !chatState.hasContextualChatMarker) { + throw new Error(`Workspace Chat did not expose the Codex app-server context surface: ${JSON.stringify(chatState)}`); + } + if (chatState.hasModelSelectorText) { + throw new Error(`Workspace Chat exposed a model/provider selector label: ${JSON.stringify(chatState)}`); + } + if (!chatState.hasStructuredObservation) { + throw new Error(`Workspace Chat did not render a structured Codex inspection observation: ${JSON.stringify(chatState)}`); + } + if (!chatState.hasEventLog || !chatState.hasThreadOrTurn) { + throw new Error(`Workspace Chat did not render app-server ledger metadata: ${JSON.stringify(chatState)}`); + } + } + return { + session: options.session, + smokeUrl: smokeUrl.href, + ...finalState, + chatState, + }; + } finally { + if (!options.keepBrowser) { + await runAgent([...baseAgentArgs(), 'close'], { timeoutMs: 30000 }).catch(() => undefined); + } + } +} + +function baseAgentArgs() { + const command = ['--json', '--session', options.session]; + if (options.browserProfile) { + command.push('--profile', options.browserProfile); + } + return command; +} + +async function getText(url) { + const response = await fetch(url, { redirect: 'follow' }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} from ${url.href}`); + } + return response.text(); +} + +function dashboardCredentials() { + const authPath = process.env.AGENT_BROWSER_DASHBOARD_AUTH_ENV || + `${homedir()}/.agent-browser/dashboard-auth.env`; + if (!existsSync(authPath)) { + throw new Error(`Dashboard auth env file is missing: ${authPath}`); + } + const values = parseEnv(readFileSync(authPath, 'utf8')); + const username = values.AGENT_BROWSER_DASHBOARD_CODEX_USERNAME || + values.AGENT_BROWSER_DASHBOARD_ADMIN_USERNAME || + 'admin'; + const password = values.AGENT_BROWSER_DASHBOARD_CODEX_PASSWORD || + values.AGENT_BROWSER_DASHBOARD_ADMIN_PASSWORD; + if (!password) { + throw new Error(`Dashboard auth env file does not contain a usable dashboard password: ${authPath}`); + } + return { username, password }; +} + +function parseEnv(text) { + const values = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const index = trimmed.indexOf('='); + if (index <= 0) continue; + const key = trimmed.slice(0, index).trim(); + let value = trimmed.slice(index + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + values[key] = value.replace(/\\"/g, '"'); + } + return values; +} + +async function evalAgent(script) { + const result = await runAgent(['--json', '--session', options.session, 'eval', '--stdin'], { + input: script, + timeoutMs: 60000, + }); + const parsed = parseJson(result.stdout, 'agent-browser eval'); + if (!parsed.success) { + throw new Error(`agent-browser eval failed: ${result.stdout}${result.stderr}`); + } + return parsed.data?.result; +} + +function parseEvalJson(value, label) { + if (typeof value !== 'string') { + throw new Error(`${label} did not return a JSON string: ${JSON.stringify(value)}`); + } + return parseJson(value, label); +} + +function parseJson(text, label) { + try { + return JSON.parse(String(text).trim()); + } catch (error) { + throw new Error(`Failed to parse ${label} JSON: ${error instanceof Error ? error.message : String(error)}\n${text}`); + } +} + +function runAgent(commandArgs, { input = '', timeoutMs = 60000 } = {}) { + return new Promise((resolve, reject) => { + const child = spawn(options.agentBrowserBin, commandArgs, { + cwd: new URL('..', import.meta.url), + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const timeout = setTimeout(() => { + child.kill('SIGTERM'); + reject(new Error(`agent-browser command timed out: ${commandArgs.join(' ')}`)); + }, timeoutMs); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on('exit', (code, signal) => { + clearTimeout(timeout); + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject(new Error(`agent-browser ${commandArgs.join(' ')} failed with code=${code} signal=${signal}\n${stdout}${stderr}`)); + } + }); + if (input) child.stdin.end(input); + else child.stdin.end(); + }); +} + +function requiredValue(values, index, flag) { + const value = values[index]; + if (!value) fail(`Missing value for ${flag}`); + return value; +} + +function fail(message) { + console.error(message); + process.exit(2); +} + +function printHelp() { + console.log(`Usage: node scripts/smoke-local-dashboard-runtime.js [options] + +Options: + --dashboard-url <url> Dashboard URL to verify. Default: http://127.0.0.1:4848/ + --expect-marker <text> Require a served HTML or JS bundle to contain text. Repeatable. + --agent-browser-bin <path> agent-browser binary used for browser smoke. + --browser-profile <path> Use an isolated runtime profile for the smoke browser. + --workspace-session <name> Open a workspace viewport route for a daemon session. + --skip-browser Only run HTTP and bundle marker checks. + --json Print structured JSON. +`); +} diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index 607c63b63..c1ec99e92 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -12,6 +12,8 @@ The CLI uses Chrome/Chromium via CDP directly. For this fork, install the native For ordinary agent work on this workstation, prefer the service-owned hidden headed remote-control path over ad hoc local headless launches. Treat `chromium-stealthrdp` as operator shorthand for patched `stealthcdp_chromium` launched through `remote_headed`, exposed through the RDP or Guacamole stream, controlled with `manual_attached_desktop`, and still available to agent-browser through CDP when the site policy allows it. +When `service.defaultBrowserBuild` is set, ordinary launch and queued tab paths use the same service access-plan resolver as software clients. That means a default patched Chromium build can select the compatible managed profile, remote headed posture, view stream provider, and guarded executable binding without requiring every caller to pass the full access-plan command. Explicit caller choices for profile, browser host, headless mode, executable path, or browser build still win. The shipped Google Sheets policy routes `https://docs.google.com/spreadsheets` through the stealth remote-view lane while leaving Google sign-in seeding on its own policy. + Start with an access plan whenever service identity, profile identity, or browser identity matters: ```bash @@ -546,7 +548,7 @@ Launch-shaping options such as `--args` or `AGENT_BROWSER_ARGS` apply only to co Run `pnpm test:service-status-no-launch` to validate that service status remains read-only when launch defaults such as `AGENT_BROWSER_ARGS` are configured. Run `pnpm test:service-contracts-no-launch` to validate that HTTP `GET /api/service/contracts` returns compatibility metadata without launching or recording a browser, and that `getServiceContracts()` exposes browser capability registry, profile lookup, and readiness client-helper metadata to software clients. -Run `pnpm test:service-profile-lookup-no-launch` to validate that HTTP `GET /api/service/profiles/lookup` selects an authenticated target profile over a target-only profile from seeded temporary service state without launching a browser. The same smoke calls `lookupServiceProfile()` against the live stream server so the software-client helper is covered end to end. Profile collections include `profileSources`, and profile lookup plus access-plan responses include `selectedProfileSource`, so callers can distinguish config, runtime-observed, and persisted profile provenance. Run `pnpm test:service-profile-sources-no-launch` when changing effective profile source metadata. For the broader no-launch recommendation, use `GET /api/service/access-plan`, `getServiceAccessPlan()`, or MCP `agent-browser://access-plan?serviceName=<name>&agentName=<agent>&taskName=<task>&loginId=<id>` so agent-browser can combine profile selection, readiness, site policy, providers, retained challenges, advisory `browserCapabilityEvidence`, caller-label warnings, and the recommended action before a caller requests a tab. Access-plan readiness decisions are scoped to the requested target identities, so an unrelated stale or unseeded login on the same profile does not block the requested site. `browserCapabilityEvidence` reports matching browser hosts, executables, capabilities, profile compatibility rows, preference bindings, and validation evidence from `service.browserCapabilityRegistry`. Preference bindings can set the access-plan browser build recommendation when no explicit request, site policy, or profile browser build has already won; the queued launch path may apply the matching local executable only after host ownership, executable existence, profile compatibility, and validation evidence gates pass. The decision includes `attention`, a UI-neutral intervention summary with whether attention is required, who owns the next step, severity, reason, message, and suggested action tokens. Clients decide whether to show it as a log, prompt, dashboard affordance, or popup. The decision also includes auth provider IDs, challenge provider IDs, challenge strategy, missing challenge-provider capabilities, interaction risk, pacing details derived from site-policy rate limits, launch posture for headed, headless, remote-view, detached-seeding behavior, and `browserBuild` recommendations (`stock_chrome`, `stealthcdp_chromium`, or `cdp_free_headed`). `decision.launchPosture.browserBuildSelection` explains the winning browser build source, evidence source, explicit operator override status, selected preference binding, profile compatibility summary, and validation evidence summary before any browser is launched. The decision also includes `freshnessUpdate` instructions with the selected profile, target identities, HTTP route, MCP tool, and `updateServiceProfileFreshness` helper to use after a bounded auth probe, `postSeedingProbe` instructions with the post-close `verify-seeding` CLI, `runServiceAccessPlanPostSeedingProbe()` and `verifyServiceProfileSeeding()` helpers, and `examples/service-client/post-seeding-probe.mjs` command, `monitorRunDue` instructions with HTTP `POST /api/service/monitors/run-due`, MCP `service_monitors_run_due`, CLI `agent-browser service monitors run-due`, and `runServiceAccessPlanMonitorRunDue()`, and `serviceRequest`, a copyable queued tab-request recipe for HTTP `POST /api/service/request`, MCP `service_request`, and `requestServiceTab()`. `serviceRequest.available` is true when the planned tab request can be queued immediately; `recommendedAfterManualAction` means the same service-owned request should be reused after manual seeding, challenge approval, or provider work completes. Headed access plans include `params.headless=false` in the queued tab request so clients do not accidentally launch true headless Chrome for a headed site policy. Both `query` and `decision` include `namingWarnings` plus `hasNamingWarning` when `serviceName`, `agentName`, or `taskName` is missing. Google, Gmail, Microsoft, and UPS have shipped default site policies when no local policy overrides them. The UPS policy is based on live 2026-05-17 evidence: true headless stealth Chromium failed UPS tracking navigation while headed stealth Chromium loaded the tracking page, so access plans prefer `stealthcdp_chromium` with a remote-view-capable headed host. `sitePolicySource` explains whether the selected policy came from config, persisted state, or a built-in default. Run `pnpm test:service-access-plan-no-launch` when changing that surface; it checks HTTP, MCP, and the service client helper against the same seeded temporary service state without creating browsers or browser-launching jobs. Run `pnpm test:service-site-policy-sources-no-launch` when changing effective site-policy collection source metadata. +Run `pnpm test:service-profile-lookup-no-launch` to validate that HTTP `GET /api/service/profiles/lookup` selects an authenticated target profile over a target-only profile from seeded temporary service state without launching a browser. The same smoke calls `lookupServiceProfile()` against the live stream server so the software-client helper is covered end to end. Profile collections include `profileSources`, and profile lookup plus access-plan responses include `selectedProfileSource`, so callers can distinguish config, runtime-observed, and persisted profile provenance. Run `pnpm test:service-profile-sources-no-launch` when changing effective profile source metadata. For the broader no-launch recommendation, use `GET /api/service/access-plan`, `getServiceAccessPlan()`, or MCP `agent-browser://access-plan?serviceName=<name>&agentName=<agent>&taskName=<task>&loginId=<id>` so agent-browser can combine profile selection, readiness, site policy, providers, retained challenges, advisory `browserCapabilityEvidence`, caller-label warnings, and the recommended action before a caller requests a tab. Access-plan readiness decisions are scoped to the requested target identities, so an unrelated stale or unseeded login on the same profile does not block the requested site. `browserCapabilityEvidence` reports matching browser hosts, executables, capabilities, profile compatibility rows, preference bindings, and validation evidence from `service.browserCapabilityRegistry`. Preference bindings can set the access-plan browser build recommendation when no explicit request, site policy, or profile browser build has already won; the queued launch path may apply the matching local executable only after host ownership, executable existence, profile compatibility, and validation evidence gates pass. Ordinary launch and queued tab paths consume `service.defaultBrowserBuild` through this same resolver unless the caller explicitly supplies a profile, browser host, headless mode, executable, or browser build. The decision includes `attention`, a UI-neutral intervention summary with whether attention is required, who owns the next step, severity, reason, message, and suggested action tokens. Clients decide whether to show it as a log, prompt, dashboard affordance, or popup. The decision also includes auth provider IDs, challenge provider IDs, challenge strategy, missing challenge-provider capabilities, interaction risk, pacing details derived from site-policy rate limits, launch posture for headed, headless, remote-view, detached-seeding behavior, and `browserBuild` recommendations (`stock_chrome`, `stealthcdp_chromium`, or `cdp_free_headed`). `decision.launchPosture.browserBuildSelection` explains the winning browser build source, evidence source, explicit operator override status, selected preference binding, profile compatibility summary, and validation evidence summary before any browser is launched. The decision also includes `freshnessUpdate` instructions with the selected profile, target identities, HTTP route, MCP tool, and `updateServiceProfileFreshness` helper to use after a bounded auth probe, `postSeedingProbe` instructions with the post-close `verify-seeding` CLI, `runServiceAccessPlanPostSeedingProbe()` and `verifyServiceProfileSeeding()` helpers, and `examples/service-client/post-seeding-probe.mjs` command, `monitorRunDue` instructions with HTTP `POST /api/service/monitors/run-due`, MCP `service_monitors_run_due`, CLI `agent-browser service monitors run-due`, and `runServiceAccessPlanMonitorRunDue()`, and `serviceRequest`, a copyable queued tab-request recipe for HTTP `POST /api/service/request`, MCP `service_request`, and `requestServiceTab()`. `serviceRequest.available` is true when the planned tab request can be queued immediately; `recommendedAfterManualAction` means the same service-owned request should be reused after manual seeding, challenge approval, or provider work completes. Headed access plans include `params.headless=false` in the queued tab request so clients do not accidentally launch true headless Chrome for a headed site policy. Both `query` and `decision` include `namingWarnings` plus `hasNamingWarning` when `serviceName`, `agentName`, or `taskName` is missing. Google Sheets, Google, Gmail, Microsoft, and UPS have shipped default site policies when no local policy overrides them. The UPS policy is based on live 2026-05-17 evidence: true headless stealth Chromium failed UPS tracking navigation while headed stealth Chromium loaded the tracking page, so access plans prefer `stealthcdp_chromium` with a remote-view-capable headed host. The Google Sheets policy applies the same inspectable remote stealth lane for spreadsheet review without changing Google sign-in seeding policy. `sitePolicySource` explains whether the selected policy came from config, persisted state, or a built-in default. Run `pnpm test:service-access-plan-no-launch` when changing that surface; it checks HTTP, MCP, and the service client helper against the same seeded temporary service state without creating browsers or browser-launching jobs. Run `pnpm test:service-site-policy-sources-no-launch` when changing effective site-policy collection source metadata. Use `summarizeServiceAccessPlanBrowserBuildSelection()` when software needs the access-plan browser-build explanation as compact routing audit fields. It returns the selected build, source, evidence source, explicit operator override status, selected preference binding, profile compatibility status, validation evidence status, attention flags, and compact string without requiring clients to copy nested `decision.launchPosture.browserBuildSelection` parsing.