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/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-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/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..25e835fe --- /dev/null +++ b/crates/botloft-store/src/crew_access.rs @@ -0,0 +1,139 @@ +//! `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::{OptionalExtension, 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. 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 + .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())) + } +} + +#[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()); + + 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/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/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 new file mode 100644 index 00000000..381309c7 --- /dev/null +++ b/crates/botloftd/src/service/crew_access.rs @@ -0,0 +1,209 @@ +//! 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, CrewAccess, CrewAccessIdParams, CrewAccessListParams}; +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))) + } +} + +/// `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( + 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..04bb4071 --- /dev/null +++ b/crates/botloftd/src/tools/crew_access.rs @@ -0,0 +1,295 @@ +//! `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::Serialize)] +#[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", + )); + } + // 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/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..1b9467ee --- /dev/null +++ b/crates/botloftd/tests/crew_access.rs @@ -0,0 +1,266 @@ +//! 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 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); + 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")); + + // 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/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..b789d366 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,17 @@ 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. +- **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) 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. @@ -670,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) |