Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/src/features/desktop/DesktopGrants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Button } from "../../ui/Button";
import { attempt } from "../../ui/toast";
import { RealInput } from "./RealInput";
import { Unattended } from "./Unattended";
import { WholeDesktop } from "./WholeDesktop";

/** The bot's grants, read when shown and kept current by `bot.desktop`. */
export function useDesktopGrants(botId: Bot["id"]): DesktopGrant[] | null {
Expand Down Expand Up @@ -89,6 +90,11 @@ export function DesktopGrants({ bot }: { bot: Bot }) {
})}
</ul>
)}
{grants !== null && !grants.some((grant) => grant.scope === "desktop") && (
<div className="mt-2">
<WholeDesktop bot={bot} />
</div>
)}
</dd>
</div>
);
Expand Down
85 changes: 85 additions & 0 deletions app/src/features/desktop/WholeDesktop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Giving a bot the owner's whole desktop (spec 24.2, 24.10): a button in
// the bot's details that opens the risks screen, with the level to give;
// the confirm button lights only once the owner checks that they
// understand the risks. Never asked for in the chat.

import { Check, Monitor } from "lucide-react";
import { useState } from "react";
import { useT } from "../../i18n";
import type { Bot, DesktopLevel } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";
import { Button } from "../../ui/Button";
import { Choices } from "../../ui/Choices";
import { Dialog } from "../../ui/Dialog";
import { attempt } from "../../ui/toast";

export function WholeDesktop({ bot }: { bot: Bot }) {
const words = useT().desktop.grants;
const api = useApi();
const [asking, setAsking] = useState(false);
const [level, setLevel] = useState<DesktopLevel>("see");
const [understood, setUnderstood] = useState(false);
const close = () => {
setAsking(false);
setUnderstood(false);
setLevel("see");
};
const give = () =>
attempt(words.wholeFailed, () =>
api.call("desktop.grantWhole", { botId: bot.id, level, acceptedRisks: true }),
);
return (
<>
<Button variant="secondary" size="sm" icon={Monitor} onClick={() => setAsking(true)}>
{words.giveWhole}
</Button>
{asking && (
<Dialog
title={words.wholeTitle(bot.name)}
onClose={close}
footer={
<>
<Button variant="ghost" onClick={close}>
{words.cancel}
</Button>
<Button
variant="primary"
icon={Check}
disabled={!understood}
onClick={() => {
close();
void give();
}}
>
{words.wholeConfirm}
</Button>
</>
}
>
<Choices<DesktopLevel>
label={words.wholeLevel}
value={level}
options={[
{ value: "see", label: words.see },
{ value: "act", label: words.act },
]}
onChange={setLevel}
/>
<ul className="mt-4 flex list-disc flex-col gap-1.5 pl-5 text-sm">
{words.wholeRisks(bot.name).map((risk) => (
<li key={risk}>{risk}</li>
))}
</ul>
<label className="mt-4 flex items-center gap-2 font-medium text-sm">
<input
type="checkbox"
checked={understood}
onChange={(event) => setUnderstood(event.target.checked)}
/>
{words.awayUnderstood}
</label>
</Dialog>
)}
</>
);
}
60 changes: 60 additions & 0 deletions app/src/features/desktop/whole.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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();
});

async function details() {
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" });
fireEvent.click(screen.getByRole("button", { name: /^Show details/ }));
const about = screen.getByRole("complementary", { name: "About Scout" });
return { fake, scout, about };
}

describe("the whole desktop", () => {
test("is given only after the owner chooses a level and accepts the risks", async () => {
const { fake, about } = await details();
fireEvent.click(await within(about).findByRole("button", { name: "Give the whole desktop" }));
const dialog = screen.getByRole("dialog", { name: "Give Scout your whole desktop?" });
expect(within(dialog).getByText(/without asking you first/)).toBeDefined();
expect(within(dialog).getByText(/covered in black/)).toBeDefined();
const confirm = within(dialog).getByRole("button", { name: "Give it" }) as HTMLButtonElement;
expect(confirm.disabled).toBe(true);

fireEvent.click(within(dialog).getByRole("radio", { name: "Can see and use" }));
fireEvent.click(within(dialog).getByRole("checkbox", { name: "I understand the risks" }));
expect(confirm.disabled).toBe(false);
fireEvent.click(confirm);
await waitFor(() => expect(fake.desktop.grants[0]?.scope).toBe("desktop"));
expect(fake.desktop.grants[0]?.level).toBe("act");
expect(fake.desktop.grants[0]?.acceptedRisksAt).not.toBeNull();

// Listed like any grant, with its options; the button goes.
expect(await within(about).findByText("The whole desktop")).toBeDefined();
expect(
within(about).getByRole("switch", { name: "Real mouse and keyboard: The whole desktop" }),
).toBeDefined();
expect(within(about).queryByRole("button", { name: "Give the whole desktop" })).toBeNull();
}, 15_000);

test("speaks the owner's language", async () => {
const { about } = await details();
act(() => setLocaleChoice("pt-BR"));
fireEvent.click(await within(about).findByRole("button", { name: "Dar o desktop inteiro" }));
expect(
screen.getByRole("dialog", { name: "Dar a Scout o seu desktop inteiro?" }),
).toBeDefined();
});
});
12 changes: 12 additions & 0 deletions app/src/i18n/en/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ export const desktop = {
],
awayUnderstood: "I understand the risks",
awayConfirm: "Turn on",
giveWhole: "Give the whole desktop",
wholeFailed: "Could not give the whole desktop",
wholeTitle: (bot: string) => `Give ${bot} your whole desktop?`,
wholeLevel: "What it may do",
wholeRisks: (bot: string) => [
`${bot} sees every window open on your computer, personal data, emails, chats and client data included, and pictures of the whole screen.`,
`It reads any app without asking you first. If you choose "Can see and use", it also clicks and types in any of them.`,
`${bot} can make mistakes: delete, send or change something as if it were you.`,
`Text in an app or a message can try to trick ${bot} into doing something else.`,
"Never, not even then: Botloft itself, apps running as administrator, terminals and command boxes, the Task Manager, password managers and password fields. They are covered in black in its pictures.",
],
wholeConfirm: "Give it",
},
away: {
label: "While you were away",
Expand Down
12 changes: 12 additions & 0 deletions app/src/i18n/es/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ export const desktop: Messages["desktop"] = {
],
awayUnderstood: "Entiendo los riesgos",
awayConfirm: "Activar",
giveWhole: "Dar todo el escritorio",
wholeFailed: "No se pudo dar todo el escritorio",
wholeTitle: (bot) => `¿Dar a ${bot} todo tu escritorio?`,
wholeLevel: "Lo que puede hacer",
wholeRisks: (bot) => [
`${bot} ve toda ventana abierta en tu computadora, incluidos datos personales, correos, conversaciones y datos de clientes, y fotos de toda la pantalla.`,
`Lee cualquier app sin preguntarte antes. Si eliges "Puede ver y usar", también hace clic y escribe en cualquiera.`,
`${bot} puede equivocarse: borrar, enviar o cambiar algo como si fueras tú.`,
`Un texto en una app o en un mensaje puede intentar engañar a ${bot} para que haga otra cosa.`,
"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. En sus fotos quedan cubiertos de negro.",
],
wholeConfirm: "Dar",
},
away: {
label: "Mientras no estabas",
Expand Down
12 changes: 12 additions & 0 deletions app/src/i18n/pt-BR/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ export const desktop: Messages["desktop"] = {
],
awayUnderstood: "Entendi os riscos",
awayConfirm: "Ligar",
giveWhole: "Dar o desktop inteiro",
wholeFailed: "Não foi possível dar o desktop inteiro",
wholeTitle: (bot) => `Dar a ${bot} o seu desktop inteiro?`,
wholeLevel: "O que ele pode fazer",
wholeRisks: (bot) => [
`${bot} vê toda janela aberta no seu computador, inclusive dados pessoais, e-mails, conversas e dados de clientes, e fotos da tela inteira.`,
`Ele lê qualquer app sem te perguntar antes. Escolhendo "Pode ver e usar", também clica e digita em qualquer um.`,
`${bot} pode errar: apagar, enviar ou mudar algo como se fosse você.`,
`Um texto num app ou numa mensagem pode tentar enganar ${bot} para fazer outra coisa.`,
"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. Nas fotos dele, eles ficam cobertos de preto.",
],
wholeConfirm: "Dar",
},
away: {
label: "Enquanto você estava fora",
Expand Down
28 changes: 28 additions & 0 deletions app/src/lib/fakeDesktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,34 @@ export class FakeDesktop {
this.fake.bot(botId);
return this.set(botId, { stopped: false });
},
"desktop.grantWhole": ({ botId, level, acceptedRisks }) => {
this.fake.bot(botId);
if (!acceptedRisks) {
throw new Error("the whole desktop needs the risks accepted first");
}
const same = this.grants.find(
(grant) => grant.botId === botId && grant.scope === "desktop",
);
if (same) {
same.level = level;
same.acceptedRisksAt = this.fake.now;
} else {
this.grants.push({
id: this.fake.id("dsk"),
botId,
scope: "desktop",
appPath: null,
appName: null,
level,
realInput: false,
unattended: false,
acceptedRisksAt: this.fake.now,
createdAt: this.fake.now,
});
}
this.changed(botId);
return { botId, grants: this.of(botId) };
},
"desktop.awayUses": () => this.away,
"desktop.dismissAway": () => {
this.away = [];
Expand Down
11 changes: 11 additions & 0 deletions app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,16 @@ from: number, until: number,
*/
itemId: ChatItemId | null, };

/**
* `desktop.grantWhole`: the owner gives the bot the whole desktop (spec
* 24.10), never asked for in the chat.
*/
export type DesktopWholeParams = { botId: BotId, level: DesktopLevel,
/**
* The owner checked "I understand the risks"; without it, refused.
*/
acceptedRisks: boolean, };

export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string,
/**
* What the bot says the command is for, as in `ToolItem`. The bot
Expand Down Expand Up @@ -1676,6 +1686,7 @@ export interface RpcMethods {
"desktop.grants": { params: DesktopGrantsParams; result: Array<DesktopGrant> };
"desktop.revoke": { params: DesktopGrantIdParams; result: BotDesktop };
"desktop.setOptions": { params: DesktopOptionsParams; result: BotDesktop };
"desktop.grantWhole": { params: DesktopWholeParams; result: BotDesktop };
"desktop.watch": { params: DesktopBotParams; result: DesktopView };
"desktop.unwatch": { params: undefined; result: null };
"desktop.stop": { params: DesktopBotParams; result: DesktopState };
Expand Down
12 changes: 12 additions & 0 deletions crates/botloft-core/src/protocol/desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,18 @@ pub struct DesktopOptionsParams {
pub accepted_risks: Option<bool>,
}

/// `desktop.grantWhole`: the owner gives the bot the whole desktop (spec
/// 24.10), never asked for in the chat.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(test, derive(ts_rs::TS))]
pub struct DesktopWholeParams {
pub bot_id: BotId,
pub level: DesktopLevel,
/// The owner checked "I understand the risks"; without it, refused.
pub accepted_risks: bool,
}

/// 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.
Expand Down
6 changes: 6 additions & 0 deletions crates/botloft-core/src/protocol/export_desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub(super) fn decls(out: &mut Out) {
out.decl::<DesktopView>();
out.decl::<DesktopBotParams>();
out.decl::<DesktopAwayUse>();
out.decl::<DesktopWholeParams>();
}

pub(super) fn methods(out: &mut Out) {
Expand All @@ -39,6 +40,11 @@ pub(super) fn methods(out: &mut Out) {
&out.name::<DesktopOptionsParams>(),
&out.name::<BotDesktop>(),
);
out.method(
method::DESKTOP_GRANT_WHOLE,
&out.name::<DesktopWholeParams>(),
&out.name::<BotDesktop>(),
);
let bot = out.name::<DesktopBotParams>();
out.method(method::DESKTOP_WATCH, &bot, &out.name::<DesktopView>());
out.method(method::DESKTOP_UNWATCH, "undefined", "null");
Expand Down
1 change: 1 addition & 0 deletions crates/botloft-core/src/protocol/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ pub mod method {
pub const DESKTOP_GRANTS: &str = "desktop.grants";
pub const DESKTOP_REVOKE: &str = "desktop.revoke";
pub const DESKTOP_SET_OPTIONS: &str = "desktop.setOptions";
pub const DESKTOP_GRANT_WHOLE: &str = "desktop.grantWhole";
pub const DESKTOP_WATCH: &str = "desktop.watch";
pub const DESKTOP_UNWATCH: &str = "desktop.unwatch";
pub const DESKTOP_STOP: &str = "desktop.stop";
Expand Down
Loading
Loading