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 1/2] 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. From 116c89eb4ec194acc69ab2f3f8c115b17781fa07 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:44:04 -0300 Subject: [PATCH 2/2] feat(app): the card to let a bot reach another crew, and the list in its details The request card says what the bot wants in the other crew and why, with Only now, Always , Always the crew and Deny. The answer keeps the request with its scope, so the answered line says for how long. The bot's details list 'Other crews' it reaches for good, each with a button to take it back (crewAccess.list and crewAccess.revoke). Spec 10.4, 11. --- app/src/features/bots/BotPanels.tsx | 2 + app/src/features/bots/CrewAccessList.tsx | 87 ++++++++++ app/src/features/chat/ApprovalCard.tsx | 3 + app/src/features/chat/CrewAccessCard.tsx | 151 ++++++++++++++++++ app/src/features/chat/ToolLines.tsx | 2 + app/src/features/chat/crewAccess.test.tsx | 81 ++++++++++ app/src/i18n/en/bots.ts | 7 + app/src/i18n/en/chat.ts | 22 +++ app/src/i18n/en/tools.ts | 1 + app/src/i18n/es/bots.ts | 7 + app/src/i18n/es/chat.ts | 19 +++ app/src/i18n/es/tools.ts | 1 + app/src/i18n/pt-BR/bots.ts | 7 + app/src/i18n/pt-BR/chat.ts | 19 +++ app/src/i18n/pt-BR/tools.ts | 1 + app/src/lib/fake.ts | 3 + app/src/lib/fakeCrewAccess.ts | 49 ++++++ app/src/lib/protocol.gen.ts | 28 ++++ .../botloft-core/src/protocol/crew_access.rs | 37 +++++ crates/botloft-core/src/protocol/export.rs | 9 +- .../src/protocol/export_crew_access.rs | 23 +++ crates/botloft-core/src/protocol/mod.rs | 4 + crates/botloft-store/src/crew_access.rs | 28 +++- crates/botloftd/src/rpc/dispatch.rs | 8 +- crates/botloftd/src/service/crew_access.rs | 43 ++++- crates/botloftd/src/tools/crew_access.rs | 7 +- crates/botloftd/tests/crew_access.rs | 38 +++++ docs/spec.md | 5 +- 28 files changed, 675 insertions(+), 17 deletions(-) create mode 100644 app/src/features/bots/CrewAccessList.tsx create mode 100644 app/src/features/chat/CrewAccessCard.tsx create mode 100644 app/src/features/chat/crewAccess.test.tsx create mode 100644 app/src/lib/fakeCrewAccess.ts create mode 100644 crates/botloft-core/src/protocol/crew_access.rs create mode 100644 crates/botloft-core/src/protocol/export_crew_access.rs diff --git a/app/src/features/bots/BotPanels.tsx b/app/src/features/bots/BotPanels.tsx index 5654ab80..6d838ba9 100644 --- a/app/src/features/bots/BotPanels.tsx +++ b/app/src/features/bots/BotPanels.tsx @@ -12,6 +12,7 @@ import { DesktopGrants } from "../desktop/DesktopGrants"; import { SignInButton } from "../onboarding/SignIn"; import { AllowRules } from "./AllowRules"; import type { stateView } from "./BotStateBadge"; +import { CrewAccessList } from "./CrewAccessList"; export function Notices({ bot, @@ -78,6 +79,7 @@ export function Details({ bot, onClose }: { bot: Bot; onClose(): void }) { + diff --git a/app/src/features/bots/CrewAccessList.tsx b/app/src/features/bots/CrewAccessList.tsx new file mode 100644 index 00000000..e0bb7dc9 --- /dev/null +++ b/app/src/features/bots/CrewAccessList.tsx @@ -0,0 +1,87 @@ +// The other crews a bot may reach for good (spec 10.4), in its details: +// each crew, or one bot of it, with a button that takes it back. Read when +// shown and again when a request to reach a crew is answered. + +import { Network, X } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useT } from "../../i18n"; +import type { Bot, CrewAccess } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { attempt } from "../../ui/toast"; +import { CREW_ACCESS_TOOL } from "../chat/CrewAccessCard"; + +function useCrewAccess(botId: Bot["id"]) { + const api = useApi(); + const [list, setList] = useState(null); + useEffect(() => { + let alive = true; + const read = () => + api.call("crewAccess.list", { botId }).then( + (listed) => alive && setList(listed), + // An older daemon has no access to other crews: the list stays empty. + () => alive && setList([]), + ); + setList(null); + read(); + const stop = api.subscribe((event) => { + if (event.name !== "chat.item" || event.params.item.botId !== botId) { + return; + } + const body = event.params.item.body; + if (body.kind === "approval" && body.toolName === CREW_ACCESS_TOOL) { + read(); + } + }); + return () => { + alive = false; + stop(); + }; + }, [api, botId]); + return [list, setList] as const; +} + +export function CrewAccessList({ bot }: { bot: Bot }) { + const words = useT().bots.details; + const api = useApi(); + const [list, setList] = useCrewAccess(bot.id); + return ( +
+
{words.crews}
+
+ {list !== null && list.length === 0 && ( +

{words.crewsNone(bot.name)}

+ )} + {list !== null && list.length > 0 && ( +
    + {list.map((access) => { + const what = access.targetName + ? words.crewsBot(access.targetName, access.crewName) + : words.crewsWhole(access.crewName); + return ( +
  • + + {what} +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/app/src/features/chat/ApprovalCard.tsx b/app/src/features/chat/ApprovalCard.tsx index c5228583..47f7158b 100644 --- a/app/src/features/chat/ApprovalCard.tsx +++ b/app/src/features/chat/ApprovalCard.tsx @@ -18,6 +18,7 @@ import { } from "../routines/RoutineChangeCard"; import { ROUTINE_TOOL, RoutineRequestCard } from "../routines/RoutineRequestCard"; import { BotChangeCard, CHANGE_BOT_TOOL } from "./BotChangeCard"; +import { CREW_ACCESS_TOOL, CrewAccessCard } from "./CrewAccessCard"; import { commandOf, isCommand } from "./command"; import { PLAN_TOOL, PlanCard } from "./PlanCard"; import { SUGGEST_TOOL, SuggestionCard } from "./SuggestionCard"; @@ -74,6 +75,8 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B return ; case DELETE_ROUTINE_TOOL: return ; + case CREW_ACCESS_TOOL: + return ; case CHANGE_BOT_TOOL: return ; case SITE_TOOL: diff --git a/app/src/features/chat/CrewAccessCard.tsx b/app/src/features/chat/CrewAccessCard.tsx new file mode 100644 index 00000000..c41a6e41 --- /dev/null +++ b/app/src/features/chat/CrewAccessCard.tsx @@ -0,0 +1,151 @@ +// A bot asking to reach another crew, or one bot of it (spec 10.4): what +// it wants there and why. The owner allows it only now, always for that +// bot, always for the whole crew, or says no with a note. Answered, it +// shrinks to one line that says for how long. + +import { Ban, Check, Network, TimerOff } from "lucide-react"; +import { useState } from "react"; +import { useT } from "../../i18n"; +import { errorText } from "../../lib/api"; +import type { ApprovalItem, Bot } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { NoteArea, SectionCard, SettledLine } from "../../ui/ChatCard"; + +export const CREW_ACCESS_TOOL = "mcp__botloft__ask_crew_access"; + +type Scope = "once" | "bot" | "crew"; + +interface Asked { + crew: string; + bot: string | null; + access: string[]; + why: string; + scope?: Scope; +} + +function askedOf(input: string): Partial { + try { + return JSON.parse(input) as Partial; + } catch { + return {}; + } +} + +function Answered({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { + const w = useT().chat.crewAccess; + const asked = askedOf(approval.input); + const where = asked.crew ?? ""; + if (approval.status === "allowed") { + const scope = asked.scope ?? "once"; + const text = + scope === "crew" + ? w.doneCrew(bot.name, where) + : scope === "bot" + ? w.doneBot(bot.name, asked.bot ?? "", where) + : w.doneOnce(bot.name, where); + return ; + } + if (approval.status === "denied") { + return ( + + ); + } + return ; +} + +export function CrewAccessCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { + const t = useT(); + const w = t.chat.crewAccess; + const api = useApi(); + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + if (approval.status !== "pending") { + return ; + } + const asked = askedOf(approval.input); + const crew = asked.crew ?? ""; + const target = asked.bot ?? null; + const answer = async (allow: boolean, scope?: Scope) => { + setBusy(true); + setError(null); + const trimmed = note.trim(); + try { + await api.call("approvals.answer", { + approvalId: approval.approvalId, + allow, + ...(!allow && trimmed ? { note: trimmed } : {}), + ...(allow && scope ? { input: JSON.stringify({ scope }) } : {}), + }); + } catch (failure) { + setError(`${w.failed}: ${errorText(failure)}`); + } + setBusy(false); + }; + return ( + + +
+ + {target && ( + + )} + + +
+ + } + > +
+
    + {(asked.access ?? []).map((kind) => ( +
  • + {kind === "talk" ? w.talk(target ? target : crew) : kind} +
  • + ))} +
+ {asked.why && ( +

+ {w.why(bot.name)} + {asked.why} +

+ )} +

{w.explain}

+ {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/app/src/features/chat/ToolLines.tsx b/app/src/features/chat/ToolLines.tsx index fccc923a..2cdb9684 100644 --- a/app/src/features/chat/ToolLines.tsx +++ b/app/src/features/chat/ToolLines.tsx @@ -18,6 +18,7 @@ import { LoaderCircle, type LucideIcon, Monitor, + Network, Send, SquareTerminal, UserPen, @@ -58,6 +59,7 @@ const ICONS: Record = { send_message: Send, suggest_bot: UserPlus, change_bot: UserPen, + ask_crew_access: Network, schedule_routine: AlarmClock, my_routines: AlarmClock, change_routine: AlarmClock, diff --git a/app/src/features/chat/crewAccess.test.tsx b/app/src/features/chat/crewAccess.test.tsx new file mode 100644 index 00000000..10e00476 --- /dev/null +++ b/app/src/features/chat/crewAccess.test.tsx @@ -0,0 +1,81 @@ +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp } from "../../test/app"; +import { CREW_ACCESS_TOOL } from "./CrewAccessCard"; + +afterEach(cleanup); + +async function openScout() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.setBotState(scout.id, "idle"); + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + await screen.findByRole("list", { name: "Messages" }); + return { fake, scout }; +} + +const answered = (fake: FakeBotloft) => + fake.calls.find((call) => call.method === "approvals.answer")?.params as + | { allow: boolean; input?: string; note?: string } + | undefined; + +const request = (bot: string | null) => + JSON.stringify({ + crew: "Blog", + bot, + handle: bot?.toLowerCase() ?? null, + access: ["talk"], + why: "To get this week's post.", + }); + +describe("a bot asking to reach another crew", () => { + test("for one bot: only now, always that bot, always the crew or deny", async () => { + const { fake, scout } = await openScout(); + act(() => { + fake.chat.ask(scout.id, CREW_ACCESS_TOOL, "Blog", request("Writer")); + }); + const card = screen.getByRole("region", { + name: "Scout wants to reach Writer, of the crew Blog", + }); + expect(within(card).getByText(/^Talk with Writer/)).toBeDefined(); + expect(within(card).getByText("To get this week's post.")).toBeDefined(); + expect(within(card).getByRole("button", { name: "Only now" })).toBeDefined(); + expect(within(card).getByRole("button", { name: "Always the crew Blog" })).toBeDefined(); + fireEvent.click(within(card).getByRole("button", { name: "Always Writer" })); + await waitFor(() => expect(answered(fake)?.allow).toBe(true)); + expect(JSON.parse(answered(fake)?.input as string)).toEqual({ scope: "bot" }); + }); + + test("for the whole crew there is no bot to always allow", async () => { + const { fake, scout } = await openScout(); + act(() => { + fake.chat.ask(scout.id, CREW_ACCESS_TOOL, "Blog", request(null)); + }); + const card = screen.getByRole("region", { name: "Scout wants to reach the crew Blog" }); + expect(within(card).queryByRole("button", { name: /^Always Writer/ })).toBeNull(); + fireEvent.change(within(card).getByRole("textbox"), { target: { value: "Not this week" } }); + fireEvent.click(within(card).getByRole("button", { name: "Deny" })); + await waitFor(() => + expect(answered(fake)).toMatchObject({ allow: false, note: "Not this week" }), + ); + }); + + test("lasting access shows in the bot's details and can be taken back", async () => { + const { fake, scout } = await openScout(); + fake.crewAccess.add(scout.id, "Blog"); + fake.crewAccess.add(scout.id, "Shop", { id: "bot_clerk", name: "Clerk" }); + fireEvent.click(screen.getByRole("button", { name: /^Show details/ })); + const details = screen.getByRole("complementary", { name: "About Scout" }); + expect(await within(details).findByText("The whole crew Blog")).toBeDefined(); + expect(within(details).getByText("Clerk, of the crew Shop")).toBeDefined(); + fireEvent.click( + within(details).getByRole("button", { name: "Take back: The whole crew Blog" }), + ); + await waitFor(() => expect(within(details).queryByText("The whole crew Blog")).toBeNull()); + expect(within(details).getByText("Clerk, of the crew Shop")).toBeDefined(); + }); +}); diff --git a/app/src/i18n/en/bots.ts b/app/src/i18n/en/bots.ts index a94dd385..d1d499ee 100644 --- a/app/src/i18n/en/bots.ts +++ b/app/src/i18n/en/bots.ts @@ -130,5 +130,12 @@ export const bots = { `Nothing yet. When ${bot} asks for something, "Always allow" puts it here.`, alwaysRemove: (what: string) => `Ask again: ${what}`, alwaysRemoveFailed: "Could not remove it", + crews: "Other crews", + crewsNone: (bot: string) => + `${bot} reaches only its own crew. When it asks for another one, "Always" puts it here.`, + crewsWhole: (crew: string) => `The whole crew ${crew}`, + crewsBot: (bot: string, crew: string) => `${bot}, of the crew ${crew}`, + crewsRemove: (what: string) => `Take back: ${what}`, + crewsRemoveFailed: "Could not take it back", }, }; diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index 280460e6..a6bd0bbd 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -319,6 +319,28 @@ export const chat = { declined: (name: string) => `You kept the routine ${name}`, expired: (name: string) => `Deleting ${name} was not answered in time`, }, + /** A bot asking to reach another crew, or one bot of it (spec 10.4). */ + crewAccess: { + titleCrew: (bot: string, crew: string) => `${bot} wants to reach the crew ${crew}`, + titleBot: (bot: string, other: string, crew: string) => + `${bot} wants to reach ${other}, of the crew ${crew}`, + talk: (whom: string) => `Talk with ${whom}: see who is there and send messages and tasks`, + why: (bot: string) => `Why, says ${bot}:`, + explain: "Crews do not see each other. Only now lasts until the bot ends this turn.", + once: "Only now", + alwaysBot: (other: string) => `Always ${other}`, + alwaysCrew: (crew: string) => `Always the crew ${crew}`, + decline: "Deny", + noteLabel: (bot: string) => `What to tell ${bot} if you deny it`, + notePlaceholder: (bot: string) => `If you deny it, tell ${bot} why (optional)`, + failed: "Could not send the answer", + doneOnce: (bot: string, crew: string) => `You let ${bot} reach ${crew} only now`, + doneBot: (bot: string, other: string, crew: string) => + `You let ${bot} always reach ${other}, of ${crew}`, + doneCrew: (bot: string, crew: string) => `You let ${bot} always reach the crew ${crew}`, + declined: (bot: string, crew: string) => `You did not let ${bot} reach ${crew}`, + expired: (crew: string) => `The request to reach ${crew} was not answered in time`, + }, /** A bot asking to rename or change itself, or the chief another bot (spec 10.3). */ botChange: { titleSelf: (bot: string) => `${bot} wants to change how it is set up`, diff --git a/app/src/i18n/en/tools.ts b/app/src/i18n/en/tools.ts index b6146491..7a8bc9af 100644 --- a/app/src/i18n/en/tools.ts +++ b/app/src/i18n/en/tools.ts @@ -37,6 +37,7 @@ export const tools = { change_routine: "Change a routine", delete_routine: "Delete a routine", change_bot: "Change a bot", + ask_crew_access: "Reach another crew", share_file: "Share a file", ask_owner: "Ask you a question", browser: "Use a site", diff --git a/app/src/i18n/es/bots.ts b/app/src/i18n/es/bots.ts index 5e8134b0..4b9b3ce3 100644 --- a/app/src/i18n/es/bots.ts +++ b/app/src/i18n/es/bots.ts @@ -128,5 +128,12 @@ export const bots: Messages["bots"] = { `Nada todavía. Cuando ${bot} pida algo, "Permitir siempre" lo pone aquí.`, alwaysRemove: (what: string) => `Volver a preguntar: ${what}`, alwaysRemoveFailed: "No se pudo quitar", + crews: "Otros equipos", + crewsNone: (bot) => + `${bot} solo llega a su propio equipo. Cuando pida otro, "Siempre" lo pone aquí.`, + crewsWhole: (crew) => `Todo el equipo ${crew}`, + crewsBot: (bot, crew) => `${bot}, del equipo ${crew}`, + crewsRemove: (what) => `Quitar el acceso: ${what}`, + crewsRemoveFailed: "No se pudo quitar el acceso", }, }; diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts index 9f1afd41..a65733ec 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -309,6 +309,25 @@ export const chat: Messages["chat"] = { declined: (name) => `Mantuviste la rutina ${name}`, expired: (name) => `Borrar ${name} quedó sin respuesta`, }, + crewAccess: { + titleCrew: (bot, crew) => `${bot} quiere acceder al equipo ${crew}`, + titleBot: (bot, other, crew) => `${bot} quiere acceder a ${other}, del equipo ${crew}`, + talk: (whom) => `Hablar con ${whom}: ver quién está y enviar mensajes y tareas`, + why: (bot) => `Por qué, según ${bot}:`, + explain: "Los equipos no se ven entre sí. Solo ahora vale hasta que el bot termine este turno.", + once: "Solo ahora", + alwaysBot: (other) => `Siempre ${other}`, + alwaysCrew: (crew) => `Siempre el equipo ${crew}`, + decline: "Denegar", + noteLabel: (bot) => `Qué decirle a ${bot} si lo deniegas`, + notePlaceholder: (bot) => `Si lo deniegas, dile a ${bot} por qué (opcional)`, + failed: "No se pudo enviar la respuesta", + doneOnce: (bot, crew) => `Dejaste que ${bot} accediera a ${crew} solo ahora`, + doneBot: (bot, other, crew) => `Dejaste que ${bot} acceda siempre a ${other}, de ${crew}`, + doneCrew: (bot, crew) => `Dejaste que ${bot} acceda siempre al equipo ${crew}`, + declined: (bot, crew) => `No dejaste que ${bot} accediera a ${crew}`, + expired: (crew) => `La solicitud para acceder a ${crew} quedó sin respuesta`, + }, botChange: { titleSelf: (bot) => `${bot} quiere cambiar su propia configuración`, titleOther: (bot, other) => `${bot} quiere cambiar a ${other}`, diff --git a/app/src/i18n/es/tools.ts b/app/src/i18n/es/tools.ts index e62e4b28..3b347baa 100644 --- a/app/src/i18n/es/tools.ts +++ b/app/src/i18n/es/tools.ts @@ -38,6 +38,7 @@ export const tools: Messages["tools"] = { change_routine: "Cambiar una rutina", delete_routine: "Borrar una rutina", change_bot: "Cambiar un bot", + ask_crew_access: "Acceder a otro equipo", share_file: "Compartir un archivo", ask_owner: "Hacerte una pregunta", browser: "Usar un sitio", diff --git a/app/src/i18n/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts index ecfed4c5..f220d30c 100644 --- a/app/src/i18n/pt-BR/bots.ts +++ b/app/src/i18n/pt-BR/bots.ts @@ -127,5 +127,12 @@ export const bots: Messages["bots"] = { `Nada ainda. Quando ${bot} pedir algo, "Permitir sempre" coloca aqui.`, alwaysRemove: (what: string) => `Voltar a perguntar: ${what}`, alwaysRemoveFailed: "Não foi possível remover", + crews: "Outras equipes", + crewsNone: (bot) => + `${bot} só alcança a própria equipe. Quando pedir outra, "Sempre" a coloca aqui.`, + crewsWhole: (crew) => `A equipe ${crew} inteira`, + crewsBot: (bot, crew) => `${bot}, da equipe ${crew}`, + crewsRemove: (what) => `Tirar o acesso: ${what}`, + crewsRemoveFailed: "Não foi possível tirar o acesso", }, }; diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts index 7302bb9b..e6454399 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -306,6 +306,25 @@ export const chat: Messages["chat"] = { declined: (name) => `Você manteve a rotina ${name}`, expired: (name) => `Apagar ${name} ficou sem resposta`, }, + crewAccess: { + titleCrew: (bot, crew) => `${bot} quer acessar a equipe ${crew}`, + titleBot: (bot, other, crew) => `${bot} quer acessar ${other}, da equipe ${crew}`, + talk: (whom) => `Conversar com ${whom}: ver quem está lá e mandar mensagens e tarefas`, + why: (bot) => `Por quê, segundo ${bot}:`, + explain: "As equipes não se enxergam. Só agora vale até o bot terminar esta vez.", + once: "Só agora", + alwaysBot: (other) => `Sempre ${other}`, + alwaysCrew: (crew) => `Sempre a equipe ${crew}`, + decline: "Negar", + noteLabel: (bot) => `O que dizer a ${bot} se você negar`, + notePlaceholder: (bot) => `Se negar, diga a ${bot} por quê (opcional)`, + failed: "Não foi possível mandar a resposta", + doneOnce: (bot, crew) => `Você deixou ${bot} acessar ${crew} só agora`, + doneBot: (bot, other, crew) => `Você deixou ${bot} acessar sempre ${other}, de ${crew}`, + doneCrew: (bot, crew) => `Você deixou ${bot} acessar sempre a equipe ${crew}`, + declined: (bot, crew) => `Você não deixou ${bot} acessar ${crew}`, + expired: (crew) => `O pedido para acessar ${crew} ficou sem resposta`, + }, botChange: { titleSelf: (bot) => `${bot} quer mudar a própria configuração`, titleOther: (bot, other) => `${bot} quer mudar ${other}`, diff --git a/app/src/i18n/pt-BR/tools.ts b/app/src/i18n/pt-BR/tools.ts index 2245dae0..333da8ef 100644 --- a/app/src/i18n/pt-BR/tools.ts +++ b/app/src/i18n/pt-BR/tools.ts @@ -37,6 +37,7 @@ export const tools: Messages["tools"] = { change_routine: "Mudar uma rotina", delete_routine: "Apagar uma rotina", change_bot: "Mudar um bot", + ask_crew_access: "Acessar outra equipe", share_file: "Compartilhar um arquivo", ask_owner: "Fazer uma pergunta a você", browser: "Usar um site", diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 00fd0d32..86cb8a49 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -8,6 +8,7 @@ import { botHandlers } from "./fakeBots"; import { FakeBrowser } from "./fakeBrowser"; import { FakeChat } from "./fakeChat"; import { FakeConversation } from "./fakeConversation"; +import { FakeCrewAccess } from "./fakeCrewAccess"; import { crewHandlers } from "./fakeCrews"; import { FakeDesktop } from "./fakeDesktop"; import { FakeFiles } from "./fakeFiles"; @@ -74,6 +75,7 @@ export class FakeBotloft implements BotloftApi { readonly calls: { method: Method; params: unknown }[] = []; readonly chat = new FakeChat(this); readonly allow = new FakeAllow(this); + readonly crewAccess = new FakeCrewAccess(this); readonly desktop = new FakeDesktop(this); /** The language the app last told, as the daemon keeps it (spec 24.7). */ locale: string | null = null; @@ -267,6 +269,7 @@ export class FakeBotloft implements BotloftApi { ...botHandlers(this), ...this.chat.handlers(), ...this.allow.handlers(), + ...this.crewAccess.handlers(), ...this.desktop.handlers(), ...this.conversation.handlers(), ...this.files.handlers(), diff --git a/app/src/lib/fakeCrewAccess.ts b/app/src/lib/fakeCrewAccess.ts new file mode 100644 index 00000000..fd0bc55a --- /dev/null +++ b/app/src/lib/fakeCrewAccess.ts @@ -0,0 +1,49 @@ +// The fake daemon's lasting access to other crews (spec 10.4): what each +// bot may reach for good, and `crewAccess.*`. The request itself is a plain +// approval (`chat.ask` with the tool's name). + +import type { FakeBotloft, Handlers } from "./fake"; +import { notFound } from "./fakeRules"; +import type { BotId, CrewAccess } from "./protocol.gen"; + +type AccessMethods = Extract; + +export class FakeCrewAccess { + private list: CrewAccess[] = []; + private next = 1; + + constructor(private readonly fake: FakeBotloft) {} + + /** `bot` may reach `crewName` for good, or only `target` in it. */ + add(bot: BotId, crewName: string, target?: { id: BotId; name: string }): CrewAccess { + const access: CrewAccess = { + id: `cxa_${this.next++}`, + botId: bot, + crewId: `crw_${crewName.toLowerCase()}`, + crewName, + targetBotId: target?.id ?? null, + targetName: target?.name ?? null, + createdAt: this.fake.now, + }; + this.list.push(access); + return access; + } + + handlers(): Pick { + const of = (bot: BotId) => this.list.filter((access) => access.botId === bot); + return { + "crewAccess.list": ({ botId }) => { + this.fake.bot(botId, false); + return of(botId); + }, + "crewAccess.revoke": ({ accessId }) => { + const found = this.list.find((access) => access.id === accessId); + if (!found) { + throw notFound(`access ${accessId}`); + } + this.list = this.list.filter((access) => access.id !== accessId); + return of(found.botId); + }, + }; + } +} diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index f4393228..c3203298 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -81,6 +81,11 @@ export type QuestionId = string; */ export type DesktopGrantId = string; +/** + * Identifies what the owner let a bot reach in another crew (spec 10.4). + */ +export type CrewAccessId = string; + /** * A group of bots that can message each other and share a folder. */ @@ -1002,6 +1007,27 @@ export type DesktopWholeParams = { botId: BotId, level: DesktopLevel, */ acceptedRisks: boolean, }; +/** + * One lasting access: the whole crew, or one bot of it. + */ +export type CrewAccess = { id: CrewAccessId, +/** + * The bot that may reach. + */ +botId: BotId, crewId: CrewId, crewName: string, +/** + * One bot of the crew; `null` for the whole crew. + */ +targetBotId: BotId | null, targetName: string | null, +/** + * Unix time in milliseconds. + */ +createdAt: number, }; + +export type CrewAccessListParams = { botId: BotId, }; + +export type CrewAccessIdParams = { accessId: CrewAccessId, }; + export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string, /** * What the bot says the command is for, as in `ToolItem`. The bot @@ -1693,6 +1719,8 @@ export interface RpcMethods { "desktop.resume": { params: DesktopBotParams; result: DesktopState }; "desktop.awayUses": { params: undefined; result: Array }; "desktop.dismissAway": { params: undefined; result: null }; + "crewAccess.list": { params: CrewAccessListParams; result: Array }; + "crewAccess.revoke": { params: CrewAccessIdParams; result: Array }; "routines.list": { params: RoutinesListParams; result: Array }; "routines.create": { params: RoutinesCreateParams; result: Routine }; "routines.update": { params: RoutinesUpdateParams; result: Routine }; diff --git a/crates/botloft-core/src/protocol/crew_access.rs b/crates/botloft-core/src/protocol/crew_access.rs new file mode 100644 index 00000000..bf370c82 --- /dev/null +++ b/crates/botloft-core/src/protocol/crew_access.rs @@ -0,0 +1,37 @@ +//! What the owner let a bot reach in another crew for good (spec 10.4), as +//! the bot's details list it. + +use serde::{Deserialize, Serialize}; + +use crate::ids::{BotId, CrewAccessId, CrewId}; + +/// One lasting access: the whole crew, or one bot of it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct CrewAccess { + pub id: CrewAccessId, + /// The bot that may reach. + pub bot_id: BotId, + pub crew_id: CrewId, + pub crew_name: String, + /// One bot of the crew; `null` for the whole crew. + pub target_bot_id: Option, + pub target_name: Option, + /// Unix time in milliseconds. + pub created_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct CrewAccessListParams { + pub bot_id: BotId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct CrewAccessIdParams { + pub access_id: CrewAccessId, +} diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index e7c3273c..59a83cb3 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -10,12 +10,14 @@ use ts_rs::{Config, TS}; use super::*; use crate::avatar::PALETTE; use crate::ids::{ - ApprovalId, AttachmentId, BotId, ChatItemId, CrewId, DeliveryId, DesktopGrantId, MessageId, - QuestionId, RoutineId, RoutineRunId, RuleId, TaskId, + ApprovalId, AttachmentId, BotId, ChatItemId, CrewAccessId, CrewId, DeliveryId, DesktopGrantId, + MessageId, QuestionId, RoutineId, RoutineRunId, RuleId, TaskId, }; #[path = "export_browser.rs"] mod browser; +#[path = "export_crew_access.rs"] +mod crew_access; #[path = "export_desktop.rs"] mod desktop; #[path = "export_questions.rs"] @@ -110,6 +112,7 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -180,6 +183,7 @@ fn export_bindings() { out.decl::(); rules::decls(&mut out); desktop::decls(&mut out); + crew_access::decls(&mut out); out.decl::(); usage::decls(&mut out); questions::decls(&mut out); @@ -349,6 +353,7 @@ fn export_bindings() { usage::methods(&mut out); rules::methods(&mut out); desktop::methods(&mut out); + crew_access::methods(&mut out); routines::methods(&mut out); questions::methods(&mut out); reactions::methods(&mut out); diff --git a/crates/botloft-core/src/protocol/export_crew_access.rs b/crates/botloft-core/src/protocol/export_crew_access.rs new file mode 100644 index 00000000..18b3ec97 --- /dev/null +++ b/crates/botloft-core/src/protocol/export_crew_access.rs @@ -0,0 +1,23 @@ +//! The other-crew access part of the TypeScript bindings (spec 10.4). + +use super::super::*; +use super::Out; + +pub(super) fn decls(out: &mut Out) { + out.decl::(); + out.decl::(); + out.decl::(); +} + +pub(super) fn methods(out: &mut Out) { + out.method( + method::CREW_ACCESS_LIST, + &out.name::(), + &out.name::>(), + ); + out.method( + method::CREW_ACCESS_REVOKE, + &out.name::(), + &out.name::>(), + ); +} diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 7d3c4022..90de3c07 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -52,6 +52,7 @@ macro_rules! text_enum { mod browser; mod browser_input; mod chat; +mod crew_access; mod desktop; #[cfg(test)] mod export; @@ -73,6 +74,7 @@ mod usage; pub use browser::*; pub use browser_input::*; pub use chat::*; +pub use crew_access::*; pub use desktop::*; pub use files::*; pub use lesson::*; @@ -124,6 +126,8 @@ pub mod method { pub const APPROVALS_ANSWER: &str = "approvals.answer"; pub const RULES_LIST: &str = "rules.list"; pub const RULES_DELETE: &str = "rules.delete"; + pub const CREW_ACCESS_LIST: &str = "crewAccess.list"; + pub const CREW_ACCESS_REVOKE: &str = "crewAccess.revoke"; pub const DESKTOP_GRANTS: &str = "desktop.grants"; pub const DESKTOP_REVOKE: &str = "desktop.revoke"; pub const DESKTOP_SET_OPTIONS: &str = "desktop.setOptions"; diff --git a/crates/botloft-store/src/crew_access.rs b/crates/botloft-store/src/crew_access.rs index fa46770b..25e835fe 100644 --- a/crates/botloft-store/src/crew_access.rs +++ b/crates/botloft-store/src/crew_access.rs @@ -2,7 +2,7 @@ //! good (spec 10.4). use botloft_core::ids::{BotId, CrewAccessId, CrewId}; -use rusqlite::{Row, params}; +use rusqlite::{OptionalExtension, Row, params}; use crate::{Result, Store, parse_column}; @@ -76,12 +76,18 @@ impl Store { 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 + /// Takes the access back. The bot that had it, or `None` if there was + /// none with that id. + pub fn revoke_crew_access(&self, id: &CrewAccessId) -> Result> { + let bot: Option = self .conn - .execute("DELETE FROM crew_access WHERE id = ?1", [id.as_str()])? - > 0) + .query_row( + "DELETE FROM crew_access WHERE id = ?1 RETURNING bot_id", + [id.as_str()], + |row| row.get(0), + ) + .optional()?; + Ok(bot.and_then(|bot| bot.parse().ok())) } } @@ -120,8 +126,14 @@ mod tests { 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")); + let revoked = fx.store.revoke_crew_access(&access[0].id).expect("revoke"); + assert_eq!(revoked.as_ref(), Some(scout)); + assert!( + fx.store + .revoke_crew_access(&access[0].id) + .expect("again") + .is_none() + ); assert_eq!(fx.store.crew_access(scout).expect("list").len(), 1); } } diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index a9733b11..73758fa7 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -8,9 +8,9 @@ use serde_json::Value; use super::jsonrpc::{RpcError, empty_params}; use crate::approvals; use crate::service::{ - self, ApiResult, archive, attachments, bots, chat, crews, delete, deliveries, desktop, files, - lead, messages, models, modes, questions, reactions, routines, rules, screens, settings, tasks, - usage, + self, ApiResult, archive, attachments, bots, chat, crew_access, crews, delete, deliveries, + desktop, files, lead, messages, models, modes, questions, reactions, routines, rules, screens, + settings, tasks, usage, }; use crate::state::Daemon; @@ -54,6 +54,8 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(approvals::answer(daemon, parse(params)?)), method::RULES_LIST => reply(rules::list(daemon, parse(params)?)), method::RULES_DELETE => reply(rules::delete(daemon, parse(params)?)), + method::CREW_ACCESS_LIST => reply(crew_access::list(daemon, parse(params)?)), + method::CREW_ACCESS_REVOKE => reply(crew_access::revoke(daemon, parse(params)?)), method::DESKTOP_GRANTS => reply(desktop::grants(daemon, parse(params)?)), method::DESKTOP_REVOKE => reply(desktop::revoke(daemon, parse(params)?)), method::DESKTOP_STOP => reply(desktop::stop(daemon, parse(params)?)), diff --git a/crates/botloftd/src/service/crew_access.rs b/crates/botloftd/src/service/crew_access.rs index f5628c61..381309c7 100644 --- a/crates/botloftd/src/service/crew_access.rs +++ b/crates/botloftd/src/service/crew_access.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::sync::{Mutex, PoisonError}; use botloft_core::ids::{BotId, CrewId}; -use botloft_core::protocol::Crew; +use botloft_core::protocol::{Crew, CrewAccess, CrewAccessIdParams, CrewAccessListParams}; use botloft_store::{BotRecord, Store}; use super::{ApiError, ApiResult}; @@ -64,6 +64,47 @@ impl TurnAccess { } } +/// `crewAccess.list`: what the bot may reach in other crews for good. +pub fn list(daemon: &Daemon, params: CrewAccessListParams) -> ApiResult> { + let store = daemon.store(); + if store.bot(¶ms.bot_id)?.is_none() { + return Err(ApiError::NotFound(format!("bot {}", params.bot_id))); + } + described(&store, ¶ms.bot_id) +} + +/// `crewAccess.revoke`: takes one back. The bot's list after it. +pub fn revoke(daemon: &Daemon, params: CrewAccessIdParams) -> ApiResult> { + let store = daemon.store(); + let bot = store + .revoke_crew_access(¶ms.access_id)? + .ok_or_else(|| ApiError::NotFound(format!("access {}", params.access_id)))?; + described(&store, &bot) +} + +fn described(store: &Store, bot: &BotId) -> ApiResult> { + store + .crew_access(bot)? + .into_iter() + .map(|access| { + let crew_name = store.crew(&access.crew_id)?.map(|crew| crew.name); + let target_name = match &access.target_bot_id { + Some(target) => store.bot(target)?.map(|bot| bot.name), + None => None, + }; + Ok(CrewAccess { + id: access.id, + bot_id: access.bot_id, + crew_id: access.crew_id, + crew_name: crew_name.unwrap_or_default(), + target_bot_id: access.target_bot_id, + target_name, + created_at: access.created_at, + }) + }) + .collect() +} + /// 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( diff --git a/crates/botloftd/src/tools/crew_access.rs b/crates/botloftd/src/tools/crew_access.rs index 8892a6ad..04bb4071 100644 --- a/crates/botloftd/src/tools/crew_access.rs +++ b/crates/botloftd/src/tools/crew_access.rs @@ -34,7 +34,7 @@ struct Args { } /// How long the owner allowed it, as `approvals.answer` sends it back. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, serde::Serialize)] #[serde(rename_all = "snake_case")] enum Scope { /// Until the bot's turn ends. @@ -288,5 +288,8 @@ pub(crate) fn check_answer(asked: &str, answer: &str) -> ApiResult { "the bot asked for the whole crew: allow it now or for the crew", )); } - Ok(answer.to_owned()) + // Kept in place of the request: the request, with how long. + let mut kept = asked; + kept["scope"] = serde_json::to_value(answered.scope).unwrap_or_default(); + Ok(kept.to_string()) } diff --git a/crates/botloftd/tests/crew_access.rs b/crates/botloftd/tests/crew_access.rs index 59e96007..1b9467ee 100644 --- a/crates/botloftd/tests/crew_access.rs +++ b/crates/botloftd/tests/crew_access.rs @@ -184,6 +184,23 @@ async fn always_for_the_crew_lasts_and_a_task_comes_back() { 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 settled = c + .app + .call("chat.history", json!({ "botId": c.scout["id"] })) + .await + .expect("history"); + let kept = settled + .as_array() + .expect("items") + .iter() + .find(|item| item["body"]["kind"] == "approval") + .expect("approval")["body"]["input"] + .as_str() + .expect("input") + .to_owned(); + let kept: Value = serde_json::from_str(&kept).expect("json"); + assert_eq!(kept["crew"], "Blog"); + assert_eq!(kept["scope"], "crew"); let scout: BotId = c.scout["id"].as_str().expect("id").parse().expect("bot id"); c.t.daemon.crew_access.end_turn(&scout); @@ -225,4 +242,25 @@ async fn always_for_the_crew_lasts_and_a_task_comes_back() { .expect("task") .expect("already"); assert!(again["note"].as_str().expect("note").contains("already")); + + // The owner sees it in the scout's details and takes it back. + let listed = c + .app + .call("crewAccess.list", json!({ "botId": c.scout["id"] })) + .await + .expect("list"); + assert_eq!(listed[0]["crewName"], "Blog"); + assert_eq!(listed[0]["targetBotId"], Value::Null); + let left = c + .app + .call("crewAccess.revoke", json!({ "accessId": listed[0]["id"] })) + .await + .expect("revoke"); + assert_eq!(left, json!([])); + let closed = c + .scout_mcp + .tool("send_message", note("editor", "Hi")) + .await + .expect_err("taken back"); + assert!(closed.contains("cannot reach"), "{closed}"); } diff --git a/docs/spec.md b/docs/spec.md index 0620d474..b789d366 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -639,6 +639,7 @@ Crews não se enxergam (7.5, 10). Quando o dono pede, um bot pode pedir para alc - **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. +- **No app:** o cartão diz " quer acessar a equipe " ou " quer acessar , da equipe ", o que o bot quer lá ("Conversar com ...: ver quem está lá e mandar mensagens e tarefas") e o porquê, com Só agora, Sempre (só num pedido de um bot), Sempre a equipe e Negar, e o campo para dizer por que não. A resposta guarda, no lugar da entrada, o pedido com `scope`, e o cartão respondido diz por quanto tempo. Nos detalhes do bot, "Outras equipes" lista o que ele alcança sempre (`crewAccess.list`), com um botão para tirar cada um (`crewAccess.revoke`); a lista é lida de novo quando um pedido desses é respondido. IDs com o prefixo `cxa_`. - **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) @@ -682,9 +683,11 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `bots.delete` | `botId, recycleFolder?` | `{botId, crewId}`; exclui o bot, ativo ou arquivado; com `recycleFolder`, a pasta dele vai depois para a Lixeira (7.6) | | `chat.history` | `botId, before?, limit?` ou `botId, until` | `ChatItem[]`, mais novo primeiro; `limit` de 1 a 200, 50 se ausente; `until`: do item até o mais novo, até 1 000 (8.8) | | `chat.search` | `query, botId?, crewId?, before?, limit?` | `SearchHit[]`: itens com todas as palavras, mais novo primeiro, com um trecho em destaque (8.8) | -| `approvals.answer` | `approvalId, allow, note?, input?, always?` (`input`: a sugestão de bot como o dono a deixou, 10.2; `always`: com `allow`, grava o `always` do pedido como regra do bot, 10.1) | `Approval` | +| `approvals.answer` | `approvalId, allow, note?, input?, always?` (`input`: a sugestão de bot como o dono a deixou, 10.2, ou `{"scope": ...}` de um pedido de outra crew, 10.4; `always`: com `allow`, grava o `always` do pedido como regra do bot, 10.1) | `Approval` | | `rules.list` | `botId` | `AllowRule[]`: o que o bot faz sem perguntar, da regra mais antiga à mais nova (10.1) | | `rules.delete` | `ruleId` | `{botId, rules}`: as regras que ficaram; o bot volta a perguntar pelo que saiu (10.1) | +| `crewAccess.list` | `botId` | `CrewAccess[]`: as outras crews, ou bots delas, que o bot alcança sempre (`crewName`, `targetBotId` e `targetName`, nulos para a crew inteira), da mais antiga (10.4) | +| `crewAccess.revoke` | `accessId` | `CrewAccess[]`: tira uma e devolve as que ficam (10.4) | | `messages.send` | `botId, body, attachments?` (`[{name, mediaType, data}]`, data em base64), `replyTo?` (id de um item do chat do bot, 9.3) | `Message` | | `messages.list` | `crewId?, botId?, before?, limit?` | `Message[]` | | `attachments.read` | `attachmentId` | `{mediaType, data}`, data em base64 (9.5) |