chrome-agent 0.15.0

Browser automation for AI agents. Single binary, zero deps, CDP direct to Chrome.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::element_ref::ElementRef;

const SESSION_FILE: &str = "sessions.json";

/// Top-level session state persisted to disk.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct SessionStore {
    #[serde(default)]
    pub browsers: HashMap<String, BrowserSession>,
    /// Browser names present when this store was loaded. Used at save time to
    /// distinguish entries this process deliberately removed (delete from disk)
    /// from entries other processes added after our load (leave alone).
    #[serde(skip)]
    loaded_names: HashSet<String>,
    /// Serialized value of each browser as we loaded it. At save time an entry that
    /// still matches its loaded value is one we never touched, so we leave whatever
    /// is on disk instead of republishing our copy. Without this, an agent that only
    /// read the file would write its stale view of *other* agents' browsers back over
    /// their newer state — a lost update between parallel `--browser <name>` agents.
    #[serde(skip)]
    loaded_entries: HashMap<String, String>,
}

/// Per-browser session state.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserSession {
    pub ws_endpoint: String,
    pub pid: Option<u32>,
    #[serde(default)]
    pub headless: bool,
    #[serde(default)]
    pub proxy_server: Option<String>,
    /// Extra `--chrome-arg` flags this browser was launched with, in the order given.
    /// Fixed at launch like `proxy_server` — a running Chrome cannot pick up a new one,
    /// so a mismatch on reconnect is refused rather than silently reapplied.
    #[serde(default)]
    pub chrome_args: Vec<String>,
    #[serde(default)]
    pub daemon_pid: Option<u32>,
    #[serde(default)]
    pub pages: HashMap<String, PageSession>,
}

/// Per-page session state.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageSession {
    pub target_id: String,
    #[serde(default)]
    pub uid_map: HashMap<String, ElementRef>,
    #[serde(default)]
    pub last_snapshot: Option<String>,
    /// Document `last_snapshot` was taken from. uids are `backendNodeId`s and those
    /// counters overlap between documents, so `diff` needs this to tell "the page
    /// changed under me" from "I am looking at a different page entirely".
    /// `(frameId, loaderId)` of the document `last_snapshot` was taken from. The loader id
    /// is the only signal that moves exactly when the document is replaced; a URL moves on
    /// a fragment jump and stays put across a reload.
    #[serde(default)]
    pub last_snapshot_frame: Option<String>,
    #[serde(default)]
    pub last_snapshot_loader: Option<String>,
    /// Requested device metrics for this named page. They are reapplied to its current target on
    /// each connection. A Chrome relaunch replaces the surrounding browser entry, so the metrics
    /// expire with the browser process rather than leaking into a new one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub device_emulation: Option<crate::emulation::DeviceEmulation>,
}

/// Load the session store from disk. Returns empty store if file doesn't exist.
pub fn load_session() -> Result<SessionStore, SessionError> {
    crate::session_load::load_from(&session_path()?)
}

/// Save the session store to disk, merging with the current on-disk state so
/// parallel agents don't clobber each other's entries.
pub fn save_session(store: &mut SessionStore) -> Result<(), SessionError> {
    let result = save_to(&session_path()?, store);
    if result.is_ok() {
        // A pid that is now on disk is reachable by `close`, `status` and the interrupt
        // handler, so it is no longer this invocation's to reap. Disarming here rather
        // than at each call site is the point: the write that makes a browser reachable
        // is the same event that ends the window, and a save path added later inherits
        // it instead of having to remember. See `kill::UNPERSISTED`.
        for pid in store.browsers.values().filter_map(|b| b.pid) {
            crate::kill::disarm(pid);
        }
    }
    result
}

impl SessionStore {
    /// Take this view as the baseline the next save merges against: which names we hold,
    /// and the exact bytes of each entry. The save path reads the first to tell an entry
    /// this process deliberately dropped from one another agent added after our load, and
    /// the second to tell an entry we changed from one we merely read — republishing the
    /// latter is the lost update between parallel `--browser <name>` agents.
    ///
    /// One method rather than two assignments in three places, because the two fields only
    /// mean anything together: a name in `loaded_names` with no bytes in `loaded_entries`
    /// reads as "changed" and gets republished.
    pub fn take_baseline(&mut self) {
        self.loaded_names = self.browsers.keys().cloned().collect();
        self.loaded_entries = self
            .browsers
            .iter()
            .filter_map(|(name, entry)| {
                serde_json::to_string(entry).ok().map(|json| (name.clone(), json))
            })
            .collect();
    }
}

/// Persist `store` to `path` under an exclusive lock, merging with whatever is
/// currently on disk. This is the concurrency-safe core:
///
/// 1. Take an exclusive advisory lock so no two writers interleave.
/// 2. Re-read the on-disk store (another agent may have written since we loaded).
/// 3. Delete only the browsers this process held at load but no longer holds
///    (e.g. `close`), leaving entries other agents added after our load intact.
/// 4. Upsert this process's browsers.
/// 5. Drop every entry whose browser process is provably gone (`prune_dead`).
/// 6. Atomically replace the file.
///
/// Crate-visible (this module is private, so `pub` here reaches no further) for step 2's
/// sake: what that re-read does with each of its three outcomes is
/// the subject of `session_load`, and the tests that pin it have to drive the merge that
/// consumes it. Nothing outside this crate can reach it.
pub fn save_to(path: &Path, store: &mut SessionStore) -> Result<(), SessionError> {
    let parent = path
        .parent()
        .ok_or_else(|| SessionError("session path has no parent directory".into()))?;
    std::fs::create_dir_all(parent)
        .map_err(|e| SessionError(format!("Failed to create dir: {e}")))?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
    }

    // Serialize concurrent writers for the read-merge-write critical section.
    let _lock = FileLock::acquire(&parent.join("sessions.lock"))?;

    // Merge our changes onto the freshest on-disk state. This read used to be
    // `.unwrap_or_default()`, which made a store this process could not READ — the file
    // intact, every other agent still reading it fine — into an empty one, and the merge
    // below then published this process's single browser as the whole store. The three
    // outcomes are separated in `session_load::reread_for_merge`, which explains why only
    // the unreadable one refuses; the `?` here is the refusal, taken while the lock is held
    // and before anything has been written, so the file keeps exactly what it had.
    let mut merged = crate::session_load::reread_for_merge(path)?;
    for name in &store.loaded_names {
        if !store.browsers.contains_key(name) {
            // Compare-and-delete: the drop was decided about the entry we loaded. If
            // another writer republished this name since (e.g. relaunched the browser
            // with a new pid while our stale-cleanup was in flight), the on-disk entry
            // is not the one we judged dead — deleting it would orphan a live Chrome.
            let on_disk_is_what_we_loaded = merged
                .browsers
                .get(name)
                .and_then(|entry| serde_json::to_string(entry).ok())
                .is_none_or(|json| store.loaded_entries.get(name) == Some(&json));
            if on_disk_is_what_we_loaded {
                merged.browsers.remove(name);
            }
        }
    }
    for (name, entry) in &store.browsers {
        // Untouched since load: another agent may have advanced it while we were
        // working, so keep the on-disk value rather than republishing our stale copy.
        let untouched = serde_json::to_string(entry)
            .ok()
            .is_some_and(|json| store.loaded_entries.get(name) == Some(&json));
        if untouched && merged.browsers.contains_key(name) {
            continue;
        }
        merged.browsers.insert(name.clone(), entry.clone());
    }

    // Runs after the upsert so the test is applied to the pids actually about to be
    // written, including this process's own. A browser that died mid-command leaves
    // nothing worth persisting.
    let pruned = prune_dead(&mut merged.browsers);
    // Stop carrying the dropped entries in memory: the baseline below is taken from
    // `store`, and an entry still present there would be re-upserted by our next save
    // (the upsert branch treats "untouched and absent from disk" as ours to publish).
    for name in &pruned {
        store.browsers.remove(name);
    }

    let json = serde_json::to_string_pretty(&merged)
        .map_err(|e| SessionError(format!("Failed to serialize session: {e}")))?;

    // Atomic replace via a per-process temp file (unique name avoids clashing
    // with a crashed process's leftover temp; the lock covers same-process races).
    let tmp_path = path.with_extension(format!("json.tmp.{}", std::process::id()));
    std::fs::write(&tmp_path, &json)
        .map_err(|e| SessionError(format!("Failed to write {}: {e}", tmp_path.display())))?;
    // Restrict permissions before publishing: the file holds WebSocket URLs that
    // grant full browser control. Only the owning user should read it.
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600));
    }
    std::fs::rename(&tmp_path, path).map_err(|e| {
        // The temp name is per-PID, so nothing else will ever reclaim it.
        let _ = std::fs::remove_file(&tmp_path);
        SessionError(format!("Failed to rename session file: {e}"))
    })?;

    // Profile directories, judged against the store we just published and still under the
    // lock that makes that store a fixed point. After the rename, not before: a sweep is
    // housekeeping and must not delay or endanger the write it rides on.
    crate::profiles::sweep_orphans(
        &parent.join("browsers"),
        &merged.browsers.keys().cloned().collect(),
        &crate::profiles::Limits::default(),
    );

    // Our view is now the baseline for subsequent saves in this process.
    store.take_baseline();

    Ok(())
}

/// Where `browser::browser_profile_dir` puts profiles. Exposed so `close --purge-orphans`
/// sweeps the same directory the save path does.
pub fn browsers_dir() -> Result<PathBuf, SessionError> {
    Ok(dev_browser_dir()?.join("browsers"))
}

/// Exclusive advisory file lock, released on drop. Best-effort no-op on
/// non-Unix platforms (single-user desktop usage).
#[cfg(unix)]
struct FileLock(std::fs::File);

#[cfg(unix)]
impl FileLock {
    fn acquire(path: &Path) -> Result<Self, SessionError> {
        use std::os::unix::io::AsRawFd;
        let file = std::fs::OpenOptions::new()
            .create(true)
            .truncate(false)
            .write(true)
            .open(path)
            .map_err(|e| SessionError(format!("Failed to open lock {}: {e}", path.display())))?;
        // SAFETY: flock on a valid fd only takes an advisory lock; no memory unsafety.
        #[allow(unsafe_code)]
        let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
        if rc != 0 {
            return Err(SessionError(format!(
                "Failed to lock session store: {}",
                std::io::Error::last_os_error()
            )));
        }
        Ok(Self(file))
    }
}

#[cfg(unix)]
impl Drop for FileLock {
    fn drop(&mut self) {
        use std::os::unix::io::AsRawFd;
        // SAFETY: unlocking a valid fd we hold; no memory unsafety.
        #[allow(unsafe_code)]
        unsafe {
            libc::flock(self.0.as_raw_fd(), libc::LOCK_UN);
        }
    }
}

#[cfg(not(unix))]
struct FileLock;

#[cfg(not(unix))]
impl FileLock {
    fn acquire(_path: &Path) -> Result<Self, SessionError> {
        Ok(Self)
    }
}

/// Drop every entry whose browser process is provably gone, returning the names
/// dropped. Called on the merged map inside the exclusive lock, so the pid it tests
/// is the pid on disk at that instant.
///
/// Nothing ever removed an entry whose Chrome had exited — `close` removes only the
/// browser it is given a name for — and each entry carries a `uid_map` plus a
/// `last_snapshot` per page. Measured on a developer machine: 5,212,694 bytes, 2131
/// entries, 2123 of them naming pids the kernel reports as gone, parsed *and*
/// rewritten by every invocation including read-only ones. After one save: 7,827
/// bytes, 8 entries, and `text --selector body` on a warm browser went from 0.38 s
/// to under 0.01 s.
fn prune_dead(browsers: &mut HashMap<String, BrowserSession>) -> Vec<String> {
    let dead: Vec<String> = browsers
        .iter()
        .filter(|(_, session)| is_provably_dead(session))
        .map(|(name, _)| name.clone())
        .collect();
    for name in &dead {
        browsers.remove(name);
    }
    dead
}

/// Whether an entry may be dropped. Deliberately one-sided: keeping a stale entry
/// costs bytes, deleting a live one costs the caller its browser.
///
/// - `pid: None` is kept unconditionally and without a probe. Both `--connect` and a
///   managed reconnect through `DevToolsActivePort` store no pid (`browser.rs`), so
///   "no pid" carries no information about liveness — and a probe here would put an
///   HTTP round trip per entry on the path of every save.
/// - A pid the OS will not classify is kept. See [`Liveness`].
fn is_provably_dead(session: &BrowserSession) -> bool {
    session.pid.is_some_and(|pid| liveness(pid) == Liveness::Dead)
}

/// What the OS will say about a pid. `Unknown` is not a shrug that gets rounded to
/// `Dead`: `EPERM` means the process exists under another uid, and a recycled pid
/// reads as `Alive` under a name that Chrome no longer holds. Both keep the entry —
/// a stale entry is inert, and the launch path already relaunches over one.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Liveness {
    Alive,
    Dead,
    Unknown,
}

pub fn liveness(pid: u32) -> Liveness {
    #[cfg(unix)]
    {
        // kill() reads a non-positive pid as a process *group*, so those are not a
        // question about one process and must not be answered as one.
        let Ok(raw) = libc::pid_t::try_from(pid) else {
            return Liveness::Unknown;
        };
        if raw <= 0 {
            return Liveness::Unknown;
        }
        // SAFETY: kill(pid, 0) only checks existence and permission. No signal sent.
        #[allow(unsafe_code)]
        let rc = unsafe { libc::kill(raw, 0) };
        if rc == 0 {
            return Liveness::Alive;
        }
        if std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) {
            Liveness::Dead
        } else {
            Liveness::Unknown
        }
    }
    #[cfg(not(unix))]
    {
        // No portable probe wired here, so no entry is ever provably dead and the
        // store is never pruned. Growth is the previous behaviour, not a regression.
        let _ = pid;
        Liveness::Unknown
    }
}

/// Remove stale browser sessions where the process is no longer running
/// or the WebSocket endpoint is unreachable.
pub fn cleanup_stale(store: &mut SessionStore) {
    store.browsers.retain(|_name, session| {
        if let Some(pid) = session.pid {
            is_process_alive(pid)
        } else {
            // External connection (--connect) — probe HTTP endpoint
            is_ws_reachable(&session.ws_endpoint)
        }
    });
}

/// Quick check if a WebSocket endpoint's Chrome is still alive
/// by probing the HTTP /json/version endpoint (same host:port).
fn is_ws_reachable(ws_url: &str) -> bool {
    let http_url = crate::browser::extract_http_from_ws(ws_url);
    let version_url = format!("{http_url}/json/version");
    let agent = ureq::Agent::config_builder()
        .timeout_global(Some(std::time::Duration::from_millis(500)))
        .build()
        .new_agent();
    agent.get(&version_url).call().is_ok()
}

/// Ensure a browser session entry exists, returning a mutable ref.
pub fn ensure_browser<'a>(
    store: &'a mut SessionStore,
    name: &str,
    ws_endpoint: &str,
    pid: Option<u32>,
    headless: bool,
    proxy_server: Option<String>,
    chrome_args: Vec<String>,
) -> &'a mut BrowserSession {
    store
        .browsers
        .entry(name.to_string())
        .or_insert_with(|| BrowserSession {
            ws_endpoint: ws_endpoint.to_string(),
            pid,
            headless,
            proxy_server,
            chrome_args,
            daemon_pid: None,
            pages: HashMap::new(),
        })
}

/// Guard proxy compatibility when reconnecting to a live named browser.
///
/// A managed browser's proxy is fixed at launch, so a running browser cannot
/// change it. When no proxy is requested (the common case for follow-up
/// commands), we inherit the browser's existing proxy silently. We only refuse
/// when the caller explicitly asks for a *different* proxy than the one the
/// browser was launched with.
pub fn ensure_proxy_compatible(
    browser: &BrowserSession,
    requested_proxy: Option<&str>,
) -> Result<(), SessionError> {
    let Some(requested) = requested_proxy else {
        return Ok(());
    };
    if browser.proxy_server.as_deref() == Some(requested) {
        return Ok(());
    }
    Err(SessionError(
        "named browser is already running with a different proxy; close or purge it (chrome-agent --browser <name> close --purge), or select another browser name"
            .into(),
    ))
}

/// `--chrome-arg` compatibility lives in `chrome_args.rs` (split for the 1000-line cap);
/// re-exported so call sites keep spelling it `session::ensure_chrome_args_compatible`.
pub use crate::chrome_args::ensure_chrome_args_compatible;

/// Ensure a page session entry exists, returning a mutable ref.
pub fn ensure_page<'a>(
    browser: &'a mut BrowserSession,
    page_name: &str,
    target_id: &str,
) -> &'a mut PageSession {
    browser
        .pages
        .entry(page_name.to_string())
        .or_insert_with(|| PageSession {
            target_id: target_id.to_string(),
            uid_map: HashMap::new(),
            last_snapshot: None,
            last_snapshot_frame: None,
            last_snapshot_loader: None,
            device_emulation: None,
        })
}

/// Check if the daemon socket exists.
pub fn daemon_socket_exists() -> bool {
    daemon_socket_path().is_ok_and(|p| p.exists())
}

/// Path to the daemon socket.
pub fn daemon_socket_path() -> Result<PathBuf, SessionError> {
    Ok(dev_browser_dir()?.join("daemon.sock"))
}

/// Path to the daemon PID file.
pub fn daemon_pid_path() -> Result<PathBuf, SessionError> {
    Ok(dev_browser_dir()?.join("daemon.pid"))
}

fn session_path() -> Result<PathBuf, SessionError> {
    Ok(dev_browser_dir()?.join(SESSION_FILE))
}

fn dev_browser_dir() -> Result<PathBuf, SessionError> {
    dirs::home_dir()
        .map(|h| h.join(".chrome-agent"))
        .ok_or_else(|| SessionError("Could not determine home directory".into()))
}

/// Treats anything short of a definite "no such process" as alive, so `cleanup_stale`
/// keeps whatever [`liveness`] could not classify — the same bias as [`is_provably_dead`].
fn is_process_alive(pid: u32) -> bool {
    liveness(pid) != Liveness::Dead
}

#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct SessionError(pub String);

#[cfg(test)]
mod tests {
    use super::*;
    use crate::session_load::load_from;

    /// Two agents work on their own `--browser` names at the same time. The one that
    /// only *read* the other's entry must not write its stale copy back over it.
    /// This is the lost update that made a concurrent agent lose its snapshot.
    #[test]
    fn a_reader_does_not_clobber_another_agents_concurrent_write() {
        let dir = std::env::temp_dir().join(format!("chrome-agent-session-test-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("sessions.json");
        let _ = std::fs::remove_file(&path);

        // Agent A publishes its browser.
        let mut a = SessionStore::default();
        ensure_browser(&mut a, "agent-a", "ws://a", None, true, None, Vec::new());
        save_to(&path, &mut a).unwrap();

        // Agent B loads the file, so it now holds a copy of agent-a it never touched.
        let mut b = load_from(&path).unwrap();
        ensure_browser(&mut b, "agent-b", "ws://b", None, true, None, Vec::new());

        // Agent A moves on and records a snapshot while B is still working.
        let mut a2 = load_from(&path).unwrap();
        let browser = a2.browsers.get_mut("agent-a").unwrap();
        let page = ensure_page(browser, "default", "target-1");
        page.last_snapshot = Some("uid=n1 RootWebArea".into());
        save_to(&path, &mut a2).unwrap();

        // B saves last. Its own entry must land, and A's snapshot must survive.
        save_to(&path, &mut b).unwrap();

        let final_state = load_from(&path).unwrap();
        assert!(final_state.browsers.contains_key("agent-b"), "agent-b should be saved");
        let snapshot = final_state.browsers["agent-a"].pages.get("default").and_then(|p| p.last_snapshot.as_deref());
        assert_eq!(
            snapshot,
            Some("uid=n1 RootWebArea"),
            "agent-a's snapshot was clobbered by an agent that only read it"
        );

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// The daemon heartbeat decides "browser 'foo' is dead, delete it" from a snapshot
    /// read outside the lock. If another agent relaunches 'foo' (same name, new pid)
    /// between that read and the heartbeat's save, the delete must not take the fresh
    /// entry down with it — that would silently orphan a running Chrome seconds after
    /// it was launched.
    ///
    /// Both pids are live: what is under test is the compare-and-delete, and a fixture
    /// pid the OS reports as gone would be swept by `prune_dead` before reaching it.
    #[test]
    fn a_stale_delete_does_not_clobber_a_concurrent_relaunch() {
        let dir = std::env::temp_dir().join(format!("chrome-agent-session-relaunch-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("sessions.json");
        let relaunched = LivePid::spawn();

        // The browser exists, and the heartbeat is about to judge it stale.
        let mut original = SessionStore::default();
        ensure_browser(&mut original, "foo", "ws://old", Some(std::process::id()), true, None, Vec::new());
        save_to(&path, &mut original).unwrap();

        // Heartbeat tick: loads, judges the entry stale, drops it in memory.
        let mut heartbeat = load_from(&path).unwrap();
        heartbeat.browsers.remove("foo");

        // Before the heartbeat saves, another agent relaunches 'foo'.
        let mut agent = load_from(&path).unwrap();
        agent.browsers.remove("foo");
        ensure_browser(&mut agent, "foo", "ws://fresh", Some(relaunched.id()), true, None, Vec::new());
        save_to(&path, &mut agent).unwrap();

        // The heartbeat's delete was decided about the old entry, not this one.
        save_to(&path, &mut heartbeat).unwrap();

        let final_state = load_from(&path).unwrap();
        let survivor = final_state.browsers.get("foo");
        assert_eq!(
            survivor.and_then(|b| b.pid),
            Some(relaunched.id()),
            "the freshly relaunched browser was deleted by a stale-cleanup decision made about its predecessor"
        );

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// A live process standing in for a running Chrome, so a fixture entry is not swept
    /// by the dead-pid prune. Reaped on drop.
    struct LivePid(std::process::Child);

    impl LivePid {
        fn spawn() -> Self {
            Self(
                std::process::Command::new("sleep")
                    .arg("30")
                    .spawn()
                    .expect("spawn a stand-in for a running browser"),
            )
        }
        fn id(&self) -> u32 {
            self.0.id()
        }
    }

    impl Drop for LivePid {
        fn drop(&mut self) {
            let _ = self.0.kill();
            let _ = self.0.wait();
        }
    }

    /// A save that cannot publish leaves no temp file behind.
    ///
    /// The destination is an existing non-empty directory, which is unopenable as a file.
    /// It used to reach `fs::rename(file, dir)` and fail there, after the temp file had
    /// been written — the leak this pins. It now fails one step earlier, at the re-read
    /// inside the lock, because a destination that is present and will not read is exactly
    /// the case `reread_for_merge` refuses instead of merging onto a guess. Both assertions
    /// still hold and the second is now true for a stronger reason: nothing was created at
    /// all. What this fixture can no longer construct is a destination that READS cleanly
    /// and still fails to rename, so the `remove_file` in that arm is no longer exercised
    /// here; it is a two-line cleanup on a branch reachable only from the filesystem
    /// (`EXDEV`, a concurrent `rmdir` of the parent), which no in-process fixture reaches.
    #[test]
    fn a_save_that_cannot_publish_leaves_no_temp_file_behind() {
        let dir = std::env::temp_dir().join(format!("chrome-agent-session-tmpleak-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("sessions.json");
        std::fs::create_dir_all(path.join("occupied")).unwrap();

        let mut store = SessionStore::default();
        ensure_browser(&mut store, "leaky", "ws://x", None, true, None, Vec::new());
        let result = save_to(&path, &mut store);
        let err = result.expect_err("an unpublishable destination should fail the save").0;
        assert!(err.contains("Failed to read"), "the refusal names what it could not do: {err}");

        let tmp_path = path.with_extension(format!("json.tmp.{}", std::process::id()));
        assert!(
            !tmp_path.exists(),
            "failed save left {} behind",
            tmp_path.display()
        );

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// A pid the OS reports as gone. Searched instead of hardcoded: any fixed number
    /// can be in use on the machine running the test.
    #[cfg(unix)]
    fn a_dead_pid() -> u32 {
        (60_000..99_990u32)
            .find(|&pid| liveness(pid) == Liveness::Dead)
            .expect("no unused pid in range")
    }

    /// The store grew forever: `close` removes the browser it is named, and nothing
    /// removed an entry whose Chrome had exited. A save now drops those, and only those.
    #[cfg(unix)]
    #[test]
    fn save_drops_dead_browsers_and_keeps_live_and_pidless_ones() {
        let dir = tmp_dir("prune");
        let path = dir.join(SESSION_FILE);

        let mut seed = SessionStore::default();
        // Alive: this very test process.
        ensure_browser(&mut seed, "live", "ws://live", Some(std::process::id()), true, None, Vec::new());
        // Dead: exited Chrome, the case that accumulated.
        ensure_browser(&mut seed, "dead", "ws://dead", Some(a_dead_pid()), true, None, Vec::new());
        ensure_browser(&mut seed, "dead-2", "ws://dead2", Some(a_dead_pid()), true, None, Vec::new());
        // No pid: `--connect`, or a managed reconnect via DevToolsActivePort. Dropping
        // this is how an agent loses the user's real Chrome.
        ensure_browser(&mut seed, "external", "ws://127.0.0.1:9222/x", None, false, None, Vec::new());
        // Give the dead entries the bulk an accumulated store carries.
        for name in ["dead", "dead-2"] {
            let browser = seed.browsers.get_mut(name).unwrap();
            let page = ensure_page(browser, "default", "target-1");
            page.last_snapshot = Some("x".repeat(4096));
        }
        // Written without going through `save_to`: this is the file an older binary
        // left behind, which is the state that has to be recoverable.
        std::fs::write(&path, serde_json::to_string_pretty(&seed).unwrap()).unwrap();
        let size_with_dead = std::fs::metadata(&path).unwrap().len();

        // Any save prunes — including one from a process that only read the file.
        let mut reader = load_from(&path).unwrap();
        save_to(&path, &mut reader).unwrap();

        let disk = load_from(&path).unwrap();
        let mut survivors: Vec<&str> = disk.browsers.keys().map(String::as_str).collect();
        survivors.sort_unstable();
        assert_eq!(
            survivors,
            ["external", "live"],
            "expected only the dead entries to go"
        );
        let size_pruned = std::fs::metadata(&path).unwrap().len();
        assert!(
            size_pruned < size_with_dead,
            "file did not shrink: {size_with_dead} -> {size_pruned}"
        );

        // The saving process must stop carrying the dropped entries, or its next save
        // re-publishes them: the upsert branch reads "untouched and absent from disk"
        // as an entry of ours to restore.
        assert!(
            !reader.browsers.contains_key("dead"),
            "the pruned entry is still staged in memory: {:?}",
            reader.browsers.keys()
        );
        save_to(&path, &mut reader).unwrap();
        assert_eq!(
            std::fs::metadata(&path).unwrap().len(),
            size_pruned,
            "a second save was not a no-op"
        );

        std::fs::remove_dir_all(&dir).ok();
    }

    /// Pruning tests the pid found on disk under the lock, so it cannot reach another
    /// agent's running browser: that pid answers `kill(pid, 0)`.
    #[cfg(unix)]
    #[test]
    fn pruning_leaves_a_concurrent_agents_live_browser_alone() {
        let dir = tmp_dir("prune-concurrent");
        let path = dir.join(SESSION_FILE);

        // Agent A publishes a live browser and records a snapshot on it.
        let mut a = SessionStore::default();
        ensure_browser(&mut a, "agent-a", "ws://a", Some(std::process::id()), true, None, Vec::new());
        let browser = a.browsers.get_mut("agent-a").unwrap();
        ensure_page(browser, "default", "target-a").last_snapshot = Some("uid=n1 RootWebArea".into());
        save_to(&path, &mut a).unwrap();

        // Agent B loads that view, adds its own browser and a dead leftover, and saves.
        let mut b = load_from(&path).unwrap();
        ensure_browser(&mut b, "agent-b", "ws://b", Some(std::process::id()), true, None, Vec::new());
        ensure_browser(&mut b, "leftover", "ws://old", Some(a_dead_pid()), true, None, Vec::new());
        save_to(&path, &mut b).unwrap();

        // A saves again, holding its own stale copy of agent-b.
        save_to(&path, &mut a).unwrap();

        let disk = load_from(&path).unwrap();
        assert!(!disk.browsers.contains_key("leftover"), "dead entry survived");
        assert_eq!(
            disk.browsers["agent-a"]
                .pages
                .get("default")
                .and_then(|p| p.last_snapshot.as_deref()),
            Some("uid=n1 RootWebArea"),
            "agent-a lost its snapshot"
        );
        assert!(
            disk.browsers.contains_key("agent-b"),
            "another agent's live browser was pruned: {:?}",
            disk.browsers.keys()
        );

        std::fs::remove_dir_all(&dir).ok();
    }

    /// The one-sided predicate, stated directly.
    #[test]
    fn only_a_pid_the_os_calls_gone_makes_an_entry_droppable() {
        let mut external = browser("ws://127.0.0.1:9222/x");
        external.pid = None;
        assert!(!is_provably_dead(&external), "--connect entry must be kept");

        let mut live = browser("ws://live");
        live.pid = Some(std::process::id());
        assert!(!is_provably_dead(&live));

        // Out of pid_t range: kill() would read it as a process group, so it is not a
        // question about one process and the entry is kept.
        let mut absurd = browser("ws://absurd");
        absurd.pid = Some(u32::MAX);
        assert!(!is_provably_dead(&absurd));
        let mut zero = browser("ws://zero");
        zero.pid = Some(0);
        assert!(!is_provably_dead(&zero));

        #[cfg(unix)]
        {
            let mut dead = browser("ws://dead");
            dead.pid = Some(a_dead_pid());
            assert!(is_provably_dead(&dead));
        }
    }

    #[test]
    fn session_roundtrip() {
        let mut store = SessionStore::default();
        let browser =
            ensure_browser(
                &mut store,
                "test",
                "ws://localhost:9222",
                Some(1234),
                true,
                Some("http://127.0.0.1:8080".into()),
                vec!["--enable-features=WebMCP,WebMCPTesting".into()],
            );
        ensure_page(browser, "main", "target-abc");

        let json = serde_json::to_string(&store).unwrap();
        let loaded: SessionStore = serde_json::from_str(&json).unwrap();

        assert!(loaded.browsers.contains_key("test"));
        let b = &loaded.browsers["test"];
        assert_eq!(b.ws_endpoint, "ws://localhost:9222");
        assert_eq!(b.pid, Some(1234));
        assert!(b.headless);
        assert_eq!(b.proxy_server.as_deref(), Some("http://127.0.0.1:8080"));
        assert_eq!(b.chrome_args, vec!["--enable-features=WebMCP,WebMCPTesting".to_string()]);
        assert!(b.pages.contains_key("main"));
        assert_eq!(b.pages["main"].target_id, "target-abc");
    }

    #[test]
    fn named_browser_proxy_must_match_before_reuse() {
        let existing = browser("ws://localhost:9222");
        assert!(ensure_proxy_compatible(&existing, None).is_ok());
        assert!(
            ensure_proxy_compatible(&existing, Some("http://127.0.0.1:8080"))
                .unwrap_err()
                .to_string()
                .contains("different proxy")
        );
    }

    #[test]
    fn proxied_browser_inherits_proxy_when_flag_omitted() {
        let mut existing = browser("ws://localhost:9222");
        existing.proxy_server = Some("http://127.0.0.1:8080".into());
        // Follow-up command without --proxy-server inherits the running proxy.
        assert!(ensure_proxy_compatible(&existing, None).is_ok());
        // Same proxy is fine.
        assert!(ensure_proxy_compatible(&existing, Some("http://127.0.0.1:8080")).is_ok());
        // A different explicit proxy is refused.
        assert!(ensure_proxy_compatible(&existing, Some("http://127.0.0.1:9090")).is_err());
    }

    // `ensure_chrome_args_compatible` itself is tested in `chrome_args.rs`, where it lives.

    // `bug_session_corrupt_json` and `bug_session_empty_file` moved to `session_load.rs`
    // with the function they exercise.

    #[test]
    fn bug_element_ref_unknown_type() {
        let json = r#"{"type":"futureType","data":"unknown"}"#;
        let result: Result<crate::element_ref::ElementRef, _> = serde_json::from_str(json);
        assert!(result.is_err());
    }

    // --- Concurrent session store (issue: parallel agents clobber sessions.json) ---

    fn tmp_dir(tag: &str) -> PathBuf {
        let dir = std::env::temp_dir()
            .join(format!("chrome-agent_sess_{}_{}", tag, std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    fn browser(ws: &str) -> BrowserSession {
        BrowserSession {
            ws_endpoint: ws.to_string(),
            pid: Some(1),
            headless: true,
            proxy_server: None,
            chrome_args: Vec::new(),
            daemon_pid: None,
            pages: HashMap::new(),
        }
    }

    #[test]
    fn save_merges_concurrent_additions_from_another_process() {
        let dir = tmp_dir("merge");
        let path = dir.join(SESSION_FILE);

        // This process loads an empty store and stages its own browser "a".
        let mut mine = load_from(&path).unwrap();
        mine.browsers.insert("a".into(), browser("ws://a"));

        // Meanwhile another process persists browser "b".
        let mut theirs = load_from(&path).unwrap();
        theirs.browsers.insert("b".into(), browser("ws://b"));
        save_to(&path, &mut theirs).unwrap();

        // Our save must NOT clobber "b".
        save_to(&path, &mut mine).unwrap();

        let disk = load_from(&path).unwrap();
        assert!(disk.browsers.contains_key("a"), "own entry lost: {:?}", disk.browsers.keys());
        assert!(disk.browsers.contains_key("b"), "concurrent entry clobbered: {:?}", disk.browsers.keys());
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn save_deletes_only_entries_this_process_removed() {
        let dir = tmp_dir("delete");
        let path = dir.join(SESSION_FILE);

        // Seed disk with two browsers.
        let mut seed = SessionStore::default();
        seed.browsers.insert("a".into(), browser("ws://a"));
        seed.browsers.insert("b".into(), browser("ws://b"));
        save_to(&path, &mut seed).unwrap();

        // Load, drop "a" (like `close --browser a`), save.
        let mut store = load_from(&path).unwrap();
        store.browsers.remove("a");
        save_to(&path, &mut store).unwrap();

        let disk = load_from(&path).unwrap();
        assert!(!disk.browsers.contains_key("a"), "removed entry should be gone");
        assert!(disk.browsers.contains_key("b"), "untouched entry should remain");
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn save_does_not_delete_entries_added_by_others_after_load() {
        let dir = tmp_dir("nodelete");
        let path = dir.join(SESSION_FILE);

        // We load empty, stage "a".
        let mut mine = load_from(&path).unwrap();
        mine.browsers.insert("a".into(), browser("ws://a"));

        // Another process adds "c" after our load.
        let mut other = load_from(&path).unwrap();
        other.browsers.insert("c".into(), browser("ws://c"));
        save_to(&path, &mut other).unwrap();

        // Our save adds "a" and must leave "c" alone (we never knew about it).
        save_to(&path, &mut mine).unwrap();

        let disk = load_from(&path).unwrap();
        assert!(disk.browsers.contains_key("a"));
        assert!(disk.browsers.contains_key("c"), "must not delete an entry we never loaded");
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn concurrent_saves_under_lock_lose_no_updates() {
        let dir = tmp_dir("threads");
        let path = dir.join(SESSION_FILE);
        let n = 24;

        let handles: Vec<_> = (0..n)
            .map(|i| {
                let path = path.clone();
                std::thread::spawn(move || {
                    let mut store = load_from(&path).unwrap_or_default();
                    store.browsers.insert(format!("b{i}"), browser(&format!("ws://{i}")));
                    save_to(&path, &mut store).unwrap();
                })
            })
            .collect();
        for h in handles {
            h.join().unwrap();
        }

        let disk = load_from(&path).unwrap();
        for i in 0..n {
            assert!(
                disk.browsers.contains_key(&format!("b{i}")),
                "lost update for b{i}; have {:?}",
                disk.browsers.keys()
            );
        }
        std::fs::remove_dir_all(&dir).ok();
    }
}