Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/agent-controller/src/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ trait Keychain: Send + Sync {
account: &str,
) -> std::result::Result<Zeroizing<Vec<u8>>, Failure>;
fn add(&self, service: &str, account: &str, value: &[u8]) -> std::result::Result<(), Failure>;
fn delete(&self, service: &str, account: &str) -> std::result::Result<(), Failure>;
}

pub struct PlatformCredentials {
Expand Down Expand Up @@ -123,6 +124,16 @@ impl Credentials for PlatformCredentials {
.add(SERVICE, &account, value.as_bytes())
.map_err(Failure::message)
}
fn delete(&self, id: &str, pubkey: &str) -> Result<()> {
let account = account(id)?;
if id.split_once('-').map(|(key, _)| key) != Some(pubkey) {
return Err("Credential identifier does not match the selected agent".into());
}
match self.keychain.delete(SERVICE, &account) {
Ok(()) | Err(Failure::Absent) => Ok(()),
Err(error) => Err(error.message()),
}
}
}

// Wipe all parsed values, including when parsing fails mid-map. Reject duplicate
Expand Down
14 changes: 14 additions & 0 deletions crates/agent-controller/src/credentials/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub(super) struct OsKeychain;
#[cfg(all(target_os = "macos", not(test)))]
mod macos {
use super::*;
use security_framework::item::{ItemClass, ItemSearchOptions};
use security_framework::os::macos::{keychain::SecKeychain, passwords::find_generic_password};

fn error(error: security_framework::base::Error) -> Failure {
Expand All @@ -33,6 +34,16 @@ mod macos {
.add_generic_password(service, account, value)
.map_err(error)
}
fn delete(&self, service: &str, account: &str) -> Result<(), Failure> {
let keychain = SecKeychain::default().map_err(error)?;
ItemSearchOptions::new()
.class(ItemClass::generic_password())
.keychains(&[keychain])
.service(service)
.account(account)
.delete()
.map_err(error)
}
}
}

Expand All @@ -58,6 +69,9 @@ impl Keychain for OsKeychain {
fn add(&self, _: &str, _: &str, _: &[u8]) -> Result<(), Failure> {
Err(Failure::Unavailable)
}
fn delete(&self, _: &str, _: &str) -> Result<(), Failure> {
Err(Failure::Unavailable)
}
}

#[test]
Expand Down
41 changes: 41 additions & 0 deletions crates/agent-controller/src/credentials/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ impl Fake {
}
}
impl Keychain for Fake {
fn delete(&self, service: &str, account: &str) -> std::result::Result<(), Failure> {
self.calls
.lock()
.unwrap()
.push(("delete".into(), service.into(), account.into()));
if let Some(error) = *self.failure.lock().unwrap() {
return Err(error);
}
self.entries
.lock()
.unwrap()
.remove(&(service.into(), account.into()))
.map(|_| ())
.ok_or(Failure::Absent)
}
fn legacy(
&self,
service: &str,
Expand Down Expand Up @@ -82,6 +97,32 @@ fn fixture() -> (PlatformCredentials, Arc<Fake>) {
keychain,
)
}
#[test]
fn deletion_is_bound_to_this_apps_exact_credential_and_can_be_retried() {
let (credentials, fake) = fixture();
let id = agent_id(PUB, "wss://relay.example");
let account = format!("agent:{id}");
fake.put(SERVICE, &account, KEY.as_bytes().to_vec());
fake.put("buzz-desktop", "secrets", blob());
assert!(credentials.delete(&id, &"ab".repeat(32)).is_err());
assert!(fake
.entries
.lock()
.unwrap()
.contains_key(&(SERVICE.into(), account.clone())));
credentials.delete(&id, PUB).unwrap();
credentials.delete(&id, PUB).unwrap();
assert!(!fake
.entries
.lock()
.unwrap()
.contains_key(&(SERVICE.into(), account)));
assert!(fake
.entries
.lock()
.unwrap()
.contains_key(&("buzz-desktop".into(), "secrets".into())));
}
fn blob() -> Vec<u8> {
serde_json::to_vec(&json!({format!("agent:{PUB}"):KEY, "identity":"not-the-agent-key"}))
.unwrap()
Expand Down
4 changes: 4 additions & 0 deletions crates/agent-controller/src/import/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ struct Memory {
expected_source: Option<LegacySource>,
}
impl Credentials for Memory {
fn delete(&self, id: &str, _: &str) -> Result<()> {
self.keys.lock().unwrap().remove(id);
Ok(())
}
fn read_legacy(&self, source: LegacySource, pubkey: &str) -> Result<Secret> {
self.reads.fetch_add(1, Ordering::SeqCst);
self.sources.lock().unwrap().push(source);
Expand Down
21 changes: 21 additions & 0 deletions crates/agent-controller/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,27 @@ impl Controller {
self.store.save(id, revision, edit)?;
self.snapshot()
}
pub fn delete(&mut self, id: &str, revision: u64) -> Result<ControlSnapshot> {
let agent = self
.store
.agents()?
.into_iter()
.find(|agent| agent.id == id)
.ok_or("Agent no longer exists")?;
if agent.revision != revision {
return Err("Agent settings changed. Reload before deleting".into());
}
// Stop must be confirmed before removing custody or durable settings.
self.stop(id)?;
self.store.enabled(id, false)?;
// A failed settings write leaves the card available for an explicit retry.
// Credential deletion is idempotent, so that retry can finish cleanup.
self.credentials
.delete(&agent.credential_id, &agent.pubkey)?;
self.store.remove(id, revision)?;
self.errors.remove(id);
self.snapshot()
}
pub fn action(&mut self, id: &str, action: Action) -> Result<ControlSnapshot> {
// Start/restart still require a saved identity; Stop must not depend on it.
if !matches!(action, Action::Stop) && !self.store.agents()?.iter().any(|a| a.id == id) {
Expand Down
67 changes: 67 additions & 0 deletions crates/agent-controller/src/runtime/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000
const PUB: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
struct Memory;
impl Credentials for Memory {
fn delete(&self, _: &str, _: &str) -> Result<()> {
Ok(())
}
fn read_legacy(&self, _: crate::LegacySource, _: &str) -> Result<Secret> {
panic!("Runtime must never import")
}
Expand Down Expand Up @@ -45,6 +48,66 @@ fn agent(workspace: &Path) -> Agent {
extra: BTreeMap::new(),
}
}
#[test]
fn delete_refuses_stale_revision_and_removes_stopped_agent() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("config");
let mut store = Store::open(root.clone()).unwrap();
let saved = agent(dir.path());
store.insert(vec![saved.clone()]).unwrap();
let mut controller = Controller::new(
store,
Arc::new(Memory),
Err("No fixture runtime".into()),
dir.path().join("ownership"),
);
assert!(controller.delete(&saved.id, saved.revision + 1).is_err());
assert_eq!(controller.snapshot().unwrap().agents.len(), 1);
assert!(controller
.delete(&saved.id, saved.revision)
.unwrap()
.agents
.is_empty());
drop(controller);
assert!(Store::open(root).unwrap().agents().unwrap().is_empty());
}
#[test]
fn denied_credential_deletion_keeps_a_disabled_card_for_retry() {
struct Denied;
impl Credentials for Denied {
fn read_legacy(&self, _: crate::LegacySource, _: &str) -> Result<Secret> {
unreachable!()
}
fn read(&self, _: &str, _: &str) -> Result<Option<Secret>> {
unreachable!()
}
fn add(&self, _: &str, _: &Secret) -> Result<()> {
unreachable!()
}
fn delete(&self, _: &str, _: &str) -> Result<()> {
Err("Credential deletion denied".into())
}
}
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("config")).unwrap();
let mut saved = agent(dir.path());
saved.enabled = true;
store.insert(vec![saved.clone()]).unwrap();
let mut controller = Controller::new(
store,
Arc::new(Denied),
Err("No fixture runtime".into()),
dir.path().join("ownership"),
);
assert!(controller
.delete(&saved.id, saved.revision)
.err()
.unwrap()
.contains("denied"));
let remaining = controller.store.agents().unwrap();
assert_eq!(remaining.len(), 1);
assert!(!remaining[0].enabled);
}
#[cfg(unix)]
fn bundle(directory: &Path) -> RuntimeBundle {
use std::os::unix::fs::PermissionsExt;
Expand Down Expand Up @@ -214,6 +277,10 @@ fn actual_spawn_save_restart_stop_and_restore_contract() {
assert!(controller.launch_ids().unwrap().is_empty());
controller.restore().unwrap();
assert!(controller.running.is_empty());
controller.action(&a.id, Action::Start).unwrap();
assert_eq!(controller.running.len(), 1);
assert!(controller.delete(&a.id, 2).unwrap().agents.is_empty());
assert!(controller.running.is_empty());
}
#[test]
#[cfg(unix)]
Expand Down
2 changes: 2 additions & 0 deletions crates/agent-controller/src/secret.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ pub trait Credentials: Send + Sync {
fn read_legacy(&self, source: crate::LegacySource, pubkey: &str) -> Result<Secret>;
fn read(&self, id: &str, pubkey: &str) -> Result<Option<Secret>>;
fn add(&self, id: &str, key: &Secret) -> Result<()>;
/// Remove only this app's exact saved key. Absence is successful for retry.
fn delete(&self, id: &str, pubkey: &str) -> Result<()>;
}

/// V1 starts only unrestricted, verified NIP-OA credentials. Conditional grants
Expand Down
26 changes: 25 additions & 1 deletion crates/agent-controller/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ impl Store {
Ok(doc)
}
fn write(&self, doc: &Document) -> Result<()> {
self.write_with_backup(doc, true)
}
fn write_with_backup(&self, doc: &Document, backup: bool) -> Result<()> {
validate(doc)?;
let bytes =
serde_json::to_vec_pretty(doc).map_err(|_| "Could not encode agent settings")?;
Expand All @@ -108,10 +111,18 @@ impl Store {
}
// Validate/read first: never replace a newly corrupted file on a later save.
let old = self.read()?;
if self.path().exists() {
if backup && self.path().exists() {
let backup =
serde_json::to_vec_pretty(&old).map_err(|_| "Could not back up agent settings")?;
atomic_write(&self.root.join("agents.previous.json"), &backup)?;
} else if !backup {
// A successful delete must not leave the removed settings in the
// previous-version file. Clear it before replacing the live file.
match fs::remove_file(self.root.join("agents.previous.json")) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => return Err("Could not clear previous agent settings".into()),
}
}
atomic_write(&self.path(), &bytes)
}
Expand Down Expand Up @@ -140,6 +151,19 @@ impl Store {
agent.apply(edit)?;
self.write(&doc)
}
pub(crate) fn remove(&mut self, id: &str, revision: u64) -> Result<()> {
let mut doc = self.read()?;
let index = doc
.agents
.iter()
.position(|agent| agent.id == id)
.ok_or("Agent no longer exists")?;
if doc.agents[index].revision != revision {
return Err("Agent settings changed. Reload before deleting".into());
}
doc.agents.remove(index);
self.write_with_backup(&doc, false)
}
pub(crate) fn enabled(&mut self, id: &str, enabled: bool) -> Result<()> {
let mut doc = self.read()?;
let agent = doc
Expand Down
20 changes: 20 additions & 0 deletions crates/agent-controller/src/store/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,26 @@ fn real_store_save_cas_unknown_fields_secret_projection_and_reopen() {
}
}
#[test]
fn remove_requires_current_revision_and_persists_absence() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().to_owned()).unwrap();
let agent = fixture();
store.insert(vec![agent.clone()]).unwrap();
store.enabled(&agent.id, false).unwrap();
assert!(dir.path().join("agents.previous.json").exists());
assert!(store.remove(&agent.id, agent.revision + 1).is_err());
assert_eq!(store.agents().unwrap().len(), 1);
store.remove(&agent.id, agent.revision).unwrap();
assert!(store.agents().unwrap().is_empty());
assert!(!dir.path().join("agents.previous.json").exists());
drop(store);
assert!(Store::open(dir.path().to_owned())
.unwrap()
.agents()
.unwrap()
.is_empty());
}
#[test]
fn environment_patch_preserves_deletes_and_rejects_host_overrides_without_writing() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().to_owned()).unwrap();
Expand Down
8 changes: 6 additions & 2 deletions docs/agent-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ quit other Foundation copies first. Saved enabled agents can restore on startup.
Keep imported agents disabled and old Buzz running until an attended handover.

Open **Agents → My agents** for imported identities, their destination community,
process evidence and visible **Start / Stop**. **Edit** remains secondary in the
card’s three-dot menu. Same-key identities at different destinations have separate
process evidence and visible **Start / Stop**. **Edit**, **Duplicate**, and
**Delete** are in the card’s three-dot menu. Duplicate seeds Create with editable
settings and a fresh identity; write-only environment values require re-entry.
Delete stops the local process and removes this app's settings and Keychain key
after confirmation. It does not archive the relay identity or erase messages.
Same-key identities at different destinations have separate
cards; actions use native ID/revision, never the display name. Managed controls
remain available when the old library is disconnected, unavailable or archived.

Expand Down
1 change: 1 addition & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ fn main() {
"agent_control_snapshot",
"agent_control_save",
"agent_control_start_on_app_launch",
"agent_control_delete",
"agent_control_action",
"agent_control_import_preview",
"agent_control_import_commit",
Expand Down
1 change: 1 addition & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"allow-agent-control-snapshot",
"allow-agent-control-save",
"allow-agent-control-start-on-app-launch",
"allow-agent-control-delete",
"allow-agent-control-action",
"allow-agent-control-import-preview",
"allow-agent-control-import-commit",
Expand Down
13 changes: 13 additions & 0 deletions src-tauri/src/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,19 @@ pub(crate) async fn agent_control_start_on_app_launch(
.await
}
#[tauri::command]
pub(crate) async fn agent_control_delete(
state: tauri::State<'_, AgentHost>,
id: String,
expected_revision: u64,
) -> Result<Snapshot, String> {
run(state.inner().clone(), move |host| {
host.starts.remove(&id);
host.controller.delete(&id, expected_revision)?;
host.snapshot()
})
.await
}
#[tauri::command]
pub(crate) async fn agent_control_action(
state: tauri::State<'_, AgentHost>,
id: String,
Expand Down
Loading
Loading