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
87 changes: 87 additions & 0 deletions app/src/features/bots/AllowRules.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// What the bot may do without asking (spec 10.1), in its details: each
// "Allow always" the owner gave, with a button that makes the bot ask again.

import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { useT } from "../../i18n";
import type { AllowRule, Bot } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";
import { Button } from "../../ui/Button";
import { attempt } from "../../ui/toast";
import { toolTitle } from "../chat/toolNames";

/** The bot's rules, read when shown and kept current by `bot.rules`. */
function useAllowRules(botId: Bot["id"]): AllowRule[] | null {
const api = useApi();
const [rules, setRules] = useState<AllowRule[] | null>(null);
useEffect(() => {
let alive = true;
setRules(null);
const stop = api.subscribe((event) => {
if (event.name === "bot.rules" && event.params.botId === botId) {
setRules(event.params.rules);
}
});
api.call("rules.list", { botId }).then(
(listed) => alive && setRules((current) => current ?? listed),
// An older daemon has no rules: the list stays empty.
() => alive && setRules([]),
);
return () => {
alive = false;
stop();
};
}, [api, botId]);
return rules;
}

export function AllowRules({ bot }: { bot: Bot }) {
const t = useT();
const words = t.bots.details;
const api = useApi();
const rules = useAllowRules(bot.id);
return (
<div>
<dt className="text-muted text-xs">{words.always}</dt>
<dd className="mt-1">
{rules !== null && rules.length === 0 && (
<p className="text-ink-soft text-xs leading-relaxed">{words.alwaysNone(bot.name)}</p>
)}
{rules !== null && rules.length > 0 && (
<ul className="flex flex-col gap-1.5">
{rules.map((rule) => {
const title = toolTitle(rule.scope.toolName, t.tools);
const what = rule.scope.value ? `${title}: ${rule.scope.value}` : title;
return (
<li
key={rule.id}
className="flex items-start gap-2 rounded-lg border border-line bg-sunken py-1.5 pr-1 pl-2.5"
>
<span className="min-w-0 flex-1">
<span className="block text-ink-soft text-xs">{title}</span>
{rule.scope.value && (
<span className="block break-all font-mono text-xs" data-selectable>
{rule.scope.value}
</span>
)}
</span>
<Button
variant="ghost"
size="sm"
icon={X}
label={words.alwaysRemove(what)}
onClick={() =>
attempt(words.alwaysRemoveFailed, () =>
api.call("rules.delete", { ruleId: rule.id }),
)
}
/>
</li>
);
})}
</ul>
)}
</dd>
</div>
);
}
2 changes: 2 additions & 0 deletions app/src/features/bots/BotView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { BotRoutines } from "../routines/RoutineList";
import { ScreensPanel } from "../screens/ScreensPanel";
import { ShowScreen } from "../screens/showScreen";
import { useScreens } from "../screens/useScreens";
import { AllowRules } from "./AllowRules";
import { BotHeader } from "./BotHeader";
import { stateView } from "./BotStateBadge";
import { useFollowBot } from "./useFollowBot";
Expand Down Expand Up @@ -287,6 +288,7 @@ function Details({ bot, onClose }: { bot: Bot; onClose(): void }) {
{bot.instructions || words.noInstructions}
</dd>
</div>
<AllowRules bot={bot} />
</dl>
</SidePanel>
);
Expand Down
33 changes: 30 additions & 3 deletions app/src/features/chat/ApprovalCard.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// A permission request (spec 10.1): what the bot wants to do, with Allow
// and Deny. Once answered it shrinks to one line.

import { Ban, Check, Hand, TimerOff } from "lucide-react";
import { Ban, Check, CheckCheck, Hand, TimerOff } from "lucide-react";
import { useId } from "react";
import { useT } from "../../i18n";
import type { ApprovalItem, Bot } from "../../lib/protocol.gen";
import type { AllowScope, ApprovalItem, Bot } from "../../lib/protocol.gen";
import { Button } from "../../ui/Button";
import { Details } from "../../ui/Details";
import { HELP_TOOL, HelpCard } from "../browser/HelpCard";
Expand Down Expand Up @@ -78,6 +78,23 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B
}
}

/** "Allow always", by what it would cover. */
function alwaysLabel(
scope: AllowScope,
words: ReturnType<typeof useT>["chat"]["approval"]["always"],
): string {
switch (scope.kind) {
case "command":
return words.command;
case "site":
return words.site(scope.value);
case "file":
return words.file;
default:
return words.tool;
}
}

const BOX =
"max-h-56 overflow-auto break-all rounded-lg border border-line bg-sunken px-2.5 py-1.5";

Expand Down Expand Up @@ -158,10 +175,20 @@ function ToolApproval({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {
placeholder={t.chat.approval.notePlaceholder}
className="mt-3 h-9 w-full rounded-xl border border-line-strong bg-canvas px-3 text-sm outline-none placeholder:text-muted focus:border-muted"
/>
<div className="mt-2.5 flex gap-2">
<div className="mt-2.5 flex flex-wrap gap-2">
<Button variant="primary" icon={Check} disabled={busy} onClick={() => answer(true)}>
{t.chat.approval.allow}
</Button>
{approval.always && (
<Button
icon={CheckCheck}
disabled={busy}
title={t.chat.approval.always.hint(bot.name)}
onClick={() => answer(true, undefined, true)}
>
{alwaysLabel(approval.always, t.chat.approval.always)}
</Button>
)}
<Button variant="danger" icon={Ban} disabled={busy} onClick={() => answer(false)}>
{t.chat.approval.deny}
</Button>
Expand Down
76 changes: 76 additions & 0 deletions app/src/features/chat/allowAlways.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, openBot, renderApp } from "../../test/app";

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 details = () => screen.getByRole("complementary", { name: "About Scout" });

describe("Allow always", () => {
test("allows the request for good and lists it in the bot's details, where it can go", async () => {
const { fake, scout } = await openScout();
act(() => {
fake.chat.ask(scout.id, "Bash", "git status", '{"command":"git status"}');
});
const card = screen.getByRole("region", { name: "Scout asks to run a command" });
const always = within(card).getByRole("button", { name: "Always allow this command" });
expect(always.getAttribute("title")).toBe(
"Scout will not ask for this again. You can undo it in Scout's details.",
);
fireEvent.click(always);
expect(await screen.findByText("Allowed: run a command")).toBeDefined();
expect(fake.calls.find((call) => call.method === "approvals.answer")?.params).toMatchObject({
allow: true,
always: true,
});

fireEvent.click(screen.getByRole("button", { name: /^Show details/ }));
const rule = await within(details()).findByText("git status");
expect(rule).toBeDefined();
fireEvent.click(
within(details()).getByRole("button", { name: "Ask again: Run a command: git status" }),
);
expect(
await within(details()).findByText(
'Nothing yet. When Scout asks for something, "Always allow" puts it here.',
),
).toBeDefined();
});

test("names the site, and is not offered for what cannot be allowed for good", async () => {
const { fake, scout } = await openScout();
act(() => {
fake.chat.ask(
scout.id,
"WebFetch",
"https://www.example.com/a",
'{"url":"https://www.example.com/a"}',
);
});
const page = screen.getByRole("region", { name: /^Scout asks to/ });
expect(within(page).getByRole("button", { name: "Always allow example.com" })).toBeDefined();
fireEvent.click(within(page).getByRole("button", { name: "Allow" }));
await screen.findByText(/^Allowed:/);
// A plain Allow keeps no rule.
expect(fake.allow.rules).toEqual([]);

act(() => {
fake.chat.ask(scout.id, "Glob", "*.md", '{"pattern":"*.md"}');
});
const glob = screen.getByRole("region", { name: /^Scout asks to/ });
expect(within(glob).queryByRole("button", { name: /^Always allow/ })).toBeNull();
});
});
6 changes: 4 additions & 2 deletions app/src/features/chat/useAnswer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import { attempt } from "../../ui/toast";

/**
* Answering a request; the note goes to the bot on a denial. `input` is a
* bot suggestion as the owner changed it (spec 10.2).
* bot suggestion as the owner changed it (spec 10.2); `always` allows the
* request for good (spec 10.1).
*/
export function useAnswer(approval: ApprovalItem, failed: { allow: string; deny: string }) {
const api = useApi();
const [note, setNote] = useState("");
const [busy, setBusy] = useState(false);
const answer = async (allow: boolean, input?: string) => {
const answer = async (allow: boolean, input?: string, always = false) => {
setBusy(true);
const trimmed = note.trim();
await attempt(allow ? failed.allow : failed.deny, () =>
Expand All @@ -20,6 +21,7 @@ export function useAnswer(approval: ApprovalItem, failed: { allow: string; deny:
allow,
...(allow || !trimmed ? {} : { note: trimmed }),
...(allow && input !== undefined ? { input } : {}),
...(allow && always ? { always } : {}),
}),
);
setBusy(false);
Expand Down
5 changes: 5 additions & 0 deletions app/src/i18n/en/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,10 @@ export const bots = {
generation: (generation: number) => `generation ${generation}`,
instructions: "Instructions",
noInstructions: "None yet.",
always: "Allowed without asking",
alwaysNone: (bot: string) =>
`Nothing yet. When ${bot} asks for something, "Always allow" puts it here.`,
alwaysRemove: (what: string) => `Ask again: ${what}`,
alwaysRemoveFailed: "Could not remove it",
},
};
9 changes: 9 additions & 0 deletions app/src/i18n/en/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ export const chat = {
notePlaceholder: "Why not? Sent to the bot if you deny (optional)",
allow: "Allow",
deny: "Deny",
/** "Allow always" (spec 10.1), by what it covers. */
always: {
command: "Always allow this command",
site: (site: string) => `Always allow ${site}`,
file: "Always allow this file",
tool: "Always allow",
hint: (bot: string) =>
`${bot} will not ask for this again. You can undo it in ${bot}'s details.`,
},
allowFailed: "Could not allow it",
denyFailed: "Could not deny it",
allowed: (action: string) => `Allowed: ${action}`,
Expand Down
5 changes: 5 additions & 0 deletions app/src/i18n/es/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,5 +114,10 @@ export const bots: Messages["bots"] = {
generation: (generation: number) => `generación ${generation}`,
instructions: "Instrucciones",
noInstructions: "Ninguna todavía.",
always: "Permitido sin preguntar",
alwaysNone: (bot: string) =>
`Nada todavía. Cuando ${bot} pida algo, "Permitir siempre" lo pone aquí.`,
alwaysRemove: (what: string) => `Volver a preguntar: ${what}`,
alwaysRemoveFailed: "No se pudo quitar",
},
};
8 changes: 8 additions & 0 deletions app/src/i18n/es/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ export const chat: Messages["chat"] = {
notePlaceholder: "¿Por qué no? Se envía al bot si deniegas (opcional)",
allow: "Permitir",
deny: "Denegar",
always: {
command: "Permitir siempre este comando",
site: (site: string) => `Permitir siempre en ${site}`,
file: "Permitir siempre este archivo",
tool: "Permitir siempre",
hint: (bot: string) =>
`${bot} no volverá a pedir esto. Puedes deshacerlo en los detalles de ${bot}.`,
},
allowFailed: "No se pudo permitir",
denyFailed: "No se pudo denegar",
allowed: (action: string) => `Permitido: ${action}`,
Expand Down
5 changes: 5 additions & 0 deletions app/src/i18n/pt-BR/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,5 +113,10 @@ export const bots: Messages["bots"] = {
generation: (generation: number) => `geração ${generation}`,
instructions: "Instruções",
noInstructions: "Nenhuma ainda.",
always: "Permitido sem perguntar",
alwaysNone: (bot: string) =>
`Nada ainda. Quando ${bot} pedir algo, "Permitir sempre" coloca aqui.`,
alwaysRemove: (what: string) => `Voltar a perguntar: ${what}`,
alwaysRemoveFailed: "Não foi possível remover",
},
};
8 changes: 8 additions & 0 deletions app/src/i18n/pt-BR/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ export const chat: Messages["chat"] = {
notePlaceholder: "Por que não? Vai para o bot se você negar (opcional)",
allow: "Permitir",
deny: "Negar",
always: {
command: "Permitir sempre este comando",
site: (site: string) => `Permitir sempre em ${site}`,
file: "Permitir sempre neste arquivo",
tool: "Permitir sempre",
hint: (bot: string) =>
`${bot} não vai pedir isso de novo. Dá para desfazer nos detalhes de ${bot}.`,
},
allowFailed: "Não foi possível permitir",
denyFailed: "Não foi possível negar",
allowed: (action: string) => `Permitido: ${action}`,
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// handles, archiving, and the notifications each change sends.

import type { BotloftApi } from "./api";
import { FakeAllow } from "./fakeAllow";
import { botHandlers } from "./fakeBots";
import { FakeBrowser } from "./fakeBrowser";
import { FakeChat } from "./fakeChat";
Expand Down Expand Up @@ -67,6 +68,7 @@ export class FakeBotloft implements BotloftApi {
/** Every call, in order. */
readonly calls: { method: Method; params: unknown }[] = [];
readonly chat = new FakeChat(this);
readonly allow = new FakeAllow(this);
readonly conversation = new FakeConversation(this);
readonly routines = new FakeRoutines(this);
readonly files = new FakeFiles(this);
Expand Down Expand Up @@ -249,6 +251,7 @@ export class FakeBotloft implements BotloftApi {
...crewHandlers(this),
...botHandlers(this),
...this.chat.handlers(),
...this.allow.handlers(),
...this.conversation.handlers(),
...this.files.handlers(),
...this.browser.handlers(),
Expand Down
Loading
Loading