openlatch-client 0.6.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
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
//! Which Cline hosts have loaded the OpenLatch plugin.
//!
//! The plugin passes `--plugin-host-pid <process.ppid>` to `openlatch-hook`, which forwards it as
//! [`PLUGIN_HOST_PID_HEADER`](crate::hook_output::PLUGIN_HOST_PID_HEADER). That pid is the process
//! that spawned the plugin's sandbox child: Cline.app's hub (`code-sidecar`), VS Code's extension
//! host, the Cline CLI — or, on Windows, OpenLatch's own runtime launcher, in which case its parent
//! is the host. After the verdict has been answered, the daemon inspects that pid, classifies it
//! as a [`HostClass`], and records when each class was last seen in
//! `<ol_dir>/state/cline-plugin-seen.json`. Doctor reads that file: "delivered" is a claim, a plugin
//! event seen from the host is the proof.
//!
//! Never on the verdict path: the handler calls [`record_plugin_host`] after `process_envelope`
//! returns; a cache hit costs a map lookup, and the process-table read runs on the blocking pool.

use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use crate::hooks::cline_app::{self, process::ProcessTable};
use crate::hooks::cline_hosts::{self, HostClass};
use crate::hooks::cline_runtime;

use super::AppState;

/// `<ol_dir>/state/cline-plugin-seen.json`.
pub(crate) const SEEN_FILE: &str = "cline-plugin-seen.json";

/// How long a pid's class is trusted before the process is inspected again.
const CACHE_TTL: Duration = Duration::from_secs(10 * 60);

/// How long a pid that could not be classified is left alone before it is inspected again.
const NEGATIVE_TTL: Duration = Duration::from_secs(60);

/// The most pids the class cache holds.
const CACHE_MAX: usize = 256;

/// At most one write of the seen-state per class per this interval. An event inside the window
/// schedules one trailing write at its end, so the newest timestamp always reaches the file
/// within this long.
const PERSIST_EVERY: Duration = Duration::from_secs(30);

pub(crate) fn seen_path(ol_dir: &Path) -> PathBuf {
    ol_dir.join("state").join(SEEN_FILE)
}

/// The recorded last-seen times, Unix ms, per class. Missing or unreadable → empty; keys that are
/// not a [`HostClass`] are ignored.
pub(crate) fn read_seen(ol_dir: &Path) -> BTreeMap<HostClass, i64> {
    let Ok(raw) = std::fs::read_to_string(seen_path(ol_dir)) else {
        return BTreeMap::new();
    };
    let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(&raw) else {
        return BTreeMap::new();
    };
    map.into_iter()
        .filter_map(|(k, v)| {
            let class: HostClass = serde_json::from_value(serde_json::Value::String(k)).ok()?;
            Some((class, v.as_i64()?))
        })
        .collect()
}

/// What doctor, the binding's `liveness()` and the attestation judge "the plugin loaded" by: the
/// delivery state (`state/cline-plugin-delivery.json`) and the seen-state
/// (`state/cline-plugin-seen.json`), read once. One set of facts for every renderer.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginHosts {
    pub(crate) delivery: Option<super::cline_delivery::DeliveryState>,
    pub(crate) seen: BTreeMap<HostClass, i64>,
}

impl PluginHosts {
    /// Both files under `ol_dir`; a missing or unreadable one reads as empty.
    pub(crate) fn read(ol_dir: &Path) -> Self {
        Self {
            delivery: super::cline_delivery::read_state(ol_dir),
            seen: read_seen(ol_dir),
        }
    }

    /// The delivery entry for a class (`hub`, `vscode`); the CLI never has one.
    pub(crate) fn entry(&self, class: HostClass) -> Option<&super::cline_delivery::HostDelivery> {
        let delivery = self.delivery.as_ref()?;
        match class {
            HostClass::Hub => delivery.hub.as_ref(),
            HostClass::VsCode => delivery.vscode.as_ref(),
            HostClass::Cli => None,
        }
    }

    /// When a plugin event was last seen from `class`, Unix ms.
    pub(crate) fn seen_ms(&self, class: HostClass) -> Option<i64> {
        self.seen.get(&class).copied()
    }

    /// The plugin is proven loaded on `class`: delivered and seen since delivery started — or,
    /// for the CLI (which ships its own bootstrap and has no delivery entry), seen at all.
    pub(crate) fn loaded(&self, class: HostClass) -> bool {
        match class {
            HostClass::Cli => self.seen_ms(class).is_some(),
            HostClass::Hub | HostClass::VsCode => {
                let Some(entry) = self.entry(class) else {
                    return false;
                };
                match (entry.delivered, entry.since, self.seen_ms(class)) {
                    (true, Some(since), Some(seen)) => seen >= since,
                    _ => false,
                }
            }
        }
    }

    /// Every class the plugin is proven loaded on, in `hub`, `vscode`, `cli` order.
    pub(crate) fn loaded_on(&self) -> Vec<HostClass> {
        HostClass::ALL
            .into_iter()
            .filter(|c| self.loaded(*c))
            .collect()
    }

    /// Every delivery-tracked class (`hub`, `vscode`) NOT proven loaded, with why: the delivery
    /// reason, or `pending-restart` for a host delivered and not seen since.
    pub(crate) fn not_yet(&self) -> Vec<(HostClass, String)> {
        HostClass::ALL
            .into_iter()
            .filter(|c| !self.loaded(*c))
            .filter_map(|c| {
                let entry = self.entry(c)?;
                let reason = entry.reason.clone().unwrap_or_else(|| {
                    crate::hooks::cline_runtime::reason::PENDING_RESTART.to_string()
                });
                Some((c, reason))
            })
            .collect()
    }

    /// [`not_yet`](Self::not_yet), each host rendered by `item(class, reason)` and joined with
    /// `sep`; None when every delivery-tracked host is loaded or none is tracked.
    pub(crate) fn not_yet_summary(
        &self,
        item: impl Fn(&str, &str) -> String,
        sep: &str,
    ) -> Option<String> {
        let items: Vec<String> = self
            .not_yet()
            .into_iter()
            .map(|(class, reason)| item(class.as_str(), &reason))
            .collect();
        (!items.is_empty()).then(|| items.join(sep))
    }

    /// At least one delivery-tracked host (`hub`, `vscode`) was delivered the plugin.
    pub(crate) fn any_delivered(&self) -> bool {
        [HostClass::Hub, HostClass::VsCode]
            .into_iter()
            .any(|c| self.entry(c).is_some_and(|e| e.delivered))
    }

    /// `armed`: the plugin is proven loaded on at least one host.
    pub(crate) fn any_loaded(&self) -> bool {
        !self.loaded_on().is_empty()
    }

    /// Every class with a delivery entry or a seen timestamp, in `hub`, `vscode`, `cli` order.
    pub(crate) fn classes(&self) -> Vec<HostClass> {
        HostClass::ALL
            .into_iter()
            .filter(|c| self.entry(*c).is_some() || self.seen_ms(*c).is_some())
            .collect()
    }

    /// `doctor --json`'s `plugin_hosts`: `{ "<class>": { delivered, seen_ms, reason, core } }`.
    pub(crate) fn to_json(&self) -> serde_json::Value {
        let map: serde_json::Map<String, serde_json::Value> = self
            .classes()
            .into_iter()
            .map(|c| {
                let entry = self.entry(c);
                (
                    c.as_str().to_string(),
                    serde_json::json!({
                        "delivered": entry.is_some_and(|e| e.delivered),
                        "seen_ms": self.seen_ms(c),
                        "reason": entry.and_then(|e| e.reason.clone()),
                        "core": entry.and_then(|e| e.core.clone()),
                    }),
                )
            })
            .collect();
        serde_json::Value::Object(map)
    }
}

/// Sets `class` to `ms` in the seen-state file, keeping every other key (unknown ones included).
fn write_class(ol_dir: &Path, class: HostClass, ms: i64) -> std::io::Result<()> {
    let path = seen_path(ol_dir);
    let mut map = std::fs::read_to_string(&path)
        .ok()
        .and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
        .and_then(|v| match v {
            serde_json::Value::Object(map) => Some(map),
            _ => None,
        })
        .unwrap_or_default();
    map.insert(class.as_str().to_string(), serde_json::Value::from(ms));
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let body = serde_json::to_string_pretty(&serde_json::Value::Object(map))
        .map_err(std::io::Error::other)?;
    crate::fs_secure::write_owner_only(&path, &body)
}

/// The class `exe` belongs to. `sidecar` is the detected Cline.app `code-sidecar`; `vscode` is the
/// detected VS Code binary. None = the executable could not be read (Unknown, never recorded).
pub(crate) fn classify(
    exe: Option<&Path>,
    sidecar: Option<&Path>,
    vscode: Option<&Path>,
) -> Option<HostClass> {
    let exe = exe?;
    if sidecar.is_some_and(|s| cline_app::same_exe(Some(exe), s)) {
        return Some(HostClass::Hub);
    }
    if vscode.is_some_and(|v| is_under_vscode(exe, v)) || is_vscode_name(exe) {
        return Some(HostClass::VsCode);
    }
    Some(HostClass::Cli)
}

/// VS Code's own executables: `Code`, `code`, `Code - Insiders`, `Code Helper…`, `Code.exe`,
/// `code-insiders`.
fn is_vscode_name(exe: &Path) -> bool {
    let Some(name) = exe.file_name().and_then(|n| n.to_str()) else {
        return false;
    };
    matches!(
        name,
        "Code" | "code" | "Code - Insiders" | "Code.exe" | "code-insiders"
    ) || name.starts_with("Code Helper")
}

/// `exe` lies under the detected VS Code binary's app root: the outermost `*.app` bundle holding it
/// on macOS (the extension host is a helper app nested inside it), else its directory.
fn is_under_vscode(exe: &Path, vscode: &Path) -> bool {
    let root = vscode
        .ancestors()
        .filter(|a| a.extension().is_some_and(|e| e == "app"))
        .last()
        .or_else(|| vscode.parent());
    let Some(root) = root else { return false };
    match (cline_app::canon_key(exe), cline_app::canon_key(root)) {
        (Some(exe), Some(root)) => exe.starts_with(root),
        _ => false,
    }
}

/// OpenLatch's Windows runtime launcher, staged as each host's Windows wrapper: its parent, not
/// itself, is the Cline host.
fn is_launcher(exe: Option<&Path>) -> bool {
    exe.and_then(|e| e.file_name())
        .and_then(|n| n.to_str())
        .is_some_and(|n| {
            HostClass::ALL
                .into_iter()
                .any(|c| n == cline_runtime::wrapper_file_name(c, true))
        })
}

/// The executable of the Cline host behind `pid`: `pid`'s own, or its parent's when `pid` is our
/// launcher. Blocking: the process table may exec a tool.
fn host_exe(table: &dyn ProcessTable, pid: u32) -> Option<PathBuf> {
    let mut info = table.inspect(pid)?;
    if is_launcher(info.exe.as_deref()) {
        info = table.inspect(info.ppid?)?;
    }
    info.exe
}

/// The detected Cline.app `code-sidecar` and VS Code binary.
type DetectedHosts = (Option<PathBuf>, Option<PathBuf>);

/// Inspects `pid` (one level up when it is our launcher) and classifies it against the hosts
/// `detect` finds — called only when the executable's name does not already say VS Code, since
/// detection reads the disk and, for the hub, the process table. Blocking: the process table may
/// exec a tool.
pub(crate) fn resolve_host_class(
    table: &dyn ProcessTable,
    pid: u32,
    detect: impl FnOnce() -> DetectedHosts,
) -> Option<HostClass> {
    let exe = host_exe(table, pid)?;
    if is_vscode_name(&exe) {
        return Some(HostClass::VsCode);
    }
    let (sidecar, vscode) = detect();
    classify(Some(&exe), sidecar.as_deref(), vscode.as_deref())
}

/// This machine's Cline.app sidecar and VS Code binary.
fn detect_hosts() -> DetectedHosts {
    let app = cline_app::detect();
    let sidecar = app.as_ref().map(|a| a.sidecar.clone());
    let vscode = cline_hosts::detect_with(&crate::config::openlatch_dir(), app)
        .into_iter()
        .find(|h| h.class == HostClass::VsCode)
        .map(|h| h.runtime);
    (sidecar, vscode)
}

/// What [`PluginSeen::note`] asks the caller to do about the file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Persist {
    /// Write now.
    Now,
    /// Schedule the one trailing write this long from now.
    After(Duration),
    /// A trailing write is already scheduled; it will carry this timestamp.
    Pending,
}

#[derive(Debug, Default)]
struct ClassSlot {
    latest_ms: i64,
    last_persist: Option<Instant>,
    pending: bool,
}

/// How long a cached pid entry is trusted: a class for [`CACHE_TTL`], an unclassifiable pid for
/// [`NEGATIVE_TTL`].
fn ttl(class: Option<HostClass>) -> Duration {
    if class.is_some() {
        CACHE_TTL
    } else {
        NEGATIVE_TTL
    }
}

/// The daemon's in-memory side of the seen-state: the pid → class cache (unclassifiable pids
/// included), the pids being resolved, the per-class write throttle, and the lock every write of
/// the file holds.
#[derive(Default)]
pub struct PluginSeen {
    cache: Mutex<HashMap<u32, (Option<HostClass>, Instant)>>,
    resolving: Mutex<HashSet<u32>>,
    classes: Mutex<HashMap<HostClass, ClassSlot>>,
    write_lock: Mutex<()>,
}

impl PluginSeen {
    /// `None`: not cached. `Some(None)`: cached as unclassifiable.
    fn cached(&self, pid: u32, now: Instant) -> Option<Option<HostClass>> {
        let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
        cache
            .get(&pid)
            .filter(|(class, at)| now.duration_since(*at) < ttl(*class))
            .map(|(class, _)| *class)
    }

    /// Claims `pid` for resolution; `false` when a resolution of it is already in flight.
    fn begin_resolve(&self, pid: u32) -> bool {
        self.resolving
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .insert(pid)
    }

    /// Caches `pid`'s resolution and ends its in-flight claim.
    fn remember(&self, pid: u32, class: Option<HostClass>, now: Instant) {
        {
            let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
            if cache.len() >= CACHE_MAX {
                cache.retain(|_, (class, at)| now.duration_since(*at) < ttl(*class));
                if cache.len() >= CACHE_MAX {
                    if let Some(oldest) =
                        cache.iter().min_by_key(|(_, (_, at))| *at).map(|(p, _)| *p)
                    {
                        cache.remove(&oldest);
                    }
                }
            }
            cache.insert(pid, (class, now));
        }
        self.resolving
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .remove(&pid);
    }

    /// Sets `class` to `ms` in the seen-state file. Serialized: the file is read, changed and
    /// written back, and two classes written at once must not erase each other.
    fn write(&self, ol_dir: &Path, class: HostClass, ms: i64) -> std::io::Result<()> {
        let _guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
        write_class(ol_dir, class, ms)
    }

    fn note(&self, class: HostClass, now_ms: i64, now: Instant) -> Persist {
        let mut classes = self.classes.lock().unwrap_or_else(|e| e.into_inner());
        let slot = classes.entry(class).or_default();
        slot.latest_ms = slot.latest_ms.max(now_ms);
        if slot.pending {
            return Persist::Pending;
        }
        match slot.last_persist {
            Some(at) if now.duration_since(at) < PERSIST_EVERY => {
                slot.pending = true;
                Persist::After(PERSIST_EVERY - now.duration_since(at))
            }
            _ => {
                slot.last_persist = Some(now);
                Persist::Now
            }
        }
    }

    /// The trailing write is due: mark it done and return the timestamp to write.
    fn flush(&self, class: HostClass, now: Instant) -> i64 {
        let mut classes = self.classes.lock().unwrap_or_else(|e| e.into_inner());
        let slot = classes.entry(class).or_default();
        slot.pending = false;
        slot.last_persist = Some(now);
        slot.latest_ms
    }
}

/// Records that a plugin event arrived from the Cline host whose pid the hook forwarded. Called by
/// the handler after the verdict: a cached pid costs a lookup, and anything that inspects a
/// process or writes the file is spawned. Every failure (an unreadable process, an unwritable
/// file) costs this one record and nothing else.
pub(crate) fn record_plugin_host(state: &Arc<AppState>, pid: u32, now_ms: i64) {
    match state.plugin_seen.cached(pid, Instant::now()) {
        Some(Some(class)) => note_seen(state, class, now_ms),
        Some(None) => {}
        None => {
            if !state.plugin_seen.begin_resolve(pid) {
                return;
            }
            let state = state.clone();
            tokio::spawn(async move {
                let resolved = tokio::task::spawn_blocking(move || {
                    resolve_host_class(&*cline_app::process::table(), pid, detect_hosts)
                })
                .await
                .ok()
                .flatten();
                state.plugin_seen.remember(pid, resolved, Instant::now());
                match resolved {
                    Some(class) => note_seen(&state, class, now_ms),
                    None => tracing::debug!(pid, "Cline plugin host unreadable; not recorded"),
                }
            });
        }
    }
}

/// Feeds the per-class throttle and spawns the write it asks for, if any.
fn note_seen(state: &Arc<AppState>, class: HostClass, now_ms: i64) {
    match state.plugin_seen.note(class, now_ms, Instant::now()) {
        Persist::Now => {
            tokio::spawn(persist(
                state.clone(),
                crate::config::openlatch_dir(),
                class,
                now_ms,
            ));
        }
        Persist::After(delay) => {
            let state = state.clone();
            let ol_dir = crate::config::openlatch_dir();
            tokio::spawn(async move {
                tokio::time::sleep(delay).await;
                let ms = state.plugin_seen.flush(class, Instant::now());
                persist(state, ol_dir, class, ms).await;
            });
        }
        Persist::Pending => {}
    }
}

async fn persist(state: Arc<AppState>, ol_dir: PathBuf, class: HostClass, ms: i64) {
    let written =
        tokio::task::spawn_blocking(move || state.plugin_seen.write(&ol_dir, class, ms)).await;
    if let Ok(Err(e)) = written {
        tracing::warn!(
            code = crate::error::ERR_CLINE_PLUGIN_NOT_LOADED,
            error = %e,
            host = class.as_str(),
            "could not record the Cline plugin host"
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::hooks::cline_app::process::{test_support::FakeTable, ProcInfo};

    fn proc_info(exe: Option<&Path>, ppid: Option<u32>) -> ProcInfo {
        ProcInfo {
            exe: exe.map(Path::to_path_buf),
            args: Vec::new(),
            env: None,
            start: None,
            ppid,
        }
    }

    fn touch(path: &Path) -> PathBuf {
        std::fs::create_dir_all(path.parent().expect("a parent")).expect("mkdir");
        std::fs::write(path, b"").expect("touch");
        path.to_path_buf()
    }

    /// The host that spawned the plugin, classified by its executable. Cline.app's
    /// own sidecar is the hub; VS Code's binaries (by name, or anywhere under the detected VS Code
    /// app) are VS Code; anything else is the CLI; an unreadable executable is Unknown — `None`,
    /// never recorded. OpenLatch's Windows launcher is looked through to its parent.
    #[test]
    fn classify_host_exe() {
        let root = tempfile::tempdir().expect("tempdir");
        let sidecar = touch(
            &root
                .path()
                .join("Cline.app/Contents/Resources/code-sidecar"),
        );
        let vscode = touch(
            &root
                .path()
                .join("Visual Studio Code.app/Contents/MacOS/Electron"),
        );
        let ext_host = touch(&root.path().join(
            "Visual Studio Code.app/Contents/Frameworks/Code Helper (Plugin).app/Contents/MacOS/helper",
        ));
        let node = touch(&root.path().join("bin/node"));

        let classify = |exe: Option<&Path>| classify(exe, Some(&sidecar), Some(&vscode));
        assert_eq!(classify(Some(&sidecar)), Some(HostClass::Hub));
        assert_eq!(
            classify(Some(Path::new(
                "/Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper (Plugin).app/Contents/MacOS/Code Helper (Plugin)"
            ))),
            Some(HostClass::VsCode),
            "VS Code's extension host, by name"
        );
        for name in [
            "Code",
            "code",
            "Code - Insiders",
            "Code.exe",
            "code-insiders",
        ] {
            assert_eq!(
                classify(Some(&Path::new("/opt/vscode").join(name))),
                Some(HostClass::VsCode),
                "{name}"
            );
        }
        assert_eq!(
            classify(Some(&ext_host)),
            Some(HostClass::VsCode),
            "under the detected VS Code app, whatever the helper is called"
        );
        assert_eq!(classify(Some(&node)), Some(HostClass::Cli));
        assert_eq!(
            super::classify(Some(&sidecar), None, None),
            Some(HostClass::Cli),
            "no Cline.app detected: a sidecar-shaped exe is not proof of the hub"
        );
        assert_eq!(classify(None), None, "unreadable → Unknown, never a class");

        // Through the process table: our launcher is looked through to its parent.
        let table = FakeTable::new(1);
        {
            let mut procs = table.procs.lock().unwrap_or_else(|e| e.into_inner());
            procs.insert(
                10,
                proc_info(
                    Some(Path::new("C:/ol/bin/cline-js-runtime-vscode.exe")),
                    Some(20),
                ),
            );
            procs.insert(
                20,
                proc_info(Some(Path::new("C:/VSCode/Code.exe")), Some(1)),
            );
            procs.insert(30, proc_info(Some(&sidecar), Some(1)));
            procs.insert(40, proc_info(Some(&node), Some(1)));
            procs.insert(
                50,
                proc_info(Some(Path::new("C:/ol/bin/cline-js-runtime-hub.exe")), None),
            );
            procs.insert(60, proc_info(None, Some(1)));
        }
        let resolve = |pid| {
            resolve_host_class(&table, pid, || {
                (Some(sidecar.clone()), Some(vscode.clone()))
            })
        };
        assert_eq!(resolve(10), Some(HostClass::VsCode), "launcher → parent");
        assert_eq!(resolve(30), Some(HostClass::Hub));
        assert_eq!(resolve(40), Some(HostClass::Cli));
        assert_eq!(resolve(50), None, "a launcher whose parent is unknown");
        assert_eq!(resolve(60), None, "an unreadable exe");
        assert_eq!(resolve(99), None, "a pid that is gone");
    }

    /// Two classes persisted at once both survive, and a key this build does not know is kept:
    /// every read-modify-write of the file holds one lock.
    #[test]
    fn concurrent_writes_keep_every_class() {
        let root = tempfile::tempdir().expect("tempdir");
        let ol_dir = root.path().to_path_buf();
        let path = seen_path(&ol_dir);
        std::fs::create_dir_all(path.parent().expect("state dir")).expect("state dir");
        std::fs::write(&path, r#"{"future-host": 7}"#).expect("seed");

        let seen = Arc::new(PluginSeen::default());
        let barrier = Arc::new(std::sync::Barrier::new(2));
        let writers: Vec<_> = [HostClass::Hub, HostClass::VsCode]
            .into_iter()
            .map(|class| {
                let (seen, barrier, ol_dir) = (seen.clone(), barrier.clone(), ol_dir.clone());
                std::thread::spawn(move || {
                    barrier.wait();
                    for ms in 1..=50 {
                        seen.write(&ol_dir, class, ms).expect("write");
                    }
                })
            })
            .collect();
        for writer in writers {
            writer.join().expect("writer");
        }

        let raw: serde_json::Value =
            serde_json::from_str(&std::fs::read_to_string(&path).expect("read")).expect("json");
        assert_eq!(raw["hub"], 50, "{raw}");
        assert_eq!(raw["vscode"], 50, "{raw}");
        assert_eq!(raw["future-host"], 7, "{raw}");
    }
}