openlatch-client 0.5.2

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
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
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
//! The agent binding contract — one trait every supported agent answers, and
//! the detector that constructs them.
//!
//! `Agent Binding Architecture` §1 states the containment test: *if a change
//! requires editing shared decision code, the line is in the wrong place and we
//! move the line*. Every question shared code used to ask in Claude Code's own
//! shape is a method here.

use std::path::{Path, PathBuf};
use std::sync::Arc;

use serde_json::Value;

use crate::core::hook_state::marker::OpenlatchMarker;
use crate::model_relay::wire_format::WireFormat;

use super::bindings::claude_code::ClaudeCodeBinding;
use super::bindings::cline::ClineBinding;
use super::bindings::codex_cli::CodexCliBinding;
use super::{AgentKind, DetectedAgent};

/// Everything the client needs to know about one agent.
///
/// Object-safe by construction: shared code holds an `Arc<dyn AgentBinding>`
/// and never matches on which agent it has.
pub trait AgentBinding: Send + Sync {
    /// CloudEvents `source` wire value: `"claude-code"`, `"codex-cli"`.
    ///
    /// `'static` rather than a borrowed `&str`: `Check.agent` is
    /// `Option<&'static str>` and is fed from here.
    fn agent_type(&self) -> &'static str;

    /// Human-facing label: `"Claude Code"`.
    ///
    /// Not derivable from [`agent_type`](Self::agent_type) — that is the wire
    /// string, and rendering `Agent: claude-code (…)` would change human
    /// output. On the trait rather than a `match kind` at the call site,
    /// because a match is a second per-agent mapping that can drift from the
    /// binding.
    fn display_name(&self) -> &'static str;

    /// The agent's configuration directory.
    fn config_dir(&self) -> PathBuf;

    /// The file hook registrations are written into.
    fn hook_config_path(&self) -> PathBuf;

    /// **What shape** that surface is: one config file, or a directory whose
    /// entries are the registrations.
    ///
    /// [`hook_config_path`](Self::hook_config_path) answers *where*; every
    /// caller reading it has had to guess *what*. `Path::exists()` is true for
    /// a directory, so a file-shaped caller sails past its own guard and reads,
    /// rewrites or exfiltrates a directory — which is why five subsystems each
    /// grew a separate guard against the one binding whose hook surface is one.
    /// This asks that question once, in a shape the compiler makes exhaustive:
    /// a sixth caller cannot re-derive the assumption, it has to answer the
    /// `match`.
    ///
    /// Defaulted to `ConfigFile(hook_config_path())` — what every file-shaped
    /// binding already meant — so those bindings are unchanged by this method
    /// existing, and `hook_config_path()` stays the right answer for them and
    /// for `model_relay_config_path`'s routing.
    fn hook_surface(&self) -> HookSurface {
        HookSurface::ConfigFile(self.hook_config_path())
    }

    /// The directory holding this agent's OpenLatch **enforcement plugin**,
    /// when the agent has a plugin lane at all.
    ///
    /// `None` means it has none — a question that does not apply, never a
    /// failure — which is why it is defaulted rather than added to every
    /// binding. Cline is the one agent that answers it today: its file-hook
    /// lane can capture but cannot refuse, so the verdict is delivered by a
    /// plugin instead, and [`crate::hooks::cline_plugin`] writes it.
    ///
    /// **The directory, not the file**, because the directory's *name* is the
    /// plugin id Cline matches against `disabledPlugins`. The artefact inside
    /// it is `cline_plugin::entry_path(dir)`.
    ///
    /// Defaulted to `None` for the same reason
    /// [`hook_surface`](Self::hook_surface) is defaulted: a binding with no
    /// plugin lane is unchanged by this method existing, and — the sharper half
    /// — a fixture that has not opted in cannot have a plugin written anywhere
    /// near a real agent store.
    fn plugin_surface(&self) -> Option<PathBuf> {
        None
    }

    /// Whether this agent's hook surface can be written at all.
    ///
    /// `false` is a **declaration, not a failure**: the agent is installed,
    /// detected and reported, and this build writes nothing into it. Every
    /// mutation path asks this before it acts — the two primitives
    /// [`install_hooks`](crate::hooks::install_hooks) and
    /// [`remove_hooks`](crate::hooks::remove_hooks) first of all, so a caller
    /// that forgets cannot reach a write.
    ///
    /// Defaulted to `true` on purpose. The two agents this build installs into
    /// answer it without being edited, which is what keeps their bindings
    /// byte-identical across the change that introduced this method — and a
    /// binding that genuinely cannot be written into has to say so out loud.
    ///
    /// It is also what makes an empty
    /// [`hook_event_types`](Self::hook_event_types) honest. With
    /// `installable() == true`, an empty event list walks the whole install:
    /// it creates the settings file, writes the daemon's bearer token into it
    /// and registers no hook at all, then reports success. That is not a
    /// hypothetical — it is the exact path this method exists to cut off.
    fn installable(&self) -> bool {
        true
    }

    /// Native event names, PascalCase, as written into the agent's config.
    /// THE one list of events install writes.
    fn hook_event_types(&self) -> &'static [&'static str];

    /// Which of *this* agent's events must be present for capture to work.
    ///
    /// An agent that registers a different set is judged on its set, never on
    /// Claude Code's.
    fn load_bearing_events(&self) -> &'static [&'static str];

    /// How a spawned hook — a fresh process, per event — finds its daemon.
    ///
    /// The channel is the agent's, not ours: some agents forward environment
    /// variables we pin in their config, some cannot express that at all.
    fn daemon_channel(&self) -> DaemonChannel;

    /// Is enforcement actually **armed**, or merely installed? Two different
    /// claims, and only the binding knows the difference. The common detector
    /// asks every agent this one question and renders the answer uniformly —
    /// never a second rendering path per agent.
    fn liveness(&self) -> LivenessReport;

    /// Owns every per-agent quirk (Claude Code's `"matcher": ""`) and the
    /// ownership marker.
    fn build_hook_entry(
        &self,
        event: &str,
        binary: &Path,
        port: u16,
        marker: &OpenlatchMarker,
    ) -> Value;

    /// True when [`config_dir`](Self::config_dir) resolves to the
    /// machine-global location.
    fn config_is_machine_global(&self) -> bool;

    /// What this agent's hook protocol can and cannot express.
    fn capabilities(&self) -> BindingCapabilities;

    /// How the agent is pointed at the model relay, when it has a request
    /// plane at all. `None` means it has none — a question that does not apply,
    /// never a failure.
    fn model_relay_wiring(&self) -> Option<ModelRelayWiring>;

    /// The agent's provider slots, when it carries many providers at once and
    /// each is wired to its own relay endpoint
    /// ([`crate::hooks::provider_endpoints`]).
    ///
    /// Independent of [`model_relay_wiring`](Self::model_relay_wiring), which
    /// names ONE slot on the main relay port. Defaulted to `None`, so an agent
    /// with a single slot is unchanged by this method existing.
    fn provider_endpoints(
        &self,
    ) -> Option<&'static dyn crate::hooks::provider_endpoints::ProviderEndpoints> {
        None
    }
}

/// The shape of an agent's hook surface — see [`AgentBinding::hook_surface`].
///
/// Deliberately not a path newtype with a boolean beside it: the *variant* is
/// the fact callers branch on, and the path it carries is the one to act on for
/// that shape. A caller that only wants the path still has
/// [`AgentBinding::hook_config_path`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookSurface {
    /// One config file holding every registration: Claude Code's
    /// `settings.json`, Codex CLI's `hooks.json`.
    ConfigFile(PathBuf),
    /// A directory whose entries **are** the registrations. Cline discovers
    /// executable hook scripts by filename under its asset root's `Hooks/`;
    /// there is no file to rewrite, so nothing JSON-shaped may run against it.
    Directory(PathBuf),
}

/// How an agent is wired to the model relay.
#[derive(Debug, Clone)]
pub struct ModelRelayWiring {
    /// The provider protocol this agent's request plane speaks. Keyed to the
    /// model relay's own registry, so the map key, the upstream and the
    /// `wireformat` attribute cannot drift apart.
    pub wire_format: WireFormat,
    /// The mechanism that carries the base URL.
    pub endpoint: EndpointConvention,
    /// Always `"x-openlatch-install-id"`.
    ///
    /// **This is where the writer reads the name from** — the merge, the strip
    /// and the `http_headers` entry all take it from here rather than from a
    /// constant of their own, so no shared writer carries one agent's
    /// vocabulary. `model_relay::proxy` holds the matching read-side constant.
    pub install_id_header: &'static str,
}

/// The mechanism an agent offers for naming its model provider.
#[derive(Debug, Clone)]
pub enum EndpointConvention {
    /// Claude Code: `env.ANTHROPIC_BASE_URL` + `env.ANTHROPIC_CUSTOM_HEADERS`.
    EnvVars {
        /// Env var name carrying the base URL.
        base_url: &'static str,
        /// Env var name carrying the extra static headers.
        headers: &'static str,
    },
    /// Codex: a `[model_providers.<name>]` table in `config.toml`.
    TomlProvider {
        /// The provider table's name.
        provider_name: &'static str,
        /// The `wire_api` value that table declares.
        wire_api: &'static str,
    },
}

/// How the hook process is handed its port and bearer token.
#[derive(Debug, Clone, Copy)]
pub enum DaemonChannel {
    /// The agent forwards named environment variables that install pins into
    /// its config.
    EnvVars {
        /// Env var name carrying the daemon bearer token.
        token: &'static str,
        /// Env var name carrying the daemon port.
        port: &'static str,
    },
    /// The agent cannot forward environment variables, so the hook is told
    /// where to look on its own command line and reads both secrets from that
    /// directory. The token *value* never reaches the config — only a path.
    OpenlatchDirArg,
}

/// What a binding can say about whether it is armed.
///
/// `installed` is a file fact; `armed` is an enforcement fact. Rendering the
/// first as the second is the failure *off is never a pass* exists to stop.
#[derive(Debug, Clone)]
pub struct LivenessReport {
    /// `None` — this build cannot tell, because there is nothing to tell:
    /// installed *is* armed. The renderer pushes nothing.
    /// `Some(false)` — installed and provably not enforcing.
    /// `Some(true)` — proven armed.
    pub armed: Option<bool>,
    /// Why, in the agent's own terms. Feeds the `Check`'s detail.
    pub detail: Option<String>,
    /// The actionable remedy when `armed == Some(false)` — **mandatory** in
    /// that state: `Check::validate` returns a contract violation for a
    /// `requires_remedy()` state carrying a code but no remedy.
    pub remedy: Option<String>,
    /// The `OL-XXXX` code stamped on the `Check` when `armed == Some(false)`.
    /// Not decoration: a failed `Check` without one fails `Check::validate`.
    pub code: Option<&'static str>,
}

/// What an agent's hook protocol can express.
#[derive(Debug, Clone, Copy)]
pub struct BindingCapabilities {
    /// Subset of `allow` | `ask` | `deny` this agent can be told.
    pub expressible: &'static [&'static str],
    /// Whether a verdict can rewrite the tool call's arguments.
    pub can_mutate_arguments: bool,
    /// What the agent does when the hook itself fails.
    pub native_failure_mode: FailureMode,
    /// Whether the settings file is administrator-owned.
    pub admin_owned_settings: bool,
    /// Whether the agent names its own session inside the request body.
    pub declares_session_in_request: bool,
}

/// What an agent does when its hook fails.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureMode {
    /// The tool call proceeds.
    FailOpen,
    /// The tool call is refused.
    FailClosed,
    /// Undocumented, or not observed.
    Unknown,
}

/// Display names of the agents **this build can detect** — the bindings
/// [`detect_all`] actually constructs.
///
/// Not the supported wire vocabulary. That is the eight-name
/// `crate::generated::known_values::SCHEMA_AGENT_TYPES`, generated from
/// `schemas/enums.schema.json`. Two lists, two different questions: *what can I
/// detect* versus *what values are valid on the wire*. Do not merge them.
/// A plain const array, checked by nothing: adding a binding to [`detect_all`]
/// and forgetting a name here compiles, ships, and leaves an operator reading a
/// remedy that does not mention the agent they have.
/// `agent_not_found_remedy_names_every_detectable_agent` is the only thing
/// standing behind it, and it asserts the remedy names each of these — not that
/// this list is complete.
pub const DETECTABLE_AGENT_NAMES: &[&str] = &["Claude Code", "Codex CLI", "Cline"];

/// Every agent installed on this host, in detection order.
///
/// Declaration order **is** detection order and is load-bearing: callers that
/// legitimately want one agent take the first.
pub fn detect_all() -> Vec<DetectedAgent> {
    let mut found = Vec::new();
    if let Some(b) = ClaudeCodeBinding::detect() {
        found.push(DetectedAgent {
            kind: AgentKind::ClaudeCode,
            binding: Arc::new(b),
        });
    }
    if let Some(b) = CodexCliBinding::detect() {
        found.push(DetectedAgent {
            kind: AgentKind::CodexCli,
            binding: Arc::new(b),
        });
    }
    // Last, and it matters that it is last. Declaration order is detection
    // order, `detect_agent()` takes the first, and Cline is the one binding
    // here that declares `installable() == false` — so a host running Claude
    // Code and Cline must not hand a singular caller the agent this build
    // writes nothing into.
    if let Some(b) = ClineBinding::detect() {
        found.push(DetectedAgent {
            kind: AgentKind::Cline,
            binding: Arc::new(b),
        });
    }
    found
}

#[cfg(test)]
pub mod test_support {
    //! A second `AgentBinding` that exists ONLY so multi-agent tests can be
    //! written against a build that detects one agent. Never constructed by
    //! [`detect_all`](super::detect_all).

    use super::*;

    /// The two-agent fixture every multi-agent test in this repo is written
    /// against: the real Claude binding, plus a `cursor` [`FakeBinding`], both
    /// rooted under `root` with their config directories created.
    ///
    /// Seeding the two `settings.json` files is deliberately left to the
    /// caller, because that is the one axis the suites genuinely differ on:
    /// `doctor_rescue` needs them only to EXIST (its `found` predicate is
    /// `settings_path().exists()`), while `doctor_fix` needs their CONTENTS to
    /// be dead or healthy per test. Everything above that line is the same
    /// fixture, and it was written out twice before this helper existed —
    /// which is what plan 02 §5 meant by "the fixture §6 reuses".
    pub fn two_detected_agents(root: &std::path::Path) -> Vec<crate::hooks::DetectedAgent> {
        let claude_dir = root.join("claude");
        let cursor_dir = root.join("cursor");
        for dir in [&claude_dir, &cursor_dir] {
            std::fs::create_dir_all(dir).expect("fixture dirs");
        }
        vec![
            crate::hooks::DetectedAgent {
                kind: crate::hooks::AgentKind::ClaudeCode,
                binding: std::sync::Arc::new(
                    crate::hooks::bindings::claude_code::ClaudeCodeBinding {
                        settings_path: claude_dir.join("settings.json"),
                        claude_dir,
                    },
                ),
            },
            crate::hooks::DetectedAgent {
                // Deliberately still `ClaudeCode`, now that `AgentKind::Cline`
                // exists and is the obvious-looking home for a second fake.
                // Retargeting it there would make this fixture indistinguishable
                // from the real Cline agent wherever `kind` is read — the
                // `Debug` impl at `hooks/mod.rs` prints it — which is the
                // fake-versus-real ambiguity the new variant was added to
                // remove. The fake also answers `installable() == true`, and
                // Cline answers `false`; one of the two facts would have to be
                // a lie.
                kind: crate::hooks::AgentKind::ClaudeCode,
                binding: std::sync::Arc::new(FakeBinding {
                    agent_type: "cursor",
                    display_name: "Cursor",
                    config_dir: cursor_dir,
                    ..Default::default()
                }),
            },
        ]
    }

    /// A detected agent whose binding declares no writable hook surface, keyed
    /// to `agent_type` and rooted at `dir`.
    ///
    /// `hook_config_path()` is `<dir>/settings.json`, and nothing here creates
    /// it: what sits at that path is the caller's to arrange, because that is
    /// the one axis the guard suites genuinely differ on — absent, a file
    /// written dead, or a DIRECTORY that `Path::exists()` answers `true` for.
    ///
    /// `kind` is deliberately NOT `AgentKind::Cline`, for the same reason
    /// [`two_detected_agents`]' second agent is not: the guards this fixture
    /// drives turn on `installable()`, not on an agent's identity, and a fake
    /// wearing the real variant is the ambiguity that variant was added to
    /// remove.
    ///
    /// `display_name` is `Cline` and not a parameter: `init`'s refusal asserts
    /// that the message names the agent it refused, so the label is
    /// load-bearing rather than decorative.
    ///
    /// This was written out four times before the helper existed — twice as a
    /// private `fn` with a byte-identical body (`doctor`, `init`), once inlined
    /// (`doctor --fix`), and once as a closure (`doctor --rescue`). The first
    /// three call this; the fourth deliberately does not, because it takes
    /// `installable` as a parameter to build a fixture and its own flipped
    /// control from ONE constructor — "the SAME path, the SAME fixture,
    /// `installable()` flipped" is the claim that test makes, and it stops
    /// being checkable the moment the two halves come from different code.
    pub fn non_installable_agent(
        agent_type: &'static str,
        dir: &std::path::Path,
    ) -> crate::hooks::DetectedAgent {
        crate::hooks::DetectedAgent {
            kind: crate::hooks::AgentKind::ClaudeCode,
            binding: std::sync::Arc::new(FakeBinding {
                agent_type,
                display_name: "Cline",
                config_dir: dir.to_path_buf(),
                installable: false,
                ..Default::default()
            }),
        }
    }

    /// A settable stand-in for an agent that is not Claude Code.
    ///
    /// Seven fields back thirteen accessors. Five of the six that are not
    /// fields answer Claude-shaped, because every test that drives this fake
    /// traverses shared code that asks those questions of the binding — a
    /// blanket `unimplemented!()` would turn each assertion into a panic. The
    /// sixth, `capabilities`, is the deliberate exception and does panic: this
    /// fake models no capability declaration at all.
    ///
    /// *Count corrected when `installable` landed:* the doc said six and
    /// eleven, and had been stale since `capabilities` was added.
    pub struct FakeBinding {
        /// The wire value this fake keys to.
        pub agent_type: &'static str,
        /// The human label doctor and init render.
        pub display_name: &'static str,
        /// The config directory; `hook_config_path()` is `settings.json` in it.
        pub config_dir: PathBuf,
        /// Drives the liveness renderer through all three `armed` states.
        pub liveness: LivenessReport,
        /// `None` — the default — is an agent with no request plane at all.
        pub model_relay_wiring: Option<ModelRelayWiring>,
        /// Drives the daemon ownership guard through both answers.
        pub config_is_machine_global: bool,
        /// Whether this fake models an agent whose hook surface can be
        /// written.
        ///
        /// A field, and stated explicitly in the `impl` below, rather than
        /// inherited from the trait default. Left defaulted, this fake would
        /// compile untouched and every multi-agent test would go on silently
        /// asserting `true` for a fixture that models no install surface —
        /// while the one condition the new guards turn on had no fixture able
        /// to express it.
        ///
        /// `true` by default, because the suites that drive this fake through
        /// `install_hooks` and `doctor --fix` are asserting what a writable
        /// agent does; a test about the guard sets it to `false`.
        pub installable: bool,
        /// The events this fake registers. Defaults to Claude Code's set,
        /// which is what every file-shaped fixture wants; a directory-surface
        /// fixture sets `&[]`, because the `Directory` arm takes its ten from
        /// `hook_files::CLINE_HOOK_FILES` and a fixture that also declared ten
        /// here would make it impossible to tell which list was used.
        pub hook_event_types: &'static [&'static str],
        /// As `hook_event_types`, for the load-bearing subset.
        pub load_bearing_events: &'static [&'static str],
        /// `None` keeps Claude Code's `EnvVars` pair. `Some` is for a fixture
        /// modelling an agent on a different channel — a directory surface is
        /// `OpenlatchDirArg`, because D-09 forbids a daemon token reaching an
        /// agent's asset root.
        pub daemon_channel: Option<DaemonChannel>,
        /// When `Some`, this fake's hook surface is that DIRECTORY rather than
        /// a config file.
        ///
        /// Added for the same reason `installable` is a field and not a trait
        /// default: without it no fixture can express a directory surface, and
        /// the guards that only fire on one would have no test able to reach
        /// them. `None` by default, so every existing fixture stays file-shaped
        /// and unchanged.
        pub hook_surface_dir: Option<PathBuf>,
        /// When `Some`, this fake declares an enforcement-plugin directory
        /// there.
        ///
        /// `None` by default, and that default is load-bearing rather than
        /// tidy: every existing directory-surface fixture keeps writing ten
        /// scripts and nothing else, so arming the plugin installer widened no
        /// test's blast radius. A fixture that wants the plugin says so, with a
        /// `tempdir()` path of its own.
        pub plugin_surface_dir: Option<PathBuf>,
        /// When `Some`, this fake carries provider slots wired to relay
        /// endpoints. `None` by default, so no existing fixture grows any.
        pub provider_endpoints:
            Option<&'static dyn crate::hooks::provider_endpoints::ProviderEndpoints>,
    }

    impl Default for FakeBinding {
        fn default() -> Self {
            Self {
                agent_type: "fake",
                display_name: "Fake",
                // A deterministic path under the system temp directory rather
                // than a live `tempfile::TempDir`: the default holds no guard
                // to drop, and the path does not exist, so a fixture that does
                // not care about the config file reads as "not installed".
                config_dir: std::env::temp_dir().join("openlatch-fake-binding"),
                liveness: LivenessReport {
                    armed: None,
                    detail: None,
                    remedy: None,
                    code: None,
                },
                model_relay_wiring: None,
                config_is_machine_global: false,
                installable: true,
                hook_event_types: &super::super::bindings::claude_code::EVENT_TYPES,
                load_bearing_events: &super::super::bindings::claude_code::LOAD_BEARING_EVENTS,
                daemon_channel: None,
                hook_surface_dir: None,
                plugin_surface_dir: None,
                provider_endpoints: None,
            }
        }
    }

    impl AgentBinding for FakeBinding {
        fn agent_type(&self) -> &'static str {
            self.agent_type
        }

        fn display_name(&self) -> &'static str {
            self.display_name
        }

        fn config_dir(&self) -> PathBuf {
            self.config_dir.clone()
        }

        fn hook_config_path(&self) -> PathBuf {
            self.config_dir.join("settings.json")
        }

        fn hook_surface(&self) -> HookSurface {
            match &self.hook_surface_dir {
                Some(dir) => HookSurface::Directory(dir.clone()),
                None => HookSurface::ConfigFile(self.hook_config_path()),
            }
        }

        fn plugin_surface(&self) -> Option<PathBuf> {
            self.plugin_surface_dir.clone()
        }

        /// Explicit, never the trait default — see the field.
        fn installable(&self) -> bool {
            self.installable
        }

        fn hook_event_types(&self) -> &'static [&'static str] {
            self.hook_event_types
        }

        fn load_bearing_events(&self) -> &'static [&'static str] {
            self.load_bearing_events
        }

        fn daemon_channel(&self) -> DaemonChannel {
            self.daemon_channel.unwrap_or(DaemonChannel::EnvVars {
                token: crate::hooks::OPENLATCH_TOKEN_ENV,
                port: crate::hooks::OPENLATCH_PORT_ENV,
            })
        }

        fn liveness(&self) -> LivenessReport {
            self.liveness.clone()
        }

        fn build_hook_entry(
            &self,
            event: &str,
            binary: &Path,
            port: u16,
            marker: &OpenlatchMarker,
        ) -> Value {
            crate::hooks::claude_code::build_hook_entry(
                event,
                port,
                crate::hooks::OPENLATCH_TOKEN_ENV,
                binary,
                marker,
            )
        }

        fn config_is_machine_global(&self) -> bool {
            self.config_is_machine_global
        }

        fn capabilities(&self) -> BindingCapabilities {
            unimplemented!(
                "FakeBinding models no capability declaration — a test that reaches \
                 capabilities() is testing something this seam was not built for"
            )
        }

        fn model_relay_wiring(&self) -> Option<ModelRelayWiring> {
            self.model_relay_wiring.clone()
        }

        fn provider_endpoints(
            &self,
        ) -> Option<&'static dyn crate::hooks::provider_endpoints::ProviderEndpoints> {
            self.provider_endpoints
        }
    }
}

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

    /// Restores `$HOME`, `$CLAUDE_CONFIG_DIR`, `$CODEX_HOME` and Cline's three
    /// seams on unwind as well as on success, so a failing assertion cannot
    /// leak a redirected home into the next test in this binary.
    ///
    /// `CODEX_HOME` joined the trio when [`detect_all`] gained its Codex arm:
    /// a test asserting that a bare `$HOME` detects nothing now has two
    /// directories to keep out of the way, not one. Every taker holds
    /// `codex_cli::CONFIG_DIR_ENV_LOCK` as well, because that variable has its
    /// own lock and clearing it under only Claude's would race the suites that
    /// set it.
    ///
    /// Cline's three joined **before** [`detect_all`] gained its Cline arm —
    /// which it now has, so these seams are load-bearing rather than
    /// precautionary, and that ordering was the point. They come from
    /// [`crate::hooks::cline::absent_seams`], which is the one definition of
    /// which three and where they point — pointed under a temp dir at paths
    /// that are never created, rather than cleared like the two above, so a
    /// detector that stats a directory answers `None` whatever `$HOME` happens
    /// to hold. What an incomplete triple leaks is on that helper.
    struct HomeGuard {
        home: Option<std::ffi::OsString>,
        config_dir: Option<std::ffi::OsString>,
        codex_home: Option<std::ffi::OsString>,
        /// Restores Cline's three seams on drop. Declared before the two
        /// fields under it so it is dropped before them: the seams go back
        /// while the temp dir they name still exists and the seam lock is
        /// still held.
        _cline_env: crate::hooks::cline::EnvOverride,
        /// Kept alive for the guard's lifetime: the three seams above name
        /// paths inside it, and a deleted temp dir would leave them naming
        /// nothing on a machine where another test later creates that path.
        _cline_root: tempfile::TempDir,
        /// Held for the guard's whole lifetime, because this guard WRITES the
        /// three Cline seams. Without it a test running `cline_isolated()` — or
        /// any `hooks::cline` resolver test — can observe this guard's
        /// `absent-cline` values, or have its own seams restored out from under
        /// it by `Drop`. The two locks would not exclude each other otherwise.
        /// Acquired at the TAIL of the crate's documented order, after Claude's
        /// and Codex's, so this extends the chain rather than inverting it.
        _seam_lock: std::sync::MutexGuard<'static, ()>,
    }

    impl HomeGuard {
        fn take() -> Self {
            // Before ANY read or write of a Cline seam below.
            let seam_lock = crate::hooks::cline::SEAM_ENV_LOCK
                .lock()
                .unwrap_or_else(|e| e.into_inner());
            let cline_root = tempfile::tempdir().expect("temp dir");
            let cline_env = crate::hooks::cline::EnvOverride::absent_cline_seams(cline_root.path());
            let guard = Self {
                home: std::env::var_os("HOME"),
                config_dir: std::env::var_os(crate::hooks::claude_code::CONFIG_DIR_ENV),
                codex_home: std::env::var_os(crate::hooks::codex_cli::CONFIG_DIR_ENV),
                _cline_env: cline_env,
                _cline_root: cline_root,
                _seam_lock: seam_lock,
            };
            std::env::remove_var(crate::hooks::claude_code::CONFIG_DIR_ENV);
            std::env::remove_var(crate::hooks::codex_cli::CONFIG_DIR_ENV);
            guard
        }
    }

    impl Drop for HomeGuard {
        fn drop(&mut self) {
            for (key, value) in [
                ("HOME", &self.home),
                (crate::hooks::claude_code::CONFIG_DIR_ENV, &self.config_dir),
                (crate::hooks::codex_cli::CONFIG_DIR_ENV, &self.codex_home),
            ] {
                match value {
                    Some(v) => std::env::set_var(key, v),
                    None => std::env::remove_var(key),
                }
            }
        }
    }

    /// A host with no agent is not an error condition — it is an empty list.
    /// `detect_agent()` is the shim that turns emptiness into `OL-1400`; the
    /// primitive says nothing at all.
    #[test]
    #[cfg(unix)]
    fn detect_agents_returns_empty_without_an_agent() {
        // `CONFIG_DIR_ENV_LOCK` is the lock for `$CLAUDE_CONFIG_DIR` *and*
        // `$HOME`; its own doc names this exact pair of suites. `$CODEX_HOME`
        // has its own, taken last — the ordering rule for every test in this
        // binary that needs more than one env lock.
        let _lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        // `identity::ENV_LOCK` is not optional here, and its absence was a
        // latent flake this suite has always carried. `CLAUDE_CONFIG_DIR` is in
        // `identity::MANAGED`, so a sibling holding only THIS lock may set it
        // to an existing directory mid-assertion — which makes
        // `ClaudeCodeBinding::detect()` succeed and `detect_all()` non-empty.
        // The two locks do not exclude each other, so taking one is not enough.
        // Same order as `detect_order_is_claude_codex_cline` below, which
        // has always taken all three: claude, identity, codex last.
        let _identity_lock = crate::daemon::identity::ENV_LOCK.blocking_lock();
        let _codex_lock = crate::hooks::codex_cli::CONFIG_DIR_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let _guard = HomeGuard::take();
        let empty = tempfile::tempdir().expect("temp dir");
        std::env::set_var("HOME", empty.path());

        assert!(
            detect_all().is_empty(),
            "no agent on this host means an empty Vec, never an error"
        );
    }

    /// Declaration order is detection order (D-04), and the shim takes the
    /// first: Claude Code, then whatever I-2 appends.
    #[test]
    fn detect_agent_shim_takes_the_first() {
        let _lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        // `identity::ENV_LOCK` is not optional here, and its absence was a
        // latent flake this suite has always carried. `CLAUDE_CONFIG_DIR` is in
        // `identity::MANAGED`, so a sibling holding only THIS lock may set it
        // to an existing directory mid-assertion — which makes
        // `ClaudeCodeBinding::detect()` succeed and `detect_all()` non-empty.
        // The two locks do not exclude each other, so taking one is not enough.
        // Same order as `detect_order_is_claude_codex_cline` below, which
        // has always taken all three: claude, identity, codex last.
        let _identity_lock = crate::daemon::identity::ENV_LOCK.blocking_lock();
        let _codex_lock = crate::hooks::codex_cli::CONFIG_DIR_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let _guard = HomeGuard::take();
        let claude_dir = tempfile::tempdir().expect("temp dir");
        std::env::set_var(crate::hooks::claude_code::CONFIG_DIR_ENV, claude_dir.path());

        let all = detect_all();
        assert!(
            !all.is_empty(),
            "a relocated Claude dir exists, so it detects"
        );
        assert_eq!(all[0].kind, AgentKind::ClaudeCode, "Claude Code is first");

        let first = crate::hooks::detect_agent().expect("an agent was detected");
        assert_eq!(first.kind, all[0].kind);
        assert_eq!(first.agent_type(), all[0].agent_type());
        assert_eq!(first.agent_type(), "claude-code");
    }

    /// Declaration order **is** detection order: Claude Code, Codex CLI,
    /// Cline. *Corrected 2026-09-14:* this comment claimed "the four singular
    /// callers of `detect_agent()` take the first"; there are **zero** such
    /// callers left in `src/` — `daemon/mod.rs` records itself as the last one
    /// removed. Declaration order still governs, but it protects the meaning of
    /// the order and these tests, not a live blast radius.
    ///
    /// Cline last is nonetheless load-bearing beyond the order's meaning: it is
    /// the one detected binding that answers `installable() == false`, and the
    /// shim `detect_agent()` takes the first.
    ///
    /// Env-driven rather than `FakeBinding`-driven: `detect_all()` has no
    /// injection seam, and the fake is never constructed by it. All three temp
    /// directories must EXIST, because all three resolvers stat before
    /// answering.
    ///
    /// Four locks, always in this order — Claude's `CONFIG_DIR_ENV_LOCK`,
    /// `identity::ENV_LOCK` (which is what guards `CLAUDE_CONFIG_DIR`'s
    /// `EnvGuard`, since that variable is in `identity::MANAGED`),
    /// `codex_cli::CONFIG_DIR_ENV_LOCK`, then `cline::SEAM_ENV_LOCK` last.
    /// `CODEX_HOME` is not managed by `EnvGuard`, so this test saves and
    /// restores it itself, before asserting, so a failed assertion cannot leak
    /// it. Cline's three seams are not managed either, and this test does not
    /// redirect `$HOME`, so they get the same treatment for the same reason.
    #[test]
    fn detect_order_is_claude_codex_cline() {
        let _claude_lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let _identity_lock = crate::daemon::identity::ENV_LOCK.blocking_lock();
        let env = crate::daemon::identity::test_support::EnvGuard::clear();
        let _codex_lock = crate::hooks::codex_cli::CONFIG_DIR_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        // This test writes the three Cline seams by hand (it uses no
        // `HomeGuard`), so it needs the seam lock for the same reason that
        // guard does. Tail position, after Codex's.
        let _cline_lock = crate::hooks::cline::SEAM_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());

        let claude_dir = tempfile::tempdir().expect("temp dir");
        let codex_dir = tempfile::tempdir().expect("temp dir");
        env.set("CLAUDE_CONFIG_DIR", claude_dir.path());

        let previous_codex = std::env::var_os(crate::hooks::codex_cli::CONFIG_DIR_ENV);
        std::env::set_var(crate::hooks::codex_cli::CONFIG_DIR_ENV, codex_dir.path());

        // Cline's three seams, written out by hand rather than taken from
        // `cline::absent_seams`, for two separate reasons.
        //
        // First, this test needs Cline to DETECT, and `absent_seams` names
        // paths that are never created — its own doc says a suite needing a
        // *present* root writes its own triple and must not create directories
        // under the paths that helper returns, or every fixture using it arms
        // Cline detection.
        //
        // Second, saved and restored BY HAND rather than through
        // `cline::EnvOverride`: this test restores before it asserts, so a
        // failed assertion cannot leak a redirected seam, and a `Drop`-based
        // guard would restore after. `identity::MANAGED` does not list these
        // and this test never redirects `$HOME`, so nothing else stands between
        // `detect_all()` and the developer's real `~/.cline`.
        //
        // All three named, not just the one that is stat-ed: two out of three
        // leaves the remaining root resolving to the developer's own machine,
        // which is what `absent_seams` documents at length.
        let cline_root = tempfile::tempdir().expect("temp dir");
        let cline_store = cline_root.path().join("store");
        // `ClineBinding::detect()` stats this directory, exactly as Claude's
        // and Codex's resolvers stat theirs.
        std::fs::create_dir_all(&cline_store).expect("the store root must exist to be detected");
        let previous_cline: Vec<(&str, Option<std::ffi::OsString>)> = [
            (crate::hooks::cline::STORE_DIR_ENV, cline_store.clone()),
            (crate::hooks::cline::DATA_DIR_ENV, cline_store.join("data")),
            (
                crate::hooks::cline::ASSETS_DIR_ENV,
                cline_root.path().join("assets"),
            ),
        ]
        .into_iter()
        .map(|(key, value)| {
            let previous = std::env::var_os(key);
            std::env::set_var(key, value);
            (key, previous)
        })
        .collect();

        let kinds: Vec<AgentKind> = detect_all().iter().map(|a| a.kind).collect();

        match previous_codex {
            Some(v) => std::env::set_var(crate::hooks::codex_cli::CONFIG_DIR_ENV, v),
            None => std::env::remove_var(crate::hooks::codex_cli::CONFIG_DIR_ENV),
        }
        for (key, value) in previous_cline {
            match value {
                Some(v) => std::env::set_var(key, v),
                None => std::env::remove_var(key),
            }
        }

        assert_eq!(
            kinds,
            vec![AgentKind::ClaudeCode, AgentKind::CodexCli, AgentKind::Cline],
            "declaration order is detection order, and the shim takes the first — \
             so the binding this build cannot install into goes last"
        );
    }

    /// Spec AC-6. A seam naming a path that does not exist means Cline is not
    /// on this host: `detect()` answers `None` and nothing downstream has an
    /// agent to act on.
    ///
    /// The negative half of the ordering test above, and the property every
    /// existing fixture in this crate depends on — `cline::absent_seams` points
    /// all three seams at paths it never creates precisely so that arming
    /// detection did not change any fixture's agent count. If this test ever
    /// goes red, every one of those fixtures is silently a three-agent test.
    ///
    /// Locks in the crate's documented order; only the seam lock is strictly
    /// needed, but `store_root()` falls back to `$HOME` when the seam is unset
    /// and a sibling clearing it mid-assertion would reach the developer's real
    /// `~/.cline`.
    #[test]
    fn detect_returns_none_when_seam_path_absent() {
        let _claude_lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let _cline_lock = crate::hooks::cline::SEAM_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());

        let root = tempfile::tempdir().expect("temp dir");
        let _seams = crate::hooks::cline::EnvOverride::absent_cline_seams(root.path());

        // The premise, asserted rather than assumed: the resolver answers a
        // path, and that path is not there. Without this line a resolver that
        // started returning `None` outright would make the assertion below
        // pass for the wrong reason.
        let (store, _) = crate::hooks::cline::store_root().expect("the seam names a store root");
        assert!(
            !store.exists(),
            "the premise: `absent_seams` names paths it never creates, and this one is at {}",
            store.display()
        );

        assert!(
            ClineBinding::detect().is_none(),
            "a store root that is not there is not an installed agent"
        );
    }

    /// DD-09. The fake answers [`AgentBinding::installable`] from a field it
    /// owns, not from the trait's default.
    ///
    /// There is no runtime way to ask whether a default was overridden, so the
    /// proof is behavioural: a fake constructed with `installable: false`
    /// answers `false`. That can only hold if the fake states the method
    /// itself — the default is `true` and cannot read a field it does not know
    /// about. The `true` half is asserted too, because every multi-agent suite
    /// in this crate leans on it and a flipped default would take them all
    /// down at once with a confusing message.
    #[test]
    fn fake_binding_declares_installable_explicitly() {
        use test_support::FakeBinding;

        assert!(
            FakeBinding::default().installable(),
            "the fake models a writable agent by default — the suites that drive it \
             through install_hooks depend on it"
        );
        assert!(
            !FakeBinding {
                installable: false,
                ..Default::default()
            }
            .installable(),
            "and it can model the one condition the guards turn on, which a defaulted \
             method could not express"
        );
    }

    /// `OL-1400` used to name Claude Code and only Claude Code, with a
    /// `claude.ai/download` link — the regression a host that could have run a
    /// different agent would hit. The remedy is built from the detectable list,
    /// so I-2 appending a binding appends its name here in the same edit.
    #[test]
    fn agent_not_found_remedy_names_every_detectable_agent() {
        let err = super::super::agent_not_found_err();
        assert_eq!(err.code, crate::error::ERR_HOOK_AGENT_NOT_FOUND);
        let suggestion = err.suggestion.expect("OL-1400 carries a suggestion");
        for name in DETECTABLE_AGENT_NAMES {
            assert!(
                suggestion.contains(name),
                "the remedy must name every detectable agent; {name} is missing from {suggestion:?}"
            );
        }
        assert!(
            !suggestion.contains("claude.ai/download"),
            "one download URL cannot serve a list of agents: {suggestion:?}"
        );
    }

    /// The default answers what every file-shaped binding already meant, so a
    /// binding that says nothing keeps its current behaviour at all five call
    /// sites. The only override in the tree is Cline's.
    #[test]
    fn the_default_hook_surface_is_the_config_file() {
        use test_support::FakeBinding;

        let fake = FakeBinding::default();
        assert_eq!(
            fake.hook_surface(),
            HookSurface::ConfigFile(fake.hook_config_path()),
            "a binding that does not override hook_surface() is file-shaped"
        );
    }

    #[test]
    fn agent_binding_is_object_safe() {
        fn _assert_object_safe(_: &dyn AgentBinding) {}
    }

    #[test]
    fn agent_binding_is_send_sync() {
        fn _assert_send_sync<T: Send + Sync>() {}
        _assert_send_sync::<Arc<dyn AgentBinding>>();
    }
}