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
//! Cline.app's hub, from the daemon (06 §5, Cline I-5 plan 02 §4): start it with what is desired — the relay's
//! capture when it is live, the plugin env when delivery to the hub is wanted — restart it by the D-46 rules, and
//! take everything of ours off it on a graceful stop.
//!
//! ONE owner: the `cline-plugin-delivery` task (`daemon::cline_delivery`) calls [`reconcile`]; the relay's wiring
//! loop only publishes its capture state. The decisions are pure ([`decide`], [`hub_management_wanted`]); every
//! process-table call goes through `cline_app`, whose state detector is the one doctor reads too.

use crate::config::Config;
use crate::error::OlError;
use crate::hooks::bindings::cline::ClineBinding;
use crate::hooks::cline_app::{
    self,
    process::{self, ProcessTable},
    ClineApp, Desired, HubState, RestartOutcome, RestartWhen, StaleWhy, HUB_STARTUP_POLL,
    IDLE_RECHECK,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HubAction {
    Nothing,
    Start,
    RestartIfIdle,
    /// Every restart respawns with `hub_env(port, desired)`; "clean" is simply the empty Desired.
    RestartNow,
}

/// What a hub step left in place (plan 02 §3 step 5). Only [`HubOutcome::carries_desired`] outcomes let the delivery
/// state say the hub is delivered.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum HubOutcome {
    /// Nothing needed doing: the hub already carries what is desired.
    AlreadyOk,
    Started,
    Restarted,
    /// The restart waits for a busy hub to go idle.
    DeferredBusy,
    /// The restart waits: the hub's idleness could not be read.
    IdleUnknown,
    Failed(String),
    /// The port is held by a process OpenLatch does not manage.
    NotManaged,
}

impl HubOutcome {
    /// The hub now carries the desired env.
    pub(crate) fn carries_desired(&self) -> bool {
        matches!(self, Self::AlreadyOk | Self::Started | Self::Restarted)
    }

    fn of(r: &Result<RestartOutcome, OlError>) -> Self {
        match r {
            Ok(RestartOutcome::Started { .. }) => Self::Started,
            Ok(RestartOutcome::Restarted { .. }) => Self::Restarted,
            Ok(RestartOutcome::DeferredBusy { .. }) => Self::DeferredBusy,
            Ok(RestartOutcome::IdleUnknown(_)) => Self::IdleUnknown,
            Ok(RestartOutcome::LeftForeign { .. }) => Self::NotManaged,
            Ok(RestartOutcome::NothingToDo) => Self::AlreadyOk,
            Err(e) => Self::Failed(e.message.clone()),
        }
    }
}

/// The gate (INDEX trap 4): a hub on the default port is ONE per machine, like the shell profile,
/// so only the machine's own install manages it. A relocated hub (`CLINE_HUB_PORT` off the default,
/// beside a relocated Cline store) belongs to the isolated instance that relocated it.
pub(super) fn hub_management_wanted(
    owns_wiring: bool,
    owns_machine: bool,
    hub_relocated: bool,
) -> bool {
    owns_wiring && (owns_machine || hub_relocated)
}

/// Whether THIS instance manages Cline.app's hub. The one question the delivery task, `release_hub`
/// and doctor all ask.
pub(crate) fn manages_hub(
    config: &Config,
    binding: &dyn crate::hooks::binding::AgentBinding,
) -> bool {
    hub_management_wanted(
        super::owns_wiring_for(config, binding),
        crate::supervision::owns_machine_supervision(),
        cline_app::hub_port() != cline_app::DEFAULT_HUB_PORT && !binding.config_is_machine_global(),
    )
}

/// The hub-management gate with no binding in hand (Cline's store can be gone while our hub still
/// runs): nothing says the hub is relocated, so only the machine install manages it.
pub(super) fn manages_hub_without_binding(config: &Config) -> bool {
    hub_management_wanted(
        config.model_relay.owns_agent_wiring(),
        crate::supervision::owns_machine_supervision(),
        false,
    )
}

/// Does this state carry something of THIS install's (so a release must take it off)? A record
/// exists only for a hub started with something of ours.
fn carries_ours(s: &HubState) -> bool {
    matches!(
        s,
        HubState::Ours { .. }
            | HubState::Stale {
                why: StaleWhy::OlderApp { .. } | StaleWhy::ProxyChanged | StaleWhy::EnvChanged,
                ..
            }
    )
}

/// Pure (plan 02 §4.3, owner ruling R1). A change that removes or changes capture restarts NOW (a
/// hub aimed at a dead or moved relay port fails every call — I-4 G4); a change that only adds or
/// removes the plugin env, or adds capture, restarts only when idle (the kill switch included).
/// Exhaustive: 7 state shapes × capture{None,Some} × plugin{None,Some} = 28 rows, each asserted by
/// `decide_covers_every_state_28`.
pub(crate) fn decide(state: &HubState, desired: &Desired) -> HubAction {
    let empty = desired.is_empty();
    match state {
        HubState::Absent if empty => HubAction::Nothing,
        HubState::Absent => HubAction::Start,
        HubState::Ours { .. } => HubAction::Nothing,
        // Doctor names it; never signalled.
        HubState::Foreign { .. } => HubAction::Nothing,
        // Cline.app's own hub, with or without a customer's proxy: never when nothing of ours is
        // desired, and then only when idle (the R2 invariant).
        HubState::Stale {
            why: StaleWhy::NoOurEnv,
            ..
        } if empty => HubAction::Nothing,
        HubState::Stale {
            why: StaleWhy::NoOurEnv,
            ..
        } => HubAction::RestartIfIdle,
        // With an empty Desired this is a hub carrying only our plugin (step 4 already caught one
        // carrying stale capture), so idle-only too.
        HubState::Stale {
            why: StaleWhy::OlderApp { .. },
            ..
        } => HubAction::RestartIfIdle,
        // G4: it carries our capture and that capture moved or came off; busy or not.
        HubState::Stale {
            why: StaleWhy::ProxyChanged,
            ..
        } => HubAction::RestartNow,
        HubState::Stale {
            why: StaleWhy::EnvChanged,
            ..
        } => HubAction::RestartIfIdle,
    }
}

/// The delivery task's hub step (plan 02 §3 step 5): classify against `desired`, adopt an
/// unrecorded `Ours` hub (the daemon's job only — `classify` is pure), and act. Blocking; the task
/// runs it in `spawn_blocking`. Returns the action decided and what it achieved (a table that could
/// not be read is `Foreign { pid: 0 }`, so `Nothing` → `NotManaged`).
pub(crate) fn reconcile(
    t: &dyn ProcessTable,
    app: &ClineApp,
    port: u16,
    desired: &Desired,
    recheck: std::time::Duration,
    poll: std::time::Duration,
) -> (HubAction, HubOutcome) {
    let (state, info) = cline_app::hub_state_inspected(t, port, Some(app), desired);
    // §3.3: removed only on Absent or a readable other pid; never on Foreign { pid: 0 }.
    cline_app::forget_record_unless(&state);
    if let (HubState::Ours { pid }, Some(info)) = (&state, info.as_ref()) {
        if let Err(e) = cline_app::adopt_record(*pid, info, app, port, desired) {
            log(Err(e));
        }
    }
    let action = decide(&state, desired);
    let r = match action {
        HubAction::Nothing => {
            // `decide` answers Nothing for Foreign, Ours, and — with nothing desired — Absent or a hub with nothing
            // of ours on it: only Foreign leaves the desired env undelivered.
            let outcome = if matches!(state, HubState::Foreign { .. }) {
                HubOutcome::NotManaged
            } else {
                HubOutcome::AlreadyOk
            };
            return (action, outcome);
        }
        HubAction::Start => cline_app::start_hub_with(t, app, desired, port, poll)
            .map(|pid| RestartOutcome::Started { pid }),
        // Still the A-2 guard: restart_hub_with reaches terminate only for Ours/Stale, i.e. the
        // detected app's own code-sidecar (§3.4 step 2); Foreign answers LeftForeign.
        HubAction::RestartIfIdle => {
            cline_app::restart_hub_with(t, app, desired, RestartWhen::IfIdle, port, recheck, poll)
        }
        HubAction::RestartNow => {
            cline_app::restart_hub_with(t, app, desired, RestartWhen::Now, port, recheck, poll)
        }
    };
    let outcome = HubOutcome::of(&r);
    log(r);
    (action, outcome)
}

/// Every variant's fields are read here: fields read only through a derived Debug are "never
/// read" under `-D warnings`.
fn log(r: Result<RestartOutcome, OlError>) {
    match r {
        // start_hub_with already logs the info line.
        Ok(RestartOutcome::Started { pid }) => tracing::debug!(pid, "hub start acknowledged"),
        Ok(RestartOutcome::Restarted { old, new }) => {
            tracing::info!(old, new = ?new, "Cline.app hub restarted");
        }
        Ok(RestartOutcome::DeferredBusy { clients }) => tracing::info!(
            clients,
            "Cline.app hub does not carry OpenLatch's environment yet; quit Cline.app and keep it \
             closed until the hub is restarted (within a minute) — the hub outlives Cline.app"
        ),
        Ok(RestartOutcome::IdleUnknown(w)) => tracing::info!(
            reason = %w,
            "Cline.app hub does not carry OpenLatch's environment and its idleness is unknown; \
             it is left running"
        ),
        Ok(RestartOutcome::LeftForeign { pid }) => tracing::info!(
            pid,
            "Cline.app hub port held by a process OpenLatch does not manage"
        ),
        Ok(RestartOutcome::NothingToDo) => {}
        Err(e) => tracing::warn!(
            code = %e.code,
            error = %e.message,
            "Cline.app hub could not be managed"
        ),
    }
}

/// Graceful stop, uninstall and post-death: restart an owned hub NOW **without capture and without
/// plugin** (an empty Desired) — the daemon is going away, and nothing will maintain the tree or the
/// relay the hub would point at. No binding may be in hand (Cline's store can be gone while our hub
/// still runs), so the gate falls back to the binding-free predicate the CLI uses.
pub(crate) fn release_hub(config: &Config) {
    let managed = crate::hooks::detect_agents()
        .into_iter()
        .find(|a| a.agent_type() == ClineBinding::AGENT_TYPE)
        .map_or(manages_hub_without_binding(config), |a| {
            manages_hub(config, &*a.binding)
        });
    if !managed {
        return;
    }
    release_with(
        &*cline_app::process::table(),
        cline_app::detect().as_ref(),
        cline_app::hub_port(),
        IDLE_RECHECK,
        HUB_STARTUP_POLL,
    );
}

/// Restarts a hub carrying something of ours NOW with the empty Desired (stops it when the app is gone). The caller
/// has already applied the hub-management gate. `AlreadyOk` when nothing of ours is on it.
pub(super) fn release_with(
    t: &dyn ProcessTable,
    app: Option<&ClineApp>,
    port: u16,
    recheck: std::time::Duration,
    poll: std::time::Duration,
) -> HubOutcome {
    let empty = Desired::default();
    let (state, info) = cline_app::hub_state_inspected(t, port, app, &empty);
    if !carries_ours(&state) {
        return HubOutcome::AlreadyOk;
    }
    match app {
        Some(app) => {
            let r =
                cline_app::restart_hub_with(t, app, &empty, RestartWhen::Now, port, recheck, poll);
            let outcome = HubOutcome::of(&r);
            log(r);
            outcome
        }
        None => {
            // The app uninstalled under a running hub: stop ours, start nothing. With no app,
            // §3.4 step 2 lets only the record's own process (pid, exe, start) be non-Foreign.
            // Signalled only while the pid is still the process just validated (C1).
            let outcome = match (state, info.as_ref()) {
                (HubState::Ours { pid } | HubState::Stale { pid, .. }, Some(info)) => {
                    match process::terminate(t, pid, Some(&process::ProcIdentity::of(info))) {
                        Ok(()) => HubOutcome::Restarted,
                        Err(e) => HubOutcome::Failed(e),
                    }
                }
                _ => HubOutcome::NotManaged,
            };
            cline_app::remove_record();
            outcome
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::hooks::cline_app::test_fixture::{
        clean_env, hub_fx, listen, our_env, record_exists, seed_record,
    };

    #[test]
    fn hub_management_wanted_needs_wiring_and_a_hub_of_its_own() {
        // (owns_wiring, owns_machine, hub_relocated) -> managed
        let rows = [
            ((true, true, true), true),
            ((true, true, false), true),
            // A relocated instance manages only a relocated hub: the default-port hub is the
            // machine's.
            ((true, false, true), true),
            ((true, false, false), false),
            ((false, true, true), false),
            ((false, true, false), false),
            ((false, false, true), false),
            ((false, false, false), false),
        ];
        for ((w, m, r), want) in rows {
            assert_eq!(
                hub_management_wanted(w, m, r),
                want,
                "wiring={w} machine={m} relocated={r}"
            );
        }
    }

    /// Plan 02 §4.3's table, every cell: 7 state shapes × capture{None,Some} × plugin{None,Some}.
    #[test]
    fn decide_covers_every_state_28() {
        use crate::hooks::cline_app::{PluginEnv, ProxyEnvVars};
        use HubAction::{Nothing, RestartIfIdle, RestartNow, Start};

        let stale = |why| HubState::Stale { pid: 5, why };
        let older = || StaleWhy::OlderApp {
            hub: "0.0.33".into(),
            installed: "0.0.34".into(),
        };
        let capture = ProxyEnvVars {
            proxy_url: "http://127.0.0.1:7600".into(),
            ca_pem: "/ol/ca.pem".into(),
            no_proxy: "127.0.0.1:7600".into(),
        };
        let plugin = PluginEnv {
            wrapper_path: "/ol/cline-plugin-bootstrap/wrapper".into(),
            runtime: "/ol/bin/cline-js-runtime-hub".into(),
        };
        // Columns: (None,None), (None,Some), (Some,None), (Some,Some) — (capture, plugin).
        let desired = [
            Desired::default(),
            Desired {
                capture: None,
                plugin: Some(plugin.clone()),
            },
            Desired {
                capture: Some(capture.clone()),
                plugin: None,
            },
            Desired {
                capture: Some(capture),
                plugin: Some(plugin),
            },
        ];
        let table = [
            (HubState::Absent, [Nothing, Start, Start, Start]),
            (
                HubState::Ours { pid: 5 },
                [Nothing, Nothing, Nothing, Nothing],
            ),
            (
                HubState::Foreign { pid: 5 },
                [Nothing, Nothing, Nothing, Nothing],
            ),
            (
                stale(StaleWhy::NoOurEnv),
                [Nothing, RestartIfIdle, RestartIfIdle, RestartIfIdle],
            ),
            (
                stale(older()),
                [RestartIfIdle, RestartIfIdle, RestartIfIdle, RestartIfIdle],
            ),
            (
                stale(StaleWhy::ProxyChanged),
                [RestartNow, RestartNow, RestartNow, RestartNow],
            ),
            (
                stale(StaleWhy::EnvChanged),
                [RestartIfIdle, RestartIfIdle, RestartIfIdle, RestartIfIdle],
            ),
        ];
        let rows: Vec<(&HubState, &Desired, HubAction)> = table
            .iter()
            .flat_map(|(state, wants)| {
                desired
                    .iter()
                    .zip(wants.iter())
                    .map(move |(d, want)| (state, d, *want))
            })
            .collect();
        assert_eq!(rows.len(), 28);
        for (state, d, want) in rows {
            assert_eq!(decide(state, d), want, "{state:?} desired={d:?}");
        }
    }

    #[test]
    fn a_relocated_instance_never_reaches_the_process_table() {
        let _state = crate::config::OPENLATCH_DIR_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let root = tempfile::tempdir().expect("tempdir");
        let ol = root.path().join("openlatch");
        std::fs::create_dir_all(&ol).expect("mkdir");
        let apps = root.path().join("apps");
        crate::hooks::cline_app::write_fake_bundle(
            &apps,
            crate::hooks::cline_app::BUNDLE_ID,
            "0.0.34",
        );
        let _seam = crate::hooks::cline::cline_isolated([
            ("OPENLATCH_DIR", Some(ol.into_os_string())),
            (
                crate::hooks::cline::STORE_DIR_ENV,
                Some(root.path().join("store").into_os_string()),
            ),
            (
                crate::hooks::cline::DATA_DIR_ENV,
                Some(root.path().join("data").into_os_string()),
            ),
            (
                crate::hooks::cline::ASSETS_DIR_ENV,
                Some(root.path().join("assets").into_os_string()),
            ),
            (
                crate::hooks::cline_app::APP_DIR_ENV,
                Some(apps.into_os_string()),
            ),
            // The default-port hub is the machine's, whatever the shell running the tests exports.
            (crate::hooks::cline_app::HUB_PORT_ENV, None),
        ]);
        let _table = cline_app::process::install_for_tests(std::sync::Arc::new(
            cline_app::process::test_support::PanickingTable,
        ));
        assert!(
            cline_app::detect().is_some(),
            "the app is there to be managed"
        );
        assert!(!crate::supervision::owns_machine_supervision());
        release_hub(&Config::load(None, None, false).expect("config"));
    }

    /// Plan 02 §4.4: a release takes EVERYTHING of ours off — capture and plugin — with an empty Desired, Now,
    /// and the respawn for a busy hub is the unrecorded clean spawn (`hub_mode_env(port)` only).
    #[test]
    fn release_takes_plugin_off_an_owned_hub() {
        use crate::hooks::cline_app::{test_fixture::seed_record_for, PluginEnv, ProxyEnvVars};
        let fx = hub_fx();
        const RELAY: &str = "http://127.0.0.1:7600";

        // A BUSY Ours hub carrying capture AND the plugin env → terminated NOW, respawned clean for the attached
        // client: exactly the hub-mode env, no capture, no plugin, and no record.
        let plugin = PluginEnv {
            wrapper_path: fx
                .root
                .path()
                .join("cline-plugin-bootstrap")
                .join("wrapper"),
            runtime: fx.root.path().join("bin").join("cline-js-runtime-hub"),
        };
        for recorded in [
            Desired {
                capture: Some(ProxyEnvVars::for_relay(7600)),
                plugin: Some(plugin.clone()),
            },
            Desired {
                capture: None,
                plugin: Some(plugin.clone()),
            },
        ] {
            let t = fx.fake();
            seed_record_for(&fx, 500, 1, "0.0.34", &recorded);
            listen(&t, 500, fx.hub_info(Some(clean_env())));
            *t.clients.lock().unwrap() = Ok(vec![500, 4242]);
            release_with(&t, Some(&fx.app), fx.port, IDLE_RECHECK, HUB_STARTUP_POLL);
            assert_eq!(*t.terminated.lock().unwrap(), vec![500], "{recorded:?}");
            let spawns = t.spawns.lock().unwrap().clone();
            assert_eq!(spawns.len(), 1, "{recorded:?}");
            assert_eq!(
                spawns[0].3,
                cline_app::hub_env(fx.port, &Desired::default()),
                "{recorded:?}: the release spawn carries the hub-mode env only"
            );
            assert!(
                !spawns[0].3.iter().any(|(k, _)| {
                    crate::core::login_env::ALLOWED.contains(&k.as_str())
                        || k.eq_ignore_ascii_case("HTTPS_PROXY")
                }),
                "{recorded:?}"
            );
            assert!(
                !record_exists(),
                "{recorded:?}: the clean spawn is unrecorded"
            );
        }

        // An Ours hub → terminated, and the record removed.
        let t = fx.fake();
        seed_record(&fx, 500, 1, "0.0.34", RELAY);
        listen(&t, 500, fx.hub_info(Some(clean_env())));
        *t.clients.lock().unwrap() = Ok(vec![500]);
        release_with(&t, Some(&fx.app), fx.port, IDLE_RECHECK, HUB_STARTUP_POLL);
        assert_eq!(*t.terminated.lock().unwrap(), vec![500]);
        assert!(!record_exists());

        // Stale { NoOurEnv } → untouched.
        let t = fx.fake();
        listen(&t, 500, fx.hub_info(Some(clean_env())));
        release_with(&t, Some(&fx.app), fx.port, IDLE_RECHECK, HUB_STARTUP_POLL);
        assert!(t.terminated.lock().unwrap().is_empty());
        assert!(t.spawns.lock().unwrap().is_empty());

        // No app + Ours (the record matches pid, exe and start) → terminated, nothing started.
        let t = fx.fake();
        seed_record(&fx, 500, 1, "0.0.34", RELAY);
        listen(&t, 500, fx.hub_info(Some(clean_env())));
        release_with(&t, None, fx.port, IDLE_RECHECK, HUB_STARTUP_POLL);
        assert_eq!(*t.terminated.lock().unwrap(), vec![500]);
        assert!(t.spawns.lock().unwrap().is_empty());
        assert!(!record_exists());

        // No app + the record's pid and exe, another start, and our variables → Foreign.
        let t = fx.fake();
        seed_record(&fx, 500, 1, "0.0.34", RELAY);
        let mut info = fx.hub_info(Some(our_env(RELAY)));
        info.start = Some(2);
        listen(&t, 500, info);
        assert_eq!(
            cline_app::hub_state_with(&t, fx.port, None, &Desired::default()),
            HubState::Foreign { pid: 500 }
        );
        release_with(&t, None, fx.port, IDLE_RECHECK, HUB_STARTUP_POLL);
        assert!(t.terminated.lock().unwrap().is_empty());
        assert!(t.spawns.lock().unwrap().is_empty());
    }
}