Skip to repository content494 lines · 20.2 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:30:34.546Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
uninstall.rs
1//! What `omega --uninstall` removes. OMEGA-DELTA-0036.
2//!
3//! Up to `0.2.0-rc14` this path ran upstream's uninstaller unchanged. A flag
4//! advertised as "Uninstall Omega from user system" removed the *other*
5//! editor's application bundle, its whole application-support tree, its logs,
6//! its preferences and its remote-server directory, told the user that editor
7//! had been uninstalled, and removed no Omega path whatsoever. Omega survived
8//! the uninstall intact.
9//!
10//! The defect was not a typo in a path. It was that the paths lived in a
11//! hand-written table in a shell script, disconnected from the code that
12//! creates those directories, so nothing in the tree could notice that the two
13//! had never agreed. So there is no table here either: every root comes from
14//! the `paths::` function that produced it, and the plan is a value the tests
15//! can read.
16
17use std::path::{Path, PathBuf};
18
19/// The installation root containing `path`.
20///
21/// On macOS an Omega installation is one directory — `Omega.app` — and every
22/// path a caller has to hand names something *inside* it: the running
23/// executable, the `cli` beside it, a resource. Removing what the caller
24/// happened to be holding is not an uninstall, so any path under a `.app`
25/// resolves to the outermost `.app` in it. Anything else, including a
26/// development build sitting loose in `target/debug`, is returned unchanged.
27#[must_use]
28pub fn installation_root(path: &Path) -> PathBuf {
29 let mut root: Option<&Path> = None;
30 let mut cursor = Some(path);
31 while let Some(candidate) = cursor {
32 if candidate
33 .extension()
34 .is_some_and(|extension| extension.eq_ignore_ascii_case("app"))
35 {
36 root = Some(candidate);
37 }
38 cursor = candidate.parent().filter(|parent| !parent.as_os_str().is_empty());
39 }
40 root.unwrap_or(path).to_path_buf()
41}
42
43/// The roots an Omega installation occupies on this machine.
44///
45/// One field per place Omega writes. `plan` destructures this exhaustively, so
46/// a root added here without being planned does not compile — which is the
47/// property the old shell table could not have.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct UninstallRoots {
50 /// The installation root — the whole directory Omega occupies, never one
51 /// executable inside it.
52 ///
53 /// The name and the doc comment used to read "the application bundle or
54 /// executable", and the caller took the second reading: `0.2.0-rc16`
55 /// planned `Omega.app/Contents/MacOS/omega`, so a completed uninstall left
56 /// `Omega.app` in `/Applications` with 130.9 MB, four more executables and
57 /// a bundled Node runtime in it — including a `cli` that still carried
58 /// `--uninstall` (omega#92). `installation_root` now resolves any path
59 /// inside a bundle to the bundle itself, so a caller that hands over an
60 /// executable cannot reintroduce that.
61 pub app_root: Option<PathBuf>,
62 /// `paths::data_dir()` — database, extensions, embeddings.
63 pub data_dir: PathBuf,
64 /// `paths::config_dir()` — settings and keymap. Prompted for, never
65 /// removed silently: it is the only root a user may want to keep.
66 pub config_dir: PathBuf,
67 /// `paths::logs_dir()` — on macOS this is `~/Library/Logs/<slug>`, not a
68 /// subdirectory of the data root. The tripwire harness got this wrong in
69 /// the other direction (omega#90).
70 pub logs_dir: PathBuf,
71 /// `paths::temp_dir()` — the cache root.
72 pub temp_dir: PathBuf,
73 /// `paths::state_dir()`.
74 pub state_dir: PathBuf,
75 /// The `omega` symlink an earlier "install CLI" put on `PATH`.
76 pub cli_symlink: PathBuf,
77 /// Paths keyed by the macOS bundle identifier: preferences, HTTP storage,
78 /// saved application state. Empty off macOS.
79 pub platform_paths: Vec<PathBuf>,
80}
81
82/// The display name and the paths, ready to hand to `script/uninstall.sh`.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct UninstallPlan {
85 /// Channel display name, e.g. `Omega RC`.
86 pub product: String,
87 /// Everything removed without asking.
88 pub removals: Vec<PathBuf>,
89 /// Removed only if the user says so.
90 pub config_dir: PathBuf,
91}
92
93impl UninstallRoots {
94 /// The roots of the installation this CLI binary belongs to.
95 ///
96 /// Every field is read from the function that writes it. Nothing here is a
97 /// literal path.
98 #[cfg(any(target_os = "linux", target_os = "macos"))]
99 pub fn from_installed_paths(app_root: Option<PathBuf>) -> Self {
100 let app_id = release_channel::RELEASE_CHANNEL.app_id();
101 let home = paths::home_dir();
102 let platform_paths = if cfg!(target_os = "macos") {
103 vec![
104 home.join("Library/HTTPStorages").join(app_id),
105 home.join("Library/Preferences").join(format!("{app_id}.plist")),
106 home.join("Library/Saved Application State")
107 .join(format!("{app_id}.savedState")),
108 ]
109 } else {
110 vec![
111 home.join(".local/share/applications")
112 .join(format!("{app_id}.desktop")),
113 ]
114 };
115 Self {
116 // Normalized here rather than trusted from the caller: the caller
117 // holds an executable, and every macOS caller always will.
118 app_root: app_root.as_deref().map(installation_root),
119 data_dir: paths::data_dir().clone(),
120 config_dir: paths::config_dir().clone(),
121 logs_dir: paths::logs_dir().clone(),
122 temp_dir: paths::temp_dir().clone(),
123 state_dir: paths::state_dir().clone(),
124 cli_symlink: home.join(".local/bin").join(paths::BINARY_NAME),
125 platform_paths,
126 }
127 }
128
129 /// Turn the roots into a plan, de-duplicated and in a stable order.
130 ///
131 /// The config directory is held back deliberately: it is the user's
132 /// settings and keymap, and the caller asks before removing it.
133 pub fn plan(&self, product: &str) -> UninstallPlan {
134 let Self {
135 app_root,
136 data_dir,
137 config_dir,
138 logs_dir,
139 temp_dir,
140 state_dir,
141 cli_symlink,
142 platform_paths,
143 } = self;
144
145 let mut removals: Vec<PathBuf> = Vec::new();
146 let mut push = |path: &Path| {
147 let path = path.to_path_buf();
148 if path != *config_dir && !removals.contains(&path) {
149 removals.push(path);
150 }
151 };
152 if let Some(app_root) = app_root {
153 push(app_root);
154 }
155 push(data_dir);
156 push(logs_dir);
157 push(temp_dir);
158 push(state_dir);
159 push(cli_symlink);
160 for path in platform_paths {
161 push(path);
162 }
163
164 UninstallPlan {
165 product: product.to_owned(),
166 removals,
167 config_dir: config_dir.clone(),
168 }
169 }
170}
171
172impl UninstallPlan {
173 /// The newline-separated path list `script/uninstall.sh` reads.
174 pub fn paths_env(&self) -> String {
175 self.removals
176 .iter()
177 .map(|path| path.to_string_lossy().into_owned())
178 .collect::<Vec<_>>()
179 .join("\n")
180 }
181}
182
183#[cfg(test)]
184#[allow(
185 clippy::disallowed_methods,
186 reason = "These tests run the shipped uninstall script as a subprocess; there is no async runtime here."
187)]
188mod tests {
189 use super::*;
190
191 /// The path the CLI actually holds: the executable it is running beside,
192 /// inside the bundle. Every root below is derived from it the way the
193 /// product derives it, so a plan that names the executable fails here
194 /// rather than in `/Applications`.
195 fn installed_executable(home: &Path) -> PathBuf {
196 home.join("Applications/Omega.app/Contents/MacOS/omega")
197 }
198
199 fn roots(home: &Path) -> UninstallRoots {
200 UninstallRoots {
201 app_root: Some(installation_root(&installed_executable(home))),
202 data_dir: home.join("Library/Application Support/Omega RC"),
203 config_dir: home.join(".config/omega-rc"),
204 logs_dir: home.join("Library/Logs/omega-rc"),
205 temp_dir: home.join("Library/Caches/omega-rc"),
206 state_dir: home.join(".local/state/omega-rc"),
207 cli_symlink: home.join(".local/bin/omega"),
208 platform_paths: vec![
209 home.join("Library/HTTPStorages/com.openagents.omega.rc"),
210 home.join("Library/Preferences/com.openagents.omega.rc.plist"),
211 ],
212 }
213 }
214
215 #[test]
216 fn the_plan_holds_the_config_directory_back_and_keeps_everything_else() {
217 let home = Path::new("/tmp/omega-uninstall-fixture");
218 let plan = roots(home).plan("Omega RC");
219 assert_eq!(plan.product, "Omega RC");
220 assert!(
221 !plan.removals.contains(&plan.config_dir),
222 "settings and keymap are prompted for, never removed silently"
223 );
224 for expected in [
225 home.join("Applications/Omega.app"),
226 home.join("Library/Application Support/Omega RC"),
227 home.join("Library/Logs/omega-rc"),
228 home.join("Library/Caches/omega-rc"),
229 home.join(".local/state/omega-rc"),
230 home.join(".local/bin/omega"),
231 ] {
232 assert!(
233 plan.removals.contains(&expected),
234 "the plan omits {}, which an installed Omega writes",
235 expected.display()
236 );
237 }
238 assert_eq!(
239 plan.paths_env().lines().count(),
240 plan.removals.len(),
241 "one path per line, and no path carrying a newline"
242 );
243 }
244
245 /// OMEGA-DELTA-0043. The plan names the installation, not one file in it.
246 ///
247 /// `0.2.0-rc16` planned `Omega.app/Contents/MacOS/omega`, so an uninstall
248 /// that reported success left the bundle, the `cli` that carries
249 /// `--uninstall`, `omega-identity-proof` and a bundled Node runtime in
250 /// `/Applications` (omega#92). Both directions are asserted: the bundle is
251 /// in the plan, and the executable is not standing in for it.
252 #[test]
253 fn the_plan_names_the_bundle_root_and_never_an_executable_inside_it() {
254 let home = Path::new("/tmp/omega-uninstall-fixture");
255 let bundle = home.join("Applications/Omega.app");
256 let plan = roots(home).plan("Omega RC");
257
258 assert!(
259 plan.removals.contains(&bundle),
260 "the plan does not remove {}, so the installation survives an \
261 uninstall that reports success",
262 bundle.display()
263 );
264 for inside in [
265 installed_executable(home),
266 bundle.join("Contents/MacOS/cli"),
267 bundle.join("Contents/Resources/omega-effectd/runtime/bin/node"),
268 ] {
269 assert!(
270 !plan.removals.contains(&inside),
271 "the plan names {}, a file inside the bundle. Removing one file \
272 out of an installation is not an uninstall.",
273 inside.display()
274 );
275 }
276
277 // The derivation itself, on the shapes a caller can hand it.
278 assert_eq!(
279 installation_root(&installed_executable(home)),
280 bundle,
281 "a path inside a bundle resolves to the bundle"
282 );
283 assert_eq!(
284 installation_root(&bundle),
285 bundle,
286 "the bundle resolves to itself"
287 );
288 let development = Path::new("/tmp/omega/target/debug/omega");
289 assert_eq!(
290 installation_root(development),
291 development,
292 "a loose development build has no bundle to climb to, and inventing \
293 one would plan its parent directory for removal"
294 );
295 }
296
297 /// OMEGA-DELTA-0036. The shipped script, run for real, against a machine
298 /// that has both editors installed.
299 ///
300 /// This is the assertion `0.2.0-rc14` would have failed on every count: it
301 /// removed none of the Omega tree and all of the other one.
302 #[test]
303 fn the_script_removes_omega_and_leaves_the_other_editor_untouched() {
304 let temp = tempfile::tempdir().expect("temp dir");
305 let home = temp.path();
306 let roots = roots(home);
307
308 // An Omega installation. The bundle is planted the way one ships:
309 // several executables and a bundled Node runtime under the same root,
310 // because "the app is gone" and "one file in the app is gone" look
311 // identical from a root that holds a single marker (omega#92).
312 // Named here, not read out of `roots`: reading it back from the plan
313 // would make this circular, and a plan that named the executable would
314 // plant its files under the executable and pass.
315 let bundle = home.join("Applications/Omega.app");
316 let bundle_contents: Vec<PathBuf> = [
317 "Contents/Info.plist",
318 "Contents/MacOS/omega",
319 "Contents/MacOS/cli",
320 "Contents/MacOS/omega-identity-proof",
321 "Contents/Resources/omega-effectd/dist/omega-effectd.mjs",
322 "Contents/Resources/omega-effectd/runtime/bin/node",
323 ]
324 .iter()
325 .map(|relative| bundle.join(relative))
326 .collect();
327 for path in &bundle_contents {
328 std::fs::create_dir_all(path.parent().unwrap()).expect("create bundle directory");
329 std::fs::write(path, b"omega").expect("write bundle file");
330 }
331
332 let mut planted = Vec::new();
333 for directory in [
334 &bundle,
335 &roots.data_dir,
336 &roots.config_dir,
337 &roots.logs_dir,
338 &roots.temp_dir,
339 &roots.state_dir,
340 ] {
341 std::fs::create_dir_all(directory).expect("create omega root");
342 let file = directory.join("marker");
343 std::fs::write(&file, b"omega").expect("write omega marker");
344 planted.push(file);
345 }
346 std::fs::create_dir_all(roots.cli_symlink.parent().unwrap()).unwrap();
347 std::fs::write(&roots.cli_symlink, b"omega").unwrap();
348 for path in &roots.platform_paths {
349 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
350 std::fs::write(path, b"omega").unwrap();
351 }
352
353 // The other editor, installed alongside it. Read back byte for byte
354 // afterwards: "we did not mean to" is not an observation.
355 let foreign: Vec<PathBuf> = [
356 "Applications/OtherEditor.app/Contents/Info.plist",
357 "Library/Application Support/OtherEditor/db/state",
358 "Library/Logs/OtherEditor/log.txt",
359 ".config/othereditor/settings.json",
360 ".othereditor_server/bin",
361 ]
362 .iter()
363 .map(|relative| home.join(relative))
364 .collect();
365 for path in &foreign {
366 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
367 std::fs::write(path, b"not ours to remove").unwrap();
368 }
369
370 let plan = roots.plan("Omega RC");
371 let script = Path::new(env!("CARGO_MANIFEST_DIR"))
372 .join("../../script/uninstall.sh")
373 .canonicalize()
374 .expect("the shipped uninstall script");
375 let output = std::process::Command::new("sh")
376 .arg(&script)
377 .env("HOME", home)
378 .env("OMEGA_UNINSTALL_PRODUCT", &plan.product)
379 .env("OMEGA_UNINSTALL_PATHS", plan.paths_env())
380 .env("OMEGA_UNINSTALL_CONFIG_DIR", &plan.config_dir)
381 .env("OMEGA_UNINSTALL_ASSUME_YES", "1")
382 .output()
383 .expect("run the uninstall script");
384 assert!(
385 output.status.success(),
386 "uninstall failed: {}",
387 String::from_utf8_lossy(&output.stderr)
388 );
389
390 // Read against the ROOTS, not against the plan. Asserting that
391 // everything the plan listed is gone is circular: a plan that forgot a
392 // root would pass it. Destructuring is exhaustive, so a root added to
393 // the struct and left out of the plan fails to compile here.
394 let UninstallRoots {
395 app_root,
396 data_dir,
397 config_dir,
398 logs_dir,
399 temp_dir,
400 state_dir,
401 cli_symlink,
402 platform_paths,
403 } = &roots;
404 let occupied: Vec<&PathBuf> = app_root
405 .iter()
406 .chain([data_dir, config_dir, logs_dir, temp_dir, state_dir, cli_symlink])
407 .chain(platform_paths.iter())
408 .collect();
409 for path in occupied {
410 assert!(
411 !path.exists(),
412 "{} survived the uninstall, so Omega was not removed",
413 path.display()
414 );
415 }
416
417 // And nothing inside the bundle either. A plan that names the
418 // executable removes one file and reports success, which is what
419 // `0.2.0-rc16` shipped: 130.9 MB and five executables survived
420 // (omega#92). The root check above cannot see that, because the root it
421 // was handed would have been the executable.
422 for path in &bundle_contents {
423 assert!(
424 !path.exists(),
425 "{} survived the uninstall. The plan removed something inside \
426 the installation instead of the installation.",
427 path.display()
428 );
429 }
430 for path in &foreign {
431 assert_eq!(
432 std::fs::read(path).ok().as_deref(),
433 Some(b"not ours to remove".as_slice()),
434 "{} was touched; the uninstall reached into another product",
435 path.display()
436 );
437 }
438 }
439
440 /// Every test in this module runs the real uninstaller, so every one of
441 /// them gets a `HOME` of its own.
442 ///
443 /// This is not hygiene. On 2026-07-25 a lane falsifying this delta restored
444 /// the `0.2.0-rc14` script over this file and ran the tests; the refusal
445 /// test below did not override `HOME`, the restored script ignored the
446 /// plan entirely, and it deleted the real machine's other editor and its
447 /// whole application-support tree. A test that runs an uninstaller must
448 /// never be able to see the real home directory, whatever the script under
449 /// it happens to be that minute.
450 #[test]
451 fn the_script_refuses_an_empty_or_relative_plan() {
452 let temp = tempfile::tempdir().expect("temp dir");
453 let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../script/uninstall.sh");
454 for (paths, why) in [
455 ("", "an empty plan"),
456 ("Library/Application Support/Omega RC", "a relative path"),
457 ("/", "the root of the filesystem"),
458 ("/Applications", "a whole system directory"),
459 ] {
460 let output = std::process::Command::new("sh")
461 .arg(&script)
462 .env("HOME", temp.path())
463 .env("OMEGA_UNINSTALL_PRODUCT", "Omega RC")
464 .env("OMEGA_UNINSTALL_PATHS", paths)
465 .env("OMEGA_UNINSTALL_ASSUME_YES", "1")
466 .output()
467 .expect("run the uninstall script");
468 assert!(
469 !output.status.success(),
470 "the script accepted {why}; refusing is the safe direction"
471 );
472 }
473 }
474
475 #[test]
476 fn the_uninstall_script_names_no_other_product() {
477 let script = std::fs::read_to_string(
478 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../script/uninstall.sh"),
479 )
480 .expect("the shipped uninstall script");
481 let lowered = script.to_lowercase();
482 assert!(
483 !lowered.contains("zed"),
484 "script/uninstall.sh names a competitor's product. It ships inside \
485 the signed `cli` binary and removes whatever it names."
486 );
487 assert!(
488 script.contains("OMEGA_UNINSTALL_PATHS"),
489 "the script no longer reads its plan from the caller, so it has a \
490 path table again, which is what shipped omega#88"
491 );
492 }
493}
494