use std::path::PathBuf;
use castellan::authority::{IdentityIntentOutcome, PersonaeHost};
use castellan::projection::{
CreateProfileIntentV1, PROFILE_CREATE_INTENT, PROFILE_SWITCH_INTENT, SwitchProfileIntentV1,
project_identity, render_identity_surface,
};
use castellan::view::VaultProtectionView;
use personae::bootstrap::{self, Unlock};
use personae::roster;
use personae::ssh_slot::{protocol_key_for, slot_for};
use personae::vault::IdentityStorage;
use personae::{Ed25519Keypair, IdentityVault, Profile, ProfileId, UnlockTier};
use serde_json::json;
use ssh_agent_lib::agent::Session;
use ssh_key::Algorithm;
const PASSPHRASE: &[u8] = b"graphshell-persona-switch-receipt";
fn persona(
id: &str,
name: &str,
seed: u8,
) -> Result<(Profile, String), Box<dyn std::error::Error>> {
let mut private = ssh_key::PrivateKey::random(&mut rand_core::OsRng, Algorithm::Ed25519)?;
private.set_comment(format!("{name} key"));
let mut profile = Profile::new(
ProfileId(id.to_string()),
name,
Ed25519Keypair::from_seed([seed; 32]),
);
let key = protocol_key_for(&private);
let fingerprint = key.instance.clone().unwrap_or_default();
profile
.slots
.insert(key, slot_for(&private, UnlockTier::Session)?);
Ok((profile, fingerprint))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let out = std::env::args()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("ports/graphshell/docs/receipts"));
std::fs::create_dir_all(&out)?;
let vault_dir = out.join("vault");
let _ = std::fs::remove_dir_all(&vault_dir);
let unlock = || Unlock::Passphrase(PASSPHRASE.to_vec().into());
let opened = bootstrap::open_storage(&vault_dir, unlock())?;
let (work, work_key) = persona("work", "Work", 0x11)?;
let (alt, alt_key) = persona("alt", "Late Night Alt", 0x12)?;
opened.storage.save_profile(&work)?;
opened.storage.save_profile(&alt)?;
let host = PersonaeHost::new(
IdentityVault::open(opened.storage, &ProfileId("work".into()))?,
None,
VaultProtectionView::Passphrase,
)
.with_vault_dir(vault_dir.clone());
let before = host.snapshot()?;
let cards = project_identity(&before);
let offered: Vec<&str> = cards
.iter()
.filter(|card| {
card.actions
.iter()
.any(|action| action.intent == PROFILE_SWITCH_INTENT)
})
.map(|card| card.key.as_str())
.collect();
assert_eq!(
offered,
["identity:profile:alt"],
"the switch is offered on the other persona and not on the one in use"
);
let surface_before = render_identity_surface(&before);
assert!(surface_before.contains("Speak as this persona"));
std::fs::write(out.join("01_before_switch.html"), &surface_before)?;
let mut session = host.agent_session();
let listed_before: Vec<String> = session
.request_identities()
.await?
.into_iter()
.map(|identity| identity.comment)
.collect();
assert!(
listed_before.iter().any(|c| c.contains("Work")),
"the agent starts on the persona the host opened: {listed_before:?}"
);
assert!(
!listed_before.iter().any(|c| c.contains("Late Night Alt")),
"and does not already serve the other persona: {listed_before:?}"
);
let outcome = host.apply_intent(
PROFILE_SWITCH_INTENT,
&serde_json::to_vec(&SwitchProfileIntentV1 {
profile: "alt".to_string(),
})?,
)?;
let IdentityIntentOutcome::ProfileSwitch(receipt) = outcome else {
return Err("a switch intent returned some other outcome".into());
};
assert_eq!(receipt.profile, "alt");
assert!(receipt.remembered, "an on-disk vault records the choice");
let listed_after: Vec<String> = session
.request_identities()
.await?
.into_iter()
.map(|identity| identity.comment)
.collect();
assert!(
listed_after.iter().any(|c| c.contains("Late Night Alt")),
"the held session follows the switch without a restart: {listed_after:?}"
);
assert!(
!listed_after.iter().any(|c| c.contains("Work")),
"and stops serving the persona it was opened on: {listed_after:?}"
);
let remembered = roster::chosen_profile(&vault_dir);
assert_eq!(
remembered,
Some(ProfileId("alt".into())),
"the family's remembered choice names the new persona"
);
let after = host.snapshot()?;
let selected: Vec<&str> = after
.profiles
.iter()
.filter(|profile| profile.selected)
.map(|profile| profile.id.as_str())
.collect();
assert_eq!(selected, ["alt"], "the marker follows the switch");
let cards_after = project_identity(&after);
let offered_after: Vec<&str> = cards_after
.iter()
.filter(|card| {
card.actions
.iter()
.any(|action| action.intent == PROFILE_SWITCH_INTENT)
})
.map(|card| card.key.as_str())
.collect();
assert_eq!(offered_after, ["identity:profile:work"]);
std::fs::write(
out.join("02_after_switch.html"),
render_identity_surface(&after),
)?;
let missing = host.apply_intent(
PROFILE_SWITCH_INTENT,
&serde_json::to_vec(&SwitchProfileIntentV1 {
profile: "nobody".to_string(),
})?,
);
assert!(missing.is_err(), "an absent persona is refused");
assert_eq!(
host.snapshot()?
.profiles
.iter()
.filter(|p| p.selected)
.map(|p| p.id.as_str())
.collect::<Vec<_>>(),
["alt"],
"and a refused switch leaves the host where it was"
);
let created = host.apply_intent(
PROFILE_CREATE_INTENT,
&serde_json::to_vec(&CreateProfileIntentV1 {
id: "studio".to_string(),
display_name: "Studio".to_string(),
})?,
)?;
let IdentityIntentOutcome::ProfileCreated(created) = created else {
return Err("a create intent returned some other outcome".into());
};
let with_studio = host.snapshot()?;
let mut roster_ids: Vec<&str> = with_studio
.profiles
.iter()
.map(|profile| profile.id.as_str())
.collect();
roster_ids.sort();
assert_eq!(
roster_ids,
["alt", "studio", "work"],
"the persona is in the vault"
);
assert_eq!(
with_studio
.profiles
.iter()
.filter(|profile| profile.selected)
.map(|profile| profile.id.as_str())
.collect::<Vec<_>>(),
["alt"],
"creating is not switching"
);
let hostile = host.apply_intent(
PROFILE_CREATE_INTENT,
&serde_json::to_vec(&CreateProfileIntentV1 {
id: "../../evil".to_string(),
display_name: "Escape".to_string(),
})?,
);
assert!(
hostile.is_err(),
"an id that cannot be a filename is refused"
);
assert_eq!(
host.snapshot()?.profiles.len(),
3,
"and the refusal minted nothing"
);
std::fs::write(
out.join("03_after_create.html"),
render_identity_surface(&host.snapshot()?),
)?;
let receipt = json!({
"claim_created": {
"id": created.id,
"display_name": created.display_name,
"fingerprint": created.master_public_fingerprint,
"roster_after": roster_ids,
"switched_by_creating": false,
"hostile_id_refused": true,
},
"claim_live": {
"session_reconnected": false,
"served_before": listed_before,
"served_after": listed_after,
"work_key": work_key,
"alt_key": alt_key,
},
"claim_remembered": {
"choice_file": roster::choice_path(&vault_dir).display().to_string(),
"names": remembered.map(|id| id.0),
},
"claim_visible": {
"offered_before": offered,
"offered_after": offered_after,
"selected_after": selected,
},
"absent_persona_refused": true,
"vault_protection": "passphrase (portable, so this runs on every platform)",
});
let path = out.join("persona_switch.json");
std::fs::write(&path, serde_json::to_vec_pretty(&receipt)?)?;
println!("{}", serde_json::to_string_pretty(&receipt)?);
println!("receipt: {}", path.display());
Ok(())
}