Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:30:34.546Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

uninstall.rs

494 lines · 20.2 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.