From 3e749fdb8a8e4ec60f9246091017f8850a427423 Mon Sep 17 00:00:00 2001 From: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Date: Thu, 24 Sep 2026 18:44:24 -0400 Subject: [PATCH] Finish incomplete local setup through a signed Use here action without starting Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- crates/agent-controller/src/community.rs | 85 +++++++ crates/agent-controller/src/config.rs | 5 + crates/agent-controller/src/import.rs | 11 +- crates/agent-controller/src/import/tests.rs | 2 + crates/agent-controller/src/lib.rs | 2 + crates/agent-controller/src/runtime.rs | 38 ++- crates/agent-controller/src/runtime/tests.rs | 223 ++++++++++++++++++ crates/agent-controller/src/store.rs | 50 ++++ dev/relay-broker-api.test.mjs | 44 ++++ dev/relay-broker.mjs | 27 +++ src-tauri/build.rs | 1 + src-tauri/capabilities/default.json | 1 + src-tauri/src/agents.rs | 14 ++ src-tauri/src/browser_permissions_tests.rs | 1 + src-tauri/src/lib.rs | 3 +- src/bundled/agents/AgentEditor.tsx | 22 +- src/bundled/agents/AgentsPage.test.tsx | 54 ++++- src/bundled/agents/AgentsPage.tsx | 7 + .../agents/LocalInventoryAction.test.tsx | 59 +++++ src/bundled/agents/LocalInventoryAction.tsx | 89 +++++++ src/bundled/agents/ManagedAgentActions.tsx | 41 +++- .../agents/choices-enrollment.test.ts | 17 ++ src/features/agents/choices.ts | 5 +- src/features/agents/control-native.test.ts | 16 ++ src/features/agents/control-native.ts | 2 + src/features/agents/control-testing.ts | 9 + src/features/agents/control.ts | 33 +++ 27 files changed, 827 insertions(+), 34 deletions(-) create mode 100644 crates/agent-controller/src/community.rs create mode 100644 src/bundled/agents/LocalInventoryAction.test.tsx create mode 100644 src/bundled/agents/LocalInventoryAction.tsx create mode 100644 src/features/agents/choices-enrollment.test.ts diff --git a/crates/agent-controller/src/community.rs b/crates/agent-controller/src/community.rs new file mode 100644 index 000000000..bd5803eec --- /dev/null +++ b/crates/agent-controller/src/community.rs @@ -0,0 +1,85 @@ +//! Owner-signed intent to configure one identity/community pair. +//! Local import and existing setups do not depend on this confirmation. +use crate::config::{canonical_key, canonical_relay}; +use crate::Result; +use secp256k1::{schnorr::Signature, Secp256k1, XOnlyPublicKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CommunityResolution { + pub pubkey: String, + pub relay_url: String, + pub owner: String, + pub signature: String, +} +impl CommunityResolution { + pub fn verify(&self, auth: &str) -> Result<()> { + crate::secret::validate_attestation(auth, &self.pubkey)?; + let tag: Vec = serde_json::from_str(auth).map_err(|_| "Invalid source owner")?; + if !canonical_key(&self.pubkey) + || tag[1] != self.owner + || canonical_relay(&self.relay_url)? != self.relay_url + { + return Err( + "Community resolution does not match the source owner or destination".into(), + ); + } + let owner: XOnlyPublicKey = self.owner.parse().map_err(|_| "Invalid resolution owner")?; + let signature: Signature = self + .signature + .parse() + .map_err(|_| "Invalid resolution signature")?; + let digest = Sha256::digest(format!( + "nostr:agent-community:{}:{}", + self.pubkey, self.relay_url + )); + Secp256k1::verification_only() + .verify_schnorr(&signature, &digest, &owner) + .map_err(|_| "Community resolution was not signed by the source owner".into()) + } +} +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use secp256k1::{Keypair, SecretKey}; + const PUB: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + pub(crate) fn resolution(relay: &str) -> CommunityResolution { + let secp = Secp256k1::new(); + let mut bytes = [0; 32]; + bytes[31] = 2; + let pair = Keypair::from_secret_key(&secp, &SecretKey::from_byte_array(bytes).unwrap()); + CommunityResolution { + pubkey: PUB.into(), + relay_url: relay.into(), + owner: pair.x_only_public_key().0.to_string(), + signature: secp + .sign_schnorr_no_aux_rand( + &Sha256::digest(format!("nostr:agent-community:{PUB}:{relay}")), + &pair, + ) + .to_string(), + } + } + #[test] + fn owner_can_confirm_multiple_pairs_without_import_or_storage() { + let auth = crate::secret::test_attestation(PUB); + resolution("wss://one.example").verify(&auth).unwrap(); + resolution("wss://two.example").verify(&auth).unwrap(); + } + #[test] + fn unsigned_or_retargeted_resolution_is_rejected() { + let auth = crate::secret::test_attestation(PUB); + for change in ["destination", "identity", "owner", "signature"] { + let mut value = resolution("wss://one.example"); + match change { + "destination" => value.relay_url = "wss://two.example".into(), + "identity" => value.pubkey = "ab".repeat(32), + "owner" => value.owner = "cd".repeat(32), + _ => value.signature = "00".repeat(64), + } + assert!(value.verify(&auth).is_err()); + } + } +} diff --git a/crates/agent-controller/src/config.rs b/crates/agent-controller/src/config.rs index 252ec5778..4ee89966f 100644 --- a/crates/agent-controller/src/config.rs +++ b/crates/agent-controller/src/config.rs @@ -50,6 +50,7 @@ pub struct AgentView { pub launch_provider_env: Option<&'static str>, /// Redacted saved-versus-running differences while the process is alive. pub restart_diff: Vec, + pub configured: bool, } #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -115,6 +116,9 @@ pub(crate) struct Agent { pub extra: BTreeMap, } impl Agent { + pub(crate) fn configured(&self) -> bool { + self.extra.get("configured") != Some(&Value::Bool(false)) + } pub fn view(&self) -> AgentView { let defaults = crate::build_defaults(); let launch = defaults.launch_view(&self.harness, &self.environment); @@ -139,6 +143,7 @@ impl Agent { status: ProcessStatus::Stopped, error: None, diagnostics: Vec::new(), + configured: self.configured(), profile_pending: self.extra.get("profilePending") == Some(&Value::Bool(true)), start_on_app_launch: self.starts_on_launch(), respond_to: self.respond_to(defaults.owner_only).ok().map(str::to_owned), diff --git a/crates/agent-controller/src/import.rs b/crates/agent-controller/src/import.rs index fd2da2976..ed3283aca 100644 --- a/crates/agent-controller/src/import.rs +++ b/crates/agent-controller/src/import.rs @@ -257,7 +257,16 @@ impl CredentialedImport { { return Err("Selected identity is already imported".into()); } - store.insert(self.agents) + let agents = self + .agents + .into_iter() + .map(|mut agent| { + agent.extra.insert("configured".into(), Value::Bool(true)); + agent.enabled = false; + agent + }) + .collect(); + store.insert(agents) } } fn string<'a>(value: &'a Value, key: &str) -> &'a str { diff --git a/crates/agent-controller/src/import/tests.rs b/crates/agent-controller/src/import/tests.rs index 128daefad..b0247e990 100644 --- a/crates/agent-controller/src/import/tests.rs +++ b/crates/agent-controller/src/import/tests.rs @@ -104,6 +104,8 @@ fn preview_is_keyless_commit_resolves_preserves_and_never_enables_or_mutates_sou assert!(!saved.enabled); // Source `start_on_app_launch: true` does not auto-start an imported record. assert_eq!(saved.start_on_app_launch, Some(false)); + assert!(saved.configured()); + assert_eq!(saved.relay_url, "wss://relay.example"); assert_eq!(saved.system_prompt, "definition-prompt"); assert_eq!(saved.harness.model, "definition-model"); assert_eq!(saved.harness.provider, "global-provider"); diff --git a/crates/agent-controller/src/lib.rs b/crates/agent-controller/src/lib.rs index a0007369d..bb3b75742 100644 --- a/crates/agent-controller/src/lib.rs +++ b/crates/agent-controller/src/lib.rs @@ -1,7 +1,9 @@ //! Local configuration and process ownership; never tied to a page or relay session. //! No legacy desktop dependency, implicit identity creation, or credential projection. mod bundle; +mod community; mod config; +pub use community::CommunityResolution; pub mod connection; mod create; mod credentials; diff --git a/crates/agent-controller/src/runtime.rs b/crates/agent-controller/src/runtime.rs index 805f2b091..9770d2b9c 100644 --- a/crates/agent-controller/src/runtime.rs +++ b/crates/agent-controller/src/runtime.rs @@ -443,6 +443,14 @@ impl Controller { || !agent.imported.is_null(), ) } + pub fn use_here( + &mut self, + id: &str, + resolution: crate::CommunityResolution, + ) -> Result { + self.store.use_here(id, resolution)?; + self.snapshot() + } pub fn prepare_import( &self, imports: &mut crate::Imports, @@ -459,12 +467,18 @@ impl Controller { self.snapshot() } pub fn delete(&mut self, id: &str, revision: u64) -> Result { - let agent = self - .store - .agents()? - .into_iter() + let agents = self.store.agents()?; + let agent = agents + .iter() .find(|agent| agent.id == id) + .cloned() .ok_or("Agent no longer exists")?; + // Use here setups of one identity share its key; keep it for the others. + let shared = agents.iter().any(|other| { + other.id != agent.id + && other.credential_id == agent.credential_id + && other.pubkey == agent.pubkey + }); if agent.revision != revision { return Err("Agent settings changed. Reload before deleting".into()); } @@ -473,8 +487,10 @@ impl Controller { 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)?; + if !shared { + self.credentials + .delete(&agent.credential_id, &agent.pubkey)?; + } self.store.remove(id, revision)?; self.errors.remove(id); self.snapshot() @@ -524,7 +540,7 @@ impl Controller { .store .agents()? .into_iter() - .filter(Agent::starts_on_launch) + .filter(|a| a.starts_on_launch() && a.configured()) { // Like the host restore, a launch preference is a Start: it enables. if let Err(error) = self @@ -544,6 +560,9 @@ impl Controller { .into_iter() .find(|a| a.id == id) .ok_or("Agent no longer exists")?; + if !agent.configured() { + return Err("Choose Use here before opening this identity’s credentials".into()); + } let workspace = effective_databricks(&agent)?.map(|s| s.host); self.bundle.as_ref().map_err(Clone::clone)?; Ok((agent.credential_id, agent.pubkey, agent.revision, workspace)) @@ -584,7 +603,7 @@ impl Controller { .store .agents()? .into_iter() - .filter(Agent::starts_on_launch) + .filter(|a| a.starts_on_launch() && a.configured()) .map(|a| a.id) .collect()) } @@ -609,6 +628,9 @@ impl Controller { .into_iter() .find(|a| a.id == id) .ok_or("Agent no longer exists")?; + if !agent.configured() { + return Err("Choose Use here before starting this imported identity".into()); + } if !agent.enabled { return Err("Agent is disabled".into()); } diff --git a/crates/agent-controller/src/runtime/tests.rs b/crates/agent-controller/src/runtime/tests.rs index 513d6f08d..2890f9b2d 100644 --- a/crates/agent-controller/src/runtime/tests.rs +++ b/crates/agent-controller/src/runtime/tests.rs @@ -72,6 +72,49 @@ fn delete_refuses_stale_revision_and_removes_stopped_agent() { assert!(Store::open(root).unwrap().agents().unwrap().is_empty()); } #[test] +fn delete_keeps_a_key_shared_by_another_setup_of_the_same_identity() { + use std::sync::Mutex; + #[derive(Default)] + struct Tracked(Mutex>); + impl Credentials for Tracked { + fn read_legacy(&self, _: crate::LegacySource, _: &str) -> Result { + unreachable!() + } + fn read(&self, _: &str, _: &str) -> Result> { + unreachable!() + } + fn add(&self, _: &str, _: &Secret) -> Result<()> { + unreachable!() + } + fn delete(&self, id: &str, _: &str) -> Result<()> { + self.0.lock().unwrap().push(id.into()); + Ok(()) + } + } + let dir = tempfile::tempdir().unwrap(); + let mut store = Store::open(dir.path().join("config")).unwrap(); + let first = agent(dir.path()); + let mut second = first.clone(); + second.id = agent_id(PUB, "wss://other.example"); + second.relay_url = "wss://other.example".into(); + store.insert(vec![first.clone(), second.clone()]).unwrap(); + let credentials = Arc::new(Tracked::default()); + let mut controller = Controller::new( + store, + credentials.clone(), + Err("No fixture runtime".into()), + dir.path().join("ownership"), + ); + controller.delete(&first.id, first.revision).unwrap(); + assert!(credentials.0.lock().unwrap().is_empty()); + assert!(controller + .delete(&second.id, second.revision) + .unwrap() + .agents + .is_empty()); + assert_eq!(*credentials.0.lock().unwrap(), vec![second.credential_id]); +} +#[test] fn denied_credential_deletion_keeps_a_disabled_card_for_retry() { struct Denied; impl Credentials for Denied { @@ -1428,3 +1471,183 @@ fn pi_selection_and_extensions_survive_save_reopen_and_reach_adapter() { .is_ok()); } } + +#[test] +fn retained_import_cannot_enable_or_open_credentials_until_explicit_setup() { + let root = tempfile::tempdir().unwrap(); + let mut imported = agent(root.path()); + imported.extra.insert("configured".into(), json!(false)); + let id = imported.id.clone(); + let mut store = Store::open(root.path().into()).unwrap(); + store.insert(vec![imported]).unwrap(); + let mut controller = Controller::new( + store, + Arc::new(Memory), + Err("No runtime".into()), + root.path().join("locks"), + ); + assert!(controller.action(&id, Action::Start).unwrap().agents[0] + .error + .as_ref() + .unwrap() + .contains("Use here")); + assert!(controller.credential_request(&id).is_err()); + assert!(controller.launch_ids().unwrap().is_empty()); + assert!(!controller.restore().unwrap().agents[0].enabled); + let resolution = crate::community::tests::resolution("wss://relay.example"); + let snapshot = controller.use_here(&id, resolution).unwrap(); + assert!(snapshot.agents[0].configured); + assert!(!snapshot.agents[0].enabled); + assert_eq!(snapshot.agents[0].pubkey, PUB); +} +#[test] +fn use_here_clears_retained_startup_intent_until_an_explicit_start() { + // (destination, explicit startup preference, legacy enabled) + for (relay, explicit, legacy) in [ + ("wss://relay.example", Some(true), false), + ("wss://other.example", Some(true), false), + ("wss://relay.example", None, true), + ] { + let root = tempfile::tempdir().unwrap(); + let mut imported = agent(root.path()); + imported.extra.insert("configured".into(), json!(false)); + imported.start_on_app_launch = explicit; + imported.enabled = legacy; + let id = imported.id.clone(); + let mut store = Store::open(root.path().into()).unwrap(); + store.insert(vec![imported]).unwrap(); + let mut controller = Controller::new( + store, + Arc::new(Memory), + Err("No runtime".into()), + root.path().join("locks"), + ); + assert!(controller.launch_ids().unwrap().is_empty(), "{relay}"); + let snapshot = controller + .use_here(&id, crate::community::tests::resolution(relay)) + .unwrap(); + assert!(controller.launch_ids().unwrap().is_empty(), "{relay}"); + let target = agent_id(PUB, relay); + let saved = snapshot.agents.iter().find(|a| a.id == target).unwrap(); + assert!(saved.configured); + assert!(!saved.enabled); + assert!(!saved.start_on_app_launch); + // Reopening the saved state must not restore a running agent. + drop(controller); + let reopened = Controller::new( + Store::open(root.path().into()).unwrap(), + Arc::new(Memory), + Err("No runtime".into()), + root.path().join("locks"), + ); + assert!(reopened.launch_ids().unwrap().is_empty(), "{relay}"); + } +} + +#[test] +fn use_here_retry_keeps_a_configured_agent_startup_preference() { + let root = tempfile::tempdir().unwrap(); + let mut configured = agent(root.path()); + configured.enabled = true; + configured.start_on_app_launch = Some(true); + let id = configured.id.clone(); + let mut store = Store::open(root.path().into()).unwrap(); + store.insert(vec![configured]).unwrap(); + let path = root.path().join("agents.json"); + let before = fs::read(&path).unwrap(); + store + .use_here( + &id, + crate::community::tests::resolution("wss://relay.example"), + ) + .unwrap(); + assert_eq!(fs::read(&path).unwrap(), before); +} + +#[test] +fn use_here_rejects_configured_other_community_without_writes() { + let root = tempfile::tempdir().unwrap(); + let mut original = agent(root.path()); + original.enabled = true; + let id = original.id.clone(); + let mut store = Store::open(root.path().into()).unwrap(); + store.insert(vec![original]).unwrap(); + let path = root.path().join("agents.json"); + let before = fs::read(&path).unwrap(); + let error = store + .use_here( + &id, + crate::community::tests::resolution("wss://other.example"), + ) + .unwrap_err(); + assert!(error.contains("Clone")); + assert_eq!(fs::read(&path).unwrap(), before); +} + +#[test] +fn use_here_recovers_incomplete_import_but_cannot_add_a_third_community() { + let root = tempfile::tempdir().unwrap(); + let mut imported = agent(root.path()); + imported.extra.insert("configured".into(), json!(false)); + let id = imported.id.clone(); + let mut store = Store::open(root.path().into()).unwrap(); + store.insert(vec![imported]).unwrap(); + let path = root.path().join("agents.json"); + let before = fs::read(&path).unwrap(); + let mut forged = crate::community::tests::resolution("wss://other.example"); + forged.relay_url = "wss://attacker.example".into(); + assert!(store.use_here(&id, forged).is_err()); + assert_eq!(fs::read(&path).unwrap(), before); + store + .use_here( + &id, + crate::community::tests::resolution("wss://other.example"), + ) + .unwrap(); + let agents = store.agents().unwrap(); + assert!(!agents[0].configured()); + assert!(agents[1].configured()); + assert!(!agents[1].enabled); + assert_eq!(agents[1].pubkey, agents[0].pubkey); + assert_eq!(agents[1].credential_id, agents[0].credential_id); + let after = fs::read(&path).unwrap(); + // A delayed retry of the completed recovery is harmless. + store + .use_here( + &id, + crate::community::tests::resolution("wss://other.example"), + ) + .unwrap(); + assert_eq!(fs::read(&path).unwrap(), after); + assert!(store + .use_here( + &id, + crate::community::tests::resolution("wss://third.example") + ) + .is_err()); + assert_eq!(fs::read(&path).unwrap(), after); +} + +#[test] +fn use_here_exhausted_revision_preserves_the_saved_import() { + let root = tempfile::tempdir().unwrap(); + let mut imported = agent(root.path()); + imported.revision = 9_007_199_254_740_991; + imported.extra.insert("configured".into(), json!(false)); + let id = imported.id.clone(); + let mut store = Store::open(root.path().into()).unwrap(); + store.insert(vec![imported]).unwrap(); + let path = root.path().join("agents.json"); + let before = fs::read(&path).unwrap(); + assert_eq!( + store + .use_here( + &id, + crate::community::tests::resolution("wss://relay.example") + ) + .unwrap_err(), + "Agent revision exhausted" + ); + assert_eq!(fs::read(path).unwrap(), before); + assert!(!store.snapshot().unwrap().agents[0].configured); +} diff --git a/crates/agent-controller/src/store.rs b/crates/agent-controller/src/store.rs index 17ced5392..1945fcc6e 100644 --- a/crates/agent-controller/src/store.rs +++ b/crates/agent-controller/src/store.rs @@ -164,6 +164,53 @@ impl Store { runtime_message: Some("Native runtime has not been connected".into()), }) } + /// Configure retained custody, never reread the old installation or move it. + pub fn use_here(&mut self, id: &str, resolution: crate::CommunityResolution) -> Result<()> { + let mut doc = self.read()?; + let source = doc + .agents + .iter() + .find(|agent| agent.id == id) + .cloned() + .ok_or("Imported identity no longer exists")?; + resolution.verify(source.auth_tag.as_deref().unwrap_or(""))?; + if resolution.pubkey != source.pubkey { + return Err("Use here must preserve the imported identity".into()); + } + let target_id = crate::config::agent_id(&source.pubkey, &resolution.relay_url); + if doc.agents.iter().any(|agent| { + agent.pubkey == source.pubkey && agent.configured() && agent.id != target_id + }) { + return Err("This identity is already configured in another community. Clone it to create a new identity here.".into()); + } + if let Some(target) = doc.agents.iter_mut().find(|agent| agent.id == target_id) { + resolution.verify(target.auth_tag.as_deref().unwrap_or(""))?; + if !target.configured() { + // Setup completes custody only; starting remains a separate + // explicit action, so drop any retained startup intent. + target.extra.insert("configured".into(), Value::Bool(true)); + target.enabled = false; + target.start_on_app_launch = Some(false); + target.revision = target + .revision + .checked_add(1) + .filter(|n| *n <= 9_007_199_254_740_991) + .ok_or("Agent revision exhausted")?; + } + } else { + // Explicit owner-signed setup of an existing identity/community pair. + // Keep the source setup and credential reference; the new pair starts stopped. + let mut target = source.clone(); + target.id = target_id; + target.relay_url = resolution.relay_url; + target.enabled = false; + target.start_on_app_launch = Some(false); + target.revision = 1; + target.extra.insert("configured".into(), Value::Bool(true)); + doc.agents.push(target); + } + self.write(&doc) + } pub fn save(&mut self, id: &str, revision: u64, edit: AgentEdit) -> Result<()> { let mut doc = self.read()?; let agent = doc @@ -199,6 +246,9 @@ impl Store { .iter_mut() .find(|a| a.id == id) .ok_or("Agent no longer exists")?; + if enabled && !agent.configured() { + return Err("Choose Use here before starting this imported identity".into()); + } agent.enabled = enabled; self.write(&doc) } diff --git a/dev/relay-broker-api.test.mjs b/dev/relay-broker-api.test.mjs index 1b89caad3..e926e0108 100644 --- a/dev/relay-broker-api.test.mjs +++ b/dev/relay-broker-api.test.mjs @@ -2143,3 +2143,47 @@ test("private feedback crosses the real broker and session without a readback or await h.close(); } }); + +test("community setup signs explicit owner intent without inventory or enrollment", async () => { + const key = new Uint8Array(32); + key[31] = 7; + const viewer = getPublicKey(key); + const pubkey = "ab".repeat(32); + const h = await harness(() => { + throw new Error("No relay read permitted"); + }); + const route = `${encodeURIComponent(fixtureRelayUrl)}/resolve-agent-community`; + const input = { pubkey, owner: viewer, confirmed: true }; + try { + for (const invalid of [ + { ...input, confirmed: false }, + { ...input, owner: pubkey }, + { ...input, pubkey: viewer }, + { ...input, pubkey: "invalid" }, + { ...input, extra: true }, + ]) + expect((await h.post(route, invalid)).status).toBe(400); + const response = await h.post(route, input); + expect(response.status).toBe(200); + const resolution = await response.json(); + expect(resolution).toMatchObject({ + pubkey, + owner: viewer, + relayUrl: fixtureRelayUrl.replace(/^https:/, "wss:"), + }); + const { schnorr } = await import("@noble/curves/secp256k1.js"); + expect( + schnorr.verify( + Buffer.from(resolution.signature, "hex"), + createHash("sha256") + .update(`nostr:agent-community:${pubkey}:${resolution.relayUrl}`) + .digest(), + Buffer.from(viewer, "hex"), + ), + ).toBe(true); + expect(h.calls).toHaveLength(0); + expect(h.publications).toHaveLength(0); + } finally { + await h.close(); + } +}); diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index 05fe37b34..ad269dd20 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -1611,6 +1611,7 @@ export function relayBrokerPlugin({ "/api/relay/profile", "/api/relay/direct-message", "/api/relay/authorize-agent", + "/api/relay/resolve-agent-community", "/api/relay/claim", "/api/relay/accept-policy", "/api/relay/invite", @@ -1705,6 +1706,32 @@ export function relayBrokerPlugin({ gitReads--; } } + if (route === "/api/relay/resolve-agent-community") { + if ( + !scoped || + filters?.owner !== viewer || + !/^[0-9a-f]{64}$/.test(filters?.pubkey ?? "") || + filters.pubkey === viewer || + filters?.confirmed !== true || + Object.keys(filters).length !== 3 + ) + return json(res, 400, { + error: "Explicit owner community resolution required", + }); + // The signed account confirms setup intent. Native verifies it against + // retained source-owner authorization; inventory is not permission. + cancel.signal.throwIfAborted(); + const relayUrl = relay.replace(/^https:/, "wss:"); + const digest = createHash("sha256") + .update(`nostr:agent-community:${filters.pubkey}:${relayUrl}`) + .digest(); + return json(res, 200, { + pubkey: filters.pubkey, + relayUrl, + owner: viewer, + signature: Buffer.from(schnorr.sign(digest, key)).toString("hex"), + }); + } if (route === "/api/relay/authorize-agent") { if ( !scoped || diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 111c29a57..758568c82 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -37,6 +37,7 @@ fn main() { "agent_control_action", "agent_control_import_preview", "agent_control_import_commit", + "agent_control_use_here", "agent_models_begin", "agent_models_cancel", "agent_models_run", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d04be0652..375f98ee4 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -24,6 +24,7 @@ "allow-agent-control-action", "allow-agent-control-import-preview", "allow-agent-control-import-commit", + "allow-agent-control-use-here", "allow-agent-models-begin", "allow-agent-models-cancel", "allow-agent-models-run", diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index cc0cde2ea..bc501d636 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -16,6 +16,7 @@ pub(crate) struct Snapshot { data: ControlSnapshot, import_available: bool, create_available: bool, + local_inventory_actions: bool, default_workspace: String, harness_options: Vec, databricks_defaults: crate::agent_models::Defaults, @@ -27,6 +28,7 @@ impl Snapshot { data, import_available, create_available: import_available, + local_inventory_actions: true, default_workspace: workspace.to_string_lossy().into_owned(), harness_options: harness_options(), databricks_defaults: crate::agent_models::defaults(), @@ -495,6 +497,18 @@ async fn start( .await } #[tauri::command] +pub(crate) async fn agent_control_use_here( + state: tauri::State<'_, AgentHost>, + id: String, + resolution: buzz_agent_controller::CommunityResolution, +) -> Result { + run(state.inner().clone(), move |host| { + host.controller.use_here(&id, resolution)?; + host.snapshot() + }) + .await +} +#[tauri::command] pub(crate) async fn agent_control_import_preview( state: tauri::State<'_, AgentHost>, source: LegacySource, diff --git a/src-tauri/src/browser_permissions_tests.rs b/src-tauri/src/browser_permissions_tests.rs index 6dd0d878a..52f9aee45 100644 --- a/src-tauri/src/browser_permissions_tests.rs +++ b/src-tauri/src/browser_permissions_tests.rs @@ -81,6 +81,7 @@ fn native_command_permissions_allow_only_main_webview() { "agent_control_action", "agent_control_import_preview", "agent_control_import_commit", + "agent_control_use_here", "agent_models_begin", "agent_models_cancel", "agent_models_run", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7550f0755..0f675ab3d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,7 +20,7 @@ use agents::{ agent_control_action, agent_control_create_commit, agent_control_create_prepare, agent_control_creation_profile, agent_control_delete, agent_control_import_commit, agent_control_import_preview, agent_control_save, agent_control_snapshot, - agent_control_start_on_app_launch, AgentHost, + agent_control_start_on_app_launch, agent_control_use_here, AgentHost, }; use buzzodz_plugins::{ imports::{prepare_folder, prepare_git, PreparedImport, Preview}, @@ -354,6 +354,7 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen agent_control_create_commit, agent_control_creation_profile, agent_control_snapshot, + agent_control_use_here, agent_control_save, agent_control_delete, agent_control_action, diff --git a/src/bundled/agents/AgentEditor.tsx b/src/bundled/agents/AgentEditor.tsx index 7b753ed37..87540ecae 100644 --- a/src/bundled/agents/AgentEditor.tsx +++ b/src/bundled/agents/AgentEditor.tsx @@ -150,15 +150,17 @@ export function AgentEditor({ {agentProcessLabel(agent)}

- {agent.enabled - ? !state.data?.runtimeAvailable - ? "Enabled intent saved · execution unavailable" + {agent.configured === false + ? "Imported · close the editor and choose Use here before starting" + : agent.enabled + ? !state.data?.runtimeAvailable + ? "Enabled intent saved · execution unavailable" + : agent.startOnAppLaunch + ? "Enabled · starts with buzz-app" + : "Enabled · manual-start only" : agent.startOnAppLaunch - ? "Enabled · starts with buzz-app" - : "Enabled · manual-start only" - : agent.startOnAppLaunch - ? "Stopped · starts with buzz-app" - : "Stopped · a later sent mention can start this agent"} + ? "Stopped · starts with buzz-app" + : "Stopped · a later sent mention can start this agent"}

@@ -187,8 +189,8 @@ export function AgentEditor({ Saved revision {agent.revision} · Running revision{" "} {agent.runningRevision ?? "none"}. {unapplied && " Saved changes are not running yet."}{" "} - Stop ends current work; a later sent mention can - start it again. + Stop ends current work. After setup, a later sent + mention can start it again.

), diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index 5905dde5f..c68b6d561 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -478,7 +478,7 @@ it("focuses the imported managed identity without starting it", async () => { await screen.findByRole("button", { name: "Import Fixture agent" }), ); const notice = await screen.findByText( - "Imported, not started. Mention this agent in a channel to start it.", + "Imported, not started. Start it when you are ready.", ); const imported = notice.closest("article"); if (!imported) throw Error("Imported card missing"); @@ -945,9 +945,7 @@ it("credential import keeps real Stop controls reachable without trapping the ed }); await waitFor(() => expect(control.snapshot().busy).toBe(false)); expect( - screen.queryByText( - "Imported, not started. Mention this agent in a channel to start it.", - ), + screen.queryByText("Imported, not started. Start it when you are ready."), ).toBeNull(); await act(async () => control.refresh()); const imported = control @@ -1632,3 +1630,51 @@ it("clears an obsolete route before editing another card", async () => { { replace: true }, ); }); + +it("Use here retries owner confirmation and keeps setup stopped until a separate Start", async () => { + const request = vi + .spyOn(communityApi, "communityRequest") + .mockRejectedValueOnce(new Error("Confirmation unavailable")) + .mockResolvedValueOnce({ + pubkey: "ab".repeat(32), + relayUrl: "wss://relay.example.test", + owner: "de".repeat(32), + signature: "fixture", + }); + const { f } = setup("connected", (fixture) => { + Object.assign(fixture.agent, { + configured: false, + enabled: false, + status: "stopped", + runningRevision: null, + }); + }); + const cards = await screen.findAllByRole("article", { + name: "Agent Fixture agent", + }); + const card = cards.find((entry) => + entry.textContent?.includes("wss://relay.example.test"), + ); + if (!card) throw Error("Imported card missing"); + expect(within(card).getByRole("button", { name: "Start" })).toBeDisabled(); + fireEvent.click(within(card).getByRole("button", { name: "Use here" })); + await within(card).findByText("Confirmation unavailable"); + expect(f.calls.some((call) => call.action === "configure")).toBe(false); + fireEvent.click(within(card).getByRole("button", { name: "Use here" })); + await waitFor(() => + expect(within(card).getByRole("button", { name: "Start" })).toBeEnabled(), + ); + expect(request).toHaveBeenLastCalledWith( + "https://relay.example.test", + "resolve-agent-community", + { pubkey: f.agent.pubkey, owner: "de".repeat(32), confirmed: true }, + ); + expect(f.agent.enabled).toBe(false); + expect( + f.calls.some((call) => call.action === "start" || call.action === "import"), + ).toBe(false); + fireEvent.click(within(card).getByRole("button", { name: "Start" })); + await waitFor(() => + expect(f.calls.some((call) => call.action === "start")).toBe(true), + ); +}); diff --git a/src/bundled/agents/AgentsPage.tsx b/src/bundled/agents/AgentsPage.tsx index a6aa5c6fa..d579b14a8 100644 --- a/src/bundled/agents/AgentsPage.tsx +++ b/src/bundled/agents/AgentsPage.tsx @@ -133,6 +133,7 @@ export function AgentsPage({ importedId={importedId} control={control} connection={connection} + destination={importDestination} /> ) } @@ -161,6 +162,7 @@ function ManagedAgents({ control, connection, label, + destination, }: { label(agent: AgentView): string; state: AgentControlState; @@ -170,6 +172,7 @@ function ManagedAgents({ importedId: string | null; control: AgentControl; connection: RelaySnapshot; + destination: string; }) { const library = connection.session.agentLibrary; const snapshot = useSyncExternalStore( @@ -217,6 +220,10 @@ function ManagedAgents({ state={state} control={control} imported={agent.id === importedId} + destination={destination} + owner={ + connection.status === "ready" ? (connection.viewer ?? "") : "" + } /> ); diff --git a/src/bundled/agents/LocalInventoryAction.test.tsx b/src/bundled/agents/LocalInventoryAction.test.tsx new file mode 100644 index 000000000..da65d2ff1 --- /dev/null +++ b/src/bundled/agents/LocalInventoryAction.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { afterEach, expect, it, vi } from "vitest"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import * as api from "../../features/communities/api"; +import { controlFixture } from "../../features/agents/control-testing"; +import { + createAgentControl, + type CommunityResolution, +} from "../../features/agents/control"; +import { LocalInventoryAction } from "./LocalInventoryAction"; +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); +it("does not configure after the destination dialog is dismissed during resolution", async () => { + let finish!: (r: CommunityResolution) => void; + const pending = new Promise((resolve) => { + finish = resolve; + }); + const request = vi.spyOn(api, "communityRequest").mockReturnValue(pending); + const f = controlFixture(); + const control = createAgentControl(f.host); + await control.refresh(); + const used = vi.fn(); + const mounted = render( + {}} + onUsed={used} + />, + ); + fireEvent.click(screen.getByRole("button", { name: "Use here" })); + await waitFor(() => expect(request).toHaveBeenCalledOnce()); + mounted.unmount(); + await act(async () => { + finish({ + pubkey: f.agent.pubkey, + relayUrl: f.agent.relayUrl, + owner: "de".repeat(32), + signature: "signed", + }); + await pending; + }); + expect(f.calls.some((c) => c.action === "configure")).toBe(false); + expect(used).not.toHaveBeenCalled(); + control.dispose(); +}); diff --git a/src/bundled/agents/LocalInventoryAction.tsx b/src/bundled/agents/LocalInventoryAction.tsx new file mode 100644 index 000000000..1212acc04 --- /dev/null +++ b/src/bundled/agents/LocalInventoryAction.tsx @@ -0,0 +1,89 @@ +import { useEffect, useRef, useState } from "react"; +import type { + AgentControl, + AgentView, + CommunityResolution, +} from "../../features/agents/control"; +import { communityRequest } from "../../features/communities/api"; +import { Button } from "../../shared/design-system/ui/Button"; + +/** Uses retained app custody, never a legacy preview or credential import. */ +export function LocalInventoryAction({ + control, + agent, + destination, + owner, + disabled, + onPending, + onUsed, +}: { + control: AgentControl; + agent?: AgentView | undefined; + destination: string; + owner: string; + disabled: boolean; + onPending(pending: boolean): void; + onUsed(): void; +}) { + const active = useRef(true); + useEffect(() => { + active.current = true; + return () => { + active.current = false; + onPending(false); + }; + }, [onPending]); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + return ( + <> +

+ Finish setting up this older incomplete import in this community using + its existing key. It stays stopped. +

+ {destination &&

Destination: {destination}

} + {(!destination || !owner) && ( +

Connect to the destination community to use this identity there.

+ )} + + {error &&

{error}

} + + ); +} diff --git a/src/bundled/agents/ManagedAgentActions.tsx b/src/bundled/agents/ManagedAgentActions.tsx index 95af623f1..eff505b8d 100644 --- a/src/bundled/agents/ManagedAgentActions.tsx +++ b/src/bundled/agents/ManagedAgentActions.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { canStopAgent, agentLaunchBlock, @@ -6,6 +6,7 @@ import { type AgentControlState, type AgentView, } from "../../features/agents/control"; +import { LocalInventoryAction } from "./LocalInventoryAction"; import { Button } from "../../shared/design-system/ui/Button"; import { agentProcessLabel } from "./agent-edit"; @@ -14,12 +15,17 @@ export function ManagedAgentActions({ state, control, imported, + destination = "", + owner = "", }: { agent: AgentView; state: AgentControlState; control: AgentControl; imported: boolean; + destination?: string; + owner?: string; }) { + const [settingUp, setSettingUp] = useState(false); const details = useRef(null); useEffect(() => { if (imported) { @@ -32,7 +38,7 @@ export function ManagedAgentActions({ void control.action(agent.id, action).catch(() => {}); }; return ( -
+

{agent.relayUrl} @@ -43,21 +49,38 @@ export function ManagedAgentActions({

{imported && !agent.enabled && ( -

- Imported, not started. Mention this agent in a channel to start it. +

+ Imported, not started.{" "} + {agent.configured === false + ? "Choose Use here to set up this identity in a community." + : "Start it when you are ready."}

)} + {agent.configured === false && + (state.data?.localInventoryActions && control.configureHere ? ( + {}} + /> + ) : ( +

Update the desktop app to set up this imported identity.

+ ))} {agent.enabled && ( -

Starts with this app.

+

Starts with this app.

)} {agent.error && ( -

+

{agent.error}

)} {agent.profilePending && (
-

+

Agent saved. Publish its profile so people can find it by name.

{startBlock && agent.status !== "running" && ( -

{startBlock}

+

{startBlock}

)}
); diff --git a/src/features/agents/choices-enrollment.test.ts b/src/features/agents/choices-enrollment.test.ts new file mode 100644 index 000000000..46f67e1d1 --- /dev/null +++ b/src/features/agents/choices-enrollment.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from "vitest"; +import { sameCommunityAgents } from "./choices"; +import { controlFixture } from "./control-testing"; +it("excludes imported-but-unconfigured identities from mention enrollment", () => { + const { agent } = controlFixture(); + const scope = `https://relay.example.test:${"de".repeat(32)}`; + expect( + sameCommunityAgents( + [ + agent, + { ...agent, id: "retained", configured: false }, + { ...agent, id: "other", relayUrl: "wss://other.example" }, + ], + scope, + ), + ).toEqual([agent]); +}); diff --git a/src/features/agents/choices.ts b/src/features/agents/choices.ts index 8508d403e..0e2e2f34d 100644 --- a/src/features/agents/choices.ts +++ b/src/features/agents/choices.ts @@ -12,7 +12,10 @@ export function sameCommunityAgents( if (!/^[0-9a-f]{64}$/.test(viewer)) return []; return agents.filter((agent) => { try { - return `${relayOrigin(agent.relayUrl)}:${viewer}` === scope; + return ( + agent.configured !== false && + `${relayOrigin(agent.relayUrl)}:${viewer}` === scope + ); } catch { return false; } diff --git a/src/features/agents/control-native.test.ts b/src/features/agents/control-native.test.ts index 28f4d42c2..19a4367ad 100644 --- a/src/features/agents/control-native.test.ts +++ b/src/features/agents/control-native.test.ts @@ -84,3 +84,19 @@ it("mention replay floor is transient IPC input on the existing Start command", replayFloor: 1234567890, }); }); + +it("retained inventory actions use native custody commands", async () => { + vi.mocked(invoke).mockClear(); + vi.mocked(isTauri).mockReturnValue(true); + const host = nativeAgentControlHost(); + const resolution = { + pubkey: "ab".repeat(32), + relayUrl: "wss://relay.example", + owner: "cd".repeat(32), + signature: "signed", + }; + await host?.configureHere?.("retained", resolution); + expect(vi.mocked(invoke).mock.calls).toEqual([ + ["agent_control_use_here", { id: "retained", resolution }], + ]); +}); diff --git a/src/features/agents/control-native.ts b/src/features/agents/control-native.ts index b11b128fe..98bd8b0cf 100644 --- a/src/features/agents/control-native.ts +++ b/src/features/agents/control-native.ts @@ -27,6 +27,8 @@ export function nativeAgentControlHost(): AgentControlHost | null { action, ...(replayFloor === undefined ? {} : { replayFloor }), }), + configureHere: (id, resolution) => + invoke("agent_control_use_here", { id, resolution }), previewImport: (source, destination) => invoke("agent_control_import_preview", { source, destination }), commitImport: (token, ids) => diff --git a/src/features/agents/control-testing.ts b/src/features/agents/control-testing.ts index 76f4ebaf2..e6bfd7458 100644 --- a/src/features/agents/control-testing.ts +++ b/src/features/agents/control-testing.ts @@ -34,6 +34,7 @@ export function controlFixture() { restartDiff: [], }; const data: ControlSnapshot = { + localInventoryActions: true, runtimeAvailable: true, agents: [agent], // Simulates the native snapshot; never imported by production UI. @@ -50,6 +51,13 @@ export function controlFixture() { let failStartOnAppLaunch = false; let importDestination = ""; const host: AgentControlHost = { + async configureHere(id, resolution) { + calls.push({ action: "configure", payload: { id, resolution } }); + const target = data.agents.find((a) => a.id === id); + if (!target) throw Error("Missing identity"); + target.configured = true; + return structuredClone(data); + }, async snapshot() { calls.push({ action: "snapshot" }); return structuredClone(data); @@ -107,6 +115,7 @@ export function controlFixture() { calls.push({ action: "import", payload: { token, ids } }); data.agents.push({ ...structuredClone(agent), + configured: true, id: "second-fixture", pubkey: "cd".repeat(32), relayUrl: importDestination, diff --git a/src/features/agents/control.ts b/src/features/agents/control.ts index d32b6be4e..1a3fecbac 100644 --- a/src/features/agents/control.ts +++ b/src/features/agents/control.ts @@ -61,8 +61,11 @@ export interface AgentView { launchProviderEnv: string | null; /** Empty unless a running process was started with different saved settings. */ restartDiff: RestartDiffEntry[]; + /** Absent on older hosts means an existing configured setup. */ + configured?: boolean; } export interface ControlSnapshot { + localInventoryActions?: boolean; agents: AgentView[]; runtimeAvailable: boolean; /** Native-owned editing suggestions, not installation or execution evidence. @@ -96,7 +99,17 @@ export interface AgentImportPreview { candidates: Pick[]; warnings: string[]; } +export type CommunityResolution = { + pubkey: string; + relayUrl: string; + owner: string; + signature: string; +}; export interface AgentControlHost { + configureHere?( + id: string, + resolution: CommunityResolution, + ): Promise; models?: ModelHost; prepareCreate?( requestId: string, @@ -140,6 +153,7 @@ export interface AgentControlState { error: string | null; } export interface AgentControl { + configureHere?: AgentControlHost["configureHere"]; models?: AgentModels; create?( requestId: string, @@ -171,6 +185,8 @@ export function agentLaunchBlock( state: AgentControlState, agent: AgentView, ): string | null { + if (agent.configured === false) + return "Choose Use here before starting this imported identity."; if (state.status !== "ready") return "Refresh status before starting."; if (state.busy) return "Waiting for the current operation."; if (!state.data?.runtimeAvailable) @@ -450,6 +466,7 @@ export function createAgentControl( const agents = state.data?.agents.filter( (agent) => + agent.configured !== false && pubkeys.includes(agent.pubkey) && relayOrigin(agent.relayUrl) === relayOrigin(relayUrl), ) ?? []; @@ -483,6 +500,22 @@ export function createAgentControl( update({ mentionError: `Message sent, but ${failures.join(" ")}` }); }; }, + ...(host?.configureHere + ? { + configureHere: (id: string, resolution: CommunityResolution) => + run( + (native) => { + if (!native.configureHere) + throw new Error("Use here is unavailable."); + return native.configureHere(id, resolution); + }, + (data) => update({ data }), + false, + undefined, + true, + ), + } + : {}), previewImport: (source, destination) => run( (native) => native.previewImport(source, destination),