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
/// `openlatch uninstall` command handler.
///
/// Removes OpenLatch hooks from the agent config and stops the daemon.
/// Optionally deletes the openlatch data directory with `--purge`.
///
/// SECURITY (T-02-08): Only deletes openlatch_dir(), never follows symlinks outside it.
/// Requires --yes or interactive confirmation.
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;
/// Run the `openlatch uninstall` command.
///
/// Steps:
/// 1. Confirm (unless --yes or non-interactive)
/// 2. Remove hooks from settings.json
/// 3. Stop the daemon
/// 4. If --purge, delete the openlatch data directory
///
/// # Errors
///
/// Returns an error if hook removal or directory deletion fails.
pub fn run_uninstall(args: &UninstallArgs, output: &OutputConfig) -> Result<(), OlError> {
// Step 1: Confirm (T-02-08: require explicit confirmation)
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(());
}
}
}
// Step 2: Remove hooks from agent settings
match hooks::detect_agent() {
Ok(agent) => {
match hooks::remove_hooks(&agent) {
Ok(()) => {
let settings_path = match &agent {
hooks::DetectedAgent::ClaudeCode { settings_path, .. } => settings_path,
};
output.print_step(&format!("Hooks removed from {}", settings_path.display()));
// Also tear down the model-boundary wiring (ANTHROPIC_BASE_URL +
// install-id header). The boundary is on by default, so without
// this uninstall would leave the agent pointed at a dead
// 127.0.0.1:7600. Idempotent + loopback-safe: only OUR loopback
// URL / install-id lines are removed; customer-set values survive.
#[cfg(feature = "boundary")]
if let Err(e) = hooks::remove_boundary_config(settings_path) {
output.print_info(&format!(
"Warning: could not remove boundary wiring: {} ({})",
e.message, e.code
));
}
}
Err(e) => {
// Non-fatal: log but continue
output.print_info(&format!(
"Warning: could not remove hooks: {} ({})",
e.message, e.code
));
}
}
}
Err(_) => {
// Agent not found — hooks might not be installed, that's fine
output.print_info("Agent not detected — skipping hook removal");
}
}
// Step 2.5: Tear down OS supervision BEFORE stopping the daemon.
// If we stopped the daemon first, the supervisor would immediately restart
// it. Best-effort — never block uninstall.
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
)),
}
}
// Best-effort: persist mode=disabled so post-uninstall state reflects reality.
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"),
);
}
// Step 3: Stop the daemon
lifecycle::run_stop(output)?;
// Step 4: Purge data directory if --purge (T-02-08: only openlatch_dir(), no symlink following)
if args.purge {
let ol_dir = config::openlatch_dir();
// SECURITY: Canonicalize and verify the directory is within expected bounds
// before deletion to prevent path traversal.
if ol_dir.exists() {
let canonical = std::fs::canonicalize(&ol_dir).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot canonicalize openlatch directory: {e}"),
)
})?;
// Sanity check: the canonical path should end with "openlatch" or ".openlatch"
// This prevents accidental deletion of the wrong directory.
let dir_name = canonical.file_name().and_then(|n| n.to_str()).unwrap_or("");
if dir_name != "openlatch" && dir_name != ".openlatch" {
return Err(OlError::new(
ERR_INVALID_CONFIG,
format!(
"Unexpected openlatch directory name '{}' — refusing to delete for safety",
canonical.display()
),
)
.with_suggestion("If this is correct, manually delete the directory."));
}
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");
}
}
// Telemetry: emit uninstalled. Only one supported agent today, so the
// count is 1 if removal was attempted, 0 otherwise. This is best-effort —
// we do not retain the prior detection result here.
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(())
}