use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::core::hook_state::marker::OpenlatchMarker;
use crate::model_relay::wire_format::WireFormat;
use super::super::binding::{
AgentBinding, BindingCapabilities, DaemonChannel, EndpointConvention, FailureMode, HookSurface,
LivenessReport, ModelRelayWiring, ProxyDelivery,
};
use crate::hooks::cline::ASSET_HOOKS_DIR_NAME as HOOKS_DIR_NAME;
pub struct ClineBinding {
enforcement: crate::hooks::cline_plugin::EnforcementSurface,
}
impl ClineBinding {
pub const AGENT_TYPE: &'static str = "cline";
#[cfg(test)]
pub(crate) fn detached() -> Self {
Self {
enforcement: crate::hooks::cline_plugin::EnforcementSurface::None,
}
}
pub fn detect() -> Option<Self> {
let (store, _located_by) = crate::hooks::cline::store_root()?;
if !store.is_dir() {
return None;
}
let enforcement = crate::hooks::cline::enforcement_surface();
Some(Self { enforcement })
}
}
pub(crate) fn vscode_user_settings() -> Option<PathBuf> {
vscode_user_settings_in(
dirs::config_dir(),
crate::supervision::owns_machine_supervision(),
)
}
pub(crate) fn vscode_user_settings_in(
config_dir: Option<PathBuf>,
owns_machine: bool,
) -> Option<PathBuf> {
if !owns_machine {
return None;
}
Some(config_dir?.join("Code").join("User").join("settings.json"))
}
const CLINE_DELIVERY: &[ProxyDelivery] = &[
ProxyDelivery::SettingsKey {
file: vscode_user_settings,
key: "http.proxy",
},
ProxyDelivery::EnvFile {
commands: &["cline"],
},
];
impl AgentBinding for ClineBinding {
fn agent_type(&self) -> &'static str {
Self::AGENT_TYPE
}
fn display_name(&self) -> &'static str {
"Cline"
}
fn config_dir(&self) -> PathBuf {
crate::hooks::cline::store_root()
.map(|(path, _)| path)
.unwrap_or_default()
}
fn hook_config_path(&self) -> PathBuf {
crate::hooks::cline::asset_root()
.unwrap_or_default()
.join(HOOKS_DIR_NAME)
}
fn hook_surface(&self) -> HookSurface {
HookSurface::Directory(self.hook_config_path())
}
fn plugin_surface(&self) -> Option<PathBuf> {
crate::hooks::cline::plugin_dir()
}
fn installable(&self) -> bool {
true
}
fn hook_event_types(&self) -> &'static [&'static str] {
&crate::hooks::hook_files::CLINE_HOOK_FILES
}
fn load_bearing_events(&self) -> &'static [&'static str] {
&["PreToolUse", "UserPromptSubmit", "TaskStart"]
}
fn daemon_channel(&self) -> DaemonChannel {
DaemonChannel::OpenlatchDirArg
}
fn liveness(&self) -> LivenessReport {
use crate::hooks::cline_plugin::EnforcementSurface;
match self.enforcement {
EnforcementSurface::Plugin => LivenessReport {
armed: Some(true),
detail: Some(
"Cline's ten hook scripts capture every event, and the OpenLatch plugin \
refuses a denied tool call on its own — the one lane that stops a single \
call without aborting the developer's task."
.to_string(),
),
remedy: None,
code: None,
off: false,
},
EnforcementSurface::Disabled => LivenessReport {
armed: Some(false),
detail: Some(
"OpenLatch's Cline plugin is installed and listed in `disabledPlugins`, so \
Cline never loads it: the ten hook scripts still capture every event and \
no tool call can be refused."
.to_string(),
),
remedy: Some(
"Remove \"openlatch\" from `disabledPlugins` in Cline's \
`global-settings.json` — OpenLatch never edits that list, because it is \
your switch — then run `openlatch doctor` again."
.to_string(),
),
code: Some(crate::error::ERR_CLINE_NOT_ENFORCING),
off: false,
},
EnforcementSurface::None => LivenessReport {
armed: Some(false),
detail: Some(
"Cline's ten hook scripts post every event and discard the verdict — a deny \
returned through its file-hook lane aborts the developer's whole task \
rather than the one tool call — and the plugin that can refuse one is not \
installed."
.to_string(),
),
remedy: Some(
"Run `openlatch init --agent cline` to install the enforcement plugin. If a \
file that is not ours already sits at `plugins/openlatch/index.js`, \
OpenLatch leaves it alone: move it aside first."
.to_string(),
),
code: Some(crate::error::ERR_CLINE_NOT_ENFORCING),
off: false,
},
}
}
fn build_hook_entry(
&self,
_event: &str,
_binary: &Path,
_port: u16,
_marker: &OpenlatchMarker,
) -> Value {
serde_json::json!({})
}
fn config_is_machine_global(&self) -> bool {
crate::hooks::cline::config_is_machine_global()
}
fn capabilities(&self) -> BindingCapabilities {
BindingCapabilities {
expressible: if self.enforcement.is_enforcing() {
&["allow", "deny"]
} else {
&[]
},
can_mutate_arguments: false,
native_failure_mode: FailureMode::Unknown,
admin_owned_settings: false,
declares_session_in_request: false,
}
}
fn model_relay_wiring(&self) -> Option<ModelRelayWiring> {
Some(ModelRelayWiring {
wire_format: WireFormat::OpenAiChatCompletions,
endpoint: EndpointConvention::ProxyEnv {
intercept_hosts: crate::hooks::cline_providers::CLINE_INTERCEPT_HOSTS,
delivery: CLINE_DELIVERY,
},
install_id_header: "x-openlatch-install-id",
})
}
fn provider_endpoints(
&self,
) -> Option<&'static dyn crate::hooks::provider_endpoints::ProviderEndpoints> {
static ENDPOINTS: crate::hooks::cline_providers::ClineProviderEndpoints =
crate::hooks::cline_providers::ClineProviderEndpoints::RESOLVED;
Some(&ENDPOINTS)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cline_declares_it_can_be_installed_into() {
assert!(
ClineBinding::detached().installable(),
"Cline's hook surface IS written by this build — ten executable \
shims into the asset root's Hooks/ directory"
);
}
#[test]
fn cline_declares_proxy_env_with_the_vscode_key_and_the_cline_wrapper() {
let binding = ClineBinding::detached();
let wiring = binding.model_relay_wiring().expect("ProxyEnv wiring");
assert_eq!(wiring.wire_format, WireFormat::OpenAiChatCompletions);
match wiring.endpoint {
EndpointConvention::ProxyEnv {
intercept_hosts,
delivery,
} => {
assert_eq!(
intercept_hosts,
crate::hooks::cline_providers::CLINE_INTERCEPT_HOSTS,
"the static host list is exactly CLINE_INTERCEPT_HOSTS"
);
assert_eq!(delivery.len(), 2);
assert!(matches!(
delivery[0],
ProxyDelivery::SettingsKey {
key: "http.proxy",
..
}
));
assert!(matches!(
delivery[1],
ProxyDelivery::EnvFile {
commands: ["cline"]
}
));
}
other => panic!("expected ProxyEnv, got {other:?}"),
}
assert!(binding.provider_endpoints().is_some());
}
#[test]
fn vscode_user_settings_is_the_default_profile_of_the_machines_own_install() {
let d = std::path::PathBuf::from("/tmp/config");
assert_eq!(
vscode_user_settings_in(Some(d.clone()), true),
Some(d.join("Code").join("User").join("settings.json"))
);
assert_eq!(vscode_user_settings_in(Some(d), false), None);
assert_eq!(vscode_user_settings_in(None, true), None);
}
#[test]
fn the_answers_are_tied_to_installability() {
let binding = ClineBinding::detached();
assert!(binding.installable());
assert_eq!(
binding.hook_event_types(),
crate::hooks::hook_files::CLINE_HOOK_FILES,
"the binding must register exactly what the writer installs"
);
assert_eq!(
binding.load_bearing_events(),
["PreToolUse", "UserPromptSubmit", "TaskStart"],
);
for event in binding.load_bearing_events() {
assert!(
binding.hook_event_types().contains(event),
"{event} is load-bearing and is not registered — it would be \
reported missing on every host"
);
}
assert!(
binding.capabilities().expressible.is_empty(),
"installing is not enforcing: `expressible` widens for a host whose \
plugin is installed and enabled, and not for one that merely has \
Cline's ten hook scripts"
);
}
#[test]
fn the_daemon_channel_never_carries_a_token() {
assert!(
matches!(
ClineBinding::detached().daemon_channel(),
DaemonChannel::OpenlatchDirArg
),
"an EnvVars channel would put the bearer token in plaintext in a file we \
have no business writing at all"
);
}
#[test]
fn liveness_is_monitored_rather_than_unknowable() {
let report = ClineBinding::detached().liveness();
assert_eq!(report.armed, Some(false));
assert_eq!(report.code, Some(crate::error::ERR_CLINE_NOT_ENFORCING));
assert!(
report.remedy.is_some(),
"a Some(false) liveness without a remedy fails Check::validate"
);
assert!(
report.detail.is_some(),
"capturing and enforcing are two claims, and the detail is what \
tells them apart for a reader of the Monitored row"
);
}
#[test]
fn enforcement_answers_follow_the_plugin() {
let _lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _seam_lock = crate::hooks::cline::SEAM_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let root = tempfile::tempdir().expect("temp dir");
let store = root.path().join("store");
let data = store.join("data");
let settings = data.join("settings");
std::fs::create_dir_all(&settings).expect("seed the store and its settings dir");
let _seams = crate::hooks::cline::EnvOverride::apply([
(
crate::hooks::cline::STORE_DIR_ENV,
Some(store.clone().into_os_string()),
),
(
crate::hooks::cline::DATA_DIR_ENV,
Some(data.clone().into_os_string()),
),
(
crate::hooks::cline::ASSETS_DIR_ENV,
Some(root.path().join("assets").into_os_string()),
),
]);
let binding = ClineBinding::detect().expect("the store root exists");
assert!(
binding.capabilities().expressible.is_empty(),
"a file-only install must advertise no verdict channel at all"
);
assert_eq!(binding.liveness().armed, Some(false));
let plugin_dir = binding.plugin_surface().expect("Cline has a plugin lane");
crate::hooks::cline_plugin::install(&plugin_dir, root.path()).expect("install the plugin");
let armed = ClineBinding::detect().expect("the store root still exists");
assert_eq!(
armed.capabilities().expressible,
&["allow", "deny"],
"a verified plugin is the one thing that widens this"
);
let report = armed.liveness();
assert_eq!(report.armed, Some(true));
assert!(
report.code.is_none() && report.remedy.is_none(),
"an Enforced row has nothing to act on and no code to carry"
);
std::fs::write(
settings.join("global-settings.json"),
format!(
r#"{{"disabledPlugins":["{}"]}}"#,
crate::hooks::cline_plugin::PLUGIN_ID
),
)
.expect("seed the developer's switch");
let disabled = ClineBinding::detect().expect("the store root still exists");
assert!(
disabled.capabilities().expressible.is_empty(),
"a plugin Cline never loads delivers nothing, however correct the file is"
);
let report = disabled.liveness();
assert_eq!(report.armed, Some(false));
assert_eq!(
report.code,
Some(crate::error::ERR_CLINE_NOT_ENFORCING),
"one condition — not enforcing — carries one code, whatever the reason"
);
assert!(
report
.remedy
.as_deref()
.is_some_and(|remedy| remedy.contains("disabledPlugins")),
"the remedy must name the switch the developer actually holds: {:?}",
report.remedy
);
std::fs::remove_file(crate::hooks::cline_plugin::entry_path(&plugin_dir))
.expect("remove the plugin");
let gone = ClineBinding::detect().expect("the store root still exists");
assert!(gone.capabilities().expressible.is_empty());
assert_eq!(gone.liveness().armed, Some(false));
}
#[test]
fn hook_config_path_is_the_asset_roots_hooks_directory() {
let _lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _seam_lock = crate::hooks::cline::SEAM_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let root = tempfile::tempdir().expect("temp dir");
let store = root.path().join("store");
let assets = root.path().join("assets");
let _seams = crate::hooks::cline::EnvOverride::apply([
(
crate::hooks::cline::STORE_DIR_ENV,
Some(store.clone().into_os_string()),
),
(
crate::hooks::cline::DATA_DIR_ENV,
Some(store.join("data").into_os_string()),
),
(
crate::hooks::cline::ASSETS_DIR_ENV,
Some(assets.clone().into_os_string()),
),
]);
let binding = ClineBinding::detached();
assert_eq!(binding.hook_config_path(), assets.join("Hooks"));
assert_eq!(
binding.hook_surface(),
HookSurface::Directory(assets.join("Hooks")),
);
assert_eq!(
binding.config_dir(),
store,
"config_dir is the STORE root, a different tree from the asset root"
);
}
#[test]
fn the_plugin_surface_is_under_the_store_root() {
let _lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _seam_lock = crate::hooks::cline::SEAM_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let root = tempfile::tempdir().expect("temp dir");
let store = root.path().join("store");
let assets = root.path().join("assets");
let _seams = crate::hooks::cline::EnvOverride::apply([
(
crate::hooks::cline::STORE_DIR_ENV,
Some(store.clone().into_os_string()),
),
(
crate::hooks::cline::DATA_DIR_ENV,
Some(store.join("data").into_os_string()),
),
(
crate::hooks::cline::ASSETS_DIR_ENV,
Some(assets.clone().into_os_string()),
),
]);
let binding = ClineBinding::detached();
let plugin_dir = binding.plugin_surface().expect("Cline has a plugin lane");
assert_eq!(
plugin_dir,
store.join("plugins").join("openlatch"),
"Cline's plugin search is `join(resolveClineDir(), \"plugins\")`; anywhere \
else is a file it never loads"
);
assert!(
!plugin_dir.starts_with(&assets),
"the plugin must not land under the asset root, where the ten live"
);
assert_eq!(
plugin_dir
.file_name()
.expect("a directory name")
.to_string_lossy(),
crate::hooks::cline_plugin::PLUGIN_ID,
"the directory name IS the id `disabledPlugins` matches"
);
}
#[test]
fn detect_stats_the_store_root() {
let _lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _seam_lock = crate::hooks::cline::SEAM_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let root = tempfile::tempdir().expect("temp dir");
let store = root.path().join("store");
let _seams = crate::hooks::cline::EnvOverride::apply([
(
crate::hooks::cline::STORE_DIR_ENV,
Some(store.clone().into_os_string()),
),
(
crate::hooks::cline::DATA_DIR_ENV,
Some(store.join("data").into_os_string()),
),
(
crate::hooks::cline::ASSETS_DIR_ENV,
Some(root.path().join("assets").into_os_string()),
),
]);
assert!(
ClineBinding::detect().is_none(),
"a store root that does not exist is not an installed agent"
);
std::fs::create_dir_all(&store).expect("create the store root");
assert!(
ClineBinding::detect().is_some(),
"and the directory existing is what 'installed' means"
);
}
#[test]
fn build_hook_entry_is_inert_rather_than_a_panic() {
let marker = OpenlatchMarker::new("test-install-id".to_string());
let entry = ClineBinding::detached().build_hook_entry(
"PreToolUse",
std::path::Path::new("/nonexistent/openlatch-hook"),
1234,
&marker,
);
assert!(
entry.is_object() && entry.as_object().is_some_and(|o| o.is_empty()),
"an agent we never register with has no hook entry shape; the empty \
object makes a drift diff report unhealable drift, which is TRUE, \
instead of taking the daemon down with it"
);
}
#[test]
#[cfg(all(feature = "model-relay", unix))]
fn an_isolated_instance_does_not_own_the_machines_own_store() {
let _lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _seam_lock = crate::hooks::cline::SEAM_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let root = tempfile::tempdir().expect("temp dir");
let home = root.path().join("home");
let store = home.join(".cline");
let settings = store.join("data").join("settings");
std::fs::create_dir_all(&settings).expect("seed the machine-global store");
std::fs::write(
settings.join("providers.json"),
r#"{"lastUsedProvider":"ollama","providers":{"ollama":{"settings":{"provider":"ollama","baseUrl":"http://127.0.0.1:11434"}}}}"#,
)
.expect("seed providers.json");
let _seams = crate::hooks::cline::EnvOverride::apply([
("HOME", Some(home.clone().into_os_string())),
(crate::hooks::cline::STORE_DIR_ENV, None),
(crate::hooks::cline::DATA_DIR_ENV, None),
(
crate::hooks::cline::ASSETS_DIR_ENV,
Some(root.path().join("assets").into_os_string()),
),
]);
let binding = ClineBinding::detect().expect("the store root was just created");
assert!(
binding.provider_endpoints().is_some(),
"the premise: this binding offers provider slots, which is what \
un-skips the ownership guard"
);
assert!(
binding.config_is_machine_global(),
"and the store it resolves to is the machine's own — a `false` here \
would read as 'relocated, safe to own'"
);
let mut cfg = crate::config::Config::defaults();
cfg.model_relay.port = crate::model_relay::default_model_relay_port() + 1;
cfg.model_relay.own_agent_wiring = Some(true);
assert!(
!crate::daemon::owns_wiring_for(&cfg, &binding),
"an isolated daemon must not seize the machine's shared Cline store, \
opt-in or not — opting in is a decision about your own sandbox"
);
cfg.model_relay.port = crate::model_relay::default_model_relay_port();
assert!(crate::daemon::owns_wiring_for(&cfg, &binding));
}
#[test]
#[cfg(feature = "model-relay")]
fn an_isolated_instance_does_own_a_relocated_store() {
let _lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _seam_lock = crate::hooks::cline::SEAM_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let root = tempfile::tempdir().expect("temp dir");
let store = root.path().join("relocated-cline");
let settings = store.join("data").join("settings");
std::fs::create_dir_all(&settings).expect("seed the relocated store");
std::fs::write(
settings.join("providers.json"),
r#"{"lastUsedProvider":"ollama","providers":{"ollama":{"settings":{"provider":"ollama","baseUrl":"http://127.0.0.1:11434"}}}}"#,
)
.expect("seed providers.json");
let _seams = crate::hooks::cline::EnvOverride::apply([
(
crate::hooks::cline::STORE_DIR_ENV,
Some(store.clone().into_os_string()),
),
(
crate::hooks::cline::DATA_DIR_ENV,
Some(store.join("data").into_os_string()),
),
(
crate::hooks::cline::ASSETS_DIR_ENV,
Some(root.path().join("assets").into_os_string()),
),
]);
let binding = ClineBinding::detect().expect("the relocated store exists");
assert!(
binding.provider_endpoints().is_some(),
"the premise: provider slots are what un-skip the guard at all"
);
assert!(
!binding.config_is_machine_global(),
"a store reached through CLINE_DIR is relocated, not the machine's own"
);
let mut cfg = crate::config::Config::defaults();
cfg.model_relay.port = crate::model_relay::default_model_relay_port() + 1;
cfg.model_relay.own_agent_wiring = Some(true);
assert!(
crate::daemon::owns_wiring_for(&cfg, &binding),
"opting in owns your OWN relocated store — the refusal is only ever \
about the machine-global one"
);
}
#[test]
fn config_is_machine_global_is_delegated_not_hardcoded() {
let _lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _seam_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::apply(
crate::hooks::cline::absent_seams(root.path())
.map(|(k, v)| (k, Some(v.into_os_string()))),
);
assert_eq!(
ClineBinding::detached().config_is_machine_global(),
crate::hooks::cline::config_is_machine_global(),
"the binding must answer with the resolver, not a literal"
);
}
}