Skip to main content

pitboard_core/switch/
adopt.rs

1//! Taking over a pitboard directory that another machine wrote.
2//!
3//! The machine stamp is right and the reason for it is sound: a parked login is a refresh
4//! token, and two machines taking turns presenting one makes Claude Code drop the login on
5//! both. What was wrong was the shape of the refusal. Every command reads the state, so a
6//! stamp that does not match stopped all of them, including `uninstall` and, in effect,
7//! `doctor`. A person who buys a new Mac and lets Migration Assistant bring their home and
8//! their keychain across arrives at a tool that will not do anything at all, with an error
9//! telling them to enrol their accounts again and no command that makes that possible.
10//!
11//! So this is that command. It keeps everything that is a fact about an account rather than
12//! about a machine: the label, the email, the account and organisation uuid, and the
13//! numbers pitboard remembers. It drops every parked login, because a login is the one
14//! thing that does not move. The way back is one sign-in per account.
15//!
16//! What it deliberately does not do is ask Anthropic whether a parked token still works.
17//! Finding out means exchanging it, and exchanging it is exactly the act that would rotate
18//! it past the other machine's copy. Until someone has measured, on a throwaway account and
19//! two machines, whether an exchange invalidates the copy the other machine holds, there is
20//! no safe probe and the honest default is to drop the login and say so.
21
22use super::{exclusive, purge};
23use crate::context::Context;
24use crate::error::Result;
25use crate::{audit, state};
26
27/// What taking over found.
28#[derive(Debug, PartialEq, Eq)]
29pub struct Adopted {
30    /// The accounts kept, in the order they were enrolled, named the way they are typed:
31    /// bare for Claude Code, `codex/work` for another tool.
32    pub accounts: Vec<String>,
33    /// Accounts that arrived holding a parked login, now dropped, named the same way.
34    pub logins_dropped: Vec<String>,
35}
36
37/// Take over this pitboard directory. `None` when it was already this machine's, which is
38/// the ordinary case and not an error: running it when there is nothing to do says so.
39pub fn adopt(ctx: &Context) -> Result<Option<Adopted>> {
40    let _exclusive = exclusive(ctx)?;
41    let (mut state, here) = state::load_any_machine(ctx)?;
42    if here {
43        return Ok(None);
44    }
45
46    let accounts: Vec<String> = state.accounts.iter().map(|a| a.key().typed()).collect();
47    let mut logins_dropped = Vec::new();
48    let mut parks = Vec::new();
49    for account in &state.accounts {
50        if let Some(park) = &account.parked {
51            logins_dropped.push(account.key().typed());
52            parks.push(park.service.clone());
53        }
54    }
55    // Listed rather than deleted outright, so a delete that fails is retried. On a machine
56    // that did not receive the keychain there is nothing there to delete, and deleting what
57    // is not there succeeds. Released rather than discarded, because one `repair` gave back
58    // may be another pitboard's, and that keychain may have come across too.
59    for service in &parks {
60        state.release(service);
61    }
62    state.active.clear();
63    state.slot.clear();
64    state.machine = state::machine_id();
65    state::save(ctx, &state)?;
66    purge(ctx, &mut state);
67
68    audit::record(ctx, "adopt", "", "ok");
69    Ok(Some(Adopted {
70        accounts,
71        logins_dropped,
72    }))
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::provider::ProviderId;
79    use crate::state::{Account, State};
80    use crate::store::memory::MemoryHost;
81    use crate::time::{Clock, FixedClock};
82    use serde_json::json;
83    use std::sync::Arc;
84
85    const NOW: i64 = 1_760_000_000;
86
87    struct Scratch(std::path::PathBuf);
88    impl Drop for Scratch {
89        fn drop(&mut self) {
90            let _ = std::fs::remove_dir_all(&self.0);
91        }
92    }
93
94    fn machine(name: &str) -> (Context, Arc<MemoryHost>, Scratch) {
95        let root = std::env::temp_dir().join(format!(
96            "pitboard-adopt-{name}-{}-{:?}",
97            std::process::id(),
98            std::thread::current().id()
99        ));
100        let _ = std::fs::remove_dir_all(&root);
101        let mem = MemoryHost::new();
102        let ctx = Context::new(root.clone())
103            .with_pitboard_home(root.clone())
104            .with_memory_stores(Arc::clone(&mem))
105            .with_clock(Arc::new(FixedClock::at(NOW)) as Arc<dyn Clock>);
106        crate::home::ensure(&ctx).expect("a home");
107        (ctx, mem, Scratch(root))
108    }
109
110    fn oauth() -> serde_json::Value {
111        json!({
112            "refreshToken": "r",
113            "accessToken": "a",
114            "expiresAt": (NOW + 3600) * 1000,
115            "refreshTokenExpiresAt": (NOW + 30 * 86_400) * 1000
116        })
117    }
118
119    /// A home that came from somewhere else, with a login in the keychain that came with it.
120    fn from_elsewhere(ctx: &Context, mem: &MemoryHost) -> String {
121        let service = "pitboard-park-acc-1750000000000";
122        mem.vault().plant(service, &oauth().to_string());
123        let mut state = State {
124            machine: "a hash from another computer".into(),
125            ..State::default()
126        };
127        state.accounts.push(Account {
128            last_used_at: None,
129            label: "work".into(),
130            account_uuid: "acc".into(),
131            email: "me@example.com".into(),
132            detail: state::Detail::Claude {
133                organization_uuid: "org".into(),
134                oauth_account: json!({"accountUuid": "acc"}),
135            },
136            parked: Some(crate::park::describe(
137                crate::provider::ProviderId::Claude,
138                service,
139                NOW,
140                &oauth(),
141            )),
142        });
143        state.set_active(ProviderId::Claude, Some("work".into()));
144        let raw = serde_json::to_string(&state).expect("serialisable");
145        std::fs::write(crate::home::dir(ctx).join("state.json"), raw).expect("written");
146        service.to_string()
147    }
148
149    #[test]
150    fn a_home_from_another_machine_keeps_its_accounts_and_loses_its_logins() {
151        let (ctx, mem, _scratch) = machine("takeover");
152        let service = from_elsewhere(&ctx, &mem);
153
154        assert!(
155            matches!(
156                state::load(&ctx),
157                Err(crate::error::Error::StateWrongMachine { .. })
158            ),
159            "every other command still refuses it"
160        );
161
162        let adopted = adopt(&ctx)
163            .expect("adopting")
164            .expect("there was work to do");
165        assert_eq!(adopted.accounts, vec!["work".to_string()]);
166        assert_eq!(adopted.logins_dropped, vec!["work".to_string()]);
167
168        let state = state::load(&ctx).expect("now it is this machine's");
169        let account = state
170            .get(&crate::state::Key::new(
171                crate::provider::ProviderId::Claude,
172                "work",
173            ))
174            .expect("the account is kept");
175        assert_eq!(account.email, "me@example.com");
176        assert_eq!(account.account_uuid, "acc");
177        assert!(
178            account.parked.is_none(),
179            "the login is the one thing that does not move"
180        );
181        assert!(
182            mem.vault().peek(&service).is_none(),
183            "and the copy that came with it is deleted rather than left to be presented"
184        );
185        assert!(
186            state.active.is_empty(),
187            "who was signed in was true elsewhere"
188        );
189    }
190
191    #[test]
192    fn adopting_a_home_that_is_already_this_machines_does_nothing() {
193        let (ctx, _mem, _scratch) = machine("nothing-to-do");
194        let mut state = State::default();
195        state.accounts.push(Account {
196            last_used_at: None,
197            label: "work".into(),
198            account_uuid: "acc".into(),
199            email: "me@example.com".into(),
200            detail: state::Detail::Claude {
201                organization_uuid: "org".into(),
202                oauth_account: json!({}),
203            },
204            parked: None,
205        });
206        state::save(&ctx, &state).expect("saved");
207
208        assert_eq!(adopt(&ctx).expect("adopting"), None);
209        assert_eq!(
210            state::load(&ctx).expect("still readable").accounts.len(),
211            1,
212            "and it changed nothing"
213        );
214    }
215
216    #[test]
217    fn adopting_an_empty_home_is_not_an_error() {
218        let (ctx, _mem, _scratch) = machine("empty");
219        assert_eq!(adopt(&ctx).expect("adopting"), None);
220    }
221}