use dialoguer::{Confirm, Input, Select};
use zeph_core::config::DurableBackend;
use super::WizardState;
const ROTATE_CONFIRMATION_PHRASE: &str = "rotate";
fn wants_rotation(input: &str) -> bool {
input.trim() == ROTATE_CONFIRMATION_PHRASE
}
pub(super) fn step_durable(state: &mut WizardState) -> anyhow::Result<()> {
println!("== Durable Execution ==\n");
state.durable.enabled = Confirm::new()
.with_prompt("Enable durable execution (crash-resumable agent turns)?")
.default(false)
.interact()?;
if !state.durable.enabled {
println!();
return Ok(());
}
let backends = ["local (dedicated durable.db)", "restate (external server)"];
let backend = Select::new()
.with_prompt("Durable backend")
.items(backends)
.default(0)
.interact()?;
state.durable.backend = if backend == 1 {
DurableBackend::Restate
} else {
DurableBackend::Local
};
state.durable.shared_db = Confirm::new()
.with_prompt(
"Is this durable journal database shared across multiple processes/containers \
(e.g. a network-shared volume, or a shared Postgres server)?",
)
.default(false)
.interact()?;
let customize = Confirm::new()
.with_prompt("Customize retention (TTL and size caps)?")
.default(false)
.interact()?;
if customize {
state.durable.retention.ttl_completed_secs = Input::new()
.with_prompt("Completed-execution TTL (seconds)")
.default(state.durable.retention.ttl_completed_secs)
.interact_text()?;
state.durable.retention.ttl_failed_secs = Input::new()
.with_prompt("Failed/aborted-execution TTL (seconds)")
.default(state.durable.retention.ttl_failed_secs)
.interact_text()?;
state.durable.retention.max_executions = Input::new()
.with_prompt("Maximum stored executions")
.default(state.durable.retention.max_executions)
.interact_text()?;
}
if state.vault_backend == "age"
&& vault_has_durable_key(&zeph_core::vault::default_vault_dir())?
{
println!(
"A ZEPH_DURABLE_KEY already exists in the age vault. Reusing it by default — \
rotating it will PERMANENTLY and IRRECOVERABLY orphan every durable payload already \
sealed under the old key."
);
let confirmation: String = Input::new()
.with_prompt(format!(
"Type \"{ROTATE_CONFIRMATION_PHRASE}\" to rotate the key and discard all \
existing sealed payloads, or leave blank to keep the existing key"
))
.allow_empty(true)
.interact_text()?;
if !wants_rotation(&confirmation) {
println!("Keeping the existing ZEPH_DURABLE_KEY.\n");
return Ok(());
}
println!("Rotating ZEPH_DURABLE_KEY — previously sealed durable payloads will be lost.");
}
state.durable_key_b64 = Some(zeph_core::durable::generate_durable_key_b64());
println!("Generated a new ZEPH_DURABLE_KEY (stored in the age vault during review).");
println!();
Ok(())
}
fn vault_has_durable_key(dir: &std::path::Path) -> anyhow::Result<bool> {
let key_path = dir.join("vault-key.txt");
let vault_path = dir.join("secrets.age");
if !key_path.exists() || !vault_path.exists() {
return Ok(false);
}
let provider = zeph_core::vault::AgeVaultProvider::load(&key_path, &vault_path)
.map_err(|e| anyhow::anyhow!("failed to load age vault: {e}"))?;
Ok(provider.list_keys().contains(&"ZEPH_DURABLE_KEY"))
}
pub(super) fn store_durable_key(state: &WizardState) -> anyhow::Result<()> {
let Some(key) = state.durable_key_b64.as_deref() else {
return Ok(());
};
if !state.durable.enabled {
return Ok(());
}
if state.vault_backend != "age" {
println!(
"Durable execution stores its AEAD key in the age vault. Re-run `zeph --init` with the \
age backend, then the key will be stored automatically."
);
return Ok(());
}
let dir = zeph_core::vault::default_vault_dir();
let key_path = dir.join("vault-key.txt");
let vault_path = dir.join("secrets.age");
if !key_path.exists() || !vault_path.exists() {
zeph_core::vault::AgeVaultProvider::init_vault(&dir)
.map_err(|e| anyhow::anyhow!("failed to initialize age vault: {e}"))?;
}
let mut provider = zeph_core::vault::AgeVaultProvider::load(&key_path, &vault_path)
.map_err(|e| anyhow::anyhow!("failed to load age vault: {e}"))?;
provider
.set_secret_mut("ZEPH_DURABLE_KEY".to_owned(), key.to_owned(), true)
.map_err(|e| anyhow::anyhow!("failed to set ZEPH_DURABLE_KEY: {e}"))?;
provider
.save()
.map_err(|e| anyhow::anyhow!("failed to save age vault: {e}"))?;
println!(
"Stored ZEPH_DURABLE_KEY in the age vault ({}).",
vault_path.display()
);
Ok(())
}
#[cfg(test)]
mod tests {
use serial_test::serial;
use super::{WizardState, store_durable_key, vault_has_durable_key, wants_rotation};
struct VaultDirGuard {
_dir: tempfile::TempDir,
prev_xdg: Option<String>,
}
impl VaultDirGuard {
#[allow(unsafe_code)]
fn new() -> Self {
let dir = tempfile::tempdir().expect("tempdir");
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir.path());
}
Self {
_dir: dir,
prev_xdg,
}
}
}
impl Drop for VaultDirGuard {
#[allow(unsafe_code)]
fn drop(&mut self) {
unsafe {
match &self.prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
}
}
fn durable_state(durable_key_b64: Option<String>) -> WizardState {
let durable = zeph_core::config::DurableConfig {
enabled: true,
..zeph_core::config::DurableConfig::default()
};
WizardState {
vault_backend: "age".into(),
durable,
durable_key_b64,
..WizardState::default()
}
}
#[test]
#[serial]
fn store_durable_key_noop_preserves_existing_key_when_b64_none() {
let _guard = VaultDirGuard::new();
let vault_root = zeph_core::vault::default_vault_dir();
zeph_core::vault::AgeVaultProvider::init_vault(&vault_root).expect("init vault");
let key_path = vault_root.join("vault-key.txt");
let vault_path = vault_root.join("secrets.age");
let mut provider =
zeph_core::vault::AgeVaultProvider::load(&key_path, &vault_path).expect("load vault");
provider
.set_secret_mut(
"ZEPH_DURABLE_KEY".to_owned(),
"existing-key".to_owned(),
false,
)
.expect("set secret");
provider.save().expect("save vault");
let state = durable_state(None);
store_durable_key(&state).expect("store_durable_key must not error");
let reloaded =
zeph_core::vault::AgeVaultProvider::load(&key_path, &vault_path).expect("reload vault");
assert_eq!(reloaded.get("ZEPH_DURABLE_KEY"), Some("existing-key"));
}
#[test]
#[serial]
fn store_durable_key_overwrites_existing_key_when_b64_some() {
let _guard = VaultDirGuard::new();
let vault_root = zeph_core::vault::default_vault_dir();
zeph_core::vault::AgeVaultProvider::init_vault(&vault_root).expect("init vault");
let key_path = vault_root.join("vault-key.txt");
let vault_path = vault_root.join("secrets.age");
let mut provider =
zeph_core::vault::AgeVaultProvider::load(&key_path, &vault_path).expect("load vault");
provider
.set_secret_mut(
"ZEPH_DURABLE_KEY".to_owned(),
"existing-key".to_owned(),
false,
)
.expect("set secret");
provider.save().expect("save vault");
let new_key = zeph_core::durable::generate_durable_key_b64();
let state = durable_state(Some(new_key.clone()));
store_durable_key(&state).expect("store_durable_key must not error");
let reloaded =
zeph_core::vault::AgeVaultProvider::load(&key_path, &vault_path).expect("reload vault");
assert_eq!(reloaded.get("ZEPH_DURABLE_KEY"), Some(new_key.as_str()));
}
#[test]
#[serial]
fn store_durable_key_generates_fresh_key_when_no_existing_vault() {
let _guard = VaultDirGuard::new();
let vault_root = zeph_core::vault::default_vault_dir();
assert!(!vault_has_durable_key(&vault_root).expect("vault must not exist yet"));
let new_key = zeph_core::durable::generate_durable_key_b64();
let state = durable_state(Some(new_key.clone()));
store_durable_key(&state).expect("store_durable_key must not error");
let key_path = vault_root.join("vault-key.txt");
let vault_path = vault_root.join("secrets.age");
let reloaded =
zeph_core::vault::AgeVaultProvider::load(&key_path, &vault_path).expect("load vault");
assert_eq!(reloaded.get("ZEPH_DURABLE_KEY"), Some(new_key.as_str()));
}
#[test]
fn wants_rotation_true_for_exact_phrase() {
assert!(wants_rotation("rotate"));
}
#[test]
fn wants_rotation_true_for_phrase_with_surrounding_whitespace() {
assert!(wants_rotation(" rotate "));
}
#[test]
fn wants_rotation_false_for_blank_input() {
assert!(!wants_rotation(""));
assert!(!wants_rotation(" "));
}
#[test]
fn wants_rotation_false_for_wrong_case() {
assert!(!wants_rotation("Rotate"));
assert!(!wants_rotation("ROTATE"));
}
#[test]
fn wants_rotation_false_for_unrelated_input() {
assert!(!wants_rotation("yes"));
assert!(!wants_rotation("rotate key"));
}
#[test]
fn vault_has_durable_key_false_when_vault_missing() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(!vault_has_durable_key(dir.path()).expect("should not error"));
}
#[test]
fn vault_has_durable_key_true_when_key_present() {
let dir = tempfile::tempdir().expect("tempdir");
zeph_core::vault::AgeVaultProvider::init_vault(dir.path()).expect("init vault");
let key_path = dir.path().join("vault-key.txt");
let vault_path = dir.path().join("secrets.age");
let mut provider =
zeph_core::vault::AgeVaultProvider::load(&key_path, &vault_path).expect("load vault");
provider
.set_secret_mut(
"ZEPH_DURABLE_KEY".to_owned(),
"existing-key".to_owned(),
false,
)
.expect("set secret");
provider.save().expect("save vault");
assert!(vault_has_durable_key(dir.path()).expect("should not error"));
}
#[test]
fn vault_has_durable_key_false_when_other_secrets_present() {
let dir = tempfile::tempdir().expect("tempdir");
zeph_core::vault::AgeVaultProvider::init_vault(dir.path()).expect("init vault");
let key_path = dir.path().join("vault-key.txt");
let vault_path = dir.path().join("secrets.age");
let mut provider =
zeph_core::vault::AgeVaultProvider::load(&key_path, &vault_path).expect("load vault");
provider
.set_secret_mut("ZEPH_OTHER_KEY".to_owned(), "value".to_owned(), false)
.expect("set secret");
provider.save().expect("save vault");
assert!(!vault_has_durable_key(dir.path()).expect("should not error"));
}
}