diff --git a/app/src/features/desktop/AwayNotice.tsx b/app/src/features/desktop/AwayNotice.tsx new file mode 100644 index 00000000..9a86f685 --- /dev/null +++ b/app/src/features/desktop/AwayNotice.tsx @@ -0,0 +1,84 @@ +// What bots did on the owner's desktop while the owner was away (spec +// 24.8): one line per bot and app above the main pane, each opening the +// bot's chat where it began, until the owner dismisses them. + +import { MessageSquare, Monitor } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useT } from "../../i18n"; +import type { DesktopAwayUse } from "../../lib/protocol.gen"; +import { useApi, useApp } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { attempt } from "../../ui/toast"; + +/** The list now, kept current by `desktop.away`. */ +function useAwayUses(): DesktopAwayUse[] { + const api = useApi(); + const [uses, setUses] = useState([]); + useEffect(() => { + let alive = true; + let heard = false; + const stop = api.subscribe((event) => { + if (event.name === "desktop.away") { + heard = true; + setUses(event.params); + } + }); + api.call("desktop.awayUses").then( + (listed) => alive && !heard && setUses(listed), + // An older daemon has none. + () => undefined, + ); + return () => { + alive = false; + stop(); + }; + }, [api]); + return uses; +} + +export function AwayNotice() { + const words = useT().desktop.away; + const api = useApi(); + const uses = useAwayUses(); + const bots = useApp((state) => state.bots); + const openAt = useApp((state) => state.openAt); + const selectBot = useApp((state) => state.selectBot); + const shown = uses.filter((used) => bots[used.botId]); + if (shown.length === 0) { + return null; + } + return ( +
+ +
    + {shown.map((used) => ( +
  • + + {words.used(bots[used.botId]?.name ?? "", used.app)} + + +
  • + ))} +
+ +
+ ); +} diff --git a/app/src/features/desktop/DesktopGrants.tsx b/app/src/features/desktop/DesktopGrants.tsx index 1dd74b26..a7559a02 100644 --- a/app/src/features/desktop/DesktopGrants.tsx +++ b/app/src/features/desktop/DesktopGrants.tsx @@ -9,6 +9,7 @@ import { useApi } from "../../store/context"; import { Button } from "../../ui/Button"; import { attempt } from "../../ui/toast"; import { RealInput } from "./RealInput"; +import { Unattended } from "./Unattended"; /** The bot's grants, read when shown and kept current by `bot.desktop`. */ function useDesktopGrants(botId: Bot["id"]): DesktopGrant[] | null { @@ -62,6 +63,7 @@ export function DesktopGrants({ bot }: { bot: Bot }) { {grant.level === "act" ? words.act : words.see} {grant.level === "act" && } + {grant.appPath && ( + attempt(words.realFailed, () => + api.call("desktop.setOptions", { + grantId: grant.id, + unattended, + ...(unattended ? { acceptedRisks: true } : {}), + }), + ); + const close = () => { + setAsking(false); + setUnderstood(false); + }; + return ( + <> + + (on ? setAsking(true) : void set(false))} + /> + {words.away} + + {asking && ( + + + + + } + > +
    + {words.awayRisks(bot.name, app).map((risk) => ( +
  • {risk}
  • + ))} +
+ +
+ )} + + ); +} diff --git a/app/src/features/desktop/away.test.tsx b/app/src/features/desktop/away.test.tsx new file mode 100644 index 00000000..8cd6878e --- /dev/null +++ b/app/src/features/desktop/away.test.tsx @@ -0,0 +1,94 @@ +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { setLocaleChoice } from "../../i18n"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp } from "../../test/app"; + +afterEach(() => { + act(() => setLocaleChoice("en")); + cleanup(); +}); + +const NOTEPAD = "C:\\Windows\\notepad.exe"; + +async function opened() { + 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"); + return { fake, scout }; +} + +describe("use while the owner is away", () => { + test("is off, and goes on only after the owner checks that they understand the risks", async () => { + const { fake, scout } = await opened(); + openBot("Scout"); + await screen.findByRole("list", { name: "Messages" }); + act(() => { + fake.desktop.grant(scout.id, NOTEPAD, "Notepad"); + }); + fireEvent.click(screen.getByRole("button", { name: /^Show details/ })); + const details = screen.getByRole("complementary", { name: "About Scout" }); + const toggle = await within(details).findByRole("switch", { + name: "While you are away: Notepad", + }); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + + fireEvent.click(toggle); + const dialog = screen.getByRole("dialog", { + name: "Let Scout work in Notepad while you are away?", + }); + expect(within(dialog).getByText(/Scout can make mistakes/)).toBeDefined(); + expect(within(dialog).getByText(/can try to trick Scout/)).toBeDefined(); + expect(within(dialog).getByText(/nobody watches as it happens/)).toBeDefined(); + expect(within(dialog).getByText(/password managers/)).toBeDefined(); + const confirm = within(dialog).getByRole("button", { name: "Turn on" }) as HTMLButtonElement; + expect(confirm.disabled).toBe(true); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(fake.desktop.grants[0]?.unattended).toBe(false); + + // Asked again, the box starts unchecked. + fireEvent.click(toggle); + const again = screen.getByRole("dialog"); + const box = within(again).getByRole("checkbox", { name: "I understand the risks" }); + expect((box as HTMLInputElement).checked).toBe(false); + fireEvent.click(box); + fireEvent.click(within(again).getByRole("button", { name: "Turn on" })); + await waitFor(() => expect(fake.desktop.grants[0]?.unattended).toBe(true)); + expect(fake.desktop.grants[0]?.acceptedRisksAt).not.toBeNull(); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + + // Off at once, without asking. + fireEvent.click(toggle); + await waitFor(() => expect(fake.desktop.grants[0]?.unattended).toBe(false)); + }, 15_000); + + test("the owner hears, when back, what each bot used and opens its chat", async () => { + const { fake, scout } = await opened(); + act(() => { + fake.desktop.usedAway(scout.id, "Notepad"); + }); + const notice = await screen.findByRole("region", { name: "While you were away" }); + expect(within(notice).getByText("Scout used Notepad while you were away")).toBeDefined(); + + fireEvent.click(within(notice).getByRole("button", { name: "See in the chat" })); + await screen.findByRole("list", { name: "Messages" }); + + fireEvent.click(within(notice).getByRole("button", { name: "Got it" })); + await waitFor(() => + expect(screen.queryByRole("region", { name: "While you were away" })).toBeNull(), + ); + expect(fake.desktop.away).toEqual([]); + }); + + test("speaks the owner's language", async () => { + const { fake, scout } = await opened(); + act(() => setLocaleChoice("pt-BR")); + act(() => { + fake.desktop.usedAway(scout.id, "Excel"); + }); + expect(await screen.findByText("Scout usou o Excel enquanto você estava fora")).toBeDefined(); + }); +}); diff --git a/app/src/features/desktop/realInput.test.tsx b/app/src/features/desktop/realInput.test.tsx index 17fccc4f..d74343dd 100644 --- a/app/src/features/desktop/realInput.test.tsx +++ b/app/src/features/desktop/realInput.test.tsx @@ -54,7 +54,7 @@ describe("the real mouse and keyboard", () => { // Off at once, without asking. fireEvent.click(toggle); await waitFor(() => expect(fake.desktop.grants[0]?.realInput).toBe(false)); - }); + }, 15_000); test("are not offered where the bot may only see", async () => { const { fake, scout } = await openScout(); diff --git a/app/src/i18n/en/desktop.ts b/app/src/i18n/en/desktop.ts index 995e34c4..1390f998 100644 --- a/app/src/i18n/en/desktop.ts +++ b/app/src/i18n/en/desktop.ts @@ -66,5 +66,23 @@ export const desktop = { ], realConfirm: "Turn on", cancel: "Cancel", + away: "While you are away", + awayTitle: (bot: string, app: string) => `Let ${bot} work in ${app} while you are away?`, + awayRisks: (bot: string, app: string) => [ + `${bot} sees everything open in ${app}, personal data, emails, chats and client data included.`, + `${bot} can make mistakes: delete, send or change something in the app as if it were you.`, + `Text in an app or a message can try to trick ${bot} into doing something else.`, + "With you away, nobody watches as it happens. You see it later, in the chat, and Botloft tells you when you are back.", + "Never, not even then: Botloft itself, apps running as administrator, terminals and command boxes, the Task Manager, password managers and password fields.", + ], + awayUnderstood: "I understand the risks", + awayConfirm: "Turn on", + }, + away: { + label: "While you were away", + used: (bot: string, app: string) => `${bot} used ${app} while you were away`, + open: "See in the chat", + dismiss: "Got it", + dismissFailed: "Could not hide it", }, }; diff --git a/app/src/i18n/es/desktop.ts b/app/src/i18n/es/desktop.ts index ffba854f..310da7ba 100644 --- a/app/src/i18n/es/desktop.ts +++ b/app/src/i18n/es/desktop.ts @@ -66,5 +66,23 @@ export const desktop: Messages["desktop"] = { ], realConfirm: "Activar", cancel: "Cancelar", + away: "Sin ti delante", + awayTitle: (bot, app) => `¿Dejar que ${bot} trabaje en ${app} sin ti delante?`, + awayRisks: (bot, app) => [ + `${bot} ve todo lo que esté abierto en ${app}, incluidos datos personales, correos, conversaciones y datos de clientes.`, + `${bot} puede equivocarse: borrar, enviar o cambiar algo en la app como si fueras tú.`, + `Un texto en una app o en un mensaje puede intentar engañar a ${bot} para que haga otra cosa.`, + "Sin ti delante, nadie lo ve en el momento. Lo ves después, en el chat, y Botloft te avisa cuando vuelvas.", + "Nunca, ni así: el propio Botloft, apps que se ejecutan como administrador, terminales y ventanas de comandos, el Administrador de tareas, gestores de contraseñas y campos de contraseña.", + ], + awayUnderstood: "Entiendo los riesgos", + awayConfirm: "Activar", + }, + away: { + label: "Mientras no estabas", + used: (bot, app) => `${bot} usó ${app} mientras no estabas`, + open: "Ver en el chat", + dismiss: "Entendido", + dismissFailed: "No se pudo ocultar", }, }; diff --git a/app/src/i18n/pt-BR/desktop.ts b/app/src/i18n/pt-BR/desktop.ts index 9649f993..ecf0e4e8 100644 --- a/app/src/i18n/pt-BR/desktop.ts +++ b/app/src/i18n/pt-BR/desktop.ts @@ -65,5 +65,23 @@ export const desktop: Messages["desktop"] = { ], realConfirm: "Ligar", cancel: "Cancelar", + away: "Sem você na frente", + awayTitle: (bot, app) => `Deixar ${bot} trabalhar no ${app} sem você na frente?`, + awayRisks: (bot, app) => [ + `${bot} vê tudo o que estiver aberto no ${app}, inclusive dados pessoais, e-mails, conversas e dados de clientes.`, + `${bot} pode errar: apagar, enviar ou mudar algo no app como se fosse você.`, + `Um texto num app ou numa mensagem pode tentar enganar ${bot} para fazer outra coisa.`, + "Sem você na frente, ninguém vê na hora. Você vê depois, no chat, e o Botloft avisa quando você voltar.", + "Nunca, nem assim: o próprio Botloft, apps rodando como administrador, terminais e caixas de comando, o Gerenciador de Tarefas, gerenciadores de senha e campos de senha.", + ], + awayUnderstood: "Entendi os riscos", + awayConfirm: "Ligar", + }, + away: { + label: "Enquanto você estava fora", + used: (bot, app) => `${bot} usou o ${app} enquanto você estava fora`, + open: "Ver no chat", + dismiss: "Entendi", + dismissFailed: "Não foi possível esconder", }, }; diff --git a/app/src/lib/fakeDesktop.ts b/app/src/lib/fakeDesktop.ts index 93c014e9..9a71714e 100644 --- a/app/src/lib/fakeDesktop.ts +++ b/app/src/lib/fakeDesktop.ts @@ -8,7 +8,9 @@ import type { FakeBotloft, Handlers } from "./fake"; import { notFound } from "./fakeRules"; import type { BotId, + ChatItemId, DesktopAction, + DesktopAwayUse, DesktopFrame, DesktopGrant, DesktopLevel, @@ -22,6 +24,8 @@ export class FakeDesktop { readonly grants: DesktopGrant[] = []; private readonly states = new Map(); private readonly frames = new Map(); + /** What bots did while the owner was away. */ + away: DesktopAwayUse[] = []; /** The bot whose panel the app watches, if any. */ watching: BotId | null = null; @@ -79,6 +83,17 @@ export class FakeDesktop { return this.set(botId, { window, action, at: this.fake.now }); } + /** The bot used `app` while the owner was away (spec 24.8). */ + usedAway(botId: BotId, app: string, itemId: ChatItemId | null = null): void { + const same = this.away.find((used) => used.botId === botId && used.app === app); + if (same) { + same.until = this.fake.now; + } else { + this.away.push({ botId, app, from: this.fake.now, until: this.fake.now, itemId }); + } + this.fake.emit({ name: "desktop.away", params: [...this.away] }); + } + /** A new picture of the bot's window: sent while its panel watches. */ paint(botId: BotId, data: string, width = 800, height = 600): void { const frame = { botId, data, width, height }; @@ -104,14 +119,21 @@ export class FakeDesktop { this.changed(grant.botId); return { botId: grant.botId, grants: this.of(grant.botId) }; }, - "desktop.setOptions": ({ grantId, realInput }) => { + "desktop.setOptions": ({ grantId, realInput, unattended, acceptedRisks }) => { const grant = this.grants.find((each) => each.id === grantId); if (!grant) { throw notFound(`desktop grant ${grantId}`); } + if (unattended && !acceptedRisks) { + throw new Error("use while the owner is away needs the risks accepted first"); + } if (realInput !== undefined) { grant.realInput = realInput; } + if (unattended !== undefined) { + grant.unattended = unattended; + grant.acceptedRisksAt = unattended ? this.fake.now : grant.acceptedRisksAt; + } this.changed(grant.botId); return { botId: grant.botId, grants: this.of(grant.botId) }; }, @@ -132,6 +154,12 @@ export class FakeDesktop { this.fake.bot(botId); return this.set(botId, { stopped: false }); }, + "desktop.awayUses": () => this.away, + "desktop.dismissAway": () => { + this.away = []; + this.fake.emit({ name: "desktop.away", params: [] }); + return null; + }, }; } } diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 57dc2efb..ad8ccfab 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -876,7 +876,17 @@ export type DesktopOptionsParams = { grantId: DesktopGrantId, /** * The bot may use the real mouse and keyboard there (spec 24.7). */ -realInput?: boolean, }; +realInput?: boolean, +/** + * The bot may use the grant's reach while the owner is away (spec + * 24.8); turning it on needs `acceptedRisks`. + */ +unattended?: boolean, +/** + * The owner checked "I understand the risks" on the risks screen + * (spec 24.10). + */ +acceptedRisks?: boolean, }; /** * A bot's grants after one changed: the result of `desktop.revoke` and @@ -956,6 +966,26 @@ export type DesktopView = { state: DesktopState, frame: DesktopFrame | null, }; */ export type DesktopBotParams = { botId: BotId, }; +/** + * A bot used an app while the owner was away (spec 24.8): what the app + * tells them when they are back, until they dismiss it. The result of + * `desktop.awayUses` and the params of `desktop.away` are a list of these. + */ +export type DesktopAwayUse = { botId: BotId, +/** + * The app's name for people. + */ +app: string, +/** + * The first and the last use, Unix ms. + */ +from: number, until: number, +/** + * The newest item of the bot's chat at the first use, to open the chat + * there. + */ +itemId: ChatItemId | null, }; + export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string, /** * What the bot says the command is for, as in `ToolItem`. The bot @@ -1644,6 +1674,8 @@ export interface RpcMethods { "desktop.unwatch": { params: undefined; result: null }; "desktop.stop": { params: DesktopBotParams; result: DesktopState }; "desktop.resume": { params: DesktopBotParams; result: DesktopState }; + "desktop.awayUses": { params: undefined; result: Array }; + "desktop.dismissAway": { params: undefined; result: null }; "routines.list": { params: RoutinesListParams; result: Array }; "routines.create": { params: RoutinesCreateParams; result: Routine }; "routines.update": { params: RoutinesUpdateParams; result: Routine }; @@ -1695,6 +1727,7 @@ export interface RpcNotifications { "bot.desktop": BotDesktop; "desktop.changed": DesktopState; "desktop.frame": DesktopFrame; + "desktop.away": Array; "browser.changed": BrowserState; "browser.action": BrowserAction; "browser.frame": BrowserFrame; diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx index 0098a809..bcdafe76 100644 --- a/app/src/shell/Workspace.tsx +++ b/app/src/shell/Workspace.tsx @@ -8,6 +8,7 @@ import { BotView } from "../features/bots/BotView"; import { CrewsOverview } from "../features/crews/CrewsOverview"; import { CrewView } from "../features/crews/CrewView"; import { Sidebar } from "../features/crews/Sidebar"; +import { AwayNotice } from "../features/desktop/AwayNotice"; import { useLocaleToDaemon } from "../features/desktop/useLocaleToDaemon"; import { FailedDeliveries } from "../features/messages/FailedDeliveries"; import { ClaudeCodeHelp } from "../features/onboarding/ClaudeCodeHelp"; @@ -91,6 +92,7 @@ export function Workspace() { ) )} + diff --git a/crates/botloft-core/src/protocol/desktop.rs b/crates/botloft-core/src/protocol/desktop.rs index 38743aa5..6759edcf 100644 --- a/crates/botloft-core/src/protocol/desktop.rs +++ b/crates/botloft-core/src/protocol/desktop.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; -use crate::ids::{BotId, DesktopGrantId}; +use crate::ids::{BotId, ChatItemId, DesktopGrantId}; /// What a grant reaches. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -107,6 +107,34 @@ pub struct DesktopOptionsParams { #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(test, ts(optional))] pub real_input: Option, + /// The bot may use the grant's reach while the owner is away (spec + /// 24.8); turning it on needs `acceptedRisks`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, ts(optional))] + pub unattended: Option, + /// The owner checked "I understand the risks" on the risks screen + /// (spec 24.10). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, ts(optional))] + pub accepted_risks: Option, +} + +/// A bot used an app while the owner was away (spec 24.8): what the app +/// tells them when they are back, until they dismiss it. The result of +/// `desktop.awayUses` and the params of `desktop.away` are a list of these. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct DesktopAwayUse { + pub bot_id: BotId, + /// The app's name for people. + pub app: String, + /// The first and the last use, Unix ms. + pub from: i64, + pub until: i64, + /// The newest item of the bot's chat at the first use, to open the chat + /// there. + pub item_id: Option, } /// A bot's grants after one changed: the result of `desktop.revoke` and diff --git a/crates/botloft-core/src/protocol/export_desktop.rs b/crates/botloft-core/src/protocol/export_desktop.rs index bb181620..be663315 100644 --- a/crates/botloft-core/src/protocol/export_desktop.rs +++ b/crates/botloft-core/src/protocol/export_desktop.rs @@ -20,6 +20,7 @@ pub(super) fn decls(out: &mut Out) { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); } pub(super) fn methods(out: &mut Out) { @@ -44,6 +45,9 @@ pub(super) fn methods(out: &mut Out) { let state = out.name::(); out.method(method::DESKTOP_STOP, &bot, &state); out.method(method::DESKTOP_RESUME, &bot, &state); + let away = out.name::>(); + out.method(method::DESKTOP_AWAY_USES, "undefined", &away); + out.method(method::DESKTOP_DISMISS_AWAY, "undefined", "null"); } pub(super) fn notifications(out: &mut Out) { @@ -57,4 +61,6 @@ pub(super) fn notifications(out: &mut Out) { ); let frame = out.name::(); let _ = writeln!(out.text, " \"{}\": {frame};", notification::DESKTOP_FRAME); + let away = out.name::>(); + let _ = writeln!(out.text, " \"{}\": {away};", notification::DESKTOP_AWAY); } diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 772e478f..24a89126 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -131,6 +131,8 @@ pub mod method { pub const DESKTOP_UNWATCH: &str = "desktop.unwatch"; pub const DESKTOP_STOP: &str = "desktop.stop"; pub const DESKTOP_RESUME: &str = "desktop.resume"; + pub const DESKTOP_AWAY_USES: &str = "desktop.awayUses"; + pub const DESKTOP_DISMISS_AWAY: &str = "desktop.dismissAway"; pub const MESSAGES_SEND: &str = "messages.send"; pub const MESSAGES_LIST: &str = "messages.list"; pub const ATTACHMENTS_READ: &str = "attachments.read"; @@ -182,6 +184,8 @@ pub mod notification { pub const BOT_RULES: &str = "bot.rules"; pub const BOT_DESKTOP: &str = "bot.desktop"; pub const DESKTOP_CHANGED: &str = "desktop.changed"; + /// What bots did while the owner was away, whenever it changes. + pub const DESKTOP_AWAY: &str = "desktop.away"; /// Only to the connection watching that bot's desktop panel (spec 24.9). pub const DESKTOP_FRAME: &str = "desktop.frame"; pub const CHAT_ITEM: &str = "chat.item"; diff --git a/crates/botloft-store/src/desktop_grants.rs b/crates/botloft-store/src/desktop_grants.rs index 0d39c9f3..2899a502 100644 --- a/crates/botloft-store/src/desktop_grants.rs +++ b/crates/botloft-store/src/desktop_grants.rs @@ -98,6 +98,28 @@ impl Store { .optional()?) } + /// Lets the bot use the grant's reach while the owner is away, or not + /// (spec 24.8). Turning it on keeps `now` as when the owner accepted + /// the risks (spec 24.10); the bot the grant belongs to, or `None` if + /// there is no such grant. + pub fn set_desktop_unattended( + &self, + grant: &DesktopGrantId, + on: bool, + now: i64, + ) -> Result> { + Ok(self + .conn + .query_row( + "UPDATE desktop_grants SET unattended = ?2, \ + accepted_risks_at = CASE WHEN ?2 THEN ?3 ELSE accepted_risks_at END \ + WHERE id = ?1 RETURNING bot_id", + params![grant.as_str(), on, now], + |row| parse_column(row, 0), + ) + .optional()?) + } + /// The bot a grant belongs to, if the grant is there. pub fn desktop_grants_of(&self, grant: &DesktopGrantId) -> Result> { Ok(self @@ -187,6 +209,32 @@ mod tests { assert!(!fx.store.desktop_grants(scout).expect("grants")[0].real_input); } + #[test] + fn use_while_away_keeps_when_the_risks_were_accepted() { + let fx = Fixture::new(); + let scout = &fx.bots[0].id; + let grant = fx + .store + .grant_desktop_app(scout, EXCEL, "Microsoft Excel", DesktopLevel::See, 10) + .expect("grant"); + assert_eq!( + fx.store + .set_desktop_unattended(&grant.id, true, 50) + .expect("on"), + Some(scout.clone()) + ); + let on = &fx.store.desktop_grants(scout).expect("grants")[0]; + assert!(on.unattended); + assert_eq!(on.accepted_risks_at, Some(50)); + // Turning it off keeps when they accepted. + fx.store + .set_desktop_unattended(&grant.id, false, 60) + .expect("off"); + let off = &fx.store.desktop_grants(scout).expect("grants")[0]; + assert!(!off.unattended); + assert_eq!(off.accepted_risks_at, Some(50)); + } + #[test] fn a_grant_can_be_taken_away() { let fx = Fixture::new(); diff --git a/crates/botloftd/src/desktop/away.rs b/crates/botloftd/src/desktop/away.rs new file mode 100644 index 00000000..0728952e --- /dev/null +++ b/crates/botloftd/src/desktop/away.rs @@ -0,0 +1,80 @@ +//! What bots did on the owner's desktop while the owner was away (spec +//! 24.8): one line per bot and app, kept until the owner dismisses them, +//! so the app tells them when they are back. In memory: what the bot did +//! is in its chat anyway. + +use std::sync::{Mutex, PoisonError}; + +use botloft_core::ids::{BotId, ChatItemId}; +use botloft_core::protocol::DesktopAwayUse; + +#[derive(Default)] +pub struct AwayUses { + uses: Mutex>, +} + +impl AwayUses { + /// `bot` used `app` at `now`; `item` is the newest of its chat then. + /// The list now. + pub fn record( + &self, + bot: &BotId, + app: &str, + now: i64, + item: Option, + ) -> Vec { + let mut uses = self.uses.lock().unwrap_or_else(PoisonError::into_inner); + match uses + .iter_mut() + .find(|used| used.bot_id == *bot && used.app == app) + { + Some(used) => used.until = now, + None => uses.push(DesktopAwayUse { + bot_id: bot.clone(), + app: app.to_owned(), + from: now, + until: now, + item_id: item, + }), + } + uses.clone() + } + + /// Oldest first. + pub fn list(&self) -> Vec { + self.uses + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + /// The owner saw them. + pub fn clear(&self) { + self.uses + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn one_line_per_bot_and_app_until_the_owner_dismisses_them() { + let away = AwayUses::default(); + let (scout, writer) = (BotId::generate(), BotId::generate()); + let item = ChatItemId::generate(); + away.record(&scout, "Excel", 10, Some(item.clone())); + away.record(&scout, "Excel", 20, None); + away.record(&writer, "Excel", 30, None); + let uses = away.record(&scout, "Notepad", 40, None); + assert_eq!(uses.len(), 3); + assert_eq!((uses[0].from, uses[0].until), (10, 20)); + assert_eq!(uses[0].item_id, Some(item), "the chat where it began"); + assert_eq!(uses[1].bot_id, writer); + away.clear(); + assert!(away.list().is_empty()); + } +} diff --git a/crates/botloftd/src/desktop/mod.rs b/crates/botloftd/src/desktop/mod.rs index c4fd8d74..db92bcdd 100644 --- a/crates/botloftd/src/desktop/mod.rs +++ b/crates/botloftd/src/desktop/mod.rs @@ -4,10 +4,12 @@ //! its panel, live. mod activity; +mod away; pub mod notice; mod screen; pub use activity::{Activity, STOPPED}; +pub use away::AwayUses; pub use screen::DesktopWatching; use std::collections::HashMap; @@ -51,6 +53,8 @@ pub struct Desktop { turn: tokio::sync::Mutex<()>, idle: Mutex, pub activity: Arc, + /// What bots did while the owner was away (spec 24.8). + pub away: AwayUses, screens: Arc, /// When the owner last took the mouse or keyboard back from a bot. took_over: Mutex>, @@ -95,6 +99,7 @@ impl Desktop { turn: tokio::sync::Mutex::new(()), idle: Mutex::new(idle), activity: Arc::default(), + away: AwayUses::default(), screens: Arc::default(), took_over: Mutex::new(None), pictures: Mutex::new(HashMap::new()), diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index 382d3f75..9a076731 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -58,6 +58,8 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(desktop::revoke(daemon, parse(params)?)), method::DESKTOP_STOP => reply(desktop::stop(daemon, parse(params)?)), method::DESKTOP_SET_OPTIONS => reply(desktop::set_options(daemon, parse(params)?)), + method::DESKTOP_AWAY_USES => reply(desktop::away_uses(daemon)), + method::DESKTOP_DISMISS_AWAY => reply(desktop::dismiss_away(daemon)), method::DESKTOP_RESUME => reply(desktop::resume(daemon, parse(params)?)), method::MESSAGES_SEND => reply(messages::send(daemon, parse(params)?)), method::MESSAGES_LIST => reply(messages::list(daemon, parse(params)?)), diff --git a/crates/botloftd/src/rpc/notify.rs b/crates/botloftd/src/rpc/notify.rs index 1c2d65f5..02d81f2f 100644 --- a/crates/botloftd/src/rpc/notify.rs +++ b/crates/botloftd/src/rpc/notify.rs @@ -18,6 +18,7 @@ pub(super) fn to_notification(event: &Event) -> String { Event::BotRules(rules) => note(notification::BOT_RULES, rules), Event::BotDesktop(desktop) => note(notification::BOT_DESKTOP, desktop), Event::DesktopChanged(state) => note(notification::DESKTOP_CHANGED, state), + Event::DesktopAway(uses) => note(notification::DESKTOP_AWAY, uses), Event::ChatItem(item) => note(notification::CHAT_ITEM, item), Event::ChatDelta(delta) => note(notification::CHAT_DELTA, delta), Event::MessageCreated(message) => note(notification::MESSAGE_CREATED, message), diff --git a/crates/botloftd/src/service/desktop.rs b/crates/botloftd/src/service/desktop.rs index 249b1599..4781d776 100644 --- a/crates/botloftd/src/service/desktop.rs +++ b/crates/botloftd/src/service/desktop.rs @@ -6,8 +6,8 @@ use botloft_core::ids::BotId; use botloft_core::protocol::{ - BotDesktop, DesktopBotParams, DesktopGrant, DesktopGrantIdParams, DesktopGrantsParams, - DesktopOptionsParams, DesktopState, + BotDesktop, DesktopAwayUse, DesktopBotParams, DesktopGrant, DesktopGrantIdParams, + DesktopGrantsParams, DesktopOptionsParams, DesktopState, }; use tracing::{info, warn}; @@ -22,23 +22,42 @@ pub fn grants(daemon: &Daemon, params: DesktopGrantsParams) -> ApiResult ApiResult { - let Some(on) = params.real_input else { - let store = daemon.store(); - let grant = store - .desktop_grants_of(¶ms.grant_id)? - .ok_or_else(|| ApiError::NotFound(format!("desktop grant {}", params.grant_id)))?; - drop(store); - return changed(daemon, &grant); - }; - let bot_id = daemon - .store() - .set_desktop_real_input(¶ms.grant_id, on)? + if params.unattended == Some(true) && params.accepted_risks != Some(true) { + return Err(ApiError::Conflict( + "use while the owner is away needs the risks accepted first".to_owned(), + )); + } + let store = daemon.store(); + let bot_id = store + .desktop_grants_of(¶ms.grant_id)? .ok_or_else(|| ApiError::NotFound(format!("desktop grant {}", params.grant_id)))?; + if let Some(on) = params.real_input { + store.set_desktop_real_input(¶ms.grant_id, on)?; + } + if let Some(on) = params.unattended { + store.set_desktop_unattended(¶ms.grant_id, on, daemon.clock.now_ms())?; + info!(on, "use of the desktop while the owner is away changed"); + } + drop(store); changed(daemon, &bot_id) } +/// What bots did while the owner was away, for the app to tell them. +pub fn away_uses(daemon: &Daemon) -> ApiResult> { + Ok(daemon.desktop.away.list()) +} + +/// The owner saw what bots did while they were away. +pub fn dismiss_away(daemon: &Daemon) -> ApiResult<()> { + daemon.desktop.away.clear(); + daemon.emit(Event::DesktopAway(Vec::new())); + Ok(()) +} + /// Takes a grant away: the bot asks for that again. pub fn revoke(daemon: &Daemon, params: DesktopGrantIdParams) -> ApiResult { let bot_id = daemon diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs index f4578162..827792db 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -8,8 +8,8 @@ use std::time::Instant; use botloft_core::protocol::{ AccountUsage, Bot, BotContextChanged, BotDeleted, BotDesktop, BotRules, BotStateChanged, BrowserAction, BrowserState, ChatDelta, ChatItemChanged, Crew, CrewDeleted, Delivery, - DesktopState, FolderRecycled, Message, Question, ReactionChanged, Routine, RoutineRun, - ScreenDraft, Task, + DesktopAwayUse, DesktopState, FolderRecycled, Message, Question, ReactionChanged, Routine, + RoutineRun, ScreenDraft, Task, }; use botloft_store::Store; use tokio::runtime::{Handle, RuntimeFlavor}; @@ -45,6 +45,7 @@ pub enum Event { BotRules(BotRules), BotDesktop(BotDesktop), DesktopChanged(DesktopState), + DesktopAway(Vec), ChatItem(ChatItemChanged), ChatDelta(ChatDelta), MessageCreated(Message), diff --git a/crates/botloftd/src/tools/desktop.rs b/crates/botloftd/src/tools/desktop.rs index ae3ee76d..f08c50a9 100644 --- a/crates/botloftd/src/tools/desktop.rs +++ b/crates/botloftd/src/tools/desktop.rs @@ -2,7 +2,8 @@ //! one through its accessibility tree or takes its picture, and clicks, //! types, chooses and scrolls in it. Each app asks the owner first, to see //! and then to use it (spec 24.2); what is never granted stays out (24.3), -//! and the owner must be at their computer (24.8). +//! and the owner must be at their computer, unless the grant lets the bot +//! work while they are away (24.8). use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; @@ -14,7 +15,7 @@ use tracing::debug; use super::calls::explain; use super::desktop_act::{Ask, act, answered}; use super::desktop_grant::{blocking, granted, heading}; -use super::desktop_list::list; +use super::desktop_list::{in_reach, list}; use super::desktop_real::{click_at, press}; use crate::desktop::STOPPED; use crate::platform::desktop::Window; @@ -176,10 +177,6 @@ async fn run(daemon: &Daemon, bot: &BotId, generation: u64, tool: Tool) -> Resul if daemon.desktop.activity.stopped(bot) { return Err(STOPPED.to_owned()); } - daemon - .desktop - .owner_here() - .map_err(|away| away.why().to_owned())?; let windows = blocking(platform::windows).await?; let grants = daemon .store() @@ -187,7 +184,7 @@ async fn run(daemon: &Daemon, bot: &BotId, generation: u64, tool: Tool) -> Resul .map_err(|err| explain(err.into()))?; let see = DesktopLevel::See; match tool { - Tool::Windows => Ok(Reply::Text(list(&windows, &grants))), + Tool::Windows => Ok(Reply::Text(list(&windows, &in_reach(daemon, grants)?))), Tool::Look { window, from, why } => { let window = granted( daemon, diff --git a/crates/botloftd/src/tools/desktop_grant.rs b/crates/botloftd/src/tools/desktop_grant.rs index da031724..2fe42ea1 100644 --- a/crates/botloftd/src/tools/desktop_grant.rs +++ b/crates/botloftd/src/tools/desktop_grant.rs @@ -12,7 +12,7 @@ use super::desktop_list::covering; use crate::approvals::{self, Answer}; use crate::platform::desktop::{DesktopError, Window, never}; use crate::service::desktop::changed; -use crate::state::Daemon; +use crate::state::{Daemon, Event}; pub(super) fn describe(err: &DesktopError) -> String { match err { @@ -81,6 +81,20 @@ fn verb(level: DesktopLevel) -> &'static str { } } +/// The bot used `window` with the owner away: the app tells them when they +/// are back, with the chat where it began. +fn used_away(daemon: &Daemon, bot: &BotId, window: &Window) { + let item = daemon + .store() + .chat_history(bot, None, 1) + .ok() + .and_then(|items| items.into_iter().next()) + .map(|item| item.id); + let now = daemon.clock.now_ms(); + let uses = daemon.desktop.away.record(bot, &window.app.name, now, item); + daemon.emit(Event::DesktopAway(uses)); +} + /// The window `id`, once the bot may see or act in its app (`level`): /// asks the owner the first time, with the bot's `why` (spec 24.2). #[allow(clippy::too_many_arguments)] @@ -105,6 +119,15 @@ pub(super) async fn granted( never.why() )); } + // Without the owner there, only a grant for use while they are away + // reaches the window, and nobody is asked (spec 24.8). + if let Err(away) = daemon.desktop.owner_here() { + if !covering(grants, &window, level).is_some_and(|grant| grant.unattended) { + return Err(away.why().to_owned()); + } + used_away(daemon, bot, &window); + return Ok(window); + } if covering(grants, &window, level).is_some() { return Ok(window); } diff --git a/crates/botloftd/src/tools/desktop_list.rs b/crates/botloftd/src/tools/desktop_list.rs index 635b8ccf..7ac15bef 100644 --- a/crates/botloftd/src/tools/desktop_list.rs +++ b/crates/botloftd/src/tools/desktop_list.rs @@ -5,11 +5,32 @@ use botloft_core::protocol::{DesktopGrant, DesktopLevel, DesktopScope}; use crate::platform::desktop::{Window, never}; +use crate::state::Daemon; fn same_path(left: &str, right: &str) -> bool { left.eq_ignore_ascii_case(right) } +/// The grants whose windows the bot sees by title now: all of them with +/// the owner there; with the owner away, only those for use while they +/// are, and none at all is a refusal (spec 24.8). +pub(super) fn in_reach( + daemon: &Daemon, + grants: Vec, +) -> Result, String> { + let Err(away) = daemon.desktop.owner_here() else { + return Ok(grants); + }; + let alone: Vec<_> = grants + .into_iter() + .filter(|grant| grant.unattended) + .collect(); + if alone.is_empty() { + return Err(away.why().to_owned()); + } + Ok(alone) +} + /// The grant of `bot` that covers `window` at `level`, if one does. pub(super) fn covering<'a>( grants: &'a [DesktopGrant], diff --git a/crates/botloftd/tests/desktop_away.rs b/crates/botloftd/tests/desktop_away.rs new file mode 100644 index 00000000..d7f3a7c4 --- /dev/null +++ b/crates/botloftd/tests/desktop_away.rs @@ -0,0 +1,106 @@ +//! A bot on the owner's desktop while the owner is away (spec 24.8): only +//! in the reach of a grant they turned that on for, after accepting the +//! risks (24.10), and the app hears of each app the bot used then. +//! Windows only. +#![cfg(windows)] + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use common::browsing::{call, pending_approval}; +use common::desktop::{TITLE, TestWindow, reading, ref_in, setup}; +use serde_json::json; + +#[tokio::test(flavor = "multi_thread")] +async fn a_bot_works_while_the_owner_is_away_only_where_they_allowed_it() { + let window = TestWindow::open(); + let mut s = setup().await; + let look = json!({ "window": window.id, "why": "To file the night's visits" }); + + // The owner lets the bot see the app while at the computer. + let reading_it = call(&s.mcp, "desktop_look", look.clone()); + let asked = pending_approval(&mut s.app).await; + let allow = json!({ "approvalId": asked["approvalId"], "allow": true }); + s.app.call("approvals.answer", allow).await.expect("answer"); + reading_it.await.expect("task").expect("reading"); + let grants = s + .app + .call("desktop.grants", json!({ "botId": s.bot["id"] })) + .await + .expect("grants"); + let grant = grants[0]["id"].clone(); + + // Then they leave: without the option, nothing works and nobody is asked. + s.t.daemon + .desktop + .set_owner_idle(Arc::new(|| Some(Duration::from_secs(3600)))); + let away = s.mcp.tool_text("desktop_look", look.clone()).await; + assert!( + reading(&away).contains("has not used the computer"), + "{}", + reading(&away) + ); + let listed = s.mcp.tool_text("desktop_windows", json!({})).await; + assert!(listed.is_err(), "{}", reading(&listed)); + + // The option goes on only with the risks accepted. + let unaccepted = json!({ "grantId": grant, "unattended": true }); + assert!(s.app.call("desktop.setOptions", unaccepted).await.is_err()); + let accepted = json!({ "grantId": grant, "unattended": true, "acceptedRisks": true }); + let options = s + .app + .call("desktop.setOptions", accepted) + .await + .expect("options"); + assert_eq!(options["grants"][0]["unattended"], true); + assert!(options["grants"][0]["acceptedRisksAt"].is_i64()); + + // Now the bot sees that app with the owner away. + let listed = s + .mcp + .tool_text("desktop_windows", json!({})) + .await + .expect("windows"); + assert!(listed.contains(TITLE), "{listed}"); + let text = s + .mcp + .tool_text("desktop_look", look) + .await + .expect("reading"); + assert!(text.contains(r#"button "Save""#), "{text}"); + + // The app hears of it, to tell the owner when they are back. + let used = s.app.notification("desktop.away").await; + assert_eq!(used[0]["botId"], s.bot["id"]); + assert!(used[0]["app"].is_string(), "{used}"); + + // Using it needs a grant to use, and the owner is not there to give it. + let save = ref_in(&text, r#"button "Save""#); + let click = json!({ "ref": save, "why": "To save the visit" }); + let refused = s.mcp.tool_text("desktop_click", click).await; + assert!( + reading(&refused).contains("has not used the computer"), + "{}", + reading(&refused) + ); + + // Back, the owner dismisses the notice. + let uses = s + .app + .call("desktop.awayUses", json!(null)) + .await + .expect("uses"); + assert_eq!(uses.as_array().map(Vec::len), Some(1)); + s.app + .call("desktop.dismissAway", json!(null)) + .await + .expect("dismiss"); + let uses = s + .app + .call("desktop.awayUses", json!(null)) + .await + .expect("uses"); + assert_eq!(uses.as_array().map(Vec::len), Some(0)); +} diff --git a/docs/spec.md b/docs/spec.md index cb415642..c79cd1b7 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1653,7 +1653,7 @@ Tabela `questions`: `id, crew_id, bot_id, chat_item_id, text, options (JSON), st ## 24. Desktop -Status: **D1, D2, D3 e D4 implementados** (24.12). É o item 10 da seção 18. Decisões e riscos em `docs/adr/0002-desktop-use.md`. +Status: **D1 a D5 implementados** (24.12). É o item 10 da seção 18. Decisões e riscos em `docs/adr/0002-desktop-use.md`. ### 24.1 O que é @@ -1758,8 +1758,9 @@ No servidor `botloft` (11), como as do navegador. ### 24.8 Sem você na frente - Só no alcance de uma permissão com "Sem você na frente" ligado (24.2). Sem isso, as tools `desktop_*` só agem enquanto o app do Botloft está aberto (alguma conexão que passou do `session.hello`) e o dono mexeu no computador nos últimos 5 minutos (`GetLastInputInfo`; a partir do D4, sem contar a entrada injetada pelo próprio bot); senão respondem que o dono não está e que é preciso ligar a opção. +- A regra vale depois de achar a janela: com o dono fora, ela passa só se uma permissão com a opção ligada cobre o app no nível que a tool pede. Ninguém é perguntado sem o dono: uma permissão de ver com a opção não vira de usar enquanto ele está fora, e um app sem permissão recusa na hora. `desktop_windows` com o dono fora mostra com título só as janelas dessas permissões; sem nenhuma, recusa. - Com a tela bloqueada, ver e mexer por acessibilidade continuam, se o Windows deixar (19); mouse e teclado, não (24.7). -- Tudo o que o bot fez sem o dono fica no chat como sempre, e o app avisa na volta: " usou o Excel enquanto você estava fora", com o link para o trecho do chat. +- Tudo o que o bot fez sem o dono fica no chat como sempre, e o app avisa na volta: " usou o Excel enquanto você estava fora", com "Ver no chat", que abre o chat do bot no item mais novo de quando ele começou (`openAt`). O daemon guarda, na memória, uma linha por bot e app (`DesktopAwayUse`: o bot, o app, o primeiro e o último uso e esse item), cada uso de uma janela com o dono fora a atualiza e sai `desktop.away` com a lista toda. O app a lê ao abrir (`desktop.awayUses`) e a mostra numa faixa acima do painel principal até o dono apertar "Entendi" (`desktop.dismissAway`, que limpa a lista e manda `desktop.away` vazio). Um daemon que reinicia esquece a lista; o chat continua lá. ### 24.9 App @@ -1779,7 +1780,8 @@ No servidor `botloft` (11), como as do navegador. - um texto num app ou numa mensagem pode tentar enganar o bot para fazer outra coisa; - sem você na frente, ninguém vê na hora; você vê depois, no chat; - o que nunca é liberado (24.3). - O botão de confirmar só acende depois de o dono marcar "Entendi os riscos". A data em que ele aceitou fica gravada com a permissão. + O botão de confirmar só acende depois de o dono marcar "Entendi os riscos"; a caixa começa desmarcada toda vez. A data em que ele aceitou fica gravada com a permissão (`accepted_risks_at`) e fica lá se ele desliga. +- **Sem você na frente**: em cada permissão, de ver ou de usar, um interruptor, desligado de início. Ligar abre a tela de riscos acima; desligar é na hora. O app chama `desktop.setOptions {grantId, unattended, acceptedRisks}`; o daemon recusa ligar sem `acceptedRisks: true`. - **Mouse e teclado de verdade**: em cada permissão de usar um app, um interruptor, desligado de início (só nas de usar). Ligar abre antes um diálogo, "Deixar usar seu mouse e teclado de verdade no ?", que diz o que muda: quando a acessibilidade não alcança, o bot move o cursor e digita como se fosse o dono; enquanto ele age, o mouse e o teclado são dele, com o aviso na tela; mexer no mouse ou numa tecla o para na hora, e Ctrl+Alt+Esc para todos; nunca campos de senha nem teclas do Windows. Desligar é na hora. O app chama `desktop.setOptions`. ### 24.11 Dados