pitboard-core 0.14.0

The engine behind Pitboard: switches Claude Code, Codex and Claude Desktop between accounts by parking and restoring their logins. Its API serves Pitboard's own front ends.
Documentation
//! Taking Pitboard off a machine without leaving credentials behind.

use super::{Result, Settled, purge};
use crate::context::Context;
use crate::error::Error;
use crate::provider::{self, LoginKind, ProviderId};
use crate::service::{Permit, Warning};
use crate::state::Account;
use crate::{home, schedule, state};

/// What was removed, for the report. It may say more in a later release, so it cannot be
/// built outside this crate.
#[derive(Debug)]
#[non_exhaustive]
pub struct Removed {
    // Parked logins deleted: from the keychain or the vault, or a folder under `parks`.
    pub parks: usize,
    /// Parked logins that could not be deleted, and renewed logins not saved where their tool
    /// keeps them yet, which is why the home was kept.
    pub pending: usize,
    /// Parked logins left where they are because this Pitboard did not write them:
    /// `repair` gave them back from a store every Pitboard on the machine shares, so each
    /// may be another Pitboard's. Always none where the vault is inside Pitboard's own
    /// directory.
    pub left: usize,
    // Folders set aside, deleted with every parked login.
    pub set_aside: usize,
    /// Whether ~/.pitboard itself is gone.
    pub home_removed: bool,
    /// Whether the daily renewal schedule was taken away. `false` where there was none.
    pub schedule_removed: bool,
    // A move of an app's login that waited, as it was settled first.
    pub warnings: Vec<Warning>,
}

/// Takes away the daily renewal schedule, deletes every parked login this Pitboard wrote,
/// then removes Pitboard's own directory. Each tool's login is left exactly as it is:
/// whoever is signed in stays signed in.
///
/// The schedule goes first. It runs `pitboard renew` every day, which would make a new
/// directory once this one is gone.
///
/// The directory is removed last and only when every parked login is gone, because
/// state.json is the only index of those keychain items. Deleting it first would leave live
/// refresh tokens on the machine with no way left to name them. A login `repair` gave back
/// is not this Pitboard's to delete, so it is left, and does not keep the directory. A
/// renewed login settling could not save yet is the only copy of that login, so it is
/// neither deleted nor left unnamed: it keeps the directory too.
pub(crate) fn uninstall(settled: Settled) -> Result<Removed> {
    let Settled {
        _exclusive,
        mut state,
        ctx,
        permit,
    } = settled;
    // An app's folder is read only where a login of it is parked or set aside or a move of one
    // waits, and held until every park is deleted. Refused before anything is taken away.
    let mut warnings = Vec::new();
    let mut _closed = Vec::new();
    for &tool in ProviderId::ALL {
        match provider::of(tool) {
            LoginKind::Document(_) => {}
            LoginKind::Folder(app) => {
                if let Some((closed, said)) =
                    super::folder::before_uninstall(&ctx, permit, &mut state, app)?
                {
                    _closed.push(closed);
                    warnings.extend(said);
                }
            }
        }
    }
    match take_away(&ctx, permit, &mut state) {
        Ok(removed) => Ok(Removed {
            warnings,
            ..removed
        }),
        Err(refused) => Err(refused.after(warnings)),
    }
}

// The rest of `uninstall`, once each app's folder is held and a move of it settled.
fn take_away(ctx: &Context, permit: Permit, state: &mut state::State) -> Result<Removed> {
    let schedule_removed = remove_schedule(ctx, permit)?;
    let held = state.accounts.iter().filter_map(|a| a.parked.as_ref());
    let left = held
        .clone()
        .filter(|park| state.is_foreign(&park.service))
        .count();
    let parks = held.count() - left;
    let asides: Vec<String> = state.aside.iter().map(|aside| aside.name.clone()).collect();
    for key in state.accounts.iter().map(Account::key).collect::<Vec<_>>() {
        state.remove(&key);
    }
    state.forget_asides();
    state.forget_discarded();
    state.forget_in_use();
    state::save(ctx, permit, state)?;
    let undeleted = purge(ctx, permit, state);
    let asides_left = asides
        .iter()
        .filter(|name| {
            state
                .discarded
                .iter()
                .any(|listed| listed.service == **name)
        })
        .count();
    let pending = undeleted + state.renewing.len();
    // The sweep in settle has already resolved every outstanding name, so what is left
    // refers to nothing. The home goes next, and an index of names with no home is noise.
    if pending == 0 {
        crate::pending::clear(ctx, permit);
    }
    let home_removed = pending == 0 && remove_home(ctx, permit);
    Ok(Removed {
        parks: parks.saturating_sub(undeleted - asides_left),
        pending,
        left,
        set_aside: asides.len() - asides_left,
        home_removed,
        schedule_removed,
        warnings: Vec::new(),
    })
}

/// The schedule, where it is this home's. One that renews another home is that home's to
/// take away, and a machine with no scheduler has nothing to take.
fn remove_schedule(ctx: &Context, permit: Permit) -> Result<bool> {
    if !schedule::serves(ctx) {
        return Ok(false);
    }
    match schedule::uninstall(ctx, permit) {
        Err(Error::ScheduleUnsupported) => Ok(false),
        removed => removed,
    }
}

/// The lock file this run holds lives in here too; on macOS and Linux an open file goes on
/// existing until the last handle closes, so removing the directory now is safe.
fn remove_home(ctx: &Context, permit: Permit) -> bool {
    crate::host::fs::remove_dir_all(permit, &home::dir(ctx)).is_ok()
}

#[cfg(test)]
mod tests {
    use super::super::harness::{codex_machine, machine};
    use super::super::settle;
    use super::*;

    #[test]
    #[cfg_attr(windows, ignore = "W25: Task Scheduler")]
    fn uninstalling_takes_the_renewal_schedule_with_it() {
        for make in [machine, codex_machine] {
            let m = make("uninstall-schedule");
            schedule::install(&m.ctx, Permit::for_a_test()).expect("scheduled");

            let removed = uninstall(
                settle(&m.ctx, Permit::for_a_test(), None)
                    .expect("nothing to recover")
                    .0,
            )
            .expect("uninstalled");

            assert!(removed.schedule_removed);
            assert!(removed.home_removed);
            assert_eq!(schedule::status(&m.ctx), schedule::Installed::No);
        }
    }

    /// A schedule that cannot be taken away stops the uninstall before anything else is
    /// touched. Left running, it would renew logins whose index is gone.
    #[test]
    #[cfg_attr(windows, ignore = "W25: Task Scheduler")]
    fn a_schedule_that_cannot_be_taken_away_leaves_every_login_where_it_was() {
        use crate::host::fs::testing;
        for make in [machine, codex_machine] {
            let m = make("uninstall-stuck");
            schedule::install(&m.ctx, Permit::for_a_test()).expect("scheduled");
            let dir = schedule::path(&m.ctx)
                .and_then(|p| p.parent().map(std::path::Path::to_path_buf))
                .expect("where the scheduler keeps it");
            testing::deny_changes(&dir).expect("read-only");
            let refused = uninstall(
                settle(&m.ctx, Permit::for_a_test(), None)
                    .expect("nothing to recover")
                    .0,
            );
            testing::allow_changes(&dir).expect("changeable again");

            let Err(error) = refused else {
                panic!("{:?}: uninstalled with the schedule still there", m.which);
            };
            assert_eq!(error.code(), "home_unwritable");
            let state = state::load(&m.ctx).expect("the account list");
            assert_eq!(state.accounts.len(), 2, "{:?}", m.which);
            let there = state.get(&m.key("there")).expect("`there` is enrolled");
            let park = there.parked.as_ref().expect("`there` is still parked");
            crate::park::load(&m.ctx, provider::document(m.which), &m.key("there"), park)
                .expect("its parked login is kept");
            assert!(home::dir(&m.ctx).is_dir());
            assert!(matches!(
                schedule::status(&m.ctx),
                schedule::Installed::Yes { .. }
            ));
        }
    }

    #[test]
    #[cfg_attr(
        windows,
        ignore = "W16: Pitboard writing, replacing and removing files on Windows"
    )]
    fn uninstalling_where_nothing_is_scheduled_is_not_a_failure() {
        let m = machine("uninstall-unscheduled");
        let removed = uninstall(
            settle(&m.ctx, Permit::for_a_test(), None)
                .expect("nothing to recover")
                .0,
        )
        .expect("uninstalled");
        assert!(!removed.schedule_removed);
        assert!(removed.home_removed);
    }

    /// A renewed login waiting in the vault to be saved where Claude Code keeps it is the
    /// only copy of that login, and state.json the only index naming it. Uninstalling while
    /// it cannot be saved keeps both, and uninstalling again once it can removes the home.
    #[test]
    #[cfg_attr(
        windows,
        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
    )]
    fn uninstalling_keeps_a_renewed_login_not_saved_yet() {
        use super::super::harness::{lapsed, owner, renews};
        use crate::store::memory::Fault;
        let m = machine("uninstall-renewing");
        m.sign_in(&lapsed("here-renewed"));
        renews(&m, "here-renewed", "here-again");
        m.api.owned_by("access-here-again", owner("here"));
        let pitboard = crate::service::Pitboard::new(m.ctx.clone());
        let died = crate::fault::killing("refresh.exchanged", || pitboard.switch_to("there"));
        assert_eq!(died.err().as_deref(), Some("refresh.exchanged"));
        let copies = |m: &super::super::harness::Machine| -> Vec<String> {
            m.mem
                .vault()
                .services()
                .into_iter()
                .filter(|service| service.contains("-renewing-"))
                .collect()
        };
        let [copy] = copies(&m).try_into().expect("one copy");
        m.fault_live(Fault::Locked);

        let removed = uninstall(
            settle(&m.ctx, Permit::for_a_test(), None)
                .expect("nothing to recover")
                .0,
        )
        .expect("uninstalled");

        assert!(!removed.home_removed);
        assert_eq!(removed.pending, 1);
        assert!(m.mem.vault().peek(&copy).is_some());
        assert!(state::load(&m.ctx).expect("state").names(&copy));

        m.mem.live().heal_all();
        let removed = uninstall(
            settle(&m.ctx, Permit::for_a_test(), None)
                .expect("nothing to recover")
                .0,
        )
        .expect("uninstalled");
        assert!(removed.home_removed);
        assert_eq!(copies(&m), Vec::<String>::new());
        assert_eq!(
            m.live()
                .and_then(|stored| stored["claudeAiOauth"]["refreshToken"]
                    .as_str()
                    .map(str::to_owned)),
            Some("here-again".into())
        );
    }

    #[test]
    #[cfg_attr(
        windows,
        ignore = "Claude Desktop is macOS only: Windows makes and removes no folder"
    )]
    fn uninstalling_counts_the_set_aside_folders_it_deleted_and_keeps_the_rest_listed() {
        use crate::host::fs::testing;
        use crate::provider::ProviderId;
        let m = machine("uninstall-asides");
        let parks = crate::store::folder::parks_dir(&m.ctx);
        let names = ["pitboard-aside-1", "pitboard-aside-2"];
        let mut state = state::load(&m.ctx).expect("state");
        for name in names {
            std::fs::create_dir_all(parks.join(name).join("items")).expect("a folder");
            std::fs::write(parks.join(name).join("items/Cookies"), b"a jar").expect("a jar");
            state.aside.push(state::Aside {
                name: name.into(),
                tool: ProviderId::Desktop,
                at: 1,
                why: state::SetAside::InTheWay,
                account: None,
                login: None,
            });
        }
        state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
        let uninstalled = || {
            uninstall(
                settle(&m.ctx, Permit::for_a_test(), None)
                    .expect("nothing to recover")
                    .0,
            )
            .expect("uninstalled")
        };

        // Open to others: nothing under it is deleted, and the vault's park still is.
        testing::allow_changes(&parks).expect("open to others");
        let removed = uninstalled();
        assert_eq!(
            (removed.parks, removed.set_aside, removed.pending),
            (1, 0, 2)
        );
        assert!(!removed.home_removed);
        for name in names {
            assert!(parks.join(name).join("items/Cookies").exists(), "{name}");
            assert!(state::load(&m.ctx).expect("state").names(name), "{name}");
        }

        testing::make_private(&parks).expect("private");
        let removed = uninstalled();
        assert_eq!(removed.pending, 0);
        assert!(removed.home_removed);
    }

    /// launchd and systemd renew the default home, so uninstalling any other one leaves
    /// the schedule to the Pitboard it serves.
    #[test]
    #[cfg_attr(windows, ignore = "W25: Task Scheduler")]
    fn uninstalling_another_home_leaves_the_schedule_alone() {
        let m = machine("uninstall-elsewhere");
        schedule::install(&m.ctx, Permit::for_a_test()).expect("scheduled");
        let elsewhere = m
            .ctx
            .clone()
            .with_pitboard_home(m.ctx_home().join("elsewhere"));

        let removed = uninstall(
            settle(&elsewhere, Permit::for_a_test(), None)
                .expect("nothing to recover")
                .0,
        )
        .expect("uninstalled");

        assert!(!removed.schedule_removed);
        assert!(matches!(
            schedule::status(&m.ctx),
            schedule::Installed::Yes { .. }
        ));
    }
}