use super::{Error, Readied, Result, Settled, identify_document, nothing_signed_in, purge};
use crate::api::Owner;
use crate::context::Context;
use crate::provider::{self, ProviderId};
use crate::service::Warning;
use crate::state::{Account, Key, Park, State};
use crate::{home, lock, park, state, store};
use serde_json::{Value, json};
use std::fs::{File, OpenOptions, TryLockError};
use std::os::unix::fs::OpenOptionsExt;
use std::path::PathBuf;
#[derive(Debug)]
pub enum Enrolled {
Current { email: String },
SignedIn { email: String },
Renewed { email: String },
InUse { email: String, again: bool },
}
pub struct SignIn {
provider: ProviderId,
dir: PathBuf,
document: Value,
ctx: Context,
_one_at_a_time: File,
}
impl SignIn {
pub fn provider(&self) -> ProviderId {
self.provider
}
}
impl Drop for SignIn {
fn drop(&mut self) {
provider::of(self.provider).discard_signin(&self.ctx, &self.dir);
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn reserve_signin(ctx: &Context, which: ProviderId) -> Result<SignIn> {
let home = home::ensure(ctx).map_err(|source| Error::HomeUnwritable {
path: home::dir(ctx),
source,
})?;
let lock_path = home.join("signin.lock");
let one_at_a_time = OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.mode(0o600)
.open(&lock_path)
.map_err(|source| Error::HomeUnwritable {
path: lock_path.clone(),
source,
})?;
match one_at_a_time.try_lock() {
Ok(()) => {}
Err(TryLockError::WouldBlock) => return Err(Error::SignInInProgress),
Err(TryLockError::Error(source)) => {
return Err(Error::HomeUnwritable {
path: lock_path,
source,
});
}
}
let dir = home.join("signin");
for &tool in ProviderId::ALL {
provider::of(tool).discard_signin(ctx, &dir);
}
let _ = std::fs::remove_dir_all(&dir);
home::create_private(&dir).map_err(|source| Error::HomeUnwritable {
path: dir.clone(),
source,
})?;
Ok(SignIn {
provider: which,
dir,
document: Value::Null,
ctx: ctx.clone(),
_one_at_a_time: one_at_a_time,
})
}
#[cfg(test)]
pub(super) fn planted(ctx: &Context, which: ProviderId, document: Value) -> Result<SignIn> {
let mut pending = reserve_signin(ctx, which)?;
pending.document = document;
Ok(pending)
}
pub fn sign_in(ctx: &Context, which: ProviderId) -> Result<SignIn> {
let mut pending = reserve_signin(ctx, which)?;
let finished = provider::of(which)
.sign_in(ctx, &pending.dir)
.stdout(std::io::stderr())
.status()
.map_err(|e| started(which, e))?
.success();
if !finished {
return Err(Error::SignInIncomplete);
}
pending.document = signed_in_document(ctx, which, &pending.dir)?;
Ok(pending)
}
fn started(which: ProviderId, e: std::io::Error) -> Error {
match e.kind() {
std::io::ErrorKind::NotFound => Error::ProgramNotFound { tool: which },
_ => Error::SignInIncomplete,
}
}
fn signed_in_document(ctx: &Context, which: ProviderId, dir: &std::path::Path) -> Result<Value> {
let raw = provider::of(which)
.read_signin(ctx, dir)?
.ok_or(Error::SignInIncomplete)?;
serde_json::from_str(&raw).map_err(|e| Error::LiveCredentialShapeUnexpected {
tool: which,
detail: e.to_string(),
})
}
pub struct WatchedSignIn {
child: std::process::Child,
said: Said,
pending: SignIn,
}
#[derive(Clone)]
pub struct Said(std::sync::Arc<std::sync::Mutex<std::sync::mpsc::Receiver<String>>>);
impl Said {
pub fn next(&self) -> Option<String> {
self.0.lock().ok()?.recv().ok()
}
}
impl WatchedSignIn {
pub fn provider(&self) -> ProviderId {
self.pending.provider
}
pub fn said(&self) -> Said {
self.said.clone()
}
pub fn paste(&mut self, line: &str) -> Result<()> {
use std::io::Write;
let stdin = self.child.stdin.as_mut().ok_or(Error::SignInIncomplete)?;
writeln!(stdin, "{line}").map_err(|_| Error::SignInIncomplete)?;
stdin.flush().map_err(|_| Error::SignInIncomplete)
}
pub fn finish(mut self) -> Result<SignIn> {
let finished = self
.child
.wait()
.map_err(|_| Error::SignInIncomplete)?
.success();
if !finished {
return Err(Error::SignInIncomplete);
}
let mut pending = self.pending;
pending.document =
signed_in_document(&pending.ctx.clone(), pending.provider, &pending.dir.clone())?;
Ok(pending)
}
pub fn cancel(mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
pub fn sign_in_watched(ctx: &Context, which: ProviderId) -> Result<WatchedSignIn> {
let pending = reserve_signin(ctx, which)?;
let command = provider::of(which).sign_in(ctx, &pending.dir);
watch(command, pending).map_err(|e| started(which, e))
}
fn watch(mut command: std::process::Command, pending: SignIn) -> std::io::Result<WatchedSignIn> {
let mut child = command
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
let (say, said) = std::sync::mpsc::channel();
for stream in [
child.stdout.take().map(Readable::Out),
child.stderr.take().map(Readable::Err),
]
.into_iter()
.flatten()
{
let say = say.clone();
std::thread::spawn(move || {
use std::io::Read;
let mut reader: Box<dyn Read + Send> = match stream {
Readable::Out(o) => Box::new(o),
Readable::Err(e) => Box::new(e),
};
let mut buffer = [0_u8; 1024];
while let Ok(read) = reader.read(&mut buffer) {
if read == 0 {
break;
}
let text = String::from_utf8_lossy(&buffer[..read]).into_owned();
if say.send(text).is_err() {
break;
}
}
});
}
Ok(WatchedSignIn {
child,
said: Said(std::sync::Arc::new(std::sync::Mutex::new(said))),
pending,
})
}
enum Readable {
Out(std::process::ChildStdout),
Err(std::process::ChildStderr),
}
pub fn enroll(
settled: Settled,
key: &Key,
signed_in: Option<SignIn>,
) -> Result<(Enrolled, Vec<Warning>)> {
let Settled {
_exclusive,
mut state,
ctx,
} = settled;
match signed_in {
Some(login) if login.provider != key.provider => Err(Error::Usage(format!(
"that sign-in was {}'s, and `{key}` is a {} account",
login.provider.name(),
key.provider.name()
))),
Some(login) => from_sign_in(&ctx, key, &mut state, &login),
None => record_current(&ctx, key, &mut state).map(|e| (e, Vec::new())),
}
}
fn claim(state: &State, key: &Key, owner: &Owner) -> Result<()> {
if let Some(taken) = state.get(key)
&& taken.account_uuid != owner.account_uuid
{
return Err(Error::LabelTaken {
label: key.typed(),
email: taken.email.clone(),
});
}
if let Some(existing) = state.by_uuid(key.provider, &owner.account_uuid)
&& existing.label != key.label
{
return Err(Error::AlreadyEnrolled {
tool: key.provider,
email: owner.email.clone(),
label: existing.key().typed(),
});
}
Ok(())
}
fn record_current(ctx: &Context, key: &Key, state: &mut State) -> Result<Enrolled> {
let which = key.provider;
let label = key.label.as_str();
let store = super::live_store(ctx, which)?;
let live =
store::read(&store.chain, &store.service)?.ok_or_else(|| nothing_signed_in(ctx, which))?;
match provider::of(which).slice(&live) {
Err(provider::ProviderError::NoLogin { .. }) => return Err(nothing_signed_in(ctx, which)),
Err(other) => return Err(super::shape(which, other)),
Ok(_) => {}
}
let owner = identify_document(ctx, which, &live)?;
claim(state, key, &owner)?;
let existing = state.get(key);
let parked = existing.and_then(|a| a.parked.clone());
let last_used_at = Some(ctx.now());
state.upsert(account(which, label, &owner, parked, last_used_at, &live));
state.set_active(which, Some(label.to_string()));
state::save(ctx, state)?;
Ok(Enrolled::Current { email: owner.email })
}
fn from_sign_in(
ctx: &Context,
key: &Key,
state: &mut State,
login: &SignIn,
) -> Result<(Enrolled, Vec<Warning>)> {
let owner = identify_document(ctx, login.provider, &login.document)?;
claim(state, key, &owner)?;
match signed_in_now(ctx, key.provider, &owner) {
InUse::Theirs(live, first) => {
install_signed_in(ctx, key, state, login, &owner, &live, &first)
}
InUse::NotTheirs => park_signed_in(ctx, key, state, login, &owner),
InUse::Untold(why) => {
let last_in_use = state.active_for(key.provider) == Some(key.label.as_str());
let (enrolled, mut warnings) = park_signed_in(ctx, key, state, login, &owner)?;
if last_in_use {
warnings.push(Warning::SignInParkedNotInUse {
tool: key.provider,
label: state.typed(key),
why: untold(&why),
});
}
Ok((enrolled, warnings))
}
}
}
enum InUse {
Theirs(provider::LiveStore, Value),
NotTheirs,
Untold(Error),
}
fn signed_in_now(ctx: &Context, which: ProviderId, owner: &Owner) -> InUse {
let read = super::live_store(ctx, which)
.and_then(|live| super::read_live(ctx, which, &live).map(|(_, first)| (live, first)));
let (live, first) = match read {
Ok(read) => read,
Err(Error::LiveCredentialAbsent { .. }) => return InUse::NotTheirs,
Err(other) => return InUse::Untold(other),
};
match identify_document(ctx, which, &first) {
Ok(found) if found.account_uuid == owner.account_uuid => InUse::Theirs(live, first),
Ok(_) => InUse::NotTheirs,
Err(e) => InUse::Untold(e),
}
}
fn untold(error: &Error) -> String {
match error {
Error::SessionExpired { tool } => format!("{} refused its access token", tool.service()),
Error::IdentityUnverifiable { detail, .. }
| Error::LiveCredentialShapeUnexpected { detail, .. } => detail.clone(),
Error::LiveStoreUnsupported { reason, .. } => reason.clone(),
Error::LiveCredentialElsewhere { email } => {
format!("its config names {email}, and pitboard cannot find that login")
}
Error::Store(e) => e.to_string(),
other => other.code().replace('_', " "),
}
}
fn install_signed_in(
ctx: &Context,
key: &Key,
state: &mut State,
login: &SignIn,
owner: &Owner,
live: &provider::LiveStore,
first: &Value,
) -> Result<(Enrolled, Vec<Warning>)> {
let which = key.provider;
let name = state.typed(key);
let not_kept = |detail: String| Error::SignInNotKept {
tool: which,
label: name.clone(),
detail,
};
let slice = provider::of(which)
.slice(&login.document)
.map_err(|e| super::shape(which, e))?;
let Readied {
guard,
before_raw,
next,
on_the_command_line,
..
} = super::ready(ctx, which, live, first, &owner.account_uuid, &slice, &name).map_err(|e| {
match e {
Error::SignedInAccountChanged => not_kept(format!(
"{} was signed in to another account meanwhile",
which.name()
)),
other => other,
}
})?;
let written = on_the_command_line.into_iter().collect::<Vec<_>>();
match super::install_with(
which,
|body| store::write_raw(&live.chain, &live.service, body),
|| store::read_raw(&live.chain, &live.service),
&next,
&before_raw,
&name,
&name,
) {
Ok(()) => {}
Err(Error::SwitchRolledBack { detail, .. }) => return Err(not_kept(detail)),
Err(Error::SwitchUnverified { detail, .. } | Error::SwitchCorrupted { detail, .. }) => {
return Err(not_installed(
ctx, key, state, login, owner, detail, written,
));
}
Err(other) => return Err(other),
}
crate::fault::point("enroll.installed");
let lock_lost = guard.as_ref().is_some_and(lock::Guard::compromised);
let lost = match super::holds(which, live) {
Ok(true) => None,
Ok(false) => Some("it was gone again before pitboard finished".to_string()),
Err(unreadable) => Some(unreadable.to_string()),
};
if let Some(detail) = lost {
return Err(not_installed(
ctx, key, state, login, owner, detail, written,
));
}
let again = state.get(key).is_some();
let parked = state.get(key).and_then(|a| a.parked.clone());
state.upsert(account(
which,
&key.label,
owner,
parked,
Some(ctx.now()),
&login.document,
));
state.set_active(which, Some(key.label.clone()));
state::save(ctx, state)?;
crate::fault::point("enroll.recorded");
drop(guard);
let still_running = super::running_sessions(ctx, which).map(|(program, count)| {
Warning::SessionsKeepTheOldLogin {
program,
count,
label: name.clone(),
}
});
let warnings = written
.into_iter()
.chain(still_running)
.chain(lock_lost.then_some(Warning::LockCompromised { tool: which }))
.collect();
Ok((
Enrolled::InUse {
email: owner.email.clone(),
again,
},
warnings,
))
}
fn not_installed(
ctx: &Context,
key: &Key,
state: &mut State,
login: &SignIn,
owner: &Owner,
detail: String,
mut warnings: Vec<Warning>,
) -> Error {
let parked = match park_signed_in(ctx, key, state, login, owner) {
Ok((_, parking)) => {
warnings.extend(parking);
true
}
Err(_) => false,
};
Error::SignInNotInstalled {
tool: key.provider,
label: state.typed(key),
detail,
parked,
warnings,
}
}
fn park_signed_in(
ctx: &Context,
key: &Key,
state: &mut State,
login: &SignIn,
owner: &Owner,
) -> Result<(Enrolled, Vec<Warning>)> {
let label = key.label.as_str();
let slice = provider::of(login.provider)
.slice(&login.document)
.map_err(|e| super::shape(login.provider, e))?;
let parking = park::price(
ctx,
login.provider,
&key.typed(),
&park::service_name(&owner.account_uuid, ctx.now_millis()),
&slice,
)?;
let service = park::reserve(ctx, &owner.account_uuid)?;
let fresh = park::store_at(ctx, key.provider, &service, &slice)?;
crate::fault::point("enroll.park_stored");
let existing = state.get(key);
let previous = existing.and_then(|a| a.parked.clone());
let renewed = existing.is_some();
let last_used_at = existing.and_then(|a| a.last_used_at);
state.upsert(account(
login.provider,
label,
owner,
previous,
last_used_at,
&login.document,
));
state.park(key, fresh);
state::save(ctx, state).inspect_err(|_| {
let _ = store::vault_delete(ctx, &service);
})?;
crate::fault::point("enroll.park_recorded");
purge(ctx, state);
let email = owner.email.clone();
let enrolled = if renewed {
Enrolled::Renewed { email }
} else {
Enrolled::SignedIn { email }
};
Ok((enrolled, parking.into_iter().collect()))
}
fn account(
which: ProviderId,
label: &str,
owner: &Owner,
parked: Option<Park>,
last_used_at: Option<i64>,
login: &Value,
) -> Account {
let detail = match which {
ProviderId::Claude => state::Detail::Claude {
organization_uuid: owner.organization_uuid.clone(),
oauth_account: json!({
"accountUuid": owner.account_uuid,
"emailAddress": owner.email,
"organizationUuid": owner.organization_uuid,
}),
},
ProviderId::Codex => {
let claims = login["tokens"]["id_token"]
.as_str()
.and_then(crate::provider::jwt::claims)
.unwrap_or(Value::Null);
let openai = "https://api.openai.com/auth";
state::Detail::Codex {
workspace_id: Some(owner.organization_uuid.clone()).filter(|id| !id.is_empty()),
plan: crate::provider::jwt::claim(&claims, &[openai, "chatgpt_plan_type"])
.map(str::to_owned),
}
}
};
Account {
last_used_at,
label: label.to_string(),
account_uuid: owner.account_uuid.clone(),
email: owner.email.clone(),
parked,
detail,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::scripted::Trouble;
use crate::store::memory::Fault;
use crate::switch::harness::{
Machine, NOW, codex_id, codex_login, codex_machine, hold, login_of, machine, oauth, renews,
signed_in,
};
use crate::switch::{Due, Outcome, renew_due, settle, switch};
use crate::time::FixedClock;
use std::sync::Arc;
use std::time::{Duration, Instant};
type Make = fn(&str) -> Machine;
const MACHINES: [(&str, Make); 2] = [("claude", machine), ("codex", codex_machine)];
fn in_use(m: &Machine, refresh: &str) -> bool {
m.live().is_some_and(|live| {
provider::of(m.which).fingerprint(&live) == store::fingerprint(refresh)
})
}
fn enrolled_as(m: &Machine, label: &str, login: SignIn) -> Result<(Enrolled, Vec<Warning>)> {
let settled = settle(&m.ctx, Some(m.which)).expect("nothing to recover").0;
enroll(settled, &m.key(label), Some(login))
}
fn park_of(m: &Machine, label: &str) -> Option<Park> {
state::load(&m.ctx)
.expect("state")
.get(&m.key(label))
.and_then(|a| a.parked.clone())
}
#[test]
fn signing_in_again_to_the_account_in_use_puts_the_new_login_in_use() {
for (tool, make) in MACHINES {
let m = make("again-in-use");
let vault = m.mem.vault().services();
let (enrolled, _) = enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
.unwrap_or_else(|e| panic!("{tool}: {e}"));
assert!(
matches!(
enrolled,
Enrolled::InUse { ref email, again: true } if email == "here@example.com"
),
"{tool}: {enrolled:?}"
);
assert!(
in_use(&m, "here-refresh-2"),
"{tool}: the new login is in use"
);
assert_eq!(m.mem.vault().services(), vault, "{tool}: nothing is parked");
assert!(park_of(&m, "here").is_none(), "{tool}");
let state = state::load(&m.ctx).expect("state");
assert_eq!(state.active_for(m.which), Some("here"), "{tool}");
assert_eq!(
state.get(&m.key("here")).and_then(|a| a.last_used_at),
Some(NOW),
"{tool}: signing in to the account in use is using it"
);
hold(&m, &format!("{tool}, after signing in again"));
}
}
#[test]
fn signing_in_again_to_the_account_in_use_keeps_the_park_it_holds() {
for (tool, make) in MACHINES {
let m = make("again-keeps-park");
let (uuid, older) = match m.which {
ProviderId::Claude => ("here".to_string(), oauth("here-older", 30)),
ProviderId::Codex => (codex_id("here"), codex_login("here", "here-older")),
};
let service = park::reserve(&m.ctx, &uuid).expect("a free name");
let held = park::store_at(&m.ctx, m.which, &service, &older).expect("parked");
let mut state = state::load(&m.ctx).expect("state");
state.park(&m.key("here"), held.clone());
state::save(&m.ctx, &state).expect("saved");
let vault = m.mem.vault().services();
enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
.unwrap_or_else(|e| panic!("{tool}: {e}"));
assert!(in_use(&m, "here-refresh-2"), "{tool}");
assert_eq!(
park_of(&m, "here"),
Some(held),
"{tool}: the park is untouched"
);
assert_eq!(m.mem.vault().services(), vault, "{tool}");
hold(&m, &format!("{tool}, after signing in again beside a park"));
}
}
#[test]
fn the_next_switch_away_parks_the_new_login_not_the_old() {
for (tool, make) in MACHINES {
let m = make("again-then-switch");
enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
.unwrap_or_else(|e| panic!("{tool}: {e}"));
let settled = settle(&m.ctx, Some(m.which)).expect("nothing to recover").0;
let (outcome, _) = switch(settled, &m.key("there"))
.unwrap_or_else(|e| panic!("{tool}: the switch away: {e}"));
assert!(matches!(outcome, Outcome::Switched { .. }), "{tool}");
let parked = park_of(&m, "here").expect("the outgoing login is parked");
assert_eq!(
parked.refresh_fingerprint,
store::fingerprint("here-refresh-2"),
"{tool}: the new login is parked, not the one it replaced"
);
hold(&m, &format!("{tool}, after the switch away"));
}
}
#[test]
fn a_sign_in_of_another_account_is_parked_beside_the_one_in_use() {
for (tool, make) in MACHINES {
let m = make("another-parked");
let live = m.live();
let (enrolled, _) = enrolled_as(&m, "third", signed_in(&m, "third", "third-refresh"))
.unwrap_or_else(|e| panic!("{tool}: {e}"));
assert!(
matches!(enrolled, Enrolled::SignedIn { .. }),
"{tool}: {enrolled:?}"
);
assert_eq!(m.live(), live, "{tool}: the login in use is untouched");
assert_eq!(
park_of(&m, "third").map(|p| p.refresh_fingerprint),
Some(store::fingerprint("third-refresh")),
"{tool}"
);
hold(&m, &format!("{tool}, after another account's sign-in"));
}
}
#[test]
fn a_login_in_use_whose_account_cannot_be_told_is_not_written_over() {
let mut cases: Vec<(String, Machine)> = Vec::new();
for (name, trouble) in [
("refused", Trouble::Unauthorized),
("offline", Trouble::Offline),
] {
let m = machine(&format!("untold-{name}"));
m.api.token_trouble("access-here-refresh", trouble);
cases.push((format!("claude, {name}"), m));
}
let m = codex_machine("untold");
let mut unreadable = codex_login("here", "here-refresh");
unreadable["tokens"]["id_token"] = "not a token".into();
m.sign_in(&unreadable);
cases.push(("codex".into(), m));
for (case, m) in cases {
let live = m.live();
let (enrolled, warnings) =
enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
.unwrap_or_else(|e| panic!("{case}: {e}"));
assert!(
matches!(enrolled, Enrolled::Renewed { .. }),
"{case}: {enrolled:?}"
);
assert_eq!(m.live(), live, "{case}: the login in use is untouched");
assert_eq!(
park_of(&m, "here").map(|p| p.refresh_fingerprint),
Some(store::fingerprint("here-refresh-2")),
"{case}: the new login is parked"
);
let said = warnings
.iter()
.find(|w| w.code() == "sign_in_parked_not_in_use")
.unwrap_or_else(|| panic!("{case}: {warnings:?}"))
.to_string();
assert!(
said.contains("parked the new login for `"),
"{case}: {said}"
);
assert!(
said.contains("goes on with the login it has"),
"{case}: {said}"
);
assert!(said.contains("sign in to `"), "{case}: {said}");
}
}
#[test]
fn a_sign_in_to_a_parked_account_is_not_warned_about_the_login_in_use() {
let m = machine("untold-parked");
m.api
.token_trouble("access-here-refresh", Trouble::Unauthorized);
let (enrolled, warnings) =
enrolled_as(&m, "there", signed_in(&m, "there", "there-refresh-2"))
.unwrap_or_else(|e| panic!("{e}"));
assert!(matches!(enrolled, Enrolled::Renewed { .. }), "{enrolled:?}");
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn a_first_sign_in_to_the_account_in_use_enrols_it_with_the_new_login_in_use() {
for (tool, make) in MACHINES {
let m = make("first-in-use");
let mut state = state::load(&m.ctx).expect("state");
state.accounts.retain(|a| a.label != "here");
state.set_active(m.which, None);
state::save(&m.ctx, &state).expect("saved");
let (enrolled, _) =
enrolled_as(&m, "personal", signed_in(&m, "here", "here-refresh-2"))
.unwrap_or_else(|e| panic!("{tool}: {e}"));
assert!(
matches!(enrolled, Enrolled::InUse { again: false, .. }),
"{tool}: {enrolled:?}"
);
assert!(in_use(&m, "here-refresh-2"), "{tool}");
let state = state::load(&m.ctx).expect("state");
assert_eq!(state.active_for(m.which), Some("personal"), "{tool}");
hold(&m, &format!("{tool}, after enrolling the account in use"));
}
}
#[test]
fn the_account_in_use_changing_before_the_write_is_refused_and_nothing_is_written() {
for (tool, make) in MACHINES {
let m = make("changed-before-write");
let key = m.key("here");
let login = signed_in(&m, "here", "here-refresh-2");
let owner = identify_document(&m.ctx, m.which, &login.document).expect("whose");
let InUse::Theirs(live, first) = signed_in_now(&m.ctx, m.which, &owner) else {
panic!("{tool}: `here` is signed in");
};
m.sign_in(&login_of(&m, "other", "other-refresh"));
let vault = m.mem.vault().services();
let recorded = serde_json::to_value(state::load(&m.ctx).expect("state")).unwrap();
let mut state = state::load(&m.ctx).expect("state");
let refused =
install_signed_in(&m.ctx, &key, &mut state, &login, &owner, &live, &first)
.expect_err("refused");
assert_eq!(refused.code(), "sign_in_not_kept", "{tool}: {refused}");
assert!(
refused
.to_string()
.contains("was signed in to another account meanwhile"),
"{tool}: {refused}"
);
assert!(in_use(&m, "other-refresh"), "{tool}: the other login stays");
assert_eq!(m.mem.vault().services(), vault, "{tool}");
assert_eq!(
serde_json::to_value(state::load(&m.ctx).expect("state")).unwrap(),
recorded,
"{tool}: and nothing is recorded"
);
}
}
#[test]
fn a_new_login_that_cannot_be_written_leaves_the_old_one_in_use() {
for (tool, make) in MACHINES {
let m = make("again-write-fails");
let vault = m.mem.vault().services();
let login = signed_in(&m, "here", "here-refresh-2");
m.fault_live(Fault::FailWrite("refused".into()));
let failed = enrolled_as(&m, "here", login).expect_err("the write failed");
assert_eq!(failed.code(), "sign_in_not_kept", "{tool}: {failed}");
let said = failed.to_string();
assert!(said.contains("was not kept"), "{tool}: {said}");
assert!(
said.contains(&format!("pitboard enroll {} --sign-in", m.key("here"))),
"{tool}: {said}"
);
assert!(!said.contains("nothing was lost"), "{tool}: {said}");
assert!(in_use(&m, "here-refresh"), "{tool}");
assert_eq!(m.mem.vault().services(), vault, "{tool}");
}
}
#[test]
fn a_new_login_that_did_not_hold_is_parked_rather_than_lost() {
for (tool, make) in MACHINES {
let m = make("again-did-not-hold");
let login = signed_in(&m, "here", "here-refresh-2");
m.fault_live(Fault::DeletedAfterWrite);
let failed = enrolled_as(&m, "here", login).expect_err("it did not hold");
assert!(
matches!(failed, Error::SignInNotInstalled { parked: true, .. }),
"{tool}: {failed:?}"
);
assert!(
failed.to_string().contains("parked, so it is not lost"),
"{tool}: {failed}"
);
assert_eq!(
park_of(&m, "here").map(|p| p.refresh_fingerprint),
Some(store::fingerprint("here-refresh-2")),
"{tool}"
);
hold(&m, &format!("{tool}, after a new login that did not hold"));
}
}
#[test]
fn a_new_login_that_could_not_be_read_back_is_not_kept_beside_itself() {
for (tool, make) in MACHINES {
for locked in ["by the write", "after it"] {
for next in ["renewal", "change"] {
let at = format!("{tool}, locked {locked}, then a {next}");
let m = make(&format!("unread-{}-{next}", locked.replace(' ', "-")));
let login = signed_in(&m, "here", "here-refresh-2");
let (store, service) = m.live_store();
let failed = if locked == "by the write" {
store.fault(&service, Fault::LocksAfterWrite);
enrolled_as(&m, "here", login)
} else {
crate::fault::meanwhile(
"enroll.installed",
move || store.fault(&service, Fault::Unreadable("locked".into())),
|| enrolled_as(&m, "here", login),
)
}
.expect_err("it could not be read back");
assert!(
matches!(failed, Error::SignInNotInstalled { parked: true, .. }),
"{at}: {failed:?}"
);
assert_eq!(
park_of(&m, "here").map(|p| p.refresh_fingerprint),
Some(store::fingerprint("here-refresh-2")),
"{at}: parked as well"
);
m.live_store().0.heal_all();
match next {
"renewal" => {
let later = m
.ctx
.clone()
.with_clock(Arc::new(FixedClock::at(NOW + 11 * 86_400)));
renews(&m, "here-refresh-2", "here-refresh-3");
let renewed = renew_due(&later, Due::ToBeAsked);
assert!(
renewed.iter().all(|(key, _)| key.label != "here"),
"{at}: {renewed:?}"
);
}
_ => {
settle(&m.ctx, None).expect("the next change");
}
}
assert!(in_use(&m, "here-refresh-2"), "{at}");
assert!(park_of(&m, "here").is_none(), "{at}: the copy is dropped");
hold(&m, &at);
}
}
}
}
#[test]
fn sessions_running_on_the_old_login_are_counted_and_warned_about() {
for (tool, make) in MACHINES {
let m = make("again-sessions");
m.mem.runs("codex", 2);
m.mem.runs("claude", 2);
let (_, warnings) = enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
.unwrap_or_else(|e| panic!("{tool}: {e}"));
let said = warnings
.iter()
.find(|w| w.code() == "sessions_keep_old_login");
match m.which {
ProviderId::Claude => assert!(said.is_none(), "{warnings:?}"),
ProviderId::Codex => {
let text = said.expect("warned").to_string();
assert!(text.contains("2 `codex` sessions"), "{text}");
assert!(text.contains("`codex/here`'s old login"), "{text}");
assert!(text.contains("Quit them and start again"), "{text}");
assert!(text.contains("put the old login back"), "{text}");
}
}
assert!(
warnings
.iter()
.all(|w| w.code() != "sessions_still_running"),
"{tool}: nobody switched away from anything: {warnings:?}"
);
}
}
#[test]
fn a_cancel_does_not_wait_on_a_tool_that_says_nothing() {
let m = codex_machine("cancel");
let pending = reserve_signin(&m.ctx, ProviderId::Codex).expect("reserved");
let mut silent = std::process::Command::new("sleep");
silent.arg("30");
let watched = watch(silent, pending).expect("started");
let said = watched.said();
let reader = std::thread::spawn(move || said.next());
std::thread::sleep(Duration::from_millis(100));
let asked = Instant::now();
watched.cancel();
assert_eq!(reader.join().expect("the reader"), None);
assert!(
asked.elapsed() < Duration::from_secs(10),
"the cancel waited on the tool"
);
reserve_signin(&m.ctx, ProviderId::Codex)
.expect("a cancelled sign-in lets the next one start");
}
}