openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
/// `openlatch uninstall` command handler.
///
/// Removes OpenLatch hooks from the agent config and stops the daemon.
/// Optionally deletes the openlatch data directory and the OS-keychain
/// credential 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;

/// Directory names `--purge` will delete.
///
/// The last line of defence before an unrecoverable `remove_dir_all`, so it is
/// a fixed list rather than a heuristic. Anything else — an `OPENLATCH_DIR`
/// pointed at a home directory, a repository root, a typo — is refused.
pub(crate) const PURGEABLE_STATE_DIR_NAMES: [&str; 2] = ["openlatch", ".openlatch"];

/// Would `--purge` accept a state directory with this basename?
///
/// Shared with `tools/sandbox` (the `olbox` binary), which has to produce a
/// layout this accepts: a sandbox is a complete install, so `uninstall --purge`
/// inside one must work. It did not — the sandbox state directory was called
/// `ol`, and the command refused it with `OL-1300` after it had already removed
/// the hooks and the supervisor, leaving a half-uninstalled sandbox. The
/// coupling is pinned by `tests::the_state_directory_olbox_creates_is_purgeable`
/// below, which is the only thing keeping the two repositories' halves in
/// agreement now that they build separately.
pub(crate) fn is_purgeable_state_dir_name(name: &str) -> bool {
    PURGEABLE_STATE_DIR_NAMES.contains(&name)
}

/// Resolve and validate what `--purge` would delete, before anything is
/// touched.
///
/// `Ok(None)` means there is nothing there — not an error, just an install
/// whose data directory is already gone. `Err` means the path exists and must
/// not be deleted, and returning it from step 0 is what keeps a rejected name
/// from costing the operator their hooks and their supervisor first.
///
/// # Errors
///
/// `OL-1300` when the path cannot be canonicalized, or when its basename is not
/// one [`is_purgeable_state_dir_name`] accepts.
fn resolve_purge_target() -> Result<Option<std::path::PathBuf>, OlError> {
    let ol_dir = config::openlatch_dir();
    if !ol_dir.exists() {
        return Ok(None);
    }

    // SECURITY: canonicalize before comparing, so a symlink cannot present an
    // acceptable basename for an unacceptable target.
    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))
}

/// Is the install being uninstalled the machine's default one?
///
/// The OS keychain holds exactly one `openlatch` credential per machine, no
/// matter how many state directories sit beside it. A sandbox — `olbox`, a test
/// lab, any `OPENLATCH_DIR` override — is a complete install in every respect
/// except that one, so a `--purge` inside it that deleted the keychain entry
/// would log the machine's real install out of the cloud. Same reasoning
/// [`crate::supervision::unreproducible_environment`] applies to the supervisor
/// unit: a machine-global artifact belongs to the default install and to
/// nothing else.
///
/// Answered before anything is deleted, so both sides can still be
/// canonicalized (a path that no longer exists compares as written).
fn is_machine_default_install() -> bool {
    same_directory(&config::openlatch_dir(), &config::default_openlatch_dir())
}

/// Do two paths name the same directory? Resolved before comparing, so an
/// `OPENLATCH_DIR` pointed deliberately at the default — through a symlink, or
/// with a trailing slash — is still recognised as the default. A path that
/// cannot be resolved compares as written.
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)
}

/// Run the `openlatch uninstall` command.
///
/// Steps:
/// 0. If --purge, resolve and validate the target — before anything is touched
/// 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 and the OS-keychain
///    credential
///
/// # Errors
///
/// Returns an error if hook removal or directory deletion fails.
pub fn run_uninstall(args: &UninstallArgs, output: &OutputConfig) -> Result<(), OlError> {
    // Step 0: if `--purge` cannot finish, refuse before anything is touched.
    //
    // This check used to sit at the end, next to the `remove_dir_all` it
    // guards, which reads like the safe place for it and is the opposite. A
    // state directory the guard rejects failed the command *after* the hooks
    // were removed, the supervisor was torn down and the daemon was stopped —
    // so a name mismatch did not fail safely, it half-uninstalled and then told
    // the operator to finish the job by hand. Validated up front, the same
    // mismatch costs nothing.
    let purge_target = if args.purge {
        resolve_purge_target()?
    } else {
        None
    };
    // Also resolved up front, while the state directory is still on disk.
    let purge_machine_global = args.purge && is_machine_default_install();

    // 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 every detected agent's settings.
    //
    // The mirror of `init`'s install loop: `init` wires every agent on the
    // host, so `uninstall` unwires every one of them. `--agent` narrows it —
    // and only this step, not the machine-wide teardown below. A name that is
    // not on this machine fails BEFORE anything is touched, because acting on
    // a typo here costs the operator a stopped daemon.
    let selected = match hooks::select_agents(hooks::detect_agents(), &args.agent) {
        Ok(v) => v,
        Err(e) => {
            output.print_error(&e);
            return Err(e);
        }
    };

    if selected.is_empty() {
        // No agent found — hooks might not be installed, that's fine.
        output.print_info("Agent not detected — skipping hook removal");
    }

    for agent in &selected {
        // One agent's failure never stops the others: leaving a second agent
        // wired to a daemon this command is about to stop is the worse outcome.
        match hooks::remove_hooks(&*agent.binding) {
            Ok(()) => {
                let settings_path = agent.settings_path();
                output.print_step(&format!("Hooks removed from {}", settings_path.display()));

                // Also tear down the model-boundary wiring. 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 endpoint / install-id lines are removed, whatever the
                // agent recorded before us is restored, and customer-set values
                // survive.
                //
                // No convention gate here any more: the writer dispatches on the
                // binding, and an agent that declares no request plane at all is
                // its own `Ok(())` arm inside it. A `matches!` on `EnvVars` here
                // would silently exempt every future convention from uninstall.
                #[cfg(feature = "boundary")]
                if let Err(e) = hooks::remove_boundary_config(&*agent.binding) {
                    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 for {}: {} ({})",
                    agent.display_name(),
                    e.message,
                    e.code
                ));
            }
        }
    }

    // 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.
    //
    // Only when the unit is ours to remove. It is one machine-global artifact,
    // and `supervision enable` already refuses to install one from an isolated
    // shell for that reason — an uninstall run inside an `olbox` sandbox
    // deregistering the machine's daemon is the same mistake with the sign
    // flipped.
    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
            )),
        }
    }
    // 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 the data directory. The path was resolved and validated at
    // step 0, so nothing here can refuse after the fact.
    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");
        }

        // The API key never lived in that directory. On a machine with a
        // working keychain it is an OS-level secret under the `openlatch`
        // service, so deleting the state directory left a usable credential
        // behind on a machine with no OpenLatch left on it.
        //
        // Local only, deliberately: `auth logout` also revokes the key
        // server-side, which invalidates it for every machine that shares it.
        // `--purge` uninstalls *this* one. Best-effort — a headless box with no
        // secret service must not fail the uninstall over it.
        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",
            );
        }
    }

    // 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(())
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The state directory `olbox` creates must be one `--purge` accepts.
    ///
    /// This is the client's half of a coupling whose other half lives in
    /// `tools/sandbox` — a separate crate, which names a sandbox's state
    /// directory `openlatch` for exactly this reason. A sandbox is a complete
    /// install, so `openlatch uninstall --purge` has to work inside one.
    ///
    /// It is asserted here, not there, because this is the side that can
    /// enforce it: `olbox` cannot fail its own build over a list this binary
    /// owns. And the failure is not theoretical — the directory used to be
    /// called `ol`, `--purge` refused it with `OL-1300` *after* removing the
    /// hooks and tearing down the supervisor, and the operator was left with a
    /// half-uninstalled sandbox and instructions to finish by hand.
    ///
    /// If this fails, the fix is one of two: put `openlatch` back on the list,
    /// or change `Sandbox::openlatch_dir` in `tools/sandbox/src/sandbox.rs` to
    /// a name that is on it.
    /// A sandbox is not the machine, however complete it looks.
    ///
    /// `--purge` clears the OS-keychain credential, which is one per machine
    /// rather than one per state directory. The guard is a path comparison, and
    /// the half that is easy to get wrong is the *false negative*: an
    /// `OPENLATCH_DIR` pointed deliberately at the default install through a
    /// symlink is that install, and refusing to clear its credential would
    /// leave the very key `--purge` promises to take.
    #[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"
        );
    }
}