diff --git a/Cargo.lock b/Cargo.lock index e00dadeebb7..078365c1aa7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2079,6 +2079,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "subtle", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", diff --git a/crates/buzz-backend-kubernetes/src/sandbox_fake.rs b/crates/buzz-backend-kubernetes/src/sandbox_fake.rs index 97377a24420..8481356c2e2 100644 --- a/crates/buzz-backend-kubernetes/src/sandbox_fake.rs +++ b/crates/buzz-backend-kubernetes/src/sandbox_fake.rs @@ -159,12 +159,21 @@ impl State { let path = path.split('?').next().unwrap_or_default(); let segments: Vec<&str> = path.trim_start_matches('/').split('/').collect(); match segments.as_slice() { - ["apis", "agents.x-k8s.io", "v1beta1", "namespaces", _, "sandboxes", rest @ ..] => { - self.sandbox(method, rest.first().copied(), body) + ["apis", "agents.x-k8s.io", "v1beta1", "namespaces", namespace, "sandboxes", rest @ ..] => { + self.sandbox(method, namespace, rest.first().copied(), body) } - ["api", "v1", "namespaces", _, "pods"] => { + ["api", "v1", "namespaces", namespace, "pods"] => { let names: Vec = self.pods.keys().cloned().collect(); - let items: Vec = names.iter().filter_map(|n| self.pod(n)).collect(); + let items: Vec = names + .iter() + .filter(|name| { + self.sandboxes + .get(*name) + .and_then(|s| s["metadata"]["namespace"].as_str()) + .is_none_or(|stored| stored == *namespace) + }) + .filter_map(|n| self.pod(n)) + .collect(); ok(&json!({"apiVersion":"v1","kind":"PodList","metadata":{},"items":items})) } ["api", "v1", "namespaces", _, "pods", name] => { @@ -277,7 +286,13 @@ impl State { } } - fn sandbox(&mut self, method: &Method, name: Option<&str>, body: &[u8]) -> Response { + fn sandbox( + &mut self, + method: &Method, + namespace: &str, + name: Option<&str>, + body: &[u8], + ) -> Response { match (method.clone(), name) { (Method::GET, Some(name)) => match self.sandboxes.get(name) { Some(sandbox) => ok(sandbox), @@ -289,6 +304,7 @@ impl State { return status(500, "InternalError"); } let mut sandbox: Value = serde_json::from_slice(body).unwrap(); + sandbox["metadata"]["namespace"] = namespace.into(); let name = sandbox["metadata"]["name"].as_str().unwrap().to_string(); if self.sandboxes.contains_key(&name) { return status(409, "AlreadyExists"); diff --git a/crates/buzz-sandbox-reaper/Cargo.toml b/crates/buzz-sandbox-reaper/Cargo.toml index b0aa71eccc1..bd375a59031 100644 --- a/crates/buzz-sandbox-reaper/Cargo.toml +++ b/crates/buzz-sandbox-reaper/Cargo.toml @@ -56,6 +56,7 @@ uuid = { workspace = true } rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } [dev-dependencies] +tempfile = "3" buzz-backend-kubernetes = { path = "../buzz-backend-kubernetes", features = ["test-utils"] } # The relay provider's end-to-end tests run against this crate's fake relay # and the manager in process (serve::relay_provider_tests). diff --git a/crates/buzz-sandbox-reaper/src/manager.rs b/crates/buzz-sandbox-reaper/src/manager.rs index 5115cb55495..53efead6fd0 100644 --- a/crates/buzz-sandbox-reaper/src/manager.rs +++ b/crates/buzz-sandbox-reaper/src/manager.rs @@ -456,6 +456,12 @@ impl Manager { self.client.clone() } + /// Sharing expands task invocation only when the matching harness rollout + /// is enabled. Owner lifecycle operations retain their separate checks. + pub(crate) fn sharing_enabled(&self) -> bool { + self.config.shared_tasks + } + /// Reuse mobile start validation without manufacturing an owner control event. pub(crate) async fn start_mention( &self, @@ -467,11 +473,26 @@ impl Manager { let Some(developer) = self.config.developer(&owner) else { return refusal(ErrorCode::UnauthorizedOwner, "owner is not configured"); }; - let start = match Start::admit( - serde_json::json!({"agent_pubkey": agent, "relay_url": relay, "trigger": event}), - owner, - Timestamp::now().as_secs(), - ) { + let admitted = if event.pubkey == owner { + Start::admit( + serde_json::json!({"agent_pubkey": agent, "relay_url": relay, "trigger": event}), + owner, + Timestamp::now().as_secs(), + ) + } else { + let request = crate::shared_tasks::Request { + relay_url: relay.into(), + trigger: event.clone(), + }; + match self + .authorize_shared(owner, agent, &developer.namespace, &request) + .await + { + Ok(task) => Start::shared(task, relay, event), + Err(error) => Err(error), + } + }; + let start = match admitted { Ok(start) => start, Err(error) => return error, }; @@ -819,29 +840,49 @@ impl Manager { namespace: &str, request: &crate::shared_tasks::Request, ) -> serde_json::Value { + match self + .authorize_shared(owner, agent, namespace, request) + .await + { + Ok(task) => { + serde_json::json!({"ok":true,"request":task.origin.request,"origin_agent":task.origin.agent, + "human":task.human,"channel":task.scope.channel,"thread_root":task.scope.root}) + } + Err(error) => error, + } + } + + async fn authorize_shared( + &self, + owner: PublicKey, + agent: PublicKey, + namespace: &str, + request: &crate::shared_tasks::Request, + ) -> Result { use buzz_sdk::sharing::policy::Error as PolicyError; if !self.config.shared_tasks { - return serde_json::json!({"ok":false,"code":"shared_task_denied","error":"shared tasks are not enabled on this manager"}); + return Err( + serde_json::json!({"ok":false,"code":"shared_task_denied","error":"shared tasks are not enabled on this manager"}), + ); } let result=tokio::time::timeout(std::time::Duration::from_secs(40),async { let task=match crate::shared_tasks::verify(self.client.clone(),namespace,owner,agent,request).await { Ok(task)=>task, - Err(PolicyError::Denied(reason))=>return serde_json::json!({"ok":false,"code":"shared_task_denied","error":reason}), - Err(PolicyError::Unavailable)=>return serde_json::json!({"ok":false,"code":"shared_task_retry","error":"current sharing authorization is unavailable; retry this task"}), + Err(PolicyError::Denied(reason))=>return Err(serde_json::json!({"ok":false,"code":"shared_task_denied","error":reason})), + Err(PolicyError::Unavailable)=>return Err(serde_json::json!({"ok":false,"code":"shared_task_retry","error":"current sharing authorization is unavailable; retry this task"})), }; let Some(origin)=self.config.developer(&task.origin_owner) else { - return serde_json::json!({"ok":false,"code":"shared_task_denied","error":"the initial agent must use this session manager"}); + return Err(serde_json::json!({"ok":false,"code":"shared_task_denied","error":"the initial agent must use this session manager"})); }; match crate::shared_budget::admit(self.client.clone(),&origin.namespace,&request.relay_url,&task,Timestamp::now().as_secs()).await { - Ok(())=>serde_json::json!({"ok":true,"request":task.origin.request,"origin_agent":task.origin.agent, - "human":task.human,"channel":task.scope.channel,"thread_root":task.scope.root}), + Ok(())=>Ok(task), Err(error)=>{ let code=if matches!(error,crate::shared_budget::Error::Storage) {"shared_task_retry"} else {"shared_task_denied"}; - serde_json::json!({"ok":false,"code":code,"error":error.to_string()}) + Err(serde_json::json!({"ok":false,"code":code,"error":error.to_string()})) } } }).await; - result.unwrap_or_else(|_|serde_json::json!({"ok":false,"code":"shared_task_retry","error":"task authorization timed out; retry this task"})) + result.unwrap_or_else(|_|Err(serde_json::json!({"ok":false,"code":"shared_task_retry","error":"task authorization timed out; retry this task"}))) } /// Step 3: parse the provider request and bind its agent key to the diff --git a/crates/buzz-sandbox-reaper/src/mentions.rs b/crates/buzz-sandbox-reaper/src/mentions.rs index 67cdc36335a..1bc76ee4d91 100644 --- a/crates/buzz-sandbox-reaper/src/mentions.rs +++ b/crates/buzz-sandbox-reaper/src/mentions.rs @@ -2,6 +2,7 @@ //! Clients need no control protocol. Agent-authenticated subscriptions retain //! the relay's channel access checks; credentials never leave the manager. +mod channels; mod journal; mod registry; pub(crate) use registry::Identity; diff --git a/crates/buzz-sandbox-reaper/src/mentions/channels.rs b/crates/buzz-sandbox-reaper/src/mentions/channels.rs new file mode 100644 index 00000000000..96d304018c0 --- /dev/null +++ b/crates/buzz-sandbox-reaper/src/mentions/channels.rs @@ -0,0 +1,75 @@ +//! Bound the wake subscription to the addressed agent's current channels. +//! On Buzz, an unscoped live subscription routes global events only. + +use super::{registry::Identity, Error, IO_TIMEOUT}; +use buzz_ws_client::{NostrWsConnection, RelayMessage}; +use serde_json::json; +use std::collections::BTreeSet; +use uuid::Uuid; + +const MAX_CHANNELS: usize = 64; + +pub(super) async fn read( + connection: &mut NostrWsConnection, + identity: &Identity, +) -> Result, Error> { + tokio::time::timeout(IO_TIMEOUT, async { + // Kind 39002 is relay-authored channel membership metadata. Its + // coordinate is d, not h. No manager membership is needed to read it. + connection + .send_raw(&json!(["REQ","mention-channels",{ + "kinds":[39002],"#p":[identity.keys.public_key()],"limit":MAX_CHANNELS+1 + }])) + .await + .map_err(|_| Error::Relay)?; + let mut channels = BTreeSet::new(); + for _ in 0..MAX_CHANNELS + 2 { + match connection + .next_event(IO_TIMEOUT) + .await + .map_err(|_| Error::Relay)? + { + RelayMessage::Event { + subscription_id, + event, + } if subscription_id == "mention-channels" => { + if event.kind.as_u16() != 39002 + || event.verify().is_err() + || !event.tags.iter().any(|tag| { + let t = tag.as_slice(); + t.first().map(String::as_str) == Some("p") + && t.get(1) == Some(&identity.keys.public_key().to_hex()) + }) + { + return Err(Error::Relay); + } + let d = buzz_sdk::sharing::task::single_tag(&event, "d", 2) + .map_err(|_| Error::Relay)? + .ok_or(Error::Relay)?; + let channel = Uuid::parse_str(&d[1]).map_err(|_| Error::Relay)?; + if channel.to_string() != d[1] { + return Err(Error::Relay); + } + channels.insert(channel); + if channels.len() > MAX_CHANNELS { + return Err(Error::Capacity); + } + } + RelayMessage::Eose { subscription_id } if subscription_id == "mention-channels" => { + connection + .send_raw(&json!(["CLOSE", "mention-channels"])) + .await + .map_err(|_| Error::Relay)?; + return Ok(channels.into_iter().collect()); + } + RelayMessage::Closed { .. } | RelayMessage::Auth { .. } => { + return Err(Error::Relay) + } + _ => {} + } + } + Err(Error::Capacity) + }) + .await + .map_err(|_| Error::Deadline)? +} diff --git a/crates/buzz-sandbox-reaper/src/mentions/tests.rs b/crates/buzz-sandbox-reaper/src/mentions/tests.rs index ccae768337b..e2a2e2f64e7 100644 --- a/crates/buzz-sandbox-reaper/src/mentions/tests.rs +++ b/crates/buzz-sandbox-reaper/src/mentions/tests.rs @@ -4,6 +4,7 @@ mod live_relay; mod relay; mod revoked; +mod shared_live; use std::sync::Arc; use std::time::Duration; @@ -153,6 +154,43 @@ fn completed(cluster: &sandbox_fake::Shared, event: &Event) -> bool { inbox(cluster)["completed"].get(event.id.to_hex()).is_some() } +#[tokio::test] +async fn requested_history_close_ack_keeps_the_live_listener_running() { + let relay = relay::Relay::start().await; + let cluster = sandbox_fake::Shared::default(); + let identity = register(&cluster, &relay.url).await; + let mut journal = Journal::acquire( + sandbox_fake::client(&cluster), + &identity, + "close-ack", + Timestamp::now().as_secs(), + ) + .await + .unwrap(); + let manager = manager(&cluster); + let mut listener = tokio::spawn(async move { + worker::session(&manager, &identity, &mut journal, &Semaphore::new(1)).await + }); + relay.wait_subscription("closed:mentions-history").await; + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut listener) + .await + .is_err(), + "an acknowledgement of our CLOSE must not terminate live delivery" + ); + let mut phone = NostrWsConnection::connect_authenticated(&relay.url, &keys("33"), None) + .await + .unwrap(); + let event = message("@agent after history closed", None); + assert!(phone.send_event(event.clone()).await.unwrap().accepted); + wait_for(|| completed(&cluster, &event)).await; + assert_eq!(cluster.lock().unwrap().sandboxes.len(), 1); + assert_eq!(relay.count("mentions-live"), 1); + listener.abort(); + let _ = listener.await; + relay.stop().await; +} + #[tokio::test] async fn durable_pending_launch_survives_restart_and_storage_failure_prevents_deploy() { let relay = relay::Relay::start().await; diff --git a/crates/buzz-sandbox-reaper/src/mentions/tests/relay.rs b/crates/buzz-sandbox-reaper/src/mentions/tests/relay.rs index f84c17ddf39..5c6b24959e6 100644 --- a/crates/buzz-sandbox-reaper/src/mentions/tests/relay.rs +++ b/crates/buzz-sandbox-reaper/src/mentions/tests/relay.rs @@ -39,7 +39,14 @@ impl Relay { ]) .sign_with_keys(&keys("33")) .unwrap(); - let events = Arc::new(Mutex::new(vec![metadata, listing])); + let members = EventBuilder::new(Kind::Custom(39002), "") + .tags([ + Tag::parse(["d", CHANNEL]).unwrap(), + Tag::public_key(keys("22").public_key()), + ]) + .sign_with_keys(&keys("11")) + .unwrap(); + let events = Arc::new(Mutex::new(vec![metadata, listing, members])); let cancel = CancellationToken::new(); let lose_next_ack = Arc::new(AtomicBool::new(false)); let (subscriptions, receiver) = watch::channel(BTreeMap::new()); @@ -177,7 +184,11 @@ async fn connection( ws.send(frame(json!(["EOSE", id]))).await?; subscriptions.send_modify(|counts| { *counts.entry(id).or_default() += 1; }); } - "CLOSE" => { filters.remove(input[1].as_str().unwrap()); } + "CLOSE" => { + filters.remove(input[1].as_str().unwrap()); + ws.send(frame(json!(["CLOSED",input[1],""]))).await?; + subscriptions.send_modify(|counts| { *counts.entry(format!("closed:{}",input[1].as_str().unwrap())).or_default() += 1; }); + } "EVENT" => { let event: Event = serde_json::from_value(input[1].clone())?; let accepted = Some(event.pubkey) == author && event.verify().is_ok() @@ -197,6 +208,7 @@ async fn connection( let event = event?; if event.kind.as_u16() != 24210 && !event.tags.iter().any(|t| t.as_slice() == ["h", CHANNEL]) { continue; } for (id, filter) in &filters { + if event.kind.as_u16()!=24210 && filter.get("#h").is_none() {continue;} if matches(filter, &event) { ws.send(frame(json!(["EVENT",id,event]))).await?; } } } diff --git a/crates/buzz-sandbox-reaper/src/mentions/tests/shared_agent.py b/crates/buzz-sandbox-reaper/src/mentions/tests/shared_agent.py new file mode 100644 index 00000000000..9ddc70df6bf --- /dev/null +++ b/crates/buzz-sandbox-reaper/src/mentions/tests/shared_agent.py @@ -0,0 +1,48 @@ +"""Bounded ACP test adapter. Real Buzz CLI and harness perform all routing.""" +import ctypes +import json +import os +from pathlib import Path +import subprocess +import sys + +if sys.platform == "linux": + ctypes.CDLL(None).prctl(1, 9) # Do not survive a failed test's harness. + + +def send(value): + print(json.dumps(value), flush=True) + + +for line in sys.stdin: + message = json.loads(line) + if "id" not in message: + continue + method = message.get("method") + result = {} + if method == "initialize": + result = {"protocolVersion": 1, "agentCapabilities": {}, + "agentInfo": {"name": "shared-fixture", "version": "1"}} + elif method == "session/new": + result = {"sessionId": "shared-fixture"} + elif method == "session/prompt": + marker = Path("model-turns") + count = int(marker.read_text()) if marker.exists() else 0 + marker.write_text(str(count + 1)) + if os.environ.get("SHARED_FIXTURE_TARGET") and count == 0: + subprocess.run([os.environ["SHARED_FIXTURE_CLI"], "agents", "handoff", + os.environ["SHARED_FIXTURE_TARGET"], "--from", + os.environ["SHARED_FIXTURE_FROM"], "--message", + "shared fixture handoff"], check=True, timeout=15, + stdout=subprocess.DEVNULL) + subprocess.run([os.environ["SHARED_FIXTURE_CLI"], "messages", "send", + "--channel", os.environ["SHARED_FIXTURE_CHANNEL"], + "--reply-to", os.environ["SHARED_FIXTURE_FROM"], + "--content", "shared fixture completed"], check=True, + timeout=15, stdout=subprocess.DEVNULL) + send({"jsonrpc": "2.0", "method": "session/update", "params": { + "sessionId": "shared-fixture", "update": { + "sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": "shared fixture completed"}}}}) + result = {"stopReason": "end_turn"} + send({"jsonrpc": "2.0", "id": message["id"], "result": result}) diff --git a/crates/buzz-sandbox-reaper/src/mentions/tests/shared_live.rs b/crates/buzz-sandbox-reaper/src/mentions/tests/shared_live.rs new file mode 100644 index 00000000000..12fea19dd78 --- /dev/null +++ b/crates/buzz-sandbox-reaper/src/mentions/tests/shared_live.rs @@ -0,0 +1,393 @@ +//! Full routing witness: a real relay, two offline owners, production manager, +//! shipped harness and CLI, and a deterministic ACP model adapter. Kubernetes +//! admission uses the existing fake API; no cloud resources or model API calls. + +use super::*; +use buzz_ws_client::RelayMessage; +use std::path::{Path, PathBuf}; +use tokio::process::{Child, Command}; + +#[tokio::test] +#[ignore = "requires isolated local relay and built buzz-acp/buzz binaries"] +async fn shared_mentions_wake_offline_owners_agents_and_handoff_in_the_original_thread() { + let _ = tracing_subscriber::fmt() + .with_env_filter("buzz_sandbox_reaper=info,buzz_sandbox_reaper::mentions=debug") + .with_test_writer() + .try_init(); + let relay = std::env::var("BUZZ_MENTION_TEST_RELAY").unwrap(); + let url = url::Url::parse(&relay).unwrap(); + assert_eq!(url.scheme(), "ws"); + assert!(matches!(url.host_str(), Some("127.0.0.1" | "localhost"))); + let acp = PathBuf::from(std::env::var("BUZZ_SHARING_TEST_ACP").unwrap()); + let cli = PathBuf::from(std::env::var("BUZZ_SHARING_TEST_CLI").unwrap()); + assert!(acp.is_file() && cli.is_file()); + tokio::time::timeout(Duration::from_secs(100), exercise(&relay, &acp, &cli)) + .await + .expect("shared-agent workflow timed out"); +} + +async fn publish(socket: &mut NostrWsConnection, builder: EventBuilder, signer: &Keys) -> Event { + let event = builder.sign_with_keys(signer).unwrap(); + let reply = socket.send_event(event.clone()).await.unwrap(); + assert!( + reply.accepted, + "fixture publication refused: {}", + reply.message + ); + event +} + +async fn channel_events(socket: &mut NostrWsConnection, channel: uuid::Uuid) -> Vec { + socket + .send_raw(&json!(["REQ","shared-fixture",{"kinds":[9,40002],"#h":[channel],"limit":100}])) + .await + .unwrap(); + let mut events = Vec::new(); + for _ in 0..110 { + match socket.next_event(Duration::from_secs(5)).await.unwrap() { + RelayMessage::Event { + subscription_id, + event, + } if subscription_id == "shared-fixture" => events.push(*event), + RelayMessage::Eose { subscription_id } if subscription_id == "shared-fixture" => { + socket + .send_raw(&json!(["CLOSE", "shared-fixture"])) + .await + .unwrap(); + return events; + } + _ => {} + } + } + panic!("bounded channel query exhausted"); +} + +async fn wait_message( + socket: &mut NostrWsConnection, + channel: uuid::Uuid, + author: nostr::PublicKey, + text: &str, +) -> Event { + tokio::time::timeout(Duration::from_secs(30), async { + loop { + if let Some(event) = channel_events(socket, channel) + .await + .into_iter() + .find(|e| e.pubkey == author && e.content.contains(text)) + { + return event; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("no {text} from {author}")) +} + +fn harness( + binaries: (&Path, &Path), + directory: &Path, + relay: &str, + identity: (&Keys, &Keys), + root: &Event, + target: Option, +) -> Child { + let (acp, cli) = binaries; + let (owner, agent) = identity; + let auth = buzz_sdk::nip_oa::compute_auth_tag(owner, &agent.public_key(), "").unwrap(); + std::fs::write( + directory.join("adapter.py"), + include_str!("shared_agent.py"), + ) + .unwrap(); + let log = std::fs::File::create(directory.join("harness.log")).unwrap(); + let channel = + buzz_sdk::sharing::task::scope(root, keys("55").public_key(), Timestamp::now().as_secs()) + .unwrap() + .channel; + let mut command = Command::new(acp); + for (name, _) in std::env::vars() + .filter(|(name, _)| name.starts_with("BUZZ_") || name.starts_with("SHARED_FIXTURE_")) + { + command.env_remove(name); + } + command + .current_dir(directory) + .kill_on_drop(true) + .args([ + "--agent-command", + "python3", + "--agent-args", + "adapter.py", + "--heartbeat-interval", + "0", + ]) + .env("BUZZ_PRIVATE_KEY", agent.secret_key().to_secret_hex()) + .env("BUZZ_RELAY_URL", relay) + .env("BUZZ_AUTH_TAG", auth) + .env( + "BUZZ_ACP_BOUND_SESSION", + json!({"channel_id":channel,"thread_root":root.id}).to_string(), + ) + .env( + "BUZZ_ACP_REPLAY_FLOOR", + root.created_at.as_secs().to_string(), + ) + .env("BUZZ_ACP_SESSION_POLICY", "thread") + .env("BUZZ_ACP_RESPOND_TO", "owner") + .env("BUZZ_ACP_TRIGGER", "mentions") + .env("BUZZ_ACP_SHARING_MANAGER", keys("11").public_key().to_hex()) + .env("SHARED_FIXTURE_CLI", cli) + .env("SHARED_FIXTURE_CHANNEL", channel.to_string()) + .env("SHARED_FIXTURE_FROM", root.id.to_hex()) + .env("RUST_LOG", "warn") + .stdout(std::process::Stdio::null()) + .stderr(log); + if let Some(target) = target { + command.env("SHARED_FIXTURE_TARGET", target.to_hex()); + } + command.spawn().unwrap() +} + +async fn stop(mut child: Child) { + if let Some(pid) = child.id() { + let _ = tokio::time::timeout( + Duration::from_secs(2), + Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .status(), + ) + .await; + } + if tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .is_err() + { + child.kill().await.unwrap(); + } +} + +async fn exercise(relay: &str, acp: &Path, cli: &Path) { + let (a, b, agent_a, agent_b) = (keys("33"), keys("44"), keys("22"), keys("55")); + let channel = uuid::Uuid::new_v4(); + let mut phone = NostrWsConnection::connect_authenticated(relay, &a, None) + .await + .unwrap(); + for member in [b.public_key(), keys("11").public_key()] { + publish( + &mut phone, + EventBuilder::new(Kind::Custom(9030), "").tag(Tag::public_key(member)), + &a, + ) + .await; + } + let mut second = NostrWsConnection::connect_authenticated(relay, &b, None) + .await + .unwrap(); + for (owner, socket) in [(&a, &mut phone), (&b, &mut second)] { + publish(socket, EventBuilder::new(Kind::Metadata, "{}"), owner).await; + } + publish( + &mut phone, + buzz_sdk::build_create_channel( + channel, + &format!("shared-{channel}"), + Some(buzz_sdk::Visibility::Private), + Some(buzz_sdk::ChannelKind::Stream), + None, + None, + ) + .unwrap(), + &a, + ) + .await; + publish( + &mut phone, + buzz_sdk::build_add_member(channel, &b.public_key().to_hex(), None).unwrap(), + &a, + ) + .await; + let cluster = sandbox_fake::Shared::default(); + for (owner, agent, socket, namespace) in [ + (&a, &agent_a, &mut phone, NS), + (&b, &agent_b, &mut second, "buzz-agent-staging-peer"), + ] { + let auth = buzz_sdk::nip_oa::compute_auth_tag(owner, &agent.public_key(), "").unwrap(); + let tag = buzz_sdk::nip_oa::parse_auth_tag(&auth).unwrap(); + let mut connection = NostrWsConnection::connect_authenticated(relay, agent, Some(&tag)) + .await + .unwrap(); + publish( + &mut connection, + EventBuilder::new(Kind::Metadata, "{}").tag(tag), + agent, + ) + .await; + publish( + socket, + buzz_sdk::build_add_member( + channel, + &agent.public_key().to_hex(), + Some(buzz_sdk::MemberRole::Bot), + ) + .unwrap(), + owner, + ) + .await; + publish( + socket, + buzz_sdk::sharing::Grant::builder(agent.public_key(), channel, true).unwrap(), + owner, + ) + .await; + publish( + socket, + EventBuilder::new(Kind::Custom(30177), "{}").tags([ + Tag::parse(["d", &agent.public_key().to_hex()]).unwrap(), + Tag::parse(["runtime", "voltage.remote-workspace/sandbox"]).unwrap(), + ]), + owner, + ) + .await; + Registration::new(owner.public_key(),agent.public_key(),json!({"relay_url":relay, + "private_key_nsec":agent.secret_key().to_bech32().unwrap(),"auth_tag":auth, + "launch":{"command":"codex-acp","args":[],"env":{},"policy_env":{"BUZZ_ACP_SESSION_POLICY":"thread"},"owner_pubkey":owner.public_key()}})) + .unwrap().save(sandbox_fake::client(&cluster),namespace).await.unwrap(); + } + drop(second); // No owner's desktop is available to start the other agent. + let config=ManagerConfig::parse(&json!({"version":1,"shared_tasks":true,"checkpoint_bucket":"fixture-checkpoints","developers":{ + "dev":{"owner_pubkey":a.public_key(),"namespace":NS,"profile_secret_arn":"arn:aws:secretsmanager:us-west-2:000000000000:secret:voltage/staging/agents/dev/profile-AbCdEf"}, + "peer":{"owner_pubkey":b.public_key(),"namespace":"buzz-agent-staging-peer","profile_secret_arn":"arn:aws:secretsmanager:us-west-2:000000000000:secret:voltage/staging/agents/peer/profile-AbCdEf"}}}).to_string()).unwrap(); + let manager=Arc::new(Manager::new(keys("11"),config,sandbox_fake::client(&cluster),crate::enroll::tests::offline(Default::default())) + .with_sandbox_defaults(json!({"image":format!("example.invalid/agent@sha256:{}","a".repeat(64)),"pod_options":{"active_deadline_seconds":3600}}).as_object().unwrap().clone())); + let cancel = CancellationToken::new(); + let server = { + let manager = manager.clone(); + let cancel = cancel.clone(); + let relay = relay.to_owned(); + tokio::spawn(async move { + crate::serve::run_services(manager, &relay, None, true, cancel.cancelled()).await; + }) + }; + wait_for(|| { + cluster + .lock() + .unwrap() + .secrets + .values() + .filter(|secret| secret["data"]["inbox.json"].is_string()) + .count() + == 2 + }) + .await; + // Discovery's activation floor is seconds, so a current message is included + // by both first history and the overlapping live subscription. + let first = publish( + &mut phone, + EventBuilder::new(Kind::Custom(9), "shared fixture task").tags([ + Tag::parse(["h", &channel.to_string()]).unwrap(), + Tag::public_key(agent_b.public_key()), + ]), + &a, + ) + .await; + wait_for(|| cluster.lock().unwrap().sandboxes.len() == 1).await; + let b_dir = tempfile::Builder::new() + .prefix("buzz-shared-b-") + .tempdir() + .unwrap() + .keep(); + let a_dir = tempfile::Builder::new() + .prefix("buzz-shared-a-") + .tempdir() + .unwrap() + .keep(); + eprintln!("Harness evidence: {} {}", a_dir.display(), b_dir.display()); + let b_process = harness( + (acp, cli), + &b_dir, + relay, + (&b, &agent_b), + &first, + Some(agent_a.public_key()), + ); + let handoff = wait_message( + &mut phone, + channel, + agent_b.public_key(), + "shared fixture handoff", + ) + .await; + wait_for(|| cluster.lock().unwrap().sandboxes.len() == 2).await; + let a_process = harness((acp, cli), &a_dir, relay, (&a, &agent_a), &first, None); + let reply = wait_message( + &mut phone, + channel, + agent_a.public_key(), + "shared fixture completed", + ) + .await; + let thread_tags: Vec<_> = reply + .tags + .iter() + .map(Tag::as_slice) + .filter(|t| { + t.first().map(String::as_str) == Some("e") + && matches!(t.get(3).map(String::as_str), Some("root" | "reply")) + }) + .collect(); + assert!(!thread_tags.is_empty()); + assert!(thread_tags + .iter() + .all(|t| t.get(1) == Some(&first.id.to_hex()))); + assert_eq!( + buzz_sdk::sharing::task::origin(&handoff) + .unwrap() + .unwrap() + .request, + first.id + ); + assert_eq!( + std::fs::read_to_string(a_dir.join("model-turns")).unwrap(), + "1" + ); + publish( + &mut phone, + buzz_sdk::sharing::Grant::builder(agent_a.public_key(), channel, false) + .unwrap() + .custom_created_at(Timestamp::from(Timestamp::now().as_secs() + 1)), + &a, + ) + .await; + let auth = buzz_sdk::nip_oa::compute_auth_tag(&b, &agent_b.public_key(), "").unwrap(); + let tag = buzz_sdk::nip_oa::parse_auth_tag(&auth).unwrap(); + let mut sender = NostrWsConnection::connect_authenticated(relay, &agent_b, Some(&tag)) + .await + .unwrap(); + publish( + &mut sender, + buzz_sdk::sharing::task::handoff( + &first, + agent_b.public_key(), + agent_a.public_key(), + "denied after revoke", + Timestamp::now().as_secs(), + ) + .unwrap(), + &agent_b, + ) + .await; + wait_message(&mut phone, channel, agent_a.public_key(), "refused").await; + assert_eq!(cluster.lock().unwrap().sandboxes.len(), 2); + assert_eq!( + std::fs::read_to_string(a_dir.join("model-turns")).unwrap(), + "1", + "revocation must stop model access" + ); + stop(a_process).await; + stop(b_process).await; + std::fs::remove_dir_all(a_dir).unwrap(); + std::fs::remove_dir_all(b_dir).unwrap(); + cancel.cancel(); + tokio::time::timeout(BOUND, server).await.unwrap().unwrap(); +} diff --git a/crates/buzz-sandbox-reaper/src/mentions/worker.rs b/crates/buzz-sandbox-reaper/src/mentions/worker.rs index 4653fe26786..31a10591a10 100644 --- a/crates/buzz-sandbox-reaper/src/mentions/worker.rs +++ b/crates/buzz-sandbox-reaper/src/mentions/worker.rs @@ -101,9 +101,18 @@ pub(super) async fn session( ) -> Result<(), Error> { drain(manager, identity, journal, permits).await?; let mut connection = connect(identity).await?; - let connected_at = Timestamp::now().as_secs(); + let channels = super::channels::read(&mut connection, identity).await?; + if channels.is_empty() { + tokio::time::sleep(RECYCLE_AFTER).await; + return Ok(()); + } + let mut connected_at = Timestamp::now().as_secs(); let started = tokio::time::Instant::now(); - let filter = json!({"kinds": [9, 40002], "authors": [identity.owner], "#p": [identity.keys.public_key()]}); + let mut filter = + json!({"kinds": [9, 40002], "#h":channels, "#p": [identity.keys.public_key()]}); + if !manager.sharing_enabled() { + filter["authors"] = json!([identity.owner]); + } let mut live = filter.clone(); live["limit"] = json!(0); send(&mut connection, json!(["REQ", "mentions-live", live])).await?; @@ -126,7 +135,10 @@ pub(super) async fn session( Err(WsClientError::Timeout) => { continue; } - Err(_) => return Err(Error::Relay), + Err(_) => { + tracing::warn!("mention socket failed"); + return Err(Error::Relay); + } }; match message { RelayMessage::Event { @@ -144,7 +156,9 @@ pub(super) async fn session( } oldest = oldest.min(event.created_at.as_secs()); } - if eligible(identity, journal, &event) { + let actionable = eligible(identity, journal, &event, manager.sharing_enabled()); + tracing::debug!(event_id=%event.id, agent=%identity.keys.public_key(), actionable,"received mention"); + if actionable { let persisted = tokio::time::timeout(IO_TIMEOUT, journal.enqueue(*event)) .await .map_err(|_| Error::Deadline)??; @@ -160,6 +174,11 @@ pub(super) async fn session( if subscription_id == "mentions-live" && !history_started => { history_started = true; + // Fence after live installation so discovery cannot open a + // delivery gap before the finite history query begins. + connected_at = Timestamp::now().as_secs(); + oldest = connected_at; + until = connected_at; history(&mut connection, filter.clone(), journal.state.since, until).await?; progress = tokio::time::Instant::now(); } @@ -185,7 +204,19 @@ pub(super) async fn session( } progress = tokio::time::Instant::now(); } - RelayMessage::Closed { .. } | RelayMessage::Auth { .. } => return Err(Error::Relay), + // Buzz acknowledges our explicit CLOSE. The live subscription + // must keep running after history has finished and checkpointed. + RelayMessage::Closed { + subscription_id, .. + } if subscription_id == "mention-channels" + || (subscription_id == "mentions-history" && history_done) => {} + RelayMessage::Closed { + subscription_id, .. + } => { + tracing::warn!(%subscription_id, "mention subscription closed"); + return Err(Error::Relay); + } + RelayMessage::Auth { .. } => return Err(Error::Relay), _ => {} } } @@ -203,14 +234,39 @@ async fn history( send(connection, json!(["REQ", "mentions-history", filter])).await } -fn eligible(identity: &Identity, journal: &Journal, event: &Event) -> bool { +fn eligible(identity: &Identity, journal: &Journal, event: &Event, shared: bool) -> bool { if event.created_at.as_secs() < journal.state.activated || serde_json::to_vec(event).map_or(true, |v| v.len() > MAX_EVENT_BYTES) { return false; } - Start::admit(json!({"agent_pubkey": identity.keys.public_key(), "relay_url": identity.relay, "trigger": event}), - identity.owner, Timestamp::now().as_secs()).is_ok() + if event.pubkey != identity.owner { + return shared + && buzz_sdk::sharing::task::scope( + event, + identity.keys.public_key(), + Timestamp::now().as_secs(), + ) + .is_ok(); + } + conversation(identity, event).is_ok() +} + +fn conversation(identity: &Identity, event: &Event) -> Result<(String, String), Error> { + if event.pubkey == identity.owner { + let start = Start::admit(json!({"agent_pubkey":identity.keys.public_key(),"relay_url":identity.relay,"trigger":event}), + identity.owner,Timestamp::now().as_secs()).map_err(|_|Error::Registration)?; + let (channel, root) = start.conversation(); + Ok((channel.into(), root.into())) + } else { + let scope = buzz_sdk::sharing::task::scope( + event, + identity.keys.public_key(), + Timestamp::now().as_secs(), + ) + .map_err(|_| Error::Registration)?; + Ok((scope.channel.to_string(), scope.root.to_hex())) + } } /// The journal survives all cancellation points, including a successful deploy @@ -223,7 +279,7 @@ async fn drain( ) -> Result<(), Error> { while journal.state.pending.is_some() { let pending = journal.state.pending.as_ref().ok_or(Error::Storage)?; - if !eligible(identity, journal, &pending.event) { + if !eligible(identity, journal, &pending.event, manager.sharing_enabled()) { return Err(Error::Registration); } if pending.notice.is_some() { @@ -277,6 +333,7 @@ async fn drain( Ok(LaunchPermission::NoLongerRemote) => json!({"ok": true}), Err(_) => json!({"ok": false, "code": "delivery_unconfirmed"}), }; + tracing::debug!(event_id=%event.id,ok=result["ok"].as_bool(),code=result["code"].as_str(),"mention launch result"); if result["ok"] == true { tokio::time::timeout(IO_TIMEOUT, journal.complete()) .await @@ -294,6 +351,7 @@ async fn drain( | "unauthorized_owner" | "invalid_start" | "unsupported_conversation" + | "shared_task_denied" ) || pending.attempts >= MAX_ATTEMPTS { pending.notice = Some(failure_notice(identity, &event, code)?); @@ -365,19 +423,19 @@ async fn flush_notices(identity: &Identity, journal: &mut Journal) -> Result<(), } fn failure_notice(identity: &Identity, event: &Event, code: &str) -> Result { - let start = Start::admit(json!({"agent_pubkey": identity.keys.public_key(), "relay_url": identity.relay, "trigger": event}), - identity.owner, Timestamp::now().as_secs()).map_err(|_| Error::Registration)?; + let (channel, root) = conversation(identity, event)?; let text = match code { "session_ended" | "session_ending" | "recovery_interrupted" => "This thread's agent session ended. Recover it from desktop, or mention me in a new thread.", "config_differs" => "This thread has different agent settings. Continue its existing session or mention me in a new thread.", "launch_not_registered" => "Start this agent once from desktop to register its launch settings, then mention me again.", "unsupported_conversation" => "Automatic remote startup requires a channel thread. Mention me in a channel, or manage this conversation from desktop.", + "shared_task_denied" => "Shared task refused: current sharing permission, channel membership, or handoff budget does not permit this request. Ask the agent owner to check sharing.", + "shared_task_retry" => "Current sharing authorization is unavailable. Mention me again to retry; this request has not been allowed to run.", _ => "Agent startup could not be confirmed. Mention me again to retry.", }; - let (channel, root) = start.conversation(); let tags = [ - vec!["h", channel], - vec!["e", root, "", "root"], + vec!["h", &channel], + vec!["e", &root, "", "root"], vec!["e", &event.id.to_hex(), "", "reply"], ] .into_iter() @@ -398,9 +456,7 @@ async fn channel_launch_allowed( trigger: &Event, ) -> Result { tokio::time::timeout(IO_TIMEOUT, async { - let start = Start::admit(json!({"agent_pubkey":identity.keys.public_key(), "relay_url":identity.relay, "trigger":trigger}), - identity.owner, Timestamp::now().as_secs()).map_err(|_| Error::Registration)?; - let (channel, _) = start.conversation(); + let (channel, _) = conversation(identity,trigger)?; let mut connection = connect(identity).await?; send(&mut connection, json!(["REQ", "mention-trigger", {"kinds":[9,40002],"ids":[trigger.id],"#h":[channel],"limit":1}])).await?; send(&mut connection, json!(["REQ", "mention-channel", {"kinds":[39000],"#d":[channel],"limit":1}])).await?; @@ -416,7 +472,7 @@ async fn channel_launch_allowed( } RelayMessage::Event { subscription_id, event } if subscription_id == "mention-channel" => { if event.kind.as_u16() != 39000 || event.verify().is_err() - || !event.tags.iter().any(|tag| tag.as_slice() == ["d", channel]) { return Err(Error::Relay); } + || !event.tags.iter().any(|tag| tag.as_slice() == ["d", channel.as_str()]) { return Err(Error::Relay); } metadata = Some(!event.tags.iter().any(|tag| { let tag = tag.as_slice(); tag.first().map(String::as_str) == Some("hidden") || tag == ["t", "dm"] || tag == ["archived", "true"] diff --git a/crates/buzz-sandbox-reaper/src/mobile_launch.rs b/crates/buzz-sandbox-reaper/src/mobile_launch.rs index f51ed979d83..e67ad87ffb4 100644 --- a/crates/buzz-sandbox-reaper/src/mobile_launch.rs +++ b/crates/buzz-sandbox-reaper/src/mobile_launch.rs @@ -147,6 +147,27 @@ struct StartPayload { } impl Start { + /// Construct a confined launch only after fresh sharing verification and + /// atomic budget admission. This never manufactures an owner-signed event. + pub(crate) fn shared( + task: buzz_sdk::sharing::policy::Task, + relay: &str, + event: &Event, + ) -> Result { + if task.trigger != event.id || task.requester != event.pubkey { + return Err(invalid()); + } + let scope = json!({"channel_id":task.scope.channel,"thread_root":task.scope.root}); + provider::check_sandbox_scope(&json!({"sandbox":scope})).map_err(|_| invalid())?; + Ok(Self { + owner: task.owner, + agent: task.agent, + relay: relay_endpoint(relay)?, + scope, + replay_floor: event.created_at.as_secs(), + }) + } + /// Verify the owner's signed mention and derive its immutable thread scope. pub fn admit(payload: Value, owner: PublicKey, request_time: u64) -> Result { let payload: StartPayload = serde_json::from_value(payload).map_err(|_| invalid())?; diff --git a/docs/shared-channel-agents.md b/docs/shared-channel-agents.md new file mode 100644 index 00000000000..7849be18020 --- /dev/null +++ b/docs/shared-channel-agents.md @@ -0,0 +1,87 @@ +# Shared channel agents + +A human owner can let current members of one channel invoke their remote agent +and its configured tools by an explicit mention. A shared task can hand work to +another opted-in agent in the same thread. The request keeps its actual signer. +Ownership, settings, credentials, recovery, and lifecycle control remain with +the owner. DMs are not shared. + +## Enable an agent + +Use a CLI session authenticated as the human owner. First start the agent from +desktop once to register its launch configuration with the manager. Both the +owner and the agent must be explicit members of the channel. + +```sh +buzz agents share --channel +buzz agents sharing --channel +``` + +Then another current channel member can mention the agent in a task. Sharing +allows use of the agent's configured tool access; it does not create a separate +tool sandbox for each requester. Choose the agent's credentials accordingly. + +Revoke with: + +```sh +buzz agents unshare --channel +``` + +A verified owner can revoke after being removed from the channel or after the +channel is archived. Revocation blocks new shared turns, including queued turns +when they reach model dispatch. It does not undo work already executed. + +An agent handling an admitted request can hand off specific work with its normal +agent-authenticated CLI: + +```sh +buzz agents handoff --from --message 'Review the permission changes' +``` + +The target must also be opted in. Handoffs retain the original human request and +parent event. A durable manager journal permits four handoffs total and at most +two admissions per agent under one human-to-initial-agent task. A duplicate +admission consumes no additional budget. The task expires after seven days. + +## Rollout + +The implementation is off by default. Before enabling it: + +1. Deploy a relay that supports owner-signed kind 30180 grants. +2. Deploy the matching manager and agent images from the complete implementation + stack. Enable ordinary mention startup with `BUZZ_SANDBOX_MENTION_LAUNCH=true`. +3. Set `shared_tasks: true` in the manager's version 1 JSON config through its + owning infrastructure source. The manager then supplies + `BUZZ_ACP_SHARING_MANAGER` to channel-thread launches. +4. Use a new thread or explicit recovery for a thread with older launch settings. +5. Have each human owner enable the desired channel grants, then test with a + second human member before wider use. + +Shared-capable channel sessions disable personal core memory before session +creation, including when sharing is enabled later. The manager reads using each +agent's existing identity and channel permissions. It does not join channels. +Each registered agent can subscribe to at most 64 current channels. Membership +is rediscovered on the bounded listener recycle; fresh admission also rechecks +current membership and grants. Authorization failures leave a visible refusal +or a durable retry record. Startup does not implicitly recover an ended session. + +The rollout uses existing owner namespaces, quotas, and Spot capacity. Human +acceptance and staging rollout must be recorded separately from local tests. + +## Local workflow evidence + +Build `buzz-acp` and `buzz` from the implementation branch. Use an isolated local +relay with NIP-OA and relay membership enabled, with test owner key `33` repeated +32 times. The integration test only accepts a loopback `ws://` relay URL. + +```sh +BUZZ_MENTION_TEST_RELAY=ws://127.0.0.1:13049 \ +BUZZ_SHARING_TEST_ACP="$PWD/target/debug/buzz-acp" \ +BUZZ_SHARING_TEST_CLI="$PWD/target/debug/buzz" \ +cargo test -p buzz-sandbox-reaper shared_mentions_wake -- --ignored --nocapture +``` + +This runs real relay admission, wake listeners, harness processes, and CLI +commands for two owners and two agents, followed by revocation. A deterministic +ACP adapter replaces the model and a simulated Kubernetes API replaces the +cluster. It does not validate a real image pull, cloud Pod, or external model.