From ac13c5905332e9579344bbe38fe01fc198242945 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:32:22 -0300 Subject: [PATCH] feat(tools): ask_crew_access lets a bot reach another crew with the owner's OK Crews stay closed to each other. When the owner wants it, a bot asks with ask_crew_access: the crew, one bot of it or the whole crew, what it needs (talk) and why. The owner allows it only now (until the bot's turn ends), always for that bot, always for the crew, or declines. With access, crew_roster and send_message take crew; the other side can answer, and tasks come back. Messages and tasks across crews belong to the recipient's crew, so deleting a crew leaves nothing dangling. Spec 9.3, 10, 10.4. --- crates/botloft-core/src/chat.rs | 3 + crates/botloft-core/src/envelope.rs | 29 +- crates/botloft-core/src/ids.rs | 4 + .../migrations/0021_crew_access.sql | 14 + crates/botloft-store/src/crew_access.rs | 127 ++++++++ crates/botloft-store/src/delete.rs | 5 +- crates/botloft-store/src/delete/tests.rs | 1 + crates/botloft-store/src/lib.rs | 2 + crates/botloft-store/src/messages.rs | 10 + crates/botloft-store/src/migrate.rs | 1 + crates/botloftd/src/approvals/mod.rs | 9 +- crates/botloftd/src/chat/events.rs | 1 + crates/botloftd/src/courier/mod.rs | 13 +- crates/botloftd/src/courier/render.rs | 6 + crates/botloftd/src/service/crew_access.rs | 168 ++++++++++ crates/botloftd/src/service/mod.rs | 1 + crates/botloftd/src/service/tasks.rs | 42 ++- crates/botloftd/src/service/tasks/expire.rs | 4 +- crates/botloftd/src/state.rs | 3 + crates/botloftd/src/supervisor/spawn.rs | 1 + crates/botloftd/src/tools/calls.rs | 32 +- crates/botloftd/src/tools/catalog.rs | 22 +- crates/botloftd/src/tools/crew_access.rs | 292 ++++++++++++++++++ crates/botloftd/src/tools/mod.rs | 7 + crates/botloftd/src/workspace/files.rs | 7 +- crates/botloftd/tests/crew_access.rs | 228 ++++++++++++++ crates/botloftd/tests/mcp.rs | 3 +- docs/spec.md | 18 +- 28 files changed, 1029 insertions(+), 24 deletions(-) create mode 100644 crates/botloft-store/migrations/0021_crew_access.sql create mode 100644 crates/botloft-store/src/crew_access.rs create mode 100644 crates/botloftd/src/service/crew_access.rs create mode 100644 crates/botloftd/src/tools/crew_access.rs create mode 100644 crates/botloftd/tests/crew_access.rs diff --git a/crates/botloft-core/src/chat.rs b/crates/botloft-core/src/chat.rs index defd0dd9..67625da9 100644 --- a/crates/botloft-core/src/chat.rs +++ b/crates/botloft-core/src/chat.rs @@ -23,6 +23,8 @@ pub const DELETE_ROUTINE_TOOL: &str = "mcp__botloft__delete_routine"; /// A bot asking to rename or change itself, or the chief another bot /// (spec 10.3). pub const CHANGE_BOT_TOOL: &str = "mcp__botloft__change_bot"; +/// A bot asking to reach another crew (spec 10.4). +pub const CREW_ACCESS_TOOL: &str = "mcp__botloft__ask_crew_access"; /// A bot asking to use its browser on a site (spec 21.5). Not a tool the /// model calls: the daemon opens this request from inside the `browser_*` /// tools. @@ -116,6 +118,7 @@ pub fn tool_summary(name: &str, input: &Value) -> String { SUGGEST_TOOL | ROUTINE_TOOL | CHANGE_ROUTINE_TOOL | DELETE_ROUTINE_TOOL | CHANGE_BOT_TOOL => field(input, "name").map(str::to_owned), BROWSER_SITE_TOOL => field(input, "site").map(str::to_owned), + CREW_ACCESS_TOOL => field(input, "crew").map(str::to_owned), DESKTOP_TOOL => field(input, "app").map(str::to_owned), "mcp__botloft__desktop_look" | "mcp__botloft__desktop_screenshot" => { field(input, "why").map(str::to_owned) diff --git a/crates/botloft-core/src/envelope.rs b/crates/botloft-core/src/envelope.rs index 2570ba07..58cd222d 100644 --- a/crates/botloft-core/src/envelope.rs +++ b/crates/botloft-core/src/envelope.rs @@ -20,6 +20,8 @@ pub enum Sender<'a> { #[derive(Debug, Clone, Copy)] pub struct Envelope<'a> { pub from: Sender<'a>, + /// The sending bot's crew, when it is not the recipient's (spec 10.4). + pub from_crew: Option<&'a str>, /// Display name of the crew. pub crew: &'a str, pub kind: MessageKind, @@ -31,7 +33,11 @@ pub struct Envelope<'a> { impl Envelope<'_> { /// Renders the envelope as of `now_ms`, which sets the relative deadline. pub fn render(&self, now_ms: i64) -> String { - let mut header = format!("[botloft] from {} · crew {}", self.sender(), self.crew); + let mut header = format!("[botloft] from {}", self.sender()); + if let Some(from_crew) = self.from_crew { + header.push_str(&format!(" of crew {from_crew}")); + } + header.push_str(&format!(" · crew {}", self.crew)); if let Some(task) = self.task { let detail = match self.kind { MessageKind::Task => { @@ -47,7 +53,12 @@ impl Envelope<'_> { } let mut text = header; if let Sender::Bot { handle } = self.from { - text.push_str(&format!("\nReply with send_message(to: \"{handle}\").")); + match self.from_crew { + None => text.push_str(&format!("\nReply with send_message(to: \"{handle}\").")), + Some(from_crew) => text.push_str(&format!( + "\nReply with send_message(to: \"{handle}\", crew: \"{from_crew}\")." + )), + } if let (MessageKind::Task, Some(task)) = (self.kind, self.task) { text.push_str(&format!( " When the task is done, call complete_task(task_id: \"{}\").", @@ -225,6 +236,7 @@ mod tests { fn envelope<'a>(from: Sender<'a>, kind: MessageKind, task: Option<&'a Task>) -> Envelope<'a> { Envelope { from, + from_crew: None, crew: "Site", kind, task, @@ -251,6 +263,19 @@ mod tests { ); } + #[test] + fn a_bot_of_another_crew_is_answered_in_its_crew() { + let mut from_blog = envelope(Sender::Bot { handle: "writer" }, MessageKind::Note, None); + from_blog.from_crew = Some("Blog"); + assert_eq!( + from_blog.render(0), + "[botloft] from @writer of crew Blog · crew Site\n\ + Reply with send_message(to: \"writer\", crew: \"Blog\").\n\ + \n\ + Check the build.\nThanks!" + ); + } + #[test] fn owner_notes_have_no_reply_line() { let text = envelope(Sender::Owner, MessageKind::Note, None).render(0); diff --git a/crates/botloft-core/src/ids.rs b/crates/botloft-core/src/ids.rs index 52dccf5b..6afd8414 100644 --- a/crates/botloft-core/src/ids.rs +++ b/crates/botloft-core/src/ids.rs @@ -121,6 +121,10 @@ prefixed_id!( /// (spec 24.2). DesktopGrantId, "desktop grant", "dsk_" ); +prefixed_id!( + /// Identifies what the owner let a bot reach in another crew (spec 10.4). + CrewAccessId, "crew access", "cxa_" +); /// A random version 4 UUID, for Claude Code session ids and the uuid of /// each message written to a bot (spec 7.3, 9.2). diff --git a/crates/botloft-store/migrations/0021_crew_access.sql b/crates/botloft-store/migrations/0021_crew_access.sql new file mode 100644 index 00000000..b0ba4be3 --- /dev/null +++ b/crates/botloft-store/migrations/0021_crew_access.sql @@ -0,0 +1,14 @@ +-- What the owner let a bot reach in another crew for good (spec 10.4): the +-- whole crew, or one of its bots. Access for one turn only stays in memory. +CREATE TABLE crew_access ( + id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL REFERENCES bots (id), + crew_id TEXT NOT NULL REFERENCES crews (id), + target_bot_id TEXT REFERENCES bots (id), + talk INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + +-- One row per bot and reach: the crew, or one bot of it. +CREATE UNIQUE INDEX crew_access_reach + ON crew_access (bot_id, crew_id, coalesce(target_bot_id, '')); diff --git a/crates/botloft-store/src/crew_access.rs b/crates/botloft-store/src/crew_access.rs new file mode 100644 index 00000000..fa46770b --- /dev/null +++ b/crates/botloft-store/src/crew_access.rs @@ -0,0 +1,127 @@ +//! `crew_access` table: what the owner let a bot reach in another crew for +//! good (spec 10.4). + +use botloft_core::ids::{BotId, CrewAccessId, CrewId}; +use rusqlite::{Row, params}; + +use crate::{Result, Store, parse_column}; + +/// A bot's lasting access to another crew, or to one bot of it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CrewAccessRecord { + pub id: CrewAccessId, + /// The bot that asked. + pub bot_id: BotId, + pub crew_id: CrewId, + /// One bot of the crew; `None` for the whole crew. + pub target_bot_id: Option, + /// Talking with its bots: roster, messages and tasks. + pub talk: bool, + pub created_at: i64, +} + +const COLUMNS: &str = "id, bot_id, crew_id, target_bot_id, talk, created_at"; + +fn from_row(row: &Row<'_>) -> rusqlite::Result { + let target: Option = row.get(3)?; + Ok(CrewAccessRecord { + id: parse_column(row, 0)?, + bot_id: parse_column(row, 1)?, + crew_id: parse_column(row, 2)?, + target_bot_id: match target { + Some(_) => Some(parse_column(row, 3)?), + None => None, + }, + talk: row.get(4)?, + created_at: row.get(5)?, + }) +} + +impl Store { + /// Lets `bot` reach `crew`, or only `target` in it. Access already + /// there keeps what it had and gains what is new. + pub fn grant_crew_access( + &self, + bot: &BotId, + crew: &CrewId, + target: Option<&BotId>, + talk: bool, + now: i64, + ) -> Result<()> { + self.conn.execute( + "INSERT INTO crew_access (id, bot_id, crew_id, target_bot_id, talk, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ + ON CONFLICT (bot_id, crew_id, coalesce(target_bot_id, '')) DO UPDATE SET \ + talk = talk OR excluded.talk", + params![ + CrewAccessId::generate().as_str(), + bot.as_str(), + crew.as_str(), + target.map(BotId::as_str), + talk, + now, + ], + )?; + Ok(()) + } + + /// What `bot` may reach in other crews, oldest first. + pub fn crew_access(&self, bot: &BotId) -> Result> { + let mut statement = self.conn.prepare_cached(&format!( + "SELECT {COLUMNS} FROM crew_access WHERE bot_id = ?1 ORDER BY created_at, id" + ))?; + let rows = statement + .query_map([bot.as_str()], from_row)? + .collect::>()?; + Ok(rows) + } + + /// Takes the access back; `false` if there was none with that id. + pub fn revoke_crew_access(&self, id: &CrewAccessId) -> Result { + Ok(self + .conn + .execute("DELETE FROM crew_access WHERE id = ?1", [id.as_str()])? + > 0) + } +} + +#[cfg(test)] +mod tests { + use botloft_core::ids::CrewId; + + use crate::tests::Fixture; + + #[test] + fn access_is_kept_per_bot_and_reach_and_only_grows() { + let fx = Fixture::new(); + let (scout, writer) = (&fx.bots[0].id, &fx.bots[1].id); + let blog = CrewId::generate(); + fx.store + .insert_crew(&botloft_core::protocol::Crew { + id: blog.clone(), + slug: "blog".into(), + ..fx.crew.clone() + }) + .expect("blog"); + fx.store + .grant_crew_access(scout, &blog, Some(writer), true, 10) + .expect("bot"); + fx.store + .grant_crew_access(scout, &blog, None, false, 20) + .expect("crew"); + fx.store + .grant_crew_access(scout, &blog, None, true, 30) + .expect("crew again"); + let access = fx.store.crew_access(scout).expect("list"); + assert_eq!(access.len(), 2); + assert_eq!(access[0].target_bot_id.as_ref(), Some(writer)); + assert!(access[1].target_bot_id.is_none()); + assert!(access[1].talk); + assert_eq!(access[1].created_at, 20); + assert!(fx.store.crew_access(writer).expect("none").is_empty()); + + assert!(fx.store.revoke_crew_access(&access[0].id).expect("revoke")); + assert!(!fx.store.revoke_crew_access(&access[0].id).expect("again")); + assert_eq!(fx.store.crew_access(scout).expect("list").len(), 1); + } +} diff --git a/crates/botloft-store/src/delete.rs b/crates/botloft-store/src/delete.rs index 8a7e6033..7dc5d5aa 100644 --- a/crates/botloft-store/src/delete.rs +++ b/crates/botloft-store/src/delete.rs @@ -20,6 +20,7 @@ const DELETE_BOT: &[&str] = &[ "DELETE FROM chat_items WHERE bot_id = ?1", "DELETE FROM browser_sites WHERE bot_id = ?1", "DELETE FROM desktop_grants WHERE bot_id = ?1", + "DELETE FROM crew_access WHERE bot_id = ?1 OR target_bot_id = ?1", "DELETE FROM allow_rules WHERE bot_id = ?1", "DELETE FROM routine_runs WHERE routine_id IN (SELECT id FROM routines WHERE bot_id = ?1)", // What the bot received goes with it. @@ -74,7 +75,9 @@ impl Store { delete_bot_in(&tx, bot)?; } // Every message and task of a crew is to or for one of its bots, so - // nothing of the crew is left to hold it. + // nothing of the crew is left to hold it. Other bots' access to it + // goes too. + tx.execute("DELETE FROM crew_access WHERE crew_id = ?1", [id.as_str()])?; let deleted = tx.execute("DELETE FROM crews WHERE id = ?1", [id.as_str()])? > 0; tx.commit()?; Ok(deleted) diff --git a/crates/botloft-store/src/delete/tests.rs b/crates/botloft-store/src/delete/tests.rs index 6208aa53..7825aab2 100644 --- a/crates/botloft-store/src/delete/tests.rs +++ b/crates/botloft-store/src/delete/tests.rs @@ -328,6 +328,7 @@ fn a_deleted_crew_takes_its_bots_and_leaves_other_crews_alone() { "browser_sites", "allow_rules", "desktop_grants", + "crew_access", ] { assert_eq!(count(&fx.store, table), 0, "{table}"); } diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index a6a852e3..a9dc26de 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -9,6 +9,7 @@ mod approvals; mod bots; mod browser_sites; mod chat; +mod crew_access; mod crews; mod delete; mod deliveries; @@ -35,6 +36,7 @@ use rusqlite::{Connection, Row, ffi}; pub use approvals::ApprovalRecord; pub use bots::BotRecord; +pub use crew_access::CrewAccessRecord; pub use deliveries::DeliveryOutcome; pub use messages::MessageFilter; pub use migrate::LATEST_VERSION; diff --git a/crates/botloft-store/src/messages.rs b/crates/botloft-store/src/messages.rs index 3ef10d5d..1cf75fca 100644 --- a/crates/botloft-store/src/messages.rs +++ b/crates/botloft-store/src/messages.rs @@ -178,6 +178,16 @@ impl Store { .optional()?) } + /// Whether `from` sent `to` a message at `since` or later. + pub fn wrote_to_since(&self, from: &BotId, to: &BotId, since: i64) -> Result { + Ok(self.conn.query_row( + "SELECT EXISTS (SELECT 1 FROM messages \ + WHERE from_bot_id = ?1 AND to_bot_id = ?2 AND created_at >= ?3)", + params![from.as_str(), to.as_str(), since], + |row| row.get(0), + )?) + } + pub fn message(&self, id: &MessageId) -> Result> { let message = self .conn diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs index fa891767..2ea0366d 100644 --- a/crates/botloft-store/src/migrate.rs +++ b/crates/botloft-store/src/migrate.rs @@ -27,6 +27,7 @@ const MIGRATIONS: &[(u32, &str)] = &[ (18, include_str!("../migrations/0018_message_reply.sql")), (19, include_str!("../migrations/0019_reactions.sql")), (20, include_str!("../migrations/0020_desktop_grants.sql")), + (21, include_str!("../migrations/0021_crew_access.sql")), ]; /// Schema version after every migration has run. diff --git a/crates/botloftd/src/approvals/mod.rs b/crates/botloftd/src/approvals/mod.rs index 3201ab9d..f1523893 100644 --- a/crates/botloftd/src/approvals/mod.rs +++ b/crates/botloftd/src/approvals/mod.rs @@ -10,7 +10,8 @@ use std::collections::HashMap; use std::sync::{Mutex, MutexGuard}; use botloft_core::chat::{ - CHANGE_ROUTINE_TOOL, ROUTINE_TOOL, SUGGEST_TOOL, clip, tool_input_max, tool_summary, + CHANGE_ROUTINE_TOOL, CREW_ACCESS_TOOL, ROUTINE_TOOL, SUGGEST_TOOL, clip, tool_input_max, + tool_summary, }; use botloft_core::command::tool_explanation; use botloft_core::ids::{ApprovalId, BotId, ChatItemId}; @@ -146,7 +147,8 @@ pub fn answer(daemon: &Daemon, params: ApprovalsAnswerParams) -> ApiResult { let value: Value = serde_json::from_str(&input) @@ -159,6 +161,9 @@ pub fn answer(daemon: &Daemon, params: ApprovalsAnswerParams) -> ApiResult Some( crate::tools::check_changed_routine_change(daemon, &existing.approval.input, &input)?, ), + Some(input) if params.allow && existing.approval.tool_name == CREW_ACCESS_TOOL => Some( + crate::tools::check_crew_access_answer(&existing.approval.input, &input)?, + ), _ => None, }; let Some(record) = settle(daemon, &existing, status, note.as_deref(), input.as_deref()) else { diff --git a/crates/botloftd/src/chat/events.rs b/crates/botloftd/src/chat/events.rs index 1063594a..43c7d1e3 100644 --- a/crates/botloftd/src/chat/events.rs +++ b/crates/botloftd/src/chat/events.rs @@ -232,6 +232,7 @@ fn result(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { } routines::turn_ended(daemon, bot, failed); crate::screens::turn_ended(daemon, bot); + daemon.crew_access.end_turn(bot); daemon.supervisor.turn_ended(bot, generation); } diff --git a/crates/botloftd/src/courier/mod.rs b/crates/botloftd/src/courier/mod.rs index 7c0bc6c1..92f8dc07 100644 --- a/crates/botloftd/src/courier/mod.rs +++ b/crates/botloftd/src/courier/mod.rs @@ -209,9 +209,17 @@ fn prepare(daemon: &Daemon, store: &Store, delivery: &Delivery) -> Result None, }; let sender = match (&message.from_kind, &message.from_bot_id) { - (SenderKind::Bot, Some(id)) => store.bot(id)?.map(|sender| sender.handle), + (SenderKind::Bot, Some(id)) => store.bot(id)?, _ => None, }; + // A bot of another crew says which one, to be answered there. + let sender_crew = match &sender { + Some(sender) if sender.crew_id != bot.crew_id => { + store.crew(&sender.crew_id)?.map(|crew| crew.name) + } + _ => None, + }; + let sender = sender.map(|sender| sender.handle); let routine = context::routine(store, &message)?; let question = context::question(store, &message)?; let reactions = store @@ -224,6 +232,7 @@ fn prepare(daemon: &Daemon, store: &Store, delivery: &Delivery) -> Result, + sender_crew: Option, task: Option, workspace: PathBuf, routine: Option, @@ -251,6 +261,7 @@ impl Draft { let context = Context { crew_name: &self.crew_name, sender_handle: self.sender_handle.as_deref(), + sender_crew: self.sender_crew.as_deref(), task: self.task.as_ref(), workspace: &self.workspace, routine: self.routine.as_ref(), diff --git a/crates/botloftd/src/courier/render.rs b/crates/botloftd/src/courier/render.rs index f08fd44a..69d9e886 100644 --- a/crates/botloftd/src/courier/render.rs +++ b/crates/botloftd/src/courier/render.rs @@ -22,6 +22,8 @@ pub(super) struct Context<'a> { pub crew_name: &'a str, /// Handle of the sending bot, for bot messages. pub sender_handle: Option<&'a str>, + /// The sending bot's crew, when it is another one (spec 10.4). + pub sender_crew: Option<&'a str>, pub task: Option<&'a Task>, /// The recipient's workspace, where attachments were saved. pub workspace: &'a Path, @@ -94,6 +96,7 @@ pub(super) fn render(message: &Message, context: &Context<'_>, now: i64) -> Rend fn envelope(message: &Message, context: &Context<'_>, from: Sender<'_>, now: i64) -> String { Envelope { from, + from_crew: context.sender_crew, crew: context.crew_name, kind: message.kind, task: context.task, @@ -174,6 +177,7 @@ mod tests { let context = Context { crew_name: "Ops", sender_handle: Some("scout"), + sender_crew: None, task: None, workspace: dir.path(), routine: None, @@ -201,6 +205,7 @@ mod tests { let context = Context { crew_name: "Ops", sender_handle: None, + sender_crew: None, task: None, workspace: dir.path(), routine: None, @@ -246,6 +251,7 @@ Make it yearly" let context = Context { crew_name: "Ops", sender_handle: None, + sender_crew: None, task: None, workspace: dir.path(), routine: None, diff --git a/crates/botloftd/src/service/crew_access.rs b/crates/botloftd/src/service/crew_access.rs new file mode 100644 index 00000000..f5628c61 --- /dev/null +++ b/crates/botloftd/src/service/crew_access.rs @@ -0,0 +1,168 @@ +//! A bot reaching another crew (spec 10.4). Crews never see each other, +//! except where the owner allowed it: for good, in the `crew_access` +//! table, or for the bot's current turn only, kept here in memory until +//! the turn ends. + +use std::collections::HashMap; +use std::sync::{Mutex, PoisonError}; + +use botloft_core::ids::{BotId, CrewId}; +use botloft_core::protocol::Crew; +use botloft_store::{BotRecord, Store}; + +use super::{ApiError, ApiResult}; +use crate::state::Daemon; + +/// What a bot may reach in another crew: the whole crew, or one bot of it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Reach { + pub crew: CrewId, + pub target: Option, +} + +impl Reach { + fn covers(&self, crew: &CrewId, bot: Option<&BotId>) -> bool { + self.crew == *crew + && match (&self.target, bot) { + (None, _) => true, + (Some(target), Some(bot)) => target == bot, + (Some(_), None) => false, + } + } +} + +/// Access the owner gave "only now": it lasts until the bot's turn ends. +#[derive(Default)] +pub struct TurnAccess { + turns: Mutex>>, +} + +impl TurnAccess { + pub fn allow(&self, bot: &BotId, reach: Reach) { + let mut turns = self.turns.lock().unwrap_or_else(PoisonError::into_inner); + let list = turns.entry(bot.clone()).or_default(); + if !list.contains(&reach) { + list.push(reach); + } + } + + /// The bot's turn ended, or its process did: what it had for the turn + /// goes. + pub fn end_turn(&self, bot: &BotId) { + self.turns + .lock() + .unwrap_or_else(PoisonError::into_inner) + .remove(bot); + } + + fn covers(&self, bot: &BotId, crew: &CrewId, target: Option<&BotId>) -> bool { + self.turns + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(bot) + .is_some_and(|list| list.iter().any(|reach| reach.covers(crew, target))) + } +} + +/// Whether `bot` may talk with `target` of `crew` (or, with `None`, see +/// the crew's roster), for good or for this turn. +pub(crate) fn reaches( + daemon: &Daemon, + store: &Store, + bot: &BotId, + crew: &CrewId, + target: Option<&BotId>, +) -> ApiResult { + if daemon.crew_access.covers(bot, crew, target) { + return Ok(true); + } + Ok(store.crew_access(bot)?.iter().any(|access| { + access.talk + && Reach { + crew: access.crew_id.clone(), + target: access.target_bot_id.clone(), + } + .covers(crew, target) + })) +} + +/// How long a bot may answer a bot of another crew that wrote to it. +pub(crate) const ANSWER_WINDOW_MS: i64 = 24 * 60 * 60 * 1000; + +/// Whether `from` may send to `to`, a bot of another crew: `from` was let +/// in, `to` was let in to `from`, or `to` wrote to `from` in the last day +/// and this is the answer. Bots talk in turns, so the answer usually comes +/// after the turn that was let in has ended. +pub(crate) fn may_talk( + daemon: &Daemon, + store: &Store, + from: &BotRecord, + to: &BotRecord, +) -> ApiResult { + let since = daemon.clock.now_ms() - ANSWER_WINDOW_MS; + Ok(reaches(daemon, store, &from.id, &to.crew_id, Some(&to.id))? + || reaches(daemon, store, &to.id, &from.crew_id, Some(&from.id))? + || store.wrote_to_since(&to.id, &from.id, since)?) +} + +/// Another active crew, by its name or folder name, case aside. The +/// caller's own crew is not "another". +pub(crate) fn other_crew(store: &Store, own: &CrewId, name: &str) -> ApiResult { + let wanted = name.trim().to_lowercase(); + let found = store + .crews(false)? + .into_iter() + .find(|crew| crew.name.to_lowercase() == wanted || crew.slug == wanted); + match found { + Some(crew) if crew.id == *own => Err(ApiError::validation( + "that is your own crew: leave out `crew` to reach its bots", + )), + Some(crew) => Ok(crew), + None => Err(ApiError::NotFound(format!( + "there is no crew called \"{}\"; ask the owner for its exact name", + name.trim() + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_crew_reach_covers_its_bots_and_a_bot_reach_only_that_bot() { + let (crew, other) = (CrewId::generate(), CrewId::generate()); + let (writer, editor) = (BotId::generate(), BotId::generate()); + let whole = Reach { + crew: crew.clone(), + target: None, + }; + assert!(whole.covers(&crew, Some(&writer))); + assert!(whole.covers(&crew, None)); + assert!(!whole.covers(&other, Some(&writer))); + let one = Reach { + crew: crew.clone(), + target: Some(writer.clone()), + }; + assert!(one.covers(&crew, Some(&writer))); + assert!(!one.covers(&crew, Some(&editor))); + assert!(!one.covers(&crew, None)); + } + + #[test] + fn access_for_the_turn_goes_when_it_ends() { + let access = TurnAccess::default(); + let (bot, crew) = (BotId::generate(), CrewId::generate()); + access.allow( + &bot, + Reach { + crew: crew.clone(), + target: None, + }, + ); + assert!(access.covers(&bot, &crew, None)); + assert!(!access.covers(&BotId::generate(), &crew, None)); + access.end_turn(&bot); + assert!(!access.covers(&bot, &crew, None)); + } +} diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index dfb1150f..1ec74072 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -5,6 +5,7 @@ pub mod archive; pub mod attachments; pub mod bots; pub mod chat; +pub mod crew_access; pub mod crews; pub mod delete; pub mod deliveries; diff --git a/crates/botloftd/src/service/tasks.rs b/crates/botloftd/src/service/tasks.rs index 1ab159f1..aff24fa4 100644 --- a/crates/botloftd/src/service/tasks.rs +++ b/crates/botloftd/src/service/tasks.rs @@ -14,7 +14,7 @@ use botloft_store::{BotRecord, Store, TaskFilter}; pub(crate) use self::expire::expire_overdue; use super::messages::{announce, pending_delivery, post}; -use super::{ApiError, ApiResult, bots, crews}; +use super::{ApiError, ApiResult, bots, crew_access, crews}; use crate::clock; use crate::config::Config; use crate::state::Daemon; @@ -45,8 +45,11 @@ impl TaskSettings { /// `send_message` as a bot calls it. #[derive(Debug, Clone)] pub struct BotMessage { - /// Handle of a bot in the sender's crew, with or without `@`. + /// Handle of a bot in the sender's crew, with or without `@`, or in + /// `crew`. pub to: String, + /// Another crew the owner let the sender reach (spec 10.4). + pub crew: Option, pub body: String, pub task: bool, pub deadline_minutes: Option, @@ -65,8 +68,10 @@ pub fn list(daemon: &Daemon, params: TasksListParams) -> ApiResult> { })?) } -/// A note or a task from one bot to another of its crew. A task started -/// while the sender works on another one continues that chain (spec 9.4). +/// A note or a task from one bot to another of its crew, or of a crew the +/// owner let it reach (spec 10.4); the message and the task belong to the +/// recipient's crew. A task started while the sender works on another one +/// continues that chain (spec 9.4). pub fn send( daemon: &Daemon, sender: &BotId, @@ -74,8 +79,29 @@ pub fn send( ) -> ApiResult<(Message, Option)> { let body = validate::message("body", &request.body)?; let store = daemon.store(); - let (crew, from) = bots::active(&store, sender)?; - let to = recipient(&store, &crew, &from, &request.to)?; + let (own, from) = bots::active(&store, sender)?; + let (crew, to) = match &request.crew { + None => { + let to = recipient(&store, &own, &from, &request.to)?; + (own, to) + } + Some(name) => { + let other = crew_access::other_crew(&store, &own.id, name)?; + let to = recipient(&store, &other, &from, &request.to) + .ok() + .filter(|to| crew_access::may_talk(daemon, &store, &from, to).unwrap_or(false)); + // Missing or out of reach read the same. + let to = to.ok_or_else(|| { + ApiError::Conflict(format!( + "you cannot reach @{} in the crew {}; if the owner wants you to, call \ + ask_crew_access with what you need and why", + request.to.trim().trim_start_matches('@'), + other.name + )) + })?; + (other, to) + } + }; let now = daemon.clock.now_ms(); let task = if request.task { Some(new_task( @@ -220,9 +246,11 @@ pub fn complete( ))); } let now = daemon.clock.now_ms(); + // The requester may be in another crew (spec 10.4): the report is in its. + let requester = bots::find(&store, &task.requester_bot_id)?; let report = Message { id: MessageId::generate(), - crew_id: crew.id, + crew_id: requester.crew_id, from_kind: SenderKind::Bot, from_bot_id: Some(me.id), to_bot_id: task.requester_bot_id.clone(), diff --git a/crates/botloftd/src/service/tasks/expire.rs b/crates/botloftd/src/service/tasks/expire.rs index cc1c9bb5..fcc66e0f 100644 --- a/crates/botloftd/src/service/tasks/expire.rs +++ b/crates/botloftd/src/service/tasks/expire.rs @@ -28,9 +28,11 @@ pub(crate) fn expire_overdue(daemon: &Daemon, store: &Store, now: i64) { fn expire(daemon: &Daemon, store: &Store, task: &Task, now: i64) -> ApiResult<()> { let assignee = bots::find(store, &task.assignee_bot_id)?.handle; + // The requester may be in another crew (spec 10.4): the notice is in its. + let requester = bots::find(store, &task.requester_bot_id)?; let notice = Message { id: MessageId::generate(), - crew_id: task.crew_id.clone(), + crew_id: requester.crew_id, from_kind: SenderKind::System, from_bot_id: None, to_bot_id: task.requester_bot_id.clone(), diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs index 827792db..70d64df4 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -116,6 +116,8 @@ pub struct Daemon { pub browsers: Browsers, pub screens: Screens, pub desktop: Desktop, + /// Other crews a bot may reach for its current turn (spec 10.4). + pub crew_access: crate::service::crew_access::TurnAccess, pub trash: Arc, pub contexts: Contexts, /// Time for everything stored or compared with stored times. @@ -154,6 +156,7 @@ impl Daemon { ), screens: Screens::default(), desktop: Desktop::new(options.owner_idle), + crew_access: Default::default(), trash: options.trash, contexts: Contexts::default(), clock: options.clock, diff --git a/crates/botloftd/src/supervisor/spawn.rs b/crates/botloftd/src/supervisor/spawn.rs index 7873d5ef..eb64fd5f 100644 --- a/crates/botloftd/src/supervisor/spawn.rs +++ b/crates/botloftd/src/supervisor/spawn.rs @@ -88,6 +88,7 @@ impl Supervisor { routines::process_ended(daemon, bot); context::process_ended(daemon, bot); crate::screens::turn_ended(daemon, bot); + daemon.crew_access.end_turn(bot); self.wake(); } } diff --git a/crates/botloftd/src/tools/calls.rs b/crates/botloftd/src/tools/calls.rs index b77bf465..afb89fd3 100644 --- a/crates/botloftd/src/tools/calls.rs +++ b/crates/botloftd/src/tools/calls.rs @@ -36,7 +36,10 @@ pub(super) fn call(daemon: &Daemon, bot: &BotId, params: &Value) -> Result roster(daemon, bot), + CREW_ROSTER => parse(arguments).and_then(|args: RosterArgs| match args.crew { + Some(crew) => super::crew_access::roster(daemon, bot, &crew).map_err(explain), + None => roster(daemon, bot), + }), SEND_MESSAGE => parse(arguments).and_then(|args| send(daemon, bot, args)), COMPLETE_TASK => parse(arguments).and_then(|args| complete(daemon, bot, args)), MY_TASKS => parse(arguments).and_then(|args| my_tasks(daemon, bot, args)), @@ -84,6 +87,13 @@ pub(super) fn explain(err: ApiError) -> String { } } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RosterArgs { + #[serde(default)] + crew: Option, +} + fn roster(daemon: &Daemon, bot: &BotId) -> Outcome { let (crew, me) = bots::active(&daemon.store(), bot).map_err(explain)?; let list = BotsListParams { @@ -116,6 +126,8 @@ fn roster(daemon: &Daemon, bot: &BotId) -> Outcome { #[serde(deny_unknown_fields)] struct SendArgs { to: String, + #[serde(default)] + crew: Option, body: String, #[serde(default)] kind: Option, @@ -134,6 +146,7 @@ fn send(daemon: &Daemon, bot: &BotId, args: SendArgs) -> Outcome { } let request = BotMessage { to: args.to, + crew: args.crew, body: args.body, task, deadline_minutes: args.deadline_minutes, @@ -184,6 +197,17 @@ fn complete(daemon: &Daemon, bot: &BotId, args: CompleteArgs) -> Outcome { })) } +/// "writer of crew Blog", for a bot outside the caller's crew. +fn elsewhere(store: &botloft_store::Store, id: &BotId) -> String { + let Ok(Some(bot)) = store.bot(id) else { + return String::new(); + }; + match store.crew(&bot.crew_id) { + Ok(Some(crew)) => format!("{} of crew {}", bot.handle, crew.name), + _ => bot.handle, + } +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct MyTasksArgs { @@ -209,7 +233,11 @@ fn my_tasks(daemon: &Daemon, bot: &BotId, args: MyTasksArgs) -> Outcome { .collect(); let now = daemon.clock.now_ms(); let describe = |task: &Task| -> Result { - let handle = |id: &BotId| handles.get(id).cloned().unwrap_or_default(); + // A bot of another crew (spec 10.4) is named with its crew. + let handle = |id: &BotId| match handles.get(id) { + Some(handle) => handle.clone(), + None => elsewhere(&store, id), + }; let request = store .task_request(&task.id) .map_err(|err| explain(err.into()))? diff --git a/crates/botloftd/src/tools/catalog.rs b/crates/botloftd/src/tools/catalog.rs index 100f2ece..3f35dbce 100644 --- a/crates/botloftd/src/tools/catalog.rs +++ b/crates/botloftd/src/tools/catalog.rs @@ -31,7 +31,7 @@ pub fn tools() -> Value { ] .into_iter() .chain(super::routine_catalog::tools()) - .chain([super::bot_change::tool()]) + .chain([super::bot_change::tool(), super::crew_access::tool()]) .chain(super::browser_catalog::tools()) .chain(super::desktop_catalog::tools()); list.splice(at..at, more); @@ -56,8 +56,18 @@ fn crew_tools() -> Value { "title": "Crew roster", "description": "Lists the other bots of your crew with their handle, name, role and \ current state (idle, busy, offline...). Use it to find who can help and the \ - handle to send a message to.", - "inputSchema": { "type": "object", "additionalProperties": false }, + handle to send a message to. With `crew`, the bots of another crew you were \ + let in to (ask_crew_access).", + "inputSchema": { + "type": "object", + "properties": { + "crew": { + "type": "string", + "description": "Another crew's name, only once the owner let you reach it.", + }, + }, + "additionalProperties": false, + }, "annotations": { "readOnlyHint": true }, }, { @@ -74,6 +84,12 @@ fn crew_tools() -> Value { "type": "string", "description": "Handle of the bot, as crew_roster shows it (\"revisor\" or \"@revisor\").", }, + "crew": { + "type": "string", + "description": "Another crew's name, for a bot of a crew the owner let you \ + reach (ask_crew_access), or to answer a bot of another crew that wrote \ + to you. Leave it out for your own crew.", + }, "body": { "type": "string", "description": "The message. Write it so it makes sense on its own.", diff --git a/crates/botloftd/src/tools/crew_access.rs b/crates/botloftd/src/tools/crew_access.rs new file mode 100644 index 00000000..8892a6ad --- /dev/null +++ b/crates/botloftd/src/tools/crew_access.rs @@ -0,0 +1,292 @@ +//! `ask_crew_access` (spec 10.4): a bot asks the owner to reach another +//! crew, or one bot of it, saying what for. The owner allows it only now +//! (until the bot's turn ends), always for that bot, or always for the +//! whole crew, or declines; the call waits, like a permission request. A +//! bot that bypasses permissions gets it for the turn at once. With +//! access, `crew_roster` and `send_message` take `crew`. + +use botloft_core::chat::CREW_ACCESS_TOOL; +use botloft_core::ids::BotId; +use botloft_core::protocol::{BotsListParams, PermissionMode}; +use botloft_core::slug; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::calls::{explain, parse, tool_result}; +use super::suggest::REASON_MAX; +use crate::approvals::{self, Answer}; +use crate::service::crew_access::{self, Reach}; +use crate::service::{ApiError, ApiResult, bots, lead}; +use crate::state::Daemon; + +pub const ASK_CREW_ACCESS: &str = "ask_crew_access"; + +/// What a bot may ask for in another crew. Only talking, for now. +const KINDS: [&str; 1] = ["talk"]; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Args { + crew: String, + bot: Option, + access: Vec, + why: String, +} + +/// How long the owner allowed it, as `approvals.answer` sends it back. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +enum Scope { + /// Until the bot's turn ends. + Once, + /// For good, for the bot asked about. + Bot, + /// For good, for every bot of the crew. + Crew, +} + +#[derive(Debug, Deserialize)] +struct Answered { + scope: Scope, +} + +pub(super) fn tool() -> Value { + json!({ + "name": ASK_CREW_ACCESS, + "title": "Ask to reach another crew", + "description": "Your crew cannot see or reach other crews. When the owner wants you to \ + work with another crew, or one of its bots, call this with the crew's name as the \ + owner gave it, the bot's handle if it is one bot, what you need (`talk`: see its \ + bots and send them messages and tasks) and why. The owner allows it only for now, \ + always for that bot, or always for the whole crew, or declines; the call waits for \ + that. Then use crew_roster and send_message with `crew`.", + "inputSchema": { + "type": "object", + "properties": { + "crew": { "type": "string", "description": "The other crew's name." }, + "bot": { + "type": "string", + "description": "Handle of one bot of that crew, when you need only it.", + }, + "access": { + "type": "array", + "items": { "type": "string", "enum": KINDS }, + "minItems": 1, + "description": "What you need: `talk`.", + }, + "why": { + "type": "string", + "maxLength": REASON_MAX, + "description": "For the owner: what you will do there and why, in a sentence \ + or two, in the owner's language.", + }, + }, + "required": ["crew", "access", "why"], + "additionalProperties": false, + }, + }) +} + +pub(super) async fn ask(daemon: &Daemon, bot: &BotId, generation: u64, args: Value) -> Value { + tool_result(run(daemon, bot, generation, args).await) +} + +async fn run(daemon: &Daemon, me: &BotId, generation: u64, args: Value) -> Result { + let args: Args = parse(args)?; + if args.access.is_empty() + || args + .access + .iter() + .any(|kind| !KINDS.contains(&kind.as_str())) + { + return Err("access must list what you need: \"talk\"".to_owned()); + } + let why = args.why.trim(); + if why.is_empty() || why.chars().count() > REASON_MAX { + return Err(format!( + "why must say, in at most {REASON_MAX} characters, what you will do and why" + )); + } + let (crew, target, mode) = { + let store = daemon.store(); + let (own, record) = bots::active(&store, me).map_err(explain)?; + let crew = crew_access::other_crew(&store, &own.id, &args.crew).map_err(explain)?; + let target = match args.bot.as_deref() { + None => None, + Some(handle) => Some(find(&store, &crew, handle).map_err(explain)?), + }; + let already = + crew_access::reaches(daemon, &store, me, &crew.id, target.as_ref().map(|t| &t.id)) + .map_err(explain)?; + if already { + return Ok(granted( + &crew.name, + target.as_ref().map(|t| t.handle.as_str()), + "You can already reach it.", + )); + } + (crew, target, record.permission_mode) + }; + let reach = Reach { + crew: crew.id.clone(), + target: target.as_ref().map(|t| t.id.clone()), + }; + let handle = target.as_ref().map(|t| t.handle.as_str()); + if mode == PermissionMode::BypassPermissions { + daemon.crew_access.allow(me, reach); + return Ok(granted( + &crew.name, + handle, + "You bypass permissions, so you have it for this turn without asking.", + )); + } + let input = json!({ + "crew_id": crew.id, + "crew": crew.name, + "bot_id": target.as_ref().map(|t| &t.id), + "bot": target.as_ref().map(|t| &t.name), + "handle": handle, + "access": args.access, + "why": why, + }); + match approvals::ask(daemon, me, generation, CREW_ACCESS_TOOL, &input, "").await { + Some(Answer::Allowed { input }) => { + let scope = match input { + Some(answer) => { + serde_json::from_str::(&answer) + .map_err(|err| format!("the owner's answer could not be read: {err}"))? + .scope + } + None => Scope::Once, + }; + let now = daemon.clock.now_ms(); + let how = match scope { + Scope::Once => { + daemon.crew_access.allow(me, reach); + "The owner allowed it for this turn only." + } + Scope::Bot | Scope::Crew => { + let target = if scope == Scope::Bot { + reach.target.as_ref() + } else { + None + }; + daemon + .store() + .grant_crew_access(me, &crew.id, target, true, now) + .map_err(|err| explain(err.into()))?; + "The owner allowed it for good." + } + }; + let whole = scope == Scope::Crew; + Ok(granted(&crew.name, if whole { None } else { handle }, how)) + } + Some(Answer::Denied { note }) => { + let why = note.map_or_else(String::new, |note| format!(" They said: {note}")); + Ok(json!({ + "allowed": false, + "note": format!( + "The owner did not let you reach {}.{why} Do without it.", + crew.name + ), + })) + } + Some(Answer::Expired) => Ok(json!({ + "allowed": false, + "note": "The owner did not answer in time. Tell the owner, and ask again later if it \ + still helps.", + })), + None => Err("Botloft could not ask the owner.".to_owned()), + } +} + +fn find( + store: &botloft_store::Store, + crew: &botloft_core::protocol::Crew, + handle: &str, +) -> ApiResult { + let handle = slug::slugify(handle.trim().trim_start_matches('@'), ""); + let found = match handle.as_str() { + "" => None, + handle => store.active_bot_by_handle(&crew.id, handle)?, + }; + let id = found.ok_or_else(|| { + ApiError::NotFound(format!( + "no bot of the crew {} answers to @{handle}; ask the owner for its name", + crew.name + )) + })?; + bots::find(store, &id) +} + +fn granted(crew: &str, handle: Option<&str>, how: &str) -> Value { + let whom = handle.map_or_else( + || format!("send_message(to: , crew: \"{crew}\")"), + |handle| format!("send_message(to: \"{handle}\", crew: \"{crew}\")"), + ); + json!({ + "allowed": true, + "crew": crew, + "bot": handle, + "note": format!( + "{how} See who you can reach with crew_roster(crew: \"{crew}\") and write with \ + {whom}. Tell them who you are and what you need: they do not know you." + ), + }) +} + +/// `crew_roster` with `crew`: the bots of another crew the caller may reach. +pub(super) fn roster(daemon: &Daemon, me: &BotId, name: &str) -> ApiResult { + let (crew, mine) = { + let store = daemon.store(); + let (own, mine) = bots::active(&store, me)?; + (crew_access::other_crew(&store, &own.id, name)?, mine) + }; + let list = bots::list( + daemon, + BotsListParams { + crew_id: Some(crew.id.clone()), + }, + )?; + let store = daemon.store(); + let mut reachable = Vec::new(); + for bot in list { + let record = bots::find(&store, &bot.id)?; + if crew_access::may_talk(daemon, &store, &mine, &record)? { + reachable.push(json!({ + "handle": bot.handle, + "name": bot.name, + "role": bot.role, + "state": bot.state, + "chief": lead::is_lead(&crew, &bot.id), + })); + } + } + if reachable.is_empty() { + return Err(ApiError::Conflict(format!( + "you cannot reach the crew {}; if the owner wants you to, call ask_crew_access with \ + what you need and why", + crew.name + ))); + } + Ok(json!({ + "crew": crew.name, + "bots": reachable, + "note": format!("Write with send_message(to, crew: \"{}\").", crew.name), + })) +} + +/// The owner's answer to a request (`approvals.answer` with `input`): how +/// long, checked before the request is settled. "This bot" needs a bot. +pub(crate) fn check_answer(asked: &str, answer: &str) -> ApiResult { + let answered: Answered = serde_json::from_str(answer) + .map_err(|_| ApiError::validation("scope must be \"once\", \"bot\" or \"crew\""))?; + let asked: Value = serde_json::from_str(asked) + .map_err(|_| ApiError::validation("the request could not be read"))?; + if answered.scope == Scope::Bot && asked["bot_id"].is_null() { + return Err(ApiError::validation( + "the bot asked for the whole crew: allow it now or for the crew", + )); + } + Ok(answer.to_owned()) +} diff --git a/crates/botloftd/src/tools/mod.rs b/crates/botloftd/src/tools/mod.rs index f691f832..3d841650 100644 --- a/crates/botloftd/src/tools/mod.rs +++ b/crates/botloftd/src/tools/mod.rs @@ -15,6 +15,7 @@ mod browser_reply; mod browser_sites; mod calls; mod catalog; +mod crew_access; mod desktop; mod desktop_act; mod desktop_catalog; @@ -42,6 +43,7 @@ use botloft_core::protocol::error_code; use serde_json::{Value, json}; use tracing::debug; +pub(crate) use self::crew_access::check_answer as check_crew_access_answer; use self::era::{Era, LEGACY, era, supported}; pub(crate) use self::routine::check_changed as check_changed_routine; pub(crate) use self::routine_change::check_changed as check_changed_routine_change; @@ -158,6 +160,11 @@ pub async fn handle( let result = routine_change::delete(&daemon, &bot, generation, args).await; Ok(decorate(era, &request, result)) } + Ok(era) if called(&request) == Some(crew_access::ASK_CREW_ACCESS) => { + let args = arguments(&request); + let result = crew_access::ask(&daemon, &bot, generation, args).await; + Ok(decorate(era, &request, result)) + } Ok(era) if called(&request) == Some(bot_change::CHANGE_BOT) => { let args = arguments(&request); let result = bot_change::change(&daemon, &bot, generation, args).await; diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs index 913c8498..c0216516 100644 --- a/crates/botloftd/src/workspace/files.rs +++ b/crates/botloftd/src/workspace/files.rs @@ -170,7 +170,11 @@ app; the other bots of your crew send you messages too. - `send_signal`: when something a routine waits for is done (`crew_roster` lists the signals), send that signal; those routines run. - Never call `permission_prompt` yourself; Claude Code uses it for approvals. -- You can only reach bots of your own crew. +- You can only reach bots of your own crew. Other crews are closed to you: + only when the owner asks you to work with one of them, or with one of its + bots, call `ask_crew_access` with its name, what you need and why. Once + the owner allows it, `crew_roster` and `send_message` take `crew`. A bot + of another crew that writes to you can be answered the same way. - A task you receive ends with `complete_task`, also when you could not do it (`status: \"failed\"` and why): the bot that asked is waiting for it. - Do not wait in a loop for a reply. Finish your turn; the reply arrives as a @@ -297,6 +301,7 @@ mod tests { assert!(rules.contains("`schedule_routine`")); assert!(rules.contains("`change_routine` or `delete_routine`")); assert!(rules.contains("call `change_bot` with just what changes")); + assert!(rules.contains("call `ask_crew_access` with its name")); assert!(rules.contains(r#""#)); assert!(!rules.contains("You lead this crew")); diff --git a/crates/botloftd/tests/crew_access.rs b/crates/botloftd/tests/crew_access.rs new file mode 100644 index 00000000..59e96007 --- /dev/null +++ b/crates/botloftd/tests/crew_access.rs @@ -0,0 +1,228 @@ +//! A bot reaching another crew (spec 10.4): closed until the owner allows +//! it, only now, always for one bot or always for the whole crew; then +//! `crew_roster` and `send_message` take `crew`, and the other side can +//! answer. + +mod common; + +use botloft_core::ids::BotId; +use common::bots::{ready_bot, text_of}; +use common::mcp::Mcp; +use common::{Client, TestDaemon}; +use serde_json::{Value, json}; +use tokio::task::JoinHandle; + +const TOOL: &str = "mcp__botloft__ask_crew_access"; + +struct Crews { + t: TestDaemon, + app: Client, + ops: Value, + scout: Value, + scout_mcp: Mcp, + writer_mcp: Mcp, + writer_process: botloftd::runtime::fake::FakeProcess, +} + +/// "Ops" with @scout; "Blog" with @writer and @editor. +async fn crews() -> Crews { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let ops = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("ops"); + let blog = app + .call("crews.create", json!({ "name": "Blog" })) + .await + .expect("blog"); + let (scout, _, scout_mcp) = ready_bot(&t, &mut app, &ops, "Scout").await; + let (_, writer_process, writer_mcp) = ready_bot(&t, &mut app, &blog, "Writer").await; + ready_bot(&t, &mut app, &blog, "Editor").await; + Crews { + t, + app, + ops, + scout, + scout_mcp, + writer_mcp, + writer_process, + } +} + +fn ask(mcp: &Mcp, arguments: Value) -> JoinHandle> { + let mut mcp = Mcp::new(mcp.addr, mcp.token.clone()); + tokio::spawn(async move { mcp.tool("ask_crew_access", arguments).await }) +} + +async fn pending(app: &mut Client) -> Value { + loop { + let changed = app.notification("chat.item").await; + let body = &changed["item"]["body"]; + if body["kind"] == "approval" && body["status"] == "pending" { + assert_eq!(body["toolName"], TOOL); + return body.clone(); + } + } +} + +async fn answer(app: &mut Client, asked: &Value, scope: Option<&str>) { + let mut params = json!({ "approvalId": asked["approvalId"], "allow": true }); + if let Some(scope) = scope { + params["input"] = json!(json!({ "scope": scope }).to_string()); + } + app.call("approvals.answer", params).await.expect("answer"); +} + +fn note(to: &str, body: &str) -> Value { + json!({ "to": to, "crew": "Blog", "body": body }) +} + +#[tokio::test] +async fn another_crew_is_closed_until_the_owner_lets_a_bot_in() { + let mut c = crews().await; + let roster = c + .scout_mcp + .tool("crew_roster", json!({ "crew": "Blog" })) + .await + .expect_err("closed"); + assert!(roster.contains("ask_crew_access"), "{roster}"); + // A bot there and a bot that is not read the same. + for to in ["writer", "nobody"] { + let refused = c + .scout_mcp + .tool("send_message", note(to, "Hi")) + .await + .expect_err("closed"); + assert!(refused.contains("cannot reach"), "{refused}"); + } + let own = c + .scout_mcp + .tool("crew_roster", json!({ "crew": "ops" })) + .await + .expect_err("own crew"); + assert!(own.contains("your own crew"), "{own}"); + + // Allowed only now, for @writer. + let waiting = ask( + &c.scout_mcp, + json!({ "crew": "blog", "bot": "@writer", "access": ["talk"], "why": "To get the post." }), + ); + let asked = pending(&mut c.app).await; + assert_eq!(asked["summary"], "Blog"); + let input: Value = serde_json::from_str(asked["input"].as_str().expect("input")).expect("json"); + assert_eq!(input["handle"], "writer"); + assert_eq!(input["why"], "To get the post."); + answer(&mut c.app, &asked, None).await; + let allowed = waiting.await.expect("task").expect("allowed"); + assert_eq!(allowed["allowed"], true); + + let roster = c + .scout_mcp + .tool("crew_roster", json!({ "crew": "Blog" })) + .await + .expect("roster"); + let handles: Vec<&str> = roster["bots"] + .as_array() + .expect("bots") + .iter() + .filter_map(|bot| bot["handle"].as_str()) + .collect(); + assert_eq!(handles, ["writer"]); + c.scout_mcp + .tool("send_message", note("writer", "Send me the post.")) + .await + .expect("sent"); + let lines = c.writer_process.wait_lines(1).await; + let text = text_of(&lines[0]); + assert!( + text.starts_with("[botloft] from @scout of crew Ops · crew Blog"), + "{text}" + ); + assert!( + text.contains("send_message(to: \"scout\", crew: \"Ops\")"), + "{text}" + ); + + // The writer answers without being let in itself. + c.writer_mcp + .tool( + "send_message", + json!({ "to": "scout", "crew": "Ops", "body": "Here it is." }), + ) + .await + .expect("answer"); + + // The turn ends: what was allowed only now goes. + let scout: BotId = c.scout["id"].as_str().expect("id").parse().expect("bot id"); + c.t.daemon.crew_access.end_turn(&scout); + let refused = c + .scout_mcp + .tool("send_message", note("editor", "Hi")) + .await + .expect_err("not let in"); + assert!(refused.contains("cannot reach"), "{refused}"); + + // Messages between the crews do not keep a crew from being deleted. + c.app + .call("crews.delete", json!({ "crewId": c.ops["id"] })) + .await + .expect("delete"); +} + +#[tokio::test] +async fn always_for_the_crew_lasts_and_a_task_comes_back() { + let mut c = crews().await; + // "This bot" needs a bot to be about. + let waiting = ask( + &c.scout_mcp, + json!({ "crew": "Blog", "access": ["talk"], "why": "Weekly report." }), + ); + let asked = pending(&mut c.app).await; + let wrong = json!({ "approvalId": asked["approvalId"], "allow": true, + "input": json!({ "scope": "bot" }).to_string() }); + assert!(c.app.call("approvals.answer", wrong).await.is_err()); + answer(&mut c.app, &asked, Some("crew")).await; + waiting.await.expect("task").expect("allowed"); + + let scout: BotId = c.scout["id"].as_str().expect("id").parse().expect("bot id"); + c.t.daemon.crew_access.end_turn(&scout); + let roster = c + .scout_mcp + .tool("crew_roster", json!({ "crew": "Blog" })) + .await + .expect("roster"); + assert_eq!(roster["bots"].as_array().map(Vec::len), Some(2)); + + let sent = c + .scout_mcp + .tool( + "send_message", + json!({ "to": "writer", "crew": "Blog", "body": "Write it.", "kind": "task" }), + ) + .await + .expect("task"); + let mine = c + .writer_mcp + .tool("my_tasks", json!({ "role": "assigned" })) + .await + .expect("tasks"); + assert_eq!(mine["assigned"][0]["from"], "scout of crew Ops"); + c.writer_mcp + .tool( + "complete_task", + json!({ "task_id": sent["task_id"], "result": "Done." }), + ) + .await + .expect("done"); + + // Asking again for what it has needs no owner. + let again = ask( + &c.scout_mcp, + json!({ "crew": "Blog", "bot": "editor", "access": ["talk"], "why": "Again." }), + ) + .await + .expect("task") + .expect("already"); + assert!(again["note"].as_str().expect("note").contains("already")); +} diff --git a/crates/botloftd/tests/mcp.rs b/crates/botloftd/tests/mcp.rs index 77270224..c692ce82 100644 --- a/crates/botloftd/tests/mcp.rs +++ b/crates/botloftd/tests/mcp.rs @@ -80,6 +80,7 @@ async fn claude_code_discovers_the_server_and_lists_the_tools() { "change_routine", "delete_routine", "change_bot", + "ask_crew_access", "browser_open", "browser_look", "browser_click", @@ -144,7 +145,7 @@ async fn earlier_revisions_start_with_initialize() { let listed = mcp::post(t.addr, &legacy, &list).await; assert_eq!( listed.body["result"]["tools"].as_array().map(Vec::len), - Some(34) + Some(35) ); let unknown = json!({ "jsonrpc": "2.0", "id": 2, "method": "prompts/list" }).to_string(); let unknown = mcp::post(t.addr, &legacy, &unknown).await; diff --git a/docs/spec.md b/docs/spec.md index c73b7ebb..0620d474 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -508,6 +508,7 @@ Reply with send_message(to: "revisor"). When the task is done, call complete_tas ``` - Nota de outro bot: primeira linha `[botloft] from @revisor · crew Exemplo` e só a instrução de resposta. +- Bot de outra crew (10.4): `[botloft] from @writer of crew Blog · crew Exemplo`, e a instrução de resposta leva a crew: `send_message(to: "writer", crew: "Blog")`. - Resultado de task: `· result of task tsk_... · done` (ou `failed`) e a instrução de resposta. - Aviso do daemon (task vencida, task de um bot excluído): `from Botloft` e o texto do aviso, sem instrução. - Message de um bot que o dono excluiu enquanto ela esperava na fila (7.6): `from a deleted bot`, sem instrução de resposta, porque não há a quem responder. @@ -555,12 +556,13 @@ Transporte: Streamable HTTP, só POST e resposta `application/json`, sem sessão | Tool | Entrada | Saída (JSON em texto) | |---|---|---| -| `crew_roster` | nenhuma | `crew`, `you`, `you_lead`, os outros bots da crew (handle, nome, papel, estado e `chief`) e `signals`, os avisos que as rotinas da crew esperam (20.13) | -| `send_message` | `to` (handle, com ou sem `@`), `body`, `kind?` (`note` padrão, `task`), `deadline_minutes?` (só task) | `message_id`, `task_id` e `due` (task), e um lembrete de que a resposta chega depois | +| `crew_roster` | `crew?` (outra crew, 10.4) | `crew`, `you`, `you_lead`, os outros bots da crew (handle, nome, papel, estado e `chief`) e `signals`, os avisos que as rotinas da crew esperam (20.13). Com `crew`: o nome dela e só os bots que o bot alcança lá | +| `send_message` | `to` (handle, com ou sem `@`), `crew?` (outra crew, 10.4), `body`, `kind?` (`note` padrão, `task`), `deadline_minutes?` (só task) | `message_id`, `task_id` e `due` (task), e um lembrete de que a resposta chega depois | | `complete_task` | `task_id`, `result`, `status?` (`done` padrão, `failed`) | `task_id`, `status` e quem recebe o resultado | | `my_tasks` | `role?` (`assigned`, `requested`) | tasks `open` e `expired`: id, de, para, status, prazo relativo, hops e o pedido original | | `suggest_bot` | `name`, `role`, `instructions` (até 8 000 caracteres), `model?`, `reason` (até 1 000) | `created` e, criado, `handle`, `name`, `role`, `model` e um lembrete para mandar a primeira task; recusado ou sem resposta, o porquê (10.2). Só o chefe | | `schedule_routine` | `name`, `prompt` (até 8 000 caracteres), `schedule` (como em 20.2), `bot?` (handle de outro bot da crew) | `created` e, criada, `routine_id`, nome, pedido, horário, fuso e a próxima vez; recusada ou sem resposta, o aviso de que nada foi agendado (20.12) | +| `ask_crew_access` | `crew` (nome da outra crew), `bot?` (handle de um bot dela), `access` (`["talk"]`), `why` (até 1 000) | `allowed` e, permitido, como usar; recusado ou sem resposta, o porquê (10.4) | | `change_bot` | `bot?` (handle de outro bot da crew, só o chefe), `name?`, `role?`, `instructions?` (até 8 000 caracteres), `reason?` (até 1 000) | `done` e, mudado, `handle`, `name`, `role` e quando vale; recusado ou sem resposta, o aviso de que nada mudou (10.3) | | `share_file` | `files` (1 a 10 caminhos, absolutos ou relativos à pasta do bot) | `shown` (um `BotFile` por arquivo, 8.4) e um lembrete de que o dono vê cada um como cartão no chat; um arquivo fora das pastas do bot e não escrito por ele, ou que não existe, recusa a chamada inteira e diz qual e por quê | | `send_signal` | `name`, `note?` (até 2 000 caracteres) | o aviso normalizado e, para cada rotina da crew que o espera, se rodou e por que não (20.13) | @@ -571,7 +573,7 @@ Transporte: Streamable HTTP, só POST e resposta `application/json`, sem sessão - Erro que o modelo pode corrigir (handle desconhecido, argumento inválido, limite de hops, task de outro bot) volta como resultado com `isError: true` e uma frase explicando. Só tool desconhecida ou chamada malformada vira erro JSON-RPC (`-32602`). - Erro interno não expõe detalhes ao bot; vai para o log. -Endereçamento só dentro da crew. Bot não enxerga bots nem tasks de outras crews. +Endereçamento só dentro da crew. Bot não enxerga bots nem tasks de outras crews, a menos que o dono deixe (10.4). ### 10.1 Aprovações @@ -629,6 +631,16 @@ Quando o dono pede, um bot pode mudar o próprio nome, papel ou instruções, e - **Aprovação:** um pedido no chat de quem chama, `toolName: "mcp__botloft__change_bot"`, com entrada `bot_id`, `handle`, `name` (o nome atual, também o resumo), `before`, `after` (nome, papel e instruções) e `reason`. O cartão diz " quer mudar " ou " quer mudar a própria configuração", mostra nome e papel antes e depois, as instruções novas atrás de uma seta e o porquê, com Mudar, Agora não e o campo para dizer por que não. O dono não ajusta o pedido no cartão; pode mudar o bot depois nos detalhes. - **Resposta:** permitir confere de novo e aplica como `bots.update`: o nome e o handle mudam na hora; papel e instruções valem a partir do próximo início do bot, como quando o dono os edita. Recusado ou sem resposta, nada muda, e o resultado diz isso ao bot. Quem chama em `bypass_permissions` muda sem perguntar. +### 10.4 Bots que alcançam outra crew + +Crews não se enxergam (7.5, 10). Quando o dono pede, um bot pode pedir para alcançar outra crew, ou um bot dela, com `ask_crew_access`: o nome da crew como o dono deu (nome ou pasta, sem diferença de maiúsculas; a própria crew dá erro), o handle do bot se for um só, o que quer (`talk`: ver os bots de lá e mandar mensagens e tasks) e por quê. Ler e editar arquivos de outra crew ficam para depois. + +- **Pedido:** um pedido no chat de quem pede, `toolName: "mcp__botloft__ask_crew_access"`, com entrada `crew_id`, `crew`, `bot_id`, `bot`, `handle`, `access` e `why`; o resumo é o nome da crew. A chamada espera a resposta. Um pedido do que o bot já alcança volta na hora, sem pedido. Quem está em `bypass_permissions` recebe o acesso só para a vez, sem perguntar. +- **Resposta:** Negar, ou permitir com `approvals.answer` e `input` `{"scope": ...}`: `once` (só agora, o padrão sem `input`), `bot` (sempre, só o bot pedido; recusado se o pedido era a crew) ou `crew` (sempre, a crew inteira). "Só agora" fica em memória e vale até o fim da vez do bot (o `result` do turno ou o fim do processo). "Sempre" vai para a tabela `crew_access` (bot, crew, bot alvo ou nenhum, `talk`), que guarda o que já tinha e soma o novo; apagar o bot, o alvo ou a crew apaga a linha. +- **Com acesso:** `crew_roster` com `crew` lista só os bots de lá que o bot alcança; `send_message` com `crew` manda nota ou task a um deles. Um bot fora de alcance e um que não existe dão o mesmo erro, que manda pedir com `ask_crew_access`. +- **Resposta do outro lado:** o bot que recebe pode responder a quem escreveu sem pedir: a quem tem acesso a ele, ou a um bot de outra crew que lhe escreveu nas últimas 24 horas (bots conversam em vezes; a resposta costuma chegar depois que a vez com acesso acabou). `complete_task` não depende de acesso: só o designado completa a task. +- **Onde fica:** a mensagem e a task entre crews são da crew de quem recebe, e o resultado e o aviso de prazo, da crew de quem pediu. Assim cada crew só guarda o que é para os bots dela, e apagar uma crew não deixa nada pendurado. `my_tasks` mostra um bot de outra crew como "writer of crew Blog". + ## 11. Protocolo do app (JSON-RPC 2.0 sobre WebSocket) Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests com `id`, respostas com `result` ou `error`, notificações do servidor sem `id`. O maior frame aceito do app é de 32 MiB, por causa dos anexos.