use std::io::{IsTerminal, Write};
use crate::cli::commands::lifecycle;
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::cli::UninstallArgs;
use crate::config;
use crate::error::{OlError, ERR_INVALID_CONFIG};
use crate::hooks;
pub(crate) const PURGEABLE_STATE_DIR_NAMES: [&str; 2] = ["openlatch", ".openlatch"];
pub(crate) fn is_purgeable_state_dir_name(name: &str) -> bool {
PURGEABLE_STATE_DIR_NAMES.contains(&name)
}
fn resolve_purge_target() -> Result<Option<std::path::PathBuf>, OlError> {
let ol_dir = config::openlatch_dir();
if !ol_dir.exists() {
return Ok(None);
}
let canonical = std::fs::canonicalize(&ol_dir).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot canonicalize openlatch directory: {e}"),
)
})?;
let dir_name = canonical.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !is_purgeable_state_dir_name(dir_name) {
return Err(OlError::new(
ERR_INVALID_CONFIG,
format!(
"Unexpected openlatch directory name '{}' — refusing to delete for safety",
canonical.display()
),
)
.with_suggestion(format!(
"`--purge` only deletes a directory named {}. Point OPENLATCH_DIR at one, or \
delete this directory by hand. Nothing has been changed.",
PURGEABLE_STATE_DIR_NAMES
.map(|n| format!("'{n}'"))
.join(" or ")
)));
}
Ok(Some(canonical))
}
fn is_machine_default_install() -> bool {
same_directory(&config::openlatch_dir(), &config::default_openlatch_dir())
}
fn same_directory(a: &std::path::Path, b: &std::path::Path) -> bool {
let canonical =
|p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
canonical(a) == canonical(b)
}
fn remove_from_agents(selected: &[hooks::DetectedAgent], output: &OutputConfig) {
for agent in selected {
#[cfg(feature = "model-relay")]
if let Some(endpoints) = agent.binding.provider_endpoints() {
let summary = hooks::provider_endpoints::release_all(
endpoints,
hooks::model_relay_endpoints::ReleasedBy::Teardown,
);
if summary.restored > 0 {
output.print_step(&format!(
"Restored {} {} provider setting(s) the model relay had replaced",
summary.restored,
agent.binding.display_name()
));
}
for failure in &summary.failed {
output.print_info(&format!(
"Warning: could not restore a provider setting: {failure}"
));
}
}
#[cfg(feature = "model-relay")]
if hooks::declares_proxy_env(&*agent.binding) {
if let Err(e) = hooks::release_proxy_env(
&*agent.binding,
hooks::model_relay_endpoints::ReleasedBy::Teardown,
) {
output.print_info(&format!(
"Warning: could not release the model relay proxy settings: {} ({})",
e.message, e.code
));
}
}
if !agent.installable() {
#[cfg(feature = "model-relay")]
if let Err(e) = hooks::remove_model_relay_config(&*agent.binding) {
output.print_info(&format!(
"Warning: could not remove model relay wiring: {} ({})",
e.message, e.code
));
}
continue;
}
match hooks::remove_hooks(&*agent.binding) {
Ok(()) => {
let settings_path = agent.settings_path();
output.print_step(&format!("Hooks removed from {}", settings_path.display()));
#[cfg(feature = "model-relay")]
if let Err(e) = hooks::remove_model_relay_config(&*agent.binding) {
output.print_info(&format!(
"Warning: could not remove model relay wiring: {} ({})",
e.message, e.code
));
}
}
Err(e) => {
output.print_info(&format!(
"Warning: could not remove hooks for {}: {} ({})",
agent.display_name(),
e.message,
e.code
));
}
}
}
}
#[cfg(feature = "model-relay")]
pub(crate) struct InstallScope<'a> {
pub ol_dir: &'a std::path::Path,
pub home: Option<&'a std::path::Path>,
pub documents: Option<&'a std::path::Path>,
pub machine_global: bool, pub store: &'a dyn crate::model_relay::trust_store::TrustStore,
}
#[cfg(feature = "model-relay")]
fn proxy_env_survivors(
selected: &[hooks::DetectedAgent],
detected: &[hooks::DetectedAgent],
) -> Vec<&'static str> {
use crate::hooks::binding::EndpointConvention;
detected
.iter()
.filter(|a| !selected.iter().any(|s| s.agent_type() == a.agent_type()))
.filter(|a| {
a.binding
.model_relay_wiring()
.is_some_and(|w| matches!(w.endpoint, EndpointConvention::ProxyEnv { .. }))
})
.map(|a| a.agent_type())
.collect()
}
#[cfg(feature = "model-relay")]
fn dir_holds_files(dir: &std::path::Path) -> bool {
std::fs::read_dir(dir)
.map(|mut entries| entries.next().is_some())
.unwrap_or(false)
}
#[cfg(feature = "model-relay")]
fn remove_install_scoped_relay_artefacts(
selected: &[hooks::DetectedAgent],
detected: &[hooks::DetectedAgent],
scope: &InstallScope<'_>,
output: &OutputConfig,
) {
use crate::hooks::{proxy_env_file, shell_profile};
use crate::model_relay::{ca, ca_lifecycle};
let survivors = proxy_env_survivors(selected, detected);
if !survivors.is_empty() {
output.print_info(&format!(
"The model relay's certificate authority is kept: {} still route through it",
survivors.join(", ")
));
return;
}
for dir in [
ca::ca_dir(scope.ol_dir),
ca_lifecycle::staging_dir(scope.ol_dir),
ca_lifecycle::prev_dir(scope.ol_dir), ] {
let info = ca::inspect(&dir);
let mut keep_dir = false;
if let (Some(info), true) = (&info, scope.machine_global) {
let _ = scope.store.remove(&info.sha256_hex);
match scope.store.is_trusted(&info.sha256_hex) {
Ok(false) => {
output.print_step("Model relay certificate removed from your trust store")
}
Ok(true) | Err(_) => {
keep_dir = true;
output.print_note(&format!(
"Warning: the model relay certificate (SHA-256 {}) is still in your trust \
store — re-run `openlatch uninstall` from a desktop session; it is kept \
on disk until then ({})",
info.sha256_hex,
crate::error::ERR_MODEL_RELAY_CA_UNTRUSTED
));
}
}
} else if info.is_none() && scope.machine_global && dir_holds_files(&dir) {
keep_dir = true;
output.print_note(&format!(
"Warning: {} could not be read, so its trust-store entry cannot be identified — \
it is kept on disk; remove the certificate from your user trust store by hand, \
then delete {} yourself ({})",
ca::ca_pem_path(&dir).display(),
dir.display(),
crate::error::ERR_MODEL_RELAY_CA_UNTRUSTED
));
}
if keep_dir {
continue;
}
match ca::remove(&dir) {
Ok(true) if !dir.exists() => {
output.print_step("Model relay certificate authority removed")
}
Ok(true) => output.print_note("Warning: the model relay CA directory is still on disk"),
Ok(false) => {}
Err(e) => output.print_note(&format!(
"Warning: could not remove the model relay CA: {} ({})",
e.message, e.code
)),
}
}
for p in [
proxy_env_file::env_sh_path(scope.ol_dir),
proxy_env_file::env_ps1_path(scope.ol_dir),
proxy_env_file::live_marker_path(scope.ol_dir),
] {
let _ = std::fs::remove_file(p); }
if scope.machine_global {
if let Some(home) = scope.home {
if let Ok(files) = shell_profile::remove_line(home, scope.documents) {
if !files.is_empty() {
output.print_step("Shell profile line removed");
}
}
}
}
}
pub fn run_uninstall(args: &UninstallArgs, output: &OutputConfig) -> Result<(), OlError> {
let purge_target = if args.purge {
resolve_purge_target()?
} else {
None
};
let purge_machine_global = args.purge && is_machine_default_install();
if !args.yes {
let is_tty = std::io::stdout().is_terminal();
if is_tty && output.format == OutputFormat::Human {
let purge_note = if args.purge {
" and DELETE all OpenLatch data"
} else {
""
};
eprint!("This will remove OpenLatch hooks{purge_note} and stop the daemon. Continue? [y/N] ");
let _ = std::io::stderr().flush();
let mut line = String::new();
let _ = std::io::stdin().read_line(&mut line);
if !line.trim().eq_ignore_ascii_case("y") {
output.print_info("Aborted.");
return Ok(());
}
}
}
let detected = hooks::detect_agents();
let selected = match hooks::select_agents(detected.clone(), &args.agent) {
Ok(v) => v,
Err(e) => {
output.print_error(&e);
return Err(e);
}
};
if selected.is_empty() {
output.print_info("Agent not detected — skipping hook removal");
}
remove_from_agents(&selected, output);
if !crate::supervision::owns_machine_supervision() {
output.print_info("Isolated install — the machine's supervisor unit is left in place");
} else if let Some(supervisor) = crate::supervision::select_supervisor() {
match supervisor.uninstall() {
Ok(()) => output.print_step("Supervision removed"),
Err(e) => output.print_info(&format!(
"Warning: could not remove supervision: {} ({})",
e.message, e.code
)),
}
}
let config_path = config::openlatch_dir().join("config.toml");
if config_path.exists() {
let _ = config::persist_supervision_state(
&config_path,
&crate::supervision::SupervisionMode::Disabled,
&crate::supervision::SupervisorKind::None,
Some("uninstalled"),
);
}
lifecycle::run_stop(output)?;
#[cfg(feature = "model-relay")]
remove_install_scoped_relay_artefacts(
&selected,
&detected,
&InstallScope {
ol_dir: &config::openlatch_dir(),
home: dirs::home_dir().as_deref(),
documents: dirs::document_dir().as_deref(),
machine_global: crate::supervision::owns_machine_supervision(),
store: &*crate::model_relay::trust_store::store(),
},
output,
);
if args.purge {
if let Some(canonical) = purge_target {
std::fs::remove_dir_all(&canonical).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!(
"Cannot delete openlatch directory '{}': {e}",
canonical.display()
),
)
.with_suggestion("Check that you have write permission.")
})?;
output.print_step(&format!("Data directory removed: {}", canonical.display()));
} else {
output.print_info("Data directory does not exist — nothing to purge");
}
if purge_machine_global {
match crate::auth::CredentialStore::delete(&crate::auth::KeyringCredentialStore::new())
{
Ok(()) => output.print_step("Credentials cleared from the OS keychain"),
Err(e) => output.print_info(&format!(
"Warning: could not clear the OS keychain credential: {} ({})",
e.message, e.code
)),
}
} else {
output.print_info(
"Isolated install — the machine's OS-keychain credential is left in place",
);
}
}
crate::telemetry::capture_global(crate::telemetry::Event::uninstalled(1));
if output.format == OutputFormat::Json {
let json = serde_json::json!({
"status": "ok",
"purged": args.purge,
});
output.print_json(&json);
} else if !output.quiet {
eprintln!();
eprintln!("OpenLatch uninstalled successfully.");
if args.purge {
eprintln!("All data removed.");
} else {
eprintln!(
"Data directory preserved at {}. Use --purge to remove it.",
config::openlatch_dir().display()
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(feature = "model-relay")]
fn uninstall_restores_every_cline_provider_slot() {
use crate::hooks::cline_providers::{write_slot_in, LaneTag, SlotId};
use crate::hooks::model_relay_endpoints::{EndpointRecord, SlotState, SlotValue};
use std::sync::Arc;
let _state_lock = crate::config::OPENLATCH_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let home_lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let root = tempfile::tempdir().expect("temp dir");
let data = root.path().join("store").join("data");
let _seam = crate::hooks::cline::cline_isolated([
("HOME", Some(root.path().join("home").into_os_string())),
(
"OPENLATCH_DIR",
Some(root.path().join("openlatch").into_os_string()),
),
(
crate::hooks::cline::STORE_DIR_ENV,
Some(root.path().join("store").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()),
),
]);
std::fs::create_dir_all(&data).expect("data dir");
std::fs::create_dir_all(root.path().join("openlatch")).expect("openlatch dir");
let state = data.join("globalState.json");
let before = concat!(
"{\n",
" // the customer's own comment\n",
" \"actModeApiProvider\": \"openai-compatible\",\n",
" \"openAiBaseUrl\": \"https://qwen.internal.bea/v1\",\n",
" \"telemetrySetting\": \"disabled\"\n",
"}\n"
);
std::fs::write(&state, before).expect("seed");
let key = "cline:gs:shared:openAiBaseUrl";
crate::hooks::model_relay_endpoints::put_endpoint(
key,
EndpointRecord {
port: 7605,
origin: "https://qwen.internal.bea/".into(),
prior: SlotValue::Text("https://qwen.internal.bea/v1".into()),
last_written: Some("http://127.0.0.1:7605/v1".into()),
file: state.clone(),
state: SlotState::Wired,
released_by: None,
changed_at: 1,
proven_at: None,
family: None,
pending_event: None,
proven_by: None,
misconfigured_event: None,
},
)
.expect("record");
let slot = SlotId::GlobalState {
lane: LaneTag::Shared,
key: "openAiBaseUrl".into(),
};
write_slot_in(
&state,
&slot,
&SlotValue::Text("http://127.0.0.1:7605/v1".into()),
&|_| true,
)
.expect("wire");
assert!(std::fs::read_to_string(&state)
.expect("read back")
.contains("http://127.0.0.1:7605/v1"));
let binding =
crate::hooks::bindings::cline::ClineBinding::detect().expect("store root exists");
let selected = vec![hooks::DetectedAgent {
kind: hooks::AgentKind::Cline,
binding: Arc::new(binding),
}];
remove_from_agents(
&selected,
&OutputConfig {
format: OutputFormat::Human,
verbose: false,
debug: false,
quiet: true,
color: false,
},
);
assert_eq!(
std::fs::read_to_string(&state).expect("read back"),
before,
"uninstall must restore Cline's file byte-identically"
);
drop(home_lock);
}
#[test]
#[cfg(unix)]
fn a_redirected_state_dir_is_the_default_one_only_when_it_resolves_there() {
let home = tempfile::tempdir().unwrap();
let default = home.path().join(".openlatch");
std::fs::create_dir_all(&default).unwrap();
let elsewhere = home.path().join("sandbox").join("openlatch");
std::fs::create_dir_all(&elsewhere).unwrap();
assert!(
!same_directory(&elsewhere, &default),
"a sandbox state directory must not pass for the machine's install"
);
let via_symlink = home.path().join("link");
std::os::unix::fs::symlink(&default, &via_symlink).unwrap();
assert!(
same_directory(&via_symlink, &default),
"a redirection that resolves to the default IS the default"
);
}
#[test]
fn the_state_directory_olbox_creates_is_purgeable() {
assert!(
is_purgeable_state_dir_name("openlatch"),
"`olbox` names a sandbox's state directory `openlatch`; `--purge` must accept it"
);
}
fn quiet_output() -> OutputConfig {
OutputConfig {
format: OutputFormat::Human,
verbose: false,
debug: false,
quiet: true,
color: false,
}
}
#[cfg(feature = "model-relay")]
use crate::model_relay::trust_store::test_support::FakeStore;
#[test]
#[cfg(feature = "model-relay")]
fn whole_uninstall_removes_the_ca_and_its_store_entry_read_back_to_zero() {
use crate::model_relay::trust_store::TrustStore;
let ol = tempfile::tempdir().expect("tempdir");
let ca = crate::model_relay::ca::LocalCa::generate_into(&crate::model_relay::ca::ca_dir(
ol.path(),
))
.expect("ca");
let next = crate::model_relay::ca::LocalCa::generate_into(
&crate::model_relay::ca_lifecycle::staging_dir(ol.path()),
)
.expect("next");
let prev = crate::model_relay::ca::LocalCa::generate_into(
&crate::model_relay::ca_lifecycle::prev_dir(ol.path()),
)
.expect("prev");
let env_sh = crate::hooks::proxy_env_file::env_sh_path(ol.path());
std::fs::create_dir_all(env_sh.parent().expect("parent")).expect("mkdir");
std::fs::write(&env_sh, "content\n").expect("write env.sh");
std::fs::write(
crate::hooks::proxy_env_file::env_ps1_path(ol.path()),
"content\n",
)
.expect("write env.ps1");
std::fs::write(
crate::hooks::proxy_env_file::live_marker_path(ol.path()),
"1234",
)
.expect("write marker");
let home = tempfile::tempdir().expect("home");
let zshrc = home.path().join(".zshrc");
std::fs::write(
&zshrc,
format!(
"customer line\nsome line {}\n",
crate::hooks::shell_profile::MARKER
),
)
.expect("write zshrc");
let store = FakeStore::default();
store.trust(&ca.sha256_hex());
store.trust(&next.sha256_hex());
store.trust(&prev.sha256_hex());
remove_install_scoped_relay_artefacts(
&[],
&[],
&InstallScope {
ol_dir: ol.path(),
home: Some(home.path()),
documents: None,
machine_global: true,
store: &store,
},
&quiet_output(),
);
assert!(!crate::model_relay::ca::ca_dir(ol.path()).exists());
assert!(!crate::model_relay::ca_lifecycle::staging_dir(ol.path()).exists());
assert!(!crate::model_relay::ca_lifecycle::prev_dir(ol.path()).exists());
assert!(!env_sh.exists());
assert!(!crate::hooks::proxy_env_file::env_ps1_path(ol.path()).exists());
assert!(!crate::hooks::proxy_env_file::live_marker_path(ol.path()).exists());
assert!(!store.is_trusted(&ca.sha256_hex()).expect("is_trusted"));
assert!(!store.is_trusted(&next.sha256_hex()).expect("is_trusted"));
assert!(!store.is_trusted(&prev.sha256_hex()).expect("is_trusted"));
assert!(!std::fs::read_to_string(&zshrc)
.expect("read zshrc")
.contains(crate::hooks::shell_profile::MARKER));
}
#[test]
#[cfg(feature = "model-relay")]
fn per_agent_uninstall_keeps_the_ca_while_another_proxy_env_agent_remains() {
use crate::hooks::binding::test_support::{proxy_env_wiring, FakeBinding};
use crate::hooks::binding::ProxyDelivery;
use std::sync::Arc;
const DELIVERY: &[ProxyDelivery] = &[ProxyDelivery::EnvFile {
commands: &["fakecli"],
}];
fn agent(agent_type: &'static str) -> hooks::DetectedAgent {
hooks::DetectedAgent {
kind: hooks::AgentKind::ClaudeCode,
binding: Arc::new(FakeBinding {
agent_type,
model_relay_wiring: Some(proxy_env_wiring(&["a.test"], DELIVERY)),
..Default::default()
}),
}
}
let a = agent("fake-a");
let b = agent("fake-b");
let detected = vec![a.clone(), b.clone()];
assert_eq!(
proxy_env_survivors(std::slice::from_ref(&a), &detected),
vec!["fake-b"]
);
let ol = tempfile::tempdir().expect("tempdir");
crate::model_relay::ca::LocalCa::generate_into(&crate::model_relay::ca::ca_dir(ol.path()))
.expect("ca");
let store = FakeStore::default();
remove_install_scoped_relay_artefacts(
std::slice::from_ref(&a),
&detected,
&InstallScope {
ol_dir: ol.path(),
home: None,
documents: None,
machine_global: true,
store: &store,
},
&quiet_output(),
);
assert!(crate::model_relay::ca::ca_dir(ol.path()).exists());
assert_eq!(
store.calls().len(),
0,
"a survivor means the store is never touched"
);
remove_install_scoped_relay_artefacts(
&[a, b],
&detected,
&InstallScope {
ol_dir: ol.path(),
home: None,
documents: None,
machine_global: true,
store: &store,
},
&quiet_output(),
);
assert!(!crate::model_relay::ca::ca_dir(ol.path()).exists());
}
#[test]
#[cfg(feature = "model-relay")]
fn an_isolated_uninstall_never_touches_the_store_or_the_profile() {
let ol = tempfile::tempdir().expect("tempdir");
crate::model_relay::ca::LocalCa::generate_into(&crate::model_relay::ca::ca_dir(ol.path()))
.expect("ca");
let home = tempfile::tempdir().expect("home");
let zshrc = home.path().join(".zshrc");
let original = format!(
"customer line\nsome line {}\n",
crate::hooks::shell_profile::MARKER
);
std::fs::write(&zshrc, &original).expect("write zshrc");
let store = FakeStore::default();
remove_install_scoped_relay_artefacts(
&[],
&[],
&InstallScope {
ol_dir: ol.path(),
home: Some(home.path()),
documents: None,
machine_global: false,
store: &store,
},
&quiet_output(),
);
assert_eq!(
store.calls().len(),
0,
"an isolated instance never installed, and never removes"
);
assert_eq!(
std::fs::read_to_string(&zshrc).expect("read zshrc"),
original,
"the profile is untouched"
);
}
#[test]
#[cfg(feature = "model-relay")]
fn a_store_removal_that_does_not_read_back_keeps_the_ca_on_disk() {
let ol = tempfile::tempdir().expect("tempdir");
let ca = crate::model_relay::ca::LocalCa::generate_into(&crate::model_relay::ca::ca_dir(
ol.path(),
))
.expect("ca");
let store = FakeStore::default();
store.trust(&ca.sha256_hex());
store.refuse_removal_of(&ca.sha256_hex());
remove_install_scoped_relay_artefacts(
&[],
&[],
&InstallScope {
ol_dir: ol.path(),
home: None,
documents: None,
machine_global: true,
store: &store,
},
&quiet_output(),
);
assert!(
crate::model_relay::ca::ca_dir(ol.path()).exists(),
"a refused read-back must keep the CA on disk"
);
store.allow_removal_of(&ca.sha256_hex());
remove_install_scoped_relay_artefacts(
&[],
&[],
&InstallScope {
ol_dir: ol.path(),
home: None,
documents: None,
machine_global: true,
store: &store,
},
&quiet_output(),
);
assert!(
!crate::model_relay::ca::ca_dir(ol.path()).exists(),
"a retry with a working store then removes both"
);
}
#[test]
#[cfg(feature = "model-relay")]
fn a_garbage_ca_pem_in_a_machine_global_scope_is_kept_for_manual_removal() {
let ol = tempfile::tempdir().expect("tempdir");
let ca_dir = crate::model_relay::ca::ca_dir(ol.path());
std::fs::create_dir_all(&ca_dir).expect("mkdir");
std::fs::write(ca_dir.join("ca.pem"), b"not a certificate").expect("write garbage");
assert!(
crate::model_relay::ca::inspect(&ca_dir).is_none(),
"the fixture must actually be unreadable"
);
let store = FakeStore::default();
remove_install_scoped_relay_artefacts(
&[],
&[],
&InstallScope {
ol_dir: ol.path(),
home: None,
documents: None,
machine_global: true,
store: &store,
},
&quiet_output(),
);
assert!(
ca_dir.exists(),
"an unreadable CA directory must be kept, not deleted"
);
assert_eq!(
store.calls().len(),
0,
"with no readable fingerprint there is nothing to remove from the store"
);
let ol2 = tempfile::tempdir().expect("tempdir");
let ca_dir2 = crate::model_relay::ca::ca_dir(ol2.path());
std::fs::create_dir_all(&ca_dir2).expect("mkdir");
std::fs::write(ca_dir2.join("ca.pem"), b"not a certificate").expect("write garbage");
let store2 = FakeStore::default();
remove_install_scoped_relay_artefacts(
&[],
&[],
&InstallScope {
ol_dir: ol2.path(),
home: None,
documents: None,
machine_global: false,
store: &store2,
},
&quiet_output(),
);
assert!(
!ca_dir2.exists(),
"an isolated install still removes its own garbage directory"
);
}
#[test]
#[cfg(feature = "model-relay")]
fn uninstall_leaves_every_customer_value_intact() {
let ol = tempfile::tempdir().expect("tempdir");
let home = tempfile::tempdir().expect("home");
let zshrc = home.path().join(".zshrc");
let customer = format!(
"export PATH=/custom:$PATH\nsome line {}\nexport FOO=bar\n",
crate::hooks::shell_profile::MARKER
);
std::fs::write(&zshrc, &customer).expect("write zshrc");
let bashrc = home.path().join(".bashrc");
std::fs::write(&bashrc, "export ONLY_CUSTOMER=1\n").expect("write bashrc");
let store = FakeStore::default();
remove_install_scoped_relay_artefacts(
&[],
&[],
&InstallScope {
ol_dir: ol.path(),
home: Some(home.path()),
documents: None,
machine_global: true,
store: &store,
},
&quiet_output(),
);
assert_eq!(
std::fs::read_to_string(&zshrc).expect("read zshrc"),
"export PATH=/custom:$PATH\nexport FOO=bar\n"
);
assert_eq!(
std::fs::read_to_string(&bashrc).expect("read bashrc"),
"export ONLY_CUSTOMER=1\n"
);
}
#[cfg(feature = "model-relay")]
const EDITOR_SEED: &str = "{\n \"a\": 1,\n}\n";
#[cfg(feature = "model-relay")]
struct ClineHost {
_seam: crate::hooks::cline::ClineSeam,
_home_lock: std::sync::MutexGuard<'static, ()>,
_state_lock: std::sync::MutexGuard<'static, ()>,
root: tempfile::TempDir,
data: std::path::PathBuf,
}
#[cfg(feature = "model-relay")]
fn cline_host() -> ClineHost {
let _state_lock = crate::config::OPENLATCH_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _home_lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let root = tempfile::tempdir().expect("temp dir");
let data = root.path().join("store").join("data");
let _seam = crate::hooks::cline::cline_isolated([
("HOME", Some(root.path().join("home").into_os_string())),
(
"OPENLATCH_DIR",
Some(root.path().join("openlatch").into_os_string()),
),
(
crate::hooks::cline::STORE_DIR_ENV,
Some(root.path().join("store").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()),
),
]);
std::fs::create_dir_all(&data).expect("data dir");
std::fs::create_dir_all(root.path().join("openlatch")).expect("openlatch dir");
ClineHost {
_seam,
_home_lock,
_state_lock,
root,
data,
}
}
#[cfg(feature = "model-relay")]
fn wire_slot(
file: &std::path::Path,
slot: crate::hooks::cline_providers::SlotId,
port: u16,
origin: &str,
) {
use crate::hooks::model_relay_endpoints::{EndpointRecord, SlotState, SlotValue};
let ours = format!("http://127.0.0.1:{port}");
crate::hooks::model_relay_endpoints::put_endpoint(
&slot.record_key(),
EndpointRecord {
port,
origin: origin.into(),
prior: SlotValue::Absent,
last_written: Some(ours.clone()),
file: file.to_path_buf(),
state: SlotState::Wired,
released_by: None,
changed_at: 1,
proven_at: None,
family: None,
pending_event: None,
proven_by: None,
misconfigured_event: None,
},
)
.expect("record");
crate::hooks::cline_providers::write_slot_in(file, &slot, &SlotValue::Text(ours), &|_| {
true
})
.expect("wire");
}
#[cfg(feature = "model-relay")]
fn deliver_proxy(agent: &str, editor: &std::path::Path, key: &str, commands: &[&str]) {
const RELAY: &str = "http://127.0.0.1:7600";
crate::hooks::proxy_settings::write_proxy_key(editor, key, RELAY, agent)
.expect("editor key");
let ol = config::openlatch_dir();
crate::hooks::proxy_env_file::write_block(
agent,
commands,
RELAY,
&crate::model_relay::ca::ca_pem_path(&crate::model_relay::ca::ca_dir(&ol)),
"127.0.0.1:7600",
)
.expect("wrapper");
}
#[cfg(feature = "model-relay")]
fn wire_every_cline_surface(host: &ClineHost) -> Vec<(std::path::PathBuf, Vec<u8>)> {
use crate::hooks::cline_providers::{LaneTag, SlotId};
let state = host.data.join("globalState.json");
std::fs::write(&state, "{\n \"actModeApiProvider\": \"anthropic\"\n}\n").expect("seed");
let pj = crate::hooks::cline::providers_json_path().expect("providers.json path");
std::fs::create_dir_all(pj.parent().expect("parent")).expect("mkdir");
std::fs::write(&pj, "{\"providers\":{\"deepseek\":{\"settings\":{}}}}\n").expect("seed");
let editor = host.root.path().join("editor").join("settings.json");
std::fs::create_dir_all(editor.parent().expect("parent")).expect("mkdir");
std::fs::write(&editor, EDITOR_SEED).expect("seed");
let snapshots: Vec<(std::path::PathBuf, Vec<u8>)> = [&state, &pj, &editor]
.into_iter()
.map(|p| (p.clone(), std::fs::read(p).expect("snapshot")))
.collect();
let gs = |key: &str| SlotId::GlobalState {
lane: LaneTag::Shared,
key: key.into(),
};
wire_slot(
&state,
gs("anthropicBaseUrl"),
7601,
"https://api.anthropic.com/",
);
wire_slot(&state, gs("ollamaBaseUrl"), 7602, "http://127.0.0.1:11434/");
wire_slot(
&pj,
SlotId::ProvidersJson {
id: "deepseek".into(),
},
7603,
"https://api.deepseek.com/",
);
deliver_proxy("cline", &editor, "http.proxy", &["cline"]);
let ol = config::openlatch_dir();
crate::hooks::proxy_env_file::set_live(true).expect("marker");
for (path, _) in &snapshots {
assert!(
std::fs::read_to_string(path)
.expect("read")
.contains("127.0.0.1:76"),
"{} must name the relay before",
path.display()
);
}
assert!(crate::hooks::proxy_env_file::env_sh_path(&ol).exists());
assert!(crate::hooks::proxy_env_file::live_marker_path(&ol).exists());
snapshots
}
#[cfg(feature = "model-relay")]
fn real_cline() -> hooks::DetectedAgent {
let binding =
crate::hooks::bindings::cline::ClineBinding::detect().expect("store root exists");
assert!(
hooks::declares_proxy_env(&binding),
"premise: Cline declares ProxyEnv"
);
hooks::DetectedAgent {
kind: hooks::AgentKind::Cline,
binding: std::sync::Arc::new(binding),
}
}
#[cfg(feature = "model-relay")]
fn assert_nothing_aimed_at_the_relay(snapshots: &[(std::path::PathBuf, Vec<u8>)]) {
for (path, before) in snapshots {
assert_eq!(
&std::fs::read(path).expect("read back"),
before,
"{} must be byte-identical to its snapshot",
path.display()
);
}
let ol = config::openlatch_dir();
assert!(!crate::hooks::proxy_env_file::env_sh_path(&ol).exists());
assert!(!crate::hooks::proxy_env_file::live_marker_path(&ol).exists());
}
#[test]
#[cfg(feature = "model-relay")]
fn uninstall_leaves_no_cline_surface_aimed_at_the_relay() {
let host = cline_host();
let snapshots = wire_every_cline_surface(&host);
remove_from_agents(&[real_cline()], &quiet_output());
assert_nothing_aimed_at_the_relay(&snapshots);
}
#[test]
#[cfg(feature = "model-relay")]
fn a_whole_uninstall_with_cline_on_proxy_env_releases_then_removes_the_ca() {
use crate::model_relay::trust_store::TrustStore;
let host = cline_host();
let snapshots = wire_every_cline_surface(&host);
let ol = config::openlatch_dir();
let ca =
crate::model_relay::ca::LocalCa::generate_into(&crate::model_relay::ca::ca_dir(&ol))
.expect("ca");
let store = FakeStore::default();
store.trust(&ca.sha256_hex());
let cline = real_cline();
let claude_shaped = hooks::DetectedAgent {
kind: hooks::AgentKind::ClaudeCode,
binding: std::sync::Arc::new(crate::hooks::binding::test_support::FakeBinding {
agent_type: "claude-shaped",
installable: false,
model_relay_wiring: Some(crate::hooks::binding::ModelRelayWiring {
wire_format: crate::model_relay::wire_format::WireFormat::AnthropicMessages,
endpoint: crate::hooks::binding::EndpointConvention::EnvVars {
base_url: "FAKE_BASE_URL",
headers: "FAKE_HEADERS",
},
install_id_header: "x-openlatch-install-id",
}),
..Default::default()
}),
};
let detected = vec![cline.clone(), claude_shaped];
assert!(
proxy_env_survivors(std::slice::from_ref(&cline), &detected).is_empty(),
"a per-agent Cline uninstall leaves no ProxyEnv survivor either"
);
remove_from_agents(std::slice::from_ref(&cline), &quiet_output());
assert_nothing_aimed_at_the_relay(&snapshots);
remove_install_scoped_relay_artefacts(
&detected,
&detected,
&InstallScope {
ol_dir: &ol,
home: None,
documents: None,
machine_global: true,
store: &store,
},
&quiet_output(),
);
assert!(!crate::model_relay::ca::ca_dir(&ol).exists());
assert!(!store.is_trusted(&ca.sha256_hex()).expect("is_trusted"));
assert_nothing_aimed_at_the_relay(&snapshots);
}
#[test]
#[cfg(feature = "model-relay")]
fn a_failed_hook_removal_still_releases_the_proxy_delivery() {
use crate::hooks::binding::test_support::{proxy_env_wiring, FakeBinding};
use crate::hooks::binding::ProxyDelivery;
fn editor_file() -> Option<std::path::PathBuf> {
Some(
config::openlatch_dir()
.join("fake-editor")
.join("settings.json"),
)
}
const DELIVERY: &[ProxyDelivery] = &[
ProxyDelivery::SettingsKey {
file: editor_file,
key: "fake.proxy",
},
ProxyDelivery::EnvFile {
commands: &["fakecli"],
},
];
let _lock = crate::config::OPENLATCH_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
let _env = crate::hooks::cline::EnvOverride::apply([(
"OPENLATCH_DIR",
Some(tmp.path().join("openlatch").into_os_string()),
)]);
let ol = config::openlatch_dir();
let config_dir = tmp.path().join("fake-agent");
std::fs::create_dir_all(config_dir.join("settings.json")).expect("plant a directory");
let binding = FakeBinding {
agent_type: "fake-a",
display_name: "Fake",
config_dir,
installable: true,
model_relay_wiring: Some(proxy_env_wiring(&["a.test"], DELIVERY)),
..Default::default()
};
assert!(
hooks::remove_hooks(&binding).is_err(),
"premise: the hook removal fails"
);
let editor = editor_file().expect("path");
std::fs::create_dir_all(editor.parent().expect("parent")).expect("mkdir");
std::fs::write(&editor, EDITOR_SEED).expect("seed");
deliver_proxy("fake-a", &editor, "fake.proxy", &["fakecli"]);
assert!(crate::hooks::proxy_env_file::env_sh_path(&ol).exists());
remove_from_agents(
&[hooks::DetectedAgent {
kind: hooks::AgentKind::ClaudeCode,
binding: std::sync::Arc::new(binding),
}],
&quiet_output(),
);
assert_eq!(std::fs::read_to_string(&editor).expect("read"), EDITOR_SEED);
assert!(!crate::hooks::proxy_env_file::env_sh_path(&ol).exists());
}
}