From 4598a1e4c03de63896d9953c12584851aebd7d0c Mon Sep 17 00:00:00 2001 From: Lutz Lange Date: Wed, 14 Oct 2026 21:41:09 +0200 Subject: [PATCH 1/4] feat: report the browser PAC per device, with an optional compliance check Browsers have no per-tool LLM setting; enterprises steer browser AI traffic with a PAC file pushed by MDM or GPO. The daemon now reports, per device, the PAC that browser machine policy sets: absent, found with its URL, or differs between browsers. - agent: Chrome, Edge, Chromium and Firefox policy readers for Linux (policy files) and Windows (registry; HKCU only when running as the user); the PAC URL is fetched directly and identified by SHA-256 and size, never its content; policy directories are watched - proto: Inventory.browser_pacs = 3, Inventory.pac_fetches = 4 - controller: migration 0004_browser_pacs; optional browserEgress.compliance.pacUrl in the controller configuration, evaluated at read time (compliant or not, with the reason); PAC versions grouped by URL and hash on the overview - UI: fleet device column, device card and overview tiles; desktop app card - README section, regenerated schemas Co-Authored-By: Claude Opus 5.5 --- README.md | 47 + crates/agent/Cargo.toml | 1 + crates/agent/src/browser_pac.rs | 1302 +++++++++++++++++ crates/agent/src/cli.rs | 41 +- crates/agent/src/daemon.rs | 10 + crates/agent/src/inventory_watch.rs | 39 +- crates/agent/src/lib.rs | 1 + crates/agent/src/llm_proxy.rs | 10 +- crates/agent/src/provider/claude_code/mod.rs | 2 + .../agent/src/provider/claude_desktop/mod.rs | 2 + crates/agent/src/provider/codex/mod.rs | 2 + crates/agent/src/provider/copilot/mod.rs | 2 + crates/agent/src/provider/cursor/mod.rs | 2 + crates/agent/src/provider/grok/mod.rs | 2 + crates/agent/src/provider/ollama/mod.rs | 2 + crates/agent/src/provider/opencode/mod.rs | 2 + crates/agent/src/provider/vscode/mod.rs | 2 + crates/agent/src/reconcile/mod.rs | 10 +- crates/agent/src/remote.rs | 29 + .../migrations/0004_browser_pacs.sql | 24 + crates/controller/src/admin.rs | 77 +- crates/controller/src/browser_pac.rs | 336 +++++ crates/controller/src/database.rs | 317 +++- crates/controller/src/lib.rs | 1 + crates/controller/src/main.rs | 5 + crates/core/src/config.rs | 70 + crates/core/src/model.rs | 118 +- crates/proto/proto/fleet.proto | 32 + .../controller/src/components/BrowserPac.tsx | 108 ++ .../controller/src/components/DeviceTable.tsx | 5 + frontend/controller/src/stories/fixtures.ts | 79 + frontend/controller/src/styles.css | 24 + frontend/controller/src/types.ts | 47 + frontend/controller/src/views/DeviceView.tsx | 108 ++ .../controller/src/views/OverviewView.tsx | 59 + frontend/desktop/src/stories/fixtures.ts | 23 + frontend/desktop/src/types.ts | 18 + frontend/desktop/src/views/ToolsView.tsx | 62 +- frontend/ui/src/tools.tsx | 10 + schema/controller-config.json | 43 + schema/controller-config.md | 3 + 41 files changed, 3051 insertions(+), 26 deletions(-) create mode 100644 crates/agent/src/browser_pac.rs create mode 100644 crates/controller/migrations/0004_browser_pacs.sql create mode 100644 crates/controller/src/browser_pac.rs create mode 100644 frontend/controller/src/components/BrowserPac.tsx diff --git a/README.md b/README.md index 351885f8..ae709fae 100644 --- a/README.md +++ b/README.md @@ -805,6 +805,53 @@ frontendPolicies: The desktop app then shows **Usage**. [`examples/standalone`](examples/standalone) runs this setup. +### Browser PAC + +Browsers have no per-tool LLM setting, so organizations steer browser AI +traffic with a proxy auto-config (PAC) file delivered by MDM or GPO. The daemon +reports which PAC each browser's policy sets: `PAC: absent`, or `PAC: found` +with its URL, one value per device (`differs` when browsers disagree). +`agentdesktop discover`, the desktop app and the controller's device pages show +it. + +It reads: + +- Linux: Chrome, Edge and Chromium policy JSON under `/etc/opt/chrome`, + `/etc/opt/edge`, `/etc/chromium` and `/etc/chromium-browser` (`managed/` and + `recommended/`), and Firefox `policies.json` under `/etc/firefox/policies` + and the install's `distribution/` directory. +- Windows: Chrome and Edge policy under `HKLM\SOFTWARE\Policies\…` (`ProxySettings`, + or the legacy `ProxyMode` and `ProxyPacUrl`; the `Recommended` subkey counts as + user-overridable), Firefox under `HKLM\SOFTWARE\Policies\Mozilla\Firefox\Proxy` + or the install's `distribution\policies.json`. Per-user policy (`HKCU`) is + read only when the daemon runs as the user (`--user`). +- macOS: not yet. + +It sends the browser name, packaging, PAC URL, whether the value is +user-overridable (`recommended`) or locked (Firefox), and the policy source. It +also fetches each PAC URL directly, as a browser would (results are reused for +five minutes), and reports the SHA-256 and size of the body, or why the fetch +failed, never its content: the hash is the PAC's identity. The controller +overview groups devices by PAC URL and hash, so devices that get a different +PAC, or cannot reach it, stand out. It never evaluates the PAC and never +changes browser settings. + +The controller can compare the reported PAC with an expected one. The setting +lives in the controller configuration, not in the configuration sent to +devices, so daemons of any version accept the fleet configuration: + +```yaml +browserEgress: + compliance: + pacUrl: https://pac.corp.example/ai-egress.pac +``` + +The controller evaluates this when a page is loaded and shows each device as +compliant or not compliant with the reason: no PAC, a different URL, browsers +that differ, a policy that cannot be read, or a policy the user can override. +It is a compliance check only. Changing the expected URL takes a controller +restart. + ## Core capabilities - **AI tool discovery:** detect supported developer tools and their versions diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 695324a8..8437ef81 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -84,4 +84,5 @@ windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", + "Win32_System_Registry", ] } diff --git a/crates/agent/src/browser_pac.rs b/crates/agent/src/browser_pac.rs new file mode 100644 index 00000000..f4c6d1b4 --- /dev/null +++ b/crates/agent/src/browser_pac.rs @@ -0,0 +1,1302 @@ +//! The proxy auto-config (PAC) set by browser policy. +//! +//! Browsers have no per-tool LLM setting, so organisations steer browser AI +//! traffic with a PAC file delivered by MDM or GPO. This module reports which +//! PAC each installed browser's machine policy sets, and fetches each PAC URL +//! once to report its identity (SHA-256 and size of the body). It never +//! evaluates the PAC, never reports its content and never writes browser +//! settings. +//! +//! Policy is read on Linux (JSON policy files) and Windows (registry, plus +//! Firefox `policies.json`). macOS (managed preferences) reports nothing yet. + +#[cfg(any(target_os = "linux", windows, test))] +use std::path::Path; +use std::path::PathBuf; + +use std::time::Duration; + +use agentdesktop_core::model::{BrowserPac, PacFetch}; +#[cfg(any(target_os = "linux", windows, test))] +use serde_json::Value; +use sha2::{Digest, Sha256}; + +/// At most this many distinct PAC URLs are fetched per scan. +const MAX_PAC_FETCHES: usize = 4; +/// A PAC is a small script; larger bodies are not hashed. +const MAX_PAC_BYTES: usize = 1024 * 1024; +const PAC_FETCH_TIMEOUT: Duration = Duration::from_secs(5); +/// A fetched PAC's identity is reused for this long, so inventory refreshes +/// triggered by unrelated file changes don't hit the PAC server each time. +const PAC_FETCH_CACHE_TTL: Duration = Duration::from_secs(5 * 60); + +type PacFetchCache = + std::sync::Mutex>; + +fn pac_fetch_cache() -> &'static PacFetchCache { + static CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); + CACHE.get_or_init(Default::default) +} + +/// Fetches each distinct PAC URL the browsers use and reports its identity. +/// +/// Browsers fetch the PAC directly, so the system proxy settings are ignored +/// here too. The body is hashed and dropped; only `http` and `https` URLs +/// are fetched. +pub(crate) async fn fetch_pacs(browsers: &[BrowserPac]) -> Vec { + let mut urls: Vec = browsers + .iter() + .filter_map(|browser| browser.pac_url.clone()) + .collect(); + urls.sort_unstable(); + urls.dedup(); + urls.truncate(MAX_PAC_FETCHES); + let now = std::time::Instant::now(); + let mut fetches = Vec::new(); + let mut pending = Vec::new(); + { + let cache = pac_fetch_cache().lock().expect("PAC fetch cache lock"); + for url in urls { + match cache.get(&url) { + Some((at, fetch)) if now.duration_since(*at) < PAC_FETCH_CACHE_TTL => { + fetches.push(fetch.clone()); + } + _ => pending.push(url), + } + } + } + if !pending.is_empty() { + let fresh = fetch_uncached(pending).await; + let mut cache = pac_fetch_cache().lock().expect("PAC fetch cache lock"); + cache.retain(|_, (at, _)| now.duration_since(*at) < PAC_FETCH_CACHE_TTL); + for fetch in &fresh { + cache.insert(fetch.url.clone(), (now, fetch.clone())); + } + fetches.extend(fresh); + } + fetches.sort_by(|left, right| left.url.cmp(&right.url)); + fetches +} + +/// Fetches the URLs concurrently, so a scan waits at most one timeout. +async fn fetch_uncached(urls: Vec) -> Vec { + let client = match reqwest::Client::builder() + .no_proxy() + .timeout(PAC_FETCH_TIMEOUT) + .build() + { + Ok(client) => client, + Err(error) => { + return urls + .into_iter() + .map(|url| PacFetch { + url, + error: Some(crate::reconcile::truncate_detail(format!( + "build HTTP client: {error}" + ))), + ..PacFetch::default() + }) + .collect(); + } + }; + let mut tasks = tokio::task::JoinSet::new(); + for url in urls { + let client = client.clone(); + tasks.spawn(async move { + match fetch_pac(&client, &url).await { + Ok((sha256, size)) => PacFetch { + url, + sha256: Some(sha256), + size: Some(size), + error: None, + }, + Err(error) => PacFetch { + url, + error: Some(crate::reconcile::truncate_detail(error)), + ..PacFetch::default() + }, + } + }); + } + let mut fetches = Vec::new(); + while let Some(result) = tasks.join_next().await { + if let Ok(fetch) = result { + fetches.push(fetch); + } + } + fetches +} + +async fn fetch_pac(client: &reqwest::Client, url: &str) -> Result<(String, u64), String> { + let parsed = reqwest::Url::parse(url).map_err(|error| format!("invalid URL: {error}"))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(format!("not fetched: {} URL", parsed.scheme())); + } + let mut response = client + .get(parsed) + .send() + .await + .and_then(reqwest::Response::error_for_status) + .map_err(|error| error.without_url().to_string())?; + let mut hasher = Sha256::new(); + let mut size = 0usize; + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| error.without_url().to_string())? + { + size += chunk.len(); + if size > MAX_PAC_BYTES { + return Err(format!("larger than {MAX_PAC_BYTES} bytes")); + } + hasher.update(&chunk); + } + let sha256 = hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + Ok((sha256, size as u64)) +} + +/// The browsers' PAC policy on this device. +/// +/// `user_policy`: read per-user policy (Windows `HKCU`) too. Only a daemon +/// running as the user sees that user's hive; a service would read its own. +pub(crate) fn discover(user_policy: bool) -> Vec { + #[cfg(target_os = "linux")] + { + let _ = user_policy; + linux::discover(Path::new("/")) + } + #[cfg(windows)] + { + windows_policy::discover( + &win_registry::WinRegistry, + windows_policy::existing_path, + user_policy, + ) + } + #[cfg(not(any(target_os = "linux", windows)))] + { + let _ = user_policy; + Vec::new() + } +} + +/// Directories holding the policy files [`discover`] reads, for file watching. +pub(crate) fn policy_directories() -> Vec { + #[cfg(target_os = "linux")] + { + linux::policy_directories(Path::new("/")) + } + #[cfg(not(target_os = "linux"))] + { + Vec::new() + } +} + +/// The proxy settings one policy source sets, if any. +#[cfg(any(target_os = "linux", windows, test))] +#[derive(Debug, PartialEq)] +struct ProxyPolicy { + pac_url: Option, +} + +/// Chromium policy: `ProxySettings` (current), else the legacy top-level +/// `ProxyMode` and `ProxyPacUrl`. A PAC URL counts only with +/// `ProxyMode: pac_script`; Chrome ignores `ProxyPacUrl` otherwise. +#[cfg(any(target_os = "linux", windows, test))] +fn chromium_proxy(policy: &Value) -> Option { + let settings = policy.get("ProxySettings").unwrap_or(policy); + let mode = settings.get("ProxyMode").and_then(Value::as_str); + let url = settings.get("ProxyPacUrl").and_then(Value::as_str); + if mode.is_none() && url.is_none() { + return None; + } + Some(ProxyPolicy { + pac_url: (mode == Some("pac_script")) + .then_some(url) + .flatten() + .filter(|url| !url.trim().is_empty()) + .map(str::to_owned), + }) +} + +/// Firefox `policies.json`: the PAC URL (only with `Mode: autoConfig`) and +/// `Locked`, when the file sets `Proxy` at all. +#[cfg(any(target_os = "linux", windows, test))] +fn firefox_proxy(policies: &Value) -> Option<(Option, bool)> { + let proxy = policies.get("policies")?.get("Proxy")?; + let mode = proxy.get("Mode").and_then(Value::as_str); + let pac_url = (mode == Some("autoConfig")) + .then(|| proxy.get("AutoConfigURL").and_then(Value::as_str)) + .flatten() + .filter(|url| !url.trim().is_empty()) + .map(str::to_owned); + let locked = proxy + .get("Locked") + .and_then(Value::as_bool) + .unwrap_or(false); + Some((pac_url, locked)) +} + +#[cfg(any(target_os = "linux", windows, test))] +fn read_json(file: &Path) -> Result { + let contents = std::fs::read_to_string(file) + .map_err(|error| format!("read {}: {error}", file.display()))?; + serde_json::from_str(&contents).map_err(|error| format!("parse {}: {error}", file.display())) +} + +#[cfg(target_os = "linux")] +mod linux { + use super::*; + + /// A Chromium-family browser: install markers and policy roots, each with + /// `managed/` and `recommended/` subdirectories of JSON files. + struct Chromium { + name: &'static str, + installs: &'static [(&'static str, &'static str)], + policy_roots: &'static [&'static str], + } + + const CHROMIUM_FAMILY: &[Chromium] = &[ + Chromium { + name: "chrome", + installs: &[("opt/google/chrome/chrome", "system")], + policy_roots: &["etc/opt/chrome/policies"], + }, + Chromium { + name: "edge", + installs: &[("opt/microsoft/msedge/msedge", "system")], + policy_roots: &["etc/opt/edge/policies"], + }, + Chromium { + name: "chromium", + installs: &[ + ("snap/bin/chromium", "snap"), + ("var/lib/flatpak/app/org.chromium.Chromium", "flatpak"), + ("usr/bin/chromium", "system"), + ("usr/bin/chromium-browser", "system"), + ], + // The snap reads /etc/chromium-browser/policies. + policy_roots: &["etc/chromium/policies", "etc/chromium-browser/policies"], + }, + ]; + + const FIREFOX_INSTALLS: &[(&str, &str)] = &[ + ("snap/bin/firefox", "snap"), + ("var/lib/flatpak/app/org.mozilla.firefox", "flatpak"), + ("usr/lib/firefox/firefox", "system"), + ("usr/lib64/firefox/firefox", "system"), + ("opt/firefox/firefox", "system"), + ]; + + /// Firefox policy files, system-wide first (read by the deb and the + /// snap), then the install's `distribution/`. The first file that sets + /// `Proxy` is reported; Firefox's own precedence between the two is not + /// verified here. + const FIREFOX_POLICY_FILES: &[&str] = &[ + "etc/firefox/policies/policies.json", + "usr/lib/firefox/distribution/policies.json", + "usr/lib64/firefox/distribution/policies.json", + "opt/firefox/distribution/policies.json", + ]; + + pub(super) fn discover(root: &Path) -> Vec { + let mut browsers: Vec = CHROMIUM_FAMILY + .iter() + .filter_map(|browser| chromium(root, browser)) + .collect(); + browsers.extend(firefox(root)); + browsers + } + + pub(super) fn policy_directories(root: &Path) -> Vec { + let mut directories: Vec = CHROMIUM_FAMILY + .iter() + .flat_map(|browser| browser.policy_roots.iter()) + .flat_map(|policies| { + ["managed", "recommended"].map(|level| root.join(policies).join(level)) + }) + .collect(); + directories.extend( + FIREFOX_POLICY_FILES + .iter() + .filter_map(|file| root.join(file).parent().map(Path::to_path_buf)), + ); + // A directory an MDM creates later is caught through its nearest + // existing parent below /etc; the rescan then watches it directly. + let stop = [root.join("etc"), root.to_path_buf()]; + let mut watched: Vec = directories + .into_iter() + .filter_map(|directory| { + directory + .ancestors() + .find(|ancestor| ancestor.is_dir()) + .filter(|existing| !stop.iter().any(|stop| stop == existing)) + .map(Path::to_path_buf) + }) + .collect(); + watched.sort(); + watched.dedup(); + watched + } + + fn packaging(root: &Path, installs: &[(&str, &'static str)]) -> Option<&'static str> { + installs + .iter() + .find(|(path, _)| root.join(path).exists()) + .map(|(_, packaging)| *packaging) + } + + fn chromium(root: &Path, browser: &Chromium) -> Option { + let packaging = packaging(root, browser.installs); + let mut row = BrowserPac { + browser: browser.name.to_owned(), + packaging: packaging.unwrap_or_default().to_owned(), + ..BrowserPac::default() + }; + // Managed policy wins; recommended policy is user-overridable. + let mut found_policy = false; + let mut unreadable = Vec::new(); + for (level, recommended) in [("managed", false), ("recommended", true)] { + let directories: Vec = browser + .policy_roots + .iter() + .map(|policies| root.join(policies).join(level)) + .collect(); + match chromium_level(&directories, &mut unreadable) { + Ok(None) => continue, + Ok(Some((policy, source))) => { + row.pac_url = policy.pac_url; + row.recommended = recommended; + row.source = Some(source); + } + Err(detail) => row.detail = Some(detail), + } + found_policy = true; + break; + } + // Chrome skips policy files it can't parse; report them without + // letting them hide a valid proxy policy in another file. + if !unreadable.is_empty() && row.detail.is_none() { + row.detail = Some(format!("skipped {}", unreadable.join("; "))); + found_policy = true; + } + row.detail = row.detail.map(crate::reconcile::truncate_detail); + (packaging.is_some() || found_policy).then_some(row) + } + + /// Reads every `*.json` in the directories of one level. Two files that + /// set different proxy values are reported as a conflict rather than + /// guessing Chrome's merge order; unreadable files are collected in + /// `unreadable` and skipped. + fn chromium_level( + directories: &[PathBuf], + unreadable: &mut Vec, + ) -> Result, String> { + let mut found: Option<(ProxyPolicy, PathBuf)> = None; + for directory in directories { + let Ok(entries) = std::fs::read_dir(directory) else { + continue; + }; + let mut files: Vec = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .is_some_and(|extension| extension == "json") + }) + .collect(); + files.sort(); + for file in files { + let value = match read_json(&file) { + Ok(value) => value, + Err(error) => { + unreadable.push(error); + continue; + } + }; + let Some(policy) = chromium_proxy(&value) else { + continue; + }; + match &found { + Some((existing, first)) if *existing != policy => { + return Err(format!( + "conflicting proxy policy in {} and {}", + first.display(), + file.display() + )); + } + Some(_) => {} + None => found = Some((policy, file)), + } + } + } + Ok(found) + } + + fn firefox(root: &Path) -> Option { + let packaging = packaging(root, FIREFOX_INSTALLS); + let mut row = BrowserPac { + browser: "firefox".to_owned(), + packaging: packaging.unwrap_or_default().to_owned(), + ..BrowserPac::default() + }; + let mut found_policy = false; + for file in FIREFOX_POLICY_FILES.iter().map(|file| root.join(file)) { + if !file.is_file() { + continue; + } + let value = match read_json(&file) { + Ok(value) => value, + Err(detail) => { + row.detail = Some(crate::reconcile::truncate_detail(detail)); + found_policy = true; + break; + } + }; + let Some((pac_url, locked)) = firefox_proxy(&value) else { + continue; + }; + row.pac_url = pac_url; + row.locked = locked; + row.source = Some(file); + found_policy = true; + break; + } + (packaging.is_some() || found_policy).then_some(row) + } + + #[cfg(test)] + mod tests { + use std::fs; + + use super::*; + + fn temp_root(name: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "agentdesktop-browser-pac-{name}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + root + } + + fn write(root: &Path, path: &str, contents: &str) { + let path = root.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); + } + + const PAC: &str = "http://127.0.0.1:8088/ai-egress.pac"; + + #[test] + fn lab_policies_report_the_pac_for_chrome_and_firefox() { + // The files placed on the lab VM (MDM stand-in). + let root = temp_root("lab"); + write(&root, "opt/google/chrome/chrome", ""); + write(&root, "snap/bin/firefox", ""); + write( + &root, + "etc/opt/chrome/policies/managed/agentdesktop-egress.json", + &format!( + r#"{{"ProxySettings":{{"ProxyMode":"pac_script","ProxyPacUrl":"{PAC}"}},"QuicAllowed":false}}"# + ), + ); + write( + &root, + "etc/firefox/policies/policies.json", + &format!( + r#"{{"policies":{{"Proxy":{{"Mode":"autoConfig","AutoConfigURL":"{PAC}","Locked":true}}}}}}"# + ), + ); + let browsers = discover(&root); + assert_eq!(browsers.len(), 2); + let chrome = &browsers[0]; + assert_eq!(chrome.browser, "chrome"); + assert_eq!(chrome.packaging, "system"); + assert_eq!(chrome.pac_url.as_deref(), Some(PAC)); + assert!(!chrome.recommended); + assert_eq!( + chrome.source.as_deref(), + Some( + root.join("etc/opt/chrome/policies/managed/agentdesktop-egress.json") + .as_path() + ) + ); + let firefox = &browsers[1]; + assert_eq!(firefox.browser, "firefox"); + assert_eq!(firefox.packaging, "snap"); + assert_eq!(firefox.pac_url.as_deref(), Some(PAC)); + assert!(firefox.locked); + assert_eq!( + agentdesktop_core::model::DevicePac::from_browsers(&browsers), + agentdesktop_core::model::DevicePac::Found(PAC.to_owned()) + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn installed_browser_without_policy_has_no_pac() { + let root = temp_root("nopolicy"); + write(&root, "opt/google/chrome/chrome", ""); + let browsers = discover(&root); + assert_eq!(browsers.len(), 1); + assert_eq!(browsers[0].pac_url, None); + assert_eq!(browsers[0].source, None); + assert!(discover(&temp_root("empty")).is_empty()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn recommended_policy_is_flagged_and_managed_wins() { + let root = temp_root("levels"); + write( + &root, + "etc/opt/chrome/policies/recommended/p.json", + r#"{"ProxyMode":"pac_script","ProxyPacUrl":"https://user.example/p.pac"}"#, + ); + let browsers = discover(&root); + assert!(browsers[0].recommended); + assert_eq!( + browsers[0].pac_url.as_deref(), + Some("https://user.example/p.pac") + ); + write( + &root, + "etc/opt/chrome/policies/managed/p.json", + &format!( + r#"{{"ProxySettings":{{"ProxyMode":"pac_script","ProxyPacUrl":"{PAC}"}}}}"# + ), + ); + let browsers = discover(&root); + assert!(!browsers[0].recommended); + assert_eq!(browsers[0].pac_url.as_deref(), Some(PAC)); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn non_pac_modes_and_conflicts_report_no_pac_url() { + let root = temp_root("modes"); + // Fixed servers: proxy policy, but no PAC. + write( + &root, + "etc/opt/edge/policies/managed/a.json", + r#"{"ProxySettings":{"ProxyMode":"fixed_servers","ProxyServer":"agw:3128"}}"#, + ); + // A PAC URL without pac_script mode is ignored by Chrome. + write( + &root, + "etc/chromium-browser/policies/managed/a.json", + &format!(r#"{{"ProxyPacUrl":"{PAC}"}}"#), + ); + // Two managed files with different PAC URLs. + write( + &root, + "etc/opt/chrome/policies/managed/a.json", + &format!(r#"{{"ProxyMode":"pac_script","ProxyPacUrl":"{PAC}"}}"#), + ); + write( + &root, + "etc/opt/chrome/policies/managed/b.json", + r#"{"ProxyMode":"pac_script","ProxyPacUrl":"https://other.example/p.pac"}"#, + ); + // Firefox with a system proxy instead of a PAC. + write( + &root, + "etc/firefox/policies/policies.json", + r#"{"policies":{"Proxy":{"Mode":"system"}}}"#, + ); + let browsers = discover(&root); + let by_name = |name: &str| browsers.iter().find(|row| row.browser == name).unwrap(); + assert_eq!(by_name("edge").pac_url, None); + assert!(by_name("edge").source.is_some()); + assert_eq!(by_name("chromium").pac_url, None); + let chrome = by_name("chrome"); + assert_eq!(chrome.pac_url, None); + assert!( + chrome + .detail + .as_deref() + .unwrap() + .contains("conflicting proxy policy") + ); + assert_eq!(by_name("firefox").pac_url, None); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn an_unreadable_file_does_not_hide_a_valid_proxy_policy() { + let root = temp_root("mixed"); + write( + &root, + "etc/opt/chrome/policies/managed/a-broken.json", + "{not json", + ); + write( + &root, + "etc/opt/chrome/policies/managed/egress.json", + &format!(r#"{{"ProxyMode":"pac_script","ProxyPacUrl":"{PAC}"}}"#), + ); + let chrome = &discover(&root)[0]; + assert_eq!(chrome.pac_url.as_deref(), Some(PAC)); + assert!( + chrome + .detail + .as_deref() + .unwrap() + .starts_with("skipped parse") + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn unreadable_policy_is_reported_not_fatal() { + let root = temp_root("broken"); + write( + &root, + "etc/opt/chrome/policies/managed/broken.json", + "{not json", + ); + write(&root, "etc/firefox/policies/policies.json", "["); + let browsers = discover(&root); + assert_eq!(browsers.len(), 2); + assert!( + browsers + .iter() + .all(|row| row.pac_url.is_none() && row.detail.is_some()) + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn policy_directories_are_the_existing_ones() { + let root = temp_root("dirs"); + write(&root, "etc/opt/chrome/policies/managed/a.json", "{}"); + write(&root, "etc/firefox/policies/policies.json", "{}"); + assert_eq!( + policy_directories(&root), + vec![ + // existing parent of recommended/ and of edge's tree + root.join("etc/firefox/policies"), + root.join("etc/opt"), + root.join("etc/opt/chrome/policies"), + root.join("etc/opt/chrome/policies/managed"), + ] + ); + fs::remove_dir_all(root).unwrap(); + } + } +} + +#[cfg(test)] +mod fetch_tests { + use axum::{Router, routing::get}; + + use super::*; + + fn browser(pac_url: &str) -> BrowserPac { + BrowserPac { + browser: "chrome".to_owned(), + pac_url: Some(pac_url.to_owned()), + ..BrowserPac::default() + } + } + + #[tokio::test] + async fn a_fresh_fetch_is_reused_from_the_cache() { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + let hits = Arc::new(AtomicUsize::new(0)); + let counter = hits.clone(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve( + listener, + Router::new().route( + "/cached.pac", + get(move || { + counter.fetch_add(1, Ordering::SeqCst); + async { "function FindProxyForURL() { return \"DIRECT\"; }" } + }), + ), + ) + .await + .unwrap(); + }); + let url = format!("http://{address}/cached.pac"); + let first = fetch_pacs(&[browser(&url)]).await; + let second = fetch_pacs(&[browser(&url)]).await; + assert_eq!(first, second); + assert!(first[0].sha256.is_some()); + assert_eq!(hits.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn fetches_each_distinct_pac_once_and_reports_its_hash() { + const PAC: &str = "function FindProxyForURL(url, host) { return \"DIRECT\"; }\n"; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve( + listener, + Router::new() + .route("/ai.pac", get(|| async { PAC })) + .route("/big.pac", get(|| async { "x".repeat(MAX_PAC_BYTES + 1) })), + ) + .await + .unwrap(); + }); + let ok = format!("http://{address}/ai.pac"); + let missing = format!("http://{address}/missing.pac"); + let big = format!("http://{address}/big.pac"); + let fetches = fetch_pacs(&[ + browser(&ok), + browser(&ok), + browser(&missing), + browser(&big), + browser("file:///etc/ai.pac"), + ]) + .await; + let by_url = |url: &str| fetches.iter().find(|fetch| fetch.url == url).unwrap(); + assert_eq!(fetches.len(), 4); + let expected: String = Sha256::digest(PAC.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + assert_eq!(by_url(&ok).sha256.as_deref(), Some(expected.as_str())); + assert_eq!(by_url(&ok).size, Some(PAC.len() as u64)); + assert!(by_url(&missing).error.as_deref().unwrap().contains("404")); + assert!( + by_url(&big) + .error + .as_deref() + .unwrap() + .contains("larger than") + ); + assert_eq!( + by_url("file:///etc/ai.pac").error.as_deref(), + Some("not fetched: file URL") + ); + assert!(fetch_pacs(&[]).await.is_empty()); + } +} + +/// Windows: Chromium-family and Firefox policy from the registry, and Firefox +/// `policies.json`. The logic sits behind [`windows_policy::Registry`] so it +/// is tested on every platform; only [`win_registry`] touches the registry. +#[cfg(any(windows, test))] +mod windows_policy { + use super::*; + + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] + pub(super) enum Hive { + LocalMachine, + CurrentUser, + } + + impl Hive { + fn prefix(self) -> &'static str { + match self { + Self::LocalMachine => "HKLM", + Self::CurrentUser => "HKCU", + } + } + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub(super) enum RegValue { + String(String), + Dword(u32), + } + + /// Read access to registry values (`REG_SZ`/`REG_EXPAND_SZ` and `REG_DWORD`). + pub(super) trait Registry { + fn value(&self, hive: Hive, key: &str, name: &str) -> Option; + } + + /// A path below a known folder: (environment variable, relative path). + type KnownPath = (&'static str, &'static str); + + struct Chromium { + name: &'static str, + installs: &'static [KnownPath], + policy_key: &'static str, + } + + // %LOCALAPPDATA% is the daemon's own profile: a per-user install is seen + // by a daemon running as that user, not by the service. + const CHROMIUM_FAMILY: &[Chromium] = &[ + Chromium { + name: "chrome", + installs: &[ + ("ProgramFiles", r"Google\Chrome\Application\chrome.exe"), + ("ProgramFiles(x86)", r"Google\Chrome\Application\chrome.exe"), + ("LOCALAPPDATA", r"Google\Chrome\Application\chrome.exe"), + ], + policy_key: r"SOFTWARE\Policies\Google\Chrome", + }, + Chromium { + name: "edge", + installs: &[ + ( + "ProgramFiles(x86)", + r"Microsoft\Edge\Application\msedge.exe", + ), + ("ProgramFiles", r"Microsoft\Edge\Application\msedge.exe"), + ], + policy_key: r"SOFTWARE\Policies\Microsoft\Edge", + }, + ]; + + const FIREFOX_INSTALLS: &[KnownPath] = &[ + ("ProgramFiles", r"Mozilla Firefox\firefox.exe"), + ("ProgramFiles(x86)", r"Mozilla Firefox\firefox.exe"), + ]; + const FIREFOX_POLICY_KEY: &str = r"SOFTWARE\Policies\Mozilla\Firefox\Proxy"; + const FIREFOX_POLICY_FILES: &[KnownPath] = &[ + ( + "ProgramFiles", + r"Mozilla Firefox\distribution\policies.json", + ), + ( + "ProgramFiles(x86)", + r"Mozilla Firefox\distribution\policies.json", + ), + ]; + + /// Machine policy first, then user policy; managed before recommended + /// (the `Recommended` subkey is user-overridable). + const SEARCH: &[(Hive, bool)] = &[ + (Hive::LocalMachine, false), + (Hive::CurrentUser, false), + (Hive::LocalMachine, true), + (Hive::CurrentUser, true), + ]; + + /// The existing file at a known path, resolved from the environment. + #[cfg(windows)] + pub(super) fn existing_path(known: KnownPath) -> Option { + let path = PathBuf::from(std::env::var_os(known.0)?).join(known.1); + path.exists().then_some(path) + } + + /// `user_policy`: also read `HKCU`. Only meaningful when the daemon runs + /// as the signed-in user; a service's `HKCU` is the service account's. + pub(super) fn discover( + registry: &impl Registry, + existing: impl Fn(KnownPath) -> Option, + user_policy: bool, + ) -> Vec { + let hives: &[Hive] = if user_policy { + &[Hive::LocalMachine, Hive::CurrentUser] + } else { + &[Hive::LocalMachine] + }; + let mut browsers: Vec = CHROMIUM_FAMILY + .iter() + .filter_map(|browser| chromium(registry, &existing, hives, browser)) + .collect(); + browsers.extend(firefox(registry, &existing, hives)); + browsers + } + + fn chromium( + registry: &impl Registry, + existing: &impl Fn(KnownPath) -> Option, + hives: &[Hive], + browser: &Chromium, + ) -> Option { + let installed = browser + .installs + .iter() + .any(|known| existing(*known).is_some()); + let mut row = BrowserPac { + browser: browser.name.to_owned(), + packaging: if installed { + "system".to_owned() + } else { + String::new() + }, + ..BrowserPac::default() + }; + for &(hive, recommended) in SEARCH.iter().filter(|(hive, _)| hives.contains(hive)) { + let key = if recommended { + format!(r"{}\Recommended", browser.policy_key) + } else { + browser.policy_key.to_owned() + }; + match chromium_key(registry, hive, &key) { + Ok(None) => continue, + Ok(Some(policy)) => { + row.pac_url = policy.pac_url; + row.recommended = recommended; + } + Err(detail) => row.detail = Some(crate::reconcile::truncate_detail(detail)), + } + row.source = Some(PathBuf::from(format!(r"{}\{key}", hive.prefix()))); + return Some(row); + } + installed.then_some(row) + } + + /// `ProxySettings` is a JSON string value; the legacy `ProxyMode` and + /// `ProxyPacUrl` are plain string values. + fn chromium_key( + registry: &impl Registry, + hive: Hive, + key: &str, + ) -> Result, String> { + let string = |name: &str| match registry.value(hive, key, name) { + Some(RegValue::String(value)) => Some(value), + _ => None, + }; + let policy = match string("ProxySettings") { + Some(json) => serde_json::from_str::(&json) + .map(|settings| serde_json::json!({ "ProxySettings": settings })) + .map_err(|error| { + format!(r"parse {}\{key}\ProxySettings: {error}", hive.prefix()) + })?, + None => { + let mut legacy = serde_json::Map::new(); + for name in ["ProxyMode", "ProxyPacUrl"] { + if let Some(value) = string(name) { + legacy.insert(name.to_owned(), Value::String(value)); + } + } + Value::Object(legacy) + } + }; + Ok(chromium_proxy(&policy)) + } + + /// Registry policy first, then `policies.json` in the install directory. + fn firefox( + registry: &impl Registry, + existing: &impl Fn(KnownPath) -> Option, + hives: &[Hive], + ) -> Option { + let installed = FIREFOX_INSTALLS + .iter() + .any(|known| existing(*known).is_some()); + let mut row = BrowserPac { + browser: "firefox".to_owned(), + packaging: if installed { + "system".to_owned() + } else { + String::new() + }, + ..BrowserPac::default() + }; + for &hive in hives { + let string = |name: &str| match registry.value(hive, FIREFOX_POLICY_KEY, name) { + Some(RegValue::String(value)) => Some(value), + _ => None, + }; + let mode = string("Mode"); + let url = string("AutoConfigURL"); + if mode.is_none() && url.is_none() { + continue; + } + row.pac_url = (mode.as_deref() == Some("autoConfig")) + .then_some(url) + .flatten() + .filter(|url| !url.trim().is_empty()); + row.locked = matches!( + registry.value(hive, FIREFOX_POLICY_KEY, "Locked"), + Some(RegValue::Dword(value)) if value != 0 + ); + row.source = Some(PathBuf::from(format!( + r"{}\{FIREFOX_POLICY_KEY}", + hive.prefix() + ))); + return Some(row); + } + for file in FIREFOX_POLICY_FILES + .iter() + .filter_map(|known| existing(*known)) + { + match read_json(&file) { + Ok(value) => { + let Some((pac_url, locked)) = firefox_proxy(&value) else { + continue; + }; + row.pac_url = pac_url; + row.locked = locked; + } + Err(detail) => row.detail = Some(crate::reconcile::truncate_detail(detail)), + } + row.source = Some(file); + return Some(row); + } + installed.then_some(row) + } + + #[cfg(test)] + mod tests { + use std::collections::BTreeMap; + + use super::*; + + #[derive(Default)] + struct FakeRegistry(BTreeMap<(Hive, String, String), RegValue>); + + impl FakeRegistry { + fn set(mut self, hive: Hive, key: &str, name: &str, value: RegValue) -> Self { + self.0 + .insert((hive, key.to_owned(), name.to_owned()), value); + self + } + } + + impl Registry for FakeRegistry { + fn value(&self, hive: Hive, key: &str, name: &str) -> Option { + self.0 + .get(&(hive, key.to_owned(), name.to_owned())) + .cloned() + } + } + + const PAC: &str = "https://pac.aks.solo.sademo.de/ai-egress.pac"; + const CHROME: &str = r"SOFTWARE\Policies\Google\Chrome"; + const EDGE: &str = r"SOFTWARE\Policies\Microsoft\Edge"; + + fn installed(known: KnownPath) -> Option { + (known.0 == "ProgramFiles" + && (known.1.ends_with("chrome.exe") || known.1.ends_with("firefox.exe"))) + .then(|| PathBuf::from(r"C:\Program Files").join(known.1)) + } + + fn s(value: &str) -> RegValue { + RegValue::String(value.to_owned()) + } + + #[test] + fn proxy_settings_json_and_firefox_registry_policy() { + let registry = FakeRegistry::default() + .set( + Hive::LocalMachine, + CHROME, + "ProxySettings", + s(&format!( + r#"{{"ProxyMode":"pac_script","ProxyPacUrl":"{PAC}"}}"# + )), + ) + .set( + Hive::LocalMachine, + FIREFOX_POLICY_KEY, + "Mode", + s("autoConfig"), + ) + .set( + Hive::LocalMachine, + FIREFOX_POLICY_KEY, + "AutoConfigURL", + s(PAC), + ) + .set( + Hive::LocalMachine, + FIREFOX_POLICY_KEY, + "Locked", + RegValue::Dword(1), + ); + let browsers = discover(®istry, installed, true); + assert_eq!(browsers.len(), 2); + let chrome = &browsers[0]; + assert_eq!(chrome.browser, "chrome"); + assert_eq!(chrome.packaging, "system"); + assert_eq!(chrome.pac_url.as_deref(), Some(PAC)); + assert!(!chrome.recommended); + assert_eq!( + chrome.source, + Some(PathBuf::from(format!(r"HKLM\{CHROME}"))) + ); + let firefox = &browsers[1]; + assert_eq!(firefox.pac_url.as_deref(), Some(PAC)); + assert!(firefox.locked); + assert_eq!( + agentdesktop_core::model::DevicePac::from_browsers(&browsers), + agentdesktop_core::model::DevicePac::Found(PAC.to_owned()) + ); + } + + #[test] + fn precedence_legacy_values_and_recommended() { + // User policy only, legacy values: found, not recommended. + let registry = FakeRegistry::default() + .set(Hive::CurrentUser, CHROME, "ProxyMode", s("pac_script")) + .set(Hive::CurrentUser, CHROME, "ProxyPacUrl", s(PAC)); + let chrome = &discover(®istry, installed, true)[0]; + assert_eq!(chrome.pac_url.as_deref(), Some(PAC)); + assert_eq!( + chrome.source, + Some(PathBuf::from(format!(r"HKCU\{CHROME}"))) + ); + // Machine policy wins over user policy. + let registry = registry.set( + Hive::LocalMachine, + CHROME, + "ProxySettings", + s(r#"{"ProxyMode":"direct"}"#), + ); + assert_eq!(discover(®istry, installed, true)[0].pac_url, None); + // Only the Recommended subkey: reported as recommended. + let registry = FakeRegistry::default().set( + Hive::LocalMachine, + &format!(r"{CHROME}\Recommended"), + "ProxySettings", + s(&format!( + r#"{{"ProxyMode":"pac_script","ProxyPacUrl":"{PAC}"}}"# + )), + ); + let chrome = &discover(®istry, installed, true)[0]; + assert!(chrome.recommended); + assert_eq!(chrome.pac_url.as_deref(), Some(PAC)); + } + + #[test] + fn a_service_ignores_hkcu() { + let registry = FakeRegistry::default() + .set(Hive::CurrentUser, CHROME, "ProxyMode", s("pac_script")) + .set(Hive::CurrentUser, CHROME, "ProxyPacUrl", s(PAC)) + .set( + Hive::CurrentUser, + FIREFOX_POLICY_KEY, + "Mode", + s("autoConfig"), + ) + .set( + Hive::CurrentUser, + FIREFOX_POLICY_KEY, + "AutoConfigURL", + s(PAC), + ); + let browsers = discover(®istry, installed, false); + assert!( + browsers + .iter() + .all(|row| row.pac_url.is_none() && row.source.is_none()) + ); + assert!( + discover(®istry, installed, true) + .iter() + .all(|row| row.pac_url.as_deref() == Some(PAC)) + ); + } + + #[test] + fn edge_policy_without_install_and_broken_json() { + let registry = FakeRegistry::default() + .set( + Hive::LocalMachine, + EDGE, + "ProxySettings", + s(&format!( + r#"{{"ProxyMode":"pac_script","ProxyPacUrl":"{PAC}"}}"# + )), + ) + .set(Hive::LocalMachine, CHROME, "ProxySettings", s("{not json")); + let browsers = discover(®istry, |_| None, true); + let edge = browsers.iter().find(|row| row.browser == "edge").unwrap(); + assert_eq!(edge.packaging, ""); + assert_eq!(edge.pac_url.as_deref(), Some(PAC)); + let chrome = browsers.iter().find(|row| row.browser == "chrome").unwrap(); + assert_eq!(chrome.pac_url, None); + assert!(chrome.detail.as_deref().unwrap().contains("ProxySettings")); + assert!(browsers.iter().all(|row| row.browser != "firefox")); + } + + #[test] + fn installed_without_policy_reports_no_pac() { + let browsers = discover(&FakeRegistry::default(), installed, true); + assert_eq!(browsers.len(), 2); + assert!( + browsers + .iter() + .all(|row| row.pac_url.is_none() && row.source.is_none()) + ); + } + } +} + +/// The real registry, read-only (`RegGetValueW`). +#[cfg(windows)] +mod win_registry { + use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS}; + use windows_sys::Win32::System::Registry::{ + HKEY, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, REG_DWORD, REG_SZ, RRF_RT_REG_DWORD, + RRF_RT_REG_SZ, RegGetValueW, + }; + + use super::windows_policy::{Hive, RegValue, Registry}; + + pub(super) struct WinRegistry; + + fn wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() + } + + impl Registry for WinRegistry { + fn value(&self, hive: Hive, key: &str, name: &str) -> Option { + let root: HKEY = match hive { + Hive::LocalMachine => HKEY_LOCAL_MACHINE, + Hive::CurrentUser => HKEY_CURRENT_USER, + }; + let key = wide(key); + let name = wide(name); + // RRF_RT_REG_SZ also returns REG_EXPAND_SZ values, expanded, as + // REG_SZ; asking for RRF_RT_REG_EXPAND_SZ without RRF_NOEXPAND + // is rejected with ERROR_INVALID_PARAMETER. + let flags = RRF_RT_REG_SZ | RRF_RT_REG_DWORD; + let mut kind = 0u32; + let mut buffer = vec![0u8; 512]; + loop { + let mut size = buffer.len() as u32; + // SAFETY: the key and name are NUL-terminated UTF-16; the + // buffer is writable for `size` bytes. + let status = unsafe { + RegGetValueW( + root, + key.as_ptr(), + name.as_ptr(), + flags, + &mut kind, + buffer.as_mut_ptr().cast(), + &mut size, + ) + }; + if status == ERROR_MORE_DATA && size as usize > buffer.len() && size <= 1 << 20 { + buffer.resize(size as usize, 0); + continue; + } + if status != ERROR_SUCCESS { + return None; + } + buffer.truncate(size as usize); + return match kind { + REG_DWORD if buffer.len() >= 4 => Some(RegValue::Dword(u32::from_le_bytes([ + buffer[0], buffer[1], buffer[2], buffer[3], + ]))), + REG_SZ => { + let units: Vec = buffer + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .take_while(|unit| *unit != 0) + .collect(); + Some(RegValue::String(String::from_utf16_lossy(&units))) + } + _ => None, + }; + } + } + } +} diff --git a/crates/agent/src/cli.rs b/crates/agent/src/cli.rs index 9ad852f0..024eeac8 100644 --- a/crates/agent/src/cli.rs +++ b/crates/agent/src/cli.rs @@ -4,8 +4,8 @@ use agentdesktop_client as client; use agentdesktop_core::{ config::DaemonConfig, model::{ - Discovery, EnrollmentStatus, Health, LlmGatewayCredential, LlmGatewayLoginStatus, - TelemetryEventKind, + DevicePac, Discovery, EnrollmentStatus, Health, LlmGatewayCredential, + LlmGatewayLoginStatus, TelemetryEventKind, }, }; use clap::Subcommand; @@ -106,11 +106,44 @@ pub async fn run(command: ClientCommand, socket: PathBuf) -> anyhow::Result<()> agent.executable.display() ); } - for runtime in discovery.model_runtimes { - for model in runtime.models { + for runtime in &discovery.model_runtimes { + for model in &runtime.models { println!("{}\tmodel\t{}", runtime.kind, model.name); } } + if !discovery.browser_pacs.is_empty() { + match DevicePac::from_browsers(&discovery.browser_pacs) { + DevicePac::Absent => println!("PAC: absent"), + DevicePac::Found(url) => println!("PAC: found\t{url}"), + DevicePac::Differs => println!("PAC: differs between browsers"), + } + for fetch in &discovery.pac_fetches { + match (&fetch.sha256, &fetch.error) { + (Some(sha256), _) => println!( + "PAC sha256\t{sha256}\t{} bytes\t{}", + fetch.size.unwrap_or_default(), + fetch.url + ), + (None, error) => println!( + "PAC fetch failed\t{}\t{}", + error.as_deref().unwrap_or("unknown error"), + fetch.url + ), + } + } + for browser in &discovery.browser_pacs { + println!( + "{}\tpac\t{}{}", + browser.browser, + browser.pac_url.as_deref().unwrap_or("none"), + if browser.recommended { + "\t(recommended)" + } else { + "" + } + ); + } + } } ClientCommand::Config => { let config: DaemonConfig = client::get(&socket, "/v1/config").await?; diff --git a/crates/agent/src/daemon.rs b/crates/agent/src/daemon.rs index 58c2d4f4..5bb3f8f9 100644 --- a/crates/agent/src/daemon.rs +++ b/crates/agent/src/daemon.rs @@ -841,6 +841,14 @@ fn log_discovery(discovery: &agentdesktop_core::model::Discovery) { "discovered model runtime" ); } + for browser in &discovery.browser_pacs { + tracing::info!( + browser = %browser.browser, + pac_url = browser.pac_url.as_deref().unwrap_or("none"), + recommended = browser.recommended, + "discovered browser PAC policy" + ); + } } /// Re-runs discovery on an interval so the inventory reflects tools installed, @@ -1358,6 +1366,8 @@ mod tests { }) .collect(), model_runtimes: Vec::new(), + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), } } diff --git a/crates/agent/src/inventory_watch.rs b/crates/agent/src/inventory_watch.rs index 0ba87329..a8c5bb97 100644 --- a/crates/agent/src/inventory_watch.rs +++ b/crates/agent/src/inventory_watch.rs @@ -27,6 +27,8 @@ const DEBOUNCE: Duration = Duration::from_millis(250); pub(crate) struct InventoryWatch { debouncer: Debouncer, watched: BTreeMap, + /// Browser policy directories, watched whatever the inventory holds. + policy_directories: fn() -> Vec, } impl InventoryWatch { @@ -58,6 +60,7 @@ impl InventoryWatch { Self { debouncer, watched: BTreeMap::new(), + policy_directories: crate::browser_pac::policy_directories, }, receiver, )) @@ -68,7 +71,7 @@ impl InventoryWatch { /// Called again after every published snapshot, so a tool that gains or /// loses configuration files is followed without restarting the daemon. pub(crate) fn sync(&mut self, discovery: &Discovery) { - let wanted = source_directories(discovery); + let wanted = source_directories(discovery, (self.policy_directories)()); let stale: Vec<_> = self .watched .iter() @@ -114,7 +117,10 @@ impl InventoryWatch { /// already discovered, both register. Executables are deliberately not /// watched: an upgrade changes a version rather than a configuration, and /// install directories are noisy enough to make every scan a wasted one. -fn source_directories(discovery: &Discovery) -> BTreeMap { +fn source_directories( + discovery: &Discovery, + policy_directories: Vec, +) -> BTreeMap { let mut directories = BTreeMap::new(); for agent in &discovery.agents { for server in &agent.mcp_servers { @@ -136,6 +142,12 @@ fn source_directories(discovery: &Discovery) -> BTreeMap } } } + // Browser policy directories that exist, so an MDM push of a PAC policy + // shows up without waiting for the interval. Watched whether or not the + // current inventory has a PAC: the first push is the change that matters. + for directory in policy_directories { + insert(&mut directories, directory, RecursiveMode::NonRecursive); + } directories } @@ -200,6 +212,8 @@ mod tests { skills, }], model_runtimes: Vec::new(), + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), } } @@ -227,7 +241,8 @@ mod tests { let config = root.join("mcp.json"); fs::write(&config, "{}").unwrap(); - let directories = source_directories(&inventory(vec![server(&config)], Vec::new())); + let directories = + source_directories(&inventory(vec![server(&config)], Vec::new()), Vec::new()); assert_eq!(directories.get(&root), Some(&RecursiveMode::NonRecursive)); // The executable's directory is deliberately not watched. @@ -247,7 +262,8 @@ mod tests { let file = nested.join("SKILL.md"); fs::write(&file, "---\nname: review-agent\n---\n").unwrap(); - let directories = source_directories(&inventory(Vec::new(), vec![skill(&file)])); + let directories = + source_directories(&inventory(Vec::new(), vec![skill(&file)]), Vec::new()); assert_eq!(directories.get(&skills), Some(&RecursiveMode::Recursive)); assert!( @@ -263,7 +279,8 @@ mod tests { let file = root.join("SKILL.md"); fs::write(&file, "---\nname: loose\n---\n").unwrap(); - let directories = source_directories(&inventory(Vec::new(), vec![skill(&file)])); + let directories = + source_directories(&inventory(Vec::new(), vec![skill(&file)]), Vec::new()); assert_eq!(directories.get(&root), Some(&RecursiveMode::NonRecursive)); let _ = fs::remove_dir_all(&root); @@ -271,10 +288,13 @@ mod tests { #[test] fn ignores_sources_whose_directory_is_gone() { - let directories = source_directories(&inventory( - vec![server(Path::new("/nonexistent-agentdesktop/mcp.json"))], + let directories = source_directories( + &inventory( + vec![server(Path::new("/nonexistent-agentdesktop/mcp.json"))], + Vec::new(), + ), Vec::new(), - )); + ); assert!(directories.is_empty()); } @@ -289,6 +309,7 @@ mod tests { fs::write(second.join("mcp.json"), "{}").unwrap(); let (mut watch, _changes) = InventoryWatch::new().expect("create watcher"); + watch.policy_directories = Vec::new; watch.sync(&inventory( vec![server(&first.join("mcp.json"))], @@ -320,6 +341,7 @@ mod tests { fs::write(&config, "{}").unwrap(); let (mut watch, mut changes) = InventoryWatch::new().expect("create watcher"); + watch.policy_directories = Vec::new; watch.sync(&inventory(vec![server(&config)], Vec::new())); fs::write(&config, r#"{"mcpServers":{}}"#).unwrap(); @@ -341,6 +363,7 @@ mod tests { fs::write(&config, "{}").unwrap(); let (mut watch, mut changes) = InventoryWatch::new().expect("create watcher"); + watch.policy_directories = Vec::new; watch.sync(&inventory(vec![server(&config)], Vec::new())); for index in 0..25 { diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 1c5ef6f6..7937b8c2 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -1,5 +1,6 @@ pub mod anthropic_oauth; pub mod api; +mod browser_pac; pub mod cli; pub mod daemon; pub mod enrollment; diff --git a/crates/agent/src/llm_proxy.rs b/crates/agent/src/llm_proxy.rs index 22f630a7..0aef1835 100644 --- a/crates/agent/src/llm_proxy.rs +++ b/crates/agent/src/llm_proxy.rs @@ -1402,7 +1402,7 @@ mod tests { "refreshToken": null, "refreshExpiresAt": 0, }).to_string()).unwrap(); let config = parse_daemon(&format!("llmGateway:\n url: http://{address}/gateway\n githubOAuth:\n clientId: github-test\n authentication:\n type: oidc\n issuer: https://issuer.example/\n clientId: proxy-test\n")).unwrap(); - let (_, discovery) = watch::channel(Arc::new(Discovery { agents: vec![], model_runtimes: vec![] })); + let (_, discovery) = watch::channel(Arc::new(Discovery { agents: vec![], model_runtimes: vec![], browser_pacs: vec![], pac_fetches: vec![] })); let state = AppState { controller_status: None, config, @@ -2026,6 +2026,8 @@ mod tests { let (_, discovery) = watch::channel(Arc::new(Discovery { agents: vec![], model_runtimes: vec![], + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), })); let state = AppState { controller_status: None, @@ -2415,6 +2417,8 @@ mod tests { let (_, discovery) = watch::channel(Arc::new(Discovery { agents: vec![], model_runtimes: vec![], + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), })); let state = AppState { controller_status: None, @@ -2593,6 +2597,8 @@ mod tests { let (_, discovery) = watch::channel(Arc::new(Discovery { agents: vec![], model_runtimes: vec![], + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), })); let state = AppState { controller_status: None, @@ -2662,6 +2668,8 @@ mod tests { let (_, discovery) = watch::channel(Arc::new(Discovery { agents: vec![], model_runtimes: vec![], + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), })); let state = AppState { controller_status: None, diff --git a/crates/agent/src/provider/claude_code/mod.rs b/crates/agent/src/provider/claude_code/mod.rs index 0c016487..4eee1713 100644 --- a/crates/agent/src/provider/claude_code/mod.rs +++ b/crates/agent/src/provider/claude_code/mod.rs @@ -80,6 +80,8 @@ impl Provider for ClaudeCode { .into_iter() .collect(), model_runtimes: Vec::new(), + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), } } diff --git a/crates/agent/src/provider/claude_desktop/mod.rs b/crates/agent/src/provider/claude_desktop/mod.rs index e6a09e58..7d02fc95 100644 --- a/crates/agent/src/provider/claude_desktop/mod.rs +++ b/crates/agent/src/provider/claude_desktop/mod.rs @@ -41,6 +41,8 @@ impl Provider for ClaudeDesktop { .into_iter() .collect(), model_runtimes: Vec::new(), + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), } } diff --git a/crates/agent/src/provider/codex/mod.rs b/crates/agent/src/provider/codex/mod.rs index 0fceb0c6..350d0115 100644 --- a/crates/agent/src/provider/codex/mod.rs +++ b/crates/agent/src/provider/codex/mod.rs @@ -37,6 +37,8 @@ impl Provider for Codex { Discovery { agents: discovery::discover().into_iter().collect(), model_runtimes: Vec::new(), + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), } } diff --git a/crates/agent/src/provider/copilot/mod.rs b/crates/agent/src/provider/copilot/mod.rs index c461b9a3..1f23a48e 100644 --- a/crates/agent/src/provider/copilot/mod.rs +++ b/crates/agent/src/provider/copilot/mod.rs @@ -48,6 +48,8 @@ impl Provider for Copilot { Discovery { agents: discovery::discover().into_iter().collect(), model_runtimes: Vec::new(), + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), } } diff --git a/crates/agent/src/provider/cursor/mod.rs b/crates/agent/src/provider/cursor/mod.rs index 0877f61f..bc644777 100644 --- a/crates/agent/src/provider/cursor/mod.rs +++ b/crates/agent/src/provider/cursor/mod.rs @@ -21,6 +21,8 @@ impl Provider for Cursor { Discovery { agents: discovery::discover().into_iter().collect(), model_runtimes: Vec::new(), + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), } } } diff --git a/crates/agent/src/provider/grok/mod.rs b/crates/agent/src/provider/grok/mod.rs index b91e24e5..ef1cddce 100644 --- a/crates/agent/src/provider/grok/mod.rs +++ b/crates/agent/src/provider/grok/mod.rs @@ -37,6 +37,8 @@ impl Provider for Grok { Discovery { agents: discovery::discover().into_iter().collect(), model_runtimes: Vec::new(), + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), } } diff --git a/crates/agent/src/provider/ollama/mod.rs b/crates/agent/src/provider/ollama/mod.rs index e05a75b8..0b96c4bd 100644 --- a/crates/agent/src/provider/ollama/mod.rs +++ b/crates/agent/src/provider/ollama/mod.rs @@ -21,6 +21,8 @@ impl Provider for Ollama { Discovery { agents: Vec::new(), model_runtimes: discovery::discover().await.into_iter().collect(), + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), } } } diff --git a/crates/agent/src/provider/opencode/mod.rs b/crates/agent/src/provider/opencode/mod.rs index 9c86ebe9..89a6be35 100644 --- a/crates/agent/src/provider/opencode/mod.rs +++ b/crates/agent/src/provider/opencode/mod.rs @@ -39,6 +39,8 @@ impl Provider for OpenCode { Discovery { agents: discovery::discover().into_iter().collect(), model_runtimes: Vec::new(), + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), } } diff --git a/crates/agent/src/provider/vscode/mod.rs b/crates/agent/src/provider/vscode/mod.rs index 8e5c7d97..b1967dae 100644 --- a/crates/agent/src/provider/vscode/mod.rs +++ b/crates/agent/src/provider/vscode/mod.rs @@ -52,6 +52,8 @@ impl Provider for VsCode { Discovery { agents: discovery::discover().into_iter().collect(), model_runtimes: Vec::new(), + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), } } diff --git a/crates/agent/src/reconcile/mod.rs b/crates/agent/src/reconcile/mod.rs index dee29d4d..79f4f3dc 100644 --- a/crates/agent/src/reconcile/mod.rs +++ b/crates/agent/src/reconcile/mod.rs @@ -122,12 +122,16 @@ impl Reconciler { let mut discovery = Discovery { agents: Vec::new(), model_runtimes: Vec::new(), + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), }; for provider in self.providers.iter() { let found = provider.discover().await; discovery.agents.extend(found.agents); discovery.model_runtimes.extend(found.model_runtimes); } + discovery.browser_pacs = crate::browser_pac::discover(self.context.merge_user_settings); + discovery.pac_fetches = crate::browser_pac::fetch_pacs(&discovery.browser_pacs).await; discovery } @@ -422,7 +426,7 @@ pub(crate) fn outcomes_differ( /// The longest detail an outcome carries, in bytes. const DETAIL_LIMIT: usize = 1024; -fn truncate_detail(mut detail: String) -> String { +pub(crate) fn truncate_detail(mut detail: String) -> String { if detail.len() > DETAIL_LIMIT { let mut end = DETAIL_LIMIT; while !detail.is_char_boundary(end) { @@ -1282,6 +1286,8 @@ programs: agentdesktop_core::model::Discovery { agents: Vec::new(), model_runtimes: Vec::new(), + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), } } @@ -2091,6 +2097,8 @@ programs: agentdesktop_core::model::Discovery { agents: Vec::new(), model_runtimes: Vec::new(), + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), } } diff --git a/crates/agent/src/remote.rs b/crates/agent/src/remote.rs index aac7c2f1..ed60068b 100644 --- a/crates/agent/src/remote.rs +++ b/crates/agent/src/remote.rs @@ -1094,6 +1094,33 @@ async fn send_inventory( .collect(), }) .collect(), + browser_pacs: discovered + .browser_pacs + .iter() + .map(|browser| agentdesktop_proto::fleet::BrowserPac { + browser: browser.browser.clone(), + packaging: browser.packaging.clone(), + pac_url: browser.pac_url.clone().unwrap_or_default(), + recommended: browser.recommended, + locked: browser.locked, + source: browser + .source + .as_ref() + .map(|source| source.display().to_string()) + .unwrap_or_default(), + detail: browser.detail.clone().unwrap_or_default(), + }) + .collect(), + pac_fetches: discovered + .pac_fetches + .iter() + .map(|fetch| agentdesktop_proto::fleet::PacFetch { + url: fetch.url.clone(), + sha256: fetch.sha256.clone().unwrap_or_default(), + size: fetch.size.unwrap_or_default(), + error: fetch.error.clone().unwrap_or_default(), + }) + .collect(), }), ) .await?; @@ -1345,6 +1372,8 @@ mod tests { AgentDiscovery { agents: Vec::new(), model_runtimes: Vec::new(), + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), } } diff --git a/crates/controller/migrations/0004_browser_pacs.sql b/crates/controller/migrations/0004_browser_pacs.sql new file mode 100644 index 00000000..b7ff6b96 --- /dev/null +++ b/crates/controller/migrations/0004_browser_pacs.sql @@ -0,0 +1,24 @@ +CREATE TABLE browser_pacs ( + device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + browser TEXT NOT NULL, + packaging TEXT NOT NULL DEFAULT '', + pac_url TEXT NOT NULL DEFAULT '', + recommended BIGINT NOT NULL DEFAULT 0, + locked BIGINT NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '', + PRIMARY KEY (device_id, browser) +); + +CREATE INDEX browser_pacs_device_id_idx ON browser_pacs(device_id); + +CREATE TABLE pac_fetches ( + device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + url TEXT NOT NULL, + sha256 TEXT NOT NULL DEFAULT '', + size BIGINT NOT NULL DEFAULT 0, + error TEXT NOT NULL DEFAULT '', + PRIMARY KEY (device_id, url) +); + +CREATE INDEX pac_fetches_device_id_idx ON pac_fetches(device_id); diff --git a/crates/controller/src/admin.rs b/crates/controller/src/admin.rs index 3456caa8..f8c84a1f 100644 --- a/crates/controller/src/admin.rs +++ b/crates/controller/src/admin.rs @@ -27,6 +27,8 @@ pub struct AdminState { database: Database, daemon_config: DaemonConfigStore, settings: ControllerSettings, + /// `browserEgress.compliance.pacUrl` from the controller configuration. + expected_pac_url: Option, } #[derive(Clone, Serialize)] @@ -44,11 +46,13 @@ impl AdminState { database: Database, daemon_config: DaemonConfigStore, settings: ControllerSettings, + expected_pac_url: Option, ) -> Self { Self { database, daemon_config, settings, + expected_pac_url, } } } @@ -65,6 +69,24 @@ struct Overview { config_failures: usize, active_revision: Option, recent_devices: Vec, + browser_pac: BrowserPacOverview, +} + +/// Device counts by browser PAC value, and by compliance state when the +/// fleet configuration has a compliance profile. +#[derive(Serialize, Default)] +struct BrowserPacOverview { + found: usize, + absent: usize, + differs: usize, + not_reported: usize, + /// `None` without a compliance profile. + expected_pac_url: Option, + compliant: usize, + not_compliant: usize, + /// Devices that found a PAC, grouped by URL and SHA-256 of what they + /// fetched: more than one group per URL means devices get different PACs. + versions: Vec, } pub async fn serve( @@ -91,8 +113,45 @@ pub async fn serve( Ok(()) } +/// Fills in each device's PAC value and compliance at read time, so a +/// profile change shows without waiting for the next inventory. +fn with_browser_pac(state: &AdminState, mut devices: Vec) -> Vec { + let expected = state.expected_pac_url.as_ref(); + for device in &mut devices { + device.browser_pac = crate::browser_pac::with_identity( + crate::browser_pac::evaluate(&device.browser_pacs, expected), + &device.pac_fetches, + ); + } + devices +} + async fn overview(State(state): State) -> Result, AdminError> { - let devices = state.database.list_devices().await?; + let devices = with_browser_pac(&state, state.database.list_devices().await?); + let mut browser_pac = BrowserPacOverview { + expected_pac_url: state.expected_pac_url.clone().map(String::from), + ..BrowserPacOverview::default() + }; + browser_pac.versions = + crate::browser_pac::pac_versions(devices.iter().map(|device| &device.browser_pac)); + for device in &devices { + match device.browser_pac.state { + "found" => browser_pac.found += 1, + "absent" => browser_pac.absent += 1, + "differs" => browser_pac.differs += 1, + _ => browser_pac.not_reported += 1, + } + match device + .browser_pac + .compliance + .as_ref() + .map(|compliance| compliance.state) + { + Some("compliant") => browser_pac.compliant += 1, + Some("not_compliant") => browser_pac.not_compliant += 1, + _ => {} + } + } let now = unix_time_seconds(); let online_devices = devices .iter() @@ -113,23 +172,31 @@ async fn overview(State(state): State) -> Result, Adm config_failures, active_revision: state.daemon_config.current().map(|config| config.revision), recent_devices: devices.into_iter().take(5).collect(), + browser_pac, })) } async fn devices(State(state): State) -> Result>, AdminError> { - Ok(Json(state.database.list_devices().await?)) + Ok(Json(with_browser_pac( + &state, + state.database.list_devices().await?, + ))) } async fn device( State(state): State, Path(device_id): Path, ) -> Result, AdminError> { - state + let mut detail = state .database .get_device(&device_id) .await? - .map(Json) - .ok_or(AdminError::NotFound) + .ok_or(AdminError::NotFound)?; + detail.device.browser_pac = crate::browser_pac::with_identity( + crate::browser_pac::evaluate(&detail.device.browser_pacs, state.expected_pac_url.as_ref()), + &detail.device.pac_fetches, + ); + Ok(Json(detail)) } async fn delete_device( diff --git a/crates/controller/src/browser_pac.rs b/crates/controller/src/browser_pac.rs new file mode 100644 index 00000000..9b214324 --- /dev/null +++ b/crates/controller/src/browser_pac.rs @@ -0,0 +1,336 @@ +//! The browser PAC value per device and the fleet compliance check. +//! +//! Devices report the PAC their browser policy sets. The device value is shown +//! with or without a profile; the compliance check only runs when the fleet +//! configuration sets `browserEgress.compliance`, and is evaluated here at read +//! time so a profile change shows at once. Agentdesktop never configures +//! browsers from it. + +use std::collections::BTreeMap; + +use agentdesktop_core::model::{BrowserPac, DevicePac, PacFetch}; +use serde::Serialize; +use url::Url; + +/// One PAC value per device, plus the compliance result when a profile is set. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct DeviceBrowserPac { + /// `absent`, `found`, `differs`, or `not_reported` (no browser rows: an + /// older daemon, or no supported browser on the device). + pub state: &'static str, + /// The PAC URL when `state` is `found`. + pub url: Option, + /// SHA-256 of the PAC this device fetched from `url`: its identity. + pub sha256: Option, + /// Body size in bytes, with `sha256`. + pub size: Option, + /// Why the device could not fetch `url`. + pub fetch_error: Option, + /// `None` without a compliance profile in the fleet configuration. + pub compliance: Option, +} + +impl Default for DeviceBrowserPac { + fn default() -> Self { + Self { + state: "not_reported", + url: None, + sha256: None, + size: None, + fetch_error: None, + compliance: None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct PacCompliance { + /// `compliant`, `not_compliant` or `not_reported`. + pub state: &'static str, + /// Why the device is not compliant. + pub reason: Option, +} + +impl PacCompliance { + fn not_compliant(reason: String) -> Self { + Self { + state: "not_compliant", + reason: Some(reason), + } + } +} + +/// The device value, and the compliance check against `expected` when set. +pub fn evaluate(browsers: &[BrowserPac], expected: Option<&Url>) -> DeviceBrowserPac { + if browsers.is_empty() { + return DeviceBrowserPac { + state: "not_reported", + compliance: expected.map(|_| PacCompliance { + state: "not_reported", + reason: None, + }), + ..DeviceBrowserPac::default() + }; + } + let device = DevicePac::from_browsers(browsers); + let (state, url) = match &device { + DevicePac::Absent => ("absent", None), + DevicePac::Found(url) => ("found", Some(url.clone())), + DevicePac::Differs => ("differs", None), + }; + DeviceBrowserPac { + state, + url, + compliance: expected.map(|expected| compliance(browsers, &device, expected)), + ..DeviceBrowserPac::default() + } +} + +/// Adds the identity of the device's PAC from the device's fetch of its URL. +pub fn with_identity(mut pac: DeviceBrowserPac, fetches: &[PacFetch]) -> DeviceBrowserPac { + if let Some(fetch) = pac + .url + .as_deref() + .and_then(|url| fetches.iter().find(|fetch| fetch.url == url)) + { + pac.sha256 = fetch.sha256.clone(); + pac.size = fetch.size; + pac.fetch_error = fetch.error.clone(); + } + pac +} + +/// Devices that found a PAC, grouped by URL and identity, largest group first. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct PacVersion { + pub url: String, + /// `None` for devices that could not fetch the URL (or older daemons). + pub sha256: Option, + pub devices: usize, +} + +pub fn pac_versions<'a>(pacs: impl IntoIterator) -> Vec { + let mut groups: BTreeMap<(String, Option), usize> = BTreeMap::new(); + for pac in pacs { + if let Some(url) = &pac.url { + *groups.entry((url.clone(), pac.sha256.clone())).or_default() += 1; + } + } + let mut versions: Vec = groups + .into_iter() + .map(|((url, sha256), devices)| PacVersion { + url, + sha256, + devices, + }) + .collect(); + versions.sort_by_key(|version| std::cmp::Reverse(version.devices)); + versions +} + +fn compliance(browsers: &[BrowserPac], device: &DevicePac, expected: &Url) -> PacCompliance { + // A browser whose policy could not be read (unreadable or conflicting + // files) explains a missing or differing PAC better than the PAC itself. + if let Some(browser) = browsers + .iter() + .find(|browser| browser.pac_url.is_none() && browser.detail.is_some()) + { + return PacCompliance::not_compliant(format!( + "{} policy: {}", + browser.browser, + browser.detail.as_deref().unwrap_or_default() + )); + } + let url = match device { + DevicePac::Absent => { + return PacCompliance::not_compliant("no PAC in browser policy".to_owned()); + } + DevicePac::Differs => { + return PacCompliance::not_compliant("browsers set different PACs".to_owned()); + } + DevicePac::Found(url) => url, + }; + if !same_url(url, expected) { + return PacCompliance::not_compliant(format!("PAC is {url}, expected {expected}")); + } + // The value must be one the user cannot change. + let overridable: Vec<&str> = browsers + .iter() + .filter(|browser| browser.recommended || (browser.browser == "firefox" && !browser.locked)) + .map(|browser| browser.browser.as_str()) + .collect(); + if !overridable.is_empty() { + return PacCompliance::not_compliant(format!( + "user-overridable policy: {}", + overridable.join(", ") + )); + } + PacCompliance { + state: "compliant", + reason: None, + } +} + +/// Exact match, after URL parsing on both sides so that the profile's +/// normalised form (`https://pac.example/` for `https://pac.example`) matches. +fn same_url(reported: &str, expected: &Url) -> bool { + Url::parse(reported).map_or(reported == expected.as_str(), |reported| { + &reported == expected + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PAC: &str = "https://pac.corp.example/ai.pac"; + + fn browser(name: &str, pac_url: Option<&str>) -> BrowserPac { + BrowserPac { + browser: name.to_owned(), + pac_url: pac_url.map(str::to_owned), + locked: name == "firefox", + ..BrowserPac::default() + } + } + + fn expected() -> Url { + Url::parse(PAC).unwrap() + } + + #[test] + fn device_value_without_a_profile_has_no_verdict() { + let found = evaluate(&[browser("chrome", Some(PAC))], None); + assert_eq!(found.state, "found"); + assert_eq!(found.url.as_deref(), Some(PAC)); + assert_eq!(found.compliance, None); + assert_eq!(evaluate(&[browser("chrome", None)], None).state, "absent"); + assert_eq!(evaluate(&[], None).state, "not_reported"); + } + + #[test] + fn compliant_when_every_browser_sets_the_expected_pac_from_managed_policy() { + let result = evaluate( + &[browser("chrome", Some(PAC)), browser("firefox", Some(PAC))], + Some(&expected()), + ); + assert_eq!( + result.compliance, + Some(PacCompliance { + state: "compliant", + reason: None + }) + ); + } + + #[test] + fn not_compliant_reasons() { + let expected = expected(); + let reason = |browsers: &[BrowserPac]| { + let compliance = evaluate(browsers, Some(&expected)).compliance.unwrap(); + assert_eq!(compliance.state, "not_compliant"); + compliance.reason.unwrap() + }; + assert_eq!( + reason(&[browser("chrome", None)]), + "no PAC in browser policy" + ); + assert_eq!( + reason(&[browser("chrome", Some(PAC)), browser("firefox", None)]), + "browsers set different PACs" + ); + assert_eq!( + reason(&[browser("chrome", Some("https://old.example/ai.pac"))]), + "PAC is https://old.example/ai.pac, expected https://pac.corp.example/ai.pac" + ); + let mut recommended = browser("chrome", Some(PAC)); + recommended.recommended = true; + let mut unlocked = browser("firefox", Some(PAC)); + unlocked.locked = false; + assert_eq!( + reason(&[recommended, unlocked]), + "user-overridable policy: chrome, firefox" + ); + } + + #[test] + fn a_policy_read_error_is_the_reason() { + let mut broken = browser("chrome", None); + broken.detail = Some("conflicting proxy policy in a.json and b.json".to_owned()); + let reason = evaluate(&[broken], Some(&expected())) + .compliance + .unwrap() + .reason + .unwrap(); + assert_eq!( + reason, + "chrome policy: conflicting proxy policy in a.json and b.json" + ); + } + + #[test] + fn no_rows_with_a_profile_is_not_reported() { + let result = evaluate(&[], Some(&expected())); + assert_eq!(result.compliance.unwrap().state, "not_reported"); + } + + #[test] + fn identity_comes_from_the_fetch_of_the_device_url() { + let fetches = [ + PacFetch { + url: PAC.to_owned(), + sha256: Some("aa".repeat(32)), + size: Some(10), + error: None, + }, + PacFetch { + url: "https://other.example/p.pac".to_owned(), + error: Some("timed out".to_owned()), + ..PacFetch::default() + }, + ]; + let found = with_identity(evaluate(&[browser("chrome", Some(PAC))], None), &fetches); + assert_eq!(found.sha256, Some("aa".repeat(32))); + assert_eq!(found.size, Some(10)); + let failed = with_identity( + evaluate( + &[browser("chrome", Some("https://other.example/p.pac"))], + None, + ), + &fetches, + ); + assert_eq!(failed.sha256, None); + assert_eq!(failed.fetch_error.as_deref(), Some("timed out")); + let absent = with_identity(evaluate(&[browser("chrome", None)], None), &fetches); + assert_eq!(absent.sha256, None); + } + + #[test] + fn versions_group_devices_by_url_and_hash() { + let pac = |sha: Option<&str>| DeviceBrowserPac { + state: "found", + url: Some(PAC.to_owned()), + sha256: sha.map(str::to_owned), + ..DeviceBrowserPac::default() + }; + let devices = [ + pac(Some("aa")), + pac(Some("aa")), + pac(Some("bb")), + pac(None), + DeviceBrowserPac::default(), + ]; + let versions = pac_versions(&devices); + assert_eq!(versions.len(), 3); + assert_eq!(versions[0].sha256.as_deref(), Some("aa")); + assert_eq!(versions[0].devices, 2); + } + + #[test] + fn url_comparison_uses_the_parsed_form() { + let expected = Url::parse("https://pac.corp.example").unwrap(); + assert!(same_url("https://pac.corp.example", &expected)); + assert!(same_url("https://pac.corp.example/", &expected)); + assert!(!same_url("https://pac.corp.example/other.pac", &expected)); + } +} diff --git a/crates/controller/src/database.rs b/crates/controller/src/database.rs index 02d9b727..037f7e1f 100644 --- a/crates/controller/src/database.rs +++ b/crates/controller/src/database.rs @@ -2,7 +2,7 @@ use anyhow::Context; use sqlx::{AnyPool, any::AnyPoolOptions}; use std::{collections::BTreeMap, path::PathBuf}; -use agentdesktop_core::model::{LocalModel, McpServer, ModelRuntime, Skill}; +use agentdesktop_core::model::{BrowserPac, LocalModel, McpServer, ModelRuntime, PacFetch, Skill}; use agentdesktop_proto::fleet::{ ConfigStatus, Hello, Inventory, ProgramState, TelemetryEvent, telemetry_event, }; @@ -37,6 +37,15 @@ pub struct DeviceSummary { pub config_updated_at: Option, pub discovery_count: i64, pub installed_tools: Vec, + /// The PAC browser policy sets and, with a fleet compliance profile, the + /// compliance result. Filled in by the admin API at read time. + pub browser_pac: crate::browser_pac::DeviceBrowserPac, + /// The reported browser rows `browser_pac` is derived from. + #[serde(skip)] + pub browser_pacs: Vec, + /// The reported PAC fetches (identity per PAC URL). + #[serde(skip)] + pub pac_fetches: Vec, } #[derive(sqlx::FromRow)] @@ -75,6 +84,9 @@ impl From for DeviceSummary { config_updated_at: row.config_updated_at, discovery_count: row.discovery_count, installed_tools: Vec::new(), + browser_pac: Default::default(), + browser_pacs: Vec::new(), + pac_fetches: Vec::new(), } } } @@ -118,6 +130,10 @@ pub struct DeviceDetail { pub device: DeviceSummary, pub discoveries: Vec, pub model_runtimes: Vec, + /// The PAC each browser's policy sets, as reported. + pub browser_pacs: Vec, + /// One fetch per PAC URL: SHA-256 and size of the body, or the error. + pub pac_fetches: Vec, pub recent_events: Vec, /// Per-program configuration status, in the order the agent reported it. pub programs: Vec, @@ -156,6 +172,35 @@ pub(crate) fn validate_program_status(status: &ConfigStatus) -> anyhow::Result<( Ok(()) } +/// Bound on browser rows per device; a device has a handful of browsers. +const MAX_BROWSER_PACS: usize = 16; + +/// Bounds on a reported browser row, as for program status. Rows outside +/// them are not stored. +fn accepted_browser_pac(browser: &agentdesktop_proto::fleet::BrowserPac) -> bool { + !browser.browser.is_empty() + && browser.browser.len() <= 32 + && browser + .browser + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && browser.packaging.len() <= 32 + && browser.pac_url.len() <= 2048 + && browser.source.len() <= 4096 + && browser.detail.len() <= 2048 +} + +/// Bounds on a reported PAC fetch: a URL, a hex SHA-256 or an error. +fn accepted_pac_fetch(fetch: &agentdesktop_proto::fleet::PacFetch) -> bool { + !fetch.url.is_empty() + && fetch.url.len() <= 2048 + && (fetch.sha256.is_empty() + || (fetch.sha256.len() == 64 + && fetch.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()))) + && fetch.error.len() <= 2048 + && fetch.size <= 1024 * 1024 +} + /// The stored name of a program state; values this controller does not know /// are `unspecified`. fn program_state_name(state: i64) -> &'static str { @@ -197,6 +242,82 @@ pub struct ProgramStatusRecord { pub updated_at: i64, } +/// One reported browser row, as served by the admin API. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct BrowserPacRecord { + pub browser: String, + pub packaging: String, + pub pac_url: Option, + pub recommended: bool, + pub locked: bool, + pub source: Option, + pub detail: Option, +} + +impl From<&BrowserPac> for BrowserPacRecord { + fn from(browser: &BrowserPac) -> Self { + Self { + browser: browser.browser.clone(), + packaging: browser.packaging.clone(), + pac_url: browser.pac_url.clone(), + recommended: browser.recommended, + locked: browser.locked, + source: browser + .source + .as_ref() + .map(|source| source.display().to_string()), + detail: browser.detail.clone(), + } + } +} + +#[derive(sqlx::FromRow)] +struct BrowserPacRow { + device_id: String, + browser: String, + packaging: String, + pac_url: String, + recommended: i64, + locked: i64, + source: String, + detail: String, +} + +impl From for BrowserPac { + fn from(row: BrowserPacRow) -> Self { + Self { + browser: row.browser, + packaging: row.packaging, + pac_url: (!row.pac_url.is_empty()).then_some(row.pac_url), + recommended: row.recommended != 0, + locked: row.locked != 0, + source: (!row.source.is_empty()).then(|| PathBuf::from(row.source)), + detail: (!row.detail.is_empty()).then_some(row.detail), + } + } +} + +#[derive(sqlx::FromRow)] +struct PacFetchRow { + device_id: String, + url: String, + sha256: String, + size: i64, + error: String, +} + +impl From for PacFetch { + fn from(row: PacFetchRow) -> Self { + let fetched = !row.sha256.is_empty(); + Self { + url: row.url, + sha256: fetched.then_some(row.sha256), + size: fetched.then_some(row.size.max(0) as u64), + error: (!row.error.is_empty()).then_some(row.error), + } + } +} + #[derive(sqlx::FromRow)] struct ModelRuntimeRow { kind: String, @@ -346,12 +467,85 @@ impl Database { for (device_id, kind) in discovered_tools { tools_by_device.entry(device_id).or_default().push(kind); } + let mut pacs_by_device = self.browser_pacs(None).await?; + let mut fetches_by_device = self.pac_fetches(None).await?; for device in &mut devices { device.installed_tools = tools_by_device.remove(&device.id).unwrap_or_default(); + device.browser_pacs = pacs_by_device.remove(&device.id).unwrap_or_default(); + device.pac_fetches = fetches_by_device.remove(&device.id).unwrap_or_default(); } Ok(devices) } + /// Reported PAC fetches, per device; one device when `device_id` is set. + async fn pac_fetches( + &self, + device_id: Option<&str>, + ) -> anyhow::Result>> { + let rows: Vec = match device_id { + Some(device_id) => { + sqlx::query_as( + "SELECT device_id, url, sha256, size, error + FROM pac_fetches WHERE device_id = $1 ORDER BY url", + ) + .bind(device_id) + .fetch_all(&self.pool) + .await + } + None => { + sqlx::query_as( + "SELECT device_id, url, sha256, size, error + FROM pac_fetches ORDER BY device_id, url", + ) + .fetch_all(&self.pool) + .await + } + } + .context("load PAC fetches")?; + let mut by_device: BTreeMap> = BTreeMap::new(); + for row in rows { + by_device + .entry(row.device_id.clone()) + .or_default() + .push(row.into()); + } + Ok(by_device) + } + + /// Reported browser PAC rows, per device; one device when `device_id` is set. + async fn browser_pacs( + &self, + device_id: Option<&str>, + ) -> anyhow::Result>> { + let rows: Vec = + match device_id { + Some(device_id) => sqlx::query_as( + "SELECT device_id, browser, packaging, pac_url, recommended, locked, source, + detail + FROM browser_pacs WHERE device_id = $1 ORDER BY browser", + ) + .bind(device_id) + .fetch_all(&self.pool) + .await, + None => sqlx::query_as( + "SELECT device_id, browser, packaging, pac_url, recommended, locked, source, + detail + FROM browser_pacs ORDER BY device_id, browser", + ) + .fetch_all(&self.pool) + .await, + } + .context("load browser PAC policy")?; + let mut by_device: BTreeMap> = BTreeMap::new(); + for row in rows { + by_device + .entry(row.device_id.clone()) + .or_default() + .push(row.into()); + } + Ok(by_device) + } + pub async fn get_device(&self, device_id: &str) -> anyhow::Result> { let device: Option = sqlx::query_as( "SELECT d.id, d.hostname, d.os, d.architecture, d.agent_version, @@ -403,6 +597,18 @@ impl Database { .iter() .map(|discovery: &DeviceDiscovery| discovery.kind.clone()) .collect(); + device.browser_pacs = self + .browser_pacs(Some(device_id)) + .await? + .remove(device_id) + .unwrap_or_default(); + let browser_pacs = device.browser_pacs.iter().map(Into::into).collect(); + device.pac_fetches = self + .pac_fetches(Some(device_id)) + .await? + .remove(device_id) + .unwrap_or_default(); + let pac_fetches = device.pac_fetches.clone(); let recent_events = self.recent_telemetry(device_id, 50).await?; let programs_reported: Option = sqlx::query_scalar( "SELECT programs_reported FROM device_config_status WHERE device_id = $1", @@ -433,6 +639,8 @@ impl Database { device, discoveries, model_runtimes, + browser_pacs, + pac_fetches, recent_events, programs, programs_reported: programs_reported.map(|reported| reported != 0), @@ -551,6 +759,56 @@ impl Database { .execute(&mut *transaction) .await?; } + sqlx::query("DELETE FROM browser_pacs WHERE device_id = $1") + .bind(device_id) + .execute(&mut *transaction) + .await?; + for browser in inventory + .browser_pacs + .iter() + .filter(|browser| accepted_browser_pac(browser)) + .take(MAX_BROWSER_PACS) + { + sqlx::query( + "INSERT INTO browser_pacs + (device_id, browser, packaging, pac_url, recommended, locked, source, detail) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (device_id, browser) DO NOTHING", + ) + .bind(device_id) + .bind(&browser.browser) + .bind(&browser.packaging) + .bind(&browser.pac_url) + .bind(i64::from(browser.recommended)) + .bind(i64::from(browser.locked)) + .bind(&browser.source) + .bind(&browser.detail) + .execute(&mut *transaction) + .await?; + } + sqlx::query("DELETE FROM pac_fetches WHERE device_id = $1") + .bind(device_id) + .execute(&mut *transaction) + .await?; + for fetch in inventory + .pac_fetches + .iter() + .filter(|fetch| accepted_pac_fetch(fetch)) + .take(MAX_BROWSER_PACS) + { + sqlx::query( + "INSERT INTO pac_fetches (device_id, url, sha256, size, error) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (device_id, url) DO NOTHING", + ) + .bind(device_id) + .bind(&fetch.url) + .bind(&fetch.sha256) + .bind(fetch.size as i64) + .bind(&fetch.error) + .execute(&mut *transaction) + .await?; + } transaction.commit().await?; Ok(()) } @@ -741,11 +999,12 @@ mod tests { use std::{collections::BTreeMap, path::PathBuf}; use agentdesktop_proto::fleet::{ - ConfigState, ConfigStatus, Discovery, Inventory, LocalModel, McpServer, ModelRuntime, - ProgramState, ProgramStatus, Skill, TelemetryEvent, ToolUseEvent, telemetry_event, + BrowserPac, ConfigState, ConfigStatus, Discovery, Inventory, LocalModel, McpServer, + ModelRuntime, PacFetch, ProgramState, ProgramStatus, Skill, TelemetryEvent, ToolUseEvent, + telemetry_event, }; - use super::Database; + use super::{BrowserPacRecord, Database}; #[tokio::test] async fn persists_device_inventory_and_telemetry() { @@ -795,6 +1054,32 @@ mod tests { name: "qwen3:8b".to_owned(), }], }], + browser_pacs: vec![ + BrowserPac { + browser: "chrome".to_owned(), + packaging: "system".to_owned(), + pac_url: "https://pac.corp.example/ai.pac".to_owned(), + source: "/etc/opt/chrome/policies/managed/egress.json".to_owned(), + ..Default::default() + }, + BrowserPac { + browser: "INVALID name".to_owned(), + ..Default::default() + }, + ], + pac_fetches: vec![ + PacFetch { + url: "https://pac.corp.example/ai.pac".to_owned(), + sha256: "ab".repeat(32), + size: 435, + error: String::new(), + }, + PacFetch { + url: "https://bad.example/p.pac".to_owned(), + sha256: "not-hex".to_owned(), + ..Default::default() + }, + ], }, ) .await @@ -833,6 +1118,30 @@ mod tests { "llm-research" ); assert_eq!(device.model_runtimes[0].kind, "ollama"); + assert_eq!( + device.browser_pacs, + vec![BrowserPacRecord { + browser: "chrome".to_owned(), + packaging: "system".to_owned(), + pac_url: Some("https://pac.corp.example/ai.pac".to_owned()), + recommended: false, + locked: false, + source: Some("/etc/opt/chrome/policies/managed/egress.json".to_owned()), + detail: None, + }] + ); + assert_eq!(device.device.browser_pacs.len(), 1); + assert_eq!( + device.pac_fetches, + vec![agentdesktop_core::model::PacFetch { + url: "https://pac.corp.example/ai.pac".to_owned(), + sha256: Some("ab".repeat(32)), + size: Some(435), + error: None, + }] + ); + let listed = database.list_devices().await.unwrap(); + assert_eq!(listed[0].browser_pacs, device.device.browser_pacs); assert_eq!(device.model_runtimes[0].models[0].name, "qwen3:8b"); assert_eq!(device.recent_events.len(), 1); assert_eq!(device.recent_events[0].event_type, "tool.use"); diff --git a/crates/controller/src/lib.rs b/crates/controller/src/lib.rs index 9cf6f1aa..a3da5ac6 100644 --- a/crates/controller/src/lib.rs +++ b/crates/controller/src/lib.rs @@ -1,4 +1,5 @@ pub mod admin; +pub mod browser_pac; pub mod daemon_config; pub mod database; pub mod device_ca; diff --git a/crates/controller/src/main.rs b/crates/controller/src/main.rs index 69e7e650..77891146 100644 --- a/crates/controller/src/main.rs +++ b/crates/controller/src/main.rs @@ -96,6 +96,11 @@ async fn main() -> anyhow::Result<()> { gateway_jwt_enabled: gateway_jwt_issuer.is_some(), usage_enabled: usage_ingest.is_some(), }, + config + .browser_egress + .as_ref() + .and_then(|browser_egress| browser_egress.compliance.as_ref()) + .map(|compliance| compliance.pac_url.clone()), ); let ca_certificate = std::fs::read_to_string(&tls.client_ca_certificate).with_context(|| { diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 1169cc59..5aed0d8f 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -489,6 +489,33 @@ pub struct ControllerConfig { /// This escape hatch is only appropriate for isolated local development. #[serde(default)] pub allow_insecure_dev: bool, + /// Browser proxy (PAC) reporting. Devices always report the PAC their + /// browser policy sets; these settings add a compliance check shown in + /// the management UI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub browser_egress: Option, +} + +/// Browser proxy (PAC) settings of the controller. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ControllerBrowserEgressConfig { + /// Compliance check: the controller compares the PAC each device's + /// browser policy sets with these values and shows the result in the + /// management UI. Agentdesktop does not configure browsers from them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compliance: Option, +} + +/// Expected browser proxy settings for the compliance check. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BrowserEgressCompliance { + /// The PAC URL browser policy is expected to set (exact match). + #[cfg_attr(feature = "schema", schemars(with = "String"))] + pub pac_url: Url, } /// OpenID Connect settings used for interactive device enrollment. @@ -1198,6 +1225,14 @@ pub fn parse_controller(contents: &str) -> anyhow::Result { { anyhow::bail!("daemonConfig.revision must be greater than zero"); } + if let Some(compliance) = config + .browser_egress + .as_ref() + .and_then(|browser_egress| browser_egress.compliance.as_ref()) + && !matches!(compliance.pac_url.scheme(), "http" | "https") + { + anyhow::bail!("browserEgress.compliance.pacUrl must be an http or https URL"); + } if let Some(gateway) = &config.gateway_jwt { if gateway.issuer.trim().is_empty() { anyhow::bail!("gatewayJwt.issuer cannot be empty"); @@ -1714,6 +1749,41 @@ controller: assert!(parse_daemon(valid).is_ok()); } + #[test] + fn controller_browser_egress_compliance_takes_an_http_pac_url() { + let base = "tls: /etc/agentdesktop/tls\noidc:\n issuer: https://idp.example.com\n clientId: agentdesktop\n"; + let controller = parse_controller(&format!( + "{base}browserEgress:\n compliance:\n pacUrl: https://pac.corp.example/ai.pac\n" + )) + .expect("compliance profile"); + assert_eq!( + controller + .browser_egress + .and_then(|browser_egress| browser_egress.compliance) + .expect("compliance") + .pac_url + .as_str(), + "https://pac.corp.example/ai.pac" + ); + assert!(parse_controller(base).unwrap().browser_egress.is_none()); + assert!( + parse_controller(&format!( + "{base}browserEgress:\n compliance:\n pacUrl: file:///etc/ai.pac\n" + )) + .unwrap_err() + .to_string() + .contains("http or https") + ); + assert!( + parse_controller(&format!( + "{base}browserEgress:\n compliance:\n pacUrl: https://a.example/\n extra: 1\n" + )) + .is_err() + ); + // Daemons never see it: the pushed daemon configuration has no such key. + assert!(parse_daemon("browserEgress: {}\n").is_err()); + } + #[test] fn controller_tls_accepts_explicit_files_and_directory_shorthand() { let explicit = r#" diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index f415593d..119160f9 100644 --- a/crates/core/src/model.rs +++ b/crates/core/src/model.rs @@ -8,6 +8,86 @@ pub struct Discovery { pub agents: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub model_runtimes: Vec, + /// Proxy auto-config (PAC) set by browser policy, one entry per browser + /// that is installed or has policy, as read from machine policy. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub browser_pacs: Vec, + /// One fetch per distinct PAC URL in `browser_pacs`: the identity of the + /// PAC this device gets (SHA-256 of the body), never its content. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pac_fetches: Vec, +} + +/// The result of fetching one PAC URL, as a browser on this device would. +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PacFetch { + pub url: String, + /// Lowercase hex SHA-256 of the response body, when the fetch succeeded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha256: Option, + /// Body size in bytes, when the fetch succeeded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, + /// Why the fetch failed, at most 1024 bytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// The PAC a browser's policy sets, as read from machine policy. +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BrowserPac { + /// `chrome`, `edge`, `chromium` or `firefox`. + pub browser: String, + /// How the browser is installed (`system`, `snap`, `flatpak`), or empty + /// when only policy was found. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub packaging: String, + /// The PAC URL, when policy selects PAC mode with a non-empty URL. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pac_url: Option, + /// The value came from a location the user can override + /// (`policies/recommended`). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub recommended: bool, + /// Firefox `Proxy.Locked`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub locked: bool, + /// The policy file the value came from. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + /// A read error or conflicting policy files, at most 1024 bytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +/// One PAC value per device, derived from its browser rows. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DevicePac { + /// No browser's policy sets a PAC. + Absent, + /// Every browser row names this PAC URL. + Found(String), + /// Browsers disagree: different URLs, or some have a PAC and some don't. + Differs, +} + +impl DevicePac { + /// Derives the device value. No browser rows at all is `Absent`. + pub fn from_browsers(browsers: &[BrowserPac]) -> Self { + let mut urls = browsers.iter().map(|browser| browser.pac_url.as_deref()); + let Some(first) = urls.next() else { + return Self::Absent; + }; + if urls.any(|url| url != first) { + return Self::Differs; + } + match first { + Some(url) => Self::Found(url.to_owned()), + None => Self::Absent, + } + } } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -316,7 +396,43 @@ pub enum TelemetryEventKind { #[cfg(test)] mod tests { - use super::{ControllerConnectionError, ControllerConnectionStatus}; + use super::{BrowserPac, ControllerConnectionError, ControllerConnectionStatus, DevicePac}; + + fn browser(name: &str, pac_url: Option<&str>) -> BrowserPac { + BrowserPac { + browser: name.to_owned(), + pac_url: pac_url.map(str::to_owned), + ..BrowserPac::default() + } + } + + #[test] + fn device_pac_is_one_value_when_browsers_agree() { + let pac = "https://pac.corp.example/ai.pac"; + assert_eq!(DevicePac::from_browsers(&[]), DevicePac::Absent); + assert_eq!( + DevicePac::from_browsers(&[browser("chrome", None), browser("firefox", None)]), + DevicePac::Absent + ); + assert_eq!( + DevicePac::from_browsers(&[ + browser("chrome", Some(pac)), + browser("firefox", Some(pac)) + ]), + DevicePac::Found(pac.to_owned()) + ); + assert_eq!( + DevicePac::from_browsers(&[browser("chrome", Some(pac)), browser("firefox", None)]), + DevicePac::Differs + ); + assert_eq!( + DevicePac::from_browsers(&[ + browser("chrome", Some(pac)), + browser("edge", Some("https://other.example/ai.pac")), + ]), + DevicePac::Differs + ); + } #[test] fn controller_connection_error_is_a_closed_camel_case_set() { diff --git a/crates/proto/proto/fleet.proto b/crates/proto/proto/fleet.proto index ee90d272..9f7701a5 100644 --- a/crates/proto/proto/fleet.proto +++ b/crates/proto/proto/fleet.proto @@ -177,6 +177,38 @@ message Heartbeat { message Inventory { repeated Discovery discoveries = 1; repeated ModelRuntime model_runtimes = 2; + // The PAC browser policy sets, one entry per browser that is installed or + // has policy. Absent from older agents. + repeated BrowserPac browser_pacs = 3; + // One fetch per distinct PAC URL in browser_pacs: the PAC's identity on + // this device (SHA-256 of the body), never its content. + repeated PacFetch pac_fetches = 4; +} + +message PacFetch { + string url = 1; + // Lowercase hex SHA-256 of the body; empty when the fetch failed. + string sha256 = 2; + uint64 size = 3; + // Why the fetch failed, at most 1024 bytes; empty on success. + string error = 4; +} + +message BrowserPac { + // chrome | edge | chromium | firefox + string browser = 1; + // system | snap | flatpak, or empty when only policy was found. + string packaging = 2; + // Empty when policy sets no PAC. + string pac_url = 3; + // The value came from a user-overridable (recommended) policy location. + bool recommended = 4; + // Firefox Proxy.Locked. + bool locked = 5; + // The policy file the value came from. + string source = 6; + // A read error or conflicting policy files, at most 1024 bytes. + string detail = 7; } message ModelRuntime { diff --git a/frontend/controller/src/components/BrowserPac.tsx b/frontend/controller/src/components/BrowserPac.tsx new file mode 100644 index 00000000..986b41aa --- /dev/null +++ b/frontend/controller/src/components/BrowserPac.tsx @@ -0,0 +1,108 @@ +import { Check, CircleAlert, X } from "lucide-react"; +import type { ReactNode } from "react"; + +import type { BrowserPacCompliance, DeviceBrowserPac } from "../types"; + +/** The device's PAC value from browser policy: absent, found (with URL), or differs. */ +export function PacBadge({ pac }: { pac: DeviceBrowserPac }) { + switch (pac.state) { + case "found": + return ( + + PAC found + + ); + case "absent": + return PAC absent; + case "differs": + return ( + + PAC differs + + ); + default: + return —; + } +} + +/** Fleet compliance check result; only shown when a profile is configured. */ +export function PacComplianceBadge({ + compliance, +}: { + compliance: BrowserPacCompliance; +}) { + switch (compliance.state) { + case "compliant": + return Compliant; + case "not_compliant": + return ( + + Not compliant + + ); + default: + return Not reported; + } +} + +/** + * One icon per device for tables: – no PAC, grey check PAC found (no + * compliance profile), green check compliant, red cross not compliant, + * amber alert when browsers differ or the PAC can't be fetched. Details are + * in the tooltip and on the device page. + */ +export function PacCell({ pac }: { pac: DeviceBrowserPac }) { + const { label, tone, icon } = pacStatus(pac); + const details = [ + label, + pac.url, + pac.sha256 && `SHA-256 ${pac.sha256.slice(0, 12)}`, + pac.compliance?.reason, + ] + .filter(Boolean) + .join("\n"); + return ( + + {icon} + + ); +} + +function pacStatus(pac: DeviceBrowserPac): { + label: string; + tone: string; + icon: ReactNode; +} { + if (pac.state === "not_reported") { + return { label: "Not reported", tone: "muted", icon: null }; + } + // With a compliance profile the verdict decides: red is never hidden by + // a warning. + if (pac.compliance?.state === "not_compliant") { + const icon = pac.state === "absent" ? "–" : ; + return { label: "PAC not compliant", tone: "danger", icon }; + } + if (pac.state === "absent") { + return { label: "No PAC", tone: "muted", icon: "–" }; + } + if (pac.state === "differs" || pac.fetch_error) { + const label = + pac.state === "differs" + ? "Browsers set different PACs" + : "PAC not reachable"; + return { label, tone: "warning", icon: }; + } + if (pac.compliance?.state === "compliant") { + return { + label: "PAC compliant", + tone: "success", + icon: , + }; + } + return { label: "PAC found", tone: "muted", icon: }; +} diff --git a/frontend/controller/src/components/DeviceTable.tsx b/frontend/controller/src/components/DeviceTable.tsx index 8d90b3e2..b2b97155 100644 --- a/frontend/controller/src/components/DeviceTable.tsx +++ b/frontend/controller/src/components/DeviceTable.tsx @@ -4,6 +4,7 @@ import { Check, ChevronRight, CircleAlert } from "lucide-react"; import { friendlyOs } from "../format"; import { navigate } from "../router"; import type { Device } from "../types"; +import { PacCell } from "./BrowserPac"; import { EmptyDevices } from "./ViewStates"; export function DeviceTable({ @@ -28,6 +29,7 @@ export function DeviceTable({ Platform {!compact && Agent} Tools + Browser PAC Configuration Open @@ -69,6 +71,9 @@ export function DeviceTable({ + + + ; }>; + browser_pacs: BrowserPacRecord[]; + pac_fetches: Array<{ + url: string; + sha256?: string; + size?: number; + error?: string; + }>; recent_events: Array<{ id: string; timestamp_unix_ms: number; @@ -84,6 +119,18 @@ export type Overview = { config_failures: number; active_revision: number | null; recent_devices: Device[]; + browser_pac: { + found: number; + absent: number; + differs: number; + not_reported: number; + /** Null without a fleet compliance profile. */ + expected_pac_url: string | null; + compliant: number; + not_compliant: number; + /** Devices that found a PAC, grouped by URL and SHA-256 of what they fetched. */ + versions: Array<{ url: string; sha256: string | null; devices: number }>; + }; }; export type ControllerSettings = { diff --git a/frontend/controller/src/views/DeviceView.tsx b/frontend/controller/src/views/DeviceView.tsx index fb357ca4..87e4af6b 100644 --- a/frontend/controller/src/views/DeviceView.tsx +++ b/frontend/controller/src/views/DeviceView.tsx @@ -1,5 +1,6 @@ import { CardHeader, + friendlyBrowser, friendlyTool, ModelRuntimeInventory, ToolIcon, @@ -20,6 +21,7 @@ import { } from "lucide-react"; import { useEffect, useRef } from "react"; +import { PacBadge, PacComplianceBadge } from "../components/BrowserPac"; import { ConfigBadge, DefinitionList, @@ -161,6 +163,7 @@ export function DeviceView({ {usage && (usage.enabled || usage.totals.requests > 0) ? ( ) : null} +
= { unspecified: "Unknown", }; +function BrowserPacCard({ device }: { device: DeviceDetail }) { + const pac = device.browser_pac; + return ( +
+ + {pac.state === "not_reported" ? ( +

+ No browser policy reported by this agent +

+ ) : ( +
+
+ + Device + + + + {pac.url && {pac.url}} + + +
+ {(pac.sha256 || pac.fetch_error) && ( +
+ + + PAC identity + SHA-256 of what the device fetched + + + + {pac.sha256 ? ( + + {pac.sha256.slice(0, 16)} · {pac.size} bytes + + ) : ( + <> + PAC not reachable + + {pac.fetch_error} + + + )} + +
+ )} + {pac.compliance?.reason && ( +
+ + Not compliant because + + + + {pac.compliance.reason} + + +
+ )} + {device.browser_pacs.map((browser) => ( +
+ + + {friendlyBrowser(browser.browser)} + {browser.packaging && {browser.packaging}} + + + + + {browser.pac_url ?? browser.detail ?? "No PAC in policy"} + + {browser.recommended && ( + Recommended + )} + {browser.locked && ( + Locked + )} + +
+ ))} +
+ )} +
+ ); +} + function ProgramBadge({ state }: { state: ProgramStatus["state"] }) { const tone = state === "conflict" || state === "failed" || state === "blocked" diff --git a/frontend/controller/src/views/OverviewView.tsx b/frontend/controller/src/views/OverviewView.tsx index beddeaec..e13cb176 100644 --- a/frontend/controller/src/views/OverviewView.tsx +++ b/frontend/controller/src/views/OverviewView.tsx @@ -25,6 +25,65 @@ export function OverviewView({ data }: { data: Overview }) {
+
+ {data.browser_pac.expected_pac_url ? ( + <> + + + + + ) : ( + <> + + + + + )} + +
+ + {data.browser_pac.versions.length > 0 && ( +
+ +
+ {data.browser_pac.versions.map((version) => ( +
+ + {version.url} + + + {version.sha256 ? ( + + {version.sha256.slice(0, 12)} + + ) : ( + Not fetched + )} + + +
+ ))} +
+
+ )} +
; }>; + browserPacs?: BrowserPac[]; + pacFetches?: Array<{ + url: string; + sha256?: string; + size?: number; + error?: string; + }>; +} + +/** The PAC one browser's machine policy sets, as read from policy. */ +export interface BrowserPac { + browser: string; + packaging?: string; + pacUrl?: string; + recommended?: boolean; + locked?: boolean; + source?: string; + detail?: string; } export type UsageRange = "24h" | "7d" | "30d"; diff --git a/frontend/desktop/src/views/ToolsView.tsx b/frontend/desktop/src/views/ToolsView.tsx index 0f1c702c..ef4617d3 100644 --- a/frontend/desktop/src/views/ToolsView.tsx +++ b/frontend/desktop/src/views/ToolsView.tsx @@ -1,11 +1,22 @@ import { CardHeader, + friendlyBrowser, ModelRuntimeInventory, ToolInventory, } from "@agentdesktop/ui"; -import { AlertCircle, Box, Cpu } from "lucide-react"; +import { AlertCircle, Box, Cpu, Globe } from "lucide-react"; -import type { Discovery } from "../types"; +import type { BrowserPac, Discovery } from "../types"; + +/** One PAC value for the device: the URL when every browser agrees. */ +function devicePac(browsers: BrowserPac[]): string { + const urls = new Set(browsers.map((browser) => browser.pacUrl ?? "")); + if (urls.size > 1) { + return "PAC: differs between browsers"; + } + const [url] = urls; + return url ? `PAC: found ${url}` : "PAC: absent"; +} export interface ToolsViewProps { discovery: Discovery | null; @@ -15,6 +26,8 @@ export interface ToolsViewProps { export function ToolsView({ discovery, unavailable }: ToolsViewProps) { const agents = discovery?.agents ?? []; const modelRuntimes = discovery?.modelRuntimes ?? []; + const browserPacs = discovery?.browserPacs ?? []; + const pacFetches = discovery?.pacFetches ?? []; const modelCount = modelRuntimes.reduce( (total, runtime) => total + runtime.models.length, 0, @@ -126,6 +139,51 @@ export function ToolsView({ discovery, unavailable }: ToolsViewProps) { )}
) : null} + {!unavailable ? ( +
+ + {browserPacs.length ? ( +
+
+ +
+ {devicePac(browserPacs)} + {pacFetches.map((fetch) => ( + + {fetch.sha256 + ? `SHA-256 ${fetch.sha256.slice(0, 12)} · ${fetch.size} bytes` + : `PAC not reachable: ${fetch.error ?? "unknown error"}`} + + ))} + {browserPacs.map((browser) => ( + + {friendlyBrowser(browser.browser)}:{" "} + {browser.pacUrl ?? browser.detail ?? "no PAC in policy"} + {browser.recommended + ? " (recommended, user can change)" + : ""} + + ))} +
+
+
+ ) : ( +
+ +
+ No browser policy reported + + No supported browser was found, or browser policy is not read + on this platform yet. + +
+
+ )} +
+ ) : null}
); } diff --git a/frontend/ui/src/tools.tsx b/frontend/ui/src/tools.tsx index 6ebce7f0..029c9352 100644 --- a/frontend/ui/src/tools.tsx +++ b/frontend/ui/src/tools.tsx @@ -376,3 +376,13 @@ export function ToolInventory({ discovery }: { discovery: ToolDiscovery }) { ); } + +export function friendlyBrowser(browser: string) { + const names: Record = { + chrome: "Google Chrome", + edge: "Microsoft Edge", + chromium: "Chromium", + firefox: "Firefox", + }; + return names[browser] ?? browser; +} diff --git a/schema/controller-config.json b/schema/controller-config.json index a8feb2cb..f772d620 100644 --- a/schema/controller-config.json +++ b/schema/controller-config.json @@ -14,6 +14,17 @@ "type": "boolean", "default": false }, + "browserEgress": { + "description": "Browser proxy (PAC) reporting. Devices always report the PAC their\nbrowser policy sets; these settings add a compliance check shown in\nthe management UI.", + "anyOf": [ + { + "$ref": "#/$defs/ControllerBrowserEgressConfig" + }, + { + "type": "null" + } + ] + }, "daemonConfig": { "description": "Daemon configuration distributed to enrolled devices.", "anyOf": [ @@ -72,6 +83,38 @@ "tls" ], "$defs": { + "BrowserEgressCompliance": { + "description": "Expected browser proxy settings for the compliance check.", + "type": "object", + "properties": { + "pacUrl": { + "description": "The PAC URL browser policy is expected to set (exact match).", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "pacUrl" + ] + }, + "ControllerBrowserEgressConfig": { + "description": "Browser proxy (PAC) settings of the controller.", + "type": "object", + "properties": { + "compliance": { + "description": "Compliance check: the controller compares the PAC each device's\nbrowser policy sets with these values and shows the result in the\nmanagement UI. Agentdesktop does not configure browsers from them.", + "anyOf": [ + { + "$ref": "#/$defs/BrowserEgressCompliance" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, "ControllerDaemonConfig": { "description": "Controller-owned daemon configuration file and its revision.", "type": "object", diff --git a/schema/controller-config.md b/schema/controller-config.md index c0c5b126..7a94a81c 100644 --- a/schema/controller-config.md +++ b/schema/controller-config.md @@ -4,6 +4,9 @@ |-|-|-| |`adminListen`|string|Loopback address on which the controller management UI listens.| |`allowInsecureDev`|boolean|Permit a non-HTTPS OIDC issuer for isolated local development.

This escape hatch is only appropriate for isolated local development.| +|`browserEgress`|object|Browser proxy (PAC) reporting. Devices always report the PAC their
browser policy sets; these settings add a compliance check shown in
the management UI.| +|`browserEgress.compliance`|object|Compliance check: the controller compares the PAC each device's
browser policy sets with these values and shows the result in the
management UI. Agentdesktop does not configure browsers from them.| +|`browserEgress.compliance.pacUrl`|string|The PAC URL browser policy is expected to set (exact match).| |`daemonConfig`|object|Daemon configuration distributed to enrolled devices.| |`daemonConfig.path`|string|Path to the watched YAML configuration distributed to enrolled devices.

Relative paths are resolved from the controller configuration directory.
Valid file changes are published to connected devices automatically.| |`daemonConfig.revision`|integer|Monotonically increasing revision assigned to the daemon configuration.| From dbb9f37881da3d3e5fa17acb251c1a8bc7e69e16 Mon Sep 17 00:00:00 2001 From: Lutz Lange Date: Wed, 14 Oct 2026 21:54:51 +0200 Subject: [PATCH 2/4] fix: address review on browser PAC reporting - Linux: merge the proxy keys across policy files the way Chrome merges policies, so ProxyMode and ProxyPacUrl in different files still give a PAC; the same key with different values is still a conflict - Linux: a policy directory that can't be read is reported instead of looking like "no policy" - inventory refresh: resync the file watch after every scan, not only when the inventory changed, so a policy file in a directory created empty first is seen without waiting for the interval (regression test) - fleet UI: when browsers set different PACs, the device page lists each PAC URL with its hash or fetch error Co-Authored-By: Claude Opus 5.5 --- crates/agent/src/browser_pac.rs | 108 ++++++++++++++---- crates/agent/src/daemon.rs | 76 +++++++++++- crates/agent/src/inventory_watch.rs | 7 ++ frontend/controller/src/stories/fixtures.ts | 29 +++++ .../src/views/DeviceView.stories.tsx | 14 +++ frontend/controller/src/views/DeviceView.tsx | 24 ++++ 6 files changed, 235 insertions(+), 23 deletions(-) diff --git a/crates/agent/src/browser_pac.rs b/crates/agent/src/browser_pac.rs index f4c6d1b4..eb5fc10a 100644 --- a/crates/agent/src/browser_pac.rs +++ b/crates/agent/src/browser_pac.rs @@ -388,18 +388,26 @@ mod linux { (packaging.is_some() || found_policy).then_some(row) } - /// Reads every `*.json` in the directories of one level. Two files that - /// set different proxy values are reported as a conflict rather than - /// guessing Chrome's merge order; unreadable files are collected in - /// `unreadable` and skipped. + /// Reads every `*.json` in the directories of one level and merges the + /// proxy keys across files the way Chrome merges policies key by key + /// (`ProxyMode` in one file and `ProxyPacUrl` in another still give a + /// PAC). The same key with different values in two files is reported as + /// a conflict rather than guessing Chrome's merge order. Unreadable files + /// and directories are collected in `unreadable` and skipped. fn chromium_level( directories: &[PathBuf], unreadable: &mut Vec, ) -> Result, String> { - let mut found: Option<(ProxyPolicy, PathBuf)> = None; + const KEYS: [&str; 3] = ["ProxySettings", "ProxyMode", "ProxyPacUrl"]; + let mut merged: Vec<(&str, Value, PathBuf)> = Vec::new(); for directory in directories { - let Ok(entries) = std::fs::read_dir(directory) else { - continue; + let entries = match std::fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + unreadable.push(format!("read {}: {error}", directory.display())); + continue; + } }; let mut files: Vec = entries .filter_map(Result::ok) @@ -418,23 +426,39 @@ mod linux { continue; } }; - let Some(policy) = chromium_proxy(&value) else { - continue; - }; - match &found { - Some((existing, first)) if *existing != policy => { - return Err(format!( - "conflicting proxy policy in {} and {}", - first.display(), - file.display() - )); + for key in KEYS { + let Some(setting) = value.get(key) else { + continue; + }; + match merged.iter().find(|(existing, _, _)| *existing == key) { + Some((_, existing, first)) if existing != setting => { + return Err(format!( + "conflicting proxy policy in {} and {}", + first.display(), + file.display() + )); + } + Some(_) => {} + None => merged.push((key, setting.clone(), file.clone())), } - Some(_) => {} - None => found = Some((policy, file)), } } } - Ok(found) + // The source shown is the file that set the PAC URL, if any. + let Some(source) = ["ProxySettings", "ProxyPacUrl", "ProxyMode"] + .iter() + .find_map(|key| merged.iter().find(|(existing, _, _)| existing == key)) + .map(|(_, _, file)| file.clone()) + else { + return Ok(None); + }; + let policy = Value::Object( + merged + .into_iter() + .map(|(key, setting, _)| (key.to_owned(), setting)) + .collect(), + ); + Ok(chromium_proxy(&policy).map(|policy| (policy, source))) } fn firefox(root: &Path) -> Option { @@ -629,6 +653,50 @@ mod linux { fs::remove_dir_all(root).unwrap(); } + #[test] + fn legacy_keys_split_across_files_are_merged() { + let root = temp_root("split"); + write( + &root, + "etc/opt/chrome/policies/managed/a.json", + r#"{"ProxyMode":"pac_script"}"#, + ); + write( + &root, + "etc/opt/chrome/policies/managed/b.json", + &format!(r#"{{"ProxyPacUrl":"{PAC}"}}"#), + ); + let chrome = &discover(&root)[0]; + assert_eq!(chrome.pac_url.as_deref(), Some(PAC)); + assert_eq!( + chrome.source.as_deref(), + Some( + root.join("etc/opt/chrome/policies/managed/b.json") + .as_path() + ) + ); + assert_eq!(chrome.detail, None); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn an_unreadable_policy_directory_is_reported() { + use std::os::unix::fs::PermissionsExt; + let root = temp_root("noaccess"); + let managed = root.join("etc/opt/chrome/policies/managed"); + write(&root, "etc/opt/chrome/policies/managed/p.json", "{}"); + fs::set_permissions(&managed, fs::Permissions::from_mode(0o000)).unwrap(); + let readable = fs::read_dir(&managed).is_ok(); // root ignores modes + let chrome = discover(&root).into_iter().next(); + fs::set_permissions(&managed, fs::Permissions::from_mode(0o755)).unwrap(); + if !readable { + let chrome = chrome.expect("reported"); + assert_eq!(chrome.pac_url, None); + assert!(chrome.detail.as_deref().unwrap().contains("read ")); + } + fs::remove_dir_all(root).unwrap(); + } + #[test] fn an_unreadable_file_does_not_hide_a_valid_proxy_policy() { let root = temp_root("mixed"); diff --git a/crates/agent/src/daemon.rs b/crates/agent/src/daemon.rs index 5bb3f8f9..dcb07fd3 100644 --- a/crates/agent/src/daemon.rs +++ b/crates/agent/src/daemon.rs @@ -909,14 +909,17 @@ async fn refresh_inventory_with( return; } let discovery = discover().await; + // Every scan, not only changed ones: a scan can leave the inventory + // unchanged and still make new directories worth watching, such as + // an empty browser policy directory an MDM push just created. + if let Some(watch) = watch.as_mut() { + watch.sync(&discovery); + } if **sender.borrow() == discovery { continue; } tracing::info!(trigger, "inventory changed"); log_discovery(&discovery); - if let Some(watch) = watch.as_mut() { - watch.sync(&discovery); - } if sender.send(Arc::new(discovery)).is_err() { return; } @@ -1451,6 +1454,73 @@ mod tests { refresher.abort(); } + /// A policy directory created empty first and filled later: the scan + /// after the creation leaves the inventory unchanged, but must still + /// start watching the new directory so the later file is seen. + #[tokio::test] + async fn watches_a_new_directory_even_when_the_inventory_is_unchanged() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + static ROOT: std::sync::OnceLock = std::sync::OnceLock::new(); + fn policy_directories() -> Vec { + let root = ROOT.get().expect("root set"); + let managed = root.join("managed"); + vec![if managed.is_dir() { + managed + } else { + root.clone() + }] + } + let root = std::env::temp_dir().join(format!( + "agentdesktop-staged-policy-{}-{}", + std::process::id(), + rand::random::() + )); + std::fs::create_dir_all(&root).unwrap(); + ROOT.set(root.clone()).unwrap(); + + let (watch, changes) = crate::inventory_watch::InventoryWatch::new().unwrap(); + let watch = watch.with_policy_directories(policy_directories); + let scans = Arc::new(AtomicUsize::new(0)); + let counter = scans.clone(); + let (sender, _receiver) = watch::channel(Arc::new(discovery(&["codex"]))); + let refresher = tokio::spawn(super::refresh_inventory_with( + sender, + Duration::from_secs(3600), + changes, + Some(watch), + move || { + counter.fetch_add(1, Ordering::SeqCst); + async { discovery(&["codex"]) } + }, + )); + let wait_for = |target: usize| { + let scans = scans.clone(); + async move { + for _ in 0..100 { + if scans.load(Ordering::SeqCst) >= target { + return true; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + false + } + }; + tokio::time::sleep(Duration::from_millis(300)).await; + + std::fs::create_dir(root.join("managed")).unwrap(); + assert!(wait_for(1).await, "creating the directory triggers a scan"); + tokio::time::sleep(Duration::from_millis(500)).await; + std::fs::write(root.join("managed/egress.json"), "{}").unwrap(); + assert!( + wait_for(2).await, + "a file in the new directory triggers a scan" + ); + + refresher.abort(); + std::fs::remove_dir_all(root).unwrap(); + } + #[tokio::test(start_paused = true)] async fn stops_refreshing_once_every_reader_is_gone() { let (sender, receiver) = watch::channel(Arc::new(discovery(&["codex"]))); diff --git a/crates/agent/src/inventory_watch.rs b/crates/agent/src/inventory_watch.rs index a8c5bb97..66c72e01 100644 --- a/crates/agent/src/inventory_watch.rs +++ b/crates/agent/src/inventory_watch.rs @@ -66,6 +66,13 @@ impl InventoryWatch { )) } + /// Uses `directories` instead of the browser policy directories. + #[cfg(test)] + pub(crate) fn with_policy_directories(mut self, directories: fn() -> Vec) -> Self { + self.policy_directories = directories; + self + } + /// Points the watch at the directories behind `discovery`. /// /// Called again after every published snapshot, so a tool that gains or diff --git a/frontend/controller/src/stories/fixtures.ts b/frontend/controller/src/stories/fixtures.ts index 439838c4..a6fec5c5 100644 --- a/frontend/controller/src/stories/fixtures.ts +++ b/frontend/controller/src/stories/fixtures.ts @@ -740,3 +740,32 @@ export const sandboxDaemonConfig: DaemonConfigDocument = { codex: {}, }, }; + +export const differsDeviceDetail: DeviceDetail = { + ...deviceDetail, + browser_pac: { + state: "differs", + url: null, + sha256: null, + size: null, + fetch_error: null, + compliance: { + state: "not_compliant", + reason: "browsers set different PACs", + }, + }, + browser_pacs: [ + { ...deviceDetail.browser_pacs[0] }, + { + ...deviceDetail.browser_pacs[1], + pac_url: "https://old-pac.corp.example/ai-egress.pac", + }, + ], + pac_fetches: [ + ...deviceDetail.pac_fetches, + { + url: "https://old-pac.corp.example/ai-egress.pac", + error: "error sending request", + }, + ], +}; diff --git a/frontend/controller/src/views/DeviceView.stories.tsx b/frontend/controller/src/views/DeviceView.stories.tsx index 3e3ef755..8c38468a 100644 --- a/frontend/controller/src/views/DeviceView.stories.tsx +++ b/frontend/controller/src/views/DeviceView.stories.tsx @@ -5,6 +5,7 @@ import { ControllerStoryFrame } from "../stories/ControllerStoryFrame"; import { deviceDetail, deviceUsageReport, + differsDeviceDetail, emptyDeviceDetail, emptyUsageReport, failedDeviceDetail, @@ -88,6 +89,19 @@ export const OlderAgentWithoutProgramStatus: Story = { }, }; +export const BrowsersSetDifferentPacs: Story = { + args: { device: differsDeviceDetail }, + play: async ({ canvas }) => { + await expect(canvas.getByText("PAC differs")).toBeVisible(); + await expect( + canvas.getByText("https://old-pac.corp.example/ai-egress.pac", { + selector: "small", + }), + ).toBeVisible(); + await expect(canvas.getByText("PAC not reachable")).toBeVisible(); + }, +}; + export const NoActivityOrTools: Story = { args: { device: emptyDeviceDetail }, }; diff --git a/frontend/controller/src/views/DeviceView.tsx b/frontend/controller/src/views/DeviceView.tsx index 87e4af6b..0df44d75 100644 --- a/frontend/controller/src/views/DeviceView.tsx +++ b/frontend/controller/src/views/DeviceView.tsx @@ -480,6 +480,30 @@ function BrowserPacCard({ device }: { device: DeviceDetail }) {