Skip to content

Commit 691d58e

Browse files
authored
Merge pull request #188 from httpsphl/feat/desktop-whole
feat(desktop): D6, the whole desktop
2 parents ce59a3a + 34c579d commit 691d58e

29 files changed

Lines changed: 730 additions & 48 deletions

‎app/src/features/desktop/DesktopGrants.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Button } from "../../ui/Button";
1010
import { attempt } from "../../ui/toast";
1111
import { RealInput } from "./RealInput";
1212
import { Unattended } from "./Unattended";
13+
import { WholeDesktop } from "./WholeDesktop";
1314

1415
/** The bot's grants, read when shown and kept current by `bot.desktop`. */
1516
export function useDesktopGrants(botId: Bot["id"]): DesktopGrant[] | null {
@@ -89,6 +90,11 @@ export function DesktopGrants({ bot }: { bot: Bot }) {
8990
})}
9091
</ul>
9192
)}
93+
{grants !== null && !grants.some((grant) => grant.scope === "desktop") && (
94+
<div className="mt-2">
95+
<WholeDesktop bot={bot} />
96+
</div>
97+
)}
9298
</dd>
9399
</div>
94100
);
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Giving a bot the owner's whole desktop (spec 24.2, 24.10): a button in
2+
// the bot's details that opens the risks screen, with the level to give;
3+
// the confirm button lights only once the owner checks that they
4+
// understand the risks. Never asked for in the chat.
5+
6+
import { Check, Monitor } from "lucide-react";
7+
import { useState } from "react";
8+
import { useT } from "../../i18n";
9+
import type { Bot, DesktopLevel } from "../../lib/protocol.gen";
10+
import { useApi } from "../../store/context";
11+
import { Button } from "../../ui/Button";
12+
import { Choices } from "../../ui/Choices";
13+
import { Dialog } from "../../ui/Dialog";
14+
import { attempt } from "../../ui/toast";
15+
16+
export function WholeDesktop({ bot }: { bot: Bot }) {
17+
const words = useT().desktop.grants;
18+
const api = useApi();
19+
const [asking, setAsking] = useState(false);
20+
const [level, setLevel] = useState<DesktopLevel>("see");
21+
const [understood, setUnderstood] = useState(false);
22+
const close = () => {
23+
setAsking(false);
24+
setUnderstood(false);
25+
setLevel("see");
26+
};
27+
const give = () =>
28+
attempt(words.wholeFailed, () =>
29+
api.call("desktop.grantWhole", { botId: bot.id, level, acceptedRisks: true }),
30+
);
31+
return (
32+
<>
33+
<Button variant="secondary" size="sm" icon={Monitor} onClick={() => setAsking(true)}>
34+
{words.giveWhole}
35+
</Button>
36+
{asking && (
37+
<Dialog
38+
title={words.wholeTitle(bot.name)}
39+
onClose={close}
40+
footer={
41+
<>
42+
<Button variant="ghost" onClick={close}>
43+
{words.cancel}
44+
</Button>
45+
<Button
46+
variant="primary"
47+
icon={Check}
48+
disabled={!understood}
49+
onClick={() => {
50+
close();
51+
void give();
52+
}}
53+
>
54+
{words.wholeConfirm}
55+
</Button>
56+
</>
57+
}
58+
>
59+
<Choices<DesktopLevel>
60+
label={words.wholeLevel}
61+
value={level}
62+
options={[
63+
{ value: "see", label: words.see },
64+
{ value: "act", label: words.act },
65+
]}
66+
onChange={setLevel}
67+
/>
68+
<ul className="mt-4 flex list-disc flex-col gap-1.5 pl-5 text-sm">
69+
{words.wholeRisks(bot.name).map((risk) => (
70+
<li key={risk}>{risk}</li>
71+
))}
72+
</ul>
73+
<label className="mt-4 flex items-center gap-2 font-medium text-sm">
74+
<input
75+
type="checkbox"
76+
checked={understood}
77+
onChange={(event) => setUnderstood(event.target.checked)}
78+
/>
79+
{words.awayUnderstood}
80+
</label>
81+
</Dialog>
82+
)}
83+
</>
84+
);
85+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { setLocaleChoice } from "../../i18n";
4+
import { FakeBotloft } from "../../lib/fake";
5+
import { crewOpened, openBot, renderApp } from "../../test/app";
6+
7+
afterEach(() => {
8+
act(() => setLocaleChoice("en"));
9+
cleanup();
10+
});
11+
12+
async function details() {
13+
const fake = new FakeBotloft();
14+
const ops = fake.addCrew("Ops");
15+
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
16+
fake.setBotState(scout.id, "idle");
17+
renderApp(fake);
18+
await crewOpened("Ops");
19+
openBot("Scout");
20+
await screen.findByRole("list", { name: "Messages" });
21+
fireEvent.click(screen.getByRole("button", { name: /^Show details/ }));
22+
const about = screen.getByRole("complementary", { name: "About Scout" });
23+
return { fake, scout, about };
24+
}
25+
26+
describe("the whole desktop", () => {
27+
test("is given only after the owner chooses a level and accepts the risks", async () => {
28+
const { fake, about } = await details();
29+
fireEvent.click(await within(about).findByRole("button", { name: "Give the whole desktop" }));
30+
const dialog = screen.getByRole("dialog", { name: "Give Scout your whole desktop?" });
31+
expect(within(dialog).getByText(/without asking you first/)).toBeDefined();
32+
expect(within(dialog).getByText(/covered in black/)).toBeDefined();
33+
const confirm = within(dialog).getByRole("button", { name: "Give it" }) as HTMLButtonElement;
34+
expect(confirm.disabled).toBe(true);
35+
36+
fireEvent.click(within(dialog).getByRole("radio", { name: "Can see and use" }));
37+
fireEvent.click(within(dialog).getByRole("checkbox", { name: "I understand the risks" }));
38+
expect(confirm.disabled).toBe(false);
39+
fireEvent.click(confirm);
40+
await waitFor(() => expect(fake.desktop.grants[0]?.scope).toBe("desktop"));
41+
expect(fake.desktop.grants[0]?.level).toBe("act");
42+
expect(fake.desktop.grants[0]?.acceptedRisksAt).not.toBeNull();
43+
44+
// Listed like any grant, with its options; the button goes.
45+
expect(await within(about).findByText("The whole desktop")).toBeDefined();
46+
expect(
47+
within(about).getByRole("switch", { name: "Real mouse and keyboard: The whole desktop" }),
48+
).toBeDefined();
49+
expect(within(about).queryByRole("button", { name: "Give the whole desktop" })).toBeNull();
50+
}, 15_000);
51+
52+
test("speaks the owner's language", async () => {
53+
const { about } = await details();
54+
act(() => setLocaleChoice("pt-BR"));
55+
fireEvent.click(await within(about).findByRole("button", { name: "Dar o desktop inteiro" }));
56+
expect(
57+
screen.getByRole("dialog", { name: "Dar a Scout o seu desktop inteiro?" }),
58+
).toBeDefined();
59+
});
60+
});

‎app/src/i18n/en/desktop.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ export const desktop = {
7979
],
8080
awayUnderstood: "I understand the risks",
8181
awayConfirm: "Turn on",
82+
giveWhole: "Give the whole desktop",
83+
wholeFailed: "Could not give the whole desktop",
84+
wholeTitle: (bot: string) => `Give ${bot} your whole desktop?`,
85+
wholeLevel: "What it may do",
86+
wholeRisks: (bot: string) => [
87+
`${bot} sees every window open on your computer, personal data, emails, chats and client data included, and pictures of the whole screen.`,
88+
`It reads any app without asking you first. If you choose "Can see and use", it also clicks and types in any of them.`,
89+
`${bot} can make mistakes: delete, send or change something as if it were you.`,
90+
`Text in an app or a message can try to trick ${bot} into doing something else.`,
91+
"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.",
92+
],
93+
wholeConfirm: "Give it",
8294
},
8395
away: {
8496
label: "While you were away",

‎app/src/i18n/es/desktop.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ export const desktop: Messages["desktop"] = {
7979
],
8080
awayUnderstood: "Entiendo los riesgos",
8181
awayConfirm: "Activar",
82+
giveWhole: "Dar todo el escritorio",
83+
wholeFailed: "No se pudo dar todo el escritorio",
84+
wholeTitle: (bot) => `¿Dar a ${bot} todo tu escritorio?`,
85+
wholeLevel: "Lo que puede hacer",
86+
wholeRisks: (bot) => [
87+
`${bot} ve toda ventana abierta en tu computadora, incluidos datos personales, correos, conversaciones y datos de clientes, y fotos de toda la pantalla.`,
88+
`Lee cualquier app sin preguntarte antes. Si eliges "Puede ver y usar", también hace clic y escribe en cualquiera.`,
89+
`${bot} puede equivocarse: borrar, enviar o cambiar algo como si fueras tú.`,
90+
`Un texto en una app o en un mensaje puede intentar engañar a ${bot} para que haga otra cosa.`,
91+
"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.",
92+
],
93+
wholeConfirm: "Dar",
8294
},
8395
away: {
8496
label: "Mientras no estabas",

‎app/src/i18n/pt-BR/desktop.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ export const desktop: Messages["desktop"] = {
7878
],
7979
awayUnderstood: "Entendi os riscos",
8080
awayConfirm: "Ligar",
81+
giveWhole: "Dar o desktop inteiro",
82+
wholeFailed: "Não foi possível dar o desktop inteiro",
83+
wholeTitle: (bot) => `Dar a ${bot} o seu desktop inteiro?`,
84+
wholeLevel: "O que ele pode fazer",
85+
wholeRisks: (bot) => [
86+
`${bot} vê toda janela aberta no seu computador, inclusive dados pessoais, e-mails, conversas e dados de clientes, e fotos da tela inteira.`,
87+
`Ele lê qualquer app sem te perguntar antes. Escolhendo "Pode ver e usar", também clica e digita em qualquer um.`,
88+
`${bot} pode errar: apagar, enviar ou mudar algo como se fosse você.`,
89+
`Um texto num app ou numa mensagem pode tentar enganar ${bot} para fazer outra coisa.`,
90+
"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.",
91+
],
92+
wholeConfirm: "Dar",
8193
},
8294
away: {
8395
label: "Enquanto você estava fora",

‎app/src/lib/fakeDesktop.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,34 @@ export class FakeDesktop {
154154
this.fake.bot(botId);
155155
return this.set(botId, { stopped: false });
156156
},
157+
"desktop.grantWhole": ({ botId, level, acceptedRisks }) => {
158+
this.fake.bot(botId);
159+
if (!acceptedRisks) {
160+
throw new Error("the whole desktop needs the risks accepted first");
161+
}
162+
const same = this.grants.find(
163+
(grant) => grant.botId === botId && grant.scope === "desktop",
164+
);
165+
if (same) {
166+
same.level = level;
167+
same.acceptedRisksAt = this.fake.now;
168+
} else {
169+
this.grants.push({
170+
id: this.fake.id("dsk"),
171+
botId,
172+
scope: "desktop",
173+
appPath: null,
174+
appName: null,
175+
level,
176+
realInput: false,
177+
unattended: false,
178+
acceptedRisksAt: this.fake.now,
179+
createdAt: this.fake.now,
180+
});
181+
}
182+
this.changed(botId);
183+
return { botId, grants: this.of(botId) };
184+
},
157185
"desktop.awayUses": () => this.away,
158186
"desktop.dismissAway": () => {
159187
this.away = [];

‎app/src/lib/protocol.gen.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,16 @@ from: number, until: number,
992992
*/
993993
itemId: ChatItemId | null, };
994994

995+
/**
996+
* `desktop.grantWhole`: the owner gives the bot the whole desktop (spec
997+
* 24.10), never asked for in the chat.
998+
*/
999+
export type DesktopWholeParams = { botId: BotId, level: DesktopLevel,
1000+
/**
1001+
* The owner checked "I understand the risks"; without it, refused.
1002+
*/
1003+
acceptedRisks: boolean, };
1004+
9951005
export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string,
9961006
/**
9971007
* What the bot says the command is for, as in `ToolItem`. The bot
@@ -1676,6 +1686,7 @@ export interface RpcMethods {
16761686
"desktop.grants": { params: DesktopGrantsParams; result: Array<DesktopGrant> };
16771687
"desktop.revoke": { params: DesktopGrantIdParams; result: BotDesktop };
16781688
"desktop.setOptions": { params: DesktopOptionsParams; result: BotDesktop };
1689+
"desktop.grantWhole": { params: DesktopWholeParams; result: BotDesktop };
16791690
"desktop.watch": { params: DesktopBotParams; result: DesktopView };
16801691
"desktop.unwatch": { params: undefined; result: null };
16811692
"desktop.stop": { params: DesktopBotParams; result: DesktopState };

‎crates/botloft-core/src/protocol/desktop.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,18 @@ pub struct DesktopOptionsParams {
119119
pub accepted_risks: Option<bool>,
120120
}
121121

122+
/// `desktop.grantWhole`: the owner gives the bot the whole desktop (spec
123+
/// 24.10), never asked for in the chat.
124+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125+
#[serde(rename_all = "camelCase")]
126+
#[cfg_attr(test, derive(ts_rs::TS))]
127+
pub struct DesktopWholeParams {
128+
pub bot_id: BotId,
129+
pub level: DesktopLevel,
130+
/// The owner checked "I understand the risks"; without it, refused.
131+
pub accepted_risks: bool,
132+
}
133+
122134
/// A bot used an app while the owner was away (spec 24.8): what the app
123135
/// tells them when they are back, until they dismiss it. The result of
124136
/// `desktop.awayUses` and the params of `desktop.away` are a list of these.

‎crates/botloft-core/src/protocol/export_desktop.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pub(super) fn decls(out: &mut Out) {
2121
out.decl::<DesktopView>();
2222
out.decl::<DesktopBotParams>();
2323
out.decl::<DesktopAwayUse>();
24+
out.decl::<DesktopWholeParams>();
2425
}
2526

2627
pub(super) fn methods(out: &mut Out) {
@@ -39,6 +40,11 @@ pub(super) fn methods(out: &mut Out) {
3940
&out.name::<DesktopOptionsParams>(),
4041
&out.name::<BotDesktop>(),
4142
);
43+
out.method(
44+
method::DESKTOP_GRANT_WHOLE,
45+
&out.name::<DesktopWholeParams>(),
46+
&out.name::<BotDesktop>(),
47+
);
4248
let bot = out.name::<DesktopBotParams>();
4349
out.method(method::DESKTOP_WATCH, &bot, &out.name::<DesktopView>());
4450
out.method(method::DESKTOP_UNWATCH, "undefined", "null");

0 commit comments

Comments
 (0)